From 9811a1c5d9bc50cc65c5bd81234f603d1d7bd4ef Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 8 Jun 2021 16:46:07 +0100 Subject: [PATCH 001/403] DEV: Allow `transformed` values to be used in all widget hbs statements (#13331) Previously, the `transformed.blah` shortcut could only be used in top-level hbs statements like {{transformed.blah}}. When attempting to use it in a sub-expression like `{{concat "hello" transformed.world}}`, it would raise a "transformed is not defined" error. This commit updates the shortcut logic to make `transformed.blah` and `attrs.blah` work consistently in all hbs expressions. Co-authored-by: Jordan Vidrine --- .../tests/integration/widgets/widget-test.js | 24 +++++++++++++++++++ lib/javascripts/widget-hbs-compiler.js | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js index 6df1ef695a..aa1a27b016 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js @@ -262,6 +262,30 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, }); + componentTest("using transformed values in a subexpression", { + template: hbs`{{mount-widget widget="attach-test"}}`, + + beforeEach() { + createWidget("testing", { + tagName: "span.value", + template: widgetHbs`{{attrs.value}}`, + }); + + createWidget("attach-test", { + transform() { + return { someValue: "world" }; + }, + tagName: "div.container", + template: widgetHbs`{{testing value=(concat "hello" " " transformed.someValue)}}`, + }); + }, + + test(assert) { + assert.ok(queryAll(".container").length, "renders container"); + assert.equal(queryAll(".container .value").text(), "hello world"); + }, + }); + componentTest("handlebars d-icon", { template: hbs`{{mount-widget widget="hbs-icon-test" args=args}}`, diff --git a/lib/javascripts/widget-hbs-compiler.js b/lib/javascripts/widget-hbs-compiler.js index e36aa3549c..63b12cdc72 100644 --- a/lib/javascripts/widget-hbs-compiler.js +++ b/lib/javascripts/widget-hbs-compiler.js @@ -16,7 +16,7 @@ function sexpValue(value) { } else if (value.type === "SubExpression") { return sexp(value); } - return pValue; + return resolve(pValue); } function pairsToObj(pairs) { From 21e8a33177047e69f25c7103480c06ba8e23d01c Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 8 Jun 2021 17:54:12 +0200 Subject: [PATCH 002/403] DEV: Clean up QUnit tests (#13328) * DEV: Use `query` helper instead of `queryAll()[0]` * DEV: Replace `queryAll().length` w/ `exists()`/`count()` * DEV: Use `exists()` instead of `count() > 0`, `count() === 0` * DEV: Use `count()`/`exists()` instead of `find().length` --- .../tests/acceptance/account-created-test.js | 6 +- .../acceptance/admin-search-log-term-test.js | 2 +- .../acceptance/admin-search-logs-test.js | 4 +- .../acceptance/admin-suspend-user-test.js | 36 ++- .../tests/acceptance/admin-user-index-test.js | 11 +- .../acceptance/admin-watched-words-test.js | 9 +- .../tests/acceptance/category-banner-test.js | 7 +- .../acceptance/category-edit-security-test.js | 19 +- .../tests/acceptance/click-track-test.js | 13 +- .../tests/acceptance/composer-actions-test.js | 68 ++--- .../acceptance/composer-hyperlink-test.js | 3 +- .../tests/acceptance/composer-test.js | 97 ++++--- .../acceptance/composer-topic-links-test.js | 4 +- .../acceptance/composer-uncategorized-test.js | 12 +- .../create-account-user-fields-test.js | 5 +- .../acceptance/create-invite-modal-test.js | 42 +-- .../tests/acceptance/do-not-disturb-test.js | 10 +- .../tests/acceptance/flag-post-test.js | 12 +- .../tests/acceptance/forgot-password-test.js | 2 +- .../tests/acceptance/group-index-test.js | 11 +- .../group-manage-categories-test.js | 14 +- .../group-manage-interaction-test.js | 22 +- .../acceptance/group-manage-logs-test.js | 18 +- .../group-manage-membership-test.js | 80 +++--- .../acceptance/group-manage-profile-test.js | 24 +- .../acceptance/group-manage-tags-test.js | 14 +- .../tests/acceptance/group-requests-test.js | 8 +- .../discourse/tests/acceptance/group-test.js | 39 ++- .../tests/acceptance/groups-index-test.js | 4 +- .../tests/acceptance/groups-new-test.js | 24 +- .../tests/acceptance/mobile-pan-test.js | 18 +- .../discourse/tests/acceptance/modal-test.js | 52 ++-- .../acceptance/notifications-filter-test.js | 8 +- .../tests/acceptance/notifications-test.js | 14 +- .../plugin-outlet-connector-class-test.js | 14 +- .../plugin-outlet-multi-template-test.js | 16 +- .../plugin-outlet-single-template-test.js | 11 +- .../tests/acceptance/preferences-test.js | 18 +- .../acceptance/raw-plugin-outlet-test.js | 10 +- .../discourse/tests/acceptance/review-test.js | 62 ++--- .../tests/acceptance/search-full-test.js | 19 +- .../tests/acceptance/search-mobile-test.js | 10 +- .../tests/acceptance/share-topic-test.js | 3 +- .../tests/acceptance/shared-drafts-test.js | 12 +- .../tests/acceptance/sign-in-test.js | 5 +- .../tests/acceptance/tag-groups-test.js | 6 +- .../discourse/tests/acceptance/tags-test.js | 42 ++- .../tests/acceptance/topic-anonymous-test.js | 8 +- .../acceptance/topic-quote-button-test.js | 30 +-- .../discourse/tests/acceptance/topic-test.js | 15 +- .../tests/acceptance/user-anonymous-test.js | 14 +- .../tests/acceptance/user-bookmarks-test.js | 2 +- .../acceptance/user-drafts-stream-test.js | 11 +- .../user-preferences-interface-test.js | 6 +- .../user-preferences-notifications-test.js | 4 +- .../integration/components/ace-editor-test.js | 7 +- .../components/activation-controls-test.js | 7 +- .../components/admin-report-test.js | 3 +- .../integration/components/cook-text-test.js | 9 +- .../integration/components/d-button-test.js | 57 ++-- .../integration/components/d-editor-test.js | 24 +- .../group-membership-button-test.js | 25 +- .../components/image-uploader-test.js | 38 ++- .../components/invite-panel-test.js | 7 +- .../components/secret-value-list-test.js | 24 +- .../components/simple-list-test.js | 34 +-- .../components/site-header-test.js | 12 +- .../components/slow-mode-info-test.js | 6 +- .../integration/components/text-field-test.js | 5 +- .../components/themes-list-item-test.js | 15 +- .../components/themes-list-test.js | 17 +- .../components/user-avatar-flair-test.js | 27 +- .../components/user-selector-test.js | 9 +- .../integration/components/value-list-test.js | 30 ++- .../widgets/actions-summary-test.js | 15 +- .../integration/widgets/avatar-flair-test.js | 9 +- .../tests/integration/widgets/button-test.js | 34 +-- .../widgets/default-notification-item-test.js | 10 +- .../widgets/hamburger-menu-test.js | 56 ++-- .../tests/integration/widgets/header-test.js | 14 +- .../integration/widgets/home-logo-test.js | 41 ++- .../integration/widgets/post-links-test.js | 14 +- .../integration/widgets/post-menu-test.js | 10 +- .../integration/widgets/post-stream-test.js | 19 +- .../tests/integration/widgets/post-test.js | 246 +++++++----------- .../integration/widgets/poster-name-test.js | 23 +- .../widgets/quick-access-item-test.js | 9 +- .../widgets/small-user-list-test.js | 9 +- .../widgets/software-update-prompt-test.js | 11 +- .../integration/widgets/topic-status-test.js | 9 +- .../integration/widgets/user-menu-test.js | 28 +- .../widgets/widget-dropdown-test.js | 12 +- .../tests/integration/widgets/widget-test.js | 38 +-- .../acceptance/details-button-test.js.es6 | 10 +- .../acceptance/poll-breakdown-test.js.es6 | 21 +- .../acceptance/poll-pie-chart-test.js.es6 | 10 +- .../acceptance/poll-quote-test.js.es6 | 6 +- .../widgets/discourse-poll-test.js.es6 | 6 +- 98 files changed, 976 insertions(+), 1070 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/acceptance/account-created-test.js b/app/assets/javascripts/discourse/tests/acceptance/account-created-test.js index fab4de4e04..0eac23bc6f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/account-created-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/account-created-test.js @@ -60,7 +60,7 @@ acceptance("Account Created", function () { await click(".activation-controls .edit-email"); assert.equal(currentRouteName(), "account-created.edit-email"); - assert.ok(queryAll(".activation-controls .btn-primary:disabled").length); + assert.ok(exists(".activation-controls .btn-primary:disabled")); await click(".activation-controls .edit-cancel"); @@ -79,11 +79,11 @@ acceptance("Account Created", function () { await click(".activation-controls .edit-email"); - assert.ok(queryAll(".activation-controls .btn-primary:disabled").length); + assert.ok(exists(".activation-controls .btn-primary:disabled")); await fillIn(".activate-new-email", "newemail@example.com"); - assert.notOk(queryAll(".activation-controls .btn-primary:disabled").length); + assert.notOk(exists(".activation-controls .btn-primary:disabled")); await click(".activation-controls .btn-primary"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-search-log-term-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-search-log-term-test.js index b4a9df1cde..6b4d080b68 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-search-log-term-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-search-log-term-test.js @@ -8,7 +8,7 @@ acceptance("Admin - Search Log Term", function (needs) { test("show search log term details", async function (assert) { await visit("/admin/logs/search_logs/term?term=ruby"); - assert.ok($("div.search-logs-filter").length, "has the search type filter"); + assert.ok(exists("div.search-logs-filter"), "has the search type filter"); assert.ok(exists("canvas.chartjs-render-monitor"), "has graph canvas"); assert.ok(exists("div.header-search-results"), "has header search results"); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-search-logs-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-search-logs-test.js index 9b19b29b6b..8d6335451c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-search-logs-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-search-logs-test.js @@ -8,7 +8,7 @@ acceptance("Admin - Search Logs", function (needs) { test("show search logs", async function (assert) { await visit("/admin/logs/search_logs"); - assert.ok($("table.search-logs-list.grid").length, "has the div class"); + assert.ok(exists("table.search-logs-list.grid"), "has the div class"); assert.ok( exists(".search-logs-list .admin-list-item .col"), @@ -18,7 +18,7 @@ acceptance("Admin - Search Logs", function (needs) { await click(".term a"); assert.ok( - $("div.search-logs-filter").length, + exists("div.search-logs-filter"), "it should show the search log term page" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-suspend-user-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-suspend-user-test.js index 05a8a86dee..f9821d9043 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-suspend-user-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-suspend-user-test.js @@ -1,7 +1,8 @@ import { acceptance, + count, exists, - queryAll, + query, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -31,38 +32,39 @@ acceptance("Admin - Suspend User", function (needs) { await visit("/admin/users/1234/regular"); await click(".suspend-user"); - assert.equal(queryAll(".suspend-user-modal:visible").length, 1); + assert.equal(count(".suspend-user-modal:visible"), 1); await click(".d-modal-cancel"); - assert.equal(queryAll(".suspend-user-modal:visible").length, 0); + assert.ok(!exists(".suspend-user-modal:visible")); }); test("suspend a user - cancel with input", async function (assert) { await visit("/admin/users/1234/regular"); await click(".suspend-user"); - assert.equal(queryAll(".suspend-user-modal:visible").length, 1); + assert.equal(count(".suspend-user-modal:visible"), 1); await fillIn("input.suspend-reason", "for breaking the rules"); await fillIn(".suspend-message", "this is an email reason why"); await click(".d-modal-cancel"); - assert.equal(queryAll(".bootbox.modal:visible").length, 1); + assert.equal(count(".bootbox.modal:visible"), 1); await click(".modal-footer .btn-default"); - assert.equal(queryAll(".suspend-user-modal:visible").length, 1); + assert.equal(count(".suspend-user-modal:visible"), 1); assert.equal( - queryAll(".suspend-message")[0].value, + query(".suspend-message").value, "this is an email reason why" ); await click(".d-modal-cancel"); - assert.equal(queryAll(".bootbox.modal:visible").length, 1); - assert.equal(queryAll(".suspend-user-modal:visible").length, 0); + assert.equal(count(".bootbox.modal:visible"), 1); + assert.ok(!exists(".suspend-user-modal:visible")); + await click(".modal-footer .btn-primary"); - assert.equal(queryAll(".bootbox.modal:visible").length, 0); + assert.ok(!exists(".bootbox.modal:visible")); }); test("suspend, then unsuspend a user", async function (assert) { @@ -76,11 +78,7 @@ acceptance("Admin - Suspend User", function (needs) { await click(".suspend-user"); - assert.equal( - queryAll(".perform-suspend[disabled]").length, - 1, - "disabled by default" - ); + assert.equal(count(".perform-suspend[disabled]"), 1, "disabled by default"); await suspendUntilCombobox.expand(); await suspendUntilCombobox.selectRowByValue("tomorrow"); @@ -88,15 +86,11 @@ acceptance("Admin - Suspend User", function (needs) { await fillIn("input.suspend-reason", "for breaking the rules"); await fillIn(".suspend-message", "this is an email reason why"); - assert.equal( - queryAll(".perform-suspend[disabled]").length, - 0, - "no longer disabled" - ); + assert.ok(!exists(".perform-suspend[disabled]"), "no longer disabled"); await click(".perform-suspend"); - assert.equal(queryAll(".suspend-user-modal:visible").length, 0); + assert.ok(!exists(".suspend-user-modal:visible")); assert.ok(exists(".suspension-info")); await click(".unsuspend-user"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js index 731c77c216..296b876505 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-user-index-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; @@ -81,9 +85,8 @@ acceptance("Admin - User Index", function (needs) { "the name should be correct" ); - assert.equal( - queryAll('.group-chooser span[title="Macdonald"]').length, - 0, + assert.ok( + !exists('.group-chooser span[title="Macdonald"]'), "group should not be set" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js index 8d5595381c..d04bf15984 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -12,7 +13,7 @@ acceptance("Admin - Watched Words", function (needs) { test("list words in groups", async function (assert) { await visit("/admin/customize/watched_words/action/block"); - assert.equal(find(".admin-watched-words .alert-error").length, 0); + assert.ok(!exists(".admin-watched-words .alert-error")); assert.ok( !exists(".watched-words-list"), @@ -27,7 +28,7 @@ acceptance("Admin - Watched Words", function (needs) { await fillIn(".admin-controls .controls input[type=text]", "li"); assert.equal( - queryAll(".watched-words-list .watched-word").length, + count(".watched-words-list .watched-word"), 1, "When filtering, show words even if checkbox is unchecked." ); @@ -83,7 +84,7 @@ acceptance("Admin - Watched Words", function (needs) { await click("#" + $(word).attr("id")); - assert.equal(queryAll(".watched-words-list .watched-word").length, 2); + assert.equal(count(".watched-words-list .watched-word"), 2); }); test("test modal - replace", async function (assert) { @@ -131,6 +132,6 @@ acceptance("Admin - Watched Words - Bad regular expressions", function (needs) { test("shows an error message if regex is invalid", async function (assert) { await visit("/admin/customize/watched_words/action/block"); - assert.equal(find(".admin-watched-words .alert-error").length, 1); + assert.equal(count(".admin-watched-words .alert-error"), 1); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/category-banner-test.js b/app/assets/javascripts/discourse/tests/acceptance/category-banner-test.js index ad04cefe06..2811618351 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/category-banner-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/category-banner-test.js @@ -1,6 +1,6 @@ import { acceptance, - queryAll, + count, visible, } from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; @@ -60,8 +60,9 @@ acceptance("Category Banners", function (needs) { await click(".modal-footer>.btn-primary"); assert.ok(!visible(".bootbox.modal"), "it closes the modal"); assert.ok(visible(".category-read-only-banner"), "it shows a banner"); - assert.ok( - queryAll(".category-read-only-banner .inner").length === 1, + assert.equal( + count(".category-read-only-banner .inner"), + 1, "it allows staff to embed html in the message" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/category-edit-security-test.js b/app/assets/javascripts/discourse/tests/acceptance/category-edit-security-test.js index 020aa1fde3..072fba5ae5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/category-edit-security-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/category-edit-security-test.js @@ -1,4 +1,9 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import I18n from "I18n"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -68,20 +73,12 @@ acceptance("Category Edit - security", function (needs) { await click(".row-body .remove-permission"); - assert.equal( - queryAll(".row-body").length, - 0, - "removes the permission from the list" - ); + assert.ok(!exists(".row-body"), "removes the permission from the list"); await availableGroups.expand(); await availableGroups.selectRowByValue("everyone"); - assert.equal( - queryAll(".row-body").length, - 1, - "adds back the permission tp the list" - ); + assert.equal(count(".row-body"), 1, "adds back the permission tp the list"); const firstRow = queryAll(".row-body").first(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/click-track-test.js b/app/assets/javascripts/discourse/tests/acceptance/click-track-test.js index 9829cc7512..a8beee4272 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/click-track-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/click-track-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, +} from "discourse/tests/helpers/qunit-helpers"; import { click, currentURL, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -13,13 +17,10 @@ acceptance("Click Track", function (needs) { test("Do not track mentions", async function (assert) { await visit("/t/internationalization-localization/280"); - assert.ok( - queryAll(".user-card.show").length === 0, - "card should not appear" - ); + assert.ok(!exists(".user-card.show"), "card should not appear"); await click('article[data-post-id="3651"] a.mention'); - assert.ok(queryAll(".user-card.show").length === 1, "card appear"); + assert.equal(count(".user-card.show"), 1, "card appear"); assert.equal(currentURL(), "/t/internationalization-localization/280"); assert.ok(!tracked); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js index 36cf8cc521..f2252193f9 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-actions-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, updateCurrentUser, @@ -111,23 +112,25 @@ acceptance("Composer Actions", function (needs) { ); assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 0, + !exists(".composer-actions svg.d-icon-far-eye-slash"), "whisper icon is not visible" ); - assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-share"), + 1, "reply icon is visible" ); await composerActions.expand(); await composerActions.selectRowByValue("toggle_whisper"); - assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, "whisper icon is visible" ); assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 0, + !exists(".composer-actions svg.d-icon-share"), "reply icon is not visible" ); }); @@ -169,7 +172,7 @@ acceptance("Composer Actions", function (needs) { const composerActions = selectKit(".composer-actions"); await composerActions.expand(); await composerActions.selectRowByValue("reply_as_new_topic"); - assert.equal(exists(queryAll(".bootbox")), false); + assert.ok(!exists(".bootbox")); }); test("reply_as_new_group_message", async function (assert) { @@ -234,7 +237,7 @@ acceptance("Composer Actions", function (needs) { await composerActions.selectRowByValue("reply_to_post"); await composerActions.expand(); - assert.ok(exists(queryAll(".action-title img.avatar"))); + assert.ok(exists(".action-title img.avatar")); assert.equal( queryAll(".action-title .user-link").text().trim(), "codinghorror" @@ -291,23 +294,25 @@ acceptance("Composer Actions", function (needs) { await click("article#post_3 button.reply"); assert.ok( - queryAll(".composer-actions svg.d-icon-anchor").length === 0, + !exists(".composer-actions svg.d-icon-anchor"), "no-bump icon is not visible" ); - assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-share"), + 1, "reply icon is visible" ); await composerActions.expand(); await composerActions.selectRowByValue("toggle_topic_bump"); - assert.ok( - queryAll(".composer-actions svg.d-icon-anchor").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-anchor"), + 1, "no-bump icon is visible" ); assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 0, + !exists(".composer-actions svg.d-icon-share"), "reply icon is not visible" ); @@ -315,11 +320,12 @@ acceptance("Composer Actions", function (needs) { await composerActions.selectRowByValue("toggle_topic_bump"); assert.ok( - queryAll(".composer-actions svg.d-icon-anchor").length === 0, + !exists(".composer-actions svg.d-icon-anchor"), "no-bump icon is not visible" ); - assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-share"), + 1, "reply icon is visible" ); }); @@ -331,15 +337,16 @@ acceptance("Composer Actions", function (needs) { await click("article#post_3 button.reply"); assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 0, + !exists(".composer-actions svg.d-icon-far-eye-slash"), "whisper icon is not visible" ); assert.ok( - queryAll(".composer-fields .whisper .d-icon-anchor").length === 0, + !exists(".composer-fields .whisper .d-icon-anchor"), "no-bump icon is not visible" ); - assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-share"), + 1, "reply icon is visible" ); @@ -348,16 +355,18 @@ acceptance("Composer Actions", function (needs) { await composerActions.expand(); await composerActions.selectRowByValue("toggle_whisper"); - assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, "whisper icon is visible" ); - assert.ok( - queryAll(".composer-fields .no-bump .d-icon-anchor").length === 1, + assert.equal( + count(".composer-fields .no-bump .d-icon-anchor"), + 1, "no-bump icon is visible" ); assert.ok( - queryAll(".composer-actions svg.d-icon-share").length === 0, + !exists(".composer-actions svg.d-icon-share"), "reply icon is not visible" ); }); @@ -492,12 +501,13 @@ acceptance("Composer Actions With New Topic Draft", function (needs) { queryAll("#reply-control .btn-primary.create .d-button-label").text(), I18n.t("composer.create_shared_draft") ); - assert.ok( - queryAll(".composer-actions svg.d-icon-far-clipboard").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-far-clipboard"), + 1, "shared draft icon is visible" ); - assert.ok(queryAll("#reply-control.composing-shared-draft").length === 1); + assert.equal(count("#reply-control.composing-shared-draft"), 1); await click(".modal-footer .btn.btn-default"); } finally { toggleCheckDraftPopup(false); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js index 3cc5eb98eb..a780a6a27b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-hyperlink-test.js @@ -1,6 +1,7 @@ import { acceptance, exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, triggerKeyEvent, visit } from "@ember/test-helpers"; @@ -55,7 +56,7 @@ acceptance("Composer - Hyperlink", function (needs) { "modal dismissed after cancelling" ); - const textarea = queryAll("#reply-control .d-editor-input")[0]; + const textarea = query("#reply-control .d-editor-input"); textarea.selectionStart = 0; textarea.selectionEnd = 6; await click(".d-editor button.link"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index 5440a5aa2c..8839d170b7 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -1,7 +1,9 @@ import { acceptance, + count, exists, invisible, + query, queryAll, updateCurrentUser, visible, @@ -91,7 +93,7 @@ acceptance("Composer", function (needs) { "the body is now good" ); - const textarea = queryAll("#reply-control .d-editor-input")[0]; + const textarea = query("#reply-control .d-editor-input"); textarea.selectionStart = textarea.value.length; textarea.selectionEnd = textarea.value.length; @@ -284,7 +286,7 @@ acceptance("Composer", function (needs) { test("Create an enqueued Reply", async function (assert) { await visit("/t/internationalization-localization/280"); - assert.notOk(queryAll(".pending-posts .reviewable-item").length); + assert.ok(!exists(".pending-posts .reviewable-item")); await click("#topic-footer-buttons .btn.create"); assert.ok(exists(".d-editor-input"), "the composer input is visible"); @@ -305,7 +307,7 @@ acceptance("Composer", function (needs) { await click(".modal-footer button"); assert.ok(invisible(".d-modal"), "the modal can be dismissed"); - assert.ok(queryAll(".pending-posts .reviewable-item").length); + assert.ok(exists(".pending-posts .reviewable-item")); }); test("Edit the first post", async function (assert) { @@ -355,7 +357,7 @@ acceptance("Composer", function (needs) { await fillIn("#reply-title", "This is the new text for the title"); await click("#reply-control button.create"); - assert.equal(find(".topic-post.staged").length, 1); + assert.equal(count(".topic-post.staged"), 1); assert.ok( find(".topic-post:nth-of-type(1)")[0].className.includes("staged") ); @@ -374,7 +376,7 @@ acceptance("Composer", function (needs) { await fillIn("#reply-title", "This is the new text for the title"); await click("#reply-control button.create"); - assert.equal(find(".topic-post.staged").length, 0); + assert.ok(!exists(".topic-post.staged")); assert.equal( find(".topic-post .cooked")[0].innerText, "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" @@ -447,8 +449,9 @@ acceptance("Composer", function (needs) { await menu.expand(); await menu.selectRowByValue("toggleWhisper"); - assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, "it sets the post type to whisper" ); @@ -456,7 +459,7 @@ acceptance("Composer", function (needs) { await menu.selectRowByValue("toggleWhisper"); assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 0, + !exists(".composer-actions svg.d-icon-far-eye-slash"), "it removes the whisper mode" ); @@ -477,37 +480,42 @@ acceptance("Composer", function (needs) { await visit("/t/this-is-a-test-topic/9"); await click(".topic-post:nth-of-type(1) button.reply"); - assert.ok( - queryAll("#reply-control.open").length === 1, + assert.equal( + count("#reply-control.open"), + 1, "it starts in open state by default" ); await click(".toggle-fullscreen"); - assert.ok( - queryAll("#reply-control.fullscreen").length === 1, + assert.equal( + count("#reply-control.fullscreen"), + 1, "it expands composer to full screen" ); await click(".toggle-fullscreen"); - assert.ok( - queryAll("#reply-control.open").length === 1, + assert.equal( + count("#reply-control.open"), + 1, "it collapses composer to regular size" ); await fillIn(".d-editor-input", "This is a dirty reply"); await click(".toggler"); - assert.ok( - queryAll("#reply-control.draft").length === 1, + assert.equal( + count("#reply-control.draft"), + 1, "it collapses composer to draft bar" ); await click(".toggle-fullscreen"); - assert.ok( - queryAll("#reply-control.open").length === 1, + assert.equal( + count("#reply-control.open"), + 1, "from draft, it expands composer back to open state" ); }); @@ -521,8 +529,9 @@ acceptance("Composer", function (needs) { "toggleWhisper" ); - assert.ok( - queryAll(".composer-actions svg.d-icon-far-eye-slash").length === 1, + assert.equal( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, "it sets the post type to whisper" ); @@ -531,7 +540,7 @@ acceptance("Composer", function (needs) { await click("#create-topic"); assert.ok( - queryAll(".composer-fields .whisper .d-icon-far-eye-slash").length === 0, + !exists(".composer-fields .whisper .d-icon-far-eye-slash"), "it should reset the state of the composer's model" ); @@ -551,9 +560,7 @@ acceptance("Composer", function (needs) { await click(".topic-post:nth-of-type(1) button.reply"); assert.ok( - queryAll(".composer-fields .whisper") - .text() - .indexOf(I18n.t("composer.unlist")) === -1, + !exists(".composer-fields .whisper"), "it should reset the state of the composer's model" ); }); @@ -675,17 +682,20 @@ acceptance("Composer", function (needs) { await fillIn(".d-editor-input", longText); + assert.ok( + exists( + '.action-title a[href="/t/internationalization-localization/280"]' + ), + "the mode should be: reply to post" + ); + await click("article#post_3 button.reply"); const composerActions = selectKit(".composer-actions"); await composerActions.expand(); await composerActions.selectRowByValue("reply_as_private_message"); - assert.equal( - queryAll(".modal-body").text(), - "", - "abandon popup shouldn't come" - ); + assert.ok(!exists(".modal-body"), "abandon popup shouldn't come"); assert.ok( queryAll(".d-editor-input").val().includes(longText), @@ -693,7 +703,7 @@ acceptance("Composer", function (needs) { ); assert.ok( - queryAll( + !exists( '.action-title a[href="/t/internationalization-localization/280"]' ), "mode should have changed" @@ -786,9 +796,9 @@ acceptance("Composer", function (needs) { I18n.t("composer.create_pm"), "reply button says Message" ); - assert.ok( - queryAll(".save-or-cancel button.create svg.d-icon-envelope").length === - 1, + assert.equal( + count(".save-or-cancel button.create svg.d-icon-envelope"), + 1, "reply button has envelope icon" ); }); @@ -803,9 +813,9 @@ acceptance("Composer", function (needs) { I18n.t("composer.save_edit"), "save button says Save Edit" ); - assert.ok( - queryAll(".save-or-cancel button.create svg.d-icon-pencil-alt").length === - 1, + assert.equal( + count(".save-or-cancel button.create svg.d-icon-pencil-alt"), + 1, "save button has pencil icon" ); }); @@ -844,8 +854,9 @@ acceptance("Composer", function (needs) { await fillIn(".d-editor-input", uploads.join("\n")); - assert.ok( - queryAll(".button-wrapper").length === 10, + assert.equal( + count(".button-wrapper"), + 10, "it adds correct amount of scaling button groups" ); @@ -925,8 +936,8 @@ acceptance("Composer", function (needs) { ); assert.ok( - queryAll("script").length === 0, - "it does not unescapes script tags in code blocks" + !exists("script"), + "it does not unescape script tags in code blocks" ); }); @@ -947,13 +958,13 @@ acceptance("Composer", function (needs) { ); await fillIn(".d-editor-input", "[](https://discourse.org)"); - assert.equal(find(".composer-popup").length, 0); + assert.ok(!exists(".composer-popup")); await fillIn(".d-editor-input", "[quote][](https://github.com)[/quote]"); - assert.equal(find(".composer-popup").length, 0); + assert.ok(!exists(".composer-popup")); await fillIn(".d-editor-input", "[](https://github.com)"); - assert.equal(find(".composer-popup").length, 1); + assert.equal(count(".composer-popup"), 1); }); test("Shows the 'group_mentioned' notice", async function (assert) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-topic-links-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-topic-links-test.js index 02307c2698..67587ee9c8 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-topic-links-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-topic-links-test.js @@ -181,7 +181,7 @@ acceptance( await visit("/"); await click("#create-topic"); assert.ok( - queryAll(".d-editor-textarea-wrapper.disabled").length, + exists(".d-editor-textarea-wrapper.disabled"), "textarea is disabled" ); await fillIn("#reply-title", "http://www.example.com/has-title.html"); @@ -199,7 +199,7 @@ acceptance( "title is from the oneboxed article" ); assert.ok( - queryAll(".d-editor-textarea-wrapper.disabled").length === 0, + !exists(".d-editor-textarea-wrapper.disabled"), "textarea is enabled" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-uncategorized-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-uncategorized-test.js index f9b8c1be32..7d34f5fd0b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-uncategorized-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-uncategorized-test.js @@ -1,8 +1,4 @@ -import { - acceptance, - exists, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; @@ -39,7 +35,7 @@ acceptance( await categoryChooser.selectRowByValue(2); assert.ok( - queryAll(".d-editor-textarea-wrapper.disabled").length === 0, + !exists(".d-editor-textarea-wrapper.disabled"), "textarea is enabled" ); @@ -48,7 +44,7 @@ acceptance( await categoryChooser.selectRowByIndex(0); assert.ok( - queryAll(".d-editor-textarea-wrapper.disabled").length === 0, + !exists(".d-editor-textarea-wrapper.disabled"), "textarea is still enabled" ); }); @@ -91,7 +87,7 @@ acceptance( "category errors are hidden by default" ); assert.ok( - queryAll(".d-editor-textarea-wrapper.disabled").length === 0, + !exists(".d-editor-textarea-wrapper.disabled"), "textarea is enabled" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js index c85cb272c5..02adf36a8d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-account-user-fields-test.js @@ -1,6 +1,7 @@ import { acceptance, exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; @@ -62,12 +63,12 @@ acceptance("Create Account - User Fields", function (needs) { ); await click(".modal-footer .btn-primary"); - assert.equal(queryAll("#modal-alert")[0].style.display, ""); + assert.equal(query("#modal-alert").style.display, ""); await fillIn(".user-field input[type=text]:nth-of-type(1)", "Barky"); await click(".user-field input[type=checkbox]"); await click(".modal-footer .btn-primary"); - assert.equal(queryAll("#modal-alert")[0].style.display, "none"); + assert.equal(query("#modal-alert").style.display, "none"); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js index 80afabea8e..770292118e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/create-invite-modal-test.js @@ -1,5 +1,9 @@ import { click, fillIn, visit } from "@ember/test-helpers"; -import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, +} from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; acceptance("Invites - Create & Edit Invite Modal", function (needs) { @@ -52,18 +56,9 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) { ); await click(".modal-footer .show-advanced"); - await assert.ok( - find(".invite-to-groups").length > 0, - "shows advanced options" - ); - await assert.ok( - find(".invite-to-topic").length > 0, - "shows advanced options" - ); - await assert.ok( - find(".invite-expires-at").length > 0, - "shows advanced options" - ); + await assert.ok(exists(".invite-to-groups"), "shows advanced options"); + await assert.ok(exists(".invite-to-topic"), "shows advanced options"); + await assert.ok(exists(".invite-expires-at"), "shows advanced options"); await click(".modal-close"); assert.ok(deleted, "deletes the invite if not saved"); @@ -73,17 +68,11 @@ acceptance("Invites - Create & Edit Invite Modal", function (needs) { await visit("/u/eviltrout/invited/pending"); await click(".invite-controls .btn:first-child"); - assert.ok( - find("tbody tr").length === 0, - "does not show invite before saving" - ); + assert.ok(!exists("tbody tr"), "does not show invite before saving"); await click(".btn-primary"); - assert.ok( - find("tbody tr").length === 1, - "adds invite to list after saving" - ); + assert.equal(count("tbody tr"), 1, "adds invite to list after saving"); await click(".modal-close"); assert.notOk(deleted, "does not delete invite on close"); @@ -138,10 +127,7 @@ acceptance("Invites - Link Invites", function (needs) { await visit("/u/eviltrout/invited/pending"); await click(".invite-controls .btn:first-child"); - assert.ok( - find("#invite-max-redemptions").length, - "shows max redemptions field" - ); + assert.ok(exists("#invite-max-redemptions"), "shows max redemptions field"); }); }); @@ -180,10 +166,10 @@ acceptance("Invites - Email Invites", function (needs) { await visit("/u/eviltrout/invited/pending"); await click(".invite-controls .btn:first-child"); - assert.ok(find("#invite-email").length, "shows email field"); + assert.ok(exists("#invite-email"), "shows email field"); await fillIn("#invite-email", "test@example.com"); - assert.ok(find(".save-invite").length, "shows save without email button"); + assert.ok(exists(".save-invite"), "shows save without email button"); await click(".save-invite"); assert.ok( lastRequest.requestBody.indexOf("skip_email=true") !== -1, @@ -191,7 +177,7 @@ acceptance("Invites - Email Invites", function (needs) { ); await fillIn("#invite-email", "test2@example.com"); - assert.ok(find(".send-invite").length, "shows save and send email button"); + assert.ok(exists(".send-invite"), "shows save and send email button"); await click(".send-invite"); assert.ok( lastRequest.requestBody.indexOf("send_email=true") !== -1, diff --git a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js index fea8383dee..7f376eafbd 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/do-not-disturb-test.js @@ -1,6 +1,7 @@ import { acceptance, exists, + query, queryAll, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; @@ -37,7 +38,7 @@ acceptance("Do not disturb", function (needs) { await click(tiles[0]); assert.ok( - queryAll(".do-not-disturb-modal")[0].style.display === "none", + query(".do-not-disturb-modal").style.display === "none", "modal is hidden" ); @@ -55,15 +56,12 @@ acceptance("Do not disturb", function (needs) { await visit("/"); await click(".header-dropdown-toggle.current-user"); await click(".menu-links-row .user-preferences-link"); - assert.equal( - queryAll(".do-not-disturb .relative-date")[0].textContent, - "1h" - ); + assert.equal(query(".do-not-disturb .relative-date").textContent, "1h"); await click(".do-not-disturb"); assert.ok( - queryAll(".do-not-disturb-background").length === 0, + !exists(".do-not-disturb-background"), "The active moon icons are removed" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js index 253a7b28fa..ef40fe01c4 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js @@ -1,7 +1,7 @@ import { acceptance, + count, exists, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -115,7 +115,7 @@ acceptance("flagging", function (needs) { await silenceUntilCombobox.selectRowByValue("tomorrow"); await fillIn(".silence-reason", "for breaking the rules"); await click(".perform-silence"); - assert.equal(queryAll(".bootbox.modal:visible").length, 0); + assert.ok(!exists(".bootbox.modal:visible")); }); test("Gets dismissable warning from canceling incomplete silence from take action", async function (assert) { @@ -130,16 +130,16 @@ acceptance("flagging", function (needs) { await silenceUntilCombobox.selectRowByValue("tomorrow"); await fillIn(".silence-reason", "for breaking the rules"); await click(".d-modal-cancel"); - assert.equal(queryAll(".bootbox.modal:visible").length, 1); + assert.equal(count(".bootbox.modal:visible"), 1); await click(".modal-footer .btn-default"); - assert.equal(queryAll(".bootbox.modal:visible").length, 0); + assert.ok(!exists(".bootbox.modal:visible")); assert.ok(exists(".silence-user-modal"), "it shows the silence modal"); await click(".d-modal-cancel"); - assert.equal(queryAll(".bootbox.modal:visible").length, 1); + assert.equal(count(".bootbox.modal:visible"), 1); await click(".modal-footer .btn-primary"); - assert.equal(queryAll(".bootbox.modal:visible").length, 0); + assert.ok(!exists(".bootbox.modal:visible")); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/forgot-password-test.js b/app/assets/javascripts/discourse/tests/acceptance/forgot-password-test.js index 92df2514a1..0905fd351d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/forgot-password-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/forgot-password-test.js @@ -58,7 +58,7 @@ acceptance("Forgot password", function (needs) { await click(".forgot-password-reset"); assert.notOk( - exists(queryAll(".alert-error")), + exists(".alert-error"), "it should remove the flash error when succeeding" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js index 93a463a8d7..674ddce0d5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js @@ -1,6 +1,7 @@ import { acceptance, count, + exists, queryAll, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; @@ -17,10 +18,10 @@ acceptance("Group Members - Anonymous", function () { count(".avatar-flair .d-icon-adjust") === 1, "it displays the group's avatar flair" ); - assert.ok(count(".group-members tr") > 0, "it lists group members"); + assert.ok(exists(".group-members tr"), "it lists group members"); assert.ok( - count(".group-member-dropdown") === 0, + !exists(".group-member-dropdown"), "it does not allow anon user to manage group members" ); @@ -48,7 +49,7 @@ acceptance("Group Members", function (needs) { await click(".group-members-add"); assert.equal( - queryAll("#group-add-members-user-selector").length, + count("#group-add-members-user-selector"), 1, "it should display the add members modal" ); @@ -58,7 +59,7 @@ acceptance("Group Members", function (needs) { await visit("/g/discourse"); assert.ok( - count(".group-member-dropdown") > 0, + exists(".group-member-dropdown"), "it allows admin user to manage group members" ); @@ -72,7 +73,7 @@ acceptance("Group Members", function (needs) { test("Shows bulk actions", async function (assert) { await visit("/g/discourse"); - assert.ok(count("button.bulk-select") > 0); + assert.ok(exists("button.bulk-select")); await click("button.bulk-select"); await click(queryAll("input.bulk-select")[0]); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-categories-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-categories-test.js index 573b606da3..62187603a4 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-categories-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-categories-test.js @@ -1,7 +1,7 @@ import { acceptance, count, - queryAll, + exists, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; @@ -12,7 +12,7 @@ acceptance("Managing Group Category Notification Defaults", function () { await visit("/g/discourse/manage/categories"); assert.ok( - count(".group-members tr") > 0, + exists(".group-members tr"), "it should redirect to members page for an anonymous user" ); }); @@ -23,8 +23,9 @@ acceptance("Managing Group Category Notification Defaults", function (needs) { test("As an admin", async function (assert) { await visit("/g/discourse/manage/categories"); - assert.ok( - queryAll(".groups-notifications-form .category-selector").length === 5, + assert.equal( + count(".groups-notifications-form .category-selector"), + 5, "it should display category inputs" ); }); @@ -34,8 +35,9 @@ acceptance("Managing Group Category Notification Defaults", function (needs) { await visit("/g/discourse/manage/categories"); - assert.ok( - queryAll(".groups-notifications-form .category-selector").length === 5, + assert.equal( + count(".groups-notifications-form .category-selector"), + 5, "it should display category inputs" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-interaction-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-interaction-test.js index e5bf10edbb..67797ae306 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-interaction-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-interaction-test.js @@ -1,6 +1,6 @@ import { acceptance, - queryAll, + count, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; @@ -20,31 +20,31 @@ acceptance("Managing Group Interaction Settings", function (needs) { await visit("/g/alternative-group/manage/interaction"); assert.equal( - queryAll(".groups-form-visibility-level").length, + count(".groups-form-visibility-level"), 1, "it should display visibility level selector" ); assert.equal( - queryAll(".groups-form-mentionable-level").length, + count(".groups-form-mentionable-level"), 1, "it should display mentionable level selector" ); assert.equal( - queryAll(".groups-form-messageable-level").length, + count(".groups-form-messageable-level"), 1, "it should display messageable level selector" ); assert.equal( - queryAll(".groups-form-incoming-email").length, + count(".groups-form-incoming-email"), 1, "it should display incoming email input" ); assert.equal( - queryAll(".groups-form-default-notification-level").length, + count(".groups-form-default-notification-level"), 1, "it should display default notification level input" ); @@ -60,31 +60,31 @@ acceptance("Managing Group Interaction Settings", function (needs) { await visit("/g/discourse/manage/interaction"); assert.equal( - queryAll(".groups-form-visibility-level").length, + count(".groups-form-visibility-level"), 0, "it should not display visibility level selector" ); assert.equal( - queryAll(".groups-form-mentionable-level").length, + count(".groups-form-mentionable-level"), 1, "it should display mentionable level selector" ); assert.equal( - queryAll(".groups-form-messageable-level").length, + count(".groups-form-messageable-level"), 1, "it should display messageable level selector" ); assert.equal( - queryAll(".groups-form-incoming-email").length, + count(".groups-form-incoming-email"), 0, "it should not display incoming email input" ); assert.equal( - queryAll(".groups-form-default-notification-level").length, + count(".groups-form-default-notification-level"), 1, "it should display default notification level input" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-logs-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-logs-test.js index 6ae37982a5..4d1046a019 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-logs-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-logs-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + query, +} from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -95,14 +99,16 @@ acceptance("Group logs", function (needs) { test("Browsing group logs", async function (assert) { await visit("/g/snorlax/manage/logs"); - assert.ok( - queryAll("tr.group-manage-logs-row").length === 2, + assert.equal( + count("tr.group-manage-logs-row"), + 2, "it should display the right number of logs" ); - await click(queryAll(".group-manage-logs-row button")[0]); - assert.ok( - queryAll("tr.group-manage-logs-row").length === 1, + await click(query(".group-manage-logs-row button")); + assert.equal( + count("tr.group-manage-logs-row"), + 1, "it should display the right number of logs" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-membership-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-membership-test.js index afef5e560c..45e0ae73ac 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-membership-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-membership-test.js @@ -1,6 +1,7 @@ import { acceptance, - queryAll, + count, + exists, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; @@ -15,61 +16,70 @@ acceptance("Managing Group Membership", function (needs) { await visit("/g/alternative-group/manage/membership"); - assert.ok( - queryAll('label[for="automatic_membership"]').length === 1, + assert.equal( + count('label[for="automatic_membership"]'), + 1, "it should display automatic membership label" ); - assert.ok( - queryAll(".groups-form-primary-group").length === 1, + assert.equal( + count(".groups-form-primary-group"), + 1, "it should display set as primary group checkbox" ); - assert.ok( - queryAll(".groups-form-grant-trust-level").length === 1, + assert.equal( + count(".groups-form-grant-trust-level"), + 1, "it should display grant trust level selector" ); - assert.ok( - queryAll(".group-form-public-admission").length === 1, + assert.equal( + count(".group-form-public-admission"), + 1, "it should display group public admission input" ); - assert.ok( - queryAll(".group-form-public-exit").length === 1, + assert.equal( + count(".group-form-public-exit"), + 1, "it should display group public exit input" ); - assert.ok( - queryAll(".group-form-allow-membership-requests").length === 1, + assert.equal( + count(".group-form-allow-membership-requests"), + 1, "it should display group allow_membership_request input" ); - assert.ok( - queryAll(".group-form-allow-membership-requests[disabled]").length === 1, + assert.equal( + count(".group-form-allow-membership-requests[disabled]"), + 1, "it should disable group allow_membership_request input" ); - assert.ok( - queryAll(".group-flair-inputs").length === 1, + assert.equal( + count(".group-flair-inputs"), + 1, "it should display avatar flair inputs" ); await click(".group-form-public-admission"); await click(".group-form-allow-membership-requests"); - assert.ok( - queryAll(".group-form-public-admission[disabled]").length === 1, + assert.equal( + count(".group-form-public-admission[disabled]"), + 1, "it should disable group public admission input" ); assert.ok( - queryAll(".group-form-public-exit[disabled]").length === 0, + !exists(".group-form-public-exit[disabled]"), "it should not disable group public exit input" ); assert.equal( - queryAll(".group-form-membership-request-template").length, + count(".group-form-membership-request-template"), 1, "it should display the membership request template field" ); @@ -90,42 +100,46 @@ acceptance("Managing Group Membership", function (needs) { await visit("/g/discourse/manage/membership"); assert.ok( - queryAll('label[for="automatic_membership"]').length === 0, + !exists('label[for="automatic_membership"]'), "it should not display automatic membership label" ); assert.ok( - queryAll(".groups-form-automatic-membership-retroactive").length === 0, + !exists(".groups-form-automatic-membership-retroactive"), "it should not display automatic membership retroactive checkbox" ); assert.ok( - queryAll(".groups-form-primary-group").length === 0, + !exists(".groups-form-primary-group"), "it should not display set as primary group checkbox" ); assert.ok( - queryAll(".groups-form-grant-trust-level").length === 0, + !exists(".groups-form-grant-trust-level"), "it should not display grant trust level selector" ); - assert.ok( - queryAll(".group-form-public-admission").length === 1, + assert.equal( + count(".group-form-public-admission"), + 1, "it should display group public admission input" ); - assert.ok( - queryAll(".group-form-public-exit").length === 1, + assert.equal( + count(".group-form-public-exit"), + 1, "it should display group public exit input" ); - assert.ok( - queryAll(".group-form-allow-membership-requests").length === 1, + assert.equal( + count(".group-form-allow-membership-requests"), + 1, "it should display group allow_membership_request input" ); - assert.ok( - queryAll(".group-form-allow-membership-requests[disabled]").length === 1, + assert.equal( + count(".group-form-allow-membership-requests[disabled]"), + 1, "it should disable group allow_membership_request input" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-profile-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-profile-test.js index 7a0ced0802..70fda0422f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-profile-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-profile-test.js @@ -1,7 +1,7 @@ import { acceptance, count, - queryAll, + exists, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; @@ -12,7 +12,7 @@ acceptance("Managing Group Profile", function () { await visit("/g/discourse/manage/profile"); assert.ok( - count(".group-members tr") > 0, + exists(".group-members tr"), "it should redirect to members page for an anonymous user" ); }); @@ -24,16 +24,19 @@ acceptance("Managing Group Profile", function (needs) { test("As an admin", async function (assert) { await visit("/g/discourse/manage/profile"); - assert.ok( - queryAll(".group-form-bio").length === 1, + assert.equal( + count(".group-form-bio"), + 1, "it should display group bio input" ); - assert.ok( - queryAll(".group-form-name").length === 1, + assert.equal( + count(".group-form-name"), + 1, "it should display group name input" ); - assert.ok( - queryAll(".group-form-full-name").length === 1, + assert.equal( + count(".group-form-full-name"), + 1, "it should display group full name input" ); }); @@ -47,9 +50,8 @@ acceptance("Managing Group Profile", function (needs) { await visit("/g/discourse/manage/profile"); - assert.equal( - queryAll(".group-form-name").length, - 0, + assert.ok( + !exists(".group-form-name"), "it should not display group name input" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-tags-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-tags-test.js index f309023e6d..6b235c88d0 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-tags-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-tags-test.js @@ -1,7 +1,7 @@ import { acceptance, count, - queryAll, + exists, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; @@ -12,7 +12,7 @@ acceptance("Managing Group Tag Notification Defaults", function () { await visit("/g/discourse/manage/tags"); assert.ok( - count(".group-members tr") > 0, + exists(".group-members tr"), "it should redirect to members page for an anonymous user" ); }); @@ -24,8 +24,9 @@ acceptance("Managing Group Tag Notification Defaults", function (needs) { test("As an admin", async function (assert) { await visit("/g/discourse/manage/tags"); - assert.ok( - queryAll(".groups-notifications-form .tag-chooser").length === 5, + assert.equal( + count(".groups-notifications-form .tag-chooser"), + 5, "it should display tag inputs" ); }); @@ -35,8 +36,9 @@ acceptance("Managing Group Tag Notification Defaults", function (needs) { await visit("/g/discourse/manage/tags"); - assert.ok( - queryAll(".groups-notifications-form .tag-chooser").length === 5, + assert.equal( + count(".groups-notifications-form .tag-chooser"), + 5, "it should display tag inputs" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js index 3c91365f64..fbfca35e2d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-requests-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -85,7 +89,7 @@ acceptance("Group Requests", function (needs) { test("Group Requests", async function (assert) { await visit("/g/Macdonald/requests"); - assert.equal(queryAll(".group-members tr").length, 2); + assert.equal(count(".group-members tr"), 2); assert.equal( queryAll(".group-members tr:first-child td:nth-child(1)") .text() diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-test.js index e172429904..3ba3ea4a7c 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-test.js @@ -28,15 +28,14 @@ acceptance("Group - Anonymous", function (needs) { test("Anonymous Viewing Group", async function (assert) { await visit("/g/discourse"); - assert.equal( - count(".nav-pills li a[title='Messages']"), - 0, + assert.ok( + !exists(".nav-pills li a[title='Messages']"), "it does not show group messages navigation link" ); await click(".nav-pills li a[title='Activity']"); - assert.ok(count(".user-stream-item") > 0, "it lists stream items"); + assert.ok(exists(".user-stream-item"), "it lists stream items"); await click(".activity-nav li a[href='/g/discourse/activity/topics']"); @@ -45,16 +44,16 @@ acceptance("Group - Anonymous", function (needs) { await click(".activity-nav li a[href='/g/discourse/activity/mentions']"); - assert.ok(count(".user-stream-item") > 0, "it lists stream items"); + assert.ok(exists(".user-stream-item"), "it lists stream items"); assert.ok( - queryAll(".nav-pills li a[title='Edit Group']").length === 0, + !exists(".nav-pills li a[title='Edit Group']"), "it should not show messages tab if user is not admin" ); assert.ok( - queryAll(".nav-pills li a[title='Logs']").length === 0, + !exists(".nav-pills li a[title='Logs']"), "it should not show Logs tab if user is not admin" ); - assert.ok(count(".user-stream-item") > 0, "it lists stream items"); + assert.ok(exists(".user-stream-item"), "it lists stream items"); const groupDropdown = selectKit(".group-dropdown"); await groupDropdown.expand(); @@ -73,9 +72,8 @@ acceptance("Group - Anonymous", function (needs) { await groupDropdown.expand(); - assert.equal( - queryAll(".group-dropdown-filter").length, - 0, + assert.ok( + !exists(".group-dropdown-filter"), "it should not display the default header" ); }); @@ -83,9 +81,8 @@ acceptance("Group - Anonymous", function (needs) { test("Anonymous Viewing Automatic Group", async function (assert) { await visit("/g/moderators"); - assert.equal( - count(".nav-pills li a[title='Manage']"), - 0, + assert.ok( + !exists(".nav-pills li a[title='Manage']"), "it does not show group messages navigation link" ); }); @@ -214,7 +211,7 @@ acceptance("Group - Authenticated", function (needs) { await click(".group-message-button"); - assert.ok(count("#reply-control") === 1, "it opens the composer"); + assert.equal(count("#reply-control"), 1, "it opens the composer"); assert.equal( queryAll("#private-message-users .selected-name").text().trim(), "discourse", @@ -249,8 +246,9 @@ acceptance("Group - Authenticated", function (needs) { test("Admin Viewing Group", async function (assert) { await visit("/g/discourse"); - assert.ok( - queryAll(".nav-pills li a[title='Manage']").length === 1, + assert.equal( + count(".nav-pills li a[title='Manage']"), + 1, "it should show manage group tab if user is admin" ); @@ -281,15 +279,16 @@ acceptance("Group - Authenticated", function (needs) { test("Moderator Viewing Group", async function (assert) { await visit("/g/alternative-group"); - assert.ok( - queryAll(".nav-pills li a[title='Manage']").length === 1, + assert.equal( + count(".nav-pills li a[title='Manage']"), + 1, "it should show manage group tab if user can_admin_group" ); await click(".group-members-add.btn"); assert.ok( - queryAll(".group-add-members-modal .group-add-members-make-owner"), + exists(".group-add-members-modal .group-add-members-make-owner"), "it allows moderators to set group owners" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/groups-index-test.js b/app/assets/javascripts/discourse/tests/acceptance/groups-index-test.js index e1bc069f20..22a425a189 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/groups-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/groups-index-test.js @@ -18,12 +18,12 @@ acceptance("Groups", function () { assert.equal(count(".group-box"), 2, "it displays visible groups"); assert.equal( - queryAll(".group-index-join").length, + count(".group-index-join"), 1, "it shows button to join group" ); assert.equal( - queryAll(".group-index-request").length, + count(".group-index-request"), 1, "it shows button to request for group membership" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/groups-new-test.js b/app/assets/javascripts/discourse/tests/acceptance/groups-new-test.js index 421add93b5..c79299dcdc 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/groups-new-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/groups-new-test.js @@ -1,4 +1,9 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import I18n from "I18n"; import { test } from "qunit"; @@ -7,9 +12,8 @@ acceptance("New Group - Anonymous", function () { test("As an anon user", async function (assert) { await visit("/g"); - assert.equal( - queryAll(".groups-header-new").length, - 0, + assert.ok( + !exists(".groups-header-new"), "it should not display the button to create a group" ); }); @@ -22,7 +26,7 @@ acceptance("New Group - Authenticated", function (needs) { await click(".groups-header-new"); assert.equal( - queryAll(".group-form-save[disabled]").length, + count(".group-form-save[disabled]"), 1, "save button should be disabled" ); @@ -35,8 +39,9 @@ acceptance("New Group - Authenticated", function (needs) { "it should show the right validation tooltip" ); - assert.ok( - queryAll(".group-form-save:disabled").length === 1, + assert.equal( + count(".group-form-save:disabled"), + 1, "it should disable the save button" ); @@ -69,9 +74,8 @@ acceptance("New Group - Authenticated", function (needs) { await click(".group-form-public-admission"); - assert.equal( - queryAll("groups-new-allow-membership-requests").length, - 0, + assert.ok( + !exists("groups-new-allow-membership-requests"), "it should disable the membership requests checkbox" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/mobile-pan-test.js b/app/assets/javascripts/discourse/tests/acceptance/mobile-pan-test.js index 837e303e80..ff78f54779 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/mobile-pan-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/mobile-pan-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, +} from "discourse/tests/helpers/qunit-helpers"; import { click, triggerEvent, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -42,6 +46,7 @@ async function triggerSwipeStart(touchTarget) { }); return touchStart; } + async function triggerSwipeMove({ x, y, touchTarget }) { const touch = new Touch({ identifier: "test", @@ -54,6 +59,7 @@ async function triggerSwipeMove({ x, y, touchTarget }) { targetTouches: [touch], }); } + async function triggerSwipeEnd({ x, y, touchTarget }) { const touch = new Touch({ identifier: "test", @@ -70,6 +76,7 @@ async function triggerSwipeEnd({ x, y, touchTarget }) { acceptance("Mobile - menu swipes", function (needs) { needs.mobileView(); needs.user(); + test("swipe to close hamburger", async function (assert) { await visit("/"); await click(".hamburger-dropdown"); @@ -81,7 +88,7 @@ acceptance("Mobile - menu swipes", function (needs) { await triggerSwipeEnd(swipe); assert.ok( - queryAll(".panel-body").length === 0, + !exists(".panel-body"), "it should close hamburger on a left swipe" ); }); @@ -98,8 +105,9 @@ acceptance("Mobile - menu swipes", function (needs) { await triggerSwipeMove(swipe); await triggerSwipeEnd(swipe); - assert.ok( - queryAll(".panel-body").length === 1, + assert.equal( + count(".panel-body"), + 1, "it should re-open hamburger on a right swipe" ); }); @@ -115,7 +123,7 @@ acceptance("Mobile - menu swipes", function (needs) { await triggerSwipeEnd(swipe); assert.ok( - queryAll(".panel-body").length === 0, + !exists(".panel-body"), "it should close user menu on a left swipe" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/modal-test.js b/app/assets/javascripts/discourse/tests/acceptance/modal-test.js index f4ca0364a2..e1afdc715f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/modal-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/modal-test.js @@ -1,6 +1,8 @@ import { acceptance, controllerFor, + count, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, triggerKeyEvent, visit } from "@ember/test-helpers"; @@ -31,35 +33,26 @@ acceptance("Modal", function (needs) { skip("modal", async function (assert) { await visit("/"); - assert.ok( - queryAll(".d-modal:visible").length === 0, - "there is no modal at first" - ); + assert.ok(!exists(".d-modal:visible"), "there is no modal at first"); await click(".login-button"); - assert.ok(queryAll(".d-modal:visible").length === 1, "modal should appear"); + assert.equal(count(".d-modal:visible"), 1, "modal should appear"); let controller = controllerFor("modal"); assert.equal(controller.name, "login"); await click(".modal-outer-container"); assert.ok( - queryAll(".d-modal:visible").length === 0, + !exists(".d-modal:visible"), "modal should disappear when you click outside" ); assert.equal(controller.name, null); await click(".login-button"); - assert.ok( - queryAll(".d-modal:visible").length === 1, - "modal should reappear" - ); + assert.equal(count(".d-modal:visible"), 1, "modal should reappear"); await triggerKeyEvent("#main-outlet", "keyup", 27); - assert.ok( - queryAll(".d-modal:visible").length === 0, - "ESC should close the modal" - ); + assert.ok(!exists(".d-modal:visible"), "ESC should close the modal"); Ember.TEMPLATES[ "modal/not-dismissable" @@ -67,16 +60,18 @@ acceptance("Modal", function (needs) { run(() => showModal("not-dismissable", {})); - assert.ok(queryAll(".d-modal:visible").length === 1, "modal should appear"); + assert.equal(count(".d-modal:visible"), 1, "modal should appear"); await click(".modal-outer-container"); - assert.ok( - queryAll(".d-modal:visible").length === 1, + assert.equal( + count(".d-modal:visible"), + 1, "modal should not disappear when you click outside" ); await triggerKeyEvent("#main-outlet", "keyup", 27); - assert.ok( - queryAll(".d-modal:visible").length === 1, + assert.equal( + count(".d-modal:visible"), + 1, "ESC should not close the modal" ); }); @@ -126,7 +121,7 @@ acceptance("Modal", function (needs) { run(() => showModal("test-title")); assert.ok( - queryAll(".d-modal .title").length === 0, + !exists(".d-modal .title"), "it should not re-use the previous title" ); }); @@ -142,20 +137,19 @@ acceptance("Modal Keyboard Events", function (needs) { await click(".admin-topic-timer-update button"); await triggerKeyEvent(".d-modal", "keyup", 13); - assert.ok( - queryAll("#modal-alert:visible").length === 1, + assert.equal( + count("#modal-alert:visible"), + 1, "hitting Enter triggers modal action" ); - assert.ok( - queryAll(".d-modal:visible").length === 1, + assert.equal( + count(".d-modal:visible"), + 1, "hitting Enter does not dismiss modal due to alert error" ); await triggerKeyEvent("#main-outlet", "keyup", 27); - assert.ok( - queryAll(".d-modal:visible").length === 0, - "ESC should close the modal" - ); + assert.ok(!exists(".d-modal:visible"), "ESC should close the modal"); await click(".topic-body button.reply"); @@ -163,7 +157,7 @@ acceptance("Modal Keyboard Events", function (needs) { await triggerKeyEvent(".d-modal", "keyup", 13); assert.ok( - queryAll(".d-modal:visible").length === 0, + !exists(".d-modal:visible"), "modal should disappear on hitting Enter" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js b/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js index 14ccee5afc..25f4631738 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/notifications-filter-test.js @@ -1,4 +1,4 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; import { visit } from "@ember/test-helpers"; @@ -9,7 +9,7 @@ acceptance("Notifications filter", function (needs) { test("Notifications filter true", async function (assert) { await visit("/u/eviltrout/notifications"); - assert.ok(queryAll(".large-notification").length >= 0); + assert.ok(exists(".large-notification")); }); test("Notifications filter read", async function (assert) { @@ -19,7 +19,7 @@ acceptance("Notifications filter", function (needs) { await dropdown.expand(); await dropdown.selectRowByValue("read"); - assert.ok(queryAll(".large-notification").length >= 0); + assert.ok(exists(".large-notification")); }); test("Notifications filter unread", async function (assert) { @@ -29,6 +29,6 @@ acceptance("Notifications filter", function (needs) { await dropdown.expand(); await dropdown.selectRowByValue("unread"); - assert.ok(queryAll(".large-notification").length >= 0); + assert.ok(exists(".large-notification")); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/notifications-test.js b/app/assets/javascripts/discourse/tests/acceptance/notifications-test.js index 4ff691b932..200b2900e0 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/notifications-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/notifications-test.js @@ -1,7 +1,10 @@ import { visit } from "@ember/test-helpers"; import { acceptance, + count, publishToMessageBus, + query, + queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; @@ -32,7 +35,7 @@ acceptance("User Notifications", function (needs) { await visit("/"); // wait for re-render - assert.equal(find("#quick-access-notifications li").length, 5); + assert.equal(count("#quick-access-notifications li"), 5); // high priority, unread notification - should be first @@ -77,9 +80,9 @@ acceptance("User Notifications", function (needs) { await visit("/"); // wait for re-render - assert.equal(find("#quick-access-notifications li").length, 6); + assert.equal(count("#quick-access-notifications li"), 6); assert.equal( - find("#quick-access-notifications li span[data-topic-id]")[0].innerText, + query("#quick-access-notifications li span[data-topic-id]").innerText, "First notification" ); @@ -127,9 +130,10 @@ acceptance("User Notifications", function (needs) { await visit("/"); // wait for re-render - assert.equal(find("#quick-access-notifications li").length, 7); + assert.equal(count("#quick-access-notifications li"), 7); assert.equal( - find("#quick-access-notifications li span[data-topic-id]")[1].innerText, + queryAll("#quick-access-notifications li span[data-topic-id]")[1] + .innerText, "Second notification" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js index c7028c7acb..e69156dd53 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-connector-class-test.js @@ -1,4 +1,9 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import { action } from "@ember/object"; import { extraConnectorClass } from "discourse/lib/plugin-connectors"; @@ -65,12 +70,13 @@ acceptance("Plugin Outlet - Connector Class", function (needs) { test("Renders a template into the outlet", async function (assert) { await visit("/u/eviltrout"); - assert.ok( - queryAll(".user-profile-primary-outlet.hello").length === 1, + assert.equal( + count(".user-profile-primary-outlet.hello"), + 1, "it has class names" ); assert.ok( - !queryAll(".user-profile-primary-outlet.dont-render").length, + !exists(".user-profile-primary-outlet.dont-render"), "doesn't render" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-multi-template-test.js b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-multi-template-test.js index 640916bfee..ae0e298abf 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-multi-template-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-multi-template-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { clearCache } from "discourse/lib/plugin-connectors"; import hbs from "htmlbars-inline-precompile"; import { test } from "qunit"; @@ -23,12 +27,14 @@ acceptance("Plugin Outlet - Multi Template", function (needs) { test("Renders a template into the outlet", async function (assert) { await visit("/u/eviltrout"); - assert.ok( - queryAll(".user-profile-primary-outlet.hello").length === 1, + assert.equal( + count(".user-profile-primary-outlet.hello"), + 1, "it has class names" ); - assert.ok( - queryAll(".user-profile-primary-outlet.goodbye").length === 1, + assert.equal( + count(".user-profile-primary-outlet.goodbye"), + 1, "it has class names" ); assert.equal( diff --git a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-single-template-test.js b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-single-template-test.js index edd4504072..27fc2e7443 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-single-template-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/plugin-outlet-single-template-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; import { test } from "qunit"; import { visit } from "@ember/test-helpers"; @@ -19,8 +23,9 @@ acceptance("Plugin Outlet - Single Template", function (needs) { test("Renders a template into the outlet", async function (assert) { await visit("/u/eviltrout"); - assert.ok( - queryAll(".user-profile-primary-outlet.hello").length === 1, + assert.equal( + count(".user-profile-primary-outlet.hello"), + 1, "it has class names" ); assert.equal( diff --git a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js index a4ee8a6d23..0014ebc2bf 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/preferences-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, updateCurrentUser, @@ -495,27 +496,30 @@ acceptance("Security", function (needs) { I18n.t("user.auth_tokens.show_all", { count: 3 }), "it should display two tokens" ); - assert.ok( - queryAll(".pref-auth-tokens .auth-token").length === 2, + assert.equal( + count(".pref-auth-tokens .auth-token"), + 2, "it should display two tokens" ); await click(".pref-auth-tokens > a:nth-of-type(1)"); - assert.ok( - queryAll(".pref-auth-tokens .auth-token").length === 3, + assert.equal( + count(".pref-auth-tokens .auth-token"), + 3, "it should display three tokens" ); await click(".auth-token-dropdown button:nth-of-type(1)"); await click("li[data-value='notYou']"); - assert.ok(queryAll(".d-modal:visible").length === 1, "modal should appear"); + assert.equal(count(".d-modal:visible"), 1, "modal should appear"); await click(".modal-footer .btn-primary"); - assert.ok( - queryAll(".pref-password.highlighted").length === 1, + assert.equal( + count(".pref-password.highlighted"), + 1, "it should highlight password preferences" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/raw-plugin-outlet-test.js b/app/assets/javascripts/discourse/tests/acceptance/raw-plugin-outlet-test.js index 24d8055fb7..ba6465bf71 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/raw-plugin-outlet-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/raw-plugin-outlet-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; import { addRawTemplate, removeRawTemplate, @@ -23,9 +27,9 @@ acceptance("Raw Plugin Outlet", function (needs) { }); test("Renders the raw plugin outlet", async function (assert) { await visit("/"); - assert.ok(queryAll(".topic-lala").length > 0, "it renders the outlet"); + assert.ok(exists(".topic-lala"), "it renders the outlet"); assert.equal( - queryAll(".topic-lala:nth-of-type(1)")[0].innerText, + query(".topic-lala:nth-of-type(1)").innerText, "11557", "it has the topic id" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/review-test.js b/app/assets/javascripts/discourse/tests/acceptance/review-test.js index dbdf2f0289..522fe2c189 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/review-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/review-test.js @@ -1,6 +1,9 @@ import { acceptance, + count, + exists, publishToMessageBus, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; @@ -16,18 +19,18 @@ acceptance("Review", function (needs) { test("It returns a list of reviewable items", async function (assert) { await visit("/review"); - assert.ok(queryAll(".reviewable-item").length, "has a list of items"); - assert.ok(queryAll(user).length); + assert.ok(exists(".reviewable-item"), "has a list of items"); + assert.ok(exists(user)); assert.ok( - queryAll(`${user}.reviewable-user`).length, + exists(`${user}.reviewable-user`), "applies a class for the type" ); assert.ok( - queryAll(`${user} .reviewable-action.approve`).length, + exists(`${user} .reviewable-action.approve`), "creates a button for approve" ); assert.ok( - queryAll(`${user} .reviewable-action.reject`).length, + exists(`${user} .reviewable-action.reject`), "creates a button for reject" ); }); @@ -35,7 +38,7 @@ acceptance("Review", function (needs) { test("Grouped by topic", async function (assert) { await visit("/review/topics"); assert.ok( - queryAll(".reviewable-topic").length, + exists(".reviewable-topic"), "it has a list of reviewable topics" ); }); @@ -70,10 +73,7 @@ acceptance("Review", function (needs) { test("Settings", async function (assert) { await visit("/review/settings"); - assert.ok( - queryAll(".reviewable-score-type").length, - "has a list of bonuses" - ); + assert.ok(exists(".reviewable-score-type"), "has a list of bonuses"); const field = selectKit( ".reviewable-score-type:nth-of-type(1) .field .combo-box" @@ -82,15 +82,14 @@ acceptance("Review", function (needs) { await field.selectRowByValue("5"); await click(".save-settings"); - assert.ok(queryAll(".reviewable-settings .saved").length, "it saved"); + assert.ok(exists(".reviewable-settings .saved"), "it saved"); }); test("Flag related", async function (assert) { await visit("/review"); assert.ok( - queryAll(".reviewable-flagged-post .post-contents .username a[href]") - .length, + exists(".reviewable-flagged-post .post-contents .username a[href]"), "it has a link to the user" ); @@ -99,36 +98,26 @@ acceptance("Review", function (needs) { "cooked content" ); - assert.equal( - queryAll(".reviewable-flagged-post .reviewable-score").length, - 2 - ); + assert.equal(count(".reviewable-flagged-post .reviewable-score"), 2); }); test("Flag related", async function (assert) { await visit("/review/1"); - assert.ok( - queryAll(".reviewable-flagged-post").length, - "it shows the flagged post" - ); + assert.ok(exists(".reviewable-flagged-post"), "it shows the flagged post"); }); test("Clicking the buttons triggers actions", async function (assert) { await visit("/review"); await click(`${user} .reviewable-action.approve`); - assert.equal( - queryAll(user).length, - 0, - "it removes the reviewable on success" - ); + assert.ok(!exists(user), "it removes the reviewable on success"); }); test("Editing a reviewable", async function (assert) { const topic = '.reviewable-item[data-reviewable-id="4321"]'; await visit("/review"); - assert.ok(queryAll(`${topic} .reviewable-action.approve`).length); - assert.ok(!queryAll(`${topic} .category-name`).length); + assert.ok(exists(`${topic} .reviewable-action.approve`)); + assert.ok(!exists(`${topic} .category-name`)); assert.equal( queryAll(`${topic} .discourse-tag:nth-of-type(1)`).text(), "hello" @@ -146,14 +135,13 @@ acceptance("Review", function (needs) { await click(`${topic} .reviewable-action.edit`); await click(`${topic} .reviewable-action.save-edit`); assert.ok( - queryAll(`${topic} .reviewable-action.approve`).length, + exists(`${topic} .reviewable-action.approve`), "saving without changes is a cancel" ); await click(`${topic} .reviewable-action.edit`); - assert.equal( - queryAll(`${topic} .reviewable-action.approve`).length, - 0, + assert.ok( + !exists(`${topic} .reviewable-action.approve`), "when editing actions are disabled" ); @@ -201,10 +189,10 @@ acceptance("Review", function (needs) { test("Reviewables can become stale", async function (assert) { await visit("/review"); - const reviewable = find("[data-reviewable-id=1234]")[0]; + const reviewable = query(`[data-reviewable-id="1234"]`); assert.notOk(reviewable.className.includes("reviewable-stale")); - assert.equal(find("[data-reviewable-id=1234] .status .pending").length, 1); - assert.equal(find(".stale-help").length, 0); + assert.equal(count(`[data-reviewable-id="1234"] .status .pending`), 1); + assert.ok(!exists(".stale-help")); publishToMessageBus("/reviewable_counts", { review_count: 1, @@ -216,7 +204,7 @@ acceptance("Review", function (needs) { await visit("/review"); // wait for re-render assert.ok(reviewable.className.includes("reviewable-stale")); - assert.equal(find("[data-reviewable-id=1234] .status .approved").length, 1); - assert.equal(find(".stale-help").length, 1); + assert.equal(count("[data-reviewable-id=1234] .status .approved"), 1); + assert.equal(count(".stale-help"), 1); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-full-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-full-test.js index 48f0d1f9ed..04e1fe1eba 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-full-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-full-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, selectDate, @@ -96,19 +97,20 @@ acceptance("Search - Full Page", function (needs) { assert.ok($("body.search-page").length, "has body class"); assert.ok(exists(".search-container"), "has container class"); - assert.ok(queryAll(".search-query").length > 0); - assert.ok(queryAll(".fps-topic").length === 0); + assert.ok(exists(".search-query")); + assert.ok(!exists(".fps-topic")); await fillIn(".search-query", "none"); await click(".search-cta"); - assert.ok(queryAll(".fps-topic").length === 0, "has no results"); - assert.ok(queryAll(".no-results-suggestion .google-search-form")); + assert.ok(!exists(".fps-topic"), "has no results"); + assert.ok(exists(".no-results-suggestion")); + assert.ok(exists(".google-search-form")); await fillIn(".search-query", "discourse"); await click(".search-cta"); - assert.ok(queryAll(".fps-topic").length === 1, "has one post"); + assert.equal(count(".fps-topic"), 1, "has one post"); }); test("search for personal messages", async function (assert) { @@ -117,10 +119,11 @@ acceptance("Search - Full Page", function (needs) { await fillIn(".search-query", "discourse in:personal"); await click(".search-cta"); - assert.ok(queryAll(".fps-topic").length === 1, "has one post"); + assert.equal(count(".fps-topic"), 1, "has one post"); - assert.ok( - queryAll(".topic-status .personal_message").length === 1, + assert.equal( + count(".topic-status .personal_message"), + 1, "shows the right icon" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-mobile-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-mobile-test.js index 0f302c2ca9..c32647550e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-mobile-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-mobile-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -23,18 +24,19 @@ acceptance("Search - Mobile", function (needs) { await click(".search-advanced-title"); - assert.ok( - queryAll(".search-advanced-filters").length === 1, + assert.equal( + count(".search-advanced-filters"), + 1, "it should expand advanced search filters" ); await fillIn(".search-query", "discourse"); await click(".search-cta"); - assert.ok(queryAll(".fps-topic").length === 1, "has one post"); + assert.equal(count(".fps-topic"), 1, "has one post"); assert.ok( - queryAll(".search-advanced-filters").length === 0, + !exists(".search-advanced-filters"), "it should collapse advanced search filters" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js index ed50a785c4..0d8e4c0049 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/share-topic-test.js @@ -1,6 +1,7 @@ import { click, visit } from "@ember/test-helpers"; import { acceptance, + count, exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -29,7 +30,7 @@ acceptance("Share and Invite modal", function (needs) { "it shows the topic sharing url" ); - assert.ok(queryAll(".social-link").length > 1, "it shows social sources"); + assert.ok(count(".social-link") > 1, "it shows social sources"); assert.ok( exists(".btn-primary[aria-label='Notify']"), diff --git a/app/assets/javascripts/discourse/tests/acceptance/shared-drafts-test.js b/app/assets/javascripts/discourse/tests/acceptance/shared-drafts-test.js index 493d361eb5..3b498dd35d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/shared-drafts-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/shared-drafts-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + exists, +} from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; @@ -6,19 +10,19 @@ import { test } from "qunit"; acceptance("Shared Drafts", function () { test("Viewing and publishing", async function (assert) { await visit("/t/some-topic/9"); - assert.ok(queryAll(".shared-draft-controls").length === 1); + assert.equal(count(".shared-draft-controls"), 1); let categoryChooser = selectKit(".shared-draft-controls .category-chooser"); assert.equal(categoryChooser.header().value(), "3"); await click(".publish-shared-draft"); await click(".bootbox .btn-primary"); - assert.ok(queryAll(".shared-draft-controls").length === 0); + assert.ok(!exists(".shared-draft-controls")); }); test("Updating category", async function (assert) { await visit("/t/some-topic/9"); - assert.ok(queryAll(".shared-draft-controls").length === 1); + assert.equal(count(".shared-draft-controls"), 1); await click(".edit-topic"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/sign-in-test.js b/app/assets/javascripts/discourse/tests/acceptance/sign-in-test.js index 9b6136663a..55c46a3e0b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sign-in-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sign-in-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -64,12 +65,12 @@ acceptance("Signing In", function () { await click(".modal-footer button.edit-email"); assert.equal(queryAll(".activate-new-email").val(), "current@example.com"); assert.equal( - queryAll(".modal-footer .btn-primary:disabled").length, + count(".modal-footer .btn-primary:disabled"), 1, "must change email" ); await fillIn(".activate-new-email", "different@example.com"); - assert.equal(queryAll(".modal-footer .btn-primary:disabled").length, 0); + assert.ok(!exists(".modal-footer .btn-primary:disabled")); await click(".modal-footer .btn-primary"); assert.equal(queryAll(".modal-body b").text(), "different@example.com"); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/tag-groups-test.js b/app/assets/javascripts/discourse/tests/acceptance/tag-groups-test.js index 352f6746e0..3de7f851df 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/tag-groups-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/tag-groups-test.js @@ -1,7 +1,7 @@ import { acceptance, exists, - queryAll, + query, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -53,7 +53,7 @@ acceptance("Tag Groups", function (needs) { await tags.expand(); await click(".group-tags-list .tag-chooser .choice:nth-of-type(1)"); - assert.ok(!queryAll(".tag-group-content .btn.btn-danger")[0].disabled); + assert.ok(!query(".tag-group-content .btn.btn-danger").disabled); }); test("tag groups can have multiple groups added to them", async function (assert) { @@ -72,7 +72,7 @@ acceptance("Tag Groups", function (needs) { await groups.selectRowByIndex(1); await groups.selectRowByIndex(0); - assert.ok(!queryAll(".tag-group-content .btn.btn-primary")[0].disabled); + assert.ok(!query(".tag-group-content .btn.btn-primary").disabled); await click(".tag-group-content .btn.btn-primary"); await click(".tag-groups-sidebar li:first-child a"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/tags-test.js b/app/assets/javascripts/discourse/tests/acceptance/tags-test.js index 7e45eda718..3c259a88bb 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/tags-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/tags-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, invisible, queryAll, @@ -233,18 +234,18 @@ acceptance("Tags listed by group", function (needs) { updateCurrentUser({ moderator: false, admin: false }); await visit("/tag/regular-tag"); - assert.ok(queryAll("#create-topic:disabled").length === 0); + assert.ok(!exists("#create-topic:disabled")); await visit("/tag/staff-only-tag"); - assert.ok(queryAll("#create-topic:disabled").length === 1); + assert.equal(count("#create-topic:disabled"), 1); updateCurrentUser({ moderator: true }); await visit("/tag/regular-tag"); - assert.ok(queryAll("#create-topic:disabled").length === 0); + assert.ok(!exists("#create-topic:disabled")); await visit("/tag/staff-only-tag"); - assert.ok(queryAll("#create-topic:disabled").length === 0); + assert.ok(!exists("#create-topic:disabled")); }); }); @@ -383,7 +384,7 @@ acceptance("Tag info", function (needs) { updateCurrentUser({ moderator: false, admin: false }); await visit("/tag/planters"); - assert.ok(queryAll("#show-tag-info").length === 1); + assert.equal(count("#show-tag-info"), 1); await click("#show-tag-info"); assert.ok(exists(".tag-info .tag-name"), "show tag"); @@ -391,14 +392,12 @@ acceptance("Tag info", function (needs) { queryAll(".tag-info .tag-associations").text().indexOf("Gardening") >= 0, "show tag group names" ); - assert.ok( - queryAll(".tag-info .synonyms-list .tag-box").length === 2, + assert.equal( + count(".tag-info .synonyms-list .tag-box"), + 2, "shows the synonyms" ); - assert.ok( - queryAll(".tag-info .badge-category").length === 1, - "show the category" - ); + assert.equal(count(".tag-info .badge-category"), 1, "show the category"); assert.ok(!exists("#rename-tag"), "can't rename tag"); assert.ok(!exists("#edit-synonyms"), "can't edit synonyms"); assert.ok(!exists("#delete-tag"), "can't delete tag"); @@ -408,7 +407,7 @@ acceptance("Tag info", function (needs) { updateCurrentUser({ moderator: false, admin: true }); await visit("/tag/happy-monkey"); - assert.ok(queryAll("#show-tag-info").length === 1); + assert.equal(count("#show-tag-info"), 1); await click("#show-tag-info"); assert.ok(exists(".tag-info .tag-name"), "show tag"); @@ -416,7 +415,7 @@ acceptance("Tag info", function (needs) { await click("#edit-synonyms"); await click("#add-synonyms .filter-input"); - assert.equal(find(".tag-chooser-row").length, 2); + assert.equal(count(".tag-chooser-row"), 2); assert.deepEqual( Array.from(find(".tag-chooser-row")).map((x) => x.dataset["value"]), ["monkey", "not-monkey"] @@ -436,7 +435,7 @@ acceptance("Tag info", function (needs) { updateCurrentUser({ moderator: false, admin: true }); await visit("/tag/planters"); - assert.ok(queryAll("#show-tag-info").length === 1); + assert.equal(count("#show-tag-info"), 1); await click("#show-tag-info"); assert.ok(exists("#rename-tag"), "can rename tag"); @@ -444,18 +443,13 @@ acceptance("Tag info", function (needs) { assert.ok(exists("#delete-tag"), "can delete tag"); await click("#edit-synonyms"); - assert.ok( - queryAll(".unlink-synonym:visible").length === 2, - "unlink UI is visible" - ); - assert.ok( - queryAll(".delete-synonym:visible").length === 2, - "delete UI is visible" - ); + assert.ok(count(".unlink-synonym:visible"), 2, "unlink UI is visible"); + assert.equal(count(".delete-synonym:visible"), 2, "delete UI is visible"); await click(".unlink-synonym:nth-of-type(1)"); - assert.ok( - queryAll(".tag-info .synonyms-list .tag-box").length === 1, + assert.equal( + count(".tag-info .synonyms-list .tag-box"), + 1, "removed a synonym" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-anonymous-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-anonymous-test.js index d07cac2381..e0c1b80dde 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-anonymous-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-anonymous-test.js @@ -1,8 +1,4 @@ -import { - acceptance, - exists, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import { visit } from "@ember/test-helpers"; @@ -12,7 +8,7 @@ acceptance("Topic - Anonymous", function () { assert.ok(exists("#topic"), "The topic was rendered"); assert.ok(exists("#topic .cooked"), "The topic has cooked posts"); assert.ok( - queryAll(".shared-draft-notice").length === 0, + !exists(".shared-draft-notice"), "no shared draft unless there's a dest category id" ); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js index a72cfecdfd..66d78a2ea0 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-quote-button-test.js @@ -29,11 +29,7 @@ acceptance("Topic - Quote button - logged in", function (needs) { await visit("/t/internationalization-localization/280"); await selectText("#post_5 blockquote"); assert.ok(exists(".insert-quote"), "it shows the quote button"); - assert.equal( - queryAll(".quote-sharing").length, - 0, - "it does not show quote sharing" - ); + assert.ok(!exists(".quote-sharing"), "it does not show quote sharing"); }); test("Shows quote share buttons with the right site settings", async function (assert) { @@ -85,11 +81,7 @@ acceptance("Topic - Quote button - anonymous", function (needs) { exists(`.quote-sharing .btn[title='${I18n.t("share.email")}']`), "it includes the email share button" ); - assert.equal( - queryAll(".insert-quote").length, - 0, - "it does not show the quote button" - ); + assert.ok(!exists(".insert-quote"), "it does not show the quote button"); }); test("Shows single share button when site setting only has one item", async function (assert) { @@ -103,9 +95,8 @@ acceptance("Topic - Quote button - anonymous", function (needs) { exists(`.quote-sharing .btn[title='${I18n.t("share.twitter")}']`), "it includes the twitter share button" ); - assert.equal( - queryAll(".quote-share-label").length, - 0, + assert.ok( + !exists(".quote-share-label"), "it does not show the Share label" ); }); @@ -116,16 +107,7 @@ acceptance("Topic - Quote button - anonymous", function (needs) { await visit("/t/internationalization-localization/280"); await selectText("#post_5 blockquote"); - assert.equal( - queryAll(".quote-sharing").length, - 0, - "it does not show quote sharing" - ); - - assert.equal( - queryAll(".insert-quote").length, - 0, - "it does not show the quote button" - ); + assert.ok(!exists(".quote-sharing"), "it does not show quote sharing"); + assert.ok(!exists(".insert-quote"), "it does not show the quote button"); }); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index 32fd762985..52338fc035 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -1,6 +1,8 @@ import { acceptance, + count, exists, + query, queryAll, visible, } from "discourse/tests/helpers/qunit-helpers"; @@ -140,16 +142,13 @@ acceptance("Topic", function (needs) { test("Marking a topic as wiki", async function (assert) { await visit("/t/internationalization-localization/280"); - assert.ok( - queryAll("a.wiki").length === 0, - "it does not show the wiki icon" - ); + assert.ok(!exists("a.wiki"), "it does not show the wiki icon"); await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".topic-post:nth-of-type(1) button.show-post-admin-menu"); await click(".btn.wiki"); - assert.ok(queryAll("button.wiki").length === 1, "it shows the wiki icon"); + assert.equal(count("button.wiki"), 1, "it shows the wiki icon"); }); test("Visit topic routes", async function (assert) { @@ -337,7 +336,7 @@ acceptance("Topic", function (needs) { await visit("/t/internationalization-localization/280"); await click(".gap"); - assert.equal(queryAll(".gap").length, 0, "it hides gap"); + assert.ok(!exists(".gap"), "it hides gap"); }); test("Quoting a quote keeps the original poster name", async function (assert) { @@ -448,12 +447,12 @@ acceptance("Topic with title decorated", function (needs) { await visit("/t/internationalization-localization/280"); assert.ok( - queryAll(".fancy-title")[0].innerText.endsWith("-280-topic-title"), + query(".fancy-title").innerText.endsWith("-280-topic-title"), "it decorates topic title" ); assert.ok( - queryAll(".raw-topic-link:nth-child(1)")[0].innerText.endsWith( + query(".raw-topic-link:nth-child(1)").innerText.endsWith( "-27331-topic-list-item-title" ), "it decorates topic list item title" diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js index 5e86c5c869..6274bf797e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js @@ -1,8 +1,4 @@ -import { - acceptance, - count, - exists, -} from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { currentRouteName, currentURL, visit } from "@ember/test-helpers"; import { test } from "qunit"; @@ -17,15 +13,15 @@ acceptance("User Anonymous", function () { await visit("/u/eviltrout/activity"); assert.ok($("body.user-activity-page").length, "has the body class"); assert.ok(exists(".user-main .about"), "it has the about section"); - assert.ok(count(".user-stream .item") > 0, "it has stream items"); + assert.ok(exists(".user-stream .item"), "it has stream items"); await visit("/u/eviltrout/activity/topics"); - assert.equal(count(".user-stream .item"), 0, "has no stream displayed"); - assert.ok(count(".topic-list tr") > 0, "it has a topic list"); + assert.ok(!exists(".user-stream .item"), "has no stream displayed"); + assert.ok(exists(".topic-list tr"), "it has a topic list"); await visit("/u/eviltrout/activity/replies"); assert.ok(exists(".user-main .about"), "it has the about section"); - assert.ok(count(".user-stream .item") > 0, "it has stream items"); + assert.ok(exists(".user-stream .item"), "it has stream items"); assert.ok(exists(".user-stream.filter-5"), "stream has filter class"); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-bookmarks-test.js index 79c6dd90e1..366465352d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-bookmarks-test.js @@ -14,7 +14,7 @@ acceptance("User's bookmarks", function (needs) { test("removing a bookmark with no reminder does not show a confirmation", async function (assert) { await visit("/u/eviltrout/activity/bookmarks"); - assert.ok(queryAll(".bookmark-list-item").length > 0); + assert.ok(exists(".bookmark-list-item")); const dropdown = selectKit(".bookmark-actions-dropdown:nth-of-type(1)"); await dropdown.expand(); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js index 2f39a4cfde..1b2aa090f5 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-drafts-stream-test.js @@ -1,5 +1,7 @@ import { acceptance, + count, + exists, queryAll, visible, } from "discourse/tests/helpers/qunit-helpers"; @@ -11,21 +13,22 @@ acceptance("User Drafts", function (needs) { test("Stream", async function (assert) { await visit("/u/eviltrout/activity/drafts"); - assert.ok(queryAll(".user-stream-item").length === 3, "has drafts"); + assert.equal(count(".user-stream-item"), 3, "has drafts"); await click(".user-stream-item:last-child .remove-draft"); assert.ok(visible(".bootbox")); await click(".bootbox .btn-primary"); - assert.ok( - queryAll(".user-stream-item").length === 2, + assert.equal( + count(".user-stream-item"), + 2, "draft removed, list length diminished by one" ); }); test("Stream - resume draft", async function (assert) { await visit("/u/eviltrout/activity/drafts"); - assert.ok(queryAll(".user-stream-item").length > 0, "has drafts"); + assert.ok(exists(".user-stream-item"), "has drafts"); await click(".user-stream-item .resume-draft"); assert.equal( diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js index d2ad0e4ef6..6869f9fbad 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-interface-test.js @@ -1,5 +1,6 @@ import { acceptance, + count, exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -125,10 +126,7 @@ acceptance("User Preferences - Interface", function (needs) { assert.equal(selectKit(".theme .select-kit").header().value(), 2); await selectKit(".light-color-scheme .select-kit").expand(); - assert.equal( - queryAll(".light-color-scheme .select-kit .select-kit-row").length, - 2 - ); + assert.equal(count(".light-color-scheme .select-kit .select-kit-row"), 2); document.querySelector("meta[name='discourse_theme_ids']").remove(); }); diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js index 10dd6b8e8c..285367d713 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-preferences-notifications-test.js @@ -1,7 +1,7 @@ import { acceptance, + count, exists, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -78,7 +78,7 @@ acceptance("User notification schedule", function (needs) { "set monday label to none" ); assert.equal( - queryAll(".day.Monday .select-kit.single-select").length, + count(".day.Monday .select-kit.single-select"), 1, "The end time input is hidden" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/ace-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/ace-editor-test.js index 30105cf388..034c228cfa 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/ace-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/ace-editor-test.js @@ -3,6 +3,7 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -15,7 +16,7 @@ discourseModule("Integration | Component | ace-editor", function (hooks) { template: hbs`{{ace-editor mode="css"}}`, test(assert) { assert.expect(1); - assert.ok(queryAll(".ace_editor").length, "it renders the ace editor"); + assert.ok(exists(".ace_editor"), "it renders the ace editor"); }, }); @@ -24,7 +25,7 @@ discourseModule("Integration | Component | ace-editor", function (hooks) { template: hbs`{{ace-editor mode="html" content="wat"}}`, test(assert) { assert.expect(1); - assert.ok(queryAll(".ace_editor").length, "it renders the ace editor"); + assert.ok(exists(".ace_editor"), "it renders the ace editor"); }, }); @@ -33,7 +34,7 @@ discourseModule("Integration | Component | ace-editor", function (hooks) { template: hbs`{{ace-editor mode="sql" content="SELECT * FROM users"}}`, test(assert) { assert.expect(1); - assert.ok(queryAll(".ace_editor").length, "it renders the ace editor"); + assert.ok(exists(".ace_editor"), "it renders the ace editor"); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/activation-controls-test.js b/app/assets/javascripts/discourse/tests/integration/components/activation-controls-test.js index f8a1668a10..1c8c5ae730 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/activation-controls-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/activation-controls-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, exists } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; discourseModule( @@ -20,7 +17,7 @@ discourseModule( }, test(assert) { - assert.equal(queryAll("button.edit-email").length, 0); + assert.ok(!exists("button.edit-email")); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js b/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js index 284f5dcd5c..df9a581b73 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js @@ -2,6 +2,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, exists, queryAll, @@ -94,7 +95,7 @@ discourseModule("Integration | Component | admin-report", function (hooks) { test(assert) { assert.ok(exists(".pagination"), "it paginates the results"); assert.equal( - queryAll(".pagination button").length, + count(".pagination button"), 3, "it creates the correct number of pages" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/cook-text-test.js b/app/assets/javascripts/discourse/tests/integration/components/cook-text-test.js index 05e87b8236..101b02ce3a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/cook-text-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/cook-text-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; import pretender from "discourse/tests/helpers/create-pretender"; import { resetCache } from "pretty-text/upload-short-url"; @@ -16,7 +13,7 @@ discourseModule("Integration | Component | cook-text", function (hooks) { template: hbs`{{cook-text "_foo_" class="post-body"}}`, test(assert) { - const html = queryAll(".post-body")[0].innerHTML.trim(); + const html = query(".post-body").innerHTML.trim(); assert.equal(html, "

foo

"); }, }); @@ -45,7 +42,7 @@ discourseModule("Integration | Component | cook-text", function (hooks) { }, test(assert) { - const html = queryAll(".post-body")[0].innerHTML.trim(); + const html = query(".post-body").innerHTML.trim(); assert.equal( html, '

an image

' diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-button-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-button-test.js index 8c6eed65a3..b0340a4018 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-button-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-button-test.js @@ -18,13 +18,10 @@ discourseModule("Integration | Component | d-button", function (hooks) { test(assert) { assert.ok( - queryAll("button.btn.btn-icon.no-text").length, + exists("button.btn.btn-icon.no-text"), "it has all the classes" ); - assert.ok( - queryAll("button .d-icon.d-icon-plus").length, - "it has the icon" - ); + assert.ok(exists("button .d-icon.d-icon-plus"), "it has the icon"); assert.equal( queryAll("button").attr("tabindex"), "3", @@ -37,18 +34,9 @@ discourseModule("Integration | Component | d-button", function (hooks) { template: hbs`{{d-button icon="plus" label="topic.create"}}`, test(assert) { - assert.ok( - queryAll("button.btn.btn-icon-text").length, - "it has all the classes" - ); - assert.ok( - queryAll("button .d-icon.d-icon-plus").length, - "it has the icon" - ); - assert.ok( - queryAll("button span.d-button-label").length, - "it has the label" - ); + assert.ok(exists("button.btn.btn-icon-text"), "it has all the classes"); + assert.ok(exists("button .d-icon.d-icon-plus"), "it has the icon"); + assert.ok(exists("button span.d-button-label"), "it has the label"); }, }); @@ -56,14 +44,8 @@ discourseModule("Integration | Component | d-button", function (hooks) { template: hbs`{{d-button label="topic.create"}}`, test(assert) { - assert.ok( - queryAll("button.btn.btn-text").length, - "it has all the classes" - ); - assert.ok( - queryAll("button span.d-button-label").length, - "it has the label" - ); + assert.ok(exists("button.btn.btn-text"), "it has all the classes"); + assert.ok(exists("button span.d-button-label"), "it has the label"); }, }); @@ -80,7 +62,7 @@ discourseModule("Integration | Component | d-button", function (hooks) { test(assert) { assert.ok( - queryAll("button.btn-link:not(.btn)").length, + exists("button.btn-link:not(.btn)"), "it has the right classes" ); }, @@ -95,22 +77,22 @@ discourseModule("Integration | Component | d-button", function (hooks) { test(assert) { assert.ok( - queryAll("button.is-loading .loading-icon").length, + exists("button.is-loading .loading-icon"), "it has a spinner showing" ); assert.ok( - queryAll("button[disabled]").length, + exists("button[disabled]"), "while loading the button is disabled" ); this.set("isLoading", false); assert.notOk( - queryAll("button .loading-icon").length, + exists("button .loading-icon"), "it doesn't have a spinner showing" ); assert.ok( - queryAll("button:not([disabled])").length, + exists("button:not([disabled])"), "while not loading the button is enabled" ); }, @@ -124,14 +106,11 @@ discourseModule("Integration | Component | d-button", function (hooks) { }, test(assert) { - assert.ok(queryAll("button[disabled]").length, "the button is disabled"); + assert.ok(exists("button[disabled]"), "the button is disabled"); this.set("disabled", false); - assert.ok( - queryAll("button:not([disabled])").length, - "the button is enabled" - ); + assert.ok(exists("button:not([disabled])"), "the button is enabled"); }, }); @@ -146,7 +125,7 @@ discourseModule("Integration | Component | d-button", function (hooks) { this.set("ariaLabel", "test.fooAriaLabel"); assert.equal( - queryAll("button")[0].getAttribute("aria-label"), + query("button").getAttribute("aria-label"), I18n.t("test.fooAriaLabel") ); @@ -155,7 +134,7 @@ discourseModule("Integration | Component | d-button", function (hooks) { translatedAriaLabel: "bar", }); - assert.equal(queryAll("button")[0].getAttribute("aria-label"), "bar"); + assert.equal(query("button").getAttribute("aria-label"), "bar"); }, }); @@ -169,7 +148,7 @@ discourseModule("Integration | Component | d-button", function (hooks) { test(assert) { this.set("title", "test.fooTitle"); assert.equal( - queryAll("button")[0].getAttribute("title"), + query("button").getAttribute("title"), I18n.t("test.fooTitle") ); @@ -178,7 +157,7 @@ discourseModule("Integration | Component | d-button", function (hooks) { translatedTitle: "bar", }); - assert.equal(queryAll("button")[0].getAttribute("title"), "bar"); + assert.equal(query("button").getAttribute("title"), "bar"); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js index f088f40f68..047ce443a0 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/d-editor-test.js @@ -4,6 +4,8 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { @@ -24,7 +26,7 @@ discourseModule("Integration | Component | d-editor", function (hooks) { template: hbs`{{d-editor value=value}}`, async test(assert) { - assert.ok(queryAll(".d-editor-button-bar").length); + assert.ok(exists(".d-editor-button-bar")); await fillIn(".d-editor-input", "hello **world**"); assert.equal(this.value, "hello **world**"); @@ -80,7 +82,7 @@ discourseModule("Integration | Component | d-editor", function (hooks) { this.set("value", "hello world."); }, test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); testFunc.call(this, assert, textarea); }, }); @@ -94,7 +96,7 @@ discourseModule("Integration | Component | d-editor", function (hooks) { }, test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); testFunc.call(this, assert, textarea); }, }); @@ -236,7 +238,7 @@ discourseModule("Integration | Component | d-editor", function (hooks) { }, async test(assert) { - const textarea = queryAll("textarea.d-editor-input")[0]; + const textarea = query("textarea.d-editor-input"); textarea.selectionStart = 0; textarea.selectionEnd = textarea.value.length; @@ -261,7 +263,7 @@ discourseModule("Integration | Component | d-editor", function (hooks) { }, async test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); await click("button.code"); assert.equal(this.value, ` ${I18n.t("composer.code_text")}`); @@ -346,7 +348,7 @@ third line` }, async test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); await click("button.code"); assert.equal( @@ -458,7 +460,7 @@ third line` this.set("value", "one\n\ntwo\n\nthree"); }, async test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); textarea.selectionStart = 0; @@ -479,7 +481,7 @@ third line` this.set("value", "one\n\n\n\ntwo"); }, async test(assert) { - const textarea = jumpEnd(queryAll("textarea.d-editor-input")[0]); + const textarea = jumpEnd(query("textarea.d-editor-input")); textarea.selectionStart = 6; textarea.selectionEnd = 10; @@ -676,7 +678,7 @@ third line` }, async test(assert) { - jumpEnd(queryAll("textarea.d-editor-input")[0]); + jumpEnd(query("textarea.d-editor-input")); await click("button.emoji"); await click( @@ -721,7 +723,7 @@ third line` }, async test(assert) { - let element = queryAll(".d-editor")[0]; + let element = query(".d-editor"); await paste(element, "\ta\tb\n1\t2\t3"); assert.equal(this.value, "||a|b|\n|---|---|---|\n|1|2|3|\n"); }, @@ -735,7 +737,7 @@ third line` }, async test(assert) { - let element = queryAll(".d-editor")[0]; + let element = query(".d-editor"); await paste(element, '\ta\tb\n1\t"2\n2.5"\t3'); assert.equal(this.value, "||a|b|\n|---|---|---|\n|1|2
2.5|3|\n"); }, diff --git a/app/assets/javascripts/discourse/tests/integration/components/group-membership-button-test.js b/app/assets/javascripts/discourse/tests/integration/components/group-membership-button-test.js index 69f9247521..4f46c7ac68 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/group-membership-button-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/group-membership-button-test.js @@ -1,6 +1,7 @@ import { + count, discourseModule, - queryAll, + exists, } from "discourse/tests/helpers/qunit-helpers"; import componentTest, { setupRenderingTest, @@ -21,21 +22,18 @@ discourseModule( async test(assert) { assert.ok( - queryAll(".group-index-join").length === 0, + !exists(".group-index-join"), "can't join group if public_admission is false" ); this.set("model.public_admission", true); assert.ok( - queryAll(".group-index-join").length === 0, + !exists(".group-index-join"), "can't join group if user is already in the group" ); this.set("model.is_group_user", false); - assert.ok( - queryAll(".group-index-join").length, - "allowed to join group" - ); + assert.ok(exists(".group-index-join"), "allowed to join group"); }, }); @@ -46,21 +44,18 @@ discourseModule( }, async test(assert) { assert.ok( - queryAll(".group-index-leave").length === 0, + !exists(".group-index-leave"), "can't leave group if public_exit is false" ); this.set("model.public_exit", true); assert.ok( - queryAll(".group-index-leave").length === 0, + !exists(".group-index-leave"), "can't leave group if user is not in the group" ); this.set("model.is_group_user", true); - assert.ok( - queryAll(".group-index-leave").length === 1, - "allowed to leave group" - ); + assert.equal(count(".group-index-leave"), 1, "allowed to leave group"); }, }); @@ -75,12 +70,12 @@ discourseModule( async test(assert) { assert.ok( - queryAll(".group-index-request").length === 0, + !exists(".group-index-request"), "can't request for membership if user is already in the group" ); this.set("model.is_group_user", false); assert.ok( - queryAll(".group-index-request").length, + exists(".group-index-request"), "allowed to request for group membership" ); }, diff --git a/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js b/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js index 61af90d0fd..02c06a5054 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/image-uploader-test.js @@ -2,8 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, } from "discourse/tests/helpers/qunit-helpers"; import { click } from "@ember/test-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -18,20 +19,19 @@ discourseModule("Integration | Component | image-uploader", function (hooks) { async test(assert) { assert.equal( - queryAll(".d-icon-far-image").length, + count(".d-icon-far-image"), 1, "it displays the upload icon" ); assert.equal( - queryAll(".d-icon-far-trash-alt").length, + count(".d-icon-far-trash-alt"), 1, "it displays the trash icon" ); - assert.equal( - queryAll(".placeholder-overlay").length, - 0, + assert.ok( + !exists(".placeholder-overlay"), "it does not display the placeholder image" ); @@ -50,20 +50,18 @@ discourseModule("Integration | Component | image-uploader", function (hooks) { test(assert) { assert.equal( - queryAll(".d-icon-far-image").length, + count(".d-icon-far-image"), 1, "it displays the upload icon" ); - assert.equal( - queryAll(".d-icon-far-trash-alt").length, - 0, + assert.ok( + !exists(".d-icon-far-trash-alt"), "it does not display trash icon" ); - assert.equal( - queryAll(".image-uploader-lightbox-btn").length, - 0, + assert.ok( + !exists(".image-uploader-lightbox-btn"), "it does not display the button to open image lightbox" ); }, @@ -74,25 +72,23 @@ discourseModule("Integration | Component | image-uploader", function (hooks) { test(assert) { assert.equal( - queryAll(".d-icon-far-image").length, + count(".d-icon-far-image"), 1, "it displays the upload icon" ); - assert.equal( - queryAll(".d-icon-far-trash-alt").length, - 0, + assert.ok( + !exists(".d-icon-far-trash-alt"), "it does not display trash icon" ); - assert.equal( - queryAll(".image-uploader-lightbox-btn").length, - 0, + assert.ok( + !exists(".image-uploader-lightbox-btn"), "it does not display the button to open image lightbox" ); assert.equal( - queryAll(".placeholder-overlay").length, + count(".placeholder-overlay"), 1, "it displays the placeholder image" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js b/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js index 8d161d2022..965b480023 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/invite-panel-test.js @@ -5,10 +5,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import pretender from "discourse/tests/helpers/create-pretender"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, exists } from "discourse/tests/helpers/qunit-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import hbs from "htmlbars-inline-precompile"; @@ -45,7 +42,7 @@ discourseModule("Integration | Component | invite-panel", function (hooks) { await input.expand(); await fillIn(".invite-user-input .filter-input", "eviltrout@example.com"); await input.selectRowByValue("eviltrout@example.com"); - assert.ok(queryAll(".send-invite:disabled").length === 0); + assert.ok(!exists(".send-invite:disabled")); await click(".generate-invite-link"); assert.equal( find(".invite-link-input")[0].value, diff --git a/app/assets/javascripts/discourse/tests/integration/components/secret-value-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/secret-value-list-test.js index 6e5fb866e3..7e9651a86c 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/secret-value-list-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/secret-value-list-test.js @@ -3,7 +3,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; @@ -23,8 +25,9 @@ discourseModule( await fillIn(".new-value-input.key", "thirdKey"); await click(".add-value-btn"); - assert.ok( - queryAll(".values .value").length === 2, + assert.equal( + count(".values .value"), + 2, "it doesn't add the value to the list if secret is missing" ); @@ -32,8 +35,9 @@ discourseModule( await fillIn(".new-value-input.secret", "thirdValue"); await click(".add-value-btn"); - assert.ok( - queryAll(".values .value").length === 2, + assert.equal( + count(".values .value"), + 2, "it doesn't add the value to the list if key is missing" ); @@ -41,8 +45,9 @@ discourseModule( await fillIn(".new-value-input.secret", "thirdValue"); await click(".add-value-btn"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); @@ -63,7 +68,7 @@ discourseModule( await click(".add-value-btn"); assert.ok( - queryAll(".values .value").length === 0, + !exists(".values .value"), "it doesn't add the value to the list of values" ); @@ -91,8 +96,9 @@ discourseModule( await click(".values .value[data-index='0'] .remove-value-btn"); - assert.ok( - queryAll(".values .value").length === 1, + assert.equal( + count(".values .value"), + 1, "it removes the value from the list of values" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/simple-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/simple-list-test.js index 1dabaf5c20..73c421bbe4 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/simple-list-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/simple-list-test.js @@ -3,8 +3,10 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, + query, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -20,29 +22,30 @@ discourseModule("Integration | Component | simple-list", function (hooks) { async test(assert) { assert.ok( - queryAll(".add-value-btn[disabled]").length, + exists(".add-value-btn[disabled]"), "while loading the + button is disabled" ); await fillIn(".add-value-input", "penar"); await click(".add-value-btn"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); assert.ok( - queryAll(".values .value[data-index='2'] .value-input")[0].value === - "penar", + query(".values .value[data-index='2'] .value-input").value === "penar", "it sets the correct value for added item" ); await fillIn(".add-value-input", "eviltrout"); await triggerKeyEvent(".add-value-input", "keydown", 13); // enter - assert.ok( - queryAll(".values .value").length === 4, + assert.equal( + count(".values .value"), + 4, "it adds the value when keying Enter" ); }, @@ -58,14 +61,14 @@ discourseModule("Integration | Component | simple-list", function (hooks) { async test(assert) { await click(".values .value[data-index='0'] .remove-value-btn"); - assert.ok( - queryAll(".values .value").length === 1, + assert.equal( + count(".values .value"), + 1, "it removes the value from the list of values" ); assert.ok( - queryAll(".values .value[data-index='0'] .value-input")[0].value === - "osama", + query(".values .value[data-index='0'] .value-input").value === "osama", "it removes the correct value" ); }, @@ -82,13 +85,14 @@ discourseModule("Integration | Component | simple-list", function (hooks) { await fillIn(".add-value-input", "eviltrout"); await click(".add-value-btn"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); assert.ok( - queryAll(".values .value[data-index='2'] .value-input")[0].value === + query(".values .value[data-index='2'] .value-input").value === "eviltrout", "it adds the correct value" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js index 73eacaf5f5..cf33a76b3a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/site-header-test.js @@ -2,8 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, } from "discourse/tests/helpers/qunit-helpers"; import pretender from "discourse/tests/helpers/create-pretender"; import hbs from "htmlbars-inline-precompile"; @@ -20,8 +21,9 @@ discourseModule("Integration | Component | site-header", function (hooks) { }, async test(assert) { - assert.ok( - queryAll(".ring-backdrop").length === 1, + assert.equal( + count(".ring-backdrop"), + 1, "there is the first notification mask" ); @@ -29,7 +31,7 @@ discourseModule("Integration | Component | site-header", function (hooks) { await click("header.d-header"); assert.ok( - queryAll(".ring-backdrop").length === 0, + !exists(".ring-backdrop"), "it hides the first notification mask" ); }, @@ -41,7 +43,7 @@ discourseModule("Integration | Component | site-header", function (hooks) { async test(assert) { assert.ok( - queryAll(".ring-backdrop").length === 0, + !exists(".ring-backdrop"), "there is no first notification mask for anonymous users" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js b/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js index 25f302b034..63204bdf2a 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js @@ -2,9 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, exists, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -43,7 +43,7 @@ discourseModule("Integration | Component | slow-mode-info", function (hooks) { }, test(assert) { - assert.ok(queryAll(".slow-mode-heading").length === 1); + assert.equal(count(".slow-mode-heading"), 1); }, }); @@ -58,7 +58,7 @@ discourseModule("Integration | Component | slow-mode-info", function (hooks) { }, test(assert) { - assert.ok(queryAll(".slow-mode-remove").length === 1); + assert.equal(count(".slow-mode-remove"), 1); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/text-field-test.js b/app/assets/javascripts/discourse/tests/integration/components/text-field-test.js index 91b419be43..dc89583291 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/text-field-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/text-field-test.js @@ -3,6 +3,7 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; @@ -17,7 +18,7 @@ discourseModule("Integration | Component | text-field", function (hooks) { template: hbs`{{text-field}}`, test(assert) { - assert.ok(queryAll("input[type=text]").length); + assert.ok(exists("input[type=text]")); }, }); @@ -29,7 +30,7 @@ discourseModule("Integration | Component | text-field", function (hooks) { }, test(assert) { - assert.ok(queryAll("input[type=text]").length); + assert.ok(exists("input[type=text]")); assert.equal( queryAll("input").prop("placeholder"), "placeholder.i18n.key" diff --git a/app/assets/javascripts/discourse/tests/integration/components/themes-list-item-test.js b/app/assets/javascripts/discourse/tests/integration/components/themes-list-item-test.js index 0dc7fea9c4..56c517c5be 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/themes-list-item-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/themes-list-item-test.js @@ -4,6 +4,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -19,11 +20,7 @@ discourseModule("Integration | Component | themes-list-item", function (hooks) { test(assert) { assert.expect(1); - assert.equal( - queryAll(".d-icon-check").length, - 1, - "shows default theme icon" - ); + assert.equal(count(".d-icon-check"), 1, "shows default theme icon"); }, }); @@ -38,11 +35,7 @@ discourseModule("Integration | Component | themes-list-item", function (hooks) { test(assert) { assert.expect(1); - assert.equal( - queryAll(".d-icon-sync").length, - 1, - "shows pending update icon" - ); + assert.equal(count(".d-icon-sync"), 1, "shows pending update icon"); }, }); @@ -61,7 +54,7 @@ discourseModule("Integration | Component | themes-list-item", function (hooks) { test(assert) { assert.expect(1); assert.equal( - queryAll(".d-icon-exclamation-circle").length, + count(".d-icon-exclamation-circle"), 1, "shows broken theme icon" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js index 3ab035c5f3..375340ae6e 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/themes-list-test.js @@ -4,6 +4,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -49,11 +50,7 @@ discourseModule("Integration | Component | themes-list", function (hooks) { -1, "there is no inactive themes separator when all themes are inactive" ); - assert.equal( - queryAll(".themes-list-item").length, - 5, - "displays all themes" - ); + assert.equal(count(".themes-list-item"), 5, "displays all themes"); [2, 3].forEach((num) => this.themes[num].set("user_selectable", true)); this.themes[4].set("default", true); @@ -82,7 +79,7 @@ discourseModule("Integration | Component | themes-list", function (hooks) { this.set("themes", []); assert.equal( - queryAll(".themes-list-item").length, + count(".themes-list-item"), 1, "shows one entry with a message when there is nothing to display" ); @@ -132,15 +129,11 @@ discourseModule("Integration | Component | themes-list", function (hooks) { -1, "there is no separator" ); - assert.equal( - queryAll(".themes-list-item").length, - 5, - "displays all components" - ); + assert.equal(count(".themes-list-item"), 5, "displays all components"); this.set("components", []); assert.equal( - queryAll(".themes-list-item").length, + count(".themes-list-item"), 1, "shows one entry with a message when there is nothing to display" ); diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js index 7b81e20913..81900086cd 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js @@ -3,6 +3,7 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -61,8 +62,8 @@ discourseModule( resetFlair(); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok(queryAll("svg.d-icon-bars").length, "it has the svg icon"); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-bars"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #CC000A; color: #FFFFFA; ", @@ -86,8 +87,8 @@ discourseModule( resetFlair(); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok(queryAll("svg.d-icon-bars").length, "it has the svg icon"); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-bars"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #CC0005; color: #FFFFF5; ", @@ -111,11 +112,8 @@ discourseModule( resetFlair(); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok( - queryAll("svg.d-icon-dice-two").length, - "it has the svg icon" - ); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-dice-two"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #CC0002; color: #FFFFF2; ", @@ -139,11 +137,8 @@ discourseModule( resetFlair(); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok( - queryAll("svg.d-icon-dice-two").length, - "it has the svg icon" - ); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-dice-two"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #CC0002; color: #FFFFF2; ", @@ -171,8 +166,8 @@ discourseModule( resetFlair(); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok(queryAll("svg.d-icon-times").length, "it has the svg icon"); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-times"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #123456; color: #B0B0B0; ", diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js index f22fc3b17c..9ca7a9be2f 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-selector-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; function paste(element, text) { @@ -24,7 +21,7 @@ discourseModule("Integration | Component | user-selector", function (hooks) { }, test(assert) { - let element = queryAll(".test-selector")[0]; + let element = query(".test-selector"); assert.equal(this.get("usernames"), "evil,trout"); paste(element, "zip,zap,zoom"); @@ -55,7 +52,7 @@ discourseModule("Integration | Component | user-selector", function (hooks) { }, test(assert) { - let element = queryAll(".test-selector")[0]; + let element = query(".test-selector"); paste(element, "roman,penar,jeff,robin"); assert.equal(this.get("usernames"), "mark,roman,penar"); }, diff --git a/app/assets/javascripts/discourse/tests/integration/components/value-list-test.js b/app/assets/javascripts/discourse/tests/integration/components/value-list-test.js index cd02aa0a20..3c2e3520ed 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/value-list-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/value-list-test.js @@ -2,8 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + query, } from "discourse/tests/helpers/qunit-helpers"; import { click } from "@ember/test-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -26,8 +27,9 @@ discourseModule("Integration | Component | value-list", function (hooks) { await selectKit().fillInFilter("eviltrout"); await selectKit().keyboard("enter"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); @@ -49,8 +51,9 @@ discourseModule("Integration | Component | value-list", function (hooks) { async test(assert) { await click(".values .value[data-index='0'] .remove-value-btn"); - assert.ok( - queryAll(".values .value").length === 1, + assert.equal( + count(".values .value"), + 1, "it removes the value from the list of values" ); @@ -59,7 +62,7 @@ discourseModule("Integration | Component | value-list", function (hooks) { await selectKit().expand(); assert.ok( - queryAll(".select-kit-collection li.select-kit-row span.name")[0] + query(".select-kit-collection li.select-kit-row span.name") .innerText === "vinkas", "it adds the removed value to choices" ); @@ -80,8 +83,9 @@ discourseModule("Integration | Component | value-list", function (hooks) { await selectKit().expand(); await selectKit().selectRowByValue("maja"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); @@ -107,8 +111,9 @@ discourseModule("Integration | Component | value-list", function (hooks) { await selectKit().fillInFilter("eviltrout"); await selectKit().keyboard("enter"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); @@ -134,8 +139,9 @@ discourseModule("Integration | Component | value-list", function (hooks) { await selectKit().fillInFilter("eviltrout"); await selectKit().keyboard("enter"); - assert.ok( - queryAll(".values .value").length === 3, + assert.equal( + count(".values .value"), + 3, "it adds the value to the list of values" ); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/actions-summary-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/actions-summary-test.js index a7ff62bfab..0b036d76cb 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/actions-summary-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/actions-summary-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { count, discourseModule } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; discourseModule( @@ -22,12 +19,14 @@ discourseModule( }); }, test(assert) { - assert.ok( - queryAll(".post-action .d-icon-far-trash-alt").length === 1, + assert.equal( + count(".post-action .d-icon-far-trash-alt"), + 1, "it has the deleted icon" ); - assert.ok( - queryAll(".avatar[title=eviltrout]").length === 1, + assert.equal( + count(".avatar[title=eviltrout]"), + 1, "it has the deleted by avatar" ); }, diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/avatar-flair-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/avatar-flair-test.js index fd3bb7ac7d..1ed63b8bc2 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/avatar-flair-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/avatar-flair-test.js @@ -3,6 +3,7 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -22,8 +23,8 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok(queryAll("svg.d-icon-bars").length, "it has the svg icon"); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(exists("svg.d-icon-bars"), "it has the svg icon"); assert.equal( queryAll(".avatar-flair").attr("style"), "background-color: #CC0000; color: #FFFFFF; ", @@ -40,8 +41,8 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll(".avatar-flair").length, "it has the tag"); - assert.ok(queryAll("svg").length === 0, "it does not have an svg icon"); + assert.ok(exists(".avatar-flair"), "it has the tag"); + assert.ok(!exists("svg"), "it does not have an svg icon"); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/button-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/button-test.js index 2606de6fbc..c8b5bf9f7c 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/button-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/button-test.js @@ -3,8 +3,8 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, query, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -20,13 +20,10 @@ discourseModule("Integration | Component | Widget | button", function (hooks) { test(assert) { assert.ok( - queryAll("button.btn.btn-icon.no-text").length, + exists("button.btn.btn-icon.no-text"), "it has all the classes" ); - assert.ok( - queryAll("button .d-icon.d-icon-far-smile").length, - "it has the icon" - ); + assert.ok(exists("button .d-icon.d-icon-far-smile"), "it has the icon"); }, }); @@ -38,18 +35,9 @@ discourseModule("Integration | Component | Widget | button", function (hooks) { }, test(assert) { - assert.ok( - queryAll("button.btn.btn-icon-text").length, - "it has all the classes" - ); - assert.ok( - queryAll("button .d-icon.d-icon-plus").length, - "it has the icon" - ); - assert.ok( - queryAll("button span.d-button-label").length, - "it has the label" - ); + assert.ok(exists("button.btn.btn-icon-text"), "it has all the classes"); + assert.ok(exists("button .d-icon.d-icon-plus"), "it has the icon"); + assert.ok(exists("button span.d-button-label"), "it has the label"); }, }); @@ -61,14 +49,8 @@ discourseModule("Integration | Component | Widget | button", function (hooks) { }, test(assert) { - assert.ok( - queryAll("button.btn.btn-text").length, - "it has all the classes" - ); - assert.ok( - queryAll("button span.d-button-label").length, - "it has the label" - ); + assert.ok(exists("button.btn.btn-text"), "it has all the classes"); + assert.ok(exists("button span.d-button-label"), "it has the label"); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/default-notification-item-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/default-notification-item-test.js index 7136ca23b5..6b09fb2889 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/default-notification-item-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/default-notification-item-test.js @@ -2,8 +2,10 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, + query, } from "discourse/tests/helpers/qunit-helpers"; import EmberObject from "@ember/object"; import hbs from "htmlbars-inline-precompile"; @@ -59,18 +61,18 @@ discourseModule( ]; }); - assert.equal(queryAll("li.read").length, 0); + assert.ok(!exists("li.read")); $(document).trigger( $.Event("mouseup", { - target: queryAll("li")[0], + target: query("li"), button: 1, which: 2, }) ); await settled(); - assert.equal(queryAll("li.read").length, 1); + assert.equal(count("li.read"), 1); assert.equal(requests, 1); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/hamburger-menu-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/hamburger-menu-test.js index b07bdd9c5c..fde73beabb 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/hamburger-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/hamburger-menu-test.js @@ -2,7 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { NotificationLevels } from "discourse/lib/notification-levels"; @@ -27,8 +29,8 @@ discourseModule( }, test(assert) { - assert.ok(queryAll(".faq-priority").length); - assert.ok(!queryAll(".faq-link").length); + assert.ok(exists(".faq-priority")); + assert.ok(!exists(".faq-link")); }, }); @@ -41,8 +43,8 @@ discourseModule( }, test(assert) { - assert.ok(!queryAll(".faq-priority").length); - assert.ok(queryAll(".faq-link").length); + assert.ok(!exists(".faq-priority")); + assert.ok(exists(".faq-link")); }, }); @@ -54,7 +56,7 @@ discourseModule( }, test(assert) { - assert.ok(!queryAll(".admin-link").length); + assert.ok(!exists(".admin-link")); }, }); @@ -67,9 +69,9 @@ discourseModule( }, test(assert) { - assert.ok(queryAll(".admin-link").length); - assert.ok(queryAll(".review").length); - assert.ok(!queryAll(".settings-link").length); + assert.ok(exists(".admin-link")); + assert.ok(exists(".review")); + assert.ok(!exists(".settings-link")); }, }); @@ -81,7 +83,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll(".settings-link").length); + assert.ok(exists(".settings-link")); }, }); @@ -89,8 +91,8 @@ discourseModule( template: hbs`{{mount-widget widget="hamburger-menu"}}`, test(assert) { - assert.ok(queryAll(".new-topics-link").length); - assert.ok(queryAll(".unread-topics-link").length); + assert.ok(exists(".new-topics-link")); + assert.ok(exists(".unread-topics-link")); }, }); @@ -99,13 +101,13 @@ discourseModule( anonymous: true, test(assert) { - assert.ok(queryAll("li[class='']").length === 0); - assert.ok(queryAll(".latest-topics-link").length); - assert.ok(!queryAll(".new-topics-link").length); - assert.ok(!queryAll(".unread-topics-link").length); - assert.ok(queryAll(".top-topics-link").length); - assert.ok(queryAll(".badge-link").length); - assert.ok(queryAll(".category-link").length > 0); + assert.ok(!exists("li[class='']")); + assert.ok(exists(".latest-topics-link")); + assert.ok(!exists(".new-topics-link")); + assert.ok(!exists(".unread-topics-link")); + assert.ok(exists(".top-topics-link")); + assert.ok(exists(".badge-link")); + assert.ok(exists(".category-link")); }, }); @@ -120,7 +122,7 @@ discourseModule( }, test(assert) { - assert.equal(queryAll(".category-link").length, 8); + assert.equal(count(".category-link"), 8); assert.equal( queryAll(".category-link .category-name").text(), this.site @@ -142,7 +144,7 @@ discourseModule( }, test(assert) { - assert.equal(queryAll(".category-link").length, 8); + assert.equal(count(".category-link"), 8); assert.equal( queryAll(".category-link .category-name").text(), this.site @@ -198,7 +200,7 @@ discourseModule( test(assert) { assert.equal( - queryAll(".category-link").length, + count(".category-link"), maxCategoriesToDisplay, "categories displayed limited by header_dropdown_category_count" ); @@ -235,7 +237,7 @@ discourseModule( }, test(assert) { - assert.ok(!queryAll(".badge-link").length); + assert.ok(!exists(".badge-link")); }, }); @@ -243,7 +245,7 @@ discourseModule( template: hbs`{{mount-widget widget="hamburger-menu"}}`, test(assert) { - assert.ok(queryAll(".badge-link").length); + assert.ok(exists(".badge-link")); }, }); @@ -251,7 +253,7 @@ discourseModule( template: hbs`{{mount-widget widget="hamburger-menu"}}`, test(assert) { - assert.ok(queryAll(".user-directory-link").length); + assert.ok(exists(".user-directory-link")); }, }); @@ -263,7 +265,7 @@ discourseModule( }, test(assert) { - assert.ok(!queryAll(".user-directory-link").length); + assert.ok(!exists(".user-directory-link")); }, }); @@ -271,8 +273,8 @@ discourseModule( template: hbs`{{mount-widget widget="hamburger-menu"}}`, test(assert) { - assert.ok(queryAll(".about-link").length); - assert.ok(queryAll(".keyboard-shortcuts-link").length); + assert.ok(exists(".about-link")); + assert.ok(exists(".keyboard-shortcuts-link")); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/header-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/header-test.js index c099e69261..433ace45ca 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/header-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/header-test.js @@ -1,11 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - exists, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, exists } from "discourse/tests/helpers/qunit-helpers"; import { click } from "@ember/test-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -15,8 +11,8 @@ discourseModule("Integration | Component | Widget | header", function (hooks) { componentTest("rendering basics", { template: hbs`{{mount-widget widget="header"}}`, test(assert) { - assert.ok(queryAll("header.d-header").length); - assert.ok(queryAll("#site-logo").length); + assert.ok(exists("header.d-header")); + assert.ok(exists("#site-logo")); }, }); @@ -38,8 +34,8 @@ discourseModule("Integration | Component | Widget | header", function (hooks) { }, async test(assert) { - assert.ok(queryAll("button.sign-up-button").length); - assert.ok(queryAll("button.login-button").length); + assert.ok(exists("button.sign-up-button")); + assert.ok(exists("button.login-button")); await click("button.sign-up-button"); assert.ok(this.signupShown); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/home-logo-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/home-logo-test.js index 41992f056f..ca23507ac7 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/home-logo-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/home-logo-test.js @@ -2,7 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import Session from "discourse/models/session"; @@ -31,9 +33,9 @@ discourseModule( }, test(assert) { - assert.ok(queryAll(".title").length === 1); + assert.equal(count(".title"), 1); - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal(queryAll("#site-logo").attr("src"), bigLogo); assert.equal(queryAll("#site-logo").attr("alt"), title); }, @@ -49,7 +51,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img.logo-small").length === 1); + assert.equal(count("img.logo-small"), 1); assert.equal(queryAll("img.logo-small").attr("src"), smallLogo); assert.equal(queryAll("img.logo-small").attr("alt"), title); assert.equal(queryAll("img.logo-small").attr("width"), 36); @@ -66,7 +68,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("h1#site-text-logo.text-logo").length === 1); + assert.equal(count("h1#site-text-logo.text-logo"), 1); assert.equal(queryAll("#site-text-logo").text(), title); }, }); @@ -81,7 +83,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll(".d-icon-home").length === 1); + assert.equal(count(".d-icon-home"), 1); }, }); @@ -94,7 +96,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-mobile").length === 1); + assert.equal(count("img#site-logo.logo-mobile"), 1); assert.equal(queryAll("#site-logo").attr("src"), mobileLogo); }, }); @@ -107,7 +109,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal(queryAll("#site-logo").attr("src"), bigLogo); }, }); @@ -124,7 +126,7 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal(queryAll("#site-logo").attr("src"), bigLogo); assert.equal( @@ -182,12 +184,9 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal(queryAll("#site-logo").attr("src"), bigLogo); - assert.ok( - queryAll("picture").length === 0, - "does not include alternative logo" - ); + assert.ok(!exists("picture"), "does not include alternative logo"); }, }); @@ -199,12 +198,9 @@ discourseModule( }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal(queryAll("#site-logo").attr("src"), bigLogo); - assert.ok( - queryAll("picture").length === 0, - "does not include alternative logo" - ); + assert.ok(!exists("picture"), "does not include alternative logo"); }, }); @@ -219,16 +215,13 @@ discourseModule( Session.currentProp("defaultColorSchemeIsDark", null); }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal( queryAll("#site-logo").attr("src"), darkLogo, "uses dark logo" ); - assert.ok( - queryAll("picture").length === 0, - "does not add dark mode alternative" - ); + assert.ok(!exists("picture"), "does not add dark mode alternative"); }, }); @@ -243,7 +236,7 @@ discourseModule( Session.currentProp("defaultColorSchemeIsDark", null); }, test(assert) { - assert.ok(queryAll("img#site-logo.logo-big").length === 1); + assert.equal(count("img#site-logo.logo-big"), 1); assert.equal( queryAll("#site-logo").attr("src"), bigLogo, diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-links-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-links-test.js index 614ff999b9..e1d56b4508 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/post-links-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-links-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { count, discourseModule } from "discourse/tests/helpers/qunit-helpers"; import { click } from "@ember/test-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -34,7 +31,7 @@ discourseModule( }, test(assert) { assert.equal( - queryAll(".post-links a.track-link").length, + count(".post-links a.track-link"), 1, "it hides the dupe link" ); @@ -86,12 +83,9 @@ discourseModule( }); }, async test(assert) { - assert.ok( - queryAll(".expand-links").length === 1, - "collapsed by default" - ); + assert.equal(count(".expand-links"), 1, "collapsed by default"); await click("a.expand-links"); - assert.equal(queryAll(".post-links a.track-link").length, 7); + assert.equal(count(".post-links a.track-link"), 7); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js index 1e79fae7ca..4c03c0c8f3 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-menu-test.js @@ -2,8 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; import { withPluginApi } from "discourse/lib/plugin-api"; @@ -30,8 +31,9 @@ discourseModule( }); }, async test(assert) { - assert.ok( - queryAll(".actions .extra-buttons .hot-coffee").length === 1, + assert.equal( + count(".actions .extra-buttons .hot-coffee"), + 1, "It renders extra button" ); }, @@ -47,7 +49,7 @@ discourseModule( }, async test(assert) { assert.ok( - queryAll(".actions .extra-buttons .hot-coffee").length === 0, + !exists(".actions .extra-buttons .hot-coffee"), "It doesn't removes coffee button" ); }, diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-stream-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-stream-test.js index b4118cccb4..eb3a5ba6ca 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/post-stream-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-stream-test.js @@ -2,6 +2,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, queryAll, } from "discourse/tests/helpers/qunit-helpers"; @@ -72,8 +73,8 @@ discourseModule( }, test(assert) { - assert.equal(queryAll(".post-stream").length, 1); - assert.equal(queryAll(".topic-post").length, 6, "renders all posts"); + assert.equal(count(".post-stream"), 1); + assert.equal(count(".topic-post"), 6, "renders all posts"); // look for special class bindings assert.equal( @@ -113,11 +114,11 @@ discourseModule( ); // it renders an article for the body with appropriate attributes - assert.equal(queryAll("article#post_2").length, 1); - assert.equal(queryAll('article[data-user-id="123"]').length, 1); - assert.equal(queryAll('article[data-post-id="3"]').length, 1); - assert.equal(queryAll("article#post_5.via-email").length, 1); - assert.equal(queryAll("article#post_6.is-auto-generated").length, 1); + assert.equal(count("article#post_2"), 1); + assert.equal(count('article[data-user-id="123"]'), 1); + assert.equal(count('article[data-post-id="3"]'), 1); + assert.equal(count("article#post_5.via-email"), 1); + assert.equal(count("article#post_6.is-auto-generated"), 1); assert.equal( queryAll("article:nth-of-type(1) .main-avatar").length, @@ -143,12 +144,12 @@ discourseModule( test(assert) { assert.equal( - queryAll(".topic-post.deleted").length, + count(".topic-post.deleted"), 1, "it applies the deleted class" ); assert.equal( - queryAll(".deleted-user-avatar").length, + count(".deleted-user-avatar"), 1, "it has the trash avatar" ); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js index 995ec592ad..4598e48579 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js @@ -2,7 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import EmberObject from "@ember/object"; @@ -19,11 +21,11 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { shareUrl: "/example", post_number: 1 }); }, test(assert) { - assert.ok(queryAll(".names").length, "includes poster name"); + assert.ok(exists(".names"), "includes poster name"); - assert.ok(queryAll("a.post-date").length, "includes post date"); - assert.ok(queryAll("a.post-date[data-share-url]").length); - assert.ok(queryAll("a.post-date[data-post-number]").length); + assert.ok(exists("a.post-date"), "includes post date"); + assert.ok(exists("a.post-date[data-share-url]")); + assert.ok(exists("a.post-date[data-post-number]")); }, }); @@ -147,8 +149,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { isWhisper: true }); }, test(assert) { - assert.ok(queryAll(".topic-post.whisper").length === 1); - assert.ok(queryAll(".post-info.whisper").length === 1); + assert.equal(count(".topic-post.whisper"), 1); + assert.equal(count(".post-info.whisper"), 1); }, }); @@ -167,18 +169,18 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { likeCount: 1 }); }, async test(assert) { - assert.ok(queryAll("button.like-count").length === 1); - assert.ok(queryAll(".who-liked").length === 0); + assert.equal(count("button.like-count"), 1); + assert.ok(!exists(".who-liked")); // toggle it on await click("button.like-count"); - assert.ok(queryAll(".who-liked").length === 1); - assert.ok(queryAll(".who-liked a.trigger-user-card").length === 1); + assert.equal(count(".who-liked"), 1); + assert.equal(count(".who-liked a.trigger-user-card"), 1); // toggle it off await click("button.like-count"); - assert.ok(queryAll(".who-liked").length === 0); - assert.ok(queryAll(".who-liked a.trigger-user-card").length === 0); + assert.ok(!exists(".who-liked")); + assert.ok(!exists(".who-liked a.trigger-user-card")); }, }); @@ -188,7 +190,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { likeCount: 0 }); }, test(assert) { - assert.ok(queryAll("button.like-count").length === 0); + assert.ok(!exists("button.like-count")); }, }); @@ -199,7 +201,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }, test(assert) { assert.ok( - !!queryAll(".actions button[data-share-url]").length, + exists(".actions button[data-share-url]"), "it renders a share button" ); }, @@ -218,18 +220,18 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, async test(assert) { - assert.ok(!!queryAll(".actions button.like").length); - assert.ok(queryAll(".actions button.like-count").length === 0); + assert.ok(exists(".actions button.like")); + assert.ok(!exists(".actions button.like-count")); await click(".actions button.like"); - assert.ok(!queryAll(".actions button.like").length); - assert.ok(!!queryAll(".actions button.has-like").length); - assert.ok(queryAll(".actions button.like-count").length === 1); + assert.ok(!exists(".actions button.like")); + assert.ok(exists(".actions button.has-like")); + assert.equal(count(".actions button.like-count"), 1); await click(".actions button.has-like"); - assert.ok(!!queryAll(".actions button.like").length); - assert.ok(!queryAll(".actions button.has-like").length); - assert.ok(queryAll(".actions button.like-count").length === 0); + assert.ok(exists(".actions button.like")); + assert.ok(!exists(".actions button.has-like")); + assert.ok(!exists(".actions button.like-count")); }, }); @@ -244,8 +246,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("showLogin", () => (this.loginShown = true)); }, async test(assert) { - assert.ok(!!queryAll(".actions button.like").length); - assert.ok(queryAll(".actions button.like-count").length === 0); + assert.ok(exists(".actions button.like")); + assert.ok(!exists(".actions button.like-count")); assert.equal( queryAll("button.like").attr("title"), @@ -278,11 +280,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canEdit: false }); }, test(assert) { - assert.equal( - queryAll("button.edit").length, - 0, - `button is not displayed` - ); + assert.ok(!exists("button.edit"), "button is not displayed"); }, }); @@ -320,11 +318,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canDeleteTopic: false }); }, test(assert) { - assert.equal( - queryAll("button.delete").length, - 0, - `button is not displayed` - ); + assert.ok(!exists("button.delete"), `button is not displayed`); }, }); @@ -343,16 +337,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { async test(assert) { await click(".show-more-actions"); - assert.equal( - queryAll("button.create-flag").length, - 1, - `button is displayed` - ); - assert.equal( - queryAll("button.delete").length, - 1, - `button is displayed` - ); + assert.equal(count("button.create-flag"), 1, `button is displayed`); + assert.equal(count("button.delete"), 1, `button is displayed`); assert.equal( queryAll("button.delete").attr("title"), I18n.t("post.controls.delete_topic_disallowed"), @@ -382,11 +368,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canRecoverTopic: false }); }, test(assert) { - assert.equal( - queryAll("button.recover").length, - 0, - `button is not displayed` - ); + assert.ok(!exists("button.recover"), `button is not displayed`); }, }); @@ -411,11 +393,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canDelete: false }); }, test(assert) { - assert.equal( - queryAll("button.delete").length, - 0, - `button is not displayed` - ); + assert.ok(!exists("button.delete"), `button is not displayed`); }, }); @@ -429,16 +407,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, test(assert) { - assert.equal( - queryAll("button.delete").length, - 0, - `delete button is not displayed` - ); - assert.equal( - queryAll("button.create-flag").length, - 0, - `flag button is not displayed` - ); + assert.ok(!exists("button.delete"), `delete button is not displayed`); + assert.ok(!exists("button.create-flag"), `flag button is not displayed`); }, }); @@ -462,11 +432,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canRecover: false }); }, test(assert) { - assert.equal( - queryAll("button.recover").length, - 0, - `button is not displayed` - ); + assert.ok(!exists("button.recover"), `button is not displayed`); }, }); @@ -479,7 +445,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("showFlags", () => (this.flagsShown = true)); }, async test(assert) { - assert.ok(queryAll("button.create-flag").length === 1); + assert.equal(count("button.create-flag"), 1); await click("button.create-flag"); assert.ok(this.flagsShown, "it triggered the action"); @@ -492,7 +458,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canFlag: false }); }, test(assert) { - assert.ok(queryAll("button.create-flag").length === 0); + assert.ok(!exists("button.create-flag")); }, }); @@ -502,7 +468,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canFlag: true, hidden: true }); }, test(assert) { - assert.ok(queryAll("button.create-flag").length === 0); + assert.ok(!exists("button.create-flag")); }, }); @@ -512,7 +478,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { read: true }); }, test(assert) { - assert.ok(queryAll(".read-state.read").length); + assert.ok(exists(".read-state.read")); }, }); @@ -522,7 +488,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { read: false }); }, test(assert) { - assert.ok(queryAll(".read-state").length); + assert.ok(exists(".read-state")); }, }); @@ -536,12 +502,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, test(assert) { - assert.equal(queryAll("a.reply-to-tab").length, 0, "hides the tab"); - assert.equal( - queryAll(".avoid-tab").length, - 0, - "doesn't have the avoid tab class" - ); + assert.ok(!exists("a.reply-to-tab"), "hides the tab"); + assert.ok(!exists(".avoid-tab"), "doesn't have the avoid tab class"); }, }); @@ -555,8 +517,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, test(assert) { - assert.ok(queryAll("a.reply-to-tab").length, "shows the tab"); - assert.equal(queryAll(".avoid-tab").length, 1, "has the avoid tab class"); + assert.ok(exists("a.reply-to-tab"), "shows the tab"); + assert.equal(count(".avoid-tab"), 1, "has the avoid tab class"); }, }); @@ -571,13 +533,10 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.siteSettings.suppress_reply_directly_above = false; }, async test(assert) { - assert.equal(queryAll(".avoid-tab").length, 1, "has the avoid tab class"); + assert.equal(count(".avoid-tab"), 1, "has the avoid tab class"); await click("a.reply-to-tab"); - assert.equal(queryAll("section.embedded-posts.top .cooked").length, 1); - assert.equal( - queryAll("section.embedded-posts .d-icon-arrow-up").length, - 1 - ); + assert.equal(count("section.embedded-posts.top .cooked"), 1); + assert.equal(count("section.embedded-posts .d-icon-arrow-up"), 1); }, }); @@ -603,7 +562,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }, async test(assert) { await click(".topic-body .expand-post"); - assert.equal(queryAll(".expand-post").length, 0, "button is gone"); + assert.ok(!exists(".expand-post"), "button is gone"); }, }); @@ -613,8 +572,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canBookmark: false }); }, test(assert) { - assert.equal(queryAll("button.bookmark").length, 0); - assert.equal(queryAll("button.bookmarked").length, 0); + assert.ok(!exists("button.bookmark")); + assert.ok(!exists("button.bookmarked")); }, }); @@ -629,8 +588,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("toggleBookmark", () => (args.bookmarked = true)); }, async test(assert) { - assert.equal(queryAll(".post-menu-area .bookmark").length, 1); - assert.equal(queryAll("button.bookmarked").length, 0); + assert.equal(count(".post-menu-area .bookmark"), 1); + assert.ok(!exists("button.bookmarked")); }, }); @@ -640,7 +599,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canManage: false }); }, test(assert) { - assert.equal(queryAll(".post-menu-area .show-post-admin-menu").length, 0); + assert.ok(!exists(".post-menu-area .show-post-admin-menu")); }, }); @@ -650,17 +609,12 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canManage: true }); }, async test(assert) { - assert.equal(queryAll(".post-admin-menu").length, 0); + assert.ok(!exists(".post-admin-menu")); await click(".post-menu-area .show-post-admin-menu"); - assert.equal( - queryAll(".post-admin-menu").length, - 1, - "it shows the popup" - ); + assert.equal(count(".post-admin-menu"), 1, "it shows the popup"); await click(".post-menu-area"); - assert.equal( - queryAll(".post-admin-menu").length, - 0, + assert.ok( + !exists(".post-admin-menu"), "clicking outside clears the popup" ); }, @@ -680,11 +634,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { await click(".post-admin-menu .toggle-post-type"); assert.ok(this.toggled); - assert.equal( - queryAll(".post-admin-menu").length, - 0, - "also hides the menu" - ); + assert.ok(!exists(".post-admin-menu"), "also hides the menu"); }, }); componentTest("toggle moderator post", { @@ -701,11 +651,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { await click(".post-admin-menu .toggle-post-type"); assert.ok(this.toggled); - assert.equal( - queryAll(".post-admin-menu").length, - 0, - "also hides the menu" - ); + assert.ok(!exists(".post-admin-menu"), "also hides the menu"); }, }); @@ -721,11 +667,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { await click(".post-menu-area .show-post-admin-menu"); await click(".post-admin-menu .rebuild-html"); assert.ok(this.baked); - assert.equal( - queryAll(".post-admin-menu").length, - 0, - "also hides the menu" - ); + assert.ok(!exists(".post-admin-menu"), "also hides the menu"); }, }); @@ -742,11 +684,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { await click(".post-menu-area .show-post-admin-menu"); await click(".post-admin-menu .unhide-post"); assert.ok(this.unhidden); - assert.equal( - queryAll(".post-admin-menu").length, - 0, - "also hides the menu" - ); + assert.ok(!exists(".post-admin-menu"), "also hides the menu"); }, }); @@ -763,11 +701,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { await click(".post-menu-area .show-post-admin-menu"); await click(".post-admin-menu .change-owner"); assert.ok(this.owned); - assert.equal( - queryAll(".post-admin-menu").length, - 0, - "also hides the menu" - ); + assert.ok(!exists(".post-admin-menu"), "also hides the menu"); }, }); @@ -791,7 +725,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { canCreatePost: false }); }, test(assert) { - assert.equal(queryAll(".post-controls .create").length, 0); + assert.ok(!exists(".post-controls .create")); }, }); @@ -801,7 +735,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { replyCount: 0 }); }, test(assert) { - assert.equal(queryAll("button.show-replies").length, 0); + assert.ok(!exists("button.show-replies")); }, }); @@ -812,7 +746,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { replyCount: 2, replyDirectlyBelow: true }); }, test(assert) { - assert.equal(queryAll("button.show-replies").length, 1); + assert.equal(count("button.show-replies"), 1); }, }); @@ -823,7 +757,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { replyCount: 1, replyDirectlyBelow: true }); }, test(assert) { - assert.equal(queryAll("button.show-replies").length, 0); + assert.ok(!exists("button.show-replies")); }, }); @@ -835,11 +769,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }, async test(assert) { await click("button.show-replies"); - assert.equal(queryAll("section.embedded-posts.bottom .cooked").length, 1); - assert.equal( - queryAll("section.embedded-posts .d-icon-arrow-down").length, - 1 - ); + assert.equal(count("section.embedded-posts.bottom .cooked"), 1); + assert.equal(count("section.embedded-posts .d-icon-arrow-down"), 1); }, }); @@ -849,7 +780,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { showTopicMap: false }); }, test(assert) { - assert.equal(queryAll(".topic-map").length, 0); + assert.ok(!exists(".topic-map")); }, }); @@ -863,15 +794,14 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, async test(assert) { - assert.equal( - queryAll("li.avatars a.poster").length, - 0, + assert.ok( + !exists("li.avatars a.poster"), "shows no participants when collapsed" ); await click("nav.buttons button"); assert.equal( - queryAll(".topic-map-expanded a.poster").length, + count(".topic-map-expanded a.poster"), 2, "shows all when expanded" ); @@ -895,19 +825,19 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }, async test(assert) { assert.equal( - queryAll("li.avatars a.poster").length, + count("li.avatars a.poster"), 3, "limits to three participants" ); await click("nav.buttons button"); - assert.equal(queryAll("li.avatars a.poster").length, 0); + assert.ok(!exists("li.avatars a.poster")); assert.equal( - queryAll(".topic-map-expanded a.poster").length, + count(".topic-map-expanded a.poster"), 4, "shows all when expanded" ); - assert.equal(queryAll("a.poster.toggled").length, 2, "two are toggled"); + assert.equal(count("a.poster.toggled"), 2, "two are toggled"); }, }); @@ -927,23 +857,23 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, async test(assert) { - assert.equal(queryAll(".topic-map").length, 1); - assert.equal(queryAll(".map.map-collapsed").length, 1); - assert.equal(queryAll(".topic-map-expanded").length, 0); + assert.equal(count(".topic-map"), 1); + assert.equal(count(".map.map-collapsed"), 1); + assert.ok(!exists(".topic-map-expanded")); await click("nav.buttons button"); - assert.equal(queryAll(".map.map-collapsed").length, 0); - assert.equal(queryAll(".topic-map .d-icon-chevron-up").length, 1); - assert.equal(queryAll(".topic-map-expanded").length, 1); + assert.ok(!exists(".map.map-collapsed")); + assert.equal(count(".topic-map .d-icon-chevron-up"), 1); + assert.equal(count(".topic-map-expanded"), 1); assert.equal( - queryAll(".topic-map-expanded .topic-link").length, + count(".topic-map-expanded .topic-link"), 5, "it limits the links displayed" ); await click(".link-summary button"); assert.equal( - queryAll(".topic-map-expanded .topic-link").length, + count(".topic-map-expanded .topic-link"), 6, "all links now shown" ); @@ -956,7 +886,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("args", { showTopicMap: true }); }, test(assert) { - assert.equal(queryAll(".toggle-summary").length, 0); + assert.ok(!exists(".toggle-summary")); }, }); @@ -967,7 +897,7 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { this.set("showSummary", () => (this.summaryToggled = true)); }, async test(assert) { - assert.equal(queryAll(".toggle-summary").length, 1); + assert.equal(count(".toggle-summary"), 1); await click(".toggle-summary button"); assert.ok(this.summaryToggled); @@ -985,8 +915,8 @@ discourseModule("Integration | Component | Widget | post", function (hooks) { }); }, test(assert) { - assert.equal(queryAll(".private-message-map").length, 1); - assert.equal(queryAll(".private-message-map .user").length, 1); + assert.equal(count(".private-message-map"), 1); + assert.equal(count(".private-message-map .user"), 1); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/poster-name-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/poster-name-test.js index b4f685b2e6..b46077a3d3 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/poster-name-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/poster-name-test.js @@ -3,6 +3,7 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -23,9 +24,9 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll(".names").length); - assert.ok(queryAll("span.username").length); - assert.ok(queryAll('a[data-user-card="eviltrout"]').length); + assert.ok(exists(".names")); + assert.ok(exists("span.username")); + assert.ok(exists('a[data-user-card="eviltrout"]')); assert.equal(queryAll(".username a").text(), "eviltrout"); assert.equal(queryAll(".full-name a").text(), "Robin Ward"); assert.equal(queryAll(".user-title").text(), "Trout Master"); @@ -46,12 +47,12 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll("span.staff").length); - assert.ok(queryAll("span.admin").length); - assert.ok(queryAll("span.moderator").length); - assert.ok(queryAll(".d-icon-shield-alt").length); - assert.ok(queryAll("span.new-user").length); - assert.ok(queryAll("span.fish").length); + assert.ok(exists("span.staff")); + assert.ok(exists("span.admin")); + assert.ok(exists("span.moderator")); + assert.ok(exists(".d-icon-shield-alt")); + assert.ok(exists("span.new-user")); + assert.ok(exists("span.fish")); }, }); @@ -62,7 +63,7 @@ discourseModule( this.set("args", { username: "eviltrout", name: "Robin Ward" }); }, test(assert) { - assert.equal(queryAll(".full-name").length, 0); + assert.ok(!exists(".full-name")); }, }); @@ -74,7 +75,7 @@ discourseModule( this.set("args", { username: "eviltrout", name: "evil-trout" }); }, test(assert) { - assert.equal(queryAll(".second").length, 0); + assert.ok(!exists(".second")); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/quick-access-item-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/quick-access-item-test.js index 0c3f898556..69a9f9525e 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/quick-access-item-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/quick-access-item-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, query } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; const CONTENT_DIV_SELECTOR = "li > a > div"; @@ -22,7 +19,7 @@ discourseModule( }, test(assert) { - const contentDiv = queryAll(CONTENT_DIV_SELECTOR)[0]; + const contentDiv = query(CONTENT_DIV_SELECTOR); assert.equal(contentDiv.innerText, "bold"); }, }); @@ -35,7 +32,7 @@ discourseModule( }, test(assert) { - const contentDiv = queryAll(CONTENT_DIV_SELECTOR)[0]; + const contentDiv = query(CONTENT_DIV_SELECTOR); assert.equal(contentDiv.innerText, '"quote"'); }, }); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/small-user-list-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/small-user-list-test.js index ac8dbcdaef..3488879b19 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/small-user-list-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/small-user-list-test.js @@ -2,8 +2,9 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, - queryAll, + exists, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; @@ -23,9 +24,9 @@ discourseModule( }); }, async test(assert) { - assert.ok(queryAll('[data-user-card="eviltrout"]').length === 1); - assert.ok(queryAll('[data-user-card="someone"]').length === 0); - assert.ok(queryAll(".unknown").length, "includes unknown user"); + assert.equal(count('[data-user-card="eviltrout"]'), 1); + assert.ok(!exists('[data-user-card="someone"]')); + assert.ok(exists(".unknown"), "includes unknown user"); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/software-update-prompt-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/software-update-prompt-test.js index dec9287d47..881f94aa95 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/software-update-prompt-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/software-update-prompt-test.js @@ -2,9 +2,10 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, publishToMessageBus, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import hbs from "htmlbars-inline-precompile"; import { later } from "@ember/runloop"; @@ -21,7 +22,7 @@ discourseModule( test(assert) { assert.ok( - queryAll("div.software-update-prompt").length === 0, + !exists("div.software-update-prompt"), "it does not have the class to show the prompt" ); @@ -29,9 +30,9 @@ discourseModule( const done = assert.async(); later(() => { - assert.ok( - queryAll("div.software-update-prompt.require-software-refresh") - .length === 1, + assert.equal( + count("div.software-update-prompt.require-software-refresh"), + 1, "it does have the class to show the prompt" ); done(); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/topic-status-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/topic-status-test.js index 683a074c06..08eaccb51f 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/topic-status-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/topic-status-test.js @@ -1,10 +1,7 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; -import { - discourseModule, - queryAll, -} from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule, exists } from "discourse/tests/helpers/qunit-helpers"; import TopicStatusIcons from "discourse/helpers/topic-status-icons"; import hbs from "htmlbars-inline-precompile"; @@ -22,7 +19,7 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll(".topic-status .d-icon-lock").length); + assert.ok(exists(".topic-status .d-icon-lock")); }, }); @@ -42,7 +39,7 @@ discourseModule( }); }, test(assert) { - assert.ok(queryAll(".topic-status .d-icon-far-check-square").length); + assert.ok(exists(".topic-status .d-icon-far-check-square")); }, }); } diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/user-menu-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/user-menu-test.js index 6654fa504d..624c683719 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/user-menu-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/user-menu-test.js @@ -3,6 +3,8 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, + exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import DiscourseURL from "discourse/lib/url"; @@ -20,12 +22,12 @@ discourseModule( template: hbs`{{mount-widget widget="user-menu"}}`, test(assert) { - assert.ok(queryAll(".user-menu").length); - assert.ok(queryAll(".user-preferences-link").length); - assert.ok(queryAll(".user-notifications-link").length); - assert.ok(queryAll(".user-bookmarks-link").length); - assert.ok(queryAll(".quick-access-panel").length); - assert.ok(queryAll(".notifications-dismiss").length); + assert.ok(exists(".user-menu")); + assert.ok(exists(".user-preferences-link")); + assert.ok(exists(".user-notifications-link")); + assert.ok(exists(".user-bookmarks-link")); + assert.ok(exists(".quick-access-panel")); + assert.ok(exists(".notifications-dismiss")); }, }); @@ -98,7 +100,7 @@ discourseModule( async test(assert) { await click(".user-preferences-link"); - assert.ok(queryAll(".logout").length); + assert.ok(exists(".logout")); await click(".logout button"); assert.ok(this.loggedOut); @@ -112,7 +114,7 @@ discourseModule( }, test(assert) { - assert.ok(!queryAll(".user-pms-link").length); + assert.ok(!exists(".user-pms-link")); }, }); @@ -127,7 +129,7 @@ discourseModule( assert.ok(userPmsLink); await click(".user-pms-link"); - const message = queryAll(".quick-access-panel li a")[0]; + const message = query(".quick-access-panel li a"); assert.ok(message); assert.ok( @@ -158,7 +160,7 @@ discourseModule( async test(assert) { await click(".user-bookmarks-link"); - const bookmark = queryAll(".quick-access-panel li a")[0]; + const bookmark = query(".quick-access-panel li a"); assert.ok(bookmark); assert.ok(bookmark.href.includes("/t/yelling-topic-title/119")); @@ -195,7 +197,7 @@ discourseModule( async test(assert) { await click(".user-preferences-link"); - assert.ok(queryAll(".enable-anonymous").length); + assert.ok(exists(".enable-anonymous")); await click(".enable-anonymous"); assert.ok(this.anonymous); @@ -211,7 +213,7 @@ discourseModule( async test(assert) { await click(".user-preferences-link"); - assert.ok(!queryAll(".enable-anonymous").length); + assert.ok(!exists(".enable-anonymous")); }, }); @@ -229,7 +231,7 @@ discourseModule( async test(assert) { await click(".user-preferences-link"); - assert.ok(queryAll(".disable-anonymous").length); + assert.ok(exists(".disable-anonymous")); await click(".disable-anonymous"); assert.notOk(this.anonymous); diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/widget-dropdown-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/widget-dropdown-test.js index 49e0060dcf..2b298090b4 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/widget-dropdown-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/widget-dropdown-test.js @@ -4,6 +4,7 @@ import componentTest, { import { discourseModule, exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; @@ -27,7 +28,7 @@ async function clickRowById(id) { } function rowById(id) { - return queryAll(`#my-dropdown .widget-dropdown-item.item-${id}`)[0]; + return query(`#my-dropdown .widget-dropdown-item.item-${id}`); } async function toggle() { @@ -41,11 +42,11 @@ function headerLabel() { } function header() { - return queryAll("#my-dropdown .widget-dropdown-header")[0]; + return query("#my-dropdown .widget-dropdown-header"); } function body() { - return queryAll("#my-dropdown .widget-dropdown-body")[0]; + return query("#my-dropdown .widget-dropdown-body"); } const TEMPLATE = hbs` @@ -150,10 +151,7 @@ discourseModule( beforeEach() { this.setProperties(DEFAULT_CONTENT); - this.set( - "onChange", - (item) => (queryAll("#test")[0].innerText = item.id) - ); + this.set("onChange", (item) => (query("#test").innerText = item.id)); }, async test(assert) { diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js index aa1a27b016..6d18467917 100644 --- a/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js +++ b/app/assets/javascripts/discourse/tests/integration/widgets/widget-test.js @@ -2,7 +2,10 @@ import componentTest, { setupRenderingTest, } from "discourse/tests/helpers/component-test"; import { + count, discourseModule, + exists, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; @@ -97,10 +100,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok( - queryAll(".test.static.cool-class").length, - "it has all the classes" - ); + assert.ok(exists(".test.static.cool-class"), "it has all the classes"); }, }); @@ -120,8 +120,8 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll('.test[data-evil="trout"]').length); - assert.ok(queryAll('.test[aria-label="accessibility"]').length); + assert.ok(exists('.test[data-evil="trout"]')); + assert.ok(exists('.test[aria-label="accessibility"]')); }, }); @@ -139,7 +139,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll("#test-1234").length); + assert.ok(exists("#test-1234")); }, }); @@ -163,10 +163,10 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, async test(assert) { - assert.ok(queryAll("button.test").length, "it renders the button"); + assert.ok(exists("button.test"), "it renders the button"); assert.equal(queryAll("button.test").text(), "0 clicks"); - await click(queryAll("button")[0]); + await click(query("button")); assert.equal(queryAll("button.test").text(), "1 clicks"); }, }); @@ -200,7 +200,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { async test(assert) { assert.equal(queryAll("button.test").text().trim(), "No name"); - await click(queryAll("button")[0]); + await click(query("button")); assert.equal(queryAll("button.test").text().trim(), "Robin"); }, }); @@ -218,8 +218,8 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll(".container").length, "renders container"); - assert.ok(queryAll(".container .embedded").length, "renders attached"); + assert.ok(exists(".container"), "renders container"); + assert.ok(exists(".container .embedded"), "renders attached"); }, }); @@ -236,8 +236,8 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll(".container").length, "renders container"); - assert.ok(queryAll(".container .embedded").length, "renders attached"); + assert.ok(exists(".container"), "renders container"); + assert.ok(exists(".container .embedded"), "renders attached"); }, }); @@ -257,7 +257,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll(".container").length, "renders container"); + assert.ok(exists(".container"), "renders container"); assert.equal(queryAll(".container .value").text(), "hello world"); }, }); @@ -296,7 +296,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.equal(queryAll(".d-icon-arrow-down").length, 1); + assert.equal(count(".d-icon-arrow-down"), 1); }, }); @@ -355,7 +355,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.equal(queryAll("ul li").length, 3); + assert.equal(count("ul li"), 3); assert.equal(queryAll("ul li:nth-of-type(1)").text(), "one"); }, }); @@ -381,7 +381,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { }, test(assert) { - assert.ok(queryAll(".decorate").length); + assert.ok(exists(".decorate")); assert.equal(queryAll(".decorate b").text(), "before"); assert.equal(queryAll(".decorate i").text(), "after"); }, @@ -456,7 +456,7 @@ discourseModule("Integration | Component | Widget | base", function (hooks) { test(assert) { assert.ok( - queryAll("section.override").length, + exists("section.override"), "renders container with overrided tagName" ); }, diff --git a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 index 34f0437f62..25c310dac3 100644 --- a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 +++ b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import selectKit from "discourse/tests/helpers/select-kit-helper"; @@ -26,7 +30,7 @@ acceptance("Details Button", function (needs) { await fillIn(".d-editor-input", "This is my title"); - const textarea = queryAll(".d-editor-input")[0]; + const textarea = query(".d-editor-input"); textarea.selectionStart = 0; textarea.selectionEnd = textarea.value.length; @@ -115,7 +119,7 @@ acceptance("Details Button", function (needs) { await click("#create-topic"); await fillIn(".d-editor-input", multilineInput); - const textarea = queryAll(".d-editor-input")[0]; + const textarea = query(".d-editor-input"); textarea.selectionStart = 0; textarea.selectionEnd = textarea.value.length; diff --git a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 index 28bfd6d450..38aba91743 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 @@ -1,4 +1,9 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + count, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; acceptance("Poll breakdown", function (needs) { @@ -69,19 +74,19 @@ acceptance("Poll breakdown", function (needs) { await click(".poll-show-breakdown"); assert.equal( - queryAll(".poll-breakdown-total-votes")[0].textContent.trim(), + query(".poll-breakdown-total-votes").textContent.trim(), "2 votes", "display the correct total vote count" ); assert.equal( - queryAll(".poll-breakdown-chart-container").length, + count(".poll-breakdown-chart-container"), 2, "renders a chart for each of the groups in group_results response" ); assert.ok( - queryAll(".poll-breakdown-chart-container > canvas")[0].$chartjs, + query(".poll-breakdown-chart-container > canvas").$chartjs, "$chartjs is defined on the pie charts" ); }); @@ -91,7 +96,7 @@ acceptance("Poll breakdown", function (needs) { await click(".poll-show-breakdown"); assert.equal( - queryAll(".poll-breakdown-option-count")[0].textContent.trim(), + query(".poll-breakdown-option-count").textContent.trim(), "40.0%", "displays the correct vote percentage" ); @@ -99,7 +104,7 @@ acceptance("Poll breakdown", function (needs) { await click(".modal-tabs .count"); assert.equal( - queryAll(".poll-breakdown-option-count")[0].textContent.trim(), + query(".poll-breakdown-option-count").textContent.trim(), "2", "displays the correct vote count" ); @@ -107,8 +112,8 @@ acceptance("Poll breakdown", function (needs) { await click(".modal-tabs .percentage"); assert.equal( - queryAll(".poll-breakdown-option-count:last")[0].textContent.trim(), - "20.0%", + query(".poll-breakdown-option-count").textContent.trim(), + "40.0%", "displays the percentage again" ); }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 index 721f764a1d..994df7fdb4 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; acceptance("Rendering polls with pie charts", function (needs) { needs.user(); @@ -10,10 +14,10 @@ acceptance("Rendering polls with pie charts", function (needs) { test("Displays the pie chart", async function (assert) { await visit("/t/-/topic_with_pie_chart_poll"); - const poll = queryAll(".poll")[0]; + const poll = query(".poll"); assert.equal( - queryAll(".info-number", poll)[0].innerHTML, + query(".info-number", poll).innerHTML, "2", "it should display the right number of voters" ); diff --git a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 index 846f97c4a1..d88ee700e6 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 @@ -1,4 +1,4 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, count } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; acceptance("Poll quote", function (needs) { @@ -427,7 +427,7 @@ acceptance("Poll quote", function (needs) { test("renders and extends", async function (assert) { await visit("/t/-/topic_with_two_quoted_polls"); await click(".quote-controls"); - assert.equal(queryAll(".poll").length, 2, "polls are rendered"); - assert.equal(queryAll(".poll-buttons").length, 2, "polls are extended"); + assert.equal(count(".poll"), 2, "polls are rendered"); + assert.equal(count(".poll-buttons"), 2, "polls are extended"); }); }); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 index 6be48becf7..fc1efc0e67 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 @@ -4,7 +4,7 @@ import { } from "discourse/tests/helpers/widget-test"; import EmberObject from "@ember/object"; import I18n from "I18n"; -import { queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { count, exists, queryAll } from "discourse/tests/helpers/qunit-helpers"; let requests = 0; @@ -100,7 +100,7 @@ widgetTest("can vote", { await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']"); assert.equal(requests, 1); - assert.equal(queryAll(".chosen").length, 1); + assert.equal(count(".chosen"), 1); assert.equal(queryAll(".chosen").text(), "100%yes"); assert.equal(queryAll(".toggle-results").text(), "Show vote"); @@ -152,6 +152,6 @@ widgetTest("cannot vote if not member of the right group", { I18n.t("poll.results.groups.title", { groups: "foo" }) ); assert.equal(requests, 0); - assert.equal(queryAll(".chosen").length, 0); + assert.ok(!exists(".chosen")); }, }); From 49c231c9933dd1a7446ea03d2785c8cb1e670b30 Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Tue, 8 Jun 2021 20:14:34 +0400 Subject: [PATCH 003/403] UX: add a hint that tags can be included in tag groups (#13326) --- .../javascripts/discourse/app/templates/components/tag-info.hbs | 2 +- config/locales/client.en.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs b/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs index efa16be7a7..7862457f4b 100644 --- a/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/tag-info.hbs @@ -28,7 +28,7 @@ {{#if tagInfo.category_restricted}} {{i18n "tagging.category_restricted"}} {{else}} - {{i18n "tagging.default_info"}} + {{html-safe (i18n "tagging.default_info" basePath=(base-path))}} {{/if}} {{/if}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 9628521655..bb78a811e5 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3634,7 +3634,7 @@ en: tags: "Tags" choose_for_topic: "optional tags" info: "Info" - default_info: "This tag isn't restricted to any categories, and has no synonyms." + default_info: "This tag isn't restricted to any categories, and has no synonyms. To add restrictions, put this tag in a tag group." category_restricted: "This tag is restricted to categories you don't have permission to access." synonyms: "Synonyms" synonyms_description: "When the following tags are used, they will be replaced with %{base_tag_name}." From c2c647b990c3974a058ab32155655b7296427975 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Tue, 8 Jun 2021 13:25:51 -0400 Subject: [PATCH 004/403] FIX: errors loading secure uploads when secure uploads is disabled (#13047) --- lib/url_helper.rb | 5 +++-- spec/components/url_helper_spec.rb | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/url_helper.rb b/lib/url_helper.rb index 4bbafe331f..b1f10df26c 100644 --- a/lib/url_helper.rb +++ b/lib/url_helper.rb @@ -77,15 +77,16 @@ class UrlHelper end def self.cook_url(url, secure: false, local: nil) + is_secure = SiteSetting.secure_media && secure local = is_local(url) if local.nil? return url if !local - url = secure ? secure_proxy_without_cdn(url) : absolute_without_cdn(url) + url = is_secure ? secure_proxy_without_cdn(url) : absolute_without_cdn(url) # we always want secure media to come from # Discourse.base_url_no_prefix/secure-media-uploads # to avoid asset_host mixups - return schemaless(url) if secure + return schemaless(url) if is_secure # PERF: avoid parsing url except for extreme conditions # this is a hot path used on home page diff --git a/spec/components/url_helper_spec.rb b/spec/components/url_helper_spec.rb index 0d8afc9598..36c66e7972 100644 --- a/spec/components/url_helper_spec.rb +++ b/spec/components/url_helper_spec.rb @@ -170,6 +170,8 @@ describe UrlHelper do Rails.configuration.action_controller.asset_host = "https://test.some-cdn.com/dev" FileStore::S3Store.any_instance.stubs(:has_been_uploaded?).returns(true) + + SiteSetting.secure_media = true end def cooked @@ -184,6 +186,16 @@ describe UrlHelper do "//test.localhost/secure-media-uploads/dev/original/3X/2/e/2e6f2ef81b6910ea592cd6d21ee897cd51cf72e4.jpeg" ) end + + context "and secure_media setting is disabled" do + before { SiteSetting.secure_media = false } + + it "returns the local_cdn_url" do + expect(cooked).to eq( + "//s3bucket.s3.dualstack.us-west-1.amazonaws.com/dev/original/3X/2/e/2e6f2ef81b6910ea592cd6d21ee897cd51cf72e4.jpeg" + ) + end + end end context "when the upload for the url is not secure" do From d500d0cc9966aaf0028097d7167425a627648bb6 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Tue, 8 Jun 2021 13:20:08 -0500 Subject: [PATCH 005/403] FEATURE: Add group filter to user directory (#13330) --- .../discourse/app/controllers/users.js | 23 +++++++++++++++++-- .../javascripts/discourse/app/routes/users.js | 6 ++++- .../discourse/app/templates/mobile/users.hbs | 11 ++++++++- .../discourse/app/templates/users.hbs | 11 ++++++++- .../stylesheets/common/base/directory.scss | 3 +++ app/assets/stylesheets/mobile/directory.scss | 6 +---- config/locales/client.en.yml | 2 ++ 7 files changed, 52 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js index 98f48728bc..e2878823ae 100644 --- a/app/assets/javascripts/discourse/app/controllers/users.js +++ b/app/assets/javascripts/discourse/app/controllers/users.js @@ -18,6 +18,7 @@ export default Controller.extend({ exclude_usernames: null, isLoading: false, columns: null, + groupsOptions: null, showTimeRead: equal("period", "all"), @@ -32,7 +33,7 @@ export default Controller.extend({ .map((c) => c.user_field_id) .join("|"); - this.store + return this.store .find("directoryItem", Object.assign(params, { user_field_ids })) .then((model) => { const lastUpdatedAt = model.get("resultSetMeta.last_updated_at"); @@ -47,13 +48,31 @@ export default Controller.extend({ }); }, + loadGroups() { + return this.store.findAll("group").then((groups) => { + const groupOptions = groups.map((group) => { + return { + name: group.name, + id: group.id, + }; + }); + this.set("groupOptions", groupOptions); + }); + }, + + @action + groupChanged(_, groupAttrs) { + // First param is the group name, which include none or 'all groups'. Ignore this and look at second param. + this.set("group", groupAttrs.id ? groupAttrs.name : null); + }, + @action showEditColumnsModal() { showModal("edit-user-directory-columns"); }, @action - onFilterChanged(filter) { + onUsernameFilterChanged(filter) { discourseDebounce(this, this._setName, filter, 500); }, diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js index 8c059adab8..181204d786 100644 --- a/app/assets/javascripts/discourse/app/routes/users.js +++ b/app/assets/javascripts/discourse/app/routes/users.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; import PreloadStore from "discourse/lib/preload-store"; +import { Promise } from "rsvp"; export default DiscourseRoute.extend({ queryParams: { @@ -44,7 +45,10 @@ export default DiscourseRoute.extend({ setupController(controller, model) { controller.set("columns", model.columns); - controller.loadUsers(model.params); + return Promise.all([ + controller.loadGroups(), + controller.loadUsers(model.params), + ]); }, actions: { diff --git a/app/assets/javascripts/discourse/app/templates/mobile/users.hbs b/app/assets/javascripts/discourse/app/templates/mobile/users.hbs index 44d463a001..ff80213deb 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/users.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/users.hbs @@ -13,10 +13,19 @@ {{/if}} {{input value=(readonly nameInput) - input=(action "onFilterChanged" value="target.value") + input=(action "onUsernameFilterChanged" value="target.value") placeholderKey="directory.filter_name" class="filter-name no-blur" }} + {{combo-box + class="directory-group-selector" + value=group + content=groupOptions + onChange=(action groupChanged) + options=(hash + none="directory.group.all" + ) + }} {{#if currentUser.staff}} {{d-button icon="wrench" diff --git a/app/assets/javascripts/discourse/app/templates/users.hbs b/app/assets/javascripts/discourse/app/templates/users.hbs index d2c85f68d8..d6c7450aad 100644 --- a/app/assets/javascripts/discourse/app/templates/users.hbs +++ b/app/assets/javascripts/discourse/app/templates/users.hbs @@ -21,10 +21,19 @@ {{input value=(readonly nameInput) - input=(action "onFilterChanged" value="target.value") + input=(action "onUsernameFilterChanged" value="target.value") placeholderKey="directory.filter_name" class="filter-name no-blur" }} + {{combo-box + class="directory-group-selector" + value=group + content=groupOptions + onChange=(action groupChanged) + options=(hash + none="directory.group.all" + ) + }} {{#if currentUser.staff}} {{d-button icon="wrench" diff --git a/app/assets/stylesheets/common/base/directory.scss b/app/assets/stylesheets/common/base/directory.scss index 3d938034f1..2a8e82b0d4 100644 --- a/app/assets/stylesheets/common/base/directory.scss +++ b/app/assets/stylesheets/common/base/directory.scss @@ -20,6 +20,9 @@ margin: 0; } } + .directory-group-selector { + vertical-align: top; + } } .user-info { diff --git a/app/assets/stylesheets/mobile/directory.scss b/app/assets/stylesheets/mobile/directory.scss index 25532eaa8b..987580ccfc 100644 --- a/app/assets/stylesheets/mobile/directory.scss +++ b/app/assets/stylesheets/mobile/directory.scss @@ -3,14 +3,9 @@ font-size: $font-up-1; } - .open-edit-columns-btn { - margin: -0.7em 0 0.5em; - } - &.users-directory { .filter-name { width: 100%; - margin-bottom: 1em; } .directory-last-updated { @@ -59,4 +54,5 @@ .edit-user-directory-columns-modal .modal-inner-container { width: 90%; + min-width: unset; } diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index bb78a811e5..57ef0b2c24 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -650,6 +650,8 @@ en: title: "Edit Directory Columns" save: "Save" reset_to_default: "Reset to default" + group: + all: "all groups" group_histories: actions: From a27de199b7ed3806e632cf28d7645cd72e6dabe5 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Tue, 8 Jun 2021 13:37:42 -0500 Subject: [PATCH 006/403] DEV: Refactor user_badge select_for_grouping scope (#13334) --- app/models/user_badge.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb index 114ce19176..089029c2fa 100644 --- a/app/models/user_badge.rb +++ b/app/models/user_badge.rb @@ -7,6 +7,8 @@ class UserBadge < ActiveRecord::Base belongs_to :notification, dependent: :destroy belongs_to :post + BOOLEAN_ATTRIBUTES = %w(is_favorite) + scope :grouped_with_count, -> { group(:badge_id, :user_id) .select_for_grouping @@ -17,11 +19,8 @@ class UserBadge < ActiveRecord::Base scope :select_for_grouping, -> { select( UserBadge.attribute_names.map do |name| - if name == 'is_favorite' - "BOOL_OR(user_badges.#{name}) AS is_favorite" - else - "MAX(user_badges.#{name}) AS #{name}" - end + operation = BOOLEAN_ATTRIBUTES.include?(name) ? "BOOL_OR" : "MAX" + "#{operation}(user_badges.#{name}) AS #{name}" end, 'COUNT(*) AS "count"' ) From 940eb28e31636cf585a27daa92a1e86a14238423 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Tue, 8 Jun 2021 22:03:59 +0300 Subject: [PATCH 007/403] FIX: Theme tests should work in production (#13333) The `ember_jquery` bundle contains production builds of Ember and jQuery which doesn't work with tests. This commits introduces a new `theme_qunit_vendor` bundle which is copy of the `vendor` bundle but doesn't contain `ember_jquery`. This commit is a partial revert of https://github.com/discourse/discourse/commit/409c8585e46250ee495356e1897a4fda905152b3 --- .../tests/theme_qunit_ember_jquery.js | 6 +++ .../tests/theme_qunit_tests_vendor.js | 15 ++++++ .../discourse/tests/theme_qunit_vendor.js | 46 ++++++++++++++----- app/views/qunit/theme.html.erb | 3 +- config/application.rb | 4 +- spec/requests/qunit_controller_spec.rb | 3 +- 6 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 app/assets/javascripts/discourse/tests/theme_qunit_ember_jquery.js create mode 100644 app/assets/javascripts/discourse/tests/theme_qunit_tests_vendor.js diff --git a/app/assets/javascripts/discourse/tests/theme_qunit_ember_jquery.js b/app/assets/javascripts/discourse/tests/theme_qunit_ember_jquery.js new file mode 100644 index 0000000000..caccb2d83d --- /dev/null +++ b/app/assets/javascripts/discourse/tests/theme_qunit_ember_jquery.js @@ -0,0 +1,6 @@ +// discourse-skip-module + +//= require env +//= require jquery.debug +//= require ember.debug +//= require discourse-loader diff --git a/app/assets/javascripts/discourse/tests/theme_qunit_tests_vendor.js b/app/assets/javascripts/discourse/tests/theme_qunit_tests_vendor.js new file mode 100644 index 0000000000..3c6865b1e0 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/theme_qunit_tests_vendor.js @@ -0,0 +1,15 @@ +// discourse-skip-module + +//= require qunit +//= require ember-qunit +//= require fake_xml_http_request +//= require route-recognizer +//= require pretender + +// These are not loaded in prod or development +// But we need them for testing handlebars templates in qunit +//= require handlebars +//= require ember-template-compiler + +//= require sinon +//= require break_string diff --git a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js index 3c6865b1e0..5776eb6784 100644 --- a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js +++ b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js @@ -1,15 +1,37 @@ -// discourse-skip-module +// This bundle contains the same dependencies as app/assets/javascripts/vendor.js +// minus ember_jquery. +// ember_jquery doesn't work with theme tests in production because it +// contains production builds of Ember and jQuery, so we have a separate bundle +// caled theme_qunit_ember_jquery which contains a debug build for Ember and jQuery. +// We don't put theme_qunit_ember_jquery in this bundle because it would make the +// bundle too big and cause OOM exceptions during rebuilds for self-hosters on +// low-end machines. -//= require qunit -//= require ember-qunit -//= require fake_xml_http_request -//= require route-recognizer -//= require pretender +//= require logster -// These are not loaded in prod or development -// But we need them for testing handlebars templates in qunit -//= require handlebars -//= require ember-template-compiler +//= require template_include.js -//= require sinon -//= require break_string +//= require message-bus +//= require jquery.ui.widget.js +//= require Markdown.Converter.js +//= require bootbox.js +//= require popper.js +//= require bootstrap-modal.js +//= require caret_position +//= require jquery.color.js +//= require jquery.fileupload.js +//= require jquery.iframe-transport.js +//= require jquery.tagsinput.js +//= require jquery.sortable.js +//= require lodash.js +//= require mousetrap.js +//= require mousetrap-global-bind.js +//= require rsvp.js +//= require show-html.js +//= require buffered-proxy +//= require jquery.autoellipsis-1.0.10 +//= require virtual-dom +//= require virtual-dom-amd +//= require intersection-observer +//= require discourse-shims +//= require pretty-text-bundle diff --git a/app/views/qunit/theme.html.erb b/app/views/qunit/theme.html.erb index c12f34adce..3016de7711 100644 --- a/app/views/qunit/theme.html.erb +++ b/app/views/qunit/theme.html.erb @@ -7,8 +7,9 @@ <%= discourse_stylesheet_link_tag(:desktop, theme_ids: nil) %> <%= discourse_stylesheet_link_tag(:test_helper, theme_ids: nil) %> <%= preload_script "locales/en" %> - <%= preload_script "vendor" %> + <%= preload_script "discourse/tests/theme_qunit_ember_jquery" %> <%= preload_script "discourse/tests/theme_qunit_vendor" %> + <%= preload_script "discourse/tests/theme_qunit_tests_vendor" %> <%= preload_script "markdown-it-bundle" %> <%= preload_script "application" %> <%- Discourse.find_plugin_js_assets(include_official: allow_plugins?, include_unofficial: allow_third_party_plugins?, request: request).each do |file| %> diff --git a/config/application.rb b/config/application.rb index f3a47a481a..b1a4d82ed7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -173,8 +173,10 @@ module Discourse confirm-new-email/bootstrap.js onpopstate-handler.js embed-application.js - discourse/tests/theme_qunit_helper.js + discourse/tests/theme_qunit_ember_jquery.js discourse/tests/theme_qunit_vendor.js + discourse/tests/theme_qunit_tests_vendor.js + discourse/tests/theme_qunit_helper.js discourse/tests/test_starter.js } diff --git a/spec/requests/qunit_controller_spec.rb b/spec/requests/qunit_controller_spec.rb index ec825e71a9..aa7773127e 100644 --- a/spec/requests/qunit_controller_spec.rb +++ b/spec/requests/qunit_controller_spec.rb @@ -98,8 +98,9 @@ describe QunitController do expect(response.body).to include("/stylesheets/desktop_") expect(response.body).to include("/stylesheets/test_helper_") expect(response.body).to include("/assets/locales/en.js") - expect(response.body).to include("/assets/vendor.js") + expect(response.body).to include("/assets/discourse/tests/theme_qunit_ember_jquery.js") expect(response.body).to include("/assets/discourse/tests/theme_qunit_vendor.js") + expect(response.body).to include("/assets/discourse/tests/theme_qunit_tests_vendor.js") expect(response.body).to include("/assets/markdown-it-bundle.js") expect(response.body).to include("/assets/application.js") expect(response.body).to include("/assets/admin.js") From 0241748876d9ababafe952a0c19b74f0cf76bb77 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 9 Jun 2021 12:44:33 +1000 Subject: [PATCH 008/403] DEV: ember-cli -u can be used to run a standalone dev discourse (#13336) Previously we would need to launch unicorn separately this achieves the same goal by making 2 modifications: 1. If -u is supplied ember-cli binary will launch and monitor ember cli and unicorn 2. We suppress 200 requests to keep console clean (we may consider moving to development rails logs) Also cleans out output a bit by supplying silent flags to yarn. --- bin/ember-cli | 42 ++++++++++++++++++++++++++++++++++++------ bin/unicorn | 2 +- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/bin/ember-cli b/bin/ember-cli index 1395ecc924..0fc2496464 100755 --- a/bin/ember-cli +++ b/bin/ember-cli @@ -30,18 +30,48 @@ end if ARGV.include?("-h") || ARGV.include?("--help") puts "ember-cli OPTIONS" - puts "#{"--try".cyan} To proxy try.discourse.org", "" - puts "#{"--test".cyan} To run the test suite", "" + puts "#{"--try".cyan} To proxy try.discourse.org" + puts "#{"--test".cyan} To run the test suite" + puts "#{"--unicorn, -u".cyan} To run a unicorn server as well" puts "The rest of the arguments are passed to ember server per:", "" - exec "yarn --cwd #{yarn_dir} run ember #{command} --help" + exec "yarn -s --cwd #{yarn_dir} run ember #{command} --help" end -args = ["--cwd", yarn_dir, "run", "ember", command] + ARGV.reject { |a| a == "--try" || a == "--test" } +args = ["-s", "--cwd", yarn_dir, "run", "ember", command] + ARGV.reject do |a| + ["--try", "--test", "--unicorn", "-u"].include?(a) +end if !args.include?("--proxy") args << "--proxy" args << PROXY end -system "yarn install --cwd #{yarn_dir}" -exec "yarn", *args.to_a.flatten +system "yarn -s install --cwd #{yarn_dir}" + +if (ARGV - ["--unicorn", "-"]).length < 2 + unicorn_pid = spawn(__dir__ + "/unicorn") + + Thread.new do + require 'open3' + Open3.popen2e("yarn", *args.to_a.flatten) do |i, oe, t| + puts "Ember CLI running on PID: #{t.pid}" + oe.each do |line| + if line.include?("\e[32m200\e") || line.include?("\e[36m304\e[0m") || line.include?("POST /message-bus") + # skip 200s and 304s and message bus + else + puts line + end + end + end + end + + trap("SIGINT") do + # we got to swallow sigint to give time for + # children to handle it + end + + Process.wait(unicorn_pid) + +else + exec "yarn", *args.to_a.flatten +end diff --git a/bin/unicorn b/bin/unicorn index cc241696db..7f8a9d3a97 100755 --- a/bin/unicorn +++ b/bin/unicorn @@ -20,7 +20,7 @@ require 'fileutils' dev_mode = false def ensure_cache_clean! - all_plugin_directories = Pathname.new(RAILS_ROOT + '/plugins').children.select(&:directory?) + _all_plugin_directories = Pathname.new(RAILS_ROOT + '/plugins').children.select(&:directory?) core_git_sha = `git rev-parse HEAD`.strip plugins_combined_git_sha = `git ls-files -s plugins | git hash-object --stdin`.strip super_sha = Digest::SHA1.hexdigest(core_git_sha + plugins_combined_git_sha) From 023ff9a2821e9b4a3134349fef8f566f37714b16 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 9 Jun 2021 15:55:52 +1000 Subject: [PATCH 009/403] DEV: ensure user export ordering is predictable (#13340) Flaky spec due to random ordering for the post_actions table. Introduces consistent ordering. --- app/jobs/regular/export_user_archive.rb | 4 ++++ spec/jobs/export_user_archive_spec.rb | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/jobs/regular/export_user_archive.rb b/app/jobs/regular/export_user_archive.rb index 73ce107426..7a8e4b113f 100644 --- a/app/jobs/regular/export_user_archive.rb +++ b/app/jobs/regular/export_user_archive.rb @@ -280,6 +280,7 @@ module Jobs .with_deleted .where(user_id: @current_user.id) .where(post_action_type_id: PostActionType.flag_types.values) + .order(:created_at) .each do |pa| yield [ pa.id, @@ -303,6 +304,7 @@ module Jobs .with_deleted .where(user_id: @current_user.id) .where(post_action_type_id: PostActionType.types[:like]) + .order(:created_at) .each do |pa| post = Post.with_deleted.find_by(id: pa.post_id) yield [ @@ -332,6 +334,7 @@ module Jobs .with_deleted .where(user_id: @current_user.id) .where.not(post_action_type_id: PostActionType.flag_types.values + [PostActionType.types[:like], PostActionType.types[:bookmark]]) + .order(:created_at) .each do |pa| yield [ pa.id, @@ -352,6 +355,7 @@ module Jobs # Most Reviewable fields staff-private, but post content needs to be exported. ReviewableQueuedPost .where(created_by: @current_user.id) + .order(:created_at) .each do |rev| yield [ diff --git a/spec/jobs/export_user_archive_spec.rb b/spec/jobs/export_user_archive_spec.rb index 3387a7144d..810e787d3a 100644 --- a/spec/jobs/export_user_archive_spec.rb +++ b/spec/jobs/export_user_archive_spec.rb @@ -159,7 +159,7 @@ describe Jobs::ExportUserArchive do it 'can export a post from a deleted category' do cat2 = Fabricate(:category) topic2 = Fabricate(:topic, category: cat2, user: user) - post2 = Fabricate(:post, topic: topic2, user: user) + _post2 = Fabricate(:post, topic: topic2, user: user) cat2_id = cat2.id cat2.destroy! @@ -182,7 +182,7 @@ describe Jobs::ExportUserArchive do end it 'properly includes the profile fields' do - serializer = job.preferences_export + _serializer = job.preferences_export # puts MultiJson.dump(serializer, indent: 4) output = make_component_json payload = output['user'] @@ -205,7 +205,7 @@ describe Jobs::ExportUserArchive do end it 'properly includes session records' do - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(1) expect(data[0]['user_agent']).to eq('MyWebBrowser') @@ -214,7 +214,7 @@ describe Jobs::ExportUserArchive do context 'auth token logs' do let(:component) { 'auth_token_logs' } it 'includes details such as the path' do - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(1) expect(data[0]['action']).to eq('generate') @@ -240,7 +240,7 @@ describe Jobs::ExportUserArchive do BadgeGranter.grant(badge3, user, post_id: Fabricate(:post).id) BadgeGranter.grant(badge3, user, post_id: Fabricate(:post).id) - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(6) expect(data[0]['badge_id']).to eq(badge1.id.to_s) @@ -285,7 +285,7 @@ describe Jobs::ExportUserArchive do BookmarkReminderNotificationHandler.send_notification(pending_reminder) - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(4) @@ -341,7 +341,7 @@ describe Jobs::ExportUserArchive do end it 'correctly exports the CategoryUser table' do - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.find { |r| r['category_id'] == category.id }).to be_nil expect(data.length).to eq(4) @@ -376,10 +376,11 @@ describe Jobs::ExportUserArchive do PostActionCreator.spam(user, post3) PostActionDestroyer.destroy(user, post3, :spam) PostActionCreator.inappropriate(user, post3) + result3 = PostActionCreator.off_topic(user, post4) result3.reviewable.perform(admin, :agree_and_keep) - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(4) data.sort_by! { |row| row['post_id'].to_i } @@ -411,7 +412,7 @@ describe Jobs::ExportUserArchive do PostActionDestroyer.destroy(user, post3, :like) post3.destroy! - data, csv_out = make_component_csv + data, _csv_out = make_component_csv expect(data.length).to eq(2) data.sort_by! { |row| row['post_id'].to_i } @@ -461,7 +462,7 @@ describe Jobs::ExportUserArchive do UserVisit.create(user_id: user.id, visited_at: 1.year.ago, posts_read: 4, mobile: false, time_read: 40) UserVisit.create(user_id: user2.id, visited_at: 1.minute.ago, posts_read: 1, mobile: false, time_read: 50) - data, csv_out = make_component_csv + data, _csv_out = make_component_csv # user2's data is not mixed in expect(data.length).to eq(4) From 7ba35e0d71a41a47be5efead91f413f650343b4b Mon Sep 17 00:00:00 2001 From: Grayden <38144548+graydenshand@users.noreply.github.com> Date: Wed, 9 Jun 2021 06:01:06 -0400 Subject: [PATCH 010/403] UX: Improvements for reordering categories (#13013) * UX: Improvements to reorder categories UX Before, moving a category from, for example, position 25 to position 0 would result in switching the positions of the two categories at those positions. Category A at position 0 would move to position 25, and Category B at position 25 would move to position 0. Instead of switching positions, the reorder categories function should retain the order of categories except for the one being moved. So, Category B at position 25 would still move to position 0, but Category A is merely bumped down to position 1. This improves the UX because if a user *really* wants to switch the two categories, it results in one extra step. However in the other (what I think is normal) case, it saves the 24 other switches the user has to make to get Category A back to position 1 (you can imagine the user having to click the up arrow button repeatedly to return Category A to the top of the page). Now, imagine trying to do this with a site with 100s of categories. Yikes! The UX improvement described above is what this commit accomplishes by redesigning the `move()` method of the reorder-categories controller. It adds some overhead to adjust the positions of all categories in between the origin and target positions, but in testing this is not noticible to the user. It's better for the computer to do extra work than the user. * UX: Allow decimal input in reorder-categories for more precise positioning. A common UX pattern when reordering a list of items is to allow a user to specify a target position as a decimal between two valid integer positions. The user is indicating they want the target list item to move in between the list items at the positions on either side of the target position. For example, say there are three categories Category A at position 0, Category B at position 1, and Category C at position 3. To move Category C in between Categories A and B, a user can now simply update Category C's position to 0.5. --- .../app/controllers/reorder-categories.js | 121 +++++++++++------ .../controllers/reorder-categories-test.js | 128 ++++++++---------- 2 files changed, 141 insertions(+), 108 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/reorder-categories.js b/app/assets/javascripts/discourse/app/controllers/reorder-categories.js index 47da67b284..36b2d9d9eb 100644 --- a/app/assets/javascripts/discourse/app/controllers/reorder-categories.js +++ b/app/assets/javascripts/discourse/app/controllers/reorder-categories.js @@ -60,56 +60,97 @@ export default Controller.extend(ModalFunctionality, Evented, { this.notifyPropertyChange("categoriesBuffered"); }, + countDescendants(category) { + return category.get("subcategories") + ? category + .get("subcategories") + .reduce( + (count, subcategory) => count + this.countDescendants(subcategory), + category.get("subcategories").length + ) + : 0; + }, + move(category, direction) { - let otherCategory; + let targetPosition = category.get("position") + direction; - if (direction === -1) { - // First category above current one - const categoriesOrderedDesc = this.categoriesOrdered.reverse(); - otherCategory = categoriesOrderedDesc.find( - (c) => - category.get("parent_category_id") === c.get("parent_category_id") && - c.get("position") < category.get("position") - ); - } else if (direction === 1) { - // First category under current one - otherCategory = this.categoriesOrdered.find( - (c) => - category.get("parent_category_id") === c.get("parent_category_id") && - c.get("position") > category.get("position") - ); - } else { - // Find category occupying target position - otherCategory = this.categoriesOrdered.find( - (c) => c.get("position") === category.get("position") + direction - ); - } - - if (otherCategory) { - // Try to swap positions of the two categories - if (category.get("id") !== otherCategory.get("id")) { - const currentPosition = category.get("position"); - category.set("position", otherCategory.get("position")); - otherCategory.set("position", currentPosition); + // Adjust target position for sub-categories + if (direction > 0) { + // Moving down (position gets larger) + if (category.get("isParent")) { + // This category has subcategories, adjust targetPosition to account for them + let offset = this.countDescendants(category); + if (direction <= offset) { + // Only apply offset if target position is occupied by a subcategory + // Seems weird but fixes a UX quirk + targetPosition += offset; + } + } + } else { + // Moving up (position gets smaller) + const otherCategory = this.categoriesOrdered.find( + (c) => + // find category currently at targetPosition + c.get("position") === targetPosition + ); + if (otherCategory && otherCategory.get("ancestors")) { + // Target category is a subcategory, adjust targetPosition to account for ancestors + const highestAncestor = otherCategory + .get("ancestors") + .reduce((current, min) => + current.get("position") < min.get("position") ? current : min + ); + targetPosition = highestAncestor.get("position"); } - } else if (direction < 0) { - category.set("position", -1); - } else if (direction > 0) { - category.set("position", this.categoriesOrdered.length); } + // Adjust target position for range bounds + if (targetPosition >= this.categoriesOrdered.length) { + // Set to max + targetPosition = this.categoriesOrdered.length - 1; + } else if (targetPosition < 0) { + // Set to min + targetPosition = 0; + } + + // Update other categories between current and target position + this.categoriesOrdered.map((c) => { + if (direction < 0) { + // Moving up (position gets smaller) + if ( + c.get("position") < category.get("position") && + c.get("position") >= targetPosition + ) { + const newPosition = c.get("position") + 1; + c.set("position", newPosition); + } + } else { + // Moving down (position gets larger) + if ( + c.get("position") > category.get("position") && + c.get("position") <= targetPosition + ) { + const newPosition = c.get("position") - 1; + c.set("position", newPosition); + } + } + }); + + // Update this category's position to target position + category.set("position", targetPosition); + this.reorder(); }, actions: { change(category, event) { - let newPosition = parseInt(event.target.value, 10); - newPosition = Math.min( - Math.max(newPosition, 0), - this.categoriesOrdered.length - 1 - ); - - this.move(category, newPosition - category.get("position")); + let newPosition = parseFloat(event.target.value); + newPosition = + newPosition < category.get("position") + ? Math.ceil(newPosition) + : Math.floor(newPosition); + const direction = newPosition - category.get("position"); + this.move(category, direction); }, moveUp(category) { diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/reorder-categories-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/reorder-categories-test.js index 2feb422d95..821b2cbb85 100644 --- a/app/assets/javascripts/discourse/tests/unit/controllers/reorder-categories-test.js +++ b/app/assets/javascripts/discourse/tests/unit/controllers/reorder-categories-test.js @@ -11,17 +11,14 @@ discourseModule("Unit | Controller | reorder-categories", function () { categories.push(store.createRecord("category", { id: i, position: 0 })); } - const reorderCategoriesController = this.getController( - "reorder-categories", - { site: { categories } } - ); - reorderCategoriesController.reorder(); + const controller = this.getController("reorder-categories", { + site: { categories }, + }); + controller.reorder(); - reorderCategoriesController - .get("categoriesOrdered") - .forEach((category, index) => { - assert.equal(category.get("position"), index); - }); + controller.get("categoriesOrdered").forEach((category, index) => { + assert.equal(category.get("position"), index); + }); }); test("reorder places subcategories after their parent categories, while maintaining the relative order", function (assert) { @@ -51,14 +48,13 @@ discourseModule("Unit | Controller | reorder-categories", function () { }); const expectedOrderSlugs = ["parent", "child2", "child1", "other"]; - const reorderCategoriesController = this.getController( - "reorder-categories", - { site: { categories: [child2, parent, other, child1] } } - ); - reorderCategoriesController.reorder(); + const controller = this.getController("reorder-categories", { + site: { categories: [child2, parent, other, child1] }, + }); + controller.reorder(); assert.deepEqual( - reorderCategoriesController.get("categoriesOrdered").mapBy("slug"), + controller.get("categoriesOrdered").mapBy("slug"), expectedOrderSlugs ); }); @@ -84,21 +80,18 @@ discourseModule("Unit | Controller | reorder-categories", function () { slug: "test", }); - const reorderCategoriesController = this.getController( - "reorder-categories", - { site: { categories: [elem1, elem2, elem3] } } - ); + const controller = this.getController("reorder-categories", { + site: { categories: [elem1, elem2, elem3] }, + }); - reorderCategoriesController.actions.change.call( - reorderCategoriesController, - elem1, - { target: { value: "2" } } - ); + // Move category 'foo' from position 0 to position 2 + controller.send("change", elem1, { target: { value: "2" } }); - assert.deepEqual( - reorderCategoriesController.get("categoriesOrdered").mapBy("slug"), - ["test", "bar", "foo"] - ); + assert.deepEqual(controller.get("categoriesOrdered").mapBy("slug"), [ + "bar", + "test", + "foo", + ]); }); test("changing the position number of a category should place it at given position and respect children", function (assert) { @@ -129,72 +122,71 @@ discourseModule("Unit | Controller | reorder-categories", function () { slug: "test", }); - const reorderCategoriesController = this.getController( - "reorder-categories", - { site: { categories: [elem1, child1, elem2, elem3] } } - ); + const controller = this.getController("reorder-categories", { + site: { categories: [elem1, child1, elem2, elem3] }, + }); - reorderCategoriesController.actions.change.call( - reorderCategoriesController, - elem1, - { target: { value: 3 } } - ); + controller.send("change", elem1, { target: { value: 3 } }); - assert.deepEqual( - reorderCategoriesController.get("categoriesOrdered").mapBy("slug"), - ["test", "bar", "foo", "foochild"] - ); + assert.deepEqual(controller.get("categoriesOrdered").mapBy("slug"), [ + "bar", + "test", + "foo", + "foochild", + ]); }); test("changing the position through click on arrow of a category should place it at given position and respect children", function (assert) { const store = createStore(); - const elem1 = store.createRecord("category", { - id: 1, - position: 0, - slug: "foo", + const child2 = store.createRecord("category", { + id: 105, + position: 2, + slug: "foochildchild", + parent_category_id: 104, }); const child1 = store.createRecord("category", { - id: 4, + id: 104, position: 1, slug: "foochild", - parent_category_id: 1, + parent_category_id: 101, + subcategories: [child2], }); - const child2 = store.createRecord("category", { - id: 5, - position: 2, - slug: "foochildchild", - parent_category_id: 4, + const elem1 = store.createRecord("category", { + id: 101, + position: 0, + slug: "foo", + subcategories: [child1], }); const elem2 = store.createRecord("category", { - id: 2, + id: 102, position: 3, slug: "bar", }); const elem3 = store.createRecord("category", { - id: 3, + id: 103, position: 4, slug: "test", }); - const reorderCategoriesController = this.getController( - "reorder-categories", - { site: { categories: [elem1, child1, child2, elem2, elem3] } } - ); - reorderCategoriesController.reorder(); + const controller = this.getController("reorder-categories", { + site: { categories: [elem1, child1, child2, elem2, elem3] }, + }); - reorderCategoriesController.actions.moveDown.call( - reorderCategoriesController, - elem1 - ); + controller.reorder(); - assert.deepEqual( - reorderCategoriesController.get("categoriesOrdered").mapBy("slug"), - ["bar", "foo", "foochild", "foochildchild", "test"] - ); + controller.send("moveDown", elem1); + + assert.deepEqual(controller.get("categoriesOrdered").mapBy("slug"), [ + "bar", + "foo", + "foochild", + "foochildchild", + "test", + ]); }); }); From a96f6fbdf5a35710269fd6e230826d3e1cdbec1c Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 9 Jun 2021 08:29:00 -0400 Subject: [PATCH 011/403] FIX: Do not block SVG sprite bundle if a file is missing (#13338) --- lib/svg_sprite/svg_sprite.rb | 11 ++++++++++ spec/components/svg_sprite/svg_sprite_spec.rb | 22 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 79bef31f36..b2010fe410 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -330,6 +330,11 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL end custom_svg_sprites(theme_ids).each do |fname| + if !File.exist?(fname) + cache.delete("custom_svg_sprites_#{Theme.transform_ids(theme_ids).join(',')}") + next + end + svg_file = Nokogiri::XML(File.open(fname)) do |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS end @@ -354,6 +359,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL searched_icon = process(searched_icon.dup) sprite_sources([SiteSetting.default_theme_id]).each do |fname| + next if !File.exist?(fname) + svg_file = Nokogiri::XML(File.open(fname)) svg_filename = "#{File.basename(fname, ".svg")}" @@ -375,6 +382,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL results = Set.new sprite_sources([SiteSetting.default_theme_id]).each do |fname| + next if !File.exist?(fname) + svg_file = Nokogiri::XML(File.open(fname)) svg_filename = "#{File.basename(fname, ".svg")}" @@ -475,6 +484,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL # Automatically register icons in sprites added via themes or plugins icons = [] custom_svg_sprites(theme_ids).each do |fname| + next if !File.exist?(fname) + svg_file = Nokogiri::XML(File.open(fname)) svg_file.css('symbol').each do |sym| diff --git a/spec/components/svg_sprite/svg_sprite_spec.rb b/spec/components/svg_sprite/svg_sprite_spec.rb index abb39b1a3f..bce3820441 100644 --- a/spec/components/svg_sprite/svg_sprite_spec.rb +++ b/spec/components/svg_sprite/svg_sprite_spec.rb @@ -165,7 +165,15 @@ describe SvgSprite do before do setup_s3 - stub_request(:get, upload_s3.url).to_return(status: 200, body: "Hello world") + body = <<~XML + + + + + + + XML + stub_request(:get, upload_s3.url).to_return(status: 200, body: body) end it 'includes svg sprites in themes stored in s3' do @@ -174,9 +182,19 @@ describe SvgSprite do theme.save! sprite_files = SvgSprite.custom_svg_sprites([theme.id]).join("|") - expect(sprite_files).to match(/#{upload_s3.sha1}/) expect(sprite_files).not_to match(/amazonaws/) + + SvgSprite.bundle([theme.id]) + expect(SvgSprite.cache.hash.keys).to include("custom_svg_sprites_#{theme.id}") + + external_copy = Discourse.store.download(upload_s3) + File.delete external_copy.try(:path) + + SvgSprite.bundle([theme.id]) + # when a file is missing, ensure that cache entry is cleared + expect(SvgSprite.cache.hash.keys).to_not include("custom_svg_sprites_#{theme.id}") + end end From 513bfc3a6cac80fe5f8806630131cfce22fddb38 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 9 Jun 2021 09:48:43 -0400 Subject: [PATCH 012/403] DEV: bin/ember-cli standalone by default (#13344) --- bin/ember-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ember-cli b/bin/ember-cli index 0fc2496464..53da667c87 100755 --- a/bin/ember-cli +++ b/bin/ember-cli @@ -48,7 +48,7 @@ end system "yarn -s install --cwd #{yarn_dir}" -if (ARGV - ["--unicorn", "-"]).length < 2 +if ARGV.include?("-u") || ARGV.include?("--unicorn") unicorn_pid = spawn(__dir__ + "/unicorn") Thread.new do From f12551afd329a7476e0f5d0eaf782e586fb0dc38 Mon Sep 17 00:00:00 2001 From: Jeff Wong Date: Wed, 9 Jun 2021 04:26:52 -1000 Subject: [PATCH 013/403] PERF: Animate request animation frame (#13337) * PERF: requestanimationframe for better performance on pan events * PERF: temporarily remove items on animate --- .../discourse/app/components/site-header.js | 5 +++++ .../discourse/app/mixins/pan-events.js | 22 ++++++++++++++----- app/assets/stylesheets/mobile/menu-panel.scss | 10 ++++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js index b037a30003..2830bca3d7 100644 --- a/app/assets/javascripts/discourse/app/components/site-header.js +++ b/app/assets/javascripts/discourse/app/components/site-header.js @@ -87,6 +87,7 @@ const SiteHeaderComponent = MountWidget.extend( const menuPanels = document.querySelectorAll(".menu-panel"); const menuOrigin = this._panMenuOrigin; menuPanels.forEach((panel) => { + panel.classList.remove("moving"); if (this._shouldMenuClose(event, menuOrigin)) { this._animateClosing(panel, menuOrigin); } else { @@ -129,6 +130,10 @@ const SiteHeaderComponent = MountWidget.extend( ) { e.originalEvent.preventDefault(); this._isPanning = true; + const panel = document.querySelector(".menu-panel"); + if (panel) { + panel.classList.add("moving"); + } } else { this._isPanning = false; } diff --git a/app/assets/javascripts/discourse/app/mixins/pan-events.js b/app/assets/javascripts/discourse/app/mixins/pan-events.js index b40fd68eab..0134647267 100644 --- a/app/assets/javascripts/discourse/app/mixins/pan-events.js +++ b/app/assets/javascripts/discourse/app/mixins/pan-events.js @@ -10,6 +10,7 @@ export default Mixin.create({ //velocity is pixels per ms _panState: null, + _animationPending: false, didInsertElement() { this._super(...arguments); @@ -71,9 +72,7 @@ export default Mixin.create({ //calculate delta x, y, distance from START location const deltaX = e.clientX - oldState.startLocation.x; const deltaY = e.clientY - oldState.startLocation.y; - const distance = Math.round( - Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)) - ); + const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)); //calculate velocity from previous event center location const eventDeltaX = e.clientX - oldState.center.x; @@ -87,7 +86,7 @@ export default Mixin.create({ return { startLocation: oldState.startLocation, - center: { x: Math.round(e.clientX), y: Math.round(e.clientY) }, + center: { x: e.clientX, y: e.clientY }, velocity, velocityX, velocityY, @@ -102,7 +101,7 @@ export default Mixin.create({ _panStart(e) { const newState = { - center: { x: Math.round(e.clientX), y: Math.round(e.clientY) }, + center: { x: e.clientX, y: e.clientY }, startLocation: { x: e.clientX, y: e.clientY }, velocity: 0, velocityX: 0, @@ -122,6 +121,7 @@ export default Mixin.create({ this._panStart(e); return; } + const previousState = this._panState; const newState = this._calculateNewPanState(previousState, e); if (previousState.start && newState.distance < MINIMUM_SWIPE_DISTANCE) { @@ -137,7 +137,17 @@ export default Mixin.create({ ) { this.panEnd(newState); } else if (e.type === "pointermove" && "panMove" in this) { - this.panMove(newState); + if (this._animationPending) { + return; + } + this._animationPending = true; + window.requestAnimationFrame(() => { + if (!this._animationPending) { + return; + } + this.panMove(newState); + this._animationPending = false; + }); } }, }); diff --git a/app/assets/stylesheets/mobile/menu-panel.scss b/app/assets/stylesheets/mobile/menu-panel.scss index e3351ef37d..1e5f59d1d1 100644 --- a/app/assets/stylesheets/mobile/menu-panel.scss +++ b/app/assets/stylesheets/mobile/menu-panel.scss @@ -27,12 +27,20 @@ } .menu-panel.slide-in { - transform: translate(var(--offset), 0); + transform: translateX(var(--offset)); @media (prefers-reduced-motion: no-preference) { &.animate { transition: transform 0.1s linear; } } + &.moving, + &.animate { + // PERF: only render first 20 items in a list to allow for smooth + // pan events + li:nth-child(n + 20) { + display: none; + } + } } .user-menu .quick-access-panel.quick-access-profile li:not(.show-all) { From 3b6d6c70240533e64a3256c9b90f02820b4649a2 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 9 Jun 2021 15:32:28 +0100 Subject: [PATCH 014/403] DEV: Set DISCOURSE_PORT when spawning unicorn via `ember-cli -u` (#13346) This means that Discourse will use the ember-cli proxy's port number in various places like auth redirects and emails --- bin/ember-cli | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/ember-cli b/bin/ember-cli index 53da667c87..5c1a753d94 100755 --- a/bin/ember-cli +++ b/bin/ember-cli @@ -49,7 +49,8 @@ end system "yarn -s install --cwd #{yarn_dir}" if ARGV.include?("-u") || ARGV.include?("--unicorn") - unicorn_pid = spawn(__dir__ + "/unicorn") + unicorn_env = { "DISCOURSE_PORT" => ENV["DISCOURSE_PORT"] || "4200" } + unicorn_pid = spawn(unicorn_env, __dir__ + "/unicorn") Thread.new do require 'open3' From 77d33ebe21c2994825624b64c367732a23f98537 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 9 Jun 2021 10:58:55 -0400 Subject: [PATCH 015/403] FIX: Lots of plugin tests were using old, non-Ember compat CLI APIs (#13320) --- .../javascripts/discourse/app/models/post.js | 8 +- .../javascripts/discourse/app/widgets/post.js | 12 +- .../javascripts/discourse/ember-cli-build.js | 1 + .../discourse/tests/helpers/widget-test.js | 9 + .../components/slow-mode-info-test.js | 2 +- .../acceptance/details-button-test.js.es6 | 1 + .../lib/details-cooked-test.js.es6 | 1 + .../local-dates-composer-test.js.es6 | 1 + .../lib/date-with-zone-helper-test.js.es6 | 1 + .../lib/local-date-builder-test.js.es6 | 1 + .../controllers/poll-ui-builder.js.es6 | 3 +- .../acceptance/poll-breakdown-test.js.es6 | 15 +- .../poll-builder-disabled-test.js.es6 | 7 +- .../poll-builder-enabled-test.js.es6 | 7 +- .../poll-in-reply-history-test.js.es6 | 4 +- .../acceptance/poll-pie-chart-test.js.es6 | 2 + .../acceptance/poll-quote-test.js.es6 | 2 + .../polls-bar-chart-test-desktop.js.es6 | 2 + .../polls-bar-chart-test-mobile.js.es6 | 2 + .../controllers/poll-ui-builder-test.js.es6 | 352 +++++++++--------- .../display-poll-builder-button.js.es6 | 1 + .../widgets/discourse-poll-option-test.js.es6 | 126 ++++--- ...iscourse-poll-standard-results-test.js.es6 | 157 ++++---- .../widgets/discourse-poll-test.js.es6 | 219 ++++++----- 24 files changed, 514 insertions(+), 422 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 5da002ab51..97642f069f 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -19,8 +19,14 @@ import { resolveShareUrl } from "discourse/helpers/share-url"; import { userPath } from "discourse/lib/url"; const Post = RestModel.extend({ - @discourseComputed("url") + customShare: null, + + @discourseComputed("url", "customShare") shareUrl(url) { + if (this.customShare) { + return this.customShare; + } + const user = User.current(); return resolveShareUrl(url, user); }, diff --git a/app/assets/javascripts/discourse/app/widgets/post.js b/app/assets/javascripts/discourse/app/widgets/post.js index 42aee810a9..f03364e14a 100644 --- a/app/assets/javascripts/discourse/app/widgets/post.js +++ b/app/assets/javascripts/discourse/app/widgets/post.js @@ -701,7 +701,17 @@ createWidget("post-article", { .then((posts) => { this.state.repliesAbove = posts.map((p) => { let result = transformWithCallbacks(p); - result.shareUrl = `${topicUrl}/${p.post_number}`; + + // We don't want to overwrite CPs - we are doing something a bit weird + // here by creating a post object from a transformed post. They aren't + // 100% the same. + delete result.new_user; + delete result.deleted; + delete result.shareUrl; + delete result.firstPost; + delete result.usernameUrl; + + result.customShare = `${topicUrl}/${p.post_number}`; result.asPost = this.store.createRecord("post", result); return result; }); diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 137b9a29af..98d33e1de8 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -35,6 +35,7 @@ module.exports = function (defaults) { app.import(vendorJs + "jquery.ui.widget.js"); app.import(vendorJs + "jquery.fileupload.js"); app.import(vendorJs + "jquery.autoellipsis-1.0.10.js"); + app.import(vendorJs + "show-html.js"); let adminVendor = funnel(vendorJs, { files: ["resumable.js"], diff --git a/app/assets/javascripts/discourse/tests/helpers/widget-test.js b/app/assets/javascripts/discourse/tests/helpers/widget-test.js index ed1514e0d4..93f16c38a3 100644 --- a/app/assets/javascripts/discourse/tests/helpers/widget-test.js +++ b/app/assets/javascripts/discourse/tests/helpers/widget-test.js @@ -1,8 +1,16 @@ import { addPretenderCallback } from "discourse/tests/helpers/qunit-helpers"; import componentTest from "discourse/tests/helpers/component-test"; import { moduleForComponent } from "ember-qunit"; +import { warn } from "@ember/debug"; +import deprecated from "discourse-common/lib/deprecated"; export function moduleForWidget(name, options = {}) { + warn( + "moduleForWidget will not work in the Ember CLI environment. Please upgrade your tests.", + { id: "module-for-widget" } + ); + return; + let fullName = `widget:${name}`; addPretenderCallback(fullName, options.pretend); @@ -17,5 +25,6 @@ export function moduleForWidget(name, options = {}) { } export function widgetTest(name, opts) { + deprecated("Use `componentTest` instead of `widgetTest`"); return componentTest(name, opts); } diff --git a/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js b/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js index 63204bdf2a..fbc2e8df9c 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/slow-mode-info-test.js @@ -12,7 +12,7 @@ discourseModule("Integration | Component | slow-mode-info", function (hooks) { setupRenderingTest(hooks); componentTest("doesn't render if the topic is closed", { - template: "{{slow-mode-info topic=topic}}", + template: hbs`{{slow-mode-info topic=topic}}`, beforeEach() { this.set("topic", { slow_mode_seconds: 3600, closed: true }); diff --git a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 index 25c310dac3..5c475de7c1 100644 --- a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 +++ b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 @@ -6,6 +6,7 @@ import { import I18n from "I18n"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { test } from "qunit"; acceptance("Details Button", function (needs) { needs.user(); diff --git a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 index 1962054ab9..28df6289fe 100644 --- a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 +++ b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 @@ -1,4 +1,5 @@ import PrettyText, { buildOptions } from "pretty-text/pretty-text"; +import { module, test } from "qunit"; module("lib:details-cooked-test"); diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 index bd0b79426c..1ca18d534c 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 @@ -1,4 +1,5 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; acceptance("Local Dates - composer", function (needs) { needs.user(); diff --git a/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 index af43d091f1..71848a1cfd 100644 --- a/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 @@ -1,4 +1,5 @@ import DateWithZoneHelper from "./date-with-zone-helper"; +import { module, test } from "qunit"; const PARIS = "Europe/Paris"; const SYDNEY = "Australia/Sydney"; diff --git a/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 index 74fb2907d6..9764dac471 100644 --- a/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 @@ -1,6 +1,7 @@ import I18n from "I18n"; import LocalDateBuilder from "./local-date-builder"; import sinon from "sinon"; +import { module, test } from "qunit"; const UTC = "Etc/UTC"; const SYDNEY = "Australia/Sydney"; diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 index a067838bb3..04a51d762e 100644 --- a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -98,7 +98,8 @@ export default Controller.extend(ModalFunctionality, { @discourseComputed("pollOptions.@each.value") pollOptionsCount(pollOptions) { - return pollOptions.filter((option) => option.value.length > 0).length; + return (pollOptions || []).filter((option) => option.value.length > 0) + .length; }, @discourseComputed("site.groups") diff --git a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 index 38aba91743..b7320a1e64 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 @@ -1,10 +1,12 @@ import { acceptance, count, + exists, query, - queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Poll breakdown", function (needs) { needs.user(); @@ -65,19 +67,14 @@ acceptance("Poll breakdown", function (needs) { test("Displaying the poll breakdown modal", async function (assert) { await visit("/t/-/topic_with_pie_chart_poll"); - assert.equal( - queryAll(".poll-show-breakdown").text(), - "Show breakdown", + assert.ok( + exists(".poll-show-breakdown"), "shows the breakdown button when poll_groupable_user_fields is non-empty" ); await click(".poll-show-breakdown"); - assert.equal( - query(".poll-breakdown-total-votes").textContent.trim(), - "2 votes", - "display the correct total vote count" - ); + assert.ok(exists(".poll-breakdown-total-votes"), "displays the vote count"); assert.equal( count(".poll-breakdown-chart-container"), diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 index 8969cea164..fa70f1fada 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 @@ -5,6 +5,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { displayPollBuilderButton } from "discourse/plugins/poll/helpers/display-poll-builder-button"; +import { test } from "qunit"; acceptance("Poll Builder - polls are disabled", function (needs) { needs.user(); @@ -20,7 +21,7 @@ acceptance("Poll Builder - polls are disabled", function (needs) { await displayPollBuilderButton(); assert.ok( - !exists(".select-kit-row[title='Build Poll']"), + !exists(".select-kit-row[data-value='showPollBuilder']"), "it hides the builder button" ); }); @@ -31,7 +32,7 @@ acceptance("Poll Builder - polls are disabled", function (needs) { await displayPollBuilderButton(); assert.ok( - !exists(".select-kit-row[title='Build Poll']"), + !exists(".select-kit-row[data-value='showPollBuilder']"), "it hides the builder button" ); }); @@ -42,7 +43,7 @@ acceptance("Poll Builder - polls are disabled", function (needs) { await displayPollBuilderButton(); assert.ok( - !exists(".select-kit-row[title='Build Poll']"), + !exists(".select-kit-row[data-value='showPollBuilder']"), "it hides the builder button" ); }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 index f04178b953..3321e07c6d 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 @@ -5,6 +5,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { displayPollBuilderButton } from "discourse/plugins/poll/helpers/display-poll-builder-button"; +import { test } from "qunit"; acceptance("Poll Builder - polls are enabled", function (needs) { needs.user(); @@ -20,7 +21,7 @@ acceptance("Poll Builder - polls are enabled", function (needs) { await displayPollBuilderButton(); assert.ok( - exists(".select-kit-row[title='Build Poll']"), + exists(".select-kit-row[data-value='showPollBuilder']"), "it shows the builder button" ); }); @@ -31,7 +32,7 @@ acceptance("Poll Builder - polls are enabled", function (needs) { await displayPollBuilderButton(); assert.ok( - !exists(".select-kit-row[title='Build Poll']"), + !exists(".select-kit-row[data-value='showPollBuilder]"), "it hides the builder button" ); }); @@ -42,7 +43,7 @@ acceptance("Poll Builder - polls are enabled", function (needs) { await displayPollBuilderButton(); assert.ok( - exists(".select-kit-row[title='Build Poll']"), + exists(".select-kit-row[data-value='showPollBuilder']"), "it shows the builder button" ); }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 index 1959caab88..12c507d1d5 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 @@ -1,5 +1,7 @@ -import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Poll in a post reply history", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 index 994df7fdb4..4d720b2009 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 @@ -3,6 +3,8 @@ import { query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Rendering polls with pie charts", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 index d88ee700e6..3b5bb2ddac 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 @@ -1,5 +1,7 @@ import { acceptance, count } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Poll quote", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 index e22df294bd..12147b270e 100644 --- a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 @@ -1,5 +1,7 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Rendering polls with bar charts - desktop", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 index 1920011950..654241d26c 100644 --- a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 @@ -1,5 +1,7 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { test } from "qunit"; +import { visit } from "@ember/test-helpers"; acceptance("Rendering polls with bar charts - mobile", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 index 8dc002a9f1..6f4a420f5c 100644 --- a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 +++ b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 @@ -1,214 +1,220 @@ -import { controllerModule } from "discourse/tests/helpers/qunit-helpers"; +import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; import { MULTIPLE_POLL_TYPE, NUMBER_POLL_TYPE, REGULAR_POLL_TYPE, } from "discourse/plugins/poll/controllers/poll-ui-builder"; +import { test } from "qunit"; +import { settled } from "@ember/test-helpers"; -controllerModule("controller:poll-ui-builder", { - setupController(controller) { - controller.set("toolbarEvent", { getText: () => "" }); - controller.onShow(); - }, - needs: ["controller:modal"], -}); +function setupController(ctx) { + let controller = ctx.getController("poll-ui-builder"); + controller.set("toolbarEvent", { getText: () => "" }); + controller.onShow(); + return controller; +} -test("isMultiple", function (assert) { - const controller = this.subject(); +discourseModule("Unit | Controller | poll-ui-builder", function () { + test("isMultiple", function (assert) { + const controller = setupController(this); - controller.setProperties({ - pollType: MULTIPLE_POLL_TYPE, - pollOptions: [{ value: "a" }], + controller.setProperties({ + pollType: MULTIPLE_POLL_TYPE, + pollOptions: [{ value: "a" }], + }); + + assert.equal(controller.isMultiple, true, "it should be true"); + + controller.setProperties({ + pollType: "random", + pollOptions: [{ value: "b" }], + }); + + assert.equal(controller.isMultiple, false, "it should be false"); }); - assert.equal(controller.isMultiple, true, "it should be true"); + test("isNumber", function (assert) { + const controller = setupController(this); - controller.setProperties({ - pollType: "random", - pollOptions: [{ value: "b" }], + controller.set("pollType", REGULAR_POLL_TYPE); + + assert.equal(controller.isNumber, false, "it should be false"); + + controller.set("pollType", NUMBER_POLL_TYPE); + + assert.equal(controller.isNumber, true, "it should be true"); }); - assert.equal(controller.isMultiple, false, "it should be false"); -}); + test("pollOptionsCount", function (assert) { + const controller = setupController(this); -test("isNumber", function (assert) { - const controller = this.subject(); + controller.set("pollOptions", [{ value: "1" }, { value: "2" }]); - controller.set("pollType", REGULAR_POLL_TYPE); + assert.equal(controller.pollOptionsCount, 2, "it should equal 2"); - assert.equal(controller.isNumber, false, "it should be false"); + controller.set("pollOptions", []); - controller.set("pollType", NUMBER_POLL_TYPE); - - assert.equal(controller.isNumber, true, "it should be true"); -}); - -test("pollOptionsCount", function (assert) { - const controller = this.subject(); - - controller.set("pollOptions", [{ value: "1" }, { value: "2" }]); - - assert.equal(controller.pollOptionsCount, 2, "it should equal 2"); - - controller.set("pollOptions", []); - - assert.equal(controller.pollOptionsCount, 0, "it should equal 0"); -}); - -test("disableInsert", function (assert) { - const controller = this.subject(); - controller.siteSettings.poll_maximum_options = 20; - - assert.equal(controller.disableInsert, true, "it should be true"); - - controller.set("pollOptions", [{ value: "a" }, { value: "b" }]); - - assert.equal(controller.disableInsert, false, "it should be false"); - - controller.set("pollType", NUMBER_POLL_TYPE); - - assert.equal(controller.disableInsert, false, "it should be false"); - - controller.setProperties({ - pollType: REGULAR_POLL_TYPE, - pollOptions: [{ value: "a" }, { value: "b" }, { value: "c" }], + assert.equal(controller.pollOptionsCount, 0, "it should equal 0"); }); - assert.equal(controller.disableInsert, false, "it should be false"); + test("disableInsert", function (assert) { + const controller = setupController(this); + controller.siteSettings.poll_maximum_options = 20; - controller.setProperties({ - pollType: REGULAR_POLL_TYPE, - pollOptions: [], + assert.equal(controller.disableInsert, true, "it should be true"); + + controller.set("pollOptions", [{ value: "a" }, { value: "b" }]); + + assert.equal(controller.disableInsert, false, "it should be false"); + + controller.set("pollType", NUMBER_POLL_TYPE); + + assert.equal(controller.disableInsert, false, "it should be false"); + + controller.setProperties({ + pollType: REGULAR_POLL_TYPE, + pollOptions: [{ value: "a" }, { value: "b" }, { value: "c" }], + }); + + assert.equal(controller.disableInsert, false, "it should be false"); + + controller.setProperties({ + pollType: REGULAR_POLL_TYPE, + pollOptions: [], + }); + + assert.equal(controller.disableInsert, true, "it should be true"); + + controller.setProperties({ + pollType: REGULAR_POLL_TYPE, + pollOptions: [{ value: "w" }], + }); + + assert.equal(controller.disableInsert, false, "it should be false"); }); - assert.equal(controller.disableInsert, true, "it should be true"); + test("number pollOutput", async function (assert) { + this.siteSettings.poll_maximum_options = 20; + const controller = setupController(this); - controller.setProperties({ - pollType: REGULAR_POLL_TYPE, - pollOptions: [{ value: "w" }], + controller.setProperties({ + pollType: NUMBER_POLL_TYPE, + pollMin: 1, + }); + await settled(); + + assert.equal( + controller.pollOutput, + "[poll type=number results=always min=1 max=20 step=1]\n[/poll]\n", + "it should return the right output" + ); + controller.set("pollStep", 2); + await settled(); + + assert.equal( + controller.pollOutput, + "[poll type=number results=always min=1 max=20 step=2]\n[/poll]\n", + "it should return the right output" + ); + + controller.set("publicPoll", true); + + assert.equal( + controller.pollOutput, + "[poll type=number results=always min=1 max=20 step=2 public=true]\n[/poll]\n", + "it should return the right output" + ); + + controller.set("pollStep", 0); + + assert.equal( + controller.pollOutput, + "[poll type=number results=always min=1 max=20 step=1 public=true]\n[/poll]\n", + "it should return the right output" + ); }); - assert.equal(controller.disableInsert, false, "it should be false"); -}); + test("regular pollOutput", function (assert) { + const controller = setupController(this); + controller.siteSettings.poll_maximum_options = 20; -test("number pollOutput", function (assert) { - const controller = this.subject(); - controller.siteSettings.poll_maximum_options = 20; + controller.setProperties({ + pollOptions: [{ value: "1" }, { value: "2" }], + pollType: REGULAR_POLL_TYPE, + }); - controller.setProperties({ - pollType: NUMBER_POLL_TYPE, - pollMin: 1, + assert.equal( + controller.pollOutput, + "[poll type=regular results=always chartType=bar]\n* 1\n* 2\n[/poll]\n", + "it should return the right output" + ); + + controller.set("publicPoll", "true"); + + assert.equal( + controller.pollOutput, + "[poll type=regular results=always public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", + "it should return the right output" + ); + + controller.set("pollGroups", "test"); + + assert.equal( + controller.get("pollOutput"), + "[poll type=regular results=always public=true chartType=bar groups=test]\n* 1\n* 2\n[/poll]\n", + "it should return the right output" + ); }); - assert.equal( - controller.pollOutput, - "[poll type=number results=always min=1 max=20 step=1]\n[/poll]\n", - "it should return the right output" - ); + test("multiple pollOutput", function (assert) { + const controller = setupController(this); + controller.siteSettings.poll_maximum_options = 20; - controller.set("pollStep", 2); + controller.setProperties({ + pollType: MULTIPLE_POLL_TYPE, + pollMin: 1, + pollOptions: [{ value: "1" }, { value: "2" }], + }); - assert.equal( - controller.pollOutput, - "[poll type=number results=always min=1 max=20 step=2]\n[/poll]\n", - "it should return the right output" - ); + assert.equal( + controller.pollOutput, + "[poll type=multiple results=always min=1 max=2 chartType=bar]\n* 1\n* 2\n[/poll]\n", + "it should return the right output" + ); - controller.set("publicPoll", true); + controller.set("publicPoll", "true"); - assert.equal( - controller.pollOutput, - "[poll type=number results=always min=1 max=20 step=2 public=true]\n[/poll]\n", - "it should return the right output" - ); - - controller.set("pollStep", 0); - - assert.equal( - controller.pollOutput, - "[poll type=number results=always min=1 max=20 step=1 public=true]\n[/poll]\n", - "it should return the right output" - ); -}); - -test("regular pollOutput", function (assert) { - const controller = this.subject(); - controller.siteSettings.poll_maximum_options = 20; - - controller.setProperties({ - pollOptions: [{ value: "1" }, { value: "2" }], - pollType: REGULAR_POLL_TYPE, + assert.equal( + controller.pollOutput, + "[poll type=multiple results=always min=1 max=2 public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", + "it should return the right output" + ); }); - assert.equal( - controller.pollOutput, - "[poll type=regular results=always chartType=bar]\n* 1\n* 2\n[/poll]\n", - "it should return the right output" - ); + test("staff_only option is not present for non-staff", async function (assert) { + const controller = setupController(this); + controller.currentUser = { staff: false }; + controller.notifyPropertyChange("pollResults"); - controller.set("publicPoll", "true"); - - assert.equal( - controller.pollOutput, - "[poll type=regular results=always public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", - "it should return the right output" - ); - - controller.set("pollGroups", "test"); - - assert.equal( - controller.get("pollOutput"), - "[poll type=regular results=always public=true chartType=bar groups=test]\n* 1\n* 2\n[/poll]\n", - "it should return the right output" - ); -}); - -test("multiple pollOutput", function (assert) { - const controller = this.subject(); - controller.siteSettings.poll_maximum_options = 20; - - controller.setProperties({ - pollType: MULTIPLE_POLL_TYPE, - pollMin: 1, - pollOptions: [{ value: "1" }, { value: "2" }], + assert.ok( + controller.pollResults.filterBy("value", "staff_only").length === 0, + "staff_only is not present" + ); }); - assert.equal( - controller.pollOutput, - "[poll type=multiple results=always min=1 max=2 chartType=bar]\n* 1\n* 2\n[/poll]\n", - "it should return the right output" - ); + test("poll result is always by default", function (assert) { + const controller = setupController(this); + assert.equal(controller.pollResult, "always"); + }); - controller.set("publicPoll", "true"); + test("staff_only option is present for staff", async function (assert) { + const controller = setupController(this); + controller.currentUser = { staff: true }; + controller.notifyPropertyChange("pollResults"); - assert.equal( - controller.pollOutput, - "[poll type=multiple results=always min=1 max=2 public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", - "it should return the right output" - ); -}); - -test("staff_only option is not present for non-staff", function (assert) { - const controller = this.subject(); - controller.currentUser = { staff: false }; - - assert.ok( - controller.pollResults.filterBy("value", "staff_only").length === 0, - "staff_only is not present" - ); -}); - -test("poll result is always by default", function (assert) { - const controller = this.subject(); - assert.equal(controller.pollResult, "always"); -}); - -test("staff_only option is present for staff", function (assert) { - const controller = this.subject(); - controller.currentUser = { staff: true }; - - assert.ok( - controller.pollResults.filterBy("value", "staff_only").length === 1, - "staff_only is present" - ); + assert.ok( + controller.pollResults.filterBy("value", "staff_only").length === 1, + "staff_only is present" + ); + }); }); diff --git a/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 b/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 index 8d9318cba7..e4d3ce0feb 100644 --- a/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 +++ b/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 @@ -1,4 +1,5 @@ import selectKit from "discourse/tests/helpers/select-kit-helper"; +import { click, visit } from "@ember/test-helpers"; export async function displayPollBuilderButton() { await visit("/"); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 index fb57cc75a8..ee2ca3593b 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 @@ -1,71 +1,81 @@ +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; import { - moduleForWidget, - widgetTest, -} from "discourse/tests/helpers/widget-test"; -import { queryAll } from "discourse/tests/helpers/qunit-helpers"; + discourseModule, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; -moduleForWidget("discourse-poll-option"); - -const template = `{{mount-widget +discourseModule( + "Integration | Component | Widget | discourse-poll-option", + function (hooks) { + setupRenderingTest(hooks); + const template = `{{mount-widget widget="discourse-poll-option" args=(hash option=option isMultiple=isMultiple vote=vote)}}`; -widgetTest("single, not selected", { - template, + componentTest("single, not selected", { + template, - beforeEach() { - this.set("option", { id: "opt-id" }); - this.set("vote", []); - }, + beforeEach() { + this.set("option", { id: "opt-id" }); + this.set("vote", []); + }, - test(assert) { - assert.ok(queryAll("li .d-icon-far-circle:nth-of-type(1)").length === 1); - }, -}); - -widgetTest("single, selected", { - template, - - beforeEach() { - this.set("option", { id: "opt-id" }); - this.set("vote", ["opt-id"]); - }, - - test(assert) { - assert.ok(queryAll("li .d-icon-circle:nth-of-type(1)").length === 1); - }, -}); - -widgetTest("multi, not selected", { - template, - - beforeEach() { - this.setProperties({ - option: { id: "opt-id" }, - isMultiple: true, - vote: [], + test(assert) { + assert.ok( + queryAll("li .d-icon-far-circle:nth-of-type(1)").length === 1 + ); + }, }); - }, - test(assert) { - assert.ok(queryAll("li .d-icon-far-square:nth-of-type(1)").length === 1); - }, -}); + componentTest("single, selected", { + template, -widgetTest("multi, selected", { - template, + beforeEach() { + this.set("option", { id: "opt-id" }); + this.set("vote", ["opt-id"]); + }, - beforeEach() { - this.setProperties({ - option: { id: "opt-id" }, - isMultiple: true, - vote: ["opt-id"], + test(assert) { + assert.ok(queryAll("li .d-icon-circle:nth-of-type(1)").length === 1); + }, }); - }, - test(assert) { - assert.ok( - queryAll("li .d-icon-far-check-square:nth-of-type(1)").length === 1 - ); - }, -}); + componentTest("multi, not selected", { + template, + + beforeEach() { + this.setProperties({ + option: { id: "opt-id" }, + isMultiple: true, + vote: [], + }); + }, + + test(assert) { + assert.ok( + queryAll("li .d-icon-far-square:nth-of-type(1)").length === 1 + ); + }, + }); + + componentTest("multi, selected", { + template, + + beforeEach() { + this.setProperties({ + option: { id: "opt-id" }, + isMultiple: true, + vote: ["opt-id"], + }); + }, + + test(assert) { + assert.ok( + queryAll("li .d-icon-far-check-square:nth-of-type(1)").length === 1 + ); + }, + }); + } +); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 index 3edd1c3e59..9a72956aaa 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 @@ -1,90 +1,97 @@ -import { - moduleForWidget, - widgetTest, -} from "discourse/tests/helpers/widget-test"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; import EmberObject from "@ember/object"; -import { queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + discourseModule, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; -moduleForWidget("discourse-poll-standard-results"); +discourseModule( + "Integration | Component | Widget | discourse-poll-standard-results", + function (hooks) { + setupRenderingTest(hooks); -const template = `{{mount-widget + const template = `{{mount-widget widget="discourse-poll-standard-results" args=(hash poll=poll isMultiple=isMultiple)}}`; -widgetTest("options in descending order", { - template, + componentTest("options in descending order", { + template, - beforeEach() { - this.set( - "poll", - EmberObject.create({ - options: [{ votes: 5 }, { votes: 4 }], - voters: 9, - }) - ); - }, + beforeEach() { + this.set( + "poll", + EmberObject.create({ + options: [{ votes: 5 }, { votes: 4 }], + voters: 9, + }) + ); + }, - test(assert) { - assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); - assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); - }, -}); + test(assert) { + assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); + assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); + }, + }); -widgetTest("options in ascending order", { - template, + componentTest("options in ascending order", { + template, - beforeEach() { - this.set( - "poll", - EmberObject.create({ - options: [{ votes: 4 }, { votes: 5 }], - voters: 9, - }) - ); - }, + beforeEach() { + this.set( + "poll", + EmberObject.create({ + options: [{ votes: 4 }, { votes: 5 }], + voters: 9, + }) + ); + }, - test(assert) { - assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); - assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); - }, -}); + test(assert) { + assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); + assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); + }, + }); -widgetTest("multiple options in descending order", { - template, + componentTest("multiple options in descending order", { + template, - beforeEach() { - this.set("isMultiple", true); - this.set( - "poll", - EmberObject.create({ - type: "multiple", - options: [ - { votes: 5, html: "a" }, - { votes: 2, html: "b" }, - { votes: 4, html: "c" }, - { votes: 1, html: "b" }, - { votes: 1, html: "a" }, - ], - voters: 12, - }) - ); - }, + beforeEach() { + this.set("isMultiple", true); + this.set( + "poll", + EmberObject.create({ + type: "multiple", + options: [ + { votes: 5, html: "a" }, + { votes: 2, html: "b" }, + { votes: 4, html: "c" }, + { votes: 1, html: "b" }, + { votes: 1, html: "a" }, + ], + voters: 12, + }) + ); + }, - test(assert) { - let percentages = queryAll(".option .percentage"); - assert.equal(percentages[0].innerText, "41%"); - assert.equal(percentages[1].innerText, "33%"); - assert.equal(percentages[2].innerText, "16%"); - assert.equal(percentages[3].innerText, "8%"); + test(assert) { + let percentages = queryAll(".option .percentage"); + assert.equal(percentages[0].innerText, "41%"); + assert.equal(percentages[1].innerText, "33%"); + assert.equal(percentages[2].innerText, "16%"); + assert.equal(percentages[3].innerText, "8%"); - assert.equal( - queryAll(".option")[3].querySelectorAll("span")[1].innerText, - "a" - ); - assert.equal(percentages[4].innerText, "8%"); - assert.equal( - queryAll(".option")[4].querySelectorAll("span")[1].innerText, - "b" - ); - }, -}); + assert.equal( + queryAll(".option")[3].querySelectorAll("span")[1].innerText, + "a" + ); + assert.equal(percentages[4].innerText, "8%"); + assert.equal( + queryAll(".option")[4].querySelectorAll("span")[1].innerText, + "b" + ); + }, + }); + } +); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 index fc1efc0e67..58622b6d0d 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 @@ -1,16 +1,23 @@ import { - moduleForWidget, - widgetTest, -} from "discourse/tests/helpers/widget-test"; + discourseModule, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import componentTest, { + setupRenderingTest, +} from "discourse/tests/helpers/component-test"; import EmberObject from "@ember/object"; import I18n from "I18n"; -import { count, exists, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import pretender from "discourse/tests/helpers/create-pretender"; let requests = 0; -moduleForWidget("discourse-poll", { - pretend(server) { - server.put("/polls/vote", () => { +discourseModule( + "Integration | Component | Widget | discourse-poll", + function (hooks) { + setupRenderingTest(hooks); + + pretender.put("/polls/vote", () => { ++requests; return [ 200, @@ -22,8 +29,16 @@ moduleForWidget("discourse-poll", { status: "open", results: "always", options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 1 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + { + id: "1f972d1df351de3ce35a787c89faad29", + html: "yes", + votes: 1, + }, + { + id: "d7ebc3a9beea2e680815a1e4f57d6db6", + html: "no", + votes: 0, + }, ], voters: 1, chart_type: "bar", @@ -33,7 +48,7 @@ moduleForWidget("discourse-poll", { ]; }); - server.put("/polls/vote", () => { + pretender.put("/polls/vote", () => { ++requests; return [ 200, @@ -45,8 +60,16 @@ moduleForWidget("discourse-poll", { status: "open", results: "always", options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 1 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + { + id: "1f972d1df351de3ce35a787c89faad29", + html: "yes", + votes: 1, + }, + { + id: "d7ebc3a9beea2e680815a1e4f57d6db6", + html: "no", + votes: 0, + }, ], voters: 1, chart_type: "bar", @@ -56,10 +79,8 @@ moduleForWidget("discourse-poll", { }, ]; }); - }, -}); -const template = `{{mount-widget + const template = `{{mount-widget widget="discourse-poll" args=(hash id=id post=post @@ -67,91 +88,97 @@ const template = `{{mount-widget vote=vote groupableUserFields=groupableUserFields)}}`; -widgetTest("can vote", { - template, + componentTest("can vote", { + template, - beforeEach() { - this.setProperties({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - }), - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "open", - results: "always", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 0, - chart_type: "bar", - }), - vote: [], - groupableUserFields: [], + beforeEach() { + this.setProperties({ + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + }), + poll: EmberObject.create({ + name: "poll", + type: "regular", + status: "open", + results: "always", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 0, + chart_type: "bar", + }), + vote: [], + groupableUserFields: [], + }); + }, + + async test(assert) { + requests = 0; + + await click( + "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']" + ); + assert.equal(requests, 1); + assert.equal(count(".chosen"), 1); + assert.equal(queryAll(".chosen").text(), "100%yes"); + assert.equal(queryAll(".toggle-results").text(), "Show vote"); + + await click(".toggle-results"); + assert.equal( + queryAll("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']") + .length, + 1 + ); + assert.equal(queryAll(".toggle-results").text(), "Show results"); + }, }); - }, - async test(assert) { - requests = 0; + componentTest("cannot vote if not member of the right group", { + template, - await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']"); - assert.equal(requests, 1); - assert.equal(count(".chosen"), 1); - assert.equal(queryAll(".chosen").text(), "100%yes"); - assert.equal(queryAll(".toggle-results").text(), "Show vote"); + beforeEach() { + this.setProperties({ + post: EmberObject.create({ + id: 42, + topic: { + archived: false, + }, + }), + poll: EmberObject.create({ + name: "poll", + type: "regular", + status: "open", + results: "always", + options: [ + { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, + { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, + ], + voters: 0, + chart_type: "bar", + groups: "foo", + }), + vote: [], + groupableUserFields: [], + }); + }, - await click(".toggle-results"); - assert.equal( - queryAll("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']") - .length, - 1 - ); - assert.equal(queryAll(".toggle-results").text(), "Show results"); - }, -}); + async test(assert) { + requests = 0; -widgetTest("cannot vote if not member of the right group", { - template, - - beforeEach() { - this.setProperties({ - post: EmberObject.create({ - id: 42, - topic: { - archived: false, - }, - }), - poll: EmberObject.create({ - name: "poll", - type: "regular", - status: "open", - results: "always", - options: [ - { id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 }, - { id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 }, - ], - voters: 0, - chart_type: "bar", - groups: "foo", - }), - vote: [], - groupableUserFields: [], + await click( + "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']" + ); + assert.equal( + queryAll(".poll-container .alert").text(), + I18n.t("poll.results.groups.title", { groups: "foo" }) + ); + assert.equal(requests, 0); + assert.ok(!exists(".chosen")); + }, }); - }, - - async test(assert) { - requests = 0; - - await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']"); - assert.equal( - queryAll(".poll-container .alert").text(), - I18n.t("poll.results.groups.title", { groups: "foo" }) - ); - assert.equal(requests, 0); - assert.ok(!exists(".chosen")); - }, -}); + } +); From 8fee32d277608f34c3c272e55fb44c5f81284e14 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 9 Jun 2021 11:06:56 -0400 Subject: [PATCH 016/403] A11Y: Don't mark multiple form labels as applying to the same element (#13289) Co-authored-by: Jordan Vidrine --- .../discourse/app/templates/modal/create-account.hbs | 10 +++++----- app/assets/stylesheets/common/base/login.scss | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs index 2e206501c5..f042b36622 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-account.hbs @@ -35,7 +35,7 @@ {{/if}} {{input-tip validation=emailValidation id="account-email-validation"}} - + {{i18n "user.email.instructions"}}
@@ -53,7 +53,7 @@ {{/if}} {{input-tip validation=usernameValidation id="username-validation"}} - + {{i18n "user.username.instructions"}}
@@ -74,7 +74,7 @@ {{/if}} {{input-tip validation=nameValidation}} - + {{nameInstructions}} {{/if}}
@@ -101,7 +101,7 @@ {{input-tip validation=passwordValidation}} - + {{passwordInstructions}}
{{d-icon "exclamation-triangle"}} {{i18n "login.caps_lock_warning"}}
@@ -118,7 +118,7 @@
{{input value=inviteCode class=(value-entered inviteCode) id="inviteCode"}} - + {{i18n "user.invite_code.instructions"}}
{{/if}} diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index 7eb3537010..de4a6a82dd 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -140,7 +140,7 @@ border: 1px solid var(--tertiary); box-shadow: 0 0 0 2px rgba(var(--tertiary-rgb), 0.25); } - label.more-info { + span.more-info { color: var(--primary-medium); min-height: 1.4em; // prevents height increase due to tips } From fc61a7c0ded02665502f879e1581109789b61048 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 9 Jun 2021 11:26:26 -0400 Subject: [PATCH 017/403] FIX: `count` is not defined lint error (#13347) --- plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 index 58622b6d0d..e85a560a92 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 @@ -1,4 +1,5 @@ import { + count, discourseModule, exists, queryAll, From 54c5e577f381268e34d203b4904f93bfd1afe25f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Jun 2021 01:00:53 +0200 Subject: [PATCH 018/403] Build(deps): Bump rubocop from 1.16.0 to 1.16.1 (#13352) Bumps [rubocop](https://github.com/rubocop/rubocop) from 1.16.0 to 1.16.1. - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.16.0...v1.16.1) --- updated-dependencies: - dependency-name: rubocop dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8ae9ce763..d0a1e966bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -379,7 +379,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.16.0) + rubocop (1.16.1) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) From 0ae8640650013e34e1d4a3b0e1b24eadce39edad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Jun 2021 01:01:09 +0200 Subject: [PATCH 019/403] Build(deps): Bump rubocop-rspec from 2.3.0 to 2.4.0 (#13351) Bumps [rubocop-rspec](https://github.com/rubocop/rubocop-rspec) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/rubocop/rubocop-rspec/releases) - [Changelog](https://github.com/rubocop/rubocop-rspec/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop-rspec/compare/v2.3.0...v2.4.0) --- updated-dependencies: - dependency-name: rubocop-rspec dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d0a1e966bb..428edc7297 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -393,7 +393,7 @@ GEM rubocop-discourse (2.4.2) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.3.0) + rubocop-rspec (2.4.0) rubocop (~> 1.0) rubocop-ast (>= 1.1.0) ruby-prof (1.4.3) From 2a4a20ad67e90055d66cf3680da1a03afbe19728 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 10 Jun 2021 08:50:17 +0800 Subject: [PATCH 020/403] PERF: Avoid running a pointless PG query when theme has no variables. (#13342) When `Theme#all_theme_variables` returns an empty array, we were running a pointless query in `StyleSheet::Manager#uploads_digest`. `SELECT "sha1" FROM "theme_fields" INNER JOIN "uploads" ON "uploads"."id" = "theme_fields"."upload_id" WHERE 1=0` --- lib/stylesheet/manager.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index f40e21200b..9c719efd93 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -434,7 +434,18 @@ class Stylesheet::Manager end def uploads_digest - Digest::SHA1.hexdigest(ThemeField.joins(:upload).where(id: theme&.all_theme_variables).pluck(:sha1).join(",")) + sha1s = + if (theme_ids = theme&.all_theme_variables).present? + ThemeField + .joins(:upload) + .where(id: theme_ids) + .pluck(:sha1) + .join(",") + else + "" + end + + Digest::SHA1.hexdigest(sha1s) end def color_scheme_digest From 6f764790548ebe28bb4d2679210425eccf3ee45a Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 9 Jun 2021 20:53:10 -0400 Subject: [PATCH 021/403] FEATURE: Add upgrade-insecure-requests to CSP when force_https is enabled (#13348) If force_https is enabled all resource (including markdown preview and so on) will be accessed using HTTPS If for any reason you attempt to link to non HTTPS reachable content content may appear broken --- lib/content_security_policy/default.rb | 1 + spec/lib/content_security_policy_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/lib/content_security_policy/default.rb b/lib/content_security_policy/default.rb index f3382e3f77..daebd99df5 100644 --- a/lib/content_security_policy/default.rb +++ b/lib/content_security_policy/default.rb @@ -8,6 +8,7 @@ class ContentSecurityPolicy def initialize(base_url:) @base_url = base_url @directives = {}.tap do |directives| + directives[:upgrade_insecure_requests] = [] if SiteSetting.force_https directives[:base_uri] = [:none] directives[:object_src] = [:none] directives[:script_src] = script_src diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb index c203c850eb..32a4db46d3 100644 --- a/spec/lib/content_security_policy_spec.rb +++ b/spec/lib/content_security_policy_spec.rb @@ -32,6 +32,18 @@ describe ContentSecurityPolicy do end end + describe 'upgrade-insecure-requests' do + it 'is not included when force_https is off' do + SiteSetting.force_https = false + expect(parse(policy)['upgrade-insecure-requests']).to eq(nil) + end + + it 'is included when force_https is on' do + SiteSetting.force_https = true + expect(parse(policy)['upgrade-insecure-requests']).to eq([]) + end + end + describe 'worker-src' do it 'has expected values' do worker_srcs = parse(policy)['worker-src'] From 3fefdb19733c9915475d6fc715666c396b123631 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 10 Jun 2021 10:59:30 +1000 Subject: [PATCH 022/403] A11Y: Adjust heading rules on topic lists (#13353) Previously due to "rowheader" role we would read out topic titles twice. This adjusts it so we apply the heading role only to the topic link. In turn this makes navigation through topic lists more accurate (h) only lands you on topic links. It also reduces the amount of duplicate reading NVDA does. Before: Topic title link new topic link support link b481 link 19h link 2 button... After: Topic title link This reduces noise, up and down once you land on a topic link can give you more context. --- .../javascripts/discourse/app/components/topic-list-item.js | 2 -- app/assets/javascripts/discourse/app/helpers/topic-link.js | 2 ++ .../discourse/app/templates/list/topic-list-item.hbr | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/topic-list-item.js b/app/assets/javascripts/discourse/app/components/topic-list-item.js index 8b05472b1d..b02993354e 100644 --- a/app/assets/javascripts/discourse/app/components/topic-list-item.js +++ b/app/assets/javascripts/discourse/app/components/topic-list-item.js @@ -40,8 +40,6 @@ export default Component.extend({ classNameBindings: [":topic-list-item", "unboundClassNames", "topic.visited"], attributeBindings: ["data-topic-id", "role", "ariaLevel:aria-level"], "data-topic-id": alias("topic.id"), - role: "heading", - ariaLevel: "2", didReceiveAttrs() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/app/helpers/topic-link.js b/app/assets/javascripts/discourse/app/helpers/topic-link.js index 0bc232a906..6dbcb1c18c 100644 --- a/app/assets/javascripts/discourse/app/helpers/topic-link.js +++ b/app/assets/javascripts/discourse/app/helpers/topic-link.js @@ -16,6 +16,8 @@ registerUnbound("topic-link", (topic, args) => { return htmlSafe( `${title}` ); diff --git a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr index 605e192211..03ea153bb0 100644 --- a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr @@ -13,7 +13,7 @@ This causes the topic-post-badge to be considered the same word as "text" at the end of the link, preventing it from line wrapping onto its own line. --}} - + {{~raw-plugin-outlet name="topic-list-before-link"}} {{~raw-plugin-outlet name="topic-list-before-status"}} From e9dc88a7b672dc9938b8f5f59058f501302870c2 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 10 Jun 2021 15:28:50 +1000 Subject: [PATCH 023/403] FIX: Link up reply to post correctly when emailing group (#13339) When replying to a user_private_message email originating from a group PM that does _not_ have a reply key (e.g. when replying directly to the group's SMTP address), we were mistakenly linking the new post created from the reply to the OP and the user who created the topic, based on the first IncomingEmail message ID in the topic, rather than using the correct reply to user and post number that the user actually replied to. We now use the In-Reply-To header to look up the corresponding EmailLog record when the user who replied was sent a user_private_message email, and use the post from that as the reply_to_user/post. This also removes superfluous filtering of incoming_email records. After already filtering by message_id and then addressed_to_user (which only returns incoming emails where the to, from, or cc address includes any of the user's emails), we were filtering again but in the ruby code for the exact same conditions. After removing this all existing tests still pass. --- app/models/email_log.rb | 11 +++ lib/email/receiver.rb | 34 +++---- spec/components/email/receiver_spec.rb | 89 +++++++++++++++++++ .../email_to_group_email_username_1.eml | 11 +++ .../email_to_group_email_username_2.eml | 14 +++ ...oup_email_username_2_as_unknown_sender.eml | 14 +++ 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 spec/fixtures/emails/email_to_group_email_username_1.eml create mode 100644 spec/fixtures/emails/email_to_group_email_username_2.eml create mode 100644 spec/fixtures/emails/email_to_group_email_username_2_as_unknown_sender.eml diff --git a/app/models/email_log.rb b/app/models/email_log.rb index 6cfbca497a..33c8ea12e7 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -22,6 +22,17 @@ class EmailLog < ActiveRecord::Base scope :bounced, -> { where(bounced: true) } + scope :addressed_to_user, ->(user) do + where(<<~SQL, user_id: user.id) + EXISTS( + SELECT 1 + FROM user_emails + WHERE user_emails.user_id = :user_id AND + email_logs.to_address = user_emails.email + ) + SQL + end + after_create do # Update last_emailed_at if the user_id is present and email was sent User.where(id: user_id).update_all("last_emailed_at = CURRENT_TIMESTAMP") if user_id.present? diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 5ec587ed37..da699c08bc 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -109,7 +109,7 @@ module Email # server (e.g. a message_id generated by Gmail) and does not need to # be updated, because message_ids from the IMAP server are not guaranteed # to be unique. - return unless discourse_generated_message_id? + return unless discourse_generated_message_id?(@message_id) incoming_email.update( imap_uid_validity: @opts[:imap_uid_validity], @@ -745,23 +745,27 @@ module Email def create_group_post(group, user, body, elided) message_ids = Email::Receiver.extract_reply_message_ids(@mail, max_message_id_count: 5) - post_ids = [] + # incoming emails with matching message ids, and then cross references + # these with any email addresses for the user vs to/from/cc of the + # incoming emails. in effect, any incoming email record for these + # message ids where the user is involved in any way will be returned incoming_emails = IncomingEmail.where(message_id: message_ids) if !group.allow_unknown_sender_topic_replies incoming_emails = incoming_emails.addressed_to_user(user) end + post_ids = incoming_emails.pluck(:post_id) || [] - incoming_emails = incoming_emails.pluck(:post_id, :from_address, :to_addresses, :cc_addresses) - - incoming_emails.each do |post_id, from_address, to_addresses, cc_addresses| - if group.allow_unknown_sender_topic_replies - post_ids << post_id - else - post_ids << post_id if contains_email_address_of_user?(from_address, user) || - contains_email_address_of_user?(to_addresses, user) || - contains_email_address_of_user?(cc_addresses, user) - end + # if the user is directly replying to an email send to them from discourse, + # there will be a corresponding EmailLog record, so we can use that as the + # reply post if it exists + if discourse_generated_message_id?(mail.in_reply_to) + post_id_from_email_log = EmailLog.where(message_id: mail.in_reply_to) + .addressed_to_user(user) + .order(created_at: :desc) + .limit(1) + .pluck(:post_id).last + post_ids << post_id_from_email_log end if post_ids.any? && post = Post.where(id: post_ids).order(:created_at).last @@ -989,9 +993,9 @@ module Email @host ||= Email::Sender.host_for(Discourse.base_url) end - def discourse_generated_message_id? - !!(@message_id =~ message_id_post_id_regexp) || - !!(@message_id =~ message_id_topic_id_regexp) + def discourse_generated_message_id?(message_id) + !!(message_id =~ message_id_post_id_regexp) || + !!(message_id =~ message_id_topic_id_regexp) end def message_id_post_id_regexp diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 636cc73cad..11a87b4e63 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -985,6 +985,95 @@ describe Email::Receiver do end end + context "emailing a group by email_username and following reply flow" do + let!(:topic) do + group.update!(email_username: "team@somesmtpaddress.com") + process(:email_to_group_email_username_1) + Topic.last + end + fab!(:user_in_group) do + u = Fabricate(:user) + Fabricate(:group_user, user: u, group: group) + u + end + + before do + NotificationEmailer.enable + Jobs.run_immediately! + end + + def reply_as_group_user + group_post = PostCreator.create( + user_in_group, + raw: "Thanks for your request. Please try to restart.", + topic_id: topic.id + ) + email_log = EmailLog.last + [email_log, group_post] + end + + it "the inbound processed email creates an incoming email and topic record correctly, and adds the group to the topic" do + incoming = IncomingEmail.find_by(topic: topic) + user = User.find_by_email("two@foo.com") + expect(topic.topic_allowed_users.first.user_id).to eq(user.id) + expect(topic.topic_allowed_groups.first.group_id).to eq(group.id) + expect(incoming.to_addresses).to eq("team@somesmtpaddress.com") + expect(incoming.from_address).to eq("two@foo.com") + expect(incoming.message_id).to eq("u4w8c9r4y984yh98r3h69873@foo.bar.mail") + end + + it "creates an EmailLog when someone from the group replies, and does not create an IncomingEmail record for the reply" do + email_log, group_post = reply_as_group_user + expect(email_log.message_id).to eq("topic/#{topic.id}/#{group_post.id}@test.localhost") + expect(email_log.to_address).to eq("two@foo.com") + expect(email_log.email_type).to eq("user_private_message") + expect(email_log.post_id).to eq(group_post.id) + expect(IncomingEmail.exists?(post_id: group_post.id)).to eq(false) + end + + it "processes a reply from the OP user to the group SMTP username, linking the reply_to_post_number correctly by + matching in_reply_to to the email log" do + email_log, group_post = reply_as_group_user + + reply_email = email(:email_to_group_email_username_2) + reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) + expect do + Email::Receiver.new(reply_email).process! + end.to change { Topic.count }.by(0).and change { Post.count }.by(1) + + reply_post = Post.last + expect(reply_post.reply_to_user).to eq(user_in_group) + expect(reply_post.reply_to_post_number).to eq(group_post.post_number) + end + + it "processes the reply from the user as a brand new topic if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is disabled" do + email_log, group_post = reply_as_group_user + + reply_email = email(:email_to_group_email_username_2_as_unknown_sender) + reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) + expect do + Email::Receiver.new(reply_email).process! + end.to change { Topic.count }.by(1).and change { Post.count }.by(1) + + reply_post = Post.last + expect(reply_post.topic_id).not_to eq(topic.id) + end + + it "processes the reply from the user as a reply if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is enabled" do + group.update!(allow_unknown_sender_topic_replies: true) + email_log, group_post = reply_as_group_user + + reply_email = email(:email_to_group_email_username_2_as_unknown_sender) + reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) + expect do + Email::Receiver.new(reply_email).process! + end.to change { Topic.count }.by(0).and change { Post.count }.by(1) + + reply_post = Post.last + expect(reply_post.topic_id).to eq(topic.id) + end + end + context "when message sent to a group has no key and find_related_post_with_key is enabled" do let!(:topic) do process(:email_reply_1) diff --git a/spec/fixtures/emails/email_to_group_email_username_1.eml b/spec/fixtures/emails/email_to_group_email_username_1.eml new file mode 100644 index 0000000000..5a7a0aee6b --- /dev/null +++ b/spec/fixtures/emails/email_to_group_email_username_1.eml @@ -0,0 +1,11 @@ +Return-Path: +From: Two +To: team@somesmtpaddress.com +Subject: Full email group username flow +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: +Mime-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +This is the first email. diff --git a/spec/fixtures/emails/email_to_group_email_username_2.eml b/spec/fixtures/emails/email_to_group_email_username_2.eml new file mode 100644 index 0000000000..df9bd7c202 --- /dev/null +++ b/spec/fixtures/emails/email_to_group_email_username_2.eml @@ -0,0 +1,14 @@ +Return-Path: +From: Two +To: team@somesmtpaddress.com +Subject: Full email group username flow +Date: Saturday, 16 Jan 2016 00:12:43 +0100 +Message-ID: <348ct38794nyt9338dsfsd@foo.bar.mail> +In-Reply-To: +References: + +Mime-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +Hey thanks for the suggestion, it didn't work. diff --git a/spec/fixtures/emails/email_to_group_email_username_2_as_unknown_sender.eml b/spec/fixtures/emails/email_to_group_email_username_2_as_unknown_sender.eml new file mode 100644 index 0000000000..ddb591ddd1 --- /dev/null +++ b/spec/fixtures/emails/email_to_group_email_username_2_as_unknown_sender.eml @@ -0,0 +1,14 @@ +Return-Path: +From: Mysterio +To: team@somesmtpaddress.com +Subject: Full email group username flow +Date: Saturday, 16 Jan 2016 00:12:43 +0100 +Message-ID: <348ct38794nyt9338dsfsd@foo.bar.mail> +In-Reply-To: +References: + +Mime-Version: 1.0 +Content-Type: text/plain +Content-Transfer-Encoding: 7bit + +No I don't think that did it. From a5df69369797144d1c8a4f3c124c2c5e4ef9af77 Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 10 Jun 2021 09:36:41 -0400 Subject: [PATCH 024/403] FIX: can't bootstrap with ember-cli when login_required is enabled (#13350) --- app/controllers/bootstrap_controller.rb | 2 ++ spec/requests/bootstrap_controller_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/app/controllers/bootstrap_controller.rb b/app/controllers/bootstrap_controller.rb index 9a52f741a2..0921e0aab8 100644 --- a/app/controllers/bootstrap_controller.rb +++ b/app/controllers/bootstrap_controller.rb @@ -3,6 +3,8 @@ class BootstrapController < ApplicationController include ApplicationHelper + skip_before_action :redirect_to_login_if_required + # This endpoint allows us to produce the data required to start up Discourse via JSON API, # so that you don't have to scrape the HTML for `data-*` payloads def index diff --git a/spec/requests/bootstrap_controller_spec.rb b/spec/requests/bootstrap_controller_spec.rb index d18c3557ac..182c307191 100644 --- a/spec/requests/bootstrap_controller_spec.rb +++ b/spec/requests/bootstrap_controller_spec.rb @@ -74,4 +74,11 @@ describe BootstrapController do bootstrap = json['bootstrap'] expect(bootstrap['extra_locales']).to be_present end + + it "returns data when login_required is enabled" do + SiteSetting.login_required = true + get "/bootstrap.json" + expect(response.status).to eq(200) + expect(response.parsed_body).to be_present + end end From fbfd54a9413fb66b5ef6e251a7ad7f89b2568874 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 10 Jun 2021 14:22:47 +0800 Subject: [PATCH 025/403] DEV: Increase number of mini-profiler traces in development. Assets are served via the server in development and the default 20 traces is too little for the number of assets we load in development. --- config/initializers/006-mini_profiler.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/initializers/006-mini_profiler.rb b/config/initializers/006-mini_profiler.rb index 0f808362b4..30e348b65f 100644 --- a/config/initializers/006-mini_profiler.rb +++ b/config/initializers/006-mini_profiler.rb @@ -76,6 +76,8 @@ if defined?(Rack::MiniProfiler) && defined?(Rack::MiniProfiler::Config) Rack::MiniProfiler.config.backtrace_includes = [/^\/?(app|config|lib|test|plugins)/] + Rack::MiniProfiler.config.max_traces_to_show = 100 if Rails.env.development? + Rack::MiniProfiler.counter_method(Redis::Client, :call) { 'redis' } # Rack::MiniProfiler.counter_method(ActiveRecord::QueryMethods, 'build_arel') # Rack::MiniProfiler.counter_method(Array, 'uniq') From fa0277509564a8d663fcd7878709ab4130b07f2f Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Fri, 11 Jun 2021 03:55:50 +0300 Subject: [PATCH 026/403] PERF: Perform user filtering in SQL (#13358) Notifying about a tag change sometimes resulted in loading a large number of users in memory just to perform an exclusion. This commit prefers to do inclusion (i.e. instead of exclude users X, do include users in groups Y) and does it in SQL to avoid fetching unnecessary data that is later discarded. --- app/jobs/regular/notify_tag_change.rb | 17 +++++++---------- app/services/post_alerter.rb | 10 +++++++--- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/jobs/regular/notify_tag_change.rb b/app/jobs/regular/notify_tag_change.rb index 4b513dd53b..e503287810 100644 --- a/app/jobs/regular/notify_tag_change.rb +++ b/app/jobs/regular/notify_tag_change.rb @@ -7,23 +7,20 @@ module Jobs if post&.topic&.visible? post_alerter = PostAlerter.new - post_alerter.notify_post_users(post, excluded_users(args), include_topic_watchers: !post.topic.private_message?, include_category_watchers: false) + post_alerter.notify_post_users(post, User.where(id: args[:notified_user_ids]), + group_ids: all_tags_in_hidden_groups?(args) ? tag_group_ids(args) : nil, + include_topic_watchers: !post.topic.private_message?, + include_category_watchers: false + ) post_alerter.notify_first_post_watchers(post, post_alerter.tag_watchers(post.topic)) end end private - def excluded_users(args) - if !args[:diff_tags] || !all_tags_in_hidden_groups?(args) - return User.where(id: args[:notified_user_ids]) - end - group_users_join = DB.sql_fragment("LEFT JOIN group_users ON group_users.user_id = users.id AND group_users.group_id IN (:group_ids)", group_ids: tag_group_ids(args)) - condition = DB.sql_fragment("group_users.id IS NULL OR users.id IN (:notified_user_ids)", notified_user_ids: args[:notified_user_ids]) - User.joins(group_users_join).where(condition) - end - def all_tags_in_hidden_groups?(args) + return false if args[:diff_tags].blank? + Tag .where(name: args[:diff_tags]) .joins(tag_groups: :tag_group_permissions) diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 5051070eda..297a97257d 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -651,13 +651,13 @@ class PostAlerter emails_to_skip_send end - def notify_post_users(post, notified, include_topic_watchers: true, include_category_watchers: true, include_tag_watchers: true, new_record: false) + def notify_post_users(post, notified, group_ids: nil, include_topic_watchers: true, include_category_watchers: true, include_tag_watchers: true, new_record: false) return unless post.topic warn_if_not_sidekiq condition = +<<~SQL - id IN ( + users.id IN ( SELECT id FROM users WHERE false /*topic*/ /*category*/ @@ -711,12 +711,16 @@ class PostAlerter tag_ids: tag_ids ) + if group_ids.present? + notify = notify.joins(:group_users).where("group_users.group_id IN (?)", group_ids) + end + if post.topic.private_message? notify = notify.where(staged: false).staff end exclude_user_ids = notified.map(&:id) - notify = notify.where("id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present? + notify = notify.where("users.id NOT IN (?)", exclude_user_ids) if exclude_user_ids.present? DiscourseEvent.trigger(:before_create_notifications_for_users, notify, post) From ef906fa1dad0b2a628f297f3be38d041bc8d8ca1 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 11 Jun 2021 04:00:41 +0300 Subject: [PATCH 027/403] FIX: Do not reload post if raw is present (#13335) Editing a post that was just posted caused it to be reloaded and made a request to the server. This had an additional side effect where the model instances used by post stream and composer would be different and changes did not propagate correctly. --- .../discourse/app/models/composer.js | 84 +++++++++++-------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js index 5b8f6c6815..3b6c891148 100644 --- a/app/assets/javascripts/discourse/app/models/composer.js +++ b/app/assets/javascripts/discourse/app/models/composer.js @@ -722,12 +722,14 @@ const Composer = RestModel.extend({ if (!opts) { opts = {}; } + this.set("loading", true); - const replyBlank = isEmpty(this.reply); - - const composer = this; - if (!replyBlank && (opts.reply || isEdit(opts.action)) && this.replyDirty) { + if ( + !isEmpty(this.reply) && + (opts.reply || isEdit(opts.action)) && + this.replyDirty + ) { return promise; } @@ -770,6 +772,15 @@ const Composer = RestModel.extend({ if (!this.topic) { this.set("topic", opts.post.topic); } + } else if (opts.postId) { + promise = promise.then(() => + this.store.find("post", opts.postId).then((post) => { + this.set("post", post); + if (post) { + this.set("topic", post.topic); + } + }) + ); } else { this.set("post", null); } @@ -794,19 +805,8 @@ const Composer = RestModel.extend({ (c) => c.topic_template ); - if (opts.postId) { - promise = promise.then(() => - this.store.find("post", opts.postId).then((post) => { - composer.set("post", post); - if (post) { - composer.set("topic", post.topic); - } - }) - ); - } - // If we are editing a post, load it. - if (isEdit(opts.action) && opts.post) { + if (isEdit(opts.action) && this.post) { const topicProps = this.serialize(_edit_topic_serializer); topicProps.loading = true; @@ -816,30 +816,40 @@ const Composer = RestModel.extend({ } this.setProperties(topicProps); - promise = promise.then(() => - this.store.find("post", opts.post.id).then((post) => { - composer.setProperties({ - reply: post.raw, - originalText: post.raw, - post: post, - }); + promise = promise.then(() => { + let rawPromise = Promise.resolve(); - promise = Promise.resolve(); - // edge case ... make a post then edit right away - // store does not have topic for the post - if (composer.topic && composer.topic.id === post.topic_id) { - // nothing to do ... we have the right topic - } else { - promise = this.store.find("topic", post.topic_id).then((topic) => { + if (!this.post.raw) { + rawPromise = this.store.find("post", opts.post.id).then((post) => { + this.setProperties({ + post, + reply: post.raw, + originalText: post.raw, + }); + }); + } else { + this.setProperties({ + reply: this.post.raw, + originalText: this.post.raw, + }); + } + + // edge case ... make a post then edit right away + // store does not have topic for the post + if (this.topic && this.topic.id === this.post.topic_id) { + // nothing to do ... we have the right topic + } else { + rawPromise = this.store + .find("topic", this.post.topic_id) + .then((topic) => { this.set("topic", topic); }); - } + } - return promise.then(() => { - composer.appEvents.trigger("composer:reply-reloaded", composer); - }); - }) - ); + return rawPromise.then(() => { + this.appEvents.trigger("composer:reply-reloaded", this); + }); + }); } else if (opts.action === REPLY && opts.quote) { this.setProperties({ reply: opts.quote, @@ -862,7 +872,7 @@ const Composer = RestModel.extend({ if (!isEdit(opts.action) || !opts.post) { promise = promise.then(() => - composer.appEvents.trigger("composer:reply-reloaded", composer) + this.appEvents.trigger("composer:reply-reloaded", this) ); } From 052c841550ad5dd42fdf979e0c4ad57eb960e3be Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 10 Jun 2021 21:44:30 -0400 Subject: [PATCH 028/403] FIX: Clicking on a URL with a different url prefix did not work (#13349) Before this fix if your forum was set up with a subfolder and you clicked on a link to a different subfolder it would not work. For example: subfolder: /cool link is: /about-us Previously it would try to resolve /about-us as /cool/about-us. With this fix it redirects to /about-us correctly. --- .../discourse-common/addon/lib/get-url.js | 7 ++++++ .../discourse/app/lib/click-track.js | 15 +++++++----- .../javascripts/discourse/app/lib/url.js | 22 ++++++++++-------- .../javascripts/discourse/tests/index.html | 8 +++++++ .../tests/unit/lib/click-track-test.js | 23 +++++++++++++++++++ 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/discourse-common/addon/lib/get-url.js b/app/assets/javascripts/discourse-common/addon/lib/get-url.js index 9eddfa6fee..ed6e455a60 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/get-url.js +++ b/app/assets/javascripts/discourse-common/addon/lib/get-url.js @@ -1,6 +1,13 @@ let cdn, baseUrl, baseUri, baseUriMatcher; let S3BaseUrl, S3CDN; +export function getBaseURI() { + if (baseUri === undefined) { + setPrefix($('meta[name="discourse-base-uri"]').attr("content") || ""); + } + return baseUri || "/"; +} + export default function getURL(url) { if (baseUri === undefined) { setPrefix($('meta[name="discourse-base-uri"]').attr("content") || ""); diff --git a/app/assets/javascripts/discourse/app/lib/click-track.js b/app/assets/javascripts/discourse/app/lib/click-track.js index 28744e2911..e930f2e489 100644 --- a/app/assets/javascripts/discourse/app/lib/click-track.js +++ b/app/assets/javascripts/discourse/app/lib/click-track.js @@ -4,7 +4,7 @@ import { Promise } from "rsvp"; import User from "discourse/models/user"; import { ajax } from "discourse/lib/ajax"; import bootbox from "bootbox"; -import getURL from "discourse-common/lib/get-url"; +import getURL, { getBaseURI } from "discourse-common/lib/get-url"; import { isTesting } from "discourse-common/config/environment"; import { later } from "@ember/runloop"; import { selectedText } from "discourse/lib/utilities"; @@ -71,7 +71,7 @@ export function openLinkInNewTab(link) { } export default { - trackClick(e, siteSettings) { + trackClick(e, siteSettings, { returnPromise = false } = {}) { // right clicks are not tracked if (e.which === 3) { return true; @@ -162,17 +162,20 @@ export default { openLinkInNewTab($link[0]); } else { trackPromise.finally(() => { - if (DiscourseURL.isInternal(href)) { + if ( + DiscourseURL.isInternal(href) && + href.indexOf(getBaseURI()) === 0 + ) { DiscourseURL.routeTo(href); } else { - DiscourseURL.redirectTo(href); + DiscourseURL.redirectAbsolute(href); } }); } - return false; + return returnPromise ? trackPromise : false; } - return true; + return returnPromise ? trackPromise : true; }, }; diff --git a/app/assets/javascripts/discourse/app/lib/url.js b/app/assets/javascripts/discourse/app/lib/url.js index 67732d5144..c412b6b391 100644 --- a/app/assets/javascripts/discourse/app/lib/url.js +++ b/app/assets/javascripts/discourse/app/lib/url.js @@ -9,6 +9,7 @@ import { defaultHomepage } from "discourse/lib/utilities"; import { isEmpty } from "@ember/utils"; import offsetCalculator from "discourse/lib/offset-calculator"; import { setOwner } from "@ember/application"; +import { isTesting } from "discourse-common/config/environment"; const rewrites = []; export const TOPIC_URL_REGEXP = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/; @@ -226,7 +227,7 @@ const DiscourseURL = EmberObject.extend({ } if (Session.currentProp("requiresRefresh")) { - return this.redirectTo(getURL(path)); + return this.redirectTo(path); } const pathname = path.replace(/(https?\:)?\/\/[^\/]+/, ""); @@ -308,17 +309,20 @@ const DiscourseURL = EmberObject.extend({ rewrites.push({ regexp, replacement, opts: opts || {} }); }, - redirectTo(url) { - window.location = getURL(url); + redirectAbsolute(url) { + // Redirects will kill a test runner + if (isTesting()) { + return true; + } + window.location = url; return true; }, - /** - * Determines whether a URL is internal or not - * - * @method isInternal - * @param {String} url - **/ + redirectTo(url) { + return this.redirectAbsolute(getURL(url)); + }, + + // Determines whether a URL is internal or not isInternal(url) { if (url && url.length) { if (url.indexOf("//") === 0) { diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html index 2a70fa65af..4660e3ef84 100644 --- a/app/assets/javascripts/discourse/tests/index.html +++ b/app/assets/javascripts/discourse/tests/index.html @@ -26,6 +26,14 @@ -o-transition: none !important; transition: none !important; } + #qunit-fixture { + position: absolute; + top: -10000px; + left: -10000px; + width: 1000px; + height: 1000px; + } + diff --git a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js index 14b0b66d81..3327e9451b 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js @@ -6,6 +6,7 @@ import User from "discourse/models/user"; import { later } from "@ember/runloop"; import pretender from "discourse/tests/helpers/create-pretender"; import sinon from "sinon"; +import { setPrefix } from "discourse-common/lib/get-url"; const track = ClickTrack.trackClick; @@ -47,6 +48,8 @@ module("Unit | Utility | click-track", function (hooks) { foo bar + prefix link + diff prefix link ` ); @@ -85,6 +88,26 @@ module("Unit | Utility | click-track", function (hooks) { ); }); + test("routes to internal urls", async function (assert) { + setPrefix("/forum"); + pretender.post("/clicks/track", () => [200, {}, ""]); + await track(generateClickEventOn(".prefix-url"), null, { + returnPromise: true, + }); + assert.ok(DiscourseURL.routeTo.calledWith("/forum/thing")); + }); + + test("redirects to internal urls with a different prefix", async function (assert) { + setPrefix("/forum"); + sinon.stub(DiscourseURL, "redirectAbsolute"); + + pretender.post("/clicks/track", () => [200, {}, ""]); + await track(generateClickEventOn(".diff-prefix-url"), null, { + returnPromise: true, + }); + assert.ok(DiscourseURL.redirectAbsolute.calledWith("/thing")); + }); + skip("tracks external URLs", async function (assert) { assert.expect(2); From 4681c670c0f8645459e007f881e49a5d5276eaf3 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 11 Jun 2021 12:43:23 +0800 Subject: [PATCH 029/403] DEV: Remove test that is no longer providing value. --- .../javascripts/discourse/tests/acceptance/topic-test.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index 52338fc035..b3661ee4cb 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -422,14 +422,6 @@ acceptance("Topic featured links", function (needs) { exists(".title-wrapper .remove-featured-link"), "link to remove featured link" ); - - // TODO: decide if we want to test this, test is flaky so it - // was commented out. - // If not fixed by May 2021, delete this code block - // - //await click(".title-wrapper .remove-featured-link"); - //await click(".title-wrapper .submit-edit"); - //assert.ok(!exists(".title-wrapper .topic-featured-link"), "link is gone"); }); }); From cd6ab7bdd78d1709f55d9fd0938d59c4660e1767 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Fri, 11 Jun 2021 10:37:34 +0530 Subject: [PATCH 030/403] UX: improve user delete error message & return correct post count. (#13282) Post count was incorrect on admin page causing confusion when admins attempted to delete users. --- app/controllers/admin/users_controller.rb | 2 +- app/services/user_destroyer.rb | 2 +- config/locales/server.en.yml | 1 + spec/requests/admin/users_controller_spec.rb | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index a880acfd65..15c70a1a58 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -420,7 +420,7 @@ class Admin::UsersController < Admin::AdminController rescue UserDestroyer::PostsExistError render json: { deleted: false, - message: "User #{user.username} has #{user.post_count} posts, so they can't be deleted." + message: I18n.t("user.cannot_delete_has_posts", username: user.username, post_count: user.posts.joins(:topic).count), }, status: 403 end end diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb index 5af6a4dc57..24980ce96d 100644 --- a/app/services/user_destroyer.rb +++ b/app/services/user_destroyer.rb @@ -15,7 +15,7 @@ class UserDestroyer # Returns a frozen instance of the User if the delete succeeded. def destroy(user, opts = {}) raise Discourse::InvalidParameters.new('user is nil') unless user && user.is_a?(User) - raise PostsExistError if !opts[:delete_posts] && user.posts.count != 0 + raise PostsExistError if !opts[:delete_posts] && user.posts.joins(:topic).count != 0 @guardian.ensure_can_delete_user!(user) # default to using a transaction diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 07c405f7ab..56f93b813f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2594,6 +2594,7 @@ en: email_in_spam_header: "User's first email was flagged as spam" already_silenced: "User was already silenced by %{staff} %{time_ago}." already_suspended: "User was already suspended by %{staff} %{time_ago}." + cannot_delete_has_posts: "User %{username} has %{post_count} posts(s), either public posts or personal messages, so they can't be deleted." reviewables_reminder: submitted: diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb index 1fec5579b3..8aa4924196 100644 --- a/spec/requests/admin/users_controller_spec.rb +++ b/spec/requests/admin/users_controller_spec.rb @@ -626,10 +626,16 @@ RSpec.describe Admin::UsersController do let!(:post) { Fabricate(:post, topic: topic, user: delete_me) } it "returns an api response that the user can't be deleted because it has posts" do + post_count = delete_me.posts.joins(:topic).count + delete_me_topic = Fabricate(:topic) + Fabricate(:post, topic: delete_me_topic, user: delete_me) + PostDestroyer.new(admin, delete_me_topic.first_post, context: "Deleted by admin").destroy + delete "/admin/users/#{delete_me.id}.json" expect(response.status).to eq(403) json = response.parsed_body expect(json['deleted']).to eq(false) + expect(json['message']).to eq(I18n.t("user.cannot_delete_has_posts", username: delete_me.username, post_count: post_count)) end it "doesn't return an error if delete_posts == true" do From 178b294a629cb7fa6970c45be07c71d5305910a9 Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Fri, 11 Jun 2021 13:51:27 +0400 Subject: [PATCH 031/403] FIX: flaky javascript tests with fake timers (#13235) The problem was happening in component integration tests on the rendering stage, sometimes the rendering would never finish. Using time moments in the future when faking time solves the problem. Unfortunately, I don't know why exactly it helps. It was just a lucky guess after some hours I spent trying to figure out what's going on. But I've done a lot of testings, so looks like it really works. I'll be monitoring builds for some time after merging this anyway. Unit tests seem to work alright with moments in the past. And we don't fake time in acceptance tests at the moment but I guess they would very likely be flaky with time moments from the past since they also do rendering. I'm actually thinking of moving all fake time moments to the future (including moments in unit tests) to decrease the chances of flakiness. But I don't want to do everything in one PR, because I can accidentally introduce new flakiness. A pretty easy way of picking time moments in the future for tests is to use the 2100 year. It has the same calendar as 2021. If a day is Monday in 2021 it's Monday in 2100 too. --- .../discourse/tests/helpers/qunit-helpers.js | 9 ++ .../integration/components/bookmark-test.js | 50 ++++---- .../future-date-input-selector-test.js | 11 +- .../discourse/tests/unit/lib/bookmark-test.js | 5 +- .../tests/unit/lib/time-utils-test.js | 110 +++++++++--------- 5 files changed, 97 insertions(+), 88 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 86ec6c07c0..6c7ae49050 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -76,6 +76,15 @@ export function fakeTime(timeString, timezone = null, advanceTime = false) { }); } +export function withFrozenTime(timeString, timezone, callback) { + const clock = fakeTime(timeString, timezone, false); + try { + callback(); + } finally { + clock.restore(); + } +} + let _pretenderCallbacks = {}; export function resetSite(siteSettings, extras) { diff --git a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js index b7ac8761a1..ba6d71d343 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js @@ -6,13 +6,6 @@ import { fakeTime, query, } from "discourse/tests/helpers/qunit-helpers"; -import sinon from "sinon"; - -let clock = null; - -function mockMomentTz(dateString, timezone) { - clock = fakeTime(dateString, timezone, true); -} discourseModule("Integration | Component | bookmark", function (hooks) { setupRenderingTest(hooks); @@ -32,18 +25,17 @@ discourseModule("Integration | Component | bookmark", function (hooks) { }); hooks.afterEach(function () { - if (clock) { - clock.restore(); + if (this.clock) { + this.clock.restore(); } - sinon.restore(); }); componentTest("show later this week option if today is < Thursday", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-10T08:00:00", this.currentUser._timezone); + const monday = "2100-06-07T08:00:00"; + this.clock = fakeTime(monday, this.currentUser._timezone, true); }, test(assert) { @@ -55,10 +47,10 @@ discourseModule("Integration | Component | bookmark", function (hooks) { "does not show later this week option if today is >= Thursday", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-13T08:00:00", this.currentUser._timezone); + const thursday = "2100-06-10T08:00:00"; + this.clock = fakeTime(thursday, this.currentUser._timezone, true); }, test(assert) { @@ -72,10 +64,13 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("later today does not show if later today is tomorrow", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-11T22:00:00", this.currentUser._timezone); + this.clock = fakeTime( + "2100-12-11T22:00:00", + this.currentUser._timezone, + true + ); }, test(assert) { @@ -88,10 +83,13 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("later today shows if it is after 5pm but before 6pm", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-11T14:30:00", this.currentUser._timezone); + this.clock = fakeTime( + "2100-12-11T14:30:00", + this.currentUser._timezone, + true + ); }, test(assert) { @@ -101,10 +99,13 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("later today does not show if it is after 5pm", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-11T17:00:00", this.currentUser._timezone); + this.clock = fakeTime( + "2100-12-11T17:00:00", + this.currentUser._timezone, + true + ); }, test(assert) { @@ -117,10 +118,13 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("later today does show if it is before the end of the day", { template, - skip: true, beforeEach() { - mockMomentTz("2019-12-11T13:00:00", this.currentUser._timezone); + this.clock = fakeTime( + "2100-12-11T13:00:00", + this.currentUser._timezone, + true + ); }, test(assert) { @@ -130,7 +134,6 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("prefills the custom reminder type date and time", { template, - skip: true, beforeEach() { let name = "test"; @@ -147,7 +150,6 @@ discourseModule("Integration | Component | bookmark", function (hooks) { componentTest("defaults to 08:00 for custom time", { template, - skip: true, async test(assert) { await click("#tap_tile_custom"); diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-selector-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-selector-test.js index 0bc0e7ce6e..344463e930 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-selector-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/future-date-input-selector-test.js @@ -17,7 +17,6 @@ discourseModule( hooks.beforeEach(function () { this.set("subject", selectKit()); - this.clock = fakeTime("2021-05-03T08:00:00", "UTC", true); // Monday }); hooks.afterEach(function () { @@ -25,9 +24,13 @@ discourseModule( }); componentTest("shows default options", { - skip: true, template: hbs`{{future-date-input-selector}}`, + beforeEach() { + const monday = fakeTime("2100-06-07T08:00:00", "UTC", true); + this.clock = monday; + }, + async test(assert) { await this.subject.expand(); @@ -48,11 +51,11 @@ discourseModule( }); componentTest("doesn't show 'Next Week' on Sundays", { - skip: true, template: hbs`{{future-date-input-selector}}`, beforeEach() { - this.clock = fakeTime("2021-05-02T08:00:00", "UTC", true); // Sunday + const sunday = fakeTime("2100-06-13T08:00:00", "UTC", true); + this.clock = sunday; }, async test(assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/bookmark-test.js b/app/assets/javascripts/discourse/tests/unit/lib/bookmark-test.js index 79dcb4a6fe..68c83e398a 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/bookmark-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/bookmark-test.js @@ -1,15 +1,14 @@ import { module, test } from "qunit"; import { fakeTime } from "discourse/tests/helpers/qunit-helpers"; import { formattedReminderTime } from "discourse/lib/bookmark"; -import sinon from "sinon"; module("Unit | Utility | bookmark", function (hooks) { hooks.beforeEach(function () { - fakeTime("2020-04-11 08:00:00", "Australia/Brisbane"); + this.clock = fakeTime("2020-04-11 08:00:00", "Australia/Brisbane"); }); hooks.afterEach(function () { - sinon.restore(); + this.clock.restore(); }); test("formattedReminderTime works when the reminder time is tomorrow", function (assert) { diff --git a/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js b/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js index ce174c0f5c..6cfca1c043 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js @@ -1,6 +1,6 @@ import { discourseModule, - fakeTime, + withFrozenTime, } from "discourse/tests/helpers/qunit-helpers"; import { @@ -15,33 +15,29 @@ import { test } from "qunit"; const timezone = "Australia/Brisbane"; -function mockMomentTz(dateString) { - fakeTime(dateString, timezone); -} - discourseModule("Unit | lib | timeUtils", function () { test("nextWeek gets next week correctly", function (assert) { - mockMomentTz("2019-12-11T08:00:00"); - - assert.equal(nextWeek(timezone).format("YYYY-MM-DD"), "2019-12-18"); + withFrozenTime("2019-12-11T08:00:00", timezone, () => { + assert.equal(nextWeek(timezone).format("YYYY-MM-DD"), "2019-12-18"); + }); }); test("nextMonth gets next month correctly", function (assert) { - mockMomentTz("2019-12-11T08:00:00"); - - assert.equal(nextMonth(timezone).format("YYYY-MM-DD"), "2020-01-11"); + withFrozenTime("2019-12-11T08:00:00", timezone, () => { + assert.equal(nextMonth(timezone).format("YYYY-MM-DD"), "2020-01-11"); + }); }); test("laterThisWeek gets 2 days from now", function (assert) { - mockMomentTz("2019-12-10T08:00:00"); - - assert.equal(laterThisWeek(timezone).format("YYYY-MM-DD"), "2019-12-12"); + withFrozenTime("2019-12-10T08:00:00", timezone, () => { + assert.equal(laterThisWeek(timezone).format("YYYY-MM-DD"), "2019-12-12"); + }); }); test("tomorrow gets tomorrow correctly", function (assert) { - mockMomentTz("2019-12-11T08:00:00"); - - assert.equal(tomorrow(timezone).format("YYYY-MM-DD"), "2019-12-12"); + withFrozenTime("2019-12-11T08:00:00", timezone, () => { + assert.equal(tomorrow(timezone).format("YYYY-MM-DD"), "2019-12-12"); + }); }); test("startOfDay changes the time of the provided date to 8:00am correctly", function (assert) { @@ -54,54 +50,54 @@ discourseModule("Unit | lib | timeUtils", function () { }); test("laterToday gets 3 hours from now and if before half-past, it rounds down", function (assert) { - mockMomentTz("2019-12-11T08:13:00"); - - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 11:00:00" - ); + withFrozenTime("2019-12-11T08:13:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 11:00:00" + ); + }); }); test("laterToday gets 3 hours from now and if after half-past, it rounds up to the next hour", function (assert) { - mockMomentTz("2019-12-11T08:43:00"); - - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 12:00:00" - ); + withFrozenTime("2019-12-11T08:43:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 12:00:00" + ); + }); }); test("laterToday is capped to 6pm. later today at 3pm = 6pm, 3:30pm = 6pm, 4pm = 6pm, 4:59pm = 6pm", function (assert) { - mockMomentTz("2019-12-11T15:00:00"); + withFrozenTime("2019-12-11T15:00:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 18:00:00", + "3pm should max to 6pm" + ); + }); - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 18:00:00", - "3pm should max to 6pm" - ); + withFrozenTime("2019-12-11T15:31:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 18:00:00", + "3:30pm should max to 6pm" + ); + }); - mockMomentTz("2019-12-11T15:31:00"); + withFrozenTime("2019-12-11T16:00:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 18:00:00", + "4pm should max to 6pm" + ); + }); - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 18:00:00", - "3:30pm should max to 6pm" - ); - - mockMomentTz("2019-12-11T16:00:00"); - - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 18:00:00", - "4pm should max to 6pm" - ); - - mockMomentTz("2019-12-11T16:59:00"); - - assert.equal( - laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), - "2019-12-11 18:00:00", - "4:59pm should max to 6pm" - ); + withFrozenTime("2019-12-11T16:59:00", timezone, () => { + assert.equal( + laterToday(timezone).format("YYYY-MM-DD HH:mm:ss"), + "2019-12-11 18:00:00", + "4:59pm should max to 6pm" + ); + }); }); }); From c44650eec5a489af395d26875e042be49147c086 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 14 Jun 2021 15:12:57 +0100 Subject: [PATCH 032/403] FIX: Do not render user-avatar-flair element when user has no flair (#13369) Rendering an empty flair element with the css `background-image: url();` causes the browser to attempt an image request against the current document URL. Making duplicate requests for the document URL can cause some unusual race conditions, especially related to cookies. If this user-avatar-flair element was present on the site homepage (e.g. if categories+latest is the homepage), then it can prevent the signup flow from working correctly. This commit updates the user-avatar-flair component to be a transparent wrapper around the avatar-flair component. If the user has no flair, no avatar-flair element will be rendered. This avoids the `background-image: url();` situation, and fixes the auth flow. This commit also removes the duplicate avatar flair rendering from the `latest-topic-list-item` component. This wasn't particularly obvious, since the duplicate flairs were being rendered directly on top of each other. --- .../app/components/user-avatar-flair.js | 57 +++++++++---------- .../components/latest-topic-list-item.hbs | 7 --- .../components/user-avatar-flair.hbs | 7 +++ .../components/user-avatar-flair-test.js | 18 ++++++ 4 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/templates/components/user-avatar-flair.hbs diff --git a/app/assets/javascripts/discourse/app/components/user-avatar-flair.js b/app/assets/javascripts/discourse/app/components/user-avatar-flair.js index faf749278b..35def72c69 100644 --- a/app/assets/javascripts/discourse/app/components/user-avatar-flair.js +++ b/app/assets/javascripts/discourse/app/components/user-avatar-flair.js @@ -1,41 +1,38 @@ -import MountWidget from "discourse/components/mount-widget"; -import { observes } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; import autoGroupFlairForUser from "discourse/lib/avatar-flair"; +import discourseComputed from "discourse-common/utils/decorators"; -export default MountWidget.extend({ - widget: "avatar-flair", +export default Component.extend({ + tagName: "", - @observes("user") - _rerender() { - this.queueRerender(); - }, - - buildArgs() { - if (!this.user) { + @discourseComputed("user") + flair(user) { + if (!user) { return; } + return this.primaryGroupFlair(user) || this.automaticGroupFlair(user); + }, - if ( - this.user.primary_group_flair_url || - this.user.primary_group_flair_bg_color - ) { + primaryGroupFlair(user) { + if (user.primary_group_flair_url || user.primary_group_flair_bg_color) { return { - primary_group_flair_url: this.user.primary_group_flair_url, - primary_group_flair_bg_color: this.user.primary_group_flair_bg_color, - primary_group_flair_color: this.user.primary_group_flair_color, - primary_group_name: this.user.primary_group_name, + flairURL: user.primary_group_flair_url, + flairBgColor: user.primary_group_flair_bg_color, + flairColor: user.primary_group_flair_color, + groupName: user.primary_group_name, + }; + } + }, + + automaticGroupFlair(user) { + const autoFlairAttrs = autoGroupFlairForUser(this.site, user); + if (autoFlairAttrs) { + return { + flairURL: autoFlairAttrs.primary_group_flair_url, + flairBgColor: autoFlairAttrs.primary_group_flair_bg_color, + flairColor: autoFlairAttrs.primary_group_flair_color, + groupName: autoFlairAttrs.primary_group_name, }; - } else { - const autoFlairAttrs = autoGroupFlairForUser(this.site, this.user); - if (autoFlairAttrs) { - return { - primary_group_flair_url: autoFlairAttrs.primary_group_flair_url, - primary_group_flair_bg_color: - autoFlairAttrs.primary_group_flair_bg_color, - primary_group_flair_color: autoFlairAttrs.primary_group_flair_color, - primary_group_name: autoFlairAttrs.primary_group_name, - }; - } } }, }); diff --git a/app/assets/javascripts/discourse/app/templates/components/latest-topic-list-item.hbs b/app/assets/javascripts/discourse/app/templates/components/latest-topic-list-item.hbs index 754d9fdd47..6d117176b1 100644 --- a/app/assets/javascripts/discourse/app/templates/components/latest-topic-list-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/latest-topic-list-item.hbs @@ -3,13 +3,6 @@ {{avatar topic.lastPosterUser imageSize="large"}} {{/user-link}} {{user-avatar-flair user=topic.lastPosterUser}} - {{#if topic.lastPosterGroup}} - {{avatar-flair - flairURL=topic.lastPosterGroup.flair_url - flairBgColor=topic.lastPosterGroup.flair_bg_color - flairColor=topic.lastPosterGroup.flair_color - groupName=topic.lastPosterGroup.name}} - {{/if}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-avatar-flair.hbs b/app/assets/javascripts/discourse/app/templates/components/user-avatar-flair.hbs new file mode 100644 index 0000000000..f802da599d --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/user-avatar-flair.hbs @@ -0,0 +1,7 @@ +{{#if flair}} + {{avatar-flair + flairURL=flair.flairURL + flairBgColor=flair.flairBgColor + flairColor=flair.flairColor + groupName=flair.groupName}} +{{/if}} diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js index 81900086cd..70f22db728 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js @@ -175,5 +175,23 @@ discourseModule( ); }, }); + + componentTest("user-avatar-flair for user with no flairs", { + template: hbs`{{user-avatar-flair user=args}}`, + beforeEach() { + resetFlair(); + this.set("args", { + admin: false, + moderator: false, + trust_level: 1, + }); + }, + afterEach() { + resetFlair(); + }, + test(assert) { + assert.ok(!exists(".avatar-flair"), "it does not render a flair"); + }, + }); } ); From 6abc45e57b1b011b8050baa8f109990ec796ff3e Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Mon, 14 Jun 2021 20:34:44 +0530 Subject: [PATCH 033/403] DEV: move `discourse_dev` gem to the core. (#13360) And get avatar images from `discourse_dev_assets` gem. --- Gemfile | 3 +- Gemfile.lock | 5 +- config/dev_defaults.yml | 38 ++++++++ config/routes.rb | 4 - lib/discourse_dev.rb | 15 ++++ lib/discourse_dev/category.rb | 60 +++++++++++++ lib/discourse_dev/config.rb | 149 ++++++++++++++++++++++++++++++++ lib/discourse_dev/group.rb | 38 ++++++++ lib/discourse_dev/post.rb | 107 +++++++++++++++++++++++ lib/discourse_dev/record.rb | 68 +++++++++++++++ lib/discourse_dev/tag.rb | 32 +++++++ lib/discourse_dev/topic.rb | 108 +++++++++++++++++++++++ lib/discourse_dev/user.rb | 93 ++++++++++++++++++++ lib/faker/discourse.rb | 26 ++++++ lib/faker/discourse_markdown.rb | 96 ++++++++++++++++++++ lib/js_locale_helper.rb | 1 - lib/plugin/instance.rb | 2 - lib/tasks/dev.rake | 47 ++++++++++ lib/tasks/populate.rake | 31 +++++++ 19 files changed, 913 insertions(+), 10 deletions(-) create mode 100644 config/dev_defaults.yml create mode 100644 lib/discourse_dev.rb create mode 100644 lib/discourse_dev/category.rb create mode 100644 lib/discourse_dev/config.rb create mode 100644 lib/discourse_dev/group.rb create mode 100644 lib/discourse_dev/post.rb create mode 100644 lib/discourse_dev/record.rb create mode 100644 lib/discourse_dev/tag.rb create mode 100644 lib/discourse_dev/topic.rb create mode 100644 lib/discourse_dev/user.rb create mode 100644 lib/faker/discourse.rb create mode 100644 lib/faker/discourse_markdown.rb create mode 100644 lib/tasks/dev.rake create mode 100644 lib/tasks/populate.rake diff --git a/Gemfile b/Gemfile index 703c5da332..c2680dc0b0 100644 --- a/Gemfile +++ b/Gemfile @@ -174,7 +174,8 @@ group :development do gem 'binding_of_caller' gem 'yaml-lint' gem 'annotate' - gem 'discourse_dev' + gem 'discourse_dev_assets' + gem 'faker', "~> 2.16" end # this is an optional gem, it provides a high performance replacement diff --git a/Gemfile.lock b/Gemfile.lock index 428edc7297..95ae17fad2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,7 +115,7 @@ GEM railties (>= 3.1) discourse-ember-source (3.12.2.3) discourse-fonts (0.0.8) - discourse_dev (0.2.1) + discourse_dev_assets (0.0.2) faker (~> 2.16) docile (1.4.0) ecma-re-validator (0.3.0) @@ -504,12 +504,13 @@ DEPENDENCIES discourse-ember-rails (= 0.18.6) discourse-ember-source (~> 3.12.2) discourse-fonts - discourse_dev + discourse_dev_assets email_reply_trimmer ember-handlebars-template (= 0.8.0) excon execjs fabrication + faker (~> 2.16) fakeweb fast_blank fast_xs diff --git a/config/dev_defaults.yml b/config/dev_defaults.yml new file mode 100644 index 0000000000..4c5fbd6eaf --- /dev/null +++ b/config/dev_defaults.yml @@ -0,0 +1,38 @@ +site_settings: + tagging_enabled: true + verbose_discourse_connect_logging: true + +seed: 1 +start_date: 2020-01-01 +auth_plugin_enabled: true +allow_anonymous_to_impersonate: false + +category: + count: 30 +group: + count: 15 +post: + include_images: false + max_likes_count: 10 +tag: + count: 30 +topic: + count: 30 + replies: + # number of replies per topic between min and max + min: 0 + max: 12 + overrides: + # topic titles can be found in config/locales/faker.en.yml + - title: "Coolest thing you have seen today" + count: 99 + tags: + # number of tags per topic between min and max + min: 0 + max: 3 +user: + count: 30 + +new_user: + username: new_user + email: new_user@example.com diff --git a/config/routes.rb b/config/routes.rb index e395cd0df1..6946889d64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -977,10 +977,6 @@ Discourse::Application.routes.draw do post "/do-not-disturb" => "do_not_disturb#create" delete "/do-not-disturb" => "do_not_disturb#destroy" - if Rails.env.development? - mount DiscourseDev::Engine => "/dev/" - end - get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new end end diff --git a/lib/discourse_dev.rb b/lib/discourse_dev.rb new file mode 100644 index 0000000000..45774b0773 --- /dev/null +++ b/lib/discourse_dev.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module DiscourseDev + def self.config + @config ||= Config.new + end + + def self.settings_file + File.join(root, "config", "settings.yml") + end + + def self.root + File.expand_path("..", __dir__) + end +end diff --git a/lib/discourse_dev/category.rb b/lib/discourse_dev/category.rb new file mode 100644 index 0000000000..b13c43d758 --- /dev/null +++ b/lib/discourse_dev/category.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'rails' +require 'faker' + +module DiscourseDev + class Category < Record + + def initialize + super(::Category, DiscourseDev.config.category[:count]) + @parent_category_ids = ::Category.where(parent_category_id: nil).pluck(:id) + end + + def data + name = Faker::Discourse.unique.category + parent_category_id = nil + + if Faker::Boolean.boolean(true_ratio: 0.6) + offset = Faker::Number.between(from: 0, to: @parent_category_ids.count - 1) + parent_category_id = @parent_category_ids[offset] + @permissions = ::Category.find(parent_category_id).permissions_params.presence + else + @permissions = nil + end + + { + name: name, + description: Faker::Lorem.paragraph, + user_id: ::Discourse::SYSTEM_USER_ID, + color: Faker::Color.hex_color.last(6), + parent_category_id: parent_category_id + } + end + + def permissions + return @permissions if @permissions.present? + return { everyone: :full } if Faker::Boolean.boolean(true_ratio: 0.75) + + permission = {} + group = Group.random + permission[group.id] = Faker::Number.between(from: 1, to: 3) + + permission + end + + def create! + super do |category| + category.set_permissions(permissions) + category.save! + + @parent_category_ids << category.id if category.parent_category_id.blank? + end + end + + def self.random + super(::Category) + end + end +end diff --git a/lib/discourse_dev/config.rb b/lib/discourse_dev/config.rb new file mode 100644 index 0000000000..4a2142b43c --- /dev/null +++ b/lib/discourse_dev/config.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'rails' +require 'highline/import' + +module DiscourseDev + class Config + attr_reader :config, :file_path + + def initialize + default_file_path = File.join(Rails.root, "config", "dev_defaults.yml") + @file_path = File.join(Rails.root, "config", "dev.yml") + default_config = YAML.load_file(default_file_path) + + if File.exists?(file_path) + user_config = YAML.load_file(file_path) + else + puts "I did no detect a custom `config/dev.yml` file, creating one for you where you can amend defaults." + FileUtils.cp(default_file_path, file_path) + user_config = {} + end + + @config = default_config.deep_merge(user_config).deep_symbolize_keys + end + + def update! + update_site_settings + create_admin_user + create_new_user + set_seed + end + + private + + def update_site_settings + puts "Updating site settings..." + + site_settings = config[:site_settings] || {} + + site_settings.each do |key, value| + puts "#{key} = #{value}" + SiteSetting.set(key, value) + end + + SiteSetting.refresh! + end + + def create_admin_user + puts "Creating default admin user account..." + + settings = config[:admin] + + if settings.present? + create_admin_user_from_settings(settings) + else + create_admin_user_from_input + end + end + + def create_new_user + settings = config[:new_user] + + if settings.present? + email = settings[:email] || "new_user@example.com" + + new_user = ::User.create!( + email: email, + username: settings[:username] || UserNameSuggester.suggest(email) + ) + new_user.email_tokens.update_all confirmed: true + new_user.activate + end + end + + def set_seed + seed = self.seed || 1 + Faker::Config.random = Random.new(seed) + end + + def start_date + DateTime.parse(config[:start_date]) + end + + def method_missing(name) + config[name.to_sym] + end + + def create_admin_user_from_settings(settings) + email = settings[:email] + + admin = ::User.with_email(email).first_or_create!( + email: email, + username: settings[:username] || UserNameSuggester.suggest(email), + password: settings[:password] + ) + admin.grant_admin! + if admin.trust_level < 1 + admin.change_trust_level!(1) + end + admin.email_tokens.update_all confirmed: true + admin.activate + end + + def create_admin_user_from_input + begin + email = ask("Email: ") + password = ask("Password (optional, press ENTER to skip): ") + username = UserNameSuggester.suggest(email) + + admin = ::User.new( + email: email, + username: username + ) + + if password.present? + admin.password = password + else + puts "Once site is running use https://localhost:9292/user/#{username}/become to access the account in development" + end + + admin.name = ask("Full name: ") if SiteSetting.full_name_required + saved = admin.save + + if saved + File.open(file_path, 'a') do | file| + file.puts("admin:") + file.puts(" username: #{admin.username}") + file.puts(" email: #{admin.email}") + file.puts(" password: #{password}") if password.present? + end + else + say(admin.errors.full_messages.join("\n")) + end + end while !saved + + admin.active = true + admin.save + + admin.grant_admin! + if admin.trust_level < 1 + admin.change_trust_level!(1) + end + admin.email_tokens.update_all confirmed: true + admin.activate + + say("\nAdmin account created successfully with username #{admin.username}") + end + end +end diff --git a/lib/discourse_dev/group.rb b/lib/discourse_dev/group.rb new file mode 100644 index 0000000000..c346b0c5fa --- /dev/null +++ b/lib/discourse_dev/group.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'rails' +require 'faker' + +module DiscourseDev + class Group < Record + + def initialize + super(::Group, DiscourseDev.config.group[:count]) + end + + def data + { + name: Faker::Discourse.unique.group, + public_exit: Faker::Boolean.boolean, + public_admission: Faker::Boolean.boolean, + primary_group: Faker::Boolean.boolean, + created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), + } + end + + def create! + super do |group| + if Faker::Boolean.boolean + group.add_owner(::Discourse.system_user) + group.allow_membership_requests = true + group.save! + end + end + end + + def self.random + super(::Group) + end + end +end diff --git a/lib/discourse_dev/post.rb b/lib/discourse_dev/post.rb new file mode 100644 index 0000000000..74d9bb85fe --- /dev/null +++ b/lib/discourse_dev/post.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'faker' + +module DiscourseDev + class Post < Record + + attr_reader :topic + + def initialize(topic, count) + super(::Post, count) + @topic = topic + + category = topic.category + @max_likes_count = DiscourseDev.config.post[:max_likes_count] + unless category.groups.blank? + group_ids = category.groups.pluck(:id) + @user_ids = ::GroupUser.where(group_id: group_ids).pluck(:user_id) + @user_count = @user_ids.count + @max_likes_count = @user_count - 1 + end + end + + def data + { + topic_id: topic.id, + raw: Faker::DiscourseMarkdown.sandwich(sentences: 5), + created_at: Faker::Time.between(from: topic.last_posted_at, to: DateTime.now), + skip_validations: true, + skip_guardian: true + } + end + + def create! + user = self.user + data = Faker::DiscourseMarkdown.with_user(user.id) { self.data } + post = PostCreator.new(user, data).create! + topic.reload + generate_likes(post) + end + + def generate_likes(post) + user_ids = [post.user_id] + + Faker::Number.between(from: 0, to: @max_likes_count).times do + user = self.user + next if user_ids.include?(user.id) + + PostActionCreator.new(user, post, PostActionType.types[:like], created_at: Faker::Time.between(from: post.created_at, to: DateTime.now)).perform + user_ids << user.id + end + end + + def user + return User.random if topic.category.groups.blank? + return Discourse.system_user if @user_ids.blank? + + position = Faker::Number.between(from: 0, to: @user_count - 1) + ::User.find(@user_ids[position]) + end + + def populate! + generate_likes(topic.first_post) + + @count.times do + create! + end + end + + def self.add_replies!(args) + if !args[:topic_id] + puts "Topic ID is required. Aborting." + return + end + + if !::Topic.find_by_id(args[:topic_id]) + puts "Topic ID does not match topic in DB, aborting." + return + end + + topic = ::Topic.find_by_id(args[:topic_id]) + count = args[:count] ? args[:count].to_i : 50 + + puts "Creating #{count} replies in '#{topic.title}'" + + count.times do |i| + begin + user = User.random + reply = Faker::DiscourseMarkdown.with_user(user.id) do + { + topic_id: topic.id, + raw: Faker::DiscourseMarkdown.sandwich(sentences: 5), + skip_validations: true + } + end + PostCreator.new(user, reply).create! + rescue ActiveRecord::RecordNotSaved => e + puts e + end + end + + puts "Done!" + end + + end +end diff --git a/lib/discourse_dev/record.rb b/lib/discourse_dev/record.rb new file mode 100644 index 0000000000..b6196aebcf --- /dev/null +++ b/lib/discourse_dev/record.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'discourse_dev' +require 'rails' +require 'faker' + +module DiscourseDev + class Record + DEFAULT_COUNT = 30.freeze + + attr_reader :model, :type + + def initialize(model, count = DEFAULT_COUNT) + @@initialized ||= begin + Faker::Discourse.unique.clear + RateLimiter.disable + true + end + + @model = model + @type = model.to_s + @count = count + end + + def create! + record = model.create!(data) + yield(record) if block_given? + end + + def populate! + if current_count >= @count + puts "Already have #{current_count} #{type.downcase} records" + + Rake.application.top_level_tasks.each do |task_name| + Rake::Task[task_name].reenable + end + + Rake::Task['dev:repopulate'].invoke + return + elsif current_count > 0 + @count -= current_count + puts "There are #{current_count} #{type.downcase} records. Creating #{@count} more." + else + puts "Creating #{@count} sample #{type.downcase} records" + end + + @count.times do + create! + putc "." + end + + puts + end + + def current_count + model.count + end + + def self.populate! + self.new.populate! + end + + def self.random(model) + offset = Faker::Number.between(from: 0, to: model.count - 1) + model.offset(offset).first + end + end +end diff --git a/lib/discourse_dev/tag.rb b/lib/discourse_dev/tag.rb new file mode 100644 index 0000000000..987d8656e9 --- /dev/null +++ b/lib/discourse_dev/tag.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'rails' +require 'faker' + +module DiscourseDev + class Tag < Record + + def initialize + super(::Tag, DiscourseDev.config.tag[:count]) + end + + def create! + super + rescue ActiveRecord::RecordInvalid => e + # If the name is taken, try again + retry + end + + def populate! + return unless SiteSetting.tagging_enabled + super + end + + def data + { + name: Faker::Discourse.unique.tag, + } + end + end +end diff --git a/lib/discourse_dev/topic.rb b/lib/discourse_dev/topic.rb new file mode 100644 index 0000000000..64bc86fd26 --- /dev/null +++ b/lib/discourse_dev/topic.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'faker' + +module DiscourseDev + class Topic < Record + + def initialize + @settings = DiscourseDev.config.topic + super(::Topic, @settings[:count]) + end + + def data + max_views = 0 + + case Faker::Number.between(from: 0, to: 5) + when 0 + max_views = 10 + when 1 + max_views = 100 + when 2 + max_views = SiteSetting.topic_views_heat_low + when 3 + max_views = SiteSetting.topic_views_heat_medium + when 4 + max_views = SiteSetting.topic_views_heat_high + when 5 + max_views = SiteSetting.topic_views_heat_high + SiteSetting.topic_views_heat_medium + end + + { + title: title[0, SiteSetting.max_topic_title_length], + raw: Faker::DiscourseMarkdown.sandwich(sentences: 5), + category: @category.id, + created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), + tags: tags, + topic_opts: { + import_mode: true, + views: Faker::Number.between(from: 1, to: max_views), + custom_fields: { dev_sample: true } + }, + skip_validations: true + } + end + + def title + if current_count < I18n.t("faker.discourse.topics").count + Faker::Discourse.unique.topic + else + Faker::Lorem.unique.sentence(word_count: 5, supplemental: true, random_words_to_add: 4).chomp(".") + end + end + + def tags + return unless SiteSetting.tagging_enabled + + @tags = [] + + Faker::Number.between(from: @settings.dig(:tags, :min), to: @settings.dig(:tags, :max)).times do + @tags << Faker::Discourse.tag + end + + @tags.uniq + end + + def create! + @category = Category.random + user = self.user + topic = Faker::DiscourseMarkdown.with_user(user.id) { data } + post = PostCreator.new(user, topic).create! + + if override = @settings.dig(:replies, :overrides).find { |o| o[:title] == topic[:title] } + reply_count = override[:count] + else + reply_count = Faker::Number.between(from: @settings.dig(:replies, :min), to: @settings.dig(:replies, :max)) + end + + Post.new(post.topic, reply_count).populate! + end + + def populate! + super + delete_unwanted_sidekiq_jobs + end + + def user + return User.random if @category.groups.blank? + + group_ids = @category.groups.pluck(:id) + user_ids = ::GroupUser.where(group_id: group_ids).pluck(:user_id) + user_count = user_ids.count + position = Faker::Number.between(from: 0, to: user_count - 1) + ::User.find(user_ids[position] || Discourse::SYSTEM_USER_ID) + end + + def current_count + category_definition_topic_ids = ::Category.pluck(:topic_id) + ::Topic.where(archetype: :regular).where.not(id: category_definition_topic_ids).count + end + + def delete_unwanted_sidekiq_jobs + Sidekiq::ScheduledSet.new.each do |job| + job.delete if job.item["class"] == "Jobs::UserEmail" + end + end + end +end diff --git a/lib/discourse_dev/user.rb b/lib/discourse_dev/user.rb new file mode 100644 index 0000000000..c64f8dc346 --- /dev/null +++ b/lib/discourse_dev/user.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'discourse_dev/record' +require 'faker' + +module DiscourseDev + class User < Record + attr_reader :images + + def initialize + super(::User, DiscourseDev.config.user[:count]) + + @images = DiscourseDevAssets.avatars + end + + def data + name = Faker::Name.unique.name + email = Faker::Internet.unique.email(name: name, domain: "faker.invalid") + username = Faker::Internet.unique.username(specifier: ::User.username_length) + username = UserNameSuggester.suggest(username) + username_lower = ::User.normalize_username(username) + + { + name: name, + email: email, + username: username, + username_lower: username_lower, + moderator: Faker::Boolean.boolean(true_ratio: 0.1), + trust_level: Faker::Number.between(from: 1, to: 4), + created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), + } + end + + def create! + super do |user| + user.activate + set_random_avatar(user) + Faker::Number.between(from: 0, to: 2).times do + group = Group.random + + group.add(user) + end + end + end + + def self.random + super(::User) + end + + def set_random_avatar(user) + return if images.blank? + return unless Faker::Boolean.boolean + + avatar_index = Faker::Number.between(from: 0, to: images.count - 1) + avatar_path = images[avatar_index] + create_avatar(user, avatar_path) + @images.delete_at(avatar_index) + end + + def create_avatar(user, avatar_path) + tempfile = copy_to_tempfile(avatar_path) + filename = "avatar#{File.extname(avatar_path)}" + upload = UploadCreator.new(tempfile, filename, type: "avatar").create_for(user.id) + + if upload.present? && upload.persisted? + user.create_user_avatar + user.user_avatar.update(custom_upload_id: upload.id) + user.update(uploaded_avatar_id: upload.id) + else + STDERR.puts "Failed to upload avatar for user #{user.username}: #{avatar_path}" + STDERR.puts upload.errors.inspect if upload + end + rescue + STDERR.puts "Failed to create avatar for user #{user.username}: #{avatar_path}" + ensure + tempfile.close! if tempfile + end + + private + + def copy_to_tempfile(source_path) + extension = File.extname(source_path) + tmp = Tempfile.new(['discourse-upload', extension]) + + File.open(source_path) do |source_stream| + IO.copy_stream(source_stream, tmp) + end + + tmp.rewind + tmp + end + end +end diff --git a/lib/faker/discourse.rb b/lib/faker/discourse.rb new file mode 100644 index 0000000000..a2c4720026 --- /dev/null +++ b/lib/faker/discourse.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'faker' + +module Faker + class Discourse < Base + class << self + + def tag + fetch('discourse.tags') + end + + def category + fetch('discourse.categories') + end + + def group + fetch('discourse.groups') + end + + def topic + fetch('discourse.topics') + end + end + end +end diff --git a/lib/faker/discourse_markdown.rb b/lib/faker/discourse_markdown.rb new file mode 100644 index 0000000000..b858e996c6 --- /dev/null +++ b/lib/faker/discourse_markdown.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'faker' +require 'net/http' +require 'json' + +module Faker + class DiscourseMarkdown < Markdown + class << self + attr_writer(:user_id) + + def user_id + @user_id || ::Discourse::SYSTEM_USER_ID + end + + def with_user(user_id) + current_user_id = self.user_id + self.user_id = user_id + begin + yield + ensure + self.user_id = current_user_id + end + end + + def image + image = next_image + image_file = load_image(image) + + upload = ::UploadCreator.new( + image_file, + image[:filename], + origin: image[:url] + ).create_for(user_id) + + ::UploadMarkdown.new(upload).to_markdown if upload.present? && upload.persisted? + rescue => e + STDERR.puts e + STDERR.puts e.backtrace.join("\n") + end + + private + + def next_image + if @images.blank? + if @stop_loading_images + @images = @all_images.dup + else + @next_page = (@next_page || 0) + 1 + url = URI("https://picsum.photos/v2/list?page=#{@next_page}&limit=50") + response = Net::HTTP.get(url) + json = JSON.parse(response) + + if json.blank? + @stop_loading_images = true + @images = @all_images.dup + else + @images = json.sort_by { |image| image["id"] } + @all_images = (@all_images || []).concat(@images) + end + end + end + + image = @images.pop + { filename: "#{image['id']}.jpg", url: "#{image['download_url']}.jpg" } + end + + def image_cache_dir + @image_cache_dir ||= ::File.join(Rails.root, "tmp", "discourse_dev", "images") + end + + def load_image(image) + cache_path = ::File.join(image_cache_dir, image[:filename]) + + if !::File.exists?(cache_path) + FileUtils.mkdir_p(image_cache_dir) + temp_file = ::FileHelper.download( + image[:url], + max_file_size: [SiteSetting.max_image_size_kb.kilobytes, 10.megabytes].max, + tmp_file_name: "image", + follow_redirect: true + ) + FileUtils.cp(temp_file, cache_path) + end + + ::File.open(cache_path) + end + + def available_methods + methods = super + methods << :image if ::DiscourseDev.config.post[:include_images] + methods + end + end + end +end diff --git a/lib/js_locale_helper.rb b/lib/js_locale_helper.rb index c51a881229..9ab61c941d 100644 --- a/lib/js_locale_helper.rb +++ b/lib/js_locale_helper.rb @@ -4,7 +4,6 @@ module JsLocaleHelper def self.plugin_client_files(locale_str) files = Dir["#{Rails.root}/plugins/*/config/locales/client*.#{locale_str}.yml"] - files += DiscourseDev.client_locale_files(locale_str) if Rails.env.development? I18n::Backend::DiscourseI18n.sort_locale_files(files) end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 6bf2fe0b53..a7933989c3 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -90,8 +90,6 @@ class Plugin::Instance metadata = Plugin::Metadata.parse(source) plugins << self.new(metadata, path) end - - plugins << DiscourseDev.auth_plugin if Rails.env.development? && DiscourseDev.auth_plugin_enabled? } end diff --git a/lib/tasks/dev.rake b/lib/tasks/dev.rake new file mode 100644 index 0000000000..fa6e2f8698 --- /dev/null +++ b/lib/tasks/dev.rake @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +def check_environment! + if !Rails.env.development? + raise "Database commands are only supported in development environment" + end + + ENV['SKIP_TEST_DATABASE'] = "1" + ENV['SKIP_MULTISITE'] = "1" +end + +desc 'Run db:migrate:reset task and populate sample content for development environment' +task 'dev:reset' => ['db:load_config'] do |_, args| + check_environment! + + Rake::Task['db:migrate:reset'].invoke + Rake::Task['dev:config'].invoke + Rake::Task['dev:populate'].invoke +end + +desc 'Initialize development environment' +task 'dev:config' => ['db:load_config'] do |_, args| + DiscourseDev.config.update! +end + +desc 'Populate sample content for development environment' +task 'dev:populate' => ['db:load_config'] do |_, args| + system("redis-cli flushall") + Rake::Task['groups:populate'].invoke + Rake::Task['users:populate'].invoke + Rake::Task['categories:populate'].invoke + Rake::Task['tags:populate'].invoke + Rake::Task['topics:populate'].invoke +end + +desc 'Repopulate sample datas in development environment' +task 'dev:repopulate' => ['db:load_config'] do |_, args| + require 'highline/import' + + answer = ask("Do you want to repopulate the database with fresh data? It will recreate DBs and run migration from scratch before generating all the samples. (Y/n) ") + + if (answer == "" || answer.downcase == 'y') + Rake::Task['dev:reset'].invoke + else + puts "You can run `bin/rails dev:reset` to repopulate anytime." + end +end diff --git a/lib/tasks/populate.rake b/lib/tasks/populate.rake new file mode 100644 index 0000000000..81a3bbae06 --- /dev/null +++ b/lib/tasks/populate.rake @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +desc 'Creates sample categories' +task 'groups:populate' => ['db:load_config'] do |_, args| + DiscourseDev::Group.populate! +end + +desc 'Creates sample user accounts' +task 'users:populate' => ['db:load_config'] do |_, args| + DiscourseDev::User.populate! +end + +desc 'Creates sample categories' +task 'categories:populate' => ['db:load_config'] do |_, args| + DiscourseDev::Category.populate! +end + +desc 'Creates sample tags' +task 'tags:populate' => ['db:load_config'] do |_, args| + DiscourseDev::Tag.populate! +end + +desc 'Creates sample topics' +task 'topics:populate' => ['db:load_config'] do |_, args| + DiscourseDev::Topic.populate! +end + +desc 'Add replies to a topic' +task 'replies:populate', [:topic_id, :count] => ['db:load_config'] do |_, args| + DiscourseDev::Post.add_replies!(args) +end From 9c1ef2a58a5697ec32cd657a30c712dbc29af585 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 14 Jun 2021 17:36:17 +0200 Subject: [PATCH 034/403] DEV: Fix `sh: /bin/rm: Argument list too long` (#13371) That error happens when you accrued too many temporary files in `tmp/stylesheet-cache`. --- spec/requests/stylesheets_controller_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/requests/stylesheets_controller_spec.rb b/spec/requests/stylesheets_controller_spec.rb index 04926798ac..7f6f8d6d19 100644 --- a/spec/requests/stylesheets_controller_spec.rb +++ b/spec/requests/stylesheets_controller_spec.rb @@ -19,7 +19,7 @@ describe StylesheetsController do expect(cached.digest).to eq digest # tmp folder destruction and cached - `rm #{Stylesheet::Manager.cache_fullpath}/*` + `rm -rf #{Stylesheet::Manager.cache_fullpath}` get "/stylesheets/desktop_rtl_#{digest}.css" expect(response.status).to eq(200) @@ -34,7 +34,7 @@ describe StylesheetsController do builder = Stylesheet::Manager.new(:desktop, theme.id) builder.compile - `rm #{Stylesheet::Manager.cache_fullpath}/*` + `rm -rf #{Stylesheet::Manager.cache_fullpath}` get "/stylesheets/#{builder.stylesheet_filename.sub(".css", "")}.css" @@ -47,7 +47,7 @@ describe StylesheetsController do builder = Stylesheet::Manager.new(:desktop_theme, theme.id) builder.compile - `rm #{Stylesheet::Manager.cache_fullpath}/*` + `rm -rf #{Stylesheet::Manager.cache_fullpath}` get "/stylesheets/#{builder.stylesheet_filename.sub(".css", "")}.css" From 091beaf4a22247f2a5adf243aa9c46a1c6dcade1 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 14 Jun 2021 17:38:36 +0200 Subject: [PATCH 035/403] DEV: Work around a Docker issue (#13368) Fixes our backend spec suite in GitHub Actions CI. For more information about the Docker issue see: https://github.com/docker/for-linux/issues/1015 (It's possible that error could also happen in dev/production, though thankfully that hasn't happened yet afaik) --- lib/freedom_patches/copy_file.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 lib/freedom_patches/copy_file.rb diff --git a/lib/freedom_patches/copy_file.rb b/lib/freedom_patches/copy_file.rb new file mode 100644 index 0000000000..3a411b88d3 --- /dev/null +++ b/lib/freedom_patches/copy_file.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "fileutils" + +# See: https://github.com/docker/for-linux/issues/1015 + +module FileUtils + class Entry_ + def copy_file(dest) + File.open(path()) do |s| + File.open(dest, "wb", s.stat.mode) do |f| + IO.copy_stream(s, f) + f.chmod(f.lstat.mode) + end + end + end + end +end From a470e880bde8ded7f8f4aa63b4c120a595c0ce2b Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 14 Jun 2021 17:38:57 +0200 Subject: [PATCH 036/403] FIX: De-prioritize composer category on navigation (#13372) --- .../javascripts/discourse/app/routes/build-category-route.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/discourse/app/routes/build-category-route.js b/app/assets/javascripts/discourse/app/routes/build-category-route.js index 5b77d14f82..efb6f6c5f4 100644 --- a/app/assets/javascripts/discourse/app/routes/build-category-route.js +++ b/app/assets/javascripts/discourse/app/routes/build-category-route.js @@ -202,6 +202,8 @@ export default (filterArg, params) => { deactivate() { this._super(...arguments); + + this.controllerFor("composer").set("prioritizedCategoryId", null); this.searchService.set("searchContext", null); }, From f36ecf86f8bc2fe42fda5107663af9d0ae3924e8 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 14 Jun 2021 15:13:55 -0300 Subject: [PATCH 037/403] FEATURE: Add type=website OpenGraph meta tag (#13376) --- app/helpers/application_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4972fa2c79..702f83ca5a 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -237,6 +237,7 @@ module ApplicationHelper # Add opengraph & twitter tags result = [] result << tag(:meta, property: 'og:site_name', content: SiteSetting.title) + result << tag(:meta, property: 'og:type', content: 'website') if opts[:twitter_summary_large_image].present? result << tag(:meta, name: 'twitter:card', content: "summary_large_image") From e147b1c15ade0768d10b292213e1e1e932889588 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Jun 2021 14:41:25 -0400 Subject: [PATCH 038/403] Build(deps): Bump redis from 4.2.5 to 4.3.1 (#13373) Bumps [redis](https://github.com/redis/redis-rb) from 4.2.5 to 4.3.1. - [Release notes](https://github.com/redis/redis-rb/releases) - [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md) - [Commits](https://github.com/redis/redis-rb/compare/v4.2.5...v4.3.1) --- updated-dependencies: - dependency-name: redis dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 95ae17fad2..47c7476d09 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -337,7 +337,7 @@ GEM msgpack (>= 0.4.3) optimist (>= 3.0.0) rchardet (1.8.0) - redis (4.2.5) + redis (4.3.1) redis-namespace (1.8.1) redis (>= 3.0.4) regexp_parser (2.1.1) From 7d8483f698cce5504af107870dc13e3b32b7361b Mon Sep 17 00:00:00 2001 From: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> Date: Mon, 14 Jun 2021 13:48:32 -0500 Subject: [PATCH 039/403] FIX: Adjust styling of first notification (#13366) * UX: Fix first notification layout --- app/assets/stylesheets/common/base/discourse.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index 2582b7e8ab..1709bcc56a 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -531,7 +531,7 @@ table { .first-notification, .read-later { - display: inline-block; + display: block; margin-bottom: 36px; } From c780ae9d25f416f9134cbeab29d951ddc47d50ac Mon Sep 17 00:00:00 2001 From: Kane York Date: Mon, 14 Jun 2021 14:01:17 -0700 Subject: [PATCH 040/403] FEATURE: Add a messages view for all official warnings of a user (#12659) Moderators are allowed to see the warnings list, with an access warning. https://meta.discourse.org/t/why-arent-warnings-easily-accessible-like-suspensions-are/164043 --- .../app/controllers/user-private-messages.js | 6 +++ .../discourse/app/routes/app-route-map.js | 1 + .../routes/user-private-messages-warnings.js | 9 ++++ .../discourse/app/templates/user.hbs | 6 ++- .../discourse/app/templates/user/messages.hbs | 3 ++ .../discourse/tests/acceptance/user-test.js | 15 ++++++ .../tests/helpers/create-pretender.js | 4 ++ app/controllers/list_controller.rb | 8 ++- config/locales/client.en.yml | 3 ++ config/routes.rb | 1 + lib/guardian/user_guardian.rb | 4 ++ lib/topic_query.rb | 8 +++ spec/requests/list_controller_spec.rb | 52 +++++++++++++++++++ 13 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/routes/user-private-messages-warnings.js diff --git a/app/assets/javascripts/discourse/app/controllers/user-private-messages.js b/app/assets/javascripts/discourse/app/controllers/user-private-messages.js index 222b819c77..3cc26e8361 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-private-messages.js +++ b/app/assets/javascripts/discourse/app/controllers/user-private-messages.js @@ -5,6 +5,7 @@ import I18n from "I18n"; import Topic from "discourse/models/topic"; import bootbox from "bootbox"; import discourseComputed from "discourse-common/utils/decorators"; +import { VIEW_NAME_WARNINGS } from "discourse/routes/user-private-messages-warnings"; export default Controller.extend({ userTopicsList: controller("user-topics-list"), @@ -27,6 +28,11 @@ export default Controller.extend({ return bulkSelectEnabled && selected && selected.length > 0; }, + @discourseComputed("viewingSelf", "pmView", "currentUser.admin") + showWarningsWarning(viewingSelf, pmView, isAdmin) { + return pmView === VIEW_NAME_WARNINGS && !viewingSelf && !isAdmin; + }, + bulkOperation(operation) { const selected = this.selected; let params = { type: operation }; diff --git a/app/assets/javascripts/discourse/app/routes/app-route-map.js b/app/assets/javascripts/discourse/app/routes/app-route-map.js index 2bce5afc37..6ccf9c94bd 100644 --- a/app/assets/javascripts/discourse/app/routes/app-route-map.js +++ b/app/assets/javascripts/discourse/app/routes/app-route-map.js @@ -149,6 +149,7 @@ export default function () { function () { this.route("sent"); this.route("archive"); + this.route("warnings"); this.route("group", { path: "group/:name" }); this.route("groupArchive", { path: "group/:name/archive" }); this.route("tags"); diff --git a/app/assets/javascripts/discourse/app/routes/user-private-messages-warnings.js b/app/assets/javascripts/discourse/app/routes/user-private-messages-warnings.js new file mode 100644 index 0000000000..a53e9b1017 --- /dev/null +++ b/app/assets/javascripts/discourse/app/routes/user-private-messages-warnings.js @@ -0,0 +1,9 @@ +import createPMRoute from "discourse/routes/build-private-messages-route"; + +export const VIEW_NAME_WARNINGS = "warnings"; + +export default createPMRoute( + VIEW_NAME_WARNINGS, + "private-messages-warnings", + null /* no message bus notifications */ +); diff --git a/app/assets/javascripts/discourse/app/templates/user.hbs b/app/assets/javascripts/discourse/app/templates/user.hbs index 0b6f752bf9..1635bde251 100644 --- a/app/assets/javascripts/discourse/app/templates/user.hbs +++ b/app/assets/javascripts/discourse/app/templates/user.hbs @@ -38,7 +38,11 @@
{{/if}} {{#if model.warnings_received_count}} -
{{model.warnings_received_count}}{{i18n "user.staff_counters.warnings_received"}}
+
+ {{#link-to "userPrivateMessages.warnings" model}} + {{model.warnings_received_count}}{{i18n "user.staff_counters.warnings_received"}} + {{/link-to}} +
{{/if}}
{{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/user/messages.hbs b/app/assets/javascripts/discourse/app/templates/user/messages.hbs index 004b58b4eb..23eaded6dd 100644 --- a/app/assets/javascripts/discourse/app/templates/user/messages.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/messages.hbs @@ -87,5 +87,8 @@ }} {{/if}} + {{#if showWarningsWarning}} +
{{html-safe (i18n "admin.user.warnings_list_warning")}}
+ {{/if}} {{outlet}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-test.js index 7c4137a6df..371c8be8f3 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-test.js @@ -121,3 +121,18 @@ acceptance( }); } ); + +acceptance("User Routes - Moderator viewing warnings", function (needs) { + needs.user({ + username: "notEviltrout", + moderator: true, + staff: true, + admin: false, + }); + + test("Messages - Warnings", async function (assert) { + await visit("/u/eviltrout/messages/warnings"); + assert.ok($("body.user-messages-page").length, "has the body class"); + assert.ok($("div.alert-info").length, "has the permissions alert"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 4e2b1ad305..2b843d6267 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -210,6 +210,10 @@ export function applyDefaultHandlers(pretender) { return response(fixturesByUrl["/topics/private-messages/eviltrout.json"]); }); + pretender.get("/topics/private-messages-warnings/eviltrout.json", () => { + return response(fixturesByUrl["/topics/private-messages/eviltrout.json"]); + }); + pretender.get("/topics/feature_stats.json", () => { return response({ pinned_in_category_count: 0, diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index f9f41f3d06..ecad047db9 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -168,7 +168,12 @@ class ListController < ApplicationController def message_route(action) target_user = fetch_user_from_params({ include_inactive: current_user.try(:staff?) }, [:user_stat, :user_option]) - guardian.ensure_can_see_private_messages!(target_user.id) + case action + when :private_messages_warnings + guardian.ensure_can_see_warnings!(target_user) + else + guardian.ensure_can_see_private_messages!(target_user.id) + end list_opts = build_topic_list_options list = generate_list_for(action.to_s, target_user, list_opts) url_prefix = "topics" @@ -185,6 +190,7 @@ class ListController < ApplicationController private_messages_group private_messages_group_archive private_messages_tag + private_messages_warnings }.each do |action| generate_message_route(action) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 57ef0b2c24..6fc103c886 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1177,6 +1177,7 @@ en: failed_to_move: "Failed to move selected messages (perhaps your network is down)" select_all: "Select All" tags: "Tags" + warnings: "Official Warnings" preferences_nav: account: "Account" @@ -4912,6 +4913,8 @@ en: flags_given_count: Flags Given flags_received_count: Flags Received warnings_received_count: Warnings Received + warnings_list_warning: | + As a moderator, you may not be able to view all of these topics. If necessary, ask an admin or the issuing moderator to give @moderators access to the message. flags_given_received_count: "Flags Given / Received" approve: "Approve" approved_by: "approved by" diff --git a/config/routes.rb b/config/routes.rb index 6946889d64..ee1b2644dd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -762,6 +762,7 @@ Discourse::Application.routes.draw do get "private-messages-archive/:username" => "list#private_messages_archive", as: "topics_private_messages_archive", defaults: { format: :json } get "private-messages-unread/:username" => "list#private_messages_unread", as: "topics_private_messages_unread", defaults: { format: :json } get "private-messages-tags/:username/:tag_id.json" => "list#private_messages_tag", as: "topics_private_messages_tag", defaults: { format: :json } + get "private-messages-warnings/:username" => "list#private_messages_warnings", as: "topics_private_messages_warnings", defaults: { format: :json } get "groups/:group_name" => "list#group_topics", as: "group_topics", group_name: RouteFormat.username scope "/private-messages-group/:username", group_name: RouteFormat.username do diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index 250adfe179..17a908984a 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -84,6 +84,10 @@ module UserGuardian can_merge_user?(source_user) && !target_user.nil? end + def can_see_warnings?(user) + user && (is_me?(user) || is_staff?) + end + def can_reset_bounce_score?(user) user && is_staff? end diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 5a648b9f27..dd402eb23b 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -365,6 +365,14 @@ class TopicQuery create_list(:private_messages, {}, list) end + def list_private_messages_warnings(user) + list = private_messages_for(user, :user) + list = list.where('topics.subtype = ?', TopicSubtype.moderator_warning) + # Exclude official warnings that the user created, instead of received + list = list.where('topics.user_id <> ?', user.id) + create_list(:private_messages, {}, list) + end + def list_category_topic_ids(category) query = default_results(category: category.id) pinned_ids = query.where('topics.pinned_at IS NOT NULL AND topics.category_id = ?', category.id).limit(nil).order('pinned_at DESC').pluck(:id) diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 5f69298878..283ca35c16 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -703,6 +703,58 @@ RSpec.describe ListController do end end + describe "#private_messages_warnings" do + let(:target_user) { Fabricate(:user) } + let(:admin) { Fabricate(:admin) } + let(:moderator1) { Fabricate(:moderator) } + let(:moderator2) { Fabricate(:moderator) } + + let(:create_args) do + { title: 'you need a warning buddy!', + raw: "you did something bad and I'm telling you about it!", + is_warning: true, + target_usernames: target_user.username, + archetype: Archetype.private_message } + end + + let(:warning_post) do + creator = PostCreator.new(moderator1, create_args) + creator.create + end + let(:warning_topic) { warning_post.topic } + + before do + warning_topic + end + + it "returns 403 error for unrelated users" do + sign_in(Fabricate(:user)) + get "/topics/private-messages-warnings/#{target_user.username}.json" + expect(response.status).to eq(403) + end + + it "shows the warning to moderators and admins" do + [moderator1, moderator2, admin].each do |viewer| + sign_in(viewer) + get "/topics/private-messages-warnings/#{target_user.username}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["topic_list"]["topics"].size).to eq(1) + expect(json["topic_list"]["topics"][0]["id"]).to eq(warning_topic.id) + end + end + + it "does not show the warning as applying to the authoring moderator" do + sign_in(admin) + get "/topics/private-messages-warnings/#{moderator1.username}.json" + + expect(response.status).to eq(200) + json = response.parsed_body + expect(json["topic_list"]["topics"].size).to eq(0) + end + end + describe 'read' do it 'raises an error when not logged in' do get "/read" From af15bf13503a619f66f8187901c75240bb909617 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 15 Jun 2021 10:09:25 +1000 Subject: [PATCH 041/403] FIX: Show group Email settings if just SMTP enabled (#13362) We previously only showed the link to the Email section of group settings if both SMTP and IMAP were enabled for a site, but this is not necessary now, only SMTP can be enabled by itself so we should show the section if SMTP is enabled. --- .../discourse/app/controllers/group-manage.js | 2 +- .../acceptance/group-manage-email-settings-test.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/controllers/group-manage.js b/app/assets/javascripts/discourse/app/controllers/group-manage.js index de7cea1512..3d4e684785 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-manage.js +++ b/app/assets/javascripts/discourse/app/controllers/group-manage.js @@ -29,7 +29,7 @@ export default Controller.extend({ }); if (!automatic) { - if (this.siteSettings.enable_imap && this.siteSettings.enable_smtp) { + if (this.siteSettings.enable_smtp) { defaultTabs.splice(2, 0, { route: "group.manage.email", title: "groups.manage.email.title", diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js index cbfbe082e7..409393c073 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js @@ -9,6 +9,10 @@ acceptance("Managing Group Email Settings - SMTP Disabled", function (needs) { test("When SiteSetting.enable_smtp is false", async function (assert) { await visit("/g/discourse/manage/email"); + assert.notOk( + queryAll(".user-secondary-navigation").text().includes("Email"), + "email link is not shown in the sidebar" + ); assert.equal( currentRouteName(), "group.manage.profile", @@ -25,6 +29,10 @@ acceptance( test("When SiteSetting.enable_smtp is true but SiteSetting.enable_imap is false", async function (assert) { await visit("/g/discourse/manage/email"); + assert.ok( + queryAll(".user-secondary-navigation").text().includes("Email"), + "email link is shown in the sidebar" + ); assert.equal( currentRouteName(), "group.manage.email", @@ -59,6 +67,10 @@ acceptance( test("enabling SMTP, testing, and saving", async function (assert) { await visit("/g/discourse/manage/email"); + assert.ok( + queryAll(".user-secondary-navigation").text().includes("Email"), + "email link is shown in the sidebar" + ); assert.ok( exists("#enable_imap:disabled"), "IMAP is disabled until SMTP settings are valid" From b36396d925af6b66f21b20f2e94fd8b5c07b27f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Jun 2021 22:03:40 +0000 Subject: [PATCH 042/403] Build(deps): Bump oj from 3.11.5 to 3.11.6 Bumps [oj](https://github.com/ohler55/oj) from 3.11.5 to 3.11.6. - [Release notes](https://github.com/ohler55/oj/releases) - [Changelog](https://github.com/ohler55/oj/blob/develop/CHANGELOG.md) - [Commits](https://github.com/ohler55/oj/compare/v3.11.5...v3.11.6) --- updated-dependencies: - dependency-name: oj dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 47c7476d09..d098ab50d9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,7 +254,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.11.5) + oj (3.11.6) omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) From 5f8f139ac92256142194141921ca95e1ee315369 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 11 Jun 2021 16:09:07 +0800 Subject: [PATCH 043/403] PERF: Remove extra PG query. In `Theme.list_baked_fields`, common is always included as a target. --- lib/stylesheet/manager.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index 9c719efd93..4461cfbace 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -395,8 +395,7 @@ class Stylesheet::Manager def theme_digest if [:mobile_theme, :desktop_theme].include?(@target) - scss_digest = theme.resolve_baked_field(:common, :scss) - scss_digest += theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) + scss_digest = theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) elsif @target == :embedded_theme scss_digest = theme.resolve_baked_field(:common, :embedded_scss) else From 7fca7fb7ff09c9b53f82a7ee03bc4dfea384942d Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 15 Jun 2021 11:29:46 +1000 Subject: [PATCH 044/403] DEV: Add SMTP group ID to EmailLog (#13381) Adds a new `smtp_group_id` column to `EmailLog` which is filled in if the mail `from_address` matches a group's `email_username`. This is for easier debugging, so we know which emails have been sent via group SMTP. --- app/mailers/user_notifications.rb | 3 -- app/models/email_log.rb | 36 ++++++++++--------- ...14232334_add_smtp_group_id_to_email_log.rb | 7 ++++ lib/email/sender.rb | 7 ++++ spec/components/email/sender_spec.rb | 30 ++++++++++++++++ spec/fabricators/group_fabricator.rb | 9 +++++ 6 files changed, 73 insertions(+), 19 deletions(-) create mode 100644 db/migrate/20210614232334_add_smtp_group_id_to_email_log.rb diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index acd10a0451..a63c7b1447 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -546,9 +546,6 @@ class UserNotifications < ActionMailer::Base group.smtp_server, group.smtp_port, group.smtp_ssl, group.smtp_ssl ) - # TODO (martin): Remove this once testing is over and this is more stable. - Rails.logger.warn("Using SMTP settings from group #{group.name} (#{group.id}) to send user notification for topic #{post.topic.id} and user #{user.id} (#{user.email})") - delivery_method_options = { address: group.smtp_server, port: port, diff --git a/app/models/email_log.rb b/app/models/email_log.rb index 33c8ea12e7..0770698b75 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -16,6 +16,8 @@ class EmailLog < ActiveRecord::Base belongs_to :user belongs_to :post + belongs_to :smtp_group, class_name: 'Group' + has_one :topic, through: :post validates :email_type, :to_address, presence: true @@ -89,23 +91,25 @@ end # # Table name: email_logs # -# id :integer not null, primary key -# to_address :string not null -# email_type :string not null -# user_id :integer -# created_at :datetime not null -# updated_at :datetime not null -# post_id :integer -# bounce_key :uuid -# bounced :boolean default(FALSE), not null -# message_id :string +# id :integer not null, primary key +# to_address :string not null +# email_type :string not null +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# post_id :integer +# bounce_key :uuid +# bounced :boolean default(FALSE), not null +# message_id :string +# smtp_group_id :integer # # Indexes # -# index_email_logs_on_bounce_key (bounce_key) UNIQUE WHERE (bounce_key IS NOT NULL) -# index_email_logs_on_bounced (bounced) -# index_email_logs_on_created_at (created_at) -# index_email_logs_on_message_id (message_id) -# index_email_logs_on_post_id (post_id) -# index_email_logs_on_user_id (user_id) +# idx_email_logs_on_smtp_group_id (smtp_group_id) +# index_email_logs_on_bounce_key (bounce_key) UNIQUE WHERE (bounce_key IS NOT NULL) +# index_email_logs_on_bounced (bounced) +# index_email_logs_on_created_at (created_at) +# index_email_logs_on_message_id (message_id) +# index_email_logs_on_post_id (post_id) +# index_email_logs_on_user_id (user_id) # diff --git a/db/migrate/20210614232334_add_smtp_group_id_to_email_log.rb b/db/migrate/20210614232334_add_smtp_group_id_to_email_log.rb new file mode 100644 index 0000000000..bd177fdbb9 --- /dev/null +++ b/db/migrate/20210614232334_add_smtp_group_id_to_email_log.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSmtpGroupIdToEmailLog < ActiveRecord::Migration[6.1] + def change + add_column :email_logs, :smtp_group_id, :integer, null: true, index: true + end +end diff --git a/lib/email/sender.rb b/lib/email/sender.rb index ffd9707d32..6b634203fc 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -97,6 +97,7 @@ module Email post_id = header_value('X-Discourse-Post-Id') topic_id = header_value('X-Discourse-Topic-Id') reply_key = set_reply_key(post_id, user_id) + from_address = @message.from&.first # always set a default Message ID from the host @message.header['Message-ID'] = "<#{SecureRandom.uuid}@#{host}>" @@ -228,6 +229,12 @@ module Email email_log.message_id = @message.message_id + # Log when a message is being sent from a group SMTP address, so we + # can debug deliverability issues. + if from_address && smtp_group_id = Group.where(email_username: from_address, smtp_enabled: true).pluck_first(:id) + email_log.smtp_group_id = smtp_group_id + end + DiscourseEvent.trigger(:before_email_send, @message, @email_type) begin diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index bacf426dbd..603dd76bd3 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -337,6 +337,36 @@ describe Email::Sender do expect(email_log.to_address).to eq('eviltrout@test.domain') expect(email_log.user_id).to be_blank end + + context 'when the email is sent using group SMTP credentials' do + let(:reply) { Fabricate(:post, topic: post.topic, reply_to_user: post.user, reply_to_post_number: post.post_number) } + let(:notification) { Fabricate(:posted_notification, user: post.user, post: reply) } + let(:message) do + UserNotifications.user_private_message( + post.user, + post: reply, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + end + let(:group) { Fabricate(:smtp_group) } + + before do + SiteSetting.enable_smtp = true + end + + it 'adds the group id to the email log' do + TopicAllowedGroup.create(topic: post.topic, group: group) + + email_sender.send + + expect(email_log).to be_present + expect(email_log.email_type).to eq('valid_type') + expect(email_log.to_address).to eq(post.user.email) + expect(email_log.user_id).to be_blank + expect(email_log.smtp_group_id).to eq(group.id) + end + end end context "email log with a post id and topic id" do diff --git a/spec/fabricators/group_fabricator.rb b/spec/fabricators/group_fabricator.rb index da0ac73d7f..de9993d362 100644 --- a/spec/fabricators/group_fabricator.rb +++ b/spec/fabricators/group_fabricator.rb @@ -24,3 +24,12 @@ Fabricator(:imap_group, from: :group) do email_username "discourseteam@ponyexpress.com" email_password "test" end + +Fabricator(:smtp_group, from: :group) do + smtp_server "smtp.ponyexpress.com" + smtp_port 587 + smtp_ssl true + smtp_enabled true + email_username "discourseteam@ponyexpress.com" + email_password "test" +end From 96c14c196826ade44c3adb936ac94d293f896872 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 14 Jun 2021 22:30:36 -0400 Subject: [PATCH 045/403] FIX: Some absolute links were causing full page reloads (#13377) --- .../discourse-common/addon/lib/get-url.js | 18 +++++++++++------- .../discourse/app/lib/click-track.js | 7 ++----- .../tests/unit/lib/click-track-test.js | 12 ++++++++++++ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse-common/addon/lib/get-url.js b/app/assets/javascripts/discourse-common/addon/lib/get-url.js index ed6e455a60..fa1ba6b5c1 100644 --- a/app/assets/javascripts/discourse-common/addon/lib/get-url.js +++ b/app/assets/javascripts/discourse-common/addon/lib/get-url.js @@ -1,13 +1,6 @@ let cdn, baseUrl, baseUri, baseUriMatcher; let S3BaseUrl, S3CDN; -export function getBaseURI() { - if (baseUri === undefined) { - setPrefix($('meta[name="discourse-base-uri"]').attr("content") || ""); - } - return baseUri || "/"; -} - export default function getURL(url) { if (baseUri === undefined) { setPrefix($('meta[name="discourse-base-uri"]').attr("content") || ""); @@ -76,3 +69,14 @@ export function setupS3CDN(configS3BaseUrl, configS3CDN) { S3BaseUrl = configS3BaseUrl; S3CDN = configS3CDN; } + +// We can use this to identify when navigating on the same host but outside of the +// prefix directory. For example from `/forum` to `/about-us` which is not discourse +export function samePrefix(url) { + if (baseUri === undefined) { + setPrefix($('meta[name="discourse-base-uri"]').attr("content") || ""); + } + let origin = window.location.origin; + let cmp = url[0] === "/" ? baseUri || "/" : origin + baseUri || origin; + return url.indexOf(cmp) === 0; +} diff --git a/app/assets/javascripts/discourse/app/lib/click-track.js b/app/assets/javascripts/discourse/app/lib/click-track.js index e930f2e489..72d75169db 100644 --- a/app/assets/javascripts/discourse/app/lib/click-track.js +++ b/app/assets/javascripts/discourse/app/lib/click-track.js @@ -4,7 +4,7 @@ import { Promise } from "rsvp"; import User from "discourse/models/user"; import { ajax } from "discourse/lib/ajax"; import bootbox from "bootbox"; -import getURL, { getBaseURI } from "discourse-common/lib/get-url"; +import getURL, { samePrefix } from "discourse-common/lib/get-url"; import { isTesting } from "discourse-common/config/environment"; import { later } from "@ember/runloop"; import { selectedText } from "discourse/lib/utilities"; @@ -162,10 +162,7 @@ export default { openLinkInNewTab($link[0]); } else { trackPromise.finally(() => { - if ( - DiscourseURL.isInternal(href) && - href.indexOf(getBaseURI()) === 0 - ) { + if (DiscourseURL.isInternal(href) && samePrefix(href)) { DiscourseURL.routeTo(href); } else { DiscourseURL.redirectAbsolute(href); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js index 3327e9451b..43cf438d81 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/click-track-test.js @@ -49,6 +49,7 @@ module("Unit | Utility | click-track", function (hooks) { bar prefix link + prefix link diff prefix link ` @@ -97,6 +98,17 @@ module("Unit | Utility | click-track", function (hooks) { assert.ok(DiscourseURL.routeTo.calledWith("/forum/thing")); }); + test("routes to absolute internal urls", async function (assert) { + setPrefix("/forum"); + pretender.post("/clicks/track", () => [200, {}, ""]); + await track(generateClickEventOn(".abs-prefix-url"), null, { + returnPromise: true, + }); + assert.ok( + DiscourseURL.routeTo.calledWith(window.location.origin + "/forum/thing") + ); + }); + test("redirects to internal urls with a different prefix", async function (assert) { setPrefix("/forum"); sinon.stub(DiscourseURL, "redirectAbsolute"); From 9190e4390f117a8dff7e040e9cb29d1303bea986 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 15 Jun 2021 01:29:33 -0400 Subject: [PATCH 046/403] UX: Remove reference to contact form in setting (#13380) --- config/locales/server.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 56f93b813f..af1d8c4e46 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1497,7 +1497,7 @@ en: title: "The name of this site, as used in the title tag." site_description: "Describe this site in one sentence, as used in the meta description tag." short_site_description: "Short description, as used in the title tag on homepage." - contact_email: "Email address of key contact responsible for this site. Used for critical notifications, as well as on the /about contact form for urgent matters." + contact_email: "Email address of key contact responsible for this site. Used for critical notifications, and also displayed on /about for urgent matters." contact_url: "Contact URL for this site. Displayed on the /about page for urgent matters." crawl_images: "Retrieve images from remote URLs to insert the correct width and height dimensions." download_remote_images_to_local: "Convert remote images to local images by downloading them; this prevents broken images." From ff4fb9c77119ec7e6b2091078b4ff405b8e5a629 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Tue, 15 Jun 2021 08:32:41 +0300 Subject: [PATCH 047/403] DEV: Add plugin API to extend search results (#12966) --- .../addon/routes/admin-search-logs-term.js | 6 ++- .../app/controllers/full-page-search.js | 4 +- .../discourse/app/lib/plugin-api.js | 18 ++++++- .../javascripts/discourse/app/lib/search.js | 49 ++++++++++++------- .../discourse/app/routes/full-page-search.js | 4 +- .../discourse/app/widgets/search-menu.js | 8 +-- .../discourse/tests/unit/lib/search-test.js | 4 +- app/serializers/search_post_serializer.rb | 4 ++ 8 files changed, 67 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js b/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js index b9613fe04f..9847f44d19 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js +++ b/app/assets/javascripts/admin/addon/routes/admin-search-logs-term.js @@ -20,7 +20,7 @@ export default DiscourseRoute.extend({ search_type: params.searchType, term: params.term, }, - }).then((json) => { + }).then(async (json) => { // Add zero values for missing dates if (json.term.data.length > 0) { const startDate = @@ -31,7 +31,9 @@ export default DiscourseRoute.extend({ json.term.data = fillMissingDates(json.term.data, startDate, endDate); } if (json.term.search_result) { - json.term.search_result = translateResults(json.term.search_result); + json.term.search_result = await translateResults( + json.term.search_result + ); } const model = EmberObject.create({ type: "search_log_term" }); diff --git a/app/assets/javascripts/discourse/app/controllers/full-page-search.js b/app/assets/javascripts/discourse/app/controllers/full-page-search.js index a7ba222f8c..4fdd54a6fc 100644 --- a/app/assets/javascripts/discourse/app/controllers/full-page-search.js +++ b/app/assets/javascripts/discourse/app/controllers/full-page-search.js @@ -245,8 +245,8 @@ export default Controller.extend({ const searchKey = getSearchKey(args); ajax("/search", { data: args }) - .then((results) => { - const model = translateResults(results) || {}; + .then(async (results) => { + const model = (await translateResults(results)) || {}; if (results.grouped_search_result) { this.set("q", results.grouped_search_result.term); diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 5f8a2ff4c4..5c25a4a0ae 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -72,9 +72,10 @@ import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-b import { replaceFormatter } from "discourse/lib/utilities"; import { replaceTagRenderer } from "discourse/lib/render-tag"; import { setNewCategoryDefaultColors } from "discourse/routes/new-category"; +import { addSearchResultsCallback } from "discourse/lib/search"; // If you add any methods to the API ensure you bump up this number -const PLUGIN_API_VERSION = "0.11.4"; +const PLUGIN_API_VERSION = "0.11.5"; class PluginApi { constructor(version, container) { @@ -1281,6 +1282,21 @@ class PluginApi { setNewCategoryDefaultColors(backgroundColor, textColor) { setNewCategoryDefaultColors(backgroundColor, textColor); } + + /** + * Add a callback to modify search results before displaying them. + * + * ``` + * api.addSearchResultsCallback((results) => { + * results.topics.push(Topic.create({ ... })); + * return results; + * }); + * ``` + * + */ + addSearchResultsCallback(callback) { + addSearchResultsCallback(callback); + } } // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js index e5b2002f0b..635b16f592 100644 --- a/app/assets/javascripts/discourse/app/lib/search.js +++ b/app/assets/javascripts/discourse/app/lib/search.js @@ -1,6 +1,7 @@ import Category from "discourse/models/category"; import EmberObject from "@ember/object"; import I18n from "I18n"; +import { Promise } from "rsvp"; import Post from "discourse/models/post"; import Topic from "discourse/models/topic"; import User from "discourse/models/user"; @@ -15,6 +16,12 @@ import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import { userPath } from "discourse/lib/url"; import userSearch from "discourse/lib/user-search"; +const translateResultsCallbacks = []; + +export function addSearchResultsCallback(callback) { + translateResultsCallbacks.push(callback); +} + export function translateResults(results, opts) { opts = opts || {}; @@ -84,9 +91,29 @@ export function translateResults(results, opts) { }) .compact(); - results.resultTypes = []; + return translateResultsCallbacks + .reduce( + (promise, callback) => promise.then((r) => callback(r)), + Promise.resolve(results) + ) + .then((results_) => { + translateGroupedSearchResults(results_, opts); - // TODO: consider refactoring front end to take a better structure + if ( + !results_.topics.length && + !results_.posts.length && + !results_.users.length && + !results_.categories.length + ) { + return null; + } + + return EmberObject.create(results_); + }); +} + +function translateGroupedSearchResults(results, opts) { + results.resultTypes = []; const groupedSearchResult = results.grouped_search_result; if (groupedSearchResult) { [ @@ -121,15 +148,6 @@ export function translateResults(results, opts) { } }); } - - const noResults = !!( - !results.topics.length && - !results.posts.length && - !results.users.length && - !results.categories.length - ); - - return noResults ? null : EmberObject.create(results); } export function searchForTerm(term, opts) { @@ -157,12 +175,9 @@ export function searchForTerm(term, opts) { }; } - let promise = ajax("/search/query", { data: data }); - - promise.then((results) => { - return translateResults(results, opts); - }); - + let ajaxPromise = ajax("/search/query", { data }); + const promise = ajaxPromise.then((res) => translateResults(res, opts)); + promise.abort = ajaxPromise.abort; return promise; } diff --git a/app/assets/javascripts/discourse/app/routes/full-page-search.js b/app/assets/javascripts/discourse/app/routes/full-page-search.js index 3c4fe84715..71ccb8fdbd 100644 --- a/app/assets/javascripts/discourse/app/routes/full-page-search.js +++ b/app/assets/javascripts/discourse/app/routes/full-page-search.js @@ -52,11 +52,11 @@ export default DiscourseRoute.extend({ } else { return null; } - }).then((results) => { + }).then(async (results) => { const grouped_search_result = results ? results.grouped_search_result : {}; - const model = (results && translateResults(results)) || { + const model = (results && (await translateResults(results))) || { grouped_search_result, }; setTransient("lastSearch", { searchKey, model }, 5); diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js index 28d8ce506b..246aeb4626 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js @@ -55,12 +55,12 @@ const SearchHelper = { fullSearchUrl, }); this._activeSearch - .then((content) => { + .then((results) => { // we ensure the current search term is the one used // when starting the query - if (term === searchData.term) { - searchData.noResults = content.resultTypes.length === 0; - searchData.results = content; + if (results && term === searchData.term) { + searchData.noResults = results.resultTypes.length === 0; + searchData.results = results; if (searchContext && searchContext.type === "topic") { widget.appEvents.trigger("post-stream:refresh", { force: true }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/search-test.js b/app/assets/javascripts/discourse/tests/unit/lib/search-test.js index 61f9876011..af28d03a39 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/search-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/search-test.js @@ -6,7 +6,7 @@ import { import I18n from "I18n"; module("Unit | Utility | search", function () { - test("unescapesEmojisInBlurbs", function (assert) { + test("unescapesEmojisInBlurbs", async function (assert) { const source = { posts: [ { @@ -28,7 +28,7 @@ module("Unit | Utility | search", function () { grouped_search_result: false, }; - const results = translateResults(source); + const results = await translateResults(source); const blurb = results.posts[0].get("blurb"); assert.ok(blurb.indexOf("thinking.png")); diff --git a/app/serializers/search_post_serializer.rb b/app/serializers/search_post_serializer.rb index bca2f7e04e..4d58c06ae2 100644 --- a/app/serializers/search_post_serializer.rb +++ b/app/serializers/search_post_serializer.rb @@ -21,6 +21,10 @@ class SearchPostSerializer < BasicPostSerializer options[:result].blurb(object) end + def include_blurb? + options[:result].present? + end + def include_cooked? false end From cc1e73b8e49e0c00def28d8635ace684521d36f0 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 15 Jun 2021 16:40:52 +1000 Subject: [PATCH 048/403] FIX: refresh post stream after in-place post updates (#13384) Changing the staged attribute on a post means we also need to re-render. Previously certain edits would not issue a refresh leaving a post greyed out. --- app/assets/javascripts/discourse/app/models/composer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js index 3b6c891148..8aa68daaa6 100644 --- a/app/assets/javascripts/discourse/app/models/composer.js +++ b/app/assets/javascripts/discourse/app/models/composer.js @@ -992,6 +992,7 @@ const Composer = RestModel.extend({ .catch(rollback) .finally(() => { post.set("staged", false); + this.appEvents.trigger("post-stream:refresh", { id: post.id }); }); }, From 9b200aba161787a2499df1dfa31acb37d093820b Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Tue, 15 Jun 2021 15:27:43 +0530 Subject: [PATCH 049/403] FIX: respect nofollow exclusion setting in topic featured links. (#11858) Previously, nofollow attribute is not removed even when a domain is added to the `exclude_rel_nofollow_domains` site setting. --- .../app/lib/render-topic-featured-link.js | 14 ++++++- .../discourse/tests/acceptance/topic-test.js | 39 +++++++++++++++++++ .../discourse/tests/fixtures/topic.js | 1 + config/site_settings.yml | 1 + 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/render-topic-featured-link.js b/app/assets/javascripts/discourse/app/lib/render-topic-featured-link.js index 5c3f0b4b24..45448178fd 100644 --- a/app/assets/javascripts/discourse/app/lib/render-topic-featured-link.js +++ b/app/assets/javascripts/discourse/app/lib/render-topic-featured-link.js @@ -11,6 +11,16 @@ export function addFeaturedLinkMetaDecorator(decorator) { export function extractLinkMeta(topic) { const href = topic.get("featured_link"); const target = User.currentProp("external_links_in_new_tab") ? "_blank" : ""; + const domain = topic.get("featured_link_root_domain"); + let allowList = topic.siteSettings.exclude_rel_nofollow_domains; + let rel = "nofollow ugc"; + + if (allowList) { + allowList = allowList.split("|"); + if (allowList.includes(domain)) { + rel = rel.replace("nofollow ", ""); + } + } if (!href) { return; @@ -19,8 +29,8 @@ export function extractLinkMeta(topic) { const meta = { target: target, href, - domain: topic.get("featured_link_root_domain"), - rel: "nofollow ugc", + domain: domain, + rel: rel, }; if (_decorators.length) { diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js index b3661ee4cb..061bb13851 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-test.js @@ -259,6 +259,45 @@ acceptance("Topic", function (needs) { assert.ok(exists(".category-moderator"), "it has a class applied"); assert.ok(exists(".d-icon-shield-alt"), "it shows an icon"); }); +}); + +acceptance("Topic featured links", function (needs) { + needs.user(); + needs.settings({ + topic_featured_link_enabled: true, + max_topic_title_length: 80, + exclude_rel_nofollow_domains: "example.com", + }); + + test("remove nofollow attribute", async function (assert) { + await visit("/t/-/299/1"); + + const link = queryAll(".title-wrapper .topic-featured-link"); + assert.equal(link.text(), " example.com"); + assert.equal(link.attr("rel"), "ugc"); + }); + + test("remove featured link", async function (assert) { + await visit("/t/-/299/1"); + assert.ok( + exists(".title-wrapper .topic-featured-link"), + "link is shown with topic title" + ); + + await click(".title-wrapper .edit-topic"); + assert.ok( + exists(".title-wrapper .remove-featured-link"), + "link to remove featured link" + ); + + // TODO: decide if we want to test this, test is flaky so it + // was commented out. + // If not fixed by May 2021, delete this code block + // + //await click(".title-wrapper .remove-featured-link"); + //await click(".title-wrapper .submit-edit"); + //assert.ok(!exists(".title-wrapper .topic-featured-link"), "link is gone"); + }); test("Converting to a public topic", async function (assert) { await visit("/t/test-pm/34"); diff --git a/app/assets/javascripts/discourse/tests/fixtures/topic.js b/app/assets/javascripts/discourse/tests/fixtures/topic.js index 4196457e6b..e37e505657 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/topic.js +++ b/app/assets/javascripts/discourse/tests/fixtures/topic.js @@ -4648,6 +4648,7 @@ export default { pinned_at: null, pinned_until: null, featured_link: "http://www.example.com/has-title.html", + featured_link_root_domain: "example.com", details: { auto_close_at: null, auto_close_hours: null, diff --git a/config/site_settings.yml b/config/site_settings.yml index da7b823e98..6a64b90ad9 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -47,6 +47,7 @@ required: default: "" type: group exclude_rel_nofollow_domains: + client: true default: "" type: list list_type: simple From 2be201660a4c2fef5a714a94fe9f66341f9a09f0 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Tue, 15 Jun 2021 11:59:03 +0200 Subject: [PATCH 050/403] DEV: skips three tests following cc1e73 (#13386) * DEV: skips two tests following cc1e73 Following the fix in https://github.com/discourse/discourse/commit/cc1e73b8e49e0c00def28d8635ace684521d36f0 we now refresh the whole stream which causes expected states of these tests to not exist anymore. I'm skipping theses tests while we decide for a better fix. --- .../acceptance/composer-edit-conflict-test.js | 37 ++++++++++--------- .../tests/acceptance/composer-test.js | 33 +++++++++-------- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js index 6eb7be8235..96f970237a 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js @@ -14,23 +14,26 @@ acceptance("Composer - Edit conflict", function (needs) { }); }); - test("Edit a post that causes an edit conflict", async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await click("#reply-control button.create"); - assert.equal( - queryAll("#reply-control button.create").text().trim(), - I18n.t("composer.overwrite_edit"), - "it shows the overwrite button" - ); - assert.ok( - queryAll("#draft-status .d-icon-user-edit"), - "error icon should be there" - ); - await click(".modal .btn-primary"); - }); + QUnit.skip( + "Edit a post that causes an edit conflict", + async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); + await fillIn(".d-editor-input", "this will 409"); + await click("#reply-control button.create"); + assert.equal( + queryAll("#reply-control button.create").text().trim(), + I18n.t("composer.overwrite_edit"), + "it shows the overwrite button" + ); + assert.ok( + queryAll("#draft-status .d-icon-user-edit"), + "error icon should be there" + ); + await click(".modal .btn-primary"); + } + ); test("Should not send originalText when posting a new reply", async function (assert) { await visit("/t/internationalization-localization/280"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index 8839d170b7..5fc43fee68 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -348,7 +348,7 @@ acceptance("Composer", function (needs) { ); }); - test("Editing a post stages new content", async function (assert) { + QUnit.skip("Editing a post stages new content", async function (assert) { await visit("/t/internationalization-localization/280"); await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".topic-post:nth-of-type(1) button.edit"); @@ -367,23 +367,26 @@ acceptance("Composer", function (needs) { ); }); - test("Editing a post can rollback to old content", async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); + QUnit.skip( + "Editing a post can rollback to old content", + async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await fillIn("#reply-title", "This is the new text for the title"); - await click("#reply-control button.create"); + await fillIn(".d-editor-input", "this will 409"); + await fillIn("#reply-title", "This is the new text for the title"); + await click("#reply-control button.create"); - assert.ok(!exists(".topic-post.staged")); - assert.equal( - find(".topic-post .cooked")[0].innerText, - "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" - ); + assert.ok(!exists(".topic-post.staged")); + assert.equal( + find(".topic-post .cooked")[0].innerText, + "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" + ); - await click(".bootbox.modal .btn-primary"); - }); + await click(".bootbox.modal .btn-primary"); + } + ); test("Composer can switch between edits", async function (assert) { await visit("/t/this-is-a-test-topic/9"); From 00255d0bd215d4dec831c16b460f1d301c1fc6dc Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Tue, 15 Jun 2021 16:54:00 +0400 Subject: [PATCH 051/403] FEATURE: make date pickers in the bookmarks UI and topic timer UI consistent with all other pickers (#13365) Next Week should mean next Monday, Next Month - the first day of the next month, and so on. Also, we'll be using the name "Next Monday" instead of "Next Week" because it's easier to understand. No one can get confused by next Monday. --- .../app/components/edit-topic-timer-form.js | 19 +++--- .../app/components/time-shortcut-picker.js | 4 -- .../controllers/keyboard-shortcuts-help.js | 4 -- .../discourse/app/lib/time-shortcut.js | 9 --- .../discourse/app/lib/time-utils.js | 8 +-- .../modal/keyboard-shortcuts-help.hbs | 1 - .../tests/acceptance/bookmarks-test.js | 4 -- .../tests/acceptance/topic-edit-timer-test.js | 58 +++++++++++++++--- .../integration/components/bookmark-test.js | 59 ++++++++++++++++++- .../tests/unit/lib/time-utils-test.js | 9 +-- config/locales/client.en.yml | 4 +- 11 files changed, 124 insertions(+), 55 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js b/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js index cb3ad39523..65ab8b6b92 100644 --- a/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js +++ b/app/assets/javascripts/discourse/app/components/edit-topic-timer-form.js @@ -14,7 +14,12 @@ import I18n from "I18n"; import { action } from "@ember/object"; import Component from "@ember/component"; import { isEmpty } from "@ember/utils"; -import { now, startOfDay, thisWeekend } from "discourse/lib/time-utils"; +import { + MOMENT_MONDAY, + now, + startOfDay, + thisWeekend, +} from "discourse/lib/time-utils"; import KeyboardShortcuts from "discourse/lib/keyboard-shortcuts"; import Mousetrap from "mousetrap"; @@ -82,22 +87,22 @@ export default Component.extend({ { icon: "bed", id: "this_weekend", - label: "topic.auto_update_input.this_weekend", + label: "time_shortcut.this_weekend", time: thisWeekend(), timeFormatKey: "dates.time_short_day", }, { icon: "far-clock", id: "two_weeks", - label: "topic.auto_update_input.two_weeks", - time: startOfDay(now().add(2, "weeks")), + label: "time_shortcut.two_weeks", + time: startOfDay(now().add(2, "weeks").day(MOMENT_MONDAY)), timeFormatKey: "dates.long_no_year", }, { icon: "far-calendar-plus", id: "six_months", - label: "topic.auto_update_input.six_months", - time: startOfDay(now().add(6, "months")), + label: "time_shortcut.six_months", + time: startOfDay(now().add(6, "months").startOf("month")), timeFormatKey: "dates.long_no_year", }, ]; @@ -105,7 +110,7 @@ export default Component.extend({ @discourseComputed hiddenTimeShortcutOptions() { - return ["none", "start_of_next_business_week"]; + return ["none"]; }, isCustom: equal("timerType", "custom"), diff --git a/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js index 69d4a436d5..7733cbab85 100644 --- a/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js +++ b/app/assets/javascripts/discourse/app/components/time-shortcut-picker.js @@ -32,10 +32,6 @@ const BINDINGS = { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.TOMORROW], }, - "n w": { - handler: "selectShortcut", - args: [TIME_SHORTCUT_TYPES.NEXT_WEEK], - }, "n b w": { handler: "selectShortcut", args: [TIME_SHORTCUT_TYPES.START_OF_NEXT_BUSINESS_WEEK], diff --git a/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js b/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js index f717a0fe79..eaf65105b3 100644 --- a/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js +++ b/app/assets/javascripts/discourse/app/controllers/keyboard-shortcuts-help.js @@ -151,10 +151,6 @@ export default Controller.extend(ModalFunctionality, { keys1: ["n", "d"], shortcutsDelimiter: "space", }), - next_week: buildShortcut("bookmarks.next_week", { - keys1: ["n", "w"], - shortcutsDelimiter: "space", - }), next_business_week: buildShortcut("bookmarks.next_business_week", { keys1: ["n", "b", "w"], shortcutsDelimiter: "space", diff --git a/app/assets/javascripts/discourse/app/lib/time-shortcut.js b/app/assets/javascripts/discourse/app/lib/time-shortcut.js index ddb4af4493..d2eac0b9c7 100644 --- a/app/assets/javascripts/discourse/app/lib/time-shortcut.js +++ b/app/assets/javascripts/discourse/app/lib/time-shortcut.js @@ -4,7 +4,6 @@ import { laterToday, nextBusinessWeekStart, nextMonth, - nextWeek, now, tomorrow, } from "discourse/lib/time-utils"; @@ -13,7 +12,6 @@ import I18n from "I18n"; export const TIME_SHORTCUT_TYPES = { LATER_TODAY: "later_today", TOMORROW: "tomorrow", - NEXT_WEEK: "next_week", NEXT_MONTH: "next_month", CUSTOM: "custom", RELATIVE: "relative", @@ -63,13 +61,6 @@ export function defaultShortcutOptions(timezone) { I18n.t("dates.long_no_year") ), }, - { - icon: "far-clock", - id: TIME_SHORTCUT_TYPES.NEXT_WEEK, - label: "time_shortcut.next_week", - time: nextWeek(timezone), - timeFormatted: nextWeek(timezone).format(I18n.t("dates.long_no_year")), - }, { icon: "far-calendar-plus", id: TIME_SHORTCUT_TYPES.NEXT_MONTH, diff --git a/app/assets/javascripts/discourse/app/lib/time-utils.js b/app/assets/javascripts/discourse/app/lib/time-utils.js index 25976725cb..1f765ffdc0 100644 --- a/app/assets/javascripts/discourse/app/lib/time-utils.js +++ b/app/assets/javascripts/discourse/app/lib/time-utils.js @@ -37,16 +37,12 @@ export function laterThisWeek(timezone) { return startOfDay(now(timezone).add(2, "days")); } -export function nextWeek(timezone) { - return startOfDay(now(timezone).add(7, "days")); -} - export function nextMonth(timezone) { - return startOfDay(now(timezone).add(1, "month")); + return startOfDay(now(timezone).add(1, "month").startOf("month")); } export function nextBusinessWeekStart(timezone) { - return nextWeek(timezone).day(MOMENT_MONDAY); + return startOfDay(now(timezone).add(7, "days")).day(MOMENT_MONDAY); } export function parseCustomDatetime( diff --git a/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs b/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs index 514080379d..9d3b182ebc 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/keyboard-shortcuts-help.hbs @@ -64,7 +64,6 @@
  • {{html-safe shortcuts.bookmarks.later_today}}
  • {{html-safe shortcuts.bookmarks.later_this_week}}
  • {{html-safe shortcuts.bookmarks.tomorrow}}
  • -
  • {{html-safe shortcuts.bookmarks.next_week}}
  • {{html-safe shortcuts.bookmarks.next_month}}
  • {{html-safe shortcuts.bookmarks.next_business_week}}
  • {{html-safe shortcuts.bookmarks.next_business_day}}
  • diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js index daf77fffde..bab0ff7613 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js @@ -80,9 +80,6 @@ acceptance("Bookmarking", function (needs) { await openBookmarkModal(); await click("#tap_tile_start_of_next_business_week"); - await openBookmarkModal(); - await click("#tap_tile_next_week"); - await openBookmarkModal(); await click("#tap_tile_next_month"); @@ -96,7 +93,6 @@ acceptance("Bookmarking", function (needs) { assert.deepEqual(steps, [ "tomorrow", "start_of_next_business_week", - "next_week", "next_month", "custom", ]); diff --git a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js index 0e3a35acb0..b06464376e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/topic-edit-timer-test.js @@ -1,14 +1,18 @@ import { acceptance, exists, + fakeTime, + loggedInUser, queryAll, updateCurrentUser, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; import { test } from "qunit"; import selectKit from "discourse/tests/helpers/select-kit-helper"; +import I18n from "I18n"; acceptance("Topic - Edit timer", function (needs) { + let clock = null; needs.user(); needs.pretender((server, helper) => { server.post("/t/280/timer", () => @@ -25,13 +29,23 @@ acceptance("Topic - Edit timer", function (needs) { ); }); + needs.hooks.beforeEach(() => { + const timezone = loggedInUser().resolvedTimezone(loggedInUser()); + const tuesday = "2100-06-15T08:00:00"; + clock = fakeTime(tuesday, timezone, true); + }); + + needs.hooks.afterEach(() => { + clock.restore(); + }); + test("autoclose - specific time", async function (assert) { updateCurrentUser({ moderator: true }); await visit("/t/internationalization-localization"); await click(".toggle-admin-menu"); await click(".admin-topic-timer-update button"); - await click("#tap_tile_next_week"); + await click("#tap_tile_start_of_next_business_week"); const regex = /will automatically close in/g; const html = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -47,7 +61,7 @@ acceptance("Topic - Edit timer", function (needs) { await click(".toggle-admin-menu"); await click(".admin-topic-timer-update button"); - await click("#tap_tile_next_week"); + await click("#tap_tile_start_of_next_business_week"); const regex1 = /will automatically close in/g; const html1 = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -56,7 +70,7 @@ acceptance("Topic - Edit timer", function (needs) { assert.ok(regex1.test(html1)); await click("#tap_tile_custom"); - await fillIn(".tap-tile-date-input .date-picker", "2099-11-24"); + await fillIn(".tap-tile-date-input .date-picker", "2100-11-24"); const regex2 = /will automatically close in/g; const html2 = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -89,7 +103,7 @@ acceptance("Topic - Edit timer", function (needs) { await timerType.expand(); await timerType.selectRowByValue("open"); - await click("#tap_tile_next_week"); + await click("#tap_tile_start_of_next_business_week"); const regex1 = /will automatically open in/g; const html1 = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -98,7 +112,7 @@ acceptance("Topic - Edit timer", function (needs) { assert.ok(regex1.test(html1)); await click("#tap_tile_custom"); - await fillIn(".tap-tile-date-input .date-picker", "2099-11-24"); + await fillIn(".tap-tile-date-input .date-picker", "2100-11-24"); const regex2 = /will automatically open in/g; const html2 = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -125,7 +139,7 @@ acceptance("Topic - Edit timer", function (needs) { await categoryChooser.expand(); await categoryChooser.selectRowByValue("7"); - await click("#tap_tile_next_week"); + await click("#tap_tile_start_of_next_business_week"); const regex = /will be published to #dev/g; const text = queryAll(".edit-topic-timer-modal .topic-timer-info") @@ -153,7 +167,7 @@ acceptance("Topic - Edit timer", function (needs) { ); await click("#tap_tile_custom"); - await fillIn(".tap-tile-date-input .date-picker", "2099-11-24"); + await fillIn(".tap-tile-date-input .date-picker", "2100-11-24"); await fillIn("#custom-time", "10:30"); await click(".edit-topic-timer-buttons button.btn-primary"); @@ -164,7 +178,7 @@ acceptance("Topic - Edit timer", function (needs) { exists("#tap_tile_last_custom"), "it show last custom because the custom date and time was valid" ); - let text = queryAll("#tap_tile_last_custom").text().trim(); + const text = queryAll("#tap_tile_last_custom").text().trim(); const regex = /Nov 24, 10:30 am/g; assert.ok(regex.test(text)); }); @@ -209,7 +223,7 @@ acceptance("Topic - Edit timer", function (needs) { await visit("/t/internationalization-localization"); await click(".toggle-admin-menu"); await click(".admin-topic-timer-update button"); - await click("#tap_tile_next_week"); + await click("#tap_tile_start_of_next_business_week"); await click(".edit-topic-timer-buttons button.btn-primary"); const removeTimerButton = queryAll(".topic-timer-info .topic-timer-remove"); @@ -219,4 +233,30 @@ acceptance("Topic - Edit timer", function (needs) { const topicTimerInfo = queryAll(".topic-timer-info .topic-timer-remove"); assert.equal(topicTimerInfo.length, 0); }); + + test("Shows correct time frame options", async function (assert) { + updateCurrentUser({ moderator: true }); + + await visit("/t/internationalization-localization"); + await click(".toggle-admin-menu"); + await click(".admin-topic-timer-update button"); + + const expected = [ + I18n.t("time_shortcut.tomorrow"), + I18n.t("time_shortcut.this_weekend"), + I18n.t("time_shortcut.start_of_next_business_week"), + I18n.t("time_shortcut.two_weeks"), + I18n.t("time_shortcut.next_month"), + I18n.t("time_shortcut.six_months"), + I18n.t("time_shortcut.custom"), + ]; + + const options = Array.from( + queryAll("div.tap-tile-grid div.tap-tile-title").map((_, div) => + div.innerText.trim() + ) + ); + + assert.deepEqual(options, expected); + }); }); diff --git a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js index ba6d71d343..4a637123c6 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js @@ -5,13 +5,20 @@ import { discourseModule, fakeTime, query, + queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import I18n from "I18n"; discourseModule("Integration | Component | bookmark", function (hooks) { setupRenderingTest(hooks); - let template = - '{{bookmark model=model afterSave=afterSave afterDelete=afterDelete onCloseWithoutSaving=onCloseWithoutSaving registerOnCloseHandler=(action "registerOnCloseHandler") closeModal=(action "closeModal")}}'; + const template = `{{bookmark + model=model + afterSave=afterSave + afterDelete=afterDelete + onCloseWithoutSaving=onCloseWithoutSaving + registerOnCloseHandler=(action "registerOnCloseHandler") + closeModal=(action "closeModal")}}`; hooks.beforeEach(function () { this.actions.registerOnCloseHandler = () => {}; @@ -30,6 +37,35 @@ discourseModule("Integration | Component | bookmark", function (hooks) { } }); + componentTest("shows correct options", { + template, + + beforeEach() { + const tuesday = "2100-06-08T08:00:00"; + this.clock = fakeTime(tuesday, this.currentUser._timezone, true); + }, + + async test(assert) { + const expected = [ + I18n.t("time_shortcut.later_today"), + I18n.t("time_shortcut.tomorrow"), + I18n.t("time_shortcut.later_this_week"), + I18n.t("time_shortcut.start_of_next_business_week"), + I18n.t("time_shortcut.next_month"), + I18n.t("time_shortcut.custom"), + I18n.t("time_shortcut.none"), + ]; + + const options = Array.from( + queryAll( + "div.control-group div.tap-tile-grid div.tap-tile-title" + ).map((_, div) => div.innerText.trim()) + ); + + assert.deepEqual(options, expected); + }, + }); + componentTest("show later this week option if today is < Thursday", { template, @@ -156,4 +192,23 @@ discourseModule("Integration | Component | bookmark", function (hooks) { assert.equal(query("#custom-time").value, "08:00"); }, }); + + componentTest("Next Month points to the first day of the next month", { + template, + + beforeEach() { + this.clock = fakeTime( + "2100-01-01T08:00:00", + this.currentUser._timezone, + true + ); + }, + + async test(assert) { + assert.equal( + query("div#tap_tile_next_month div.tap-tile-date").innerText, + "Feb 1, 8:00 am" + ); + }, + }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js b/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js index 6cfca1c043..5b84924527 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/time-utils-test.js @@ -7,7 +7,6 @@ import { laterThisWeek, laterToday, nextMonth, - nextWeek, startOfDay, tomorrow, } from "discourse/lib/time-utils"; @@ -16,15 +15,9 @@ import { test } from "qunit"; const timezone = "Australia/Brisbane"; discourseModule("Unit | lib | timeUtils", function () { - test("nextWeek gets next week correctly", function (assert) { - withFrozenTime("2019-12-11T08:00:00", timezone, () => { - assert.equal(nextWeek(timezone).format("YYYY-MM-DD"), "2019-12-18"); - }); - }); - test("nextMonth gets next month correctly", function (assert) { withFrozenTime("2019-12-11T08:00:00", timezone, () => { - assert.equal(nextMonth(timezone).format("YYYY-MM-DD"), "2020-01-11"); + assert.equal(nextMonth(timezone).format("YYYY-MM-DD"), "2020-01-01"); }); }); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6fc103c886..6eda56e15b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -596,12 +596,14 @@ en: later_today: "Later today" next_business_day: "Next business day" tomorrow: "Tomorrow" - next_week: "Next week" post_local_date: "Date in post" later_this_week: "Later this week" + this_weekend: "This weekend" start_of_next_business_week: "Monday" start_of_next_business_week_alt: "Next Monday" + two_weeks: "Two weeks" next_month: "Next month" + six_months: "Six months" custom: "Custom date and time" relative: "Relative time" none: "None needed" From fa57316a4e4545744178f1fba97f794186f89ac6 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 15 Jun 2021 10:10:03 -0300 Subject: [PATCH 052/403] FIX: Validate upload is still valid after calling the "before_upload_creation" event (#13091) Since we use the event to perform additional validations on the file, we should check if it added any errors to the upload before saving it. This change makes the UploadCreator more consistent since we no longer have to rely on exceptions. --- lib/upload_creator.rb | 7 ++++--- spec/lib/upload_creator_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index 9e4f961603..942b891012 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -153,9 +153,10 @@ class UploadCreator @upload.assign_attributes(attrs) end - return @upload unless @upload.save(validate: @opts[:validate]) - - DiscourseEvent.trigger(:before_upload_creation, @file, is_image, @opts[:for_export]) + # Callbacks using this event to validate the upload or the file must add errors to the + # upload errors object. + DiscourseEvent.trigger(:before_upload_creation, @file, is_image, @upload, @opts[:validate]) + return @upload unless @upload.errors.empty? && @upload.save(validate: @opts[:validate]) # store the file and update its url File.open(@file.path) do |f| diff --git a/spec/lib/upload_creator_spec.rb b/spec/lib/upload_creator_spec.rb index 3fafb3225e..a9662ed303 100644 --- a/spec/lib/upload_creator_spec.rb +++ b/spec/lib/upload_creator_spec.rb @@ -599,4 +599,30 @@ RSpec.describe UploadCreator do end end end + + describe 'before_upload_creation event' do + let(:filename) { "logo.jpg" } + let(:file) { file_from_fixtures(filename) } + + before do + setup_s3 + stub_s3_store + end + + it 'does not save the upload if an event added errors to the upload' do + error = 'This upload is invalid' + + event = Proc.new do |file, is_image, upload| + upload.errors.add(:base, error) + end + + DiscourseEvent.on(:before_upload_creation, &event) + + created_upload = UploadCreator.new(file, filename).create_for(user.id) + + expect(created_upload.persisted?).to eq(false) + expect(created_upload.errors).to contain_exactly(error) + DiscourseEvent.off(:before_upload_creation, &event) + end + end end From 365d33998595963f4d05c9ade33ee5328b2bd5aa Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Tue, 15 Jun 2021 19:08:55 +0530 Subject: [PATCH 053/403] DEV: fix Flarum import script (#13385) --- script/import_scripts/flarum_import.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/script/import_scripts/flarum_import.rb b/script/import_scripts/flarum_import.rb index bd96ad1eec..ea4eb3dddf 100644 --- a/script/import_scripts/flarum_import.rb +++ b/script/import_scripts/flarum_import.rb @@ -39,7 +39,7 @@ class ImportScripts::FLARUM < ImportScripts::Base batches(BATCH_SIZE) do |offset| results = mysql_query( - "SELECT id, username, email, join_time created_at,last_seen_time last_visit_time, password + "SELECT id, username, email, joined_at, last_seen_at FROM users LIMIT #{BATCH_SIZE} OFFSET #{offset};") @@ -53,9 +53,9 @@ class ImportScripts::FLARUM < ImportScripts::Base email: user['email'], username: user['username'], name: user['username'], - created_at: user['created_at'], - last_seen_at: user['last_visit_time'], - password: user['password'] } + created_at: user['joined_at'], + last_seen_at: user['last_seen_at'] + } end end end @@ -106,13 +106,13 @@ class ImportScripts::FLARUM < ImportScripts::Base d.first_post_id first_post_id, p.user_id user_id, p.content raw, - p.created_at created_at - t.tag_id category_id, + p.created_at created_at, + t.tag_id category_id FROM posts p, discussions d, - discussion_tag t, - + discussion_tag t WHERE p.discussion_id = d.id + AND t.discussion_id = d.id ORDER BY p.created_at LIMIT #{BATCH_SIZE} OFFSET #{offset}; From bfeaf75cd5660ea8e7fe2b020af26e0e9cab444b Mon Sep 17 00:00:00 2001 From: Discourse Translator Bot Date: Tue, 15 Jun 2021 16:00:08 +0200 Subject: [PATCH 054/403] Update translations (#13388) --- config/locales/client.ar.yml | 4 +- config/locales/client.be.yml | 2 + config/locales/client.bg.yml | 4 +- config/locales/client.bs_BA.yml | 5 +- config/locales/client.ca.yml | 4 +- config/locales/client.cs.yml | 4 +- config/locales/client.da.yml | 7 ++- config/locales/client.de.yml | 6 +- config/locales/client.el.yml | 5 +- config/locales/client.es.yml | 106 ++++++++++++++++++++++++-------- config/locales/client.et.yml | 4 +- config/locales/client.fa_IR.yml | 6 +- config/locales/client.fi.yml | 4 +- config/locales/client.fr.yml | 11 ++-- config/locales/client.gl.yml | 7 ++- config/locales/client.he.yml | 10 ++- config/locales/client.hu.yml | 4 +- config/locales/client.hy.yml | 5 +- config/locales/client.id.yml | 1 - config/locales/client.it.yml | 4 +- config/locales/client.ja.yml | 3 +- config/locales/client.ko.yml | 10 ++- config/locales/client.lt.yml | 4 +- config/locales/client.lv.yml | 4 +- config/locales/client.nb_NO.yml | 4 +- config/locales/client.nl.yml | 7 ++- config/locales/client.pl_PL.yml | 7 ++- config/locales/client.pt.yml | 6 +- config/locales/client.pt_BR.yml | 4 +- config/locales/client.ro.yml | 4 +- config/locales/client.ru.yml | 15 +++-- config/locales/client.sk.yml | 4 +- config/locales/client.sl.yml | 5 +- config/locales/client.sq.yml | 2 + config/locales/client.sr.yml | 1 - config/locales/client.sv.yml | 11 +++- config/locales/client.sw.yml | 4 +- config/locales/client.te.yml | 2 +- config/locales/client.th.yml | 4 +- config/locales/client.tr_TR.yml | 8 ++- config/locales/client.uk.yml | 7 ++- config/locales/client.ur.yml | 6 +- config/locales/client.vi.yml | 5 +- config/locales/client.zh_CN.yml | 4 +- config/locales/client.zh_TW.yml | 4 +- config/locales/server.ca.yml | 1 - config/locales/server.de.yml | 2 +- config/locales/server.es.yml | 53 +++++++++------- config/locales/server.fi.yml | 1 - config/locales/server.fr.yml | 4 +- config/locales/server.gl.yml | 1 - config/locales/server.he.yml | 1 - config/locales/server.hy.yml | 1 - config/locales/server.it.yml | 2 +- config/locales/server.ko.yml | 3 +- config/locales/server.nl.yml | 1 - config/locales/server.pl_PL.yml | 1 - config/locales/server.pt_BR.yml | 1 - config/locales/server.ru.yml | 2 +- config/locales/server.sv.yml | 3 +- config/locales/server.tr_TR.yml | 2 +- config/locales/server.uk.yml | 1 - config/locales/server.ur.yml | 1 - config/locales/server.zh_CN.yml | 2 +- config/locales/server.zh_TW.yml | 1 - 65 files changed, 283 insertions(+), 139 deletions(-) diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index 978b50bdb2..cf5872d423 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -651,7 +651,6 @@ ar: later_today: "خلال هذا اليوم" next_business_day: "يوم العمل التالي" tomorrow: "غدًا" - next_week: "الأسبوع القادم" post_local_date: "تاريخ المشاركة" later_this_week: "خلال هذا الأسبوع" start_of_next_business_week: "الاثنين" @@ -701,6 +700,8 @@ ar: edit_columns: save: "احفظ" reset_to_default: "صفّر إلى المبدئي" + group: + all: "كل المجموعات" group_histories: actions: change_group_setting: "تغيير إعدادات المجموعة" @@ -3536,7 +3537,6 @@ ar: tags: "الوسوم" choose_for_topic: "الوسوم الاختيارية" info: "معلومات" - default_info: "هذ الوسم ليس مقصورًا على أي تصنيف، وليس له مرادفات." category_restricted: "هذه الوسم مقيد بالتصنيفات التي ليس لديك الصلاحية للوصول إليها." synonyms: "مرادفات" synonyms_description: "عند استخدام الأوسمة التالية، سيتم استبدالها بـ %{base_tag_name}." diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index 65519c8387..c526f9feb6 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -292,6 +292,8 @@ be: other: " %{Count} карыстальнікаў" edit_columns: save: "Захаваць" + group: + all: "усе групы" group_histories: actions: change_group_setting: "Змяніць наладкі групы" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index e88274d259..31a46fbc4b 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -420,8 +420,8 @@ bg: later_today: "По-късно днес" next_business_day: "Следващия работен ден" tomorrow: "Утре" - next_week: "Следваща седмица" later_this_week: "По-късно тази седмица" + this_weekend: "Този уикенд" start_of_next_business_week_alt: "Следващия понеделник" next_month: "Следващия месец" custom: "Друга дата и час" @@ -463,6 +463,8 @@ bg: other: "%{count} потребители" edit_columns: save: "Запази" + group: + all: "всички групи" group_histories: actions: change_group_setting: "Промени настройки" diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index ef77ad7298..29bd3ab9f2 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -540,8 +540,8 @@ bs_BA: later_today: "Na kraju dana" next_business_day: "Sljedeći radni dan" tomorrow: "Sutra" - next_week: "Sljedeće sedmice" later_this_week: "Kasnije ove sedmice" + this_weekend: "Ovog vikenda" start_of_next_business_week: "Ponedjeljak" start_of_next_business_week_alt: "Sljedeći ponedjeljak" next_month: "Sljedeći mjesec" @@ -585,6 +585,8 @@ bs_BA: other: "%{count} korisnika" edit_columns: save: "Sačuvaj" + group: + all: "sve grupe" group_histories: actions: change_group_setting: "Promijeni postavke grupe" @@ -2981,7 +2983,6 @@ bs_BA: tags: "Oznake" choose_for_topic: "neobavezne oznake" info: "Informacija" - default_info: "Ovaj oznaka nije predodređena ni za jednu kategoriju i nema sinonime." category_restricted: "Ova oznaka je predodređena za kategorije na koje nemate pravo pristupa." synonyms: "Sinonimi" synonyms_description: "U slučaju da se sljedeće oznake koriste, iste će biti zamijenjene sa %{base_tag_name}." diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index 8b77ea968f..d8b4c4af29 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -499,8 +499,8 @@ ca: later_today: "Més tard avui" next_business_day: "El pròxim dia feiner" tomorrow: "Demà" - next_week: "La setmana que ve" later_this_week: "Més avant aquesta setmana" + this_weekend: "Aquest cap de setmana" start_of_next_business_week: "Dilluns" start_of_next_business_week_alt: "Dilluns vinent" next_month: "El mes que ve" @@ -544,6 +544,8 @@ ca: edit_columns: save: "Desa" reset_to_default: "Restableix el valor predeterminat" + group: + all: "tots els grups" group_histories: actions: change_group_setting: "Canvia la configuració del grup" diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index 7d0fe5d847..5b0942561b 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -506,9 +506,9 @@ cs: later_today: "Později dnes" next_business_day: "Následující pracovní den" tomorrow: "Zítra" - next_week: "Příští týden" post_local_date: "Datum v příspěvku" later_this_week: "Později tento týden" + this_weekend: "Tento týden" start_of_next_business_week: "Pondělí" start_of_next_business_week_alt: "Příští pondělí" next_month: "Další měsíc" @@ -552,6 +552,8 @@ cs: other: "%{count} uživatelů" edit_columns: save: "Uložit" + group: + all: "všechny skupiny" group_histories: actions: change_group_setting: "Změnit nastavení skupiny" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index c4bb874103..455c9005f0 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -541,12 +541,14 @@ da: later_today: "Senere i dag" next_business_day: "Næste hverdag" tomorrow: "I morgen" - next_week: "Næste uge" post_local_date: "Dato i indlæg" later_this_week: "Senere denne uge" + this_weekend: "Denne weekend" start_of_next_business_week: "Mandag" start_of_next_business_week_alt: "Næste mandag" + two_weeks: "To uger" next_month: "Næste måned" + six_months: "Seks måneder" custom: "Brugerdefineret dato og tid" relative: "Relativ tid" none: "Ikke nødvendig" @@ -590,6 +592,8 @@ da: edit_columns: save: "Gem" reset_to_default: "Nulstil til standard" + group: + all: "alle grupper" group_histories: actions: change_group_setting: "Skift gruppe indstilling" @@ -3325,7 +3329,6 @@ da: tags: "Mærker" choose_for_topic: "valgfrie mærker" info: "Info" - default_info: "Dette mærke er ikke begrænset til nogen kategorier og har ingen synonymer." category_restricted: "Dette mærke er begrænset til kategorier, som du ikke har adgang til." synonyms: "Synonymer" synonyms_description: "Når følgende mærker bruges, vil de blive erstattet med %{base_tag_name}." diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index 9c8d0b8cbc..704bcc47f0 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -541,7 +541,6 @@ de: later_today: "Im Laufe des Tages" next_business_day: "Nächster Arbeitstag" tomorrow: "Morgen" - next_week: "Nächste Woche" post_local_date: "Datum im Beitrag" later_this_week: "Später in dieser Woche" start_of_next_business_week: "Montag" @@ -588,8 +587,11 @@ de: one: "%{count} Benutzer" other: "%{count} Benutzer" edit_columns: + title: "Verzeichnisspalten bearbeiten" save: "Speichern" reset_to_default: "Auf Standard zurücksetzen" + group: + all: "alle Gruppen" group_histories: actions: change_group_setting: "Gruppeneinstellung ändern" @@ -3364,7 +3366,7 @@ de: tags: "Schlagwörter" choose_for_topic: "optionale Schlagwörter" info: "Info" - default_info: "Dieses Schlagwort ist nicht auf Kategorien beschränkt und hat keine Synonyme." + default_info: "Dieses Schlagwort ist nicht auf irgendwelche Kategorien beschränkt und hat keine Synonyme. Um Einschränkungen hinzuzufügen, füge dieses Schlagwort einer Schlagwort-Gruppe hinzu." category_restricted: "Dieser Tag ist auf Kategorien begrenzt, für die du keine Zugriffsberechtigung hast." synonyms: "Synonyme" synonyms_description: "Wenn die folgenden Schlagwörter verwendet werden, werden sie durch %{base_tag_name} ersetzt." diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index a9b1095f1e..4d71aa816e 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -507,8 +507,8 @@ el: later_today: "Αργότερα σήμερα" next_business_day: "Επόμενη εργάσιμη ημέρα" tomorrow: "Αύριο" - next_week: "Την άλλη εβδομάδα" later_this_week: "Αργότερα αυτήν την εβδομάδα" + this_weekend: "Αυτό το Σαββατοκύριακο" start_of_next_business_week: "Τη Δευτέρα" start_of_next_business_week_alt: "Την επόμενη Δευτέρα" next_month: "Τον άλλο μήνα" @@ -552,6 +552,8 @@ el: edit_columns: save: "Αποθήκευση" reset_to_default: "Επαναφορά στο προεπιλεγμένο" + group: + all: "όλες οι ομάδες" group_histories: actions: change_group_setting: "Αλλαγή ρυθμίσεων ομάδας" @@ -2990,7 +2992,6 @@ el: tags: "Ετικέτες" choose_for_topic: "προαιρετικές ετικέτες" info: "Πληροφορίες" - default_info: "Αυτή η ετικέτα δεν περιορίζεται σε καμία κατηγορία και δεν έχει συνώνυμα." category_restricted: "Αυτή η ετικέτα περιορίζεται σε κατηγορίες στις οποίες δεν έχετε άδεια πρόσβασης." synonyms: "Συνώνυμα" synonyms_description: "Όταν χρησιμοποιούνται οι ακόλουθες ετικέτες, θα αντικατασταθούν με %{base_tag_name}." diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index 216fdf5574..26272113dd 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -380,6 +380,7 @@ es: type_bonus: name: "tipo de bonificación" title: "Algunos tipos de revisables pueden recibir una bonificación por el staff para que tengan mayor prioridad." + stale_help: "Este elemento ha sido atendido por otra persona." claim_help: optional: "Puedes reclamar este artículo para evitar que otros lo revisen." required: "Debes reclamar los artículos antes de poder revisarlos." @@ -540,7 +541,6 @@ es: later_today: "Hoy más tarde" next_business_day: "Siguiente día hábil" tomorrow: "Mañana" - next_week: "La próxima semana" post_local_date: "Fecha en la publicación" later_this_week: "Más tarde esta semana" start_of_next_business_week: "Lunes" @@ -587,8 +587,11 @@ es: one: "%{count} usuario" other: "%{count} usuarios" edit_columns: + title: "Editar columnas del listado de usuarios" save: "Guardar" reset_to_default: "Restablecer ajustes predeterminados" + group: + all: "todos los grupos" group_histories: actions: change_group_setting: "Cambiar configuración de grupo" @@ -606,6 +609,7 @@ es: title: "Introduce nombres de usuario o direcciones de correo electrónico" input_placeholder: "Nombres de usuario o correos electrónicos" usernames: + title: "Escribe nombres de usuario" input_placeholder: "Nombres de usuario" notify_users: "Notificar usuarios" requests: @@ -632,6 +636,19 @@ es: email: title: "Correo electrónico" status: "Sincronizados %{old_emails} / %{total_emails} correos a través de IMAP." + enable_smtp: "Activar SMTP" + enable_imap: "Activar INAP" + test_settings: "Probar ajustes" + save_settings: "Guardar ajustes" + settings_required: "Todos los ajustes son obligatorios. Por favor, completa todos los campos antes de validar." + smtp_settings_valid: "Ajustes de SMTP válidos." + smtp_title: "SMTP" + imap_title: "IMAP" + imap_additional_settings: "Ajustes adicionales" + imap_settings_valid: "Ajustes de IMAP válidos." + prefill: + title: "Rellenar con los ajustes de:" + gmail: "GMail" credentials: title: "Credenciales" smtp_server: "Servidor SMTP" @@ -649,6 +666,7 @@ es: mailboxes: synchronized: "Bandeja de correo sincronizada" none_found: "No se han encontrado bandejas de correo para esta cuenta de correo electrónico." + disabled: "Desactivado" membership: title: Membresía access: Acceso @@ -965,12 +983,15 @@ es: no_bookmarks_body: > Añade publicaciones a tus marcadores con el botón %{icon}, y aparecerán aquí para que puedas volver fácilmente a ellos. ¡Hasta programarte recordatorios! no_notifications_title: "No tienes ninguna notificación todavía" + no_notifications_body: > + En esta pestaña te avisaremos sobre actividad relevante para ti incluyendo respuestas a tus temas y mensajes, @menciones o citas, y respuestas a los temas que estés siguiendo. También te notificaremos por correo si hace tiempo que no te conectas.

    A través del icono %{icon} puedes decidir sobre qué temas, categorías y etiquetas quieres que te avisemos. Ve a tus preferencias de notificaciones para más detalles. first_notification: "¡Tu primera notificación! Selecciónala para empezar." dynamic_favicon: "Mostrar número en el icono del navegador" skip_new_user_tips: description: "Omitir consejos de bienvenida y medallas" not_first_time: "¿No es tu primera vez?" skip_link: "Saltarse estos consejos" + read_later: "Lo leeré más adelante." theme_default_on_all_devices: "Hacer que este sea el tema por defecto en todos mis dispositivos" color_scheme_default_on_all_devices: "Establecer la combinación de colores como predeterminada en todos mis dispositivos" color_scheme: "Combinación de colores" @@ -1223,6 +1244,7 @@ es: invalid: "Por favor, ingresa una dirección de correo electrónico válida" authenticated: "Tu correo electrónico ha sido autenticado por %{provider}" invite_auth_email_invalid: "Su correo electrónico de invitación no coincide con el correo electrónico autenticado por %{provider}" + authenticated_by_invite: "Tu correo ha sido autenticado a través de la invitación" frequency_immediately: "Te enviaremos un correo electrónico inmediatamente si no has leído el asunto por el cual te estamos enviando el correo." frequency: one: "Sólo te enviaremos emails si no te hemos visto en el último minuto." @@ -1284,7 +1306,7 @@ es: browser_last_seen: "%{browser} | %{date}" last_posted: "Última publicación" last_emailed: "Último correo enviado" - last_seen: "Visto por última vez" + last_seen: "Última visita" created: "Creado el" log_out: "Cerrar sesión" location: "Ubicación" @@ -1408,9 +1430,18 @@ es: invite: new_title: "Crear invitación" edit_title: "Editar invitación" + instructions: "Comparte este enlace para dar acceso inmediato al sitio" copy_link: "copiar enlace" + expires_in_time: "Caduca en %{time}" + expired_at_time: "Caducada en %{time}" show_advanced: "Mostrar opciones avanzadas" hide_advanced: "Ocultar opciones avanzadas" + restrict_email: "Restringir a una dirección de correo" + max_redemptions_allowed: "Máximo de usos" + add_to_groups: "Añadir a grupos" + invite_to_topic: "Redirigir a este tema" + expires_at: "Caducar tras" + custom_message: "Mensaje personal opcional" send_invite_email: "Guardar y enviar correo" save_invite: "Guardar invitación" invite_saved: "Invitación guardada." @@ -2143,10 +2174,13 @@ es: delete: "Eliminar temas" dismiss: "Descartar" dismiss_read: "Descartar todos los temas sin leer" + dismiss_read_with_selected: "Descartar %{count} sin leer" dismiss_button: "Descartar..." + dismiss_button_with_selected: "Descartar (%{count})…" dismiss_tooltip: "Descartar solamente las nuevas publicaciones o dejar de seguir los temas" also_dismiss_topics: "Dejar de seguir estos temas para que no aparezcan más en mis mensajes no leídos" dismiss_new: "Ignorar nuevos" + dismiss_new_with_selected: "Descartar nuevos (%{count})" toggle: "activar selección de temas en bloque" actions: "Acciones en bloque" change_category: "Cambiar categoría" @@ -2256,6 +2290,8 @@ es: back_to_list: "Volver a la lista de temas" options: "Opciones del tema" show_links: "mostrar enlaces dentro de este tema" + collapse_details: "ocultar detalles del tema" + expand_details: "expandir detalles del tema" read_more_in_category: "¿Quieres leer más? Consulta otros temas en %{catLink} o %{latestLink}." read_more: "¿Quieres leer más? %{catLink} o %{latestLink}." unread_indicator: "Ningún miembro ha leído todavía la última publicación de este tema." @@ -2634,6 +2670,7 @@ es: description: one: Has seleccionado %{count} publicación. other: "Has seleccionado %{count} publicaciones." + deleted_by_author_simple: "(tema eliminado por su autor/a)" post: quote_reply: "Citar" quote_share: "Compartir" @@ -2648,6 +2685,7 @@ es: follow_quote: "ir a la publicación citada" show_full: "Mostrar la publicación completa" show_hidden: "Ver contenido ignorado." + deleted_by_author_simple: "(mensaje eliminado por su autor/a)" collapse: "contraer" expand_collapse: "expandir/contraer" locked: "un miembro del staff bloqueó la posibilidad de editar esta publicación" @@ -2785,13 +2823,13 @@ es: other: "¿Estás seguro de que quieres fusionar estas %{count} publicaciones?" revisions: controls: - first: "Primera revisión" - previous: "Revisión anterior" - next: "Siguiente revisión" - last: "Última revisión" - hide: "Ocultar revisión." - show: "Mostrar revisión." - revert: "Volver a la revisión %{revision}" + first: "Primera edición" + previous: "Edición anterior" + next: "Siguiente edición" + last: "Última edición" + hide: "Ocultar edición" + show: "Mostrar edición" + revert: "Volver a la edición %{revision}" edit_wiki: "Editar wiki" edit_post: "Editar publicación" comparing_previous_to_current_out_of_total: "%{previous} %{icon} %{current} / %{total}" @@ -3305,10 +3343,10 @@ es: other: name: Miscelánea posting: - name: Publicación - favorite_max_reached: "No puedes marcar más insignias como favoritas." - favorite_max_not_reached: "Marcar esta insignia como favorita" - favorite_count: "%{count}/%{max} insignias marcadas como favoritas" + name: Publicaciones + favorite_max_reached: "No puedes marcar más medallas como favoritas." + favorite_max_not_reached: "Marcar esta medalla como favorita" + favorite_count: "%{count}/%{max} medallas marcadas como favoritas" tagging: all_tags: "Todas las etiquetas" other_tags: "Otras etiquetas" @@ -3316,9 +3354,9 @@ es: selector_no_tags: "sin etiquetas" changed: "etiquetas cambiadas:" tags: "Etiquetas" - choose_for_topic: "etiquetas opcionales" + choose_for_topic: "etiquetas (opcional)" info: "Info" - default_info: "Esta etiqueta no está restringida a ninguna categoría, y no tiene sinónimos." + default_info: "Esta etiqueta no está restringida a ninguna categoría y no tiene sinónimos. Para añadir restricciones, añádela a un grupo de etiquetas." category_restricted: "Esta etiqueta está restringida para las categorías a las que no tienes permiso de acceso." synonyms: "Sinónimos" synonyms_description: "Cuando las siguientes etiquetas sean usadas, serán reemplazadas por %{base_tag_name}." @@ -3390,13 +3428,18 @@ es: description: "No se te notificará sobre temas nuevos con esta etiqueta, ni aparecerán en tu pestaña de no leídos." groups: title: "Grupos de etiquetas" - about_heading: "Seleccione un grupo de etiquetas o cree uno nuevo" - about_description: "Los grupos de etiquetas le ayudan a administrar los permisos de muchas etiquetas en un solo lugar." - new: "Nuevo grupo" + about_heading: "Selecciona un grupo de etiquetas, o crea uno nuevo" + about_heading_empty: "Crea un nuevo grupo de etiquetas para empezar" + about_description: "Los grupos de etiquetas te ayudan a gestionar los permisos de muchas etiquetas en un único sitio." + new: "Crear grupo" new_title: "Crear nuevo grupo" edit_title: "Editar grupo de etiquetas" + tags_label: "Etiquetas en este grupo" + parent_tag_label: "Etiqueta padre" + parent_tag_description: "Las etiquetas de este grupo solo se podrán usar si la etiqueta padre está presente." one_per_topic_label: "Limitar las etiquetas de este grupo a utilizarse solo una vez por tema" new_name: "Nuevo grupo de etiquetas" + name_placeholder: "Nombre" save: "Guardar" delete: "Eliminar" confirm_delete: "¿Estás seguro de que quieres eliminar este grupo de etiquetas?" @@ -3407,7 +3450,7 @@ es: tags_placeholder: "Buscar o crear etiquetas" parent_tag_placeholder: "Opcional" select_groups_placeholder: "Seleccionar grupos ..." - disabled: "El etiquetado está deshabilitado. " + disabled: "El etiquetado está desactivado. " topics: none: unread: "No tienes temas sin leer." @@ -3469,7 +3512,7 @@ es: last_updated: "Última actualización del panel:" discourse_last_updated: "Última actualización de Discourse:" version: "Versión" - up_to_date: "¡Estás actualizado!" + up_to_date: "Estás en la última versión" critical_available: "Actualización crítica disponible." updates_available: "Hay actualizaciones disponibles." please_upgrade: "¡Por favor, actualiza!" @@ -3562,7 +3605,7 @@ es: by: "por" groups: new: - title: "Grupo nuevo" + title: "Crear grupo" create: "Crear" name: too_short: "El nombre del grupo es muy corto" @@ -3902,7 +3945,7 @@ es: themes_intro: "Selecciona un tema existente o instala uno nuevo para empezar" themes_intro_emoji: "emoji de mujer artista" beginners_guide_title: "Guía básica para utilizar los temas de Discourse" - developers_guide_title: "Guía del desarrollador para los temas de Discourse" + developers_guide_title: "Guía de desarrollo de temas para Discourse" browse_themes: "Ver temas de la comunidad" customize_desc: "Personalizar:" title: "Temas" @@ -4058,6 +4101,7 @@ es: yaml: text: "YAML" title: "Definir ajustes al tema en formato YAML" + scss_warning_inline: "El uso de variables de color SCSS del sistema en los temas está obsoleto." colors: select_base: title: "Selecciona paleta base de color" @@ -4146,7 +4190,7 @@ es: bounced: "Rebotado" received: "Recibidos" rejected: "Rechazados" - sent_at: "Cuándo" + sent_at: "Fecha" time: "Fecha" user: "Usuario" email_type: "Tipo de correo" @@ -4383,6 +4427,7 @@ es: download: Descargar clear_all: Limpiar todo clear_all_confirm: "¿Quitar todas las palabras vigiladas para la acción %{action}?" + invalid_regex: 'La palabra vigilada «%{word}» no es una expresión regular válida.' actions: block: "Bloquear" censor: "Censurar" @@ -4397,13 +4442,19 @@ es: censor: "Permitir mensajes que contengan estas palabras, pero reemplazar estas palabras por caracteres que las censuren." require_approval: "Las publicaciones que contengan estas palabras deberán ser aprobadas por el staff antes de que se puedan visualizar." flag: "Permitir publicaciones que contengan estas palabras, pero reportar el mensaje como inapropiado para que los moderadores puedan revisarlo." + replace: "Reemplazar palabras en publicaciones por otras palabras" tag: "Etiquetas temas automáticamente basándose en su primer mensaje." + silence: "Las primeras publicaciones de usuarios que contengan estas palabras deberán ser aprobadas por el staff antes de que se puedan ver, y el usuario será silenciado automáticamente." + link: "Reemplazar palabras en publicaciones por enlaces" form: + label: "Contiene palabra o frase" + placeholder: "Escribe una palabra o frase (* es un comodín)" placeholder_regexp: "expresión regular" replace_label: "Sustitución" + replace_placeholder: "ejemplo" tag_label: "Etiqueta" link_label: "Enlace" - link_placeholder: "https://example.com" + link_placeholder: "https://ejemplo.com" add: "Agregar" success: "Éxito" exists: "Ya existe" @@ -4469,6 +4520,7 @@ es: suspend_reasons: not_listening_to_staff: "No escucha indicaciones del personal" consuming_staff_time: "Hace perder el tiempo desproporcionadamente al personal" + combative: "Demasiado combatiente" in_wrong_place: "No está en el sitio adecuado" no_constructive_purpose: "No hay otro propósito constructivo para sus acciones que crear conflictos dentro de la comunidad" custom: "Personalizado..." @@ -4710,8 +4762,8 @@ es: revert: "Deshacer cambios" revert_confirm: "¿Estás seguro de que quieres deshacer tus cambios?" go_back: "Volver a la búsqueda" - recommended: "Recomendamos personalizar el siguiente texto para que se ajusten a tus necesidades:" - show_overriden: "Solo mostrar sobrescritos" + recommended: "Te recomendamos que ajustes los siguientes textos a tu comunidad:" + show_overriden: "Solo mostrar modificados" locale: "Idioma:" fallback_locale_warning: "Estás editando un idioma basado en %{fallback}. Los usuarios que seleccionen %{fallback} como su idioma de interfaz no verán tus cambios." more_than_50_results: "Hay más de 50 resultados. Por favor, afina tu busqueda." @@ -4783,7 +4835,7 @@ es: badges: title: Medallas new_badge: Nueva medalla - new: Crear nueva + new: Crear name: Nombre badge: Medalla display_name: Nombre que se muestra diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 19cc032f2e..807f291f9e 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -426,9 +426,9 @@ et: later_today: "Täna hiljem" next_business_day: "Järgmisel tööpäeval" tomorrow: "Homme" - next_week: "Järgmisel nädalal" post_local_date: "Kuupäev postituses" later_this_week: "Hiljem sel nädalal" + this_weekend: "Sellel nädalavahetusel" start_of_next_business_week: "Esmaspäev" start_of_next_business_week_alt: "Järgmisel esmaspäeval" next_month: "Järgmisel kuul" @@ -473,6 +473,8 @@ et: other: "%{count} kasutajat" edit_columns: save: "Salvesta" + group: + all: "kõik grupid" group_histories: actions: change_group_setting: "Muuda grupi sätteid" diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index 9410bfce4f..8e9ddf8a9f 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -532,12 +532,14 @@ fa_IR: later_today: "بعد از امروز" next_business_day: "روز کاری بعدی" tomorrow: "فردا" - next_week: "هفته بعد" post_local_date: "تاریخ در پست" later_this_week: "بعد از اين هفته" + this_weekend: "آخر هفته" start_of_next_business_week: "دوشنبه" start_of_next_business_week_alt: "دوشنبه بعدی" + two_weeks: "دو هفته" next_month: "ماه بعد" + six_months: "شش ماه" custom: "تاریخ و زمان سفارشی" relative: "زمان نسبی" none: "هیچکدام نیازی نیست" @@ -580,6 +582,8 @@ fa_IR: edit_columns: save: "ذخیره" reset_to_default: "بازگردانی به پیش‌فرض" + group: + all: "همه گروه‌ها" group_histories: actions: change_group_setting: "تغییر تنظیمات گروه" diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index bfb6791d06..89387d5502 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -540,7 +540,6 @@ fi: later_today: "Myöhemmin tänään" next_business_day: "Seuraavana arkipäivänä" tomorrow: "Huomenna" - next_week: "Ensi viikolla" post_local_date: "Päivämäärä viestissä" later_this_week: "Myöhemmin tällä viikolla" start_of_next_business_week: "Maanantaina" @@ -589,6 +588,8 @@ fi: edit_columns: save: "Tallenna" reset_to_default: "Palauta oletus" + group: + all: "kaikki ryhmät" group_histories: actions: change_group_setting: "Muuta ryhmän asetusta" @@ -3324,7 +3325,6 @@ fi: tags: "Tunnisteet" choose_for_topic: "ei-pakolliset tunnisteet" info: "Tietoa" - default_info: "Tätä tunnistetta ei ole rajattu millekään alueelle eikä sillä ole synonyymejä." category_restricted: "Tämä tunniste on rajoitettu alueille, joille sinulla ei ole pääsyoikeutta." synonyms: "Synonyymit" synonyms_description: "Kun näitä tunnisteita käytetään, ne korvataan pääsynonyymillä %{base_tag_name}." diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index 95ea2fa88b..bd3d26eb23 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -541,7 +541,6 @@ fr: later_today: "Plus tard dans la journée" next_business_day: "Au prochain jour ouvré" tomorrow: "Demain" - next_week: "La semaine prochaine" post_local_date: "À la date dans le message" later_this_week: "Plus tard dans la semaine" start_of_next_business_week: "Lundi" @@ -591,6 +590,8 @@ fr: title: "Modifier les colonnes de l'annuaire" save: "Enregistrer" reset_to_default: "Réinitialiser aux valeurs par défaut" + group: + all: "tous les groupes" group_histories: actions: change_group_setting: "Changer les paramètres du groupe" @@ -3365,7 +3366,7 @@ fr: tags: "Étiquettes" choose_for_topic: "étiquettes optionnelles" info: "Détails" - default_info: "Cette étiquette n'est pas limitée à une catégorie et n'a aucun synonyme." + default_info: "Cette étiquette n'est restreinte à aucune catégorie et n'a aucun synonyme défini. Pour restreindre l'utilisation de cette étiquette, ajoutez-là à un groupe d'étiquettes." category_restricted: "Cette étiquette est limitée à des catégories dont vous n'avez pas la permission d'accéder." synonyms: "Synonymes" synonyms_description: "Quand les étiquettes suivantes sont utilisées, elles seront remplacées par %{base_tag_name}." @@ -3983,7 +3984,7 @@ fr: is_default: "Thème activé par défaut" user_selectable: "Thème sélectionnable par les utilisateurs" color_scheme_user_selectable: "Le jeu de couleurs peut être sélectionné par les utilisateurs" - auto_update: "Mettre à jour automatiquement lorsque Discours est mis à jour" + auto_update: "Mettre à jour automatiquement lors des mises à jour de Discourse" color_scheme: "Palette de couleurs" default_light_scheme: "Clair (par défaut)" color_scheme_select: "Sélectionner les couleurs utilisées par le thème" @@ -4025,7 +4026,7 @@ fr: edit_css_html: "Modifier le CSS/HTML" edit_css_html_help: "Vous n'avez modifié aucun CSS ou HTML" delete_upload_confirm: "Supprimer ce fichier envoyé ? (Le thème CSS pourrait ne plus fonctionner !)" - component_on_themes: "Inclure le composant à ces thèmes" + component_on_themes: "Inclure ce composant dans ces thèmes" included_components: "Composants inclus" add_all: "Tout ajouter" import_web_tip: "Dépôt contenant le thème" @@ -4072,7 +4073,7 @@ fr: one: "Le thème est en retard de %{count} commit !" other: "Le thème est en retard de %{count} commits !" compare_commits: "(Voir les nouveaux changements)" - remote_theme_edits: "Pour modifier ce thème, vous devez effecteur un changement dans son dépôt" + remote_theme_edits: "Pour modifier ce thème, vous devrez proposer des changements dans son dépôt de développement" repo_unreachable: "Le dépôt Git de ce thème est inaccessible. Message d'erreur :" imported_from_archive: "Ce thème a été importé depuis un fichier .zip" scss: diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index 13ccbaa771..d652d91b0d 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -535,12 +535,14 @@ gl: later_today: "Hoxe, máis tarde" next_business_day: "O vindeiro día laborábel" tomorrow: "Mañá" - next_week: "A vindeira semana" post_local_date: "Data na publicación" later_this_week: "Máis tarde, esta semana" + this_weekend: "Esta fin de semana" start_of_next_business_week: "Luns" start_of_next_business_week_alt: "O vindeiro luns" + two_weeks: "Dúas semanas" next_month: "O vindeiro mes" + six_months: "Seis meses" custom: "Personalizar data e hora" relative: "Tempo relativo" none: "Non se necesita" @@ -583,6 +585,8 @@ gl: edit_columns: save: "Gardar" reset_to_default: "Restabelecer cos valores predeterminados" + group: + all: "todos os grupos" group_histories: actions: change_group_setting: "Cambiar axustes do grupo" @@ -3273,7 +3277,6 @@ gl: tags: "Etiquetas" choose_for_topic: "etiquetas opcionais" info: "Información" - default_info: "Esta etiqueta non se restrinxe a ningunha categoría e non ten sinónimos." category_restricted: "Esta etiqueta restrínxese ás categorías ás que non ten permiso para acceder." synonyms: "Sinónimos" synonyms_description: "Cando se usen as seguintes etiquetas, substituílas por %{base_tag_name}." diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index 53bdadd593..9fef1a8f26 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -624,12 +624,14 @@ he: later_today: "בהמשך היום" next_business_day: "יום העסקים הבא" tomorrow: "מחר" - next_week: "בשבוע הבא" post_local_date: "תאריך בפוסט" later_this_week: "בהמשך השבוע" + this_weekend: "בסוף שבוע זה" start_of_next_business_week: "יום שני" start_of_next_business_week_alt: "יום שני הבא" + two_weeks: "שבועיים" next_month: "חודש הבא" + six_months: "שישה חודשים" custom: "תאריך ושעה מותאמים אישית" relative: "זמן יחסי" none: "אין צורך" @@ -676,6 +678,8 @@ he: title: "עריכת עמודות ספרייה" save: "שמירה" reset_to_default: "איפוס לבררת מחדל" + group: + all: "כל הקבוצות" group_histories: actions: change_group_setting: "שינוי הגדרות קבוצה" @@ -1194,6 +1198,7 @@ he: failed_to_move: "בעיה בהעברת ההודעות שנבחרו (אולי יש תקלה בהתחברות?)" select_all: "לבחור הכול" tags: "תגיות" + warnings: "אזהרות רשמיות" preferences_nav: account: "חשבון" security: "אבטחה" @@ -3630,7 +3635,6 @@ he: tags: "תגיות" choose_for_topic: "תגיות רשות" info: "פרטים" - default_info: "תגית זו אינה מוגבלת לקטגוריות כלשהן ואין לה מילים נרדפות." category_restricted: "תגית זו מוגבלת לקטגוריות שאין לך גישה אליהן." synonyms: "מילים נרדפות" synonyms_description: "תגיות אלו תוחלפנה בתגית %{base_tag_name}." @@ -4889,6 +4893,8 @@ he: flags_given_count: דגלים שניתנו flags_received_count: דגלים שהתקבלו warnings_received_count: התקבלו אזהרות + warnings_list_warning: | + יכול להיות שלא תהיה לך אפשרות לצפות בכל הנושאים האלו מתוקף תפקיד הפיקוח. במקרה הצורך, ניתן לבקש מההנהלה או מהמפקחים שהקצו להעניק לך גישת @moderators להודעה. flags_given_received_count: "דגלים שניתנו / התקבלו" approve: "אשר" approved_by: "אושר על ידי" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index b58e7bdf92..27f5919621 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -515,9 +515,9 @@ hu: later_today: "Ma később" next_business_day: "Következő munkanap" tomorrow: "Holnap" - next_week: "Jövő héten" post_local_date: "A bejegyzésben szereplő dátum" later_this_week: "Később ezen a héten" + this_weekend: "Hétvégén" start_of_next_business_week: "Hétfőn" start_of_next_business_week_alt: "Jövő hétfőn" next_month: "Jövő hónapban" @@ -563,6 +563,8 @@ hu: edit_columns: save: "Mentés" reset_to_default: "Alapértelmezés visszaállítása" + group: + all: "összes csoport" group_histories: actions: change_group_setting: "Csoportbeállítások módosítása" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index 9bb6913800..50afe2f861 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -477,8 +477,8 @@ hy: later_today: "Այսօր, մի փոքր ուշ" next_business_day: "Հաջորդ աշխատանքային օրը: " tomorrow: "Վաղը" - next_week: "Հաջորդ շաբաթ" later_this_week: "Այս շաբաթ, մի փոքր ավելի ուշ" + this_weekend: "Այս շաբաթ-կիրակի" start_of_next_business_week_alt: "Հաջորդ Երկուշաբթի: " next_month: "Հաջորդ ամիս" custom: "Սահմանել հիշեցումների ամսաթիվն ու ժամանակը:" @@ -521,6 +521,8 @@ hy: edit_columns: save: "Պահպանել" reset_to_default: "Վերականգնել լռելյայն" + group: + all: "բոլոր խմբերը" group_histories: actions: change_group_setting: "Փոխել խմբի կարգավորումը" @@ -2808,7 +2810,6 @@ hy: tags: "Թեգեր" choose_for_topic: "ընտրովի թեգեր" info: "Ինֆորմացիա" - default_info: "Այս տեգը չի սահմանափակվում որևէ կատեգորիայով և չունի հոմանիշ:" category_restricted: "Այս տեգը սահմանափակված է այն կատեգորիաներով, որոնցում մուտքի թույլտվություն չունեք:" synonyms: "Հոմանիշներ" synonyms_description: "Հետևյալ տեգերի օգտագործման դեպքում, դրանք կփոխարինվեն %{base_tag_name} -ով:" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 3d425fce42..c96871979d 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -436,7 +436,6 @@ id: later_today: "Nanti hari ini" next_business_day: "Hari kerja berikutnya" tomorrow: "Besok" - next_week: "Minggu depan" start_of_next_business_week: "Senin" start_of_next_business_week_alt: "Senin Depan" next_month: "Bulan depan" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 1ca0f0f96c..6de17d8b4b 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -540,7 +540,6 @@ it: later_today: "Più tardi oggi" next_business_day: "Il prossimo giorno lavorativo" tomorrow: "Domani" - next_week: "La prossima settimana" post_local_date: "Data nel messaggio" later_this_week: "Più tardi questa settimana" start_of_next_business_week: "Lunedì" @@ -589,6 +588,8 @@ it: title: "Modifica Colonne Directory" save: "Salva" reset_to_default: "Ripristina impostazioni predefinite" + group: + all: "tutti i gruppi" group_histories: actions: change_group_setting: "Cambia le impostazioni del gruppo" @@ -3344,7 +3345,6 @@ it: tags: "Etichette" choose_for_topic: "etichette facoltative" info: "Info" - default_info: "Questa etichetta non è limitata a nessuna categoria e non ha sinonimi." category_restricted: "Questa etichetta è limitata a categorie a cui non sei autorizzato ad accedere." synonyms: "Sinonimi" synonyms_description: "Quando vengono utilizzate le seguenti etichette, verranno sostituite con %{base_tag_name}." diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index f1fbff5376..a300bf5790 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -481,7 +481,6 @@ ja: later_today: "今日中" next_business_day: "翌営業日" tomorrow: "明日" - next_week: "来週" post_local_date: "投稿日時" later_this_week: "今週中" start_of_next_business_week: "月曜日" @@ -527,6 +526,8 @@ ja: other: "%{count}人のユーザー" edit_columns: save: "保存" + group: + all: "すべてのグループ" group_histories: actions: change_group_setting: "グループの設定を変更" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index 98bf87e362..8ea67fc473 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -499,12 +499,14 @@ ko: later_today: "오늘 늦게" next_business_day: "다음 영업일" tomorrow: "내일" - next_week: "다음 주" post_local_date: "게시 날짜" later_this_week: "이번 주말" + this_weekend: "이번 주말" start_of_next_business_week: "월요일" start_of_next_business_week_alt: "다음 월요일" + two_weeks: "2주" next_month: "다음 달" + six_months: "6개월" custom: "사용자 지정 날짜 및 시간" relative: "상대 시간" none: "필요 없음" @@ -548,6 +550,8 @@ ko: title: "디렉토리 열 편집" save: "저장" reset_to_default: "기본값으로 재설정" + group: + all: "모든 그룹" group_histories: actions: change_group_setting: "그룹 설정 변경" @@ -3202,7 +3206,7 @@ ko: tags: "태그" choose_for_topic: "태그 선택" info: "정보" - default_info: "이 태그는 카테고리로 제한되지 않으며 동의어가 없습니다." + default_info: "이 태그는 카테고리로 제한되지 않으며 동의어가 없습니다. 제한을 추가하려면 이 태그를 태그 그룹에 넣으십시오." category_restricted: "이 태그는 액세스 권한이없는 카테고리로 제한됩니다." synonyms: "동의어" synonyms_description: "다음 태그를 사용하면 %{base_tag_name} 으로 대체됩니다." @@ -3333,6 +3337,7 @@ ko: member: "멤버" regular: "일반" leader: "리더" + detailed_name: "%{level}: %{name}" admin_js: type_to_filter: "필터를 입력하세요" admin: @@ -4536,6 +4541,7 @@ ko: locked_will_not_be_promoted: "회원등급이 고정되었습니다. 승급되지 않을 것입니다." locked_will_not_be_demoted: "회원등급이 고정되었습니다. 강등되지 않을 것입니다." discourse_connect: + title: "DiscourseConnect 단일 사인온" external_id: "외부 ID" external_username: "아이디" external_name: "이름" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index cfbc4b3d05..4043566c22 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -455,8 +455,8 @@ lt: time_shortcut: later_today: "Šiandien vėliau" tomorrow: "Rytoj" - next_week: "Kitą savaitę" later_this_week: "Šia savaitę vėliau" + this_weekend: "Šį savaitgalį" start_of_next_business_week: "Pirmadienis" next_month: "Kitą mėnesį" user_action: @@ -498,6 +498,8 @@ lt: other: "%{count} vartotojų" edit_columns: save: "Saugoti" + group: + all: "visos grupės" group_histories: actions: change_group_setting: "Pakeisti grupės nustatymus" diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 364b68a096..387e8c4c7f 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -353,8 +353,8 @@ lv: time_shortcut: later_today: "Vēlāk šodien" tomorrow: "Rītdien" - next_week: "Nākamā nedēļā" later_this_week: "Vēlāk šonedēļ" + this_weekend: "Šajā nedēļas nogalē" next_month: "Nākamā mēnesī" user_action: user_posted_topic: "%{user} pievienoja ierakstus tēmai" @@ -394,6 +394,8 @@ lv: other: "%{count} lietotāji" edit_columns: save: "Saglabāt" + group: + all: "visas grupas" group_histories: actions: change_group_setting: "Mainīt grupas iestatījumus" diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index f2fcc9ab6d..669f6c1f94 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -402,8 +402,8 @@ nb_NO: time_shortcut: later_today: "Senere i dag" tomorrow: "I morgen" - next_week: "Neste uke" later_this_week: "Senere denne uken" + this_weekend: "Denne uken" next_month: "Neste måned" custom: "Egendefinert dato og tid" user_action: @@ -443,6 +443,8 @@ nb_NO: other: "%{count} brukere" edit_columns: save: "Lagre" + group: + all: "alle grupper" group_histories: actions: change_group_setting: "Endre gruppeinnstillinger" diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index 2b02cf77cb..72eedec635 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -540,12 +540,14 @@ nl: later_today: "Later vandaag" next_business_day: "Volgende werkdag" tomorrow: "Morgen" - next_week: "Volgende week" post_local_date: "Datum in bericht" later_this_week: "Later deze week" + this_weekend: "Dit weekend" start_of_next_business_week: "Maandag" start_of_next_business_week_alt: "Volgende maandag" + two_weeks: "Twee weken" next_month: "Volgende maand" + six_months: "Zes maanden" custom: "Aangepaste datum en tijd" relative: "Relatieve tijd" none: "Niet nodig" @@ -589,6 +591,8 @@ nl: edit_columns: save: "Opslaan" reset_to_default: "Standaardwaarden terugzetten" + group: + all: "alle groepen" group_histories: actions: change_group_setting: "Groepsinstelling wijzigen" @@ -3316,7 +3320,6 @@ nl: tags: "Tags" choose_for_topic: "optionele tags" info: "Info" - default_info: "Deze tag is niet beperkt tot categorieën, en heeft geen synoniemen." category_restricted: "Deze tag is beperkt tot categorieën waartoe u geen toegang hebt." synonyms: "Synoniemen" synonyms_description: "Wanneer de volgende tags worden gebruikt, worden deze vervangen door %{base_tag_name}." diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index a62962281b..779003ab18 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -624,12 +624,14 @@ pl_PL: later_today: "Później dzisiaj" next_business_day: "Następny dzień roboczy" tomorrow: "Jutro" - next_week: "Następny tydzień" post_local_date: "Data w poście" later_this_week: "Później w tym tygodniu" + this_weekend: "W ten weekend" start_of_next_business_week: "Poniedziałek" start_of_next_business_week_alt: "Następny poniedziałek" + two_weeks: "Dwa tygodnie" next_month: "Następny miesiąc" + six_months: "Sześć miesięcy" custom: "Niestandardowa data i godzina" relative: "Względny czas" none: "Żadne nie są potrzebne" @@ -675,6 +677,8 @@ pl_PL: edit_columns: save: "Zapisz" reset_to_default: "Przywróć ustawienia domyślne" + group: + all: "wszystkie grupy" group_histories: actions: change_group_setting: "Zmień ustawienia grupy" @@ -3595,7 +3599,6 @@ pl_PL: tags: "Tagi" choose_for_topic: "tagi opcjonalne" info: "Informacje" - default_info: "Ten tag nie jest ograniczony do żadnych kategorii i nie ma synonimów." category_restricted: "Ten tag jest ograniczony do kategorii, do których nie masz uprawnień dostępu." synonyms: "Synonimy" synonyms_description: "W przypadku użycia następujących tagów zostaną one zastąpione przez %{base_tag_name}." diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index beeca2cdd0..8447c51653 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -541,12 +541,14 @@ pt: later_today: "Hoje, mais tarde" next_business_day: "Próximo dia útil" tomorrow: "Amanhã" - next_week: "Próxima semana" post_local_date: "Data no post" later_this_week: "No final desta semana" + this_weekend: "Este fim de semana" start_of_next_business_week: "Segunda-feira" start_of_next_business_week_alt: "Próxima Segunda-feira" + two_weeks: "Duas semanas" next_month: "Próximo mês" + six_months: "Seis meses" custom: "Data e hora personalizadas" relative: "Tempo relativo" none: "Não é necessário" @@ -589,6 +591,8 @@ pt: other: "%{count} utilizadores" edit_columns: save: "Guardar" + group: + all: "todos os grupos" group_histories: actions: change_group_setting: "Mudar configuração do grupo" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index 6dfebadc23..5676e413ca 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -541,7 +541,6 @@ pt_BR: later_today: "Hoje mais tarde" next_business_day: "Próximo dia útil" tomorrow: "Amanhã" - next_week: "Próxima semana" post_local_date: "Data na postagem" later_this_week: "Mais tarde esta semana" start_of_next_business_week: "Segunda-feira" @@ -590,6 +589,8 @@ pt_BR: edit_columns: save: "Salvar" reset_to_default: "Restaurar ao padrão" + group: + all: "todos os grupos" group_histories: actions: change_group_setting: "Alterar configurações do grupo" @@ -3058,7 +3059,6 @@ pt_BR: tags: "Etiquetas" choose_for_topic: "etiquetas opcionais" info: "Informações" - default_info: "Esta etiqueta não está restrita a nenhuma categorias e não possui sinônimos." category_restricted: "Essa tag é restrita a categorias que você não tem permissão para acessar." synonyms: "Sinônimos" synonyms_description: "Quando as seguintes tags forem usadas, eles serão substituídas por %{base_tag_name}." diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index 5013efe461..b0ab08ed4a 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -549,8 +549,8 @@ ro: later_today: "Mai târziu azi" next_business_day: "Următoarea zi lucrătoare" tomorrow: "Mâine" - next_week: "Săptămâna viitoare" later_this_week: "Mai târziu săptămâna asta" + this_weekend: "Acest weekend" start_of_next_business_week: "Luni" start_of_next_business_week_alt: "Lunea viitoare" next_month: "Luna viitoare" @@ -594,6 +594,8 @@ ro: other: "%{count} de utilizatori" edit_columns: save: "Salvează" + group: + all: "toate grupurile" group_histories: actions: change_group_setting: "Schimbă setarea grupului" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index d4203ea738..36e76c49e9 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -625,12 +625,14 @@ ru: later_today: "Сегодня, но позже" next_business_day: "На следующий рабочий день" tomorrow: "Завтра" - next_week: "Через неделю" post_local_date: "Дата сообщения" later_this_week: "Позже на этой неделе" + this_weekend: "В эти выходные" start_of_next_business_week: "В понедельник" start_of_next_business_week_alt: "В следующий понедельник" + two_weeks: "Через 2 недели" next_month: "Через месяц" + six_months: "Через 6 месяцев" custom: "Установить дату и время" relative: "Через указанное время" none: "Не настраивать напоминание" @@ -677,6 +679,8 @@ ru: title: "Изменить заголовки" save: "Сохранить" reset_to_default: "Сбросить на значения по умолчанию" + group: + all: "Все группы" group_histories: actions: change_group_setting: "Настроить группу" @@ -1201,6 +1205,7 @@ ru: failed_to_move: "Невозможно переместить выделенные сообщения (возможно, у вас проблемы с сетевым подключением)" select_all: "Выбрать всё" tags: "Теги" + warnings: "Официальные предупреждения" preferences_nav: account: "Учётная запись" security: "Безопасность" @@ -3651,8 +3656,8 @@ ru: tags: "Теги" choose_for_topic: "Выберите теги..." info: "Информация" - default_info: "Этот тег не ограничен никакими разделами и не имеет синонимов." - category_restricted: "Этот тег ограничен разделами, к которым у вас нет доступа." + default_info: "Этот тег не используется ни в одном разделе и не имеет синонимов. Чтобы использовать этот тег, поместите его в группу тегов." + category_restricted: "Этот тег используется в разделах, к которым у вас нет доступа." synonyms: "Синонимы" synonyms_description: "При использовании следующих тегов они будут заменены на %{base_tag_name}." tag_groups_info: @@ -3702,7 +3707,7 @@ ru: delete_unused_confirmation: one: "%{count} тег будет удалён: %{tags}" few: "%{count} тега будут удалены: %{tags}" - many: "%{count} тегов будут удалены:: %{tags}" + many: "%{count} тегов будут удалены: %{tags}" other: "%{count} тегов будут удалены: %{tags}" delete_unused_confirmation_more_tags: one: "%{tags} и более %{count}" @@ -4905,6 +4910,8 @@ ru: flags_given_count: Отправлено жалоб flags_received_count: Получено жалоб warnings_received_count: Получено предупреждений + warnings_list_warning: | + Как модератор, вы, возможно, не сможете просматривать все эти темы. В случае необходимости попросите администратора или модератора, проверяющего сообщение, предоставить необходимый доступ к этому сообщению. flags_given_received_count: "Жалоб отправил / получил" approve: "Одобрить" approved_by: "Кем одобрено" diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 370e639533..9b526bda34 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -410,7 +410,7 @@ sk: other: "dní" time_shortcut: tomorrow: "Zajtra" - next_week: "Budúci týždeň" + this_weekend: "Tento víkend" next_month: "Budúci mesiac" user_action: user_posted_topic: "%{user} založil tému" @@ -451,6 +451,8 @@ sk: other: "%{count} používateľov" edit_columns: save: "Uložiť" + group: + all: "všetky skupiny" group_histories: actions: change_group_setting: "Zmeniť nastavenia skupiny" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index c51b68730b..6e565fb72f 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -560,9 +560,9 @@ sl: later_today: "Kasneje danes" next_business_day: "Naslednji delovni dan" tomorrow: "Jutri" - next_week: "Naslednji teden" post_local_date: "Datum v objavi" later_this_week: "Kasneje v tednu" + this_weekend: "Ta vikend" start_of_next_business_week: "Ponedeljek" start_of_next_business_week_alt: "Naslednji ponedeljek" next_month: "Naslednji mesec" @@ -609,6 +609,8 @@ sl: other: "%{count} uporabnikov" edit_columns: save: "Shrani" + group: + all: "vse skupine" group_histories: actions: change_group_setting: "Spremeni nastavitve za skupino" @@ -3284,7 +3286,6 @@ sl: changed: "spremenjene oznake:" tags: "Oznake" choose_for_topic: "neobvezne oznake" - default_info: "Ta oznaka ni omejena na nobeno kategorijo in nima sopomenk." category_restricted: "Ta oznaka je omejena na kategorije, do katerih nimate dovoljenja za dostop." synonyms: "Sopomenke" synonyms_description: "Ko bodo uporabljene naslednje oznake, bodo nadomeščene z %{base_tag_name}." diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index e920208f32..eb79b6c3a0 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -348,6 +348,8 @@ sq: other: "%{count} anëtarë" edit_columns: save: "Ruaj" + group: + all: "të gjitha grupet" group_histories: actions: change_group_setting: "Ndrysho parametrin e grupit" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 7cd972cbd1..8877a189e8 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -447,7 +447,6 @@ sr: later_today: "Kasnije ovog dana" next_business_day: "Naredni radni dan" tomorrow: "Sutra" - next_week: "Sledeće sedmice" post_local_date: "Datum u poruci" later_this_week: "Kasnije ove sedmice" start_of_next_business_week: "Ponedeljak" diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index de1dd20ab3..1d88f0ecbd 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -541,12 +541,14 @@ sv: later_today: "Senare idag" next_business_day: "Nästa vardag" tomorrow: "Imorgon" - next_week: "Nästa vecka" post_local_date: "Datum i inlägget" later_this_week: "Senare denna vecka" + this_weekend: "Detta veckoslut" start_of_next_business_week: "Måndag" start_of_next_business_week_alt: "Nästa måndag" + two_weeks: "Två veckor" next_month: "Nästa månad" + six_months: "Sex månader" custom: "Anpassa datum och tid" relative: "Relativ tid" none: "Ingen behövs" @@ -591,6 +593,8 @@ sv: title: "Redigera katalogkolumner" save: "Spara" reset_to_default: "Återställ till standard" + group: + all: "alla grupper" group_histories: actions: change_group_setting: "Ändra gruppinställningar" @@ -1102,6 +1106,7 @@ sv: failed_to_move: "Misslyckades med att flytta de markerade meddelandena (kanske ligger ditt nätverk nere)" select_all: "Markera alla" tags: "Taggar" + warnings: "Officiella varningar" preferences_nav: account: "Konto" security: "Säkerhet" @@ -3365,7 +3370,7 @@ sv: tags: "Taggar" choose_for_topic: "alternativa taggar" info: "Info" - default_info: "Denna tagg är inte begränsad till någon kategori, och har inga synonymer." + default_info: "Denna tagg är inte begränsad till några kategorier, och har inga synonymer. Om du vill lägga till begränsningar lägger du denna tagg i en taggrupp." category_restricted: "Denna tagg är begränsad till kategorier som du inte har tillträde till." synonyms: "Synonymer" synonyms_description: "När följande taggar används, kommer de att ersättas med %{base_tag_name}." @@ -4599,6 +4604,8 @@ sv: flags_given_count: Givna flaggningar flags_received_count: Mottagna flaggningar warnings_received_count: Mottagna varningar + warnings_list_warning: | + Som moderator kan du kanske inte se alla dessa ämnen. Vid behov ber du en administratör eller den utfärdande moderatorn att ge @moderatorer tillgång till meddelandet. flags_given_received_count: "Flaggor utdelade / mottagna" approve: "Godkänn" approved_by: "godkänd av" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index 359da475f5..5fedf43f95 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -334,8 +334,8 @@ sw: time_shortcut: later_today: "Baada ya mda leo" tomorrow: "Kesho" - next_week: "Wiki Ijayo" later_this_week: "Baada ya mda ndani ya wiki hii" + this_weekend: "Wikiendi hii" next_month: "Mwezi ujao" user_action: user_posted_topic: "%{mtumiaji} amechapisha mada" @@ -374,6 +374,8 @@ sw: other: "%{count} watumiaji" edit_columns: save: "hifadhi" + group: + all: "vikundi vyote" group_histories: actions: change_group_setting: "Badilisha mipangilio ya kikundi" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index f45a016032..99551db4fc 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -232,7 +232,7 @@ te: other: "రోజులు" time_shortcut: tomorrow: "రేపు" - next_week: "వచ్చే వారం" + this_weekend: "ఈ వారాంతం" next_month: "వచ్చే నెల" user_action: user_posted_topic: "విషయాన్ని %{user} రాసారు " diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index c353148b25..901d05990f 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -406,8 +406,8 @@ th: later_today: "ภายหลังในวันนี้" next_business_day: "ในวันทำการถัดไป" tomorrow: "พรุ่งนี้" - next_week: "สัปดาห์หน้า" later_this_week: "ภายหลังในสัปดาห์นี้" + this_weekend: "สัปดาห์นี้" start_of_next_business_week: "วันจันทร์" start_of_next_business_week_alt: "วันจันทร์หน้า" next_month: "เดือนหน้า" @@ -449,6 +449,8 @@ th: other: "%{count} ผู้ใช้" edit_columns: save: "บันทึก" + group: + all: "กลุ่มทั้งหมด" group_histories: actions: change_group_setting: "แก้ไขการตั้งค่ากลุ่ม" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index 7e5b886a15..3e43cded0e 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -519,9 +519,9 @@ tr_TR: later_today: "Bugün ilerleyen saatlerde" next_business_day: "Bir sonraki iş günü" tomorrow: "Yarın" - next_week: "Gelecek hafta" post_local_date: "Gönderideki tarih" later_this_week: "Bu hafta içinde" + this_weekend: "Bu hafta sonu" start_of_next_business_week: "Pazartesi" start_of_next_business_week_alt: "Gelecek pazartesi" next_month: "Gelecek ay" @@ -565,8 +565,11 @@ tr_TR: one: "%{count} kullanıcı" other: "%{count} kullanıcı" edit_columns: + title: "Dizin Sütunlarını Düzenle" save: "Kaydet" reset_to_default: "Varsayılana sıfırla" + group: + all: "tüm gruplar" group_histories: actions: change_group_setting: "Grup ayarlarını değiştir" @@ -2168,6 +2171,8 @@ tr_TR: back_to_list: "Konu listesine geri dön" options: "Konu Seçenekleri" show_links: "Bu konunun içindeki bağlantıları göster. " + collapse_details: "konu ayrıntılarını kapat" + expand_details: "konu ayrıntılarını göster" read_more_in_category: "Daha fazlası için %{catLink} kategorisine göz atabilir ya da %{latestLink}yebilirsin." read_more: "Daha fazla okumak mı istiyorsun? %{catLink} ya da %{latestLink}." unread_indicator: "Bu konunun son mesajını henüz hiç üye okumamış." @@ -3134,7 +3139,6 @@ tr_TR: tags: "Etiketler" choose_for_topic: "opsiyonel etiketler" info: "Bilgi" - default_info: "Bu etiket hiçbir kategoriyle sınırlandırılmamıştır, ve eş anlamlısı yoktur." category_restricted: "Bu etiket, erişim izniniz olmayan kategorilerle sınırlıdır." synonyms: "Eş Anlamlılar" synonyms_description: "İlgili etiketler kullanılıyorsa, %{base_tag_name} ile değiştirilecektir." diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index 972e54f109..663afa72eb 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -623,12 +623,14 @@ uk: later_today: "Пізніше сьогодні" next_business_day: "Наступний робочий день" tomorrow: "Завтра" - next_week: "На наступному тижні" post_local_date: "Дата повідомлення" later_this_week: "Пізніше на цьому тижні" + this_weekend: "В ці вихідні" start_of_next_business_week: "Понеділок" start_of_next_business_week_alt: "Наступного понеділка" + two_weeks: "Два тижні" next_month: "В наступному місяці" + six_months: "Шість місяців" custom: "Вибрати дату та час" relative: "Через вказаний час" none: "Нічого не потрібно" @@ -674,6 +676,8 @@ uk: edit_columns: save: "Зберегти" reset_to_default: "Скинути за замовчуванням" + group: + all: "усі групи" group_histories: actions: change_group_setting: "Змінити налаштування групи" @@ -3592,7 +3596,6 @@ uk: tags: "Мітки" choose_for_topic: "необов'язкові мітки" info: "Інформація" - default_info: "Цей тег не обмежений розділами та не має синонімів." category_restricted: "Цей тег обмежений категоріями, на які ви не маєте дозволу на доступ." synonyms: "Синоніми" synonyms_description: "Коли будуть використані наступні теги, вони будуть замінені на %{base_tag_name} ." diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index e2643b134b..f92902ade1 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -528,11 +528,13 @@ ur: time_shortcut: later_today: "آج بعد میں" tomorrow: "کَل" - next_week: "اگلے ہفتے" later_this_week: "اِس ہفتے بعد میں" + this_weekend: "اِس ہفتےکےآخر میں" start_of_next_business_week: "پیر" start_of_next_business_week_alt: "اگلے پیر" + two_weeks: "دو ہفتے" next_month: "اگلے ماہ" + six_months: "چھ مہینے" custom: "حسب ضرورت تاریخ اور وقت" user_action: user_posted_topic: "%{user} نے ٹاپک پوسٹ کیا" @@ -572,6 +574,8 @@ ur: edit_columns: save: "محفوظ کریں" reset_to_default: "دوبارہ پہلے جیسا کر دیں" + group: + all: "تمام گروپس" group_histories: actions: change_group_setting: "گروپ کی سیٹِنگ تبدیل کریں" diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index 934182191c..bdc8d3bb6b 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -479,9 +479,9 @@ vi: later_today: "Sau ngày hôm nay" next_business_day: "Ngày làm việc tiếp theo" tomorrow: "Ngày mai" - next_week: "Tuần tới" post_local_date: "Ngày trong bài" later_this_week: "Cuối tuần này" + this_weekend: "Cuối tuần này" start_of_next_business_week: "Thứ hai" start_of_next_business_week_alt: "Thứ hai tới" next_month: "Tháng t" @@ -524,6 +524,8 @@ vi: edit_columns: save: "Lưu" reset_to_default: "Đặt lại về mặc định" + group: + all: "các nhóm" group_histories: actions: change_group_setting: "Đổi cài đặt nhóm" @@ -3013,7 +3015,6 @@ vi: tags: "Thẻ" choose_for_topic: "thẻ không bắt buộc" info: "Thông tin" - default_info: "Thẻ này không bị giới hạn đối với bất kỳ danh mục nào và không có từ đồng nghĩa." category_restricted: "Thẻ này bị hạn chế đối với các danh mục bạn không có quyền truy cập." synonyms: "Từ đồng nghĩa" synonyms_description: "Khi các thẻ sau được sử dụng, chúng sẽ được thay thế bằng %{base_tag_name}." diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index 0c4f1e52ce..f78dadb14c 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -499,7 +499,6 @@ zh_CN: later_today: "今天晚些时候" next_business_day: "下一个工作日" tomorrow: "明天" - next_week: "下周" post_local_date: "发布日期" later_this_week: "本周晚些时候" start_of_next_business_week: "星期一" @@ -547,6 +546,8 @@ zh_CN: edit_columns: save: "保存" reset_to_default: "重置为默认" + group: + all: "所有群组" group_histories: actions: change_group_setting: "更改群组设置" @@ -3210,7 +3211,6 @@ zh_CN: tags: "标签" choose_for_topic: "可选标签" info: "详情" - default_info: "该标签不限于任何类别,并且没有同义词。" category_restricted: "此标签仅限于你无权访问的分类。" synonyms: "同义词" synonyms_description: "使用以下标签时,它们将被替换为%{base_tag_name} 。" diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index cebfc27f0f..990a0967ac 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -408,9 +408,9 @@ zh_TW: time_shortcut: later_today: "今日稍晚" tomorrow: "明天" - next_week: "下週" post_local_date: "張貼日期" later_this_week: "本週稍晚" + this_weekend: "這週末" start_of_next_business_week: "星期一" start_of_next_business_week_alt: "下個星期一" next_month: "下個月" @@ -451,6 +451,8 @@ zh_TW: other: "%{count} 個使用者" edit_columns: save: "儲存" + group: + all: "所有群組" group_histories: actions: change_group_setting: "更改群組設定" diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml index 247db106d2..30c2301481 100644 --- a/config/locales/server.ca.yml +++ b/config/locales/server.ca.yml @@ -1221,7 +1221,6 @@ ca: title: "El nom d'aquest lloc web per a l'etiqueta de títol." site_description: "Descriviu el lloc web amb una frase per a la metaetiqueta de descripció." short_site_description: "Descripció breu per a l'etiqueta de títol de la pàgina d'inici." - contact_email: "Adreça de correu de contacte del responsable del lloc web. S'utilitza per a notificacions crítiques, i també per al formulari de contacte /about per a qüestions urgents." crawl_images: "Recupera imatges d'adreces URL remotes per a inserir-hi les dimensions correctes d'amplada i alçada." download_remote_images_to_local: "Converteix les imatges remotes en imatges locals en descarregar-les. Això evita les imatges trencades." download_remote_images_threshold: "L'espai mínim necessari en el disc per a descarregar imatges remotes en local (percentatge)" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index d9ab3b499d..dc354e69d7 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -1414,7 +1414,6 @@ de: title: "Der Name dieser Site, wird für das Title-Tag verwendet." site_description: "Beschreibe diese Site in einem Satz. Wird für das \"description\" Meta-Tag verwendet." short_site_description: "Kurze Beschreibung, die im Title-Tag auf der Webseite verwendet wird." - contact_email: "E-Mail-Adresse einer verantwortlichen Person für diese Community. Wird verwendet für kritische Benachrichtigungen sowie auf dem /about Kontaktformular für dringende Anfragen." contact_url: "Kontakt-URL für diese Seite. Angezeigt auf der /about Seite für dringende Fragen." crawl_images: "Lade Bilder von fremden URLs herunter, um ihre Höhe und Breite zu bestimmen." download_remote_images_to_local: "Lade eine Kopie von extern gehosteten Bildern herunter und ersetze Links in Beiträgen entsprechend; dies verhindert defekte Bilder." @@ -2336,6 +2335,7 @@ de: email_in_spam_header: "Die erste E-Mail des Benutzers wurde als Spam gekennzeichnet" already_silenced: "Benutzer wurde bereits von %{staff} %{time_ago} stumm geschaltet." already_suspended: "Benutzer wurde bereits von %{staff} %{time_ago}gesperrt." + cannot_delete_has_posts: "Benutzer %{username} hat %{post_count} Beiträge, entweder öffentliche Beiträge oder persönliche Nachrichten, sodass sie nicht gelöscht werden können." reviewables_reminder: submitted: one: "Einträge wurden vor über %{count} Stunde übermittelt. [Bitte überprüfe sie](%{base_path}/review)." diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 562af59152..d8675a876f 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -227,6 +227,9 @@ es: user_exists: "No hay necesidad de invitar a %{email}, ¡ya tienen una cuenta!" invite_exists: "Ya has invitado a %{email}." invalid_email: "%{email} no es un correo electrónico válido." + rate_limit: + one: "Ya has enviado %{count} invitación en el último día, espera %{time_left} antes de volverlo a intentar." + other: "Ya has enviado %{count} invitaciones en el último día, espera %{time_left} antes de volverlo a intentar." confirm_email: "

    ¡Ya casi terminas! Te enviamos un correo de activación a tu dirección de correo electrónico. Por favor, sigue las instrucciones que allí se encuentran para activar tu cuenta.

    Si no llega, revisa la carpeta de spam o correo no deseado.

    " cant_invite_to_group: "No tienes permisos para invitar usuarios a los grupos especificados. Asegúrate de que eres dueño en los grupos a los que estás intentando invitar." disabled_errors: @@ -439,8 +442,8 @@ es: Tu solicitud para entrar al grupo @%{group_name} ha sido aceptada y ahora eres parte de él. education: until_posts: - one: "%{count} post" - other: "%{count} publicaciones" + one: "%{count} mensaje" + other: "%{count} mensajes" "new-topic": | Te damos la bienvenida a %{site_name}. **¡Gracias por empezar un nuevo tema!** @@ -462,47 +465,47 @@ es: Para más información [consulta nuestra guía](%{base_path}/guidelines). Este panel solo aparecerá para tus primeros %{education_posts_text} mensajes. avatar: | - ### ¿Que tal si agregas una imagen para tu cuenta? + ### ¿Qué tal si eliges una imagen para tu cuenta? Has publicado algunos temas y respuestas, pero tu imagen de perfil no es tan única como lo eres tú, es solo una letra. ¿Has considerado **[visitar tu perfil](%{profile_path})** y subir una imagen que te represente? - ¡Es más fácil seguir debates y encontrar personas interesantes en las conversaciones cuando todos tienen una imagen de perfil única! + Es más fácil seguir debates y encontrar personas interesantes en las conversaciones cuando todo el mundo tiene una imagen de perfil única. sequential_replies: | ### Considera la posibilidad de responder a varias publicaciones a la vez - En vez de publicar varias respuestas seguidas en un tema, por favor, considera incluir en una sola respuesta citas de las publicaciones previas o mencionar a un @usuario. + En vez de publicar varias respuestas seguidas en un tema, considera incluir en una sola respuesta varias citas de las publicaciones previas o @menciones a otras personas. - Puedes editar tu respuesta previa para añadir una cita seleccionando el texto y pulsando en el botón citar respuesta que aparecerá. + Puedes editar tu anterior respuesta para añadir una cita. Para ello, selecciona el texto que quieras citar y pulsa el botón citar respuesta que aparecerá. - Es más fácil para todos leer temas con menos respuestas pero más profundas que muchas respuestas pequeñas individuales. + Es más fácil leer temas que tengan menos respuestas (aunque más profundas), que tener que leer muchas respuestas individuales. dominating_topic: | ### Deja que otras personas se unan a la conversación Este tema es claramente importante para ti – has publicado más del %{percent}% de las respuestas. - Podría mejor todavía si le dieras a otras personas la oportunidad de compartir sus puntos de vista también. ¿Podrías invitarles? + Podría ser mejor todavía si le dieras a otras personas la oportunidad de compartir sus puntos de vista también. ¿Podrías invitarles? get_a_room: | ### Anima a los demás a participar - Has respondido %{count} veces a @%{reply_username} en este tema en particular + Has respondido %{count} veces a @%{reply_username} en este tema en particular. - Un buen debate incluye muchas voces y perspectivas. ¿Cómo podrías animar a que participar alguien más? + Un buen debate incluye muchas voces y perspectivas. ¿Puedes animar a que participe alguien más? - Y, no olvides: si quieres continuar la conversación con esta persona en particular fuera de la vista de todo el mundo, [envíales un mensaje personal](%{base_path}/u/%{reply_username}). + Y no olvides: si quieres seguir la conversación con esta persona en particular fuera de la vista de todo el mundo, [envíale un mensaje personal](%{base_path}/u/%{reply_username}). too_many_replies: | - ### Has alcanzado el límite de respuestas para este tema + ### Has llegado al límite de respuestas en este tema - Lo sentimos, los usuarios nuevos tienen temporalmente un límite de %{newuser_max_replies_per_topic} respuestas en el mismo tema. + Lo sentimos, los nuevos usuarios tienen temporalmente un límite de %{newuser_max_replies_per_topic} respuestas en el mismo tema. - En vez de añadir otra respuesta, por favor, considera editar tus respuestas previas o visitar otros temas. + En vez de añadir otra respuesta, puedes editar tus respuestas anteriores o visitar otros temas. reviving_old_topic: | ### ¿Revivir este tema? - La última respuesta a este tema fue **%{time_ago}**. Tu publicación reactivará el tema, subiéndolo a la parte superior de la lista y notificando a aquellos previamente involucrados. + La última respuesta a este tema fue **%{time_ago}**. Tu publicación reactivará el tema, subiéndolo a la parte superior de la lista y notificando a aquellas personas involucradas en su momento. - ¿Estás seguro de que quieres continuar esta conversación antigua? + ¿Seguro que quieres continuar esta conversación antigua? activerecord: attributes: category: @@ -585,6 +588,8 @@ es: attributes: word: too_many: "Demasiadas palabras para esa acción" + base: + invalid_url: "La dirección URL de reemplazo no es válida" <<: *errors uncategorized_category_name: "Sin categoría" vip_category_name: "Sala VIP" @@ -934,7 +939,7 @@ es: short_description: 'Violación de las directrices de la comunidad' notify_moderators: title: "Notificar a los moderadores" - description: 'Este tema necesita revisión por parte del staff con respecto a las directrices, Términos de servicio o por otra razón no mencionada anteriormente.' + description: 'Este tema necesita ser revisado por por un motivo relacionado con las directrices, Términos de servicio o por otra razón no mencionada anteriormente.' long_form: "reportado para ser atendido por los moderadores" short_description: "Requiere atención del staff por otro motivo" email_title: 'El tema «%{title}» requiere la atención de un moderador' @@ -1396,7 +1401,6 @@ es: title: "El nombre de este sitio, utilizada en la etiqueta título." site_description: "Describe este sitio en una frase, utilizada en la etiqueta meta description." short_site_description: "Descripción breve, utilizada como el título de la etiqueta en la página principal." - contact_email: "Dirección de correo electrónico del responsable de este sitio. Utilizado para notificaciones críticas, así como en la página de /información de contacto para asuntos urgentes." contact_url: "Dirección URL de contacto para el sitio. Se mostrará en la página de /about (acerca de) para temas urgentes." crawl_images: "Recuperar imágenes desde URLs remotas para insertarlas con las dimensiones correctas de ancho y de largo." download_remote_images_to_local: "Convertir imágenes remotas a imágenes locales descargándolas; esto previene imágenes rotas." @@ -1484,7 +1488,7 @@ es: post_undo_action_window_mins: "Número de minutos durante los cuales los usuarios pueden deshacer sus acciones recientes en una publicación (me gusta, reportes, etc)." must_approve_users: "El staff debe aprobar todas las cuentas nuevas antes de que se les permita acceder al sitio." invite_code: "El usuario debe ingresar este código para que se le permita el registro de la cuenta, ignorado cuando está vacío (no distingue entre mayúsculas y minúsculas)" - approve_suspect_users: "Agregar usuarios sospechosos a la lista por revisión. Los usuarios sospechosos que hayan ingresado una biografía/página web pero que no hayan registrado ninguna actividad de lectura." + approve_suspect_users: "Mandar usuarios sospechosos a la lista de revisión. Se consideran usuarios sospechosos aquellos que hayan introducido una biografía o página web, pero que no hayan registrado ninguna actividad de lectura." review_every_post: "Todas las publicaciones deben ser revisadas. ¡ADVERTENCIA! NO SE RECOMIENDA PARA SITIOS CON MUCHA ACTIVIDAD." pending_users_reminder_delay: "Notificar a los moderadores si hay usuarios nuevos que hayan estado esperando aprobación durante más de esta cantidad de horas. Usa -1 para desactivar estas notificaciones." persistent_sessions: "Los usuarios permanecerán con la sesión abierta aunque cierren su navegador" @@ -2042,7 +2046,7 @@ es: gravatar_login_url: "URL relativa a gravatar_base_url, la cual provee al usuario el acceso al servicio de Gravatar" share_quote_buttons: "Determinar los elementos que aparecen en el cuadro de compartir y su orden." share_quote_visibility: "Cuándo mostrar los botones de compartir citas: nunca, a los usuarios anónimos sólo o a todos los usuarios. " - create_revision_on_bulk_topic_moves: "Crear revisión para los primeros mensajes cuando los temas se mueven a una nueva categoría en masa." + create_revision_on_bulk_topic_moves: "Crear edición para los primeros mensajes cuando los temas se mueven a una nueva categoría en masa." errors: invalid_email: "Dirección de correo electrónico inválida. " invalid_username: "No existe ningún usuario con ese nombre de usuario. " @@ -2120,7 +2124,7 @@ es: poster_description_joiner: ", " redirected_to_top_reasons: new_user: "¡Bienvenido a nuestra comunidad! Estos son los temas recientes mas populares." - not_seen_in_a_month: "¡Bienvenido de vuelta! No te habíamos visto durante algún tiempo. Estos son los temas mas populares desde que nos visitaste por última vez." + not_seen_in_a_month: "¡Hola! Hacía tiempo que no te veíamos. Estos han sido los temas más destacados desde que nos visitaste por última vez." merge_posts: edit_reason: one: "Un tema fue juntado por %{username}" @@ -2272,6 +2276,7 @@ es: other: "Desactivado automáticamente después de %{count} días de inactividad." activated_by_staff: "Activado por el staff" new_user_typed_too_fast: "El nuevo usuario escribió demasiado rápido" + content_matches_auto_silence_regex: "Contenido coincide con regex para autosilenciar" content_matches_auto_block_regex: "El contenido coincide con una expresión regular de bloqueo automático" username: short: "debe tener al menos %{min} caracteres" @@ -2983,7 +2988,7 @@ es: text_body_template: | Hola, - Este es un mensaje automatizado desde %{site_name} para informarte que tras la revisión de miembros del staff que tu cuenta ya no se encuentra suspendida. + Esto es un mensaje automático desde %{site_name} para informarte de que, tras la revisión por parte de miembros del staff, tu cuenta ya no se encuentra suspendida. Ahora puedes crear temas y responder a otros usuarios. Gracias por tu paciencia. pending_users_reminder: @@ -3479,6 +3484,7 @@ es: Esto es un mensaje automático de %{site_name} para hacerte saber que [tu publicación](%{base_url}%{post_url}) ha sido aprobada. page_forbidden: title: "¡Ups! Esa página es privada." + site_setting_missing: "Debes rellenar el ajuste `%{name}`." page_not_found: title: "¡Ups! Esa página no existe o es privada." popular_topics: "Destacados" @@ -4054,7 +4060,7 @@ es: long_description: | Esta medalla se concede la primera vez que publicas un enlace solo en una línea, que se expande automáticamente en un onebox con un resumen, título y (cuando esté disponible) imagen. first_reply_by_email: - name: Primera respuesta por correo electrónico + name: Primera respuesta por correo description: Respondió a una publicación por correo electrónico long_description: | Esta medalla se concede la primera vez que respondes a una publicación por correo electrónico :e-mail:. @@ -4301,6 +4307,7 @@ es: restored: Restaurada reviewables: already_handled: "Gracias, pero ya revisamos esta publicación y determinamos que no necesita reportarse de nuevo." + already_handled_and_user_not_exist: "Gracias, pero otra persona ya lo ha revisado, y ese usuario ha dejado de existir." priorities: low: "Bajo" medium: "Medio" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index fd1307ce9e..00b57a0696 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -1394,7 +1394,6 @@ fi: title: "Palstan nimi, käytetään title tagissa." site_description: "Kuvaile sivustoa yhdellä lauseella, jota käytetään meta description tagissa." short_site_description: "Lyhyt kuvaus, jota käytetään etusivulla otsikkotagissa (title tag)." - contact_email: "Sivustosta vastaavan henkilön sähköpostiosoite. Siihen lähetetään kriittiset ilmoitukset ja se näkyy /about-sivulla kiireellisiä yhteydenottoja varten." contact_url: "URL-osoite sivuston yhteydenottoja varten. Näytetään Tietoja -sivulla kiireellisiä yhteydenottoja varten." crawl_images: "Lataa linkatut kuvat kuvan dimensioiden määrittamiseksi." download_remote_images_to_local: "Muunna linkatut kuvat liitetiedostoiksi lataamalla ne; tämä estää kuvien rikkoontumisen vanhentuneiden linkkien vuoksi." diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 675f719db3..ddc0959cec 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -958,7 +958,7 @@ fr: email_body: "%{link}\n\n%{message}" flagging: you_must_edit: '

    Votre message a été signalé par la communauté. Veuillez consulter vos messages directs.

    ' - user_must_edit: "

    Ce message a été signalé par la communauté et est temporairement masqué.

    " + user_must_edit: "

    Ce message est provisoirement masqué suite à des signalements de la communauté.

    " ignored: hidden_content: "

    Contenu ignoré

    " archetypes: @@ -1414,7 +1414,6 @@ fr: title: "Le nom du site, utilisé dans la balise title." site_description: "Décrivez ce site en une seule phrase, utilisée dans la balise meta description." short_site_description: "Description courte, utilisée dans la balise title de la page d'accueil." - contact_email: "Adresse courriel de la personne responsable de ce site. Elle est utilisée pour des notifications critiques et des faits urgents en provenance du formulaire de contact de la page À propos." contact_url: "URL de contact pour ce site. Elle est affichée sur la page À propos et les utilisateurs sont invités à l'utiliser en cas de problèmes urgents." crawl_images: "Récupérer les images provenant de sources tierces pour y insérer les dimensions correctes (hauteur et largeur)." download_remote_images_to_local: "Transformer les images distantes en images locales en les téléchargeant ; cela permet d'éviter les liens morts." @@ -2336,6 +2335,7 @@ fr: email_in_spam_header: "Le premier courriel de l'utilisateur a été signalé comme spam" already_silenced: "L'utilisateur a déjà été mis sous silence par %{staff} %{time_ago}." already_suspended: "L'utilisateur a déjà été suspendu par %{staff} %{time_ago}." + cannot_delete_has_posts: "L'utilisateur %{username} a déjà publié %{post_count} message(s) publics ou privés, ce qui l'empêche d'être supprimé." reviewables_reminder: submitted: one: "Ces éléments ont été soumis il y a plus de %{count} heure. [Nous vous invitons à les examiner](%{base_path}/review)." diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml index c59a66bdf0..145d82ff35 100644 --- a/config/locales/server.gl.yml +++ b/config/locales/server.gl.yml @@ -1374,7 +1374,6 @@ gl: title: "O nome deste sitio, utilizado na etiqueta título." site_description: "Describa o sitio nunha única frase, utilizada na etiqueta metadescrición." short_site_description: "Descrición curta, utilizada como o título da etiqueta na páxina principal." - contact_email: "Enderezo electrónico da/do responsable deste sitio. Utilízase para notificacións moi importantes e mais na páxina de información de contacto para asuntos urxentes." crawl_images: "Recuperar imaxes desde URL remotos para inserilas coas dimensións correctas de largura e altura." download_remote_images_to_local: "Converter imaxes remotas en locais ao descargalas; isto prevén que haxa imaxes rotas." download_remote_images_threshold: "Espazo mínimo que cómpre no disco para descargar imaxes remotas de forma local (en porcentaxe)" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 6cb80770ed..ed401867a7 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -1491,7 +1491,6 @@ he: title: "שם האתר הזה, כפי שהוא משמש בתגית הכותרת." site_description: "תארו את האתר הזה במשפט אחד, כפי שהוא מופיע במטא-תגית התיאור." short_site_description: "תיאור קצר, כפי שמתואר בתגית הכותרת באתר הבית." - contact_email: "כתובת דוא״ל של איש קשר מוביל שאחראי על האתר הזה. משמש להתראות קריטיות, כמו גם לטובת טופס יצירת הקשר ב־‎/about לטיפול בנושאים דחופים." contact_url: "כתובת יצירת הקשר של האתר הזה. מופיעה בעמוד ‎/about לנושאים דחופים." crawl_images: "לקבל תמונות מכתובות מרוחקות כדי לציין את ממדי הרוחב והגובה הנכונים." download_remote_images_to_local: "המרת תמונות מרוחקות למקומיות על ידי הורדתן, פעולה זו מסייעת במניעת תמונות שבורות." diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml index 9fd3532c31..898050aef4 100644 --- a/config/locales/server.hy.yml +++ b/config/locales/server.hy.yml @@ -1093,7 +1093,6 @@ hy: title: "Այս կայքի անունը, ինչպես որ օգտագործված է վերնագրի թեգում:" site_description: "Նկարագրեք այս կայքը մեկ նախադասությամբ, ինչպես որ օգտագործված է մետա-նկարագրության թեգում:" short_site_description: "Կարճ նկարագրություն, ինչպես որ օգտագործված է գլխավոր էջի վերնագրի թեգում:" - contact_email: "Այս կայքի համար պատասխանատու key կոնտակտի էլ. հասցեն: Օգտագործվում է ծայրահեղ ծանուցումների համար, ինչպես նաև՝ /about կոնտակտային ֆորմի մեջ՝ հրատապ դեպքերի համար:" crawl_images: "Ստանալ նկարներ հեռակա URL-ներից՝ ճշգրիտ լայնության և երկարության չափումները մուտքագրելու համար:" download_remote_images_to_local: "Դարձնել հեռակա նկարները տեղական՝ ներբեռնելով դրանք; սա կանխում է նկարների կորչելը: " download_remote_images_threshold: "Հեռակա նկարները տեղայնորեն ներբեռնելու համար դիսկի նվազագույն անհրաժեշտ տարածքը (տոկոսներով)" diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index e3864ccc8e..9d5bbf1267 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -1390,7 +1390,6 @@ it: title: "Il nome di questo sito, da usare nell'attributo title." site_description: "Descrivi questo sito in una frase, da usare nell'attributo meta description." short_site_description: "Breve descrizione, come usata nel titolo dell'Etichetta in homepage." - contact_email: "Indirizzo email del contatto principale responsabile di questo sito. Utilizzato per notifiche critiche, nonché sul modulo di contatto / su per questioni urgenti." contact_url: "URL di contatto per questo sito. Visualizzato nella pagina / Informazioni , sezione contatti urgenti." crawl_images: "Recupera le immagini dagli URL remoti per inserire le dimensioni corrette di ampiezza e altezza nel tag." download_remote_images_to_local: "Scarica localmente le immagini remote; ciò permettei di evitare immagini assenti." @@ -2197,6 +2196,7 @@ it: reviewable_reject: "Utente verificabile rifiutato" already_silenced: "L'utente è già stato silenziato da %{staff} %{time_ago}." already_suspended: "L'utente è già stato sospeso da %{staff} %{time_ago}." + cannot_delete_has_posts: "L'utente %{username} ha %{post_count} messaggi tra pubblici e personali, perciò non può essere cancellato." reviewables_reminder: submitted: one: "Gli articoli sono stati inviati oltre %{count} ora fa. [Si prega di rivederli] (%{base_path} / recensione)." diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 2964fe00d4..2f30081981 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -873,8 +873,10 @@ ko: imap_authentication_error: "제공된 IMAP 자격 증명에 문제가 있습니다. 사용자 이름과 비밀번호를 확인하고 다시 시도하세요." imap_no_response_error: "IMAP 서버와 통신하는 동안 오류가 발생했습니다. %{message}" smtp_authentication_error: "제공된 SMTP 자격 증명에 문제가 있습니다. 사용자 이름과 비밀번호를 확인하고 다시 시도하세요." + authentication_error_gmail_app_password: '애플리케이션 비밀번호가 필요합니다. 이 Google 도움말 문서에서 자세히 알아보기' smtp_server_busy_error: "SMTP 서버가 현재 사용 중입니다. 나중에 다시 시도하십시오." smtp_unhandled_error: "SMTP 서버와 통신할 때 처리되지 않은 오류가 발생했습니다. %{message}" + imap_unhandled_error: "IMAP 서버와 통신할 때 처리되지 않은 오류가 발생했습니다. %{message}" connection_error: "서버에 연결하는 동안 문제가 발생했습니다. 서버 이름과 포트를 확인한 후 다시 시도하십시오." timeout_error: "서버 연결 시간이 초과되었습니다. 서버 이름과 포트를 확인한 후 다시 시도하십시오." unhandled_error: "이메일 설정을 테스트할 때 처리되지 않은 오류가 발생했습니다. %{message}" @@ -1368,7 +1370,6 @@ ko: title: "타이틀 태그에 쓰일 이 사이트의 이름" site_description: "이 사이트를 한 문장으로 설명해 주세요. 설명 메타태그에 사용됩니다." short_site_description: "홈페이지의 제목 태그에 사용 된 간단한 설명입니다." - contact_email: "이 사이트를 담당하는 주요 담당자의 이메일 주소입니다. 긴급한 문제에 대한 / about 문의 양식뿐만 아니라 중요한 알림에 사용됩니다." contact_url: "이 사이트의 연락처 URL입니다. 긴급한 사항의 문의는 /about 페이지에서 확인하세요." crawl_images: "원격 URL에서 이미지를 가져와 올바른 너비 및 높이 치수를 삽입합니다." download_remote_images_to_local: "원격 이미지를 다운로드하여 로컬 이미지로 변환합니다. 이렇게 하면 이미지가 손상되지 않습니다." diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index a3e72f3ae6..8248655efd 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -1321,7 +1321,6 @@ nl: title: "De naam van deze website, zoals gebruikt in de titeltag" site_description: "Beschrijf deze website in één zin, zoals gebruikt in de meta-omschrijvingstag." short_site_description: "Korte beschrijving, zoals gebruikt in de titeltag op de startpagina." - contact_email: "E-mailadres van de hoofdpersoon die verantwoordelijk is voor deze website. Wordt gebruikt voor kritieke meldingen, evenals op het /about-contactformulier voor dringende zaken." crawl_images: "Afbeeldingen van externe URL's ophalen om de juiste breedte- en hoogteafmetingen in te voegen" download_remote_images_to_local: "Externe afbeeldingen naar lokale afbeeldingen converteren door ze te downloaden; dit voorkomt beschadigde afbeeldingen." download_remote_images_threshold: "Minimaal vereiste schijfruimte om externe afbeeldingen lokaal te downloaden (percentage)" diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index 12f952d5f9..18adfccd56 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -1494,7 +1494,6 @@ pl_PL: title: "Nazwa tej strony używana w tagu title." site_description: "Opis strony w jednym zdaniu, wykorzystywany w meta tagu description." short_site_description: "Krótki opis, użyty w tytule na stronie głównej." - contact_email: "Adres e-mail kluczowego kontaktu odpowiedzialnego za tę stronę. Służy do krytycznych powiadomień, a także w formularzu kontaktowym / about w pilnych sprawach." contact_url: "Adres URL kontaktu dla tej witryny. Wyświetlany na stronie /about dla pilnych spraw." crawl_images: "Pobieraj grafiki ze zdalnych URLi aby ustawić poprawną wysokość i szerokość w tagu img." download_remote_images_to_local: "Pobieraj zdalne grafiki i twórz ich lokalne kopie aby zapobiegać uszkodzonym/brakującym obrazkom na stronach." diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index fcfa436c22..f78c918811 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -1256,7 +1256,6 @@ pt_BR: title: "O nome deste site, como usado na tag de título." site_description: "Descreva este site com uma frase, como usado na tag de meta description." short_site_description: "Breve descrição, como usado na tag de título na página inicial." - contact_email: "Endereço de e-mail do contato principal responsável por este site. Usado para notificações críticas, bem como no formulário de contato da /about para questões urgentes." crawl_images: "Recupere imagens de URLs remotas para inserir as dimensões de largura e altura corretos." download_remote_images_to_local: "Converta imagens remotas para imagens locais, transferindo-as; isto evita imagens quebradas." download_remote_images_threshold: "Espaço mínimo necessário para download das imagens ( em % )" diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index cc6950d654..4b82e6bdbe 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -1515,7 +1515,6 @@ ru: title: "Название этого сайта. Будет использоваться в заголовке веб-страницы." site_description: "Опишите сайт одним предложением для использования этого текста в мета-теге 'description'." short_site_description: "Краткое описание, используемое в теге заголовка на главной странице" - contact_email: "Email ключевого контактного лица, ответственного за этот сайт. Используется для критических уведомлений или срочных обращений через контактную форму на странице /about." contact_url: "Контактный URL этого сайта. Указан на странице /about для срочных вопросов." crawl_images: "Скачивать картинки с других ресурсов для автоматического определения их размеров." download_remote_images_to_local: "Скачивать картинки, расположенные на других сайтах, и хранить их локально, что снижает риск их повреждения." @@ -2477,6 +2476,7 @@ ru: email_in_spam_header: "Первое электронное письмо пользователя было помечено как спам" already_silenced: "Пользователь уже был заблокирован сотрудником %{staff} %{time_ago}." already_suspended: "Пользователь уже был заморожен сотрудником %{staff} %{time_ago}." + cannot_delete_has_posts: "У пользователя %{username} есть %{post_count} сообщений, (публичных или личных), поэтому его нельзя удалить." reviewables_reminder: submitted: one: "Сообщения были отправлены более %{count} часа назад. [Пожалуйста, просмотрите их](%{base_path}/review)." diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index a9fc16d37d..d3cc77bdb9 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -1394,7 +1394,7 @@ sv: title: "Namnet på denna webbplats som används i titel-taggen." site_description: "Beskriv denna webbplats i en mening, som sedan används i metabeskrivningstaggen." short_site_description: "Kort beskrivning, som används i titel-taggen på hemsidan." - contact_email: "E-postadress till huvudansvarig för denna webbplats. Används för kritiska meddelanden såväl som på /about kontaktformuläret för brådskande ärenden." + contact_email: "E-postadress till nyckelkontakt som är ansvarig för denna webbplats. Används för kritiska aviseringar, och visas även på /about för brådskande ärenden." contact_url: "Kontakt-URL för denna webbplats. Visas på /about-sidan för brådskande ärenden." crawl_images: "Hämta bilder från tredjepartskällor för att infoga korrekta bredd- och höjddimensioner." download_remote_images_to_local: "Konvertera externa bilder till lokala bilder genom att hämta dem; det motverkar felaktiga bildlänkar." @@ -2316,6 +2316,7 @@ sv: email_in_spam_header: "Användarens första e-post flaggades som skräppost" already_silenced: "Användaren har redan tystats av %{staff} %{time_ago}." already_suspended: "Användaren har redan avstängts av %{staff} %{time_ago}." + cannot_delete_has_posts: "Användaren %{username} har %{post_count} inlägg, antingen offentliga inlägg eller personliga meddelanden, så de kan inte raderas." reviewables_reminder: submitted: one: "Artiklar skickades in över %{count} timme sedan. [Vi ber dig granska dem] (%{base_path}/review)." diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 22af18d244..870462a604 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -1245,7 +1245,6 @@ tr_TR: title: "Sayfa başlığı etiketinde kullanılacak, bu sitenin ismi." site_description: "Meta açıklama etiketinde kullanılacak, bu sitenin bir cümlelik açıklaması." short_site_description: "Ana sayfadaki başlık etiketinde kullanılan kısa açıklama." - contact_email: "Bu site için sorumlu kişinin e-posta adresi. /about iletişim formu içerisinde, sadece acil durumlar ve kritik bildirimler için kullanılacak." crawl_images: "Doğru genişlik ve yükseklik boyutlarını girmek için uzak URL'lerdeki resimlerin birer kopyasını alınsın." download_remote_images_to_local: "Uzaktaki resimler yerel resimlere çevirmek için indirilsin; bu ayar resim bağlantılarının kırılmasını önleyecektir" download_remote_images_threshold: "Uzaktaki resimlerin yerele indirilmesi için gereken en az disk alanı (yüzdesel)" @@ -2002,6 +2001,7 @@ tr_TR: same_ip_address: "Diğer kullanıcılarla aynı IP adresi (%{ip_address})" inactive_user: "Etkin olmayan kullanıcı" email_in_spam_header: "Kullanıcının ilk e-postası spam olarak işaretlendi" + cannot_delete_has_posts: "%{username} kullanıcısının herkese açık gönderiler veya kişisel mesajlardan oluşan %{post_count} adet gönderisi var, dolayısıyla silinemez." reviewables_reminder: submitted: one: "Öğeler bir saat önce gönderildi %{count} saat. [Lütfen onları inceleyin] (%{base_path} / review)." diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index fe0bad5863..a873a5e53e 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -1470,7 +1470,6 @@ uk: title: "Назва цього сайту. Буде додано в HTML-тег title." site_description: "Опишіть сайт одним реченням, для використання опису в мета-тег description." short_site_description: "Короткий опис, що використовується в тезі заголовка на головній сторінці" - contact_email: "E-mail адреса ключової контактної особи, відповідальної за цей сайт. Використовується для критичних повідомлень, або термінові звернення через контактну форму на сторінці /about." contact_url: "Контактний URL цього сайту. Вказано на сторінці /about для термінових питань." crawl_images: "Отримувати зображення з віддалених адрес, щоб встановити правильні розміри ширини та висоти." download_remote_images_to_local: "Завантажувати картинки, вставлені в повідомлення посиланнями на інші сайти, і зберігати їх локально, щоб запобігти їх зміни або втрату." diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index f326d7aa9c..cad26d1673 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -1179,7 +1179,6 @@ ur: title: "اس سائٹ کا نام، جیسا کہ عنوان ٹَیگ میں استعمال ہوتا ہے۔" site_description: "اِس سائٹ کو ایک جملہ میں بیان کریں، جیسا کہ مَیٹا وضاحتی ٹَیگ میں استعمال کیا جاتا ہے۔" short_site_description: "مختصر وضاحت، جیسا کہ ہوم پیج پر عنوان ٹَیگ میں استعمال ہوتا ہے۔" - contact_email: "اِس سائٹ کیلئے ذمہ دار اہم کانٹیکٹ کا ای میل ایڈریس۔ اہم اطلاعات کے لئے استعمال کیا جاتا ہے، اور اُس کے ساتھ ساتھ فوری طور کے معاملات کیلئے /about رابطہ فارم پر۔" crawl_images: "صحیح چوڑائی اور اونچائی والے طول و عرض داخل کرنے کیلئے ریمَوٹ URL سے تصاویر حاصل کریں۔" download_remote_images_to_local: "ڈاؤن لوڈ کرکے ریمَوٹ تصاویر کو مقامی تصاویر میں تبدیل کریں؛ اِس طرح سے ٹوٹی ہوئی تصاویر کو روکا جا سکتا ہے۔" download_remote_images_threshold: "مقامی طور پر ریمَوٹ تصاویر کو ڈاؤن لَوڈ کرنے کیلئے ڈِسک میں کم از کم جگہ (فیصد میں)" diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index 556e5a315a..fbd81ea9fb 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -1364,7 +1364,6 @@ zh_CN: title: "站点名字,用于 title 标签。" site_description: "用一句话描述这个论坛,用于 meta description 标签。" short_site_description: "简短的描述,用于主页上的标题标签。" - contact_email: "本网站主要负责人的电子邮件地址。用于重要通知以及/about页面中的紧急联系人。" contact_url: "这个站点的联系链接。作为紧急联系方式显示在/about页面。" crawl_images: "允许从第三方 URL 获取图片来插入宽、高数值" download_remote_images_to_local: "通过下载将远程图像转换为本地图像;此选项可避免图像损坏。" @@ -2264,6 +2263,7 @@ zh_CN: email_in_spam_header: "用户的第一封电子邮件已被标记为垃圾邮件" already_silenced: "用户已被 %{staff} 在 %{time_ago} 静音。" already_suspended: "用户已被 %{staff} 在 %{time_ago} 封禁。" + cannot_delete_has_posts: "用户 %{username} 有 %{post_count} 个帖子,可能是公开贴或私信,因此无法被删除。" reviewables_reminder: submitted: other: "项目提交于1%{count}小时前。[请审核](%{base_path}/review)。" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index 20629510a1..b917289852 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -1116,7 +1116,6 @@ zh_TW: title: "網站名字,用於 title 標籤。" site_description: "用一句話描述這個網站,用於 meta description 標籤。" short_site_description: "簡短描述,用於網頁標題。" - contact_email: "站長的聯絡方式,只適用於緊急事項通知,如未處理的標記,以及作為 /about 頁面中的緊急聯絡人。" crawl_images: "允許從第三方 URL 取得圖片,來加入寬和高的數值" download_remote_images_to_local: "下載外部鏈接的圖片到本機;以防圖片損壞。" download_remote_images_threshold: "可用來下載外部圖片到本機最少的空間(百分比)" From e36377d9ab0cb1fcfff844c906fd564b54a70a4d Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 15 Jun 2021 17:25:06 +0200 Subject: [PATCH 055/403] DEV: Don't user before(:all)/after(:all) (#13389) Leaking state and non-obvious order (before :all runs *before* RailsHelper.test_setup) are not worth it. A replacement PR for #13370. Fixes some flaky specs, e.g. ``` bin/rspec './spec/components/freedom_patches/translate_accelerator_spec.rb[1:3]' './spec/jobs/clean_up_user_export_topics_spec.rb[1:1]' --tag ~type:multisite --seed 35994 ``` Also included: * DEV: No need for locale reset (we do it anyway in rails_helper in `test_setup`) --- .../freedom_patches/translate_accelerator_spec.rb | 8 ++------ spec/components/pretty_text_spec.rb | 4 ++-- spec/components/theme_settings_parser_spec.rb | 2 +- spec/jobs/vacate_legacy_prefix_backups_spec.rb | 6 ++---- spec/lib/backup_restore/local_backup_store_spec.rb | 13 +++++-------- spec/lib/backup_restore/s3_backup_store_spec.rb | 6 ++---- .../shared_examples_for_backup_store.rb | 2 +- spec/models/theme_field_spec.rb | 6 +----- spec/models/theme_spec.rb | 5 +---- 9 files changed, 17 insertions(+), 35 deletions(-) diff --git a/spec/components/freedom_patches/translate_accelerator_spec.rb b/spec/components/freedom_patches/translate_accelerator_spec.rb index cd52c799ed..103973256f 100644 --- a/spec/components/freedom_patches/translate_accelerator_spec.rb +++ b/spec/components/freedom_patches/translate_accelerator_spec.rb @@ -3,18 +3,14 @@ require "rails_helper" describe "translate accelerator" do - before(:all) do + before do @original_i18n_load_path = I18n.load_path.dup I18n.load_path += Dir["#{Rails.root}/spec/fixtures/i18n/translate_accelerator.*.yml"] I18n.reload! end - after(:all) do - I18n.load_path = @original_i18n_load_path - I18n.reload! - end - after do + I18n.load_path = @original_i18n_load_path I18n.reload! end diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index ae4ee4a911..0d53880966 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -1319,7 +1319,7 @@ HTML end describe "censoring" do - after(:all) { Discourse.redis.flushdb } + after { Discourse.redis.flushdb } def expect_cooked_match(raw, expected_cooked) expect(PrettyText.cook(raw)).to eq(expected_cooked) @@ -1404,7 +1404,7 @@ HTML end describe "watched words - replace & link" do - after(:all) { Discourse.redis.flushdb } + after { Discourse.redis.flushdb } it "replaces words with other words" do Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "dolor sit*", replacement: "something else") diff --git a/spec/components/theme_settings_parser_spec.rb b/spec/components/theme_settings_parser_spec.rb index e65169bd95..e24ecd2011 100644 --- a/spec/components/theme_settings_parser_spec.rb +++ b/spec/components/theme_settings_parser_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' require 'theme_settings_parser' describe ThemeSettingsParser do - after(:all) do + after do ThemeField.destroy_all end diff --git a/spec/jobs/vacate_legacy_prefix_backups_spec.rb b/spec/jobs/vacate_legacy_prefix_backups_spec.rb index 4de6325ba8..45eb0df9ef 100644 --- a/spec/jobs/vacate_legacy_prefix_backups_spec.rb +++ b/spec/jobs/vacate_legacy_prefix_backups_spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true require "s3_helper" -require 'rails_helper' +require "rails_helper" describe Jobs::VacateLegacyPrefixBackups, type: :multisite do let(:bucket_name) { "backupbucket" } - before(:all) do + before do @s3_client = Aws::S3::Client.new(stub_responses: true) @s3_options = { client: @s3_client } @objects = [] @@ -15,9 +15,7 @@ describe Jobs::VacateLegacyPrefixBackups, type: :multisite do @s3_client.stub_responses(:list_objects_v2, -> (context) do { contents: objects_with_prefix(context) } end) - end - before do setup_s3 SiteSetting.s3_backup_bucket = bucket_name SiteSetting.backup_location = BackupLocationSiteSetting::S3 diff --git a/spec/lib/backup_restore/local_backup_store_spec.rb b/spec/lib/backup_restore/local_backup_store_spec.rb index 50e1a36845..5247daf28b 100644 --- a/spec/lib/backup_restore/local_backup_store_spec.rb +++ b/spec/lib/backup_restore/local_backup_store_spec.rb @@ -5,19 +5,16 @@ require 'backup_restore/local_backup_store' require_relative 'shared_examples_for_backup_store' describe BackupRestore::LocalBackupStore do - before(:all) do + before do @root_directory = Dir.mktmpdir @paths = [] - end - - after(:all) do - FileUtils.remove_dir(@root_directory, true) - end - - before do SiteSetting.backup_location = BackupLocationSiteSetting::LOCAL end + after do + FileUtils.remove_dir(@root_directory, true) + end + subject(:store) { BackupRestore::BackupStore.create(root_directory: @root_directory) } let(:expected_type) { BackupRestore::LocalBackupStore } diff --git a/spec/lib/backup_restore/s3_backup_store_spec.rb b/spec/lib/backup_restore/s3_backup_store_spec.rb index 5f6745ff15..c4120a3964 100644 --- a/spec/lib/backup_restore/s3_backup_store_spec.rb +++ b/spec/lib/backup_restore/s3_backup_store_spec.rb @@ -6,7 +6,7 @@ require 'backup_restore/s3_backup_store' require_relative 'shared_examples_for_backup_store' describe BackupRestore::S3BackupStore do - before(:all) do + before do @s3_client = Aws::S3::Client.new(stub_responses: true) @s3_options = { client: @s3_client } @@ -65,9 +65,7 @@ describe BackupRestore::S3BackupStore do last_modified: Time.zone.now } end) - end - before do SiteSetting.s3_backup_bucket = "s3-backup-bucket" SiteSetting.s3_access_key_id = "s3-access-key-id" SiteSetting.s3_secret_access_key = "s3-secret-access-key" @@ -82,7 +80,7 @@ describe BackupRestore::S3BackupStore do context "S3 specific behavior" do before { create_backups } - after(:all) { remove_backups } + after { remove_backups } describe "#delete_old" do it "doesn't delete files when cleanup is disabled" do diff --git a/spec/lib/backup_restore/shared_examples_for_backup_store.rb b/spec/lib/backup_restore/shared_examples_for_backup_store.rb index 344fb17f2f..679fd637f3 100644 --- a/spec/lib/backup_restore/shared_examples_for_backup_store.rb +++ b/spec/lib/backup_restore/shared_examples_for_backup_store.rb @@ -2,7 +2,7 @@ shared_context "backups" do before { create_backups } - after(:all) { remove_backups } + after { remove_backups } # default backup files let(:backup1) { BackupFile.new(filename: "b.tar.gz", size: 17, last_modified: Time.parse("2018-09-13T15:10:00Z")) } diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index b57a87d98f..47462f3892 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -4,14 +4,10 @@ require 'rails_helper' describe ThemeField do - after(:all) do + after do ThemeField.destroy_all end - before do - I18n.locale = :en - end - describe "scope: find_by_theme_ids" do it "returns result in the specified order" do theme = Fabricate(:theme) diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 5b681a031b..6d8f12d843 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -7,10 +7,6 @@ describe Theme do Theme.clear_cache! end - before do - I18n.locale = :en - end - fab! :user do Fabricate(:user) end @@ -21,6 +17,7 @@ describe Theme do let(:theme) { Fabricate(:theme, user: user) } let(:child) { Fabricate(:theme, user: user, component: true) } + it 'can properly clean up color schemes' do scheme = ColorScheme.create!(theme_id: theme.id, name: 'test') scheme2 = ColorScheme.create!(theme_id: theme.id, name: 'test2') From 503017474c931d44e26aef4fdb379927da74bcb7 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Tue, 15 Jun 2021 18:27:15 +0300 Subject: [PATCH 056/403] DEV: Skip CSS watcher when running QUnit tests and expose more Chrome logs (#13390) There are 2 changes in this PR: 1) Add a new environment variable called `DISCOURSE_SKIP_CSS_WATCHER` to disable our stylesheet watcher, and make the `qunit:test` rake task set this variable on the Unicorn/Rails server it spins up to disable our stylesheet watcher when running the tests because it doesn't really need it. 2) Print more Chrome logs (such as network/security errors) to the console. --- config/environments/development.rb | 2 +- lib/tasks/qunit.rake | 3 ++- test/run-qunit.js | 14 +++++++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/config/environments/development.rb b/config/environments/development.rb index 0c7810dccf..7e58350de6 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -84,7 +84,7 @@ Discourse::Application.configure do config.developer_emails = emails.split(",").map(&:downcase).map(&:strip) end - if defined?(Rails::Server) || defined?(Puma) || defined?(Unicorn) + if ENV["DISCOURSE_SKIP_CSS_WATCHER"] != "1" && (defined?(Rails::Server) || defined?(Puma) || defined?(Unicorn)) require 'stylesheet/watcher' STDERR.puts "Starting CSS change watcher" @watcher = Stylesheet::Watcher.watch diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake index bbeaba819e..34ac6c3d6b 100644 --- a/lib/tasks/qunit.rake +++ b/lib/tasks/qunit.rake @@ -49,7 +49,8 @@ task "qunit:test", [:timeout, :qunit_path] do |_, args| "SKIP_ENFORCE_HOSTNAME" => "1", "UNICORN_PID_PATH" => "#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid", # So this can run alongside development "UNICORN_PORT" => port.to_s, - "UNICORN_SIDEKIQS" => "0" + "UNICORN_SIDEKIQS" => "0", + "DISCOURSE_SKIP_CSS_WATCHER" => "1" }, "#{Rails.root}/bin/unicorn -c config/unicorn.conf.rb", pgroup: true diff --git a/test/run-qunit.js b/test/run-qunit.js index 67dbc8157b..f55a9d70b0 100644 --- a/test/run-qunit.js +++ b/test/run-qunit.js @@ -75,7 +75,19 @@ async function runAllTests() { } } - const { Inspector, Page, Runtime } = protocol; + const { Inspector, Page, Runtime, Log } = protocol; + + // Documentation https://chromedevtools.github.io/devtools-protocol/tot/Log/#type-LogEntry + Log.enable(); + Log.entryAdded(({ entry }) => { + let message = `${new Date(entry.timestamp).toISOString()} - (type: ${ + entry.source + }/${entry.level}) message: ${entry.text}`; + if (entry.url) { + message += `, url: ${entry.url}`; + } + console.log(message); + }); // eslint-disable-next-line await Promise.all([Inspector.enable(), Page.enable(), Runtime.enable()]); From 4dc8c3c409138847ee48a81af0d8ec00feb136df Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 15 Jun 2021 12:35:45 -0300 Subject: [PATCH 057/403] FEATURE: Blocking is optional when deleting a user from the review queue. (#13375) Subclasses must call #delete_user_actions inside build_actions to support user deletion. The method adds a delete user bundle, which has a delete and a delete + block option. Every subclass is responsible for implementing these actions. --- app/jobs/scheduled/auto_queue_handler.rb | 2 +- app/models/reviewable.rb | 22 +++++++++ app/models/reviewable_flagged_post.rb | 51 ++++++++++++-------- app/models/reviewable_queued_post.rb | 34 +++++++------ app/models/reviewable_user.rb | 24 ++------- app/models/user.rb | 2 +- app/services/user_destroyer.rb | 2 +- config/locales/server.en.yml | 6 --- spec/models/reviewable_flagged_post_spec.rb | 5 +- spec/models/reviewable_queued_post_spec.rb | 6 --- spec/models/reviewable_spec.rb | 19 ++++++++ spec/models/reviewable_user_spec.rb | 26 +++++----- spec/models/web_hook_spec.rb | 2 +- spec/requests/reviewables_controller_spec.rb | 4 +- 14 files changed, 117 insertions(+), 88 deletions(-) diff --git a/app/jobs/scheduled/auto_queue_handler.rb b/app/jobs/scheduled/auto_queue_handler.rb index 500a45b079..dfc87b77f1 100644 --- a/app/jobs/scheduled/auto_queue_handler.rb +++ b/app/jobs/scheduled/auto_queue_handler.rb @@ -20,7 +20,7 @@ module Jobs elsif reviewable.is_a?(ReviewableQueuedPost) reviewable.perform(Discourse.system_user, :reject_post) elsif reviewable.is_a?(ReviewableUser) - reviewable.perform(Discourse.system_user, :reject_user_delete) + reviewable.perform(Discourse.system_user, :delete_user) end end end diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index 3fc8364413..6876462a75 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -643,6 +643,28 @@ class Reviewable < ActiveRecord::Base self.score end + def delete_user_actions(actions, require_reject_reason: false) + reject = actions.add_bundle( + 'reject_user', + icon: 'user-times', + label: 'reviewables.actions.reject_user.title' + ) + + actions.add(:delete_user, bundle: reject) do |a| + a.icon = 'user-times' + a.label = "reviewables.actions.reject_user.delete.title" + a.require_reject_reason = require_reject_reason + a.description = "reviewables.actions.reject_user.delete.description" + end + + actions.add(:delete_user_block, bundle: reject) do |a| + a.icon = 'ban' + a.label = "reviewables.actions.reject_user.block.title" + a.require_reject_reason = require_reject_reason + a.description = "reviewables.actions.reject_user.block.description" + end + end + protected def increment_version!(version = nil) diff --git a/app/models/reviewable_flagged_post.rb b/app/models/reviewable_flagged_post.rb index dada954b57..f2971e7f6c 100644 --- a/app/models/reviewable_flagged_post.rb +++ b/app/models/reviewable_flagged_post.rb @@ -57,16 +57,6 @@ class ReviewableFlaggedPost < Reviewable build_action(actions, :agree_and_silence, icon: 'microphone-slash', bundle: agree, client_action: 'silence') end - if can_delete_spammer = potential_spam? && guardian.can_delete_all_posts?(target_created_by) - build_action( - actions, - :delete_spammer, - icon: 'exclamation-triangle', - bundle: agree, - confirm: true - ) - end - if post.user_deleted? build_action(actions, :agree_and_restore, icon: 'far-eye', bundle: agree) end @@ -79,6 +69,10 @@ class ReviewableFlaggedPost < Reviewable build_action(actions, :ignore, icon: 'external-link-alt') + if potential_spam? && guardian.can_delete_all_posts?(target_created_by) + delete_user_actions(actions) + end + if guardian.can_delete_post_or_topic?(post) delete = actions.add_bundle("#{id}-delete", icon: "far-trash-alt", label: "reviewables.actions.delete.title") build_action(actions, :delete_and_ignore, icon: 'external-link-alt', bundle: delete) @@ -134,17 +128,22 @@ class ReviewableFlaggedPost < Reviewable agree(performed_by, args) end - def perform_delete_spammer(performed_by, args) - UserDestroyer.new(performed_by).destroy( - post.user, - delete_posts: true, - prepare_for_destroy: true, - block_email: true, - block_urls: true, - block_ip: true, - delete_as_spammer: true, - context: "review" - ) + def perform_delete_user(performed_by, args) + delete_options = delete_opts + + UserDestroyer.new(performed_by).destroy(post.user, delete_options) + + agree(performed_by, args) + end + + def perform_delete_user_block(performed_by, args) + delete_options = delete_opts + + if Rails.env.production? + delete_options.merge!(block_email: true, block_ip: true) + end + + UserDestroyer.new(performed_by).destroy(post.user, delete_options) agree(performed_by, args) end @@ -302,6 +301,16 @@ protected private + def delete_opts + { + delete_posts: true, + prepare_for_destroy: true, + block_urls: true, + delete_as_spammer: true, + context: "review" + } + end + def destroyer(performed_by, post) PostDestroyer.new(performed_by, post, reviewable: self) end diff --git a/app/models/reviewable_queued_post.rb b/app/models/reviewable_queued_post.rb index 105559444c..da791f6798 100644 --- a/app/models/reviewable_queued_post.rb +++ b/app/models/reviewable_queued_post.rb @@ -33,12 +33,7 @@ class ReviewableQueuedPost < Reviewable end if pending? && guardian.can_delete_user?(created_by) - actions.add(:delete_user) do |action| - action.icon = 'trash-alt' - action.button_class = 'btn-danger' - action.label = 'reviewables.actions.delete_user.title' - action.confirm_message = 'reviewables.actions.delete_user.confirm' - end + delete_user_actions(actions) end actions.add(:delete) if guardian.can_delete?(self) @@ -133,24 +128,35 @@ class ReviewableQueuedPost < Reviewable end def perform_delete_user(performed_by, args) - delete_options = { - context: I18n.t('reviewables.actions.delete_user.reason'), - delete_posts: true, - block_urls: true, - block_email: true, - block_ip: true, - delete_as_spammer: true - } + delete_user(performed_by, delete_opts) + end + + def perform_delete_user_block(performed_by, args) + delete_options = delete_opts if Rails.env.production? delete_options.merge!(block_email: true, block_ip: true) end + delete_user(performed_by, delete_options) + end + + private + + def delete_user(performed_by, delete_options) reviewable_ids = Reviewable.where(created_by: created_by).pluck(:id) UserDestroyer.new(performed_by).destroy(created_by, delete_options) create_result(:success) { |r| r.remove_reviewable_ids = reviewable_ids } end + def delete_opts + { + context: I18n.t('reviewables.actions.delete_user.reason'), + delete_posts: true, + block_urls: true, + delete_as_spammer: true + } + end end # == Schema Information diff --git a/app/models/reviewable_user.rb b/app/models/reviewable_user.rb index 93291bfd40..a2325a2239 100644 --- a/app/models/reviewable_user.rb +++ b/app/models/reviewable_user.rb @@ -19,23 +19,7 @@ class ReviewableUser < Reviewable end end - reject = actions.add_bundle( - 'reject_user', - icon: 'user-times', - label: 'reviewables.actions.reject_user.title' - ) - actions.add(:reject_user_delete, bundle: reject) do |a| - a.icon = 'user-times' - a.label = "reviewables.actions.reject_user.delete.title" - a.require_reject_reason = !is_a_suspect_user? - a.description = "reviewables.actions.reject_user.delete.description" - end - actions.add(:reject_user_block, bundle: reject) do |a| - a.icon = 'ban' - a.label = "reviewables.actions.reject_user.block.title" - a.require_reject_reason = !is_a_suspect_user? - a.description = "reviewables.actions.reject_user.block.description" - end + delete_user_actions(actions, require_reject_reason: !is_a_suspect_user?) end def perform_approve_user(performed_by, args) @@ -56,7 +40,7 @@ class ReviewableUser < Reviewable create_result(:success, :approved) end - def perform_reject_user_delete(performed_by, args) + def perform_delete_user(performed_by, args) # We'll delete the user if we can if target.present? destroyer = UserDestroyer.new(performed_by) @@ -96,10 +80,10 @@ class ReviewableUser < Reviewable create_result(:success, :rejected) end - def perform_reject_user_block(performed_by, args) + def perform_delete_user_block(performed_by, args) args[:block_email] = true args[:block_ip] = true - perform_reject_user_delete(performed_by, args) + perform_delete_user(performed_by, args) end # Update's the user's fields for approval but does not save. This diff --git a/app/models/user.rb b/app/models/user.rb index e90984675c..c43cfc7a34 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1019,7 +1019,7 @@ class User < ActiveRecord::Base self.update!(active: false) if reviewable = ReviewableUser.pending.find_by(target: self) - reviewable.perform(performed_by, :reject_user_delete) + reviewable.perform(performed_by, :delete_user) end end diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb index 24980ce96d..82ac26ab8d 100644 --- a/app/services/user_destroyer.rb +++ b/app/services/user_destroyer.rb @@ -108,7 +108,7 @@ class UserDestroyer # After the user is deleted, remove the reviewable if reviewable = ReviewableUser.pending.find_by(target: user) - reviewable.perform(@actor, :reject_user_delete) + reviewable.perform(@actor, :delete_user) end result diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index af1d8c4e46..cf7534a92f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -4987,10 +4987,6 @@ en: agree_and_hide: title: "Hide Post" description: "Hide this post and automatically send the user a message urging them to edit it." - delete_spammer: - title: "Delete Spammer" - description: "Remove the user and all their posts and topics." - confirm: "Are you sure you want to delete all that user's posts, topics, and block their IP and email addresses?" delete_single: title: "Delete" delete: @@ -5047,8 +5043,6 @@ en: approve_and_restore: title: "Approve and Restore post" delete_user: - title: "Delete User" - confirm: "Are you sure you want to delete that user? This will remove all of their posts and block their email and IP address." reason: "Deleted via review queue" email_style: diff --git a/spec/models/reviewable_flagged_post_spec.rb b/spec/models/reviewable_flagged_post_spec.rb index 339a7569a3..8b36f825ca 100644 --- a/spec/models/reviewable_flagged_post_spec.rb +++ b/spec/models/reviewable_flagged_post_spec.rb @@ -34,7 +34,8 @@ RSpec.describe ReviewableFlaggedPost, type: :model do expect(actions.has?(:agree_and_keep_hidden)).to eq(false) expect(actions.has?(:agree_and_silence)).to eq(true) expect(actions.has?(:agree_and_suspend)).to eq(true) - expect(actions.has?(:delete_spammer)).to eq(true) + expect(actions.has?(:delete_user)).to eq(true) + expect(actions.has?(:delete_user_block)).to eq(true) expect(actions.has?(:disagree)).to eq(true) expect(actions.has?(:ignore)).to eq(true) expect(actions.has?(:delete_and_ignore)).to eq(true) @@ -137,7 +138,7 @@ RSpec.describe ReviewableFlaggedPost, type: :model do end it "supports deleting a spammer" do - reviewable.perform(moderator, :delete_spammer) + reviewable.perform(moderator, :delete_user_block) expect(reviewable).to be_approved expect(score.reload).to be_agreed expect(post.reload.deleted_at).to be_present diff --git a/spec/models/reviewable_queued_post_spec.rb b/spec/models/reviewable_queued_post_spec.rb index 97e5b61b76..92f17f7bf4 100644 --- a/spec/models/reviewable_queued_post_spec.rb +++ b/spec/models/reviewable_queued_post_spec.rb @@ -118,12 +118,6 @@ RSpec.describe ReviewableQueuedPost, type: :model do end context "delete_user" do - it "has the correct button class" do - expect(reviewable.actions_for(Guardian.new(moderator)).to_a. - find { |a| a.id == :delete_user }.button_class). - to eq("btn-danger") - end - it "deletes the user and rejects the post" do other_reviewable = Fabricate(:reviewable_queued_post, created_by: reviewable.created_by) diff --git a/spec/models/reviewable_spec.rb b/spec/models/reviewable_spec.rb index f616c460db..9b9f32bf5d 100644 --- a/spec/models/reviewable_spec.rb +++ b/spec/models/reviewable_spec.rb @@ -539,4 +539,23 @@ RSpec.describe Reviewable, type: :model do expect(Reviewable.by_status(Reviewable.all, :reviewed)).to contain_exactly(reviewable) end end + + context 'default actions' do + let(:reviewable) { Reviewable.new } + let(:actions) { Reviewable::Actions.new(reviewable, Guardian.new) } + + describe '#delete_user_actions' do + it 'adds a bundle with the delete_user action' do + reviewable.delete_user_actions(actions) + + expect(actions.has?(:delete_user)).to be true + end + + it 'adds a bundle with the delete_user_block action' do + reviewable.delete_user_actions(actions) + + expect(actions.has?(:delete_user_block)).to be true + end + end + end end diff --git a/spec/models/reviewable_user_spec.rb b/spec/models/reviewable_user_spec.rb index a16c6c2ac4..fde41e025b 100644 --- a/spec/models/reviewable_user_spec.rb +++ b/spec/models/reviewable_user_spec.rb @@ -17,35 +17,35 @@ RSpec.describe ReviewableUser, type: :model do it "returns correct actions in the pending state" do actions = reviewable.actions_for(Guardian.new(moderator)) expect(actions.has?(:approve_user)).to eq(true) - expect(actions.has?(:reject_user_delete)).to eq(true) - expect(actions.has?(:reject_user_block)).to eq(true) + expect(actions.has?(:delete_user)).to eq(true) + expect(actions.has?(:delete_user_block)).to eq(true) end it "doesn't return anything in the approved state" do reviewable.status = Reviewable.statuses[:approved] actions = reviewable.actions_for(Guardian.new(moderator)) expect(actions.has?(:approve_user)).to eq(false) - expect(actions.has?(:reject_user_delete)).to eq(false) + expect(actions.has?(:delete_user_block)).to eq(false) end it 'can delete a user without a giving a rejection reason if the user was a spammer' do reviewable.reviewable_scores.build(user: admin, reason: 'suspect_user') - assert_require_reject_reason(:reject_user_delete, false) + assert_require_reject_reason(:delete_user, false) end it 'requires a rejection reason to delete a user' do - assert_require_reject_reason(:reject_user_delete, true) + assert_require_reject_reason(:delete_user, true) end it 'can delete and block a user without giving a rejection reason if the user was a spammer' do reviewable.reviewable_scores.build(user: admin, reason: 'suspect_user') - assert_require_reject_reason(:reject_user_block, false) + assert_require_reject_reason(:delete_user, false) end it 'requires a rejection reason to delete and block a user' do - assert_require_reject_reason(:reject_user_block, true) + assert_require_reject_reason(:delete_user_block, true) end def assert_require_reject_reason(id, expected) @@ -95,7 +95,7 @@ RSpec.describe ReviewableUser, type: :model do end it "allows us to reject a user" do - result = reviewable.perform(moderator, :reject_user_delete, reject_reason: "reject reason") + result = reviewable.perform(moderator, :delete_user, reject_reason: "reject reason") expect(result.success?).to eq(true) expect(reviewable.pending?).to eq(false) @@ -114,7 +114,7 @@ RSpec.describe ReviewableUser, type: :model do email = reviewable.target.email ip = reviewable.target.ip_address - result = reviewable.perform(moderator, :reject_user_block, reject_reason: "reject reason") + result = reviewable.perform(moderator, :delete_user_block, reject_reason: "reject reason") expect(result.success?).to eq(true) expect(reviewable.pending?).to eq(false) @@ -132,18 +132,18 @@ RSpec.describe ReviewableUser, type: :model do it "is not sending email to the user about rejection" do SiteSetting.must_approve_users = true Jobs::CriticalUserEmail.any_instance.expects(:execute).never - reviewable.perform(moderator, :reject_user_block, reject_reason: "reject reason") + reviewable.perform(moderator, :delete_user_block, reject_reason: "reject reason") end it "optionally sends email with reject reason" do SiteSetting.must_approve_users = true Jobs::CriticalUserEmail.any_instance.expects(:execute).with(type: :signup_after_reject, user_id: reviewable.target_id, reject_reason: "reject reason").once - reviewable.perform(moderator, :reject_user_block, reject_reason: "reject reason", send_email: true) + reviewable.perform(moderator, :delete_user_block, reject_reason: "reject reason", send_email: true) end it "allows us to reject a user who has posts" do Fabricate(:post, user: reviewable.target) - result = reviewable.perform(moderator, :reject_user_delete) + result = reviewable.perform(moderator, :delete_user) expect(result.success?).to eq(true) expect(reviewable.pending?).to eq(false) @@ -158,7 +158,7 @@ RSpec.describe ReviewableUser, type: :model do it "allows us to reject a user who has been deleted" do reviewable.target.destroy! reviewable.reload - result = reviewable.perform(moderator, :reject_user_delete) + result = reviewable.perform(moderator, :delete_user) expect(result.success?).to eq(true) expect(reviewable.rejected?).to eq(true) expect(reviewable.target).to be_blank diff --git a/spec/models/web_hook_spec.rb b/spec/models/web_hook_spec.rb index 73ef8c913a..fd8ed07966 100644 --- a/spec/models/web_hook_spec.rb +++ b/spec/models/web_hook_spec.rb @@ -506,7 +506,7 @@ describe WebHook do payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(reviewable.id) - reviewable.perform(Discourse.system_user, :reject_user_delete) + reviewable.perform(Discourse.system_user, :delete_user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("reviewable_transitioned_to") diff --git a/spec/requests/reviewables_controller_spec.rb b/spec/requests/reviewables_controller_spec.rb index 5b751099b5..37e7febadd 100644 --- a/spec/requests/reviewables_controller_spec.rb +++ b/spec/requests/reviewables_controller_spec.rb @@ -182,10 +182,10 @@ describe ReviewablesController do user.activate reviewable = ReviewableUser.find_by(target: user) - put "/review/#{reviewable.id}/perform/reject_user_delete.json?version=0" + put "/review/#{reviewable.id}/perform/delete_user.json?version=0" expect(response.code).to eq("200") - put "/review/#{reviewable.id}/perform/reject_user_delete.json?version=0&index=2" + put "/review/#{reviewable.id}/perform/delete_user.json?version=0&index=2" expect(response.code).to eq("404") json = response.parsed_body From c659e3e95bbf9ae4fe7ee580b4afda0f6bde2bb7 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 16 Jun 2021 08:30:40 +1000 Subject: [PATCH 058/403] FIX: Make sure topic_user.bookmarked is synced in more places (#13383) When we call Bookmark.cleanup! we want to make sure that topic_user.bookmarked is updated for topics linked to the bookmarks that were deleted. Also when PostDestroyer calls destroy and recover. We have a job for this already -- SyncTopicUserBookmarked -- so we just utilize that. --- .../regular/sync_topic_user_bookmarked.rb | 11 +++++++--- app/models/bookmark.rb | 8 +++++++- lib/post_destroyer.rb | 4 ++++ spec/components/post_destroyer_spec.rb | 13 ++++++++++++ spec/jobs/sync_topic_user_bookmarked_spec.rb | 18 +++++++++++++++++ spec/models/bookmark_spec.rb | 20 +++++++++++++++++++ 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/sync_topic_user_bookmarked.rb b/app/jobs/regular/sync_topic_user_bookmarked.rb index 2e88f65e24..cf8db86c58 100644 --- a/app/jobs/regular/sync_topic_user_bookmarked.rb +++ b/app/jobs/regular/sync_topic_user_bookmarked.rb @@ -8,7 +8,9 @@ module Jobs DB.exec(<<~SQL, topic_id: topic_id) UPDATE topic_users SET bookmarked = true FROM bookmarks AS b + INNER JOIN posts ON posts.id = b.post_id WHERE NOT topic_users.bookmarked AND + posts.deleted_at IS NULL AND topic_users.topic_id = b.topic_id AND topic_users.user_id = b.user_id #{topic_id.present? ? "AND topic_users.topic_id = :topic_id" : ""} SQL @@ -17,9 +19,12 @@ module Jobs UPDATE topic_users SET bookmarked = false WHERE topic_users.bookmarked AND ( - SELECT COUNT(*) FROM bookmarks - WHERE topic_id = topic_users.topic_id - AND user_id = topic_users.user_id + SELECT COUNT(*) + FROM bookmarks + INNER JOIN posts ON posts.id = bookmarks.post_id + WHERE bookmarks.topic_id = topic_users.topic_id + AND bookmarks.user_id = topic_users.user_id + AND posts.deleted_at IS NULL ) = 0 #{topic_id.present? ? "AND topic_users.topic_id = :topic_id" : ""} SQL end diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index c4ccd2f24f..9137cf25ec 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -125,12 +125,18 @@ class Bookmark < ActiveRecord::Base # is deleted so that there is a grace period to un-delete. def self.cleanup! grace_time = 3.days.ago - DB.exec(<<~SQL, grace_time: grace_time) + topics_deleted = DB.query(<<~SQL, grace_time: grace_time) DELETE FROM bookmarks b USING topics t, posts p WHERE (b.topic_id = t.id AND b.post_id = p.id) AND (t.deleted_at < :grace_time OR p.deleted_at < :grace_time) + RETURNING b.topic_id SQL + + topics_deleted_ids = topics_deleted.map(&:topic_id).uniq + topics_deleted_ids.each do |topic_id| + Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic_id) + end end end diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index 60662b85cc..04d8c6d7b5 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -76,6 +76,7 @@ class PostDestroyer DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user) WebHook.enqueue_post_hooks(:post_destroyed, @post, payload) + Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic if is_first_post UserProfile.remove_featured_topic_from_all_profiles(@topic) @@ -95,8 +96,11 @@ class PostDestroyer topic.update_column(:user_id, Discourse::SYSTEM_USER_ID) if !topic.user_id topic.recover!(@user) if @post.is_first_post? topic.update_statistics + UserActionManager.post_created(@post) DiscourseEvent.trigger(:post_recovered, @post, @opts, @user) + Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic + if @post.is_first_post? UserActionManager.topic_created(topic) DiscourseEvent.trigger(:topic_recovered, topic, @user) diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb index a5ae57d487..66ae44a83f 100644 --- a/spec/components/post_destroyer_spec.rb +++ b/spec/components/post_destroyer_spec.rb @@ -241,6 +241,13 @@ describe PostDestroyer do expect(UserAction.where(target_topic_id: post.topic_id, action_type: UserAction::NEW_TOPIC).count).to eq(1) expect(UserAction.where(target_topic_id: post.topic_id, action_type: UserAction::REPLY).count).to eq(1) end + + it "runs the SyncTopicUserBookmarked for the topic that the post is in so topic_users.bookmarked is correct" do + PostDestroyer.new(@user, @reply).destroy + expect_enqueued_with(job: :sync_topic_user_bookmarked, args: { topic_id: @reply.topic_id }) do + PostDestroyer.new(@user, @reply.reload).recover + end + end end context "recovered by admin" do @@ -465,6 +472,12 @@ describe PostDestroyer do expect(post.raw).to eq(I18n.t('js.post.deleted_by_author_simple')) end + it "runs the SyncTopicUserBookmarked for the topic that the post is in so topic_users.bookmarked is correct" do + post2 = create_post + PostDestroyer.new(post2.user, post2).destroy + expect_job_enqueued(job: :sync_topic_user_bookmarked, args: { topic_id: post2.topic_id }) + end + context "as a moderator" do it "deletes the post" do author = post.user diff --git a/spec/jobs/sync_topic_user_bookmarked_spec.rb b/spec/jobs/sync_topic_user_bookmarked_spec.rb index 25a0e14dbb..4d4cd10725 100644 --- a/spec/jobs/sync_topic_user_bookmarked_spec.rb +++ b/spec/jobs/sync_topic_user_bookmarked_spec.rb @@ -27,6 +27,24 @@ RSpec.describe Jobs::SyncTopicUserBookmarked do expect(tu5.reload.bookmarked).to eq(false) end + it "does not consider topic as bookmarked if the bookmarked post is deleted" do + topic = Fabricate(:topic) + post1 = Fabricate(:post, topic: topic) + + tu1 = Fabricate(:topic_user, topic: topic, bookmarked: false) + tu2 = Fabricate(:topic_user, topic: topic, bookmarked: true) + + Fabricate(:bookmark, user: tu1.user, topic: topic, post: post1) + Fabricate(:bookmark, user: tu2.user, topic: topic, post: post1) + + post1.trash! + + subject.execute(topic_id: topic.id) + + expect(tu1.reload.bookmarked).to eq(false) + expect(tu2.reload.bookmarked).to eq(false) + end + it "works when no topic id is provided (runs for all topics)" do topic = Fabricate(:topic) Fabricate(:post, topic: topic) diff --git a/spec/models/bookmark_spec.rb b/spec/models/bookmark_spec.rb index 177c003732..500f4bf475 100644 --- a/spec/models/bookmark_spec.rb +++ b/spec/models/bookmark_spec.rb @@ -15,6 +15,26 @@ describe Bookmark do expect(Bookmark.find_by(id: bookmark2.id)).to eq(bookmark2) end + it "runs a SyncTopicUserBookmarked job for all deleted bookmark unique topics to make sure topic_user.bookmarked is in sync" do + post = Fabricate(:post) + post2 = Fabricate(:post) + bookmark = Fabricate(:bookmark, post: post, topic: post.topic) + bookmark2 = Fabricate(:bookmark, post: Fabricate(:post, topic: post.topic)) + bookmark3 = Fabricate(:bookmark, post: post2, topic: post2.topic) + bookmark4 = Fabricate(:bookmark, post: post2, topic: post2.topic) + post.trash! + post.update(deleted_at: 4.days.ago) + post2.trash! + post2.update(deleted_at: 4.days.ago) + Bookmark.cleanup! + expect(Bookmark.find_by(id: bookmark.id)).to eq(nil) + expect(Bookmark.find_by(id: bookmark2.id)).to eq(bookmark2) + expect(Bookmark.find_by(id: bookmark3.id)).to eq(nil) + expect(Bookmark.find_by(id: bookmark4.id)).to eq(nil) + expect_job_enqueued(job: :sync_topic_user_bookmarked, args: { topic_id: post.topic_id }) + expect_job_enqueued(job: :sync_topic_user_bookmarked, args: { topic_id: post2.topic_id }) + end + it "deletes bookmarks attached to a deleted topic which has been deleted for > 3 days" do post = Fabricate(:post) bookmark = Fabricate(:bookmark, post: post, topic: post.topic) From 3a3a2abdb7b49818d31ab4b71f524f3387fbd0e6 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 16 Jun 2021 03:50:27 +0300 Subject: [PATCH 059/403] FIX: Update raw and cooked immediate after edit (#13387) * Revert "DEV: skips three tests following cc1e73 (#13386)" This reverts commit 2be201660a4c2fef5a714a94fe9f66341f9a09f0. * FIX: Do not refresh post stream twice This also improves the test suite and simulates a long running request * FIX: Update local copy of raw --- .../discourse/app/models/composer.js | 7 ++- .../acceptance/composer-edit-conflict-test.js | 37 +++++++------- .../tests/acceptance/composer-test.js | 51 ++++++++++++------- .../tests/helpers/create-pretender.js | 8 ++- 4 files changed, 58 insertions(+), 45 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js index 8aa68daaa6..e7911545a9 100644 --- a/app/assets/javascripts/discourse/app/models/composer.js +++ b/app/assets/javascripts/discourse/app/models/composer.js @@ -929,6 +929,7 @@ const Composer = RestModel.extend({ editPost(opts) { const post = this.post; + const oldRaw = post.raw; const oldCooked = post.cooked; let promise = Promise.resolve(); @@ -970,16 +971,14 @@ const Composer = RestModel.extend({ this.set("composeState", SAVING); const rollback = throwAjaxError((error) => { - post.setProperties({ cooked: oldCooked, staged: false }); - this.appEvents.trigger("post-stream:refresh", { id: post.id }); - + post.setProperties({ raw: oldRaw, cooked: oldCooked }); this.set("composeState", OPEN); if (error.jqXHR && error.jqXHR.status === 409) { this.set("editConflict", true); } }); - post.setProperties({ cooked: props.cooked, staged: true }); + post.setProperties({ raw: props.raw, cooked: props.cooked, staged: true }); this.appEvents.trigger("post-stream:refresh", { id: post.id }); return promise diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js index 96f970237a..6eb7be8235 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js @@ -14,26 +14,23 @@ acceptance("Composer - Edit conflict", function (needs) { }); }); - QUnit.skip( - "Edit a post that causes an edit conflict", - async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await click("#reply-control button.create"); - assert.equal( - queryAll("#reply-control button.create").text().trim(), - I18n.t("composer.overwrite_edit"), - "it shows the overwrite button" - ); - assert.ok( - queryAll("#draft-status .d-icon-user-edit"), - "error icon should be there" - ); - await click(".modal .btn-primary"); - } - ); + test("Edit a post that causes an edit conflict", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); + await fillIn(".d-editor-input", "this will 409"); + await click("#reply-control button.create"); + assert.equal( + queryAll("#reply-control button.create").text().trim(), + I18n.t("composer.overwrite_edit"), + "it shows the overwrite button" + ); + assert.ok( + queryAll("#draft-status .d-icon-user-edit"), + "error icon should be there" + ); + await click(".modal .btn-primary"); + }); test("Should not send originalText when posting a new reply", async function (assert) { await visit("/t/internationalization-localization/280"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index 5fc43fee68..c499f1a748 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -348,15 +348,25 @@ acceptance("Composer", function (needs) { ); }); - QUnit.skip("Editing a post stages new content", async function (assert) { + test("Editing a post stages new content", async function (assert) { await visit("/t/internationalization-localization/280"); await click(".topic-post:nth-of-type(1) button.show-more-actions"); await click(".topic-post:nth-of-type(1) button.edit"); await fillIn(".d-editor-input", "will return empty json"); await fillIn("#reply-title", "This is the new text for the title"); - await click("#reply-control button.create"); + // when this promise resolves, the request had already started because + // this promise will be resolved by the pretender + const promise = new Promise((resolve) => { + window.resolveLastPromise = resolve; + }); + + // click to trigger the save, but wait until the request starts + click("#reply-control button.create"); + await promise; + + // at this point, request is in flight, so post is staged assert.equal(count(".topic-post.staged"), 1); assert.ok( find(".topic-post:nth-of-type(1)")[0].className.includes("staged") @@ -365,28 +375,31 @@ acceptance("Composer", function (needs) { find(".topic-post.staged .cooked").text().trim(), "will return empty json" ); + + // finally, finish request and wait for last render + window.resolveLastPromise(); + await visit("/t/internationalization-localization/280"); + + assert.equal(count(".topic-post.staged"), 0); }); - QUnit.skip( - "Editing a post can rollback to old content", - async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); + test("Editing a post can rollback to old content", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await fillIn("#reply-title", "This is the new text for the title"); - await click("#reply-control button.create"); + await fillIn(".d-editor-input", "this will 409"); + await fillIn("#reply-title", "This is the new text for the title"); + await click("#reply-control button.create"); - assert.ok(!exists(".topic-post.staged")); - assert.equal( - find(".topic-post .cooked")[0].innerText, - "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" - ); + assert.ok(!exists(".topic-post.staged")); + assert.equal( + find(".topic-post .cooked")[0].innerText, + "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" + ); - await click(".bootbox.modal .btn-primary"); - } - ); + await click(".bootbox.modal .btn-primary"); + }); test("Composer can switch between edits", async function (assert) { await visit("/t/this-is-a-test-topic/9"); diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 2b843d6267..b312f02e25 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -1,6 +1,7 @@ import Pretender from "pretender"; import User from "discourse/models/user"; import getURL from "discourse-common/lib/get-url"; +import { Promise } from "rsvp"; export function parsePostData(query) { const result = {}; @@ -479,12 +480,15 @@ export function applyDefaultHandlers(pretender) { pretender.put("/posts/:post_id/recover", success); pretender.get("/posts/:post_id/expand-embed", success); - pretender.put("/posts/:post_id", (request) => { + pretender.put("/posts/:post_id", async (request) => { const data = parsePostData(request.requestBody); if (data.post.raw === "this will 409") { return response(409, { errors: ["edit conflict"] }); } else if (data.post.raw === "will return empty json") { - return response(200, {}); + window.resolveLastPromise(); + return new Promise((resolve) => { + window.resolveLastPromise = resolve; + }).then(() => response(200, {})); } data.post.id = request.params.post_id; data.post.version = 2; From 03fc31e23bf7f7ed78488e700680c4388a7a319e Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 16 Jun 2021 11:42:43 +1000 Subject: [PATCH 060/403] FIX: Skip failing tests for composer (#13394) Since merging urgent fix 3a3a2abdb7b49818d31ab4b71f524f3387fbd0e6 these tests are broken. We need to skip these until someone with better knowledge of this can take a look. --- .../acceptance/composer-edit-conflict-test.js | 37 ++++++++++--------- .../tests/acceptance/composer-test.js | 31 +++++++++------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js index 6eb7be8235..96f970237a 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-edit-conflict-test.js @@ -14,23 +14,26 @@ acceptance("Composer - Edit conflict", function (needs) { }); }); - test("Edit a post that causes an edit conflict", async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await click("#reply-control button.create"); - assert.equal( - queryAll("#reply-control button.create").text().trim(), - I18n.t("composer.overwrite_edit"), - "it shows the overwrite button" - ); - assert.ok( - queryAll("#draft-status .d-icon-user-edit"), - "error icon should be there" - ); - await click(".modal .btn-primary"); - }); + QUnit.skip( + "Edit a post that causes an edit conflict", + async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); + await fillIn(".d-editor-input", "this will 409"); + await click("#reply-control button.create"); + assert.equal( + queryAll("#reply-control button.create").text().trim(), + I18n.t("composer.overwrite_edit"), + "it shows the overwrite button" + ); + assert.ok( + queryAll("#draft-status .d-icon-user-edit"), + "error icon should be there" + ); + await click(".modal .btn-primary"); + } + ); test("Should not send originalText when posting a new reply", async function (assert) { await visit("/t/internationalization-localization/280"); diff --git a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js index c499f1a748..4f8aee38cf 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/composer-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/composer-test.js @@ -383,23 +383,26 @@ acceptance("Composer", function (needs) { assert.equal(count(".topic-post.staged"), 0); }); - test("Editing a post can rollback to old content", async function (assert) { - await visit("/t/internationalization-localization/280"); - await click(".topic-post:nth-of-type(1) button.show-more-actions"); - await click(".topic-post:nth-of-type(1) button.edit"); + QUnit.skip( + "Editing a post can rollback to old content", + async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); - await fillIn(".d-editor-input", "this will 409"); - await fillIn("#reply-title", "This is the new text for the title"); - await click("#reply-control button.create"); + await fillIn(".d-editor-input", "this will 409"); + await fillIn("#reply-title", "This is the new text for the title"); + await click("#reply-control button.create"); - assert.ok(!exists(".topic-post.staged")); - assert.equal( - find(".topic-post .cooked")[0].innerText, - "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" - ); + assert.ok(!exists(".topic-post.staged")); + assert.equal( + find(".topic-post .cooked")[0].innerText, + "Any plans to support localization of UI elements, so that I (for example) could set up a completely German speaking forum?" + ); - await click(".bootbox.modal .btn-primary"); - }); + await click(".bootbox.modal .btn-primary"); + } + ); test("Composer can switch between edits", async function (assert) { await visit("/t/this-is-a-test-topic/9"); From b0416cb1c11ba762da63ebdb297dbd95da334df9 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 16 Jun 2021 10:34:39 +0100 Subject: [PATCH 061/403] FEATURE: Upload to s3 in parallel to speed up backup restores (#13391) Uploading lots of small files can be made significantly faster by parallelizing the `s3.put_object` calls. In testing, an UPLOAD_CONCURRENCY of 10 made a large restore 10x faster. An UPLOAD_CONCURRENCY of 20 made the same restore 18x faster. This commit is careful to parallelize as little as possible, to reduce the chance of concurrency issues. In the worker threads, no database transactions are performed. All modification of shared objects is controlled with a mutex. Unfortunately we do not have any existing tests for the `ToS3Migration` class. This change has been tested with a large site backup (120k uploads totalling 45GB) --- lib/file_store/to_s3_migration.rb | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/lib/file_store/to_s3_migration.rb b/lib/file_store/to_s3_migration.rb index 9af8aa3994..0d6c098327 100644 --- a/lib/file_store/to_s3_migration.rb +++ b/lib/file_store/to_s3_migration.rb @@ -7,6 +7,7 @@ module FileStore class ToS3Migration MISSING_UPLOADS_RAKE_TASK_NAME ||= 'posts:missing_uploads' + UPLOAD_CONCURRENCY ||= 20 def initialize(s3_options:, dry_run: false, migrate_to_multisite: false, skip_etag_verify: false) @@ -197,9 +198,25 @@ module FileStore log " => #{s3_objects.size} files" log " - Syncing files to S3" + queue = Queue.new synced = 0 failed = [] + lock = Mutex.new + upload_threads = UPLOAD_CONCURRENCY.times.map do + Thread.new do + while obj = queue.pop + if s3.put_object(obj[:options]).etag[obj[:etag]] + putc "." + lock.synchronize { synced += 1 } + else + putc "X" + lock.synchronize { failed << obj[:path] } + end + end + end + end + local_files.each do |file| path = File.join(public_directory, file) name = File.basename(path) @@ -244,15 +261,14 @@ module FileStore if @dry_run log "#{file} => #{options[:key]}" synced += 1 - elsif s3.put_object(options).etag[etag] - putc "." - synced += 1 else - putc "X" - failed << path + queue << { path: path, options: options, etag: etag } end end + queue.close + upload_threads.each(&:join) + puts failure_message = "S3 migration failed for db '#{@current_db}'." From 0f9d31a85e8ec645909afd6e237cfde5f5dd8df8 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 16 Jun 2021 11:22:11 +0100 Subject: [PATCH 062/403] FIX: Make avatar-flair component fail gracefully group info missing (#13398) This can happen when an avatar-flair component is rendered to an anonymous user on a login_required site (e.g. when they are redeeming an invite). The lack of group information was causing an error to be raised. With this commit, it now simple skips rendering the flair. --- .../discourse/app/lib/avatar-flair.js | 2 +- .../components/user-avatar-flair-test.js | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/lib/avatar-flair.js b/app/assets/javascripts/discourse/app/lib/avatar-flair.js index 5e6ee0e30c..3a8bb1f495 100644 --- a/app/assets/javascripts/discourse/app/lib/avatar-flair.js +++ b/app/assets/javascripts/discourse/app/lib/avatar-flair.js @@ -52,7 +52,7 @@ function initializeAutoGroupFlair(site) { "trust_level_3", "trust_level_4", ].forEach((groupName) => { - const group = site.groups.findBy("name", groupName); + const group = site.groups?.findBy("name", groupName); if (group && group.flair_url) { _noAutoFlair = false; _autoGroupFlair[groupName] = { diff --git a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js index 70f22db728..bc457a57d0 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/user-avatar-flair-test.js @@ -147,6 +147,26 @@ discourseModule( }, }); + componentTest("avatar flair for login-required site, before login", { + template: hbs`{{user-avatar-flair user=args}}`, + beforeEach() { + resetFlair(); + this.set("args", { + admin: false, + moderator: false, + trust_level: 3, + }); + // Groups not serialized for anon on login_required + this.site.groups = undefined; + }, + afterEach() { + resetFlair(); + }, + test(assert) { + assert.ok(!exists(".avatar-flair"), "it does not render a flair"); + }, + }); + componentTest("avatar flair for primary group flair", { template: hbs`{{user-avatar-flair user=args}}`, beforeEach() { From 82ebc706aaee2dfeee83553fe6bcf7bf1fe9177e Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Wed, 16 Jun 2021 18:42:21 +0400 Subject: [PATCH 063/403] =?UTF-8?q?FIX:=20The=20topic=20level=20bookmark?= =?UTF-8?q?=20button=20stops=20working=20if=20choose=20=E2=80=98No?= =?UTF-8?q?=E2=80=99=20on=20the=20clearing=20all=20bookmarks=20confirmatio?= =?UTF-8?q?n=20modal=20(#13374)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps to reproduce the bug: - Create bookmarks for several posts on a topic - Click the topic level bookmark button, it’ll open the modal that asks to confirm clearing all bookmarks from the topic - Choose No - Try to push the topic level bookmark button again - it won’t work And it's fixed with this commit --- .../discourse/app/controllers/topic.js | 10 +++- .../tests/acceptance/bookmarks-test.js | 59 ++++++++++++++++++- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index c3e4060c9b..f9b2b93560 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -1274,8 +1274,14 @@ export default Controller.extend(bufferedProperty("model"), { I18n.t("bookmarks.confirm_clear"), I18n.t("no_value"), I18n.t("yes_value"), - (confirmed) => - confirmed ? toggleBookmarkOnServer().then(resolve) : resolve() + (confirmed) => { + if (confirmed) { + toggleBookmarkOnServer().then(resolve); + } else { + this.model.set("bookmarking", false); + resolve(); + } + } ); } else { toggleBookmarkOnServer().then(resolve); diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js index bab0ff7613..f2e0dfce50 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js @@ -52,11 +52,18 @@ acceptance("Bookmarking", function (needs) { function handleRequest(request) { const data = helper.parsePostData(request.requestBody); steps.push(data.reminder_type || "none"); - return helper.response({ id: 999, success: "OK" }); + + if (data.post_id === "398") { + return helper.response({ id: 1, success: "OK" }); + } else if (data.post_id === "419") { + return helper.response({ id: 2, success: "OK" }); + } else { + throw new Error("Pretender: unknown post_id"); + } } server.post("/bookmarks", handleRequest); - server.put("/bookmarks/999", handleRequest); - server.delete("/bookmarks/999", () => + server.put("/bookmarks/1", handleRequest); + server.delete("/bookmarks/1", () => helper.response({ success: "OK", topic_bookmarked: false }) ); server.get("/t/280.json", () => helper.response(topicResponse)); @@ -262,4 +269,50 @@ acceptance("Bookmarking", function (needs) { "it does not show the local date tile" ); }); + + test("The topic level bookmark button deletes all bookmarks if several posts on the topic are bookmarked", async function (assert) { + const yesButton = "a.btn-primary"; + const noButton = "a.btn-default"; + + await visit("/t/internationalization-localization/280"); + await openBookmarkModal(1); + await click("#save-bookmark"); + await openBookmarkModal(2); + await click("#save-bookmark"); + + assert.ok( + exists(".topic-post:first-child button.bookmark.bookmarked"), + "the first bookmark is added" + ); + assert.ok( + exists(".topic-post:nth-child(3) button.bookmark.bookmarked"), + "the second bookmark is added" + ); + + // open the modal and cancel deleting + await click("#topic-footer-button-bookmark"); + await click(noButton); + + assert.ok( + exists(".topic-post:first-child button.bookmark.bookmarked"), + "the first bookmark isn't deleted" + ); + assert.ok( + exists(".topic-post:nth-child(3) button.bookmark.bookmarked"), + "the second bookmark isn't deleted" + ); + + // open the modal and accept deleting + await click("#topic-footer-button-bookmark"); + await click(yesButton); + + assert.ok( + !exists(".topic-post:first-child button.bookmark.bookmarked"), + "the first bookmark is deleted" + ); + assert.ok( + !exists(".topic-post:nth-child(3) button.bookmark.bookmarked"), + "the second bookmark is deleted" + ); + }); }); From 20dbcbf022068069172dd3af807c2d5edcb0e773 Mon Sep 17 00:00:00 2001 From: Brandon Gastelo <52423576+bgastelo@users.noreply.github.com> Date: Wed, 16 Jun 2021 08:04:21 -0700 Subject: [PATCH 064/403] FIX: Sort filelists to ensure consistant asset precompilation hash (#13393) Dir.glob does not guarantee file order and can change when ran on different machines. This means that running asset precompilation on the exact same codebase will output different content hashes. --- lib/i18n/backend/discourse_i18n.rb | 2 +- lib/plugin/instance.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/backend/discourse_i18n.rb b/lib/i18n/backend/discourse_i18n.rb index 2fd498f668..9962206f63 100644 --- a/lib/i18n/backend/discourse_i18n.rb +++ b/lib/i18n/backend/discourse_i18n.rb @@ -38,7 +38,7 @@ module I18n end def self.sort_locale_files(files) - files.sort_by do |filename| + files.sort.sort_by do |filename| matches = /(?:client|server)-([1-9]|[1-9][0-9]|100)\..+\.yml/.match(filename) matches&.[](1)&.to_i || 0 end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index a7933989c3..2a92c6d01d 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -755,7 +755,7 @@ class Plugin::Instance root_path = "#{File.dirname(@path)}/assets/javascripts" admin_path = "#{File.dirname(@path)}/admin/assets/javascripts" - Dir.glob(["#{root_path}/**/*", "#{admin_path}/**/*"]) do |f| + Dir.glob(["#{root_path}/**/*", "#{admin_path}/**/*"]).sort.each do |f| f_str = f.to_s if File.directory?(f) yield [f, true] From 651b8a23b8c619cf23468ca38b9fed63d597d9c3 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Wed, 16 Jun 2021 13:45:02 -0400 Subject: [PATCH 065/403] FIX: Ember CLI was losing some preloaded data (#13406) The `bootstrap.json` contains most preloaded information but some routes provide extra information, such as invites. This fixes the issue by having the preload request pass on the preloaded data from the source page, which is then merged with the bootstrap's preloaded data for the final HTML payload. --- .../discourse/lib/bootstrap-json/index.js | 28 ++++++++++++------- app/controllers/application_controller.rb | 4 ++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/discourse/lib/bootstrap-json/index.js b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js index f8dc77a277..7c78d2d1f5 100644 --- a/app/assets/javascripts/discourse/lib/bootstrap-json/index.js +++ b/app/assets/javascripts/discourse/lib/bootstrap-json/index.js @@ -132,10 +132,10 @@ function preloaded(buffer, bootstrap) { const BUILDERS = { "html-tag": htmlTag, "before-script-load": beforeScriptLoad, - head: head, - body: body, + head, + body, "hidden-login-form": hiddenLoginForm, - preloaded: preloaded, + preloaded, "body-footer": bodyFooter, "locale-script": localeScript, }; @@ -148,14 +148,20 @@ function replaceIn(bootstrap, template, id, headers) { return template.replace(``, contents); } -function applyBootstrap(bootstrap, template, headers) { +async function applyBootstrap(bootstrap, template, response) { + // If our initial page added some preload data let's not lose that. + let json = await response.json(); + if (json && json.preloaded) { + bootstrap.preloaded = Object.assign(json.preloaded, bootstrap.preloaded); + } + Object.keys(BUILDERS).forEach((id) => { - template = replaceIn(bootstrap, template, id, headers); + template = replaceIn(bootstrap, template, id, response); }); return template; } -function buildFromBootstrap(assetPath, proxy, baseURL, req, headers) { +function buildFromBootstrap(assetPath, proxy, baseURL, req, response) { // eslint-disable-next-line return new Promise((resolve, reject) => { fs.readFile( @@ -170,8 +176,9 @@ function buildFromBootstrap(assetPath, proxy, baseURL, req, headers) { getJSON(url, null, req.headers) .then((json) => { - resolve(applyBootstrap(json.bootstrap, template, headers)); + return applyBootstrap(json.bootstrap, template, response); }) + .then(resolve) .catch((e) => { reject( `Could not get ${proxy}${baseURL}bootstrap.json\n\n${e.toString()}` @@ -203,16 +210,17 @@ async function handleRequest(assetPath, proxy, baseURL, req, res) { let get = bent("GET", [200, 301, 302, 303, 307, 308, 404, 403, 500]); let response = await get(url, null, req.headers); res.set(response.headers); + res.set("content-type", "text/html"); if (response.headers["x-discourse-bootstrap-required"] === "true") { req.headers["X-Discourse-Asset-Path"] = req.path; - let json = await buildFromBootstrap( + let html = await buildFromBootstrap( assetPath, proxy, baseURL, req, - response.headers + response ); - return res.send(json); + return res.send(html); } res.status(response.status); res.send(await response.text()); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5d16eb256c..7ec3091869 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -322,7 +322,9 @@ class ApplicationController < ActionController::Base end def send_ember_cli_bootstrap - head 200, content_type: "text/html", "X-Discourse-Bootstrap-Required": true + response.headers['X-Discourse-Bootstrap-Required'] = true + response.headers['Content-Type'] = "application/json" + render json: { preloaded: @preloaded } end # If a controller requires a plugin, it will raise an exception if that plugin is From 6fe78cd542194576b0a02ff52cb9dbaa79bba82d Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 17 Jun 2021 08:20:09 +1000 Subject: [PATCH 066/403] FIX: Make sure reset-new for tracked is not limited by per_page count (#13395) When dismissing new topics for the Tracked filter, the dismiss was limited to 30 topics which is the default per page count for TopicQuery. This happened even if you specified which topic IDs you were selectively dismissing. This PR fixes that bug, and also moves the per_page_count into a DEFAULT_PER_PAGE_COUNT for the TopicQuery so it can be stubbed in tests. Also moves the unused stub_const method into the spec helpers for cases like this; it is much better to handle this in one place with an ensure. In a follow up PR I will clean up other specs that do the same thing and make them use stub_const. --- app/controllers/topics_controller.rb | 2 +- lib/topic_query.rb | 5 ++- spec/models/theme_spec.rb | 10 ----- spec/requests/topics_controller_spec.rb | 54 +++++++++++++++++++++++-- spec/support/helpers.rb | 10 +++++ 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 010c51bd7f..7f5e36a457 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -968,7 +968,7 @@ class TopicsController < ApplicationController Topic.joins(:tags).where(tags: { name: params[:tag_id] }) else if params[:tracked].to_s == "true" - TopicQuery.tracked_filter(TopicQuery.new(current_user).new_results, current_user.id) + TopicQuery.tracked_filter(TopicQuery.new(current_user).new_results(limit: false), current_user.id) else current_user.user_stat.update_column(:new_since, Time.zone.now) Topic diff --git a/lib/topic_query.rb b/lib/topic_query.rb index dd402eb23b..d09701fae7 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -7,6 +7,7 @@ class TopicQuery PG_MAX_INT ||= 2147483647 + DEFAULT_PER_PAGE_COUNT ||= 30 def self.validators @validators ||= begin @@ -578,7 +579,7 @@ class TopicQuery protected def per_page_setting - 30 + DEFAULT_PER_PAGE_COUNT end def private_messages_for(user, type) @@ -702,7 +703,7 @@ class TopicQuery # Create results based on a bunch of default options def default_results(options = {}) options.reverse_merge!(@options) - options.reverse_merge!(per_page: per_page_setting) + options.reverse_merge!(per_page: per_page_setting) unless options[:limit] == false # Whether to return visible topics options[:visible] = true if @user.nil? || @user.regular? diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 6d8f12d843..0d929a29b1 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -749,16 +749,6 @@ HTML describe "automatic recompile" do it 'must recompile after bumping theme_field version' do - def stub_const(target, const, value) - old = target.const_get(const) - target.send(:remove_const, const) - target.const_set(const, value) - yield - ensure - target.send(:remove_const, const) - target.const_set(const, old) - end - child.set_field(target: :common, name: "header", value: "World") child.set_field(target: :extra_js, name: "test.js.es6", value: "const hello = 'world';") child.save! diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index f72e7d833f..444fc31afc 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -2974,7 +2974,7 @@ RSpec.describe TopicsController do expect(user.user_stat.new_since.to_date).to eq(old_date.to_date) end - it "creates topic user records for each unread topic" do + it "creates dismissed topic user records for each new topic" do sign_in(user) user.user_stat.update_column(:new_since, 2.years.ago) @@ -2982,11 +2982,57 @@ RSpec.describe TopicsController do CategoryUser.set_notification_level_for_category(user, NotificationLevels.all[:tracking], tracked_category.id) - tracked_topic = create_post.topic - tracked_topic.update!(category_id: tracked_category.id) + tracked_topic = create_post(category: tracked_category).topic create_post # This is a new post, but is not tracked so a record will not be created for it - expect { put "/topics/reset-new.json?tracked=true" }.to change { DismissedTopicUser.where(user_id: user.id).count }.by(1) + expect do + put "/topics/reset-new.json?tracked=true" + end.to change { + DismissedTopicUser.where(user_id: user.id, topic_id: tracked_topic.id).count + }.by(1) + end + + it "creates dismissed topic user records if there are > 30 (default pagination) topics" do + sign_in(user) + tracked_category = Fabricate(:category) + CategoryUser.set_notification_level_for_category(user, + NotificationLevels.all[:tracking], + tracked_category.id) + + topic_ids = [] + 5.times do + topic_ids << create_post(category: tracked_category).topic.id + end + + expect do + stub_const(TopicQuery, "DEFAULT_PER_PAGE_COUNT", 2) do + put "/topics/reset-new.json?tracked=true" + end + end.to change { + DismissedTopicUser.where(user_id: user.id, topic_id: topic_ids).count + }.by(5) + end + + it "creates dismissed topic user records if there are > 30 (default pagination) topics and topic_ids are provided" do + sign_in(user) + tracked_category = Fabricate(:category) + CategoryUser.set_notification_level_for_category(user, + NotificationLevels.all[:tracking], + tracked_category.id) + + topic_ids = [] + 5.times do + topic_ids << create_post(category: tracked_category).topic.id + end + dismissing_topic_ids = topic_ids.sample(4) + + expect do + stub_const(TopicQuery, "DEFAULT_PER_PAGE_COUNT", 2) do + put "/topics/reset-new.json?tracked=true", params: { topic_ids: dismissing_topic_ids } + end + end.to change { + DismissedTopicUser.where(user_id: user.id, topic_id: topic_ids).count + }.by(4) end end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index aabf9f9768..417dcdca98 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -181,4 +181,14 @@ module Helpers `cd #{repo_dir} && git commit -am 'first commit'` repo_dir end + + def stub_const(target, const, value) + old = target.const_get(const) + target.send(:remove_const, const) + target.const_set(const, value) + yield + ensure + target.send(:remove_const, const) + target.const_set(const, old) + end end From 6bf97a47a7f02b9244ed0712d6cc273b571091fb Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 17 Jun 2021 08:21:06 +1000 Subject: [PATCH 067/403] FEATURE: Add last updated details to SMTP/IMAP group settings UI (#13396) Adds the last updated at and by SMTP/IMAP fields to the UI, we were already storing them in the DB. Also makes sure that `imap_mailbox_name` being changed makes the last_updated_at/by field update for IMAP. --- .../components/group-imap-email-settings.hbs | 10 ++ .../components/group-smtp-email-settings.hbs | 9 ++ .../group-manage-email-settings-test.js | 130 +++++++++++++++++- app/assets/stylesheets/desktop/group.scss | 7 + app/models/group.rb | 1 + config/locales/client.en.yml | 2 + 6 files changed, 158 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs index 2c6025d4ac..418e623fd7 100644 --- a/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs @@ -24,6 +24,7 @@ {{#if mailboxes}} {{combo-box name="imap_mailbox_name" + id="imap_mailbox" value=group.imap_mailbox_name valueProperty="value" content=mailboxes @@ -77,4 +78,13 @@

    {{i18n "groups.manage.email.settings.allow_unknown_sender_topic_replies_hint"}}

    + + {{#if group.imap_updated_at}} +
    + + {{i18n "groups.manage.email.last_updated"}} {{format-date group.imap_updated_at leaveAgo="true"}} + {{i18n "groups.manage.email.last_updated_by"}} {{#link-to "user" group.imap_updated_by.username}}{{group.imap_updated_by.username}}{{/link-to}} + +
    + {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/components/group-smtp-email-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/group-smtp-email-settings.hbs index fbe3f88b5c..2b807460c7 100644 --- a/app/assets/javascripts/discourse/app/templates/components/group-smtp-email-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/group-smtp-email-settings.hbs @@ -55,4 +55,13 @@
    {{/if}} + + {{#if group.smtp_updated_at}} +
    + + {{i18n "groups.manage.email.last_updated"}} {{format-date group.smtp_updated_at leaveAgo="true"}} + {{i18n "groups.manage.email.last_updated_by"}} {{#link-to "user" group.smtp_updated_by.username}}{{group.smtp_updated_by.username}}{{/link-to}} + +
    + {{/if}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js index 409393c073..e2e989c63b 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js @@ -1,4 +1,8 @@ -import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; +import { + acceptance, + query, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { click, currentRouteName, fillIn, visit } from "@ember/test-helpers"; import I18n from "I18n"; @@ -205,6 +209,130 @@ acceptance( } ); +acceptance( + "Managing Group Email Settings - SMTP and IMAP Enabled - Settings Preflled", + function (needs) { + needs.user(); + needs.settings({ enable_smtp: true, enable_imap: true }); + + needs.pretender((server, helper) => { + server.get("/groups/discourse.json", () => { + return helper.response(200, { + group: { + id: 47, + automatic: false, + name: "discourse", + full_name: "Awesome Team", + user_count: 8, + alias_level: 99, + visible: true, + public_admission: true, + public_exit: false, + flair_url: "fa-adjust", + is_group_owner: true, + mentionable: true, + messageable: true, + can_see_members: true, + has_messages: true, + message_count: 2, + smtp_server: "smtp.gmail.com", + smtp_port: 587, + smtp_ssl: true, + smtp_enabled: true, + smtp_updated_at: "2021-06-16T02:58:12.739Z", + smtp_updated_by: { + id: 19, + username: "eviltrout", + name: "Robin Ward", + avatar_template: + "/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png", + }, + imap_server: "imap.gmail.com", + imap_port: 993, + imap_ssl: true, + imap_mailbox_name: "INBOX", + imap_mailboxes: ["INBOX", "[Gmail]/All Mail", "[Gmail]/Important"], + imap_enabled: true, + imap_updated_at: "2021-06-16T02:58:12.738Z", + imap_updated_by: { + id: 19, + username: "eviltrout", + name: "Robin Ward", + avatar_template: + "/letter_avatar/eviltrout/{size}/3_f9720745f5ce6dfc2b5641fca999d934.png", + }, + email_username: "test@test.com", + email_password: "password", + }, + extras: { + visible_group_names: ["discourse"], + }, + }); + }); + }); + + test("prefills smtp and imap saved settings and shows last updated details", async function (assert) { + await visit("/g/discourse/manage/email"); + + assert.notOk(exists("#enable_smtp:disabled"), "SMTP is not disabled"); + assert.notOk(exists("#enable_imap:disabled"), "IMAP is not disabled"); + + assert.equal( + query("[name='username']").value, + "test@test.com", + "email username is prefilled" + ); + assert.equal( + query("[name='password']").value, + "password", + "email password is prefilled" + ); + assert.equal( + query("[name='smtp_server']").value, + "smtp.gmail.com", + "smtp server is prefilled" + ); + assert.equal( + query("[name='smtp_port']").value, + "587", + "smtp port is prefilled" + ); + + assert.equal( + query("[name='imap_server']").value, + "imap.gmail.com", + "imap server is prefilled" + ); + assert.equal( + query("[name='imap_port']").value, + "993", + "imap port is prefilled" + ); + assert.equal( + selectKit("#imap_mailbox").header().value(), + "INBOX", + "imap mailbox is prefilled" + ); + + const regex = /updated: (.*?) by eviltrout/; + assert.ok(exists(".group-email-last-updated-details.for-imap")); + assert.ok( + regex.test( + query(".group-email-last-updated-details.for-imap").innerText.trim() + ), + "shows last updated imap details" + ); + assert.ok(exists(".group-email-last-updated-details.for-smtp")); + assert.ok( + regex.test( + query(".group-email-last-updated-details.for-smtp").innerText.trim() + ), + "shows last updated smtp details" + ); + }); + } +); + // acceptance( // "Managing Group Email Settings - SMTP and IMAP Enabled - Email Test Invalid", // function (needs) { diff --git a/app/assets/stylesheets/desktop/group.scss b/app/assets/stylesheets/desktop/group.scss index fdb331cb14..eb919c8d96 100644 --- a/app/assets/stylesheets/desktop/group.scss +++ b/app/assets/stylesheets/desktop/group.scss @@ -46,3 +46,10 @@ width: 500px; max-width: 100%; } + +.group-smtp-email-settings, +.group-imap-email-settings { + .group-email-last-updated-details { + text-align: right; + } +} diff --git a/app/models/group.rb b/app/models/group.rb index 4e95974fea..c70918ee90 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -98,6 +98,7 @@ class Group < ActiveRecord::Base "imap_server", "imap_port", "imap_ssl", + "imap_mailbox_name", "email_username", "email_password" ] diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 6eda56e15b..721dcd25fe 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -704,6 +704,8 @@ en: enable_imap: "Enable IMAP" test_settings: "Test Settings" save_settings: "Save Settings" + last_updated: "Last updated:" + last_updated_by: "by" settings_required: "All settings are required, please fill in all fields before validation." smtp_settings_valid: "SMTP settings valid." smtp_title: "SMTP" From b6732722213bde2cfeac3ece5f75c39747992edf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Jun 2021 22:03:24 +0000 Subject: [PATCH 068/403] Build(deps): Bump ffi from 1.15.1 to 1.15.3 Bumps [ffi](https://github.com/ffi/ffi) from 1.15.1 to 1.15.3. - [Release notes](https://github.com/ffi/ffi/releases) - [Changelog](https://github.com/ffi/ffi/blob/master/CHANGELOG.md) - [Commits](https://github.com/ffi/ffi/compare/v1.15.1...v1.15.3) --- updated-dependencies: - dependency-name: ffi dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index d098ab50d9..2a6fe54c71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,7 +151,7 @@ GEM fast_blank (1.0.0) fast_xs (0.8.0) fastimage (2.2.4) - ffi (1.15.1) + ffi (1.15.3) fspath (3.1.2) gc_tracer (1.5.1) globalid (0.4.2) From a2d69ff4797e4578af795129ad64f1864e5cf067 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 17 Jun 2021 11:40:01 +1000 Subject: [PATCH 069/403] FIX: Move allow_unknown_sender_topic_replies outside SMTP/IMAP box (#13410) This setting applies to both SMTP and IMAP for the group inbox, so it should be outside those boxes in a standalone setting. --- .../components/group-imap-email-settings.hbs | 9 --------- .../components/group-manage-email-settings.hbs | 11 +++++++++++ app/assets/stylesheets/common/base/group.scss | 4 ++++ config/locales/client.en.yml | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs index 418e623fd7..e4d19aaf30 100644 --- a/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/group-imap-email-settings.hbs @@ -70,15 +70,6 @@ {{/if}} -
    -

    {{i18n "groups.manage.email.imap_additional_settings"}}

    - -

    {{i18n "groups.manage.email.settings.allow_unknown_sender_topic_replies_hint"}}

    -
    - {{#if group.imap_updated_at}}
    diff --git a/app/assets/javascripts/discourse/app/templates/components/group-manage-email-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/group-manage-email-settings.hbs index 97f08c5348..2f20944a3a 100644 --- a/app/assets/javascripts/discourse/app/templates/components/group-manage-email-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/group-manage-email-settings.hbs @@ -33,6 +33,17 @@
    {{/if}} +
    +
    +

    {{i18n "groups.manage.email.imap_additional_settings"}}

    + +

    {{i18n "groups.manage.email.settings.allow_unknown_sender_topic_replies_hint"}}

    +
    +
    +
    {{group-manage-save-button model=group disabled=(not emailSettingsValid) beforeSave=beforeSave afterSave=afterSave tabindex="14"}} diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index c39e6bee8f..0ea87b2138 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -265,3 +265,7 @@ table.group-category-permissions { } } } + +.group-manage-email-additional-settings-wrapper { + margin-top: 1em; +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 721dcd25fe..3aa726602f 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -734,7 +734,7 @@ en: settings: title: "Settings" allow_unknown_sender_topic_replies: "Allow unknown sender topic replies." - allow_unknown_sender_topic_replies_hint: "Allows unknown senders to reply to group topics. If this is not enabled, replies from email addresses not already included on the IMAP email thread or invited to the topic will create a new topic." + allow_unknown_sender_topic_replies_hint: "Allows unknown senders to reply to group topics. If this is not enabled, replies from email addresses not already invited to the topic will create a new topic." mailboxes: synchronized: "Synchronized Mailbox" none_found: "No mailboxes were found in this email account." From 63299bc14f380b6e4a6bdfe05744d7c274c1f9c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Jun 2021 22:01:16 +0000 Subject: [PATCH 070/403] Build(deps): Bump rubocop from 1.16.1 to 1.17.0 Bumps [rubocop](https://github.com/rubocop/rubocop) from 1.16.1 to 1.17.0. - [Release notes](https://github.com/rubocop/rubocop/releases) - [Changelog](https://github.com/rubocop/rubocop/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop/rubocop/compare/v1.16.1...v1.17.0) --- updated-dependencies: - dependency-name: rubocop dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2a6fe54c71..f2d43eaa3e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -379,7 +379,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.16.1) + rubocop (1.17.0) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) From 7dc0f88acd7cef0f4cc5944ef634f8ecc4886a27 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Wed, 9 Jun 2021 14:36:01 +0800 Subject: [PATCH 071/403] PERF: Cache categories in Site model. Profiling showed that we were roughly 10% of a request time creating all the ActiveRecord objects for categories in the `Site` model on a site with 61 categories. Instead of querying for the categories each time based on which categories the user can see, we can just preload all of the categories upfront and filter out the categories that the user can not see. --- app/models/category.rb | 5 ++++ app/models/category_tag.rb | 4 +++ app/models/category_tag_group.rb | 4 +++ app/models/site.rb | 36 ++++++++++++++++++++---- app/serializers/site_serializer.rb | 8 ++++-- lib/guardian/category_guardian.rb | 5 ++++ spec/serializers/site_serializer_spec.rb | 29 ++++++++++++++++--- 7 files changed, 80 insertions(+), 11 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 6692d2d1fd..93ee75536e 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -91,6 +91,7 @@ class Category < ActiveRecord::Base after_commit :trigger_category_created_event, on: :create after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy + after_commit :clear_site_cache after_save_commit :index_search @@ -957,6 +958,10 @@ class Category < ActiveRecord::Base result.map { |row| [row.group_id, row.permission_type] } end + + def clear_site_cache + Site.clear_cache + end end # == Schema Information diff --git a/app/models/category_tag.rb b/app/models/category_tag.rb index 1e21409c31..e9cba7c189 100644 --- a/app/models/category_tag.rb +++ b/app/models/category_tag.rb @@ -3,6 +3,10 @@ class CategoryTag < ActiveRecord::Base belongs_to :category belongs_to :tag + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/category_tag_group.rb b/app/models/category_tag_group.rb index 06e64ad65f..ea27bc50c1 100644 --- a/app/models/category_tag_group.rb +++ b/app/models/category_tag_group.rb @@ -3,6 +3,10 @@ class CategoryTagGroup < ActiveRecord::Base belongs_to :category belongs_to :tag_group + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/site.rb b/app/models/site.rb index f8c131ef1d..12b520445a 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -28,16 +28,42 @@ class Site UserField.order(:position).all end - def categories - @categories ||= begin + CATEGORIES_CACHE_KEY = "site_categories" + + def self.clear_cache + Discourse.cache.delete(CATEGORIES_CACHE_KEY) + end + + def self.all_categories_cache + # Categories do not change often so there is no need for us to run the + # same query and spend time creating ActiveRecord objects for every requests. + # + # Do note that any new association added to the eager loading needs a + # corresponding ActiveRecord callback to clear the categories cache. + Discourse.cache.fetch(CATEGORIES_CACHE_KEY, expires_in: 30.minutes) do categories = Category - .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups) - .secured(@guardian) + .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups, :required_tag_group) .joins('LEFT JOIN topics t on t.id = categories.topic_id') .select('categories.*, t.slug topic_slug') .order(:position) + .to_a - categories = categories.to_a + ActiveModel::ArraySerializer.new( + categories, + each_serializer: SiteCategorySerializer + ).as_json + end + end + + def categories + @categories ||= begin + categories = [] + + self.class.all_categories_cache.each do |category| + if @guardian.can_see_serialized_category?(category_id: category["id"], read_restricted: category["read_restricted"]) + categories << OpenStruct.new(category) + end + end with_children = Set.new categories.each do |c| diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 40da58e355..c7d8d5d66c 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -30,10 +30,10 @@ class SiteSerializer < ApplicationSerializer :shared_drafts_category_id, :custom_emoji_translation, :watched_words_replace, - :watched_words_link + :watched_words_link, + :categories ) - has_many :categories, serializer: SiteCategorySerializer, embed: :objects has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serializer: UserFieldSerializer has_many :auth_providers, embed: :objects, serializer: AuthProviderSerializer @@ -190,6 +190,10 @@ class SiteSerializer < ApplicationSerializer WordWatcher.word_matcher_regexps(:link) end + def categories + object.categories.map { |c| c.to_h } + end + private def ordered_flags(flags) diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index 4e9050f29a..8fc6fe28b5 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -46,6 +46,11 @@ module CategoryGuardian nil end + def can_see_serialized_category?(category_id:, read_restricted:) + return true if !read_restricted + secure_category_ids.include?(category_id) + end + def can_see_category?(category) return false unless category return true if is_admin? diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 1a53cfd11f..5bf58564c0 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -10,13 +10,34 @@ describe SiteSerializer do category.custom_fields["enable_marketplace"] = true category.save_custom_fields - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).not_to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:preloaded_custom_fields]).to eq(nil) Site.preloaded_category_custom_fields << "enable_marketplace" - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:preloaded_custom_fields]["enable_marketplace"]).to eq("t") + end + + it "includes category tags" do + tag = Fabricate(:tag) + tag_group = Fabricate(:tag_group) + tag_group_2 = Fabricate(:tag_group) + + category.tags << tag + category.tag_groups << tag_group + category.update!(required_tag_group: tag_group_2) + + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:allowed_tags]).to contain_exactly(tag.name) + expect(c1[:allowed_tag_groups]).to contain_exactly(tag_group.name) + expect(c1[:required_tag_group_name]).to eq(tag_group_2.name) end it "returns correct notification level for categories" do From 4c3d2267b4893fc36e38e53de93b7ad59ca18463 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 17 Jun 2021 09:15:20 +0200 Subject: [PATCH 072/403] FIX: ensure we dont collapse data multiple times (#13399) Note that this commit will also disable daily grouping for datasets with more than 30 data points. This will also smartly do the grouping by month when grouping a full year. --- .../addon/components/admin-report-chart.js | 61 +------------ .../components/admin-report-stacked-chart.js | 8 +- .../admin/addon/components/admin-report.js | 64 +++++--------- .../addon/controllers/admin-reports-show.js | 2 +- .../javascripts/admin/addon/models/report.js | 87 +++++++++++++++++++ .../admin/addon/routes/admin-reports-show.js | 4 + .../templates/components/admin-report.hbs | 3 +- 7 files changed, 121 insertions(+), 108 deletions(-) diff --git a/app/assets/javascripts/admin/addon/components/admin-report-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-chart.js index 8a894b9562..9e2d382fc9 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-chart.js @@ -1,3 +1,4 @@ +import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; import loadScript from "discourse/lib/load-script"; @@ -157,7 +158,7 @@ export default Component.extend({ gridLines: { display: false }, type: "time", time: { - unit: this._unitForGrouping(options), + unit: Report.unitForGrouping(options.chartGrouping), }, ticks: { sampleSize: 5, @@ -179,62 +180,6 @@ export default Component.extend({ }, _applyChartGrouping(model, data, options) { - if (!options.chartGrouping || options.chartGrouping === "daily") { - return data; - } - - if ( - options.chartGrouping === "weekly" || - options.chartGrouping === "monthly" - ) { - const isoKind = options.chartGrouping === "weekly" ? "isoWeek" : "month"; - const kind = options.chartGrouping === "weekly" ? "week" : "month"; - const startMoment = moment(model.start_date, "YYYY-MM-DD"); - - let currentIndex = 0; - let currentStart = startMoment.clone().startOf(isoKind); - let currentEnd = startMoment.clone().endOf(isoKind); - const transformedData = [ - { - x: currentStart.format("YYYY-MM-DD"), - y: 0, - }, - ]; - - data.forEach((d) => { - let date = moment(d.x, "YYYY-MM-DD"); - - if (!date.isBetween(currentStart, currentEnd)) { - currentIndex += 1; - currentStart = currentStart.add(1, kind).startOf(isoKind); - currentEnd = currentEnd.add(1, kind).endOf(isoKind); - } - - if (transformedData[currentIndex]) { - transformedData[currentIndex].y += d.y; - } else { - transformedData[currentIndex] = { - x: d.x, - y: d.y, - }; - } - }); - - return transformedData; - } - - // ensure we return something if grouping is unknown - return data; - }, - - _unitForGrouping(options) { - switch (options.chartGrouping) { - case "monthly": - return "month"; - case "weekly": - return "week"; - default: - return "day"; - } + return Report.collapse(model, data, options.chartGrouping); }, }); diff --git a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js index 4cac3b15db..4c3a9bb633 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js +++ b/app/assets/javascripts/admin/addon/components/admin-report-stacked-chart.js @@ -1,3 +1,4 @@ +import Report from "admin/models/report"; import Component from "@ember/component"; import discourseDebounce from "discourse-common/lib/debounce"; import loadScript from "discourse/lib/load-script"; @@ -63,7 +64,7 @@ export default Component.extend({ return { label: cd.label, stack: "pageviews-stack", - data: cd.data.map((d) => Math.round(parseFloat(d.y))), + data: Report.collapse(model, cd.data), backgroundColor: cd.color, }; }), @@ -129,15 +130,14 @@ export default Component.extend({ }, }, ], + xAxes: [ { display: true, gridLines: { display: false }, type: "time", - offset: true, time: { - parser: "YYYY-MM-DD", - minUnit: "day", + unit: Report.unitForDatapoints(data.labels.length), }, ticks: { sampleSize: 5, diff --git a/app/assets/javascripts/admin/addon/components/admin-report.js b/app/assets/javascripts/admin/addon/components/admin-report.js index 2fe0ab68d8..5fade627a2 100644 --- a/app/assets/javascripts/admin/addon/components/admin-report.js +++ b/app/assets/javascripts/admin/addon/components/admin-report.js @@ -1,5 +1,5 @@ import EmberObject, { action, computed } from "@ember/object"; -import Report, { SCHEMA_VERSION } from "admin/models/report"; +import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; import Component from "@ember/component"; import I18n from "I18n"; @@ -21,26 +21,6 @@ const TABLE_OPTIONS = { const CHART_OPTIONS = {}; -function collapseWeekly(data, average) { - let aggregate = []; - let bucket, i; - let offset = data.length % 7; - for (i = offset; i < data.length; i++) { - if (bucket && i % 7 === offset) { - if (average) { - bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); - } - aggregate.push(bucket); - bucket = null; - } - - bucket = bucket || { x: data[i].x, y: 0 }; - bucket.y += data[i].y; - } - - return aggregate; -} - export default Component.extend({ classNameBindings: [ "isHidden:hidden", @@ -99,6 +79,10 @@ export default Component.extend({ } this.set("endDate", endDate); + if (this.filters) { + this.set("currentMode", this.filters.mode); + } + if (this.report) { this._renderReport(this.report, this.forcedModes, this.currentMode); } else if (this.dataSourceName) { @@ -147,7 +131,7 @@ export default Component.extend({ return makeArray(modes).map((mode) => { const base = `btn-default mode-btn ${mode}`; - const cssClass = currentMode === mode ? `${base} is-current` : base; + const cssClass = currentMode === mode ? `${base} btn-primary` : base; return { mode, @@ -196,15 +180,16 @@ export default Component.extend({ return reportKey; }, - @discourseComputed("reportOptions.chartGrouping") - chartGroupings(chartGrouping) { - chartGrouping = chartGrouping || "daily"; + @discourseComputed("options.chartGrouping", "model.chartData.length") + chartGroupings(grouping, count) { + const options = ["daily", "weekly", "monthly"]; - return ["daily", "weekly", "monthly"].map((id) => { + return options.map((id) => { return { id, + disabled: id === "daily" && count >= DAILY_LIMIT_DAYS, label: `admin.dashboard.reports.${id}`, - class: `chart-grouping ${chartGrouping === id ? "active" : "inactive"}`, + class: `chart-grouping ${grouping === id ? "active" : "inactive"}`, }; }); }, @@ -240,6 +225,7 @@ export default Component.extend({ this.attrs.onRefresh({ type: this.get("model.type"), + mode: this.currentMode, chartGrouping: options.chartGrouping, startDate: typeof options.startDate === "undefined" @@ -271,7 +257,7 @@ export default Component.extend({ }, @action - changeMode(mode) { + onChangeMode(mode) { this.set("currentMode", mode); this.send("refreshReport", { @@ -329,7 +315,7 @@ export default Component.extend({ this.setProperties({ model: report, currentMode, - options: this._buildOptions(currentMode), + options: this._buildOptions(currentMode, report), }); }, @@ -391,7 +377,7 @@ export default Component.extend({ return payload; }, - _buildOptions(mode) { + _buildOptions(mode, report) { if (mode === "table") { const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); return EmberObject.create( @@ -401,7 +387,9 @@ export default Component.extend({ const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); return EmberObject.create( Object.assign(chartOptions, this.get("reportOptions.chart") || {}, { - chartGrouping: this.get("reportOptions.chartGrouping"), + chartGrouping: + this.get("reportOptions.chartGrouping") || + Report.groupingForDatapoints(report.chartData.length), }) ); } @@ -414,7 +402,7 @@ export default Component.extend({ jsonReport.chartData = jsonReport.chartData.map((chartData) => { if (chartData.length > 40) { return { - data: collapseWeekly(chartData.data), + data: chartData.data, req: chartData.req, label: chartData.label, color: chartData.color, @@ -423,11 +411,6 @@ export default Component.extend({ return chartData; } }); - } else if (jsonReport.chartData && jsonReport.chartData.length > 40) { - jsonReport.chartData = collapseWeekly( - jsonReport.chartData, - jsonReport.average - ); } if (jsonReport.prev_data) { @@ -437,13 +420,6 @@ export default Component.extend({ starDate: jsonReport.prev_startDate, endDate: jsonReport.prev_endDate, }); - - if (jsonReport.prevChartData && jsonReport.prevChartData.length > 40) { - jsonReport.prevChartData = collapseWeekly( - jsonReport.prevChartData, - jsonReport.average - ); - } } return Report.create(jsonReport); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js b/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js index 64a2c5de93..5f19399edf 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-reports-show.js @@ -2,7 +2,7 @@ import Controller from "@ember/controller"; import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ - queryParams: ["start_date", "end_date", "filters", "chart_grouping"], + queryParams: ["start_date", "end_date", "filters", "chart_grouping", "mode"], start_date: null, end_date: null, filters: null, diff --git a/app/assets/javascripts/admin/addon/models/report.js b/app/assets/javascripts/admin/addon/models/report.js index 84e6304c75..1affb8e796 100644 --- a/app/assets/javascripts/admin/addon/models/report.js +++ b/app/assets/javascripts/admin/addon/models/report.js @@ -503,7 +503,94 @@ const Report = EmberObject.extend({ }, }); +export const WEEKLY_LIMIT_DAYS = 365; +export const DAILY_LIMIT_DAYS = 30; + Report.reopenClass({ + groupingForDatapoints(count) { + if (count < DAILY_LIMIT_DAYS) { + return "daily"; + } + + if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) { + return "weekly"; + } + + if (count >= WEEKLY_LIMIT_DAYS) { + return "monthly"; + } + }, + + unitForDatapoints(count) { + if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) { + return "week"; + } else if (count >= WEEKLY_LIMIT_DAYS) { + return "month"; + } else { + return "day"; + } + }, + + unitForGrouping(grouping) { + switch (grouping) { + case "monthly": + return "month"; + case "weekly": + return "week"; + default: + return "day"; + } + }, + + collapse(model, data, grouping) { + grouping = grouping || Report.groupingForDatapoints(data.length); + + if (grouping === "daily") { + return data; + } else if (grouping === "weekly" || grouping === "monthly") { + const isoKind = grouping === "weekly" ? "isoWeek" : "month"; + const kind = grouping === "weekly" ? "week" : "month"; + const startMoment = moment(model.start_date, "YYYY-MM-DD"); + + let currentIndex = 0; + let currentStart = startMoment.clone().startOf(isoKind); + let currentEnd = startMoment.clone().endOf(isoKind); + const transformedData = [ + { + x: currentStart.format("YYYY-MM-DD"), + y: 0, + }, + ]; + + data.forEach((d) => { + const date = moment(d.x, "YYYY-MM-DD"); + + if ( + !date.isSame(currentStart) && + !date.isBetween(currentStart, currentEnd) + ) { + currentIndex += 1; + currentStart = currentStart.add(1, kind).startOf(isoKind); + currentEnd = currentEnd.add(1, kind).endOf(isoKind); + } + + if (transformedData[currentIndex]) { + transformedData[currentIndex].y += d.y; + } else { + transformedData[currentIndex] = { + x: d.x, + y: d.y, + }; + } + }); + + return transformedData; + } + + // ensure we return something if grouping is unknown + return data; + }, + fillMissingDates(report, options = {}) { const dataField = options.dataField || "data"; const filledField = options.filledField || "data"; diff --git a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js index 192158122e..f4806399cf 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js +++ b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js @@ -6,6 +6,7 @@ export default DiscourseRoute.extend({ end_date: { refreshModel: true }, filters: { refreshModel: true }, chart_grouping: { refreshModel: true }, + mode: { refreshModel: true }, }, model(params) { @@ -28,6 +29,8 @@ export default DiscourseRoute.extend({ params.chartGrouping = params.chart_grouping || "daily"; delete params.chart_grouping; + params.mode = params.mode || "table"; + return params; }, @@ -55,6 +58,7 @@ export default DiscourseRoute.extend({ onParamsChange(params) { const queryParams = { type: params.type, + mode: params.mode, start_date: params.startDate ? params.startDate.toISOString(true).split("T")[0] : null, diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-report.hbs b/app/assets/javascripts/admin/addon/templates/components/admin-report.hbs index c2d6334de9..e9fe15e831 100644 --- a/app/assets/javascripts/admin/addon/templates/components/admin-report.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/admin-report.hbs @@ -122,7 +122,7 @@
    {{#each displayedModes as |displayedMode|}} {{d-button - action=(action "changeMode") + action=(action "onChangeMode") actionParam=displayedMode.mode class=displayedMode.cssClass icon=displayedMode.icon}} @@ -137,6 +137,7 @@ label=chartGrouping.label action=(action "changeGrouping" chartGrouping.id) class=chartGrouping.class + disabled=chartGrouping.disabled }} {{/each}}
    From aa4f0aee67d6f9802856ab4abb5a7560359854b6 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 17 Jun 2021 15:17:49 +0800 Subject: [PATCH 073/403] Revert "PERF: Cache categories in Site model." This reverts commit 7dc0f88acd7cef0f4cc5944ef634f8ecc4886a27. --- app/models/category.rb | 5 ---- app/models/category_tag.rb | 4 --- app/models/category_tag_group.rb | 4 --- app/models/site.rb | 36 ++++-------------------- app/serializers/site_serializer.rb | 8 ++---- lib/guardian/category_guardian.rb | 5 ---- spec/serializers/site_serializer_spec.rb | 29 +++---------------- 7 files changed, 11 insertions(+), 80 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 93ee75536e..6692d2d1fd 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -91,7 +91,6 @@ class Category < ActiveRecord::Base after_commit :trigger_category_created_event, on: :create after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy - after_commit :clear_site_cache after_save_commit :index_search @@ -958,10 +957,6 @@ class Category < ActiveRecord::Base result.map { |row| [row.group_id, row.permission_type] } end - - def clear_site_cache - Site.clear_cache - end end # == Schema Information diff --git a/app/models/category_tag.rb b/app/models/category_tag.rb index e9cba7c189..1e21409c31 100644 --- a/app/models/category_tag.rb +++ b/app/models/category_tag.rb @@ -3,10 +3,6 @@ class CategoryTag < ActiveRecord::Base belongs_to :category belongs_to :tag - - after_commit do - Site.clear_cache - end end # == Schema Information diff --git a/app/models/category_tag_group.rb b/app/models/category_tag_group.rb index ea27bc50c1..06e64ad65f 100644 --- a/app/models/category_tag_group.rb +++ b/app/models/category_tag_group.rb @@ -3,10 +3,6 @@ class CategoryTagGroup < ActiveRecord::Base belongs_to :category belongs_to :tag_group - - after_commit do - Site.clear_cache - end end # == Schema Information diff --git a/app/models/site.rb b/app/models/site.rb index 12b520445a..f8c131ef1d 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -28,42 +28,16 @@ class Site UserField.order(:position).all end - CATEGORIES_CACHE_KEY = "site_categories" - - def self.clear_cache - Discourse.cache.delete(CATEGORIES_CACHE_KEY) - end - - def self.all_categories_cache - # Categories do not change often so there is no need for us to run the - # same query and spend time creating ActiveRecord objects for every requests. - # - # Do note that any new association added to the eager loading needs a - # corresponding ActiveRecord callback to clear the categories cache. - Discourse.cache.fetch(CATEGORIES_CACHE_KEY, expires_in: 30.minutes) do + def categories + @categories ||= begin categories = Category - .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups, :required_tag_group) + .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups) + .secured(@guardian) .joins('LEFT JOIN topics t on t.id = categories.topic_id') .select('categories.*, t.slug topic_slug') .order(:position) - .to_a - ActiveModel::ArraySerializer.new( - categories, - each_serializer: SiteCategorySerializer - ).as_json - end - end - - def categories - @categories ||= begin - categories = [] - - self.class.all_categories_cache.each do |category| - if @guardian.can_see_serialized_category?(category_id: category["id"], read_restricted: category["read_restricted"]) - categories << OpenStruct.new(category) - end - end + categories = categories.to_a with_children = Set.new categories.each do |c| diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index c7d8d5d66c..40da58e355 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -30,10 +30,10 @@ class SiteSerializer < ApplicationSerializer :shared_drafts_category_id, :custom_emoji_translation, :watched_words_replace, - :watched_words_link, - :categories + :watched_words_link ) + has_many :categories, serializer: SiteCategorySerializer, embed: :objects has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serializer: UserFieldSerializer has_many :auth_providers, embed: :objects, serializer: AuthProviderSerializer @@ -190,10 +190,6 @@ class SiteSerializer < ApplicationSerializer WordWatcher.word_matcher_regexps(:link) end - def categories - object.categories.map { |c| c.to_h } - end - private def ordered_flags(flags) diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index 8fc6fe28b5..4e9050f29a 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -46,11 +46,6 @@ module CategoryGuardian nil end - def can_see_serialized_category?(category_id:, read_restricted:) - return true if !read_restricted - secure_category_ids.include?(category_id) - end - def can_see_category?(category) return false unless category return true if is_admin? diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 5bf58564c0..1a53cfd11f 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -10,34 +10,13 @@ describe SiteSerializer do category.custom_fields["enable_marketplace"] = true category.save_custom_fields - serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json - c1 = serialized[:categories].find { |c| c[:id] == category.id } - - expect(c1[:preloaded_custom_fields]).to eq(nil) + data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) + expect(data).not_to include("enable_marketplace") Site.preloaded_category_custom_fields << "enable_marketplace" - serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json - c1 = serialized[:categories].find { |c| c[:id] == category.id } - - expect(c1[:preloaded_custom_fields]["enable_marketplace"]).to eq("t") - end - - it "includes category tags" do - tag = Fabricate(:tag) - tag_group = Fabricate(:tag_group) - tag_group_2 = Fabricate(:tag_group) - - category.tags << tag - category.tag_groups << tag_group - category.update!(required_tag_group: tag_group_2) - - serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json - c1 = serialized[:categories].find { |c| c[:id] == category.id } - - expect(c1[:allowed_tags]).to contain_exactly(tag.name) - expect(c1[:allowed_tag_groups]).to contain_exactly(tag_group.name) - expect(c1[:required_tag_group_name]).to eq(tag_group_2.name) + data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) + expect(data).to include("enable_marketplace") end it "returns correct notification level for categories" do From c893b20298aa7af3f9303cef1cc71a96d62b4db5 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Thu, 17 Jun 2021 10:45:40 +0300 Subject: [PATCH 074/403] FIX: Destroy invites of anonymized emails (#13404) Anonymizing a user changed their email address, destroyed all associated InvitedUser records, but did not destroy the invites associated to user's email. --- app/jobs/regular/anonymize_user.rb | 1 + spec/services/user_anonymizer_spec.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/jobs/regular/anonymize_user.rb b/app/jobs/regular/anonymize_user.rb index 1113476a81..c078706c04 100644 --- a/app/jobs/regular/anonymize_user.rb +++ b/app/jobs/regular/anonymize_user.rb @@ -16,6 +16,7 @@ module Jobs def make_anonymous anonymize_ips(@anonymize_ip) if @anonymize_ip + Invite.where(email: @prev_email).destroy_all InvitedUser.where(user_id: @user_id).destroy_all EmailToken.where(user_id: @user_id).destroy_all EmailLog.where(user_id: @user_id).delete_all diff --git a/spec/services/user_anonymizer_spec.rb b/spec/services/user_anonymizer_spec.rb index 1307990545..a150d1e197 100644 --- a/spec/services/user_anonymizer_spec.rb +++ b/spec/services/user_anonymizer_spec.rb @@ -368,4 +368,17 @@ describe UserAnonymizer do end + describe "anonymize_emails" do + it "destroys all associated invites" do + invite = Fabricate(:invite, email: 'test@example.com') + user = invite.redeem + + Jobs.run_immediately! + described_class.make_anonymous(user, admin) + + expect(user.email).not_to eq('test@example.com') + expect(Invite.exists?(id: invite.id)).to eq(false) + end + end + end From 007e166d13114000c93cbb85d16bce2c4c636c46 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Thu, 17 Jun 2021 10:45:53 +0300 Subject: [PATCH 075/403] FIX: Resend only pending invites (#13403) The Resend Invites button used to resend expired invites too, which was unexpected because the button was on the Pending Invites page. --- app/controllers/invites_controller.rb | 6 +----- spec/requests/invites_controller_spec.rb | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 94d889c7ad..997f6e877d 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -290,12 +290,8 @@ class InvitesController < ApplicationController def resend_all_invites guardian.ensure_can_resend_all_invites!(current_user) - Invite - .left_outer_joins(:invited_users) - .where(invited_by: current_user) + Invite.pending(current_user) .where('invites.email IS NOT NULL') - .where('invited_users.user_id IS NULL') - .group('invites.id') .find_each { |invite| invite.resend_invite } render json: success_json diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 0858261d1a..41dd9998e2 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -744,6 +744,8 @@ describe InvitesController do it 'resends all non-redeemed invites by a user' do SiteSetting.invite_expiry_days = 30 + freeze_time + user = Fabricate(:admin) new_invite = Fabricate(:invite, invited_by: user) expired_invite = Fabricate(:invite, invited_by: user) @@ -756,9 +758,9 @@ describe InvitesController do post '/invites/reinvite-all' expect(response.status).to eq(200) - expect(new_invite.reload.expires_at.to_date).to eq(30.days.from_now.to_date) - expect(expired_invite.reload.expires_at.to_date).to eq(30.days.from_now.to_date) - expect(redeemed_invite.reload.expires_at.to_date).to eq(5.days.ago.to_date) + expect(new_invite.reload.expires_at).to eq_time(30.days.from_now) + expect(expired_invite.reload.expires_at).to eq_time(2.days.ago) + expect(redeemed_invite.reload.expires_at).to eq_time(5.days.ago) end end From 90bd88627a6b56d6a3f421030050f309cfbc40a1 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 17 Jun 2021 10:07:51 +0200 Subject: [PATCH 076/403] FIX: prevents mode to be forced to unexisting mode (#13413) --- app/assets/javascripts/admin/addon/routes/admin-reports-show.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js index f4806399cf..8aec4baf31 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-reports-show.js +++ b/app/assets/javascripts/admin/addon/routes/admin-reports-show.js @@ -29,8 +29,6 @@ export default DiscourseRoute.extend({ params.chartGrouping = params.chart_grouping || "daily"; delete params.chart_grouping; - params.mode = params.mode || "table"; - return params; }, From ea2833d0d89df9b48c4fc46e422f3e9b713cac00 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Thu, 17 Jun 2021 11:53:29 +0300 Subject: [PATCH 077/403] FIX: Update post's raw from server response (#13414) The client used to update the raw, but sometimes the server changed the raw text, which resulted in false edit conflicts. --- app/assets/javascripts/discourse/app/models/composer.js | 5 ++--- app/controllers/posts_controller.rb | 2 +- spec/requests/posts_controller_spec.rb | 5 +++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/composer.js b/app/assets/javascripts/discourse/app/models/composer.js index e7911545a9..36349ed121 100644 --- a/app/assets/javascripts/discourse/app/models/composer.js +++ b/app/assets/javascripts/discourse/app/models/composer.js @@ -929,7 +929,6 @@ const Composer = RestModel.extend({ editPost(opts) { const post = this.post; - const oldRaw = post.raw; const oldCooked = post.cooked; let promise = Promise.resolve(); @@ -971,14 +970,14 @@ const Composer = RestModel.extend({ this.set("composeState", SAVING); const rollback = throwAjaxError((error) => { - post.setProperties({ raw: oldRaw, cooked: oldCooked }); + post.setProperties("cooked", oldCooked); this.set("composeState", OPEN); if (error.jqXHR && error.jqXHR.status === 409) { this.set("editConflict", true); } }); - post.setProperties({ raw: props.raw, cooked: props.cooked, staged: true }); + post.setProperties({ cooked: props.cooked, staged: true }); this.appEvents.trigger("post-stream:refresh", { id: post.id }); return promise diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 981bd70621..c59a97b71c 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -247,7 +247,7 @@ class PostsController < ApplicationController return render_json_error(post) if post.errors.present? return render_json_error(topic) if topic.errors.present? - post_serializer = PostSerializer.new(post, scope: guardian, root: false) + post_serializer = PostSerializer.new(post, scope: guardian, root: false, add_raw: true) post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key) link_counts = TopicLink.counts_for(guardian, topic, [post]) post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present? diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index 1e7f64f473..a149ed697f 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -415,10 +415,11 @@ describe PostsController do end it "updates post's raw attribute" do - put "/posts/#{post.id}.json", params: update_params + put "/posts/#{post.id}.json", params: { post: { raw: 'edited body ' } } expect(response.status).to eq(200) - expect(post.reload.raw).to eq(update_params[:post][:raw]) + expect(response.parsed_body['post']['raw']).to eq('edited body') + expect(post.reload.raw).to eq('edited body') end it "extracts links from the new body" do From 0c42a29dc4ad8e541d7b220623de09ec6354a12e Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 17 Jun 2021 09:06:18 -0500 Subject: [PATCH 078/403] DEV: Plugin API to allow creation of directory columns with item query (#13402) The first thing we needed here was an enum rather than a boolean to determine how a directory_column was created. Now we have `automatic`, `user_field` and `plugin` directory columns. This plugin API is assuming that the plugin has added a migration to a column to the `directory_items` table. This was created to be initially used by discourse-solved. PR with API usage - https://github.com/discourse/discourse-solved/pull/137/ --- .../app/components/table-header-toggle.js | 4 ++ .../edit-user-directory-columns.js | 2 +- .../discourse/app/controllers/users.js | 19 ++++-- .../app/helpers/directory-item-helpers.js | 37 +++++++++++ .../app/helpers/directory-item-label.js | 10 --- .../directory-item-user-field-value.js | 16 ----- .../app/helpers/directory-item-value.js | 11 ---- .../templates/components/directory-item.hbs | 6 +- .../templates/components/directory-table.hbs | 1 + .../mobile/components/directory-item.hbs | 2 +- .../modal/edit-user-directory-columns.hbs | 6 +- .../tests/helpers/create-pretender.js | 16 ++--- .../discourse/tests/setup-tests.js | 2 +- app/controllers/application_controller.rb | 8 ++- .../directory_columns_controller.rb | 2 +- app/controllers/directory_items_controller.rb | 9 ++- app/models/directory_column.rb | 28 ++++++++ app/models/directory_item.rb | 64 ++++++++++--------- .../directory_column_serializer.rb | 2 +- app/serializers/directory_item_serializer.rb | 2 +- ...609133551_add_type_to_directory_columns.rb | 18 ++++++ ...52431_remove_directory_column_automatic.rb | 11 ++++ lib/plugin/instance.rb | 16 +++++ spec/components/plugin/instance_spec.rb | 24 +++++++ .../admin/user_fields_controller_spec.rb | 2 +- 25 files changed, 222 insertions(+), 96 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-label.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-value.js create mode 100644 db/migrate/20210609133551_add_type_to_directory_columns.rb create mode 100644 db/post_migrate/20210609152431_remove_directory_column_automatic.rb diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js index afe7ef32eb..424879f546 100644 --- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js +++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js @@ -9,6 +9,7 @@ export default Component.extend({ chevronIcon: null, columnIcon: null, translated: false, + automatic: false, onActiveRender: null, toggleProperties() { @@ -31,6 +32,9 @@ export default Component.extend({ }, didReceiveAttrs() { this._super(...arguments); + if (!this.automatic && !this.translated) { + this.set("labelKey", this.field); + } this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`); this.toggleChevron(); }, diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js index a7fd826af8..21f2dd8a6d 100644 --- a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js +++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js @@ -58,7 +58,7 @@ export default Controller.extend(ModalFunctionality, { .forEach((column, index) => { column.setProperties({ position: column.automatic_position || index + 1, - enabled: column.automatic, + enabled: column.type === "automatic", }); }); this.set("columns", resetColumns); diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js index e2878823ae..1846b4eea3 100644 --- a/app/assets/javascripts/discourse/app/controllers/users.js +++ b/app/assets/javascripts/discourse/app/controllers/users.js @@ -28,13 +28,22 @@ export default Controller.extend({ this.set("nameInput", params.name); this.set("order", params.order); - const custom_field_columns = this.columns.filter((c) => !c.automatic); - const user_field_ids = custom_field_columns - .map((c) => c.user_field_id) - .join("|"); + const userFieldColumns = this.columns.filter( + (c) => c.type === "user_field" + ); + const userFieldIds = userFieldColumns.map((c) => c.user_field_id).join("|"); + + const pluginColumns = this.columns.filter((c) => c.type === "plugin"); + const pluginColumnIds = pluginColumns.map((c) => c.id).join("|"); return this.store - .find("directoryItem", Object.assign(params, { user_field_ids })) + .find( + "directoryItem", + Object.assign(params, { + user_field_ids: userFieldIds, + plugin_column_ids: pluginColumnIds, + }) + ) .then((model) => { const lastUpdatedAt = model.get("resultSetMeta.last_updated_at"); this.setProperties({ diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js new file mode 100644 index 0000000000..1007a506b7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js @@ -0,0 +1,37 @@ +import { htmlSafe } from "@ember/template"; +import { number } from "discourse/lib/formatter"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import I18n from "I18n"; + +registerUnbound("mobile-directory-item-label", function (args) { + // Args should include key/values { item, column } + const count = args.item.get(args.column.name); + return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); +}); + +registerUnbound("directory-item-value", function (args) { + // Args should include key/values { item, column } + return htmlSafe( + `${number(args.item.get(args.column.name))}` + ); +}); + +registerUnbound("directory-item-user-field-value", function (args) { + // Args should include key/values { item, column } + const value = + args.item.user && args.item.user.user_fields + ? args.item.user.user_fields[args.column.user_field_id] + : null; + const content = value || "-"; + return htmlSafe(`${content}`); +}); + +registerUnbound("directory-column-is-automatic", function (args) { + // Args should include key/values { column } + return args.column.type === "automatic"; +}); + +registerUnbound("directory-column-is-user-field", function (args) { + // Args should include key/values { column } + return args.column.type === "user_field"; +}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js deleted file mode 100644 index 56723ee716..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js +++ /dev/null @@ -1,10 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; -import I18n from "I18n"; - -export default registerUnbound("mobile-directory-item-label", function (args) { - // Args should include key/values { item, column } - - const count = args.item.get(args.column.name); - return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); -}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js deleted file mode 100644 index aeab4bcbe1..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js +++ /dev/null @@ -1,16 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; - -export default registerUnbound( - "directory-item-user-field-value", - function (args) { - // Args should include key/values { item, column } - - const value = - args.item.user && args.item.user.user_fields - ? args.item.user.user_fields[args.column.user_field_id] - : null; - const content = value || "-"; - return htmlSafe(`${content}`); - } -); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js deleted file mode 100644 index a3c6e3d6d3..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js +++ /dev/null @@ -1,11 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; -import { number } from "discourse/lib/formatter"; - -export default registerUnbound("directory-item-value", function (args) { - // Args should include key/values { item, column } - - return htmlSafe( - `${number(args.item.get(args.column.name))}` - ); -}); diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs index 0d4fece411..b1b083beda 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs @@ -1,10 +1,10 @@ {{user-info user=item.user}} {{#each columns as |column|}} - {{#if column.automatic}} - {{directory-item-value item=item column=column}} - {{else}} + {{#if (directory-column-is-user-field column=column)}} {{directory-item-user-field-value item=item column=column}} + {{else}} + {{directory-item-value item=item column=column}} {{/if}} {{/each}} diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs index 1aafa640cc..a646d794db 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs @@ -7,6 +7,7 @@ icon=column.icon order=order asc=asc + automatic=(directory-column-is-automatic column=column) translated=column.user_field_id onActiveRender=setActiveHeader }} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs index e43ad11e26..3f75ed7278 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs @@ -1,7 +1,7 @@ {{user-info user=item.user}} {{#each columns as |column|}} - {{#if column.automatic}} + {{#if (directory-column-is-automatic column=column)}}
    {{directory-item-value item=item column=column}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs index fb3e86465e..7fe81183a0 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs @@ -8,10 +8,12 @@
    diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index b312f02e25..b8068a5b6f 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -944,7 +944,7 @@ export function applyDefaultHandlers(pretender) { { id: 1, name: "likes_received", - automatic: true, + type: "automatic", enabled: true, automatic_position: 1, position: 1, @@ -954,7 +954,7 @@ export function applyDefaultHandlers(pretender) { { id: 2, name: "likes_given", - automatic: true, + type: "automatic", enabled: true, automatic_position: 2, position: 2, @@ -964,7 +964,7 @@ export function applyDefaultHandlers(pretender) { { id: 3, name: "topic_count", - automatic: true, + type: "automatic", enabled: true, automatic_position: 3, position: 3, @@ -974,7 +974,7 @@ export function applyDefaultHandlers(pretender) { { id: 4, name: "post_count", - automatic: true, + type: "automatic", enabled: true, automatic_position: 4, position: 4, @@ -984,7 +984,7 @@ export function applyDefaultHandlers(pretender) { { id: 5, name: "topics_entered", - automatic: true, + type: "automatic", enabled: true, automatic_position: 5, position: 5, @@ -994,7 +994,7 @@ export function applyDefaultHandlers(pretender) { { id: 6, name: "posts_read", - automatic: true, + type: "automatic", enabled: true, automatic_position: 6, position: 6, @@ -1004,7 +1004,7 @@ export function applyDefaultHandlers(pretender) { { id: 7, name: "days_visited", - automatic: true, + type: "automatic", enabled: true, automatic_position: 7, position: 7, @@ -1014,7 +1014,7 @@ export function applyDefaultHandlers(pretender) { { id: 9, name: null, - automatic: false, + type: "user_field", enabled: false, automatic_position: null, position: 8, diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index f51ff1cfda..4196a2d3e9 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -232,7 +232,7 @@ function setupTestsCommon(application, container, config) { PreloadStore.store( "directoryColumns", JSON.parse( - '[{"name":"likes_given","automatic":true,"icon":"heart","user_field_id":null},{"name":"posts_read","automatic":true,"icon":null,"user_field_id":null},{"name":"likes_received","automatic":true,"icon":"heart","user_field_id":null},{"name":"topic_count","automatic":true,"icon":null,"user_field_id":null},{"name":"post_count","automatic":true,"icon":null,"user_field_id":null},{"name":"topics_entered","automatic":true,"icon":null,"user_field_id":null},{"name":"days_visited","automatic":true,"icon":null,"user_field_id":null},{"name":"Favorite Color","automatic":false,"icon":null,"user_field_id":3}]' + '[{"name":"likes_given","type":"automatic","icon":"heart","user_field_id":null},{"name":"posts_read","type":"automatic","icon":null,"user_field_id":null},{"name":"likes_received","type":"automatic","icon":"heart","user_field_id":null},{"name":"topic_count","type":"automatic","icon":null,"user_field_id":null},{"name":"post_count","type":"automatic","icon":null,"user_field_id":null},{"name":"topics_entered","type":"automatic","icon":null,"user_field_id":null},{"name":"days_visited","type":"automatic","icon":null,"user_field_id":null},{"name":"Favorite Color","type":"user_field","icon":null,"user_field_id":3}]' ) ); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7ec3091869..c75e3f0fba 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -618,16 +618,18 @@ class ApplicationController < ActionController::Base end def directory_columns_json + types = DirectoryColumn.types DirectoryColumn .left_joins(:user_field) .where(enabled: true) .order(:position) - .pluck('directory_columns.name', - 'directory_columns.automatic', + .pluck('directory_columns.id', + 'directory_columns.name', + 'directory_columns.type', 'directory_columns.icon', 'user_fields.id', 'user_fields.name') - .map { |column| { name: column[0] || column[4], automatic: column[1], icon: column[2], user_field_id: column[3] } } + .map { |column| { id: column[0], name: column[1] || column[5], type: types.key(column[2]), icon: column[3], user_field_id: column[4] } } .to_json end diff --git a/app/controllers/directory_columns_controller.rb b/app/controllers/directory_columns_controller.rb index 2efdcd6dd4..6671aa0da3 100644 --- a/app/controllers/directory_columns_controller.rb +++ b/app/controllers/directory_columns_controller.rb @@ -50,7 +50,7 @@ class DirectoryColumnsController < ApplicationController new_directory_column_attrs.push({ user_field_id: user_field.id, enabled: false, - automatic: false, + type: DirectoryColumn.types[:user_field], position: next_position }) diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index 8f7314f75e..b8ec391b9b 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -26,13 +26,14 @@ class DirectoryItemsController < ApplicationController result = result.references(:user).where.not(users: { username: params[:exclude_usernames].split(",") }) end - order = params[:order] || DirectoryItem.headings.first + order = params[:order] || DirectoryColumn.automatic_column_names.first dir = params[:asc] ? 'ASC' : 'DESC' - if DirectoryItem.headings.include?(order.to_sym) + if DirectoryColumn.active_column_names.include?(order.to_sym) result = result.order("directory_items.#{order} #{dir}, directory_items.id") elsif params[:order] === 'username' result = result.order("users.#{order} #{dir}, directory_items.id") else + # Ordering by user field value user_field = UserField.find_by(name: params[:order]) if user_field result = result @@ -98,6 +99,10 @@ class DirectoryItemsController < ApplicationController serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i) end + if params[:plugin_column_ids] + serializer_opts[:plugin_column_ids] = params[:plugin_column_ids]&.split("|")&.map(&:to_i) + end + serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts) render_json_dump(directory_items: serialized, meta: { diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb index 4a3bc3546e..cdcfe92d48 100644 --- a/app/models/directory_column.rb +++ b/app/models/directory_column.rb @@ -1,5 +1,33 @@ # frozen_string_literal: true class DirectoryColumn < ActiveRecord::Base + self.inheritance_column = nil + + def self.automatic_column_names + @automatic_column_names ||= [:likes_received, + :likes_given, + :topics_entered, + :topic_count, + :post_count, + :posts_read, + :days_visited] + end + + def self.active_column_names + DirectoryColumn.where(type: [:automatic, :plugin]).where(enabled: true).pluck(:name).map(&:to_sym) + end + + @@plugin_directory_columns = [] + + enum type: { automatic: 0, user_field: 1, plugin: 2 } + belongs_to :user_field + + def self.add_plugin_directory_column(name) + @@plugin_directory_columns << name + end + + def self.plugin_directory_columns + @@plugin_directory_columns + end end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 930c782929..3c78dcb28c 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -4,15 +4,7 @@ class DirectoryItem < ActiveRecord::Base belongs_to :user has_one :user_stat, foreign_key: :user_id, primary_key: :user_id - def self.headings - @headings ||= [:likes_received, - :likes_given, - :topics_entered, - :topic_count, - :post_count, - :posts_read, - :days_visited] - end + @@plugin_queries = [] def self.period_types @types ||= Enum.new(all: 1, @@ -34,6 +26,14 @@ class DirectoryItem < ActiveRecord::Base Time.zone.at(val.to_i) end + def self.add_plugin_query(details) + @@plugin_queries << details + end + + def self.plugin_queries + @@plugin_queries + end + def self.refresh_period!(period_type, force: false) Discourse.redis.set("directory_#{period_types[period_type]}", Time.zone.now.to_i) @@ -53,30 +53,26 @@ class DirectoryItem < ActiveRecord::Base ActiveRecord::Base.transaction do # Delete records that belonged to users who have been deleted - DB.exec "DELETE FROM directory_items + DB.exec("DELETE FROM directory_items USING directory_items di LEFT JOIN users u ON (u.id = user_id AND u.active AND u.silenced_till IS NULL AND u.id > 0) WHERE di.id = directory_items.id AND u.id IS NULL AND - di.period_type = :period_type", period_type: period_types[period_type] + di.period_type = :period_type", period_type: period_types[period_type]) # Create new records for users who don't have one yet - DB.exec "INSERT INTO directory_items(period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) + + column_names = DirectoryColumn.automatic_column_names + DirectoryColumn.plugin_directory_columns + DB.exec("INSERT INTO directory_items(period_type, user_id, #{column_names.map(&:to_s).join(", ")}) SELECT :period_type, u.id, - 0, - 0, - 0, - 0, - 0, - 0, - 0 + #{Array.new(column_names.count) { |_| 0 }.join(", ") } FROM users u LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active #{SiteSetting.must_approve_users ? 'AND u.approved' : ''} - ", period_type: period_types[period_type] + ", period_type: period_types[period_type]) # Calculate new values and update records # @@ -84,7 +80,18 @@ class DirectoryItem < ActiveRecord::Base # TODO # WARNING: post_count is a wrong name, it should be reply_count (excluding topic post) # - DB.exec "WITH x AS (SELECT + # + query_args = { + period_type: period_types[period_type], + since: since, + like_type: UserAction::LIKE, + was_liked_type: UserAction::WAS_LIKED, + new_topic_type: UserAction::NEW_TOPIC, + reply_type: UserAction::REPLY, + regular_post_type: Post.types[:regular] + } + + DB.exec("WITH x AS (SELECT u.id user_id, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given, @@ -123,14 +130,13 @@ class DirectoryItem < ActiveRecord::Base di.topic_count <> x.topic_count OR di.post_count <> x.post_count ) - ", - period_type: period_types[period_type], - since: since, - like_type: UserAction::LIKE, - was_liked_type: UserAction::WAS_LIKED, - new_topic_type: UserAction::NEW_TOPIC, - reply_type: UserAction::REPLY, - regular_post_type: Post.types[:regular] + ", + query_args + ) + + plugin_queries.each do |plugin_query| + DB.exec(plugin_query, query_args) + end if period_type == :all DB.exec <<~SQL diff --git a/app/serializers/directory_column_serializer.rb b/app/serializers/directory_column_serializer.rb index 18e18ba67b..93fe4be947 100644 --- a/app/serializers/directory_column_serializer.rb +++ b/app/serializers/directory_column_serializer.rb @@ -3,7 +3,7 @@ class DirectoryColumnSerializer < ApplicationSerializer attributes :id, :name, - :automatic, + :type, :enabled, :automatic_position, :position, diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index 02a15ae3f4..1e18f84c80 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -20,7 +20,7 @@ class DirectoryItemSerializer < ApplicationSerializer :time_read has_one :user, embed: :objects, serializer: UserSerializer - attributes *DirectoryItem.headings + attributes *DirectoryColumn.active_column_names def id object.user_id diff --git a/db/migrate/20210609133551_add_type_to_directory_columns.rb b/db/migrate/20210609133551_add_type_to_directory_columns.rb new file mode 100644 index 0000000000..e6183b9f9b --- /dev/null +++ b/db/migrate/20210609133551_add_type_to_directory_columns.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddTypeToDirectoryColumns < ActiveRecord::Migration[6.1] + def up + add_column :directory_columns, :type, :integer, default: 0, null: false + + DB.exec( + <<~SQL + UPDATE directory_columns + SET type = CASE WHEN automatic THEN 0 ELSE 1 END; + SQL + ) + end + + def down + remove_column :directory_columns, :type, :integer, default: 0, null: false + end +end diff --git a/db/post_migrate/20210609152431_remove_directory_column_automatic.rb b/db/post_migrate/20210609152431_remove_directory_column_automatic.rb new file mode 100644 index 0000000000..1d362b95f7 --- /dev/null +++ b/db/post_migrate/20210609152431_remove_directory_column_automatic.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveDirectoryColumnAutomatic < ActiveRecord::Migration[6.1] + def up + remove_column :directory_columns, :automatic + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 2a92c6d01d..9a27224568 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -373,6 +373,17 @@ class Plugin::Instance assets end + def add_directory_column(column_name, query:, icon: nil) + validate_directory_column_name(column_name) + directory_column = DirectoryColumn + .find_or_create_by(name: column_name, icon: icon, type: DirectoryColumn.types[:plugin]) do |column| + column.position = DirectoryColumn.maximum("position") + 1 + column.enabled = false + end + DirectoryColumn.add_plugin_directory_column(column_name) + DirectoryItem.add_plugin_query(query) + end + def delete_extra_automatic_assets(good_paths) return unless Dir.exists? auto_generated_path @@ -964,6 +975,11 @@ class Plugin::Instance private + def validate_directory_column_name(column_name) + match = /^[_a-z]+$/.match(column_name) + raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores" unless match + end + def write_asset(path, contents) unless File.exists?(path) ensure_directory(path) diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index e9de28e83d..4f2880eeda 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -600,4 +600,28 @@ describe Plugin::Instance do expect(ApiKeyScope.scope_mappings.dig(:groups, :create, :actions)).to contain_exactly(*actions) end end + + describe '#add_directory_column' do + let!(:plugin) { Plugin::Instance.new } + + it 'creates a directory column record' do + plugin.add_directory_column('random_c', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + + expect(DirectoryColumn.find_by(name: 'random_c', icon: 'recycle', enabled: false).present?).to be(true) + end + + it 'errors when the column_name contains invalid characters' do + expect { + plugin.add_directory_column('Capital', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + + expect { + plugin.add_directory_column('has space', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + + expect { + plugin.add_directory_column('has_number_1', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + end + end end diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb index 828795859c..9084ebf472 100644 --- a/spec/requests/admin/user_fields_controller_spec.rb +++ b/spec/requests/admin/user_fields_controller_spec.rb @@ -130,7 +130,7 @@ describe Admin::UserFieldsController do DirectoryColumn.create( user_field_id: user_field.id, enabled: false, - automatic: false, + type: DirectoryColumn.types[:user_field], position: next_position ) expect { From 3b87271647aebb7e2ade427a9fe5cda5d74f35c6 Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Thu, 17 Jun 2021 19:24:06 +0400 Subject: [PATCH 079/403] FEATURE: Open the edit bookmark modal when clicking on the topic level bookmark button (#13407) If you click on a bookmark in the post stream you get an Edit Bookmark modal. This does not happen if you click the topic bookmark button. We want to open the Edit modal too if there is only one bookmark on a topic (it doesn't matter on the first post or not). The other behaviour if there are > 1 bookmarks in the topic is to prompt the user to confirm delete of all the bookmarks in the topic. This behaviour will stay as-is. I have done some refactoring in this PR, and still, there is a place for improvement. For example, we don't call post.deleteBookmark() method when deleting several bookmarks. I just don't want to refactor too much in one PR. --- .../discourse/app/controllers/topic.js | 56 +++++++------------ .../app/initializers/topic-footer-buttons.js | 13 ++++- .../javascripts/discourse/app/models/post.js | 2 + .../javascripts/discourse/app/models/topic.js | 14 +++-- .../tests/acceptance/bookmarks-test.js | 39 +++++++++++++ config/locales/client.en.yml | 1 + 6 files changed, 81 insertions(+), 44 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index f9b2b93560..f8427f5347 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -1219,57 +1219,43 @@ export default Controller.extend(bufferedProperty("model"), { return Promise.resolve(); } this.model.set("bookmarking", true); - const bookmark = !this.model.bookmarked; - let posts = this.model.postStream.posts; + const alreadyBookmarkedPosts = this.model.bookmarkedPosts; return this.model.firstPost().then((firstPost) => { + const bookmarkPost = async (post) => { + const opts = await this._togglePostBookmark(post); + this.model.set("bookmarking", false); + if (opts.closedWithoutSaving) { + return; + } + this.model.afterPostBookmarked(post); + return [post.id]; + }; + const toggleBookmarkOnServer = () => { - if (bookmark) { - return this._togglePostBookmark(firstPost).then((opts) => { - this.model.set("bookmarking", false); - if (opts && opts.closedWithoutSaving) { - return; - } - return this.model.afterTopicBookmarked(firstPost); - }); + if (alreadyBookmarkedPosts.length === 0) { + return bookmarkPost(firstPost); + } else if (alreadyBookmarkedPosts.length === 1) { + const post = alreadyBookmarkedPosts[0]; + return bookmarkPost(post); } else { return this.model .deleteBookmark() .then(() => { this.model.toggleProperty("bookmarked"); this.model.set("bookmark_reminder_at", null); - let clearedBookmarkProps = { - bookmarked: false, - bookmark_id: null, - bookmark_name: null, - bookmark_reminder_at: null, - }; - if (posts) { - const updated = []; - posts.forEach((post) => { - if (post.bookmarked) { - post.setProperties(clearedBookmarkProps); - updated.push(post.id); - } - }); - firstPost.setProperties(clearedBookmarkProps); - return updated; - } + alreadyBookmarkedPosts.forEach((post) => { + post.clearBookmark(); + }); + return alreadyBookmarkedPosts.mapBy("id"); }) .catch(popupAjaxError) .finally(() => this.model.set("bookmarking", false)); } }; - const unbookmarkedPosts = []; - if (!bookmark && posts) { - posts.forEach( - (post) => post.bookmarked && unbookmarkedPosts.push(post) - ); - } - return new Promise((resolve) => { - if (unbookmarkedPosts.length > 1) { + if (alreadyBookmarkedPosts.length > 1) { bootbox.confirm( I18n.t("bookmarks.confirm_clear"), I18n.t("no_value"), diff --git a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js index 6cbc34c546..d2c365b18a 100644 --- a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js +++ b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js @@ -66,7 +66,7 @@ export default { }); registerTopicFooterButton({ - dependentKeys: ["topic.bookmarked"], + dependentKeys: ["topic.bookmarked", "topic.bookmarkedPosts"], id: "bookmark", icon() { if (this.get("topic.bookmark_reminder_at")) { @@ -81,8 +81,15 @@ export default { }, label() { if (!this.get("topic.isPrivateMessage") || this.site.mobileView) { - const bookmarked = this.get("topic.bookmarked"); - return bookmarked ? "bookmarked.clear_bookmarks" : "bookmarked.title"; + const bookmarkedPostsCount = this.get("topic.bookmarkedPosts").length; + + if (bookmarkedPostsCount === 0) { + return "bookmarked.title"; + } else if (bookmarkedPostsCount === 1) { + return "bookmarked.edit_bookmark"; + } else { + return "bookmarked.clear_bookmarks"; + } } }, translatedTitle() { diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 97642f069f..8a936b5596 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -314,6 +314,7 @@ const Post = RestModel.extend({ bookmark_name: data.name, bookmark_id: data.id, }); + this.topic.incrementProperty("bookmarksWereChanged"); this.appEvents.trigger("page:bookmark-post-toggled", this); this.appEvents.trigger("post-stream:refresh", { id: this.id }); }, @@ -333,6 +334,7 @@ const Post = RestModel.extend({ bookmarked: false, bookmark_auto_delete_preference: null, }); + this.topic.incrementProperty("bookmarksWereChanged"); }, updateActionsSummary(json) { diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index deef4b89f4..d52f9e16ea 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -322,6 +322,11 @@ const Topic = RestModel.extend({ return Site.currentProp("archetypes").findBy("id", archetype); }, + @discourseComputed("bookmarksWereChanged") + bookmarkedPosts() { + return this.postStream.posts.filterBy("bookmarked", true); + }, + isPrivateMessage: equal("archetype", "private_message"), isBanner: equal("archetype", "banner"), @@ -356,12 +361,9 @@ const Topic = RestModel.extend({ }).then(() => this.set("archetype", "regular")); }, - afterTopicBookmarked(firstPost) { - if (firstPost) { - firstPost.set("bookmarked", true); - this.set("bookmark_reminder_at", firstPost.bookmark_reminder_at); - return [firstPost.id]; - } + afterPostBookmarked(post) { + post.set("bookmarked", true); + this.set("bookmark_reminder_at", post.bookmark_reminder_at); }, firstPost() { diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js index f2e0dfce50..e0dc6dea96 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js @@ -2,6 +2,7 @@ import { acceptance, exists, loggedInUser, + query, queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { click, fillIn, visit } from "@ember/test-helpers"; @@ -315,4 +316,42 @@ acceptance("Bookmarking", function (needs) { "the second bookmark is deleted" ); }); + + test("The topic level bookmark button opens the edit modal if only the first post on the topic is bookmarked", async function (assert) { + await visit("/t/internationalization-localization/280"); + await openBookmarkModal(1); + await click("#save-bookmark"); + + assert.equal( + query("#topic-footer-button-bookmark").innerText, + I18n.t("bookmarked.edit_bookmark"), + "A topic level bookmark button has a label 'Edit Bookmark'" + ); + + await click("#topic-footer-button-bookmark"); + + assert.ok( + exists("div.modal.bookmark-with-reminder"), + "The edit modal is opened" + ); + }); + + test("The topic level bookmark button opens the edit modal if only one post in the post stream is bookmarked", async function (assert) { + await visit("/t/internationalization-localization/280"); + await openBookmarkModal(2); + await click("#save-bookmark"); + + assert.equal( + query("#topic-footer-button-bookmark").innerText, + I18n.t("bookmarked.edit_bookmark"), + "A topic level bookmark button has a label 'Edit Bookmark'" + ); + + await click("#topic-footer-button-bookmark"); + + assert.ok( + exists("div.modal.bookmark-with-reminder"), + "The edit modal is opened" + ); + }); }); diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 3aa726602f..dbfc5cc67a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -297,6 +297,7 @@ en: bookmarked: title: "Bookmark" + edit_bookmark: "Edit Bookmark" clear_bookmarks: "Clear Bookmarks" help: bookmark: "Click to bookmark the first post on this topic" From 854d9656799437ee2f998b9f0bd9e8703226d6aa Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 17 Jun 2021 11:08:18 -0500 Subject: [PATCH 080/403] FIX: translations of table headers in group members directory --- .../javascripts/discourse/app/templates/group-index.hbs | 8 ++++---- .../discourse/app/templates/group-requests.hbs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index d5a7770478..abe17a0f2c 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -35,11 +35,11 @@ {{d-button action=(action "bulkClearAll") label="topics.bulk.clear_all"}} {{/if}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username"}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username" automatic=true}} - {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added"}} - {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post"}} - {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen"}} + {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added" automatic=true}} + {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post" automatic=true}} + {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen" automatic=true}} {{#if isBulk}} {{group-member-dropdown diff --git a/app/assets/javascripts/discourse/app/templates/group-requests.hbs b/app/assets/javascripts/discourse/app/templates/group-requests.hbs index 017aaaef08..b2f8d03ddf 100644 --- a/app/assets/javascripts/discourse/app/templates/group-requests.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-requests.hbs @@ -12,8 +12,8 @@ {{#load-more selector=".group-members tr" action=(action "loadMore")}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username"}} - {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested"}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" automatic=true}} + {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested" automatic=true}} From 6fd13f38a2ec3597aeb269643fe51277b52a5fd5 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 17 Jun 2021 11:50:47 -0500 Subject: [PATCH 081/403] DEV: reset plugin added directory columns in testing (#13420) --- app/models/directory_column.rb | 4 ++++ app/models/directory_item.rb | 4 ++++ spec/components/plugin/instance_spec.rb | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb index cdcfe92d48..9a873aff78 100644 --- a/app/models/directory_column.rb +++ b/app/models/directory_column.rb @@ -30,4 +30,8 @@ class DirectoryColumn < ActiveRecord::Base def self.plugin_directory_columns @@plugin_directory_columns end + + def self.clear_plugin_directory_columns + @@plugin_directory_columns = [] + end end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 3c78dcb28c..c17c83cd82 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -34,6 +34,10 @@ class DirectoryItem < ActiveRecord::Base @@plugin_queries end + def self.clear_plugin_queries + @@plugin_queries = [] + end + def self.refresh_period!(period_type, force: false) Discourse.redis.set("directory_#{period_types[period_type]}", Time.zone.now.to_i) diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index 4f2880eeda..50d7d92b3c 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -604,6 +604,11 @@ describe Plugin::Instance do describe '#add_directory_column' do let!(:plugin) { Plugin::Instance.new } + after do + DirectoryItem.clear_plugin_queries + DirectoryColumn.clear_plugin_directory_columns + end + it 'creates a directory column record' do plugin.add_directory_column('random_c', query: "SELECT COUNT(*) FROM users", icon: 'recycle') From c47f55253fd93fa2617069a20628ac6489cda1f1 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Thu, 17 Jun 2021 20:09:29 +0300 Subject: [PATCH 082/403] DEV: Add optional theme test step to the `smoke:test` rake task (#13418) The purpose of this is to allow us to catch regressions for a feature we've built recently that allows theme tests to run in production. We recently had a regression that we didn't notice for days, so to prevent that from happening again we'll use this in our internal CI pipelines. --- lib/tasks/smoke_test.rake | 29 +++++++++++++++++++++++++++++ test/run-qunit.js | 36 ++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/lib/tasks/smoke_test.rake b/lib/tasks/smoke_test.rake index d3cab6a08c..5535d6b2a0 100644 --- a/lib/tasks/smoke_test.rake +++ b/lib/tasks/smoke_test.rake @@ -81,4 +81,33 @@ task "smoke:test" do if results !~ /ALL PASSED/ raise "FAILED" end + + api_key = ENV["ADMIN_API_KEY"] + api_username = ENV["ADMIN_API_USERNAME"] + theme_url = ENV["SMOKE_TEST_THEME_URL"] + + next if api_key.blank? && api_username.blank? && theme_url.blank? + + puts "Running QUnit tests for theme #{theme_url.inspect} using API key #{api_key[0..3]}… and username #{api_username.inspect}" + + query_params = { + seed: Random.new.seed, + theme_url: theme_url, + hidepassed: 1, + report_requests: 1 + } + url += '/' if !url.end_with?('/') + full_url = "#{url}theme-qunit?#{query_params.to_query}" + timeout = 1000 * 60 * 10 + + sh( + "node", + "#{Rails.root}/test/run-qunit.js", + full_url, + timeout.to_s + ) + + if !$?.success? + raise "THEME TESTS FAILED!" + end end diff --git a/test/run-qunit.js b/test/run-qunit.js index f55a9d70b0..584e3f608c 100644 --- a/test/run-qunit.js +++ b/test/run-qunit.js @@ -76,9 +76,15 @@ async function runAllTests() { } const { Inspector, Page, Runtime, Log } = protocol; + // eslint-disable-next-line + await Promise.all([ + Inspector.enable(), + Page.enable(), + Runtime.enable(), + Log.enable(), + ]); // Documentation https://chromedevtools.github.io/devtools-protocol/tot/Log/#type-LogEntry - Log.enable(); Log.entryAdded(({ entry }) => { let message = `${new Date(entry.timestamp).toISOString()} - (type: ${ entry.source @@ -89,9 +95,6 @@ async function runAllTests() { console.log(message); }); - // eslint-disable-next-line - await Promise.all([Inspector.enable(), Page.enable(), Runtime.enable()]); - Inspector.targetCrashed((entry) => { console.log("Chrome target crashed:"); console.log(entry); @@ -119,6 +122,31 @@ async function runAllTests() { }); let url = args[0] + "&qunit_disable_auto_start=1"; + + const apiKey = process.env.ADMIN_API_KEY; + const apiUsername = process.env.ADMIN_API_USERNAME; + if (apiKey && apiUsername) { + const { Fetch } = protocol; + await Fetch.enable(); + const urlObj = new URL(url); + Fetch.requestPaused((data) => { + const requestURL = new URL(data.request.url); + if (requestURL.hostname != urlObj.hostname) { + Fetch.continueRequest({ + requestId: data.requestId, + }); + return; + } + Fetch.continueRequest({ + requestId: data.requestId, + headers: [ + { name: "Api-Key", value: apiKey }, + { name: "Api-Username", value: apiUsername }, + ], + }); + }); + } + console.log("navigate to ", url); Page.navigate({ url }); From 95b51669ad35b1d51f08a3c2dad1d2b3a474da27 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 17 Jun 2021 12:37:37 -0500 Subject: [PATCH 083/403] DEV: Revert 3 commits for plugin API to add directory columns (#13423) --- .../app/components/table-header-toggle.js | 4 -- .../edit-user-directory-columns.js | 2 +- .../discourse/app/controllers/users.js | 19 ++---- .../app/helpers/directory-item-helpers.js | 37 ---------- .../app/helpers/directory-item-label.js | 10 +++ .../directory-item-user-field-value.js | 16 +++++ .../app/helpers/directory-item-value.js | 11 +++ .../templates/components/directory-item.hbs | 6 +- .../templates/components/directory-table.hbs | 1 - .../discourse/app/templates/group-index.hbs | 8 +-- .../app/templates/group-requests.hbs | 4 +- .../mobile/components/directory-item.hbs | 2 +- .../modal/edit-user-directory-columns.hbs | 6 +- .../tests/helpers/create-pretender.js | 16 ++--- .../discourse/tests/setup-tests.js | 2 +- app/controllers/application_controller.rb | 8 +-- .../directory_columns_controller.rb | 2 +- app/controllers/directory_items_controller.rb | 9 +-- app/models/directory_column.rb | 32 --------- app/models/directory_item.rb | 68 ++++++++----------- .../directory_column_serializer.rb | 2 +- app/serializers/directory_item_serializer.rb | 2 +- ...609133551_add_type_to_directory_columns.rb | 18 ----- ...52431_remove_directory_column_automatic.rb | 11 --- lib/plugin/instance.rb | 16 ----- spec/components/plugin/instance_spec.rb | 29 -------- .../admin/user_fields_controller_spec.rb | 2 +- 27 files changed, 102 insertions(+), 241 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-label.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-value.js delete mode 100644 db/migrate/20210609133551_add_type_to_directory_columns.rb delete mode 100644 db/post_migrate/20210609152431_remove_directory_column_automatic.rb diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js index 424879f546..afe7ef32eb 100644 --- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js +++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js @@ -9,7 +9,6 @@ export default Component.extend({ chevronIcon: null, columnIcon: null, translated: false, - automatic: false, onActiveRender: null, toggleProperties() { @@ -32,9 +31,6 @@ export default Component.extend({ }, didReceiveAttrs() { this._super(...arguments); - if (!this.automatic && !this.translated) { - this.set("labelKey", this.field); - } this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`); this.toggleChevron(); }, diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js index 21f2dd8a6d..a7fd826af8 100644 --- a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js +++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js @@ -58,7 +58,7 @@ export default Controller.extend(ModalFunctionality, { .forEach((column, index) => { column.setProperties({ position: column.automatic_position || index + 1, - enabled: column.type === "automatic", + enabled: column.automatic, }); }); this.set("columns", resetColumns); diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js index 1846b4eea3..e2878823ae 100644 --- a/app/assets/javascripts/discourse/app/controllers/users.js +++ b/app/assets/javascripts/discourse/app/controllers/users.js @@ -28,22 +28,13 @@ export default Controller.extend({ this.set("nameInput", params.name); this.set("order", params.order); - const userFieldColumns = this.columns.filter( - (c) => c.type === "user_field" - ); - const userFieldIds = userFieldColumns.map((c) => c.user_field_id).join("|"); - - const pluginColumns = this.columns.filter((c) => c.type === "plugin"); - const pluginColumnIds = pluginColumns.map((c) => c.id).join("|"); + const custom_field_columns = this.columns.filter((c) => !c.automatic); + const user_field_ids = custom_field_columns + .map((c) => c.user_field_id) + .join("|"); return this.store - .find( - "directoryItem", - Object.assign(params, { - user_field_ids: userFieldIds, - plugin_column_ids: pluginColumnIds, - }) - ) + .find("directoryItem", Object.assign(params, { user_field_ids })) .then((model) => { const lastUpdatedAt = model.get("resultSetMeta.last_updated_at"); this.setProperties({ diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js deleted file mode 100644 index 1007a506b7..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js +++ /dev/null @@ -1,37 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { number } from "discourse/lib/formatter"; -import { registerUnbound } from "discourse-common/lib/helpers"; -import I18n from "I18n"; - -registerUnbound("mobile-directory-item-label", function (args) { - // Args should include key/values { item, column } - const count = args.item.get(args.column.name); - return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); -}); - -registerUnbound("directory-item-value", function (args) { - // Args should include key/values { item, column } - return htmlSafe( - `${number(args.item.get(args.column.name))}` - ); -}); - -registerUnbound("directory-item-user-field-value", function (args) { - // Args should include key/values { item, column } - const value = - args.item.user && args.item.user.user_fields - ? args.item.user.user_fields[args.column.user_field_id] - : null; - const content = value || "-"; - return htmlSafe(`${content}`); -}); - -registerUnbound("directory-column-is-automatic", function (args) { - // Args should include key/values { column } - return args.column.type === "automatic"; -}); - -registerUnbound("directory-column-is-user-field", function (args) { - // Args should include key/values { column } - return args.column.type === "user_field"; -}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js new file mode 100644 index 0000000000..56723ee716 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js @@ -0,0 +1,10 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import I18n from "I18n"; + +export default registerUnbound("mobile-directory-item-label", function (args) { + // Args should include key/values { item, column } + + const count = args.item.get(args.column.name); + return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); +}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js new file mode 100644 index 0000000000..aeab4bcbe1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js @@ -0,0 +1,16 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; + +export default registerUnbound( + "directory-item-user-field-value", + function (args) { + // Args should include key/values { item, column } + + const value = + args.item.user && args.item.user.user_fields + ? args.item.user.user_fields[args.column.user_field_id] + : null; + const content = value || "-"; + return htmlSafe(`${content}`); + } +); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js new file mode 100644 index 0000000000..a3c6e3d6d3 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js @@ -0,0 +1,11 @@ +import { htmlSafe } from "@ember/template"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import { number } from "discourse/lib/formatter"; + +export default registerUnbound("directory-item-value", function (args) { + // Args should include key/values { item, column } + + return htmlSafe( + `${number(args.item.get(args.column.name))}` + ); +}); diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs index b1b083beda..0d4fece411 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs @@ -1,10 +1,10 @@ {{#each columns as |column|}} {{/each}} diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs index a646d794db..1aafa640cc 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs @@ -7,7 +7,6 @@ icon=column.icon order=order asc=asc - automatic=(directory-column-is-automatic column=column) translated=column.user_field_id onActiveRender=setActiveHeader }} diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index abe17a0f2c..d5a7770478 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -35,11 +35,11 @@ {{d-button action=(action "bulkClearAll") label="topics.bulk.clear_all"}} {{/if}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username" automatic=true}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username"}} - {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added" automatic=true}} - {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post" automatic=true}} - {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen" automatic=true}} + {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added"}} + {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post"}} + {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen"}}
    {{i18n "groups.requests.reason"}} {{user-info user=item.user}} - {{#if (directory-column-is-user-field column=column)}} - {{directory-item-user-field-value item=item column=column}} - {{else}} + {{#if column.automatic}} {{directory-item-value item=item column=column}} + {{else}} + {{directory-item-user-field-value item=item column=column}} {{/if}} {{#if isBulk}} {{group-member-dropdown diff --git a/app/assets/javascripts/discourse/app/templates/group-requests.hbs b/app/assets/javascripts/discourse/app/templates/group-requests.hbs index b2f8d03ddf..017aaaef08 100644 --- a/app/assets/javascripts/discourse/app/templates/group-requests.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-requests.hbs @@ -12,8 +12,8 @@ {{#load-more selector=".group-members tr" action=(action "loadMore")}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" automatic=true}} - {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested" automatic=true}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username"}} + {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested"}} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs index 3f75ed7278..e43ad11e26 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs @@ -1,7 +1,7 @@ {{user-info user=item.user}} {{#each columns as |column|}} - {{#if (directory-column-is-automatic column=column)}} + {{#if column.automatic}}
    {{directory-item-value item=item column=column}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs index 7fe81183a0..fb3e86465e 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs @@ -8,12 +8,10 @@
    diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index b8068a5b6f..b312f02e25 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -944,7 +944,7 @@ export function applyDefaultHandlers(pretender) { { id: 1, name: "likes_received", - type: "automatic", + automatic: true, enabled: true, automatic_position: 1, position: 1, @@ -954,7 +954,7 @@ export function applyDefaultHandlers(pretender) { { id: 2, name: "likes_given", - type: "automatic", + automatic: true, enabled: true, automatic_position: 2, position: 2, @@ -964,7 +964,7 @@ export function applyDefaultHandlers(pretender) { { id: 3, name: "topic_count", - type: "automatic", + automatic: true, enabled: true, automatic_position: 3, position: 3, @@ -974,7 +974,7 @@ export function applyDefaultHandlers(pretender) { { id: 4, name: "post_count", - type: "automatic", + automatic: true, enabled: true, automatic_position: 4, position: 4, @@ -984,7 +984,7 @@ export function applyDefaultHandlers(pretender) { { id: 5, name: "topics_entered", - type: "automatic", + automatic: true, enabled: true, automatic_position: 5, position: 5, @@ -994,7 +994,7 @@ export function applyDefaultHandlers(pretender) { { id: 6, name: "posts_read", - type: "automatic", + automatic: true, enabled: true, automatic_position: 6, position: 6, @@ -1004,7 +1004,7 @@ export function applyDefaultHandlers(pretender) { { id: 7, name: "days_visited", - type: "automatic", + automatic: true, enabled: true, automatic_position: 7, position: 7, @@ -1014,7 +1014,7 @@ export function applyDefaultHandlers(pretender) { { id: 9, name: null, - type: "user_field", + automatic: false, enabled: false, automatic_position: null, position: 8, diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index 4196a2d3e9..f51ff1cfda 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -232,7 +232,7 @@ function setupTestsCommon(application, container, config) { PreloadStore.store( "directoryColumns", JSON.parse( - '[{"name":"likes_given","type":"automatic","icon":"heart","user_field_id":null},{"name":"posts_read","type":"automatic","icon":null,"user_field_id":null},{"name":"likes_received","type":"automatic","icon":"heart","user_field_id":null},{"name":"topic_count","type":"automatic","icon":null,"user_field_id":null},{"name":"post_count","type":"automatic","icon":null,"user_field_id":null},{"name":"topics_entered","type":"automatic","icon":null,"user_field_id":null},{"name":"days_visited","type":"automatic","icon":null,"user_field_id":null},{"name":"Favorite Color","type":"user_field","icon":null,"user_field_id":3}]' + '[{"name":"likes_given","automatic":true,"icon":"heart","user_field_id":null},{"name":"posts_read","automatic":true,"icon":null,"user_field_id":null},{"name":"likes_received","automatic":true,"icon":"heart","user_field_id":null},{"name":"topic_count","automatic":true,"icon":null,"user_field_id":null},{"name":"post_count","automatic":true,"icon":null,"user_field_id":null},{"name":"topics_entered","automatic":true,"icon":null,"user_field_id":null},{"name":"days_visited","automatic":true,"icon":null,"user_field_id":null},{"name":"Favorite Color","automatic":false,"icon":null,"user_field_id":3}]' ) ); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c75e3f0fba..7ec3091869 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -618,18 +618,16 @@ class ApplicationController < ActionController::Base end def directory_columns_json - types = DirectoryColumn.types DirectoryColumn .left_joins(:user_field) .where(enabled: true) .order(:position) - .pluck('directory_columns.id', - 'directory_columns.name', - 'directory_columns.type', + .pluck('directory_columns.name', + 'directory_columns.automatic', 'directory_columns.icon', 'user_fields.id', 'user_fields.name') - .map { |column| { id: column[0], name: column[1] || column[5], type: types.key(column[2]), icon: column[3], user_field_id: column[4] } } + .map { |column| { name: column[0] || column[4], automatic: column[1], icon: column[2], user_field_id: column[3] } } .to_json end diff --git a/app/controllers/directory_columns_controller.rb b/app/controllers/directory_columns_controller.rb index 6671aa0da3..2efdcd6dd4 100644 --- a/app/controllers/directory_columns_controller.rb +++ b/app/controllers/directory_columns_controller.rb @@ -50,7 +50,7 @@ class DirectoryColumnsController < ApplicationController new_directory_column_attrs.push({ user_field_id: user_field.id, enabled: false, - type: DirectoryColumn.types[:user_field], + automatic: false, position: next_position }) diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index b8ec391b9b..8f7314f75e 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -26,14 +26,13 @@ class DirectoryItemsController < ApplicationController result = result.references(:user).where.not(users: { username: params[:exclude_usernames].split(",") }) end - order = params[:order] || DirectoryColumn.automatic_column_names.first + order = params[:order] || DirectoryItem.headings.first dir = params[:asc] ? 'ASC' : 'DESC' - if DirectoryColumn.active_column_names.include?(order.to_sym) + if DirectoryItem.headings.include?(order.to_sym) result = result.order("directory_items.#{order} #{dir}, directory_items.id") elsif params[:order] === 'username' result = result.order("users.#{order} #{dir}, directory_items.id") else - # Ordering by user field value user_field = UserField.find_by(name: params[:order]) if user_field result = result @@ -99,10 +98,6 @@ class DirectoryItemsController < ApplicationController serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i) end - if params[:plugin_column_ids] - serializer_opts[:plugin_column_ids] = params[:plugin_column_ids]&.split("|")&.map(&:to_i) - end - serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts) render_json_dump(directory_items: serialized, meta: { diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb index 9a873aff78..4a3bc3546e 100644 --- a/app/models/directory_column.rb +++ b/app/models/directory_column.rb @@ -1,37 +1,5 @@ # frozen_string_literal: true class DirectoryColumn < ActiveRecord::Base - self.inheritance_column = nil - - def self.automatic_column_names - @automatic_column_names ||= [:likes_received, - :likes_given, - :topics_entered, - :topic_count, - :post_count, - :posts_read, - :days_visited] - end - - def self.active_column_names - DirectoryColumn.where(type: [:automatic, :plugin]).where(enabled: true).pluck(:name).map(&:to_sym) - end - - @@plugin_directory_columns = [] - - enum type: { automatic: 0, user_field: 1, plugin: 2 } - belongs_to :user_field - - def self.add_plugin_directory_column(name) - @@plugin_directory_columns << name - end - - def self.plugin_directory_columns - @@plugin_directory_columns - end - - def self.clear_plugin_directory_columns - @@plugin_directory_columns = [] - end end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index c17c83cd82..930c782929 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -4,7 +4,15 @@ class DirectoryItem < ActiveRecord::Base belongs_to :user has_one :user_stat, foreign_key: :user_id, primary_key: :user_id - @@plugin_queries = [] + def self.headings + @headings ||= [:likes_received, + :likes_given, + :topics_entered, + :topic_count, + :post_count, + :posts_read, + :days_visited] + end def self.period_types @types ||= Enum.new(all: 1, @@ -26,18 +34,6 @@ class DirectoryItem < ActiveRecord::Base Time.zone.at(val.to_i) end - def self.add_plugin_query(details) - @@plugin_queries << details - end - - def self.plugin_queries - @@plugin_queries - end - - def self.clear_plugin_queries - @@plugin_queries = [] - end - def self.refresh_period!(period_type, force: false) Discourse.redis.set("directory_#{period_types[period_type]}", Time.zone.now.to_i) @@ -57,26 +53,30 @@ class DirectoryItem < ActiveRecord::Base ActiveRecord::Base.transaction do # Delete records that belonged to users who have been deleted - DB.exec("DELETE FROM directory_items + DB.exec "DELETE FROM directory_items USING directory_items di LEFT JOIN users u ON (u.id = user_id AND u.active AND u.silenced_till IS NULL AND u.id > 0) WHERE di.id = directory_items.id AND u.id IS NULL AND - di.period_type = :period_type", period_type: period_types[period_type]) + di.period_type = :period_type", period_type: period_types[period_type] # Create new records for users who don't have one yet - - column_names = DirectoryColumn.automatic_column_names + DirectoryColumn.plugin_directory_columns - DB.exec("INSERT INTO directory_items(period_type, user_id, #{column_names.map(&:to_s).join(", ")}) + DB.exec "INSERT INTO directory_items(period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) SELECT :period_type, u.id, - #{Array.new(column_names.count) { |_| 0 }.join(", ") } + 0, + 0, + 0, + 0, + 0, + 0, + 0 FROM users u LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active #{SiteSetting.must_approve_users ? 'AND u.approved' : ''} - ", period_type: period_types[period_type]) + ", period_type: period_types[period_type] # Calculate new values and update records # @@ -84,18 +84,7 @@ class DirectoryItem < ActiveRecord::Base # TODO # WARNING: post_count is a wrong name, it should be reply_count (excluding topic post) # - # - query_args = { - period_type: period_types[period_type], - since: since, - like_type: UserAction::LIKE, - was_liked_type: UserAction::WAS_LIKED, - new_topic_type: UserAction::NEW_TOPIC, - reply_type: UserAction::REPLY, - regular_post_type: Post.types[:regular] - } - - DB.exec("WITH x AS (SELECT + DB.exec "WITH x AS (SELECT u.id user_id, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given, @@ -134,13 +123,14 @@ class DirectoryItem < ActiveRecord::Base di.topic_count <> x.topic_count OR di.post_count <> x.post_count ) - ", - query_args - ) - - plugin_queries.each do |plugin_query| - DB.exec(plugin_query, query_args) - end + ", + period_type: period_types[period_type], + since: since, + like_type: UserAction::LIKE, + was_liked_type: UserAction::WAS_LIKED, + new_topic_type: UserAction::NEW_TOPIC, + reply_type: UserAction::REPLY, + regular_post_type: Post.types[:regular] if period_type == :all DB.exec <<~SQL diff --git a/app/serializers/directory_column_serializer.rb b/app/serializers/directory_column_serializer.rb index 93fe4be947..18e18ba67b 100644 --- a/app/serializers/directory_column_serializer.rb +++ b/app/serializers/directory_column_serializer.rb @@ -3,7 +3,7 @@ class DirectoryColumnSerializer < ApplicationSerializer attributes :id, :name, - :type, + :automatic, :enabled, :automatic_position, :position, diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index 1e18f84c80..02a15ae3f4 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -20,7 +20,7 @@ class DirectoryItemSerializer < ApplicationSerializer :time_read has_one :user, embed: :objects, serializer: UserSerializer - attributes *DirectoryColumn.active_column_names + attributes *DirectoryItem.headings def id object.user_id diff --git a/db/migrate/20210609133551_add_type_to_directory_columns.rb b/db/migrate/20210609133551_add_type_to_directory_columns.rb deleted file mode 100644 index e6183b9f9b..0000000000 --- a/db/migrate/20210609133551_add_type_to_directory_columns.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class AddTypeToDirectoryColumns < ActiveRecord::Migration[6.1] - def up - add_column :directory_columns, :type, :integer, default: 0, null: false - - DB.exec( - <<~SQL - UPDATE directory_columns - SET type = CASE WHEN automatic THEN 0 ELSE 1 END; - SQL - ) - end - - def down - remove_column :directory_columns, :type, :integer, default: 0, null: false - end -end diff --git a/db/post_migrate/20210609152431_remove_directory_column_automatic.rb b/db/post_migrate/20210609152431_remove_directory_column_automatic.rb deleted file mode 100644 index 1d362b95f7..0000000000 --- a/db/post_migrate/20210609152431_remove_directory_column_automatic.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class RemoveDirectoryColumnAutomatic < ActiveRecord::Migration[6.1] - def up - remove_column :directory_columns, :automatic - end - - def down - raise ActiveRecord::IrreversibleMigration - end -end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 9a27224568..2a92c6d01d 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -373,17 +373,6 @@ class Plugin::Instance assets end - def add_directory_column(column_name, query:, icon: nil) - validate_directory_column_name(column_name) - directory_column = DirectoryColumn - .find_or_create_by(name: column_name, icon: icon, type: DirectoryColumn.types[:plugin]) do |column| - column.position = DirectoryColumn.maximum("position") + 1 - column.enabled = false - end - DirectoryColumn.add_plugin_directory_column(column_name) - DirectoryItem.add_plugin_query(query) - end - def delete_extra_automatic_assets(good_paths) return unless Dir.exists? auto_generated_path @@ -975,11 +964,6 @@ class Plugin::Instance private - def validate_directory_column_name(column_name) - match = /^[_a-z]+$/.match(column_name) - raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores" unless match - end - def write_asset(path, contents) unless File.exists?(path) ensure_directory(path) diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index 50d7d92b3c..e9de28e83d 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -600,33 +600,4 @@ describe Plugin::Instance do expect(ApiKeyScope.scope_mappings.dig(:groups, :create, :actions)).to contain_exactly(*actions) end end - - describe '#add_directory_column' do - let!(:plugin) { Plugin::Instance.new } - - after do - DirectoryItem.clear_plugin_queries - DirectoryColumn.clear_plugin_directory_columns - end - - it 'creates a directory column record' do - plugin.add_directory_column('random_c', query: "SELECT COUNT(*) FROM users", icon: 'recycle') - - expect(DirectoryColumn.find_by(name: 'random_c', icon: 'recycle', enabled: false).present?).to be(true) - end - - it 'errors when the column_name contains invalid characters' do - expect { - plugin.add_directory_column('Capital', query: "SELECT COUNT(*) FROM users", icon: 'recycle') - }.to raise_error(RuntimeError) - - expect { - plugin.add_directory_column('has space', query: "SELECT COUNT(*) FROM users", icon: 'recycle') - }.to raise_error(RuntimeError) - - expect { - plugin.add_directory_column('has_number_1', query: "SELECT COUNT(*) FROM users", icon: 'recycle') - }.to raise_error(RuntimeError) - end - end end diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb index 9084ebf472..828795859c 100644 --- a/spec/requests/admin/user_fields_controller_spec.rb +++ b/spec/requests/admin/user_fields_controller_spec.rb @@ -130,7 +130,7 @@ describe Admin::UserFieldsController do DirectoryColumn.create( user_field_id: user_field.id, enabled: false, - type: DirectoryColumn.types[:user_field], + automatic: false, position: next_position ) expect { From 36162cf396a2322e88de46b94ca0c6edf262c9dd Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 17 Jun 2021 13:42:16 -0400 Subject: [PATCH 084/403] FIX: Adding multiple auto tags in watched words admin UI (#13421) --- .../addon/components/watched-word-form.js | 1 + .../components/watched-word-form.hbs | 8 +++--- .../stylesheets/common/admin/staff_logs.scss | 6 +++++ .../common/select-kit/multi-select.scss | 26 ++----------------- 4 files changed, 14 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/admin/addon/components/watched-word-form.js b/app/assets/javascripts/admin/addon/components/watched-word-form.js index 89eb6bfe95..93dbdda751 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-form.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-form.js @@ -89,6 +89,7 @@ export default Component.extend({ word: "", replacement: "", formSubmitted: false, + selectedTags: [], showMessage: true, message: I18n.t("admin.watched_words.form.success"), }); diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index be6727ca68..ff098b6038 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -16,10 +16,12 @@ {{tag-chooser id="watched-tag" class="watched-word-input-field" - allowCreate=true - disabled=formSubmitted tags=selectedTags onChange=(action "changeSelectedTags") + options=(hash + allowAny=true + disabled=formSubmitted + ) }}
    {{/if}} @@ -31,7 +33,7 @@ {{/if}} -{{d-button class="btn-default" action=(action "submit") disabled=formSubmitted label="admin.watched_words.form.add"}} +{{d-button class="btn btn-primary" action=(action "submit") disabled=formSubmitted label="admin.watched_words.form.add"}} {{#if showMessage}} {{message}} diff --git a/app/assets/stylesheets/common/admin/staff_logs.scss b/app/assets/stylesheets/common/admin/staff_logs.scss index ee59e16483..639820ccfa 100644 --- a/app/assets/stylesheets/common/admin/staff_logs.scss +++ b/app/assets/stylesheets/common/admin/staff_logs.scss @@ -406,6 +406,8 @@ table.screened-ip-addresses { label { display: inline-block; min-width: 150px; + padding-top: 4px; + vertical-align: top; } input.watched-word-input-field { min-width: 300px; @@ -413,6 +415,10 @@ table.screened-ip-addresses { .select-kit.multi-select.watched-word-input-field { width: 300px; } + + + .btn-primary { + margin-top: 1em; + } } // Search logs diff --git a/app/assets/stylesheets/common/select-kit/multi-select.scss b/app/assets/stylesheets/common/select-kit/multi-select.scss index 7f158fc915..0c57f7da21 100644 --- a/app/assets/stylesheets/common/select-kit/multi-select.scss +++ b/app/assets/stylesheets/common/select-kit/multi-select.scss @@ -79,30 +79,8 @@ } } - .filter { - white-space: nowrap; - min-width: 50px; - padding: 0; - outline: none; - flex: 1; - display: flex; - - .filter-input, - .filter-input:focus { - border: none; - background: none; - display: inline-block; - width: 100%; - outline: none; - min-width: auto; - padding: 0; - margin: 0; - outline: 0; - border: 0; - box-shadow: none; - border-radius: 0; - min-height: unset; // overrides input defaults - } + .multi-select-filter .filter-input { + padding-left: 5px; } .selected-color { From 04a3cd3814921d53c15c48470c46b13837f72cd7 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 17 Jun 2021 14:41:41 -0400 Subject: [PATCH 085/403] FIX: Broken DB issue following a reverted migration (#13426) --- ...183010_add_automatic_column_directory_columns.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 db/migrate/20210617183010_add_automatic_column_directory_columns.rb diff --git a/db/migrate/20210617183010_add_automatic_column_directory_columns.rb b/db/migrate/20210617183010_add_automatic_column_directory_columns.rb new file mode 100644 index 0000000000..7a9940406d --- /dev/null +++ b/db/migrate/20210617183010_add_automatic_column_directory_columns.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddAutomaticColumnDirectoryColumns < ActiveRecord::Migration[6.1] + def up + if !ActiveRecord::Base.connection.column_exists?(:directory_columns, :automatic) + add_column :directory_columns, :automatic, :integer, default: true + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From fbfd1fd80b03b83670ec179c5daa102de253b5be Mon Sep 17 00:00:00 2001 From: jbrw Date: Thu, 17 Jun 2021 15:56:11 -0400 Subject: [PATCH 086/403] FIX: Allow SVG uploads if dimensions are a fraction of a unit (#13409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FIX: Allow SVG uploads if dimensions are a fraction of a unit `UploadCreator` counts the number of pixels in an file to determine if it is valid. `pixels` is calculated by multiplying the width and height of the image, as determined by FastImage. SVG files can have their width/height expressed in a variety of different units of measurement. For example, ‘px’, ‘in’, ‘cm’, ‘mm’, ‘pt’, ‘pc’, etc are all valid within SVG files. If an image has a width of `0.5in`, FastImage may interpret this as being a width of `0`, meaning it will report the `size` as being `0`. However, we don’t need to concern ourselves with the number of ‘pixels’ in a SVG files, as that is irrelevant for this file format, so we can skip over the check for `pixels == 0` when processing this file type. * DEV: Speed up getting SVG dimensions The `-ping` flag prevents the entire image from being rasterized before a result is returned. See: https://imagemagick.org/script/command-line-options.php#ping --- lib/upload_creator.rb | 4 +- spec/fixtures/images/massive.svg | 9 + spec/fixtures/images/pencil.svg | 245 ---------------------------- spec/fixtures/images/tiny.svg | 9 + spec/fixtures/images/zero_sized.svg | 9 + spec/lib/upload_creator_spec.rb | 47 +++++- 6 files changed, 69 insertions(+), 254 deletions(-) create mode 100644 spec/fixtures/images/massive.svg delete mode 100644 spec/fixtures/images/pencil.svg create mode 100644 spec/fixtures/images/tiny.svg create mode 100644 spec/fixtures/images/zero_sized.svg diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index 942b891012..bcb724286b 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -131,7 +131,7 @@ class UploadCreator begin w, h = Discourse::Utils - .execute_command("identify", "-format", "%w %h", @file.path, timeout: Upload::MAX_IDENTIFY_SECONDS) + .execute_command("identify", "-ping", "-format", "%w %h", @file.path, timeout: Upload::MAX_IDENTIFY_SECONDS) .split(' ') rescue # use default 0, 0 @@ -194,7 +194,7 @@ class UploadCreator @upload.errors.add(:base, I18n.t("upload.images.not_supported_or_corrupted")) elsif filesize <= 0 @upload.errors.add(:base, I18n.t("upload.empty")) - elsif pixels == 0 + elsif pixels == 0 && @image_info.type.to_s != 'svg' @upload.errors.add(:base, I18n.t("upload.images.size_not_found")) elsif max_image_pixels > 0 && pixels >= max_image_pixels * 2 @upload.errors.add(:base, I18n.t("upload.images.larger_than_x_megapixels", max_image_megapixels: SiteSetting.max_image_megapixels * 2)) diff --git a/spec/fixtures/images/massive.svg b/spec/fixtures/images/massive.svg new file mode 100644 index 0000000000..55e3a0309f --- /dev/null +++ b/spec/fixtures/images/massive.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/spec/fixtures/images/pencil.svg b/spec/fixtures/images/pencil.svg deleted file mode 100644 index a16d2a9b29..0000000000 --- a/spec/fixtures/images/pencil.svg +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Alfredit Designs - - - - diff --git a/spec/fixtures/images/tiny.svg b/spec/fixtures/images/tiny.svg new file mode 100644 index 0000000000..2e152a4cb5 --- /dev/null +++ b/spec/fixtures/images/tiny.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/spec/fixtures/images/zero_sized.svg b/spec/fixtures/images/zero_sized.svg new file mode 100644 index 0000000000..9452c8db08 --- /dev/null +++ b/spec/fixtures/images/zero_sized.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/spec/lib/upload_creator_spec.rb b/spec/lib/upload_creator_spec.rb index a9662ed303..9188cc6510 100644 --- a/spec/lib/upload_creator_spec.rb +++ b/spec/lib/upload_creator_spec.rb @@ -570,17 +570,50 @@ RSpec.describe UploadCreator do end end - describe "svg sizing" do - let(:svg_filename) { "pencil.svg" } - let(:svg_file) { file_from_fixtures(svg_filename) } + describe "svg sizes expressed in units other than pixels" do + let(:tiny_svg_filename) { "tiny.svg" } + let(:tiny_svg_file) { file_from_fixtures(tiny_svg_filename) } - it "should handle units in width and height" do - upload = UploadCreator.new(svg_file, svg_filename, + let(:massive_svg_filename) { "massive.svg" } + let(:massive_svg_file) { file_from_fixtures(massive_svg_filename) } + + let(:zero_sized_svg_filename) { "zero_sized.svg" } + let(:zero_sized_svg_file) { file_from_fixtures(zero_sized_svg_filename) } + + it "should be viewable when a dimension is a fraction of a unit" do + upload = UploadCreator.new(tiny_svg_file, tiny_svg_filename, force_optimize: true, ).create_for(user.id) - expect(upload.width).to be > 100 - expect(upload.height).to be > 100 + expect(upload.width).to be > 50 + expect(upload.height).to be > 50 + + expect(upload.thumbnail_width).to be <= SiteSetting.max_image_width + expect(upload.thumbnail_height).to be <= SiteSetting.max_image_height + end + + it "should not be larger than the maximum thumbnail size" do + upload = UploadCreator.new(massive_svg_file, massive_svg_filename, + force_optimize: true, + ).create_for(user.id) + + expect(upload.width).to be > 50 + expect(upload.height).to be > 50 + + expect(upload.thumbnail_width).to be <= SiteSetting.max_image_width + expect(upload.thumbnail_height).to be <= SiteSetting.max_image_height + end + + it "should handle zero dimension files" do + upload = UploadCreator.new(zero_sized_svg_file, zero_sized_svg_filename, + force_optimize: true, + ).create_for(user.id) + + expect(upload.width).to be > 50 + expect(upload.height).to be > 50 + + expect(upload.thumbnail_width).to be <= SiteSetting.max_image_width + expect(upload.thumbnail_height).to be <= SiteSetting.max_image_height end end From faca5c09fdaee0d7eeb675ef5240e6b3d24738d9 Mon Sep 17 00:00:00 2001 From: Kane York Date: Thu, 17 Jun 2021 13:17:56 -0700 Subject: [PATCH 087/403] FIX: Use correct property for jump-up embedded post link (#13425) Fixup for 77d33ebe21c #13320 which added the customShare property but did not update all uses --- .../discourse/app/widgets/embedded-post.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/discourse/app/widgets/embedded-post.js b/app/assets/javascripts/discourse/app/widgets/embedded-post.js index a723373454..2be773dcaf 100644 --- a/app/assets/javascripts/discourse/app/widgets/embedded-post.js +++ b/app/assets/javascripts/discourse/app/widgets/embedded-post.js @@ -1,5 +1,4 @@ import DecoratorHelper from "discourse/widgets/decorator-helper"; -import DiscourseURL from "discourse/lib/url"; import PostCooked from "discourse/widgets/post-cooked"; import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; @@ -10,19 +9,15 @@ createWidget("post-link-arrow", { template: hbs` {{#if attrs.above}} - + {{d-icon "arrow-up"}} {{else}} - + {{d-icon "arrow-down"}} {{/if}} `, - - click() { - DiscourseURL.routeTo(this.attrs.shareUrl); - }, }); export default createWidget("embedded-post", { @@ -39,7 +34,7 @@ export default createWidget("embedded-post", { this.attach("poster-name", attrs), this.attach("post-link-arrow", { above: state.above, - shareUrl: attrs.shareUrl, + shareUrl: attrs.customShare, }), ]), new PostCooked(attrs, new DecoratorHelper(this), this.currentUser), From 33c3bb261a72aebe4b9088a3c4e54469bd77e514 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 17 Jun 2021 15:56:48 -0500 Subject: [PATCH 088/403] FIX: Drop and recreate column properly for directory_columns (#13429) --- ..._automatic_on_directory_columns_to_bool.rb | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 db/migrate/20210617202227_change_automatic_on_directory_columns_to_bool.rb diff --git a/db/migrate/20210617202227_change_automatic_on_directory_columns_to_bool.rb b/db/migrate/20210617202227_change_automatic_on_directory_columns_to_bool.rb new file mode 100644 index 0000000000..534d2217b3 --- /dev/null +++ b/db/migrate/20210617202227_change_automatic_on_directory_columns_to_bool.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ChangeAutomaticOnDirectoryColumnsToBool < ActiveRecord::Migration[6.1] + def up + begin + Migration::SafeMigrate.disable! + + # Because of a weird state we are in where some sites have a boolean type column for `automatic` and some + # have an `integer`type, we remove the column. Then we re-create it and using `user_field_id` to determine + # if the value should be true or false. + remove_column :directory_columns, :automatic + add_column :directory_columns, :automatic, :boolean, default: true, null: false + + execute <<~SQL + UPDATE directory_columns SET automatic = (user_field_id IS NULL); + SQL + ensure + Migration::SafeMigrate.enable! + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From 7f916ad06d160c0721f3aea80db75eb985582960 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 18 Jun 2021 13:56:23 +1000 Subject: [PATCH 089/403] FIX: Dismiss new keyboard shortcut not working (#13430) The dismiss new keyboard shortcut (x,r) has been broken since 7a79bd7da3e0a59454a3c03f3be31e457d0b9fcc. A fix was done and JS tests were added in 006d52f32b8d61449dcdd42fa09b06b1573091b0 and b01e4738abe206cd1d09db74acacc743c457c374 but the test was not quite correct and so the bottom dismiss new button was not clicked. This also fixes an issue with our keyboard shortcut click handling. If multiple elements matched the selector they were all clicked. Now we just click the first match. --- .../discourse/app/lib/keyboard-shortcuts.js | 10 +++++++--- .../acceptance/keyboard-shortcuts-test.js | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js index 3ad6a9cc24..7c80fb4277 100644 --- a/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js +++ b/app/assets/javascripts/discourse/app/lib/keyboard-shortcuts.js @@ -86,8 +86,8 @@ const DEFAULT_BINDINGS = { t: { postAction: "replyAsNewTopic" }, u: { handler: "goBack", anonymous: true }, "x r": { - click: "#dismiss-new,#dismiss-new-top,#dismiss-posts,#dismiss-posts-top", - }, // dismiss new/posts + click: "#dismiss-new-bottom,#dismiss-new-top", + }, // dismiss new "x t": { click: "#dismiss-topics-bottom,#dismiss-topics-top" }, // dismiss topics }; @@ -549,7 +549,11 @@ export default { // If effective, prevent default. e.preventDefault(); } - $sel.click(); + + // If there is more than one match for the selector, just click + // the first one, we don't want to click multiple things from one + // shortcut. + $sel[0].click(); }); }, diff --git a/app/assets/javascripts/discourse/tests/acceptance/keyboard-shortcuts-test.js b/app/assets/javascripts/discourse/tests/acceptance/keyboard-shortcuts-test.js index bb316a1a92..9ba4298aef 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/keyboard-shortcuts-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/keyboard-shortcuts-test.js @@ -82,6 +82,7 @@ acceptance("Keyboard Shortcuts - Authenticated Users", function (needs) { test("dismiss unread from top and bottom button", async function (assert) { await visit("/unread"); + assert.ok(exists("#dismiss-topics-top")); await triggerKeyEvent(document, "keypress", "x".charCodeAt(0)); await triggerKeyEvent(document, "keypress", "t".charCodeAt(0)); assert.ok(exists("#dismiss-read-confirm")); @@ -98,7 +99,10 @@ acceptance("Keyboard Shortcuts - Authenticated Users", function (needs) { let originalTopics = [...topicList.topic_list.topics]; topicList.topic_list.topics = [topicList.topic_list.topics[0]]; + // visit root first so topic list starts fresh + await visit("/"); await visit("/unread"); + assert.notOk(exists("#dismiss-topics-top")); await triggerKeyEvent(document, "keypress", "x".charCodeAt(0)); await triggerKeyEvent(document, "keypress", "t".charCodeAt(0)); assert.ok(exists("#dismiss-read-confirm")); @@ -115,6 +119,7 @@ acceptance("Keyboard Shortcuts - Authenticated Users", function (needs) { test("dismiss new from top and bottom button", async function (assert) { await visit("/new"); + assert.ok(exists("#dismiss-new-top")); await triggerKeyEvent(document, "keypress", "x".charCodeAt(0)); await triggerKeyEvent(document, "keypress", "r".charCodeAt(0)); assert.equal(resetNewCalled, 1); @@ -125,7 +130,10 @@ acceptance("Keyboard Shortcuts - Authenticated Users", function (needs) { let originalTopics = [...topicList.topic_list.topics]; topicList.topic_list.topics = [topicList.topic_list.topics[0]]; + // visit root first so topic list starts fresh + await visit("/"); await visit("/new"); + assert.notOk(exists("#dismiss-new-top")); await triggerKeyEvent(document, "keypress", "x".charCodeAt(0)); await triggerKeyEvent(document, "keypress", "r".charCodeAt(0)); assert.equal(resetNewCalled, 2); @@ -133,4 +141,15 @@ acceptance("Keyboard Shortcuts - Authenticated Users", function (needs) { // restore the original topic list topicList.topic_list.topics = originalTopics; }); + + test("click event not fired twice when both dismiss buttons are present", async function (assert) { + await visit("/new"); + assert.ok(exists("#dismiss-new-top")); + assert.ok(exists("#dismiss-new-bottom")); + + await triggerKeyEvent(document, "keypress", "x".charCodeAt(0)); + await triggerKeyEvent(document, "keypress", "r".charCodeAt(0)); + + assert.equal(resetNewCalled, 1); + }); }); From ff6114d83f9c6203dee426968850023b77d1e031 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 18 Jun 2021 14:36:17 +1000 Subject: [PATCH 090/403] FIX: Do not add mailing list headers to group SMTP emails (#13431) When we are emailing people from a group inbox, we are having a PM conversation with them, as a support account would. In this case mailing list headers do not make sense. It is not like a forum topic where you may have tens or hundreds of participants -- it is a conversation between the group and a small handful of people directly contacting the group, often just one person. The only header left in tact was List-Unsubsribe which is important for letting people opt out to notifications. --- lib/email/sender.rb | 34 ++++++++++++++++++---------- spec/components/email/sender_spec.rb | 10 ++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/lib/email/sender.rb b/lib/email/sender.rb index 6b634203fc..489af2f93b 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -98,6 +98,9 @@ module Email topic_id = header_value('X-Discourse-Topic-Id') reply_key = set_reply_key(post_id, user_id) from_address = @message.from&.first + smtp_group_id = from_address.blank? ? nil : Group.where( + email_username: from_address, smtp_enabled: true + ).pluck_first(:id) # always set a default Message ID from the host @message.header['Message-ID'] = "<#{SecureRandom.uuid}@#{host}>" @@ -161,20 +164,29 @@ module Email list_id = "#{SiteSetting.title} <#{host}>" end - # https://www.ietf.org/rfc/rfc3834.txt - @message.header['Precedence'] = 'list' - @message.header['List-ID'] = list_id + # When we are emailing people from a group inbox, we are having a PM + # conversation with them, as a support account would. In this case + # mailing list headers do not make sense. It is not like a forum topic + # where you may have tens or hundreds of participants -- it is a + # conversation between the group and a small handful of people + # directly contacting the group, often just one person. + if !smtp_group_id - if topic - if SiteSetting.private_email? - @message.header['List-Archive'] = "#{Discourse.base_url}#{topic.slugless_url}" - else - @message.header['List-Archive'] = topic.url + # https://www.ietf.org/rfc/rfc3834.txt + @message.header['Precedence'] = 'list' + @message.header['List-ID'] = list_id + + if topic + if SiteSetting.private_email? + @message.header['List-Archive'] = "#{Discourse.base_url}#{topic.slugless_url}" + else + @message.header['List-Archive'] = topic.url + end end end end - if reply_key.present? && @message.header['Reply-To'].to_s =~ /\<([^\>]+)\>/ + if reply_key.present? && @message.header['Reply-To'].to_s =~ /\<([^\>]+)\>/ && !smtp_group_id email = Regexp.last_match[1] @message.header['List-Post'] = "" end @@ -231,9 +243,7 @@ module Email # Log when a message is being sent from a group SMTP address, so we # can debug deliverability issues. - if from_address && smtp_group_id = Group.where(email_username: from_address, smtp_enabled: true).pluck_first(:id) - email_log.smtp_group_id = smtp_group_id - end + email_log.smtp_group_id = smtp_group_id DiscourseEvent.trigger(:before_email_send, @message, @email_type) diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index 603dd76bd3..27e93515af 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -366,6 +366,16 @@ describe Email::Sender do expect(email_log.user_id).to be_blank expect(email_log.smtp_group_id).to eq(group.id) end + + it "does not add any of the mailing list headers" do + TopicAllowedGroup.create(topic: post.topic, group: group) + email_sender.send + + expect(message.header['List-ID']).to eq(nil) + expect(message.header['List-Post']).to eq(nil) + expect(message.header['List-Archive']).to eq(nil) + expect(message.header['Precedence']).to eq(nil) + end end end From dc63613c965f52c9d040332db2688c5cd8bbe5a9 Mon Sep 17 00:00:00 2001 From: jjaffeux Date: Thu, 17 Jun 2021 15:24:35 +0200 Subject: [PATCH 091/403] =?UTF-8?q?DEV:=20improve=20error=20message=20for?= =?UTF-8?q?=20invalid=20setting=E2=80=99s=20value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this fix we would display this exception: ``` Discourse::InvalidParameters: value ``` After this fix we will display: ``` Discourse::InvalidParameters: Invalid `x` value for `s3_region` ``` --- lib/site_settings/type_supervisor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb index b33030c713..da8f4c9a80 100644 --- a/lib/site_settings/type_supervisor.rb +++ b/lib/site_settings/type_supervisor.rb @@ -204,7 +204,7 @@ class SiteSettings::TypeSupervisor def validate_value(name, type, val) if type == self.class.types[:enum] if enum_class(name) - raise Discourse::InvalidParameters.new(:value) unless enum_class(name).valid_value?(val) + raise Discourse::InvalidParameters.new("Invalid `#{val}` value for `#{name}`") unless enum_class(name).valid_value?(val) else unless (choice = @choices[name]) raise Discourse::InvalidParameters.new(name) From 0fd55acf84b3cd8a76d3efa569e8209633deb646 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 17 Jun 2021 16:16:04 +0200 Subject: [PATCH 092/403] Update lib/site_settings/type_supervisor.rb Co-authored-by: David Taylor --- lib/site_settings/type_supervisor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb index da8f4c9a80..88d07a3474 100644 --- a/lib/site_settings/type_supervisor.rb +++ b/lib/site_settings/type_supervisor.rb @@ -204,7 +204,7 @@ class SiteSettings::TypeSupervisor def validate_value(name, type, val) if type == self.class.types[:enum] if enum_class(name) - raise Discourse::InvalidParameters.new("Invalid `#{val}` value for `#{name}`") unless enum_class(name).valid_value?(val) + raise Discourse::InvalidParameters.new("Invalid value `#{val}` for `#{name}`") unless enum_class(name).valid_value?(val) else unless (choice = @choices[name]) raise Discourse::InvalidParameters.new(name) From cbd01a0ccab5b556d8eaabf7da8305f3108909df Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 Jun 2021 11:55:49 +0200 Subject: [PATCH 093/403] REFACTOR: removes unused code (#13412) This has been fully useless since this fix https://github.com/discourse/discourse/pull/12865 The removed test is not actually real life behavior, category should be on a topic type not a fruit. --- app/assets/javascripts/discourse/app/models/store.js | 9 --------- .../discourse/tests/unit/services/store-test.js | 2 -- 2 files changed, 11 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/store.js b/app/assets/javascripts/discourse/app/models/store.js index d1783c08ef..ffac278f1c 100644 --- a/app/assets/javascripts/discourse/app/models/store.js +++ b/app/assets/javascripts/discourse/app/models/store.js @@ -1,5 +1,4 @@ import EmberObject, { set } from "@ember/object"; -import Category from "discourse/models/category"; import { Promise } from "rsvp"; import RestModel from "discourse/models/rest"; import ResultSet from "discourse/models/result-set"; @@ -278,14 +277,6 @@ export default EmberObject.extend({ }, _lookupSubType(subType, type, id, root) { - // cheat: we know we already have categories in memory - // TODO: topics do their own resolving of `category_id` - // to category. That should either respect this or be - // removed. - if (subType === "category" && type !== "topic") { - return Category.findById(id); - } - if (root.meta && root.meta.types) { subType = root.meta.types[subType] || subType; } diff --git a/app/assets/javascripts/discourse/tests/unit/services/store-test.js b/app/assets/javascripts/discourse/tests/unit/services/store-test.js index 28d676453b..7e2e818403 100644 --- a/app/assets/javascripts/discourse/tests/unit/services/store-test.js +++ b/app/assets/javascripts/discourse/tests/unit/services/store-test.js @@ -142,8 +142,6 @@ module("Unit | Service | store", function () { assert.equal(fruitCols.length, 2); assert.equal(fruitCols[0].get("id"), 1); assert.equal(fruitCols[1].get("id"), 2); - - assert.ok(fruit.get("category"), "categories are found automatically"); }); test("embedded records can be cleared", async function (assert) { From d88f792eb179ef98d50a15bab2bfeb9ec4fa1775 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 Jun 2021 12:53:30 +0200 Subject: [PATCH 094/403] DEV: removes maximum limit on tag list site setting (#13436) --- .../admin/addon/templates/components/site-settings/tag-list.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/admin/addon/templates/components/site-settings/tag-list.hbs b/app/assets/javascripts/admin/addon/templates/components/site-settings/tag-list.hbs index b03ec4f36e..a326f2a656 100644 --- a/app/assets/javascripts/admin/addon/templates/components/site-settings/tag-list.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/site-settings/tag-list.hbs @@ -4,6 +4,7 @@ everyTag=true options=(hash allowAny=false + maximum=null ) }}
    {{html-safe setting.description}}
    From c9bd4b4c641333e3cf10c8060d2cac7115027267 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 Jun 2021 14:02:21 +0200 Subject: [PATCH 095/403] FIX: ensures validValues is an array (#13435) Before this fix the setting object would have exceptions on 3 fields: computedNameProperty, computedValueProperty and validValues ``` TypeError: Cannot read property 'forEach' of undefined at Class.validValues (http://localhost:4200/assets/admin.js:10468:19) at Class. (http://localhost:4200/assets/vendor.js:82492:19) at http://localhost:4200/assets/vendor.js:28633:34 at untrack (http://localhost:4200/assets/vendor.js:26641:7) at ComputedProperty.get (http://localhost:4200/assets/vendor.js:28632:13) at Class.CPGETTER_FUNCTION [as validValues] (http://localhost:4200/assets/vendor.js:26259:25) at Class.r (:1:83) ``` --- app/assets/javascripts/admin/addon/mixins/setting-object.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/addon/mixins/setting-object.js b/app/assets/javascripts/admin/addon/mixins/setting-object.js index 29dbb0d967..2bf9e0c082 100644 --- a/app/assets/javascripts/admin/addon/mixins/setting-object.js +++ b/app/assets/javascripts/admin/addon/mixins/setting-object.js @@ -50,7 +50,7 @@ export default Mixin.create({ const vals = [], translateNames = this.translate_names; - validValues.forEach((v) => { + (validValues || []).forEach((v) => { if (v.name && v.name.length > 0 && translateNames) { vals.addObject({ name: I18n.t(v.name), value: v.value }); } else { From e9e28276369bd1262449645f0e95e7d0db635200 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 18 Jun 2021 08:57:13 -0400 Subject: [PATCH 096/403] FIX: Mobile layout for watched words admin UI (#13427) --- .../addon/controllers/admin-watched-words.js | 1 - .../admin/addon/templates/watched-words.hbs | 46 ++++++++++--------- .../stylesheets/common/admin/staff_logs.scss | 5 ++ 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js index ce41e741fd..1830e4c742 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-watched-words.js @@ -35,7 +35,6 @@ export default Controller.extend({ }) ); }); - this.set("model", model); }, diff --git a/app/assets/javascripts/admin/addon/templates/watched-words.hbs b/app/assets/javascripts/admin/addon/templates/watched-words.hbs index 3342a22b94..e353cb2bc5 100644 --- a/app/assets/javascripts/admin/addon/templates/watched-words.hbs +++ b/app/assets/javascripts/admin/addon/templates/watched-words.hbs @@ -1,26 +1,28 @@ -
    -
    - {{d-button action=(action "toggleMenu") class="menu-toggle" icon="bars"}} - {{text-field value=filter placeholderKey="admin.watched_words.search" class="no-blur"}} - {{d-button action=(action "clearFilter") label="admin.watched_words.clear_filter"}} +
    +
    +
    + {{d-button action=(action "toggleMenu") class="menu-toggle" icon="bars"}} + {{text-field value=filter placeholderKey="admin.watched_words.search" class="no-blur"}} + {{d-button action=(action "clearFilter") label="admin.watched_words.clear_filter"}} +
    -
    -
    -
      - {{#each model as |action|}} -
    • - {{#link-to "adminWatchedWords.action" action.nameKey}} - {{action.name}} - {{#if action.words}}({{action.words.length}}){{/if}} - {{/link-to}} -
    • - {{/each}} -
    -
    +
    +
      + {{#each model as |action|}} +
    • + {{#link-to "adminWatchedWords.action" action.nameKey}} + {{action.name}} + {{#if action.words}}({{action.words.length}}){{/if}} + {{/link-to}} +
    • + {{/each}} +
    +
    -
    - {{outlet}} -
    +
    + {{outlet}} +
    -
    +
    +
    diff --git a/app/assets/stylesheets/common/admin/staff_logs.scss b/app/assets/stylesheets/common/admin/staff_logs.scss index 639820ccfa..c893a174f7 100644 --- a/app/assets/stylesheets/common/admin/staff_logs.scss +++ b/app/assets/stylesheets/common/admin/staff_logs.scss @@ -396,6 +396,11 @@ table.screened-ip-addresses { .watched-word-form { margin-bottom: 1em; } + + .watched-word-controls .btn { + margin-bottom: 0.25em; + margin-right: 0.25em; + } } .watched-words-test-modal p { From e305365168528883872d4ce9efd109e41149ef0a Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 18 Jun 2021 09:15:03 -0400 Subject: [PATCH 097/403] FEATURE: Use responsive image sizes in post stream (#13343) --- .../ensure-max-image-dimensions.js | 33 ----- .../app/initializers/post-decorations.js | 11 +- .../discourse/app/lib/lazy-load-images.js | 113 ------------------ .../discourse/app/widgets/post-cooked.js | 39 ------ .../stylesheets/common/base/onebox.scss | 8 +- .../stylesheets/common/base/topic-post.scss | 18 +-- config/site_settings.yml | 5 - .../poll/assets/stylesheets/common/poll.scss | 4 - 8 files changed, 10 insertions(+), 221 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/initializers/ensure-max-image-dimensions.js diff --git a/app/assets/javascripts/discourse/app/initializers/ensure-max-image-dimensions.js b/app/assets/javascripts/discourse/app/initializers/ensure-max-image-dimensions.js deleted file mode 100644 index 66f6b92519..0000000000 --- a/app/assets/javascripts/discourse/app/initializers/ensure-max-image-dimensions.js +++ /dev/null @@ -1,33 +0,0 @@ -export default { - name: "ensure-image-dimensions", - after: "mobile", - initialize(container) { - if (!window) { - return; - } - - // This enforces maximum dimensions of images based on site settings - // for mobile we use the window width as a safeguard - // This rule should never really be at play unless for some reason images do not have dimensions - - const siteSettings = container.lookup("site-settings:main"); - let width = siteSettings.max_image_width; - let height = siteSettings.max_image_height; - - const site = container.lookup("site:main"); - if (site.mobileView) { - width = window.innerWidth - 20; - } - - let styles = `max-width:${width}px; max-height:${height}px;`; - - if (siteSettings.disable_image_size_calculations) { - styles = "max-width: 100%; height: auto;"; - } - - const styleTag = document.createElement("style"); - styleTag.id = "image-sizing-hack"; - styleTag.innerHTML = `#reply-control .d-editor-preview img:not(.thumbnail):not(.ytp-thumbnail-image):not(.emoji), .cooked img:not(.thumbnail):not(.ytp-thumbnail-image):not(.emoji) {${styles}}`; - document.head.appendChild(styleTag); - }, -}; diff --git a/app/assets/javascripts/discourse/app/initializers/post-decorations.js b/app/assets/javascripts/discourse/app/initializers/post-decorations.js index b425a81a2e..8a75560868 100644 --- a/app/assets/javascripts/discourse/app/initializers/post-decorations.js +++ b/app/assets/javascripts/discourse/app/initializers/post-decorations.js @@ -4,10 +4,7 @@ import highlightSyntax from "discourse/lib/highlight-syntax"; import lightbox from "discourse/lib/lightbox"; import { iconHTML } from "discourse-common/lib/icon-library"; import { setTextDirections } from "discourse/lib/text-direction"; -import { - nativeLazyLoading, - setupLazyLoading, -} from "discourse/lib/lazy-load-images"; +import { nativeLazyLoading } from "discourse/lib/lazy-load-images"; import { withPluginApi } from "discourse/lib/plugin-api"; export default { @@ -38,11 +35,7 @@ export default { }); } - if (siteSettings.disable_image_size_calculations) { - nativeLazyLoading(api); - } else { - setupLazyLoading(api); - } + nativeLazyLoading(api); api.decorateCooked( ($elem) => { diff --git a/app/assets/javascripts/discourse/app/lib/lazy-load-images.js b/app/assets/javascripts/discourse/app/lib/lazy-load-images.js index 46390d0504..c354d1f245 100644 --- a/app/assets/javascripts/discourse/app/lib/lazy-load-images.js +++ b/app/assets/javascripts/discourse/app/lib/lazy-load-images.js @@ -1,89 +1,6 @@ -const OBSERVER_OPTIONS = { - rootMargin: "66%", // load images slightly before they're visible -}; - // Min size in pixels for consideration for lazy loading const MINIMUM_SIZE = 150; -const hiddenData = new WeakMap(); - -const LOADING_DATA = - "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; - -// We hide an image by replacing it with a transparent gif -function hide(image) { - image.classList.add("d-lazyload"); - image.classList.add("d-lazyload-hidden"); - - hiddenData.set(image, { - src: image.src, - srcset: image.srcset, - width: image.width, - height: image.height, - className: image.className, - }); - - image.src = image.dataset.smallUpload || LOADING_DATA; - image.removeAttribute("srcset"); - - image.removeAttribute("data-small-upload"); -} - -// Restore an image when onscreen -function show(image) { - let imageData = hiddenData.get(image); - - if (imageData) { - const copyImg = new Image(); - copyImg.onload = () => { - if (copyImg.srcset) { - image.srcset = copyImg.srcset; - } - image.src = copyImg.src; - image.classList.remove("d-lazyload-hidden"); - - if (image.onload) { - // don't bother fighting with existing handler - // this can mean a slight flash on mobile - image.parentNode.removeChild(copyImg); - } else { - image.onload = () => { - image.parentNode.removeChild(copyImg); - image.onload = null; - }; - } - - copyImg.onload = null; - }; - - if (imageData.srcset) { - copyImg.srcset = imageData.srcset; - } - - copyImg.src = imageData.src; - - // width of image may not match, use computed style which - // is the actual size of the image - const computedStyle = window.getComputedStyle(image); - const actualWidth = parseInt(computedStyle.width, 10); - const actualHeight = parseInt(computedStyle.height, 10); - - copyImg.style.position = "absolute"; - copyImg.style.top = `${image.offsetTop}px`; - copyImg.style.left = `${image.offsetLeft}px`; - copyImg.style.width = `${actualWidth}px`; - copyImg.style.height = `${actualHeight}px`; - - copyImg.className = imageData.className; - - // insert after the current element so styling still will - // apply to original image firstChild selectors - image.parentNode.insertBefore(copyImg, image.nextSibling); - } else { - image.classList.remove("d-lazyload-hidden"); - } -} - function forEachImage(post, callback) { post.querySelectorAll("img").forEach((img) => { if (img.width >= MINIMUM_SIZE && img.height >= MINIMUM_SIZE) { @@ -92,36 +9,6 @@ function forEachImage(post, callback) { }); } -export function setupLazyLoading(api) { - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - const { target } = entry; - - if (entry.isIntersecting) { - show(target); - observer.unobserve(target); - } - }); - }, OBSERVER_OPTIONS); - - api.decorateCookedElement((post) => forEachImage(post, (img) => hide(img)), { - onlyStream: true, - id: "discourse-lazy-load", - }); - - // IntersectionObserver.observe must be called after the cooked - // content is adopted by the document element in chrome - // https://bugs.chromium.org/p/chromium/issues/detail?id=1073469 - api.decorateCookedElement( - (post) => forEachImage(post, (img) => observer.observe(img)), - { - onlyStream: true, - id: "discourse-lazy-load-after-adopt", - afterAdopt: true, - } - ); -} - export function nativeLazyLoading(api) { api.decorateCookedElement( (post) => diff --git a/app/assets/javascripts/discourse/app/widgets/post-cooked.js b/app/assets/javascripts/discourse/app/widgets/post-cooked.js index 1526ec0874..5a41b1d9dc 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-cooked.js +++ b/app/assets/javascripts/discourse/app/widgets/post-cooked.js @@ -57,7 +57,6 @@ export default class PostCooked { this._insertQuoteControls($cookedDiv); this._showLinkCounts($cookedDiv); - this._fixImageSizes($cookedDiv); this._applySearchHighlight($cookedDiv); this._decorateAndAdopt(cookedDiv); @@ -90,44 +89,6 @@ export default class PostCooked { } } - _fixImageSizes($html) { - if (!this.decoratorHelper || !this.decoratorHelper.widget) { - return; - } - let siteSettings = this.decoratorHelper.widget.siteSettings; - - if (siteSettings.disable_image_size_calculations) { - return; - } - - const maxImageWidth = siteSettings.max_image_width; - const maxImageHeight = siteSettings.max_image_height; - - let maxWindowWidth; - $html.find("img:not(.avatar)").each((idx, img) => { - // deferring work only for posts with images - // we got to use screen here, cause nothing is rendered yet. - // long term we may want to allow for weird margins that are enforced, instead of hardcoding at 70/20 - maxWindowWidth = - maxWindowWidth || $(window).width() - (this.attrs.mobileView ? 20 : 70); - if (maxImageWidth < maxWindowWidth) { - maxWindowWidth = maxImageWidth; - } - - const aspect = img.height / img.width; - if (img.width > maxWindowWidth) { - img.width = maxWindowWidth; - img.height = parseInt(maxWindowWidth * aspect, 10); - } - - // very unlikely but lets fix this too - if (img.height > maxImageHeight) { - img.height = maxImageHeight; - img.width = parseInt(maxWindowWidth / aspect, 10); - } - }); - } - _showLinkCounts($html) { const linkCounts = this.attrs.linkCounts; if (!linkCounts) { diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index ef88ca7ee8..c40dd3e2dd 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -680,11 +680,9 @@ aside.onebox.stackexchange .onebox-body { color: var(--primary-med-or-secondary-med); } -.onebox.xkcd .onebox-body { - img { - max-width: 100% !important; - float: none !important; - } +aside.onebox.xkcd .onebox-body img { + float: none; + max-height: unset; } // pdf onebox diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index c22dac0092..be57d2dee6 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -195,6 +195,11 @@ $quote-share-maxwidth: 150px; sup sup { top: 0; } + + img:not(.thumbnail):not(.ytp-thumbnail-image):not(.emoji) { + max-width: 100%; + height: auto; + } } // add staff color @@ -202,10 +207,6 @@ $quote-share-maxwidth: 150px; .regular > .cooked { background-color: var(--highlight-low-or-medium); padding: 10px; - img:not(.thumbnail) { - max-width: 100%; - height: auto; - } } .clearfix > .topic-meta-data > .names { span.user-title { @@ -264,15 +265,6 @@ aside.quote { } } -blockquote { - // due to #image-sizing-hack large images and lightboxes extend past the - // limits blockquotes. Since #image-sizing-hack is inline, we need to use - // !important here otherwise it won't work. - img { - max-width: 100% !important; - } -} - .quote-controls, .quote-controls .d-icon { color: var(--primary-low-mid-or-secondary-high); diff --git a/config/site_settings.yml b/config/site_settings.yml index 6a64b90ad9..0f91cd4c3e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2261,11 +2261,6 @@ uncategorized: create_revision_on_bulk_topic_moves: default: true - disable_image_size_calculations: - default: false - hidden: true - client: true - user_preferences: default_email_digest_frequency: enum: "DigestEmailSiteSetting" diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss index 04972e7e1e..652b21fd3e 100644 --- a/plugins/poll/assets/stylesheets/common/poll.scss +++ b/plugins/poll/assets/stylesheets/common/poll.scss @@ -25,10 +25,6 @@ div.poll { } img { - // TODO: remove once disable_image_size_calculations is removed - // needed to override internal styles in image-sizing hack - max-width: 100% !important; - height: auto; // Hacky way to stop images without width/height // from causing abrupt unintended scrolling &:not([width]):not(.emoji), From 09b55fd33842b616612f6474b1f41d7b66156870 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 18 Jun 2021 16:26:57 +0300 Subject: [PATCH 098/403] FIX: Update post's raw from server response (#13438) This fix is similar to ea2833d0d89df9b48c4fc46e422f3e9b713cac00, but this time raw text is updated after the post is created. --- app/serializers/new_post_result_serializer.rb | 2 +- spec/requests/api/schemas/json/topic_create_response.json | 3 +++ spec/requests/posts_controller_spec.rb | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/serializers/new_post_result_serializer.rb b/app/serializers/new_post_result_serializer.rb index 9232062711..e15af2fad1 100644 --- a/app/serializers/new_post_result_serializer.rb +++ b/app/serializers/new_post_result_serializer.rb @@ -13,7 +13,7 @@ class NewPostResultSerializer < ApplicationSerializer has_one :pending_post, serializer: TopicPendingPostSerializer, root: false, embed: :objects def post - post_serializer = PostSerializer.new(object.post, scope: scope, root: false) + post_serializer = PostSerializer.new(object.post, scope: scope, root: false, add_raw: true) post_serializer.draft_sequence = DraftSequence.current(scope.user, object.post.topic.draft_key) post_serializer.as_json end diff --git a/spec/requests/api/schemas/json/topic_create_response.json b/spec/requests/api/schemas/json/topic_create_response.json index d80c4c2901..d14e56b85c 100644 --- a/spec/requests/api/schemas/json/topic_create_response.json +++ b/spec/requests/api/schemas/json/topic_create_response.json @@ -19,6 +19,9 @@ "created_at": { "type": "string" }, + "raw": { + "type": "string" + }, "cooked": { "type": "string" }, diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index a149ed697f..5c0c00d704 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -989,7 +989,7 @@ describe PostsController do it "returns the nested post with a param" do post "/posts.json", params: { - raw: 'this is the test content', + raw: 'this is the test content ', title: 'this is the test title for the topic', nested_post: true } @@ -997,6 +997,7 @@ describe PostsController do expect(response.status).to eq(200) parsed = response.parsed_body expect(parsed['post']).to be_present + expect(parsed['post']['raw']).to eq('this is the test content') expect(parsed['post']['cooked']).to be_present end From 5b1790226389a6c183c01e2ef66514eb39eb21ab Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 18 Jun 2021 10:40:56 -0400 Subject: [PATCH 099/403] UX: Use button instead of anchor in filtered replies bar (#13439) --- app/assets/javascripts/discourse/app/widgets/post-stream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/widgets/post-stream.js b/app/assets/javascripts/discourse/app/widgets/post-stream.js index ead46aeb4e..b381dc2106 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-stream.js +++ b/app/assets/javascripts/discourse/app/widgets/post-stream.js @@ -162,7 +162,7 @@ createWidget("filter-jump-to-post", { }); createWidget("filter-show-all", { - tagName: "a.filtered-replies-show-all", + tagName: "button.filtered-replies-show-all", buildKey: (attrs) => `filtered-show-all-${attrs.id}`, buildClasses() { From 4afd8f9bdf6187fb1dddbd11ec47e2c4c811dba1 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Fri, 18 Jun 2021 12:53:10 -0300 Subject: [PATCH 100/403] FEATURE: An API key scope for editing posts. (#13441) --- app/models/api_key_scope.rb | 7 ++++++- config/locales/client.en.yml | 2 ++ spec/requests/admin/api_controller_spec.rb | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/models/api_key_scope.rb b/app/models/api_key_scope.rb index 3534785161..be781251b5 100644 --- a/app/models/api_key_scope.rb +++ b/app/models/api_key_scope.rb @@ -33,6 +33,9 @@ class ApiKeyScope < ActiveRecord::Base }, wordpress: { actions: %w[topics#wordpress], params: %i[topic_id] } }, + posts: { + edit: { actions: %w[posts#update], params: %i[id] } + }, users: { bookmarks: { actions: %w[users#bookmarks], params: %i[username] }, sync_sso: { actions: %w[admin/users#sync_sso], params: %i[sso sig] }, @@ -84,7 +87,9 @@ class ApiKeyScope < ActiveRecord::Base excluded_paths = %w[/new-topic /new-message /exception] memo.tap do |m| - m << path if actions.include?(action) && api_supported_path && !excluded_paths.include?(path) + if actions.include?(action) && api_supported_path && !excluded_paths.include?(path) + m << "#{path} (#{route.verb})" + end end end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index dbfc5cc67a..2f733105d7 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4026,6 +4026,8 @@ en: write: Create a new topic or post to an existing one. read_lists: Read topic lists like top, new, latest, etc. RSS is also supported. wordpress: Necessary for the WordPress wp-discourse plugin to work. + posts: + edit: Edit any post or a specific one. users: bookmarks: List user bookmarks. It returns bookmark reminders when using the ICS format. sync_sso: Synchronize a user using DiscourseConnect. diff --git a/spec/requests/admin/api_controller_spec.rb b/spec/requests/admin/api_controller_spec.rb index 792136438a..c52b343a55 100644 --- a/spec/requests/admin/api_controller_spec.rb +++ b/spec/requests/admin/api_controller_spec.rb @@ -222,7 +222,7 @@ describe Admin::ApiController do scopes = response.parsed_body['scopes'] - expect(scopes.keys).to contain_exactly('topics', 'users', 'email') + expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts') end end end From 74f72956316cec70bc6eba141cf1b04fe189a49c Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 18 Jun 2021 18:54:06 +0300 Subject: [PATCH 101/403] FIX: Add word boundaries to replace and tag watched words (#13405) The generated regular expressions did not contain \b which matched every text that contained the word, even if it was only a substring of a word. For example, if "art" was a watched word a post containing word "artist" matched. --- .../acceptance/admin-watched-words-test.js | 1 - .../tests/fixtures/watched-words-fixtures.js | 4 ++-- .../tests/unit/lib/pretty-text-test.js | 12 ++++++------ .../discourse-markdown/watched-words.js | 4 ++-- app/serializers/watched_word_serializer.rb | 2 +- app/services/word_watcher.rb | 19 ++++++++++++++----- spec/components/post_creator_spec.rb | 10 +++++++++- spec/components/pretty_text_spec.rb | 4 ++++ 8 files changed, 38 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js index d04bf15984..ecc29584f2 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js @@ -118,7 +118,6 @@ acceptance("Admin - Watched Words - Bad regular expressions", function (needs) { action: "block", }, ], - regular_expressions: true, compiled_regular_expressions: { block: null, censor: null, diff --git a/app/assets/javascripts/discourse/tests/fixtures/watched-words-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/watched-words-fixtures.js index 8a6ead382b..8a5ac4be2f 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/watched-words-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/watched-words-fixtures.js @@ -11,14 +11,14 @@ export default { { id: 7, word: "hi", - regexp: "hi", + regexp: "(hi)", replacement: "hello", action: "replace", }, { id: 8, word: "hello", - regexp: "hello", + regexp: "(hello)", replacement: "greeting", action: "tag", }, diff --git a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js index f07b88b237..1b8d7951af 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/pretty-text-test.js @@ -1675,21 +1675,21 @@ var bar = 'bar'; test("watched words replace", function (assert) { const opts = { - watchedWordsReplace: { fun: "times" }, + watchedWordsReplace: { "(?:\\W|^)(fun)(?=\\W|$)": "times" }, }; - assert.cookedOptions("test fun", opts, "

    test times

    "); + assert.cookedOptions("test fun funny", opts, "

    test times funny

    "); }); test("watched words link", function (assert) { const opts = { - watchedWordsLink: { fun: "https://discourse.org" }, + watchedWordsLink: { "(?:\\W|^)(fun)(?=\\W|$)": "https://discourse.org" }, }; assert.cookedOptions( - "test fun", + "test fun funny", opts, - '

    test fun

    ' + '

    test fun funny

    ' ); }); @@ -1697,7 +1697,7 @@ var bar = 'bar'; const maxMatches = 100; // same limit as MD watched-words-replace plugin const opts = { siteSettings: { watched_words_regular_expressions: true }, - watchedWordsReplace: { "\\bu?\\b": "you" }, + watchedWordsReplace: { "(\\bu?\\b)": "you" }, }; assert.cookedOptions( diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js index c8ad08a0c0..cce39254b9 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js @@ -20,8 +20,8 @@ function findAllMatches(text, matchers) { count++ < MAX_MATCHES ) { matches.push({ - index: match.index, - text: match[0], + index: match.index + match[0].indexOf(match[1]), + text: match[1], replacement: matcher.replacement, link: matcher.link, }); diff --git a/app/serializers/watched_word_serializer.rb b/app/serializers/watched_word_serializer.rb index 4b3b138be0..070da701fd 100644 --- a/app/serializers/watched_word_serializer.rb +++ b/app/serializers/watched_word_serializer.rb @@ -4,7 +4,7 @@ class WatchedWordSerializer < ApplicationSerializer attributes :id, :word, :regexp, :replacement, :action def regexp - WordWatcher.word_to_regexp(word) + WordWatcher.word_to_regexp(word, whole: true) end def action diff --git a/app/services/word_watcher.rb b/app/services/word_watcher.rb index 36699140f0..1927ac9474 100644 --- a/app/services/word_watcher.rb +++ b/app/services/word_watcher.rb @@ -54,17 +54,26 @@ class WordWatcher def self.word_matcher_regexps(action) if words = get_cached_words(action) - words.map { |w, r| [word_to_regexp(w), r] }.to_h + words.map { |w, r| [word_to_regexp(w, whole: true), r] }.to_h end end - def self.word_to_regexp(word) + def self.word_to_regexp(word, whole: false) if SiteSetting.watched_words_regular_expressions? # Strip ruby regexp format if present, we're going to make the whole thing # case insensitive anyway - return word.start_with?("(?-mix:") ? word[7..-2] : word + regexp = word.start_with?("(?-mix:") ? word[7..-2] : word + regexp = "(#{regexp})" if whole + return regexp end - Regexp.escape(word).gsub("\\*", '\S*') + + regexp = Regexp.escape(word).gsub("\\*", '\S*') + + if whole && !SiteSetting.watched_words_regular_expressions? + regexp = "(?:\\W|^)(#{regexp})(?=\\W|$)" + end + + regexp end def self.word_matcher_regexp_key(action) @@ -144,6 +153,6 @@ class WordWatcher end def word_matches?(word) - Regexp.new(WordWatcher.word_to_regexp(word), Regexp::IGNORECASE).match?(@raw) + Regexp.new(WordWatcher.word_to_regexp(word, whole: true), Regexp::IGNORECASE).match?(@raw) end end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index 5827de6ad0..e01db2b312 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -502,13 +502,21 @@ describe PostCreator do end context "without regular expressions" do - it "works" do + it "works with many tags" do Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "HELLO", replacement: "greetings , hey") @post = creator.create expect(@post.topic.tags.map(&:name)).to match_array(['greetings', 'hey']) end + it "works with overlapping words" do + Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "art", replacement: "about-art") + Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "artist*", replacement: "about-artists") + + post = PostCreator.new(user, title: "hello world topic", raw: "this is topic abour artists", archetype_id: 1).create + expect(post.topic.tags.map(&:name)).to match_array(['about-artists']) + end + it "does not treat as regular expressions" do Fabricate(:watched_word, action: WatchedWord.actions[:tag], word: "he(llo|y)", replacement: "greetings , hey") diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 0d53880966..3d31c1ae1e 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -1420,6 +1420,10 @@ HTML expect(PrettyText.cook("Lorem ipsum dolor sittt amet")).to match_html(<<~HTML)

    Lorem ipsum something else amet

    HTML + + expect(PrettyText.cook("Lorem ipsum xdolor sit amet")).to match_html(<<~HTML) +

    Lorem ipsum xdolor sit amet

    + HTML end it "replaces words with links" do From 1e992d9193647a3bdff913fe0bc12feff288826d Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 18 Jun 2021 18:55:24 +0300 Subject: [PATCH 102/403] FIX: Do not check for duplicate links in Onebox (#13345) If a user posted a URL that appeared inside a Onebox, then the user got a duplicate link notice. This was fixed by skipping those links in Ruby. If a user posted a URL that was Oneboxes and contained other links that appeared in previous posts, then the user got a duplicate link notice. This was fixed by skipping those links in JavaScript. --- .../discourse/app/controllers/composer.js | 10 +++++++++- lib/pretty_text.rb | 7 +++++-- spec/components/pretty_text_spec.rb | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 0c4be8430c..12c76c2547 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -456,7 +456,7 @@ export default Controller.extend({ $links.each((idx, l) => { const href = l.href; if (href && href.length) { - // skip links in quotes + // skip links in quotes and oneboxes for (let element = l; element; element = element.parentElement) { if ( element.tagName === "DIV" && @@ -471,6 +471,14 @@ export default Controller.extend({ ) { return true; } + + if ( + element.tagName === "ASIDE" && + element.classList.contains("onebox") && + href !== element.dataset["onebox-src"] + ) { + return true; + } } const [warn, info] = linkLookup.check(post, href); diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index cb897760c3..a298ce4f80 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -324,8 +324,11 @@ module PrettyText links = [] doc = Nokogiri::HTML5.fragment(html) - # remove href inside quotes & elided part - doc.css("aside.quote a, .elided a").each { |a| a["href"] = "" } + # extract onebox links + doc.css("aside.onebox[data-onebox-src]").each { |onebox| links << DetectedLink.new(onebox["data-onebox-src"], false) } + + # remove href inside quotes & oneboxes & elided part + doc.css("aside.quote a, aside.onebox a, .elided a").remove # extract all links doc.css("a").each do |a| diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 3d31c1ae1e..c2e5a611a5 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -779,6 +779,22 @@ describe PrettyText do ].sort) end + it "should not extract links inside oneboxes" do + onebox = <<~EOF +
    +
    + twitter.com + twitter.com +
    +
    +
    Example URL: example.com
    +
    +
    + EOF + + expect(PrettyText.extract_links(onebox).map(&:url)).to contain_exactly("https://twitter.com/EDBPostgres/status/1402528437441634306") + end + it "should not preserve tags in code blocks" do expect(PrettyText.excerpt("
    <h3>Hours</h3>
    ", 100)).to eq("<h3>Hours</h3>") end From f490a8d39a12ed2c6b125b3380f08b2d1d43ee7f Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 18 Jun 2021 18:56:54 +0300 Subject: [PATCH 103/403] FIX: Do not display twice a user who changed vote (#13284) * FIX: Fetch last page again if incomplete The next fetched page number used to increase continuously even if the last page was incomplete and fetching it again could have new voters. * FIX: Do not display twice a user who changed vote A user could appear under two voting options when they changed their vote because pressing the Load More Voters button updated only the current option. --- .../javascripts/widgets/discourse-poll.js.es6 | 141 ++-- .../acceptance/poll-results-test.js.es6 | 626 ++++++++++++++++++ 2 files changed, 710 insertions(+), 57 deletions(-) create mode 100644 plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 index 1a47c2488e..554c4efe9a 100644 --- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 +++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 @@ -14,6 +14,8 @@ import { relativeAge } from "discourse/lib/formatter"; import round from "discourse/lib/round"; import showModal from "discourse/lib/show-modal"; +const FETCH_VOTERS_COUNT = 25; + function optionHtml(option) { const $node = $(`${option.html}`); @@ -106,14 +108,14 @@ createWidget("discourse-poll-load-more", { }, click() { - const { state } = this; + const { state, attrs } = this; if (state.loading) { return; } state.loading = true; - return this.sendWidgetAction("loadMore").finally( + return this.sendWidgetAction("fetchVoters", attrs.optionId).finally( () => (state.loading = false) ); }, @@ -131,63 +133,18 @@ createWidget("discourse-poll-voters", { }; }, - fetchVoters() { - const { attrs, state } = this; - - if (state.loaded === "loading") { - return; - } - state.loaded = "loading"; - - return _fetchVoters({ - post_id: attrs.postId, - poll_name: attrs.pollName, - option_id: attrs.optionId, - page: state.page, - }).then((result) => { - state.loaded = "loaded"; - state.page += 1; - - const newVoters = - attrs.pollType === "number" - ? result.voters - : result.voters[attrs.optionId]; - - const existingVoters = new Set( - state.voters.map((voter) => voter.username) - ); - - newVoters.forEach((voter) => { - if (!existingVoters.has(voter.username)) { - existingVoters.add(voter.username); - state.voters.push(voter); - } - }); - - this.scheduleRerender(); - }); - }, - - loadMore() { - return this.fetchVoters(); - }, - - html(attrs, state) { - if (attrs.voters && state.loaded === "new") { - state.voters = attrs.voters; - } - - const contents = state.voters.map((user) => { - return h("li", [ + html(attrs) { + const contents = attrs.voters.map((user) => + h("li", [ avatarFor("tiny", { username: user.username, template: user.avatar_template, }), " ", - ]); - }); + ]) + ); - if (state.voters.length < attrs.totalVotes) { + if (attrs.voters.length < attrs.totalVotes) { contents.push(this.attach("discourse-poll-load-more", attrs)); } @@ -203,14 +160,56 @@ createWidget("discourse-poll-standard-results", { return { loaded: false }; }, - fetchVoters() { + fetchVoters(optionId) { const { attrs, state } = this; + if (!state.page) { + state.page = {}; + } + + if (!state.page[optionId]) { + state.page[optionId] = 1; + } + return _fetchVoters({ post_id: attrs.post.id, poll_name: attrs.poll.get("name"), + option_id: optionId, + page: state.page[optionId], + limit: FETCH_VOTERS_COUNT, }).then((result) => { - state.voters = result.voters; + if (!state.voters[optionId]) { + state.voters[optionId] = []; + } + + const voters = state.voters[optionId]; + const newVoters = result.voters[optionId]; + + // remove users who changed their vote + const newVotersSet = new Set(newVoters.map((voter) => voter.username)); + Object.keys(state.voters).forEach((otherOptionId) => { + if (optionId !== otherOptionId) { + state.voters[otherOptionId] = state.voters[otherOptionId].filter( + (voter) => !newVotersSet.has(voter.username) + ); + } + }); + + const votersSet = new Set(voters.map((voter) => voter.username)); + let count = 0; + newVoters.forEach((voter) => { + if (!votersSet.has(voter.username)) { + voters.push(voter); + count++; + } + }); + + // request next page in the future only if a complete set was + // returned this time + if (count >= FETCH_VOTERS_COUNT) { + state.page[optionId]++; + } + this.scheduleRerender(); }); }, @@ -295,14 +294,42 @@ createWidget("discourse-poll-number-results", { return { loaded: false }; }, - fetchVoters() { + fetchVoters(optionId) { const { attrs, state } = this; + if (!state.page) { + state.page = 1; + } + return _fetchVoters({ post_id: attrs.post.id, poll_name: attrs.poll.get("name"), + option_id: optionId, + page: state.page, + limit: FETCH_VOTERS_COUNT, }).then((result) => { - state.voters = result.voters; + if (!state.voters) { + state.voters = []; + } + + const voters = state.voters; + const newVoters = result.voters; + + const votersSet = new Set(voters.map((voter) => voter.username)); + let count = 0; + newVoters.forEach((voter) => { + if (!votersSet.has(voter.username)) { + voters.push(voter); + count++; + } + }); + + // request next page in the future only if a complete set was + // returned this time + if (count >= FETCH_VOTERS_COUNT) { + state.page++; + } + this.scheduleRerender(); }); }, diff --git a/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 new file mode 100644 index 0000000000..06956b676a --- /dev/null +++ b/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 @@ -0,0 +1,626 @@ +import { + acceptance, + publishToMessageBus, +} from "discourse/tests/helpers/qunit-helpers"; +import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; + +acceptance("Poll results", function (needs) { + needs.user(); + needs.settings({ poll_enabled: true }); + needs.hooks.beforeEach(() => { + clearPopupMenuOptionsCallback(); + }); + + needs.pretender((server, helper) => { + server.get("/posts/by_number/134/1", () => { + return helper.response({ + id: 156, + name: null, + username: "bianca", + avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + created_at: "2021-06-08T21:56:55.166Z", + cooked: + '\u003cdiv class="poll" data-poll-status="open" data-poll-public="true" data-poll-results="always" data-poll-charttype="bar" data-poll-type="regular" data-poll-name="poll"\u003e\n\u003cdiv\u003e\n\u003cdiv class="poll-container"\u003e\n\u003cul\u003e\n\u003cli data-poll-option-id="db753fe0bc4e72869ac1ad8765341764"\u003eOption \u003cspan class="hashtag"\u003e#1\u003c/span\u003e\n\u003c/li\u003e\n\u003cli data-poll-option-id="d8c22ff912e03740d9bc19e133e581e0"\u003eOption \u003cspan class="hashtag"\u003e#2\u003c/span\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/div\u003e\n\u003cdiv class="poll-info"\u003e\n\u003cp\u003e\n\u003cspan class="info-number"\u003e0\u003c/span\u003e\n\u003cspan class="info-label"\u003evoters\u003c/span\u003e\n\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003c/div\u003e', + post_number: 1, + post_type: 1, + updated_at: "2021-06-08T21:59:16.444Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 2, + readers_count: 1, + score: 0, + yours: true, + topic_id: 134, + topic_slug: "load-more-poll-voters", + display_username: null, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 1, + can_edit: true, + can_delete: false, + can_recover: false, + can_wiki: true, + title_is_group: false, + bookmarked: false, + raw: + "[poll type=regular results=always public=true chartType=bar]\n* Option #1\n* Option #2\n[/poll]", + actions_summary: [ + { id: 3, can_act: true }, + { id: 4, can_act: true }, + { id: 8, can_act: true }, + { id: 7, can_act: true }, + ], + moderator: false, + admin: true, + staff: true, + user_id: 1, + hidden: false, + trust_level: 0, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: null, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + calendar_details: [], + can_accept_answer: false, + can_unaccept_answer: false, + accepted_answer: false, + polls: [ + { + name: "poll", + type: "regular", + status: "open", + public: true, + results: "always", + options: [ + { + id: "db753fe0bc4e72869ac1ad8765341764", + html: + 'Option \u003cspan class="hashtag"\u003e#1\u003c/span\u003e', + votes: 1, + }, + { + id: "d8c22ff912e03740d9bc19e133e581e0", + html: + 'Option \u003cspan class="hashtag"\u003e#2\u003c/span\u003e', + votes: 0, + }, + ], + voters: 1, + preloaded_voters: { + db753fe0bc4e72869ac1ad8765341764: [ + { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + ], + }, + chart_type: "bar", + title: null, + }, + ], + polls_votes: { poll: ["db753fe0bc4e72869ac1ad8765341764"] }, + }); + }); + + server.get("/t/load-more-poll-voters.json", () => { + return helper.response({ + post_stream: { + posts: [ + { + id: 156, + name: null, + username: "bianca", + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + created_at: "2021-06-08T21:56:55.166Z", + cooked: + '\u003cdiv class="poll" data-poll-status="open" data-poll-public="true" data-poll-results="always" data-poll-charttype="bar" data-poll-type="regular" data-poll-name="poll"\u003e\n\u003cdiv\u003e\n\u003cdiv class="poll-container"\u003e\n\u003cul\u003e\n\u003cli data-poll-option-id="db753fe0bc4e72869ac1ad8765341764"\u003eOption \u003cspan class="hashtag"\u003e#1\u003c/span\u003e\n\u003c/li\u003e\n\u003cli data-poll-option-id="d8c22ff912e03740d9bc19e133e581e0"\u003eOption \u003cspan class="hashtag"\u003e#2\u003c/span\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/div\u003e\n\u003cdiv class="poll-info"\u003e\n\u003cp\u003e\n\u003cspan class="info-number"\u003e0\u003c/span\u003e\n\u003cspan class="info-label"\u003evoters\u003c/span\u003e\n\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003c/div\u003e', + post_number: 1, + post_type: 1, + updated_at: "2021-06-08T21:59:16.444Z", + reply_count: 0, + reply_to_post_number: null, + quote_count: 0, + incoming_link_count: 0, + reads: 2, + readers_count: 1, + score: 0, + yours: true, + topic_id: 134, + topic_slug: "load-more-poll-voters", + display_username: null, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_bg_color: null, + primary_group_flair_color: null, + version: 1, + can_edit: true, + can_delete: false, + can_recover: false, + can_wiki: true, + read: true, + title_is_group: false, + bookmarked: false, + actions_summary: [ + { id: 3, can_act: true }, + { id: 4, can_act: true }, + { id: 8, can_act: true }, + { id: 7, can_act: true }, + ], + moderator: false, + admin: true, + staff: true, + user_id: 1, + hidden: false, + trust_level: 0, + deleted_at: null, + user_deleted: false, + edit_reason: null, + can_view_edit_history: true, + wiki: false, + reviewable_id: 0, + reviewable_score_count: 0, + reviewable_score_pending_count: 0, + calendar_details: [], + can_accept_answer: false, + can_unaccept_answer: false, + accepted_answer: false, + polls: [ + { + name: "poll", + type: "regular", + status: "open", + public: true, + results: "always", + options: [ + { + id: "db753fe0bc4e72869ac1ad8765341764", + html: + 'Option \u003cspan class="hashtag"\u003e#1\u003c/span\u003e', + votes: 1, + }, + { + id: "d8c22ff912e03740d9bc19e133e581e0", + html: + 'Option \u003cspan class="hashtag"\u003e#2\u003c/span\u003e', + votes: 0, + }, + ], + voters: 1, + preloaded_voters: { + db753fe0bc4e72869ac1ad8765341764: [ + { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + ], + }, + chart_type: "bar", + title: null, + }, + ], + polls_votes: { poll: ["db753fe0bc4e72869ac1ad8765341764"] }, + }, + ], + stream: [156], + }, + timeline_lookup: [[1, 0]], + suggested_topics: [ + { + id: 7, + title: "Welcome to Discourse", + fancy_title: "Welcome to Discourse", + slug: "welcome-to-discourse", + posts_count: 9, + reply_count: 0, + highest_post_number: 9, + image_url: + "//localhost:3000/uploads/default/original/1X/ba1a510603f5112dcaf06cf42c2eb671bff83681.png", + created_at: "2021-06-02T16:21:38.347Z", + last_posted_at: "2021-06-08T20:36:29.235Z", + bumped: true, + bumped_at: "2021-06-08T20:36:29.235Z", + archetype: "regular", + unseen: false, + last_read_post_number: 9, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: true, + visible: true, + closed: false, + archived: false, + notification_level: 2, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 2, + category_id: 1, + featured_link: null, + has_accepted_answer: false, + posters: [ + { + extras: null, + description: "Original Poster", + user: { + id: -1, + username: "system", + name: "system", + avatar_template: "/images/discourse-logo-sketch-small.png", + }, + }, + { + extras: "latest", + description: "Most Recent Poster", + user: { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + }, + ], + }, + { + id: 129, + title: "This is another test topic", + fancy_title: "This is another test topic", + slug: "this-is-another-test-topic", + posts_count: 1, + reply_count: 0, + highest_post_number: 1, + image_url: null, + created_at: "2021-06-03T15:48:27.262Z", + last_posted_at: "2021-06-03T15:48:27.537Z", + bumped: true, + bumped_at: "2021-06-08T12:52:36.650Z", + archetype: "regular", + unseen: false, + last_read_post_number: 1, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 2, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 7, + category_id: 1, + featured_link: null, + has_accepted_answer: false, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user: { + id: 12, + username: "bar", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/b77776/{size}.png", + }, + }, + ], + }, + { + id: 131, + title: + "Welcome to Discourse — thanks for starting a new conversation!", + fancy_title: + "Welcome to Discourse — thanks for starting a new conversation!", + slug: "welcome-to-discourse-thanks-for-starting-a-new-conversation", + posts_count: 1, + reply_count: 0, + highest_post_number: 1, + image_url: null, + created_at: "2021-06-04T08:51:19.807Z", + last_posted_at: "2021-06-04T08:51:19.928Z", + bumped: true, + bumped_at: "2021-06-04T14:37:46.939Z", + archetype: "regular", + unseen: false, + last_read_post_number: 1, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: ["abc", "e", "b"], + like_count: 0, + views: 3, + category_id: 1, + featured_link: null, + has_accepted_answer: false, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user: { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + }, + ], + }, + { + id: 133, + title: "This is a new tpoic", + fancy_title: "This is a new tpoic", + slug: "this-is-a-new-tpoic", + posts_count: 12, + reply_count: 0, + highest_post_number: 12, + image_url: null, + created_at: "2021-06-08T14:44:03.664Z", + last_posted_at: "2021-06-08T19:57:35.853Z", + bumped: true, + bumped_at: "2021-06-08T19:57:35.853Z", + archetype: "regular", + unseen: false, + last_read_post_number: 12, + unread: 0, + new_posts: 0, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + notification_level: 3, + bookmarked: false, + liked: false, + tags: [], + like_count: 0, + views: 1, + category_id: 1, + featured_link: null, + has_accepted_answer: false, + posters: [ + { + extras: "latest single", + description: "Original Poster, Most Recent Poster", + user: { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + }, + ], + }, + ], + tags: [], + id: 134, + title: "Load more poll voters", + fancy_title: "Load more poll voters", + posts_count: 1, + created_at: "2021-06-08T21:56:55.073Z", + views: 4, + reply_count: 0, + like_count: 0, + last_posted_at: "2021-06-08T21:56:55.166Z", + visible: true, + closed: false, + archived: false, + has_summary: false, + archetype: "regular", + slug: "load-more-poll-voters", + category_id: 1, + word_count: 14, + deleted_at: null, + user_id: 1, + featured_link: null, + pinned_globally: false, + pinned_at: null, + pinned_until: null, + image_url: null, + slow_mode_seconds: 0, + draft: null, + draft_key: "topic_134", + draft_sequence: 7, + posted: true, + unpinned: null, + pinned: false, + current_post_number: 1, + highest_post_number: 1, + last_read_post_number: 1, + last_read_post_id: 156, + deleted_by: null, + has_deleted: false, + actions_summary: [ + { id: 4, count: 0, hidden: false, can_act: true }, + { id: 8, count: 0, hidden: false, can_act: true }, + { id: 7, count: 0, hidden: false, can_act: true }, + ], + chunk_size: 20, + bookmarked: false, + topic_timer: null, + message_bus_last_id: 5, + participant_count: 1, + queued_posts_count: 0, + show_read_indicator: false, + thumbnails: null, + slow_mode_enabled_until: null, + details: { + can_edit: true, + notification_level: 3, + notifications_reason_id: 1, + can_move_posts: true, + can_delete: true, + can_remove_allowed_users: true, + can_invite_to: true, + can_invite_via_email: true, + can_create_post: true, + can_reply_as_new_topic: true, + can_flag_topic: true, + can_convert_topic: true, + can_review_topic: true, + can_close_topic: true, + can_archive_topic: true, + can_split_merge_topic: true, + can_edit_staff_notes: true, + can_toggle_topic_visibility: true, + can_pin_unpin_topic: true, + can_moderate_category: true, + can_remove_self_id: 1, + participants: [ + { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + post_count: 1, + primary_group_name: null, + primary_group_flair_url: null, + primary_group_flair_color: null, + primary_group_flair_bg_color: null, + admin: true, + trust_level: 0, + }, + ], + created_by: { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + last_poster: { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + }, + pending_posts: [], + }); + }); + + server.get("/polls/voters.json", (request) => { + if ( + request.queryParams.option_id === "d8c22ff912e03740d9bc19e133e581e0" + ) { + return helper.response({ + voters: { + d8c22ff912e03740d9bc19e133e581e0: [ + { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + title: null, + }, + ], + }, + }); + } else { + return helper.response({ + voters: { + [request.queryParams.option_id]: [], + }, + }); + } + }); + }); + + test("can load more voters", async function (assert) { + await visit("/t/-/load-more-poll-voters"); + + assert.equal( + find(".poll-container .results li:nth-child(1) .poll-voters li").length, + 1 + ); + + publishToMessageBus("/polls/134", { + post_id: "156", + polls: [ + { + name: "poll", + type: "regular", + status: "open", + public: true, + results: "always", + options: [ + { + id: "db753fe0bc4e72869ac1ad8765341764", + html: 'Option #1', + votes: 1, + }, + { + id: "d8c22ff912e03740d9bc19e133e581e0", + html: 'Option #2', + votes: 1, + }, + ], + voters: 2, + preloaded_voters: { + db753fe0bc4e72869ac1ad8765341764: [ + { + id: 1, + username: "bianca", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png", + }, + ], + d8c22ff912e03740d9bc19e133e581e0: [ + { + id: 7, + username: "foo", + name: null, + avatar_template: + "/letter_avatar_proxy/v4/letter/f/b19c9b/{size}.png", + title: null, + }, + ], + }, + chart_type: "bar", + title: null, + }, + ], + }); + await visit("/t/-/load-more-poll-voters"); + + await click(".poll-voters-toggle-expand a"); + assert.equal( + find(".poll-container .results li:nth-child(1) .poll-voters li").length, + 0 + ); + assert.equal( + find(".poll-container .results li:nth-child(2) .poll-voters li").length, + 1 + ); + }); +}); From 6b3adeed0f1bce70b3036e51c5fb83351d531b1d Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 18 Jun 2021 19:54:16 +0200 Subject: [PATCH 104/403] UX: daily automatic grouping for less than 34 days instead of 30 (#13437) --- app/assets/javascripts/admin/addon/models/report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/admin/addon/models/report.js b/app/assets/javascripts/admin/addon/models/report.js index 1affb8e796..18caf66774 100644 --- a/app/assets/javascripts/admin/addon/models/report.js +++ b/app/assets/javascripts/admin/addon/models/report.js @@ -504,7 +504,7 @@ const Report = EmberObject.extend({ }); export const WEEKLY_LIMIT_DAYS = 365; -export const DAILY_LIMIT_DAYS = 30; +export const DAILY_LIMIT_DAYS = 34; Report.reopenClass({ groupingForDatapoints(count) { From fcc02412c0b43b929777ac43a80df3fadd5abd4a Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 18 Jun 2021 15:23:57 -0400 Subject: [PATCH 105/403] UX: Fix mobile progress bar button alignment (#13442) --- app/assets/stylesheets/common/topic-timeline.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/topic-timeline.scss b/app/assets/stylesheets/common/topic-timeline.scss index a0ab83ceac..b2fe0e9f00 100644 --- a/app/assets/stylesheets/common/topic-timeline.scss +++ b/app/assets/stylesheets/common/topic-timeline.scss @@ -123,6 +123,7 @@ .btn-group { margin-bottom: 0; margin-right: 15px; + vertical-align: top; } .widget-component-connector { From 497aae062ab0a2626fb5ddd939e8c53e3380e5d9 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 18 Jun 2021 16:37:17 -0400 Subject: [PATCH 106/403] UX: Fix jump-to-post layout on mobile (#13443) --- app/assets/stylesheets/common/base/modal.scss | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 7da41a3b23..1898e6c021 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -477,12 +477,16 @@ } .modal-body { overflow-y: visible; - #post-jump, - .date-picker { + #post-jump { margin: 0; width: 100px; } + .date-picker { + margin: 0; + width: 180px; + } + .input-hint-text { color: var(--primary); } @@ -491,8 +495,13 @@ color: var(--primary-medium); } - .jump-to-date-control .input-hint-text { - margin-left: 0; + .jump-to-date-control { + display: flex; + align-items: center; + .input-hint-text { + margin-left: 0; + margin-right: 0.5em; + } } .separator { From f0c10edd2813abc5afd7247fe2b348ec61065891 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 21 Jun 2021 09:33:32 +1000 Subject: [PATCH 107/403] FIX: Remove List-Unsubscribe header if using group SMTP (#13448) The other mailing list headers were removed if using group SMTP in ff6114d83f9c6203dee426968850023b77d1e031 --- app/mailers/user_notifications.rb | 5 ++++- spec/components/email/sender_spec.rb | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index a63c7b1447..7f8ce6f5f6 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -492,6 +492,7 @@ class UserNotifications < ActionMailer::Base from_address = nil delivery_method_options = nil use_from_address_for_reply_to = false + using_group_smtp = false template = +"user_notifications.user_#{notification_type}" if post.topic.private_message? @@ -562,6 +563,7 @@ class UserNotifications < ActionMailer::Base # will forward the email back into Discourse and process/link it correctly. use_from_address_for_reply_to = true from_address = group.email_username + using_group_smtp = true end if post.topic.private_message? @@ -711,7 +713,8 @@ class UserNotifications < ActionMailer::Base locale: locale, delivery_method_options: delivery_method_options, use_from_address_for_reply_to: use_from_address_for_reply_to, - from: from_address + from: from_address, + add_unsubscribe_link: !using_group_smtp } unless translation_override_exists diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index 27e93515af..3c56e93244 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -375,6 +375,7 @@ describe Email::Sender do expect(message.header['List-Post']).to eq(nil) expect(message.header['List-Archive']).to eq(nil) expect(message.header['Precedence']).to eq(nil) + expect(message.header['List-Unsubscribe']).to eq(nil) end end end From 22b96c9ce14a6387056f3f9bbeb564537448be1c Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 21 Jun 2021 11:45:00 +1000 Subject: [PATCH 108/403] FIX: Prevent resurrecting old topics via email reply for group inboxes with SMTP enabled (#13382) We already reject email replies to public topics via `SiteSetting.disallow_reply_by_email_after_days` and raising the `OldDestinationError`. This PR introduces similar behaviour for group inboxes, but without the rejection, and **only when SMTP is enabled for the group**. If a reply is sent via email and the post is older than `SiteSetting.disallow_reply_by_email_after_days` days ago, then we create a new topic instead of making a reply in the old one and link back to the original topic. This is done to prevent long running group inbox discussions. --- config/locales/server.en.yml | 5 ++- lib/email/receiver.rb | 62 +++++++++++++++++--------- spec/components/email/receiver_spec.rb | 50 +++++++++++++++++---- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cf7534a92f..dd99be5d7f 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -111,7 +111,10 @@ en: maximum_staged_user_per_email_reached: "Reached maximum number of staged users created per email." no_subject: "(no subject)" no_body: "(no body)" - missing_attachment: "(Attachment %{filename} is missing)" + missing_attachment: "(Attachment %{filename} is missing) ago." + continuing_old_discussion: + one: "Continuing the discussion from [%{title}](%{url}), because it was created more than %{count} day ago." + other: "Continuing the discussion from [%{title}](%{url}), because it was created more than %{count} days ago." errors: empty_email_error: "Happens when the raw mail we received was blank." no_message_id_error: "Happens when the mail has no 'Message-Id' header." diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index da699c08bc..a6da67cbaa 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -224,12 +224,12 @@ module Email # We don't stage new users for emails to reply addresses, exit if user is nil raise BadDestinationAddress if user.blank? + # We only get here if there are no destinations (the email is not going to + # a Category, Group, or PostReplyKey) post = find_related_post(force: true) if post && Guardian.new(user).can_see_post?(post) - num_of_days = SiteSetting.disallow_reply_by_email_after_days - - if num_of_days > 0 && post.created_at < num_of_days.days.ago + if destination_too_old?(post) raise OldDestinationError.new("#{Discourse.base_url}/p/#{post.id}") end end @@ -765,37 +765,47 @@ module Email .order(created_at: :desc) .limit(1) .pluck(:post_id).last - post_ids << post_id_from_email_log + post_ids << post_id_from_email_log if post_id_from_email_log end - if post_ids.any? && post = Post.where(id: post_ids).order(:created_at).last - - # this must be done for the unknown user (who is staged) to - # be allowed to post a reply in the topic - if group.allow_unknown_sender_topic_replies - post.topic.topic_allowed_users.find_or_create_by!(user_id: user.id) - end - - create_reply(user: user, - raw: body, - elided: elided, - post: post, - topic: post.topic, - skip_validations: true) - else - enable_email_pm_setting(user) + target_post = post_ids.any? && Post.where(id: post_ids).order(:created_at).last + too_old_for_group_smtp = (destination_too_old?(target_post) && group.smtp_enabled) + if target_post.blank? || too_old_for_group_smtp create_topic(user: user, - raw: body, + raw: new_group_topic_body(body, target_post, too_old_for_group_smtp), elided: elided, title: subject, archetype: Archetype.private_message, target_group_names: [group.name], is_group_message: true, skip_validations: true) + else + # This must be done for the unknown user (who is staged) to + # be allowed to post a reply in the topic. + if group.allow_unknown_sender_topic_replies + target_post.topic.topic_allowed_users.find_or_create_by!(user_id: user.id) + end + + create_reply(user: user, + raw: body, + elided: elided, + post: target_post, + topic: target_post.topic, + skip_validations: true) end end + def new_group_topic_body(body, target_post, too_old_for_group_smtp) + return body if !too_old_for_group_smtp + body + "\n\n----\n\n" + I18n.t( + "emails.incoming.continuing_old_discussion", + url: target_post.topic.url, + title: target_post.topic.title, + count: SiteSetting.disallow_reply_by_email_after_days + ) + end + def forwarded_reply_key?(post_reply_key, user) incoming_emails = IncomingEmail .joins(:post) @@ -1042,6 +1052,10 @@ module Email end def create_topic(options = {}) + if options[:archetype] == Archetype.private_message + enable_email_pm_setting(options[:user]) + end + create_post_with_attachments(options) end @@ -1327,5 +1341,11 @@ module Email user.user_option.update!(email_messages_level: UserOption.email_level_types[:always]) end end + + def destination_too_old?(post) + return false if post.blank? + num_of_days = SiteSetting.disallow_reply_by_email_after_days + num_of_days > 0 && post.created_at < num_of_days.days.ago + end end end diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 11a87b4e63..79e14445bc 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -986,8 +986,14 @@ describe Email::Receiver do end context "emailing a group by email_username and following reply flow" do - let!(:topic) do - group.update!(email_username: "team@somesmtpaddress.com") + let!(:original_inbound_email_topic) do + group.update!( + email_username: "team@somesmtpaddress.com", + smtp_server: "smtp.test.com", + smtp_port: 587, + smtp_ssl: true, + smtp_enabled: true + ) process(:email_to_group_email_username_1) Topic.last end @@ -999,6 +1005,7 @@ describe Email::Receiver do before do NotificationEmailer.enable + SiteSetting.disallow_reply_by_email_after_days = 10000 Jobs.run_immediately! end @@ -1006,17 +1013,17 @@ describe Email::Receiver do group_post = PostCreator.create( user_in_group, raw: "Thanks for your request. Please try to restart.", - topic_id: topic.id + topic_id: original_inbound_email_topic.id ) email_log = EmailLog.last [email_log, group_post] end it "the inbound processed email creates an incoming email and topic record correctly, and adds the group to the topic" do - incoming = IncomingEmail.find_by(topic: topic) + incoming = IncomingEmail.find_by(topic: original_inbound_email_topic) user = User.find_by_email("two@foo.com") - expect(topic.topic_allowed_users.first.user_id).to eq(user.id) - expect(topic.topic_allowed_groups.first.group_id).to eq(group.id) + expect(original_inbound_email_topic.topic_allowed_users.first.user_id).to eq(user.id) + expect(original_inbound_email_topic.topic_allowed_groups.first.group_id).to eq(group.id) expect(incoming.to_addresses).to eq("team@somesmtpaddress.com") expect(incoming.from_address).to eq("two@foo.com") expect(incoming.message_id).to eq("u4w8c9r4y984yh98r3h69873@foo.bar.mail") @@ -1024,7 +1031,7 @@ describe Email::Receiver do it "creates an EmailLog when someone from the group replies, and does not create an IncomingEmail record for the reply" do email_log, group_post = reply_as_group_user - expect(email_log.message_id).to eq("topic/#{topic.id}/#{group_post.id}@test.localhost") + expect(email_log.message_id).to eq("topic/#{original_inbound_email_topic.id}/#{group_post.id}@test.localhost") expect(email_log.to_address).to eq("two@foo.com") expect(email_log.email_type).to eq("user_private_message") expect(email_log.post_id).to eq(group_post.id) @@ -1056,7 +1063,7 @@ describe Email::Receiver do end.to change { Topic.count }.by(1).and change { Post.count }.by(1) reply_post = Post.last - expect(reply_post.topic_id).not_to eq(topic.id) + expect(reply_post.topic_id).not_to eq(original_inbound_email_topic.id) end it "processes the reply from the user as a reply if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is enabled" do @@ -1070,7 +1077,32 @@ describe Email::Receiver do end.to change { Topic.count }.by(0).and change { Post.count }.by(1) reply_post = Post.last - expect(reply_post.topic_id).to eq(topic.id) + expect(reply_post.topic_id).to eq(original_inbound_email_topic.id) + end + + it "creates a new topic with a reference back to the original if replying to a too old topic" do + SiteSetting.disallow_reply_by_email_after_days = 2 + email_log, group_post = reply_as_group_user + + group_post.update(created_at: 10.days.ago) + group_post.topic.update(created_at: 10.days.ago) + + reply_email = email(:email_to_group_email_username_2) + reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) + expect do + Email::Receiver.new(reply_email).process! + end.to change { Topic.count }.by(1).and change { Post.count }.by(1) + + reply_post = Post.last + new_topic = Topic.last + + expect(reply_post.topic).to eq(new_topic) + expect(reply_post.raw).to include( + I18n.t( + "emails.incoming.continuing_old_discussion", + url: group_post.topic.url, title: group_post.topic.title, count: SiteSetting.disallow_reply_by_email_after_days + ) + ) end end From 06fa1efd3d8a935a5ae3e7876dbbaa3193316d7c Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 18 Jun 2021 09:04:18 +0800 Subject: [PATCH 109/403] PERF: Cache categories in Site model take 2. Follow-up to aa4f0aee67d6f9802856ab4abb5a7560359854b6. Fixed the security problem in the previous attempt. --- app/models/category.rb | 5 ++ app/models/category_tag.rb | 4 ++ app/models/category_tag_group.rb | 4 ++ app/models/site.rb | 36 ++++++++++-- app/serializers/site_serializer.rb | 8 ++- lib/guardian/category_guardian.rb | 8 +++ spec/models/site_spec.rb | 71 ++++++++++++++++++------ spec/serializers/site_serializer_spec.rb | 29 ++++++++-- 8 files changed, 136 insertions(+), 29 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 6692d2d1fd..93ee75536e 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -91,6 +91,7 @@ class Category < ActiveRecord::Base after_commit :trigger_category_created_event, on: :create after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy + after_commit :clear_site_cache after_save_commit :index_search @@ -957,6 +958,10 @@ class Category < ActiveRecord::Base result.map { |row| [row.group_id, row.permission_type] } end + + def clear_site_cache + Site.clear_cache + end end # == Schema Information diff --git a/app/models/category_tag.rb b/app/models/category_tag.rb index 1e21409c31..e9cba7c189 100644 --- a/app/models/category_tag.rb +++ b/app/models/category_tag.rb @@ -3,6 +3,10 @@ class CategoryTag < ActiveRecord::Base belongs_to :category belongs_to :tag + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/category_tag_group.rb b/app/models/category_tag_group.rb index 06e64ad65f..ea27bc50c1 100644 --- a/app/models/category_tag_group.rb +++ b/app/models/category_tag_group.rb @@ -3,6 +3,10 @@ class CategoryTagGroup < ActiveRecord::Base belongs_to :category belongs_to :tag_group + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/site.rb b/app/models/site.rb index f8c131ef1d..cf59a1f156 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -28,16 +28,42 @@ class Site UserField.order(:position).all end - def categories - @categories ||= begin + CATEGORIES_CACHE_KEY = "site_categories" + + def self.clear_cache + Discourse.cache.delete(CATEGORIES_CACHE_KEY) + end + + def self.all_categories_cache + # Categories do not change often so there is no need for us to run the + # same query and spend time creating ActiveRecord objects for every requests. + # + # Do note that any new association added to the eager loading needs a + # corresponding ActiveRecord callback to clear the categories cache. + Discourse.cache.fetch(CATEGORIES_CACHE_KEY, expires_in: 30.minutes) do categories = Category - .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups) - .secured(@guardian) + .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups, :required_tag_group) .joins('LEFT JOIN topics t on t.id = categories.topic_id') .select('categories.*, t.slug topic_slug') .order(:position) + .to_a - categories = categories.to_a + ActiveModel::ArraySerializer.new( + categories, + each_serializer: SiteCategorySerializer + ).as_json + end + end + + def categories + @categories ||= begin + categories = [] + + self.class.all_categories_cache.each do |category| + if @guardian.can_see_serialized_category?(category_id: category[:id], read_restricted: category[:read_restricted]) + categories << OpenStruct.new(category) + end + end with_children = Set.new categories.each do |c| diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 40da58e355..c7d8d5d66c 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -30,10 +30,10 @@ class SiteSerializer < ApplicationSerializer :shared_drafts_category_id, :custom_emoji_translation, :watched_words_replace, - :watched_words_link + :watched_words_link, + :categories ) - has_many :categories, serializer: SiteCategorySerializer, embed: :objects has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serializer: UserFieldSerializer has_many :auth_providers, embed: :objects, serializer: AuthProviderSerializer @@ -190,6 +190,10 @@ class SiteSerializer < ApplicationSerializer WordWatcher.word_matcher_regexps(:link) end + def categories + object.categories.map { |c| c.to_h } + end + private def ordered_flags(flags) diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index 4e9050f29a..ef8441071b 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -46,6 +46,14 @@ module CategoryGuardian nil end + def can_see_serialized_category?(category_id:, read_restricted: true) + # Guard to ensure only a boolean is passed in + read_restricted = true unless !!read_restricted == read_restricted + + return true if !read_restricted + secure_category_ids.include?(category_id) + end + def can_see_category?(category) return false unless category return true if is_admin? diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index 78b3faec63..637c812de8 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -58,30 +58,65 @@ describe Site do expect(Site.new(guardian).categories.last.notification_level).to eq(1) end - it "omits categories users can not write to from the category list" do - category = Fabricate(:category) - user = Fabricate(:user) + describe '#categories' do + fab!(:category) { Fabricate(:category) } + fab!(:user) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user) } - expect(Site.new(Guardian.new(user)).categories.count).to eq(2) + after do + Site.clear_cache + end - category.set_permissions(everyone: :create_post) - category.save + it "omits read restricted categories" do + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id, category.id + ) - guardian = Guardian.new(user) + category.update!(read_restricted: true) - expect(Site.new(guardian) - .categories - .keep_if { |c| c.name == category.name } - .first - .permission) - .not_to eq(CategoryGroup.permission_types[:full]) + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id + ) + end - # If a parent category is not visible, the child categories should not be returned - category.set_permissions(staff: :full) - category.save + it "includes categories that a user's group can see" do + group = Fabricate(:group) + category.update!(read_restricted: true) + category.groups << group - sub_category = Fabricate(:category, parent_category_id: category.id) - expect(Site.new(guardian).categories).not_to include(sub_category) + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id + ) + + group.add(user) + + expect(Site.new(Guardian.new(user)).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id, category.id + ) + end + + it "omits categories users can not write to from the category list" do + expect(Site.new(guardian).categories.count).to eq(2) + + category.set_permissions(everyone: :create_post) + category.save! + + guardian = Guardian.new(user) + + expect(Site.new(guardian) + .categories + .keep_if { |c| c.name == category.name } + .first + .permission) + .not_to eq(CategoryGroup.permission_types[:full]) + + # If a parent category is not visible, the child categories should not be returned + category.set_permissions(staff: :full) + category.save! + + sub_category = Fabricate(:category, parent_category_id: category.id) + expect(Site.new(guardian).categories).not_to include(sub_category) + end end it "omits groups user can not see" do diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 1a53cfd11f..5bf58564c0 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -10,13 +10,34 @@ describe SiteSerializer do category.custom_fields["enable_marketplace"] = true category.save_custom_fields - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).not_to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:preloaded_custom_fields]).to eq(nil) Site.preloaded_category_custom_fields << "enable_marketplace" - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:preloaded_custom_fields]["enable_marketplace"]).to eq("t") + end + + it "includes category tags" do + tag = Fabricate(:tag) + tag_group = Fabricate(:tag_group) + tag_group_2 = Fabricate(:tag_group) + + category.tags << tag + category.tag_groups << tag_group + category.update!(required_tag_group: tag_group_2) + + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:allowed_tags]).to contain_exactly(tag.name) + expect(c1[:allowed_tag_groups]).to contain_exactly(tag_group.name) + expect(c1[:required_tag_group_name]).to eq(tag_group_2.name) end it "returns correct notification level for categories" do From 53dab8cf1ecf08979c0f4c17fdd8f2181b77b5ba Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 21 Jun 2021 11:50:52 +1000 Subject: [PATCH 110/403] DEV: Replace const munging in specs with stub_const helper --- spec/components/topic_view_spec.rb | 9 +-------- spec/requests/groups_controller_spec.rb | 9 +-------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index d934128688..cfda8b6ef4 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -802,19 +802,12 @@ describe TopicView do describe 'for mega topics' do it 'should return the right columns' do - begin - original_const = TopicView::MEGA_TOPIC_POSTS_COUNT - TopicView.send(:remove_const, "MEGA_TOPIC_POSTS_COUNT") - TopicView.const_set("MEGA_TOPIC_POSTS_COUNT", 2) - + stub_const(TopicView, "MEGA_TOPIC_POSTS_COUNT", 2) do expect(topic_view.filtered_post_stream).to eq([ post.id, post2.id, post3.id ]) - ensure - TopicView.send(:remove_const, "MEGA_TOPIC_POSTS_COUNT") - TopicView.const_set("MEGA_TOPIC_POSTS_COUNT", original_const) end end end diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index 1ef1174942..340376607a 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -1309,11 +1309,7 @@ describe GroupsController do end it 'display error when try to add to many users at once' do - begin - old_constant = GroupsController.const_get("ADD_MEMBERS_LIMIT") - GroupsController.send(:remove_const, "ADD_MEMBERS_LIMIT") - GroupsController.const_set("ADD_MEMBERS_LIMIT", 1) - + stub_const(GroupsController, "ADD_MEMBERS_LIMIT", 1) do expect do put "/groups/#{group.id}/members.json", params: { user_emails: [user1.email, user2.email].join(",") } @@ -1325,9 +1321,6 @@ describe GroupsController do "groups.errors.adding_too_many_users", count: 1 )) - ensure - GroupsController.send(:remove_const, "ADD_MEMBERS_LIMIT") - GroupsController.const_set("ADD_MEMBERS_LIMIT", old_constant) end end end From 8e3691d5370bb95d99fe750f46287763721fcc9c Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Tue, 15 Jun 2021 14:57:17 +0800 Subject: [PATCH 111/403] PERF: Eager load Theme associations in Stylesheet Manager. Before this change, calling `StyleSheet::Manager.stylesheet_details` for the first time resulted in multiple queries to the database. This is because the code was modelled in a way where each `Theme` was loaded from the database one at a time. This PR restructures the code such that it allows us to load all the theme records in a single query. It also allows us to eager load the required associations upfront. In order to achieve this, I removed the support of loading multiple themes per request. It was initially added to support user selectable theme components but the feature was never completed and abandoned because it wasn't a feature that we thought was worth building. --- app/controllers/application_controller.rb | 43 +- app/controllers/bootstrap_controller.rb | 19 +- app/controllers/qunit_controller.rb | 2 +- app/controllers/stylesheets_controller.rb | 24 +- app/controllers/svg_sprite_controller.rb | 8 +- app/helpers/application_helper.rb | 48 +- app/helpers/qunit_helper.rb | 2 +- app/models/color_scheme.rb | 1 + app/models/theme.rb | 69 +-- .../_discourse_publish_stylesheet.html.erb | 2 +- .../common/_discourse_stylesheet.html.erb | 3 +- app/views/layouts/application.html.erb | 2 +- app/views/layouts/crawler.html.erb | 2 +- app/views/layouts/no_ember.html.erb | 7 +- config/routes.rb | 2 +- lib/content_security_policy.rb | 8 +- lib/content_security_policy/extension.rb | 15 +- lib/content_security_policy/middleware.rb | 6 +- lib/middleware/anonymous_cache.rb | 6 +- lib/stylesheet/importer.rb | 2 +- lib/stylesheet/manager.rb | 493 ++++++------------ lib/stylesheet/manager/builder.rb | 274 ++++++++++ lib/stylesheet/manager/scss_checker.rb | 35 ++ lib/svg_sprite/svg_sprite.rb | 52 +- lib/theme_modifier_helper.rb | 2 +- spec/components/scss_checker_spec.rb | 36 ++ spec/components/stylesheet/manager_spec.rb | 307 ++++++++--- spec/components/svg_sprite/svg_sprite_spec.rb | 46 +- spec/helpers/application_helper_spec.rb | 4 +- spec/lib/theme_flag_modifier_spec.rb | 2 +- spec/models/color_scheme_spec.rb | 9 +- spec/models/theme_spec.rb | 83 ++- spec/requests/application_controller_spec.rb | 26 +- spec/requests/safe_mode_controller_spec.rb | 2 + spec/requests/stylesheets_controller_spec.rb | 9 +- 35 files changed, 983 insertions(+), 668 deletions(-) create mode 100644 lib/stylesheet/manager/builder.rb create mode 100644 lib/stylesheet/manager/scss_checker.rb create mode 100644 spec/components/scss_checker_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7ec3091869..e7fcbdcd25 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,7 +10,7 @@ class ApplicationController < ActionController::Base include Hijack include ReadOnlyHeader - attr_reader :theme_ids + attr_reader :theme_id serialization_scope :guardian @@ -448,35 +448,34 @@ class ApplicationController < ActionController::Base resolve_safe_mode return if request.env[NO_CUSTOM] - theme_ids = [] + theme_id = nil - if preview_theme_id = request[:preview_theme_id]&.to_i - ids = [preview_theme_id] - theme_ids = ids if guardian.allow_themes?(ids, include_preview: true) + if (preview_theme_id = request[:preview_theme_id]&.to_i) && + guardian.allow_themes?([preview_theme_id], include_preview: true) + + theme_id = preview_theme_id end user_option = current_user&.user_option - if theme_ids.blank? + if theme_id.blank? ids, seq = cookies[:theme_ids]&.split("|") - ids = ids&.split(",")&.map(&:to_i) - if ids.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i - theme_ids = ids if guardian.allow_themes?(ids) + id = ids&.split(",")&.map(&:to_i)&.first + if id.present? && seq && seq.to_i == user_option&.theme_key_seq.to_i + theme_id = id if guardian.allow_themes?([id]) end end - if theme_ids.blank? + if theme_id.blank? ids = user_option&.theme_ids || [] - theme_ids = ids if guardian.allow_themes?(ids) + theme_id = ids.first if guardian.allow_themes?(ids) end - if theme_ids.blank? && SiteSetting.default_theme_id != -1 - if guardian.allow_themes?([SiteSetting.default_theme_id]) - theme_ids << SiteSetting.default_theme_id - end + if theme_id.blank? && SiteSetting.default_theme_id != -1 && guardian.allow_themes?([SiteSetting.default_theme_id]) + theme_id = SiteSetting.default_theme_id end - @theme_ids = request.env[:resolved_theme_ids] = theme_ids + @theme_id = request.env[:resolved_theme_id] = theme_id end def guardian @@ -635,10 +634,10 @@ class ApplicationController < ActionController::Base target = view_context.mobile_view? ? :mobile : :desktop data = - if @theme_ids.present? + if @theme_id.present? { - top: Theme.lookup_field(@theme_ids, target, "after_header"), - footer: Theme.lookup_field(@theme_ids, target, "footer") + top: Theme.lookup_field(@theme_id, target, "after_header"), + footer: Theme.lookup_field(@theme_id, target, "footer") } else {} @@ -943,9 +942,9 @@ class ApplicationController < ActionController::Base end def activated_themes_json - ids = @theme_ids&.compact - return "{}" if ids.blank? - ids = Theme.transform_ids(ids) + id = @theme_id + return "{}" if id.blank? + ids = Theme.transform_ids(id) Theme.where(id: ids).pluck(:id, :name).to_h.to_json end end diff --git a/app/controllers/bootstrap_controller.rb b/app/controllers/bootstrap_controller.rb index 0921e0aab8..5bb4dc6ddb 100644 --- a/app/controllers/bootstrap_controller.rb +++ b/app/controllers/bootstrap_controller.rb @@ -34,7 +34,7 @@ class BootstrapController < ApplicationController ).each do |file| add_style(file, plugin: true) end - add_style(mobile_view? ? :mobile_theme : :desktop_theme) if theme_ids.present? + add_style(mobile_view? ? :mobile_theme : :desktop_theme) if theme_id.present? extra_locales = [] if ExtraLocalesController.client_overrides_exist? @@ -51,7 +51,7 @@ class BootstrapController < ApplicationController ).map { |f| script_asset_path(f) } bootstrap = { - theme_ids: theme_ids, + theme_ids: [theme_id], title: SiteSetting.title, current_homepage: current_homepage, locale_script: locale, @@ -75,15 +75,14 @@ class BootstrapController < ApplicationController private def add_scheme(scheme_id, media) return if scheme_id.to_i == -1 - theme_id = theme_ids&.first - if style = Stylesheet::Manager.color_scheme_stylesheet_details(scheme_id, media, theme_id) + if style = Stylesheet::Manager.new(theme_id: theme_id).color_scheme_stylesheet_details(scheme_id, media) @stylesheets << { href: style[:new_href], media: media } end end def add_style(target, opts = nil) - if styles = Stylesheet::Manager.stylesheet_details(target, 'all', theme_ids) + if styles = Stylesheet::Manager.new(theme_id: theme_id).stylesheet_details(target, 'all') styles.each do |style| @stylesheets << { href: style[:new_href], @@ -117,11 +116,11 @@ private theme_view = mobile_view? ? :mobile : :desktop - add_if_present(theme_html, :body_tag, Theme.lookup_field(theme_ids, theme_view, 'body_tag')) - add_if_present(theme_html, :head_tag, Theme.lookup_field(theme_ids, theme_view, 'head_tag')) - add_if_present(theme_html, :header, Theme.lookup_field(theme_ids, theme_view, 'header')) - add_if_present(theme_html, :translations, Theme.lookup_field(theme_ids, :translations, I18n.locale)) - add_if_present(theme_html, :js, Theme.lookup_field(theme_ids, :extra_js, nil)) + add_if_present(theme_html, :body_tag, Theme.lookup_field(theme_id, theme_view, 'body_tag')) + add_if_present(theme_html, :head_tag, Theme.lookup_field(theme_id, theme_view, 'head_tag')) + add_if_present(theme_html, :header, Theme.lookup_field(theme_id, theme_view, 'header')) + add_if_present(theme_html, :translations, Theme.lookup_field(theme_id, :translations, I18n.locale)) + add_if_present(theme_html, :js, Theme.lookup_field(theme_id, :extra_js, nil)) theme_html end diff --git a/app/controllers/qunit_controller.rb b/app/controllers/qunit_controller.rb index 8bb951b0fa..98f7d70dac 100644 --- a/app/controllers/qunit_controller.rb +++ b/app/controllers/qunit_controller.rb @@ -43,7 +43,7 @@ class QunitController < ApplicationController return end - request.env[:resolved_theme_ids] = [theme.id] + request.env[:resolved_theme_id] = theme.id request.env[:skip_theme_ids_transformation] = true end diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb index d9446882a2..427f2b9203 100644 --- a/app/controllers/stylesheets_controller.rb +++ b/app/controllers/stylesheets_controller.rb @@ -19,7 +19,8 @@ class StylesheetsController < ApplicationController params.require("id") params.permit("theme_id") - stylesheet = Stylesheet::Manager.color_scheme_stylesheet_details(params[:id], 'all', params[:theme_id]) + manager = Stylesheet::Manager.new(theme_id: params[:theme_id]) + stylesheet = manager.color_scheme_stylesheet_details(params[:id], 'all') render json: stylesheet end protected @@ -40,16 +41,19 @@ class StylesheetsController < ApplicationController # we hold off re-compilation till someone asks for asset if target.include?("color_definitions") split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - Stylesheet::Manager.color_scheme_stylesheet_link_tag(color_scheme_id) + + Stylesheet::Manager.new.color_scheme_stylesheet_link_tag(color_scheme_id) else - if target.include?("theme") - split_target, theme_id = target.split(/_(-?[0-9]+)/) - theme = Theme.find_by(id: theme_id) if theme_id.present? - else - split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - theme = Theme.find_by(color_scheme_id: color_scheme_id) - end - Stylesheet::Manager.stylesheet_link_tag(split_target, nil, theme&.id) + theme_id = + if target.include?("theme") + split_target, theme_id = target.split(/_(-?[0-9]+)/) + Theme.where(id: theme_id).pluck_first(:id) if theme_id.present? + else + split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) + Theme.where(color_scheme_id: color_scheme_id).pluck_first(:id) + end + + Stylesheet::Manager.new(theme_id: theme_id).stylesheet_link_tag(split_target, nil) end end diff --git a/app/controllers/svg_sprite_controller.rb b/app/controllers/svg_sprite_controller.rb index 5851e4b564..259fb11c32 100644 --- a/app/controllers/svg_sprite_controller.rb +++ b/app/controllers/svg_sprite_controller.rb @@ -12,13 +12,13 @@ class SvgSpriteController < ApplicationController no_cookies RailsMultisite::ConnectionManagement.with_hostname(params[:hostname]) do - theme_ids = params[:theme_ids].split(",").map(&:to_i) + theme_id = params[:theme_id].to_i - if SvgSprite.version(theme_ids) != params[:version] - return redirect_to path(SvgSprite.path(theme_ids)) + if SvgSprite.version(theme_id) != params[:version] + return redirect_to path(SvgSprite.path(theme_id)) end - svg_sprite = "window.__svg_sprite = #{SvgSprite.bundle(theme_ids).inspect};" + svg_sprite = "window.__svg_sprite = #{SvgSprite.bundle(theme_id).inspect};" response.headers["Last-Modified"] = 10.years.ago.httpdate response.headers["Content-Length"] = svg_sprite.bytesize.to_s diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 702f83ca5a..480b242969 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -408,14 +408,19 @@ module ApplicationHelper end end - def theme_ids + def theme_id if customization_disabled? - [nil] + nil else - request.env[:resolved_theme_ids] + request.env[:resolved_theme_id] end end + def stylesheet_manager + return @stylesheet_manager if defined?(@stylesheet_manager) + @stylesheet_manager = Stylesheet::Manager.new(theme_id: theme_id) + end + def scheme_id return @scheme_id if defined?(@scheme_id) @@ -424,12 +429,9 @@ module ApplicationHelper return custom_user_scheme_id end - return if theme_ids.blank? + return if theme_id.blank? - @scheme_id = Theme - .where(id: theme_ids.first) - .pluck(:color_scheme_id) - .first + @scheme_id = Theme.where(id: theme_id).pluck_first(:color_scheme_id) end def dark_scheme_id @@ -457,7 +459,7 @@ module ApplicationHelper def theme_lookup(name) Theme.lookup_field( - theme_ids, + theme_id, mobile_view? ? :mobile : :desktop, name, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -466,7 +468,7 @@ module ApplicationHelper def theme_translations_lookup Theme.lookup_field( - theme_ids, + theme_id, :translations, I18n.locale, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -475,7 +477,7 @@ module ApplicationHelper def theme_js_lookup Theme.lookup_field( - theme_ids, + theme_id, :extra_js, nil, skip_transformation: request.env[:skip_theme_ids_transformation].present? @@ -483,22 +485,26 @@ module ApplicationHelper end def discourse_stylesheet_link_tag(name, opts = {}) - if opts.key?(:theme_ids) - ids = opts[:theme_ids] unless customization_disabled? - else - ids = theme_ids - end + manager = + if opts.key?(:theme_id) + Stylesheet::Manager.new( + theme_id: customization_disabled? ? nil : opts[:theme_id] + ) + else + stylesheet_manager + end - Stylesheet::Manager.stylesheet_link_tag(name, 'all', ids) + manager.stylesheet_link_tag(name, 'all') end def discourse_color_scheme_stylesheets result = +"" - result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(scheme_id, 'all', theme_ids) + result << stylesheet_manager.color_scheme_stylesheet_link_tag(scheme_id, 'all') if dark_scheme_id != -1 - result << Stylesheet::Manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)', theme_ids) + result << stylesheet_manager.color_scheme_stylesheet_link_tag(dark_scheme_id, '(prefers-color-scheme: dark)') end + result.html_safe end @@ -525,7 +531,7 @@ module ApplicationHelper asset_version: Discourse.assets_digest, disable_custom_css: loading_admin?, highlight_js_path: HighlightJs.path, - svg_sprite_path: SvgSprite.path(theme_ids), + svg_sprite_path: SvgSprite.path(theme_id), enable_js_error_reporting: GlobalSetting.enable_js_error_reporting, color_scheme_is_dark: dark_color_scheme?, user_color_scheme_id: scheme_id, @@ -533,7 +539,7 @@ module ApplicationHelper } if Rails.env.development? - setup_data[:svg_icon_list] = SvgSprite.all_icons(theme_ids) + setup_data[:svg_icon_list] = SvgSprite.all_icons(theme_id) if ENV['DEBUG_PRELOADED_APP_DATA'] setup_data[:debug_preloaded_app_data] = true diff --git a/app/helpers/qunit_helper.rb b/app/helpers/qunit_helper.rb index 98f509c664..e0376a1ad2 100644 --- a/app/helpers/qunit_helper.rb +++ b/app/helpers/qunit_helper.rb @@ -2,7 +2,7 @@ module QunitHelper def theme_tests - theme = Theme.find_by(id: request.env[:resolved_theme_ids]&.first) + theme = Theme.find_by(id: request.env[:resolved_theme_id]) return "" if theme.blank? _, digest = theme.baked_js_tests_with_digest diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index dd2cf021cc..22682be18d 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -320,6 +320,7 @@ class ColorScheme < ActiveRecord::Base end if theme_ids.present? Stylesheet::Manager.cache.clear + Theme.notify_theme_change( theme_ids, with_scheme: true, diff --git a/app/models/theme.rb b/app/models/theme.rb index 8d3cd01c9b..ad5f63be4c 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -29,6 +29,9 @@ class Theme < ActiveRecord::Base has_many :locale_fields, -> { filter_locale_fields(I18n.fallbacks[I18n.locale]) }, class_name: 'ThemeField' has_many :upload_fields, -> { where(type_id: ThemeField.types[:theme_upload_var]).preload(:upload) }, class_name: 'ThemeField' has_many :extra_scss_fields, -> { where(target_id: Theme.targets[:extra_scss]) }, class_name: 'ThemeField' + has_many :yaml_theme_fields, -> { where("name = 'yaml' AND type_id = ?", ThemeField.types[:yaml]) }, class_name: 'ThemeField' + has_many :var_theme_fields, -> { where("type_id IN (?)", ThemeField.theme_var_type_ids) }, class_name: 'ThemeField' + has_many :builder_theme_fields, -> { where("name IN (?)", ThemeField.scss_fields) }, class_name: 'ThemeField' validate :component_validations @@ -164,6 +167,16 @@ class Theme < ActiveRecord::Base end end + def self.parent_theme_ids + get_set_cache "parent_theme_ids" do + Theme.where(component: false).pluck(:id) + end + end + + def self.is_parent_theme?(id) + self.parent_theme_ids.include?(id) + end + def self.user_theme_ids get_set_cache "user_theme_ids" do Theme.user_selectable.pluck(:id) @@ -188,25 +201,22 @@ class Theme < ActiveRecord::Base expire_site_cache! end - def self.transform_ids(ids, extend: true) - return [] if ids.nil? - get_set_cache "#{extend ? "extended_" : ""}transformed_ids_#{ids.join("_")}" do - next [] if ids.blank? + def self.transform_ids(id) + return [] if id.blank? - ids = ids.dup - ids.uniq! - parent = ids.shift - - components = ids - components.push(*components_for(parent)) if extend - components.sort!.uniq! - - all_ids = [parent, *components] + get_set_cache "transformed_ids_#{id}" do + all_ids = + if self.is_parent_theme?(id) + components = components_for(id).tap { |c| c.sort!.uniq! } + [id, *components] + else + [id] + end disabled_ids = Theme.where(id: all_ids) .includes(:remote_theme) .select { |t| !t.supported? || !t.enabled? } - .pluck(:id) + .map(&:id) all_ids - disabled_ids end @@ -272,11 +282,10 @@ class Theme < ActiveRecord::Base end end - def self.lookup_field(theme_ids, target, field, skip_transformation: false) - return if theme_ids.blank? - theme_ids = [theme_ids] unless Array === theme_ids + def self.lookup_field(theme_id, target, field, skip_transformation: false) + return "" if theme_id.blank? - theme_ids = transform_ids(theme_ids) if !skip_transformation + theme_ids = !skip_transformation ? transform_ids(theme_id) : [theme_id] cache_key = "#{theme_ids.join(",")}:#{target}:#{field}:#{Theme.compiler_version}" lookup = @cache[cache_key] return lookup.html_safe if lookup @@ -289,8 +298,8 @@ class Theme < ActiveRecord::Base def self.lookup_modifier(theme_ids, modifier_name) theme_ids = [theme_ids] unless Array === theme_ids - theme_ids = transform_ids(theme_ids) + get_set_cache("#{theme_ids.join(",")}:modifier:#{modifier_name}:#{Theme.compiler_version}") do ThemeModifierSet.resolve_modifier_for_themes(theme_ids, modifier_name) end @@ -335,14 +344,18 @@ class Theme < ActiveRecord::Base def notify_theme_change(with_scheme: false) DB.after_commit do - theme_ids = Theme.transform_ids([id]) + theme_ids = Theme.transform_ids(id) self.class.notify_theme_change(theme_ids, with_scheme: with_scheme) end end def self.refresh_message_for_targets(targets, theme_ids) - targets.map do |target| - Stylesheet::Manager.stylesheet_data(target.to_sym, theme_ids) + theme_ids = [theme_ids] unless theme_ids === Array + + targets.each_with_object([]) do |target, data| + theme_ids.each do |theme_id| + data << Stylesheet::Manager.new(theme_id: theme_id).stylesheet_data(target.to_sym) + end end end @@ -385,7 +398,8 @@ class Theme < ActiveRecord::Base end def list_baked_fields(target, name) - theme_ids = Theme.transform_ids([id], extend: name == :color_definitions) + theme_ids = Theme.transform_ids(id) + theme_ids = [theme_ids.first] if name != :color_definitions self.class.list_baked_fields(theme_ids, target, name) end @@ -435,7 +449,7 @@ class Theme < ActiveRecord::Base def all_theme_variables fields = {} - ids = Theme.transform_ids([id]) + ids = Theme.transform_ids(id) ThemeField.find_by_theme_ids(ids).where(type_id: ThemeField.theme_var_type_ids).each do |field| next if fields.key?(field.name) fields[field.name] = field @@ -530,7 +544,7 @@ class Theme < ActiveRecord::Base def included_settings hash = {} - Theme.where(id: Theme.transform_ids([id])).each do |theme| + Theme.where(id: Theme.transform_ids(id)).each do |theme| hash.merge!(theme.cached_settings) end @@ -641,11 +655,6 @@ class Theme < ActiveRecord::Base contents end - def has_scss(target) - name = target == :embedded_theme ? :embedded_scss : :scss - list_baked_fields(target, name).count > 0 - end - def convert_settings settings.each do |setting| setting_row = ThemeSetting.where(theme_id: self.id, name: setting.name.to_s).first diff --git a/app/views/common/_discourse_publish_stylesheet.html.erb b/app/views/common/_discourse_publish_stylesheet.html.erb index ddd5b9e40b..90fe9a9f9e 100644 --- a/app/views/common/_discourse_publish_stylesheet.html.erb +++ b/app/views/common/_discourse_publish_stylesheet.html.erb @@ -10,6 +10,6 @@ <%= discourse_stylesheet_link_tag(file) %> <%- end %> -<%- if theme_ids.present? %> +<%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> diff --git a/app/views/common/_discourse_stylesheet.html.erb b/app/views/common/_discourse_stylesheet.html.erb index 700e28cf00..3ef9c170c3 100644 --- a/app/views/common/_discourse_stylesheet.html.erb +++ b/app/views/common/_discourse_stylesheet.html.erb @@ -14,7 +14,6 @@ <%= discourse_stylesheet_link_tag(file) %> <%- end %> -<%- if theme_ids.present? %> +<%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2d9445a327..47159a614a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -5,7 +5,7 @@ <%= content_for?(:title) ? yield(:title) : SiteSetting.title %> - "> + <%= render partial: "layouts/head" %> <%= discourse_csrf_tags %> diff --git a/app/views/layouts/crawler.html.erb b/app/views/layouts/crawler.html.erb index 4e0a7e7bdd..ec1f753d6e 100644 --- a/app/views/layouts/crawler.html.erb +++ b/app/views/layouts/crawler.html.erb @@ -10,7 +10,7 @@ <%- else %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %> <%- end %> - <%- if theme_ids.present? %> + <%- if theme_id.present? %> <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile_theme : :desktop_theme) %> <%- end %> <%= theme_lookup("head_tag") %> diff --git a/app/views/layouts/no_ember.html.erb b/app/views/layouts/no_ember.html.erb index ca08de88c7..b36b9205b5 100644 --- a/app/views/layouts/no_ember.html.erb +++ b/app/views/layouts/no_ember.html.erb @@ -13,8 +13,11 @@ <%= build_plugin_html 'server:before-head-close' %> - <%= theme_lookup("header") %> - <%= build_plugin_html 'server:header' %> + <%- unless customization_disabled? %> + <%= theme_lookup("header") %> + <%= build_plugin_html 'server:header' %> + <%- end %> +
    <%= render partial: 'header', locals: { hide_auth_buttons: local_assigns[:hide_auth_buttons] } %>
    diff --git a/config/routes.rb b/config/routes.rb index ee1b2644dd..052480ef8a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -519,7 +519,7 @@ Discourse::Application.routes.draw do get "letter_avatar_proxy/:version/letter/:letter/:color/:size.png" => "user_avatars#show_proxy_letter", constraints: { format: :png } - get "svg-sprite/:hostname/svg-:theme_ids-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_ids: /([0-9]+(,[0-9]+)*)?/, format: :js } + get "svg-sprite/:hostname/svg-:theme_id-:version.js" => "svg_sprite#show", constraints: { hostname: /[\w\.-]+/, version: /\h{40}/, theme_id: /([0-9]+)?/, format: :js } get "svg-sprite/search/:keyword" => "svg_sprite#search", format: false, constraints: { keyword: /[-a-z0-9\s\%]+/ } get "svg-sprite/picker-search" => "svg_sprite#icon_picker_search", defaults: { format: :json } get "svg-sprite/:hostname/icon(/:color)/:name.svg" => "svg_sprite#svg_icon", constraints: { hostname: /[\w\.-]+/, name: /[-a-z0-9\s\%]+/, color: /(\h{3}{1,2})/, format: :svg } diff --git a/lib/content_security_policy.rb b/lib/content_security_policy.rb index 76d2246e5f..0cfd309a4b 100644 --- a/lib/content_security_policy.rb +++ b/lib/content_security_policy.rb @@ -4,15 +4,15 @@ require 'content_security_policy/extension' class ContentSecurityPolicy class << self - def policy(theme_ids = [], base_url: Discourse.base_url, path_info: "/") - new.build(theme_ids, base_url: base_url, path_info: path_info) + def policy(theme_id = nil, base_url: Discourse.base_url, path_info: "/") + new.build(theme_id, base_url: base_url, path_info: path_info) end end - def build(theme_ids, base_url:, path_info: "/") + def build(theme_id, base_url:, path_info: "/") builder = Builder.new(base_url: base_url) - Extension.theme_extensions(theme_ids).each { |extension| builder << extension } + Extension.theme_extensions(theme_id).each { |extension| builder << extension } Extension.plugin_extensions.each { |extension| builder << extension } builder << Extension.site_setting_extension builder << Extension.path_specific_extension(path_info) diff --git a/lib/content_security_policy/extension.rb b/lib/content_security_policy/extension.rb index 751e907fc5..51b59acda3 100644 --- a/lib/content_security_policy/extension.rb +++ b/lib/content_security_policy/extension.rb @@ -25,9 +25,9 @@ class ContentSecurityPolicy THEME_SETTING = 'extend_content_security_policy' - def theme_extensions(theme_ids) - key = "theme_extensions_#{Theme.transform_ids(theme_ids).join(',')}" - cache[key] ||= find_theme_extensions(theme_ids) + def theme_extensions(theme_id) + key = "theme_extensions_#{theme_id}" + cache[key] ||= find_theme_extensions(theme_id) end def clear_theme_extensions_cache! @@ -40,12 +40,11 @@ class ContentSecurityPolicy @cache ||= DistributedCache.new('csp_extensions') end - def find_theme_extensions(theme_ids) + def find_theme_extensions(theme_id) extensions = [] + theme_ids = Theme.transform_ids(theme_id) - resolved_ids = Theme.transform_ids(theme_ids) - - Theme.where(id: resolved_ids).find_each do |theme| + Theme.where(id: theme_ids).find_each do |theme| theme.cached_settings.each do |setting, value| extensions << build_theme_extension(value.split("|")) if setting.to_s == THEME_SETTING end @@ -54,7 +53,7 @@ class ContentSecurityPolicy extensions << build_theme_extension(ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions) html_fields = ThemeField.where( - theme_id: resolved_ids, + theme_id: theme_ids, target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] }, name: ThemeField.html_fields ) diff --git a/lib/content_security_policy/middleware.rb b/lib/content_security_policy/middleware.rb index d587f5994c..0435529bff 100644 --- a/lib/content_security_policy/middleware.rb +++ b/lib/content_security_policy/middleware.rb @@ -17,10 +17,10 @@ class ContentSecurityPolicy protocol = (SiteSetting.force_https || request.ssl?) ? "https://" : "http://" base_url = protocol + request.host_with_port + Discourse.base_path - theme_ids = env[:resolved_theme_ids] + theme_id = env[:resolved_theme_id] - headers['Content-Security-Policy'] = policy(theme_ids, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy - headers['Content-Security-Policy-Report-Only'] = policy(theme_ids, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy_report_only + headers['Content-Security-Policy'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy + headers['Content-Security-Policy-Report-Only'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy_report_only response end diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index ca869abb97..9fe8c8cd5b 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -132,9 +132,9 @@ module Middleware def theme_ids ids, _ = @request.cookies['theme_ids']&.split('|') - ids = ids&.split(",")&.map(&:to_i) - if ids && Guardian.new.allow_themes?(ids) - Theme.transform_ids(ids) + id = ids&.split(",")&.map(&:to_i)&.first + if id && Guardian.new.allow_themes?([id]) + Theme.transform_ids(id) else [] end diff --git a/lib/stylesheet/importer.rb b/lib/stylesheet/importer.rb index 8b02e809c9..d928246597 100644 --- a/lib/stylesheet/importer.rb +++ b/lib/stylesheet/importer.rb @@ -101,7 +101,7 @@ module Stylesheet end theme_id = @theme_id || SiteSetting.default_theme_id - resolved_ids = Theme.transform_ids([theme_id]) + resolved_ids = Theme.transform_ids(theme_id) if resolved_ids theme = Theme.find_by_id(theme_id) diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index 4461cfbace..2f4b679161 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -13,7 +13,7 @@ class Stylesheet::Manager THEME_REGEX ||= /_theme$/ COLOR_SCHEME_STYLESHEET ||= "color_definitions" - @lock = Mutex.new + @@lock = Mutex.new def self.cache @cache ||= DistributedCache.new("discourse_stylesheet") @@ -35,117 +35,6 @@ class Stylesheet::Manager cache.hash.keys.select { |k| k =~ /#{plugin}/ }.each { |k| cache.delete(k) } end - def self.stylesheet_data(target = :desktop, theme_ids = :missing) - stylesheet_details(target, "all", theme_ids) - end - - def self.stylesheet_link_tag(target = :desktop, media = 'all', theme_ids = :missing) - stylesheets = stylesheet_details(target, media, theme_ids) - stylesheets.map do |stylesheet| - href = stylesheet[:new_href] - theme_id = stylesheet[:theme_id] - data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" - %[] - end.join("\n").html_safe - end - - def self.stylesheet_details(target = :desktop, media = 'all', theme_ids = :missing) - if theme_ids == :missing - theme_ids = [SiteSetting.default_theme_id] - end - - target = target.to_sym - - theme_ids = [theme_ids] unless Array === theme_ids - theme_ids = [theme_ids.first] unless target =~ THEME_REGEX - include_components = !!(target =~ THEME_REGEX) - - theme_ids = Theme.transform_ids(theme_ids, extend: include_components) - - current_hostname = Discourse.current_hostname - - array_cache_key = "array_themes_#{theme_ids.join(",")}_#{target}_#{current_hostname}" - stylesheets = cache[array_cache_key] - return stylesheets if stylesheets.present? - - @lock.synchronize do - stylesheets = [] - theme_ids.each do |theme_id| - data = { target: target } - cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" - href = cache[cache_key] - - unless href - builder = self.new(target, theme_id) - is_theme = builder.is_theme? - has_theme = builder.theme.present? - - if is_theme && !has_theme - next - else - next if builder.theme&.component && !builder.theme&.has_scss(target) - data[:theme_id] = builder.theme.id if has_theme && is_theme - builder.compile unless File.exists?(builder.stylesheet_fullpath) - href = builder.stylesheet_path(current_hostname) - end - - cache.defer_set(cache_key, href) - end - - data[:theme_id] = theme_id if theme_id.present? && data[:theme_id].blank? - data[:new_href] = href - stylesheets << data - end - - cache.defer_set(array_cache_key, stylesheets.freeze) - stylesheets - end - end - - def self.color_scheme_stylesheet_details(color_scheme_id = nil, media, theme_id) - theme_id = theme_id || SiteSetting.default_theme_id - - color_scheme = begin - ColorScheme.find(color_scheme_id) - rescue - # don't load fallback when requesting dark color scheme - return false if media != "all" - - Theme.find_by_id(theme_id)&.color_scheme || ColorScheme.base - end - - return false if !color_scheme - - target = COLOR_SCHEME_STYLESHEET.to_sym - current_hostname = Discourse.current_hostname - cache_key = color_scheme_cache_key(color_scheme, theme_id) - stylesheets = cache[cache_key] - return stylesheets if stylesheets.present? - - stylesheet = { color_scheme_id: color_scheme&.id } - - builder = self.new(target, theme_id, color_scheme) - - builder.compile unless File.exists?(builder.stylesheet_fullpath) - - href = builder.stylesheet_path(current_hostname) - stylesheet[:new_href] = href - cache.defer_set(cache_key, stylesheet.freeze) - stylesheet - end - - def self.color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all', theme_ids = nil) - theme_id = theme_ids&.first - stylesheet = color_scheme_stylesheet_details(color_scheme_id, media, theme_id) - return '' if !stylesheet - - href = stylesheet[:new_href] - - css_class = media == 'all' ? "light-scheme" : "dark-scheme" - - %[].html_safe - end - def self.color_scheme_cache_key(color_scheme, theme_id = nil) color_scheme_name = Slug.for(color_scheme.name) + color_scheme&.id.to_s theme_string = theme_id ? "_theme#{theme_id}" : "" @@ -164,24 +53,30 @@ class Stylesheet::Manager targets += Discourse.find_plugin_css_assets(include_disabled: true, mobile_view: true, desktop_view: true) themes.each do |id, name, color_scheme_id| - targets.each do |target| - theme_id = id || SiteSetting.default_theme_id + theme_id = id || SiteSetting.default_theme_id + manager = self.new(theme_id: theme_id) + targets.each do |target| if target =~ THEME_REGEX next if theme_id == -1 - theme_ids = Theme.transform_ids([theme_id], extend: true) + scss_checker = ScssChecker.new(target, manager.theme_ids) + + manager.load_themes(manager.theme_ids).each do |theme| + builder = Stylesheet::Manager::Builder.new( + target: target, theme: theme, manager: manager + ) - theme_ids.each do |t_id| - builder = self.new(target, t_id) STDERR.puts "precompile target: #{target} #{builder.theme.name}" - next if builder.theme.component && !builder.theme.has_scss(target) + next if theme.component && !scss_checker.has_scss(theme.id) builder.compile(force: true) end else STDERR.puts "precompile target: #{target} #{name}" - builder = self.new(target, theme_id) - builder.compile(force: true) + + Stylesheet::Manager::Builder.new( + target: target, theme: manager.get_theme(theme_id), manager: manager + ).compile(force: true) end end @@ -190,8 +85,12 @@ class Stylesheet::Manager [theme_color_scheme, *color_schemes].uniq.each do |scheme| STDERR.puts "precompile target: #{COLOR_SCHEME_STYLESHEET} #{name} (#{scheme.name})" - builder = self.new(COLOR_SCHEME_STYLESHEET, id, scheme) - builder.compile(force: true) + Stylesheet::Manager::Builder.new( + target: COLOR_SCHEME_STYLESHEET, + theme: manager.get_theme(theme_id), + color_scheme: scheme, + manager: manager + ).compile(force: true) end clear_color_scheme_cache! end @@ -232,245 +131,165 @@ class Stylesheet::Manager "#{Rails.root}/#{CACHE_PATH}" end - def initialize(target = :desktop, theme_id = nil, color_scheme = nil) - @target = target - @theme_id = theme_id - @color_scheme = color_scheme + attr_reader :theme_ids + + def initialize(theme_id: nil) + @theme_id = theme_id || SiteSetting.default_theme_id + @theme_ids = Theme.transform_ids(@theme_id) + @themes_cache = {} end - def compile(opts = {}) - unless opts[:force] - if File.exists?(stylesheet_fullpath) - unless StylesheetCache.where(target: qualified_target, digest: digest).exists? - begin - source_map = begin - File.read(source_map_fullpath) - rescue Errno::ENOENT - end + def cache + self.class.cache + end - StylesheetCache.add(qualified_target, digest, File.read(stylesheet_fullpath), source_map) - rescue => e - Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" - end + def get_theme(theme_id) + if theme = @themes_cache[theme_id] + theme + else + load_themes([theme_id]).first + end + end + + def load_themes(theme_ids) + themes = [] + to_load_theme_ids = [] + + theme_ids.each do |theme_id| + if @themes_cache[theme_id] + themes << @themes_cache[theme_id] + else + to_load_theme_ids << theme_id + end + end + + Theme + .where(id: to_load_theme_ids) + .includes(:yaml_theme_fields, :theme_settings, :upload_fields, :builder_theme_fields) + .each do |theme| + + @themes_cache[theme.id] = theme + themes << theme + end + + themes + end + + def stylesheet_data(target = :desktop) + stylesheet_details(target, "all") + end + + def stylesheet_link_tag(target = :desktop, media = 'all') + stylesheets = stylesheet_details(target, media) + + stylesheets.map do |stylesheet| + href = stylesheet[:new_href] + theme_id = stylesheet[:theme_id] + data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" + %[] + end.join("\n").html_safe + end + + def stylesheet_details(target = :desktop, media = 'all') + target = target.to_sym + current_hostname = Discourse.current_hostname + + array_cache_key = "array_themes_#{@theme_ids.join(",")}_#{target}_#{current_hostname}" + stylesheets = cache[array_cache_key] + return stylesheets if stylesheets.present? + + @@lock.synchronize do + stylesheets = [] + stale_theme_ids = [] + + @theme_ids.each do |theme_id| + cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" + + if href = cache[cache_key] + stylesheets << { + target: target, + theme_id: theme_id, + new_href: href + } + else + stale_theme_ids << theme_id end - return true end - end - rtl = @target.to_s =~ /_rtl$/ - css, source_map = with_load_paths do |load_paths| - Stylesheet::Compiler.compile_asset( - @target, - rtl: rtl, - theme_id: theme&.id, - theme_variables: theme&.scss_variables.to_s, - source_map_file: source_map_filename, - color_scheme_id: @color_scheme&.id, - load_paths: load_paths - ) - rescue SassC::SyntaxError => e - if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s) - # no special errors for theme, handled in theme editor - ["", nil] - elsif @target.to_s == COLOR_SCHEME_STYLESHEET - # log error but do not crash for errors in color definitions SCSS - Rails.logger.error "SCSS compilation error: #{e.message}" - ["", nil] - else - raise Discourse::ScssError, e.message + scss_checker = ScssChecker.new(target, stale_theme_ids) + + load_themes(stale_theme_ids).each do |theme| + theme_id = theme.id + data = { target: target, theme_id: theme_id } + builder = Builder.new(target: target, theme: theme, manager: self) + + next if builder.theme.component && !scss_checker.has_scss(theme_id) + builder.compile unless File.exists?(builder.stylesheet_fullpath) + href = builder.stylesheet_path(current_hostname) + + cache.defer_set("path_#{target}_#{theme_id}_#{current_hostname}", href) + + data[:new_href] = href + stylesheets << data end - end - FileUtils.mkdir_p(cache_fullpath) - - File.open(stylesheet_fullpath, "w") do |f| - f.puts css - end - - if source_map.present? - File.open(source_map_fullpath, "w") do |f| - f.puts source_map - end - end - - begin - StylesheetCache.add(qualified_target, digest, css, source_map) - rescue => e - Rails.logger.warn "Completely unexpected error adding item to cache #{e}" - end - css - end - - def cache_fullpath - self.class.cache_fullpath - end - - def stylesheet_fullpath - "#{cache_fullpath}/#{stylesheet_filename}" - end - - def source_map_fullpath - "#{cache_fullpath}/#{source_map_filename}" - end - - def source_map_filename - "#{stylesheet_filename}.map" - end - - def stylesheet_fullpath_no_digest - "#{cache_fullpath}/#{stylesheet_filename_no_digest}" - end - - def stylesheet_cdnpath(hostname) - "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{hostname}" - end - - def stylesheet_path(hostname) - stylesheet_cdnpath(hostname) - end - - def root_path - "#{GlobalSetting.relative_url_root}/" - end - - def stylesheet_relpath - "#{root_path}stylesheets/#{stylesheet_filename}" - end - - def stylesheet_relpath_no_digest - "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" - end - - def qualified_target - if is_theme? - "#{@target}_#{theme.id}" - elsif @color_scheme - "#{@target}_#{scheme_slug}_#{@color_scheme&.id.to_s}" - else - scheme_string = theme && theme.color_scheme ? "_#{theme.color_scheme.id}" : "" - "#{@target}#{scheme_string}" + cache.defer_set(array_cache_key, stylesheets.freeze) + stylesheets end end - def stylesheet_filename(with_digest = true) - digest_string = "_#{self.digest}" if with_digest - "#{qualified_target}#{digest_string}.css" - end + def color_scheme_stylesheet_details(color_scheme_id = nil, media) + theme_id = @theme_ids.first - def stylesheet_filename_no_digest - stylesheet_filename(_with_digest = false) - end + color_scheme = begin + ColorScheme.find(color_scheme_id) + rescue + # don't load fallback when requesting dark color scheme + return false if media != "all" - def is_theme? - !!(@target.to_s =~ THEME_REGEX) - end - - def scheme_slug - Slug.for(ActiveSupport::Inflector.transliterate(@color_scheme.name), 'scheme') - end - - # digest encodes the things that trigger a recompile - def digest - @digest ||= begin - if is_theme? - theme_digest - else - color_scheme_digest - end + get_theme(theme_id)&.color_scheme || ColorScheme.base end - end - def theme - @theme ||= Theme.find_by(id: @theme_id) || :nil - @theme == :nil ? nil : @theme - end + return false if !color_scheme + + target = COLOR_SCHEME_STYLESHEET.to_sym + current_hostname = Discourse.current_hostname + cache_key = self.class.color_scheme_cache_key(color_scheme, theme_id) + stylesheets = cache[cache_key] + return stylesheets if stylesheets.present? + + stylesheet = { color_scheme_id: color_scheme.id } + + theme = get_theme(theme_id) - def with_load_paths if theme - theme.with_scss_load_paths { |p| yield p } + builder = Builder.new( + target: target, + theme: get_theme(theme_id), + color_scheme: color_scheme, + manager: self + ) + + builder.compile unless File.exists?(builder.stylesheet_fullpath) + + href = builder.stylesheet_path(current_hostname) + stylesheet[:new_href] = href + cache.defer_set(cache_key, stylesheet.freeze) + stylesheet else - yield nil + {} end end - def theme_digest - if [:mobile_theme, :desktop_theme].include?(@target) - scss_digest = theme.resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) - elsif @target == :embedded_theme - scss_digest = theme.resolve_baked_field(:common, :embedded_scss) - else - raise "attempting to look up theme digest for invalid field" - end + def color_scheme_stylesheet_link_tag(color_scheme_id = nil, media = 'all') + stylesheet = color_scheme_stylesheet_details(color_scheme_id, media) - Digest::SHA1.hexdigest(scss_digest.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest) - end + return '' if !stylesheet - # this protects us from situations where new versions of a plugin removed a file - # old instances may still be serving CSS and not aware of the change - # so we could end up poisoning the cache with a bad file that can not be removed - def plugins_digest - assets = [] - DiscoursePluginRegistry.stylesheets.each { |_, paths| assets += paths.to_a } - DiscoursePluginRegistry.mobile_stylesheets.each { |_, paths| assets += paths.to_a } - DiscoursePluginRegistry.desktop_stylesheets.each { |_, paths| assets += paths.to_a } - Digest::SHA1.hexdigest(assets.sort.join) - end + href = stylesheet[:new_href] - def settings_digest - theme_ids = Theme.components_for(@theme_id).dup - theme_ids << @theme_id + css_class = media == 'all' ? "light-scheme" : "dark-scheme" - fields = ThemeField.where( - name: "yaml", - type_id: ThemeField.types[:yaml], - theme_id: theme_ids - ).pluck(:updated_at) - - settings = ThemeSetting.where(theme_id: theme_ids).pluck(:updated_at) - timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",") - - Digest::SHA1.hexdigest(timestamps) - end - - def uploads_digest - sha1s = - if (theme_ids = theme&.all_theme_variables).present? - ThemeField - .joins(:upload) - .where(id: theme_ids) - .pluck(:sha1) - .join(",") - else - "" - end - - Digest::SHA1.hexdigest(sha1s) - end - - def color_scheme_digest - cs = @color_scheme || theme&.color_scheme - - categories_updated = self.class.cache.defer_get_set("categories_updated") do - Category - .where("uploaded_background_id IS NOT NULL") - .pluck(:updated_at) - .map(&:to_i) - .sum - end - - fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}" - - if cs || categories_updated > 0 - theme_color_defs = theme&.resolve_baked_field(:common, :color_definitions) - Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{categories_updated}-#{fonts}" - else - digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}-#{fonts}" - - if cdn_url = GlobalSetting.cdn_url - digest_string = "#{digest_string}-#{cdn_url}" - end - - Digest::SHA1.hexdigest digest_string - end + %[].html_safe end end diff --git a/lib/stylesheet/manager/builder.rb b/lib/stylesheet/manager/builder.rb new file mode 100644 index 0000000000..a04de17ede --- /dev/null +++ b/lib/stylesheet/manager/builder.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +class Stylesheet::Manager::Builder + attr_reader :theme + + def initialize(target: :desktop, theme:, color_scheme: nil, manager:) + @target = target + @theme = theme + @color_scheme = color_scheme + @manager = manager + end + + def compile(opts = {}) + if !opts[:force] + if File.exists?(stylesheet_fullpath) + unless StylesheetCache.where(target: qualified_target, digest: digest).exists? + begin + source_map = begin + File.read(source_map_fullpath) + rescue Errno::ENOENT + end + + StylesheetCache.add(qualified_target, digest, File.read(stylesheet_fullpath), source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding contents of '#{stylesheet_fullpath}' to cache #{e}" + end + end + return true + end + end + + rtl = @target.to_s =~ /_rtl$/ + css, source_map = with_load_paths do |load_paths| + Stylesheet::Compiler.compile_asset( + @target, + rtl: rtl, + theme_id: theme&.id, + theme_variables: theme&.scss_variables.to_s, + source_map_file: source_map_filename, + color_scheme_id: @color_scheme&.id, + load_paths: load_paths + ) + rescue SassC::SyntaxError => e + if Stylesheet::Importer::THEME_TARGETS.include?(@target.to_s) + # no special errors for theme, handled in theme editor + ["", nil] + elsif @target.to_s == Stylesheet::Manager::COLOR_SCHEME_STYLESHEET + # log error but do not crash for errors in color definitions SCSS + Rails.logger.error "SCSS compilation error: #{e.message}" + ["", nil] + else + raise Discourse::ScssError, e.message + end + end + + FileUtils.mkdir_p(cache_fullpath) + + File.open(stylesheet_fullpath, "w") do |f| + f.puts css + end + + if source_map.present? + File.open(source_map_fullpath, "w") do |f| + f.puts source_map + end + end + + begin + StylesheetCache.add(qualified_target, digest, css, source_map) + rescue => e + Rails.logger.warn "Completely unexpected error adding item to cache #{e}" + end + css + end + + def cache_fullpath + Stylesheet::Manager.cache_fullpath + end + + def stylesheet_fullpath + "#{cache_fullpath}/#{stylesheet_filename}" + end + + def source_map_fullpath + "#{cache_fullpath}/#{source_map_filename}" + end + + def source_map_filename + "#{stylesheet_filename}.map" + end + + def stylesheet_fullpath_no_digest + "#{cache_fullpath}/#{stylesheet_filename_no_digest}" + end + + def stylesheet_cdnpath(hostname) + "#{GlobalSetting.cdn_url}#{stylesheet_relpath}?__ws=#{hostname}" + end + + def stylesheet_path(hostname) + stylesheet_cdnpath(hostname) + end + + def root_path + "#{GlobalSetting.relative_url_root}/" + end + + def stylesheet_relpath + "#{root_path}stylesheets/#{stylesheet_filename}" + end + + def stylesheet_relpath_no_digest + "#{root_path}stylesheets/#{stylesheet_filename_no_digest}" + end + + def qualified_target + if is_theme? + "#{@target}_#{theme.id}" + elsif @color_scheme + "#{@target}_#{scheme_slug}_#{@color_scheme&.id.to_s}" + else + scheme_string = theme && theme.color_scheme ? "_#{theme.color_scheme.id}" : "" + "#{@target}#{scheme_string}" + end + end + + def stylesheet_filename(with_digest = true) + digest_string = "_#{self.digest}" if with_digest + "#{qualified_target}#{digest_string}.css" + end + + def stylesheet_filename_no_digest + stylesheet_filename(_with_digest = false) + end + + def is_theme? + !!(@target.to_s =~ Stylesheet::Manager::THEME_REGEX) + end + + def scheme_slug + Slug.for(ActiveSupport::Inflector.transliterate(@color_scheme.name), 'scheme') + end + + # digest encodes the things that trigger a recompile + def digest + @digest ||= begin + if is_theme? + theme_digest + else + color_scheme_digest + end + end + end + + def with_load_paths + if theme + theme.with_scss_load_paths { |p| yield p } + else + yield nil + end + end + + def scss_digest + if [:mobile_theme, :desktop_theme].include?(@target) + resolve_baked_field(@target.to_s.sub("_theme", ""), :scss) + elsif @target == :embedded_theme + resolve_baked_field(:common, :embedded_scss) + else + raise "attempting to look up theme digest for invalid field" + end + end + + def theme_digest + Digest::SHA1.hexdigest(scss_digest.to_s + color_scheme_digest.to_s + settings_digest + plugins_digest + uploads_digest) + end + + # this protects us from situations where new versions of a plugin removed a file + # old instances may still be serving CSS and not aware of the change + # so we could end up poisoning the cache with a bad file that can not be removed + def plugins_digest + assets = [] + DiscoursePluginRegistry.stylesheets.each { |_, paths| assets += paths.to_a } + DiscoursePluginRegistry.mobile_stylesheets.each { |_, paths| assets += paths.to_a } + DiscoursePluginRegistry.desktop_stylesheets.each { |_, paths| assets += paths.to_a } + Digest::SHA1.hexdigest(assets.sort.join) + end + + def settings_digest + theme_ids = Theme.is_parent_theme?(theme.id) ? @manager.theme_ids : [theme.id] + + themes = + if Theme.is_parent_theme?(theme.id) + @manager.load_themes(@manager.theme_ids) + else + [@manager.get_theme(theme.id)] + end + + fields = themes.each_with_object([]) do |theme, array| + array.concat(theme.yaml_theme_fields.map(&:updated_at)) + end + + settings = themes.each_with_object([]) do |theme, array| + array.concat(theme.theme_settings.map(&:updated_at)) + end + + timestamps = fields.concat(settings).map!(&:to_f).sort!.join(",") + + Digest::SHA1.hexdigest(timestamps) + end + + def uploads_digest + sha1s = [] + + theme.upload_fields.map do |upload_field| + sha1s << upload_field.upload.sha1 + end + + Digest::SHA1.hexdigest(sha1s.sort!.join("\n")) + end + + def color_scheme_digest + cs = @color_scheme || theme&.color_scheme + + categories_updated = Stylesheet::Manager.cache.defer_get_set("categories_updated") do + Category + .where("uploaded_background_id IS NOT NULL") + .pluck(:updated_at) + .map(&:to_i) + .sum + end + + fonts = "#{SiteSetting.base_font}-#{SiteSetting.heading_font}" + + if cs || categories_updated > 0 + theme_color_defs = resolve_baked_field(:common, :color_definitions) + Digest::SHA1.hexdigest "#{RailsMultisite::ConnectionManagement.current_db}-#{cs&.id}-#{cs&.version}-#{theme_color_defs}-#{Stylesheet::Manager.last_file_updated}-#{categories_updated}-#{fonts}" + else + digest_string = "defaults-#{Stylesheet::Manager.last_file_updated}-#{fonts}" + + if cdn_url = GlobalSetting.cdn_url + digest_string = "#{digest_string}-#{cdn_url}" + end + + Digest::SHA1.hexdigest digest_string + end + end + + def resolve_baked_field(target, name) + theme_ids = + if Theme.is_parent_theme?(theme.id) + @manager.theme_ids + else + [theme.id] + end + + theme_ids = [theme_ids.first] if name != :color_definitions + + baked_fields = [] + targets = [Theme.targets[target.to_sym], Theme.targets[:common]] + + @manager.load_themes(theme_ids).each do |theme| + theme.builder_theme_fields.each do |theme_field| + if theme_field.name == name.to_s && targets.include?(theme_field.target_id) + baked_fields << theme_field + end + end + end + + baked_fields.map do |f| + f.ensure_baked! + f.value_baked || f.value + end.join("\n") + end +end diff --git a/lib/stylesheet/manager/scss_checker.rb b/lib/stylesheet/manager/scss_checker.rb new file mode 100644 index 0000000000..117b4ccfe1 --- /dev/null +++ b/lib/stylesheet/manager/scss_checker.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Stylesheet::Manager::ScssChecker + def initialize(target, theme_ids) + @target = target.to_sym + @theme_ids = theme_ids + end + + def has_scss(theme_id) + !!get_themes_with_scss[theme_id] + end + + private + + def get_themes_with_scss + @themes_with_scss ||= begin + theme_target = @target.to_sym + theme_target = :mobile if theme_target == :mobile_theme + theme_target = :desktop if theme_target == :desktop_theme + name = @target == :embedded_theme ? :embedded_scss : :scss + + results = Theme + .where(id: @theme_ids) + .left_joins(:theme_fields) + .where(theme_fields: { + target_id: [Theme.targets[theme_target], Theme.targets[:common]], + name: name + }) + .group(:id) + .size + + results + end + end +end diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index b2010fe410..0754ab7b2a 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -228,12 +228,12 @@ module SvgSprite badge_icons end - def self.custom_svg_sprites(theme_ids = []) - get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_ids).join(',')}") do + def self.custom_svg_sprites(theme_id) + get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(',')}") do custom_sprite_paths = Dir.glob("#{Rails.root}/plugins/*/svg-icons/*.svg") - if theme_ids.present? - ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_ids)) + if theme_id.present? + ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_id)) .pluck(:upload_id).each do |upload_id| upload = Upload.find(upload_id) rescue nil @@ -253,15 +253,15 @@ module SvgSprite end end - def self.all_icons(theme_ids = []) - get_set_cache("icons_#{Theme.transform_ids(theme_ids).join(',')}") do + def self.all_icons(theme_id = nil) + get_set_cache("icons_#{Theme.transform_ids(theme_id).join(',')}") do Set.new() .merge(settings_icons) .merge(plugin_icons) .merge(badge_icons) .merge(group_icons) - .merge(theme_icons(theme_ids)) - .merge(custom_icons(theme_ids)) + .merge(theme_icons(theme_id)) + .merge(custom_icons(theme_id)) .delete_if { |i| i.blank? || i.include?("/") } .map! { |i| process(i.dup) } .merge(SVG_ICONS) @@ -269,25 +269,25 @@ module SvgSprite end end - def self.version(theme_ids = []) - get_set_cache("version_#{Theme.transform_ids(theme_ids).join(',')}") do - Digest::SHA1.hexdigest(bundle(theme_ids)) + def self.version(theme_id = nil) + get_set_cache("version_#{Theme.transform_ids(theme_id).join(',')}") do + Digest::SHA1.hexdigest(bundle(theme_id)) end end - def self.path(theme_ids = []) - "/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_ids&.join(",")}-#{version(theme_ids)}.js" + def self.path(theme_id = nil) + "/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_id}-#{version(theme_id)}.js" end def self.expire_cache cache&.clear end - def self.sprite_sources(theme_ids) + def self.sprite_sources(theme_id) sources = CORE_SVG_SPRITES - if theme_ids.present? - sources = sources + custom_svg_sprites(theme_ids) + if theme_id.present? + sources = sources + custom_svg_sprites(theme_id) end sources @@ -313,8 +313,8 @@ module SvgSprite end end - def self.bundle(theme_ids = []) - icons = all_icons(theme_ids) + def self.bundle(theme_id = nil) + icons = all_icons(theme_id) svg_subset = """
    -

    رُفض التغيير الذي طلبته.

    -

    ربما حاولت تغيير شيء لا تملك التصريح لتغييره.

    +

    تم رفض التغيير الذي أردته

    +

    ربما حاولت تغيير شيء لم يكن لديك إذن بالوصول إليه.

    diff --git a/public/422.de.html b/public/422.de.html index f1b4c5379c..83b3b14758 100644 --- a/public/422.de.html +++ b/public/422.de.html @@ -21,7 +21,7 @@

    Die gewünschte Änderung wurde abgelehnt.

    -

    Vielleicht hast du versucht etwas zu ändern, worauf du keinen Zugriff hast.

    +

    Vielleicht hast du versucht, etwas zu ändern, worauf du keinen Zugriff hast.

    diff --git a/public/500.ar.html b/public/500.ar.html index a6781606a3..261864f441 100644 --- a/public/500.ar.html +++ b/public/500.ar.html @@ -1,13 +1,13 @@ - آخ - خطأ 500 + عذرًا - خطأ 500 -

    آخ

    -

    واجهت البرمجية وراء منتدى النقاش هذا مشكلة غير متوقّعة. نعتذر عن هذا الإزعاج.

    -

    سُجّلت معلومات مفصّلة عن العُطل ووُلّد إخطار تلقائي. سنأخذ نظرة على هذا الإخطار.

    -

    ليس مطلوبًا منك أيّ إجراء آخر. ولكن لو لاحظت أن الحالة لم تتغيّر، فيمكنك تقديم مزيد من التفاصيل (مثل الخطوات التي أدّت للعُطل) بنشرها في موضوع نقاش في فئة ”الانطباعات“ في الموقع.

    +

    عذرًا

    +

    واجهت البرمجيات التي تدعم منتدى المناقشات هذا مشكلة غير متوقَّعة. نعتذر على هذا الإزعاج.

    +

    تم تسجيل معلومات مفصَّلة عن الخطأ، وإنشاء إشعار تلقائي. سنلقي نظرة على الأمر.

    +

    لا يلزمك اتخاذ أي إجراء آخر. ولكن إذا استمرت حالة الخطأ، يمكنك تقديم المزيد من التفاصيل، بما في ذلك الخطوات التي أدت لحدوث الخطأ من خلال نشر موضوع مناقشة في فئة الملاحظات للموقع.

    diff --git a/public/500.de.html b/public/500.de.html index 0963776b82..d776477671 100644 --- a/public/500.de.html +++ b/public/500.de.html @@ -1,13 +1,13 @@ - Hoppla - Fehler 500 + Hoppla – Fehler 500

    Hoppla

    Die Software, mit der dieses Diskussionsforum läuft, ist auf ein unerwartetes Problem gestoßen. Wir entschuldigen uns für die Unannehmlichkeiten.

    Detaillierte Informationen über den Fehler wurden protokolliert und eine automatische Benachrichtigung generiert. Wir werden uns darum kümmern.

    -

    Es sind keine weiteren Maßnahmen erforderlich. Sollte der Fehler jedoch weiterhin auftreten, kannst du zusätzliche Details, einschließlich der Schritte zum Reproduzieren des Fehlers, in Feedback-Kategorie der Website veröffentlichen.

    +

    Es sind keine weiteren Maßnahmen erforderlich. Sollte der Fehler jedoch weiterhin auftreten, kannst du zusätzliche Details, einschließlich der Schritte zum Reproduzieren des Fehlers, in der Feedback-Kategorie der Website veröffentlichen.

    diff --git a/public/500.es.html b/public/500.es.html index b56b43d408..0231c26cd6 100644 --- a/public/500.es.html +++ b/public/500.es.html @@ -8,6 +8,6 @@

    ¡Vaya!

    Ha ocurrido un error en el sistema con el que funciona este foro. Sentimos las molestias.

    Los detalles del error han sido registrados, y se ha generado una notificación automática. Lo revisaremos.

    -

    No hace falta hacer nada más. Sin embargo, si el error sigue pasando, podrías darnos más información, incluyendo los pasos para reproducirlo, publicando un tema en la sección de la categoría de sugerencias del sitio.

    +

    No hace falta hacer nada más. Sin embargo, si el error sigue pasando, podrías darnos más información, incluidos los pasos para reproducirlo, publicando un tema en la sección de la categoría de sugerencias del sitio.

    diff --git a/public/503.ar.html b/public/503.ar.html index bfa18da0b6..981be96415 100644 --- a/public/503.ar.html +++ b/public/503.ar.html @@ -1,12 +1,12 @@ - نعمل على صيانة الموقع + الموقع يخضع للصيانة حاليًا -

    نعمل حاليًا على صيانة دورية للموقع

    -

    من فضلك ادخل لاحقًا بعد بضع دقائق.

    -

    نأسف لأيّ إزعاج!

    +

    نعمل حاليًا على صيانة مقررة للموقع

    +

    يُرجى إعادة التحقُّق بعد بضع دقائق.

    +

    نأسف على الإزعاج!

    From f343cfd92e3dc9a8ae398e3834c9bdcf95d51a9f Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 22 Jun 2021 09:30:44 -0400 Subject: [PATCH 138/403] DEV: Remove IntersectionObserver polyfill (#13445) --- .../discourse/app/components/emoji-picker.js | 25 +- app/assets/javascripts/vendor.js | 1 - app/assets/stylesheets/common/base/emoji.scss | 16 + lib/tasks/javascript.rake | 3 - package.json | 1 - .../javascripts/intersection-observer.js | 726 ------------------ yarn.lock | 5 - 7 files changed, 30 insertions(+), 747 deletions(-) delete mode 100644 vendor/assets/javascripts/intersection-observer.js diff --git a/app/assets/javascripts/discourse/app/components/emoji-picker.js b/app/assets/javascripts/discourse/app/components/emoji-picker.js index 7989384446..2018919644 100644 --- a/app/assets/javascripts/discourse/app/components/emoji-picker.js +++ b/app/assets/javascripts/discourse/app/components/emoji-picker.js @@ -45,7 +45,9 @@ export default Component.extend({ this.set("recentEmojis", this.emojiStore.favorites); this.set("selectedDiversity", this.emojiStore.diversity); - this._sectionObserver = this._setupSectionObserver(); + if ("IntersectionObserver" in window) { + this._sectionObserver = this._setupSectionObserver(); + } }, didInsertElement() { @@ -107,10 +109,6 @@ export default Component.extend({ ); } - emojiPicker - .querySelectorAll(".emojis-container .section .section-header") - .forEach((p) => this._sectionObserver.observe(p)); - // this is a low-tech trick to prevent appending hundreds of emojis // of blocking the rendering of the picker later(() => { @@ -123,6 +121,12 @@ export default Component.extend({ ) { const filter = emojiPicker.querySelector("input.filter"); filter && filter.focus(); + + if (this._sectionObserver) { + emojiPicker + .querySelectorAll(".emojis-container .section .section-header") + .forEach((p) => this._sectionObserver.observe(p)); + } } if (this.selectedDiversity !== 0) { @@ -216,23 +220,22 @@ export default Component.extend({ @action onFilter(event) { - const emojiPickerArea = document.querySelector(".emoji-picker-emoji-area"); - const emojisContainer = emojiPickerArea.querySelector(".emojis-container"); - const results = emojiPickerArea.querySelector(".results"); + const emojiPicker = document.querySelector(".emoji-picker"); + const results = document.querySelector(".emoji-picker-emoji-area .results"); results.innerHTML = ""; if (event.target.value) { results.innerHTML = emojiSearch(event.target.value.toLowerCase(), { - maxResults: 10, + maxResults: 20, diversity: this.emojiStore.diversity, }) .map(this._replaceEmoji) .join(""); - emojisContainer.style.visibility = "hidden"; + emojiPicker.classList.add("has-filter"); results.scrollIntoView(); } else { - emojisContainer.style.visibility = "visible"; + emojiPicker.classList.remove("has-filter"); } }, diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index fbeadfd893..2f2fe58390 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -25,6 +25,5 @@ //= require jquery.autoellipsis-1.0.10 //= require virtual-dom //= require virtual-dom-amd -//= require intersection-observer //= require discourse-shims //= require pretty-text-bundle diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss index a617982c92..8d37308b16 100644 --- a/app/assets/stylesheets/common/base/emoji.scss +++ b/app/assets/stylesheets/common/base/emoji.scss @@ -126,6 +126,22 @@ sup img.emoji { } } } + + &.has-filter { + .emojis-container { + visibility: hidden; + height: 0px; + overflow: hidden; + } + + .emoji-picker-category-buttons { + pointer-events: none; + opacity: 0.5; + .category-button.current .emoji { + filter: grayscale(100%); + } + } + } } .emoji-picker-search-container { diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index 2ab50b57f3..a40bd98cff 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -140,9 +140,6 @@ def dependencies source: 'mousetrap/plugins/global-bind/mousetrap-global-bind.js' }, { source: 'resumablejs/resumable.js' - }, { - # TODO: drop when we eventually drop IE11, this will land in iOS in version 13 - source: 'intersection-observer/intersection-observer.js' }, { source: 'workbox-sw/build/.', destination: 'workbox', diff --git a/package.json b/package.json index 6dbc0f7f92..9d013590a1 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "diffhtml": "^1.0.0-beta.18", "eslint-config-discourse": "^1.1.8", "handlebars": "^4.7.7", - "intersection-observer": "^0.5.1", "jquery": "3.5.1", "jquery-color": "3.0.0-alpha.1", "jquery-tags-input": "1.3.5", diff --git a/vendor/assets/javascripts/intersection-observer.js b/vendor/assets/javascripts/intersection-observer.js deleted file mode 100644 index 7a079659b7..0000000000 --- a/vendor/assets/javascripts/intersection-observer.js +++ /dev/null @@ -1,726 +0,0 @@ -/** - * Copyright 2016 Google Inc. All Rights Reserved. - * - * Licensed under the W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE. - * - * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document - * - */ - -(function(window, document) { -'use strict'; - - -// Exits early if all IntersectionObserver and IntersectionObserverEntry -// features are natively supported. -if ('IntersectionObserver' in window && - 'IntersectionObserverEntry' in window && - 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { - - // Minimal polyfill for Edge 15's lack of `isIntersecting` - // See: https://github.com/w3c/IntersectionObserver/issues/211 - if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) { - Object.defineProperty(window.IntersectionObserverEntry.prototype, - 'isIntersecting', { - get: function () { - return this.intersectionRatio > 0; - } - }); - } - return; -} - - -/** - * An IntersectionObserver registry. This registry exists to hold a strong - * reference to IntersectionObserver instances currently observing a target - * element. Without this registry, instances without another reference may be - * garbage collected. - */ -var registry = []; - - -/** - * Creates the global IntersectionObserverEntry constructor. - * https://w3c.github.io/IntersectionObserver/#intersection-observer-entry - * @param {Object} entry A dictionary of instance properties. - * @constructor - */ -function IntersectionObserverEntry(entry) { - this.time = entry.time; - this.target = entry.target; - this.rootBounds = entry.rootBounds; - this.boundingClientRect = entry.boundingClientRect; - this.intersectionRect = entry.intersectionRect || getEmptyRect(); - this.isIntersecting = !!entry.intersectionRect; - - // Calculates the intersection ratio. - var targetRect = this.boundingClientRect; - var targetArea = targetRect.width * targetRect.height; - var intersectionRect = this.intersectionRect; - var intersectionArea = intersectionRect.width * intersectionRect.height; - - // Sets intersection ratio. - if (targetArea) { - // Round the intersection ratio to avoid floating point math issues: - // https://github.com/w3c/IntersectionObserver/issues/324 - this.intersectionRatio = Number((intersectionArea / targetArea).toFixed(4)); - } else { - // If area is zero and is intersecting, sets to 1, otherwise to 0 - this.intersectionRatio = this.isIntersecting ? 1 : 0; - } -} - - -/** - * Creates the global IntersectionObserver constructor. - * https://w3c.github.io/IntersectionObserver/#intersection-observer-interface - * @param {Function} callback The function to be invoked after intersection - * changes have queued. The function is not invoked if the queue has - * been emptied by calling the `takeRecords` method. - * @param {Object=} opt_options Optional configuration options. - * @constructor - */ -function IntersectionObserver(callback, opt_options) { - - var options = opt_options || {}; - - if (typeof callback != 'function') { - throw new Error('callback must be a function'); - } - - if (options.root && options.root.nodeType != 1) { - throw new Error('root must be an Element'); - } - - // Binds and throttles `this._checkForIntersections`. - this._checkForIntersections = throttle( - this._checkForIntersections.bind(this), this.THROTTLE_TIMEOUT); - - // Private properties. - this._callback = callback; - this._observationTargets = []; - this._queuedEntries = []; - this._rootMarginValues = this._parseRootMargin(options.rootMargin); - - // Public properties. - this.thresholds = this._initThresholds(options.threshold); - this.root = options.root || null; - this.rootMargin = this._rootMarginValues.map(function(margin) { - return margin.value + margin.unit; - }).join(' '); -} - - -/** - * The minimum interval within which the document will be checked for - * intersection changes. - */ -IntersectionObserver.prototype.THROTTLE_TIMEOUT = 100; - - -/** - * The frequency in which the polyfill polls for intersection changes. - * this can be updated on a per instance basis and must be set prior to - * calling `observe` on the first target. - */ -IntersectionObserver.prototype.POLL_INTERVAL = null; - -/** - * Use a mutation observer on the root element - * to detect intersection changes. - */ -IntersectionObserver.prototype.USE_MUTATION_OBSERVER = true; - - -/** - * Starts observing a target element for intersection changes based on - * the thresholds values. - * @param {Element} target The DOM element to observe. - */ -IntersectionObserver.prototype.observe = function(target) { - var isTargetAlreadyObserved = this._observationTargets.some(function(item) { - return item.element == target; - }); - - if (isTargetAlreadyObserved) { - return; - } - - if (!(target && target.nodeType == 1)) { - throw new Error('target must be an Element'); - } - - this._registerInstance(); - this._observationTargets.push({element: target, entry: null}); - this._monitorIntersections(); - this._checkForIntersections(); -}; - - -/** - * Stops observing a target element for intersection changes. - * @param {Element} target The DOM element to observe. - */ -IntersectionObserver.prototype.unobserve = function(target) { - this._observationTargets = - this._observationTargets.filter(function(item) { - - return item.element != target; - }); - if (!this._observationTargets.length) { - this._unmonitorIntersections(); - this._unregisterInstance(); - } -}; - - -/** - * Stops observing all target elements for intersection changes. - */ -IntersectionObserver.prototype.disconnect = function() { - this._observationTargets = []; - this._unmonitorIntersections(); - this._unregisterInstance(); -}; - - -/** - * Returns any queue entries that have not yet been reported to the - * callback and clears the queue. This can be used in conjunction with the - * callback to obtain the absolute most up-to-date intersection information. - * @return {Array} The currently queued entries. - */ -IntersectionObserver.prototype.takeRecords = function() { - var records = this._queuedEntries.slice(); - this._queuedEntries = []; - return records; -}; - - -/** - * Accepts the threshold value from the user configuration object and - * returns a sorted array of unique threshold values. If a value is not - * between 0 and 1 and error is thrown. - * @private - * @param {Array|number=} opt_threshold An optional threshold value or - * a list of threshold values, defaulting to [0]. - * @return {Array} A sorted list of unique and valid threshold values. - */ -IntersectionObserver.prototype._initThresholds = function(opt_threshold) { - var threshold = opt_threshold || [0]; - if (!Array.isArray(threshold)) threshold = [threshold]; - - return threshold.sort().filter(function(t, i, a) { - if (typeof t != 'number' || isNaN(t) || t < 0 || t > 1) { - throw new Error('threshold must be a number between 0 and 1 inclusively'); - } - return t !== a[i - 1]; - }); -}; - - -/** - * Accepts the rootMargin value from the user configuration object - * and returns an array of the four margin values as an object containing - * the value and unit properties. If any of the values are not properly - * formatted or use a unit other than px or %, and error is thrown. - * @private - * @param {string=} opt_rootMargin An optional rootMargin value, - * defaulting to '0px'. - * @return {Array} An array of margin objects with the keys - * value and unit. - */ -IntersectionObserver.prototype._parseRootMargin = function(opt_rootMargin) { - var marginString = opt_rootMargin || '0px'; - var margins = marginString.split(/\s+/).map(function(margin) { - var parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin); - if (!parts) { - throw new Error('rootMargin must be specified in pixels or percent'); - } - return {value: parseFloat(parts[1]), unit: parts[2]}; - }); - - // Handles shorthand. - margins[1] = margins[1] || margins[0]; - margins[2] = margins[2] || margins[0]; - margins[3] = margins[3] || margins[1]; - - return margins; -}; - - -/** - * Starts polling for intersection changes if the polling is not already - * happening, and if the page's visibility state is visible. - * @private - */ -IntersectionObserver.prototype._monitorIntersections = function() { - if (!this._monitoringIntersections) { - this._monitoringIntersections = true; - - // If a poll interval is set, use polling instead of listening to - // resize and scroll events or DOM mutations. - if (this.POLL_INTERVAL) { - this._monitoringInterval = setInterval( - this._checkForIntersections, this.POLL_INTERVAL); - } - else { - addEvent(window, 'resize', this._checkForIntersections, true); - addEvent(document, 'scroll', this._checkForIntersections, true); - - if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) { - this._domObserver = new MutationObserver(this._checkForIntersections); - this._domObserver.observe(document, { - attributes: true, - childList: true, - characterData: true, - subtree: true - }); - } - } - } -}; - - -/** - * Stops polling for intersection changes. - * @private - */ -IntersectionObserver.prototype._unmonitorIntersections = function() { - if (this._monitoringIntersections) { - this._monitoringIntersections = false; - - clearInterval(this._monitoringInterval); - this._monitoringInterval = null; - - removeEvent(window, 'resize', this._checkForIntersections, true); - removeEvent(document, 'scroll', this._checkForIntersections, true); - - if (this._domObserver) { - this._domObserver.disconnect(); - this._domObserver = null; - } - } -}; - - -/** - * Scans each observation target for intersection changes and adds them - * to the internal entries queue. If new entries are found, it - * schedules the callback to be invoked. - * @private - */ -IntersectionObserver.prototype._checkForIntersections = function() { - var rootIsInDom = this._rootIsInDom(); - var rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect(); - - this._observationTargets.forEach(function(item) { - var target = item.element; - var targetRect = getBoundingClientRect(target); - var rootContainsTarget = this._rootContainsTarget(target); - var oldEntry = item.entry; - var intersectionRect = rootIsInDom && rootContainsTarget && - this._computeTargetAndRootIntersection(target, rootRect); - - var newEntry = item.entry = new IntersectionObserverEntry({ - time: now(), - target: target, - boundingClientRect: targetRect, - rootBounds: rootRect, - intersectionRect: intersectionRect - }); - - if (!oldEntry) { - this._queuedEntries.push(newEntry); - } else if (rootIsInDom && rootContainsTarget) { - // If the new entry intersection ratio has crossed any of the - // thresholds, add a new entry. - if (this._hasCrossedThreshold(oldEntry, newEntry)) { - this._queuedEntries.push(newEntry); - } - } else { - // If the root is not in the DOM or target is not contained within - // root but the previous entry for this target had an intersection, - // add a new record indicating removal. - if (oldEntry && oldEntry.isIntersecting) { - this._queuedEntries.push(newEntry); - } - } - }, this); - - if (this._queuedEntries.length) { - this._callback(this.takeRecords(), this); - } -}; - - -/** - * Accepts a target and root rect computes the intersection between then - * following the algorithm in the spec. - * TODO(philipwalton): at this time clip-path is not considered. - * https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo - * @param {Element} target The target DOM element - * @param {Object} rootRect The bounding rect of the root after being - * expanded by the rootMargin value. - * @return {?Object} The final intersection rect object or undefined if no - * intersection is found. - * @private - */ -IntersectionObserver.prototype._computeTargetAndRootIntersection = - function(target, rootRect) { - - // If the element isn't displayed, an intersection can't happen. - if (window.getComputedStyle(target).display == 'none') return; - - var targetRect = getBoundingClientRect(target); - var intersectionRect = targetRect; - var parent = getParentNode(target); - var atRoot = false; - - while (!atRoot) { - var parentRect = null; - var parentComputedStyle = parent.nodeType == 1 ? - window.getComputedStyle(parent) : {}; - - // If the parent isn't displayed, an intersection can't happen. - if (parentComputedStyle.display == 'none') return; - - if (parent == this.root || parent == document) { - atRoot = true; - parentRect = rootRect; - } else { - // If the element has a non-visible overflow, and it's not the - // or element, update the intersection rect. - // Note: and cannot be clipped to a rect that's not also - // the document rect, so no need to compute a new intersection. - if (parent != document.body && - parent != document.documentElement && - parentComputedStyle.overflow != 'visible') { - parentRect = getBoundingClientRect(parent); - } - } - - // If either of the above conditionals set a new parentRect, - // calculate new intersection data. - if (parentRect) { - intersectionRect = computeRectIntersection(parentRect, intersectionRect); - - if (!intersectionRect) break; - } - parent = getParentNode(parent); - } - return intersectionRect; -}; - - -/** - * Returns the root rect after being expanded by the rootMargin value. - * @return {Object} The expanded root rect. - * @private - */ -IntersectionObserver.prototype._getRootRect = function() { - var rootRect; - if (this.root) { - rootRect = getBoundingClientRect(this.root); - } else { - // Use / instead of window since scroll bars affect size. - var html = document.documentElement; - var body = document.body; - rootRect = { - top: 0, - left: 0, - right: html.clientWidth || body.clientWidth, - width: html.clientWidth || body.clientWidth, - bottom: html.clientHeight || body.clientHeight, - height: html.clientHeight || body.clientHeight - }; - } - return this._expandRectByRootMargin(rootRect); -}; - - -/** - * Accepts a rect and expands it by the rootMargin value. - * @param {Object} rect The rect object to expand. - * @return {Object} The expanded rect. - * @private - */ -IntersectionObserver.prototype._expandRectByRootMargin = function(rect) { - var margins = this._rootMarginValues.map(function(margin, i) { - return margin.unit == 'px' ? margin.value : - margin.value * (i % 2 ? rect.width : rect.height) / 100; - }); - var newRect = { - top: rect.top - margins[0], - right: rect.right + margins[1], - bottom: rect.bottom + margins[2], - left: rect.left - margins[3] - }; - newRect.width = newRect.right - newRect.left; - newRect.height = newRect.bottom - newRect.top; - - return newRect; -}; - - -/** - * Accepts an old and new entry and returns true if at least one of the - * threshold values has been crossed. - * @param {?IntersectionObserverEntry} oldEntry The previous entry for a - * particular target element or null if no previous entry exists. - * @param {IntersectionObserverEntry} newEntry The current entry for a - * particular target element. - * @return {boolean} Returns true if a any threshold has been crossed. - * @private - */ -IntersectionObserver.prototype._hasCrossedThreshold = - function(oldEntry, newEntry) { - - // To make comparing easier, an entry that has a ratio of 0 - // but does not actually intersect is given a value of -1 - var oldRatio = oldEntry && oldEntry.isIntersecting ? - oldEntry.intersectionRatio || 0 : -1; - var newRatio = newEntry.isIntersecting ? - newEntry.intersectionRatio || 0 : -1; - - // Ignore unchanged ratios - if (oldRatio === newRatio) return; - - for (var i = 0; i < this.thresholds.length; i++) { - var threshold = this.thresholds[i]; - - // Return true if an entry matches a threshold or if the new ratio - // and the old ratio are on the opposite sides of a threshold. - if (threshold == oldRatio || threshold == newRatio || - threshold < oldRatio !== threshold < newRatio) { - return true; - } - } -}; - - -/** - * Returns whether or not the root element is an element and is in the DOM. - * @return {boolean} True if the root element is an element and is in the DOM. - * @private - */ -IntersectionObserver.prototype._rootIsInDom = function() { - return !this.root || containsDeep(document, this.root); -}; - - -/** - * Returns whether or not the target element is a child of root. - * @param {Element} target The target element to check. - * @return {boolean} True if the target element is a child of root. - * @private - */ -IntersectionObserver.prototype._rootContainsTarget = function(target) { - return containsDeep(this.root || document, target); -}; - - -/** - * Adds the instance to the global IntersectionObserver registry if it isn't - * already present. - * @private - */ -IntersectionObserver.prototype._registerInstance = function() { - if (registry.indexOf(this) < 0) { - registry.push(this); - } -}; - - -/** - * Removes the instance from the global IntersectionObserver registry. - * @private - */ -IntersectionObserver.prototype._unregisterInstance = function() { - var index = registry.indexOf(this); - if (index != -1) registry.splice(index, 1); -}; - - -/** - * Returns the result of the performance.now() method or null in browsers - * that don't support the API. - * @return {number} The elapsed time since the page was requested. - */ -function now() { - return window.performance && performance.now && performance.now(); -} - - -/** - * Throttles a function and delays its execution, so it's only called at most - * once within a given time period. - * @param {Function} fn The function to throttle. - * @param {number} timeout The amount of time that must pass before the - * function can be called again. - * @return {Function} The throttled function. - */ -function throttle(fn, timeout) { - var timer = null; - return function () { - if (!timer) { - timer = setTimeout(function() { - fn(); - timer = null; - }, timeout); - } - }; -} - - -/** - * Adds an event handler to a DOM node ensuring cross-browser compatibility. - * @param {Node} node The DOM node to add the event handler to. - * @param {string} event The event name. - * @param {Function} fn The event handler to add. - * @param {boolean} opt_useCapture Optionally adds the even to the capture - * phase. Note: this only works in modern browsers. - */ -function addEvent(node, event, fn, opt_useCapture) { - if (typeof node.addEventListener == 'function') { - node.addEventListener(event, fn, opt_useCapture || false); - } - else if (typeof node.attachEvent == 'function') { - node.attachEvent('on' + event, fn); - } -} - - -/** - * Removes a previously added event handler from a DOM node. - * @param {Node} node The DOM node to remove the event handler from. - * @param {string} event The event name. - * @param {Function} fn The event handler to remove. - * @param {boolean} opt_useCapture If the event handler was added with this - * flag set to true, it should be set to true here in order to remove it. - */ -function removeEvent(node, event, fn, opt_useCapture) { - if (typeof node.removeEventListener == 'function') { - node.removeEventListener(event, fn, opt_useCapture || false); - } - else if (typeof node.detatchEvent == 'function') { - node.detatchEvent('on' + event, fn); - } -} - - -/** - * Returns the intersection between two rect objects. - * @param {Object} rect1 The first rect. - * @param {Object} rect2 The second rect. - * @return {?Object} The intersection rect or undefined if no intersection - * is found. - */ -function computeRectIntersection(rect1, rect2) { - var top = Math.max(rect1.top, rect2.top); - var bottom = Math.min(rect1.bottom, rect2.bottom); - var left = Math.max(rect1.left, rect2.left); - var right = Math.min(rect1.right, rect2.right); - var width = right - left; - var height = bottom - top; - - return (width >= 0 && height >= 0) && { - top: top, - bottom: bottom, - left: left, - right: right, - width: width, - height: height - }; -} - - -/** - * Shims the native getBoundingClientRect for compatibility with older IE. - * @param {Element} el The element whose bounding rect to get. - * @return {Object} The (possibly shimmed) rect of the element. - */ -function getBoundingClientRect(el) { - var rect; - - try { - rect = el.getBoundingClientRect(); - } catch (err) { - // Ignore Windows 7 IE11 "Unspecified error" - // https://github.com/w3c/IntersectionObserver/pull/205 - } - - if (!rect) return getEmptyRect(); - - // Older IE - if (!(rect.width && rect.height)) { - rect = { - top: rect.top, - right: rect.right, - bottom: rect.bottom, - left: rect.left, - width: rect.right - rect.left, - height: rect.bottom - rect.top - }; - } - return rect; -} - - -/** - * Returns an empty rect object. An empty rect is returned when an element - * is not in the DOM. - * @return {Object} The empty rect. - */ -function getEmptyRect() { - return { - top: 0, - bottom: 0, - left: 0, - right: 0, - width: 0, - height: 0 - }; -} - -/** - * Checks to see if a parent element contains a child element (including inside - * shadow DOM). - * @param {Node} parent The parent element. - * @param {Node} child The child element. - * @return {boolean} True if the parent node contains the child node. - */ -function containsDeep(parent, child) { - var node = child; - while (node) { - if (node == parent) return true; - - node = getParentNode(node); - } - return false; -} - - -/** - * Gets the parent node of an element or its host element if the parent node - * is a shadow root. - * @param {Node} node The node whose parent to get. - * @return {Node|null} The parent node or null if no parent exists. - */ -function getParentNode(node) { - var parent = node.parentNode; - - if (parent && parent.nodeType == 11 && parent.host) { - // If the parent is a shadow root, return the host element. - return parent.host; - } - return parent; -} - - -// Exposes the constructors globally. -window.IntersectionObserver = IntersectionObserver; -window.IntersectionObserverEntry = IntersectionObserverEntry; - -}(window, document)); diff --git a/yarn.lock b/yarn.lock index c207206175..26f75cc7bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1823,11 +1823,6 @@ inquirer@^7.0.0: strip-ansi "^6.0.0" through "^2.3.6" -intersection-observer@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.5.1.tgz#e340fc56ce74290fe2b2394d1ce88c4353ac6dfa" - integrity sha512-Zd7Plneq82kiXFixs7bX62YnuZ0BMRci9br7io88LwDyF3V43cQMI+G5IiTlTNTt+LsDUppl19J/M2Fp9UkH6g== - is-absolute@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" From 4a4cceca4da02bd91acd1443b73862413e4e985c Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 22 Jun 2021 10:04:33 -0400 Subject: [PATCH 139/403] DEV: Remove leftover reference to intersection-observer (#13478) Followup to f343cfd, should fix the build. --- app/assets/javascripts/discourse/tests/theme_qunit_vendor.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js index 5776eb6784..f97174a43f 100644 --- a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js +++ b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js @@ -32,6 +32,5 @@ //= require jquery.autoellipsis-1.0.10 //= require virtual-dom //= require virtual-dom-amd -//= require intersection-observer //= require discourse-shims //= require pretty-text-bundle From 820068ddaf23373d172dbae5169c4b21538fe1d5 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 Jun 2021 17:00:55 +0200 Subject: [PATCH 140/403] FIX: `fix_missing_s3` rake task could fail due to missing upload (#13479) --- lib/tasks/uploads.rake | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index 42d089fcef..2915cd2fb1 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -1006,7 +1006,8 @@ def fix_missing_s3 verification_status: Upload.verification_statuses[:invalid_etag] ).pluck(:id) ids.each do |id| - upload = Upload.find(id) + upload = Upload.find_by(id: id) + next if !upload tempfile = nil From e76c583b91f7ae9c528dd5c04705427d7870677a Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 22 Jun 2021 16:02:24 +0100 Subject: [PATCH 141/403] DEV: Promote old post-deploy migrations to pre-deploy migrations (#13477) Having a large number of post-deploy migrations running out-of-numerical-sequence with pre-deploy migrations can be problematic. For example, if we have the sequence - db/migrate/2017... - add column - db/post_migrate/2018... - drop the column - db/migrate/2021... - add the same column again It will work fine in numerical order. But if you run the pre-deploy migrations **followed by** the post-deploy migrations, you will not get the same result. Our post-deploy system is designed to allow for seamless upgrades of Discourse. However, it is reasonable for us to only support this totally seamless experience for a limited period of time. This commit moves all post_deploy migrations which are more than 1 year old (i.e. more than 2 major Discourse versions ago) into the regular pre-deploy migrations directory. This limits the impact of any edge cases caused by out-of-numerical-sequence migrations. --- .../20180917024729_remove_superfluous_columns.rb | 0 .../20180917034056_remove_superfluous_tables.rb | 0 ...2123001_drop_group_locked_trust_level_from_user.rb | 0 .../20181112013117_migrate_url_site_settings.rb | 0 ...103065652_remove_uploaded_meta_id_from_category.rb | 0 ...121203023_drop_queued_post_id_from_user_actions.rb | 0 .../20190123171817_drop_queued_posts.rb | 0 .../20190205104116_drop_unused_auth_tables.rb | 0 .../20190208144706_drop_unused_auth_tables_again.rb | 0 .../20190312194528_drop_email_user_options_columns.rb | 0 .../20190508141824_drop_claimed_by_id.rb | 0 .../20190716124050_remove_via_email_from_invite.rb | 0 .../20191107032231_change_notification_level.rb | 0 ...90330_remove_suppress_from_latest_from_category.rb | 0 ...144706_drop_unused_google_instagram_auth_tables.rb | 0 .../20191219112000_remove_key_from_api_keys.rb | 0 ...00117174646_make_post_reply_id_column_read_only.rb | 0 .../20200120131338_drop_unused_columns.rb | 0 ...0233427_drop_old_unread_pm_notification_indices.rb | 0 .../20200408121312_remove_key_from_user_api_key.rb | 0 ...rop_automatic_membership_retroactive_from_group.rb | 0 ...4032633_remove_canonical_email_from_user_emails.rb | 0 ...0200430010528_remove_avg_time_from_topics_posts.rb | 0 ...9_allow_null_old_email_on_email_change_requests.rb | 0 .../20200513185052_drop_topic_reply_count.rb | 0 .../20200520001619_remove_fks_from_bookmarks.rb | 0 .../20200522004855_remove_access_control_post_fk.rb | 0 ...0601111500_remove_image_url_from_post_and_topic.rb | 0 .../20200610150900_correct_posts_schema.rb | 0 lib/migration/safe_migrate.rb | 11 +++++++++-- 30 files changed, 9 insertions(+), 2 deletions(-) rename db/{post_migrate => migrate}/20180917024729_remove_superfluous_columns.rb (100%) rename db/{post_migrate => migrate}/20180917034056_remove_superfluous_tables.rb (100%) rename db/{post_migrate => migrate}/20181012123001_drop_group_locked_trust_level_from_user.rb (100%) rename db/{post_migrate => migrate}/20181112013117_migrate_url_site_settings.rb (100%) rename db/{post_migrate => migrate}/20190103065652_remove_uploaded_meta_id_from_category.rb (100%) rename db/{post_migrate => migrate}/20190121203023_drop_queued_post_id_from_user_actions.rb (100%) rename db/{post_migrate => migrate}/20190123171817_drop_queued_posts.rb (100%) rename db/{post_migrate => migrate}/20190205104116_drop_unused_auth_tables.rb (100%) rename db/{post_migrate => migrate}/20190208144706_drop_unused_auth_tables_again.rb (100%) rename db/{post_migrate => migrate}/20190312194528_drop_email_user_options_columns.rb (100%) rename db/{post_migrate => migrate}/20190508141824_drop_claimed_by_id.rb (100%) rename db/{post_migrate => migrate}/20190716124050_remove_via_email_from_invite.rb (100%) rename db/{post_migrate => migrate}/20191107032231_change_notification_level.rb (100%) rename db/{post_migrate => migrate}/20191107190330_remove_suppress_from_latest_from_category.rb (100%) rename db/{post_migrate => migrate}/20191129144706_drop_unused_google_instagram_auth_tables.rb (100%) rename db/{post_migrate => migrate}/20191219112000_remove_key_from_api_keys.rb (100%) rename db/{post_migrate => migrate}/20200117174646_make_post_reply_id_column_read_only.rb (100%) rename db/{post_migrate => migrate}/20200120131338_drop_unused_columns.rb (100%) rename db/{post_migrate => migrate}/20200330233427_drop_old_unread_pm_notification_indices.rb (100%) rename db/{post_migrate => migrate}/20200408121312_remove_key_from_user_api_key.rb (100%) rename db/{post_migrate => migrate}/20200415140830_drop_automatic_membership_retroactive_from_group.rb (100%) rename db/{post_migrate => migrate}/20200424032633_remove_canonical_email_from_user_emails.rb (100%) rename db/{post_migrate => migrate}/20200430010528_remove_avg_time_from_topics_posts.rb (100%) rename db/{post_migrate => migrate}/20200508141209_allow_null_old_email_on_email_change_requests.rb (100%) rename db/{post_migrate => migrate}/20200513185052_drop_topic_reply_count.rb (100%) rename db/{post_migrate => migrate}/20200520001619_remove_fks_from_bookmarks.rb (100%) rename db/{post_migrate => migrate}/20200522004855_remove_access_control_post_fk.rb (100%) rename db/{post_migrate => migrate}/20200601111500_remove_image_url_from_post_and_topic.rb (100%) rename db/{post_migrate => migrate}/20200610150900_correct_posts_schema.rb (100%) diff --git a/db/post_migrate/20180917024729_remove_superfluous_columns.rb b/db/migrate/20180917024729_remove_superfluous_columns.rb similarity index 100% rename from db/post_migrate/20180917024729_remove_superfluous_columns.rb rename to db/migrate/20180917024729_remove_superfluous_columns.rb diff --git a/db/post_migrate/20180917034056_remove_superfluous_tables.rb b/db/migrate/20180917034056_remove_superfluous_tables.rb similarity index 100% rename from db/post_migrate/20180917034056_remove_superfluous_tables.rb rename to db/migrate/20180917034056_remove_superfluous_tables.rb diff --git a/db/post_migrate/20181012123001_drop_group_locked_trust_level_from_user.rb b/db/migrate/20181012123001_drop_group_locked_trust_level_from_user.rb similarity index 100% rename from db/post_migrate/20181012123001_drop_group_locked_trust_level_from_user.rb rename to db/migrate/20181012123001_drop_group_locked_trust_level_from_user.rb diff --git a/db/post_migrate/20181112013117_migrate_url_site_settings.rb b/db/migrate/20181112013117_migrate_url_site_settings.rb similarity index 100% rename from db/post_migrate/20181112013117_migrate_url_site_settings.rb rename to db/migrate/20181112013117_migrate_url_site_settings.rb diff --git a/db/post_migrate/20190103065652_remove_uploaded_meta_id_from_category.rb b/db/migrate/20190103065652_remove_uploaded_meta_id_from_category.rb similarity index 100% rename from db/post_migrate/20190103065652_remove_uploaded_meta_id_from_category.rb rename to db/migrate/20190103065652_remove_uploaded_meta_id_from_category.rb diff --git a/db/post_migrate/20190121203023_drop_queued_post_id_from_user_actions.rb b/db/migrate/20190121203023_drop_queued_post_id_from_user_actions.rb similarity index 100% rename from db/post_migrate/20190121203023_drop_queued_post_id_from_user_actions.rb rename to db/migrate/20190121203023_drop_queued_post_id_from_user_actions.rb diff --git a/db/post_migrate/20190123171817_drop_queued_posts.rb b/db/migrate/20190123171817_drop_queued_posts.rb similarity index 100% rename from db/post_migrate/20190123171817_drop_queued_posts.rb rename to db/migrate/20190123171817_drop_queued_posts.rb diff --git a/db/post_migrate/20190205104116_drop_unused_auth_tables.rb b/db/migrate/20190205104116_drop_unused_auth_tables.rb similarity index 100% rename from db/post_migrate/20190205104116_drop_unused_auth_tables.rb rename to db/migrate/20190205104116_drop_unused_auth_tables.rb diff --git a/db/post_migrate/20190208144706_drop_unused_auth_tables_again.rb b/db/migrate/20190208144706_drop_unused_auth_tables_again.rb similarity index 100% rename from db/post_migrate/20190208144706_drop_unused_auth_tables_again.rb rename to db/migrate/20190208144706_drop_unused_auth_tables_again.rb diff --git a/db/post_migrate/20190312194528_drop_email_user_options_columns.rb b/db/migrate/20190312194528_drop_email_user_options_columns.rb similarity index 100% rename from db/post_migrate/20190312194528_drop_email_user_options_columns.rb rename to db/migrate/20190312194528_drop_email_user_options_columns.rb diff --git a/db/post_migrate/20190508141824_drop_claimed_by_id.rb b/db/migrate/20190508141824_drop_claimed_by_id.rb similarity index 100% rename from db/post_migrate/20190508141824_drop_claimed_by_id.rb rename to db/migrate/20190508141824_drop_claimed_by_id.rb diff --git a/db/post_migrate/20190716124050_remove_via_email_from_invite.rb b/db/migrate/20190716124050_remove_via_email_from_invite.rb similarity index 100% rename from db/post_migrate/20190716124050_remove_via_email_from_invite.rb rename to db/migrate/20190716124050_remove_via_email_from_invite.rb diff --git a/db/post_migrate/20191107032231_change_notification_level.rb b/db/migrate/20191107032231_change_notification_level.rb similarity index 100% rename from db/post_migrate/20191107032231_change_notification_level.rb rename to db/migrate/20191107032231_change_notification_level.rb diff --git a/db/post_migrate/20191107190330_remove_suppress_from_latest_from_category.rb b/db/migrate/20191107190330_remove_suppress_from_latest_from_category.rb similarity index 100% rename from db/post_migrate/20191107190330_remove_suppress_from_latest_from_category.rb rename to db/migrate/20191107190330_remove_suppress_from_latest_from_category.rb diff --git a/db/post_migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb b/db/migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb similarity index 100% rename from db/post_migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb rename to db/migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb diff --git a/db/post_migrate/20191219112000_remove_key_from_api_keys.rb b/db/migrate/20191219112000_remove_key_from_api_keys.rb similarity index 100% rename from db/post_migrate/20191219112000_remove_key_from_api_keys.rb rename to db/migrate/20191219112000_remove_key_from_api_keys.rb diff --git a/db/post_migrate/20200117174646_make_post_reply_id_column_read_only.rb b/db/migrate/20200117174646_make_post_reply_id_column_read_only.rb similarity index 100% rename from db/post_migrate/20200117174646_make_post_reply_id_column_read_only.rb rename to db/migrate/20200117174646_make_post_reply_id_column_read_only.rb diff --git a/db/post_migrate/20200120131338_drop_unused_columns.rb b/db/migrate/20200120131338_drop_unused_columns.rb similarity index 100% rename from db/post_migrate/20200120131338_drop_unused_columns.rb rename to db/migrate/20200120131338_drop_unused_columns.rb diff --git a/db/post_migrate/20200330233427_drop_old_unread_pm_notification_indices.rb b/db/migrate/20200330233427_drop_old_unread_pm_notification_indices.rb similarity index 100% rename from db/post_migrate/20200330233427_drop_old_unread_pm_notification_indices.rb rename to db/migrate/20200330233427_drop_old_unread_pm_notification_indices.rb diff --git a/db/post_migrate/20200408121312_remove_key_from_user_api_key.rb b/db/migrate/20200408121312_remove_key_from_user_api_key.rb similarity index 100% rename from db/post_migrate/20200408121312_remove_key_from_user_api_key.rb rename to db/migrate/20200408121312_remove_key_from_user_api_key.rb diff --git a/db/post_migrate/20200415140830_drop_automatic_membership_retroactive_from_group.rb b/db/migrate/20200415140830_drop_automatic_membership_retroactive_from_group.rb similarity index 100% rename from db/post_migrate/20200415140830_drop_automatic_membership_retroactive_from_group.rb rename to db/migrate/20200415140830_drop_automatic_membership_retroactive_from_group.rb diff --git a/db/post_migrate/20200424032633_remove_canonical_email_from_user_emails.rb b/db/migrate/20200424032633_remove_canonical_email_from_user_emails.rb similarity index 100% rename from db/post_migrate/20200424032633_remove_canonical_email_from_user_emails.rb rename to db/migrate/20200424032633_remove_canonical_email_from_user_emails.rb diff --git a/db/post_migrate/20200430010528_remove_avg_time_from_topics_posts.rb b/db/migrate/20200430010528_remove_avg_time_from_topics_posts.rb similarity index 100% rename from db/post_migrate/20200430010528_remove_avg_time_from_topics_posts.rb rename to db/migrate/20200430010528_remove_avg_time_from_topics_posts.rb diff --git a/db/post_migrate/20200508141209_allow_null_old_email_on_email_change_requests.rb b/db/migrate/20200508141209_allow_null_old_email_on_email_change_requests.rb similarity index 100% rename from db/post_migrate/20200508141209_allow_null_old_email_on_email_change_requests.rb rename to db/migrate/20200508141209_allow_null_old_email_on_email_change_requests.rb diff --git a/db/post_migrate/20200513185052_drop_topic_reply_count.rb b/db/migrate/20200513185052_drop_topic_reply_count.rb similarity index 100% rename from db/post_migrate/20200513185052_drop_topic_reply_count.rb rename to db/migrate/20200513185052_drop_topic_reply_count.rb diff --git a/db/post_migrate/20200520001619_remove_fks_from_bookmarks.rb b/db/migrate/20200520001619_remove_fks_from_bookmarks.rb similarity index 100% rename from db/post_migrate/20200520001619_remove_fks_from_bookmarks.rb rename to db/migrate/20200520001619_remove_fks_from_bookmarks.rb diff --git a/db/post_migrate/20200522004855_remove_access_control_post_fk.rb b/db/migrate/20200522004855_remove_access_control_post_fk.rb similarity index 100% rename from db/post_migrate/20200522004855_remove_access_control_post_fk.rb rename to db/migrate/20200522004855_remove_access_control_post_fk.rb diff --git a/db/post_migrate/20200601111500_remove_image_url_from_post_and_topic.rb b/db/migrate/20200601111500_remove_image_url_from_post_and_topic.rb similarity index 100% rename from db/post_migrate/20200601111500_remove_image_url_from_post_and_topic.rb rename to db/migrate/20200601111500_remove_image_url_from_post_and_topic.rb diff --git a/db/post_migrate/20200610150900_correct_posts_schema.rb b/db/migrate/20200610150900_correct_posts_schema.rb similarity index 100% rename from db/post_migrate/20200610150900_correct_posts_schema.rb rename to db/migrate/20200610150900_correct_posts_schema.rb diff --git a/lib/migration/safe_migrate.rb b/lib/migration/safe_migrate.rb index 0b65ee0a26..3b4a386dfa 100644 --- a/lib/migration/safe_migrate.rb +++ b/lib/migration/safe_migrate.rb @@ -6,7 +6,6 @@ class Discourse::InvalidMigration < StandardError; end class Migration::SafeMigrate module SafeMigration - UNSAFE_VERSION = 20180321015220 @@enable_safe = true def self.enable_safe! @@ -19,7 +18,7 @@ class Migration::SafeMigrate def migrate(direction) if direction == :up && - version && version > UNSAFE_VERSION && + version && version > Migration::SafeMigrate.earliest_post_deploy_version && @@enable_safe != false && !is_post_deploy_migration? @@ -153,4 +152,12 @@ class Migration::SafeMigrate raise Discourse::InvalidMigration, "Attempt was made to rename or delete column" end end + + def self.earliest_post_deploy_version + @@earliest_post_deploy_version ||= begin + first_file = Dir.glob("#{Discourse::DB_POST_MIGRATE_PATH}/*.rb").sort.first + file_name = File.basename(first_file, ".rb") + file_name.first(14).to_i + end + end end From e0e1e24c14720487e9e7af94e62b3df6f77525fa Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Tue, 22 Jun 2021 12:12:39 -0300 Subject: [PATCH 142/403] FIX: Ignore posts needing approval when calculating reviewable counts. (#13464) In #12841, we started setting the ReviewableQueuedPost's target and topic after approving it instead of storing them in the payload. As a result, the reviewable_counts query started to include queued posts. When a category is set to require approval, every post has an associated reviewable. Pointing that each post has an associated queued post is not necessary in this case, so I added a WHERE clause to skip them. --- lib/topic_view.rb | 3 ++- spec/components/topic_view_spec.rb | 37 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 843e342163..99efecfdb1 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -509,7 +509,8 @@ class TopicView reviewable_scores s ON reviewable_id = r.id WHERE r.target_id IN (:post_ids) AND - r.target_type = 'Post' + r.target_type = 'Post' AND + COALESCE(s.reason, '') != 'category' GROUP BY target_id SQL diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index cfda8b6ef4..87385de49a 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -915,4 +915,41 @@ describe TopicView do expect(topic_view.show_read_indicator?).to be_falsey end end + + describe '#reviewable_counts' do + it 'exclude posts queued because the category needs approval' do + category = Fabricate.build(:category, user: admin) + category.custom_fields[Category::REQUIRE_TOPIC_APPROVAL] = true + category.save! + manager = NewPostManager.new( + user, + raw: 'to the handler I say enqueue me!', + title: 'this is the title of the queued post', + category: category.id + ) + result = manager.perform + reviewable = result.reviewable + reviewable.perform(admin, :approve_post) + + topic_view = TopicView.new(reviewable.topic, admin) + + expect(topic_view.reviewable_counts).to be_empty + end + + it 'include posts queued for other reasons' do + Fabricate(:watched_word, word: "darn", action: WatchedWord.actions[:require_approval]) + manager = NewPostManager.new( + user, + raw: 'this is darn new post content', + title: 'this is the title of the queued post' + ) + result = manager.perform + reviewable = result.reviewable + reviewable.perform(admin, :approve_post) + + topic_view = TopicView.new(reviewable.topic, admin) + + expect(topic_view.reviewable_counts.keys).to contain_exactly(reviewable.target_id) + end + end end From ee87d8c93b78dee70188199f5451b9866c630630 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Tue, 22 Jun 2021 18:58:03 +0300 Subject: [PATCH 143/403] FEATURE: Make max number of favorite configurable (#13480) It used to be hardcoded to 2 and now it uses max_favorite_badges site setting. When zero, it disables favorite badges. --- .../discourse/app/controllers/user-badges.js | 15 +++++++-------- .../discourse/app/models/user-badge.js | 3 --- .../discourse/app/templates/user/badges.hbs | 2 +- .../stylesheets/common/base/user-badges.scss | 2 +- .../stylesheets/common/components/user-card.scss | 12 ++++++------ .../stylesheets/desktop/components/user-card.scss | 1 - .../stylesheets/mobile/components/user-card.scss | 5 +++-- app/controllers/user_badges_controller.rb | 4 +--- app/models/user.rb | 12 +++++++----- app/serializers/detailed_user_badge_serializer.rb | 1 + config/locales/server.en.yml | 1 + config/site_settings.yml | 5 +++++ spec/requests/user_badges_controller_spec.rb | 8 +++++++- 13 files changed, 40 insertions(+), 31 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/user-badges.js b/app/assets/javascripts/discourse/app/controllers/user-badges.js index 8634e19e4d..c88f721c74 100644 --- a/app/assets/javascripts/discourse/app/controllers/user-badges.js +++ b/app/assets/javascripts/discourse/app/controllers/user-badges.js @@ -1,19 +1,18 @@ import Controller, { inject as controller } from "@ember/controller"; -import { action, computed } from "@ember/object"; +import { action } from "@ember/object"; import { alias, filterBy, sort } from "@ember/object/computed"; +import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ user: controller(), username: alias("user.model.username_lower"), sortedBadges: sort("model", "badgeSortOrder"), favoriteBadges: filterBy("model", "is_favorite", true), - canFavoriteMoreBadges: computed( - "favoriteBadges.length", - "model.meta.max_favorites", - function () { - return this.favoriteBadges.length < this.model.meta.max_favorites; - } - ), + + @discourseComputed("favoriteBadges.length") + canFavoriteMoreBadges(favoriteBadgesCount) { + return favoriteBadgesCount < this.siteSettings.max_favorite_badges; + }, init() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/app/models/user-badge.js b/app/assets/javascripts/discourse/app/models/user-badge.js index a4c090354f..07467f3909 100644 --- a/app/assets/javascripts/discourse/app/models/user-badge.js +++ b/app/assets/javascripts/discourse/app/models/user-badge.js @@ -7,8 +7,6 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import discourseComputed from "discourse-common/utils/decorators"; -const DEFAULT_USER_BADGES_META = { max_favorites: 2 }; - const UserBadge = EmberObject.extend({ @discourseComputed postUrl: function () { @@ -98,7 +96,6 @@ UserBadge.reopenClass({ userBadges.grant_count = json.user_badge_info.grant_count; userBadges.username = json.user_badge_info.username; } - userBadges.meta = json.meta || DEFAULT_USER_BADGES_META; return userBadges; } }, diff --git a/app/assets/javascripts/discourse/app/templates/user/badges.hbs b/app/assets/javascripts/discourse/app/templates/user/badges.hbs index b60d102095..68dd60ea50 100644 --- a/app/assets/javascripts/discourse/app/templates/user/badges.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/badges.hbs @@ -1,6 +1,6 @@ {{#d-section pageClass="user-badges" class="user-content user-badges-list"}}

    - {{i18n "badges.favorite_count" count=this.favoriteBadges.length max=model.meta.max_favorites}} + {{i18n "badges.favorite_count" count=this.favoriteBadges.length max=siteSettings.max_favorite_badges}}

    {{#each sortedBadges as |ub|}} {{badge-card diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index cbb52c5a75..aaa9aff8e8 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -7,7 +7,7 @@ display: inline-flex; align-items: center; background-color: var(--secondary); - margin: 0 0 3px; + margin: 4px 0 0; img { height: 16px; diff --git a/app/assets/stylesheets/common/components/user-card.scss b/app/assets/stylesheets/common/components/user-card.scss index 5f31e02118..82e876602e 100644 --- a/app/assets/stylesheets/common/components/user-card.scss +++ b/app/assets/stylesheets/common/components/user-card.scss @@ -225,8 +225,7 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards // badges .badge-section { - display: flex; - align-items: flex-start; + line-height: 0; .user-badge { @include ellipsis; background: var(--primary-very-low); @@ -236,11 +235,12 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards .user-card-badge-link { overflow: hidden; } + .user-card-badge-link, .more-user-badges { - flex: 0 0 auto; // this is more important than other badges, so don't allow it to shrink - a { - @extend .user-badge; - } + display: inline-block; + } + .more-user-badges a { + @extend .user-badge; } } } diff --git a/app/assets/stylesheets/desktop/components/user-card.scss b/app/assets/stylesheets/desktop/components/user-card.scss index 597458bf7d..5bdf16d42e 100644 --- a/app/assets/stylesheets/desktop/components/user-card.scss +++ b/app/assets/stylesheets/desktop/components/user-card.scss @@ -42,7 +42,6 @@ .user-badge { display: block; max-width: 185px; - margin: 0 0.5em 0 0; } .more-user-badges { max-width: 125px; diff --git a/app/assets/stylesheets/mobile/components/user-card.scss b/app/assets/stylesheets/mobile/components/user-card.scss index a912aa0e7c..1e7b33e3d2 100644 --- a/app/assets/stylesheets/mobile/components/user-card.scss +++ b/app/assets/stylesheets/mobile/components/user-card.scss @@ -53,6 +53,8 @@ $avatar_width: 120px; .user-card { // badges .badge-section { + display: flex; + align-items: flex-start; flex-wrap: wrap; .user-card-badge-link, .more-user-badges { @@ -61,8 +63,7 @@ $avatar_width: 120px; max-width: 50%; // for text ellipsis padding: 2px 0; box-sizing: border-box; - &:nth-child(1), - &:nth-child(3) { + &:nth-child(odd) { padding-right: 4px; } a { diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index 1b13f0c6e0..42f298b132 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class UserBadgesController < ApplicationController - MAX_FAVORITES = 2 MAX_BADGES = 96 # This was limited in PR#2360 to make it divisible by 8 before_action :ensure_badges_enabled @@ -51,7 +50,6 @@ class UserBadgesController < ApplicationController user_badges, DetailedUserBadgeSerializer, root: :user_badges, - meta: { max_favorites: MAX_FAVORITES }, ) end @@ -107,7 +105,7 @@ class UserBadgesController < ApplicationController return render json: failed_json, status: 403 end - if !user_badge.is_favorite && user_badges.where(is_favorite: true).count >= MAX_FAVORITES + if !user_badge.is_favorite && user_badges.where(is_favorite: true).count >= SiteSetting.max_favorite_badges return render json: failed_json, status: 400 end diff --git a/app/models/user.rb b/app/models/user.rb index c43cfc7a34..a1b9208c28 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -73,9 +73,11 @@ class User < ActiveRecord::Base }, class_name: "UserSecurityKey" has_many :badges, through: :user_badges - has_many :default_featured_user_badges, - -> { for_enabled_badges.grouped_with_count.where("featured_rank <= ?", DEFAULT_FEATURED_BADGE_COUNT) }, - class_name: "UserBadge" + has_many :default_featured_user_badges, -> { + max_featured_rank = SiteSetting.max_favorite_badges > 0 ? SiteSetting.max_favorite_badges + 1 + : DEFAULT_FEATURED_BADGE_COUNT + for_enabled_badges.grouped_with_count.where("featured_rank <= ?", max_featured_rank) + }, class_name: "UserBadge" has_many :topics_allowed, through: :topic_allowed_users, source: :topic has_many :groups, through: :group_users @@ -1035,8 +1037,8 @@ class User < ActiveRecord::Base user_stat&.distinct_badge_count end - def featured_user_badges(limit = DEFAULT_FEATURED_BADGE_COUNT) - if limit == DEFAULT_FEATURED_BADGE_COUNT + def featured_user_badges(limit = nil) + if limit.nil? default_featured_user_badges else user_badges.grouped_with_count.where("featured_rank <= ?", limit) diff --git a/app/serializers/detailed_user_badge_serializer.rb b/app/serializers/detailed_user_badge_serializer.rb index 8d3649bd6e..b924fffb26 100644 --- a/app/serializers/detailed_user_badge_serializer.rb +++ b/app/serializers/detailed_user_badge_serializer.rb @@ -25,6 +25,7 @@ class DetailedUserBadgeSerializer < BasicUserBadgeSerializer end def can_favorite + SiteSetting.max_favorite_badges > 0 && (scope.current_user.present? && object.user_id == scope.current_user.id) && !(1..4).include?(object.badge_id) end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index f04a801139..0164ecba09 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1653,6 +1653,7 @@ en: email_token_valid_hours: "Forgot password / activate account tokens are valid for (n) hours." enable_badges: "Enable the badge system" + max_favorite_badges: "Maximum number of badges that user can select" enable_whispers: "Allow staff private communication within topics." allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines. In exceptional cases you can permanently override robots.txt." diff --git a/config/site_settings.yml b/config/site_settings.yml index 0f91cd4c3e..a6ec120c76 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -307,6 +307,11 @@ basic: client: true default: false hidden: true + max_favorite_badges: + client: true + default: 2 + min: 0 + max: 5 enable_whispers: client: true default: false diff --git a/spec/requests/user_badges_controller_spec.rb b/spec/requests/user_badges_controller_spec.rb index 26b55d70fa..9e68bbc4d8 100644 --- a/spec/requests/user_badges_controller_spec.rb +++ b/spec/requests/user_badges_controller_spec.rb @@ -277,12 +277,18 @@ describe UserBadgesController do expect(response.status).to eq(403) end - it "checks that the user has less than two favorited badges" do + it "checks that the user has less than max_favorites_badges favorited badges" do sign_in(user) UserBadge.create(badge: Fabricate(:badge), user: user, granted_by: Discourse.system_user, granted_at: Time.now, is_favorite: true) UserBadge.create(badge: Fabricate(:badge), user: user, granted_by: Discourse.system_user, granted_at: Time.now, is_favorite: true) + put "/user_badges/#{user_badge.id}/toggle_favorite.json" expect(response.status).to eq(400) + + SiteSetting.max_favorite_badges = 3 + + put "/user_badges/#{user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) end it "favorites a badge" do From fe5923da06c2e432995a41039ae238809a6ef27e Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 22 Jun 2021 19:29:20 +0200 Subject: [PATCH 144/403] DEV: Do not re-throw in popupAjaxError (#13462) Effectively reverts https://github.com/discourse/discourse/commit/3ddc33b07c8b5e6e68b4d0ba92f60a8d4663341a Makes the failure states testable; see the uncommented test. I don't think we're re-catching these errors anyway? _update:_ We did in a single instance in discourse-code-review but it wasn't really intentional and I fixed it in https://github.com/discourse/discourse-code-review/pull/73 --- .../discourse/app/lib/ajax-error.js | 8 --- .../group-manage-email-settings-test.js | 62 +++++++++---------- 2 files changed, 31 insertions(+), 39 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/ajax-error.js b/app/assets/javascripts/discourse/app/lib/ajax-error.js index 0f3feba41c..4796e627d7 100644 --- a/app/assets/javascripts/discourse/app/lib/ajax-error.js +++ b/app/assets/javascripts/discourse/app/lib/ajax-error.js @@ -64,13 +64,5 @@ export function throwAjaxError(undoCallback) { } export function popupAjaxError(error) { - if (error && error._discourse_displayed) { - return; - } bootbox.alert(extractError(error)); - - error._discourse_displayed = true; - - // We re-throw in a catch to not swallow the exception - throw error; } diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js index 7cdbae5745..f021f0b55e 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-manage-email-settings-test.js @@ -333,38 +333,38 @@ acceptance( } ); -// acceptance( -// "Managing Group Email Settings - SMTP and IMAP Enabled - Email Test Invalid", -// function (needs) { -// needs.user(); -// needs.settings({ enable_smtp: true, enable_imap: true }); +acceptance( + "Managing Group Email Settings - SMTP and IMAP Enabled - Email Test Invalid", + function (needs) { + needs.user(); + needs.settings({ enable_smtp: true, enable_imap: true }); -// needs.pretender((server, helper) => { -// server.post("/groups/47/test_email_settings", () => { -// return helper.response(400, { -// success: false, -// errors: [ -// "There was an issue with the SMTP credentials provided, check the username and password and try again.", -// ], -// }); -// }); -// }); + needs.pretender((server, helper) => { + server.post("/groups/47/test_email_settings", () => { + return helper.response(422, { + success: false, + errors: [ + "There was an issue with the SMTP credentials provided, check the username and password and try again.", + ], + }); + }); + }); -// test("enabling IMAP, testing, and saving", async function (assert) { -// await visit("/g/discourse/manage/email"); + test("enabling IMAP, testing, and saving", async function (assert) { + await visit("/g/discourse/manage/email"); -// await click("#enable_smtp"); -// await click("#prefill_smtp_gmail"); -// await fillIn('input[name="username"]', "myusername@gmail.com"); -// await fillIn('input[name="password"]', "password@gmail.com"); -// await click(".test-smtp-settings"); + await click("#enable_smtp"); + await click("#prefill_smtp_gmail"); + await fillIn('input[name="username"]', "myusername@gmail.com"); + await fillIn('input[name="password"]', "password@gmail.com"); + await click(".test-smtp-settings"); -// assert.equal( -// query(".modal-body").innerText, -// "There was an issue with the SMTP credentials provided, check the username and password and try again.", -// "shows a dialogue with the error message from the server" -// ); -// await click(".modal-footer .btn.btn-primary"); -// }); -// } -// ); + assert.equal( + query(".modal-body").innerText, + "There was an issue with the SMTP credentials provided, check the username and password and try again.", + "shows a dialogue with the error message from the server" + ); + await click(".modal-footer .btn.btn-primary"); + }); + } +); From 7fc3d7bdde28229d882d98fdd86bc414cfbbe1e7 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Tue, 22 Jun 2021 13:00:04 -0500 Subject: [PATCH 145/403] DEV: Plugin API to add directory columns (#13440) --- .../app/components/table-header-toggle.js | 4 + .../edit-user-directory-columns.js | 6 +- .../discourse/app/controllers/users.js | 19 ++-- .../app/helpers/directory-item-helpers.js | 37 ++++++++ .../app/helpers/directory-item-label.js | 10 --- .../directory-item-user-field-value.js | 16 ---- .../app/helpers/directory-item-value.js | 11 --- .../javascripts/discourse/app/routes/users.js | 12 ++- .../templates/components/directory-item.hbs | 6 +- .../templates/components/directory-table.hbs | 1 + .../discourse/app/templates/group-index.hbs | 8 +- .../app/templates/group-requests.hbs | 4 +- .../mobile/components/directory-item.hbs | 2 +- .../modal/edit-user-directory-columns.hbs | 6 +- .../tests/helpers/create-pretender.js | 89 +++++++++++++++++-- .../discourse/tests/setup-tests.js | 6 -- app/controllers/application_controller.rb | 15 ---- .../directory_columns_controller.rb | 58 +----------- app/controllers/directory_items_controller.rb | 9 +- .../edit_directory_columns_controller.rb | 62 +++++++++++++ app/models/directory_column.rb | 47 ++++++++++ app/models/directory_item.rb | 66 +++++++------- .../directory_column_serializer.rb | 11 +-- app/serializers/directory_item_serializer.rb | 2 +- .../edit_directory_column_serializer.rb | 8 ++ config/routes.rb | 3 +- ...9_reintroduce_type_to_directory_columns.rb | 22 +++++ lib/discourse_event.rb | 3 + lib/plugin/instance.rb | 14 ++- spec/components/discourse_event_spec.rb | 20 ++++- spec/components/plugin/instance_spec.rb | 47 ++++++++++ .../admin/user_fields_controller_spec.rb | 2 +- .../directory_columns_controller_spec.rb | 21 +++-- 33 files changed, 452 insertions(+), 195 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-label.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js delete mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-value.js create mode 100644 app/controllers/edit_directory_columns_controller.rb create mode 100644 app/serializers/edit_directory_column_serializer.rb create mode 100644 db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js index afe7ef32eb..424879f546 100644 --- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js +++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js @@ -9,6 +9,7 @@ export default Component.extend({ chevronIcon: null, columnIcon: null, translated: false, + automatic: false, onActiveRender: null, toggleProperties() { @@ -31,6 +32,9 @@ export default Component.extend({ }, didReceiveAttrs() { this._super(...arguments); + if (!this.automatic && !this.translated) { + this.set("labelKey", this.field); + } this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`); this.toggleChevron(); }, diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js index a7fd826af8..771d5b9b30 100644 --- a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js +++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js @@ -14,7 +14,7 @@ export default Controller.extend(ModalFunctionality, { labelKey: null, onShow() { - ajax("directory-columns.json") + ajax("edit-directory-columns.json") .then((response) => { this.setProperties({ loading: false, @@ -35,7 +35,7 @@ export default Controller.extend(ModalFunctionality, { ), }; - ajax("directory-columns.json", { type: "PUT", data }) + ajax("edit-directory-columns.json", { type: "PUT", data }) .then(() => { reload(); }) @@ -58,7 +58,7 @@ export default Controller.extend(ModalFunctionality, { .forEach((column, index) => { column.setProperties({ position: column.automatic_position || index + 1, - enabled: column.automatic, + enabled: column.type === "automatic", }); }); this.set("columns", resetColumns); diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js index e2878823ae..1846b4eea3 100644 --- a/app/assets/javascripts/discourse/app/controllers/users.js +++ b/app/assets/javascripts/discourse/app/controllers/users.js @@ -28,13 +28,22 @@ export default Controller.extend({ this.set("nameInput", params.name); this.set("order", params.order); - const custom_field_columns = this.columns.filter((c) => !c.automatic); - const user_field_ids = custom_field_columns - .map((c) => c.user_field_id) - .join("|"); + const userFieldColumns = this.columns.filter( + (c) => c.type === "user_field" + ); + const userFieldIds = userFieldColumns.map((c) => c.user_field_id).join("|"); + + const pluginColumns = this.columns.filter((c) => c.type === "plugin"); + const pluginColumnIds = pluginColumns.map((c) => c.id).join("|"); return this.store - .find("directoryItem", Object.assign(params, { user_field_ids })) + .find( + "directoryItem", + Object.assign(params, { + user_field_ids: userFieldIds, + plugin_column_ids: pluginColumnIds, + }) + ) .then((model) => { const lastUpdatedAt = model.get("resultSetMeta.last_updated_at"); this.setProperties({ diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js new file mode 100644 index 0000000000..1007a506b7 --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js @@ -0,0 +1,37 @@ +import { htmlSafe } from "@ember/template"; +import { number } from "discourse/lib/formatter"; +import { registerUnbound } from "discourse-common/lib/helpers"; +import I18n from "I18n"; + +registerUnbound("mobile-directory-item-label", function (args) { + // Args should include key/values { item, column } + const count = args.item.get(args.column.name); + return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); +}); + +registerUnbound("directory-item-value", function (args) { + // Args should include key/values { item, column } + return htmlSafe( + `${number(args.item.get(args.column.name))}` + ); +}); + +registerUnbound("directory-item-user-field-value", function (args) { + // Args should include key/values { item, column } + const value = + args.item.user && args.item.user.user_fields + ? args.item.user.user_fields[args.column.user_field_id] + : null; + const content = value || "-"; + return htmlSafe(`${content}`); +}); + +registerUnbound("directory-column-is-automatic", function (args) { + // Args should include key/values { column } + return args.column.type === "automatic"; +}); + +registerUnbound("directory-column-is-user-field", function (args) { + // Args should include key/values { column } + return args.column.type === "user_field"; +}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js deleted file mode 100644 index 56723ee716..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js +++ /dev/null @@ -1,10 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; -import I18n from "I18n"; - -export default registerUnbound("mobile-directory-item-label", function (args) { - // Args should include key/values { item, column } - - const count = args.item.get(args.column.name); - return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); -}); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js deleted file mode 100644 index aeab4bcbe1..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js +++ /dev/null @@ -1,16 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; - -export default registerUnbound( - "directory-item-user-field-value", - function (args) { - // Args should include key/values { item, column } - - const value = - args.item.user && args.item.user.user_fields - ? args.item.user.user_fields[args.column.user_field_id] - : null; - const content = value || "-"; - return htmlSafe(`${content}`); - } -); diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js deleted file mode 100644 index a3c6e3d6d3..0000000000 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js +++ /dev/null @@ -1,11 +0,0 @@ -import { htmlSafe } from "@ember/template"; -import { registerUnbound } from "discourse-common/lib/helpers"; -import { number } from "discourse/lib/formatter"; - -export default registerUnbound("directory-item-value", function (args) { - // Args should include key/values { item, column } - - return htmlSafe( - `${number(args.item.get(args.column.name))}` - ); -}); diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js index 181204d786..ed1ac2cf84 100644 --- a/app/assets/javascripts/discourse/app/routes/users.js +++ b/app/assets/javascripts/discourse/app/routes/users.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import I18n from "I18n"; -import PreloadStore from "discourse/lib/preload-store"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; import { Promise } from "rsvp"; export default DiscourseRoute.extend({ @@ -38,9 +39,12 @@ export default DiscourseRoute.extend({ }, model(params) { - const columns = PreloadStore.get("directoryColumns"); - params.order = params.order || columns[0].name; - return { params, columns }; + return ajax("directory-columns.json") + .then((response) => { + params.order = params.order || response.directory_columns[0].name; + return { params, columns: response.directory_columns }; + }) + .catch(popupAjaxError); }, setupController(controller, model) { diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs index 0d4fece411..b1b083beda 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs @@ -1,10 +1,10 @@
    {{#each columns as |column|}} {{/each}} diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs index 1aafa640cc..a646d794db 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs @@ -7,6 +7,7 @@ icon=column.icon order=order asc=asc + automatic=(directory-column-is-automatic column=column) translated=column.user_field_id onActiveRender=setActiveHeader }} diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index d5a7770478..abe17a0f2c 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -35,11 +35,11 @@ {{d-button action=(action "bulkClearAll") label="topics.bulk.clear_all"}} {{/if}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username"}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" class="username" automatic=true}} - {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added"}} - {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post"}} - {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen"}} + {{table-header-toggle order=order asc=asc field="added_at" labelKey="groups.member_added" automatic=true}} + {{table-header-toggle order=order asc=asc field="last_posted_at" labelKey="last_post" automatic=true}} + {{table-header-toggle order=order asc=asc field="last_seen_at" labelKey="last_seen" automatic=true}}
    {{i18n "groups.requests.reason"}} {{user-info user=item.user}} - {{#if column.automatic}} - {{directory-item-value item=item column=column}} - {{else}} + {{#if (directory-column-is-user-field column=column)}} {{directory-item-user-field-value item=item column=column}} + {{else}} + {{directory-item-value item=item column=column}} {{/if}} {{#if isBulk}} {{group-member-dropdown diff --git a/app/assets/javascripts/discourse/app/templates/group-requests.hbs b/app/assets/javascripts/discourse/app/templates/group-requests.hbs index 017aaaef08..b2f8d03ddf 100644 --- a/app/assets/javascripts/discourse/app/templates/group-requests.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-requests.hbs @@ -12,8 +12,8 @@ {{#load-more selector=".group-members tr" action=(action "loadMore")}} - {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username"}} - {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested"}} + {{table-header-toggle order=order asc=asc field="username_lower" labelKey="username" automatic=true}} + {{table-header-toggle order=order asc=asc field="requested_at" labelKey="groups.member_requested" automatic=true}} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs index e43ad11e26..3f75ed7278 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs @@ -1,7 +1,7 @@ {{user-info user=item.user}} {{#each columns as |column|}} - {{#if column.automatic}} + {{#if (directory-column-is-automatic column=column)}}
    {{directory-item-value item=item column=column}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs index fb3e86465e..7fe81183a0 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs @@ -8,10 +8,12 @@
    diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index b312f02e25..78f3c8cd90 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -938,13 +938,13 @@ export function applyDefaultHandlers(pretender) { return [404, { "Content-Type": "application/html" }, ""]; }); - pretender.get("directory-columns.json", () => { + pretender.get("edit-directory-columns.json", () => { return response(200, { directory_columns: [ { id: 1, name: "likes_received", - automatic: true, + type: "automatic", enabled: true, automatic_position: 1, position: 1, @@ -954,7 +954,7 @@ export function applyDefaultHandlers(pretender) { { id: 2, name: "likes_given", - automatic: true, + type: "automatic", enabled: true, automatic_position: 2, position: 2, @@ -964,7 +964,7 @@ export function applyDefaultHandlers(pretender) { { id: 3, name: "topic_count", - automatic: true, + type: "automatic", enabled: true, automatic_position: 3, position: 3, @@ -974,7 +974,7 @@ export function applyDefaultHandlers(pretender) { { id: 4, name: "post_count", - automatic: true, + type: "automatic", enabled: true, automatic_position: 4, position: 4, @@ -984,7 +984,7 @@ export function applyDefaultHandlers(pretender) { { id: 5, name: "topics_entered", - automatic: true, + type: "automatic", enabled: true, automatic_position: 5, position: 5, @@ -994,7 +994,7 @@ export function applyDefaultHandlers(pretender) { { id: 6, name: "posts_read", - automatic: true, + type: "automatic", enabled: true, automatic_position: 6, position: 6, @@ -1004,7 +1004,7 @@ export function applyDefaultHandlers(pretender) { { id: 7, name: "days_visited", - automatic: true, + type: "automatic", enabled: true, automatic_position: 7, position: 7, @@ -1014,7 +1014,7 @@ export function applyDefaultHandlers(pretender) { { id: 9, name: null, - automatic: false, + type: "user_field", enabled: false, automatic_position: null, position: 8, @@ -1035,4 +1035,75 @@ export function applyDefaultHandlers(pretender) { ], }); }); + + pretender.get("directory-columns.json", () => { + return response(200, { + directory_columns: [ + { + id: 1, + name: "likes_received", + type: "automatic", + position: 1, + icon: "heart", + user_field: null, + }, + { + id: 2, + name: "likes_given", + type: "automatic", + position: 2, + icon: "heart", + user_field: null, + }, + { + id: 3, + name: "topic_count", + type: "automatic", + position: 3, + icon: null, + user_field: null, + }, + { + id: 4, + name: "post_count", + type: "automatic", + position: 4, + icon: null, + user_field: null, + }, + { + id: 5, + name: "topics_entered", + type: "automatic", + position: 5, + icon: null, + user_field: null, + }, + { + id: 6, + name: "posts_read", + type: "automatic", + position: 6, + icon: null, + user_field: null, + }, + { + id: 7, + name: "days_visited", + type: "automatic", + position: 7, + icon: null, + user_field: null, + }, + { + id: 9, + name: "Favorite Color", + type: "user_field", + position: 8, + icon: null, + user_field_id: 3, + }, + ], + }); + }); } diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js index f51ff1cfda..12f93098ab 100644 --- a/app/assets/javascripts/discourse/tests/setup-tests.js +++ b/app/assets/javascripts/discourse/tests/setup-tests.js @@ -229,12 +229,6 @@ function setupTestsCommon(application, container, config) { }); PreloadStore.reset(); - PreloadStore.store( - "directoryColumns", - JSON.parse( - '[{"name":"likes_given","automatic":true,"icon":"heart","user_field_id":null},{"name":"posts_read","automatic":true,"icon":null,"user_field_id":null},{"name":"likes_received","automatic":true,"icon":"heart","user_field_id":null},{"name":"topic_count","automatic":true,"icon":null,"user_field_id":null},{"name":"post_count","automatic":true,"icon":null,"user_field_id":null},{"name":"topics_entered","automatic":true,"icon":null,"user_field_id":null},{"name":"days_visited","automatic":true,"icon":null,"user_field_id":null},{"name":"Favorite Color","automatic":false,"icon":null,"user_field_id":3}]' - ) - ); sinon.stub(ScrollingDOMMethods, "screenNotFull"); sinon.stub(ScrollingDOMMethods, "bindOnScroll"); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ca9796b962..acfa7e1080 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -604,7 +604,6 @@ class ApplicationController < ActionController::Base store_preloaded("customEmoji", custom_emoji) store_preloaded("isReadOnly", @readonly_mode.to_s) store_preloaded("activatedThemes", activated_themes_json) - store_preloaded("directoryColumns", directory_columns_json) end def preload_current_user_data @@ -616,20 +615,6 @@ class ApplicationController < ActionController::Base store_preloaded("topicTrackingStates", MultiJson.dump(serializer)) end - def directory_columns_json - DirectoryColumn - .left_joins(:user_field) - .where(enabled: true) - .order(:position) - .pluck('directory_columns.name', - 'directory_columns.automatic', - 'directory_columns.icon', - 'user_fields.id', - 'user_fields.name') - .map { |column| { name: column[0] || column[4], automatic: column[1], icon: column[2], user_field_id: column[3] } } - .to_json - end - def custom_html_json target = view_context.mobile_view? ? :mobile : :desktop diff --git a/app/controllers/directory_columns_controller.rb b/app/controllers/directory_columns_controller.rb index 2efdcd6dd4..d11f5e30ff 100644 --- a/app/controllers/directory_columns_controller.rb +++ b/app/controllers/directory_columns_controller.rb @@ -1,62 +1,8 @@ # frozen_string_literal: true class DirectoryColumnsController < ApplicationController - requires_login - def index - raise Discourse::NotFound unless guardian.is_staff? - - ensure_user_fields_have_columns - - columns = DirectoryColumn.includes(:user_field).all - render_json_dump(directory_columns: serialize_data(columns, DirectoryColumnSerializer)) - end - - def update - raise Discourse::NotFound unless guardian.is_staff? - params.require(:directory_columns) - directory_column_params = params.permit(directory_columns: {}) - directory_columns = DirectoryColumn.all - - has_enabled_column = directory_column_params[:directory_columns].values.any? do |column_data| - column_data[:enabled].to_s == "true" - end - raise Discourse::InvalidParameters, "Must have at least one column enabled" unless has_enabled_column - - directory_column_params[:directory_columns].values.each do |column_data| - existing_column = directory_columns.detect { |c| c.id == column_data[:id].to_i } - if (existing_column.enabled != column_data[:enabled] || existing_column.position != column_data[:position].to_i) - existing_column.update(enabled: column_data[:enabled], position: column_data[:position]) - end - end - - render json: success_json - end - - private - - def ensure_user_fields_have_columns - user_fields_without_column = - UserField.left_outer_joins(:directory_column) - .where(directory_column: { user_field_id: nil }) - .where("show_on_profile=? OR show_on_user_card=?", true, true) - - return unless user_fields_without_column.count > 0 - - next_position = DirectoryColumn.maximum("position") + 1 - - new_directory_column_attrs = [] - user_fields_without_column.each do |user_field| - new_directory_column_attrs.push({ - user_field_id: user_field.id, - enabled: false, - automatic: false, - position: next_position - }) - - next_position += 1 - end - - DirectoryColumn.insert_all(new_directory_column_attrs) + directory_columns = DirectoryColumn.includes(:user_field).where(enabled: true).order(:position) + render_json_dump(directory_columns: serialize_data(directory_columns, DirectoryColumnSerializer)) end end diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index 8f7314f75e..b8ec391b9b 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -26,13 +26,14 @@ class DirectoryItemsController < ApplicationController result = result.references(:user).where.not(users: { username: params[:exclude_usernames].split(",") }) end - order = params[:order] || DirectoryItem.headings.first + order = params[:order] || DirectoryColumn.automatic_column_names.first dir = params[:asc] ? 'ASC' : 'DESC' - if DirectoryItem.headings.include?(order.to_sym) + if DirectoryColumn.active_column_names.include?(order.to_sym) result = result.order("directory_items.#{order} #{dir}, directory_items.id") elsif params[:order] === 'username' result = result.order("users.#{order} #{dir}, directory_items.id") else + # Ordering by user field value user_field = UserField.find_by(name: params[:order]) if user_field result = result @@ -98,6 +99,10 @@ class DirectoryItemsController < ApplicationController serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i) end + if params[:plugin_column_ids] + serializer_opts[:plugin_column_ids] = params[:plugin_column_ids]&.split("|")&.map(&:to_i) + end + serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts) render_json_dump(directory_items: serialized, meta: { diff --git a/app/controllers/edit_directory_columns_controller.rb b/app/controllers/edit_directory_columns_controller.rb new file mode 100644 index 0000000000..b40d13ce66 --- /dev/null +++ b/app/controllers/edit_directory_columns_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class EditDirectoryColumnsController < ApplicationController + requires_login + + def index + raise Discourse::NotFound unless guardian.is_staff? + + ensure_user_fields_have_columns + + columns = DirectoryColumn.includes(:user_field).all + render_json_dump(directory_columns: serialize_data(columns, EditDirectoryColumnSerializer)) + end + + def update + raise Discourse::NotFound unless guardian.is_staff? + params.require(:directory_columns) + directory_column_params = params.permit(directory_columns: {}) + directory_columns = DirectoryColumn.all + + has_enabled_column = directory_column_params[:directory_columns].values.any? do |column_data| + column_data[:enabled].to_s == "true" + end + raise Discourse::InvalidParameters, "Must have at least one column enabled" unless has_enabled_column + + directory_column_params[:directory_columns].values.each do |column_data| + existing_column = directory_columns.detect { |c| c.id == column_data[:id].to_i } + if (existing_column.enabled != column_data[:enabled] || existing_column.position != column_data[:position].to_i) + existing_column.update(enabled: column_data[:enabled], position: column_data[:position]) + end + end + + render json: success_json + end + + private + + def ensure_user_fields_have_columns + user_fields_without_column = + UserField.left_outer_joins(:directory_column) + .where(directory_column: { user_field_id: nil }) + .where("show_on_profile=? OR show_on_user_card=?", true, true) + + return unless user_fields_without_column.count > 0 + + next_position = DirectoryColumn.maximum("position") + 1 + + new_directory_column_attrs = [] + user_fields_without_column.each do |user_field| + new_directory_column_attrs.push({ + user_field_id: user_field.id, + enabled: false, + type: DirectoryColumn.types[:user_field], + position: next_position + }) + + next_position += 1 + end + + DirectoryColumn.insert_all(new_directory_column_attrs) + end +end diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb index 4a3bc3546e..8b73960a8d 100644 --- a/app/models/directory_column.rb +++ b/app/models/directory_column.rb @@ -1,5 +1,52 @@ # frozen_string_literal: true class DirectoryColumn < ActiveRecord::Base + + # TODO(2021-06-18): Remove automatic column + self.ignored_columns = ["automatic"] + self.inheritance_column = nil + + enum type: { automatic: 0, user_field: 1, plugin: 2 } + + def self.automatic_column_names + @automatic_column_names ||= [:likes_received, + :likes_given, + :topics_entered, + :topic_count, + :post_count, + :posts_read, + :days_visited] + end + + def self.active_column_names + DirectoryColumn.where(type: [:automatic, :plugin]).where(enabled: true).pluck(:name).map(&:to_sym) + end + + @@plugin_directory_columns = [] + + def self.plugin_directory_columns + @@plugin_directory_columns + end + belongs_to :user_field + + def self.clear_plugin_directory_columns + @@plugin_directory_columns = [] + end + + def self.find_or_create_plugin_directory_column(attrs) + directory_column = find_or_create_by( + name: attrs[:column_name], + icon: attrs[:icon], + type: DirectoryColumn.types[:plugin] + ) do |column| + column.position = DirectoryColumn.maximum("position") + 1 + column.enabled = false + end + + unless @@plugin_directory_columns.include?(directory_column.name) + @@plugin_directory_columns << directory_column.name + DirectoryItem.add_plugin_query(attrs[:query]) + end + end end diff --git a/app/models/directory_item.rb b/app/models/directory_item.rb index 930c782929..817697a434 100644 --- a/app/models/directory_item.rb +++ b/app/models/directory_item.rb @@ -4,15 +4,7 @@ class DirectoryItem < ActiveRecord::Base belongs_to :user has_one :user_stat, foreign_key: :user_id, primary_key: :user_id - def self.headings - @headings ||= [:likes_received, - :likes_given, - :topics_entered, - :topic_count, - :post_count, - :posts_read, - :days_visited] - end + @@plugin_queries = [] def self.period_types @types ||= Enum.new(all: 1, @@ -34,8 +26,16 @@ class DirectoryItem < ActiveRecord::Base Time.zone.at(val.to_i) end - def self.refresh_period!(period_type, force: false) + def self.add_plugin_query(details) + @@plugin_queries << details + end + def self.clear_plugin_queries + @@plugin_queries = [] + end + + def self.refresh_period!(period_type, force: false) + DiscourseEvent.trigger("before_directory_refresh") Discourse.redis.set("directory_#{period_types[period_type]}", Time.zone.now.to_i) # Don't calculate it if the user directory is disabled @@ -53,30 +53,26 @@ class DirectoryItem < ActiveRecord::Base ActiveRecord::Base.transaction do # Delete records that belonged to users who have been deleted - DB.exec "DELETE FROM directory_items + DB.exec("DELETE FROM directory_items USING directory_items di LEFT JOIN users u ON (u.id = user_id AND u.active AND u.silenced_till IS NULL AND u.id > 0) WHERE di.id = directory_items.id AND u.id IS NULL AND - di.period_type = :period_type", period_type: period_types[period_type] + di.period_type = :period_type", period_type: period_types[period_type]) # Create new records for users who don't have one yet - DB.exec "INSERT INTO directory_items(period_type, user_id, likes_received, likes_given, topics_entered, days_visited, posts_read, topic_count, post_count) + + column_names = DirectoryColumn.automatic_column_names + DirectoryColumn.plugin_directory_columns + DB.exec("INSERT INTO directory_items(period_type, user_id, #{column_names.map(&:to_s).join(", ")}) SELECT :period_type, u.id, - 0, - 0, - 0, - 0, - 0, - 0, - 0 + #{Array.new(column_names.count) { |_| 0 }.join(", ") } FROM users u LEFT JOIN directory_items di ON di.user_id = u.id AND di.period_type = :period_type WHERE di.id IS NULL AND u.id > 0 AND u.silenced_till IS NULL AND u.active #{SiteSetting.must_approve_users ? 'AND u.approved' : ''} - ", period_type: period_types[period_type] + ", period_type: period_types[period_type]) # Calculate new values and update records # @@ -84,7 +80,18 @@ class DirectoryItem < ActiveRecord::Base # TODO # WARNING: post_count is a wrong name, it should be reply_count (excluding topic post) # - DB.exec "WITH x AS (SELECT + # + query_args = { + period_type: period_types[period_type], + since: since, + like_type: UserAction::LIKE, + was_liked_type: UserAction::WAS_LIKED, + new_topic_type: UserAction::NEW_TOPIC, + reply_type: UserAction::REPLY, + regular_post_type: Post.types[:regular] + } + + DB.exec("WITH x AS (SELECT u.id user_id, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :was_liked_type THEN 1 ELSE 0 END) likes_received, SUM(CASE WHEN p.id IS NOT NULL AND t.id IS NOT NULL AND ua.action_type = :like_type THEN 1 ELSE 0 END) likes_given, @@ -123,14 +130,13 @@ class DirectoryItem < ActiveRecord::Base di.topic_count <> x.topic_count OR di.post_count <> x.post_count ) - ", - period_type: period_types[period_type], - since: since, - like_type: UserAction::LIKE, - was_liked_type: UserAction::WAS_LIKED, - new_topic_type: UserAction::NEW_TOPIC, - reply_type: UserAction::REPLY, - regular_post_type: Post.types[:regular] + ", + query_args + ) + + @@plugin_queries.each do |plugin_query| + DB.exec(plugin_query, query_args) + end if period_type == :all DB.exec <<~SQL diff --git a/app/serializers/directory_column_serializer.rb b/app/serializers/directory_column_serializer.rb index 18e18ba67b..4e172d3e9e 100644 --- a/app/serializers/directory_column_serializer.rb +++ b/app/serializers/directory_column_serializer.rb @@ -3,11 +3,12 @@ class DirectoryColumnSerializer < ApplicationSerializer attributes :id, :name, - :automatic, - :enabled, - :automatic_position, + :type, :position, - :icon + :icon, + :user_field_id - has_one :user_field, serializer: UserFieldSerializer, embed: :objects + def name + object.name || object.user_field.name + end end diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index 02a15ae3f4..1e18f84c80 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -20,7 +20,7 @@ class DirectoryItemSerializer < ApplicationSerializer :time_read has_one :user, embed: :objects, serializer: UserSerializer - attributes *DirectoryItem.headings + attributes *DirectoryColumn.active_column_names def id object.user_id diff --git a/app/serializers/edit_directory_column_serializer.rb b/app/serializers/edit_directory_column_serializer.rb new file mode 100644 index 0000000000..7c703d5965 --- /dev/null +++ b/app/serializers/edit_directory_column_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class EditDirectoryColumnSerializer < DirectoryColumnSerializer + attributes :enabled, + :automatic_position + + has_one :user_field, serializer: UserFieldSerializer, embed: :objects +end diff --git a/config/routes.rb b/config/routes.rb index 052480ef8a..cd39584756 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -388,7 +388,8 @@ Discourse::Application.routes.draw do get "user-cards" => "users#cards", format: :json get "directory-columns" => "directory_columns#index", format: :json - put "directory-columns" => "directory_columns#update", format: :json + get "edit-directory-columns" => "edit_directory_columns#index", format: :json + put "edit-directory-columns" => "edit_directory_columns#update", format: :json %w{users u}.each_with_index do |root_path, index| get "#{root_path}" => "users#index", constraints: { format: 'html' } diff --git a/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb b/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb new file mode 100644 index 0000000000..bb149e7eeb --- /dev/null +++ b/db/migrate/20210618135229_reintroduce_type_to_directory_columns.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ReintroduceTypeToDirectoryColumns < ActiveRecord::Migration[6.1] + def up + if !ActiveRecord::Base.connection.column_exists?(:directory_columns, :type) + # A migration that added this column was previously merged and reverted. + # Some sites have this column and some do not, so only add if missing. + add_column :directory_columns, :type, :integer, default: 0, null: false + end + + DB.exec( + <<~SQL + UPDATE directory_columns + SET type = CASE WHEN automatic THEN 0 ELSE 1 END; + SQL + ) + end + + def down + remove_column :directory_columns, :type + end +end diff --git a/lib/discourse_event.rb b/lib/discourse_event.rb index bd7a21a950..b2e03b8b1d 100644 --- a/lib/discourse_event.rb +++ b/lib/discourse_event.rb @@ -27,4 +27,7 @@ class DiscourseEvent events[event_name].delete(block) end + def self.all_off(event_name) + events.delete(event_name) + end end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 2a92c6d01d..646603effc 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -373,6 +373,14 @@ class Plugin::Instance assets end + def add_directory_column(column_name, query:, icon: nil) + validate_directory_column_name(column_name) + + DiscourseEvent.on("before_directory_refresh") do + DirectoryColumn.find_or_create_plugin_directory_column(column_name: column_name, icon: icon, query: query) + end + end + def delete_extra_automatic_assets(good_paths) return unless Dir.exists? auto_generated_path @@ -593,7 +601,6 @@ class Plugin::Instance # this allows us to present information about a plugin in the UI # prior to activations def activate! - if @path root_dir_name = File.dirname(@path) @@ -964,6 +971,11 @@ class Plugin::Instance private + def validate_directory_column_name(column_name) + match = /^[_a-z]+$/.match(column_name) + raise "Invalid directory column name '#{column_name}'. Can only contain a-z and underscores" unless match + end + def write_asset(path, contents) unless File.exists?(path) ensure_directory(path) diff --git a/spec/components/discourse_event_spec.rb b/spec/components/discourse_event_spec.rb index 4e87a07915..fe4ad080db 100644 --- a/spec/components/discourse_event_spec.rb +++ b/spec/components/discourse_event_spec.rb @@ -83,9 +83,25 @@ describe DiscourseEvent do expect(harvey.job).to eq('Supervillain') expect(harvey.name).to eq('Two Face') end - end - end + context '#all_off' do + let(:event_handler_2) do + Proc.new { |user| user.job = 'Supervillain' } + end + + before do + DiscourseEvent.on(:acid_face, &event_handler_2) + end + + it 'removes all handlers with a key' do + harvey.job = 'gardening' + DiscourseEvent.all_off(:acid_face) + DiscourseEvent.trigger(:acid_face, harvey) # Doesn't change anything + expect(harvey.job).to eq('gardening') + end + + end + end end diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index e9de28e83d..171e8c94b5 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -600,4 +600,51 @@ describe Plugin::Instance do expect(ApiKeyScope.scope_mappings.dig(:groups, :create, :actions)).to contain_exactly(*actions) end end + + describe '#add_directory_column' do + let!(:plugin) { Plugin::Instance.new } + + before do + DirectoryItem.clear_plugin_queries + end + + after do + DirectoryColumn.clear_plugin_directory_columns + end + + describe "with valid column name" do + let(:column_name) { "random_c" } + + before do + DB.exec("ALTER TABLE directory_items ADD COLUMN IF NOT EXISTS #{column_name} integer") + end + + after do + DB.exec("ALTER TABLE directory_items DROP COLUMN IF EXISTS #{column_name}") + DiscourseEvent.all_off("before_directory_refresh") + end + + it 'creates a directory column record when directory items are refreshed' do + plugin.add_directory_column(column_name, query: "SELECT COUNT(*) FROM users", icon: 'recycle') + expect(DirectoryColumn.find_by(name: column_name, icon: 'recycle', enabled: false)).not_to be_present + + DirectoryItem.refresh! + expect(DirectoryColumn.find_by(name: column_name, icon: 'recycle', enabled: false)).to be_present + end + end + + it 'errors when the column_name contains invalid characters' do + expect { + plugin.add_directory_column('Capital', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + + expect { + plugin.add_directory_column('has space', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + + expect { + plugin.add_directory_column('has_number_1', query: "SELECT COUNT(*) FROM users", icon: 'recycle') + }.to raise_error(RuntimeError) + end + end end diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb index 828795859c..9084ebf472 100644 --- a/spec/requests/admin/user_fields_controller_spec.rb +++ b/spec/requests/admin/user_fields_controller_spec.rb @@ -130,7 +130,7 @@ describe Admin::UserFieldsController do DirectoryColumn.create( user_field_id: user_field.id, enabled: false, - automatic: false, + type: DirectoryColumn.types[:user_field], position: next_position ) expect { diff --git a/spec/requests/directory_columns_controller_spec.rb b/spec/requests/directory_columns_controller_spec.rb index 6f01eb01ad..5fb8074b97 100644 --- a/spec/requests/directory_columns_controller_spec.rb +++ b/spec/requests/directory_columns_controller_spec.rb @@ -7,6 +7,17 @@ describe DirectoryColumnsController do fab!(:admin) { Fabricate(:admin) } describe "#index" do + it "returns all active directory columns" do + likes_given = DirectoryColumn.find_by(name: "likes_given") + likes_given.update(enabled: false) + + get "/directory-columns.json" + + expect(response.parsed_body["directory_columns"].map { |dc| dc["name"] }).not_to include("likes_given") + end + end + + describe "#edit-index" do fab!(:public_user_field) { Fabricate(:user_field, show_on_profile: true) } fab!(:private_user_field) { Fabricate(:user_field, show_on_profile: false, show_on_user_card: false) } @@ -14,13 +25,13 @@ describe DirectoryColumnsController do sign_in(admin) expect { - get "/directory-columns.json" + get "/edit-directory-columns.json" }.to change { DirectoryColumn.count }.by(1) end it "returns a 403 when not logged in as staff member" do sign_in(user) - get "/directory-columns.json" + get "/edit-directory-columns.json" expect(response.status).to eq(404) end @@ -50,7 +61,7 @@ describe DirectoryColumnsController do sign_in(admin) expect { - put "/directory-columns.json", params: params + put "/edit-directory-columns.json", params: params }.to change { DirectoryColumn.find(first_directory_column_id).enabled }.from(true).to(false) end @@ -59,14 +70,14 @@ describe DirectoryColumnsController do bad_params = params bad_params[:directory_columns][:"1"][:enabled] = false - put "/directory-columns.json", params: bad_params + put "/edit-directory-columns.json", params: bad_params expect(response.status).to eq(400) end it "returns a 404 when not logged in as a staff member" do sign_in(user) - put "/directory-columns.json", params: params + put "/edit-directory-columns.json", params: params expect(response.status).to eq(404) end From fc0da499f83d028d4fab83a8df5cbf78fd171d6c Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 22 Jun 2021 14:07:23 -0400 Subject: [PATCH 146/403] DEV: Refactor custom svg icon caching (#13483) Previously, we were storing custom svg sprite paths in the cache. This is a problem because sprites in themes get stored as uploads, and the returned paths were files in the temporary download cache which could sometimes be cleaned up, resulting in a broken cache. I previously tried to fix this by skipping the missing files and clearing the cache, but that didn't work out well with CDNs. This PR stores the contents of the files in the custom_svg_sprites cache to avoid the problem of missing temp files. Also, plugin custom icons are only included if the plugin is enabled. --- lib/svg_sprite/svg_sprite.rb | 73 +++++++++-------- spec/components/svg_sprite/svg_sprite_spec.rb | 79 ++++++++++++++----- .../plugins/my_plugin/svg-icons/custom.svg | 10 +++ 3 files changed, 110 insertions(+), 52 deletions(-) create mode 100644 spec/fixtures/plugins/my_plugin/svg-icons/custom.svg diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index c9eb7340c3..2c0cbe6970 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -230,7 +230,12 @@ module SvgSprite def self.custom_svg_sprites(theme_id) get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(',')}") do - custom_sprite_paths = Dir.glob("#{Rails.root}/plugins/*/svg-icons/*.svg") + plugin_paths = [] + Discourse.plugins.map { |plugin| File.dirname(plugin.path) }.each do |path| + plugin_paths << "#{path}/svg-icons/*.svg" + end + + custom_sprite_paths = Dir.glob(plugin_paths) if theme_id.present? ThemeField.where(type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_id)) @@ -249,7 +254,14 @@ module SvgSprite end end - custom_sprite_paths + custom_sprite_paths.map do |path| + if File.exist?(path) + { + filename: "#{File.basename(path, ".svg")}", + sprite: File.read(path) + } + end + end end end @@ -284,13 +296,22 @@ module SvgSprite end def self.sprite_sources(theme_id) - sources = CORE_SVG_SPRITES + sprites = [] - if theme_id.present? - sources = sources + custom_svg_sprites(theme_id) + CORE_SVG_SPRITES.each do |path| + if File.exist?(path) + sprites << { + filename: "#{File.basename(path, ".svg")}", + sprite: File.read(path) + } + end end - sources + if theme_id.present? + sprites = sprites + custom_svg_sprites(theme_id) + end + + sprites end def self.core_svgs @@ -329,20 +350,13 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL end end - custom_svg_sprites(theme_id).each do |fname| - if !File.exist?(fname) - cache.delete("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(',')}") - next - end - - svg_file = Nokogiri::XML(File.open(fname)) do |config| + custom_svg_sprites(theme_id).each do |item| + svg_file = Nokogiri::XML(item[:sprite]) do |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS end - svg_filename = "#{File.basename(fname, ".svg")}" - svg_file.css("symbol").each do |sym| - icon_id = prepare_symbol(sym, svg_filename) + icon_id = prepare_symbol(sym, item[:filename]) if icons.include? icon_id sym.attributes['id'].value = icon_id @@ -358,14 +372,11 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL def self.search(searched_icon) searched_icon = process(searched_icon.dup) - sprite_sources(SiteSetting.default_theme_id).each do |fname| - next if !File.exist?(fname) - - svg_file = Nokogiri::XML(File.open(fname)) - svg_filename = "#{File.basename(fname, ".svg")}" + sprite_sources(SiteSetting.default_theme_id).each do |item| + svg_file = Nokogiri::XML(item[:sprite]) svg_file.css('symbol').each do |sym| - icon_id = prepare_symbol(sym, svg_filename) + icon_id = prepare_symbol(sym, item[:filename]) if searched_icon == icon_id sym.attributes['id'].value = icon_id @@ -381,14 +392,11 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL def self.icon_picker_search(keyword) results = Set.new - sprite_sources(SiteSetting.default_theme_id).each do |fname| - next if !File.exist?(fname) - - svg_file = Nokogiri::XML(File.open(fname)) - svg_filename = "#{File.basename(fname, ".svg")}" + sprite_sources(SiteSetting.default_theme_id).each do |item| + svg_file = Nokogiri::XML(item[:sprite]) svg_file.css('symbol').each do |sym| - icon_id = prepare_symbol(sym, svg_filename) + icon_id = prepare_symbol(sym, item[:filename]) if keyword.empty? || icon_id.include?(keyword) sym.attributes['id'].value = icon_id sym.css('title').each(&:remove) @@ -417,7 +425,7 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL THEME_SPRITE_VAR_NAME end - def self.prepare_symbol(symbol, svg_filename) + def self.prepare_symbol(symbol, svg_filename = nil) icon_id = symbol.attr('id') case svg_filename @@ -484,11 +492,8 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL def self.custom_icons(theme_id) # Automatically register icons in sprites added via themes or plugins icons = [] - custom_svg_sprites(theme_id).each do |fname| - next if !File.exist?(fname) - - svg_file = Nokogiri::XML(File.open(fname)) - + custom_svg_sprites(theme_id).each do |item| + svg_file = Nokogiri::XML(item[:sprite]) svg_file.css('symbol').each do |sym| icons << sym.attributes['id'].value if sym.attributes['id'].present? end diff --git a/spec/components/svg_sprite/svg_sprite_spec.rb b/spec/components/svg_sprite/svg_sprite_spec.rb index 584800ea02..0ade6bc390 100644 --- a/spec/components/svg_sprite/svg_sprite_spec.rb +++ b/spec/components/svg_sprite/svg_sprite_spec.rb @@ -156,19 +156,6 @@ describe SvgSprite do expect(icons).to include("fly") end - it 'includes custom icons from a sprite in a theme' do - theme = Fabricate(:theme) - fname = "custom-theme-icon-sprite.svg" - - upload = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) - - theme.set_field(target: :common, name: SvgSprite.theme_sprite_variable_name, upload_id: upload.id, type: :theme_upload_var) - theme.save! - - expect(Upload.where(id: upload.id)).to be_exist - expect(SvgSprite.bundle(theme.id)).to match(/my-custom-theme-icon/) - end - context "s3" do let(:upload_s3) { Fabricate(:upload_s3) } @@ -191,8 +178,7 @@ describe SvgSprite do theme.save! sprite_files = SvgSprite.custom_svg_sprites(theme.id).join("|") - expect(sprite_files).to match(/#{upload_s3.sha1}/) - expect(sprite_files).not_to match(/amazonaws/) + expect(sprite_files).to match(/my-custom-theme-icon/) SvgSprite.bundle(theme.id) expect(SvgSprite.cache.hash.keys).to include("custom_svg_sprites_#{theme.id}") @@ -201,9 +187,8 @@ describe SvgSprite do File.delete external_copy.try(:path) SvgSprite.bundle(theme.id) - # when a file is missing, ensure that cache entry is cleared - expect(SvgSprite.cache.hash.keys).to_not include("custom_svg_sprites_#{theme.id}") - + # after a temp file is missing, bundling still works + expect(SvgSprite.cache.hash.keys).to include("custom_svg_sprites_#{theme.id}") end end @@ -237,4 +222,62 @@ describe SvgSprite do group = Fabricate(:group, flair_icon: "far-building") expect(SvgSprite.bundle).to match(/far-building/) end + + describe "#custom_svg_sprites" do + it 'is empty by default' do + expect(SvgSprite.custom_svg_sprites(nil)).to be_empty + expect(SvgSprite.bundle).not_to be_empty + end + + context "with a plugin" do + let :plugin1 do + plugin1 = Plugin::Instance.new + plugin1.path = "#{Rails.root}/spec/fixtures/plugins/my_plugin/plugin.rb" + plugin1 + end + + before do + Discourse.plugins << plugin1 + plugin1.activate! + end + + after do + Discourse.plugins.delete plugin1 + end + + it "includes custom icons from plugins" do + expect(SvgSprite.custom_svg_sprites(nil).size).to eq(1) + expect(SvgSprite.bundle).to match(/custom-icon/) + end + end + + it 'includes custom icons in a theme' do + theme = Fabricate(:theme) + fname = "custom-theme-icon-sprite.svg" + + upload = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) + + theme.set_field(target: :common, name: SvgSprite.theme_sprite_variable_name, upload_id: upload.id, type: :theme_upload_var) + theme.save! + + expect(Upload.where(id: upload.id)).to be_exist + expect(SvgSprite.bundle(theme.id)).to match(/my-custom-theme-icon/) + end + + it 'includes custom icons in a child theme' do + theme = Fabricate(:theme) + fname = "custom-theme-icon-sprite.svg" + child_theme = Fabricate(:theme, component: true) + theme.add_relative_theme!(:child, child_theme) + + upload = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) + + child_theme.set_field(target: :common, name: SvgSprite.theme_sprite_variable_name, upload_id: upload.id, type: :theme_upload_var) + child_theme.save! + + expect(Upload.where(id: upload.id)).to be_exist + expect(SvgSprite.bundle(theme.id)).to match(/my-custom-theme-icon/) + end + + end end diff --git a/spec/fixtures/plugins/my_plugin/svg-icons/custom.svg b/spec/fixtures/plugins/my_plugin/svg-icons/custom.svg new file mode 100644 index 0000000000..742ac324e0 --- /dev/null +++ b/spec/fixtures/plugins/my_plugin/svg-icons/custom.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 75afd50cea710d7b9d1a89bf30113e3bf2263cbb Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Tue, 22 Jun 2021 19:28:58 -0500 Subject: [PATCH 147/403] FIX: Absolute path for directory-columns.json (#13488) --- app/assets/javascripts/discourse/app/routes/users.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js index ed1ac2cf84..52db423ac8 100644 --- a/app/assets/javascripts/discourse/app/routes/users.js +++ b/app/assets/javascripts/discourse/app/routes/users.js @@ -39,7 +39,7 @@ export default DiscourseRoute.extend({ }, model(params) { - return ajax("directory-columns.json") + return ajax("/directory-columns.json") .then((response) => { params.order = params.order || response.directory_columns[0].name; return { params, columns: response.directory_columns }; From c8f4f7c235ca38b39d4b66bf9e1305410e36815e Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 22 Jun 2021 22:34:22 -0400 Subject: [PATCH 148/403] FIX: Ignore missing uploads in theme settings (#13486) In some rare cases, this could prevent the site from bootstrapping, because theme settings are invoked early in the application. --- app/models/theme.rb | 4 +++- spec/models/theme_spec.rb | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/models/theme.rb b/app/models/theme.rb index fe71405f19..494e6fad35 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -527,7 +527,9 @@ class Theme < ActiveRecord::Base theme_uploads = {} upload_fields.each do |field| - theme_uploads[field.name] = Discourse.store.cdn_url(field.upload.url) + if field.upload&.url + theme_uploads[field.name] = Discourse.store.cdn_url(field.upload.url) + end end hash['theme_uploads'] = theme_uploads if theme_uploads.present? diff --git a/spec/models/theme_spec.rb b/spec/models/theme_spec.rb index 4f121fa350..90550ff2ce 100644 --- a/spec/models/theme_spec.rb +++ b/spec/models/theme_spec.rb @@ -504,6 +504,20 @@ HTML expect(json["theme_uploads"]["bob"]).to eq(upload.url) end + it 'does not break on missing uploads in settings' do + Theme.destroy_all + + upload = UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(-1) + theme.set_field(type: :theme_upload_var, target: :common, name: "bob", upload_id: upload.id) + theme.save! + + Upload.find(upload.id).destroy + theme.clear_cached_settings! + + json = JSON.parse(cached_settings(theme.id)) + expect(json).to be_empty + end + it 'uses CDN url for theme_uploads in settings' do set_cdn_url("http://cdn.localhost") Theme.destroy_all From bbdff600fe1f87f0736a6fe6aadbd33440597f70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Jun 2021 22:02:09 +0000 Subject: [PATCH 149/403] Build(deps): Bump oj from 3.11.6 to 3.11.7 Bumps [oj](https://github.com/ohler55/oj) from 3.11.6 to 3.11.7. - [Release notes](https://github.com/ohler55/oj/releases) - [Changelog](https://github.com/ohler55/oj/blob/develop/CHANGELOG.md) - [Commits](https://github.com/ohler55/oj/compare/v3.11.6...v3.11.7) --- updated-dependencies: - dependency-name: oj dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f2d43eaa3e..762c1ee1a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -254,7 +254,7 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - oj (3.11.6) + oj (3.11.7) omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) From 425ead8f353df00c3c1e5709a0a11c776e3e154b Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 22 Jun 2021 14:26:40 +0200 Subject: [PATCH 150/403] DEV: Remove a perf fix that's no longer needed It has been added to Rails in https://github.com/rails/rails/commit/cc2d614e6310337a9d34ede3e67d634d84561cde (v6.0.0.beta2) --- lib/freedom_patches/performance_fixes.rb | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 lib/freedom_patches/performance_fixes.rb diff --git a/lib/freedom_patches/performance_fixes.rb b/lib/freedom_patches/performance_fixes.rb deleted file mode 100644 index 00b8a92cda..0000000000 --- a/lib/freedom_patches/performance_fixes.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -# perf fixes, review for each rails upgrade. - -# This speeds up calls to present? and blank? on model instances -# Eg: Topic.new.blank? (which is always false) and so on. -# -# Per: https://gist.github.com/SamSaffron/c8bbc8c7b6bf3b0148760c887df18b55 -# Comparison: -# fast present: 25253295.0 i/s -# fast present 2: 24623199.7 i/s - same-ish: difference falls within error -# slow present: 335003.0 i/s - 75.38x slower -# slow present 2: 275212.8 i/s - 91.76x slower -# -# raised with rails at: https://github.com/rails/rails/issues/35059 -class ActiveRecord::Base - def present? - true - end - def blank? - false - end -end From 30c7a9b06d939804da4b024e7c3db5c8aab89b7d Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 23 Jun 2021 13:26:37 +1000 Subject: [PATCH 151/403] DEV: Fix failing directory-columns ember CLI tests (#13490) Since #13488 the ember cli CI tests are failing. --- .../javascripts/discourse/tests/helpers/create-pretender.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js index 78f3c8cd90..f6167df657 100644 --- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js +++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js @@ -1036,7 +1036,7 @@ export function applyDefaultHandlers(pretender) { }); }); - pretender.get("directory-columns.json", () => { + pretender.get("/directory-columns.json", () => { return response(200, { directory_columns: [ { From d3a3d1b94cde5b1128f00fb707d8099c835b248c Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 23 Jun 2021 07:38:43 +0300 Subject: [PATCH 152/403] DEV: Introduce `TemporaryRedis` and unset `DISCOURSE_*` env vars in the `themes:isolated_test` rake task (#13401) The `themes:isolated_test` rake task will now unset all `DISCOURSE_*` env variables if `UNSET_DISCOURSE_ENV_VARS` env var is set and will also spin up a temporary redis server so the unicorn web server that's spun up for the tests doesn't leak into the "main" redis server. --- lib/tasks/qunit.rake | 5 +- lib/tasks/themes.rake | 19 +++++++- lib/temporary_redis.rb | 107 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 lib/temporary_redis.rb diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake index ab902a1e74..4ffcd5fdeb 100644 --- a/lib/tasks/qunit.rake +++ b/lib/tasks/qunit.rake @@ -42,7 +42,10 @@ task "qunit:test", [:timeout, :qunit_path] do |_, args| "UNICORN_PID_PATH" => "#{Rails.root}/tmp/pids/unicorn_test_#{port}.pid", # So this can run alongside development "UNICORN_PORT" => port.to_s, "UNICORN_SIDEKIQS" => "0", - "DISCOURSE_SKIP_CSS_WATCHER" => "1" + "DISCOURSE_SKIP_CSS_WATCHER" => "1", + "UNICORN_LISTENER" => "127.0.0.1:#{port}", + "LOGSTASH_UNICORN_URI" => nil, + "UNICORN_WORKERS" => "3" }, "#{Rails.root}/bin/unicorn -c config/unicorn.conf.rb", pgroup: true diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake index f11a628bec..a69b5e07fb 100644 --- a/lib/tasks/themes.rake +++ b/lib/tasks/themes.rake @@ -120,7 +120,22 @@ task "themes:qunit", :type, :value do |t, args| end desc "Install a theme/component on a temporary DB and run QUnit tests" -task "themes:install_and_test" => :environment do |t, args| +task "themes:isolated_test" => :environment do |t, args| + # This task can be called in a production environment that likely has a bunch + # of DISCOURSE_* env vars that we don't want to be picked up by the Unicorn + # server that will be spawned for the tests. So we need to unset them all + # before we proceed. + # Make this behavior opt-in to make it very obvious. + if ENV["UNSET_DISCOURSE_ENV_VARS"] == "1" + ENV.keys.each do |key| + next if !key.start_with?('DISCOURSE_') + ENV[key] = nil + end + end + + redis = TemporaryRedis.new + redis.start + $redis = redis.instance # rubocop:disable Style/GlobalVars db = TemporaryDb.new db.start db.migrate @@ -139,6 +154,7 @@ task "themes:install_and_test" => :environment do |t, args| ENV["PGHOST"] = "localhost" ENV["QUNIT_RAILS_ENV"] = "development" ENV["DISCOURSE_DEV_DB"] = "discourse" + ENV["DISCOURSE_REDIS_PORT"] = redis.port.to_s count = 0 themes.each do |(name, id)| @@ -155,4 +171,5 @@ task "themes:install_and_test" => :environment do |t, args| ensure db&.stop db&.remove + redis&.remove end diff --git a/lib/temporary_redis.rb b/lib/temporary_redis.rb new file mode 100644 index 0000000000..fb2a799401 --- /dev/null +++ b/lib/temporary_redis.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class TemporaryRedis + REDIS_TEMP_DIR = "/tmp/discourse_temp_redis" + REDIS_LOG_PATH = "#{REDIS_TEMP_DIR}/redis.log" + REDIS_PID_PATH = "#{REDIS_TEMP_DIR}/redis.pid" + + attr_reader :instance + + def initialize + set_redis_server_bin + end + + def port + @port ||= find_free_port(11000..11900) + end + + def start + return if @started + FileUtils.rm_rf(REDIS_TEMP_DIR) + Dir.mkdir(REDIS_TEMP_DIR) + FileUtils.touch(REDIS_LOG_PATH) + + puts "Starting redis on port: #{port}" + @thread = Thread.new do + system( + @redis_server_bin, + "--port", port.to_s, + "--pidfile", REDIS_PID_PATH, + "--logfile", REDIS_LOG_PATH, + "--databases", "1", + "--save", '""', + "--appendonly", "no", + "--daemonize", "no", + "--maxclients", "100", + "--dir", REDIS_TEMP_DIR + ) + end + + puts "Waiting for redis server to start..." + success = false + instance = nil + config = { + port: port, + host: "127.0.0.1", + db: 0 + } + start = Time.now + while !success + begin + instance = DiscourseRedis.new(config, namespace: true) + success = instance.ping == "PONG" + rescue Redis::CannotConnectError + ensure + if !success && (Time.now - start) >= 5 + STDERR.puts "ERROR: Could not connect to redis in 5 seconds." + self.remove + exit(1) + elsif !success + sleep 0.1 + end + end + end + puts "Redis is ready" + @instance = instance + @started = true + end + + def remove + if @instance + @instance.shutdown + @thread.join + puts "Redis has been shutdown." + end + FileUtils.rm_rf(REDIS_TEMP_DIR) + @started = false + puts "Redis files have been cleaned up." + end + + private + + def set_redis_server_bin + path = `which redis-server 2> /dev/null`.strip + if path.size < 1 + STDERR.puts 'ERROR: redis-server is not installed on this machine. Please install it' + exit(1) + end + @redis_server_bin = path + rescue => ex + STDERR.puts 'ERROR: Failed to find redis-server binary:' + STDERR.puts ex.inspect + exit(1) + end + + def find_free_port(range) + range.each do |port| + return port if port_available?(port) + end + end + + def port_available?(port) + TCPServer.open(port).close + true + rescue Errno::EADDRINUSE + false + end +end From a22aa7562a096647f939c1314b8c0f8f74e3e541 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 23 Jun 2021 14:41:23 +0300 Subject: [PATCH 153/403] FIX: Make favorite work with multiple grant badges (#13492) Badges that are awarded multiple times can be favorite and not favorite at the same time. This caused few problems when users tried to favorite them as they were counted multiple times or their state was incorrectly displayed. --- app/controllers/user_badges_controller.rb | 6 +++-- spec/requests/user_badges_controller_spec.rb | 26 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index 42f298b132..ff13086934 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -105,11 +105,13 @@ class UserBadgesController < ApplicationController return render json: failed_json, status: 403 end - if !user_badge.is_favorite && user_badges.where(is_favorite: true).count >= SiteSetting.max_favorite_badges + if !user_badge.is_favorite && user_badges.select(:badge_id).distinct.where(is_favorite: true).count >= SiteSetting.max_favorite_badges return render json: failed_json, status: 400 end - user_badge.toggle!(:is_favorite) + UserBadge + .where(user_id: user_badge.user_id, badge_id: user_badge.badge_id) + .update(is_favorite: !user_badge.is_favorite) UserBadge.update_featured_ranks!(user_badge.user_id) render_serialized(user_badge, DetailedUserBadgeSerializer, root: :user_badge) end diff --git a/spec/requests/user_badges_controller_spec.rb b/spec/requests/user_badges_controller_spec.rb index 9e68bbc4d8..42ea307fd2 100644 --- a/spec/requests/user_badges_controller_spec.rb +++ b/spec/requests/user_badges_controller_spec.rb @@ -309,5 +309,31 @@ describe UserBadgesController do user_badge = UserBadge.find_by(user: user, badge: badge) expect(user_badge.is_favorite).to be false end + + it "works with multiple grants" do + SiteSetting.max_favorite_badges = 2 + + sign_in(user) + + badge = Fabricate(:badge, multiple_grant: true) + user_badge = UserBadge.create(badge: badge, user: user, granted_by: Discourse.system_user, granted_at: Time.now, seq: 0, is_favorite: true) + user_badge2 = UserBadge.create(badge: badge, user: user, granted_by: Discourse.system_user, granted_at: Time.now, seq: 1, is_favorite: true) + other_badge = Fabricate(:badge) + other_user_badge = UserBadge.create(badge: other_badge, user: user, granted_by: Discourse.system_user, granted_at: Time.now) + + put "/user_badges/#{user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) + expect(user_badge.reload.is_favorite).to eq(false) + expect(user_badge2.reload.is_favorite).to eq(false) + + put "/user_badges/#{user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) + expect(user_badge.reload.is_favorite).to eq(true) + expect(user_badge2.reload.is_favorite).to eq(true) + + put "/user_badges/#{other_user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) + expect(other_user_badge.reload.is_favorite).to eq(true) + end end end From fa62b5e83bd8aabb560b7d79d241b054ade1c551 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 23 Jun 2021 14:50:54 +0300 Subject: [PATCH 154/403] DEV: Add a way to exclude ENV vars from getting unset in `themes:isolated_test` (#13494) --- lib/tasks/themes.rake | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake index a69b5e07fb..51746bd268 100644 --- a/lib/tasks/themes.rake +++ b/lib/tasks/themes.rake @@ -129,6 +129,7 @@ task "themes:isolated_test" => :environment do |t, args| if ENV["UNSET_DISCOURSE_ENV_VARS"] == "1" ENV.keys.each do |key| next if !key.start_with?('DISCOURSE_') + next if ENV["DONT_UNSET_#{key}"] == "1" ENV[key] = nil end end From fbe004cc63b1a96e6417798c49795c4766e45a3d Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 23 Jun 2021 10:03:52 -0400 Subject: [PATCH 155/403] DEV: Do not add proxy argument when running ember-cli test (#13498) --- bin/ember-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ember-cli b/bin/ember-cli index 5c1a753d94..7c0d5bca9b 100755 --- a/bin/ember-cli +++ b/bin/ember-cli @@ -41,7 +41,7 @@ args = ["-s", "--cwd", yarn_dir, "run", "ember", command] + ARGV.reject do |a| ["--try", "--test", "--unicorn", "-u"].include?(a) end -if !args.include?("--proxy") +if !args.include?("test") && !args.include?("--proxy") args << "--proxy" args << PROXY end From 2c2e81486c3428c53e46bbf70a92dbc50d219f60 Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Wed, 23 Jun 2021 17:31:25 +0300 Subject: [PATCH 156/403] FEATURE: Split Add Members into Add Users & Invite (#13482) Add Members could also invite new users via emails, but that was a less known fact. Splitting the previous modal into two more accessible modals should make this feature more discoverable. --- .../app/controllers/group-add-members.js | 77 +++++-------------- .../discourse/app/routes/group-index.js | 9 +++ .../discourse/app/templates/group-index.hbs | 23 ++++-- .../app/templates/modal/group-add-members.hbs | 43 +++-------- .../tests/acceptance/group-index-test.js | 2 +- .../discourse/tests/acceptance/group-test.js | 2 +- app/assets/stylesheets/common/base/modal.scss | 14 ++++ config/locales/client.en.yml | 16 ++-- 8 files changed, 77 insertions(+), 109 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/group-add-members.js b/app/assets/javascripts/discourse/app/controllers/group-add-members.js index 333060a2a5..6528f103a1 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-add-members.js +++ b/app/assets/javascripts/discourse/app/controllers/group-add-members.js @@ -1,88 +1,49 @@ import Controller from "@ember/controller"; -import I18n from "I18n"; -import ModalFunctionality from "discourse/mixins/modal-functionality"; import { action } from "@ember/object"; -import discourseComputed from "discourse-common/utils/decorators"; -import { emailValid } from "discourse/lib/utilities"; -import { extractError } from "discourse/lib/ajax-error"; import { isEmpty } from "@ember/utils"; -import { reads } from "@ember/object/computed"; +import discourseComputed from "discourse-common/utils/decorators"; +import { extractError } from "discourse/lib/ajax-error"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import I18n from "I18n"; export default Controller.extend(ModalFunctionality, { loading: false, - setAsOwner: false, + + usernames: null, + setOwner: false, notifyUsers: false, - usernamesAndEmails: null, - emailsPresent: reads("emails.length"), onShow() { this.setProperties({ - usernamesAndEmails: [], - setAsOwner: false, + loading: false, + setOwner: false, notifyUsers: false, + usernames: [], }); }, - @discourseComputed("usernamesAndEmails", "loading") - disableAddButton(usernamesAndEmails, loading) { - return loading || !usernamesAndEmails || !(usernamesAndEmails.length > 0); - }, - - @discourseComputed("usernamesAndEmails") - notifyUsersDisabled() { - return this.usernames.length === 0 && this.emails.length > 0; - }, - @discourseComputed("model.name", "model.full_name") - title(name, fullName) { + rawTitle(name, fullName) { return I18n.t("groups.add_members.title", { group_name: fullName || name }); }, - @discourseComputed("usernamesAndEmails.[]") - emails(usernamesAndEmails) { - return usernamesAndEmails.filter(emailValid).join(","); - }, - - @discourseComputed("usernamesAndEmails.[]") - usernames(usernamesAndEmails) { - return usernamesAndEmails.reject(emailValid).join(","); - }, - @action addMembers() { - this.set("loading", true); - - if (this.emailsPresent) { - this.set("setAsOwner", false); - } - - if (this.notifyUsersDisabled) { - this.set("notifyUsers", false); - } - - if (isEmpty(this.usernamesAndEmails)) { + if (isEmpty(this.usernames)) { return; } - const promise = this.setAsOwner - ? this.model.addOwners(this.usernames, true, this.notifyUsers) - : this.model.addMembers( - this.usernames, - true, - this.notifyUsers, - this.emails - ); + this.set("loading", true); + + const usernames = this.usernames.join(","); + const promise = this.setOwner + ? this.model.addOwners(usernames, true, this.notifyUsers) + : this.model.addMembers(usernames, true, this.notifyUsers); promise .then(() => { - let queryParams = {}; - - if (this.usernames) { - queryParams.filter = this.usernames; - } - this.transitionToRoute("group.members", this.get("model.name"), { - queryParams, + queryParams: usernames ? { filter: usernames } : {}, }); this.send("closeModal"); diff --git a/app/assets/javascripts/discourse/app/routes/group-index.js b/app/assets/javascripts/discourse/app/routes/group-index.js index fdd634dfa6..77a4ac19d9 100644 --- a/app/assets/javascripts/discourse/app/routes/group-index.js +++ b/app/assets/javascripts/discourse/app/routes/group-index.js @@ -28,6 +28,15 @@ export default DiscourseRoute.extend({ showModal("group-add-members", { model: this.modelFor("group") }); }, + @action + showInviteModal() { + const model = this.modelFor("group"); + const controller = showModal("create-invite"); + controller.set("showAdvanced", true); + controller.buffered.set("groupIds", [model.id]); + controller.save({ autogenerated: true }); + }, + @action didTransition() { this.controllerFor("group-index").set("filterInput", this._params.filter); diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index abe17a0f2c..dd07e01f7b 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -9,14 +9,25 @@ }} {{/if}} -
    - {{#if canManageGroup}} - {{d-button icon="plus" + {{#if canManageGroup}} +
    + {{d-button + icon="plus" action=(route-action "showAddMembersModal") label="groups.manage.add_members" - class="btn-default group-members-add"}} - {{/if}} -
    + class="btn-default group-members-add" + }} + + {{#if currentUser.can_invite_to_forum}} + {{d-button + icon="plus" + action=(route-action "showInviteModal") + label="groups.manage.invite_members" + class="btn-default group-members-add" + }} + {{/if}} +
    + {{/if}}
    {{#if hasMembers}} diff --git a/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs b/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs index 9a7427f876..873e20fb39 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/group-add-members.hbs @@ -1,47 +1,26 @@ -{{#d-modal-body rawTitle=title}} +{{#d-modal-body rawTitle=rawTitle}} -
    - -

    - {{i18n "groups.add_members.description"}} -

    +

    {{i18n "groups.add_members.description"}}

    +
    {{email-group-user-chooser - class="input-xxlarge" - value=usernamesAndEmails - id="group-add-members-user-selector" - onChange=(action (mut usernamesAndEmails)) - options=(hash - allowEmails=currentUser.can_invite_to_forum - filterPlaceholder=(if currentUser.can_invite_to_forum "groups.add_members.usernames_or_emails.input_placeholder" "groups.add_members.usernames.input_placeholder") - ) + value=usernames + onChange=(action (mut usernames)) }}
    {{#if model.can_admin_group}} -
    +
    {{/if}} -
    +
    @@ -52,6 +31,6 @@ {{d-button action=(action "addMembers") class="add btn-primary" icon="plus" - disabled=disableAddButton + disabled=(or loading (not usernames)) label="groups.add"}}
    diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js index 674ddce0d5..1c284bb622 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js @@ -49,7 +49,7 @@ acceptance("Group Members", function (needs) { await click(".group-members-add"); assert.equal( - count("#group-add-members-user-selector"), + count(".user-chooser"), 1, "it should display the add members modal" ); diff --git a/app/assets/javascripts/discourse/tests/acceptance/group-test.js b/app/assets/javascripts/discourse/tests/acceptance/group-test.js index 3ba3ea4a7c..93a949fc4f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-test.js @@ -288,7 +288,7 @@ acceptance("Group - Authenticated", function (needs) { await click(".group-members-add.btn"); assert.ok( - exists(".group-add-members-modal .group-add-members-make-owner"), + exists(".group-add-members-modal #set-owner"), "it allows moderators to set group owners" ); diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 1898e6c021..9bc306af2e 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -983,3 +983,17 @@ } } } + +.group-add-members-modal { + .input-group { + margin-bottom: 0.5em; + + &:last-child { + margin-bottom: 0; + } + } + + .user-chooser { + width: calc(100%); + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a8953102cb..70fd359a13 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -668,15 +668,10 @@ en: member_added: "Added" member_requested: "Requested at" add_members: - title: "Add members to %{group_name}" - description: "You can also paste in a comma separated list." - usernames_or_emails: - title: "Enter usernames or email addresses" - input_placeholder: "Usernames or emails" - usernames: - title: "Enter usernames" - input_placeholder: "Usernames" + title: "Add Users to %{group_name}" + description: "Enter a list of users you want to invite to the group or paste in a comma separated list:" notify_users: "Notify users" + set_owner: "Set users as owners of this group" requests: title: "Requests" reason: "Reason" @@ -690,7 +685,8 @@ en: title: "Manage" name: "Name" full_name: "Full Name" - add_members: "Add Members" + add_members: "Add Users" + invite_members: "Invite" delete_member_confirm: "Remove '%{username}' from the '%{group}' group?" profile: title: Profile @@ -3926,8 +3922,6 @@ en: available: "Group name is available" not_available: "Group name is not available" blank: "Group name cannot be blank" - add_members: - as_owner: "Set user(s) as owner(s) of this group" manage: interaction: email: Email From cfc60f41f05c48af1c6f4a7e7c256f91055b8cce Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 23 Jun 2021 11:12:48 -0400 Subject: [PATCH 157/403] DEV: Rename emoji icon (#13499) --- .../admin/addon/templates/components/emoji-value-list.hbs | 2 +- lib/svg_sprite/svg_sprite.rb | 2 +- vendor/assets/svg-icons/discourse-additional.svg | 3 +++ vendor/assets/svg-icons/emoji.svg | 7 ------- 4 files changed, 5 insertions(+), 9 deletions(-) delete mode 100644 vendor/assets/svg-icons/emoji.svg diff --git a/app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs b/app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs index d1843a2a26..fa27093cfc 100644 --- a/app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/emoji-value-list.hbs @@ -36,7 +36,7 @@ {{d-button action=(action "editValue") actionParam=data - icon="emoji-icon" + icon="discourse-emojis" class="add-emoji-button d-editor-textarea-wrapper" label="admin.site_settings.emoji_list.add_emoji_button.label" }} diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 2c0cbe6970..8f0ae39a03 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -63,11 +63,11 @@ module SvgSprite "discourse-bell-slash", "discourse-bookmark-clock", "discourse-compress", + "discourse-emojis", "discourse-expand", "download", "ellipsis-h", "ellipsis-v", - "emoji-icon", "envelope", "envelope-square", "exchange-alt", diff --git a/vendor/assets/svg-icons/discourse-additional.svg b/vendor/assets/svg-icons/discourse-additional.svg index 0ffba1b76e..3c358da9c4 100644 --- a/vendor/assets/svg-icons/discourse-additional.svg +++ b/vendor/assets/svg-icons/discourse-additional.svg @@ -32,4 +32,7 @@ Additional SVG icons + + + diff --git a/vendor/assets/svg-icons/emoji.svg b/vendor/assets/svg-icons/emoji.svg deleted file mode 100644 index 9b71517693..0000000000 --- a/vendor/assets/svg-icons/emoji.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - From 18de11f3a6f2970a3c986373f220da40b0ed006d Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Wed, 23 Jun 2021 10:21:53 -0500 Subject: [PATCH 158/403] FIX: Load more users URL respect group param (#13485) --- app/controllers/directory_items_controller.rb | 2 +- spec/requests/directory_items_controller_spec.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index b8ec391b9b..abfe46e85e 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -74,7 +74,7 @@ class DirectoryItemsController < ApplicationController result_count = result.count result = result.limit(PAGE_SIZE).offset(PAGE_SIZE * page).to_a - more_params = params.slice(:period, :order, :asc).permit! + more_params = params.slice(:period, :order, :asc, :group).permit! more_params[:page] = page + 1 load_more_uri = URI.parse(directory_items_path(more_params)) load_more_directory_items_json = "#{load_more_uri.path}.json?#{load_more_uri.query}" diff --git a/spec/requests/directory_items_controller_spec.rb b/spec/requests/directory_items_controller_spec.rb index 3fc5a4380c..d61ef2a5ce 100644 --- a/spec/requests/directory_items_controller_spec.rb +++ b/spec/requests/directory_items_controller_spec.rb @@ -53,6 +53,16 @@ describe DirectoryItemsController do expect(json['meta']['load_more_directory_items']).to include('.json') end + it "respects more_params in load_more_directory_items" do + get '/directory_items.json', params: { period: 'all', order: "likes_given", group: group.name } + expect(response.status).to eq(200) + json = response.parsed_body + + expect(json['meta']['load_more_directory_items']).to include("group=#{group.name}") + expect(json['meta']['load_more_directory_items']).to include("order=likes_given") + expect(json['meta']['load_more_directory_items']).to include("period=all") + end + it "fails when the directory is disabled" do SiteSetting.enable_user_directory = false From fa4a4625179bb1fb213328bb2c196bd41d24f804 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Wed, 23 Jun 2021 12:31:12 -0300 Subject: [PATCH 159/403] FEATURE: Optimize images before upload (#13432) Integrates [mozJPEG](https://github.com/mozilla/mozjpeg) and [Resize](https://github.com/PistonDevelopers/resize) using WebAssembly to optimize user uploads in the composer on the client-side. NPM libraries are sourced from our [Squoosh fork](https://github.com/discourse/squoosh/tree/discourse), which was needed because we have an older asset pipeline. --- .../app/components/composer-editor.js | 10 ++ ...ter-media-optimization-upload-processor.js | 20 +++ .../app/lib/media-optimization-utils.js | 63 +++++++ .../discourse/app/lib/plugin-api.js | 2 - .../app/services/media-optimization-worker.js | 128 +++++++++++++ .../javascripts/discourse/ember-cli-build.js | 1 + .../discourse/tests/theme_qunit_vendor.js | 1 + app/assets/javascripts/vendor.js | 1 + config/locales/server.en.yml | 6 + config/nginx.sample.conf | 3 +- config/site_settings.yml | 27 +++ lib/tasks/javascript.rake | 26 +++ package.json | 3 +- .../javascripts/media-optimization-worker.js | 166 +++++++++++++++++ public/javascripts/squoosh/mozjpeg_enc.js | 21 +++ public/javascripts/squoosh/mozjpeg_enc.wasm | Bin 0 -> 255878 bytes public/javascripts/squoosh/squoosh_resize.js | 129 +++++++++++++ .../squoosh/squoosh_resize_bg.wasm | Bin 0 -> 37052 bytes .../javascripts/jquery.fileupload-process.js | 169 ++++++++++++++++++ yarn.lock | 13 +- 20 files changed, 784 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js create mode 100644 app/assets/javascripts/discourse/app/lib/media-optimization-utils.js create mode 100644 app/assets/javascripts/discourse/app/services/media-optimization-worker.js create mode 100644 public/javascripts/media-optimization-worker.js create mode 100644 public/javascripts/squoosh/mozjpeg_enc.js create mode 100755 public/javascripts/squoosh/mozjpeg_enc.wasm create mode 100644 public/javascripts/squoosh/squoosh_resize.js create mode 100644 public/javascripts/squoosh/squoosh_resize_bg.wasm create mode 100644 vendor/assets/javascripts/jquery.fileupload-process.js diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index d4bf5097e5..7280058f66 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -672,6 +672,11 @@ export default Component.extend({ filename: data.files[data.index].name, })}]()\n` ); + this.setProperties({ + uploadProgress: 0, + isUploading: true, + isCancellable: false, + }); }) .on("fileuploadprocessalways", (e, data) => { this.appEvents.trigger( @@ -681,6 +686,11 @@ export default Component.extend({ })}]()\n`, "" ); + this.setProperties({ + uploadProgress: 0, + isUploading: false, + isCancellable: false, + }); }); $element.on("fileuploadpaste", (e) => { diff --git a/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js new file mode 100644 index 0000000000..3b6a975826 --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/register-media-optimization-upload-processor.js @@ -0,0 +1,20 @@ +import { addComposerUploadProcessor } from "discourse/components/composer-editor"; + +export default { + name: "register-media-optimization-upload-processor", + + initialize(container) { + let siteSettings = container.lookup("site-settings:main"); + if (siteSettings.composer_media_optimization_image_enabled) { + addComposerUploadProcessor( + { action: "optimizeJPEG" }, + { + optimizeJPEG: (data) => + container + .lookup("service:media-optimization-worker") + .optimizeImage(data), + } + ); + } + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js new file mode 100644 index 0000000000..b67424f4c4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js @@ -0,0 +1,63 @@ +import { Promise } from "rsvp"; + +export async function fileToImageData(file) { + let drawable, err; + + // Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!) + // Safari uses the `` element due to https://bugs.webkit.org/show_bug.cgi?id=182424 + if ("createImageBitmap" in self) { + drawable = await createImageBitmap(file); + } else { + const url = URL.createObjectURL(file); + const img = new Image(); + img.decoding = "async"; + img.src = url; + const loaded = new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(Error("Image loading error")); + }); + + if (img.decode) { + // Nice off-thread way supported in Safari/Chrome. + // Safari throws on decode if the source is SVG. + // https://bugs.webkit.org/show_bug.cgi?id=188347 + await img.decode().catch(() => null); + } + + // Always await loaded, as we may have bailed due to the Safari bug above. + await loaded; + + drawable = img; + } + + const width = drawable.width, + height = drawable.height, + sx = 0, + sy = 0, + sw = width, + sh = height; + // Make canvas same size as image + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + // Draw image onto canvas + const ctx = canvas.getContext("2d"); + if (!ctx) { + err = "Could not create canvas context"; + } + ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height); + const imageData = ctx.getImageData(0, 0, width, height); + canvas.remove(); + + // potentially transparent + if (/(\.|\/)(png|webp)$/i.test(file.type)) { + for (let i = 0; i < imageData.data.length; i += 4) { + if (imageData.data[i + 3] < 255) { + err = "Image has transparent pixels, won't convert to JPEG!"; + break; + } + } + } + + return { imageData, width, height, err }; +} diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 5c25a4a0ae..994d45dfbd 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -951,8 +951,6 @@ class PluginApi { /** * Registers a pre-processor for file uploads * See https://github.com/blueimp/jQuery-File-Upload/wiki/Options#file-processing-options - * Your theme/plugin will also need to load https://github.com/blueimp/jQuery-File-Upload/blob/v10.13.0/js/jquery.fileupload-process.js - * for this hook to work. * * Useful for transforming to-be uploaded files client-side * diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js new file mode 100644 index 0000000000..24069246fa --- /dev/null +++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js @@ -0,0 +1,128 @@ +import Service from "@ember/service"; +import { getOwner } from "@ember/application"; +import { Promise } from "rsvp"; +import { fileToImageData } from "discourse/lib/media-optimization-utils"; +import { getAbsoluteURL, getURLWithCDN } from "discourse-common/lib/get-url"; + +export default class MediaOptimizationWorkerService extends Service { + appEvents = getOwner(this).lookup("service:app-events"); + worker = null; + workerUrl = getAbsoluteURL("/javascripts/media-optimization-worker.js"); + currentComposerUploadData = null; + currentPromiseResolver = null; + + startWorker() { + this.worker = new Worker(this.workerUrl); // TODO come up with a workaround for FF that lacks type: module support + } + + stopWorker() { + this.worker.terminate(); + this.worker = null; + } + + ensureAvailiableWorker() { + if (!this.worker) { + this.startWorker(); + this.registerMessageHandler(); + this.appEvents.on("composer:closed", this, "stopWorker"); + } + } + + logIfDebug(message) { + if (this.siteSettings.composer_media_optimization_debug_mode) { + // eslint-disable-next-line no-console + console.log(message); + } + } + + optimizeImage(data) { + let file = data.files[data.index]; + if (!/(\.|\/)(jpe?g|png|webp)$/i.test(file.type)) { + return data; + } + if ( + file.size < + this.siteSettings + .composer_media_optimization_image_kilobytes_optimization_threshold + ) { + return data; + } + this.ensureAvailiableWorker(); + return new Promise(async (resolve) => { + this.logIfDebug(`Transforming ${file.name}`); + + this.currentComposerUploadData = data; + this.currentPromiseResolver = resolve; + + const { imageData, width, height, err } = await fileToImageData(file); + + if (err) { + this.logIfDebug(err); + return resolve(data); + } + + this.worker.postMessage( + { + type: "compress", + file: imageData.data.buffer, + fileName: file.name, + width: width, + height: height, + settings: { + mozjpeg_script: getURLWithCDN( + "/javascripts/squoosh/mozjpeg_enc.js" + ), + mozjpeg_wasm: getURLWithCDN( + "/javascripts/squoosh/mozjpeg_enc.wasm" + ), + resize_script: getURLWithCDN( + "/javascripts/squoosh/squoosh_resize.js" + ), + resize_wasm: getURLWithCDN( + "/javascripts/squoosh/squoosh_resize_bg.wasm" + ), + resize_threshold: this.siteSettings + .composer_media_optimization_image_resize_dimensions_threshold, + resize_target: this.siteSettings + .composer_media_optimization_image_resize_width_target, + resize_pre_multiply: this.siteSettings + .composer_media_optimization_image_resize_pre_multiply, + resize_linear_rgb: this.siteSettings + .composer_media_optimization_image_resize_linear_rgb, + encode_quality: this.siteSettings + .composer_media_optimization_image_encode_quality, + debug_mode: this.siteSettings + .composer_media_optimization_debug_mode, + }, + }, + [imageData.data.buffer] + ); + }); + } + + registerMessageHandler() { + this.worker.onmessage = (e) => { + this.logIfDebug("Main: Message received from worker script"); + this.logIfDebug(e); + switch (e.data.type) { + case "file": + let optimizedFile = new File([e.data.file], `${e.data.fileName}`, { + type: "image/jpeg", + }); + this.logIfDebug( + `Finished optimization of ${optimizedFile.name} new size: ${optimizedFile.size}.` + ); + let data = this.currentComposerUploadData; + data.files[data.index] = optimizedFile; + this.currentPromiseResolver(data); + break; + case "error": + this.stopWorker(); + this.currentPromiseResolver(this.currentComposerUploadData); + break; + default: + this.logIfDebug(`Sorry, we are out of ${e}.`); + } + }; + } +} diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 98d33e1de8..4650fb5292 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -34,6 +34,7 @@ module.exports = function (defaults) { app.import(vendorJs + "bootstrap-modal.js"); app.import(vendorJs + "jquery.ui.widget.js"); app.import(vendorJs + "jquery.fileupload.js"); + app.import(vendorJs + "jquery.fileupload-process.js"); app.import(vendorJs + "jquery.autoellipsis-1.0.10.js"); app.import(vendorJs + "show-html.js"); diff --git a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js index f97174a43f..a7f547667c 100644 --- a/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js +++ b/app/assets/javascripts/discourse/tests/theme_qunit_vendor.js @@ -21,6 +21,7 @@ //= require jquery.color.js //= require jquery.fileupload.js //= require jquery.iframe-transport.js +//= require jquery.fileupload-process.js //= require jquery.tagsinput.js //= require jquery.sortable.js //= require lodash.js diff --git a/app/assets/javascripts/vendor.js b/app/assets/javascripts/vendor.js index 2f2fe58390..f28366faa3 100644 --- a/app/assets/javascripts/vendor.js +++ b/app/assets/javascripts/vendor.js @@ -14,6 +14,7 @@ //= require jquery.color.js //= require jquery.fileupload.js //= require jquery.iframe-transport.js +//= require jquery.fileupload-process.js //= require jquery.tagsinput.js //= require jquery.sortable.js //= require lodash.js diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0164ecba09..d7445c4448 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1824,6 +1824,12 @@ en: strip_image_metadata: "Strip image metadata." + composer_media_optimization_image_enabled: "Enables client-side media optimization of uploaded image files." + composer_media_optimization_image_kilobytes_optimization_threshold: "Minimum image file size to trigger client-side optimization" + composer_media_optimization_image_resize_dimensions_threshold: "Minimum image width to trigger client-side resize" + composer_media_optimization_image_resize_width_target: "Images with widths larger than `composer_media_optimization_image_dimensions_resize_threshold` will be resized to this width. Must be >= than `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "JPEG encode quality used in the re-encode process." + min_ratio_to_crop: "Ratio used to crop tall images. Enter the result of width / height." simultaneous_uploads: "Maximum number of files that can be dragged & dropped in the composer" diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index 9fd6c51b90..07dcfd9d3d 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -1,6 +1,7 @@ # Additional MIME types that you'd like nginx to handle go in here types { text/csv csv; + application/wasm wasm; } upstream discourse { @@ -47,7 +48,7 @@ server { gzip_vary on; gzip_min_length 1000; gzip_comp_level 5; - gzip_types application/json text/css text/javascript application/x-javascript application/javascript image/svg+xml; + gzip_types application/json text/css text/javascript application/x-javascript application/javascript image/svg+xml application/wasm; gzip_proxied any; # Uncomment and configure this section for HTTPS support diff --git a/config/site_settings.yml b/config/site_settings.yml index a6ec120c76..12a645d55f 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1405,6 +1405,33 @@ files: decompressed_backup_max_file_size_mb: default: 100000 hidden: true + composer_media_optimization_image_enabled: + default: false + client: true + composer_media_optimization_image_kilobytes_optimization_threshold: + default: 1048576 + client: true + composer_media_optimization_image_resize_dimensions_threshold: + default: 1920 + client: true + composer_media_optimization_image_resize_width_target: + default: 1920 + client: true + composer_media_optimization_image_resize_pre_multiply: + default: false + hidden: true + client: true + composer_media_optimization_image_resize_linear_rgb: + default: false + hidden: true + client: true + composer_media_optimization_image_encode_quality: + default: 75 + client: true + composer_media_optimization_debug_mode: + default: false + client: true + hidden: true trust: default_trust_level: diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index a40bd98cff..834a3804c3 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -112,6 +112,8 @@ def dependencies source: 'blueimp-file-upload/js/jquery.fileupload.js', }, { source: 'blueimp-file-upload/js/jquery.iframe-transport.js', + }, { + source: 'blueimp-file-upload/js/jquery.fileupload-process.js', }, { source: 'blueimp-file-upload/js/vendor/jquery.ui.widget.js', }, { @@ -191,6 +193,30 @@ def dependencies { source: 'sinon/pkg/sinon.js' }, + { + source: 'squoosh/codecs/mozjpeg/enc/mozjpeg_enc.js', + destination: 'squoosh', + public: true, + skip_versioning: true + }, + { + source: 'squoosh/codecs/mozjpeg/enc/mozjpeg_enc.wasm', + destination: 'squoosh', + public: true, + skip_versioning: true + }, + { + source: 'squoosh/codecs/resize/pkg/squoosh_resize.js', + destination: 'squoosh', + public: true, + skip_versioning: true + }, + { + source: 'squoosh/codecs/resize/pkg/squoosh_resize_bg.wasm', + destination: 'squoosh', + public: true, + skip_versioning: true + }, ] end diff --git a/package.json b/package.json index 9d013590a1..0b8fddf98d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "puppeteer": "1.20", "qunit": "2.8.0", "route-recognizer": "^0.3.3", - "sinon": "^9.0.2" + "sinon": "^9.0.2", + "squoosh": "discourse/squoosh#dc9649d" }, "resolutions": { "lodash": "4.17.21" diff --git a/public/javascripts/media-optimization-worker.js b/public/javascripts/media-optimization-worker.js new file mode 100644 index 0000000000..7bbdf137bd --- /dev/null +++ b/public/javascripts/media-optimization-worker.js @@ -0,0 +1,166 @@ +function resizeWithAspect( + input_width, + input_height, + target_width, + target_height, +) { + if (!target_width && !target_height) { + throw Error('Need to specify at least width or height when resizing'); + } + + if (target_width && target_height) { + return { width: target_width, height: target_height }; + } + + if (!target_width) { + return { + width: Math.round((input_width / input_height) * target_height), + height: target_height, + }; + } + + return { + width: target_width, + height: Math.round((input_height / input_width) * target_width), + }; +} + +function logIfDebug(message) { + if (DedicatedWorkerGlobalScope.debugMode) { + // eslint-disable-next-line no-console + console.log(message); + } +} + +async function optimize(imageData, fileName, width, height, settings) { + + await loadLibs(settings); + + const mozJpegDefaultOptions = { + quality: settings.encode_quality, + baseline: false, + arithmetic: false, + progressive: true, + optimize_coding: true, + smoothing: 0, + color_space: 3 /*YCbCr*/, + quant_table: 3, + trellis_multipass: false, + trellis_opt_zero: false, + trellis_opt_table: false, + trellis_loops: 1, + auto_subsample: true, + chroma_subsample: 2, + separate_chroma_quality: false, + chroma_quality: 75, + }; + + const initialSize = imageData.byteLength; + logIfDebug(`Worker received imageData: ${initialSize}`); + + let maybeResized; + + // resize + if (width > settings.resize_threshold) { + try { + const target_dimensions = resizeWithAspect(width, height, settings.resize_target); + const resizeResult = self.codecs.resize( + new Uint8ClampedArray(imageData), + width, //in + height, //in + target_dimensions.width, //out + target_dimensions.height, //out + 3, // 3 is lanczos + settings.resize_pre_multiply, + settings.resize_linear_rgb + ); + maybeResized = new ImageData( + resizeResult, + target_dimensions.width, + target_dimensions.height, + ).data; + width = target_dimensions.width; + height = target_dimensions.height; + } catch (error) { + console.error(`Resize failed: ${error}`); + maybeResized = imageData; + } + } else { + logIfDebug(`Skipped resize: ${width} < ${settings.resize_threshold}`); + maybeResized = imageData; + } + + // mozJPEG re-encode + const result = self.codecs.mozjpeg_enc.encode( + maybeResized, + width, + height, + mozJpegDefaultOptions + ); + + const finalSize = result.byteLength + logIfDebug(`Worker post reencode file: ${finalSize}`); + logIfDebug(`Reduction: ${(initialSize / finalSize).toFixed(1)}x speedup`); + + let transferrable = Uint8Array.from(result).buffer; // decoded was allocated inside WASM so it **cannot** be transfered to another context, need to copy by value + + return transferrable; +} + +onmessage = async function (e) { + switch (e.data.type) { + case "compress": + try { + DedicatedWorkerGlobalScope.debugMode = e.data.settings.debug_mode; + let optimized = await optimize( + e.data.file, + e.data.fileName, + e.data.width, + e.data.height, + e.data.settings + ); + postMessage( + { + type: "file", + file: optimized, + fileName: e.data.fileName + }, + [optimized] + ); + } catch (error) { + console.error(error); + postMessage({ + type: "error", + }); + } + break; + default: + logIfDebug(`Sorry, we are out of ${e}.`); + } +}; + +async function loadLibs(settings){ + + if (self.codecs) return; + + importScripts(settings.mozjpeg_script); + importScripts(settings.resize_script); + + let encoderModuleOverrides = { + locateFile: function(path, prefix) { + // if it's a mem init file, use a custom dir + if (path.endsWith(".wasm")) return settings.mozjpeg_wasm; + // otherwise, use the default, the prefix (JS file's dir) + the path + return prefix + path; + }, + onRuntimeInitialized: function () { + return this; + }, + }; + const mozjpeg_enc_module = await mozjpeg_enc(encoderModuleOverrides); + + const { resize } = wasm_bindgen; + await wasm_bindgen(settings.resize_wasm); + + self.codecs = {mozjpeg_enc: mozjpeg_enc_module, resize: resize}; +} \ No newline at end of file diff --git a/public/javascripts/squoosh/mozjpeg_enc.js b/public/javascripts/squoosh/mozjpeg_enc.js new file mode 100644 index 0000000000..e52ad7a6d0 --- /dev/null +++ b/public/javascripts/squoosh/mozjpeg_enc.js @@ -0,0 +1,21 @@ + +var mozjpeg_enc = (function() { + var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined; + + return ( +function(mozjpeg_enc) { + mozjpeg_enc = mozjpeg_enc || {}; + +var Module=typeof mozjpeg_enc!=="undefined"?mozjpeg_enc:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise(function(resolve,reject){readyPromiseResolve=resolve;readyPromiseReject=reject});var moduleOverrides={};var key;for(key in Module){if(Module.hasOwnProperty(key)){moduleOverrides[key]=Module[key]}}var arguments_=[];var thisProgram="./this.program";var quit_=function(status,toThrow){throw toThrow};var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=true;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!=="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=function(url,onload,onerror){var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=function(){if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=function(title){document.title=title}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.warn.bind(console);for(key in moduleOverrides){if(moduleOverrides.hasOwnProperty(key)){Module[key]=moduleOverrides[key]}}moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var tempRet0=0;var setTempRet0=function(value){tempRet0=value};var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!=="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;var UTF8Decoder=new TextDecoder("utf8");function UTF8ArrayToString(heap,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heap[endPtr]&&!(endPtr>=endIdx))++endPtr;return UTF8Decoder.decode(heap.subarray?heap.subarray(idx,endPtr):new Uint8Array(heap.slice(idx,endPtr)))}function UTF8ToString(ptr,maxBytesToRead){if(!ptr)return"";var maxPtr=ptr+maxBytesToRead;for(var end=ptr;!(end>=maxPtr)&&HEAPU8[end];)++end;return UTF8Decoder.decode(HEAPU8.subarray(ptr,end))}function stringToUTF8Array(str,heap,outIdx,maxBytesToWrite){if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx}function stringToUTF8(str,outPtr,maxBytesToWrite){return stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite)}function lengthBytesUTF8(str){var len=0;for(var i=0;i=55296&&u<=57343)u=65536+((u&1023)<<10)|str.charCodeAt(++i)&1023;if(u<=127)++len;else if(u<=2047)len+=2;else if(u<=65535)len+=3;else len+=4}return len}var UTF16Decoder=new TextDecoder("utf-16le");function UTF16ToString(ptr,maxBytesToRead){var endPtr=ptr;var idx=endPtr>>1;var maxIdx=idx+maxBytesToRead/2;while(!(idx>=maxIdx)&&HEAPU16[idx])++idx;endPtr=idx<<1;return UTF16Decoder.decode(HEAPU8.subarray(ptr,endPtr));var str="";for(var i=0;!(i>=maxBytesToRead/2);++i){var codeUnit=HEAP16[ptr+i*2>>1];if(codeUnit==0)break;str+=String.fromCharCode(codeUnit)}return str}function stringToUTF16(str,outPtr,maxBytesToWrite){if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<2)return 0;maxBytesToWrite-=2;var startPtr=outPtr;var numCharsToWrite=maxBytesToWrite>1]=codeUnit;outPtr+=2}HEAP16[outPtr>>1]=0;return outPtr-startPtr}function lengthBytesUTF16(str){return str.length*2}function UTF32ToString(ptr,maxBytesToRead){var i=0;var str="";while(!(i>=maxBytesToRead/4)){var utf32=HEAP32[ptr+i*4>>2];if(utf32==0)break;++i;if(utf32>=65536){var ch=utf32-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}else{str+=String.fromCharCode(utf32)}}return str}function stringToUTF32(str,outPtr,maxBytesToWrite){if(maxBytesToWrite===undefined){maxBytesToWrite=2147483647}if(maxBytesToWrite<4)return 0;var startPtr=outPtr;var endPtr=startPtr+maxBytesToWrite-4;for(var i=0;i=55296&&codeUnit<=57343){var trailSurrogate=str.charCodeAt(++i);codeUnit=65536+((codeUnit&1023)<<10)|trailSurrogate&1023}HEAP32[outPtr>>2]=codeUnit;outPtr+=4;if(outPtr+4>endPtr)break}HEAP32[outPtr>>2]=0;return outPtr-startPtr}function lengthBytesUTF32(str){var len=0;for(var i=0;i=55296&&codeUnit<=57343)++i;len+=4}return len}function writeAsciiToMemory(str,buffer,dontAddNull){for(var i=0;i>0]=str.charCodeAt(i)}if(!dontAddNull)HEAP8[buffer>>0]=0}function alignUp(x,multiple){if(x%multiple>0){x+=multiple-x%multiple}return x}var buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferAndViews(buf){buffer=buf;Module["HEAP8"]=HEAP8=new Int8Array(buf);Module["HEAP16"]=HEAP16=new Int16Array(buf);Module["HEAP32"]=HEAP32=new Int32Array(buf);Module["HEAPU8"]=HEAPU8=new Uint8Array(buf);Module["HEAPU16"]=HEAPU16=new Uint16Array(buf);Module["HEAPU32"]=HEAPU32=new Uint32Array(buf);Module["HEAPF32"]=HEAPF32=new Float32Array(buf);Module["HEAPF64"]=HEAPF64=new Float64Array(buf)}var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeExited=false;function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function exitRuntime(){runtimeExited=true}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}Module["preloadedImages"]={};Module["preloadedAudios"]={};function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what+="";err(what);ABORT=true;EXITSTATUS=1;what="abort("+what+"). Build with -s ASSERTIONS=1 for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile="mozjpeg_enc.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}else{throw"both async and sync fetching of the wasm failed"}}catch(err){abort(err)}}function getBinaryPromise(){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){if(!response["ok"]){throw"failed to load wasm binary file at '"+wasmBinaryFile+"'"}return response["arrayBuffer"]()}).catch(function(){return getBinary(wasmBinaryFile)})}}return Promise.resolve().then(function(){return getBinary(wasmBinaryFile)})}function createWasm(){var info={"a":asmLibraryArg};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["C"];updateGlobalBufferAndViews(wasmMemory.buffer);wasmTable=Module["asm"]["I"];addOnInit(Module["asm"]["D"]);removeRunDependency("wasm-instantiate")}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}function instantiateArrayBuffer(receiver){return getBinaryPromise().then(function(binary){var result=WebAssembly.instantiate(binary,info);return result}).then(receiver,function(reason){err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(){if(!wasmBinary&&typeof WebAssembly.instantiateStreaming==="function"&&!isDataURI(wasmBinaryFile)&&typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){var result=WebAssembly.instantiateStreaming(response,info);return result.then(receiveInstantiationResult,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(receiveInstantiationResult)})})}else{return instantiateArrayBuffer(receiveInstantiationResult)}}if(Module["instantiateWasm"]){try{var exports=Module["instantiateWasm"](info,receiveInstance);return exports}catch(e){err("Module.instantiateWasm callback failed with error: "+e);return false}}instantiateAsync().catch(readyPromiseReject);return{}}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){var callback=callbacks.shift();if(typeof callback=="function"){callback(Module);continue}var func=callback.func;if(typeof func==="number"){if(callback.arg===undefined){wasmTable.get(func)()}else{wasmTable.get(func)(callback.arg)}}else{func(callback.arg===undefined?null:callback.arg)}}}var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function _atexit(func,arg){}function ___cxa_thread_atexit(a0,a1){return _atexit(a0,a1)}var structRegistrations={};function runDestructors(destructors){while(destructors.length){var ptr=destructors.pop();var del=destructors.pop();del(ptr)}}function simpleReadValueFromPointer(pointer){return this["fromWireType"](HEAPU32[pointer>>2])}var awaitingDependencies={};var registeredTypes={};var typeDependencies={};var char_0=48;var char_9=57;function makeLegalFunctionName(name){if(undefined===name){return"_unknown"}name=name.replace(/[^a-zA-Z0-9_]/g,"$");var f=name.charCodeAt(0);if(f>=char_0&&f<=char_9){return"_"+name}else{return name}}function createNamedFunction(name,body){name=makeLegalFunctionName(name);return function(){null;return body.apply(this,arguments)}}function extendError(baseErrorType,errorName){var errorClass=createNamedFunction(errorName,function(message){this.name=errorName;this.message=message;var stack=new Error(message).stack;if(stack!==undefined){this.stack=this.toString()+"\n"+stack.replace(/^Error(:[^\n]*)?\n/,"")}});errorClass.prototype=Object.create(baseErrorType.prototype);errorClass.prototype.constructor=errorClass;errorClass.prototype.toString=function(){if(this.message===undefined){return this.name}else{return this.name+": "+this.message}};return errorClass}var InternalError=undefined;function throwInternalError(message){throw new InternalError(message)}function whenDependentTypesAreResolved(myTypes,dependentTypes,getTypeConverters){myTypes.forEach(function(type){typeDependencies[type]=dependentTypes});function onComplete(typeConverters){var myTypeConverters=getTypeConverters(typeConverters);if(myTypeConverters.length!==myTypes.length){throwInternalError("Mismatched type converter count")}for(var i=0;i>shift])},destructorFunction:null})}var emval_free_list=[];var emval_handle_array=[{},{value:undefined},{value:null},{value:true},{value:false}];function __emval_decref(handle){if(handle>4&&0===--emval_handle_array[handle].refcount){emval_handle_array[handle]=undefined;emval_free_list.push(handle)}}function count_emval_handles(){var count=0;for(var i=5;i>2])};case 3:return function(pointer){return this["fromWireType"](HEAPF64[pointer>>3])};default:throw new TypeError("Unknown float type: "+name)}}function __embind_register_float(rawType,name,size){var shift=getShiftFromSize(size);name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":function(value){return value},"toWireType":function(destructors,value){if(typeof value!=="number"&&typeof value!=="boolean"){throw new TypeError('Cannot convert "'+_embind_repr(value)+'" to '+this.name)}return value},"argPackAdvance":8,"readValueFromPointer":floatReadValueFromPointer(name,shift),destructorFunction:null})}function craftInvokerFunction(humanName,argTypes,classType,cppInvokerFunc,cppTargetFunc){var argCount=argTypes.length;if(argCount<2){throwBindingError("argTypes array size mismatch! Must at least get return value and 'this' types!")}var isClassMethodFunc=argTypes[1]!==null&&classType!==null;var needsDestructorStack=false;for(var i=1;i>2)+i])}return array}function replacePublicSymbol(name,value,numArguments){if(!Module.hasOwnProperty(name)){throwInternalError("Replacing nonexistant public symbol")}if(undefined!==Module[name].overloadTable&&undefined!==numArguments){Module[name].overloadTable[numArguments]=value}else{Module[name]=value;Module[name].argCount=numArguments}}function dynCallLegacy(sig,ptr,args){var f=Module["dynCall_"+sig];return args&&args.length?f.apply(null,[ptr].concat(args)):f.call(null,ptr)}function dynCall(sig,ptr,args){if(sig.includes("j")){return dynCallLegacy(sig,ptr,args)}return wasmTable.get(ptr).apply(null,args)}function getDynCaller(sig,ptr){var argCache=[];return function(){argCache.length=arguments.length;for(var i=0;i>1]}:function readU16FromPointer(pointer){return HEAPU16[pointer>>1]};case 2:return signed?function readS32FromPointer(pointer){return HEAP32[pointer>>2]}:function readU32FromPointer(pointer){return HEAPU32[pointer>>2]};default:throw new TypeError("Unknown integer type: "+name)}}function __embind_register_integer(primitiveType,name,size,minRange,maxRange){name=readLatin1String(name);if(maxRange===-1){maxRange=4294967295}var shift=getShiftFromSize(size);var fromWireType=function(value){return value};if(minRange===0){var bitshift=32-8*size;fromWireType=function(value){return value<>>bitshift}}var isUnsignedType=name.includes("unsigned");registerType(primitiveType,{name:name,"fromWireType":fromWireType,"toWireType":function(destructors,value){if(typeof value!=="number"&&typeof value!=="boolean"){throw new TypeError('Cannot convert "'+_embind_repr(value)+'" to '+this.name)}if(valuemaxRange){throw new TypeError('Passing a number "'+_embind_repr(value)+'" from JS side to C/C++ side to an argument of type "'+name+'", which is outside the valid range ['+minRange+", "+maxRange+"]!")}return isUnsignedType?value>>>0:value|0},"argPackAdvance":8,"readValueFromPointer":integerReadValueFromPointer(name,shift,minRange!==0),destructorFunction:null})}function __embind_register_memory_view(rawType,dataTypeIndex,name){var typeMapping=[Int8Array,Uint8Array,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array];var TA=typeMapping[dataTypeIndex];function decodeMemoryView(handle){handle=handle>>2;var heap=HEAPU32;var size=heap[handle];var data=heap[handle+1];return new TA(buffer,data,size)}name=readLatin1String(name);registerType(rawType,{name:name,"fromWireType":decodeMemoryView,"argPackAdvance":8,"readValueFromPointer":decodeMemoryView},{ignoreDuplicateRegistrations:true})}function __embind_register_std_string(rawType,name){name=readLatin1String(name);var stdStringIsUTF8=name==="std::string";registerType(rawType,{name:name,"fromWireType":function(value){var length=HEAPU32[value>>2];var str;if(stdStringIsUTF8){var decodeStartPtr=value+4;for(var i=0;i<=length;++i){var currentBytePtr=value+4+i;if(i==length||HEAPU8[currentBytePtr]==0){var maxRead=currentBytePtr-decodeStartPtr;var stringSegment=UTF8ToString(decodeStartPtr,maxRead);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+1}}}else{var a=new Array(length);for(var i=0;i>2]=length;if(stdStringIsUTF8&&valueIsOfTypeString){stringToUTF8(value,ptr+4,length+1)}else{if(valueIsOfTypeString){for(var i=0;i255){_free(ptr);throwBindingError("String has UTF-16 code units that do not fit in 8 bits")}HEAPU8[ptr+4+i]=charCode}}else{for(var i=0;i>2];var HEAP=getHeap();var str;var decodeStartPtr=value+4;for(var i=0;i<=length;++i){var currentBytePtr=value+4+i*charSize;if(i==length||HEAP[currentBytePtr>>shift]==0){var maxReadBytes=currentBytePtr-decodeStartPtr;var stringSegment=decodeString(decodeStartPtr,maxReadBytes);if(str===undefined){str=stringSegment}else{str+=String.fromCharCode(0);str+=stringSegment}decodeStartPtr=currentBytePtr+charSize}}_free(value);return str},"toWireType":function(destructors,value){if(!(typeof value==="string")){throwBindingError("Cannot pass non-string to C++ string type "+name)}var length=lengthBytesUTF(value);var ptr=_malloc(4+length+charSize);HEAPU32[ptr>>2]=length>>shift;encodeString(value,ptr+4,length+charSize);if(destructors!==null){destructors.push(_free,ptr)}return ptr},"argPackAdvance":8,"readValueFromPointer":simpleReadValueFromPointer,destructorFunction:function(ptr){_free(ptr)}})}function __embind_register_value_object(rawType,name,constructorSignature,rawConstructor,destructorSignature,rawDestructor){structRegistrations[rawType]={name:readLatin1String(name),rawConstructor:embind__requireFunction(constructorSignature,rawConstructor),rawDestructor:embind__requireFunction(destructorSignature,rawDestructor),fields:[]}}function __embind_register_value_object_field(structType,fieldName,getterReturnType,getterSignature,getter,getterContext,setterArgumentType,setterSignature,setter,setterContext){structRegistrations[structType].fields.push({fieldName:readLatin1String(fieldName),getterReturnType:getterReturnType,getter:embind__requireFunction(getterSignature,getter),getterContext:getterContext,setterArgumentType:setterArgumentType,setter:embind__requireFunction(setterSignature,setter),setterContext:setterContext})}function __embind_register_void(rawType,name){name=readLatin1String(name);registerType(rawType,{isVoid:true,name:name,"argPackAdvance":0,"fromWireType":function(){return undefined},"toWireType":function(destructors,o){return undefined}})}var emval_symbols={};function getStringOrSymbol(address){var symbol=emval_symbols[address];if(symbol===undefined){return readLatin1String(address)}else{return symbol}}function emval_get_global(){if(typeof globalThis==="object"){return globalThis}function testGlobal(obj){obj["$$$embind_global$$$"]=obj;var success=typeof $$$embind_global$$$==="object"&&obj["$$$embind_global$$$"]===obj;if(!success){delete obj["$$$embind_global$$$"]}return success}if(typeof $$$embind_global$$$==="object"){return $$$embind_global$$$}if(typeof global==="object"&&testGlobal(global)){$$$embind_global$$$=global}else if(typeof self==="object"&&testGlobal(self)){$$$embind_global$$$=self}if(typeof $$$embind_global$$$==="object"){return $$$embind_global$$$}throw Error("unable to get global object.")}function __emval_get_global(name){if(name===0){return __emval_register(emval_get_global())}else{name=getStringOrSymbol(name);return __emval_register(emval_get_global()[name])}}function __emval_incref(handle){if(handle>4){emval_handle_array[handle].refcount+=1}}function requireRegisteredType(rawType,humanName){var impl=registeredTypes[rawType];if(undefined===impl){throwBindingError(humanName+" has unknown type "+getTypeName(rawType))}return impl}function craftEmvalAllocator(argCount){var argsList=new Array(argCount+1);return function(constructor,argTypes,args){argsList[0]=constructor;for(var i=0;i>2)+i],"parameter "+i);argsList[i+1]=argType.readValueFromPointer(args);args+=argType.argPackAdvance}var obj=new(constructor.bind.apply(constructor,argsList));return __emval_register(obj)}}var emval_newers={};function requireHandle(handle){if(!handle){throwBindingError("Cannot use deleted val. handle = "+handle)}return emval_handle_array[handle].value}function __emval_new(handle,argCount,argTypes,args){handle=requireHandle(handle);var newer=emval_newers[argCount];if(!newer){newer=craftEmvalAllocator(argCount);emval_newers[argCount]=newer}return newer(handle,argTypes,args)}function _abort(){abort()}function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){try{wasmMemory.grow(size-buffer.byteLength+65535>>>16);updateGlobalBufferAndViews(wasmMemory.buffer);return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=2147483648;if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}var ENV={};function getExecutableName(){return thisProgram||"./this.program"}function getEnvStrings(){if(!getEnvStrings.strings){var lang=(typeof navigator==="object"&&navigator.languages&&navigator.languages[0]||"C").replace("-","_")+".UTF-8";var env={"USER":"web_user","LOGNAME":"web_user","PATH":"/","PWD":"/","HOME":"/home/web_user","LANG":lang,"_":getExecutableName()};for(var x in ENV){env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(x+"="+env[x])}getEnvStrings.strings=strings}return getEnvStrings.strings}var SYSCALLS={mappings:{},buffers:[null,[],[]],printChar:function(stream,curr){var buffer=SYSCALLS.buffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer,0));buffer.length=0}else{buffer.push(curr)}},varargs:undefined,get:function(){SYSCALLS.varargs+=4;var ret=HEAP32[SYSCALLS.varargs-4>>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},get64:function(low,high){return low}};function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAP32[__environ+i*4>>2]=ptr;writeAsciiToMemory(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAP32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAP32[penviron_buf_size>>2]=bufSize;return 0}function _exit(status){exit(status)}function _fd_close(fd){return 0}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){}function _fd_write(fd,iov,iovcnt,pnum){var num=0;for(var i=0;i>2];var len=HEAP32[iov+(i*8+4)>>2];for(var j=0;j>2]=num;return 0}function _setTempRet0(val){setTempRet0(val)}InternalError=Module["InternalError"]=extendError(Error,"InternalError");embind_init_charCodes();BindingError=Module["BindingError"]=extendError(Error,"BindingError");init_emval();UnboundTypeError=Module["UnboundTypeError"]=extendError(Error,"UnboundTypeError");var asmLibraryArg={"B":___cxa_thread_atexit,"l":__embind_finalize_value_object,"p":__embind_register_bigint,"y":__embind_register_bool,"x":__embind_register_emval,"i":__embind_register_float,"f":__embind_register_function,"c":__embind_register_integer,"b":__embind_register_memory_view,"j":__embind_register_std_string,"e":__embind_register_std_wstring,"m":__embind_register_value_object,"a":__embind_register_value_object_field,"z":__embind_register_void,"g":__emval_decref,"u":__emval_get_global,"k":__emval_incref,"n":__emval_new,"h":_abort,"r":_emscripten_memcpy_big,"d":_emscripten_resize_heap,"s":_environ_get,"t":_environ_sizes_get,"A":_exit,"w":_fd_close,"o":_fd_seek,"v":_fd_write,"q":_setTempRet0};var asm=createWasm();var ___wasm_call_ctors=Module["___wasm_call_ctors"]=function(){return(___wasm_call_ctors=Module["___wasm_call_ctors"]=Module["asm"]["D"]).apply(null,arguments)};var _malloc=Module["_malloc"]=function(){return(_malloc=Module["_malloc"]=Module["asm"]["E"]).apply(null,arguments)};var _free=Module["_free"]=function(){return(_free=Module["_free"]=Module["asm"]["F"]).apply(null,arguments)};var ___getTypeName=Module["___getTypeName"]=function(){return(___getTypeName=Module["___getTypeName"]=Module["asm"]["G"]).apply(null,arguments)};var ___embind_register_native_and_builtin_types=Module["___embind_register_native_and_builtin_types"]=function(){return(___embind_register_native_and_builtin_types=Module["___embind_register_native_and_builtin_types"]=Module["asm"]["H"]).apply(null,arguments)};var dynCall_jiji=Module["dynCall_jiji"]=function(){return(dynCall_jiji=Module["dynCall_jiji"]=Module["asm"]["J"]).apply(null,arguments)};var calledRun;function ExitStatus(status){this.name="ExitStatus";this.message="Program terminated with exit("+status+")";this.status=status}dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(args){args=args||arguments_;if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}Module["run"]=run;function exit(status,implicit){EXITSTATUS=status;if(implicit&&keepRuntimeAlive()&&status===0){return}if(keepRuntimeAlive()){}else{exitRuntime();if(Module["onExit"])Module["onExit"](status);ABORT=true}quit_(status,new ExitStatus(status))}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run(); + + + return mozjpeg_enc.ready +} +); +})(); +if (typeof exports === 'object' && typeof module === 'object') + module.exports = mozjpeg_enc; +else if (typeof define === 'function' && define['amd']) + define([], function() { return mozjpeg_enc; }); +else if (typeof exports === 'object') + exports["mozjpeg_enc"] = mozjpeg_enc; diff --git a/public/javascripts/squoosh/mozjpeg_enc.wasm b/public/javascripts/squoosh/mozjpeg_enc.wasm new file mode 100755 index 0000000000000000000000000000000000000000..d3cfebf62e061f2f1c30af81ef6f757968a32d5d GIT binary patch literal 255878 zcmce<51by?Ro^{#W}ZL0v#Wjd$F{6>q&s7!$i+$&8YIU!)!xOH`m{806%*>tp=iGD7J?GqW&%Kkp?XGvYBuU(Vaj&@DEhPN8h1;`* z1zpkw5_F&3-r`qRl6aBFhnUE7+uripUXajQILLp=?MZTbC%)rlx4z@HE?`w1JV-*q zZMJZ5!5zGPyvY{Z+pk8cDp!rH;TG?m$zt8UPwmQGxVE7kyAFS+bl4g&&Pq@^%B1v;s z^s*w&@--Kwz1GDY?%FQNFUeZ%B+rs0E!JK-mUmn?ooG)KMMm1XtkZ5!rtRs=dOWvt z*M^q-H<3*^$eLw&zCwRWo1Dx#_Fv2XOIkUJF3EIBlJW7WsifPb4F9@gW33i>TVrYJ z^#3ZCw7M%DEwq!Bovht%0}sD#Zu6GA)7^DftK(ch?f2967&Hi{KJuxw=v>})-<&4y z+T>f@b;;#!N8;V}$*(!LGkJ%*!6oj-p_xJ64_m8=| z_B`qS(EYyqocpZ%J@*-R(tX@gYF^sbMEKezjKedkGo%W{}FQi zC-jveAUpr=b%w=A-Eu>0?Sy0)O)%+g;_f;$1l7 zc87c3bGW;!a$8dx@Tt$|!+v|8234ZuJeH=w*0f6|E;&_p>(^BY+g#d?EcYX)~~2X~h>RnfV8cUZfp%IB*zc-8Ap%-+-Q_j?D+7S-i*rORf#^Q~>! z+IZ>3OE+F95ImV^)2ogRH%%r*;gjOOrD<+B2kXfd*_~DEmg{o6z1z3GebN<|CqP-b zjY(K2vvek1fDp;%u4h+Thq&YGMjA6 zddihhG~cb9f@Tj`4GR;FjCy&cezwe2xzG!`BYLOzh7Ip7j3-=}nhQ?3JoN{6y^ygv znJTk_KKAA|LOL2)xDnW6+joS8DQ-8Vc}Y2SU{l(Kb|nuJJe;xrwjZh{ zXQ!0@8q#~>&wFR#f%xCTp>mu`23ZTLDp&eRDk)b{$CjfHhBNSZ*)bxfgM?~~4ur3q zac{_H7!w^my>Q0eFzFzkyJb)(H7AFv6?Kb?Jl*O?pWZYr#!2h<{%|=ykrqC?WdMJy zpve#`7=4NrLo zhpGAAD}C#V?5!2lI-BZ>hRe+^s9_ z4l3SRrQ#SWo~Z_GGs;_OWRJG*&NFt3vh61f{;5M%3O9v5%Btg5;8y4 zyjyW_f0YA$HAx>c9wz+cttY_Bii6cfKvy&w3*N2qz2=eZB=mglSKoRWhQb#S~phbNS(FihSy+T3SgXtE`I7AtQ44FwpIXQ9s?mK>L&bQ}a_5{o^ z@(MqBuv$yCswnkq4LW*f6{feH*w3rfukjc0qUYD#dSXA>lyE$&+Zp~esE6DhQZl$Bhzc|TH;2m{hS6yM7I<*E!QhJYAUTqo z8S)Met2&|Y-pFXG2T&_)ne#{<)soCrZLul(L*4c9p%>qGdapusD%tL?$hL^tt~xxM zy!nLgyncFzCC0efq@T5br)yHCx^&gQ$Z2QJY(M1NCuX-CIm{Sl;Q9$kkA=7J(}=nL zLKG8{b*{lXqDDwsl8xbQ@`ypoJF=_5p8@DY>xQuVhy7ccC0nSsD zqzTzg=~`oTgMz|psA*s%D`Y1Qx{&P-8Ad{Q<^goP z>TQAagWxg;;v_f>kQ_o~bM_WX0b}5eV5TnaAa6qNTu66|p<3a2gJ1OF&Sz8^PSY*^ z=$2==SS!Q22h^X;1cV_?3}H94*Q&WgHIf`_R7U=muW=Mw@g$9w5yr?{6gv-Z@#aN% z%e2`jZ$&Id-f9UWHE+cR8b%k>E-eMVT1$AR^|fI$b<*H?8Ei)9M%b)1%4T#Exh6KF zW6QD`>BeSo@hF>#StSC1)rMFQKZIhw+M$n+|r-KnkEB*ImR@g7sa96WedR2&(W^JjZb?k z*ZD*f5~jDBQ7QEz3)_f*NO=8GCwS$ zq}S{Mp%H zkxj^5c69>(Ep4TNCZmn>)5A;a*c|&SshEIS=VjP%sgwx!-qd5&3Sejr+$H8&nV?O1 zH&N$V3nMXT7Vh%+CWP*6u5$Bx_lWC57vqlB6BX!Tb-206%Xk&`MSlq|)`%ZrTwD)> zc1=&Otz>4bbjcuD+8S^m>{4)~V&N3zh3?>#HAqWiT-dt*d}p`)Y8qaHBCb?^7mP_Q z)!<+lufYni1TT358&%@83j%|TH5lAQFfz=uQe4v25YNKYjS=G#7Eww-Zr?m{iKdWWhu)ng-SQ z8)W!Ma0K{uFOmo3&hPQyWG|MgJ?%D;L|-QMg&tB|9cY<|2SLi3C?(y8c&w7avois| zyaV;>O>2uj_@I8j{ z4Yq}YdeAQDG4xYJ{kpJ$TNyEwMybk*Q7t1@jK+jghE@#BVJSl^CK*~W304d$e6JT! zLo2308q~&%C?gRbl2Rw;X6++Hk~ojkU;`jXBl4LbV{F#5+pLj#;-Y3Ys0wUaDCE^b zQAH~15QeQ1nKx1}3?J%<)6S}mEuwoIg#t$~Fgva1s)&Wo2zSrik_=6rV3qz0MEHVvPU^tM87?6X z-+57O4nVW{-FSR7eFM}s1N?<3aoWp5jh?5-8zwsx2kH*xXpO3_pL7%GJP0)J>G+@; zw4}f>xFf(jBu^P!DZ@%oO2FWblM!5#g6w0yH81GgJX5tABmhrv;LwWeZ4C=1(R~Zx z>twRG=6hS$P0$-~M2^Y!L|!D4V);Zz7pHgjCN+Eu=O8EK(xIA;)SY+XsSg>}5J@4M z(=r*enZ#tYyl3?So|9NN2VAn9b)>x-&Pgw0UL1=TWfJa(O_>pBeMvd>#yl~=Xl2Z5 z?`3iZ4c-aF9jdFoNTcy2rh-gi(;Sb5SgB?wuwWxG+;83SiAD?eu_1$cz)qbJcA`#W z4I>Oa)Vy-QNO<0A2~;JZme${(B@m@a92r`Z@DQ!Y{t=5>vP~8jlya0=V;p39dZZpC znaGZHm6>(LSU2NQ1_}4~V@S%Zr8%r@+Vz@b*uZBdA%p2|kPtA+0f%AD-E_qYli4$J zSCJf1!CgAAuAm^(5a#wk^n{^^CgfR6HYXE!1E6B&rDZ*~6MU$`;8aCaX@^!jb*r60 ztC(`?*NAUSMlgCR3PmtEXuq>;`;ah7c<*3>AzlFju1@2ikHm|=pw8%%i1lkaX^m#k z4{57`tYCfJuAF_P98LGEE! zjNwk7M?ZqK#)XLIX$hK;e5N+q4TfYSob4sFLQ)rTDj;#Wv=DH&qfgU{BMM;`oT2h8 zQ!!Q+D}mgMxC}EMnd6B48h13#P}91#Nk4Q)CvgH}{vGfmcXZ=6p~Tvt)z3A&?;iQ82&T41IaS*1H!2^1`~`}(3yvU&>G%D3tF}!?5t^d zyYW6Y?!Y`9xB|1-r|997D-fo#KCu#|F^EM7n$Z#fEt?w&g3X;on;V4-g-eGNaPn@L zL7MI`bJ$#gP$wxbyXtE$UNs2~U#A!olh)kp zZh9`f8UYExGkx1m?Qf z;uI<2A33(ftL8ZqcM4s&5`?|DMxr@M2j|&atQCi$+qMrt0y*?C@(gK4cLr9UteD;;Bq9$aBsJ8H-A(5#ZusAud~Pgus?b+0AA= z-o#pfXJ&J2gh->90C$KpjSzD8s;*SOX*mh-sD+mba2lhs!3eRHzL61P7D+QgqDvC^ z=t~hfld|V3plObF>V zgSF;MBttYI?8T5T4*0p=)ntPyY*u4c zT5UV!{QzyM=ZbU<;h5n=&#FXEC9HKMmzJsMJX00xKsfwLK_a68%RmHuKxKMYqZU8@ zK?H9S5|$`=`qdAbduAMuML2A4u-N-{gG*0I)TpH)uouQzqC$T6-eqI$WtArQ`(1_R zc1$-U3kdeWVHlU>01F%2^-6q$dM#-p`bjO~U4go!BnAsyJTLF0Qbtyy5`aHbk3Z#I zskJ#xdLg9IuW6IM0fZzME#`w3jSr+U)L(nR`8aQA#G6B)(eI2Ad7CQ)^mH8p+5~#S z@d?qKCQ#k1J5Uv~3vcpGY^S}I6WmB~Rysc;+gCH*2IJi_5H^71k+Q z`RraUOTt~rMl@xa&q{u96B}RB8d>lPQM(0G!0D*=Y*`4W2#DN!=PGLHHu+`ZCgI*Rv^F( z3Q!+^W9N$ny@Toqi@UuXAH;qFh86(;ZP+GuN32u`1EreI zJn&HITj6sqwWHt!X#^UgwbWbUU5JZO}5F|WQ zaBPZz`6&EppbiqG=orD7d4r$sp3b_Hv@qlXoV8RIlbbVn6=Su_RueS)$v zO|Z-?5;Lt|XgGmuKxfm5qLO$^IOYjWGKs4c%oca4s7pNsnwVFhB|($=!7HIKO2t^J zc^S_P2IOv;AP3oJz~Ef=eU)ua(9S@KM^Y0&VdKsIK2UK)C|KV(Z%wi zk^r3oJBow0$0W99+8XwDz&&d#)N~jnvEkkLdVP9Vm7YMnq;E%wec79^RD(;RnxQ~&BtzMSC;3gM zQ74ta5<2OxO)PyHz9AZfO5}gjNuc_^2Jz@5(?l#UL`Bi$GTNFcIc{%J&q&?N`q&{SSn$#$&|HYAH6Emf6tx>ai)IA=3bt*3v2&6RJ|d zxnCuL5EQjVL)6kJ%`v}Kr5vm;U1e|)@Y0TBOjs*=RNR}l~TPn@s9~y-khlDJt zhOM%Ot>gfSb`;exs8(w6!nY6*(Zr2yuKfFK)3+=-j&X*dxsRoRzjkSMFsoNP*=vpfZaSME9Z3Ca_Dft2$~<=_`6p6hF=sZ+ zX==c-YU7ECALa-Mob183OMj(IVjg^ZT~Y7WX^{UK2_dNg+LYKIkwx!&4qh@@WEvIA zv!0>Qau_dk@-VS`#1?qVwrZ(>6G;0JSUP@+hLy@P*_iWLSzfx?=SB zHabWhHYE77`{T(1MD-%T%}lwrrZ=l{`Il@)4TeQL&B3y1^M9H0viXCemZV*QQgaJg zqmGsLN-H|duZ3-MBcDCZMTKaZd$gqT7z4?YD$<~y-eM`(gg0(2@%d4$QW$4Yp|@%7 zyHN4y&3l~~rnqdL=_g*WPUO$zp{3DmTh4diuNWv?D>tManVcBZf-{w7|Pj8wQR4TD_Ah(y4 z7=5x5lsQZ<+J|Lb1_kMYltZ)0xAU|hsl+ZHC4H-YDB+ZHuO9A|HAZ-Ko$EHGy`|1& z2pr~;iI^9^tZgP>^Z59D)y8RnM{B_1xjA}4?=sq6IptQ!%}&Up&0y9-2mBh>RWDXv zBDk`tgV{%+KW^il)RlUHMw08R6HTcom?fgx;TVg_@FPf>rsWvNV)Szq6Q%@Ed*MK1^CNNru@Z=Gm z3#sR9c$gy$;c2~mcr^bX;HUla;lU;t!qa*A@UX%(gs1!R;pvXTGxqY~VFovZXZ+>E z!z6r&pW@}i!$2IuLky(iT3-(T5F$B*XY%F4GdT*+ikA=1icxr`UOqf*L>}UYRd$J; zm%xvOnb$6qI8GVwY_U*1vht!NL3PF8@b>29ShpHuBrA`yggkD$>D%Ap%>UPtd7{Xj=dCMkW@ zKlPb(Vr_{5XCWo{9atG-^PzRa9;1#UI39$82Puvv?}6UqrRiNsB@4ELpt6?kAOuUG zP)=5iru9-ZS??9Zg^o606=3jCI{@BcrLf*@4H8vqsonX9HQ~P7Z`{AX+(wo_ z*G&X;v21TEFR+m2z(n8)cNfLSi?pbJ5vh(1-S9%$W;h9GJlbkeh&OU+5o~;yq8zv2 z3ZsSyw+sJ^{w?^fhGNgJ2+8&IMwBg`IK~Q#NKl-3C+&tE{-ar#B>Z|ZzvAkAoPT*f z&VQMH#(9oEc8zcIJjq+*E@sSA-sIyc5xjJJPAH&RjGP@6$0&5eLEBkXwulW_lHr*q zLl&G4Txbc~jtjvXgm}_50B?EL7D+~K7&emdYPN>%z&tH4Hgb5vR~X~fWcZ^5IYQcG z_zNTTBo;maoZ!p;Zkz^P@X!i>vESJHr{+TbKg;6sS+^gq*7vRiO7UMks4BN)Scxkb zQLNBRA}kp_TD)Sv2&G4uHuOq2q~Ov!Q1B$Q3LDK8RnBB zJABc;iI86Cus3{(=%mSUAzz%+noUX%i^J3_nP|@d`o$r|`qzapqww*6Z5wFt?jV%v z`-!hYZnUXmL^L5eV1hAgs2#q3enpnJ1Xj@cW;<-&F^!991b0g?X9CFvg%C*hB?V#b zXFr`t(NNy+LA1~Xr~aBqYl!gT6;S}C#agH$g2Cxo_;xGUGMFWjP|)i-B92FCHPO~g zwiQ0`g&`OUc5K;f7HCjxUmY~kgjn3RSxt-#V8ot{P!I6>5fd@qKMa;=}eOlb^1yv)5TxWUJC zx!*9GG$4Itdv62DEJ1M0jjt}(Wm83xj&C!DNDJe@eXsRgexh zo~^ox)bx6E3{jbcH9ggW+}N*No7De3(EEC|eS2Bzaz{j(A##1W0jdj`Xps#%cw)nB z_sx*h#);Vdq=lAdz5k0hc)sF)3vZs3x222?;^IpS9yin@8<}CDDT&^9ypsG@f-Hf`UBN^IAn7E#9QEK1=D}h%t~Afj*5)X$GD?M$DMv3ayV?)JhlL7 zl~iK{`d?SlN;np?@=kzh5GFfW&yBqG5XxVp)PWuVJ!B3p{)@hQyl$eJh^l94gaz%DDE4`}kXXWM5 z0}E56b2Uet-i{efIDPMRVvKMSgCyefG}mTwna1(*WLo*&?Mi9m04Y?+~uR``e>q}M`H(%XV<1)zTCZgz3Z_5Kmr9wFQ1keHH-R`cVjk?2@#^ASsX zG^Jaw)Tjx^)45p3n?AKRhkJL>(>I!&br7v}y*h;@%C!R@#Fgx-q5lMQ9ctSL?`9{NE;%MneYnujw z0H&EXpoPZyYx6h7-zw4}PkA|->DX^!(c~O{mcU7qbJb<@B` zc-0Pu6N~p!K`xBlG_6sMG7-x%&c`L5HkdrNlfBy4p;xfOB@T_jpP$l%dzIhtpz6T7 z;@GipOnVT_W7+akOe#9&9Aw&|Nk_{&Lg5ni7z9t5T?AIf%qE{g*D%8{t*`o|R20nF z-(g{vTt5GPSFU2JXHv8psP(fjD~V~dN_DJM|I|@xBM1pK3!>M<%Hz}Jdiu)#Uev1u z`lm4X;Tr_HZ-f`?CvU=zCQn8gk8V=`g$IgYVymHL`HdLYdQ@;&f^~>~9Baqb1ELI; zs-Rp7b%W<3g#l0-X2;$FEP#p1A$o58&0x#M?PLY1G+-Yn3Ir}|jzfZfKk?qMEj*|w*1U);z$eh;v#JDW- zDY&V5?bHB3X~ULvc2S_3Hslv=$nQz}eKGOrk(dMyn>5QxFQ8yo=4xuyPV*Mgl2`cOAzS$Z2cu!I%cF-0tZNs3c2hG>V z1>x9SIX;30!BVa=@jM?QGQZX(tW{iS7I zyqHpHz$!h4-W7Ve%B*HGKB!(@kXp3HgbVW&Z%yB4daI8zK?>HQ;lKanul&fOYzKq` zyCO$MIxrb$o>DS0gY^`Kiv6Aj1K4IQEFi8=w<43BYGE}DL4_oFI7#6+78kjbj-bFv zEB<6Fj^f8DuFmMqVyi5XC|~0IuMXBYsuW%b(FA34^v-Hhe!J7j&66obyf$G>`Biho zuC%?Y~j=rX-3_!iG1!>c`L4$uk~xiVR5Hf%SIY>C3kDY zYW`EIiB@wS9*HzKW{A7LBn1@(cqyL#`ib#?iBAX#~P;0X^aL4p4SA=Yl7!YFvZyTN{!bhjIl|H z9iI@Ah2Dv9=C?og7j|ObM3CF46$d&&{lXStXg1L(@RQp$hopTHu@J0%7Aa{ztJ~Lv zgXdEj#sgz}BJ}?Y)kln<1nNOmPuEo~l2})D(z^0w-Id4lSk+=4s~Y>2@*ij{)eveV z(HBbpgL*o1s;;p=tnrxDcz<2v(YnTdT_eOBDc%5dd|3PuEB8*N9sLapE`ZeWO085oT@bNtKUSm5*kk zzBmCrlGS>InPWP`9t}=aCU}+qI=iLWbL7cLP{B5)nxrwgIL6iHiZIy#_UOQ%+GAN| z>~()uUEbJ-k0On(D_!K(qk}mu?U#b2uURcZ;uYqqS-5cyN6%j*bDES+@=pVAH$1zv zI_%169dhi%1QAgKW6~!2LdI#7sfNN7lQ9}%=4r#_bSnE4hy2u>3ETC^c{CdZWR-&i z1bUPvoo`H&*;gF#>-SgdXDuX0AxrCLO(6B<(s<^+5ASD=jm?XFuj3H#K0O%4j)Ut= zhELOn_6|gjL>g*Z4P39|XDl=UaWjw9X`L{3Mf!zT%p?7z`v4Mti3JdaBjV=;&YG*$ zRK>0yMq~|rQ$O}V9I4u6Bn2ZyS?5Xg%5-Vm$PJ_Qu0;4kP9ifslgAC)vQ5hTTMA;; zv~g3MJ%UXHbb5j7RbdnKK~G*_cGQ3^!$?U6Jmz7Y>C5o{NC}W6ub+|{SD}L70V@et z$T4eE3^250Mr$)}%`Lq35sRtj$+~QumJpX?QzbBsOI4YuH!e9jysroS*e`x_lJpnu z`#9Pwju2qQbHg509={yV3Oq4;>5+!x7q?riTWciEqS^qKV$XM2+*&tFpP;eM(oPgk zo4lP$V>qo-B-?t=Wi*oQy=TbOM6zv&Q+$X6e-jzRe|3zl0@)Nt8wuBqgi9ctvwjhW z?Y*L?Fy)nXrR}<~RYHIOA?7Y-;qbtlCRyZSt4e@lV2voU$Ub0 z1>?(tNfzq|_t?@frsvg(=l%c3T#qJjHMjFB-_>$=V)kUyHS^PO)rO|7yg#aYMsHen*cPZ$N zQ%kU#nn8cQT85wv7Da5it(FxRQ0z-T-Vzst-VL}NG?338{Bg|VzQjh>p#~APkeA45 z{GI?>S5Yqvou`6<4521d8{>3Oy(B;_j6rt@gOr1wZ6?Uz^+NqFO%-aM<6eI0&!4a* zs>CMY;0KmPCq!#(Oqtftjvp*N8zO9afSVDm)3jzo_r+odhOMD8XMd8`euBSC|H-5t z#}8Jj7lpQP?_1#x29Lfz-xvv7GngCUz zvs2pPx4!VDtJG0YxvO^e(ODTy-f7{=#h#;T1us(b3>z{^V7`qRmG`Bz@XKtTqrzp* zfQzKJ(N7@p*wkWYX1Z|6E*%WWX#`(lqRt|xv~|NfXx||M_K5yN2rit7tF|2;BW<$X z*ui%=kzvRK!U#EL@e@L%qjY}#t+wFCso>y1v#=wFUuNg2*o-LAT{IqajRwWm7fQ|s zBfu};!`RTo**b^Sa&utB93zbdkovB=bZKKC}`)e*HhjX^6?C zaD(-P8|2zdoI%p7+rLI!V^Asz#_C?1}Q9`{z3)mUFW(1;BjfKoW;EK3#!9@}n=g!+0?1!r z=E%Ye<77j)2UCJ0uU@4Uhb~=J@0}hJ{lq2AxR0tb=z;7!C_y4L-CTkLMyw72)k~>f zFLQ*59N;5(zcyF){MuCzFnbZIl}#u5*BtPJhcmy~P%S@#@qZ1m*(KM8+(7DQMJWB( zmBC&P9v2gQ@*-YSS-(GXphT25mRrAjFA>X-kjaV8#V0o%00~V25$)C`PYnqhU>ZP( zkd83)pVz)NRn#P~_kl*shN^T3s>%WcFa8r0Oi-h@jv? z2-%Q7%ipO4OK6)r_rY1ubQ&ZJ3 z*_jI)7|587)k*@A%nrGjnOtQN(JRWtuQmmf?qwSR2C@uAh5bL2itC3q4@NsWKiWLl zgl6-kn@4OOK9@1KIVm|G4dai4i>I;2ICxtDfjzc5!jDCGC{>8{2Oem#v#jbdiz?ar zVIURZdov@Y%9??I!pwWl|PS*1 z6}upT77R_F zd*5LPS<2Lhq@o^<@yjcgi8|SgZ_r^9pJ4pJ6CGUw#0qZQ&` z2jFN{k2C|4wzN4>Zh~TkE#O`7tTZ+aCMY@5q$G5U`jBg74{|2_yYL>xN+ZOOsg$zk z07fXm#?qDFpwd1&RI5Tf?G^Jj7W!|2=WU3EM5e);rzg{N z#;+Es;wV$#tIZBpcn&eQ4GAlGIYMq@ro#8)+CWcZR~WbGWcZ?lKS$<@BSEJA60gX} zx$+X77bf{1=XhOgNP^;wvoh-{-KCaGv;?@I+y-B-=h;lbh%L0@4{v5#3gbE*S@n|Q zyHb6bFOK4r7q&AEFfq>ol3hQVOuM(k1938)E}cbdZ>o!XQ%g2bBO&YEsBGV51Fp4F zTtczL!yZx-aU34_Z#RQ+~ zA6_)X4daFQ^H)2aGk4Q8DV$ilXX$f()^mIKtgUr;Xc_v8GKjE6SpRLd#SS*6W+oPe z!m~HKepMSE6)V0>oqu%JX4kJpoi?6_dvd=PRUTRhzX{tg}nFm!P!L>~DaOe4(|9jc;`!(wnf(8qGD}w?*aPej)G2yA^ zU{=odnHwI=%0LpSIHOb~TY9o8l8I6#EInKGO_9bUSqnmDi!*#dQ+w4;Voziysvhah z)ms$9qr=<9T5furklV83?D{Z+hO6x!;|Q3pc2sDX+AT^eHQ?ECp|w~7ycfzbNTdS- zY`Ike2dBq%I4-+Hdu7Ccvt7Hxu1Mxxu}mB0VxI$&qF%qdr#=+`{~pz3(&?(Ea~;go z*|~?*u^oFV;yv^+4^+9fw8#{oaeWSp+QcM+cvv{)eHu!OA5&!<))%E8>*L1Z6gZBa zmYL;cox#5FExLo7N}GyIY5OvvZeH2}pjLwALUYWG(LxeZJCdTVs*L7?z+sP^3Tb7+ z8k(!dX#_oo?!mlD=QT(;7jDq9GR}n$ahN9PQ)+7CcigKSRVjaLmpWwNuDprb(-I*n zUEQmZpgv`Id(;m#FJ)neFHl6ZvwAQ|0t~^cs}sD6+L-(hn|QTRmD}UoCSa8gXd^<6 zU!9{oieV7|jX`TyCo7RS=u`ClFc);94@SvBR+<}Th0a2B-w=&i2HM-9G@aq3< zf`QSn?CR8x9|F#`pGiR$bOq{@&?bRLlRSAeGixxLoYX6^l!0B#N|UmOMIx&)cI!oQ z$8bf+qpg-T5@^;udk{vogCai1HXIO0EIYnQZS9;)U%D-EC_{qPw_+ltFq|>Y;WhLU zYbiD!ZrlSX8V{@ubGi!;+%R&$rtR63v6mDNxg>=?Y;m#?aBX7$omzOHM4+%f zvs2UyXE;#B%<<%`3}3l~w_mx0g{&8*ltngHxaV%I4mMePNv!M8oy3yz_63efwTx$H zIC2MXkKA$Nba(0zrs%TL5J`&q;Cv^uaGC3V50Bo(4tAjj6D*l@!})&p>!8 zc_&s1lY^7@w`AUYQ$pv&lNu)W^4yN zXrXX$y-ISV?h~PwwK$^uHS&Z}=Y)!J+ED0_H$kItb{;7@Jx|!s(Rq#GXUT(4-cURf zVC0PZhPm^xi))3bk;A@x`~+ToP=!soBjO|uz&C~!+yYW-xo54yT{y=jNr&yiZ|8pE zraomwCUyjYN-^8*OFNlwj3jCi)5W6zWxuJpv@SxXkK6p4n`g0VmwDITS8e_)h=}v0Jtnq&zcx$>Lo7GD5GwD85pIFq4@u zf-dW{6QbA_t&dRI{e+^quHs%FWZVj_Y!b6eRy3caQtr!qekUtlh;KT3ZA|mNB?MsB z8UonVJ{O9Cso=t-b$38#s)cU_85f;AmjymT?M#VSXOE?bB{8=39-$Mry`sS&JfXR9 z%y13~(V$KI^_^ z(#5vRPJNm>rz0Bs8HV%@u1j}S`WhVuhJJT~$H!S0`o#_(Zc+}8Q)CkUEaFX*@qQ_N zgY4K!{d#>ZBb4;Vy#U#iZZc_vkTeXuBYZB|1xkp11mm01Pr}?`JBZ8Q6`0s&w6zGV z0rCPimgQDPnoh1Vra#@;#v>(Tb#CvlI_0qg8(Y?%ErLAnS%BQQi<97CXPxB%fM8=M zDkYP#o9&Lu=30zT()s+j4t+?m(j}eogDH4H(ZtlDU7VV)u!;vEE4*;5wE77trMgRm z7qXO);~CRC`2o*<*L{(96X$uy>BDknfS^Vkw}G(+N)vprVGz}==Cqm*kt{4yJbG=S zG#3F2(h?6^_=KDxlv@O#P_I;k&nN3!s0{hjl+j0SDGjbq_);kZOm0d~di{RhhYTd8 za~erZ7jyuL5%}2G#R?RnVxsVKEWcCWln-sgstR;OyNBS;y3l=tLJVJ%ryP4}hqgvC zUxUDG4aPKn+1Q5Xmb^&M_aa;FMNMdI1FR*ZlvMP9zAV}g+GX(6bj9ds*ur$NsAewlI8utg za#$}yZm#*nGROP$Q6p#hjmQ_z1ga0&I`>SVE$5bj1qTPFKxWZnxif({!QgT`14uB% z19=vgIhDxl@0+n|;(mD(ort7sG@{_#RO(ejOsA)bt7N_Wn$V%yshV$9(c(@o{{9;@IN%uv$>ROjOIlxAC=i z`QowS&ph^2CP#f!>FMpqi(%p9(b?lw{t1@01-qxRerf*Jy7G3&Mg zIpTVx76N>8q9$nOoUc|*ub6%GsXiWvS@r1IKXX%BViWAm7hhbJPAz2e*Pmp|SQ{yT zsFpNohi!A5pU@6h&6QdK0m5tsC?_MNS>{Y)0gFLn*1RS-6+iG+gV z4Qkeyl#_;u9Qy@G#0$EZ>?!N@<0m+9^m{mp0w&?0O3;q81C%Qq4}v9eBe>DkIwvN8 z6q-6To#SN|JEu8>X6%E`Dd&99>35>3~@hs-#24amooytF^omy+h&<9wlTBX_luM_F|x|@i+Y_rcwLsVt;hNZQuvDF^?5et2TOQ1>@ zg$Fh1XD6&yJS5u{6h&<7(K$Do?bucY77cjJ_Rg8Iwe4q!0b0?9HH^aEcT3ex3HkAC z3I(XLdaG|q{!2y?#9Ejk898!@u82Gzf@b zZw;Ta=XQnm9{*XQ&1jA#njJ)ny6{=97_&Mh0XewR`XveyFEGF2;>p%({<9yKr>j?Y zS0}4iXRE>R71<2O_5cq{!_dVEAH#p34Kc8yo47Q~*R|Rl)dv0%HSpiWrK1n}%8k8w z7;9vLMalT|O)i{F>z7zpc?Rgj1&SKz9*tT4n@Q%kD3Qv&jA$6sTzE&Dl(f9 z$kj#Fnp@x`IDqlY0QPGh6oO9vn9oiU<@~D*my+w3 z>ZhyYjKl(<<5SNk3NIx?@4G-LhvqnTNde6Yuh8O#iV1Nxeh_(`-m8Q5 zw=ycRt%(^il>TF^Z0NiwqVoJoMb4GbbL-SLc&Xf1fJBn73)mxjMpuo!w2=t<4+!QS~tnB zWxoM!pdUToRaYddE5HSWUrg@my_!uONCH0z&)8QPV;8SUw!so(el_L5m0~=i;M8u4 zn@v>%HQG4(OC|s}Vb}3WxSw(k1VqP%tiehOI)hITc0Q40BMVTNFbxFoIyS*;N4e5C zYD!3yJ5-%QOxZmB}}m41){DvDp# z3=`5jsaJf!=35~X)6L#)`lVEqmA>xNn%CKsSZ^`&Mvl0|`sJMRE0dcLAH2hw107hy za!;rFkZi7zlW8?e_`?cRaSUBdPMwg+r0xbRN|NSE5Kb!)3vbE1Mm#4IWOR`kl|BPu z`jQW?!^-%CqpjacdYz>BwU%3{1yzNEx0H6<0 z99J!_4D-XI%-{fdHYPwe{^>4@48dyS!LRHxtbkBd244B%stkkEjw~TVj|CwvYbx2D z^qTL3O(@&Z6uXue6Xt?ok^u^lYl$6^U~>&Bnyg_=XMjz6dx6q`W14bRwW4-!vf9A7 zG@l1+OS=$u@Q>J*@SiRc7=wt{^W`d$m=5a`SA1|CJ+6*Tg${dMnR1uTuhd6g$x-uZ z<488^E7ep~wzv^4Y7GWFWLXt72_6=Mc$QgC=f;JjloSeirC{E1yD{F%YTFcV^`*-z z>~u6lc6nPDk1!)d{Zh{hCDQXdruk1^)K z3z(8MLgwcAx7(_{U?O5$&fx0t|9$v^w=e7WeSV#$*#sRxz(%xFn%{;u1!TS53I&t6DL0@|9A zcB_&UO(wu-VyJ^#IRq`u; zd?Eex!;*HZk`Mj)g_Km7YK>dFRmq>AOuMC6BzsC?ih~ z+xH6zVS&dOLih3adkJ%(UMqYME+V9bl#lZFxg`8PsRPl%xO?9Nj4TtF6YF~Ks6|2X z+igCcWZss(<`WV^`iEf7n@zF_ioW?Oj6Tw1j|81Ffx!5-kFqd}42WKQpMmKxh51s@ zv}{tlEA#<4pIV^0riO!3SbA7QgvMZOs6`F~!0*X^0?B_)SkVeB#>g#t=t_ez4~^eY zA$hQG!Lz4@&##~t1hlMqHpLv`b^v21W*E~kQ8sYY4oeTxwXp-jgud=WAUsE|d-R>p z>Tmtf11;l>c63whsHdaos$$k10eRbx9+o9GK6uhsadMvMQe2awNvX8-<{i|a8TOy(d9p|$UGC6>xjLTrs)e!*9A8rnVxS9qDyPL2N z@HX^QzQ+uNMriw%lD@^%AdMH#L5R*CIJ*U9Kp+;x)H@BLwXYZ{=OUQ=Ty zR?uXK+g3)OVg=1gfGY4AlPfCW1fY>-?Ym=e;Aq+SEwZF^b-q)87oB^PBPFSFmON2# zEL*&8%|2QcNw1oiD#%FZV&Qz&A1LLH#&h z5wXf5Rv?6IDq=ql73qkc%%lqug3EnGOpL@i-gRA%rkcBQ+Cr*X4dR>P;|)UChcz`j zb;M`0ui?v6Y0a#U{Q1Y<^H)eyhO_NFB(5%GBUW19+-m?PvLAFz9U1Kd!&$hA7%;tY+*O9v`yC%0NxRgBQ< zqS+1ct6)=y`08fG2b4wkarV_rSMGOAu;r;M8H%C-?p%N=p zKh@IPV)g|j0L`pCSY7N_H`TAUYS#FRfn^Q6L+LX_}9VjPY0uIMSg(6fF zJN`TBc>6RRgGXae{KI!t2Q_W6n(nZgZkLvAYARHdyhJjKlDQ(9M~Zh?P7paeUDaFw zenNnciy3eqG=0L1iN*~zN)t;Xx5N7tU)=~wQ*i(o(2@3SJE>QWFee`tjlOaueGdxM z>lwPjWoK1rpzBaOem1FPt{#-oyme{|oi(oDTX*0HBsw02&SZ<^*IRSK>k{x#|(svDd>SbxgMmDobvPN^n511Ib}A8?ePl&+fX z=@^7Y=d_YDDGAcZz3mvE3d#-&!}dAc}U$(=O-+AgD4SZ#!Z7*W=;xM<$IR%j7` zT7<{Gl7!29Z_{EY%m;H=G*ZPTFSQB^Eyjxz+zncDu>yg4?Q%6#2dEB+(IN9frQJDpZ|uax{O7$dwmTlRSo|Y*7aZfvr zaN4-w%({Z;+re>K>C;^S=!98tQ^$Eiav>LKMklH)4iA;z8LN>pvDhcOd^b<;r9}m9 zeFtQjnb%9;8$|mO39F>bc=-$qYoJJAecrQLdr~r(#+rFfwfSXX;GHlnL+H`*bDq(I%5X?(?AP@APSe&dX7p;V?BA z&C|^*b3G#Ix2be%5YySGEZNP$c&AD{Nw1>YJ&hQ%%SLLTjXOL>=(hBBo*FD!&4jxH z1YXWld=)^_7a);k!at+!JIMaH@J1eZ8KI+QB4Dr?AqmlkdkWeIf)q83i01`!N`PH> zkqUB}(c_goy(hAZ;2J=pEVR{(ot?4JcgG88if{b3bXQCQ5mqhdh^7O=5NkZh_k5i} z!K(vic&2%vq9^MNkak!Dq#eKjd94Y^I;;V*4l+R6RMQ4%5Jv*O;8T$V5GI48B0JES zSjC{=UL4W%lhuyqR{VVrm>~x|E>`h%ia3SEmnNh5Q^b)x{v35J%bybKkJK;E;Y+;~ zKdPkhV>5(6wA0Hw;yaBlfuwLpMwei^mNx_-kOwgArJz%|IY}C1Pnu-Z8k1ooa>{7W z>d`nTyR>Hmju@?+n#9^WS;=+AjX)mdhSdVbM(_yo>LxU9&Z-?C)o~4k!@4HhQaY)O zk{sXeAm!7NvFa1ppM&Pf7j|#Q=(s^4pe9pG(5rR(yEmKFErR5eMwBA z{RwO~nC8O8dlR97{G!}7D77Z|jlgo&r62Vy+wmsK`7jQPocOEbPqWd!wJvZ*kk+n0 z?kgB)$8_M6A=C0V8QZFC%)0~qnVVYV(%z-D)4CSkXk3GZbdUT1mMhU^mrOEyhk$ktPnnjpSmA zY^WySAHrE0rr9`Wjt)I4pEMU24Fh;W%vBFkjaTx(^SX%ajWDwW_(|#FF7%Gst!Gs^ z5QAseV`6foIg+&k;5?iUy#7wGlKn01CMu;EPl2VmpUp z@o>hV_FNkfvPLjyNP`4jAe1g5oxp&KHO=U^44OQ?7nSSx1}4O_F^yB5V;S!pzJ;U$ z8&ExRfp^FZ0bLhXJ-h~HUh9b2OYU`0DiE@SV?F3x3SaVCnfvsAL)uwX6KZlYnw+FVnhU#q>)rAsggfmZq#N{bOCZmwp zL%I6Sa6L*UvWQGgj%8^@9?`1Fb0Gx71AxIZN+&82PMS*21{fSJM=UeL|1JnRLf31v zU>Oz$C__+_>--36a=aLVnmm6e1T~es90X-i#W1`T`eW=`f!O5+_?*4TgH1rQspv5Q-Qi!|`6{X?RF^gd#?d4SGhoMo}~93FKm70yBVd zrHP0lri_=du|q?y8L5`dS_^>f0KMSSyMqVT9hj zyTm^bHUT2-Yp~bzFJ|$ona$LeV$(HyeNGDdan8!aF0QjuTIZy+&q?VlNl6L7FhIM= z5S#t!6G(x85X3*AG9hn3-K10js{(OdOlB6Cy%2GOmej~imXk8PJC6?F&H3a%ufz*f zc!48jROX)_n!0~A(P4oRdEZzu7&FUS0vZDhhqJ5!+S&-9ylcivM1n~%WCzBO41v>* zOsavTQ@XD?^jx*Cn#g2ozZvKQwNLx!jI^fhv&L7WMX5`eY=kQ4)M!5?TcjD)PwIh= zuyxC>Y{whE{Alw)+sEq+X4W*N&8(@rX66(%$;45&W!;3Q(m7NkDpTLziE5Sc!`#*y z8}JP-3>^j}#b;WNF%{_~y{ge>TO%Vk!n@XubFGq^_6=}ni%4to92EMYT|)V@5Xs9C zLB;z9osE#v2!Aol1mOgC3S=Q!{XHLaOWAWc3Nc5PHO!VBaJ7A=OKq)*Ws|`O9UV}k z1|m5mmIu965ut)hA2N>Mh!jwGK10lCD2J3Omt{eW&@Aoka4?E9MvO1+&G@nqunV-X zfDp>ZW@;u9Pcd^e=*&reKFdma(e%jjTCpCV4H@I^MX+&>?dwOzs&bu$#JZk_*&4UN z0R791U*#F4-dPkFXhmk4=q0|xYCLxV057FPjL4U|&;;~c%j&`r)VM;<9fdVeHWHg$ z=SNVJV+ntrg`g(S-w8oYC1(TtHAT>QjFY7tRA+iIQB`MYIH<9!mT*v==K@96c{u0- z06rgr&Jo?llgp2+mk^Hs2SxWN9&oIG^`d(q%!=Z&#DjhI#iS4Vl-1w|ea8Q~VdLcw zO}I9T6&|armCk6m;k=POuJgoEO6+iPN@HzHd7N^1f^rCEwP4xn8RscmeBQv7Y#d#o zQv0YO+Z*2Z;nv&^BG?rV&Xkg6obVJ8wER&aYgFm?=|s+Yt!AwqVV%^uRj<{ThG#AN z4T@gJ@}H1J%UTYz2*DE8;FS}2Iq)hdckl$^HPrxhsiw8~%0{zhWJRZIjmu7WVG%` zw2_t@Svz3!saj=SFm{ELKUF9DpGaTBW>seU)<>=6>xGPeS`k??ws5iV6P|g15P!iT z1NtCX%pnT~31$FG%l3+mvrPUY!nF7$bk((_wuQ76o;Tp>mF~<798S|G{^%3rj#1LX ziU&kz-v5A9Lwl{7DO4lLu|{R&?>sfqN&{`mG^XLPhNURBEKAWzV=A?@981Zm zIof&6IVll&jMzgfEFQB4UYy5TVjMX%Fyf;s}o= zqJ~<389YYkMtH0>%42l1<}s>Xmd8jp9)l${jv5|=`3C$W(nszZ5`4&VqVqCLHznl9aM9d8MByHtUJTZ)?aN56g+kep$gE|67N*rHL?%p$T(IiYDVBb9 z;YNd59b`%NQmS$L=K0;QV|p)r>A+!e`Y!g;oL=9566CMg7bnKbC4XMNTi-A1#pONY zD>Z<$kZ4qcDYf*A%Z&S$zUR&jg6rnO78#8J0WO?16L;P0`t?M&8M$ zJSreiE!oV1duN|219DL)DLDD@T^6D3!k;(X)uAc3SNP+OE(>>T%G8I8*lYp;c?8!t zOj8RyRJ@Upm=r>VJK-?y{SCP>;iIV^BW=KJ5N~6D^x6Y&FghJR+K5!hq^x;5D%&X z-n#t28Hi7i_N{@DDif+4c23GLdFF#d6f7sI3S{ zM7{dZ#j;fzoF;Hpl}r!gVKYd~z=_pHV^i9`HYH-U^e!F3tv)Q<2)s4`HFm?ez{0Si z^`tRh7fqWk9dUy6;1!!kP= z)JY!0(fcSew37+`kX@n^N2C<>Cz2iXwQUJAf-VUiFwW2x(BxQaUxlrWnsi{)5Lad8 zF`UmX1uSON(6wkdn^Ds|(M@rPn7AZf5kZDg)4YV+F`TJT<^?vlz89!5MN}m^K@~@% z#$r&+aF(q^j}TcVoOG=J62sX=!q3XerLX8MJ6eoYK>!0o zdC+*IvuhLpB4+BzGcSrTC3#hocB~;SQOZk6Yuvjm?>Op+Ja2`N-vfT2`J!fgMlXgt zbQ3SgRk%OgZ5mw!)(M@AQyswyJNf*kY3;>L2>we;5SJ9+oPanEeBcuQ!LRAuigx1C zG|!k)(64I}Rn5)oU46qRSYis{m__kh8QYIItw}XRB@T!edd!0T9PSOOW>_?^1m}+hoy&H@^k%cd!pErQvt@D3X1i zF%9Fpj03^s!5U`C`)k*)0cn@zuA(2%G(d_uu=6|M@rm=Z77~RG|W> z+j?-Jwb@xF+W86&(meT%qXPCd6+v{~t4`)>g`s{&%u(Gnv$BtVPD zQY@y+i2MmPcp3E5HLb3WW}R98x5bo}hAHXiYrMPEwMurEx}aJpgtR3;^6AK-RA z9qiNT$D$c~VGz&y)mgY_P{7z4`Xsm2$9WZ^I42SHb#^b+%*^}IYHY(6`L)}J%Z#;u zsDDr!*)t0Rw@Ek!2V0dQ=vFWbNBZor(yva>J!4h?)o2?Nmo2avV#C@am%_lc*@zwk zNqttC-P!qNeAQFi@r!R1e(MnB!Jd*s#4>6_S)WZ!V4>O17B?ah$6Rcq45seKKWHLX zVd1Jp=k)e&GNx}Hib~SV2A+H8y5gg?Re4eiIhd6;sd_?GIL`69N!Vn^i_3C&0*Upw zOA!(DC<~8k14i7-rmVdib*v7cbF2>M$J7Qmcu1~HcopGEImyLjZK{JzG&0Dpt#{|C za-Bg=BMSQkHZ>O~3it&sQ;Nl%5>c&&^mOHUtCFn*cy!5t)X4+LFv)&ZjTok=o~V{d zS;lz>mXG0pVuwnU79*Xs4PGB}aaxdEAB~rkF35bL1xay6Iwm;+(TqXaB+5t@O%E&| za@2+(K)E`v$fXnLR9JeA&#BynwpwzAmNIK0*Yc{~+729TROl3WWwt3FgtfLpQk+N= zAa#MyNON=cKVFQ@uNXsUXE~o~>|)!Swn%8l&Rg?^*7I{KMpm|Z_DTYR*j1_nnG>tVA#&&%HevYO4-*4#XK(Zv9aw3|!eui)Oh%+CrshjzJWR&yLNb2-eH`a? zAsIhPMu2+7aT687|NkB*ve&|mP zCC~ko>0G{&4Tr!xJ;@LuW;E~NnD^KRE+FrFV&22UyquQjmaX=_|7IY3pvjt|dk#u9 zFPr;^V(y2Ax$W5DW%GVG=H-Y44KB`_pk^lf)QuBt*%Ch)ODqmcaGKw;Ie+&lgZhbK z&X=nDQ?bO8!xHDKd-4Cax}U0Zju<}8zBAb*Z&`*!kHwOwo05EuaoK$TG3I-I083Av zuCodLi7y%)NB?b&zwRJcc<5*=oO&wh;XiqAEBwvh=WDN|8ed=#N8#MjP5D1fyi3frp2vU~p)vlFT@^TYZMLRR31qfuRypk}I85cKlHVg=R z<&DaVNZt}{OS?2pD|AV*A(`ti4y~fSv@F|lf+X~suBC0%hTh_C7;}IBbMEVY-O~dC zgrJgQ6P$iu_v74i&(}To+%aQYJBeY?>L1I42a@cTCto2CgK>5aGi#NtAzJ0zc*D?{aegoE95HF8yuwSa zz#*bx&j%QGWcw+6I(pP@&{!}Iv3PCXLro#bdnk!6Lxjt~dssBtE5@B9AjcRLTgyCt z8j9rmg<<&vu%I!g+(Bl}sLXH;z&0mwL!xbv@CR;D688u4O%6fpGZe{Bq*%xG&Yvce z44`t|EM^AfAe^Zq?B%0O^ZDC$d0+gBGp_vN|IDtHY+*&8>%CfJ<_jK7>ZL+I6{Ex{ zpO&XABSxk#55yeY!q$P2ynYnwqIEK?+(g6t(QD%m>pm6|bGFQkc9pThdZYxp(o@OkZopB8XstsdiSv9jFuU>2nPt zo9tnQDr8en-f*NzCM@80&Pw?M?=7Z815;?Vqx{`!MpwF`&VqhYE`(B9VOx%9CprBh!RYU+=bh%8!!pJ)vR2HfCQm$kA*yXI1i|2%N3 zmU3Z_I61#`&apU`vZ+-ATOeD-bR?B8{t~9t6LyjX80KjXqGM)-t`HJ!n4|vC?nq%$ zz0+Z-G~-F`w6Uy>Ea9StwgZ6le?If0rhKWR3VBWw$_%9$<@klgt$Kn+bb$f2=T)_M zOX4rn{$ajR+Jg7sJnxWS@}{w)7En3{a(0$w2TqAI{8=KVlJlvp|K&%s)q5IcozheR zRP=6I1zw;AvfR<#OZQ43NW5u>Rh=iK0d2qVX8G&|6>dAIzQ11hdng>^%?iiaA!n8~ z)0YHgz8LbcJk=q6M!A{bT%(28m2a?fC`4mB%AI!KfLtP$H0o7T>7x`{eJ7V3+gXH}?CISLi=cApVR z)uq&RhX%*UMLUj6x=A_n@*B0@%E#oS_V);)LC%TT`Z0avv`#yoNPE(^U}+hAaoOJf zhs~^cV(KFxXqqlC6lE*d0rbpCo|)n}W>0N@YWdTYKXv@6b+R`NCC=QOB!6|_PL|GB zH&fM3E8f7ZoaF!su}W1-ZfW|j$#_M2lH!Fjq#Rcy@p}9b+P39(j(=6oTf+2%A%Em2 z3pT?IQ$M_DrJZDV)kIQE4m<#rJ>bXqQ2gY6h|v232$#d?EHqd7)T)*4y>xw^XcNDntY_lRfiMHTC6O1eS^vI7#7=B&;>E&6w= z=$v9Njn0E%=2YmAdjI}Y%%HHX#>t}fpketG=|~YeCm%c+x~D`0`mlb}N&kHEBuUwx zfxq`Ia=x`GoCb5~SJ94XQD-KRP^S^ht%SWr?L(U{XXJ!yx7)vvX8DO!?4T~-JV>a^ z`I)0bA*q0l)AiKNNxFXMWYHX;vROkV7)@8%!8qn74&S8m#S|gwC&9~d9u4%YI6$J; z)idIr_RqFtlw>Bk%t*!9!|Sp7h;RVZI}^sg_9$7&V-_Eg$a=&?u&*6|q#Ap)xTKwl zHf2Adkb!O*lvbe{6Ecd9)S6`}eYBlQPQRn@=UAFKK$eQNdZ@W-nDUH(|r&rS+T zT-C3I&b9jY@ksqBMG!M+|9($Z)ql_*tNIW7W7YmU{IRP4o&H$W|1N*5>VLOCR_$lC zv+D1R)PJX^s_K8AKUVd>-yf^`*Zi@n{{#M5)qmU{tNK6Wk5&C2=8^iD5*up&dg{q4 z0Pc&=6$FZ+u6Myt#ODfz2jX)D#HsjPLGj7>T!HZ^Jp%_ba6@pMj_E2mel|W=a6A;B zD^z?gK38x&9G@#V9*xfx9FN843XU)788{Y3;5ZZ0Rd75WpDQ??h|d)qPsZm8j;G>t z1;^9zxq^dr8$)FI4Lt(~!P>a42XuMX-VYe_nfP46@oapq;CL=RS8$w*&lMcc$L9); z7vgh;j~CPSBh8!>DL#^ZIAahXPkS_*dJ-_$$z3#z?U{R+O>hDE*{t9_hReR5na+3k zFl2A9qp!Q!vy96w_U-1fgN<}2Z5~je0a=)xYp_3z6mFOcGrn(2*y4tGf>7o3M}{E< zF)SEE60Qdm`|s>c)y=O7Z&BTTkvld=XQKS5=2g(g$tU5K1P}v@Y(x5hEGIszQW5Phs_m>}gA@8MN3*8Nr1yjb| zH)5A;<)BDLQXDSXOT$*qkui@0oJZO1TBa#NCkO&imTo;ux5~mc;L1YjNb?FFhBR6; z#c3CUl7-&Yo+O|X*e1bV4aXIJ0bc0UKtOvGlds;MT3fL0)E1ij@__Z*!cN%MmX_Kw zRc{N9GH*VDcVks3gb#>GrQyF0n&S@(SOWwbHfLlLQL$l)#c60z zZhjAnl9@bYFo`8q8i33GB>~7~TW3I-FdI zpf(T74vv=l1{1d`u|c~g#+bl--%uQ*JB|rpFY_SRgN##PVMkHfi}b;v#kJK(db0?W z^}fNnB^s=TO^_T-);3maXg?TcPuXZkCTrIW)M9R6vXUaSWwK(}&V}x%$@&kA_P}^- z*NjK{R|Z;3XAeSB!m!!0Tilif%?xjA!-DEKLw#uTrLtu|%$Pi<=b=Fx_-fFr-MYD~ zHz7`4GblkcTv@v2P=zA>{ArLgvHJPY{QO4|FJa~L_kVU(10=;24!vc@&k7lg?Ap&v zj<-$KB-50pi4~b*PZ=ys9EIAA6=&Md%r_FRP zm>bw4+K0u`#Co^;Q>=H{pJKgrdsNfh^?K{ps1E3?*E`M@)zO{xdJpV5xEy>>PL_F86&plb)_}s(caE9k0 z+jCF0E)#?TBBJk$kFmu=hNo@gVu@oPKM)hg%7?5@_Oys!V>gerIrn6zhwJ~@;rfRR zPTT&fUjM_x^$%H_d$J*8ld~h07$a!N*5n+?;rbsRu7AkTWCNM_HMak$_!!$iWM%HL zy^e(0*#5CLCJSs;1}103|1DVOh$qT)?j6yF!<^%wtz7HFhPJ4t6D%R^P5<8&aB6vn#ib+Lf}@ zDEx86A8JejPlgUU7;iROUFL#0grqTA@~I*t?V_9sRt@IS3MK)6QDx{baVGFOwnZRci;S)KyRI>AhDvps8eG<&mFfZ?kj zYK!JC`lffQ-WGPXc5{_|W|U>PEf^4Hd2cjVZ40@+xr$?ns9`&IcBpN!N$IG$N}`J} zSA|{%7AR{!)l`9@DBI0ys++5S)XrhUkES0MBkc3}9^*GAd$=By5d%`EVY4D9LLPfi z?kywI!^MQEAz6Z#-MrruR2#CcuKxkcVDj? zB$eZPO3}M?IZjx9HLl6UI_~{tmCj`O!b=MKFIz;x>zpmtzcHOv;J?&iz(HKtJ`RiX zf+b`2Iowt>7O@~R=IgNM8q!?6s6MZ!`p$>UKa}0$<<<8PD~1^>Zzr#(WRja@MM$Ep zY&f9)xEe?6=YPDI2{VCh@Tu&_irIA@XFvLpV)mW1uu;B7KINGM>HX};4KfK=o&DQ< zkWlc6UA}!-o2X`*Nm*}5CUGSeEX)wj%caSjId8De#7~urPYWqVk!m}Hj6M@ph?9(w(*#GIzd_lfl zbN)Ox{@Q2H{I}cxbbO8MR&)N`Ukv}^zx&be|F`3Fv$M_lbHDc&j{WQZ@}b|}m|KCG zCek4qoCcMOG+9eTqzhSwVLrb&&6c$~!HuxBmBO5D`+QR3R~v-*&wiwwgqd4E!?%BFD~;%_hd15ve^3I zURzVKl>w&63%Q`lC{}bZ?K1b^$(#Q}L;8@BB zfuoI;snQ0?tO&+}Yiof{v?>!}`rzi`rF?w!_@&VM!r_|=n z_q^@1%A@pq>7dl@n^XNRvrP7FmBA#Ioqfu)j=I7YafO&ulN-U&*$tfzgP@DzmM z>oC?GDctS{DO^scHLR?s6k4GLugGR-Iiqxr7lHB3!pmtD>xj8CRw=-oN>iD2Jj{!Tv~sB} zs^ktj(xst8_GE0Lx3nT>D$IFUkJK`O8YneLkNGI~axM4XP z*veN?dC=MnE*tcnt=rSe!+(cvBBwOqC#(=9Ls#kmh*)Zq5-3g-gwCT1QIM_Rk{6{> zel=4OJzpJwXBOSp~bviQY=?FD2rRa$MHH#9%0#ee$%hM4`!AWTnQnCj_ zh*J~XrPh?cbja-$>AL@6+nRZUML42Zrzl~dm|iLW>Ay|yGIBj)nJ|TpEEFwkp6 zv~o-8M@%d6X=PVHm7_Y=FFGeoAfg(wQPNGw`YR0ry{J@++Myw~-UB1c+0z+Q9_VlF z8LwEX&}2_Oibc@KdI29(cB-V(GEE0{BB#W(lK{^_#4aSxYT4l2giL&u56#E}I%Rn^ z0fo`N+aGsX5w@&G1@_`7E3^Oy%KkpvW*udq`u53b@O>ORrYW_y7Jcz@iP0X%TM0Oc zYep-&#cJ=(k}f_9hFJqdYZ2p!w=K)W%H@=D@a9ucTFYf^>#h-)o~c?sC8K&bBxg&kJoc0FcKM0uYMwrTHV~Z94^{gxess_1 zwRBm-_?1<%VRDVMh{ClZQ_Fd~gz1bm0I7K66Es)9@huu3AFbW`u|rW~=V~ zWK}{-$HI!J%x8oSB)oICz^9YL6wF9MBQ5$CnlkiPG(~+R+a)9g4N?qoQ@+_4_t20q z2mO{Qq(P@@B50Csf-HJdLW}6cCi#xr0j{9ZN?8(Zn2vicEv66SnuFY!YYH=cI$@Dh zv{R?dNUi9*ZS)Nb&| z+$Sc|u19O8h-XxDrr_k;M^~(QD+U-Bg?GP4~vBcr>xSotW>2N4PePv{?pie!ps*PR}Jl4NX zq!Ze3d;$O?kt(2?_G<70i5rnNok%AuA|3xR66yGlkx2Q7EUXtBiIhNqs-rkgUiflw zDqxT;!?~>MkjhvcqS9=?;nCYUEmqaG3^Y|dDGeCo!Yx37_M=oPl2l-Q6~q8-65~#C z))$Db_yLFUCxx`=vNF3@6k#dQxse8q5ObxFRY?ks$EFX-0gNUVf#8{S$~5|>MhMCb z3JWE`ycapxu)zqZ-Tq25owY4#)VVBoi=B(@8T->acd|U8&ed)#u%Id{rqt1B2|;U; zXiA-|c4PbkFkd-b3iy&rneMe=;29|DC(L% zEY`y|Mylu{=Awt;I`#0hjEwT(6AVVMk!5Dk#dEQX z!75{MwY%zP3rSPIW6n#BsGnt*b4^!Fq^yK3nieJ(gZ!$TCdnXR5vy`d=y7T#aLovD zv0sx(Mie|Vn$!2XRljs17dJy1F28%?c;- z%peWZg%vJvDA<7pTtu9xF>;E15NSsy)~8B8OcpKjI&^9UrSL)oE)oYsahBfeHEkP# zm^0^B%qXgZYVgAs-VC~Gwdl}s7~X~seu!z|M>3CVMxDT{Y8?*la=HL;az=5nT}qi6 z5W&C&7QP9e4(AA9QGi!MxZVDl@d9#UL_mTh@sRc`=}nq|9MR&k7NdYP5u%-?h{msQ z>%3D@*CrpMfE=GQvJW2z3fbgt6p&M5pDAMm!nnjfH32y;7d@0n0z{_EHV8=VOh8j5 z6&O>9J1qExT#9djzLA|-T){LDf$ZHT>1S#<$~;LKy+vGxrb)ag>Y`xt7MQ4$M1p|^ z{qs9oI0I}$dWwf7^t0%=*W#n}R^ih=eyVP6bC8-@@Ihr$MsAcd$U#kG^;=GEDL968 z?5Asp8_mZ6RmA|j6@&Sw$5#KbqepxrUJ0RfBv*u;g|8hkUPbic7Etdvo@Fe$DhB_N zxV(udeNmcrb1+I%FIM&Ge~gsI$AMC{ zVk740uGP3_eWWx8#{@OG1iXusm#JF0Jj8L+UB)2?ajObjOnT`m&N5u0X%nWNF zIL%EHgI(xHodPt@pTL=&0M!HzDT+dBCQJfd*s|(!dsY&9)1uKhDtdOPOsfwO;(hJt z9aNF5iF-92&05q^Q0oTLv3T_6V8;I$tT$2HLApRJWTu~_6+;BkPiDPlA+)wBo<^9g zS6<_r9I3K>JM7*FvB~p+uLH2%vlyqMp37LQ3XczX8oL{^P^D)jHJpD{7Z6aID#TDR zf#Oot#0kS^`!b_1)MpPC`r{^R6P!z#OS*tx3_D>Td^K?@=stHFlmUvQ{7dNvRV9H* zT?@wLWvhCr*pKR^*0)+pGxN})Zh4fqfrU@%r8>5gB$> z_hj&b`WYfKMSM=*D#IWbO~{)GpP@U-^oX$=LLcl%mbO1VXI>@u7*)SQ^>W!C-})v)M2HY{Mf}Rzukb5ju@S%OVxGrAg)YbHWR^~U9wYp(!n zxZVtFX&-BL_f5r`VdDa@X4w6Xz#8sH!?SIsLWi|>S;~H=f zPpfHn;{Rr_Hb%`9=J_-*b<4ztrIndtp(%-(DFjED-$?7qCb$LU&Yhz02n zJ{-vOFolsz3>|g`pUs*ixY`~X`pgWIJxo|e`E*c1CHs&trNl6%aG#u(I$l_PW6ixG zRF0Y>AV>!Iw+Dg@Cl!JWCl!L4LkO~2hs%Ya5z7Mv;j_4m2r@ds^4v&4Mq5J^WH_l1 zG(*EH;E7Ys%&< zHiRHU-33w5`Rls?1dUh;AV}k<%ZH%vG%eO{|Jo!jBC|You#^AO<38Lo`6??5l8VUS zy-@~Kq|(3$aPQHyi~yoh%ssKWMPM`a1B(Dxk*zd0i@iu3TMm%`GuHo z7qb%mGO!3HXTqk(I=CX+F+4-!9ZRh`iv&sq2j;%g6vNPa zR;S6Wwc&j}=6#&_QgUq1+WgmaG39X~BNbDv%+r2`z6U;f#U?lNIu4nk?e>pP<5#XY zx4MdhU&iamY{`h%(R{n32po784R5fQM%#VQZQs|k(W|I#==*twJ$@I~wowxsh(|nr zmlxvtSpEVb9ze1wt%nUJhclYJ@6Y0mK72TtRGpmVr;| z0wMmE$%=OS%PD)`O75MtN$PU_jDcJ&=;P=EizPF4A@)+-Fk6uSbh@SxZ`S|qMcI}A z+kaI(auP;T{`Sv(w0d6F^A!+?OJ8AHkdd#fah)^B1`BZ$aN>w}_$*fp}hSuv6oZso7W{7;(-Jo&SY!ZJd zb66hF;zTb%Sipa2LdRfth)ituL5M|DAR>f==tBV0?2sEE(LG$OL61519G&(?u+0Oe z70!0PpSnhk@wH=V`Jy*h+?JL1y_V%5`o_#=d0!8+idN@-lmiGQ#j89z+OUu$ZAZgG zp(C5r3#`&z7Cve`Z3i`yzEyU#NjV=kmZP_vl%w^-RIcr2Y~4HoF{&;rXKc5ca#l-z zva*AqpkfcSqh2legjlujX0R}y^}s1oI$Df^xGZsKig#0T;0tWcd79&i72+*W_}`+u zbMh*K3f1~`3|4k?_587z;4d*33|v~|@!?Jw=jk;^am3Ja_6?BH@T@9Gk1DC6oJ>eH z4Zp7>h($fTytwuwzI`_jyF@fP?Y-ns(Ols>Y$3ts=Cvo5=hU9&hn`qtRr|Iu^wb%8 zTIxSV27^omaIK|gI*rn&GZwtRVL@){d%G)*7-WH(8BU9< z#Ic008H4c^$-ycl<$5s~qrDj7LCcS&mI-jrg~$oUYsS?oc!3q64kx)kbqy?6W~H&= zW9cF;cB{LfPoECL%#(qLIfaxejF#uKp=_>cl?eAwS&DB2*1QlEF86sy&2g}*;B3MI zSneV1b2r6&WR0;>iQWS3KKz26;czINQRTYb5HCR?Xfq<&xS+LW{8{5S+b2)k_?N_x zKxO2$0Gk(Wi{YYe%bS+f2<@<>vrX1q79&m^WN62k~NskCnX(uh65!MwOSWajoX+l4iv-_@LRT=0L`P_F6 zP(N@Dfvd2#0_aWLS!BI<-Lw?HuAm4=>JY`GU9(I%Xpm>v0jnD4GV;7b$PFEB?v|GK zUju(wF%^0va#gP?%UP^TH)O`T7LRfdfhbj1zd#m>2QcxpD+mGV`+JYARBSB%6G8BVGefdyI z5ZHteL8!VT;*&r~$gLS{rb0wuS@{#*Z&`W2H}CDLa>Dk*6N!c95yK`6!>larcigTH!EK(jC!;31-l`{szM%jPTIr{Qaoj;&xo2k$|jtRa)R(; zf4y2ZLGUn9b08re$6~hEh3+g)^cb9{1sU*9wZua2dC6Lf>LnrCsDzpRqn>BN*8Z~v zblOB00pF`d*Qk3=(gCS1a4Bq7WwQ9MpNVf1wK99I4rqYRvDmMiH5zBzms8NVP7Qd(T4NKcV8Q4q#I&>&pVQEZzdrMum2x zdP=j^b;z(E5e-VFWcNsT#4c=9UD)V%VO1z(UX@kYBC3iLsLY^%iSN5+zy=VUvw`fP zWs-;oMDn_K1`zGo6Vxx6V2Dv9-Rp(KmA0-> z12|_6ibE0kTyb~N;CP9IHE^V7fvV*Jp8Ky+)MJN3CpyY)Fg|cik+bd(I*03M1zeY} zm7KleFh`kcZFJZLx!7G>ZyPczTdzeHk%|choVglELdC1RmT%V)uUDzRs$Sq^Q_gw< zYn&@XR58ks-zV8cz=-`65SzrQ!`xg`469V7RVN1LMp#!1&{y1+35x+OlLQ`#tA>uW z&X(nZIxmxPl9$=EGR`mr0FR1_)>rnSy` z0zPB>HDXNAr_3g+{Ak2V}$Y@1a=!WE@*1CbjBU0B>Q z6H)fuVI!u1q%+QQGwdJhYP;WJ+rcFM_jdr1jE4o9Z_56Ld~ewHnV8`0O{a@(!<+wZ zt8Mcfq}6ojda467w514_5Hs+E9k-neDvrE}7IV{-Y5N*v z5+-)R4}&=t=}M^UjefT4N1K2o?H`OzCkCnJghAr@l6lN6O+(pRSv@*asFCdt<|$B8 zot`7fN3$5G3?GEkZvVfT3Wf(>vK4WbDlcJB4K4e75b=5e&bJ||q`839@ztu?UR%wm zHr3ovZAx3OnZ4gGq)czjpva-dj1;+{F^0Dy71A|stMG$Jmsg-=D|GCuK}(Ma+T5Te zOC8z^E?Ni}6sg{&NOOz`it`rP{wIxopGKG=L_;l`(-~@CVaY>y7?k<-OD)q@+G7~J zRa%|%g(Q5-w0hK*cS)I5kMFX_T#ulHz(p zu(JJFGuy$o{6W2Xzc+Le7TY7}vdb^aehGeA@C%!YDhcaxbxE9ZDKycd1|8wp%^;`YZ) zGO3MwyZYIppIQCP=%=F}Os<^?+v4m@*zaR!!bUP$^UqIHKbX*3^Un|RlIc>ZJ*p(C zQBH~ae}&bF!M#$>vFym$?u@x@-Q#KUlF?%q>9Y{>aqXoGXs`4PEovO{YeIYNMJu+~ z9y)3-`hoUI-$_$2lB2OKm{Vi<*`l9W{jkzUjpYY#r5ei*Td}LL{1Aqq#`43mAvKmC zD+M!EjpcD;W35g+pH*Wa6q|$5DQrb1!WtW_}`TElh@yRb;jw<_ULSMwX~94EhWPBU+y zM1la`x7hcBnL;1kIRta=P3@anT5G6Aaw6d{1k(-y(Z(Qm#4Oqjf?yj>+DK3b21e-| z#lRR0qcE*j z_tz*XcJlxNJL_^_d*YT71cYf_1d=Dz&6)wias|myKElW1U!GT-k!^rtETUlqsdxub zWDp_J%nAMlh?088b4A~*IinE=+74yomJNl;6=#L3CX;PXq@j%lk{i<6{4J4mfAr0l zbUJR7#;UnyynhqKTxqdqmr&#rDXyJ5RtFrDZIH_sC6+cT^-^#U$$_!yw^Hf-!KISI z^XEfydYq*f--bgw_22mbzm$HoqHW7>|GIdR4vS=wiYo9E=e_Fst>_5s{$G9V;YbPdkQfA%KVk)-58Xa zGu=KJed2OvIuVagW@-cx!yk@b1`{mmI&fF=ey%5^4djiWG4#FEgic84WLS6WANASiiV!}Gd#*3$Th3TKvJs|_lP*fN1Fte01DXx4P+&@-GkFf68r;vo zQk}7^Sr`qZOfrm7L1uCdr2bI7FQ$2>3;*phYZNP&+ii+erTl~0VXRg<&K@Iq%-hZ z8EI=S<6kkw!ZbyhC{@E^4J{N>RUavD^L8!bA7nn=+DO?DPI*R+#(CE+zs%jY&>Rl7 zF38jf7-ZXQA7RR`W^Fhx->C`vM);bZ>I*aGh1u*h`?ec-Kmb4<2cqD>bzFZtUJg+k!ccNo0qA=OLlxxv0x2TdBvctHS+ ziv58BeQ5$3b%;Wtf|iTQzFHf&Ahq6n!;t18l^argL~nhw8ep4tnQ0^!Jf!5Cc407_ zO;#8ceCE|@ZVMWivl%uU`r>87eB{vJ5!}42X<+V5o<5Iz z?2>waXY#w3ujsE`uA<+Re*HW}e;excH>H2@odxh%8GC6|e4#~RvR{au>?+|K-(r>U z=ieDBVfK=Q!q}er@@44#XD&mTe{mVg{CAh3%s;*iWnP9NFGm+$1~C8gFS72`*{`is z@+3<)jcgesrjV-)3pB!!6(83hj5nrjH;-Ze1-ThC406lEa^WVf+%SXOTHZj#}{sRr=iwuqMmD)JrWwv6`~- zH{6S4QMD{{)6xWjs`%G9!m+}8vUdtT9CO+6?oOQFx%<`4^+qfQ>2Osm%fxLY=M<{$ z0b4SmedL3H)q1p|WzJFpo8-8)v!a*@V+RlXP;%4RzSVwa2kCudc6o?czGhy&n2~b; zbj%bp70O)T7guZnKSZPuBchM2j0r^Vnxj|ca%PZfVSQS4kOY9C9J z9OQGik8vAdsy%|K$v&nOXJ(jU+-sO>ZiXpZH(BjAO(cS$i$ex*&70aPImwB&FwiYNRs5rJNEBeH zg_H87L`Qi|x))NID~N+-aRgc(R6dAvdx{wJCPm;E{rAc35F0++dy8g| zvogdcWHwR{qpq?*5~|f1ZJbSf2XQZZ_-&GXT`qGqx@qy0kOSwREE*NzT($&+m-AVZ zxDCK1gOH<@P95zS@S&r472$w{GmI8}m5kajsRN{#GSp(`;YG;e7E0;vY*QMnwM<#y zIB>z)-r>SHU!H3DM@UK%atQ8&tgWX76nrqG?Y}|yWk$EQGH`Ss2Hn5fNoV3F1C4@^ zp@S9&>$I%7Hc=(lR)NGrQLIU@>8`QH8)j%fXb09+6gqBgIxH{Vg$$UMV5Va^+hJ3D zoVCLWo&vcF+wgWnRT3ct4N#Z1q(@4Psqo>A(jTVYq(TIaMhE4(l!#U!8&?Y+Km3E1 z0n#0Hv7Wty^b+^!-6GxF7keFp`(QjiOAGEn5k;|jSezsgW!vZ%>D|aK=^LiO;|f%* zBwTE5x2NM1|?t52{lD|s1D1mY@sxOVIuXQ&vq%|VY^S(eA)}ug& z@d2*Mf+(pzW^gbKXt<{3QGciy>n78~;FrWYc-B!uzqB}maSVX#V|r(@+=jp#lX%yt zbVE}i7bRK;BJd2y*bU1aZFax}UM2i8A|47wub31?$VlW6&P@*n0{b+3ji5>BFw;G$ zpIjmds6$n(kvfl5+S;^8)DZGsTBrgFXtJ+>d`7IVfMQ03q-Et|x<@}#`e7f44w4u! zh%i?gIEhFE%GG9a;t^?UQxm9HKj;tYnHJa(<~!+2Vj8ueXacE@7>1r8AS@Tr@Pz3r z<0i_c*CzLIzPIesD?Pxvl)DmkNp+zh00_aVQ zpdM*JZ)k@By|Gr*>5WB*GPPhS@M1E@>1_h@fjw9+ir$WWThJRboKlf~QqHFI76~nq z9Wknr-t5>O=naGDg6OSAW0-dY`?Sp(C_1_owv>MrS9_<4Dy%e+eVSWc1<{48N<1+Xw;(JUANj)g7-v}OJ139vsJ{b(zE(NEu=Lm8q^!O zaqtGrGto|e?ON%fwDi4IOricH8bz5{wY*DQR_KP3!qi%Uyx}NM9r4Q*Fmz#+iI9`u zfi>(OW~@OgkLIxDgBt6aB;v!f7aO{wqY`fDcht;h&jSX*DS>UDi z)94uHSdXIKLenGLgc4dMp)L{2FTuqx%({x^S(_*x3=Z&=$E`QLE&%W$_wCI2}v z_~%n|Yp{mo`_0Uf(Y&vRDv`$D7uXysH?69XfJrs%HCbcG750XRHn-OLt(F~J33r4% zTCS9{yDDe*C6puEPrFbEo<{o$gcvdk&xG3mx~#STah7rF{YP1Wx(hkH-6ek<&L(Qn zJQ+-pBi)v>jx+mA&0e~rw?iPhkDTchYNPOi1+J+pUF%`wYyAI{iZ8$d`hjV zB;IMmwMNhC24%_4griWcK*@Ct>Ru;_Yah!0kZ_NhC(s^$O9(1IXPIR82Mm~Nvmn>t zwb?SNgUbT3eI&j%+jV`;0?jaaeU5Dunp!E#sDYKTUPAW=;9E=;qB?HiU~uqp7G;uZ zNiHH@yF>z~{JfSyZ?N&oFXD$vyEl;HY^tn36wee-Y$$SqsI6?j8o5?zt1xcfU*;aZ zr3c&fO5uiVuYQKilnwiO;sLvStENnCLFc76csJxZT6H<7E@)ol)@U zSAn`Sc`KL}YQTG{)kbgCOOAc_r`=FWSHrhhCS@GLFjKx_`WRVBNAjY5tfx)=7%t0d zv@JA``+>C!m^tF4oyne=lq78dh~=R8cgEN(crCM|#3vG~=qKmtO?uR%5CGkn?v~l@ zEwb9Z?l7s^z3$MWcIq~7=aifA40;LPIa(&ST9a@BR8PCxpH2G+YY1ULCDsnMyF zf(@L>W4F}aB9odYCY{L#^B=q&7L3R37?y-;>%~p`oR6t~0<9l~m zuiJ`6ovv&lw%Us`-w)&%q-N@Q=O7QkMpnYfkcM@0qUJ#%HsFIgm^skaIFxR^4q7ZRn|P*uDA z=J6jK29OyM24?wxX>KxU-w%g*S>FO;&-M#y$lX}oY1w<(05gJ);(C5=3a}t(b%1u6 z+@Yu4kfLwWXUwWx7fpo&G=OkJ!~#RccKiB1B1RM6swQvEFlMr(EY1QEL9Jq8%~{`wPQq({Uyf5X-rjE9VQMAwR`+}xA}v+PMK5<@{;_-|j(sUh zu>IZ9zLP@?ZdFDo-2JK9+cX7{!jTV%XvavSWKj5Zn*SlHOg!Ho^m0?He z*ek|B-Pt0E9YwoiN2yuY=B*y(BgwZX2Mn~u_74-%4CPQ{D*hIocCz?}XFAq_pfYA2 zA*qg<*C~>A_clxuz$pZM~y?zp~-%MlM|T4XM9bYPEWQ)~_xEcjFqyI;u7KdmiE)h1uk&RJQwCwx?DU8 zp|p98>2g}tO=~MLhRJHtZZ0hKSYJQ$$Zy?vJ<4MmFoXyDy3a2M0Yf=q)Dz{e|7Fs> zO?F3l+OS<{+fje77>gX}cQVRiVNeQJz0En5Ot;E}Qa37Of098LNTr+EUK?*5Uq1Hn z`&z3<%XO|FI_gs- zhLInxU7L|M}$l(sO?n=OAc>20axmp6GE ziLmT7E1_izI!`!p4;{Ma;FkqftP_cEYF6&3s? zI6rWoxSUDf=Y&@z$FsU3NS@&;#Aw~>JdJ+FD1irUwnY)zY-?UJ#rI}W4Z~@TOQVgc+r|yhX1yI7$=#FVam6xm2X+0b$4M^YO2iPnlgYI?d`rAjxgPJ4ofKBI%3tI~ zl3bt6@$pxlOqP>mPx5N}SbmrI`}tHFQ;EF_f%v$A_^g8{pIVz?;ij+|KLQ)QF4!i^ zr~0r>mQVCyqj!g3WAnB0?2}3PX)~}4fL}6+^RI1QrVbHW*)}5;IS%8csvF9af}JcM zuGRfet?twPx>XpYHo(hd4KMmw$BR81>Io;?6bhqP!vIVn>3I69Rqr&*Pky!Poo4w! zyr&0M5g2&*s}%<9O&tUFBG#QFuw^vqnuz^${SX(F@BI&)K0{1Na=%{Jo*&Tj-~R`v z9(wBZmp?vnee%hX=hI$Hjv-}?ru|YLGif18z~W-W2GN*^KZHag5euS^EF$g|7Xu;g zHaPMpVnJt= zqOoW?Vzzrdq8zyQWN0}GI_Lt6$zEx^E|-`}h6||@op$z0EBrAkBtBV1RT_vl2L?_A)SAh=lI=`nS+x?KJ1 znw5^F3%swj2>wMOO=Ae_gx}(3V=vUG;MYljDeBS>%p z_wZ?`YZx7WGg|jly>)PwHSQt_3<#w<`YnqjXe|Syuax-Q^K#*=TFO?izN`14aBH>< zju^MZp!n5tT466Bux1KfWu}l6)wC?Nx>2N*Sa>q69jb#EJv_>?=5iFpYv<24a|R`7 z3GJPN2}0F`i@9W&s2EJK6{g6PkPTckE~}^RlTMYHz3YCL?{w0a6f7 zko&4rFoLQZvaPaa$eLxmnVqTn>a-x6a{su0p*h=hO(AhDxJHC7APF|9MR{)qeQAl4 zPD|MUKW0;<7S&sRg?z|~y!;p3fWOVl`5h7@CUI*8Ra(Xhw{lGAj~3Gx`VkyEJ^*gs z08W%&c_qK{(&g7^fqm-9OP61k04_HSAiw%3xX*lV%#w_$&E?6(!R{y;s;MteDP81epYR5!-eEmQ{t+|&~i#xZy|TB zx6mm4-a_Hqw>AItzQLFGn1m4NTfPYOhP-@N-uStmA8)NjA&d^+4i*^+GmZz+Gl6kz zXt295LoVEvY%Z+Nk_ayXDW4de@5%1ZJqylEk}zmorl+~PLL*(fBZ85=tmQcuj$oEA z=>r`7L_j($a}*5N=-v%ckZt=dxJ#I^!vn)d{JMNx?%wTgx(PxSPT1=rmm{WKPTmVl zvc-i2y=nJa(i-s_o=JHSIKt|s$)ix*L^+K|2DfpUqDCldq!^R7%{K9cExei#6l1Z_ z>i*DG<>N0V<*&b(=y63iw=epme&4=0p$l`8-5;`1FGJR8gJS?A;RlQWtgEz7E;E#9 znH$*QnV2_reP*v`mYn)$dC;i1xyqI402ST++e+^q9-+G{OGixM+AWq8@*wvO zu?|b0EoS)4@t0FO6Wkc(!trw9o?^C44i_z*MMe>LGy?an0cNEE)gMK(nC#=vJTSxf z!$WMS9)tX1a#b`WPvdB!3blA-R`6W4y5DQKEG9<~W#b3$&QU~lh7iTsTvh@HZ*iqx z!gLKyv!wzuaH~PZIsRqMOrV@ZX`*X+SEQUEE^Bj`+L3%W=vk!ypu$Em)0+S@Y}Bra zFXpq>zkW)B(sfV(KMAGh<&GPY8zJW0JW9Ll?nZn|yP=&w#jBSSWa5rJ((FubEO%(z zsB{06O(L_8+?*uqH--E<43w+FRXiohf4#`%i32}X=z93TdmH<2PWFH5mazDfkcM8} z92Udk$p_oT;wO~h9i#}05AhKQ*VjKJFEWx(;K^sox;7}uNRo;XRr(+=@Ce|c54d!_ z15S{Qc_izAuYdYw0arU}fL}ELoHh#Zs|J8ywJG4*Fxsp&*^5F48;#VGAb0w|wO8KJ)kJ*ajy)EF+Y+g1X z#5sq|lE*tb@xpB1iN`+N@`&Ta>mYJ<&o9Bq9o^}ORTlk>aq7i-g*i2^bMZ~O}rb?$vRI}#u z=44n-%i{E-J4qE-E9f;Y#VcEMSrm;z4Opb$cuwf2Ac4ljyLX7H2)$g>b9wm<-ye=w zOXVNEb_!x9R!gFU zDl9b0tqz{ar^FCVC6>5n%lw{G448^SlLNuCl&)^m4yy=lxD?BksTHQ}kZDLY-XPhY zmHK;{%~(0I6D5OmY6BtDeGVy?RccWjg4Ys|NR+;W+~uNH_->~i!hT6a5Dga zM>3H)$1NCe`SD_ICCuH`cT6A*?c<#6ph@2Epomu3!sG}KW;_RSG*Yh3Z01mUD8ap3u9`r3JB*;F^0W3mpp042ve0kPl|ZSUQe3Ok^pM zEIF`E6WpUc`?*wI%SyTVTNI7S%|dyT=26wfRhi1a4;7-p8IzGne@*@{gCN^P{vC6w zFtIgAmym8&OBA0K%ZRZ&71~@&!p;o zP}eGN(gHJxQWo%Rd6O=4*t&L^))Q-9^wcTggbm0*k`2>@>m4&9W^JcXov~~4>Qt+4 zv3BGVfg>3$9+SG^uq%NJVs3<48;wE0M|)S$P21@7Hk0r+rQR?RqRD?IzbBz5n{qQt z{^hDt!@b2VXlhOtPbXX{5qJfEu{0w*n`MPU`W?Y!fI6Z(t^vpl3HAokSmk2`0&gs> zz#27z*a{kI_d9SFE7nXf|Bei{m(RbCCj(Z^wJUVq7KGg36>fGw>-a4)`s*1wwtv&A%NXHNI z-KOr@=FAtxXJ|o|uoEZ`bK)KbR;EAC%L0%%Ll7uhD3$nw+6{7qi1og-UDZkGEm2Nu zj7UIEAj^{xsbC5Mja_cnGECEo4@v*JUdQ!qf&tZmAU8;`y;)^x-HSep{8gy~gxbzA zD%f16CW~~Ga6B>`9cE{w_L~(OWz*KitQS<60s0IE8L`fG79Q8~F+X>e($O|#+ zdQVy0R@8y<7#hnM8q-v1!7DuDz#@qs%-URBTcc#Eb9i8*x^r~21i_$IG?@q@pd2YL zv(XMnmy5Z)QBF1^(0#JQ^YU#VqC;#@{KPB)C4i@rX$om4x=R4YX79uCSTSE?BLwB+ zMiT&VuVVDED<1z7ltKT2sNHx^ecvlkXqW^04tf%l-nuAFl$STfkK=)HT)zx3^-hiG zPz|e9>@zVlH533y1j!2@g8~o;I8gKEC@}OB18;4x270D3rTJj*yrnr-nPf4L5~ z^{Un61&*o77O{&i{ET|FL1k_q2>nLY+Z8!RTtv&%*l4%%*mq~6XT(N8LU@A)BBCX1 zIOD!AVOl9QBdPM$LjqDvt@_;%7Vmas1Q{qh!DHsiWGM<0JP5gi_>$x*laMg9psXWd4ehmsF z9;oseMn5e>Av{lKAe9|IrW5w9mGf(Q5ex9Kq>aqAC~RU4^O_jKwca)^EaksaX%C`Z z0se4fe-8HDj6~g9r&$HE%a!oil=Ie)%P?=<6)Y>&wC1KcXu8`IakUK<($#a;;|x=U z6w}nU0k!&Cwk?Y(*7GGNN8D0tq= z&`_+nVg;L4V^V=c--#dqEX>*qzFkp!tEsaqlXwS{j(xn|=FzP-B2MI{ROMY9ph zjtS8YUHV5&r;i*>TW=VwdM9vc?b9u~yU*fDiQ{j4f(3$zszU<8<3hOG|0noTN3Wn!KQT`k# zS6O$^DmsqdwrT!YEtCBE_#VH!&l+T1cW$$CaNSh1?8>>@b5;QN>(aN8!ZBQ349V5O z5d12CdMxeVy5TvmAa`Wn0xMHxtGdK{pd!G^=KH#6X}8f*U@IUYHA=9YC{u}A`Ig=O zKiH%3y~$hyWC@R=?R^0jAaFScA$vKLiuX@mB$OoBn_eV#Ic6;$oh1on+8U&QL=ZZ?03ipJ7`^-`_U7+ZE?HTZzs8> zoAg#@xEyxePH;bAkHIpNCnoZIdGHscnmR06H^;cUQhst2ZiN*(dIu;JwTFdf?O z4KS=#lGfHiFGTfe5=W?)z7WQBb>^tV%qB>wfC({qMHJr83W+4#068{diVyS!&SMSBoX{c^_+z&%l|Ve|JYjmaB3Dd zGprBg5D(X9)7v0Tmiq*#nVD8?>WS`>)YW<~ZHoqO7kolGW1!HojEuS3F?O8+#rCL5A0+(y8 zKxf5{%G3LX1~i_l=UM2|Oc>C%jW+F`VfWD>7KyN6WzqTbT{g#?==Wcis> zg-sM%nmQh7HaWTsSm>dCVaN_!k$f&KkTm)-z$DxU5vkM$83rgUY4LLQbxLc@HO(X7 z_51OOwUn9Iy!gjvp=qb+!j{4GtzOSdonHL4{ar)BDaPnS@S~RB?J14NFD>PZ8C`)w`w}duGx54?c zSqGtUZ5FQ2m-UW)IIhow-*pw0V(-{}gWV_e)k0Jz5Ixd6dszt!$!HBc8&yo0=s4Up z@qV~q-C&s5k=*RD90;7fYW)Q5we^Kt+TA;n_UF^ISzp9he)+}ZO$j?ez+dG*f6*2; zWD+tdb#!loyb}Jg*%w?3Eob}r#K4`=qNQNDWueY5->6}*2+~dmRJGP1k1K<*{(caCf&~iujHARA%k@G=hx?Pilv!ZI4G(2zv8 z=TdFd(2$PO7Q<|8tASf@0t8KzlW!*?N-(Os7_*g@UN+jHEm_Sp4AZS55xYL0Q-C#Qk^)U@4e>zEFmBAmQcBa7vX z!`<;x&y(=f^h)>5>aoC!1zxbun&(jH+1$ZcZ2+#v4uo6K@HLT}Dg+B*u9fs&Ka_#_ z_4B5F6A0RE1+m(iMyO=5J=fqF^AAuHLVU5!keAo3-d0_f7lbYuE6^IHc3EwYTg`&uo0=J&NkX}`92t=(U3!h1oU;n(WtTK!zD*#+v6e%~X- zh1}suTF+Y5V`vD-Vj3XU1>m{J0m*)Wi@ohizhu?pMwl`vSTu_W1l_X43XOeFH&#}{ zENdUp@J5-msigZ_fA|Z8nP;mKdz;2t||iGtmb#ufXiv0pLg4eLIdPbgWlQ zc_|W5U34(9|IXbMmPO;obucY3O#es512^C_gfMn@~X5?RM!NSmbtJYSkCE4>3T34V;5 zL-RU(biE^G+S^5|uFUsym))$!^bUdBL*e>xB}`FVw^F>>TUIpQ7~A&9$-uqBd-65E zXL{Q1dxuDy4_c6YIK2A0?AcHpw4#5QMjZTN_h9dKy?!Qe8*UGW`1augKl#PepAl30 zhltnZGpD-;+a$l)$>V@uZvR5>u&^P@I1&z@>fIXN9DysZzNg6OoDUXnF`5UNAVg3d zFWzu>U_klZGzbLr;$XOiOo8TFn;GeP?y$tG)$j&Z zcOc*>BOHA2)XCmKjPYYmXUr zGHSakm`K^e4xvGn6;}x$;syIiN8&M^1@^!?Q85&cv=l0!(9B~%9Du1<5`A+WMIJfS%LAY6`M$A!Ma$q<17jVk!?!Y z*=r!?GKgu4NQ*iy9#TWq7yzBS9cO@}T~kbM0ZSu_3+ps!vt{B60fA%%mrK==x==@Y zya$p(grfG> zxwwbaG|IXHVh^}NP=*qS2!w#cw&7I{HIwtDNw$=o1;lg(qpo-(dEY30 z!N4C#UD470Rk0P#l|NRlRkPmWl8~uOjnPYC{gdZ#41R zhpX7phYV&j-Q?KO#96UZtz_$wV2vu?vI0x<8M0o2pKt!@dw=Sqy@O#wbOxuC&ZJ^!Jf7t$9ZGgr8Htv zo7Y>96o*eDyrEPfE*$LLnmf$^xHY9Qf;v!oP%Vd!8fwN#J%9##c(AxF96lLtqq`N- zb_?o+zxNi6Me4W>@x{%pd}Q>osHhZ@-D>^_hfk`HZac&_E0X|}OCEz%AlEE^*0V@^ zo^1bJHXmSTAnddPVFAz%Zyj2vc}R7Y!Vng0yewr-kH#%{V-u$~W|}3@r|vU@P9)E$QKy*xGgGLy{*csNU`PsD5JZymvuaf&sA8Tz30Q);$UJPkP!7Nl)fik`@5O1z^N z++%>v3f<2AS7YdaVz|rsjII)~PleZNXTnCf7SCN|h(2vz?H&3mBTFO(p<(oh6^f&4 zL7fTxQG)gAG&4XMN@fM*xJ1ARR3NnpYTiFGpi(?aqQfbt$~rmja{k7sRIA!dNU@F8 zN^chvELZv`N%{q|@A@Dg63Agj6ObVSZ9O4NO08@Fpo!R%*w7K~X-a%Ka1TO`{Jpox zMUEME05|jxbschLB`%R=s4Yq$mxc#MF@ThqU|Bzd0u3-ZOM)>KH^w&d%yEAO{XA(F z$MD(deJZrVRjkh&$&-0K@uYfK`)8-WhjbI+d;579Kx!a`uM8aBZmzm zc==v!edTHG3?kC{hxk&sHt8Hw_c|(5hz66;5O@PEGO3sg)@TdRj>70y@GN>UGfTF5 zWoBW)+i=1awhWET$`{zyF&(kXDz33Ml_=oQh$tJBhU=nVnvgC-L#17zE+7!xQo7w| zjMYbi5L3^rx%Vn_J-r5n5fn;10(;YB$+!ullD$>qV!hTUn3h4fG7Spml^-hdVy%Vt z1Qxu#JVCI>E+RRl%jg85F6l3_I#I+^(>UwsDqH~SsyQ06Mc68na6@+q0kod3(x_s> zTnp$bp_RnQQs``woHv?s3Hy*!d&#FE)U9o-a zs$gq9Yg-asHOY@FVbaiDILGes=pN|4%ohXTq0~IoYlJU)Q=25t!L6NlSQz(6 z0bb1ce+QMYJDu=WD_xF8Q42A+1ujD%Bu1lOx6zT;C@AsYR|f^eC{DaW9h*&L{P-H7 z48)4pCb+nB3b}Bvx=n-E3SlxpB*WP|65o=1FNF+Vp^a3yruqOBTc8%`mW~<_@q`nx z0#O=uB35*VhI4BbMK9qQo#Kc(1O-n=p1fNQzLD11vC9XJCCPiol zdh=?+tjLz{SJZ@fuR)otXhtgF@p4o1TApxBf(zQ#S+Uh4;UKo zUSe5dkRUCDul^9rTX|(j$2J4$)UU0!(DKlsi;@o5Vh>b9H<+A~1V*MDFS^*iZC&#KuWs?*URW2zEF(b)jT6h#;6d^Ey#fU_K z(f;#hI&VVa>-dVO6bA3YhFs>03>HHSGttG|Of`+OO}F>M>FP$3bu2W2s5dQ9Yr_nh zn4rgU{!G9;pUUgx!xqGh8*t3-ux5U*3nuR1?=|>uvfNEv2*fjc+oJIoGy=O*R5#4` z=JNgKN8hG;Z5sx19!7L`R|wBnif`Ntv0wpnRghe$C`P8KVNq0+w>Ifwb z2c|73ub9G6T~PHT=j$pRBbu95(YuSLWNDLabJ2?gx--8cvl@rFYGLkqKH8pJdzN5p zF~Ov1uCbf?)hL@eTIPE_X)z^!=Yc74-N}TP>1pag)zGlm49$!%d0}8gn7jop8qu9c z#b}gkk)Ie|^pcw@cG8!a%c;wef=G+7YUGd}i{R1lp%2++AM$Y@;#hA+78R>*<@In) zIcUFHU)4;#8nH;!7V<}65XsO)lIv*4J`>Q+y&`*c-}9+3%WA3>)(&cebuzKAM4z5B z`R>6Jn6jEwWUR^DXqhl?8hK;(?J7tVPC1Yk(VcSgcc5O1WK>>N#AGF|qW(-O^Qaoq zIS6Gc&k$9k?FQl^*Nh7nEMli77Axk(9j`=CgfhmxLIWRfv-pZsc zxKJ|9@KEvibj+SzP`c|cFx_5HH@?2#RfvXQc`jocXs#UG24Hj?49%UIK~FK3)ju_7 zZ;{PrS(?aR$}!!cBJ=ud0wlfNI=Gx=x?5?3=I%8kNEC+l8K>=z4LO!@GLU4qLL77> zuTUx`l+<_+lp#%IB)~XJlkMWibdcl@c(pHXxUKwD5~8?gloVi@w&XcgfA5X;IB5IK zj#QfcX3Ef7W+T`lr3iVyO_hSS{QFeSOPrufl2qhC+=B3BEoE6X5>CYtfYWIIQN#B< zXKXN`(WN2Ju?$A`7HJKI#)vs(r}scNj>61QNZKITBSJA0TI^8`g|u5JOlcZ7LLo@4 zP-wUWODbT1LQq;Ixq~i`2nTe=;L+7rq0q1&QP`+ZsCqylmwmRmyzU%@2+I)^D*Pax zBV_qOAxQXA1}|$9vYa8VE+9zn(N|}^r zDTq)xM(`sra1uu!2CLD5Af)v|fq4H#0D-jiO?KM9)Jx|SQ?!oTLu>p2`3dcj&-F}( zb-)#VHi0?dXJnW2v*Yv$Tg(7$90YqtB!{~ypGuA!?v;xY$ApOb*m7$55kl zd((VpX{^?7%=WZi8;T<6A0wuhA{J(hzzP*Z@}ptm zUaf@3OGY+oFe9J229|)1=4F|@^764c$V-@wlu(K}g3Wx0W3lzy6HJW$G%*R5+yQGx z@c|@d-L$2uB`gs(&7Vef- z$laumHw9x%AA{9ZSb;N5WN_pI-8ffjibm#uDeI z6m?P)Uy+zhgfbE0G_CXl@s$sYFP*z;y$w}}GSuC}oYp7rF`VWU-siMUNc>{NSZ-o$ z6He<>-YBOz$5))T3Bzv+#yF}Ir%ARLr$sSlh`goex@Ev|!Hf?dpCZ}7q8&v>OQeP- zcghkMYk_B?mW^>Q6JKpAOUAYEJ4vMZz+QY+c@iP%Ti`DQv-1|4K}qjG>if-XnZiwT zwd)>iFgaT|i$b%N=}~E73MA41OSt@YGFZl%EOkr8oNCf*YUo~$Vw0m`oVaI$j41B$ zeQgl;(|wwxn9DEjFN*OlA6ae+#w!;09+V@pG!G-uQ%05};-1+*BFl~;TKRmjHtdCv zgnAf~z{y)50pOIwl)_}mj+R@PVe1wMr~kjbF9D3Iy83^!WSu}3lCTOd1Azbu%w$iP zfea*&utPv`!7xcCWXNP@n3;rtRU<`3TNSKU)Yb)DaH*oLEo!EQMk__DxP4+>09RC0 z5VVR`{=akIeUk}`*8|VS#w}>FzUNQGccVp1EXFIGcf$=eg>vjXJBlh zftLH&0vFd_%s`9kniD#tiAI zt21oTj?F67i7|_>tj=IcMWtm6TFgZYT5N?uW0X`oT8dBoVoHb3L&o55IwQI5a zh8$46IJ$OLkF-LWdcs9>J}3kQ>~;l4@x#Ik4oaXi#?Uj4=6CdVxd1j}sF8*CV-on5 zUAPUSH5M#PEMd=E(rO9TJMhW^e_G6u{dVUfB)xJDojd7U*dnAZY@vZhLud_1qGrg$Ro7S5I#l^3GZk1+r2~mHl%V?zis&6wbAGxyUdWWT|x^o zY=#`g=4O1FMm?#b190(q|B36jyDf%BEk;OO*xfq17{@n5#s}ycjDQ-;V5BN+Mt1Zx zEk=%O!cJI(Egn02Xqgdovgp5>y0P);Ahs^Tr*7;^XGoN9E^wuU?xJelt~^Dm>mxYS zcLEnjj>MdG&s_AO7dHW??l3=rizD3@gNq)6eFcumdD)>@5Rj>T|JOf=-t1GZm1v*noqxyZL*oGJnSfQIw^~a0`*4ZKa2N zcd^MkOgm*`u=OTacSCvAupX=DgPBwJC6u0zUX=~o0oxk`D~IPZcu$V5?irmXM(x4bmMgkA9U;+=ZBmhWW3oQ7i$mu@ zn+$A?fo(D%3h%)IGqA*xJ=;jZ9HJO#5U-sp}U3#HzVCxW!usa z5-eVdrU*7Ppri8zOhHf?Qec!!8F5?y73^-S617!>OsA45p)tekI+DZ59|VMzB%xw0 zj9$>g;OR1AL8g2_2GOt0RERx+W7m1#o+LocQZ%43X*_#tcBa6rsq+7bk3IkJM`0c{wzWM#F8 z4M(8scykKv`RM7e6GCCznFeoI6{i>AQzNCD_|H0IT4yJzX>D(XZPsBli(;>0y3LDC z>0UtTO1MvlQIG_^?IDwoIxa|q7dy#^eGOYAZ3imbh$ubIGKVqI;Y?gOlMv1%hco@c znL*)<9L@|2XEMT>OqEeo%a%Z=TWB8CjgER!tg=3>C3lQRq^`!?$U16can8o3IBMuk4iW)#AZ%c0j71k$t@v?Flav zqcwHC5#O+igS*d2Z8E(Yg1PYN5?OJ@(OU`-6>l(L8VMkjL3|&)yaQSskMC*V;3Yn< zO7Cw-l5sl&LnR#4OUBwgI|~YT0*(;jAb}6PLn3`27^kc7$T~4}BCy%0qt!M%ore`r zbf0L}IH4;oywc&a7|q2s5br_s`h9VXLw+DfOL+ZaTnGhuiP9_7K4=+uPi zuwYseHDSd?mpH`5B?HM&$TZALSW%MEqu_v>oQTH*qwpp+Jjjpll^YYu!k`x-RX{su zMxPCS>3sev7|9_CM2jZl3V4AmvWJFIxd34tz0E70m4lT@PHMpP0N)M9 z!~3W(2_*?NAu#aSWEPIP?L?(A8I%m_M4C%=FecF<(atil4j_rL2rnAiJM@wXoDPPY z8d&PU5(zWW#}HOLDJTRV+f$*H(srvb2Fe1hHM36@FEO)mt7L+Rua>b z6j&~lg9<>e&_gJCg)fa9)so_#!QstTkbyGA= zkCOxC zga%+(VoZ^fP-94)sjRBG3=KTZKoCMHS#u;g8k12dOwVB)@Opf89E1BQP)f8f9NuL2 zar_c0L}ynJC=|mBAQ<>n=<#ohy{lo+oN#A1HSl# zuAD$4Aw1HW%Fa~CseZILtOh8L@5QJCMx!o(urW$5Fg$3N1XdJpR*2oIrEU|6;`uh3 zEa(6Tc6#Zs!T_*1{t5-^#xzlT!q`zUO&H_gO~N<>fY3Pk6xnQ~SI5AVVF5y`+B5dX zI3+yRY+%Y@Qn1Jo!zO7|O6*-nXmQY!*6OfPO)Uz&48~H&UjPb?0_b@GLV2T#R2XGo zsOu~Q>WmzLC01Qv0BjnJqMDvCguDB(Dedawkl zO0P^;5ygu$<+vUZrBkHGuNoBkry@#9d5RI01)?w^-PoF+-PDr=q9+(py+HH?BN{0X zJ;8`33q(&aqDFz}2}aZ;5Iw<&mIy>oFrwK4(G!g5FoEa^Ml@9*dV&#+7Kol;L}LY_ zCm7Lw0?`wU=x`(Zpd4Gxi==f?_5dF?t^HVeRu`MuW<55uXdyKk$A8%JAD%*E5wOFK z4>~DJlJq47yzAuTt?aU8T(+9r(;{O}YqlqMg^>U{1xw$#cvU5YM*GBy(Aeunv6vjb zUW=2BSlXvY141H&yic}3X?G!wK9z@+;z%qXV}ZD*1>_j)WvIs}mv*xxe=@`y7g|6j z@uIvZPydyjjfq0MM{cWkyPo{>_6JV6cxMaop3dT3B*c37J)OlnS%~*^ z7VkV|+qZK=@W-}&mB)on@y;N9m6JVw^#9GyGi^{}KUZu%_wd|R^=YMRr({c9?z$x| zG_L0-{k%fs=nVzR1DikNAu8Bdp>Yx(O;w+3MYq$l#`%QC35`3cPczpFjT0JoQZ?>u zp>aavPO8S03yl*RcTzR3SZJKkxRa`JnL^`)#+_7+8!R+VXxvHFxG_TGgvOmzjk5@i z6B>6?HEycVIH7STRpS;2jT0JoQZ=qlXq?cvld5q6p>aavPO8S?d*xkUmGOW2WZWq^ ztz;@^$Mon3iSnlHQ~rV&aZHas>D2QPmU^N1|E;M#mg1k9Rw&L4{*)73=Lz)}>hC{Q zuaN5e2Ei$ML)dho{zCoFT=gF>)L*FonXCRYh58HiKXcXp0-^pw{m)$W$MdUV$@9N^ zTu!X~uunt&cc0%oMN{7vq5i`9pSjk5g;0N?{%5ZGyM+1+^*?jf|3abuLjBKN^>+*P z7wUiJs{b#9`U~|xGu2;PLjUB1F`7$^Q8*me<1tTq>0o&;?WunK;nZFl_4S8ZgWM}a zE751tGNsmNqb}e}i;{J+eu*)H%BqvKa+Dlli*9H(>W<@0mUcljyu0)Q{&_?5?o*Z#lPlh&68)!5nqzyoP=4n`xk06YRlMM|*3sq# zyfY|DhPIy(f8r7~5^me1w5T{el>+x9B`#cgL^B{K1B!_3kdVHYWT3OkluMcp;k98I zp)EBa<2Wj`K~A)_YL^U?Hsm9?O znk?~T19|i_YUG%FtzOp1$@yr;edPF&TDd?wNOAQm0A}o7LIKN{rM6XL)Rgt2z@IdaiO{~C4hRY%}afp+l-K$NDpes_; z0=x)0f)dmrK^?n)P-D~JLy&-voM^qHu|y17L>KB9<8AY{`ftH5j!7ixy=Y(qz;vk#+twEk#4NI(&=~#tR~}X{Mz`(xn+f zk0i0^ZH5ZOLop?z))q;%Ln`1BB?*;4=onc?={VBnC{e6+)lVjFO`=wCzzSuiiFIp1D$h?R~5fFMe{x%CVd`6$a;Cc6a$L6I6dG`2F**=LKh}@yA;CFP?bzr&XNarL&ed zKJdseFXy+92o^4UXO^>-^U~XG(pe7{KE6if-?Ox6KJ_Qxsq&w-E!y53bI~K5FOjV| zTTGhIwy6GhSn{tpT)pIF&aW9B95?gD`~&ZDewQ}5r(^A^D-Upf!LZ=Wf#c_m(C<;| zE9F=RM7{Kfmy=aK-g?PDr{8>4I_KNdg4-uPK6rXQ=eLivKDKS>s z51zyMMMmqxYfEo^qn`6mX9sWGV18rUa?VRbgPR>!zwzwVoNpZ!loqtywf$Dr|FgEC z>qkxe;6cu3u43O|<@U(qpOFFLHkSnBerf=nI$Z;{0qm7}b(;^F#a9 z_!-vTnHk%E(0;7cchjKYZx63EoS)43T@k@=$KGGrk(i}ip5|NLZs7d({NSjW@7%ufVl})bxMApu_j9gO`7EnG za+`Pm9h|?bFnHDB${9~S%J~cOtmp6FuyXCwoZmd$`po8O{R97_#^2j^?a=r7*X-f^ z_P$o_fv9UPJjD5uRO^fn7G6{r_X(HpS8ZQ!zV@>fgE_w|#yVom)V;TwIKN|*bz$t} z)_+Xo{JsIfThqrss+-UGMFwl1>VsELUCjB_h1PrjXzFv@a?Z<%)@#OWsr=zroNte` zzWn|2`3<*me)GuSW=~$)2b(zW9c{hrz$;HRbZ}mJ!_qp!QhfL|m4Dx|;pOvgzI_kp z*JK74A25D6{X5RjPPMN2%QYjTdVi|a@71x^`>wjh^@7ZKZ<2M|fLWW@=Wu??4C_Zz z{*tn4GUp2mgT?25aE_E_ue3%~oa`BKiezGHc`V#JrfzMk{# z`k*fIPSd8lIq&Titn=LRmtC7VzowV<@Rn(720zdFJG55Mod-IcJ2~GnB)Ivk*U$gk zm#Tk4@Z(2q_Ke8AN_|QagS+Nl{`Rf?Ils$b{k-|kMng!*5|gLGiLu{&L<2A{&~ylt2SPw@U3MFf;k^W-q3g*_b(q1-1+pJb4oUF zUV6@Q!n$)W$LK&v9PfXGwZ`;e5kR&M!J(8TY{X=cgRt{F=yM+f`9h zv!Xsz>T^eo_0ZPn(Uk)^zbhqp`PIFmuN=$yjI*q|_1iw#I!O($v97)AySMr+;Jmcc za_5KDdzxLUzb-iQm;Xq8ua)yV;(}`y9bUTdM$We%v^gCyWoNwJ`Dg5j2{_vlRINx^2veGh4yJIcq7rokc?V8&z z+j1}G;|#&X*RIchBFOn&?QJ(Ky>9g1UQzhgcM^i14;|I;#>d=$(&rY|?@RlX@`Cwm z+j)lu7&u?@$2MKgclTLSIo~%axKcYVxjs$xf5PHwT{ixfOwOa}HIwrb1_aN$Jmr;5=W>3PHhAL@OYc|*=NIj_4EXokBOAP& zAC_Xx+UOho>P4KtI@FyOQ%IA6m9X)qNejp7SkI@RtYvzHR(%oR`+MnTm$o z`s{sb{KJ;-SAXkT@EGT}9%{SV^?up@cFwoIVM#6bjlTYQ&dU+j9Se(wj@!Zc?T6Y9 zy!1-;-uF2F;6Up=_wB5@`wPzR(Fgav{KLa_-*Mj5D>!ZF>lvn~{R-Z*_gl_CKWV$B z59g)5mTyO0_`sWkIp3CQea-A@502t|$sWta`Y)3==5rqQ=;N&W9=c^B=XdR~6q;xK z=Ej+7_)jfMuf2Qgy7M@1im>kNSUT^nI?fmBtci0kwruus{?!cY?N8o##jBTaetSgl zH`n~S@Y}07zj{!xZehWK;Wu%9U!wKi=RBXDzk&1R3D)`m?!g`D5oKR6xDU-FYgoF^upk!mtV+v^na-rzw0P# z;QaPPYy7UWa(lOM{&TZ+e9c$8cdp`mdveh9)DHRWPR`2* z+g=;gpmB9@>dW79-jo_l|Fb#vhsXm8e)-u!!`+{MmYuBdtvkKJ&96`Y z-kZk#%Z*r3dT!);=0-;r#Z&))9x^tE;Zyy!2LE{O#9QJ##kacbI~Y zuYPB}xt8-=jn<<3iwo{u%K1gI^@qUVp~Ej#`P5+Hj)=yGe$Dw^vB8ev-#z8~b!*b*TIo7US;ENFB@gP$4srf1OYq4%7Izq<4=Q+VPPg7T=HF?tshr1rX!7Kk z5#J5v{GvWV`M%k2d}ZeRx}nxjA73@$&;-s)hb;ZCvM-9BsmA}hP4n`-6Xgq3-VjWc z&ic%@g!8RQ!QnZ-s%vcF{4SGKvTS*5{S}-q$qMQpzJB-a8#ph=S$(g(zHih9&d(kk z)NF42Q@ncGn!vTgt5si66i1UBvlK!>sR&d@O&8hx6yz zti7Lp_stQPaDLawpy%0}UQN1&^U*_t(+!75ME{QSduLcj%qxF8>H*G6Pq!UhvGKda zC)DsJt6}oFR}cRO=eG_IPPq2wHDxo{u2gM)0Pb`6~;nIsd~_>$Ta@&u&@DdAT5X$$isboqw4c|3k~t8*Y2# z;2O@ij0`5A&o_W#LFDcS$^hknte`kfy>aaW8UVu5v_u1k0ubHr1qzQ_1ilPZ2qJ$h zd?~0Av=}r4MCBO@qP$8#b3pZ=rJxHyW>5)eCWz9d zc*{X8AP1-%MB%DH)gUjZ7DVA{KveECnMl4QpJY%9XfVhKB3Y2ql6=UIh-5zqr0NFA zgmglUOa5sf(hrh9`O`g>DD5#IlGE9siJ&1MiZcvEdPn|?KqR9I5ZxpB(|ytxl0W%* zK<9(#KBY4qL}}K6!sTBI{ya{La3#H=FisHZK)CFwuKhu(F3#uvDn0|j6E;+)JkT@{ z#i#m{o>E;#gQ%QTwktuDCdDH^HQrRXmw8GNwEY zAk~j#P5zmnai9v2>Zf8iiu=vNbtZ_g3-=?Nash~RfUbQ(gFqCI%1QCOoN92Tx~pl< zgL?t!7ob%j(gE@-0a0F6ASdV&(8VB%KN&>nlFpFa1Kiz!Yc(hqq}GAzN4h#3R0JY@ zB0nmB3W&-%6f_c)51I}l%*c=AD1$OV6F^kA)gVff;>jR29+ijk|2>HEqckZVrAg(b zdQkoT3Zk-5JW7+|kssCVe$W>nDksIG{3uO|M}F^t3L#&r1C^7?Litge6p#EifTG}z z1`!Ta7m^2+h4R@CB0nMnC>})h919{W4}&Nk`B5K0bsYjK23-mwe@c_$h3ifw_{Tux zPx(=r6p!*P2dxH?9}(T7{3uO|7w$*?RUj(Mr64s;x~~$|L3MX`rLs}^iQ+(NSyg$e zb)$L`g@>UwPyQ-)R9CVGq%Wx;YAY08^;h+X;*n0Ifha!;L-9zDNFRoPhJi+b2sa8t zaVZ}8kxpcR@<3Eh3Pbr(niP-x3P3iH45D~c7Rry(qE{sl1-v? zRKIY1$lg&oDLuMRq?VEDpvsfVNoi93)bgwK9)WvfL2CUdo?8CVaOZ%?#!y<6A6+Rw z@*{mO0Zj!_IaSOkO^R0nqPm?AqB>AKDhuUDX;M7$bAeR*L>Q4j*^F>fWkz{SJi?E{ zQ9SZ1?8cA$Db4QUh39c9-K2MCGLdZ~{fy^C_Hq%YYFvA9B7c(ed7wogk{S7{aY;`W zf#`lXk$w@r;qk)#7r~Dx{NBlQhs&PoK<(^I9RHA<$QIF<=Ufok2Rn%5OFBTC4lk^EJgNq!4KRh-C=;;Xij;!&J%{|mvBPLa-0niQYnko+mWst2`4@ap~Jxl(#+ zn&IKqdt~3Kj%q!qJY*}2KvXXBQ*9vG=b<2V95Wqmil^Fq8SYHb1keQ_>Z=J;iYJ5A zc+=o+0R0|B`B9n_kL>zn5Me~)7%~14;~&x=vc05()YlFHk$zIYPU9G|R}>}{q>dlR zPmF&Qn?hKmfsQr)kt9uohW#Yl`sXFXSe?S*FJj@R(dzUOkx|hxu?EI#H9DPEuZxJ# z>*0B^WZQ*U>oo`_=@TQ8(#*EVK6C6*#-+*Gm#w`sdg#PQwxrB`Y2UD%YTwG3*wnuL z`VUASJ|bh}sNB5#g2JNWNu_0zr<7Mrt(-Ui>~j{JdtObgqi%7%bBRCD)V!=^`HEkz zy8MbOS6_AY^}o5{#^2tw?&e$8Z@BC3d+yzM-~EsN@lTKa`7fIvf8uW)Pe1ebXSe=i z+sm)K`r7}zzT=I5?SA*Y_dodXqdgygvj58i2fzCI-`{+DNU~l3f@Fx-YP5R2P9H_( z*JzhXwnuRHPAY$*J|fK=nPi(2WnbFIn0;Av^2D`wKC&fdXwKYyDb>E0VpE6Z?oQuN zHDDDuzVfg9SrrJCKdkt>UX%gpQpZn?%&>yQKXYL`QHxC6fB(tM{oP-ye{><`#|0i@&%)XS!pbW?TK2MFq?=O}s zXHAyFLzX%Kts?~7JcgN40g3zBe5h2TBjBu&!@Y~u%(6zyvTRylYLO;-7Nb6;wn=ic z-POcO(;{_|iQl%9s$~kz_f=Wp9-JK1n#^dt%U{c&rSSBttP!puLn9Ad? zM@y7KENo48tsZDYXPVH+9qwA$Q>W;T-?6w6HVU<{*94#p&_Tbw(d!~}Mu0itc2Ef9 zv)qA%CsoYNH<^15MCR$ph-hne6cUBY&#gi_b~hyt3+fI~qiiJW69B4mI`3&0%9cCxavy@u?K6!4)nIQ>zMzQ}&X}Sr*O6fnUCWv3>Scc}_r8eD$JG#<-N!5pt zap#r>fK$afmUtbD zv!EZ<9(lIUvjk>ehK?h)d%5fh)I<3~aJoDmFND(Q!F^RxIu(|6LT-;6rp;Yz_d!2Z zg+kxy^!TM2o)uG(*(@(999Vmcv$3gB?o2_kcePIF9n*MbMrE(F#o_WxHZQcZhEj6N z)QS1)96lekGvM(kh4n*6pudiugF;4fXUNGgvn&m?LkD_zIpBbPF?&uOteg({xjf8R z9py30>!4P6bc_nOw<*9nXf$klt&DPdd|U%*Ai+A4o&}EXg^esKXH{}JcDD1Hdb*EOw&}g*6Jfq7>ast@1xphT=ChlAz9=R6WTjo!zCWz_J8<9@arMD)zX^8m|0@OgNt)y`BI+h8b3<01$`s&u%NaRM?`+J%hqgxb8Bu%wEyvmj$X&|*TR<{u_< zHR48Az3? zGV-R+^u}fPp@XILVV@j648)oNC#i>2?eVyzi5S?SMUw@=eY9}aI@KN@C89E7AmDOI zjz)it&*??qT3igv&Vl1yvC>m_iqM6DpX3kJ78ftWcmfhqm~eNKIll{=ldZC9^OHDc z7?yf!DGiLa7E{;iY<3)}ZOX__Hnw zZwX7Y*b!>1N)N?*FU1x$(x4x0l7g`;X>0~rIqZ!{&rM?o8o4QhIf;gx%uuG+$}QAR zj~E2;E)N-3P$Q7aV2aWIDU8yIRVzx#+b?vz)*(6EH23JP`>zJTYV&1bltdKZlqT!7 z*Qg4^DiC&;Ijj6EM6<_PD^0Z5G>}E{qx*EQkwT~~OBDnmU?Xu=yF4`w%Fq>+?9%3? zN=!|b!#Z^i@D&B7IS+*l8$zfZv;wtae)v3xl#)U-Yvia64fuNubPn=_;(FLvPSHEA zgP7Qb-wXw$QQJ`g!)~%U1pcJw!0aBII*0d7e2~fp<32~TQ?ZH|^TX1l*N(v?u2xF- zr`L8T%{osL>zl)7YiJ+-05v1%%-qU(h!+|)VQ|-|PR@$SmImsrG%u-l&~Sh`JI7O2 z#H7khQb$-Q3=O&JTheQr<)!4Uj#0u44QwOt(EM_xAA#gb2mQ4nC;W9$G+FV}>@3W*?m zBF@LiN)w#NX6h(#*Pv{E)n(pQEUWVtDrB9@zSz%Z?zzeeMQ%$bAH{imjf8&%riC6a zngG@3=&lyn1!#PRkdu@t1RDdAMU!hW=SP!P2K(rjny{LJ#?Zw|D^`Kko6iwxMhMtx zlBEnMsUR#pDh;2VH99a>UoLww7zp_(%0pu&nw_gL*z5!`S+m9jRGn0YCM*_| z107p>Et(cAN3GOp3Z?YA^cpGMRZD;Ihe)an)A7(k04p>)Wts4zJ||S@PQ|JyMe5LA zOdcv(=QN{iMkpAKEyG$bje->Y3^*~LBAo!#O{6cy-C*g5{U+EwK)~*!^((|z#}G5sN2mN<+Ex#V0HdNwbu0gN0hgj5HfH%UM}jYENA2^rJ_iu4n?<2ifss z4IGXYD}0QmNVclk=~!lA6Y23?hH&BDp&9m%gu@u}I1=eG6q_ygkO(3dR`rm}kNML) z+?@X-DfAqGlE}&aktBK!Kr@&gDv8#?);E-3Y?utia@JHSBVbEN9zCbiC6!}|o<`!w zQIwv8DdWrWQhv28XBTA6u=}zw%`TL(Tg;Lflrtu0j2U}z8SW;RYoyfZSwWNTkbz|0 zaz=HN(}l^$(k73`Uq4b(2EVNBDD&cs(wWn{j-*DSE&DMV6XJkX$+1%fvyG3McU~|(++no z@^~HTo`ZCXK-Yo)1NcbzKaFsWD9f8j>sio~2%nDhzDB-7sNOg~b2q2~VP}C3qHa@0 z;ixalJRH=5a`Z=?Mk0JQ?mq)6$GzKd{S0M$8F5}m*$#pFBA*8l{~m-lApZw({Sf3t z_-6Q@3ydtFL-6Z^a%Cak14ze%>l*M$$nQJ2|ABI4A)QB%{=K+&58~U9&U(}>fa`N8 z%Sxm%2YwR~Zxe7EhrH=m5-CZos2}}S_mOOCR%#5Thxt#3o2r|x#ADM+Qm^Fhjwq~T z+l{0iTcs~~X2I>16Q@Zf71DiOkQSr^#exz+{XjCPKPU-g0O>&y zpg2$;&;U>xC<9~yjRYA%13}54UZ6-&6sR{S1vCgW6f_EC1{H#eK-r+tpkbi1K&ha3 zP+!nsP&z0RlmjXTjRWO^vOvQ@LqH=yV?cQz3&;w}2aN?4fX0I+oB+z5tdY#QGdwG% zdL4^rc>_+5+mB?Wbu%?m?Iexll$xEA(@7WnK{;_B4%;19Mi}mlZY$GB8KoL_mwZ|% z2Kz}fD+4)IRoUk3YP;WAQ{@l%obJUHH8U%#%;uVUyRRzXvpWO+ikh-A+$*xXT%H5p2kLxyJC8^nL<%14~+&Rs57YahM*@&<+!~M+WMn|K^x4f#^=~zahR>D2q zb2z&iZg0=wAT=euWN38{`2s2>oTcY*0X1B0&*1=%lHRX-4!1-N_otr2xzuoP^&GBI z4X2;dV}0w?aG5=at5w6zLpb^+!iFX$k_~0X5*M#4#l*+bX|lYjVvzvAwh~jrdf*!xEDjI86?}U)8*`03?{3~Os^EfdJ5_y@Si$?q@*Ot_BRCT zt#T9=#jzd0O$|zP7}pf)Ls&)p{E;+TojxKmDmsS8iZ#Ub>K&hunA9gZCADwA z{sRUMI&1I{In6j!3Gie7KPB`3g|MiPK1vrEL2%>uQs6e}oZkmWwR-2qA3i*O1c{** zv#FS-Cspe7f)T~Qcwem_riCF4`2Tj@Z~=Y3EC009o>FA z@@Pj_-j1%k9m9rpsB}BhZ-*YXLzL~%sdi{oJCvs#%F+&1ZikMvW0=s6p+Gyj{&sZf z?dZi)bzXSPqApZ{J z-+}x)kbejA??C}=<`m_ca?QEfxjDJHxp}$yxdpj}xkY)VJab-lUQS+aUS3{) zUO`@AUQxa&-<+SFpOc@PpO>GXUyxszUsPZ!Fc)MO6ciK|6cw5Z&4t;8 zIfc1}d4>6f1%-u$MMWrL5wb5r(nW|?gjO(P3~HIqr)AryTVhX^fvPJni1~?xZeU%dgR^%qIAi95LD0I65>(5bUzMm z8k>>3AKU@%9tJlJ&FH=f?j_u9f!oF1<#0E0_dK{;xO)-Y%emVHcO7@Pz)f|eG*`h* zzgD=ZEEGn?U@_cOR?469rC&4#V-&6d6dsPsLS=A+y5a^g`kmd0fyr$b@~6D%SIg7( zz#X2p7d*9nAE;~E7(g~RcAGfK#DerFZQH)vV45Q&+xK)`DA{hfJ;GqP5UEvxQsyAU ngSey7O139)RgL$9cSaa=)5-UJaxJQygOuRX)JnG2^>qDz`e5Hp literal 0 HcmV?d00001 diff --git a/public/javascripts/squoosh/squoosh_resize.js b/public/javascripts/squoosh/squoosh_resize.js new file mode 100644 index 0000000000..ef47ab4e50 --- /dev/null +++ b/public/javascripts/squoosh/squoosh_resize.js @@ -0,0 +1,129 @@ +let wasm_bindgen; +(function() { + const __exports = {}; + let wasm; + + let cachegetUint8Memory0 = null; + function getUint8Memory0() { + if (cachegetUint8Memory0 === null || cachegetUint8Memory0.buffer !== wasm.memory.buffer) { + cachegetUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachegetUint8Memory0; + } + + let WASM_VECTOR_LEN = 0; + + function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1); + getUint8Memory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; + } + + let cachegetInt32Memory0 = null; + function getInt32Memory0() { + if (cachegetInt32Memory0 === null || cachegetInt32Memory0.buffer !== wasm.memory.buffer) { + cachegetInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachegetInt32Memory0; + } + + let cachegetUint8ClampedMemory0 = null; + function getUint8ClampedMemory0() { + if (cachegetUint8ClampedMemory0 === null || cachegetUint8ClampedMemory0.buffer !== wasm.memory.buffer) { + cachegetUint8ClampedMemory0 = new Uint8ClampedArray(wasm.memory.buffer); + } + return cachegetUint8ClampedMemory0; + } + + function getClampedArrayU8FromWasm0(ptr, len) { + return getUint8ClampedMemory0().subarray(ptr / 1, ptr / 1 + len); + } + /** + * @param {Uint8Array} input_image + * @param {number} input_width + * @param {number} input_height + * @param {number} output_width + * @param {number} output_height + * @param {number} typ_idx + * @param {boolean} premultiply + * @param {boolean} color_space_conversion + * @returns {Uint8ClampedArray} + */ + __exports.resize = function(input_image, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion) { + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + var ptr0 = passArray8ToWasm0(input_image, wasm.__wbindgen_malloc); + var len0 = WASM_VECTOR_LEN; + wasm.resize(retptr, ptr0, len0, input_width, input_height, output_width, output_height, typ_idx, premultiply, color_space_conversion); + var r0 = getInt32Memory0()[retptr / 4 + 0]; + var r1 = getInt32Memory0()[retptr / 4 + 1]; + var v1 = getClampedArrayU8FromWasm0(r0, r1).slice(); + wasm.__wbindgen_free(r0, r1 * 1); + return v1; + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + } + }; + + async function load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + + } else { + return instance; + } + } + } + + async function init(input) { + if (typeof input === 'undefined') { + let src; + if (typeof document === 'undefined') { + src = location.href; + } else { + src = document.currentScript.src; + } + input = src.replace(/\.js$/, '_bg.wasm'); + } + const imports = {}; + + + if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) { + input = fetch(input); + } + + + + const { instance, module } = await load(await input, imports); + + wasm = instance.exports; + init.__wbindgen_wasm_module = module; + + return wasm; + } + + wasm_bindgen = Object.assign(init, __exports); + +})(); diff --git a/public/javascripts/squoosh/squoosh_resize_bg.wasm b/public/javascripts/squoosh/squoosh_resize_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..b910c97b050df20c1024224d8b3dfab092e6d9e8 GIT binary patch literal 37052 zcmch=34B!5**|{nHv1$K2oOksJ6DvjCuE0YCO51BS=?=_k^o_ugg_P$E0963?urmA zRofy}ajn`~rIl*>R;^ZTv2Uxk+7_*CZR^`&t*w1s_L$MVWAJoF%83~oA?nSLkP>#f>zixtw7MQbjz|#)LVXkL>Gn; z5k^i>XaQmR{b=gXH$*@Lbj!4~by{t$WoRPL7Xd&?aF?z|83zx2LU%s-XeM$53x~-P+ad-7THsGkN`5tZ8m*Tf0ou z=k|NHqO+w%EDpMwaJBIC3zjW9bIGzXwfR%eOPzJH{;AVim*)~OG`^@taW+o#8sRV%z^!nrd+J4t;H0y24_O&^~T)o>F?DovVgHW+v6whN( z5FhSp@dAuC7|#d0TCB+_m=)E+=&PmTF*{%fJ7+~r%o9u5d9H}XaVaYGYCwVh`IUOP zx{5i`O1;F+I8heWjs^%)aX&`U=Ac!c+mnjxMm=W2c!I#PR>xyU zkBYFZ*?B3pk-*3%8es~DF<}@G3|z8p*k+rPZ`Qk*#|{+6hq#7o+QGv3P*=n?I~tFm z^vn6Qlsz2t>D{i;>Exv1xv0@?P1few{smFX_S?f#lZ3E`=0&g)J7)oKkc$UjpCLrg zj2rABd43TeWdlzOqK2*8k(52ETCM^haup_4;YQ|gAz^m6?5LeX4D=s{JtDwtJYs8H zzUKJteA$N>i({q0Up$J^4WuM_fc|1V5+A}M5Fdn!A7eEkcVenompBU9u}b}EjYJuz zkEzn1!zFlx=piHE=4mYz3!;2efJ(%CsKKluruzso$bus@NQ?S@Oy%6o*90DFjn!w$Tz=9O-I@P7mlz;k|VOL5C%(2a{CmyeTv*ZMec|_Vm9WFkm?bT7ThH98Hj5U z4O9nw?wzDx3Frr!wZe87k~FEf0mTlKt^~Hya(G5QW@Z zc-==4Iy!~hi`GyGn2m1C6RgEJ-I!eWCckZKc5*blnqvXHx73&iIOaMzd>=12%1el7 zrJhzTF6Z^j>RN)lLtQ_?=MsGB3%aMccfuu%H3GSCeB1;3`vg_Ia3i)T%+rMk$IcO9 z_aG4Hn(mK@N4Aw7*QvS`<>ih>tpwZMt*#)vO}verA4ZEItQ-QlR+Z#p)={N^cV-&atmvt zEzgH|8}@LDw?g46M_T8_@DeR$A5iqO=Q*=r(xp8gWar>_5bU|cAno}uIy3|9Im|NT zFJsT8o7Ci4q5!AFr2XxAZq}Z|^kwaN5L&{s=aGNQp2J6!T3l0xA+8F~_V=^r@r*s! ziUro{7Hs%Bta@B2boYqX!P`-`GU~AnnI{>u9>_i!O_qV*m{DsH8ukb^3k-WMD*GAs z@KM7aP?|oVG<`s6`he2(KB`>kW~wb{W|*%bN^L`dw8nFwy5JeYDj_DYeKhR8V+dzQ zPiWYwnxoRN8!!^lMmm3R*B~W68ukE**~GPA{4g{pJoHV_O&g?#o-ynJ1SL_=um@<^ z{RF^_!>6?ogB;heLyznsD?_Pr@P39p_=y_!AV!CC{!a{haDZVCe&U9G$Uwuc_t>Lj zBPB1!7D{UtTi}ozX|)trN~<;6vs!MTJFcPAsE>7`%{zgqv$alivHzVeZmhc#tP7Dv z&-lO9v!J`PQXqD+a|d`O5Cz4JS+6A6-z$kKuY`O>uf(EPVyS>Dqu0rS|46IL0CzaF zs3sM;Jy^x%C>WO)$hcgYh)Fg~+&Dv287L9Sr^X0r?R`hBeRQC;4;;1j|B-zzQT7dz ze`52THOXirO{p}%Cp6DAy8oJaF2V9ct~ANYI!kl=kF7HS_Kb7Jx6s2MGtK~1dLwGs zJ^ifvVc|(KW|mf;GER5heVIPgOlhqj!mW|@Zs^LmZ^G_-3IUYiAXFOCrJp6j*ACZ* z^%$FwetE!bbqu6mj0R8<5w0u&uq-e4%CuK*RFBuGVwEbIRB^5<7OP^eD(Y2Ht%?#= z6sy8kMS&{dN)v*BDm-}h>+1Faue{SM_ju)QuiW94mwRQ}E8p|rKJ1lmdgURnga;*O zJ?NDPQRID}SMK*ppiVx&(6M4P@@20)=#@`_% z(8+;W$#GY;*}4pPXps(~0~9R658bG@;iNCZ9~6zWiO2=H29i8MSIjjx%@p1b0#JB0 zkA!QW47@}avW2NQ{&XSCLGJ|}u-focR>A6GXbVGI4s2~>+@Bpq#DCW8r;z3VM$ajvy?6i(zee0h}faA*QXz zEvEm%-kgVfyo*CU@CZK2o#*5hX2J0;nhaEZbKP7gmZ>_N#FI-l&UNw%adE^y&l$vv z4~NM=a|_)|)}^r2-(84gSfMi%PYf94gsJ9Am}pg(2D3LSK?F~*=m43fI9q5N}? z?~NC_xyUfMqdVdSZp^zHEPWCiM19p@umudxgRALtSEk~_?Qq=G>w!b|{4@ zExTZS*g=Y9Ap|ah-axB~i7C$H2oxT>>0meJE;t?%)#Q|TP?Eul3i3< zMO+=Oe}>MG>Jhhsf`zvNxfV$5>S2JHsTQKyq-NsWOyNW8V8vJ^o=G7CBf#&%ifw-< z1Wsl?Y`xL3T4iI~k}As2XrLeTSZP4!^EfILtkjT*7C4TM=pe3>heU?+qx>KW?`N&ILPuVdDuKKt=1&(@I5SjDcb<$AGHVp|6#yj_Xo; zNY*<>(ldI^rHBD&#RH^lDn39F&dRQXDpg18PN*R?xtq+|PZ?4BW=ss5LLRzoKFDfc z;H2Y;#LK7n!oxV^)TbC^1TG{` zpnS#;2gYmBWDu?BMRq{Bb>q>xLp`bvoZvgk37MXUd&m+#+H3ytm>^S&lo=jpwLRFS zAGRQ!{S2^@Q(6a#+g{3>l4j_0Kx1Wj0z}6+GF^*5%tQvA-ky zOjP${105?cYz)`etM4nbAI8!hz03v)gVm8ojVTy(h|MVYx9)=$=!I);K2wt1 zH#1NU*$}5d0l&(0*(UvA-5tFMyS}tqM;8PuDLUc?O*;qcUo~U{l~-yum2)aQ$fX|k=8K_$_)(J zxf1y>*WYzt1Kfo4L9rbs3T-0TBsSgA?rnR$+&_G1%YDeZ1GE<03X>a`ya+JWX}RC{ zX;;eq#w!yVas8)l)$==1zJ67A%6&b|M_;}ZmtQVu?Bw&0xN!GNheNY*^NZI}lherO zd%w0m5q&KH?RRg&8<87uy%=Niy2Mn0fAjq*d!(i z&A~M?k2bD_BS`5@HNdWzQ(0pn;>odGHSe^sO8ILqK78JwW$=UIB`cC7|?M?$_pHi^_0W_s;>1 z^aE6l20$&c!f5vpI7V)?as7V*PUO}tQK9a*Uvx~>{o2tgU>}|!6vl6F#6oq@N*Gj) zxB~uR)!<%{0)|`A?G~&@ep}8Lf<-=aRQ! zo`H5<_9E_$qg%bN0>yHxHw()hyN<&0FF^7N2A}}_3y^3>Az?2{mE=!8hE#D2I+6iz?jyWV`!Fuo8;+bJJ?`fzK{IRI?K>C z;j_Z-yBGphP%lTWYuYAdyJUOB7k1zOc!j+kjNq2IFF{|}!F|v=P(wu>8{FIu$VjLW zt^@f!*wC{{4w$qkg+>BIHZgYQkesx z?GxJ(4Nw)S)(~BE@>fs?*V9W+0ATpv_goBoD=LX zXYB;`3`CLc@3y#Gdu**xDSr*CHQhg7{*GvrfzC_6{i}|H0bm_&q^t25)u1n=Q9_9Q zL(_fwK4=Z>W;@7EJcd0p)7_tW;@{;2;B%I?6uQ2zSeIB z0BwhM*}h$IIl0_Z>a6oLe!?ru$|--FF! zB7jJyXEAI8^d?eS8{mg#_(#@tx|U zZKLC{U5;<(0p%;C(_27#FllEg5QuK zK3ZDVJ0s`!KmP`Zch=XhtVYosu6z{5J$H`>MRz$CNC&cszMVIakYo`Ifbn~GVtfJV z#@GkIP{XMX+Lew1*jAb?Vk{-sgWP^017kW!`Z{C797UgfC$EcoV zrRY(WM>Ga48f5A?l^^ne&WAeV*Db7uMD-u|;mxRicr(J5=OA3Dgs*`o!@3FL2S)0# z-rhKDtOdEZt_C_FgR={96PWS+KR0&ts=xBSsbO!lcZU3XY<>9JsF@FkUE3@-5q97V zrv+c$vay9;gaW4?3OM#S9XWlQSKRl;7R5wX?zi>*UvUrxl-Slxpcp@gli3TLV9P|n zhm*^2V;3$hbc$*WFtdw24Xg);`E<@!WE+n?y7y*Q)?<}=Y&ZjgK-|#5(oC{I#iZhc zWt4!0a&sQ5b`JsPIVfMo`3|!)o{P>n%eUWy;81dpUAQ-M^Y|VDI+xq81X0Z(C>^g<%{@G;9sYcmtLA%wvS-&9 zYzCDn0XY{_ssrXKI`Lfn+76rxb^@HiEdZoMZG7Ov`@K~OfisDGM4paoMm>-X9N45D zvSZc+qxvfk^T`;T)yy-*qkGUEPv^!9q40R7$&ryP@SW-DKT*#bV=+f6LZ*%5Vg$<$ zd|i(A@UCjT3Acq0SL^4YL@bK^s!IJ_JBWQpY(WH4EF~>tIY@*DVY{)M;rpCW2XGy9 z^HWYf!n9N|Rz&p}a))nlqSM57LT0DM=48La98};Bw&25vZ zK`ixLcxdwM!a9U*GFSt6b}s(FiKNAbPC>$s-k-^u9Q?rznb#XrdK-XM9v%wfn6xtp zt7Ps)Houd{hWvp^dc~H&t z$POoXQ5 zg#k^GuYod(sB7enhp8*)TTH5MBk5pdk)0J+Of%#1FgEql3ODP^^SwC#wOx zJee;mL8}0~G)Nrw|Lg$A!#P-DHAe0j95?8ahk5cCNS|QTEA3JeFT^Zi#C`ysLs;$( zo;nC$7uF*^`W9?x*W=71Oc7>|P*qZn4CDlq&jYqM6;RN(tVF6BnT9sp1i)!{v;hiF z--1@MnshUe*D;uYdf@gxAvZ3~2Cfx`0KJ%Q;8=9VwGIjt9~<2m%ij)zv2p{Zap@aH z=NH1x!-K*}J8W~~y#=U!^S*}sElw`uB{=0cTLryE3c;lbH6r}nG{4g^P=ol?1t{1I ziZufo>CPkhj-5xv6tTHPFDZxI+|d=0SMCePbK&ADgjDMJxW-ekM+{ei`x@-e*}2=f z^NtoKz?K|F!f~SrE(D~AB<|_*bRL*HcOOJdZtO&*X7d3(un+)~=X{XaV-^US3uBOD z9UXzVQkV|1CDfbRO7gdSgdI}b%68Ksxlju+s$j`(oQg6}A4RfX#Jhr`hcI*?XAV}g zH9U>SC_>|m02@m4paVvrm97|4f^5SxFq)k!Ti7w;Q&6i4JGKu2xLjGR3M0D6!Qo>P zagv1)po51z+>roU3e8AINzO(GTPt8OKX#Eg5Vmo-!VtBig>=2-XAqQ)rPP7(Hjr90 zTOA^6?^aX9b+Y9Ipv zL?W$3C+M65X6@>LxM1;;MJ?=PIR+*GRsv)ILoxt*=42HdA3-(^=S%c$mh63+hFk%Fo_|g$bSlj$Fdt&~SRHA1089J35O^OvvmiMG~jm zWyY0|IE?Iha7d~P>NE&DX=%yF?~5fWD5vRO6U)0E`G95qUUJ$K8r|ATX(|wIa8Y*5mRFEB#Tt>;kT1 zAR0(b1UVn?ITc=$+RMUnp<{fq9>XzV2kil|g@VrdoiJ$mm=T1G0c_#IeZ+nk+*J4Q zy#V(0Yx)q@;Yk4{QMzo9=lM{*Ak}<%9v8|CW*1=Uv%rn|Z21ru3osu@DZ#2oz6Lgp zaj9IL4YXo4wmzqjg008>GP^`os-y&yU}^pPV<@cCa>t}p`V&jzdn{NMNoLHp{c*Jg ztmXiG1iKhLo?KdTm*dHsSSyb$W2d50KNWyl?$bOJRxd9L zaCNS{is1^*II4?rHJ6ZE?i<n5?AfhWkk zOG&`78hQE|>B(OrUrps2bbZM2%Kc=}5RwTeehlU>R`EAR#}RRuDGbd~1(Xj*4cUMR zY>4v&P_!HZlw@z5h2)zsG!_v#u+;k?yzQG~BKfTPV=9W)Acp3LMF$4M5S>_2I{gIP z{R~5m;krFwFnk(^xCJ)CWIiv(YiMsiPs5|2?*%rO%t1B*DiLx^0!cMxxo;gM<}q~5 zIm$eEd5FZc6fyT?h)I8q)PlbTCOIK1F>s=zT&XJ}+mfI_fqDrFo;TlsvhWHeE^&hK zim+~|(}*z~0D$u*9;J24koth5+$*G~H20M+-}b-s=WNaW(Z2uLrmTYd;;$~h43{*= z@WbAhFDzo1UC8W0rOCAGbFvqFti4F%;16R#YwuoUAok6ke)VO~Kb6=!v-`K-fBo-b z{NsD}LWNp0zxICn6|uan$-Of#y!Y8N&pPW#R+xMCJgkP*Ro=y5mQIND#XHqRpaNed z!@D^FdFDoQ-?;G=xi2-dVA9Mp-;W3TG8gsWnx1)u7?ZiFy-?)VO@xe4Vx|*8W*#3h z>sARFy>BFdfg;nJ=kG{j$c39Tw_XtW5XZP4-#b&tpTkIYD4fh}m}b3p##y%~_wtFw z=~plQ>)Wvc9vi{>&8Fv6G+V*jEFyV0+Cy^vdIx(k1a$Fg$~@;R8`7 zk@in9#xVZ)I8(I5j9HBRC|=gH>8FGG{vWK$oaqf-|49xGjKTcq4blOXMSuq;ybXq* zefhRO%AH?2`jd(Of}hn^I-`hssQ<47!Igp2ZO1AN#aO5A&t5jH%V@ zc^iPMFHN7JoGm;m04qQRW{zn@@YM$@YDW`hvCvUx*o-YU>8Zk9q6I}l4$dddCSp>@ zh;xGxJ&@J_JrO(8rd!gc;|^F=IjVroXv~J!FU>jQXKW{!+!nKVPBsMhFGTZ+Ti9PG z2c{iP8V|O@^s6!OPNz49G~RgE2Gep33+Dg>(tPv1n-EPlLJ62~H2~BHa}s78e9Hmk zkr~XZj5yB{W-YiJrISG}Ecl$LPU|B9;v^V=<{nXc5X0egU@NoJ@HXRE7#st+P0RM| z%soOhngta5rP~QS!k**BY{(+qp?yaT>X3`en$duph~1>gk5FbbrWl#qmgn*cb_ z9ymu>#|8AkSxIZNWmbt)AU*-1#rPasqpc)<#KHSJSUe)Yf2TiiCHtrmBqVYX7^RiR z0&%KIiLeXKnwqcvN0J^;4iAIhB^dUNuN0Wdl zIkKTfKaN<0`}~Fv*(q%^0-}Qmhyk1TJ$j z%yr3k;o~{(19z|!ceHoN?sPwr@f<4K_hc0whp{l|Ax7+GXC1 z{-n>2WO~|>25pmSDx))`*_kmx1YfPsMF9tPCJOkOaN%7WhJ_Ns?|AUVpeJyv&j#0k zEoRFg>jWL)V1kFU9kBur#lB*_*g@_qvc-wW+?%i?`y9}i5hf~aiY1j7c!g#U9yaoB zCYTFX9}j@hgzwQ}BCMtwfFl?xL4hr&c7A(^kFIV2yHgmt8d8K)+7Q@)+>pXHF<*-{ z@mM>q9H=A~;4yg7$T|>4NI9)7UMtUJvj7%mU;|{ZWx>>`5*tj&uTel; z8)Wd$%sJqz1~vdP0v6_AVO-e@iyvp$Rd8SBRY?^BXaU=R6%=QtqQ=L4b><%6#~gtb z@r`*M1tf=iZz%JDQm`&VcthqA0XP=ZFPDg5-wpuLTsL(aodcCUBpM_V9I$#Od+4PR zW%kg!?t)~X1!;ujz|l-0(%2AQLC04XP!C)o^9FXH1D&7*fDT*4rbh%Y5vzir5jcy{ z1w}44n-s*46SKs1=wa$+%@YSRW0YHQv|fklfYBiqcmjXNbx7#& z8xM43wXx;mL;$giZ$7~nIAAO}9GH^^2N5YVF0eq%xS)}mEIWx@A~oATpM-fWH3?Hy z*-3c9hLd39m*2QiplAzo$Y>iOI?t{;>;bgX9mOa(J&Ge~%Ixx;Z4>GQ5`lC&VteX$ zpaXXjr(jMSi&g`8Tbr8T`6-K;U|?fU78~r?k9^Xr=}$gTh;*T7K@yIkBGQ~{Mk}s^ zXfjhSAtt@hnm}WrG-*iuK!>sQK%j#IfR_1vthyi2@&#!S83qKlgZsF0d2mxDmbh|h z4f}PH2REqaNlfT0F#b<1wwnx{pl*!=VNO1&uz? z>!eSBM@oXAS9uclU+bFd83ehW0Ke7(Q}8uwNV-&ulY|`)sli-iaqWKKAfw0yaYI^* z>n@-}V<=f{DIN7;be_OE+R#tb@i)^v2t4*{u1S{%hrr?JU?Ao41V|D13btP!+>nR_ zj+LKAmX>HC;JiB4;XyFR!B9y5iM}`n1Ct*eO!`lm;XEv#XgEHPV=z)COO2!c6DURa z;OEnSf?&~qqE+LjisK^_+Gvw;o#cT@;54LAi`{Hf`B$6;>*p+4`itYvNR@tcMg@Ib zWM`BtDm^yvW@cI%K`Z>Msark1EtmmNRzvWzU%15wwGQ8FlALA3D=N`@Tqsbee;1xJ zH}Bx(^Q*^HOrTW=nkh5vVEqU}sE@GDRGEMtfV|!JIorRTy4ux|XQYBflP$7IY5yjm zy4?EdcB?z?``mWNUuEE}Sy!H!7BnceU`-oZ0Yx!fXzr!>FNFaSJOW_hb`wv}p>%=` z(JQdS2iRkqoZzKSc#{*c!wq>?TuR3aF^@C~^kfk{HN>vKC=EldfUdyl%_}xJ0oe?r z+q{Zl*An|MSe+lIYN>?XjU8O12Mrvf)Hs3fc~Ec>kStViDf&U>0fa~h@LlQz2~{uy z6&5wt)9Mz4BD zp%b9nCH+Ng9(LHiPO7l%cuZ3xlx6x^S|m2%P#6de)x=~!JeU2{JTMjn4Up?pkD4@& zU}8KY7fY{^d!SgA=3qFrE-H8rQo@O}HzGcys__h7u??nEq6qY)&>6!9TkS9!(oA7* zu!HF%1?46+y#iHJkSdd-c{^1+v7jN|0F?5C2UkRFdYIxZttc@?u_2~l(;@W~bBQSq z%RI4k7$jc!0N9fF0iM*n9^NFa6m~coAcmH5H%mAN;>Wv_y_wYvq@al)`b3jT#}iRF z-TR?|L&GMIFEBhbkcxqtgw%n*OCaU8*6_~vUwC%k)j$2(UxbuPgVGPJ-ta%axc;5b zKlkb*Z+Opb^%6aG1e9>YYj51}&WmsT^uSwQHN!IRZG5U?(eUo~pWpVC-nhYU!m8>A&uj3x(gfFPzVe(1P^?%z;eeK z1&|j*x!X`WT0=F|pLVI^ry4-$vdK|;&JIDFNnHSc?^4tVspl?rpkp-LK)>-)J;x>V z2(%jY2*$LpXgH!>iiQ{?)0W-Y3e#XZIC*k#|0ahT48uX9G!B93!KRtY092P!u$lyr zm2|VwZ4-c2T`&Si=2OfFC9NjGXaJm$%F0W_x0ss$Xmlaf02!9=mK95HORtwU8vGi7 zYY&dYm2e;aN*_;!kc*L|VRQf{HuybN3`nj-Jc4rwHfbt7W-lHY;G~nvE)WR^E6>{l zg_j76%8VDTBJxYh=yKtvfH548Sz*IeJsIOjONY9A&NMRqAdy@}&4wy&|1!7YBRlslGTjO+)W6I#J+jpgwcF*yq% z(_1nkz+Dlbf)hyC5&A&VrRA1O;Vt(Ac~*kBQmzZkHL=PsQ-pu5c6M<=1%yhhx`C$T7#ZypxOZ?QEa9jp=T$recwBkSvAiDc&1|m6U~uHwWyAetzaf>@ zV}gw?^Dq?UDfbODpdA508Mgt*ae9F~B}3TkX#%HvIX$?mNY#ROi`7+tT#T;nFt}(j zdBr{A5y1QGh(l)SamBZHE zoA?NdOg>`J!E+JV#(hH9EO~^0JVir;ud2ZLQ<@nH4;U6fy}ZQ^CEg2TkOTuKIo#s+ z`!Gud;X)P|%m5apAgfo{Z~+5{cf^2Kxwp)NJ#FgUdWZ?mrOJ@e_IEir0*0^J@Q$de zu=Cibon9&hzOD2|U_=5)l+z)$4IXPi;ob`r@w@t54e2i#FCeoe-9C%k->_iv?F|Sv zQ6?9pNd=Wzl01;XR7G}4lxX!9GRBmS2yTHuo;oy9fbJsvJsq4bbsLr<*~R4AJv*#? zBu)=`&(90ci0cqw9H^0}cWCD`x)E5BFFs)=XbLCe1HffKcaUHC0N@zXTkF8O0R**$ zayO{FXte_>KvaWZB03U4fKUzy3OOF$k;HhCx-#1&eF`C62+`|v{AS~Ln4A{(4oN?l-NXNAB-gXuZN51pYl(TJrT+rRI{miuf zO7L5nb&P>nhEO1#Ibc5HAB;@j5_XPz+yh21j)8zSKqXPUDdP0z{=Q5#Wx6 z$j0c^%dE$PWJF*ujTak$K8fx)m<_*>a;go1HN@n}J+KYqX~VV2M*4l&1Rczrv3d@< z5x_Gh0*(Y7CcME0FgnrUCEQ~fjfJ#O$cn18o{1BUR05nG$Z@ygv=CI3d+548Fs>R2 zHQ+n}h?RJraEhw``wT=1=2Ktog)8?U0FmG1srSFTr60=Rzz)O}_7^3tbcH05K@zZ| z13L`IBzEw+HMAzNV@m9p8SIdXKr(FD28Ub(JQ6$NSnSA=6n02Yg`E>l0qp!=XP}A82@)0}Pcp-~yh(5+Je>&+Eb&6-^y$N#jfp_=x#?3Otg^^2_sZndDYo z=QNQ6gyE|uH@Js*7m;_q4cY3bD-TbjjjWUdxzbLvTV{}Grdyvkh-xSOrvB~JA=g10 zupfH7$+ECy$3+q(u7ORp7`n=R%q4$YC1GHhQM3Q-T^!~8>moOcq$MR{wOXM+rm4TO}`0f<&%%glPY;YB!(^QE$HBI~Kl50;GsU3xu#sS$Z1RvwLm z<>YxSYHZ?yCanL0<<^|Vt+`oTFU;CL>BkyU!Bhoz>~tFkM9g)jLdY% zo`u5?YntX6Na;P;GDT-_D-{@1o<5RI^BLaa%?NNU$O!v?1SfbwJonBA7+>OC5b%Jn z3b+@yY6F2lJCt zGXSe0WCzLvJ0KXA2iqmQ=%64*A07;#Qj>?gwD6ZT<=w&=@jNwjhtS~1;44hH#{2jj z52_ib^EIqOe#slm1#txv1BOM03LLCAY?XN?GNWXD6jHS){D9~Z<@fT-N>2e0^6ub1 z?+zm!PphUiAWr>e;ljylNqc_NxH_ky6H+OGfTsygvmp=rGR^|dJqW#{8QdTs{|(f7 zfDSE>2f@upQAGbAGCWR48Up+U9f#d{?ECDvr%$-U-Fs2U^URcTZhWzcWJNg}V_Q#D z@ervEl2yKG#~mB>?ua~LfuyK>y@-0wYKM40v$TTEVV5;q%*T0u8;F-Ychvw{q2|Jj za@(8Cqr1|dz`rTc-E<7M=@@ela3v@KibKdIu`;?58HR>_!}bNU=;TQnDyHUTSn3J{ z!^)=v`mzi11mH4FY73!nhU6DbUNHT9AcyJ#3=uPC)P*if_Rjd~oaS#sZAdlod8Hx5 z`FxcR*Qf7a==O$Dh5tn$oA5sos4?-M2-JmlkN-p<$G~?wtLO4NoyA?!E4ffHb;jeM zH3I5mLm04o88T)oknsUdiV#0hf#R!)fC^~~aE3`@?{b7UN|R6mVj=KT#v#a_Amg~` zOOGhL&7%g=b3D7QyAu=@{(P4jM|bf|1&@ls&4y= z2M*umC?_cpkussnD;}w@mRBnOrIUp;9-&`{^b=Q1$41+yY%wx6u80ex9!v)-N{%u+ z@Y)=GK7{Nrota}b+-ZVn+VVv^RI#0UxdrFQae@+<^qSH{S~mWn$u8Ph3$WlE0$Ha4y0i4PVe6V$PeWs?b+Y)3spOmt=;WKSK) zg>ycMhiUrt2!}Un8f>SnUFGPKrH`^a??wIjn0o~JYWNZkvCi!!$!xusclCX}J8ib= z`%W)yVZFOkLXd3`L-R~e)ugX}C&i<${)T^V;`)9QpLN!d%*1;*D!%37*ohC-XD6;# zP3}HAaSEH0z!{Y<)QG`1R+yY1e&!dtm!;{Yxv>rPOm=y%l^Y1Xq~yf#GOSW8OzbnT z+k_Y3Jk`Q)RD#M>dWG6Kyj}y#K}=<+KyOf&IeBo8i~y+hsmd@j{3t7m(mk$g?(+12 zY{{=NM>FY3sQehF%&0Pq%URo$`;j@{rA!%|2@0n{bwSv01<{o+JsA{OdKM4dOo4@A z;PWw9paHc&g~BVIDZpL~*JtCF&KjllKb~yQHUB?Ow*9ITPv$L4F7Ojs*&7+H`){D} zY~^|>jsI~S58X)1%9ke%iZ6~a(qGQp;~)ZS=rSrrk3uT+)~rW|bGo`zA&^dZ?y)HN zjeAL;BUl4GCHLKY&`w}Aobn2=7A_hx){H607RZ&8aoE@!gSmYkm4vOZ!b_n^phu_( z>d%KaZEnV;5ZKw3<#{G*XS|ZzSl{dDF&rPcf!|4!PJ*9hmm8dqx6{y0%e3nahd-q_ z+ryGhD*?!0<)!gB20`4T<*?*383+@IuqK_YsM-h#5qkeeB4e%&-e&@<8r_5>_Y{ z4PT6n*W^ugR2OF=;LHH|_*$@(iyaW4wh`pVyYe_80m49c@Q67y6(s+3P?L}FZ`7nC z5aIVga9k`ZE~w`@2h_7Hp!`WLU2TBkY$!Ywcug=E@;f;+#*pC|@HF&#X*Jj%_Q3%> zEG&A@w1l#Qv^SoiVH%2wdTyD()Q0C#p;8y(JpmX=2@;QvXHT~AY&on5jT7o+fG1rS za$f)aK75ZkRtRw9I$*?bIF{GMkkq0CKoN!>G=q(BN^X@%qldxx|9Suq7)qcgivOb{ zHYdv#h%TV@a0J{_qsrIfSN+4+f@-FgA`wa7W8Cz4VQ-DEwl(58opqr9L(abPrGQ*pRHIN%}YdFCSm{ zp>AYi3!Mow=`jf-6AUt{*92do%Rm^QMcNb$7)VjCT%p{{E@KAif^v0~-d?H5De;N` zz-ufi!d2N$#F^$kw3YfEQM>Ag2hueLezN^E_AAXSMM4e$^DGZS#cvb=JcYi)yq$Wlqk{k1RDn}#wAv#&ItBsL?4qG@pHanbTFj9|N9E&DT$uW)C91$)QDlo^c zxttHK@_1ax80t866Ebz9Fuv^IXt8Q^nt>a9jgY{Nh|LJ+V>C%xfPOw+UbD6H3i*(9 zmlb%3QaxmbqJu(r5ZL9H+l6z{k|+XNrQ~rqq6?iNwcuO`zMNy=L%iO0Y|9w9hr`J^ zzAgp#hH%UqNsem90^vav>2wlSW|tK4*urq0aG$ucPq=yR%P1%j_z$4$2NM-@!#N6~ z5k|c{8L#w-td8Y|!>Bw-zuV^ROji#UgCtHXIcVk6iWi7t&Y0_U1GAU$zQ%R%!Ty+_NRzw8=|sUkddJ-&+D9 zk^lR4>x8bg>pPdV*ljKCE4x?OYu0yl+e>lV+_|!))9zl?+-^5_wloV-G9)HW6GEJ? z2@you+|||6*}ZygyS<`$bz95wX?9a_Tg!@WyROdeT)lEt_qe8zjbT@Gqo!$^T`gi% z{NmU7F%d!8f_nqy@|Ldd)$Pr4q^`Er03`!M%f{wq-E9}y8$}vz9kktx64Pg4xxj;^ zt8>|uw$)1~cXpu?KSM@;x#roEM^8KJ8|~B9IbWVu@%yK6{l{t7B*N3zIisha?kk)A z`X6Rb|I6afOkexUtc%9`SM$)?|SC0>09r6WV%uD)b#FUKbd|{&9A3V z{MNhEzujfjRy9XzOZN?_y=T_w+S3!`YCpQZtaiAYs6A~#W9=OepIQ6VmZsVVZf~m{ zJn5p^dqdl5@4f$u+IKJAQTymMH`c!Ny)V_yDZit3$D%|H0d+S`BrX6=ahpKI^A>HXT~+x5EN?Dp5au`g10&yVuz&KW+eu6k2Z z-PHHvbzl4Z*t-1-C)HhaQd!;jAvJZsJts+`0p&EU7#4 zo0huIJlR%f{#RFB>CZn~w=BH1ZucYG>ngi0uRAqyb=|uKJL={cy>$ydys>VW|CYK( z>^*gJ=G|8JlWlj_wLgDv-K4P(*5zIQjk>Dvx9axa@K{}M$#?61`^xv~9=!3ny5Y-T zsGH=zSoc4ZU#VL^=}_Il+F#cdp8xy0v#mf!#%ksrQUd-2Rl42>#*ZM5v2nrJGnOcZUaNtnN_O$L<#A`P!PxP%`m2l>?C2}XPOI$XpJF%l+W1_lfQ{ub%=|r7RZ%n*)=nIKOV{b`( zv-3*{`@4G**B5*x;qSUVaruw-C5Bhso!GzU-o(n>`x9l`9!wN`@b$!q&pw=}{&0Wd zqDvo5ygul$#Jb!25|iq_n;81DCllqHo=$8#>Dff&;PZ*+w;oJ9SN-3K=(|5jT=($H ziL*Dql4xoCMWS!SYl+ow|0>b){BIJEeD!yUB{#g4IB)BpF#n$u>sP;ab+Z>jlZRy(5wxjYY|NZB+6XkB&|*yeOW$uI8lVtsjg@e(t|c zN!DLgocz(*#m-YP-@@dxUs#+RzW4Oxx%Zu! zyzAo`6h4I-tF!sSB^z3nL2KQHwl>;4fs*j>!yoFp!T8|+ zkhg5K*RJoj*RHUau3g{0ylWbCv((!5>Mq+}iYnf#hImp^Qc_wnwWO@1yriO}vZSh{ zx}>JGq_nhjYH3+%d1*yyWocDub!pAilBuOrr%o-KT0XU6YUR|bsnt_!%1X*g%cho< zm6exOlvS2hl~tG3l$Vs3mQO7&D=#mvD6cH9Dz7fDsVJ!^t(aO-R#9G2QBhe@RZ(40 zQ(013S~<0{tg^haqO!8Gsv}$TqSyg#eMO9^0RaJFWO?63iY4z0Vvg-2c zit5Vhs_N?Mni_yugXwE9bPc-I;E@my_+p?YvNy|U_vDe!qBK!%LCN_t^_hNWp#Ld& zei6z5%B?7U{n{g6rAUCb_B_Bax? zNj!`3z$)Sal;qK60A@_T`%mGXyqDplWotWIBpa<*(>-O)+T~!Q$&*p<3#k5Q;yNFn zl8r+A1!Iz@2I3I^&%P4?N!~i{sO{_5(4=aHW%+!D-xml3^Fl@8;gS4kEGL?4#*9IO z1_y^~!>j^*xG}t1_<2!067!&nL;mKNwQL0bX_UU)&cboSF{#SqB`q2EyI1&_B zMq}b+z(X0OjNG^JqN}diTXN31S6#iMXyngxa_67={s)t%B+om4$v0l#e$BPl@4V~5 zZ$0|tQ_p<=7jOLfh%oa8jVmp$nl`1PhU@bXW8`_U0$FL5sUrFqHBK%wc&+j?(K`ksp7 zaQcujJfN9V%yQFjXnvnRFSIy!kpC>dVHSmgM!@hJI{uYI#I%gCPsHSgj7f%O=J|3X)69`4FR|B{sgu)BTbDdwjPPCZq46pIknpfzek4DV@`ZdO ze4p}9ux5uQVz*W^O2ZS)5x%gIz86*Tc9l+k|TKqfhl>D3_BLj zOLG^e|=JSB>m!uh?PEKrGFXuW7ODwjZqccdR~5de<1zr z_-Z5Ms}9T#M10-hQO2juPX*K43W`EQgQuG5D}DFe78z!i-fC|B*(v^rWuwbz*G$BaTFH=54}AluKUuLzU=b{ zE9(+xzjN>fGryp+>g;oR?*8ii->x_`=&~!W{Zf`6$&L#bEpIvR;YSLK{DDw7e^_PB zv^(#5`KQ6E9oOIK4^2<3Sbg2jwM+i^r%x}v>E^M;XMJ(ctzWrq-<|h;>%bGfaAe5H zX~|iq-Ldbv=lA%Bk8n;-B%gfBv`;$ul;X1LX>;eDy7-K<&L-0}Eo)iP+O=_W&y}~` zeg6Y}2fup%MbEXb-F1O;v1NcpD-3PQ z(24C{PXIkc)HU7MM zlKRH?0^464AKd%5t)okgJTtKM>MP7vD{2J&v0Y8+zlOR3?ZalLZ_1CH6C9qtZ0j83 z(uUk2moA=={>da`gsE>`H_g&673r78ooa?meOunlQ|r?IRqNBtv#i2$eQVA{b9vUbfv6ERx+9km{mw%ZN432vNlsnO>Ny*+{%-W&ldZAA*UQ#?EE{4MO?|P1kE6 z(WVYXr;XALEzElh4YYiHDC8ZjF>*lj8zFs^HXVH<=vj=xYYYp@z^{kpm;?tC>bx(~ zYXDnjj*;42&BW0VO$%tJX}UiWSgPs4uy3ATh|x5yDo4Y7R#+Po)K-|94F9 z>ko9@r{!wEzcJDnsgKg@bPscD)ewC~1+agEkw3*Ty8m>ROqi**=p zz&>2Jv>WvigQD6gf#KoFMhVuY>tnSUz`BkT3IT13R)%qL2pa3g_i$-{B+@iENU<2* z676*)$c2Gbn#G2x-H!2v{)O-ZT4ipvQHrr<8DlIhkU-M5JP7g7mKa2!Purszfguu` znl@C+@f+5616;x|ViMG0D^33c;PIg>)Xxg=Ns3rNZ42J=!pef0{w`<@9?*7RE>pul zAF_;h13kSzQ_gLe8*-OtfJ>%#=VGq{UWX@DO_M`%g&G+tw!rWFY2{!wPHA*#%& z0WC)xYH7I`B~OlEfkREBD5%3=hx8HF-I6e-5rGizk#^PlA^$Z|-h}@Gfmx zxw_r1t&{F+F)zm17p`h)moDq-_LX)M{lfu%zH|=P4W&ma#6yrJ`iRqP{@^P|2gSr_ z+&=(EmcHp*sK;Ybf=|5PeJQT#|9uQj`i@K2uWno30`IXCszk*oS>Lj&yK~CA=I-v6 z&UWgaTFhr-9LjS4nC57ECUB;#X<4&uRVO;w7u)v5aPl~J1RuDt(kC5)l6afw)$ut$ z!|*AEvZ=*GwvHS+=ZlYE8}Zr8Od8|xk(%dX3qfCk{y*WRpHBaEBtE0?;SWe5X+pZ#_>d0#;d90>{G5c(7<~A{ z=k$g7Aq~gk!yi5;{NwN$kIw{r_`~ND@tK4V{e8S#Abvz8(()B0rOlNqTAEi>O|7U|+FZ44d2>reH3Gio6{Sn7mX_5pk9JI)+I3Mo zgVcUwBb_MW!XvRCaTH<6g#HAP6pKa+}+YcKFhz_H`|CFl7N~(+-GYDC8U3@TZ zhnW5kaZG>AsuArbujWKp#9 z1@VWGABk}f7Kt|#`^38ymx!5u z-@h&18$Dh8;>e96cef?-ru<2SK7Ex~GkT5q;g(7fe0Hpuz336~r4>mr>Gw;-x5lm) zzy8DbMBT-2i@(+Q#m>{O6z{kGUW|Tli3ps3jVLkB78idoTO7WpRoDl9C@TMQllawL zBgF;H*NZ^mZ^WfvXc2qzmxycXSBdn?N5t5!1;Xbm72iL5h5q!A Date: Wed, 23 Jun 2021 10:45:18 -0500 Subject: [PATCH 160/403] PERF: Remove n+1 in user directory (#13501) --- app/controllers/directory_items_controller.rb | 9 +++++++-- app/serializers/directory_item_serializer.rb | 13 +++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index abfe46e85e..ab82011f8a 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -9,7 +9,7 @@ class DirectoryItemsController < ApplicationController period = params.require(:period) period_type = DirectoryItem.period_types[period.to_sym] raise Discourse::InvalidAccess.new(:period_type) unless period_type - result = DirectoryItem.where(period_type: period_type).includes(:user) + result = DirectoryItem.where(period_type: period_type).includes(user: :user_custom_fields) if params[:group] group = Group.find_by(name: params[:group]) @@ -96,7 +96,12 @@ class DirectoryItemsController < ApplicationController serializer_opts = {} if params[:user_field_ids] - serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i) + serializer_opts[:user_custom_field_map] = {} + + user_field_ids = params[:user_field_ids]&.split("|")&.map(&:to_i) + user_field_ids.each do |user_field_id| + serializer_opts[:user_custom_field_map]["#{User::USER_FIELD_PREFIX}#{user_field_id}"] = user_field_id + end end if params[:plugin_column_ids] diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index 1e18f84c80..aa833e1669 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -8,11 +8,20 @@ class DirectoryItemSerializer < ApplicationSerializer attributes :user_fields def user_fields - object.user_fields(@options[:user_field_ids]) + fields = {} + + object.user_custom_fields.each do |cuf| + user_field_id = @options[:user_custom_field_map][cuf.name] + if user_field_id + fields[user_field_id] = cuf.value + end + end + + fields end def include_user_fields? - user_fields.present? + @options[:user_custom_field_map].present? end end From 7e5ad9aaaa988a990890bfaea75659953b43d1b6 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 24 Jun 2021 00:32:17 +0800 Subject: [PATCH 161/403] UX: improve lightbox gallery zoom/navigation (#13500) This PR improves navigation within lightboxes that contain multiple images for both touch and non-touch devices. Currently, if a gallery contains multiple large images, and you click on the one currently displayed, two things happen. 1. we zoom in 2. we navigate to the next image https://github.com/discourse/discourse/blob/a0bbc346cb5d5b89d1a3efdfa89869349a8b067f/app/assets/javascripts/discourse/app/lib/lightbox.js#L43-L49 So, you get taken to the next image, and it shows zoomed in, even when the intention was to zoom in on the previous image. Magnific popup has an option to disable image-click navigation in galleries. This PR toggles that on for non-touch devices. The result is that if you click on an image in a gallery on a non-touch device, we zoom in on that image instead of navigating to the next one. This has no impact on arrow/keyboard navigation. Magnific popup also has an API when images change; we reset the zoom class when that happens. So, when you navigate to the next image, it won't be zoomed in. For touch devices, clicking on the image will navigate to the next one without zooming in. Users can pinch-zoom if they want to see more details on touch devices. I used jQuery for this because both Magnific popup and our implementation for this are based on jQuery. No point making a few lines use vanilla for this when the rest doesn't. --- .../javascripts/discourse/app/lib/lightbox.js | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/lightbox.js b/app/assets/javascripts/discourse/app/lib/lightbox.js index 28563fb06d..a4d2e52d33 100644 --- a/app/assets/javascripts/discourse/app/lib/lightbox.js +++ b/app/assets/javascripts/discourse/app/lib/lightbox.js @@ -8,16 +8,21 @@ import User from "discourse/models/user"; import loadScript from "discourse/lib/load-script"; import { renderIcon } from "discourse-common/lib/icon-library"; import { spinnerHTML } from "discourse/helpers/loading-spinner"; +import { helperContext } from "discourse-common/lib/helpers"; export default function (elem, siteSettings) { if (!elem) { return; } + const caps = helperContext().capabilities; + const imageClickNavigation = caps.touch; + loadScript("/javascripts/jquery.magnific-popup.min.js").then(function () { const lightboxes = elem.querySelectorAll( "*:not(.spoiler):not(.spoiled) a.lightbox" ); + $(lightboxes).magnificPopup({ type: "image", closeOnContentClick: false, @@ -31,6 +36,7 @@ export default function (elem, siteSettings) { tPrev: I18n.t("lightbox.previous"), tNext: I18n.t("lightbox.next"), tCounter: I18n.t("lightbox.counter"), + navigateByImgClick: imageClickNavigation, }, ajax: { @@ -39,17 +45,19 @@ export default function (elem, siteSettings) { callbacks: { open() { - const wrap = this.wrap, - img = this.currItem.img, - maxHeight = img.css("max-height"); + if (!imageClickNavigation) { + const wrap = this.wrap, + img = this.currItem.img, + maxHeight = img.css("max-height"); - wrap.on("click.pinhandler", "img", function () { - wrap.toggleClass("mfp-force-scrollbars"); - img.css( - "max-height", - wrap.hasClass("mfp-force-scrollbars") ? "none" : maxHeight - ); - }); + wrap.on("click.pinhandler", "img", function () { + wrap.toggleClass("mfp-force-scrollbars"); + img.css( + "max-height", + wrap.hasClass("mfp-force-scrollbars") ? "none" : maxHeight + ); + }); + } if (isAppWebview()) { postRNWebviewMessage( @@ -58,6 +66,9 @@ export default function (elem, siteSettings) { ); } }, + change() { + this.wrap.removeClass("mfp-force-scrollbars"); + }, beforeClose() { this.wrap.off("click.pinhandler"); this.wrap.removeClass("mfp-force-scrollbars"); From d2c5165052fd8ef588bb16523d616bb2732ec379 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 23 Jun 2021 16:37:07 +0100 Subject: [PATCH 162/403] FIX: Check all migrations for dropped columns/tables during restore Previously only post-deploy migrations were being checked for DROPPED_(COLUMNS|TABLES) constants --- lib/backup_restore/database_restorer.rb | 7 ++++++- spec/lib/backup_restore/database_restorer_spec.rb | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/backup_restore/database_restorer.rb b/lib/backup_restore/database_restorer.rb index 100a366cd9..ae3ac7deaa 100644 --- a/lib/backup_restore/database_restorer.rb +++ b/lib/backup_restore/database_restorer.rb @@ -51,6 +51,11 @@ module BackupRestore end end + def self.core_migration_files + Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")] + + Dir[Rails.root.join("db/migrate/*.rb")] + end + protected def restore_dump @@ -154,7 +159,7 @@ module BackupRestore @created_functions_for_table_columns = [] all_readonly_table_columns = [] - Dir[Rails.root.join(Migration::SafeMigrate.post_migration_path, "**/*.rb")].each do |path| + DatabaseRestorer.core_migration_files.each do |path| require path class_name = File.basename(path, ".rb").sub(/^\d+_/, "").camelize migration_class = class_name.constantize diff --git a/spec/lib/backup_restore/database_restorer_spec.rb b/spec/lib/backup_restore/database_restorer_spec.rb index 351406e1e3..fbe6204609 100644 --- a/spec/lib/backup_restore/database_restorer_spec.rb +++ b/spec/lib/backup_restore/database_restorer_spec.rb @@ -200,7 +200,9 @@ describe BackupRestore::DatabaseRestorer do context "readonly functions" do before do - Migration::SafeMigrate.stubs(:post_migration_path).returns("spec/fixtures/db/post_migrate/drop_column") + BackupRestore::DatabaseRestorer.stubs(:core_migration_files).returns( + Dir[Rails.root.join("spec/fixtures/db/post_migrate/drop_column/**/*.rb")] + ) end it "doesn't try to drop function when no functions have been created" do From 49f39434c48c6f05ab2037f0f31c9da521401cfb Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 23 Jun 2021 13:31:58 +0100 Subject: [PATCH 163/403] DEV: Introduce script/promote_migrations tool Post-deploy migrations exist to allow for seamless Discourse upgrades. By design, they cause migrations to run out of numerical order. This has the potential to cause some unexpected edge cases. To reduce the likelihood of these edge cases, we will promote historical post_deploy migrations to regular migrations after a full Discourse stable release cycle. This script is intended to be run at least during every Discourse release cycle. This means that truly seamless upgrades will not be possible between non-consecutive Discourse versions. (Upgrades will still work, but may cause some server errors for users during the upgrade) --- script/promote_migrations | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100755 script/promote_migrations diff --git a/script/promote_migrations b/script/promote_migrations new file mode 100755 index 0000000000..a76e751bea --- /dev/null +++ b/script/promote_migrations @@ -0,0 +1,97 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This script will promote post_migrate files +# which have existed for more than one Discourse +# stable version cycle. +# +# Renames will be staged in git, but not committed +# +# Usage: +# script/promote_migrations [--dry-run] [--plugins] + +require 'open3' +require 'fileutils' + +VERSION_REGEX = %r{\/(\d+)_} +DRY_RUN = ARGV.include? '--dry-run' +PLUGINS = ARGV.include? '--plugins' + +def run(*args, capture: true) + out, s = Open3.capture2(*args) + if s.exitstatus != 0 + STDERR.puts "Command failed: '#{args.join(' ')}'" + exit 1 + end + out.strip +end + +current_version = run 'git describe --abbrev=0 --match "v*"' +puts "Current version is #{current_version}" + +run 'git fetch' +current_stable_version = + run 'git describe --abbrev=0 --match "v*" origin/stable' +puts "Current stable version is #{current_stable_version}" + +minor = current_stable_version[/^(v\d+\.\d+)\./, 1] + +previous_stable_version = + run "git describe --abbrev=0 --match 'v*' --exclude '#{minor}*' origin/stable" +puts "Previous stable version is #{previous_stable_version}" + +stable_post_migrate_filenames = + run( + 'git', + 'ls-tree', + '--name-only', + '-r', + previous_stable_version, + 'db/post_migrate' + ).split("\n") + +stable_post_migrate_filenames.sort! +latest_stable_post_migration = stable_post_migrate_filenames.last + +puts "The latest core post_migrate file in #{previous_stable_version} is #{latest_stable_post_migration}" +puts 'Promoting this, and all earlier post_migrates, to regular migrations' + +promote_threshold = latest_stable_post_migration[VERSION_REGEX, 1].to_i +current_post_migrations = + if PLUGINS + puts 'Looking in plugins...' + Dir.glob('plugins/*/db/post_migrate/*') + else + Dir.glob('db/post_migrate/*') + end + +if current_post_migrations.length == 0 + puts 'No post_migrate files found. All done' +end + +current_post_migrations.each do |path| + version = path[VERSION_REGEX, 1].to_i + file = File.basename(path) + dir = File.dirname(path) + + if version <= promote_threshold + print "Promoting #{path}..." + if DRY_RUN + puts ' (dry run)' + else + run 'mkdir', '-p', "#{dir}/../migrate" + run 'git', '-C', dir, 'mv', file, "../migrate/#{file}" + puts ' (done)' + end + end +end + +puts 'Done! File moves are staged and ready for commit.' +puts 'Suggested commit message:' +puts '-' * 20 +puts <<~MESSAGE +DEV: Promote historic post_deploy migrations + +This commit promotes all post_deploy migrations which existed in Discourse #{previous_stable_version} (timestamp <= #{promote_threshold}) +MESSAGE +puts '-' * 20 From 5968dc07a54a30bc4aa03e3ebdac8d2ce18f785f Mon Sep 17 00:00:00 2001 From: David Taylor Date: Wed, 23 Jun 2021 13:33:09 +0100 Subject: [PATCH 164/403] DEV: Promote historic post_deploy migrations This commit promotes all post_deploy migrations which existed in Discourse v2.6.7 (timestamp <= 20201110110952) --- ...20200715045152_remove_bookmarks_delete_when_reminder_sent.rb | 0 .../20200724060632_remove_deprecated_allowlist_settings.rb | 0 .../20200728004302_drop_path_whitelist_from_embeddable_hosts.rb | 0 ...20200818084329_update_private_message_on_post_search_data.rb | 0 .../20200903045539_add_index_topics_on_timestamps_private.rb | 0 .../20201110110952_drop_github_user_infos.rb | 0 ...5508_clear_last_gravatar_download_attempt_on_user_avatars.rb | 0 .../20180820080623_migrate_polls_data.rb | 0 plugins/poll/spec/db/post_migrate/migrate_polls_data_spec.rb | 2 +- 9 files changed, 1 insertion(+), 1 deletion(-) rename db/{post_migrate => migrate}/20200715045152_remove_bookmarks_delete_when_reminder_sent.rb (100%) rename db/{post_migrate => migrate}/20200724060632_remove_deprecated_allowlist_settings.rb (100%) rename db/{post_migrate => migrate}/20200728004302_drop_path_whitelist_from_embeddable_hosts.rb (100%) rename db/{post_migrate => migrate}/20200818084329_update_private_message_on_post_search_data.rb (100%) rename db/{post_migrate => migrate}/20200903045539_add_index_topics_on_timestamps_private.rb (100%) rename db/{post_migrate => migrate}/20201110110952_drop_github_user_infos.rb (100%) rename plugins/discourse-narrative-bot/db/{post_migrate => migrate}/20200520015508_clear_last_gravatar_download_attempt_on_user_avatars.rb (100%) rename plugins/poll/db/{post_migrate => migrate}/20180820080623_migrate_polls_data.rb (100%) diff --git a/db/post_migrate/20200715045152_remove_bookmarks_delete_when_reminder_sent.rb b/db/migrate/20200715045152_remove_bookmarks_delete_when_reminder_sent.rb similarity index 100% rename from db/post_migrate/20200715045152_remove_bookmarks_delete_when_reminder_sent.rb rename to db/migrate/20200715045152_remove_bookmarks_delete_when_reminder_sent.rb diff --git a/db/post_migrate/20200724060632_remove_deprecated_allowlist_settings.rb b/db/migrate/20200724060632_remove_deprecated_allowlist_settings.rb similarity index 100% rename from db/post_migrate/20200724060632_remove_deprecated_allowlist_settings.rb rename to db/migrate/20200724060632_remove_deprecated_allowlist_settings.rb diff --git a/db/post_migrate/20200728004302_drop_path_whitelist_from_embeddable_hosts.rb b/db/migrate/20200728004302_drop_path_whitelist_from_embeddable_hosts.rb similarity index 100% rename from db/post_migrate/20200728004302_drop_path_whitelist_from_embeddable_hosts.rb rename to db/migrate/20200728004302_drop_path_whitelist_from_embeddable_hosts.rb diff --git a/db/post_migrate/20200818084329_update_private_message_on_post_search_data.rb b/db/migrate/20200818084329_update_private_message_on_post_search_data.rb similarity index 100% rename from db/post_migrate/20200818084329_update_private_message_on_post_search_data.rb rename to db/migrate/20200818084329_update_private_message_on_post_search_data.rb diff --git a/db/post_migrate/20200903045539_add_index_topics_on_timestamps_private.rb b/db/migrate/20200903045539_add_index_topics_on_timestamps_private.rb similarity index 100% rename from db/post_migrate/20200903045539_add_index_topics_on_timestamps_private.rb rename to db/migrate/20200903045539_add_index_topics_on_timestamps_private.rb diff --git a/db/post_migrate/20201110110952_drop_github_user_infos.rb b/db/migrate/20201110110952_drop_github_user_infos.rb similarity index 100% rename from db/post_migrate/20201110110952_drop_github_user_infos.rb rename to db/migrate/20201110110952_drop_github_user_infos.rb diff --git a/plugins/discourse-narrative-bot/db/post_migrate/20200520015508_clear_last_gravatar_download_attempt_on_user_avatars.rb b/plugins/discourse-narrative-bot/db/migrate/20200520015508_clear_last_gravatar_download_attempt_on_user_avatars.rb similarity index 100% rename from plugins/discourse-narrative-bot/db/post_migrate/20200520015508_clear_last_gravatar_download_attempt_on_user_avatars.rb rename to plugins/discourse-narrative-bot/db/migrate/20200520015508_clear_last_gravatar_download_attempt_on_user_avatars.rb diff --git a/plugins/poll/db/post_migrate/20180820080623_migrate_polls_data.rb b/plugins/poll/db/migrate/20180820080623_migrate_polls_data.rb similarity index 100% rename from plugins/poll/db/post_migrate/20180820080623_migrate_polls_data.rb rename to plugins/poll/db/migrate/20180820080623_migrate_polls_data.rb diff --git a/plugins/poll/spec/db/post_migrate/migrate_polls_data_spec.rb b/plugins/poll/spec/db/post_migrate/migrate_polls_data_spec.rb index 018f50ded8..a22e819dd7 100644 --- a/plugins/poll/spec/db/post_migrate/migrate_polls_data_spec.rb +++ b/plugins/poll/spec/db/post_migrate/migrate_polls_data_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'rails_helper' -require_relative '../../../db/post_migrate/20180820080623_migrate_polls_data' +require_relative '../../../db/migrate/20180820080623_migrate_polls_data' RSpec.describe MigratePollsData do let!(:user) { Fabricate(:user, id: 1) } From a9175b77057cfbf315d9be30b693253948f2a9a7 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Wed, 23 Jun 2021 12:16:00 -0500 Subject: [PATCH 165/403] FIX: Manually update DirectoryItemSerializer attributes on directory column change (#13503) --- app/controllers/edit_directory_columns_controller.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/controllers/edit_directory_columns_controller.rb b/app/controllers/edit_directory_columns_controller.rb index b40d13ce66..b80b230868 100644 --- a/app/controllers/edit_directory_columns_controller.rb +++ b/app/controllers/edit_directory_columns_controller.rb @@ -30,11 +30,17 @@ class EditDirectoryColumnsController < ApplicationController end end + update_directory_item_serializer_attributes + render json: success_json end private + def update_directory_item_serializer_attributes + ::DirectoryItemSerializer.attributes(*DirectoryColumn.active_column_names) + end + def ensure_user_fields_have_columns user_fields_without_column = UserField.left_outer_joins(:directory_column) From 385535f421f9567f703463823ad865161cde0057 Mon Sep 17 00:00:00 2001 From: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> Date: Wed, 23 Jun 2021 12:42:16 -0500 Subject: [PATCH 166/403] UX: Hide email columns when `Hide Emails` is selected (#13502) * UX: Scroll user list container when emails are present --- .../javascripts/admin/addon/templates/users-list-show.hbs | 7 +++---- app/assets/stylesheets/common/admin/admin_base.scss | 3 +++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs index ff66a923a2..a5700094c7 100644 --- a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs @@ -19,12 +19,12 @@ {{text-field value=listFilter placeholder=searchHint}}
    -{{#load-more selector=".users-list tr" action=(action "loadMore")}} +{{#load-more class="users-list-container" selector=".users-list tr" action=(action "loadMore")}} {{#if model}}
    {{i18n "groups.requests.reason"}}
    {{table-header-toggle field="username" labelKey="username" order=order asc=asc}} - {{table-header-toggle field="email" labelKey="email" order=order asc=asc}} + {{table-header-toggle class=(if showEmails "" "hidden") field="email" labelKey="email" order=order asc=asc}} {{table-header-toggle field="last_emailed" labelKey="admin.users.last_emailed" order=order asc=asc}} {{table-header-toggle field="seen" labelKey="last_seen" order=order asc=asc}} {{table-header-toggle field="topics_viewed" labelKey="admin.user.topics_entered" order=order asc=asc}} @@ -48,7 +48,7 @@ {{d-icon "far-envelope" title="user.staged" }} {{/if}} -
    + {{~user.email~}} @@ -98,7 +98,6 @@
    {{conditional-loading-spinner condition=refreshing}} - {{else}}

    {{i18n "search.no_results"}}

    {{/if}} diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 98e741171d..f9e6186dfc 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -425,6 +425,9 @@ $mobile-breakpoint: 700px; .controls { @include clearfix; } + .users-list-container { + overflow-x: auto; + } } .admin-title { From 7c94efd6c9071cd6cb50f2647709658344b7ad45 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Wed, 23 Jun 2021 13:19:30 -0500 Subject: [PATCH 167/403] FIX: Table header translations on admin users list (#13505) --- .../admin/addon/templates/users-list-show.hbs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs index a5700094c7..849a9b5144 100644 --- a/app/assets/javascripts/admin/addon/templates/users-list-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/users-list-show.hbs @@ -23,14 +23,14 @@ {{#if model}} - {{table-header-toggle field="username" labelKey="username" order=order asc=asc}} - {{table-header-toggle class=(if showEmails "" "hidden") field="email" labelKey="email" order=order asc=asc}} - {{table-header-toggle field="last_emailed" labelKey="admin.users.last_emailed" order=order asc=asc}} - {{table-header-toggle field="seen" labelKey="last_seen" order=order asc=asc}} - {{table-header-toggle field="topics_viewed" labelKey="admin.user.topics_entered" order=order asc=asc}} - {{table-header-toggle field="posts_read" labelKey="admin.user.posts_read_count" order=order asc=asc}} - {{table-header-toggle field="read_time" labelKey="admin.user.time_read" order=order asc=asc}} - {{table-header-toggle field="created" labelKey="created" order=order asc=asc}} + {{table-header-toggle field="username" labelKey="username" order=order asc=asc automatic=true}} + {{table-header-toggle class=(if showEmails "" "hidden") field="email" labelKey="email" order=order asc=asc automatic=true}} + {{table-header-toggle field="last_emailed" labelKey="admin.users.last_emailed" order=order asc=asc automatic=true}} + {{table-header-toggle field="seen" labelKey="last_seen" order=order asc=asc automatic=true}} + {{table-header-toggle field="topics_viewed" labelKey="admin.user.topics_entered" order=order asc=asc automatic=true}} + {{table-header-toggle field="posts_read" labelKey="admin.user.posts_read_count" order=order asc=asc automatic=true}} + {{table-header-toggle field="read_time" labelKey="admin.user.time_read" order=order asc=asc automatic=true}} + {{table-header-toggle field="created" labelKey="created" order=order asc=asc automatic=true}} {{#if siteSettings.must_approve_users}} {{/if}} From 1034e5fa65827d54380acee15ca02b1925c7d98f Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Wed, 23 Jun 2021 14:53:09 -0400 Subject: [PATCH 168/403] FIX: increase max favorite badges to 6 (#13507) Limit was 5 with the assumption that trust level badge will be the 6th badge. With trust level badges disabled, it should be possible to increase this to 6, or even more imo. --- config/site_settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 12a645d55f..76da7d3704 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -311,7 +311,7 @@ basic: client: true default: 2 min: 0 - max: 5 + max: 6 enable_whispers: client: true default: false From 958340b6324d79e8f1884d0b82e33940cee89f69 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 23 Jun 2021 15:20:54 -0400 Subject: [PATCH 169/403] UX: Make it easier to hide the emoji on signup (#13509) --- app/assets/stylesheets/common/base/login.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index de4a6a82dd..671106a7ad 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -73,6 +73,7 @@ } .login-subheader { align-self: start; + grid-row-start: 2; margin: 0; } .waving-hand { From 1702922a7cf114e9ebe3e9bff98deb0271b3457d Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 23 Jun 2021 15:21:17 -0400 Subject: [PATCH 170/403] UX: Fix mobile alert spacing (#13506) --- app/assets/stylesheets/mobile/alert.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/stylesheets/mobile/alert.scss b/app/assets/stylesheets/mobile/alert.scss index 2e095549d6..ac30706214 100644 --- a/app/assets/stylesheets/mobile/alert.scss +++ b/app/assets/stylesheets/mobile/alert.scss @@ -1,6 +1,4 @@ .alert.alert-info { - margin-top: 1em; - margin-bottom: 0; &.clickable { // there are (n) new or updated topics, click to show margin-top: 0; From 60a76737dcbd2004960ae46cca718d916869e3ad Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Wed, 23 Jun 2021 14:55:17 -0500 Subject: [PATCH 171/403] FIX: Always serialize the correct attributes for DirectoryItems (#13510) --- app/controllers/directory_items_controller.rb | 5 ++- .../edit_directory_columns_controller.rb | 6 ---- app/serializers/directory_item_serializer.rb | 24 ++++++++----- app/serializers/directory_serializer.rb | 11 ------ .../directory_item_serializer_spec.rb | 36 +++++++++++++++++++ 5 files changed, 55 insertions(+), 27 deletions(-) delete mode 100644 app/serializers/directory_serializer.rb create mode 100644 spec/serializers/directory_item_serializer_spec.rb diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb index ab82011f8a..326fffc2ae 100644 --- a/app/controllers/directory_items_controller.rb +++ b/app/controllers/directory_items_controller.rb @@ -28,7 +28,8 @@ class DirectoryItemsController < ApplicationController order = params[:order] || DirectoryColumn.automatic_column_names.first dir = params[:asc] ? 'ASC' : 'DESC' - if DirectoryColumn.active_column_names.include?(order.to_sym) + active_directory_column_names = DirectoryColumn.active_column_names + if active_directory_column_names.include?(order.to_sym) result = result.order("directory_items.#{order} #{dir}, directory_items.id") elsif params[:order] === 'username' result = result.order("users.#{order} #{dir}, directory_items.id") @@ -108,6 +109,8 @@ class DirectoryItemsController < ApplicationController serializer_opts[:plugin_column_ids] = params[:plugin_column_ids]&.split("|")&.map(&:to_i) end + serializer_opts[:attributes] = active_directory_column_names + serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts) render_json_dump(directory_items: serialized, meta: { diff --git a/app/controllers/edit_directory_columns_controller.rb b/app/controllers/edit_directory_columns_controller.rb index b80b230868..b40d13ce66 100644 --- a/app/controllers/edit_directory_columns_controller.rb +++ b/app/controllers/edit_directory_columns_controller.rb @@ -30,17 +30,11 @@ class EditDirectoryColumnsController < ApplicationController end end - update_directory_item_serializer_attributes - render json: success_json end private - def update_directory_item_serializer_attributes - ::DirectoryItemSerializer.attributes(*DirectoryColumn.active_column_names) - end - def ensure_user_fields_have_columns user_fields_without_column = UserField.left_outer_joins(:directory_column) diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb index aa833e1669..c4923cbbd1 100644 --- a/app/serializers/directory_item_serializer.rb +++ b/app/serializers/directory_item_serializer.rb @@ -25,21 +25,27 @@ class DirectoryItemSerializer < ApplicationSerializer end end - attributes :id, - :time_read - has_one :user, embed: :objects, serializer: UserSerializer - attributes *DirectoryColumn.active_column_names + + attributes :id def id object.user_id end - def time_read - object.user_stat.time_read - end + private - def include_time_read? - object.period_type == DirectoryItem.period_types[:all] + def attributes + hash = super + + @options[:attributes].each do |attr| + hash.merge!("#{attr}": object[attr]) + end + + if object.period_type == DirectoryItem.period_types[:all] + hash.merge!(time_read: object.user_stat.time_read) + end + + hash end end diff --git a/app/serializers/directory_serializer.rb b/app/serializers/directory_serializer.rb deleted file mode 100644 index 320e525a0e..0000000000 --- a/app/serializers/directory_serializer.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class DirectorySerializer < ApplicationSerializer - attributes :id - has_many :directory_items, serializer: DirectoryItemSerializer, embed: :objects - - def id - object.filter - end - -end diff --git a/spec/serializers/directory_item_serializer_spec.rb b/spec/serializers/directory_item_serializer_spec.rb new file mode 100644 index 0000000000..733048f855 --- /dev/null +++ b/spec/serializers/directory_item_serializer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DirectoryItemSerializer do + fab!(:user) { Fabricate(:user) } + + before do + DirectoryItem.refresh! + end + + let :serializer do + directory_item = DirectoryItem.find_by(user: user, period_type: DirectoryItem.period_types[:all]) + DirectoryItemSerializer.new(directory_item, { attributes: DirectoryColumn.active_column_names }) + end + + it "Serializes attributes for enabled directory_columns" do + DirectoryColumn.update_all(enabled: true) + + payload = serializer.as_json + expect(payload[:directory_item].keys).to include(*DirectoryColumn.pluck(:name).map(&:to_sym)) + end + + it "Doesn't serialize attributes for disabled directory columns" do + DirectoryColumn.update_all(enabled: false) + directory_column = DirectoryColumn.first + directory_column.update(enabled: true) + + payload = serializer.as_json + expect(payload[:directory_item].keys.count).to eq(4) + expect(payload[:directory_item]).to have_key(directory_column.name.to_sym) + expect(payload[:directory_item]).to have_key(:id) + expect(payload[:directory_item]).to have_key(:user) + expect(payload[:directory_item]).to have_key(:time_read) + end +end From 046a875222405a3fd312cff6cf9adbb1393c279d Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Thu, 24 Jun 2021 00:09:40 +0200 Subject: [PATCH 172/403] DEV: Improve `script/downsize_uploads.rb` (#13508) * Only shrink images that are used in Posts and no other models * Don't save the upload if the size is the same --- app/jobs/scheduled/clean_up_uploads.rb | 29 +------------ app/models/upload.rb | 34 +++++++++++++++ lib/shrink_uploaded_image.rb | 60 +++++++------------------- script/downsize_uploads.rb | 6 ++- spec/lib/shrink_uploaded_image_spec.rb | 12 ++++++ spec/models/upload_spec.rb | 30 +++++++++++-- 6 files changed, 94 insertions(+), 77 deletions(-) diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index 7b3ee54f07..55223318d5 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -29,36 +29,9 @@ module Jobs .where("uploads.retain_hours IS NULL OR uploads.created_at < current_timestamp - interval '1 hour' * uploads.retain_hours") .where("uploads.created_at < ?", grace_period.hour.ago) .where("uploads.access_control_post_id IS NULL") - .joins(<<~SQL) - LEFT JOIN site_settings ss - ON NULLIF(ss.value, '')::integer = uploads.id - AND ss.data_type = #{SiteSettings::TypeSupervisor.types[:upload].to_i} - SQL .joins("LEFT JOIN post_uploads pu ON pu.upload_id = uploads.id") - .joins("LEFT JOIN users u ON u.uploaded_avatar_id = uploads.id") - .joins("LEFT JOIN user_avatars ua ON ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id") - .joins("LEFT JOIN user_profiles up ON up.profile_background_upload_id = uploads.id OR up.card_background_upload_id = uploads.id") - .joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id") - .joins("LEFT JOIN custom_emojis ce ON ce.upload_id = uploads.id") - .joins("LEFT JOIN theme_fields tf ON tf.upload_id = uploads.id") - .joins("LEFT JOIN user_exports ue ON ue.upload_id = uploads.id") - .joins("LEFT JOIN groups g ON g.flair_upload_id = uploads.id") - .joins("LEFT JOIN badges b ON b.image_upload_id = uploads.id") .where("pu.upload_id IS NULL") - .where("u.uploaded_avatar_id IS NULL") - .where("ua.gravatar_upload_id IS NULL AND ua.custom_upload_id IS NULL") - .where("up.profile_background_upload_id IS NULL AND up.card_background_upload_id IS NULL") - .where("c.uploaded_logo_id IS NULL AND c.uploaded_background_id IS NULL") - .where("ce.upload_id IS NULL") - .where("tf.upload_id IS NULL") - .where("ue.upload_id IS NULL") - .where("g.flair_upload_id IS NULL") - .where("b.image_upload_id IS NULL") - .where("ss.value IS NULL") - - if SiteSetting.selectable_avatars.present? - result = result.where.not(id: SiteSetting.selectable_avatars.map(&:id)) - end + .with_no_non_post_relations result.find_each do |upload| if upload.sha1.present? diff --git a/app/models/upload.rb b/app/models/upload.rb index 0f1902c21b..e660073eef 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -64,6 +64,40 @@ class Upload < ActiveRecord::Base ) end + def self.with_no_non_post_relations + scope = self + .joins(<<~SQL) + LEFT JOIN site_settings ss + ON NULLIF(ss.value, '')::integer = uploads.id + AND ss.data_type = #{SiteSettings::TypeSupervisor.types[:upload].to_i} + SQL + .where("ss.value IS NULL") + .joins("LEFT JOIN users u ON u.uploaded_avatar_id = uploads.id") + .where("u.uploaded_avatar_id IS NULL") + .joins("LEFT JOIN user_avatars ua ON ua.gravatar_upload_id = uploads.id OR ua.custom_upload_id = uploads.id") + .where("ua.gravatar_upload_id IS NULL AND ua.custom_upload_id IS NULL") + .joins("LEFT JOIN user_profiles up ON up.profile_background_upload_id = uploads.id OR up.card_background_upload_id = uploads.id") + .where("up.profile_background_upload_id IS NULL AND up.card_background_upload_id IS NULL") + .joins("LEFT JOIN categories c ON c.uploaded_logo_id = uploads.id OR c.uploaded_background_id = uploads.id") + .where("c.uploaded_logo_id IS NULL AND c.uploaded_background_id IS NULL") + .joins("LEFT JOIN custom_emojis ce ON ce.upload_id = uploads.id") + .where("ce.upload_id IS NULL") + .joins("LEFT JOIN theme_fields tf ON tf.upload_id = uploads.id") + .where("tf.upload_id IS NULL") + .joins("LEFT JOIN user_exports ue ON ue.upload_id = uploads.id") + .where("ue.upload_id IS NULL") + .joins("LEFT JOIN groups g ON g.flair_upload_id = uploads.id") + .where("g.flair_upload_id IS NULL") + .joins("LEFT JOIN badges b ON b.image_upload_id = uploads.id") + .where("b.image_upload_id IS NULL") + + if SiteSetting.selectable_avatars.present? + scope = scope.where.not(id: SiteSetting.selectable_avatars.map(&:id)) + end + + scope + end + def to_s self.url end diff --git a/lib/shrink_uploaded_image.rb b/lib/shrink_uploaded_image.rb index 483a325ae4..45817669d1 100644 --- a/lib/shrink_uploaded_image.rb +++ b/lib/shrink_uploaded_image.rb @@ -12,6 +12,20 @@ class ShrinkUploadedImage end def perform + # Neither #dup or #clone provide a complete copy + original_upload = Upload.find_by(id: upload.id) + unless original_upload + log "Upload is missing" + return false + end + + posts = Post.unscoped.joins(:post_uploads).where(post_uploads: { upload_id: original_upload.id }).uniq.sort_by(&:created_at) + + if posts.empty? + log "Upload not used in any posts" + return false + end + OptimizedImage.downsize(path, path, "#{@max_pixels}@", filename: upload.original_filename) sha1 = Upload.generate_digest(path) @@ -27,13 +41,6 @@ class ShrinkUploadedImage return false end - # Neither #dup or #clone provide a complete copy - original_upload = Upload.find_by(id: upload.id) - unless original_upload - log "Upload is missing" - return false - end - ww, hh = ImageSizer.resize(w, h) # A different upload record that matches the sha1 of the downsized image @@ -49,7 +56,7 @@ class ShrinkUploadedImage filesize: File.size(path) } - if upload.filesize > upload.filesize_was + if upload.filesize >= upload.filesize_was log "No filesize reduction" return false end @@ -70,7 +77,6 @@ class ShrinkUploadedImage log "(an existing upload)" if existing_upload success = true - posts = Post.unscoped.joins(:post_uploads).where(post_uploads: { upload_id: original_upload.id }).uniq.sort_by(&:created_at) posts.each do |post| transform_post(post, original_upload, upload) @@ -99,32 +105,6 @@ class ShrinkUploadedImage log "#{Discourse.base_url}/p/#{post.id}" end - if posts.empty? - log "Upload not used in any posts" - - if User.where(uploaded_avatar_id: original_upload.id).exists? - log "Used as a User avatar" - elsif UserAvatar.where(gravatar_upload_id: original_upload.id).exists? - log "Used as a UserAvatar gravatar" - elsif UserAvatar.where(custom_upload_id: original_upload.id).exists? - log "Used as a UserAvatar custom upload" - elsif UserProfile.where(profile_background_upload_id: original_upload.id).exists? - log "Used as a UserProfile profile background" - elsif UserProfile.where(card_background_upload_id: original_upload.id).exists? - log "Used as a UserProfile card background" - elsif Category.where(uploaded_logo_id: original_upload.id).exists? - log "Used as a Category logo" - elsif Category.where(uploaded_background_id: original_upload.id).exists? - log "Used as a Category background" - elsif CustomEmoji.where(upload_id: original_upload.id).exists? - log "Used as a CustomEmoji" - elsif ThemeField.where(upload_id: original_upload.id).exists? - log "Used as a ThemeField" - else - success = false - end - end - unless success if @interactive print "Press any key to continue with the upload" @@ -157,16 +137,6 @@ class ShrinkUploadedImage PostUpload.where(upload_id: original_upload.id).update_all(upload_id: upload.id) rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation end - - User.where(uploaded_avatar_id: original_upload.id).update_all(uploaded_avatar_id: upload.id) - UserAvatar.where(gravatar_upload_id: original_upload.id).update_all(gravatar_upload_id: upload.id) - UserAvatar.where(custom_upload_id: original_upload.id).update_all(custom_upload_id: upload.id) - UserProfile.where(profile_background_upload_id: original_upload.id).update_all(profile_background_upload_id: upload.id) - UserProfile.where(card_background_upload_id: original_upload.id).update_all(card_background_upload_id: upload.id) - Category.where(uploaded_logo_id: original_upload.id).update_all(uploaded_logo_id: upload.id) - Category.where(uploaded_background_id: original_upload.id).update_all(uploaded_background_id: upload.id) - CustomEmoji.where(upload_id: original_upload.id).update_all(upload_id: upload.id) - ThemeField.where(upload_id: original_upload.id).update_all(upload_id: upload.id) else upload.optimized_images.each(&:destroy!) end diff --git a/script/downsize_uploads.rb b/script/downsize_uploads.rb index 7fcb1e6431..ce7adc2efd 100644 --- a/script/downsize_uploads.rb +++ b/script/downsize_uploads.rb @@ -35,7 +35,11 @@ def process_uploads dimensions_count = 0 downsized_count = 0 - scope = Upload.by_users.where("LOWER(extension) IN ('jpg', 'jpeg', 'gif', 'png')") + scope = Upload + .by_users + .with_no_non_post_relations + .where("LOWER(extension) IN ('jpg', 'jpeg', 'gif', 'png')") + scope = scope.where(<<-SQL, MAX_IMAGE_PIXELS) COALESCE(width, 0) = 0 OR COALESCE(height, 0) = 0 OR diff --git a/spec/lib/shrink_uploaded_image_spec.rb b/spec/lib/shrink_uploaded_image_spec.rb index d343d63939..b5cb80a4ea 100644 --- a/spec/lib/shrink_uploaded_image_spec.rb +++ b/spec/lib/shrink_uploaded_image_spec.rb @@ -66,6 +66,18 @@ describe ShrinkUploadedImage do expect(result).to be(false) end + + it "returns false when the upload is not used in any posts" do + Fabricate(:user, uploaded_avatar: upload) + + result = ShrinkUploadedImage.new( + upload: upload, + path: Discourse.store.path_for(upload), + max_pixels: 10_000 + ).perform + + expect(result).to be(false) + end end context "when S3 uploads are enabled" do diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index 9071bac3fa..a628d2e705 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -3,9 +3,7 @@ require 'rails_helper' describe Upload do - let(:upload) { build(:upload) } - let(:user_id) { 1 } let(:image_filename) { "logo.png" } @@ -20,8 +18,34 @@ describe Upload do let(:attachment_path) { __FILE__ } let(:attachment) { File.new(attachment_path) } - context ".create_thumbnail!" do + describe '.with_no_non_post_relations' do + it "does not find non-post related uploads" do + post_upload = Fabricate(:upload) + post = Fabricate(:post, raw: "") + post.link_post_uploads + badge_upload = Fabricate(:upload) + Fabricate(:badge, image_upload: badge_upload) + + avatar_upload = Fabricate(:upload) + Fabricate(:user, uploaded_avatar: avatar_upload) + + site_setting_upload = Fabricate(:upload) + SiteSetting.create!( + name: "logo", + data_type: SiteSettings::TypeSupervisor.types[:upload], + value: site_setting_upload.id + ) + + upload_ids = Upload + .by_users + .with_no_non_post_relations + .pluck(:id) + expect(upload_ids).to eq([post_upload.id]) + end + end + + context ".create_thumbnail!" do it "does not create a thumbnail when disabled" do SiteSetting.create_thumbnails = false OptimizedImage.expects(:create_for).never From fd2aab09efbdb3e74faf9d12e75018fe2ef4c3ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jun 2021 10:54:32 +1000 Subject: [PATCH 173/403] Build(deps-dev): Bump test-prof from 1.0.5 to 1.0.6 (#13511) Bumps [test-prof](https://github.com/test-prof/test-prof) from 1.0.5 to 1.0.6. - [Release notes](https://github.com/test-prof/test-prof/releases) - [Changelog](https://github.com/test-prof/test-prof/blob/master/CHANGELOG.md) - [Commits](https://github.com/test-prof/test-prof/compare/v1.0.5...v1.0.6) --- updated-dependencies: - dependency-name: test-prof dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 762c1ee1a7..9adb3c0fdc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -440,7 +440,7 @@ GEM sprockets (>= 3.0.0) sshkey (2.0.0) stackprof (0.2.17) - test-prof (1.0.5) + test-prof (1.0.6) thor (1.1.0) tilt (2.0.10) tzinfo (2.0.4) From 0e4b8c5318569ef7e7a111563709699e3b9ce219 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Wed, 23 Jun 2021 15:21:11 +0800 Subject: [PATCH 174/403] PERF: Cache categories in Site model take 3. Previous attempt resulted in custom fields going missing in the serialized output. This reverts commit 83a6ad32ffe75ae222028feddeca169fc5be54ac. --- app/models/category.rb | 9 +++ app/models/category_tag.rb | 4 ++ app/models/category_tag_group.rb | 4 ++ app/models/concerns/has_custom_fields.rb | 9 +++ app/models/site.rb | 44 ++++++++++-- app/serializers/site_serializer.rb | 8 ++- lib/guardian/category_guardian.rb | 8 +++ spec/models/site_spec.rb | 91 +++++++++++++++++++----- spec/serializers/site_serializer_spec.rb | 34 +++++++-- 9 files changed, 181 insertions(+), 30 deletions(-) diff --git a/app/models/category.rb b/app/models/category.rb index 6692d2d1fd..86ba876f9f 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -91,6 +91,7 @@ class Category < ActiveRecord::Base after_commit :trigger_category_created_event, on: :create after_commit :trigger_category_updated_event, on: :update after_commit :trigger_category_destroyed_event, on: :destroy + after_commit :clear_site_cache after_save_commit :index_search @@ -957,6 +958,14 @@ class Category < ActiveRecord::Base result.map { |row| [row.group_id, row.permission_type] } end + + def clear_site_cache + Site.clear_cache + end + + def on_custom_fields_change + clear_site_cache + end end # == Schema Information diff --git a/app/models/category_tag.rb b/app/models/category_tag.rb index 1e21409c31..e9cba7c189 100644 --- a/app/models/category_tag.rb +++ b/app/models/category_tag.rb @@ -3,6 +3,10 @@ class CategoryTag < ActiveRecord::Base belongs_to :category belongs_to :tag + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/category_tag_group.rb b/app/models/category_tag_group.rb index 06e64ad65f..ea27bc50c1 100644 --- a/app/models/category_tag_group.rb +++ b/app/models/category_tag_group.rb @@ -3,6 +3,10 @@ class CategoryTagGroup < ActiveRecord::Base belongs_to :category belongs_to :tag_group + + after_commit do + Site.clear_cache + end end # == Schema Information diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index 31b096dcee..925bb8aceb 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -142,6 +142,11 @@ module HasCustomFields super end + def on_custom_fields_change + # Callback when custom fields have changed + # Override in model + end + def custom_fields_preloaded? !!@preloaded_custom_fields end @@ -197,8 +202,11 @@ module HasCustomFields if row_count == 0 _custom_fields.create!(name: k, value: v) end + custom_fields[k.to_s] = v # We normalize custom_fields as strings end + + on_custom_fields_change end def save_custom_fields(force = false) @@ -253,6 +261,7 @@ module HasCustomFields end end + on_custom_fields_change refresh_custom_fields_from_db end end diff --git a/app/models/site.rb b/app/models/site.rb index f8c131ef1d..ced763c06d 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -9,7 +9,6 @@ class Site def initialize(guardian) @guardian = guardian - Category.preload_custom_fields(categories, preloaded_category_custom_fields) if preloaded_category_custom_fields.present? end def site_setting @@ -28,16 +27,49 @@ class Site UserField.order(:position).all end - def categories - @categories ||= begin + CATEGORIES_CACHE_KEY = "site_categories" + + def self.clear_cache + Discourse.cache.delete(CATEGORIES_CACHE_KEY) + end + + def self.all_categories_cache + # Categories do not change often so there is no need for us to run the + # same query and spend time creating ActiveRecord objects for every requests. + # + # Do note that any new association added to the eager loading needs a + # corresponding ActiveRecord callback to clear the categories cache. + Discourse.cache.fetch(CATEGORIES_CACHE_KEY, expires_in: 30.minutes) do categories = Category - .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups) - .secured(@guardian) + .includes(:uploaded_logo, :uploaded_background, :tags, :tag_groups, :required_tag_group) .joins('LEFT JOIN topics t on t.id = categories.topic_id') .select('categories.*, t.slug topic_slug') .order(:position) + .to_a - categories = categories.to_a + if preloaded_category_custom_fields.present? + Category.preload_custom_fields( + categories, + preloaded_category_custom_fields + ) + end + + ActiveModel::ArraySerializer.new( + categories, + each_serializer: SiteCategorySerializer + ).as_json + end + end + + def categories + @categories ||= begin + categories = [] + + self.class.all_categories_cache.each do |category| + if @guardian.can_see_serialized_category?(category_id: category[:id], read_restricted: category[:read_restricted]) + categories << OpenStruct.new(category) + end + end with_children = Set.new categories.each do |c| diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 40da58e355..c7d8d5d66c 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -30,10 +30,10 @@ class SiteSerializer < ApplicationSerializer :shared_drafts_category_id, :custom_emoji_translation, :watched_words_replace, - :watched_words_link + :watched_words_link, + :categories ) - has_many :categories, serializer: SiteCategorySerializer, embed: :objects has_many :archetypes, embed: :objects, serializer: ArchetypeSerializer has_many :user_fields, embed: :objects, serializer: UserFieldSerializer has_many :auth_providers, embed: :objects, serializer: AuthProviderSerializer @@ -190,6 +190,10 @@ class SiteSerializer < ApplicationSerializer WordWatcher.word_matcher_regexps(:link) end + def categories + object.categories.map { |c| c.to_h } + end + private def ordered_flags(flags) diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index 4e9050f29a..ef8441071b 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -46,6 +46,14 @@ module CategoryGuardian nil end + def can_see_serialized_category?(category_id:, read_restricted: true) + # Guard to ensure only a boolean is passed in + read_restricted = true unless !!read_restricted == read_restricted + + return true if !read_restricted + secure_category_ids.include?(category_id) + end + def can_see_category?(category) return false unless category return true if is_admin? diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index 78b3faec63..8ea6e9bb28 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -58,30 +58,85 @@ describe Site do expect(Site.new(guardian).categories.last.notification_level).to eq(1) end - it "omits categories users can not write to from the category list" do - category = Fabricate(:category) - user = Fabricate(:user) + describe '#categories' do + fab!(:category) { Fabricate(:category) } + fab!(:user) { Fabricate(:user) } + fab!(:guardian) { Guardian.new(user) } - expect(Site.new(Guardian.new(user)).categories.count).to eq(2) + after do + Site.clear_cache + end - category.set_permissions(everyone: :create_post) - category.save + it "omits read restricted categories" do + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id, category.id + ) - guardian = Guardian.new(user) + category.update!(read_restricted: true) - expect(Site.new(guardian) - .categories - .keep_if { |c| c.name == category.name } - .first - .permission) - .not_to eq(CategoryGroup.permission_types[:full]) + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id + ) + end - # If a parent category is not visible, the child categories should not be returned - category.set_permissions(staff: :full) - category.save + it "includes categories that a user's group can see" do + group = Fabricate(:group) + category.update!(read_restricted: true) + category.groups << group - sub_category = Fabricate(:category, parent_category_id: category.id) - expect(Site.new(guardian).categories).not_to include(sub_category) + expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id + ) + + group.add(user) + + expect(Site.new(Guardian.new(user)).categories.map(&:id)).to contain_exactly( + SiteSetting.uncategorized_category_id, category.id + ) + end + + it "omits categories users can not write to from the category list" do + expect(Site.new(guardian).categories.count).to eq(2) + + category.set_permissions(everyone: :create_post) + category.save! + + guardian = Guardian.new(user) + + expect(Site.new(guardian) + .categories + .keep_if { |c| c.name == category.name } + .first + .permission) + .not_to eq(CategoryGroup.permission_types[:full]) + + # If a parent category is not visible, the child categories should not be returned + category.set_permissions(staff: :full) + category.save! + + sub_category = Fabricate(:category, parent_category_id: category.id) + expect(Site.new(guardian).categories).not_to include(sub_category) + end + + it 'should clear the cache when custom fields are updated' do + Site.preloaded_category_custom_fields << "enable_marketplace" + categories = Site.new(Guardian.new).categories + + expect(categories.last[:custom_fields]["enable_marketplace"]).to eq(nil) + + category.custom_fields["enable_marketplace"] = true + category.save_custom_fields + + categories = Site.new(Guardian.new).categories + + expect(categories.last[:custom_fields]["enable_marketplace"]).to eq('t') + + category.upsert_custom_fields(enable_marketplace: false) + + categories = Site.new(Guardian.new).categories + + expect(categories.last[:custom_fields]["enable_marketplace"]).to eq('f') + end end it "omits groups user can not see" do diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 1a53cfd11f..482905de36 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -6,17 +6,43 @@ describe SiteSerializer do let(:guardian) { Guardian.new } let(:category) { Fabricate(:category) } + after do + Site.clear_cache + end + it "includes category custom fields only if its preloaded" do category.custom_fields["enable_marketplace"] = true category.save_custom_fields - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).not_to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:custom_fields]).to eq(nil) Site.preloaded_category_custom_fields << "enable_marketplace" + Site.clear_cache - data = MultiJson.dump(described_class.new(Site.new(guardian), scope: guardian, root: false)) - expect(data).to include("enable_marketplace") + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:custom_fields]["enable_marketplace"]).to eq("t") + end + + it "includes category tags" do + tag = Fabricate(:tag) + tag_group = Fabricate(:tag_group) + tag_group_2 = Fabricate(:tag_group) + + category.tags << tag + category.tag_groups << tag_group + category.update!(required_tag_group: tag_group_2) + + serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json + c1 = serialized[:categories].find { |c| c[:id] == category.id } + + expect(c1[:allowed_tags]).to contain_exactly(tag.name) + expect(c1[:allowed_tag_groups]).to contain_exactly(tag_group.name) + expect(c1[:required_tag_group_name]).to eq(tag_group_2.name) end it "returns correct notification level for categories" do From 2654a6685cea512ef8e3f8aea29079dc617775fd Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Thu, 24 Jun 2021 11:35:36 +0200 Subject: [PATCH 175/403] DEV: adds support for bannered until (#13417) ATM it only implements server side of it, as my need is for automation purposes. However it should probably be added in the UI too as it's unexpected to have pinned_until and no bannered_until. --- app/controllers/topics_controller.rb | 2 + app/jobs/regular/remove_banner.rb | 18 +++++++ app/jobs/regular/unpin_topic.rb | 2 +- app/models/topic.rb | 35 ++++++++++--- .../20210621103509_add_bannered_until.rb | 9 ++++ ...24080131_add_partial_index_pinned_until.rb | 11 ++++ spec/jobs/remove_banner_spec.rb | 51 +++++++++++++++++++ spec/models/topic_spec.rb | 18 +++++++ 8 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 app/jobs/regular/remove_banner.rb create mode 100644 db/migrate/20210621103509_add_bannered_until.rb create mode 100644 db/migrate/20210624080131_add_partial_index_pinned_until.rb create mode 100644 spec/jobs/remove_banner_spec.rb diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 7f5e36a457..7089f89ee0 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -435,6 +435,8 @@ class TopicsController < ApplicationController guardian.ensure_can_moderate!(@topic) end + params[:until] === '' ? params[:until] = nil : params[:until] + @topic.update_status(status, enabled, current_user, until: params[:until]) render json: success_json.merge!( diff --git a/app/jobs/regular/remove_banner.rb b/app/jobs/regular/remove_banner.rb new file mode 100644 index 0000000000..880c8696c9 --- /dev/null +++ b/app/jobs/regular/remove_banner.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Jobs + + class RemoveBanner < ::Jobs::Base + + def execute(args) + topic_id = args[:topic_id] + + return unless topic_id.present? + + topic = Topic.find_by(id: topic_id) + topic.remove_banner!(Discourse.system_user) if topic.present? + end + + end + +end diff --git a/app/jobs/regular/unpin_topic.rb b/app/jobs/regular/unpin_topic.rb index b99e505904..9d4f6b93ee 100644 --- a/app/jobs/regular/unpin_topic.rb +++ b/app/jobs/regular/unpin_topic.rb @@ -7,7 +7,7 @@ module Jobs def execute(args) topic_id = args[:topic_id] - raise Discourse::InvalidParameters.new(:topic_id) unless topic_id.present? + return unless topic_id.present? topic = Topic.find_by(id: topic_id) topic.update_pinned(false) if topic.present? diff --git a/app/models/topic.rb b/app/models/topic.rb index 32c004ed0a..00a3eacaa8 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1093,7 +1093,15 @@ class Topic < ActiveRecord::Base @participants_summary ||= TopicParticipantsSummary.new(self, options).summary end - def make_banner!(user) + def make_banner!(user, bannered_until = nil) + if bannered_until + bannered_until = begin + Time.parse(bannered_until) + rescue ArgumentError + raise Discourse::InvalidParameters.new(:bannered_until) + end + end + # only one banner at the same time previous_banner = Topic.where(archetype: Archetype.banner).first previous_banner.remove_banner!(user) if previous_banner.present? @@ -1102,18 +1110,25 @@ class Topic < ActiveRecord::Base .update_all(dismissed_banner_key: nil) self.archetype = Archetype.banner + self.bannered_until = bannered_until self.add_small_action(user, "banner.enabled") self.save MessageBus.publish('/site/banner', banner) + + Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id) + Jobs.enqueue_at(bannered_until, :remove_banner, topic_id: self.id) if bannered_until end def remove_banner!(user) self.archetype = Archetype.default + self.bannered_until = nil self.add_small_action(user, "banner.disabled") self.save MessageBus.publish('/site/banner', nil) + + Jobs.cancel_scheduled_job(:remove_banner, topic_id: self.id) end def banner @@ -1199,12 +1214,13 @@ class Topic < ActiveRecord::Base TopicUser.change(user.id, id, cleared_pinned_at: nil) end - def update_pinned(status, global = false, pinned_until = "") - pinned_until ||= '' - - pinned_until = begin - Time.parse(pinned_until) - rescue ArgumentError + def update_pinned(status, global = false, pinned_until = nil) + if pinned_until + pinned_until = begin + Time.parse(pinned_until) + rescue ArgumentError + raise Discourse::InvalidParameters.new(:pinned_until) + end end update_columns( @@ -1233,7 +1249,10 @@ class Topic < ActiveRecord::Base def self.ensure_consistency! # unpin topics that might have been missed - Topic.where("pinned_until < now()").update_all(pinned_at: nil, pinned_globally: false, pinned_until: nil) + Topic.where('pinned_until < ?', Time.now).update_all(pinned_at: nil, pinned_globally: false, pinned_until: nil) + Topic.where('bannered_until < ?', Time.now).find_each do |topic| + topic.remove_banner!(Discourse.system_user) + end end def inherit_auto_close_from_category(timer_type: :close) diff --git a/db/migrate/20210621103509_add_bannered_until.rb b/db/migrate/20210621103509_add_bannered_until.rb new file mode 100644 index 0000000000..6a48cf416e --- /dev/null +++ b/db/migrate/20210621103509_add_bannered_until.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddBanneredUntil < ActiveRecord::Migration[6.1] + def change + add_column :topics, :bannered_until, :datetime, null: true + + add_index :topics, :bannered_until, where: 'bannered_until IS NOT NULL' + end +end diff --git a/db/migrate/20210624080131_add_partial_index_pinned_until.rb b/db/migrate/20210624080131_add_partial_index_pinned_until.rb new file mode 100644 index 0000000000..c91cdb4523 --- /dev/null +++ b/db/migrate/20210624080131_add_partial_index_pinned_until.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddPartialIndexPinnedUntil < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :topics, :pinned_until, + where: 'pinned_until IS NOT NULL', + algorithm: :concurrently + end +end diff --git a/spec/jobs/remove_banner_spec.rb b/spec/jobs/remove_banner_spec.rb new file mode 100644 index 0000000000..13c60b87e9 --- /dev/null +++ b/spec/jobs/remove_banner_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Jobs::RemoveBanner do + fab!(:topic) { Fabricate(:topic) } + fab!(:user) { topic.user } + + context 'topic is not bannered until' do + it 'doesn’t enqueue a future job to remove it' do + expect do + topic.make_banner!(user) + end.to change { Jobs::RemoveBanner.jobs.size }.by(0) + end + end + + context 'topic is bannered until' do + context 'bannered_until is a valid date' do + it 'enqueues a future job to remove it' do + bannered_until = 5.days.from_now + + expect(topic.archetype).to eq(Archetype.default) + + expect do + topic.make_banner!(user, bannered_until.to_s) + end.to change { Jobs::RemoveBanner.jobs.size }.by(1) + + topic.reload + expect(topic.archetype).to eq(Archetype.banner) + + job = Jobs::RemoveBanner.jobs[0] + expect(Time.at(job['at'])).to be_within_one_minute_of(bannered_until) + expect(job['args'][0]['topic_id']).to eq(topic.id) + + job['class'].constantize.new.perform(*job['args']) + topic.reload + expect(topic.archetype).to eq(Archetype.default) + end + end + + context 'bannered_until is an invalid date' do + it 'doesn’t enqueue a future job to remove it' do + expect do + expect do + topic.make_banner!(user, 'xxx') + end.to raise_error(Discourse::InvalidParameters) + end.to change { Jobs::RemoveBanner.jobs.size }.by(0) + end + end + end +end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index b797e1cb07..efd5a6026c 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1380,6 +1380,24 @@ describe Topic do end + context "bannered_until date" do + + it 'sets bannered_until to be caught by ensure_consistency' do + bannered_until = 5.days.from_now + topic.make_banner!(user, bannered_until.to_s) + + freeze_time 6.days.from_now do + expect(topic.archetype).to eq(Archetype.banner) + + Topic.ensure_consistency! + topic.reload + + expect(topic.archetype).to eq(Archetype.default) + end + end + + end + end context 'last_poster info' do From cf1e8b27640721f2eff9d6c8244a0fef867e8f4c Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Thu, 24 Jun 2021 14:13:38 +0400 Subject: [PATCH 176/403] FEATURE: Accept the flag modal on CTRL + ENTER and CMD + ENTER (#13497) We want to submit the flag modal on pressing CTRL + ENTER and CMD + ENTER. Here's how our modals work: Every modal can be dismissed by pressing ESC. This behaviour can be disabled for a specific modal if we need to. Every modal can be submitted by pressing ENTER if the cursor wasn't on a text area or a form at the moment of pressing. Now, the flag modal is actually a one big form and pressing ENTER doesn't submit it. I've added submitting by CTRL+ENTER but at first it was interfering with the basic modal submitting by ENTER. It's a pretty tricky thing to fix because we use the keyup event for submitting by ENTER and we need to use the keydown event for submitting with modifiers (because submitting by CMD+ENTER on Macs doesn't work with keyup). Eventually, I fixed the problem just by adding a possibility to disable default submitting on ENTER (in the same way as we already have the possibility of disabling dismissing on ESC). Then I disabled default submitting for the flag form and implemented submitting by CTRL+ENTER and CMD+ENTER. This way everything is simple and robust. I did it only for the flag modal but it'll be easy and safe to add the same behaviour to another modal. --- .../discourse/app/components/d-modal-body.js | 2 + .../discourse/app/components/d-modal.js | 11 ++++- .../discourse/app/controllers/flag.js | 24 +++++++++- .../discourse/app/templates/modal/flag.hbs | 2 +- .../tests/acceptance/flag-post-test.js | 44 ++++++++++++++++++- 5 files changed, 79 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/d-modal-body.js b/app/assets/javascripts/discourse/app/components/d-modal-body.js index 27e092191b..120d00e23c 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal-body.js +++ b/app/assets/javascripts/discourse/app/components/d-modal-body.js @@ -3,6 +3,7 @@ import { scheduleOnce } from "@ember/runloop"; export default Component.extend({ classNames: ["modal-body"], fixed: false, + submitOnEnter: true, dismissable: true, autoFocus: true, @@ -49,6 +50,7 @@ export default Component.extend({ "fixed", "subtitle", "rawSubtitle", + "submitOnEnter", "dismissable", "headerClass", "autoFocus" diff --git a/app/assets/javascripts/discourse/app/components/d-modal.js b/app/assets/javascripts/discourse/app/components/d-modal.js index 533e03a29b..652035b467 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.js +++ b/app/assets/javascripts/discourse/app/components/d-modal.js @@ -19,6 +19,7 @@ export default Component.extend({ "role", "ariaLabelledby:aria-labelledby", ], + submitOnEnter: true, dismissable: true, title: null, subtitle: null, @@ -48,7 +49,7 @@ export default Component.extend({ @on("didInsertElement") setUp() { $("html").on("keyup.discourse-modal", (e) => { - //only respond to events when the modal is visible + // only respond to events when the modal is visible if (!this.element.classList.contains("hidden")) { if (e.which === 27 && this.dismissable) { next(() => this.attrs.closeModal("initiatedByESC")); @@ -70,6 +71,10 @@ export default Component.extend({ }, triggerClickOnEnter(e) { + if (!this.submitOnEnter) { + return false; + } + // skip when in a form or a textarea element if ( e.target.closest("form") || @@ -124,6 +129,10 @@ export default Component.extend({ this.set("subtitle", null); } + if ("submitOnEnter" in data) { + this.set("submitOnEnter", data.submitOnEnter); + } + if ("dismissable" in data) { this.set("dismissable", data.dismissable); } else { diff --git a/app/assets/javascripts/discourse/app/controllers/flag.js b/app/assets/javascripts/discourse/app/controllers/flag.js index 4872698dc6..87dbf1c8f7 100644 --- a/app/assets/javascripts/discourse/app/controllers/flag.js +++ b/app/assets/javascripts/discourse/app/controllers/flag.js @@ -1,3 +1,4 @@ +import { schedule } from "@ember/runloop"; import ActionSummary from "discourse/models/action-summary"; import Controller from "@ember/controller"; import EmberObject from "@ember/object"; @@ -6,7 +7,7 @@ import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { Promise } from "rsvp"; import User from "discourse/models/user"; -import discourseComputed from "discourse-common/utils/decorators"; +import discourseComputed, { bind } from "discourse-common/utils/decorators"; import { not } from "@ember/object/computed"; import optionalService from "discourse/lib/optional-service"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -52,6 +53,17 @@ export default Controller.extend(ModalFunctionality, { }; }, + @bind + keyDown(event) { + // CTRL+ENTER or CMD+ENTER + if (event.keyCode === 13 && (event.ctrlKey || event.metaKey)) { + if (this.submitEnabled) { + this.send("createFlag"); + return false; + } + } + }, + clientSuspend(performAction) { this._penalize("showSuspendModal", performAction); }, @@ -85,6 +97,16 @@ export default Controller.extend(ModalFunctionality, { this.set("spammerDetails", result); }); } + + schedule("afterRender", () => { + const element = document.querySelector(".flag-modal"); + element.addEventListener("keydown", this.keyDown); + }); + }, + + onClose() { + const element = document.querySelector(".flag-modal"); + element.removeEventListener("keydown", this.keyDown); }, @discourseComputed("spammerDetails.canDelete", "selected.name_key") diff --git a/app/assets/javascripts/discourse/app/templates/modal/flag.hbs b/app/assets/javascripts/discourse/app/templates/modal/flag.hbs index 0cea23d91f..4e11f469a1 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/flag.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/flag.hbs @@ -1,4 +1,4 @@ -{{#d-modal-body class="flag-modal-body" title=title}} +{{#d-modal-body class="flag-modal-body" title=title submitOnEnter=false}} {{#flag-selection nameKey=selected.name_key flags=flagsAvailable as |f|}} {{flag-action-type diff --git a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js index ef40fe01c4..926e27b969 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/flag-post-test.js @@ -2,11 +2,13 @@ import { acceptance, count, exists, + query, } from "discourse/tests/helpers/qunit-helpers"; import { click, visit } from "@ember/test-helpers"; import selectKit from "discourse/tests/helpers/select-kit-helper"; -import { test } from "qunit"; +import { skip, test } from "qunit"; import userFixtures from "discourse/tests/fixtures/user-fixtures"; +import { run } from "@ember/runloop"; async function openFlagModal() { if (exists(".topic-post:first-child button.show-more-actions")) { @@ -15,6 +17,14 @@ async function openFlagModal() { await click(".topic-post:first-child button.create-flag"); } +function keyDown(element, keyCode, modifier) { + const event = document.createEvent("Event"); + event.initEvent("keydown", true, true); + event.keyCode = 13; + event[modifier] = true; + run(() => element.dispatchEvent(event)); +} + acceptance("flagging", function (needs) { needs.user(); needs.pretender((server, helper) => { @@ -142,4 +152,36 @@ acceptance("flagging", function (needs) { await click(".modal-footer .btn-primary"); assert.ok(!exists(".bootbox.modal:visible")); }); + + skip("CTRL + ENTER accepts the modal", async function (assert) { + await visit("/t/internationalization-localization/280"); + await openFlagModal(); + + const modal = query("#discourse-modal"); + keyDown(modal, 13, "ctrlKey"); + assert.ok( + exists("#discourse-modal:visible"), + "The modal wasn't closed because the accept button was disabled" + ); + + await click("#radio_inappropriate"); // this enables the accept button + keyDown(modal, 13, "ctrlKey"); + assert.ok(!exists("#discourse-modal:visible"), "The modal was closed"); + }); + + skip("CMD or WINDOWS-KEY + ENTER accepts the modal", async function (assert) { + await visit("/t/internationalization-localization/280"); + await openFlagModal(); + + const modal = query("#discourse-modal"); + keyDown(modal, 13, "metaKey"); + assert.ok( + exists("#discourse-modal:visible"), + "The modal wasn't closed because the accept button was disabled" + ); + + await click("#radio_inappropriate"); // this enables the accept button + keyDown(modal, 13, "ctrlKey"); + assert.ok(!exists("#discourse-modal:visible"), "The modal was closed"); + }); }); From 20070f5089e1fe591a3f7bdb341e4d6e3052afaf Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 24 Jun 2021 13:57:23 +0100 Subject: [PATCH 177/403] DEV: Update script/promote_migrations (#13513) Introduces the --plugins-base flag for updating plugins in a different directory. Followup to 49f39434c48c6f05ab2037f0f31c9da521401cfb --- script/promote_migrations | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/script/promote_migrations b/script/promote_migrations index a76e751bea..a57ddde1a8 100755 --- a/script/promote_migrations +++ b/script/promote_migrations @@ -14,8 +14,19 @@ require 'open3' require 'fileutils' VERSION_REGEX = %r{\/(\d+)_} -DRY_RUN = ARGV.include? '--dry-run' -PLUGINS = ARGV.include? '--plugins' + +DRY_RUN = !!ARGV.delete('--dry-run') + +if i = ARGV.find_index('--plugins-base') + ARGV.delete_at(i) + PLUGINS = true + PLUGINS_BASE = ARGV.delete_at(i) +elsif ARV.delete('--plugins') + PLUGINS = true + PLUGINS_BASE = 'plugins' +end + +raise "Unknown arguments: #{ARGV.join(', ')}" if ARGV.length > 0 def run(*args, capture: true) out, s = Open3.capture2(*args) @@ -60,7 +71,7 @@ promote_threshold = latest_stable_post_migration[VERSION_REGEX, 1].to_i current_post_migrations = if PLUGINS puts 'Looking in plugins...' - Dir.glob('plugins/*/db/post_migrate/*') + Dir.glob("#{PLUGINS_BASE}/**/db/post_migrate/*") else Dir.glob('db/post_migrate/*') end From 2c918a31617c876481a4cd046a4fd7ff4b987a0d Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Thu, 24 Jun 2021 10:02:56 -0300 Subject: [PATCH 178/403] FEATURE: Staff can receive pending user reminders more frequently. (#13422) * FEATURE: Staff can receive pending user reminders more frequently. We now express the "pending_users_reminder_delay" in minutes instead of hours so staff can have finer control over the delay. We need to keep in mind that the reminders could still take up to 20 minutes, even when using a lower value. We send them from a scheduled job. * Migrate to a new site setting for the reminders delay --- app/jobs/scheduled/pending_users_reminder.rb | 8 +++---- config/locales/server.en.yml | 2 +- config/site_settings.yml | 4 ++-- ...te_pending_users_reminder_delay_setting.rb | 22 +++++++++++++++++++ spec/jobs/pending_users_reminder_spec.rb | 19 ++++++++-------- 5 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 db/migrate/20210621190335_migrate_pending_users_reminder_delay_setting.rb diff --git a/app/jobs/scheduled/pending_users_reminder.rb b/app/jobs/scheduled/pending_users_reminder.rb index 33d212f3b2..0d8a2d393f 100644 --- a/app/jobs/scheduled/pending_users_reminder.rb +++ b/app/jobs/scheduled/pending_users_reminder.rb @@ -3,13 +3,13 @@ module Jobs class PendingUsersReminder < ::Jobs::Scheduled - every 1.hour + every 5.minutes def execute(args) - if SiteSetting.must_approve_users && SiteSetting.pending_users_reminder_delay >= 0 + if SiteSetting.must_approve_users && SiteSetting.pending_users_reminder_delay_minutes >= 0 query = AdminUserIndexQuery.new(query: 'pending', stats: false).find_users_query # default order is: users.created_at DESC - if SiteSetting.pending_users_reminder_delay > 0 - query = query.where('users.created_at < ?', SiteSetting.pending_users_reminder_delay.hours.ago) + if SiteSetting.pending_users_reminder_delay_minutes > 0 + query = query.where('users.created_at < ?', SiteSetting.pending_users_reminder_delay_minutes.minutes.ago) end newest_username = query.limit(1).select(:username).first&.username diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d7445c4448..404d3f96de 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1601,7 +1601,7 @@ en: invite_code: "User must type this code to be allowed account registration, ignored when empty (case-insensitive)" approve_suspect_users: "Add suspicious users to the review queue. Suspicious users have entered a bio/website but have no reading activity." review_every_post: "All posts must be reviewed. WARNING! NOT RECOMMENDED FOR BUSY SITES." - pending_users_reminder_delay: "Notify moderators if new users have been waiting for approval for longer than this many hours. Set to -1 to disable notifications." + pending_users_reminder_delay_minutes: "Notify moderators if new users have been waiting for approval for longer than this many minutes. Set to -1 to disable notifications." persistent_sessions: "Users will remain logged in when the web browser is closed" maximum_session_age: "User will remain logged in for n hours since last visit" ga_version: "Version of Google Universal Analytics to use: v3 (analytics.js), v4 (gtag)" diff --git a/config/site_settings.yml b/config/site_settings.yml index 76da7d3704..c9f957e8a6 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -500,9 +500,9 @@ login: list_type: simple hide_email_address_taken: false log_out_strict: false - pending_users_reminder_delay: + pending_users_reminder_delay_minutes: min: -1 - default: 8 + default: 480 persistent_sessions: true maximum_session_age: default: 1440 diff --git a/db/migrate/20210621190335_migrate_pending_users_reminder_delay_setting.rb b/db/migrate/20210621190335_migrate_pending_users_reminder_delay_setting.rb new file mode 100644 index 0000000000..a1cd158c9d --- /dev/null +++ b/db/migrate/20210621190335_migrate_pending_users_reminder_delay_setting.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class MigratePendingUsersReminderDelaySetting < ActiveRecord::Migration[6.1] + def up + setting_value = DB.query_single("SELECT value FROM site_settings WHERE name = 'pending_users_reminder_delay'").first + + if setting_value.present? + new_value = setting_value.to_i + new_value = new_value > 0 ? new_value * 60 : new_value + + DB.exec(<<~SQL, delay: new_value) + INSERT INTO site_settings (name, data_type, value, created_at, updated_at) + VALUES ('pending_users_reminder_delay_minutes', 3, :delay, NOW(), NOW()) + SQL + + DB.exec("DELETE FROM site_settings WHERE name = 'pending_users_reminder_delay'") + end + end + + def down + end +end diff --git a/spec/jobs/pending_users_reminder_spec.rb b/spec/jobs/pending_users_reminder_spec.rb index 1f14b9210b..4d958433bb 100644 --- a/spec/jobs/pending_users_reminder_spec.rb +++ b/spec/jobs/pending_users_reminder_spec.rb @@ -21,29 +21,30 @@ describe Jobs::PendingUsersReminder do Group.refresh_automatic_group!(:moderators) end - it "sends a message if user was created more than pending_users_reminder_delay hours ago" do - SiteSetting.pending_users_reminder_delay = 8 - Fabricate(:user, created_at: 9.hours.ago) + it "sends a message if user was created more than pending_users_reminder_delay minutes ago" do + SiteSetting.pending_users_reminder_delay_minutes = 8 + Fabricate(:user, created_at: 9.minutes.ago) PostCreator.expects(:create).once Jobs::PendingUsersReminder.new.execute({}) end - it "doesn't send a message if user was created less than pending_users_reminder_delay hours ago" do - SiteSetting.pending_users_reminder_delay = 8 - Fabricate(:user, created_at: 2.hours.ago) + it "doesn't send a message if user was created less than pending_users_reminder_delay minutes ago" do + SiteSetting.pending_users_reminder_delay_minutes = 8 + Fabricate(:user, created_at: 2.minutes.ago) PostCreator.expects(:create).never Jobs::PendingUsersReminder.new.execute({}) end it "doesn't send a message if pending_users_reminder_delay is -1" do - SiteSetting.pending_users_reminder_delay = -1 + SiteSetting.pending_users_reminder_delay_minutes = -1 + Fabricate(:user, created_at: 24.hours.ago) PostCreator.expects(:create).never Jobs::PendingUsersReminder.new.execute({}) end it "sets the correct pending user count in the notification" do - SiteSetting.pending_users_reminder_delay = 8 - Fabricate(:user, created_at: 9.hours.ago) + SiteSetting.pending_users_reminder_delay_minutes = 8 + Fabricate(:user, created_at: 9.minutes.ago) PostCreator.expects(:create).with(Discourse.system_user, has_entries(title: '1 user waiting for approval')) Jobs::PendingUsersReminder.new.execute({}) end From a2b744ae2530aab0e07b96a3e247ef5cbedc674c Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Mon, 21 Jun 2021 15:52:15 -0400 Subject: [PATCH 179/403] DEV: Allow plugin tests to run in Ember CLI qunit --- app/assets/javascripts/discourse/ember-cli-build.js | 3 +++ .../discourse/public/assets/scripts/discourse-boot.js | 6 ++++++ app/assets/javascripts/discourse/tests/index.html | 4 ++++ .../tests/integration/components/bookmark-test.js | 11 ++++++----- .../javascripts/acceptance/poll-results-test.js.es6 | 2 ++ .../widgets/discourse-poll-option-test.js.es6 | 3 ++- .../discourse-poll-standard-results-test.js.es6 | 3 ++- .../javascripts/widgets/discourse-poll-test.js.es6 | 5 ++--- 8 files changed, 27 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/discourse/ember-cli-build.js b/app/assets/javascripts/discourse/ember-cli-build.js index 4650fb5292..29e9a396f6 100644 --- a/app/assets/javascripts/discourse/ember-cli-build.js +++ b/app/assets/javascripts/discourse/ember-cli-build.js @@ -37,6 +37,9 @@ module.exports = function (defaults) { app.import(vendorJs + "jquery.fileupload-process.js"); app.import(vendorJs + "jquery.autoellipsis-1.0.10.js"); app.import(vendorJs + "show-html.js"); + app.import("node_modules/ember-source/dist/ember-template-compiler.js", { + type: "test", + }); let adminVendor = funnel(vendorJs, { files: ["resumable.js"], diff --git a/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js index a4811cab76..f33a20a7bd 100644 --- a/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js +++ b/app/assets/javascripts/discourse/public/assets/scripts/discourse-boot.js @@ -166,6 +166,12 @@ define("I18n", ["exports"], function (exports) { return I18n; }); + + define("htmlbars-inline-precompile", ["exports"], function (exports) { + exports.default = function tag(strings) { + return Ember.Handlebars.compile(strings[0]); + }; + }); window.__widget_helpers = require("discourse-widget-hbs/helpers").default; // TODO: Eliminate this global diff --git a/app/assets/javascripts/discourse/tests/index.html b/app/assets/javascripts/discourse/tests/index.html index 4660e3ef84..34c33c48e4 100644 --- a/app/assets/javascripts/discourse/tests/index.html +++ b/app/assets/javascripts/discourse/tests/index.html @@ -26,6 +26,9 @@ -o-transition: none !important; transition: none !important; } + #ember-testing { + background-color: white; + } #qunit-fixture { position: absolute; top: -10000px; @@ -47,6 +50,7 @@ + diff --git a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js index 4a637123c6..877b1635fb 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/bookmark-test.js @@ -8,25 +8,26 @@ import { queryAll, } from "discourse/tests/helpers/qunit-helpers"; import I18n from "I18n"; +import hbs from "htmlbars-inline-precompile"; discourseModule("Integration | Component | bookmark", function (hooks) { setupRenderingTest(hooks); - const template = `{{bookmark + const template = hbs`{{bookmark model=model afterSave=afterSave afterDelete=afterDelete onCloseWithoutSaving=onCloseWithoutSaving - registerOnCloseHandler=(action "registerOnCloseHandler") - closeModal=(action "closeModal")}}`; + registerOnCloseHandler=registerOnCloseHandler + closeModal=closeModal}}`; hooks.beforeEach(function () { - this.actions.registerOnCloseHandler = () => {}; - this.actions.closeModal = () => {}; this.setProperties({ model: {}, + closeModal: () => {}, afterSave: () => {}, afterDelete: () => {}, + registerOnCloseHandler: () => {}, onCloseWithoutSaving: () => {}, }); }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 index 06956b676a..30a7889d43 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 @@ -2,7 +2,9 @@ import { acceptance, publishToMessageBus, } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; +import { visit } from "@ember/test-helpers"; acceptance("Poll results", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 index ee2ca3593b..bb90087646 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 @@ -5,12 +5,13 @@ import { discourseModule, queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; discourseModule( "Integration | Component | Widget | discourse-poll-option", function (hooks) { setupRenderingTest(hooks); - const template = `{{mount-widget + const template = hbs`{{mount-widget widget="discourse-poll-option" args=(hash option=option isMultiple=isMultiple vote=vote)}}`; diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 index 9a72956aaa..b7d219b88c 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 @@ -6,13 +6,14 @@ import { discourseModule, queryAll, } from "discourse/tests/helpers/qunit-helpers"; +import hbs from "htmlbars-inline-precompile"; discourseModule( "Integration | Component | Widget | discourse-poll-standard-results", function (hooks) { setupRenderingTest(hooks); - const template = `{{mount-widget + const template = hbs`{{mount-widget widget="discourse-poll-standard-results" args=(hash poll=poll isMultiple=isMultiple)}}`; diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 index e85a560a92..5666f832b2 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 @@ -10,6 +10,7 @@ import componentTest, { import EmberObject from "@ember/object"; import I18n from "I18n"; import pretender from "discourse/tests/helpers/create-pretender"; +import hbs from "htmlbars-inline-precompile"; let requests = 0; @@ -81,7 +82,7 @@ discourseModule( ]; }); - const template = `{{mount-widget + const template = hbs`{{mount-widget widget="discourse-poll" args=(hash id=id post=post @@ -126,7 +127,6 @@ discourseModule( assert.equal(requests, 1); assert.equal(count(".chosen"), 1); assert.equal(queryAll(".chosen").text(), "100%yes"); - assert.equal(queryAll(".toggle-results").text(), "Show vote"); await click(".toggle-results"); assert.equal( @@ -134,7 +134,6 @@ discourseModule( .length, 1 ); - assert.equal(queryAll(".toggle-results").text(), "Show results"); }, }); From 0adeddde610ad503de16541c90f6deb4df2f4245 Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Thu, 24 Jun 2021 19:53:39 +0530 Subject: [PATCH 180/403] FIX: follow redirects for inline/mini onebox (#13512) --- lib/retrieve_title.rb | 30 ++++++++++++++------------ spec/components/retrieve_title_spec.rb | 11 ++++++++++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/retrieve_title.rb b/lib/retrieve_title.rb index fd652b3390..abab83596a 100644 --- a/lib/retrieve_title.rb +++ b/lib/retrieve_title.rb @@ -57,24 +57,26 @@ module RetrieveTitle encoding = nil fd.get do |_response, chunk, uri| + unless Net::HTTPRedirection === _response + if current + current << chunk + else + current = chunk + end - if current - current << chunk - else - current = chunk - end - if !encoding && content_type = _response['content-type']&.strip&.downcase - if content_type =~ /charset="?([a-z0-9_-]+)"?/ - encoding = Regexp.last_match(1) - if !Encoding.list.map(&:name).map(&:downcase).include?(encoding) - encoding = nil + if !encoding && content_type = _response['content-type']&.strip&.downcase + if content_type =~ /charset="?([a-z0-9_-]+)"?/ + encoding = Regexp.last_match(1) + if !Encoding.list.map(&:name).map(&:downcase).include?(encoding) + encoding = nil + end end end - end - max_size = max_chunk_size(uri) * 1024 - title = extract_title(current, encoding) - throw :done if title || max_size < current.length + max_size = max_chunk_size(uri) * 1024 + title = extract_title(current, encoding) + throw :done if title || max_size < current.length + end end title end diff --git a/spec/components/retrieve_title_spec.rb b/spec/components/retrieve_title_spec.rb index 56e80a4130..a519e7eb72 100644 --- a/spec/components/retrieve_title_spec.rb +++ b/spec/components/retrieve_title_spec.rb @@ -89,5 +89,16 @@ describe RetrieveTitle do IPSocket.stubs(:getaddress).returns('100.2.3.4') expect(RetrieveTitle.crawl("https://brelksdjflaskfj.com/amazing")).to eq("japanese こんにちは website") end + + it "can follow redirect" do + stub_request(:get, "http://foobar.com/amazing"). + to_return(status: 301, body: "", headers: { "location" => "https://wikipedia.com/amazing" }) + + stub_request(:get, "https://wikipedia.com/amazing"). + to_return(status: 200, body: "very amazing", headers: {}) + + IPSocket.stubs(:getaddress).returns('100.2.3.4') + expect(RetrieveTitle.crawl("http://foobar.com/amazing")).to eq("very amazing") + end end end From 180c0c4dc3ffddbe19081f0b655cce9d2b1475eb Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Thu, 24 Jun 2021 10:11:24 -0500 Subject: [PATCH 181/403] FIX: Translation of plugin directory column on mobile (#13516) --- .../app/helpers/directory-item-helpers.js | 4 ++- .../mobile/components/directory-item.hbs | 26 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js index 1007a506b7..29339de185 100644 --- a/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js +++ b/app/assets/javascripts/discourse/app/helpers/directory-item-helpers.js @@ -6,7 +6,9 @@ import I18n from "I18n"; registerUnbound("mobile-directory-item-label", function (args) { // Args should include key/values { item, column } const count = args.item.get(args.column.name); - return htmlSafe(I18n.t(`directory.${args.column.name}`, { count })); + const translationPrefix = + args.column.type === "automatic" ? "directory." : ""; + return htmlSafe(I18n.t(`${translationPrefix}${args.column.name}`, { count })); }); registerUnbound("directory-item-value", function (args) { diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs index 3f75ed7278..8560f19c97 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs @@ -1,7 +1,19 @@ {{user-info user=item.user}} {{#each columns as |column|}} - {{#if (directory-column-is-automatic column=column)}} + {{#if (directory-column-is-user-field column=column)}} + {{#if (get item.user.user_fields column.user_field_id)}} +
    + + {{directory-item-user-field-value item=item column=column}} + + + {{column.name}} + +
    + {{/if}} + + {{else}}
    {{directory-item-value item=item column=column}} @@ -13,18 +25,6 @@ {{mobile-directory-item-label item=item column=column}}
    - - {{else}} - {{#if (get item.user.user_fields column.user_field_id)}} -
    - - {{directory-item-user-field-value item=item column=column}} - - - {{column.name}} - -
    - {{/if}} {{/if}} {{/each}} From cb9e004121c384bad26511fdf284eddaef785cef Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 24 Jun 2021 11:46:26 -0400 Subject: [PATCH 182/403] UX: Make bulk select checkbox easier to target (#13517) Adds a label element around the checkbox, so that user can more easily hit the element. Quite useful when checking many items in topic list. --- .../discourse/app/templates/list/topic-list-item.hbr | 8 +++++--- app/assets/stylesheets/desktop/topic-list.scss | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr index 03ea153bb0..e7964e1e32 100644 --- a/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr +++ b/app/assets/javascripts/discourse/app/templates/list/topic-list-item.hbr @@ -2,7 +2,9 @@ {{#if bulkSelectEnabled}}
    {{/if}} @@ -23,8 +25,8 @@ {{~topic-featured-link topic}} {{~/if}} {{~raw-plugin-outlet name="topic-list-after-title"}} - {{~raw "list/unread-indicator" includeUnreadIndicator=includeUnreadIndicator - topicId=topic.id + {{~raw "list/unread-indicator" includeUnreadIndicator=includeUnreadIndicator + topicId=topic.id unreadClass=unreadClass~}} {{~#if showTopicPostBadges}} {{~raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl newDotText=newDotText}} diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index 39f8f37fc9..592abf7e49 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -46,8 +46,13 @@ } td.bulk-select { - padding: 10px; + padding: 0; width: 30px; + label { + margin: 0px; + padding: 12px 10px 16px 10px; + cursor: pointer; + } + .main-link { padding-left: 0; } From a216862fe11014f1dea352975d84700491cf09c9 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Tue, 22 Jun 2021 00:50:24 +0200 Subject: [PATCH 183/403] Fix spelling in `discourse_narrative_bot.new_user_narrative.flag.instructions` This fixes https://discourse.crowdin.com/translate/f3230e7607a36bb0a2f97fd90605a44e/278/en-fr#51512 @discourse-translator-bot keep_translations_and_approvals --- plugins/discourse-narrative-bot/config/locales/server.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/discourse-narrative-bot/config/locales/server.en.yml b/plugins/discourse-narrative-bot/config/locales/server.en.yml index 505e80b35e..9abd897450 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.en.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.en.yml @@ -293,7 +293,7 @@ en: flag: instructions: |- - We like our discussions friendly, and we need your help to [keep things civilized](%{guidelines_url}). If you see a problem, please flag to privately let the author or [our helpful staff](%{about_url}) know about it. There are many reasons you might want to flag a post, ranging from an inocuous topic-splitting suggestion to a clear-cut community standards violation. If you select **Something Else**, you'll start a private message discussion with the moderators where you can ask further questions. + We like our discussions friendly, and we need your help to [keep things civilized](%{guidelines_url}). If you see a problem, please flag to privately let the author or [our helpful staff](%{about_url}) know about it. There are many reasons you might want to flag a post, ranging from an innocuous topic-splitting suggestion to a clear-cut community standards violation. If you select **Something Else**, you'll start a private message discussion with the moderators where you can ask further questions. > :imp: I wrote something nasty here From 51e4e5fde619820d323b4e4cc0b713bccfc3433b Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Thu, 24 Jun 2021 23:36:27 +0200 Subject: [PATCH 184/403] Remove unused strings --- config/locales/client.en.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 70fd359a13..c4a18377be 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -224,7 +224,6 @@ en: submit: "Submit" generic_error: "Sorry, an error has occurred." generic_error_with_reason: "An error occurred: %{error}" - go_ahead: "Go ahead" sign_up: "Sign Up" log_in: "Log In" age: "Age" @@ -253,7 +252,6 @@ en: x_more: one: "%{count} More" other: "%{count} More" - less: "Less" never: "never" every_30_minutes: "every 30 minutes" every_hour: "every hour" @@ -262,7 +260,6 @@ en: every_month: "every month" every_six_months: "every six months" max_of_count: "max of %{count}" - alternation: "or" character_count: one: "%{count} character" other: "%{count} characters" From 5c3109281dc114f3dff25fffc583328f2a1c401d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Jun 2021 22:01:58 +0000 Subject: [PATCH 185/403] Build(deps): Bump faraday from 1.4.2 to 1.4.3 Bumps [faraday](https://github.com/lostisland/faraday) from 1.4.2 to 1.4.3. - [Release notes](https://github.com/lostisland/faraday/releases) - [Changelog](https://github.com/lostisland/faraday/blob/main/CHANGELOG.md) - [Commits](https://github.com/lostisland/faraday/compare/v1.4.2...v1.4.3) --- updated-dependencies: - dependency-name: faraday dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9adb3c0fdc..282255ef48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -135,7 +135,7 @@ GEM faker (2.18.0) i18n (>= 1.6, < 2) fakeweb (1.3.0) - faraday (1.4.2) + faraday (1.4.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) From 3b32b6bc136d69bcfa94fafd0cf1d3fb7d9146dc Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 25 Jun 2021 09:15:17 +0800 Subject: [PATCH 186/403] DEV: Clean up state leak in `Site` tests. --- spec/models/site_spec.rb | 9 +++++---- spec/serializers/site_serializer_spec.rb | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index 8ea6e9bb28..3e2e0c2e81 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -3,6 +3,9 @@ require 'rails_helper' describe Site do + after do + Site.clear_cache + end def expect_correct_themes(guardian) json = Site.json_for(guardian) @@ -63,10 +66,6 @@ describe Site do fab!(:user) { Fabricate(:user) } fab!(:guardian) { Guardian.new(user) } - after do - Site.clear_cache - end - it "omits read restricted categories" do expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( SiteSetting.uncategorized_category_id, category.id @@ -136,6 +135,8 @@ describe Site do categories = Site.new(Guardian.new).categories expect(categories.last[:custom_fields]["enable_marketplace"]).to eq('f') + ensure + Site.preloaded_category_custom_fields.clear end end diff --git a/spec/serializers/site_serializer_spec.rb b/spec/serializers/site_serializer_spec.rb index 482905de36..8dca84a7e0 100644 --- a/spec/serializers/site_serializer_spec.rb +++ b/spec/serializers/site_serializer_spec.rb @@ -26,6 +26,8 @@ describe SiteSerializer do c1 = serialized[:categories].find { |c| c[:id] == category.id } expect(c1[:custom_fields]["enable_marketplace"]).to eq("t") + ensure + Site.preloaded_category_custom_fields.clear end it "includes category tags" do From 895df9c239db026c6a8b43db97092af58c1440f9 Mon Sep 17 00:00:00 2001 From: awesomerobot Date: Thu, 24 Jun 2021 15:21:11 -0400 Subject: [PATCH 187/403] UX: margin improvement for mobile alerts --- app/assets/stylesheets/common/components/banner.scss | 1 + app/assets/stylesheets/desktop/banner.scss | 1 - app/assets/stylesheets/mobile/alert.scss | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/components/banner.scss b/app/assets/stylesheets/common/components/banner.scss index 6e526adf5e..22ec02c63d 100644 --- a/app/assets/stylesheets/common/components/banner.scss +++ b/app/assets/stylesheets/common/components/banner.scss @@ -7,6 +7,7 @@ background: var(--tertiary-low); color: var(--primary); z-index: z("base") + 1; + margin-bottom: 1em; overflow: auto; &.overlay { diff --git a/app/assets/stylesheets/desktop/banner.scss b/app/assets/stylesheets/desktop/banner.scss index ecfcff7d37..933d266a2b 100644 --- a/app/assets/stylesheets/desktop/banner.scss +++ b/app/assets/stylesheets/desktop/banner.scss @@ -3,7 +3,6 @@ // -------------------------------------------------- #banner { - margin-bottom: 1em; max-width: 1090px; max-height: 250px; } diff --git a/app/assets/stylesheets/mobile/alert.scss b/app/assets/stylesheets/mobile/alert.scss index ac30706214..90d48b7deb 100644 --- a/app/assets/stylesheets/mobile/alert.scss +++ b/app/assets/stylesheets/mobile/alert.scss @@ -1,7 +1,7 @@ .alert.alert-info { &.clickable { // there are (n) new or updated topics, click to show - margin-top: 0; + margin: 0; padding: 1em; } } From c3394ed9bba48ad83e94e78fe946110cdb937e62 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 25 Jun 2021 14:22:31 +1000 Subject: [PATCH 188/403] DEV: Update aws-sdk-s3 gem for S3 multipart uploads (#13523) We are a few versions behind on this gem. We need to update it for S3 multipart uploads. In the current version we are using, we cannot do this: ```ruby Discourse.store.s3_helper.object(key).presigned_url(:upload_part, part_number: 1, upload_id: multipart_upload_id) ``` The S3 client raises an error, saying the operation is undefined. Once I updated the gem this operation works as expected and returns a presigned URL for the upload_part operation. Also remove use of Aws::S3::FileUploader::FIFTEEN_MEGABYTES. This was part of a private API and should not have been used. --- Gemfile.lock | 4 ++-- lib/s3_helper.rb | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 282255ef48..6dde31f0ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,10 +59,10 @@ GEM aws-partitions (~> 1, >= 1.239.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.42.0) + aws-sdk-kms (1.44.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.90.0) + aws-sdk-s3 (1.96.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 636b27800c..ed4d857951 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -3,6 +3,7 @@ require "aws-sdk-s3" class S3Helper + FIFTEEN_MEGABYTES = 15 * 1024 * 1024 class SettingMissing < StandardError; end @@ -42,8 +43,8 @@ class S3Helper obj = s3_bucket.object(path) etag = begin - if File.size(file.path) >= Aws::S3::FileUploader::FIFTEEN_MEGABYTES - options[:multipart_threshold] = Aws::S3::FileUploader::FIFTEEN_MEGABYTES + if File.size(file.path) >= FIFTEEN_MEGABYTES + options[:multipart_threshold] = FIFTEEN_MEGABYTES obj.upload_file(file, options) obj.load obj.etag From 8ab6fd88ef231ed782c9ce85c4424f81ec5a50a2 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Fri, 25 Jun 2021 12:08:52 +0300 Subject: [PATCH 189/403] UX: Add notice when watched words are regexes (#13493) There is a big difference between regular watched words and regular expressions and this has been confusing in the past. This notice adds an explanation. This commit also reorganizes the code of the test modal. --- .../modals/admin-watched-word-test.js | 58 +++++++++++-------- .../addon/templates/watched-words-action.hbs | 4 ++ config/locales/client.en.yml | 1 + 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js index 3ce120419c..3ea2618acb 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-watched-word-test.js @@ -18,33 +18,45 @@ export default Controller.extend(ModalFunctionality, { ) matches(value, regexpString, words, isReplace, isTag, isLink) { if (!value || !regexpString) { - return; + return []; } - const regexp = new RegExp(regexpString, "ig"); - const matches = value.match(regexp) || []; - if (isReplace || isLink) { - return matches.map((match) => ({ - match, - replacement: words.find((word) => - new RegExp(word.regexp, "ig").test(match) - ).replacement, - })); - } else if (isTag) { - return matches.map((match) => { - const tags = new Set(); - - words.forEach((word) => { - if (new RegExp(word.regexp, "ig").test(match)) { - word.replacement.split(",").forEach((tag) => tags.add(tag)); - } - }); - - return { match, tags: Array.from(tags) }; + const matches = []; + words.forEach((word) => { + const regexp = new RegExp(word.regexp, "gi"); + let match; + while ((match = regexp.exec(value)) !== null) { + matches.push({ + match: match[1], + replacement: word.replacement, + }); + } }); - } + return matches; + } else if (isTag) { + const matches = {}; + words.forEach((word) => { + const regexp = new RegExp(word.regexp, "gi"); + let match; + while ((match = regexp.exec(value)) !== null) { + if (!matches[match[1]]) { + matches[match[1]] = new Set(); + } - return matches; + let tags = matches[match[1]]; + word.replacement.split(",").forEach((tag) => { + tags.add(tag); + }); + } + }); + + return Object.entries(matches).map((entry) => ({ + match: entry[0], + tags: Array.from(entry[1]), + })); + } else { + return value.match(new RegExp(regexpString, "ig")) || []; + } }, }); diff --git a/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs b/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs index 005e4b759b..6860ac16d3 100644 --- a/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs +++ b/app/assets/javascripts/admin/addon/templates/watched-words-action.hbs @@ -26,6 +26,10 @@

    {{actionDescription}}

    +{{#if siteSettings.watched_words_regular_expressions}} +

    {{html-safe (i18n "admin.watched_words.regex_warning" basePath=(base-path))}}

    +{{/if}} + {{watched-word-form actionKey=actionNameKey action=(action "recordAdded") diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c4a18377be..ca725af394 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4752,6 +4752,7 @@ en: clear_all: Clear All clear_all_confirm: "Are you sure you want to clear all watched words for the %{action} action?" invalid_regex: 'The watched word "%{word}" is an invalid regular expression.' + regex_warning: 'Watched words are regular expressions and they do not automatically include word boundaries. If you want the regular expression to match whole words, include \b at the start and end of your regular expression.' actions: block: "Block" censor: "Censor" From b4f0a0fb9489223f6d4d56ddbfe0f5532a6f598a Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Fri, 25 Jun 2021 11:34:51 +0200 Subject: [PATCH 190/403] FIX: Nil-filled CF arrays were not being deleted (#13518) --- app/models/concerns/has_custom_fields.rb | 6 +++--- spec/components/concern/has_custom_fields_spec.rb | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index 925bb8aceb..f073544dbb 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -233,10 +233,10 @@ module HasCustomFields t = {} self.class.append_custom_field(t, f.name, f.value) - if dup[f.name] != t[f.name] - f.destroy! - else + if dup.has_key?(f.name) && dup[f.name] == t[f.name] dup.delete(f.name) + else + f.destroy! end end end diff --git a/spec/components/concern/has_custom_fields_spec.rb b/spec/components/concern/has_custom_fields_spec.rb index 6fab074a08..c2f4aff317 100644 --- a/spec/components/concern/has_custom_fields_spec.rb +++ b/spec/components/concern/has_custom_fields_spec.rb @@ -3,7 +3,6 @@ require "rails_helper" describe HasCustomFields do - context "custom_fields" do before do DB.exec("create temporary table custom_fields_test_items(id SERIAL primary key)") @@ -104,7 +103,6 @@ describe HasCustomFields do end it "handles arrays properly" do - CustomFieldsTestItem.register_custom_field_type "array", [:integer] test_item = CustomFieldsTestItem.new test_item.custom_fields = { "array" => ["1"] } @@ -136,6 +134,19 @@ describe HasCustomFields do expect(db_item.custom_fields).to eq({}) end + it "deletes nil-filled arrays" do + test_item = CustomFieldsTestItem.create! + db_item = CustomFieldsTestItem.find(test_item.id) + + db_item.custom_fields.update("a" => [nil, nil]) + db_item.save_custom_fields + db_item.custom_fields.delete("a") + expect(db_item.custom_fields).to eq({}) + + db_item.save_custom_fields + expect(db_item.custom_fields).to eq({}) + end + it "casts integers in arrays properly without error" do test_item = CustomFieldsTestItem.new test_item.custom_fields = { "a" => ["b", 10, "d"] } From 61472d6aaa61fae09d1b38cdbe7eacc32bbb85b0 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Fri, 25 Jun 2021 18:05:50 +0200 Subject: [PATCH 191/403] DEV: Rename `hilight` to `highlight` (#13526) --- app/mailers/user_notifications.rb | 2 +- lib/email/styles.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index e7c8829214..31336ce6c5 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -662,7 +662,7 @@ class UserNotifications < ActionMailer::Base message = email_post_markdown(post) + (reached_limit ? "\n\n#{I18n.t "user_notifications.reached_limit", count: SiteSetting.max_emails_per_day_per_user}" : "") end - first_footer_classes = "hilight" + first_footer_classes = "highlight" if (allow_reply_by_email && user.staged) || (user.suspended? || user.staged?) first_footer_classes = "" end diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 73c6e08329..7546740cd0 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -185,7 +185,7 @@ module Email def format_html correct_first_body_margin correct_footer_style - correct_footer_style_hilight_first + correct_footer_style_highlight_first reset_tables html_lang = SiteSetting.default_locale.sub("_", "-") @@ -376,9 +376,9 @@ module Email end end - def correct_footer_style_hilight_first + def correct_footer_style_highlight_first footernum = 0 - @fragment.css('.footer.hilight').each do |element| + @fragment.css('.footer.highlight').each do |element| linknum = 0 element.css('a').each do |inner| # we want the first footer link to be specially highlighted as IMPORTANT From fa4e5e8dad2a20515a5d7bee592c4a40e91f0371 Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Fri, 25 Jun 2021 14:48:36 -0300 Subject: [PATCH 192/403] FEATURE: Render emojis on GitHub labels when oneboxing an issue. (#13531) --- .../stylesheets/common/base/onebox.scss | 5 +++ app/helpers/emoji_helper.rb | 17 +--------- app/models/emoji.rb | 16 ++++++++++ lib/onebox/engine/github_issue_onebox.rb | 20 +++++++----- lib/onebox/templates/githubissue.mustache | 4 ++- spec/helpers/emoji_helper_spec.rb | 32 ------------------- spec/models/emoji_spec.rb | 20 ++++++++++++ 7 files changed, 57 insertions(+), 57 deletions(-) delete mode 100644 spec/helpers/emoji_helper_spec.rb diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index c40dd3e2dd..fdaf1f6a2b 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -505,6 +505,11 @@ pre.onebox code { color: var(--secondary) !important; padding: 2px 4px !important; } + + .emoji { + max-height: 15px; + margin: 0.2em; + } } .onebox.githubactions { diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb index fcf0462f34..3e3e59c778 100644 --- a/app/helpers/emoji_helper.rb +++ b/app/helpers/emoji_helper.rb @@ -2,21 +2,6 @@ module EmojiHelper def emoji_codes_to_img(str) - return if str.blank? - - str = str.gsub(/:([\w\-+]*(?::t\d)?):/) do |name| - code = $1 - - if code && Emoji.custom?(code) - emoji = Emoji[code] - "\"#{code}\"" - elsif code && Emoji.exists?(code) - "\"#{code}\"" - else - name - end - end - - raw(str) + raw(Emoji.codes_to_img(str)) end end diff --git a/app/models/emoji.rb b/app/models/emoji.rb index 4a9012c307..f755a7eaad 100644 --- a/app/models/emoji.rb +++ b/app/models/emoji.rb @@ -231,4 +231,20 @@ class Emoji @unicode_replacements_json ||= unicode_replacements.to_json end + def self.codes_to_img(str) + return if str.blank? + + str = str.gsub(/:([\w\-+]*(?::t\d)?):/) do |name| + code = $1 + + if code && Emoji.custom?(code) + emoji = Emoji[code] + "\"#{code}\"" + elsif code && Emoji.exists?(code) + "\"#{code}\"" + else + name + end + end + end end diff --git a/lib/onebox/engine/github_issue_onebox.rb b/lib/onebox/engine/github_issue_onebox.rb index 0bb26b70ac..4d387f4799 100644 --- a/lib/onebox/engine/github_issue_onebox.rb +++ b/lib/onebox/engine/github_issue_onebox.rb @@ -31,19 +31,23 @@ module Onebox body, excerpt = compute_body(raw['body']) ulink = URI(link) + labels = raw['labels'].map do |l| + { name: Emoji.codes_to_img(l['name']) } + end + { link: @url, - title: raw["title"], + title: raw['title'], body: body, excerpt: excerpt, - labels: raw["labels"], + labels: labels, user: raw['user'], - created_at: created_at.strftime("%I:%M%p - %d %b %y %Z"), - created_at_date: created_at.strftime("%F"), - created_at_time: created_at.strftime("%T"), - closed_at: closed_at&.strftime("%I:%M%p - %d %b %y %Z"), - closed_at_date: closed_at&.strftime("%F"), - closed_at_time: closed_at&.strftime("%T"), + created_at: created_at.strftime('%I:%M%p - %d %b %y %Z'), + created_at_date: created_at.strftime('%F'), + created_at_time: created_at.strftime('%T'), + closed_at: closed_at&.strftime('%I:%M%p - %d %b %y %Z'), + closed_at_date: closed_at&.strftime('%F'), + closed_at_time: closed_at&.strftime('%T'), closed_by: raw['closed_by'], avatar: "https://avatars1.githubusercontent.com/u/#{raw['user']['id']}?v=2&s=96", domain: "#{ulink.host}/#{ulink.path.split('/')[1]}/#{ulink.path.split('/')[2]}", diff --git a/lib/onebox/templates/githubissue.mustache b/lib/onebox/templates/githubissue.mustache index f8766736b3..54989581c0 100644 --- a/lib/onebox/templates/githubissue.mustache +++ b/lib/onebox/templates/githubissue.mustache @@ -29,7 +29,9 @@
    {{#labels}} - {{name}} + + {{{name}}} + {{/labels}}
    diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb deleted file mode 100644 index a8b13a9ccb..0000000000 --- a/spec/helpers/emoji_helper_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -# coding: utf-8 -# frozen_string_literal: true - -require 'rails_helper' - -describe EmojiHelper do - before do - Plugin::CustomEmoji.clear_cache - end - - after do - Plugin::CustomEmoji.clear_cache - end - - describe "emoji_codes_to_img" do - it "replaces emoji codes by images" do - Plugin::CustomEmoji.register("xxxxxx", "/public/xxxxxx.png") - - str = "This is a good day :xxxxxx: :woman: :man:t4:" - replaced_str = helper.emoji_codes_to_img(str) - - expect(replaced_str).to eq("This is a good day \"xxxxxx\" \"woman\" \"man:t4\"") - end - - it "doesn't replace if code doesn't exist" do - str = "This is a good day :woman: :foo: :bar:t4: :man:t8:" - replaced_str = helper.emoji_codes_to_img(str) - - expect(replaced_str).to eq("This is a good day \"woman\" :foo: :bar:t4: :man:t8:") - end - end -end diff --git a/spec/models/emoji_spec.rb b/spec/models/emoji_spec.rb index f7f57ae26a..df309f4bb7 100644 --- a/spec/models/emoji_spec.rb +++ b/spec/models/emoji_spec.rb @@ -87,4 +87,24 @@ describe Emoji do end end + describe '.codes_to_img' do + before { Plugin::CustomEmoji.clear_cache } + after { Plugin::CustomEmoji.clear_cache } + + it "replaces emoji codes by images" do + Plugin::CustomEmoji.register("xxxxxx", "/public/xxxxxx.png") + + str = "This is a good day :xxxxxx: :woman: :man:t4:" + replaced_str = described_class.codes_to_img(str) + + expect(replaced_str).to eq("This is a good day \"xxxxxx\" \"woman\" \"man:t4\"") + end + + it "doesn't replace if code doesn't exist" do + str = "This is a good day :woman: :foo: :bar:t4: :man:t8:" + replaced_str = described_class.codes_to_img(str) + + expect(replaced_str).to eq("This is a good day \"woman\" :foo: :bar:t4: :man:t8:") + end + end end From 203d56719d52b057602a14e9d218db90ce50009d Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Fri, 25 Jun 2021 20:13:46 +0200 Subject: [PATCH 193/403] UX: Improve blockquote styling in emails (#13527) --- lib/email/styles.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 7546740cd0..250ecff7e2 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -121,8 +121,7 @@ module Email style('aside.quote .avatar', 'margin-right: 5px; width:20px; height:20px; vertical-align:middle;') style('aside.quote', 'border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin: 0;') - style('blockquote', 'border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin: 0;') - style('blockquote > p', 'padding: 1em;') + style('blockquote', 'border-left: 5px solid #e9e9e9; background-color: #f8f8f8; margin-left: 0; padding: 12px;') # Oneboxes style('aside.onebox', "border: 5px solid #e9e9e9; padding: 12px 25px 12px 12px; margin-bottom: 10px;") @@ -198,9 +197,13 @@ module Email dir: Rtl.new(nil).enabled? ? 'rtl' : 'ltr' ) + style('blockquote > :first-child', 'margin-top: 0;') + style('blockquote > :last-child', 'margin-bottom: 0;') + style('blockquote > p', 'padding: 0;') + style('.with-accent-colors', "background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};") style('h4', 'color: #222;') - style('h3', 'margin: 15px 0 20px 0;') + style('h3', 'margin: 30px 0 10px;') style('hr', 'background-color: #ddd; height: 1px; border: 1px;') style('a', "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color};") style('ul', 'margin: 0 0 0 10px; padding: 0 0 0 20px;') From 69518bee15814a2be8aa9cbc24b1073ddc556fb2 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 28 Jun 2021 08:15:52 +1000 Subject: [PATCH 194/403] FIX: Backfill topic_id for EmailLog (#13469) In the previous commit 5222247 we added a topic_id column to EmailLog. This simply backfills it in batches. The next PR will get rid of the topic method defined on EmailLog in favour of belongs_to. --- ...10621234939_backfill_email_log_topic_id.rb | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 db/post_migrate/20210621234939_backfill_email_log_topic_id.rb diff --git a/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb b/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb new file mode 100644 index 0000000000..5e2770e167 --- /dev/null +++ b/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class BackfillEmailLogTopicId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + BATCH_SIZE = 30_000 + + def up + offset = 0 + email_log_count = DB.query_single("SELECT COUNT(*) FROM email_logs").first + + loop do + DB.exec(<<~SQL, offset: offset, batch_size: BATCH_SIZE) + WITH cte AS ( + SELECT post_id + FROM email_logs + ORDER BY id + LIMIT :batch_size + OFFSET :offset + ) + UPDATE email_logs + SET topic_id = posts.topic_id + FROM cte + INNER JOIN posts ON posts.id = cte.post_id + WHERE email_logs.post_id = cte.post_id + SQL + + offset += BATCH_SIZE + break if offset > (email_log_count + BATCH_SIZE * 2) + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From f9886ecfa23795eecec6f76bd72199aa7c784fb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Jun 2021 00:19:58 +0200 Subject: [PATCH 195/403] Build(deps-dev): Bump mocha from 1.12.0 to 1.13.0 (#13538) Bumps [mocha](https://github.com/freerange/mocha) from 1.12.0 to 1.13.0. - [Release notes](https://github.com/freerange/mocha/releases) - [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md) - [Commits](https://github.com/freerange/mocha/compare/v1.12.0...v1.13.0) --- updated-dependencies: - dependency-name: mocha dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6dde31f0ae..c9f5c2d503 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -227,7 +227,7 @@ GEM mini_suffix (0.3.2) ffi (~> 1.9) minitest (5.14.4) - mocha (1.12.0) + mocha (1.13.0) mock_redis (0.28.0) ruby2_keywords msgpack (1.4.2) From 87684f7c5e7e48841743d38df8a57ed827715b47 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 28 Jun 2021 08:55:13 +1000 Subject: [PATCH 196/403] FEATURE: Use group SMTP job and mailer instead of UserNotifications change (#13489) This PR backtracks a fair bit on this one https://github.com/discourse/discourse/pull/13220/files. Instead of sending the group SMTP email for each user via `UserNotifications`, we are changing to send only one email with the existing `Jobs::GroupSmtpEmail` job and `GroupSmtpMailer`. We are changing this job and mailer along with `PostAlerter` to make the first topic allowed user the `to_address` for the email and any other `topic_allowed_users` to be the CC address on the email. This is to cut down on emails sent via SMTP, which is subject to daily limits from providers such as Gmail. We log these details in the `EmailLog` table now. In addition to this, we have changed `PostAlerter` to no longer rely on incoming email email addresses for sending the `GroupSmtpEmail` job. This was unreliable as a user's email could have changed in the meantime. Also it was a little overcomplicated to use the incoming email records -- it is far simpler to reason about to just use topic allowed users. This also adds a fix to include cc_addresses in the EmailLog.addressed_to_user scope. --- app/jobs/regular/group_smtp_email.rb | 11 +- app/mailers/group_smtp_mailer.rb | 22 +- app/mailers/user_notifications.rb | 51 +--- app/models/email_log.rb | 3 +- app/services/post_alerter.rb | 82 +++-- lib/email/message_builder.rb | 24 +- lib/email/sender.rb | 2 + spec/components/email/message_builder_spec.rb | 41 ++- spec/components/email/sender_spec.rb | 9 +- spec/jobs/regular/group_smtp_email_spec.rb | 74 +++-- spec/mailers/group_smtp_mailer_spec.rb | 68 +++-- spec/mailers/user_notifications_spec.rb | 78 ----- spec/models/email_log_spec.rb | 23 ++ spec/services/post_alerter_spec.rb | 280 ++++++++++++++---- 14 files changed, 484 insertions(+), 284 deletions(-) diff --git a/app/jobs/regular/group_smtp_email.rb b/app/jobs/regular/group_smtp_email.rb index ba7afa98e6..8092dc38aa 100644 --- a/app/jobs/regular/group_smtp_email.rb +++ b/app/jobs/regular/group_smtp_email.rb @@ -10,6 +10,7 @@ module Jobs group = Group.find_by(id: args[:group_id]) post = Post.find_by(id: args[:post_id]) email = args[:email] + cc_addresses = args[:cc_emails] # There is a rare race condition causing the Imap::Sync class to create # an incoming email and associated post/topic, which then kicks off @@ -27,11 +28,17 @@ module Jobs ImapSyncLog.debug("Sending SMTP email for post #{post.id} in topic #{post.topic_id} to #{email}.", group) recipient_user = ::UserEmail.find_by(email: email, primary: true)&.user - message = GroupSmtpMailer.send_mail(group, email, post) + message = GroupSmtpMailer.send_mail(group, email, post, cc_addresses) + + # The EmailLog record created by the sender will have the raw email + # stored, the group smtp ID, and any cc addresses recorded for later + # cross referencing. Email::Sender.new(message, :group_smtp, recipient_user).send # Create an incoming email record to avoid importing again from IMAP - # server. + # server. While this may not be technically required if IMAP is not + # currently enabled for the group, it will help a lot with the initial + # sync if it is turned on at a later date. IncomingEmail.create!( user_id: post.user_id, topic_id: post.topic_id, diff --git a/app/mailers/group_smtp_mailer.rb b/app/mailers/group_smtp_mailer.rb index 44edec917c..574facc157 100644 --- a/app/mailers/group_smtp_mailer.rb +++ b/app/mailers/group_smtp_mailer.rb @@ -5,13 +5,10 @@ require_dependency 'email/message_builder' class GroupSmtpMailer < ActionMailer::Base include Email::BuildEmailHelper - def send_mail(from_group, to_address, post) + def send_mail(from_group, to_address, post, cc_addresses = nil) raise 'SMTP is disabled' if !SiteSetting.enable_smtp - incoming_email = IncomingEmail.joins(:post) - .where('imap_uid IS NOT NULL') - .where(topic_id: post.topic_id, posts: { post_number: 1 }) - .limit(1).first + op_incoming_email = post.topic.first_post.incoming_email context_posts = Post .where(topic_id: post.topic_id) @@ -38,29 +35,32 @@ class GroupSmtpMailer < ActionMailer::Base user_name = post.user.name unless post.user.name.blank? end - build_email(to_address, + group_name = from_group.full_name.presence || from_group.name + build_email( + to_address, message: post.raw, url: post.url(without_slug: SiteSetting.private_email?), post_id: post.id, topic_id: post.topic_id, context: context(context_posts), username: post.user.username, - group_name: from_group.name, + group_name: group_name, allow_reply_by_email: true, only_reply_by_email: true, - use_from_address_for_reply_to: SiteSetting.enable_imap && from_group.imap_enabled?, + use_from_address_for_reply_to: SiteSetting.enable_smtp && from_group.smtp_enabled?, private_reply: post.topic.private_message?, participants: participants(post), include_respond_instructions: true, template: 'user_notifications.user_posted_pm', use_topic_title_subject: true, - topic_title: incoming_email&.subject || post.topic.title, + topic_title: op_incoming_email&.subject || post.topic.title, add_re_to_subject: true, locale: SiteSetting.default_locale, delivery_method_options: delivery_options, from: from_group.email_username, - from_alias: I18n.t('email_from', user_name: user_name, site_name: Email.site_title), - html_override: html_override(post, context_posts: context_posts) + from_alias: I18n.t('email_from', user_name: group_name, site_name: Email.site_title), + html_override: html_override(post, context_posts: context_posts), + cc: cc_addresses ) end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 31336ce6c5..993d46fa25 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -327,7 +327,6 @@ class UserNotifications < ActionMailer::Base opts[:show_category_in_subject] = false opts[:show_tags_in_subject] = false opts[:show_group_in_subject] = true if SiteSetting.group_in_subject - opts[:use_group_smtp_if_configured] = true # We use the 'user_posted' event when you are emailed a post in a PM. opts[:notification_type] = 'posted' @@ -461,7 +460,6 @@ class UserNotifications < ActionMailer::Base notification_type: notification_type, use_invite_template: opts[:use_invite_template], use_topic_title_subject: use_topic_title_subject, - use_group_smtp_if_configured: opts[:use_group_smtp_if_configured], user: user } @@ -487,13 +485,6 @@ class UserNotifications < ActionMailer::Base group_name = opts[:group_name] locale = user_locale(user) - # this gets set in MessageBuilder if it is nil here, we just want to be - # able to override it if the group has SMTP enabled - from_address = nil - delivery_method_options = nil - use_from_address_for_reply_to = false - using_group_smtp = false - template = +"user_notifications.user_#{notification_type}" if post.topic.private_message? template << "_pm" @@ -531,41 +522,6 @@ class UserNotifications < ActionMailer::Base group = post.topic.allowed_groups&.first - # If the group has IMAP enabled, then this will be handled by - # the Jobs::GroupSmtpEmail which is enqueued from the PostAlerter - # - # use_group_smtp_if_configured is used to ensure that no notifications - # expect for specific ones that we bless (such as user_private_message) - # accidentally get sent with the group SMTP settings. - if group.present? && - group.smtp_enabled && - !group.imap_enabled && - SiteSetting.enable_smtp && - opts[:use_group_smtp_if_configured] - - port, enable_tls, enable_starttls_auto = EmailSettingsValidator.provider_specific_ssl_overrides( - group.smtp_server, group.smtp_port, group.smtp_ssl, group.smtp_ssl - ) - - delivery_method_options = { - address: group.smtp_server, - port: port, - domain: group.email_username_domain, - user_name: group.email_username, - password: group.email_password, - authentication: GlobalSetting.smtp_authentication, - enable_starttls_auto: enable_starttls_auto - } - - # We want from to be the same as the group's email_username, so if - # someone emails support@discourse.org they will get a reply from - # support@discourse.org and be able to email the SMTP email, which - # will forward the email back into Discourse and process/link it correctly. - use_from_address_for_reply_to = true - from_address = group.email_username - using_group_smtp = true - end - if post.topic.private_message? subject_pm = if opts[:show_group_in_subject] && group.present? @@ -692,7 +648,7 @@ class UserNotifications < ActionMailer::Base context: context, username: username, group_name: group_name, - add_unsubscribe_link: !user.staged && !using_group_smtp, + add_unsubscribe_link: !user.staged, mailing_list_mode: user.user_option.mailing_list_mode, unsubscribe_url: post.unsubscribe_url(user), allow_reply_by_email: allow_reply_by_email, @@ -710,10 +666,7 @@ class UserNotifications < ActionMailer::Base site_description: SiteSetting.site_description, site_title: SiteSetting.title, site_title_url_encoded: UrlHelper.encode_component(SiteSetting.title), - locale: locale, - delivery_method_options: delivery_method_options, - use_from_address_for_reply_to: use_from_address_for_reply_to, - from: from_address + locale: locale } unless translation_override_exists diff --git a/app/models/email_log.rb b/app/models/email_log.rb index 2e93daaede..c089746ade 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -28,7 +28,8 @@ class EmailLog < ActiveRecord::Base SELECT 1 FROM user_emails WHERE user_emails.user_id = :user_id AND - email_logs.to_address = user_emails.email + (email_logs.to_address = user_emails.email OR + email_logs.cc_addresses ILIKE '%' || user_emails.email || '%') ) SQL end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 297a97257d..da1f445af1 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -582,20 +582,16 @@ class PostAlerter warn_if_not_sidekiq - # Users who interacted with the post by _directly_ emailing the group - # via the group's email_username which is configured via SMTP/IMAP. - # - # This excludes people who replied via email to a user_private_message - # notification email which will have a PostReplyKey. These people should - # not be emailed again by the user_private_message notifications below. - # - # This also excludes people who emailed the group by one of its incoming_email - # addresses, e.g. somegroup+support@discoursemail.com, which is part of the - # normal group email flow and has nothing to do with SMTP/IMAP. - emails_to_skip_send = notify_group_direct_emailers(post) + # To simplify things and to avoid IMAP double sync issues, and to cut down + # on emails sent via SMTP, any topic_allowed_users (except those who are + # not_allowed?) for a group that has SMTP enabled will have their notification + # email combined into one and sent via a single group SMTP email with CC addresses. + emails_to_skip_send = email_using_group_smtp_if_configured(post) - # Users that aren't part of any mentioned groups and who did not email - # the group directly at the group's email_username. + # We create notifications for all directly_targeted_users and email those + # who do _not_ have their email addresses in the emails_to_skip_send array + # (which will include all topic allowed users' email addresses if group SMTP + # is enabled). users = directly_targeted_users(post).reject { |u| notified.include?(u) } DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) users.each do |user| @@ -605,7 +601,8 @@ class PostAlerter end end - # Users that are part of all mentioned groups. + # Users that are part of all mentioned groups. Emails sent by this notification + # flow will not be sent via group SMTP if it is enabled. users = indirectly_targeted_users(post).reject { |u| notified.include?(u) } DiscourseEvent.trigger(:before_create_notifications_for_users, users, post) users.each do |user| @@ -620,27 +617,54 @@ class PostAlerter end def group_notifying_via_smtp(post) - return nil if !SiteSetting.enable_smtp || !SiteSetting.enable_imap || post.post_type != Post.types[:regular] - post.topic.allowed_groups.where(smtp_enabled: true, imap_enabled: true).first + return nil if !SiteSetting.enable_smtp || post.post_type != Post.types[:regular] + post.topic.allowed_groups.where(smtp_enabled: true).first end - def notify_group_direct_emailers(post) - email_addresses = [] + def email_using_group_smtp_if_configured(post) emails_to_skip_send = [] group = group_notifying_via_smtp(post) - return emails_to_skip_send if group.blank? - # If the post already has an incoming email, it has been set in the - # Email::Receiver or via the GroupSmtpEmail job, and thus it was created - # via the IMAP/SMTP flow, so there is no need to notify those involved - # in the email chain again. - if post.incoming_email.blank? - email_addresses = post.topic.incoming_email_addresses(group: group) - if email_addresses.any? - Jobs.enqueue(:group_smtp_email, group_id: group.id, post_id: post.id, email: email_addresses) - end + to_address = nil + cc_addresses = [] + + # We need to use topic_allowed_users here instead of directly_targeted_users + # because we want to make sure the to_address goes to the OP of the topic. + topic_allowed_users_by_age = post.topic.topic_allowed_users.includes(:user).order(:created_at).reject do |tau| + not_allowed?(tau.user, post) end + return emails_to_skip_send if topic_allowed_users_by_age.empty? + + # This should usually be the OP of the topic, unless they are the one + # replying by email (they are excluded by not_allowed? then) + to_address = topic_allowed_users_by_age.first.user.email + cc_addresses = topic_allowed_users_by_age[1..-1].map { |tau| tau.user.email } + email_addresses = [to_address, cc_addresses].flatten + + # If any of these email addresses were cc address on the + # incoming email for the target post, do not send them emails (they + # already have been notified by the CC on the email) + if post.incoming_email.present? + cc_addresses = cc_addresses - post.incoming_email.cc_addresses_split + + # If the to address is one of the recently added CC addresses, then we + # need to bail early, because otherwise we are sending a notification + # email to the user who was just added by CC. In this case the OP probably + # replied and CC'd some people, and they are the only other topic users. + return if post.incoming_email.cc_addresses_split.include?(to_address) + end + + # Send a single email using group SMTP settings to cut down on the + # number of emails sent via SMTP, also to replicate how support systems + # and group inboxes generally work in other systems. + Jobs.enqueue( + :group_smtp_email, + group_id: group.id, + post_id: post.id, + email: to_address, + cc_emails: cc_addresses + ) # Add the group's email_username into the array, because it is used for # skip_send_email_to in the case of user private message notifications @@ -648,7 +672,7 @@ class PostAlerter # will make another email for IMAP to pick up in the group's mailbox) emails_to_skip_send = email_addresses.dup if email_addresses.any? emails_to_skip_send << group.email_username - emails_to_skip_send + emails_to_skip_send.uniq end def notify_post_users(post, notified, group_ids: nil, include_topic_watchers: true, include_category_watchers: true, include_tag_watchers: true, new_record: false) diff --git a/lib/email/message_builder.rb b/lib/email/message_builder.rb index fa42814bb2..187bfc364d 100644 --- a/lib/email/message_builder.rb +++ b/lib/email/message_builder.rb @@ -140,7 +140,8 @@ module Email subject: subject, body: body, charset: 'UTF-8', - from: from_value + from: from_value, + cc: @opts[:cc] } args[:delivery_method_options] = @opts[:delivery_method_options] if @opts[:delivery_method_options] @@ -161,11 +162,24 @@ module Email # please, don't send us automatic responses... result['X-Auto-Response-Suppress'] = 'All' - if allow_reply_by_email? && !@opts[:use_from_address_for_reply_to] - result[ALLOW_REPLY_BY_EMAIL_HEADER] = true - result['Reply-To'] = reply_by_email_address - else + if !allow_reply_by_email? + # This will end up being the notification_email, which is a + # noreply address. result['Reply-To'] = from_value + else + + # The only reason we use from address for reply to is for group + # SMTP emails, where the person will be replying to the group's + # email_username. + if !@opts[:use_from_address_for_reply_to] + result[ALLOW_REPLY_BY_EMAIL_HEADER] = true + result['Reply-To'] = reply_by_email_address + else + # No point in adding a reply-to header if it is going to be identical + # to the from address/alias. If the from option is not present, then + # the default reply-to address is used. + result['Reply-To'] = from_value if from_value != alias_email(@opts[:from]) + end end result.merge(MessageBuilder.custom_headers(SiteSetting.email_custom_headers)) diff --git a/lib/email/sender.rb b/lib/email/sender.rb index 75a3a97864..aafd834bfd 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -429,6 +429,8 @@ module Email end def set_reply_key(post_id, user_id) + # ALLOW_REPLY_BY_EMAIL_HEADER is only added if we are _not_ sending + # via group SMTP and if reply by email site settings are configured return if !user_id || !post_id || !header_value(Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER).present? # use safe variant here cause we tend to see concurrency issue diff --git a/spec/components/email/message_builder_spec.rb b/spec/components/email/message_builder_spec.rb index 9043652b7c..75fe28347f 100644 --- a/spec/components/email/message_builder_spec.rb +++ b/spec/components/email/message_builder_spec.rb @@ -152,12 +152,15 @@ describe Email::MessageBuilder do context "header args" do + let(:additional_opts) { {} } let(:message_with_header_args) do Email::MessageBuilder.new( to_address, + { body: 'hello world', topic_id: 1234, post_id: 4567, + }.merge(additional_opts) ) end @@ -169,6 +172,42 @@ describe Email::MessageBuilder do expect(message_with_header_args.header_args['X-Discourse-Topic-Id']).to eq('1234') end + it "uses the default reply-to header" do + expect(message_with_header_args.header_args['Reply-To']).to eq("\"Discourse\" <#{SiteSetting.notification_email}>") + end + + context "when allow_reply_by_email is enabled " do + let(:additional_opts) { { allow_reply_by_email: true } } + + it "uses the reply by email address if that is enabled" do + SiteSetting.manual_polling_enabled = true + SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" + SiteSetting.reply_by_email_enabled = true + expect(message_with_header_args.header_args['Reply-To']).to eq("\"Discourse\" ") + end + end + + context "when allow_reply_by_email is enabled and use_from_address_for_reply_to is enabled but no from address is specified" do + let(:additional_opts) { { allow_reply_by_email: true, use_from_address_for_reply_to: true } } + + it "uses the notification_email address, the default reply-to header" do + SiteSetting.manual_polling_enabled = true + SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" + SiteSetting.reply_by_email_enabled = true + expect(message_with_header_args.header_args['Reply-To']).to eq("\"Discourse\" <#{SiteSetting.notification_email}>") + end + end + + context "when allow_reply_by_email is enabled and use_from_address_for_reply_to is enabled and from is specified" do + let(:additional_opts) { { allow_reply_by_email: true, use_from_address_for_reply_to: true, from: "team@test.com" } } + + it "removes the reply-to header because it is identical to the from header" do + SiteSetting.manual_polling_enabled = true + SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" + SiteSetting.reply_by_email_enabled = true + expect(message_with_header_args.header_args['Reply-To']).to eq(nil) + end + end end context "unsubscribe link" do @@ -337,7 +376,5 @@ describe Email::MessageBuilder do SiteSetting.stubs(:email_site_title).returns("::>>>Best \"Forum\", EU: Award Winning<<<") expect(build_args[:from]).to eq("\"Best Forum EU Award Winning\" <#{SiteSetting.notification_email}>") end - end - end diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb index fbdbacb7c3..34c1a2e36a 100644 --- a/spec/components/email/sender_spec.rb +++ b/spec/components/email/sender_spec.rb @@ -343,11 +343,10 @@ describe Email::Sender do let(:reply) { Fabricate(:post, topic: post.topic, reply_to_user: post.user, reply_to_post_number: post.post_number) } let(:notification) { Fabricate(:posted_notification, user: post.user, post: reply) } let(:message) do - UserNotifications.user_private_message( - post.user, - post: reply, - notification_type: notification.notification_type, - notification_data_hash: notification.data_hash + GroupSmtpMailer.send_mail( + group, + post.user.email, + post ) end let(:group) { Fabricate(:smtp_group) } diff --git a/spec/jobs/regular/group_smtp_email_spec.rb b/spec/jobs/regular/group_smtp_email_spec.rb index a3456c510b..ffba33863d 100644 --- a/spec/jobs/regular/group_smtp_email_spec.rb +++ b/spec/jobs/regular/group_smtp_email_spec.rb @@ -4,63 +4,91 @@ require 'rails_helper' RSpec.describe Jobs::GroupSmtpEmail do fab!(:post) do - topic = Fabricate(:topic) + topic = Fabricate(:topic, title: "Help I need support") Fabricate(:post, topic: topic) Fabricate(:post, topic: topic) end - fab!(:group) { Fabricate(:imap_group) } + fab!(:group) { Fabricate(:smtp_group, name: "support-group", full_name: "Support Group") } fab!(:recipient_user) { Fabricate(:user, email: "test@test.com") } let(:post_id) { post.id } let(:args) do { group_id: group.id, post_id: post_id, - email: "test@test.com" + email: "test@test.com", + cc_emails: ["otherguy@test.com", "cormac@lit.com"] } end before do - SiteSetting.reply_by_email_address = "test+%{reply_key}@incoming.com" - SiteSetting.manual_polling_enabled = true - SiteSetting.reply_by_email_enabled = true SiteSetting.enable_smtp = true + SiteSetting.manual_polling_enabled = true + SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" + SiteSetting.reply_by_email_enabled = true end it "sends an email using the GroupSmtpMailer and Email::Sender" do message = Mail::Message.new(body: "hello", to: "myemail@example.invalid") - GroupSmtpMailer.expects(:send_mail).with(group, "test@test.com", post).returns(message) - Email::Sender.expects(:new).with(message, :group_smtp, recipient_user).returns(stub(send: nil)) + GroupSmtpMailer.expects(:send_mail).with(group, "test@test.com", post, ["otherguy@test.com", "cormac@lit.com"]).returns(message) subject.execute(args) end - it "creates an IncomingEmail record to avoid double processing via IMAP" do + it "creates an EmailLog record with the correct details" do subject.execute(args) - incoming = IncomingEmail.find_by(post_id: post.id, user_id: post.user_id, topic_id: post.topic_id) - expect(incoming).not_to eq(nil) - expect(incoming.message_id).to eq("topic/#{post.topic_id}/#{post.id}@test.localhost") - expect(incoming.created_via).to eq(IncomingEmail.created_via_types[:group_smtp]) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log).not_to eq(nil) + expect(email_log.message_id).to eq("topic/#{post.topic_id}/#{post.id}@test.localhost") end - it "creates a PostReplyKey and correctly uses it for the email reply_key substitution" do + it "creates an IncomingEmail record with the correct details to avoid double processing IMAP" do subject.execute(args) - incoming = IncomingEmail.find_by(post_id: post.id, user_id: post.user_id, topic_id: post.topic_id) + incoming_email = IncomingEmail.find_by(post_id: post.id, topic_id: post.topic_id, user_id: post.user.id) + expect(incoming_email).not_to eq(nil) + expect(incoming_email.message_id).to eq("topic/#{post.topic_id}/#{post.id}@test.localhost") + expect(incoming_email.created_via).to eq(IncomingEmail.created_via_types[:group_smtp]) + expect(incoming_email.to_addresses).to eq("test@test.com") + expect(incoming_email.cc_addresses).to eq("otherguy@test.com;cormac@lit.com") + expect(incoming_email.subject).to eq("Re: Help I need support") + end + + it "does not create a post reply key, it always replies to the group email_username" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) post_reply_key = PostReplyKey.where(user_id: recipient_user, post_id: post.id).first - expect(post_reply_key).not_to eq(nil) - expect(incoming.raw).to include("Reply-To: Discourse ") + expect(post_reply_key).to eq(nil) + expect(email_log.raw).not_to include("Reply-To: Support Group via Discourse <#{group.email_username}") + expect(email_log.raw).to include("From: Support Group via Discourse <#{group.email_username}") end - it "has the from_address and the to_addresses and subject filled in correctly" do + it "falls back to the group name if full name is blank" do + group.update(full_name: "") subject.execute(args) - incoming = IncomingEmail.find_by(post_id: post.id, user_id: post.user_id, topic_id: post.topic_id) - expect(incoming.to_addresses).to eq("test@test.com") - expect(incoming.subject).to include("Re: This is a test topic") - expect(incoming.from_address).to eq("discourseteam@ponyexpress.com") + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.raw).to include("From: support-group via Discourse <#{group.email_username}") + end + + it "has the group_smtp_id and the to_address filled in correctly" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.to_address).to eq("test@test.com") + expect(email_log.smtp_group_id).to eq(group.id) + end + + context "when there are cc_addresses" do + let!(:cormac_user) { Fabricate(:user, email: "cormac@lit.com") } + + it "has the cc_addresses and cc_user_ids filled in correctly" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.cc_addresses).to eq("otherguy@test.com;cormac@lit.com") + expect(email_log.cc_user_ids).to eq([cormac_user.id]) + end end context "when the post in the argument is the OP" do let(:post_id) { post.topic.posts.first.id } it "aborts and does not send a group SMTP email; the OP is the one that sent the email in the first place" do - expect { subject.execute(args) }.not_to(change { IncomingEmail.count }) + expect { subject.execute(args) }.not_to(change { EmailLog.count }) end end end diff --git a/spec/mailers/group_smtp_mailer_spec.rb b/spec/mailers/group_smtp_mailer_spec.rb index 0eedddab14..afca00f11e 100644 --- a/spec/mailers/group_smtp_mailer_spec.rb +++ b/spec/mailers/group_smtp_mailer_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' require 'email/receiver' describe GroupSmtpMailer do - let(:group) { + let(:group) do Fabricate(:group, name: 'Testers', title: 'Tester', @@ -19,15 +19,15 @@ describe GroupSmtpMailer do email_username: 'bugs@gmail.com', email_password: 'super$secret$password' ) - } + end - let(:user) { + let(:user) do user = Fabricate(:user) group.add_owner(user) user - } + end - let(:email) { + let(:email) do <<~EOF Delivered-To: bugs@gmail.com MIME-Version: 1.0 @@ -42,17 +42,18 @@ describe GroupSmtpMailer do How are you doing? EOF - } + end - let(:receiver) { - receiver = Email::Receiver.new(email, - destinations: [group], - uid_validity: 1, - uid: 10000 - ) - receiver.process! - receiver - } + let(:receiver) do + receiver = Email::Receiver.new( + email, + destinations: [group], + uid_validity: 1, + uid: 10000 + ) + receiver.process! + receiver + end let(:raw) { 'hello, how are you doing?' } @@ -60,6 +61,9 @@ describe GroupSmtpMailer do SiteSetting.enable_smtp = true SiteSetting.enable_imap = true Jobs.run_immediately! + SiteSetting.manual_polling_enabled = true + SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" + SiteSetting.reply_by_email_enabled = true end it 'sends an email as reply' do @@ -72,11 +76,21 @@ describe GroupSmtpMailer do sent_mail = ActionMailer::Base.deliveries[0] expect(sent_mail.to).to contain_exactly('john@doe.com') - expect(sent_mail.reply_to).to contain_exactly('bugs@gmail.com') + expect(sent_mail.reply_to).to eq(nil) expect(sent_mail.subject).to eq('Re: Hello from John') expect(sent_mail.to_s).to include(raw) end + it "uses the OP incoming email subject for the subject over topic title" do + receiver.incoming_email.topic.update(title: "blah") + post = PostCreator.create(user, + topic_id: receiver.incoming_email.topic.id, + raw: raw + ) + sent_mail = ActionMailer::Base.deliveries[0] + expect(sent_mail.subject).to eq('Re: Hello from John') + end + context "when the site has a reply by email address configured" do before do SiteSetting.manual_polling_enabled = true @@ -84,7 +98,7 @@ describe GroupSmtpMailer do SiteSetting.reply_by_email_enabled = true end - it 'uses the correct IMAP/SMTP reply to address' do + it 'uses the correct IMAP/SMTP reply to address and does not create a post reply key' do post = PostCreator.create(user, topic_id: receiver.incoming_email.topic.id, raw: raw @@ -92,8 +106,11 @@ describe GroupSmtpMailer do expect(ActionMailer::Base.deliveries.size).to eq(1) + expect(PostReplyKey.find_by(user_id: user.id, post_id: post.id)).to eq(nil) + sent_mail = ActionMailer::Base.deliveries[0] - expect(sent_mail.reply_to).to contain_exactly('bugs@gmail.com') + expect(sent_mail.reply_to).to eq(nil) + expect(sent_mail.from).to contain_exactly('bugs@gmail.com') end context "when IMAP is disabled for the group" do @@ -101,6 +118,21 @@ describe GroupSmtpMailer do group.update(imap_enabled: false) end + it "does send the email" do + post = PostCreator.create(user, + topic_id: receiver.incoming_email.topic.id, + raw: raw + ) + + expect(ActionMailer::Base.deliveries.size).to eq(1) + end + end + + context "when SMTP is disabled for the group" do + before do + group.update(smtp_enabled: false) + end + it "does not send the email" do post = PostCreator.create(user, topic_id: receiver.incoming_email.topic.id, diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 344cc55a8f..3edb1f3b33 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -611,84 +611,6 @@ describe UserNotifications do expect(mail.body).to include("[group1 (2)](http://test.localhost/groups/group1), [group2 (1)](http://test.localhost/groups/group2), [one](http://test.localhost/u/one), [two](http://test.localhost/u/two)") end - context "when group smtp is configured and SiteSetting.enable_smtp" do - let!(:group1) do - Fabricate( - :group, - name: "group1", - smtp_enabled: true, - smtp_port: 587, - smtp_ssl: true, - smtp_server: "smtp.test.com", - email_username: "user@test.com", - email_password: "password" - ) - end - - before do - SiteSetting.enable_smtp = true - topic.allowed_groups = [group1] - end - - it "uses the from address, which is the group's email_username, for reply-to" do - mail = UserNotifications.user_private_message( - user, - post: response, - notification_type: notification.notification_type, - notification_data_hash: notification.data_hash - ) - - expect(mail.from).to eq([group1.email_username]) - expect(mail.reply_to).to eq([group1.email_username]) - end - - it "uses the SMTP settings from the group for delivery" do - mail = UserNotifications.user_private_message( - user, - post: response, - notification_type: notification.notification_type, - notification_data_hash: notification.data_hash - ) - - delivery_method = mail.delivery_method.settings - expect(delivery_method[:port]).to eq(group1.smtp_port) - expect(delivery_method[:address]).to eq(group1.smtp_server) - expect(delivery_method[:domain]).to eq("test.com") - expect(delivery_method[:password]).to eq("password") - expect(delivery_method[:user_name]).to eq("user@test.com") - end - - context "when imap is configured for the group" do - before do - group1.update( - imap_server: "imap.test.com", - imap_port: 993, - imap_ssl: true, - imap_enabled: true, - imap_mailbox_name: "All Mail" - ) - end - - it "does not use group SMTP settings for delivery, this is handled by Jobs::GroupSmtpEmail" do - mail = UserNotifications.user_private_message( - user, - post: response, - notification_type: notification.notification_type, - notification_data_hash: notification.data_hash - ) - - expect(mail.from).to eq([SiteSetting.notification_email]) - expect(mail.reply_to).to eq([SiteSetting.notification_email]) - delivery_method = mail.delivery_method.settings - expect(delivery_method[:port]).not_to eq(group1.smtp_port) - expect(delivery_method[:address]).not_to eq(group1.smtp_server) - expect(delivery_method[:domain]).not_to eq("test.com") - expect(delivery_method[:password]).not_to eq("password") - expect(delivery_method[:user_name]).not_to eq("user@test.com") - end - end - end - context "when SiteSetting.group_name_in_subject is true" do before do SiteSetting.group_in_subject = true diff --git a/spec/models/email_log_spec.rb b/spec/models/email_log_spec.rb index 0e6206bb1f..60064f4b86 100644 --- a/spec/models/email_log_spec.rb +++ b/spec/models/email_log_spec.rb @@ -138,4 +138,27 @@ describe EmailLog do end end end + + describe ".addressed_to_user scope" do + let(:user) { Fabricate(:user, email: "test@test.com") } + before do + Fabricate(:email_log, to_address: "john@smith.com") + Fabricate(:email_log, cc_addresses: "jane@jones.com;elle@someplace.org") + user.reload + end + + it "returns email logs where the to address matches" do + user.user_emails.first.update!(email: "john@smith.com") + expect(EmailLog.addressed_to_user(user).count).to eq(1) + end + + it "returns email logs where a cc address matches" do + user.user_emails.first.update!(email: "elle@someplace.org") + expect(EmailLog.addressed_to_user(user).count).to eq(1) + end + + it "returns nothing if no emails match" do + expect(EmailLog.addressed_to_user(user).count).to eq(0) + end + end end diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index ea7cd26f0a..c9d4773a87 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -1295,7 +1295,7 @@ describe PostAlerter do context "SMTP (group_smtp_email)" do before do SiteSetting.enable_smtp = true - SiteSetting.enable_imap = true + SiteSetting.email_in = true Jobs.run_immediately! end @@ -1315,99 +1315,257 @@ describe PostAlerter do ) end - fab!(:topic) do - Fabricate( - :private_message_topic, - topic_allowed_groups: [ - Fabricate.build(:topic_allowed_group, group: group) - ] - ) - end - def create_post_with_incoming - Fabricate( - :post, - topic: topic, - incoming_email: - Fabricate( - :incoming_email, - topic: topic, - from_address: "foo@discourse.org", - to_addresses: group.email_username, - cc_addresses: "bar@discourse.org" - ) - ) + raw_mail = <<~MAIL + From: Foo + To: discourse@example.com + Cc: bar@discourse.org, jim@othersite.com + Subject: Full email group username flow + Date: Fri, 15 Jan 2021 00:12:43 +0100 + Message-ID: + Mime-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + This is the first email. + MAIL + + Email::Receiver.new(raw_mail, {}).process! end - it "does not send a group smtp email when the post already has an incoming email" do - post = create_post_with_incoming - - expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) - end - - it "sends a group smtp email when the post does not have an incoming email" do - create_post_with_incoming + it "sends a group smtp email because SMTP is enabled for the site and the group" do + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic post = Fabricate(:post, topic: topic) expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(1) email = ActionMailer::Base.deliveries.last expect(email.from).to include(group.email_username) - expect(email.to).to contain_exactly("foo@discourse.org", "bar@discourse.org") + expect(email.to).to contain_exactly(topic.reload.topic_allowed_users.order(:created_at).first.user.email) + expect(email.cc).to match_array(["bar@discourse.org", "jim@othersite.com"]) expect(email.subject).to eq("Re: #{topic.title}") end - it "does not send a group smtp email if imap is not enabled for the group" do - group.update!(imap_enabled: false) - create_post_with_incoming - post = Fabricate(:post, topic: topic) - expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) - end - - it "does not send a group smtp email if SiteSetting.enable_imap is false" do - SiteSetting.enable_imap = false - create_post_with_incoming + it "does not send a group smtp email if smtp is not enabled for the group" do + group.update!(smtp_enabled: false) + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic post = Fabricate(:post, topic: topic) expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) end it "does not send a group smtp email if SiteSetting.enable_smtp is false" do SiteSetting.enable_smtp = false - create_post_with_incoming + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic post = Fabricate(:post, topic: topic) expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) end it "does not send group smtp emails for a whisper" do - create_post_with_incoming + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic post = Fabricate(:post, topic: topic, post_type: Post.types[:whisper]) expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) end - it "does not send a notification email to the group when the post does not have an incoming email" do - PostAlerter.any_instance.expects(:create_notification).with(kind_of(User), Notification.types[:private_message], kind_of(Post), skip_send_email_to: ["discourse@example.com"]).at_least_once - post = create_post_with_incoming - staged_group_user = Fabricate(:staged, email: "discourse@example.com") - Fabricate(:topic_user, user: staged_group_user, topic: post.topic) - topic.allowed_users << staged_group_user - topic.save - expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(0) + it "skips sending a notification email to the group and all other email addresses that are _not_ members of the group, + sends a group_smtp_email instead" do + NotificationEmailer.enable + + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic + + group_user1 = Fabricate(:group_user, group: group) + group_user2 = Fabricate(:group_user, group: group) + TopicUser.create(user: group_user1.user, notification_level: TopicUser.notification_levels[:watching], topic: topic) + post = Fabricate(:post, topic: topic.reload) + + # Sends an email for: + # + # 1. the group user that is watching the post (but does not send this email with group SMTO) + # 2. the group smtp email to notify all topic_users not in the group + expect { PostAlerter.new.after_save_post(post, true) }.to change { + ActionMailer::Base.deliveries.size + }.by(2).and change { Notification.count }.by(2) + + # The group smtp email + email = ActionMailer::Base.deliveries.first + expect(email.from).to eq([group.email_username]) + expect(email.to).to contain_exactly("foo@discourse.org") + expect(email.cc).to match_array(["bar@discourse.org", "jim@othersite.com"]) + expect(email.subject).to eq("Re: #{topic.title}") + + # The watching group user notification email + email = ActionMailer::Base.deliveries.last + expect(email.from).to eq([SiteSetting.notification_email]) + expect(email.to).to contain_exactly(group_user1.user.email) + expect(email.cc).to eq(nil) + expect(email.subject).to eq("[Discourse] [PM] #{topic.title}") end - it "skips sending a notification email to the group and all other incoming email addresses" do + it "skips sending a notification email to the cc address that was added on the same post with an incoming email" do + NotificationEmailer.enable - create_post_with_incoming - PostAlerter.any_instance.expects(:create_notification).with(kind_of(User), Notification.types[:private_message], kind_of(Post), skip_send_email_to: ["foo@discourse.org", "bar@discourse.org", "discourse@example.com"]).at_least_once + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic post = Fabricate(:post, topic: topic.reload) - staged_group_user = Fabricate(:staged, email: "discourse@example.com") - Fabricate(:topic_user, user: staged_group_user, topic: post.topic) - topic.allowed_users << staged_group_user - topic.save - - expect { PostAlerter.new.after_save_post(post, true) }.to change { ActionMailer::Base.deliveries.size }.by(1) + expect { PostAlerter.new.after_save_post(post, true) }.to change { + ActionMailer::Base.deliveries.size + }.by(1).and change { Notification.count }.by(1) email = ActionMailer::Base.deliveries.last + + # the reply post from someone who was emailed + reply_raw_mail = <<~MAIL + From: Bar + To: discourse@example.com + Cc: someothernewcc@baz.com, finalnewcc@doom.com + Subject: #{email.subject} + Date: Fri, 16 Jan 2021 00:12:43 +0100 + Message-ID: + In-Reply-To: #{email.message_id} + Mime-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + Hey here is my reply! + MAIL + + reply_post_from_email = nil + expect { + reply_post_from_email = Email::Receiver.new(reply_raw_mail, {}).process! + }.to change { + User.count # the two new cc addresses have users created + }.by(2).and change { + TopicAllowedUser.where(topic: topic).count # and they are added as topic allowed users + }.by(2).and change { + # but they are not sent emails because they were cc'd on an email, only jim@othersite.com + # is emailed because he is a topic allowed user cc'd on the _original_ email and he is not + # the one creating the post, and foo@discourse.org, who is the OP of the topic + ActionMailer::Base.deliveries.size + }.by(1).and change { + Notification.count # and they are still sent their normal discourse notification + }.by(2) + + email = ActionMailer::Base.deliveries.last + + expect(email.to).to eq(["foo@discourse.org"]) + expect(email.cc).to eq(["jim@othersite.com"]) expect(email.from).to eq([group.email_username]) - expect(email.to).to contain_exactly("foo@discourse.org", "bar@discourse.org") expect(email.subject).to eq("Re: #{topic.title}") end + + it "handles the OP of the topic replying by email and sends a group email to the other topic allowed users successfully" do + NotificationEmailer.enable + + incoming_email_post = create_post_with_incoming + topic = incoming_email_post.topic + post = Fabricate(:post, topic: topic.reload) + expect { PostAlerter.new.after_save_post(post, true) }.to change { + ActionMailer::Base.deliveries.size + }.by(1).and change { Notification.count }.by(1) + email = ActionMailer::Base.deliveries.last + + # the reply post from someone who was emailed + reply_raw_mail = <<~MAIL + From: Foo + To: discourse@example.com + Cc: someothernewcc@baz.com, finalnewcc@doom.com + Subject: #{email.subject} + Date: Fri, 16 Jan 2021 00:12:43 +0100 + Message-ID: + In-Reply-To: #{email.message_id} + Mime-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + I am ~~Commander Shepherd~~ the OP and I approve of this message. + MAIL + + reply_post_from_email = nil + expect { + reply_post_from_email = Email::Receiver.new(reply_raw_mail, {}).process! + }.to change { + User.count # the two new cc addresses have users created + }.by(2).and change { + TopicAllowedUser.where(topic: topic).count # and they are added as topic allowed users + }.by(2).and change { + # but they are not sent emails because they were cc'd on an email, only jim@othersite.com + # is emailed because he is a topic allowed user cc'd on the _original_ email and he is not + # the one creating the post + ActionMailer::Base.deliveries.size + }.by(1).and change { + Notification.count # and they are still sent their normal discourse notification + }.by(2) + + email = ActionMailer::Base.deliveries.last + + expect(email.to).to eq(["bar@discourse.org"]) + expect(email.cc).to eq(["jim@othersite.com"]) + expect(email.from).to eq([group.email_username]) + expect(email.subject).to eq("Re: #{topic.title}") + end + + it "handles the OP of the topic replying by email and cc'ing new people, and does not send a group SMTP email to those newly cc'd users" do + NotificationEmailer.enable + + # this is a special case where we are not CC'ing on the original email, + # only on the follow up email + raw_mail = <<~MAIL + From: Foo + To: discourse@example.com + Subject: Full email group username flow + Date: Fri, 14 Jan 2021 00:12:43 +0100 + Message-ID: + Mime-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + This is the first email. + MAIL + + incoming_email_post = Email::Receiver.new(raw_mail, {}).process! + topic = incoming_email_post.topic + post = Fabricate(:post, topic: topic.reload) + expect { PostAlerter.new.after_save_post(post, true) }.to change { + ActionMailer::Base.deliveries.size + }.by(1).and change { Notification.count }.by(1) + email = ActionMailer::Base.deliveries.last + + # the reply post from the OP, cc'ing new people in + reply_raw_mail = <<~MAIL + From: Foo + To: discourse@example.com + Cc: someothernewcc@baz.com, finalnewcc@doom.com + Subject: #{email.subject} + Date: Fri, 16 Jan 2021 00:12:43 +0100 + Message-ID: <3849cu9843yncr9834yr9348x934@discourse.org.mail> + In-Reply-To: #{email.message_id} + Mime-Version: 1.0 + Content-Type: text/plain + Content-Transfer-Encoding: 7bit + + I am inviting my mates to this email party. + MAIL + + reply_post_from_email = nil + expect { + reply_post_from_email = Email::Receiver.new(reply_raw_mail, {}).process! + }.to change { + User.count # the two new cc addresses have users created + }.by(2).and change { + TopicAllowedUser.where(topic: topic).count # and they are added as topic allowed users + }.by(2).and change { + # but they are not sent emails because they were cc'd on an email. + # no group smtp message is sent because the OP is not sent an email, + # they made this post. + ActionMailer::Base.deliveries.size + }.by(0).and change { + Notification.count # and they are still sent their normal discourse notification + }.by(2) + + last_email = ActionMailer::Base.deliveries.last + expect(email).to eq(last_email) + end end end From d3e27cabf6beea7f77f1a14be43d1976853dfe8e Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 28 Jun 2021 10:42:06 +1000 Subject: [PATCH 197/403] FIX: Improve participant display in group SMTP emails (#13539) This PR makes several changes to the group SMTP email contents to make it look more like a support inbox message. * Remove the context posts, they only add clutter to the email and replies * Display email addresses of staged users instead of odd generated usernames * Add a "please reply above this line" message to sent emails --- app/mailers/group_smtp_mailer.rb | 58 ++++++------------ app/mailers/user_notifications.rb | 3 +- app/models/email_log.rb | 15 +++++ app/models/group.rb | 6 +- app/views/email/notification.html.erb | 5 ++ config/locales/server.en.yml | 1 + lib/email/styles.rb | 1 + spec/jobs/regular/group_smtp_email_spec.rb | 68 +++++++++++++++++++--- 8 files changed, 108 insertions(+), 49 deletions(-) diff --git a/app/mailers/group_smtp_mailer.rb b/app/mailers/group_smtp_mailer.rb index 574facc157..045f669ec0 100644 --- a/app/mailers/group_smtp_mailer.rb +++ b/app/mailers/group_smtp_mailer.rb @@ -10,16 +10,6 @@ class GroupSmtpMailer < ActionMailer::Base op_incoming_email = post.topic.first_post.incoming_email - context_posts = Post - .where(topic_id: post.topic_id) - .where("post_number < ?", post.post_number) - .where(user_deleted: false) - .where(hidden: false) - .where(post_type: Post.types[:regular]) - .order(created_at: :desc) - .limit(SiteSetting.email_posts_context) - .to_a - delivery_options = { address: from_group.smtp_server, port: from_group.smtp_port, @@ -35,14 +25,14 @@ class GroupSmtpMailer < ActionMailer::Base user_name = post.user.name unless post.user.name.blank? end - group_name = from_group.full_name.presence || from_group.name + group_name = from_group.name_full_preferred build_email( to_address, message: post.raw, url: post.url(without_slug: SiteSetting.private_email?), post_id: post.id, topic_id: post.topic_id, - context: context(context_posts), + context: "", username: post.user.username, group_name: group_name, allow_reply_by_email: true, @@ -59,45 +49,25 @@ class GroupSmtpMailer < ActionMailer::Base delivery_method_options: delivery_options, from: from_group.email_username, from_alias: I18n.t('email_from', user_name: group_name, site_name: Email.site_title), - html_override: html_override(post, context_posts: context_posts), + html_override: html_override(post), cc: cc_addresses ) end private - def context(context_posts) - return "" if SiteSetting.private_email? - - context = +"" - - if context_posts.size > 0 - context << +"-- \n*#{I18n.t('user_notifications.previous_discussion')}*\n" - context_posts.each { |post| context << email_post_markdown(post, true) } - end - - context - end - - def email_post_markdown(post, add_posted_by = false) - result = +"#{post.raw}\n\n" - if add_posted_by - result << "#{I18n.t('user_notifications.posted_by', username: post.username, post_date: post.created_at.strftime("%m/%d/%Y"))}\n\n" - end - result - end - - def html_override(post, context_posts: nil) + def html_override(post) UserNotificationRenderer.render( template: 'email/notification', format: :html, locals: { - context_posts: context_posts, + context_posts: nil, reached_limit: nil, post: post, in_reply_to_post: post.reply_to_post, classes: Rtl.new(nil).css_class, - first_footer_classes: '' + first_footer_classes: '', + reply_above_line: true } ) end @@ -106,14 +76,22 @@ class GroupSmtpMailer < ActionMailer::Base list = [] post.topic.allowed_groups.each do |g| - list.push("[#{g.name} (#{g.users.count})](#{Discourse.base_url}/groups/#{g.name})") + list.push("[#{g.name_full_preferred}](#{Discourse.base_url}/groups/#{g.name})") end post.topic.allowed_users.each do |u| if SiteSetting.prioritize_username_in_ux? - list.push("[#{u.username}](#{Discourse.base_url}/u/#{u.username_lower})") + if u.staged? + list.push("[#{u.email}](#{Discourse.base_url}/u/#{u.username_lower})") + else + list.push("[#{u.username}](#{Discourse.base_url}/u/#{u.username_lower})") + end else - list.push("[#{u.name.blank? ? u.username : u.name}](#{Discourse.base_url}/u/#{u.username_lower})") + if u.staged? + list.push("[#{u.email}](#{Discourse.base_url}/u/#{u.username_lower})") + else + list.push("[#{u.name.blank? ? u.username : u.name}](#{Discourse.base_url}/u/#{u.username_lower})") + end end end diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 993d46fa25..d5a20e0f07 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -632,7 +632,8 @@ class UserNotifications < ActionMailer::Base post: post, in_reply_to_post: in_reply_to_post, classes: Rtl.new(user).css_class, - first_footer_classes: first_footer_classes + first_footer_classes: first_footer_classes, + reply_above_line: false } ) end diff --git a/app/models/email_log.rb b/app/models/email_log.rb index c089746ade..e8dd29a5df 100644 --- a/app/models/email_log.rb +++ b/app/models/email_log.rb @@ -96,6 +96,21 @@ class EmailLog < ActiveRecord::Base def cc_addresses_split @cc_addresses_split ||= self.cc_addresses&.split(";") || [] end + + def as_mail_message + return if self.raw.blank? + @mail_message ||= Mail.new(self.raw) + end + + def raw_headers + return if self.raw.blank? + as_mail_message.header.raw_source + end + + def raw_body + return if self.raw.blank? + as_mail_message.body + end end # == Schema Information diff --git a/app/models/group.rb b/app/models/group.rb index c70918ee90..8a6d7183a3 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -894,11 +894,15 @@ class Group < ActiveRecord::Base SystemMessage.create_from_system_user( user, owner ? :user_added_to_group_as_owner : :user_added_to_group_as_member, - group_name: self.full_name.presence || self.name, + group_name: name_full_preferred, group_path: "/g/#{self.name}" ) end + def name_full_preferred + self.full_name.presence || self.name + end + def message_count return 0 unless self.has_messages TopicAllowedGroup.where(group_id: self.id).joins(:topic).count diff --git a/app/views/email/notification.html.erb b/app/views/email/notification.html.erb index cc30418c06..b3d3a2cba7 100644 --- a/app/views/email/notification.html.erb +++ b/app/views/email/notification.html.erb @@ -1,4 +1,9 @@
    > + <%- if reply_above_line.present? %> +
    + <%= t('user_notifications.reply_above_line') %> +
    + <% end %>
    %{header_instructions}
    diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 404d3f96de..319b7728e8 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -3471,6 +3471,7 @@ en: only_reply_by_email_pm: "Reply to this email to respond to %{participants}." visit_link_to_respond: "[Visit Topic](%{base_url}%{url}) to respond." visit_link_to_respond_pm: "[Visit Message](%{base_url}%{url}) to respond to %{participants}." + reply_above_line: "## Please type your reply above this line. ##" posted_by: "Posted by %{username} on %{post_date}" diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 250ecff7e2..5938dffaf4 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -232,6 +232,7 @@ module Email style('.lightbox-wrapper .meta', 'display: none') style('div.undecorated-link-footer a', "font-weight: normal;") style('.mso-accent-link', "mso-border-alt: 6px solid #{SiteSetting.email_accent_bg_color}; background-color: #{SiteSetting.email_accent_bg_color};") + style('.reply-above-line', "font-size: 10px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color: #b5b5b5;padding: 5px 0px 20px;border-top: 1px dotted #ddd;") onebox_styles plugin_styles diff --git a/spec/jobs/regular/group_smtp_email_spec.rb b/spec/jobs/regular/group_smtp_email_spec.rb index ffba33863d..1bcae95af0 100644 --- a/spec/jobs/regular/group_smtp_email_spec.rb +++ b/spec/jobs/regular/group_smtp_email_spec.rb @@ -3,10 +3,10 @@ require 'rails_helper' RSpec.describe Jobs::GroupSmtpEmail do + fab!(:topic) { Fabricate(:private_message_topic, title: "Help I need support") } fab!(:post) do - topic = Fabricate(:topic, title: "Help I need support") - Fabricate(:post, topic: topic) - Fabricate(:post, topic: topic) + Fabricate(:post, topic: topic, raw: "some first post content") + Fabricate(:post, topic: topic, raw: "this is the second post reply") end fab!(:group) { Fabricate(:smtp_group, name: "support-group", full_name: "Support Group") } fab!(:recipient_user) { Fabricate(:user, email: "test@test.com") } @@ -19,12 +19,19 @@ RSpec.describe Jobs::GroupSmtpEmail do cc_emails: ["otherguy@test.com", "cormac@lit.com"] } end + let(:staged1) { Fabricate(:staged, email: "otherguy@test.com") } + let(:staged2) { Fabricate(:staged, email: "cormac@lit.com") } + let(:normaluser) { Fabricate(:user, email: "justanormalguy@test.com", username: "normaluser") } before do SiteSetting.enable_smtp = true SiteSetting.manual_polling_enabled = true SiteSetting.reply_by_email_address = "test+%{reply_key}@test.com" SiteSetting.reply_by_email_enabled = true + TopicAllowedGroup.create(group: group, topic: topic) + TopicAllowedUser.create(user: staged1, topic: topic) + TopicAllowedUser.create(user: staged2, topic: topic) + TopicAllowedUser.create(user: normaluser, topic: topic) end it "sends an email using the GroupSmtpMailer and Email::Sender" do @@ -33,6 +40,55 @@ RSpec.describe Jobs::GroupSmtpEmail do subject.execute(args) end + it "includes a 'reply above this line' message" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.as_mail_message.html_part.to_s).to include(I18n.t("user_notifications.reply_above_line")) + end + + it "does not include context posts" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.as_mail_message.text_part.to_s).not_to include(I18n.t("user_notifications.previous_discussion")) + expect(email_log.as_mail_message.text_part.to_s).not_to include("some first post content") + end + + it "includes the participants in the correct format" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log.as_mail_message.text_part.to_s).to include("Support Group") + expect(email_log.as_mail_message.text_part.to_s).to include("otherguy@test.com") + expect(email_log.as_mail_message.text_part.to_s).to include("cormac@lit.com") + expect(email_log.as_mail_message.text_part.to_s).to include("normaluser") + end + + it "creates an EmailLog record with the correct details" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + expect(email_log).not_to eq(nil) + expect(email_log.message_id).to eq("topic/#{post.topic_id}/#{post.id}@test.localhost") + end + + it "creates an IncomingEmail record with the correct details to avoid double processing IMAP" do + subject.execute(args) + incoming_email = IncomingEmail.find_by(post_id: post.id, topic_id: post.topic_id, user_id: post.user.id) + expect(incoming_email).not_to eq(nil) + expect(incoming_email.message_id).to eq("topic/#{post.topic_id}/#{post.id}@test.localhost") + expect(incoming_email.created_via).to eq(IncomingEmail.created_via_types[:group_smtp]) + expect(incoming_email.to_addresses).to eq("test@test.com") + expect(incoming_email.cc_addresses).to eq("otherguy@test.com;cormac@lit.com") + expect(incoming_email.subject).to eq("Re: Help I need support") + end + + it "does not create a post reply key, it always replies to the group email_username" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + post_reply_key = PostReplyKey.where(user_id: recipient_user, post_id: post.id).first + expect(post_reply_key).to eq(nil) + expect(email_log.raw_headers).not_to include("Reply-To: Support Group via Discourse <#{group.email_username}") + expect(email_log.raw_headers).to include("From: Support Group via Discourse <#{group.email_username}") + end + it "creates an EmailLog record with the correct details" do subject.execute(args) email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) @@ -64,7 +120,7 @@ RSpec.describe Jobs::GroupSmtpEmail do group.update(full_name: "") subject.execute(args) email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) - expect(email_log.raw).to include("From: support-group via Discourse <#{group.email_username}") + expect(email_log.raw_headers).to include("From: support-group via Discourse <#{group.email_username}") end it "has the group_smtp_id and the to_address filled in correctly" do @@ -75,13 +131,11 @@ RSpec.describe Jobs::GroupSmtpEmail do end context "when there are cc_addresses" do - let!(:cormac_user) { Fabricate(:user, email: "cormac@lit.com") } - it "has the cc_addresses and cc_user_ids filled in correctly" do subject.execute(args) email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) expect(email_log.cc_addresses).to eq("otherguy@test.com;cormac@lit.com") - expect(email_log.cc_user_ids).to eq([cormac_user.id]) + expect(email_log.cc_user_ids).to match_array([staged1.id, staged2.id]) end end From ff1c53dd6fa2a882ef104117274a0b77dddd424a Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Mon, 28 Jun 2021 10:39:13 +0800 Subject: [PATCH 198/403] FIX: Missing category edit icon. Follow-up to 0e4b8c5318569ef7e7a111563709699e3b9ce219 --- app/models/site.rb | 22 ++++++++----- app/serializers/site_category_serializer.rb | 1 - lib/guardian/category_guardian.rb | 9 ++++++ spec/models/site_spec.rb | 35 +++++++++++++++------ 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index ced763c06d..338091faee 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -67,14 +67,14 @@ class Site self.class.all_categories_cache.each do |category| if @guardian.can_see_serialized_category?(category_id: category[:id], read_restricted: category[:read_restricted]) - categories << OpenStruct.new(category) + categories << category end end with_children = Set.new categories.each do |c| - if c.parent_category_id - with_children << c.parent_category_id + if c[:parent_category_id] + with_children << c[:parent_category_id] end end @@ -91,13 +91,19 @@ class Site default_notification_level = CategoryUser.default_notification_level categories.each do |category| - category.notification_level = notification_levels[category.id] || default_notification_level - category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create&.include?(category.id) || @guardian.is_admin? - category.has_children = with_children.include?(category.id) - by_id[category.id] = category + category[:notification_level] = notification_levels[category[:id]] || default_notification_level + category[:permission] = CategoryGroup.permission_types[:full] if allowed_topic_create&.include?(category[:id]) || @guardian.is_admin? + category[:has_children] = with_children.include?(category[:id]) + + category[:can_edit] = @guardian.can_edit_serialized_category?( + category_id: category[:id], + read_restricted: category[:read_restricted] + ) + + by_id[category[:id]] = category end - categories.reject! { |c| c.parent_category_id && !by_id[c.parent_category_id] } + categories.reject! { |c| c[:parent_category_id] && !by_id[c[:parent_category_id]] } categories end end diff --git a/app/serializers/site_category_serializer.rb b/app/serializers/site_category_serializer.rb index 89868e9389..8634ba3874 100644 --- a/app/serializers/site_category_serializer.rb +++ b/app/serializers/site_category_serializer.rb @@ -32,5 +32,4 @@ class SiteCategorySerializer < BasicCategorySerializer def required_tag_group_name object.required_tag_group&.name end - end diff --git a/lib/guardian/category_guardian.rb b/lib/guardian/category_guardian.rb index ef8441071b..94a48466d6 100644 --- a/lib/guardian/category_guardian.rb +++ b/lib/guardian/category_guardian.rb @@ -22,6 +22,15 @@ module CategoryGuardian ) end + def can_edit_serialized_category?(category_id:, read_restricted:) + is_admin? || + ( + SiteSetting.moderators_manage_categories_and_groups && + is_moderator? && + can_see_serialized_category?(category_id: category_id, read_restricted: read_restricted) + ) + end + def can_delete_category?(category) can_edit_category?(category) && category.topic_count <= 0 && diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index 3e2e0c2e81..5ce3426a19 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -54,11 +54,11 @@ describe Site do it "returns correct notification level for categories" do category = Fabricate(:category) guardian = Guardian.new - expect(Site.new(guardian).categories.last.notification_level).to eq(1) + expect(Site.new(guardian).categories.last[:notification_level]).to eq(1) SiteSetting.mute_all_categories_by_default = true - expect(Site.new(guardian).categories.last.notification_level).to eq(0) + expect(Site.new(guardian).categories.last[:notification_level]).to eq(0) SiteSetting.default_categories_tracking = category.id.to_s - expect(Site.new(guardian).categories.last.notification_level).to eq(1) + expect(Site.new(guardian).categories.last[:notification_level]).to eq(1) end describe '#categories' do @@ -67,13 +67,13 @@ describe Site do fab!(:guardian) { Guardian.new(user) } it "omits read restricted categories" do - expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + expect(Site.new(guardian).categories.map { |c| c[:id] }).to contain_exactly( SiteSetting.uncategorized_category_id, category.id ) category.update!(read_restricted: true) - expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + expect(Site.new(guardian).categories.map { |c| c[:id] }).to contain_exactly( SiteSetting.uncategorized_category_id ) end @@ -83,13 +83,13 @@ describe Site do category.update!(read_restricted: true) category.groups << group - expect(Site.new(guardian).categories.map(&:id)).to contain_exactly( + expect(Site.new(guardian).categories.map { |c| c[:id] }).to contain_exactly( SiteSetting.uncategorized_category_id ) group.add(user) - expect(Site.new(Guardian.new(user)).categories.map(&:id)).to contain_exactly( + expect(Site.new(Guardian.new(user)).categories.map { |c| c[:id] }).to contain_exactly( SiteSetting.uncategorized_category_id, category.id ) end @@ -104,9 +104,8 @@ describe Site do expect(Site.new(guardian) .categories - .keep_if { |c| c.name == category.name } - .first - .permission) + .keep_if { |c| c[:name] == category.name } + .first[:permission]) .not_to eq(CategoryGroup.permission_types[:full]) # If a parent category is not visible, the child categories should not be returned @@ -138,6 +137,22 @@ describe Site do ensure Site.preloaded_category_custom_fields.clear end + + it 'sets the can_edit field for categories correctly' do + categories = Site.new(Guardian.new).categories + + expect(categories.map { |c| c[:can_edit] }).to contain_exactly(false, false) + + site = Site.new(Guardian.new(Fabricate(:moderator))) + + expect(site.categories.map { |c| c[:can_edit] }).to contain_exactly(false, false) + + SiteSetting.moderators_manage_categories_and_groups = true + + site = Site.new(Guardian.new(Fabricate(:moderator))) + + expect(site.categories.map { |c| c[:can_edit] }).to contain_exactly(true, true) + end end it "omits groups user can not see" do From fd8016d6787218e0a3915995458c6f4e97a2b858 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 25 Jun 2021 11:20:03 +0800 Subject: [PATCH 199/403] DEV: Remove unused attributes from topic-tracking-state. --- .../javascripts/discourse/app/models/topic-tracking-state.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js index 8b424ec007..a36bc2d878 100644 --- a/app/assets/javascripts/discourse/app/models/topic-tracking-state.js +++ b/app/assets/javascripts/discourse/app/models/topic-tracking-state.js @@ -48,8 +48,6 @@ const TopicTrackingState = EmberObject.extend({ @on("init") _setup() { - this.unreadSequence = []; - this.newSequence = []; this.states = new Map(); this.messageIncrementCallbacks = {}; this.stateChangeCallbacks = {}; From 4d0178deab8533e6ad25b94d8a61bbfe5bc8948b Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 28 Jun 2021 13:19:17 +1000 Subject: [PATCH 200/403] FIX: Do not show In Reply To for group SMTP emails (#13541) We do not want to show the In Reply To section of the group SMTP email template, it is similar to Context Posts which we removed and is unnecessary. This PR also removes the link to staged user profiles in the email; their email addresses will just be converted to regular mailto: links. --- app/mailers/group_smtp_mailer.rb | 6 +++--- spec/jobs/regular/group_smtp_email_spec.rb | 24 +++++++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/mailers/group_smtp_mailer.rb b/app/mailers/group_smtp_mailer.rb index 045f669ec0..9107101792 100644 --- a/app/mailers/group_smtp_mailer.rb +++ b/app/mailers/group_smtp_mailer.rb @@ -64,7 +64,7 @@ class GroupSmtpMailer < ActionMailer::Base context_posts: nil, reached_limit: nil, post: post, - in_reply_to_post: post.reply_to_post, + in_reply_to_post: nil, classes: Rtl.new(nil).css_class, first_footer_classes: '', reply_above_line: true @@ -82,13 +82,13 @@ class GroupSmtpMailer < ActionMailer::Base post.topic.allowed_users.each do |u| if SiteSetting.prioritize_username_in_ux? if u.staged? - list.push("[#{u.email}](#{Discourse.base_url}/u/#{u.username_lower})") + list.push("#{u.email}") else list.push("[#{u.username}](#{Discourse.base_url}/u/#{u.username_lower})") end else if u.staged? - list.push("[#{u.email}](#{Discourse.base_url}/u/#{u.username_lower})") + list.push("#{u.email}") else list.push("[#{u.name.blank? ? u.username : u.name}](#{Discourse.base_url}/u/#{u.username_lower})") end diff --git a/spec/jobs/regular/group_smtp_email_spec.rb b/spec/jobs/regular/group_smtp_email_spec.rb index 1bcae95af0..8e24ac7eef 100644 --- a/spec/jobs/regular/group_smtp_email_spec.rb +++ b/spec/jobs/regular/group_smtp_email_spec.rb @@ -6,6 +6,7 @@ RSpec.describe Jobs::GroupSmtpEmail do fab!(:topic) { Fabricate(:private_message_topic, title: "Help I need support") } fab!(:post) do Fabricate(:post, topic: topic, raw: "some first post content") + Fabricate(:post, topic: topic, raw: "some intermediate content") Fabricate(:post, topic: topic, raw: "this is the second post reply") end fab!(:group) { Fabricate(:smtp_group, name: "support-group", full_name: "Support Group") } @@ -53,13 +54,26 @@ RSpec.describe Jobs::GroupSmtpEmail do expect(email_log.as_mail_message.text_part.to_s).not_to include("some first post content") end - it "includes the participants in the correct format" do + it "does not include in reply to post in email but still has the header" do + second_post = topic.posts.find_by(post_number: 2) + post.update!(reply_to_post_number: 1, reply_to_user: second_post.user) + PostReply.create(post: second_post, reply: post) subject.execute(args) email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) - expect(email_log.as_mail_message.text_part.to_s).to include("Support Group") - expect(email_log.as_mail_message.text_part.to_s).to include("otherguy@test.com") - expect(email_log.as_mail_message.text_part.to_s).to include("cormac@lit.com") - expect(email_log.as_mail_message.text_part.to_s).to include("normaluser") + expect(email_log.raw_headers).to include("In-Reply-To: ") + expect(email_log.as_mail_message.html_part.to_s).not_to include(I18n.t("user_notifications.in_reply_to")) + end + + it "includes the participants in the correct format, and does not have links for the staged users" do + subject.execute(args) + email_log = EmailLog.find_by(post_id: post.id, topic_id: post.topic_id, user_id: recipient_user.id) + email_text = email_log.as_mail_message.text_part.to_s + expect(email_text).to include("Support Group") + expect(email_text).to include("otherguy@test.com") + expect(email_text).not_to include("[otherguy@test.com]") + expect(email_text).to include("cormac@lit.com") + expect(email_text).not_to include("[cormac@lit.com]") + expect(email_text).to include("normaluser") end it "creates an EmailLog record with the correct details" do From a6b928947788bd75cdc6ff9256d81274015e20af Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 28 Jun 2021 14:57:51 +1000 Subject: [PATCH 201/403] DEV: Remove old group form code (#13542) We don't use this group form anymore since https://github.com/discourse/discourse/commit/964da218173db007fefe6357e96292f5545c513e when we revamped the UI --- .../components/groups-form-email-fields.js | 19 ----- .../components/groups-form-email-fields.hbs | 82 ------------------- 2 files changed, 101 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/components/groups-form-email-fields.js delete mode 100644 app/assets/javascripts/discourse/app/templates/components/groups-form-email-fields.hbs diff --git a/app/assets/javascripts/discourse/app/components/groups-form-email-fields.js b/app/assets/javascripts/discourse/app/components/groups-form-email-fields.js deleted file mode 100644 index e9f4a7e7c6..0000000000 --- a/app/assets/javascripts/discourse/app/components/groups-form-email-fields.js +++ /dev/null @@ -1,19 +0,0 @@ -import Component from "@ember/component"; -import discourseComputed from "discourse-common/utils/decorators"; - -export default Component.extend({ - @discourseComputed("model.imap_mailboxes") - mailboxes(imapMailboxes) { - return imapMailboxes.map((mailbox) => ({ name: mailbox, value: mailbox })); - }, - - @discourseComputed("model.imap_old_emails") - oldEmails(oldEmails) { - return oldEmails || 0; - }, - - @discourseComputed("model.imap_old_emails", "model.imap_new_emails") - totalEmails(oldEmails, newEmails) { - return (oldEmails || 0) + (newEmails || 0); - }, -}); diff --git a/app/assets/javascripts/discourse/app/templates/components/groups-form-email-fields.hbs b/app/assets/javascripts/discourse/app/templates/components/groups-form-email-fields.hbs deleted file mode 100644 index 386935c16c..0000000000 --- a/app/assets/javascripts/discourse/app/templates/components/groups-form-email-fields.hbs +++ /dev/null @@ -1,82 +0,0 @@ -{{#if currentUser.admin}} -
    - -
    - - {{#if model.imap_last_error}} -
    {{model.imap_last_error}}
    - {{else}} -
    - {{i18n "groups.manage.email.status" old_emails=oldEmails total_emails=totalEmails}} -
    - {{/if}} - -
    - - {{input type="text" name="smtp_server" value=model.smtp_server}} -
    - -
    - - {{input type="text" name="smtp_port" value=model.smtp_port}} -
    - -
    - -
    - -
    - - {{input type="text" name="imap_server" value=model.imap_server}} -
    - -
    - - {{input type="text" name="imap_port" value=model.imap_port}} -
    - -
    - -
    - -
    - - {{input type="text" name="username" value=model.email_username}} -
    - -
    - - {{input type="password" name="password" value=model.email_password}} -
    - -
    - {{#if mailboxes}} - - {{combo-box name="imap_mailbox_name" - value=model.imap_mailbox_name - valueProperty="value" - content=mailboxes - none="groups.manage.email.mailboxes.disabled" - onChange=(action (mut model.imap_mailbox_name))}} - {{else}} - {{i18n "groups.manage.email.mailboxes.none_found"}} - {{/if}} -
    - -
    - -
    -
    - -

    {{i18n "groups.manage.email.settings.allow_unknown_sender_topic_replies_hint"}}

    -
    -{{/if}} From 14a0247301510a5703d1d4b7f62141c13935cc97 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 28 Jun 2021 16:16:22 +1000 Subject: [PATCH 202/403] PERF: optimise backfilling of topic_id (#13545) Relying on large offsets can have uneven performance on huge table, new implementation recovers more cleanly and avoids double updates --- ...10621234939_backfill_email_log_topic_id.rb | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb b/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb index 5e2770e167..232f9e2718 100644 --- a/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb +++ b/db/post_migrate/20210621234939_backfill_email_log_topic_id.rb @@ -5,27 +5,22 @@ class BackfillEmailLogTopicId < ActiveRecord::Migration[6.1] BATCH_SIZE = 30_000 def up - offset = 0 - email_log_count = DB.query_single("SELECT COUNT(*) FROM email_logs").first - loop do - DB.exec(<<~SQL, offset: offset, batch_size: BATCH_SIZE) + count = DB.exec(<<~SQL, batch_size: BATCH_SIZE) WITH cte AS ( - SELECT post_id - FROM email_logs - ORDER BY id + SELECT l1.id, p1.topic_id + FROM email_logs l1 + INNER JOIN posts p1 ON p1.id = l1.post_id + WHERE l1.topic_id IS NULL AND p1.topic_id IS NOT NULL LIMIT :batch_size - OFFSET :offset ) UPDATE email_logs - SET topic_id = posts.topic_id + SET topic_id = cte.topic_id FROM cte - INNER JOIN posts ON posts.id = cte.post_id - WHERE email_logs.post_id = cte.post_id + WHERE email_logs.id = cte.id SQL - offset += BATCH_SIZE - break if offset > (email_log_count + BATCH_SIZE * 2) + break if count == 0 end end From 7719453fb73d66181508bf44e3abf104c3e71be9 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Mon, 28 Jun 2021 14:25:26 +0800 Subject: [PATCH 203/403] DEV: Don't eager load tags when tagging is not enabled. --- lib/topic_query.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/topic_query.rb b/lib/topic_query.rb index d09701fae7..d296fc8f48 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -586,7 +586,8 @@ class TopicQuery options = @options options.reverse_merge!(per_page: per_page_setting) - result = Topic.includes(:tags, :allowed_users) + result = Topic.includes(:allowed_users) + result = result.includes(:tags) if SiteSetting.tagging_enabled if type == :group result = result.joins( From 6be4699954a0cc2f89e3eb9db4d794468877b4d7 Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Mon, 28 Jun 2021 12:24:23 +0400 Subject: [PATCH 204/403] FIX: topic level bookmark button (#13530) We changed (https://github.com/discourse/discourse/pull/13407) behaviour of the topic level bookmark button recently. That PR made the button be opening the edit bookmark modal when there is only one bookmark on the topic instead of just removing that bookmark as it was before. This PR fixes the next problems that weren't taken into account in the previous PR: 1. Everything should work fine even on very big topics when a bookmarked post is unloaded from the post stream. I've added code that loads the post we need and makes everything work as expected 2. When at least one bookmark on the topic has a reminder, we should always be showing the icon with a clock on the topic level bookmark button 3. We should show correct tooltips for the topic level bookmark button --- .../discourse/app/controllers/topic.js | 114 ++++++++++-------- .../app/initializers/topic-footer-buttons.js | 41 +++---- .../javascripts/discourse/app/models/post.js | 1 + .../javascripts/discourse/app/models/topic.js | 40 +++--- .../tests/acceptance/bookmarks-test.js | 44 +++++++ app/serializers/post_serializer.rb | 7 +- app/serializers/topic_view_serializer.rb | 10 +- .../web_hook_topic_view_serializer.rb | 1 + config/locales/client.en.yml | 3 +- lib/topic_view.rb | 15 +-- spec/components/topic_view_spec.rb | 18 ++- spec/serializers/post_serializer_spec.rb | 8 -- 12 files changed, 190 insertions(+), 112 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index f8427f5347..c27f4bab82 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -1204,75 +1204,89 @@ export default Controller.extend(bufferedProperty("model"), { post.appEvents.trigger("post-stream:refresh", { id: post.id }); }, afterSave: (savedData) => { + this._addOrUpdateBookmarkedPost(post.id, savedData.reminderAt); post.createBookmark(savedData); resolve({ closedWithoutSaving: false }); }, afterDelete: (topicBookmarked) => { + this.model.set( + "bookmarked_posts", + this.model.bookmarked_posts.filter((x) => x.post_id !== post.id) + ); post.deleteBookmark(topicBookmarked); }, }); }); }, + _addOrUpdateBookmarkedPost(postId, reminderAt) { + if (!this.model.bookmarked_posts) { + this.model.set("bookmarked_posts", []); + } + + let bookmarkedPost = this.model.bookmarked_posts.findBy("post_id", postId); + if (!bookmarkedPost) { + bookmarkedPost = { post_id: postId }; + this.model.bookmarked_posts.pushObject(bookmarkedPost); + } + + bookmarkedPost.reminder_at = reminderAt; + }, + _toggleTopicBookmark() { if (this.model.bookmarking) { return Promise.resolve(); } this.model.set("bookmarking", true); - const alreadyBookmarkedPosts = this.model.bookmarkedPosts; + const bookmarkedPostsCount = this.model.bookmarked_posts + ? this.model.bookmarked_posts.length + : 0; - return this.model.firstPost().then((firstPost) => { - const bookmarkPost = async (post) => { - const opts = await this._togglePostBookmark(post); - this.model.set("bookmarking", false); - if (opts.closedWithoutSaving) { - return; - } - this.model.afterPostBookmarked(post); - return [post.id]; - }; + const bookmarkPost = async (post) => { + const opts = await this._togglePostBookmark(post); + this.model.set("bookmarking", false); + if (opts.closedWithoutSaving) { + return; + } + this.model.afterPostBookmarked(post); + return [post.id]; + }; - const toggleBookmarkOnServer = () => { - if (alreadyBookmarkedPosts.length === 0) { - return bookmarkPost(firstPost); - } else if (alreadyBookmarkedPosts.length === 1) { - const post = alreadyBookmarkedPosts[0]; - return bookmarkPost(post); - } else { - return this.model - .deleteBookmark() - .then(() => { - this.model.toggleProperty("bookmarked"); - this.model.set("bookmark_reminder_at", null); - alreadyBookmarkedPosts.forEach((post) => { - post.clearBookmark(); - }); - return alreadyBookmarkedPosts.mapBy("id"); - }) - .catch(popupAjaxError) - .finally(() => this.model.set("bookmarking", false)); - } - }; + const toggleBookmarkOnServer = async () => { + if (bookmarkedPostsCount === 0) { + const firstPost = await this.model.firstPost(); + return bookmarkPost(firstPost); + } else if (bookmarkedPostsCount === 1) { + const postId = this.model.bookmarked_posts[0].post_id; + const post = await this.model.postById(postId); + return bookmarkPost(post); + } else { + return this.model + .deleteBookmarks() + .then(() => this.model.clearBookmarks()) + .catch(popupAjaxError) + .finally(() => this.model.set("bookmarking", false)); + } + }; - return new Promise((resolve) => { - if (alreadyBookmarkedPosts.length > 1) { - bootbox.confirm( - I18n.t("bookmarks.confirm_clear"), - I18n.t("no_value"), - I18n.t("yes_value"), - (confirmed) => { - if (confirmed) { - toggleBookmarkOnServer().then(resolve); - } else { - this.model.set("bookmarking", false); - resolve(); - } + return new Promise((resolve) => { + if (bookmarkedPostsCount > 1) { + bootbox.confirm( + I18n.t("bookmarks.confirm_clear"), + I18n.t("no_value"), + I18n.t("yes_value"), + (confirmed) => { + if (confirmed) { + toggleBookmarkOnServer().then(resolve); + } else { + this.model.set("bookmarking", false); + resolve(); } - ); - } else { - toggleBookmarkOnServer().then(resolve); - } - }); + } + ); + } else { + toggleBookmarkOnServer().then(resolve); + } }); }, diff --git a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js index d2c365b18a..344b4b0c81 100644 --- a/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js +++ b/app/assets/javascripts/discourse/app/initializers/topic-footer-buttons.js @@ -1,5 +1,4 @@ import I18n from "I18n"; -import { formattedReminderTime } from "discourse/lib/bookmark"; import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-button"; import showModal from "discourse/lib/show-modal"; @@ -12,8 +11,7 @@ const DEFER_PRIORITY = 500; export default { name: "topic-footer-buttons", - initialize(container) { - const currentUser = container.lookup("current-user:main"); + initialize() { registerTopicFooterButton({ id: "share-and-invite", icon: "link", @@ -66,22 +64,27 @@ export default { }); registerTopicFooterButton({ - dependentKeys: ["topic.bookmarked", "topic.bookmarkedPosts"], + dependentKeys: ["topic.bookmarked", "topic.bookmarksWereChanged"], id: "bookmark", icon() { - if (this.get("topic.bookmark_reminder_at")) { + const bookmarkedPosts = this.topic.bookmarked_posts; + if (bookmarkedPosts && bookmarkedPosts.find((x) => x.reminder_at)) { return "discourse-bookmark-clock"; } return "bookmark"; }, priority: BOOKMARK_PRIORITY, classNames() { - const bookmarked = this.get("topic.bookmarked"); - return bookmarked ? ["bookmark", "bookmarked"] : ["bookmark"]; + return this.topic.bookmarked + ? ["bookmark", "bookmarked"] + : ["bookmark"]; }, label() { - if (!this.get("topic.isPrivateMessage") || this.site.mobileView) { - const bookmarkedPostsCount = this.get("topic.bookmarkedPosts").length; + if (!this.topic.isPrivateMessage || this.site.mobileView) { + const bookmarkedPosts = this.topic.bookmarked_posts; + const bookmarkedPostsCount = bookmarkedPosts + ? bookmarkedPosts.length + : 0; if (bookmarkedPostsCount === 0) { return "bookmarked.title"; @@ -93,20 +96,16 @@ export default { } }, translatedTitle() { - const bookmarked = this.get("topic.bookmarked"); - const bookmark_reminder_at = this.get("topic.bookmark_reminder_at"); - if (bookmarked) { - if (bookmark_reminder_at) { - return I18n.t("bookmarked.help.unbookmark_with_reminder", { - reminder_at: formattedReminderTime( - bookmark_reminder_at, - currentUser.resolvedTimezone(currentUser) - ), - }); - } + const bookmarkedPosts = this.topic.bookmarked_posts; + if (!bookmarkedPosts || bookmarkedPosts.length === 0) { + return I18n.t("bookmarked.help.bookmark"); + } else if (bookmarkedPosts.length === 1) { + return I18n.t("bookmarked.help.edit_bookmark"); + } else if (bookmarkedPosts.find((x) => x.reminder_at)) { + return I18n.t("bookmarked.help.unbookmark_with_reminder"); + } else { return I18n.t("bookmarked.help.unbookmark"); } - return I18n.t("bookmarked.help.bookmark"); }, action: "toggleBookmark", dropdown() { diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 8a936b5596..6b352f1c87 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -322,6 +322,7 @@ const Post = RestModel.extend({ deleteBookmark(bookmarked) { this.set("topic.bookmarked", bookmarked); this.clearBookmark(); + this.topic.incrementProperty("bookmarksWereChanged"); this.appEvents.trigger("page:bookmark-post-toggled", this); }, diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index d52f9e16ea..0720289df8 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -322,11 +322,6 @@ const Topic = RestModel.extend({ return Site.currentProp("archetypes").findBy("id", archetype); }, - @discourseComputed("bookmarksWereChanged") - bookmarkedPosts() { - return this.postStream.posts.filterBy("bookmarked", true); - }, - isPrivateMessage: equal("archetype", "private_message"), isBanner: equal("archetype", "banner"), @@ -363,7 +358,6 @@ const Topic = RestModel.extend({ afterPostBookmarked(post) { post.set("bookmarked", true); - this.set("bookmark_reminder_at", post.bookmark_reminder_at); }, firstPost() { @@ -376,22 +370,40 @@ const Topic = RestModel.extend({ const postId = postStream.findPostIdForPostNumber(1); if (postId) { - // try loading from identity map first - firstPost = postStream.findLoadedPost(postId); - if (firstPost) { - return Promise.resolve(firstPost); - } - - return this.postStream.loadPost(postId); + return this.postById(postId); } else { return this.postStream.loadPostByPostNumber(1); } }, - deleteBookmark() { + postById(id) { + const loaded = this.postStream.findLoadedPost(id); + if (loaded) { + return Promise.resolve(loaded); + } + + return this.postStream.loadPost(id); + }, + + deleteBookmarks() { return ajax(`/t/${this.id}/remove_bookmarks`, { type: "PUT" }); }, + clearBookmarks() { + this.toggleProperty("bookmarked"); + + const postIds = this.bookmarked_posts.mapBy("post_id"); + postIds.forEach((postId) => { + const loadedPost = this.postStream.findLoadedPost(postId); + if (loadedPost) { + loadedPost.clearBookmark(); + } + }); + this.set("bookmarked_posts", []); + + return postIds; + }, + createGroupInvite(group) { return ajax(`/t/${this.id}/invite-group`, { type: "POST", diff --git a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js index e0dc6dea96..60978895f8 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/bookmarks-test.js @@ -22,6 +22,39 @@ async function openEditBookmarkModal() { await click(".topic-post:first-child button.bookmarked"); } +async function testTopicLevelBookmarkButtonIcon(assert, postNumber) { + const iconWithoutClock = "d-icon-bookmark"; + const iconWithClock = "d-icon-discourse-bookmark-clock"; + + await visit("/t/internationalization-localization/280"); + assert.ok( + query("#topic-footer-button-bookmark svg").classList.contains( + iconWithoutClock + ), + "Shows an icon without a clock when there is no a bookmark" + ); + + await openBookmarkModal(postNumber); + await click("#save-bookmark"); + + assert.ok( + query("#topic-footer-button-bookmark svg").classList.contains( + iconWithoutClock + ), + "Shows an icon without a clock when there is a bookmark without a reminder" + ); + + await openBookmarkModal(postNumber); + await click("#tap_tile_tomorrow"); + + assert.ok( + query("#topic-footer-button-bookmark svg").classList.contains( + iconWithClock + ), + "Shows an icon with a clock when there is a bookmark with a reminder" + ); +} + acceptance("Bookmarking", function (needs) { needs.user(); let steps = []; @@ -64,6 +97,7 @@ acceptance("Bookmarking", function (needs) { } server.post("/bookmarks", handleRequest); server.put("/bookmarks/1", handleRequest); + server.put("/bookmarks/2", handleRequest); server.delete("/bookmarks/1", () => helper.response({ success: "OK", topic_bookmarked: false }) ); @@ -354,4 +388,14 @@ acceptance("Bookmarking", function (needs) { "The edit modal is opened" ); }); + + test("The topic level bookmark button shows an icon with a clock if there is a bookmark with a reminder on the first post", async function (assert) { + const postNumber = 1; + await testTopicLevelBookmarkButtonIcon(assert, postNumber); + }); + + test("The topic level bookmark button shows an icon with a clock if there is a bookmark with a reminder on the second post", async function (assert) { + const postNumber = 2; + await testTopicLevelBookmarkButtonIcon(assert, postNumber); + }); }); diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 8be001d5a4..3d6f81b44c 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -358,8 +358,11 @@ class PostSerializer < BasicPostSerializer end def post_bookmark - return nil if @topic_view.blank? - @post_bookmark ||= @topic_view.user_post_bookmarks.find { |bookmark| bookmark.post_id == object.id } + if @topic_view.present? + @post_bookmark ||= @topic_view.user_post_bookmarks.find { |bookmark| bookmark.post_id == object.id } + else + @post_bookmark ||= object.bookmarks.find_by(user: scope.user) + end end def bookmark_reminder_at diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 3a5e1d747d..141fc7fc2c 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -62,7 +62,7 @@ class TopicViewSerializer < ApplicationSerializer :is_warning, :chunk_size, :bookmarked, - :bookmark_reminder_at, + :bookmarked_posts, :message_archived, :topic_timer, :unicode_title, @@ -193,12 +193,8 @@ class TopicViewSerializer < ApplicationSerializer object.has_bookmarks? end - def include_bookmark_reminder_at? - bookmarked - end - - def bookmark_reminder_at - object.first_post_bookmark_reminder_at + def bookmarked_posts + object.bookmarked_posts end def topic_timer diff --git a/app/serializers/web_hook_topic_view_serializer.rb b/app/serializers/web_hook_topic_view_serializer.rb index 13159fdf20..bff763f9b8 100644 --- a/app/serializers/web_hook_topic_view_serializer.rb +++ b/app/serializers/web_hook_topic_view_serializer.rb @@ -22,6 +22,7 @@ class WebHookTopicViewSerializer < TopicViewSerializer image_url slow_mode_seconds slow_mode_enabled_until + bookmarked_posts }.each do |attr| define_method("include_#{attr}?") do false diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ca725af394..a8d0ac280a 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -298,8 +298,9 @@ en: clear_bookmarks: "Clear Bookmarks" help: bookmark: "Click to bookmark the first post on this topic" + edit_bookmark: "Click to edit the bookmark on this topic" unbookmark: "Click to remove all bookmarks in this topic" - unbookmark_with_reminder: "Click to remove all bookmarks and reminders in this topic. You have a reminder set %{reminder_at} for this topic." + unbookmark_with_reminder: "Click to remove all bookmarks and reminders in this topic." bookmarks: created: "You've bookmarked this post. %{name}" diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 99efecfdb1..c3d5d2e95b 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -395,13 +395,14 @@ class TopicView @topic.bookmarks.exists?(user_id: @user.id) end - def first_post_bookmark_reminder_at - @first_post_bookmark_reminder_at ||= \ - begin - first_post = @topic.posts.with_deleted.find_by(post_number: 1) - return if !first_post - first_post.bookmarks.where(user: @user).pluck_first(:reminder_at) - end + def bookmarked_posts + return nil unless has_bookmarks? + @topic.bookmarks.where(user: @user).pluck(:post_id, :reminder_at).map do |post_id, reminder_at| + { + post_id: post_id, + reminder_at: reminder_at + } + end end MAX_PARTICIPANTS = 24 diff --git a/spec/components/topic_view_spec.rb b/spec/components/topic_view_spec.rb index 87385de49a..0a6e07b657 100644 --- a/spec/components/topic_view_spec.rb +++ b/spec/components/topic_view_spec.rb @@ -413,9 +413,17 @@ describe TopicView do context "#first_post_bookmark_reminder_at" do let!(:user) { Fabricate(:user) } let!(:bookmark1) { Fabricate(:bookmark_next_business_day_reminder, post: topic.first_post, user: user) } + let!(:bookmark2) { Fabricate(:bookmark_next_business_day_reminder, post: topic.posts[1], user: user) } it "gets the first post bookmark reminder at for the user" do - expect(TopicView.new(topic.id, user).first_post_bookmark_reminder_at).to eq_time(bookmark1.reminder_at) + topic_view = TopicView.new(topic.id, user) + + bookmarked_posts = topic_view.bookmarked_posts + first, second = bookmarked_posts + expect(first[:post_id]).to eq(bookmark1.post_id) + expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at) + expect(second[:post_id]).to eq(bookmark2.post_id) + expect(second[:reminder_at]).to eq_time(bookmark1.reminder_at) end context "when the topic is deleted" do @@ -423,7 +431,13 @@ describe TopicView do topic_view = TopicView.new(topic, user) PostDestroyer.new(Fabricate(:admin), topic.first_post).destroy topic.reload - expect(topic_view.first_post_bookmark_reminder_at).to eq_time(bookmark1.reminder_at) + + bookmarked_posts = topic_view.bookmarked_posts + first, second = bookmarked_posts + expect(first[:post_id]).to eq(bookmark1.post_id) + expect(first[:reminder_at]).to eq_time(bookmark1.reminder_at) + expect(second[:post_id]).to eq(bookmark2.post_id) + expect(second[:reminder_at]).to eq_time(bookmark1.reminder_at) end end end diff --git a/spec/serializers/post_serializer_spec.rb b/spec/serializers/post_serializer_spec.rb index 1009542bce..8c02098580 100644 --- a/spec/serializers/post_serializer_spec.rb +++ b/spec/serializers/post_serializer_spec.rb @@ -245,14 +245,6 @@ describe PostSerializer do it "returns the reminder_at for the bookmark" do expect(serialized.as_json[:bookmark_reminder_at]).to eq(bookmark.reminder_at.iso8601) end - - context "if topic_view is blank" do - let(:topic_view) { nil } - - it "the bookmarked attribute will be false" do - expect(serialized.as_json[:bookmarked]).to eq(false) - end - end end end end From 3dda926cb22751beced12b427dd14657b53b6dfe Mon Sep 17 00:00:00 2001 From: Arpit Jalan Date: Mon, 28 Jun 2021 15:14:18 +0530 Subject: [PATCH 205/403] FIX: only delete the word/phrase when the 'x' icon is clicked (#13547) --- .../admin/addon/components/admin-watched-word.js | 4 +++- .../admin/addon/templates/components/admin-watched-word.hbs | 2 +- .../discourse/tests/acceptance/admin-watched-words-test.js | 2 +- app/assets/stylesheets/common/admin/staff_logs.scss | 6 +++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/admin/addon/components/admin-watched-word.js b/app/assets/javascripts/admin/addon/components/admin-watched-word.js index 8c5fd51496..b198ffcf2b 100644 --- a/app/assets/javascripts/admin/addon/components/admin-watched-word.js +++ b/app/assets/javascripts/admin/addon/components/admin-watched-word.js @@ -2,6 +2,7 @@ import Component from "@ember/component"; import { equal } from "@ember/object/computed"; import bootbox from "bootbox"; import discourseComputed from "discourse-common/utils/decorators"; +import { action } from "@ember/object"; import I18n from "I18n"; export default Component.extend({ @@ -16,7 +17,8 @@ export default Component.extend({ return replacement.split(","); }, - click() { + @action + deleteWord() { this.word .destroy() .then(() => { diff --git a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs index 53d61a9b97..31cfd51957 100644 --- a/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/admin-watched-word.hbs @@ -1,4 +1,4 @@ -{{d-icon "times"}} {{word.word}} +{{d-icon "times"}} {{word.word}} {{#if (or isReplace isLink)}} → {{word.replacement}} {{else if isTag}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js index ecc29584f2..5e40f78497 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-watched-words-test.js @@ -82,7 +82,7 @@ acceptance("Admin - Watched Words", function (needs) { } }); - await click("#" + $(word).attr("id")); + await click(`#${$(word).attr("id")} .delete-word-record`); assert.equal(count(".watched-words-list .watched-word"), 2); }); diff --git a/app/assets/stylesheets/common/admin/staff_logs.scss b/app/assets/stylesheets/common/admin/staff_logs.scss index c893a174f7..3953f0847d 100644 --- a/app/assets/stylesheets/common/admin/staff_logs.scss +++ b/app/assets/stylesheets/common/admin/staff_logs.scss @@ -377,10 +377,10 @@ table.screened-ip-addresses { .d-icon { margin-right: 0.25em; color: var(--primary-medium); - } - &:hover .d-icon { - color: var(--danger); + &:hover { + color: var(--danger); + } } } From d015907668d10e7eff726eafcff096695b7f480d Mon Sep 17 00:00:00 2001 From: Dan Ungureanu Date: Mon, 28 Jun 2021 15:52:44 +0300 Subject: [PATCH 206/403] FIX: Remove extra margin from share topic modal (#13549) The styling between the "Create Invite" and "Share Topic" modals is shared. The margin that was used to organize inputs in a list is not needed for the "Share Topic" modal. --- app/assets/stylesheets/common/base/modal.scss | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 9bc306af2e..c35dfa35bb 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -856,8 +856,7 @@ .group-chooser, .user-chooser, .future-date-input-selector { - margin-left: 25px; - width: calc(100% - 25px); + width: 100%; } &.invite-to-topic input[type="radio"] { @@ -956,6 +955,19 @@ } } +.create-invite-modal { + .input-group { + textarea#invite-message, + &.invite-to-topic input[type="text"], + .group-chooser, + .user-chooser, + .future-date-input-selector { + margin-left: 25px; + width: calc(100% - 25px); + } + } +} + .share-topic-modal { .sources { align-items: center; From 04baca593ba64c31a7cb2ef4122149341c9179e3 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 28 Jun 2021 15:04:33 +0200 Subject: [PATCH 207/403] UX: Tweak the timestamp line in Twitter onebox (#13551) Fixed alignment and made the color less intrusive to make the actual content pop out more. --- .../stylesheets/common/base/onebox.scss | 34 ++++++++++++------- lib/onebox/templates/twitterstatus.mustache | 2 +- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index fdaf1f6a2b..1ef4c79756 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -637,6 +637,28 @@ aside.onebox.twitterstatus .onebox-body { } } } + + .date { + display: flex; + line-height: $line-height-small; + + .timestamp { + color: var(--primary-medium); + } + } + + .like, + .retweet { + align-items: center; + color: var(--primary-medium); + display: flex; + margin-left: 0.75em; + + svg { + fill: currentColor; + margin-right: 0.25em; + } + } } // Onebox - Imgur/Flickr - Album @@ -780,18 +802,6 @@ aside.onebox.xkcd .onebox-body img { } } -.onebox.twitterstatus { - .like, - .retweet { - color: var(--primary-med-or-secondary-med); - padding-left: 10px; - svg { - fill: currentColor; - vertical-align: middle; - } - } -} - // mobile specific style .mobile-view article.onebox-body { border-top: none; diff --git a/lib/onebox/templates/twitterstatus.mustache b/lib/onebox/templates/twitterstatus.mustache index cb80cf71a2..7f1dc251c4 100644 --- a/lib/onebox/templates/twitterstatus.mustache +++ b/lib/onebox/templates/twitterstatus.mustache @@ -16,7 +16,7 @@
    - {{timestamp}} + {{timestamp}} {{#likes}} From 7d0d13c32e5b851d78b75bae26ac5271fb117400 Mon Sep 17 00:00:00 2001 From: mintsaxon Date: Sun, 27 Jun 2021 02:09:53 +0200 Subject: [PATCH 208/403] FEATURE: add slow_mode_prevents_editing setting.. ..as per https://meta.discourse.org/t/slow-mode-for-a-category/179574/16 --- config/locales/server.en.yml | 1 + config/site_settings.yml | 1 + lib/post_revisor.rb | 2 +- spec/components/post_revisor_spec.rb | 10 ++++++++++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 319b7728e8..61a2eef970 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1980,6 +1980,7 @@ en: reviewable_low_priority_threshold: "The priority filter hides reviewable items that don't meet this score unless the '(any)' filter is used." high_trust_flaggers_auto_hide_posts: "New user posts are automatically hidden after being flagged as spam by a TL3+ user" cooldown_hours_until_reflag: "How much time users will have to wait until they are able to reflag a post" + slow_mode_prevents_editing: "Does 'Slow Mode' prevent editing, after editing_grace_period?" reply_by_email_enabled: "Enable replying to topics via email." reply_by_email_address: "Template for reply by email incoming email address, for example: %%{reply_key}@reply.example.com or replies+%%{reply_key}@example.com" diff --git a/config/site_settings.yml b/config/site_settings.yml index c9f957e8a6..6f7bb4d03b 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1731,6 +1731,7 @@ spam: cooldown_hours_until_reflag: default: 24 min: 0 + slow_mode_prevents_editing: true reviewable_claiming: client: true diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index cbd5f71a5e..a96e658a9e 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -156,7 +156,7 @@ class PostRevisor @revised_at = @opts[:revised_at] || Time.now @last_version_at = @post.last_version_at || Time.now - if guardian.affected_by_slow_mode?(@topic) && !ninja_edit? + if guardian.affected_by_slow_mode?(@topic) && !ninja_edit? && SiteSetting.slow_mode_prevents_editing @post.errors.add(:base, I18n.t("cannot_edit_on_slow_mode")) return false end diff --git a/spec/components/post_revisor_spec.rb b/spec/components/post_revisor_spec.rb index 6832e9f5d7..f8a76bee5c 100644 --- a/spec/components/post_revisor_spec.rb +++ b/spec/components/post_revisor_spec.rb @@ -188,6 +188,16 @@ describe PostRevisor do expect(post.errors).to be_empty end + it 'edits are generally allowed' do + SiteSetting.slow_mode_prevents_editing = false + + subject.revise!(post.user, { raw: 'updated body' }, revised_at: post.updated_at + 10.minutes) + + post.reload + + expect(post.errors).to be_empty + end + it 'staff is allowed to edit posts even if the topic is in slow mode' do admin = Fabricate(:admin) subject.revise!(admin, { raw: 'updated body' }, revised_at: post.updated_at + 10.minutes) From d03aee46427595ebff93827e388eb48e0fed1212 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Mon, 28 Jun 2021 15:10:38 -0500 Subject: [PATCH 209/403] UX: Horizontal scroll bar on top of user directory (when needed) (#13553) --- .../app/components/directory-table.js | 93 ++++++++++++++++++- .../templates/components/directory-table.hbs | 54 ++++++----- .../stylesheets/common/base/directory.scss | 5 + 3 files changed, 125 insertions(+), 27 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/directory-table.js b/app/assets/javascripts/discourse/app/components/directory-table.js index f1b819310e..f9c03ba08f 100644 --- a/app/assets/javascripts/discourse/app/components/directory-table.js +++ b/app/assets/javascripts/discourse/app/components/directory-table.js @@ -2,16 +2,103 @@ import Component from "@ember/component"; import { action } from "@ember/object"; export default Component.extend({ - classNames: ["directory-table-container"], + lastScrollPosition: 0, + ticking: false, + _topHorizontalScrollBar: null, + _tableContainer: null, + _table: null, + _fakeScrollContent: null, + + didInsertElement() { + this._super(...arguments); + this.setProperties({ + _tableContainer: this.element.querySelector(".directory-table-container"), + _topHorizontalScrollBar: this.element.querySelector( + ".directory-table-top-scroll" + ), + _fakeScrollContent: this.element.querySelector( + ".directory-table-top-scroll-fake-content" + ), + _table: this.element.querySelector(".directory-table"), + }); + + this._tableContainer.addEventListener("scroll", this.onBottomScroll); + this._topHorizontalScrollBar.addEventListener("scroll", this.onTopScroll); + + // Set active header might have already scrolled the _tableContainer. + // Call onHorizontalScroll manually to scroll the _topHorizontalScrollBar + this.onResize(); + this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar); + window.addEventListener("resize", this.onResize); + }, + + @action + onResize() { + if ( + this._tableContainer.getBoundingClientRect().bottom < window.innerHeight + ) { + // Bottom of the table is visible. Hide the scrollbar + this._fakeScrollContent.style.height = 0; + } else { + this._fakeScrollContent.style.width = `${this._table.offsetWidth}px`; + this._fakeScrollContent.style.height = "1px"; + } + }, + + @action + onTopScroll() { + this.onHorizontalScroll(this._topHorizontalScrollBar, this._tableContainer); + }, + + @action + onBottomScroll() { + this.onHorizontalScroll(this._tableContainer, this._topHorizontalScrollBar); + }, + + @action + onHorizontalScroll(primary, replica) { + if (this.lastScrollPosition === primary.scrollLeft) { + return; + } + + this.set("lastScrollPosition", primary.scrollLeft); + + if (!this.ticking) { + window.requestAnimationFrame(() => { + replica.scrollLeft = this.lastScrollPosition; + this.set("ticking", false); + }); + + this.set("ticking", true); + } + }, + + willDestoryElement() { + this._tableContainer.removeEventListener("scroll", this.onBottomScroll); + this._topHorizontalScrollBar.removeEventListener( + "scroll", + this.onTopScroll + ); + window.removeEventListener("resize", this.onResize); + }, @action setActiveHeader(header) { // After render, scroll table left to ensure the order by column is visible + if (!this._tableContainer) { + this.set( + "_tableContainer", + document.querySelector(".directory-table-container") + ); + } const scrollPixels = - header.offsetLeft + header.offsetWidth + 10 - this.element.offsetWidth; + header.offsetLeft + + header.offsetWidth + + 10 - + this._tableContainer.offsetWidth; if (scrollPixels > 0) { - this.element.scrollLeft = scrollPixels; + this._tableContainer.scrollLeft = scrollPixels; } }, }); diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs index a646d794db..5864d03839 100644 --- a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs @@ -1,25 +1,31 @@ -
    {{i18n "admin.users.approved"}} - +
    - - {{table-header-toggle field="username" order=order asc=asc}} - {{#each columns as |column|}} - {{table-header-toggle - field=column.name - icon=column.icon - order=order - asc=asc - automatic=(directory-column-is-automatic column=column) - translated=column.user_field_id - onActiveRender=setActiveHeader - }} - {{/each}} +
    +
    +
    - {{#if showTimeRead}} - - {{/if}} - - - {{#each items as |item|}} - {{directory-item item=item columns=columns showTimeRead=showTimeRead}} - {{/each}} - -
    {{i18n "directory.time_read"}}
    +
    + + + {{table-header-toggle field="username" order=order asc=asc}} + {{#each columns as |column|}} + {{table-header-toggle + field=column.name + icon=column.icon + order=order + asc=asc + automatic=(directory-column-is-automatic column=column) + translated=column.user_field_id + onActiveRender=setActiveHeader + }} + {{/each}} + + {{#if showTimeRead}} + + {{/if}} + + + {{#each items as |item|}} + {{directory-item item=item columns=columns showTimeRead=showTimeRead}} + {{/each}} + +
    {{i18n "directory.time_read"}}
    +
    diff --git a/app/assets/stylesheets/common/base/directory.scss b/app/assets/stylesheets/common/base/directory.scss index 2a8e82b0d4..ffbd1a5144 100644 --- a/app/assets/stylesheets/common/base/directory.scss +++ b/app/assets/stylesheets/common/base/directory.scss @@ -6,6 +6,11 @@ overflow-x: auto; } + .directory-table-top-scroll { + width: 100%; + overflow-x: auto; + } + .open-edit-columns-btn { vertical-align: top; padding: 0.45em 0.8em; From 99da2210348bd22ee045e50f876f89400d5de489 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 28 Jun 2021 18:21:39 -0300 Subject: [PATCH 210/403] FIX: Handle image decoding failure in composer image optimization (#13555) There are some hard limits in browser Canvas implementations, that will throw a runtime exception when crossed. Since those limits are platform dependent, the best we can do is catch it and back off from trying to optimize a problematic file. For example, a 60MB PNG can be processed fine by Chrome but Firefox will fail trying to extract the ImageData from the CanvasRenderingContext2D with NS_ERROR_FAILURE. Also cleans up the media-optimization-utils and add post-resize size logs --- .../app/lib/media-optimization-utils.js | 49 ++++++++++++------- .../app/services/media-optimization-worker.js | 15 +++--- .../javascripts/media-optimization-worker.js | 1 + 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js index b67424f4c4..2d18cbf98f 100644 --- a/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js +++ b/app/assets/javascripts/discourse/app/lib/media-optimization-utils.js @@ -1,12 +1,10 @@ import { Promise } from "rsvp"; -export async function fileToImageData(file) { - let drawable, err; - - // Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!) - // Safari uses the `` element due to https://bugs.webkit.org/show_bug.cgi?id=182424 +// Chrome and Firefox use a native method to do Image -> Bitmap Array (it happens of the main thread!) +// Safari uses the `` element due to https://bugs.webkit.org/show_bug.cgi?id=182424 +async function fileToDrawable(file) { if ("createImageBitmap" in self) { - drawable = await createImageBitmap(file); + return await createImageBitmap(file); } else { const url = URL.createObjectURL(file); const img = new Image(); @@ -26,38 +24,55 @@ export async function fileToImageData(file) { // Always await loaded, as we may have bailed due to the Safari bug above. await loaded; - - drawable = img; + return img; } +} +function drawableToimageData(drawable) { const width = drawable.width, height = drawable.height, sx = 0, sy = 0, sw = width, sh = height; + // Make canvas same size as image const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; + // Draw image onto canvas const ctx = canvas.getContext("2d"); if (!ctx) { - err = "Could not create canvas context"; + throw "Could not create canvas context"; } ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height); const imageData = ctx.getImageData(0, 0, width, height); canvas.remove(); + return imageData; +} - // potentially transparent - if (/(\.|\/)(png|webp)$/i.test(file.type)) { - for (let i = 0; i < imageData.data.length; i += 4) { - if (imageData.data[i + 3] < 255) { - err = "Image has transparent pixels, won't convert to JPEG!"; - break; - } +function isTransparent(type, imageData) { + if (!/(\.|\/)(png|webp)$/i.test(type)) { + return false; + } + + for (let i = 0; i < imageData.data.length; i += 4) { + if (imageData.data[i + 3] < 255) { + return true; } } - return { imageData, width, height, err }; + return false; +} + +export async function fileToImageData(file) { + const drawable = await fileToDrawable(file); + const imageData = drawableToimageData(drawable); + + if (isTransparent(file.type, imageData)) { + throw "Image has transparent pixels, won't convert to JPEG!"; + } + + return imageData; } diff --git a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js index 24069246fa..f108061ad7 100644 --- a/app/assets/javascripts/discourse/app/services/media-optimization-worker.js +++ b/app/assets/javascripts/discourse/app/services/media-optimization-worker.js @@ -54,10 +54,11 @@ export default class MediaOptimizationWorkerService extends Service { this.currentComposerUploadData = data; this.currentPromiseResolver = resolve; - const { imageData, width, height, err } = await fileToImageData(file); - - if (err) { - this.logIfDebug(err); + let imageData; + try { + imageData = await fileToImageData(file); + } catch (error) { + this.logIfDebug(error); return resolve(data); } @@ -66,8 +67,8 @@ export default class MediaOptimizationWorkerService extends Service { type: "compress", file: imageData.data.buffer, fileName: file.name, - width: width, - height: height, + width: imageData.width, + height: imageData.height, settings: { mozjpeg_script: getURLWithCDN( "/javascripts/squoosh/mozjpeg_enc.js" @@ -102,8 +103,6 @@ export default class MediaOptimizationWorkerService extends Service { registerMessageHandler() { this.worker.onmessage = (e) => { - this.logIfDebug("Main: Message received from worker script"); - this.logIfDebug(e); switch (e.data.type) { case "file": let optimizedFile = new File([e.data.file], `${e.data.fileName}`, { diff --git a/public/javascripts/media-optimization-worker.js b/public/javascripts/media-optimization-worker.js index 7bbdf137bd..7bd196854c 100644 --- a/public/javascripts/media-optimization-worker.js +++ b/public/javascripts/media-optimization-worker.js @@ -81,6 +81,7 @@ async function optimize(imageData, fileName, width, height, settings) { ).data; width = target_dimensions.width; height = target_dimensions.height; + logIfDebug(`Worker post resizing file: ${maybeResized.byteLength}`); } catch (error) { console.error(`Resize failed: ${error}`); maybeResized = imageData; From d860e2717b98627fecbc10269dd4b2cf2530d908 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 28 Jun 2021 18:22:22 -0300 Subject: [PATCH 211/403] UX: Adds 'Processing Upload' to the composer status area during upload optimization (#13556) --- .../discourse/app/components/composer-editor.js | 3 +++ .../javascripts/discourse/app/controllers/composer.js | 1 + .../javascripts/discourse/app/templates/composer.hbs | 7 ++++++- config/locales/client.en.yml | 1 + 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index 7280058f66..552e2a9beb 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -635,6 +635,7 @@ export default Component.extend({ this.setProperties({ uploadProgress: 0, isUploading: false, + isProcessingUpload: false, isCancellable: false, }); } @@ -675,6 +676,7 @@ export default Component.extend({ this.setProperties({ uploadProgress: 0, isUploading: true, + isProcessingUpload: true, isCancellable: false, }); }) @@ -689,6 +691,7 @@ export default Component.extend({ this.setProperties({ uploadProgress: 0, isUploading: false, + isProcessingUpload: false, isCancellable: false, }); }); diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js index 12c76c2547..9443a74907 100644 --- a/app/assets/javascripts/discourse/app/controllers/composer.js +++ b/app/assets/javascripts/discourse/app/controllers/composer.js @@ -104,6 +104,7 @@ export default Controller.extend({ prioritizedCategoryId: null, lastValidatedAt: null, isUploading: false, + isProcessingUpload: false, topic: null, linkLookup: null, showPreview: true, diff --git a/app/assets/javascripts/discourse/app/templates/composer.hbs b/app/assets/javascripts/discourse/app/templates/composer.hbs index 7779c0e1ae..fa362d7cce 100644 --- a/app/assets/javascripts/discourse/app/templates/composer.hbs +++ b/app/assets/javascripts/discourse/app/templates/composer.hbs @@ -118,6 +118,7 @@ popupMenuOptions=popupMenuOptions draftStatus=model.draftStatus isUploading=isUploading + isProcessingUpload=isProcessingUpload allowUpload=allowUpload uploadIcon=uploadIcon isCancellable=isCancellable @@ -168,7 +169,11 @@ {{#if isUploading}}
    - {{loading-spinner size="small"}}{{i18n "upload_selector.uploading"}} {{uploadProgress}}% + {{#if isProcessingUpload}} + {{loading-spinner size="small"}}{{i18n "upload_selector.processing"}} + {{else}} + {{loading-spinner size="small"}}{{i18n "upload_selector.uploading"}} {{uploadProgress}}% + {{/if}} {{#if isCancellable}} {{d-icon "times"}} {{/if}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a8d0ac280a..7b2b26969d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2247,6 +2247,7 @@ en: hint: "(you can also drag & drop into the editor to upload)" hint_for_supported_browsers: "you can also drag and drop or paste images into the editor" uploading: "Uploading" + processing: "Processing Upload" select_file: "Select File" default_image_alt_text: image supported_formats: "supported formats" From 6afba429856cce015e268ad68d73358f93790fc4 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Mon, 28 Jun 2021 23:35:31 +0200 Subject: [PATCH 212/403] UX: Tweak spacing in the admin dashboard (#13557) Even margins, indented `li > ul`, no extra space inside parens `( sha )`. --- .../stylesheets/common/admin/dashboard.scss | 79 ++++++++++++------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/app/assets/stylesheets/common/admin/dashboard.scss b/app/assets/stylesheets/common/admin/dashboard.scss index cf1e738385..1d00eb79d2 100644 --- a/app/assets/stylesheets/common/admin/dashboard.scss +++ b/app/assets/stylesheets/common/admin/dashboard.scss @@ -8,13 +8,9 @@ .dashboard, .dashboard-next { - .section-top { - margin-bottom: 0.5em; - } - .navigation { display: flex; - margin: 0 0 2.5em 0; + margin: 0 0 1em 0; border-bottom: 1px solid var(--primary-low-mid); .navigation-item { @@ -101,6 +97,16 @@ .section { .section-title { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--primary-low); + padding-bottom: 0.5em; + + @media screen and (max-width: 400px) { + flex-wrap: wrap; + } + h2 { margin: 0 0.5em 0 0; @@ -108,20 +114,11 @@ color: var(--primary); } } - - display: flex; - @media screen and (max-width: 400px) { - flex-wrap: wrap; - } - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--primary-low); - margin-bottom: 0.5em; - padding-bottom: 0.5em; } .section-body { - padding: 1em 0 0; + margin-top: 1em; + > p { margin-top: 0; } @@ -223,7 +220,7 @@ } .top-referred-topics { - margin-bottom: 1.5em; + margin-bottom: 2em; } .top-referred-topics, @@ -247,7 +244,11 @@ } .dashboard-problems { - margin-bottom: 2.5em; + margin-bottom: 2em; + + .problem-messages ul { + margin: 0 0 0 1.25em; + } .d-icon-exclamation-triangle { color: var(--danger); @@ -341,7 +342,7 @@ } .activity-metrics { - margin-bottom: 1.5em; + margin-bottom: 2em; } .user-metrics { @@ -440,17 +441,17 @@ .users-by-trust-level, .users-by-type { - margin-bottom: 1.5em; + margin-bottom: 2em; } .community-health.section { - margin-bottom: 1.5em; + margin-bottom: 2em; } .dashboard-moderation, .dashboard-security { .section-body { - margin-bottom: 1em; + margin-bottom: 2em; } .main-section { @@ -519,10 +520,15 @@ .version-checks { display: flex; flex-wrap: wrap; + .section-title { flex: 1 1 100%; border-bottom: 1px solid var(--primary-low); - margin-bottom: 0.5em; + padding-bottom: 0.5em; + } + + h2 { + margin: 0; } } @@ -533,44 +539,56 @@ align-items: flex-start; align-self: flex-start; justify-content: space-between; - padding: 10px 0 10px 0; + padding-top: 1em; + .upgrade-header { flex: 1 1 100%; + @media screen and (max-width: 650px) { margin: 0; } + tr { border: none; } + th { background: transparent; padding: 0; } } + h2 { flex: 1 1 100%; } + .version-number { font-size: $font-up-2; - line-height: $line-height-medium; + line-height: $line-height-small; box-sizing: border-box; font-weight: bold; - margin: 0 0 1em 0; - padding-right: 20px; + margin-bottom: 2em; + margin-right: 1em; flex: 1 1 27%; + h3 { flex: 1 0 auto; + margin: 0; white-space: nowrap; } - h4, - .sha-link { + + h4 { font-size: $font-down-2; - margin-bottom: 0; + margin-bottom: 0.5em; } + .sha-link { + display: inline-flex; + font-size: $font-down-2; font-weight: normal; } } + .version-status { display: flex; align-items: center; @@ -617,6 +635,7 @@ display: flex; flex-direction: column; } + .dashboard-new-features { &.ordered-first { order: -1; From 69e557be6691a00b8d1d0da1a83b97831b08a4af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Jun 2021 00:34:17 +0200 Subject: [PATCH 213/403] Build(deps): Bump excon from 0.82.0 to 0.83.0 (#13558) --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c9f5c2d503..5366b233b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -128,7 +128,7 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.82.0) + excon (0.83.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) From 03338f9086e65e8020598cd3346ed23a3d69a01e Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 29 Jun 2021 09:16:25 +1000 Subject: [PATCH 214/403] FIX: Remove legacy topic timer code (#13544) The new topic timer backend code introduced six months ago in 0034cbd is now used instead of this legacy code. It can be safely removed now. --- app/jobs/regular/topic_reminder.rb | 31 -------------- app/models/topic_timer.rb | 65 ------------------------------ spec/models/topic_timer_spec.rb | 65 ------------------------------ 3 files changed, 161 deletions(-) delete mode 100644 app/jobs/regular/topic_reminder.rb diff --git a/app/jobs/regular/topic_reminder.rb b/app/jobs/regular/topic_reminder.rb deleted file mode 100644 index e4b103d66d..0000000000 --- a/app/jobs/regular/topic_reminder.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class TopicReminder < ::Jobs::Base - - def execute(args) - # noop, TODO(martin 2021-03-11): Remove this after timers migrated and outstanding jobs cancelled - return - - topic_timer = TopicTimer.find_by(id: args[:topic_timer_id]) - - topic = topic_timer&.topic - user = topic_timer&.user - - if topic_timer.blank? || topic.blank? || user.blank? || - topic_timer.execute_at > Time.zone.now - return - end - - user.notifications.create!( - notification_type: Notification.types[:topic_reminder], - topic_id: topic.id, - post_number: 1, - data: { topic_title: topic.title, display_username: user.username }.to_json - ) - - topic_timer.trash!(Discourse.system_user) - end - - end -end diff --git a/app/models/topic_timer.rb b/app/models/topic_timer.rb index 2227004d56..2a9a690ade 100644 --- a/app/models/topic_timer.rb +++ b/app/models/topic_timer.rb @@ -36,10 +36,6 @@ class TopicTimer < ActiveRecord::Base if (will_save_change_to_execute_at? && !attribute_in_database(:execute_at).nil?) || will_save_change_to_user_id? - - # TODO(martin - 2021-05-01) - Remove this backwards compatability for outstanding - # jobs once they have all been run and after Jobs::TopicTimerEnqueuer is in place - self.send("cancel_auto_#{self.class.types[status_type]}_job") end end @@ -67,24 +63,9 @@ class TopicTimer < ActiveRecord::Base end def enqueue_typed_job(time: nil) - return if typed_job_scheduled? self.send("schedule_auto_#{status_type_name}_job") end - # TODO(martin - 2021-05-01) - Remove this backwards compatability for outstanding - # jobs once they have all been run and after Jobs::TopicTimerEnqueuer is in place - def typed_job_scheduled? - scheduled = Jobs.scheduled_for( - TopicTimer.type_job_map[status_type_name], topic_timer_id: id - ).any? - - if [:close, :silent_close, :open].include?(status_type_name) - return scheduled || Jobs.scheduled_for(:toggle_topic_closed, topic_timer_id: id).any? - end - - scheduled - end - def self.type_job_map { close: :close_topic, @@ -165,48 +146,6 @@ class TopicTimer < ActiveRecord::Base self.status_type.to_i == TopicTimer.types[:publish_to_category] end - # TODO(martin - 2021-05-01) - Remove cancels for toggle_topic_closed once topic timer revamp completed. - def cancel_auto_close_job - Jobs.cancel_scheduled_job(:toggle_topic_closed, topic_timer_id: id) - Jobs.cancel_scheduled_job(:close_topic, topic_timer_id: id) - end - - # TODO(martin - 2021-05-01) - Remove cancels for toggle_topic_closed once topic timer revamp completed. - def cancel_auto_open_job - Jobs.cancel_scheduled_job(:toggle_topic_closed, topic_timer_id: id) - Jobs.cancel_scheduled_job(:open_topic, topic_timer_id: id) - end - - # TODO(martin - 2021-05-01) - Remove cancels for toggle_topic_closed once topic timer revamp completed. - def cancel_auto_silent_close_job - Jobs.cancel_scheduled_job(:toggle_topic_closed, topic_timer_id: id) - Jobs.cancel_scheduled_job(:close_topic, topic_timer_id: id) - end - - def cancel_auto_publish_to_category_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:publish_to_category], topic_timer_id: id) - end - - def cancel_auto_delete_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:delete], topic_timer_id: id) - end - - def cancel_auto_reminder_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:reminder], topic_timer_id: id) - end - - def cancel_auto_bump_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:bump], topic_timer_id: id) - end - - def cancel_auto_delete_replies_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:delete_replies], topic_timer_id: id) - end - - def cancel_auto_clear_slow_mode_job - Jobs.cancel_scheduled_job(TopicTimer.type_job_map[:clear_slow_mode], topic_timer_id: id) - end - def schedule_auto_delete_replies_job Jobs.enqueue(TopicTimer.type_job_map[:delete_replies], topic_timer_id: id) end @@ -235,10 +174,6 @@ class TopicTimer < ActiveRecord::Base Jobs.enqueue(TopicTimer.type_job_map[:delete], topic_timer_id: id) end - def schedule_auto_reminder_job - # noop, TODO(martin 2021-03-11): Remove this after timers migrated and outstanding jobs cancelled - end - def schedule_auto_clear_slow_mode_job Jobs.enqueue(TopicTimer.type_job_map[:clear_slow_mode], topic_timer_id: id) end diff --git a/spec/models/topic_timer_spec.rb b/spec/models/topic_timer_spec.rb index cff5316d5c..fde80ad856 100644 --- a/spec/models/topic_timer_spec.rb +++ b/spec/models/topic_timer_spec.rb @@ -48,44 +48,6 @@ RSpec.describe TopicTimer, type: :model do end end - describe "#typed_job_scheduled?" do - let(:scheduled_jobs) { Sidekiq::ScheduledSet.new } - - after do - scheduled_jobs.clear - end - - it "returns true if the job is scheduled" do - Sidekiq::Testing.disable! do - scheduled_jobs.clear - Jobs.enqueue_at(3.hours.from_now, :close_topic, topic_timer_id: topic_timer.id) - expect(topic_timer.typed_job_scheduled?).to eq(true) - end - end - - it "returns false if the job is not already scheduled" do - Sidekiq::Testing.disable! do - scheduled_jobs.clear - expect(topic_timer.typed_job_scheduled?).to eq(false) - end - end - - it "returns true if the toggle_topic_closed job is scheduled for close,open,silent_close types" do - Sidekiq::Testing.disable! do - scheduled_jobs.clear - topic_timer1 = Fabricate(:topic_timer, status_type: TopicTimer.types[:close]) - Jobs.enqueue_at(3.hours.from_now, :toggle_topic_closed, topic_timer_id: topic_timer1.id) - topic_timer2 = Fabricate(:topic_timer, status_type: TopicTimer.types[:open]) - Jobs.enqueue_at(3.hours.from_now, :toggle_topic_closed, topic_timer_id: topic_timer2.id) - topic_timer3 = Fabricate(:topic_timer, status_type: TopicTimer.types[:silent_close]) - Jobs.enqueue_at(3.hours.from_now, :toggle_topic_closed, topic_timer_id: topic_timer3.id) - expect(topic_timer1.typed_job_scheduled?).to eq(true) - expect(topic_timer2.typed_job_scheduled?).to eq(true) - expect(topic_timer3.typed_job_scheduled?).to eq(true) - end - end - end - describe '#execute_at' do describe 'when #execute_at is greater than #created_at' do it 'should be valid' do @@ -146,38 +108,11 @@ RSpec.describe TopicTimer, type: :model do describe 'when #execute_at and #user_id are not changed' do it 'should not schedule another to update topic' do Jobs.expects(:enqueue_at).never - Jobs.expects(:cancel_scheduled_job).never topic_timer.update!(topic: Fabricate(:topic)) end end - describe 'when #execute_at value is changed' do - it 'reschedules the job' do - Jobs.expects(:cancel_scheduled_job).with( - :toggle_topic_closed, topic_timer_id: topic_timer.id - ) - Jobs.expects(:cancel_scheduled_job).with( - :close_topic, topic_timer_id: topic_timer.id - ) - - topic_timer.update!(execute_at: 3.days.from_now, created_at: Time.zone.now) - end - end - - describe 'when user is changed' do - it 'should update the job' do - Jobs.expects(:cancel_scheduled_job).with( - :toggle_topic_closed, topic_timer_id: topic_timer.id - ) - Jobs.expects(:cancel_scheduled_job).with( - :close_topic, topic_timer_id: topic_timer.id - ) - - topic_timer.update!(user: admin) - end - end - describe 'when a open topic status update is created for an open topic' do fab!(:topic) { Fabricate(:topic, closed: false) } fab!(:topic_timer) do From d098f51ad3a3649d34717fa6567489998709ba6c Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 29 Jun 2021 09:27:12 +1000 Subject: [PATCH 215/403] DEV: Drop duration column from topic timers (#13543) The duration column has been ignored since the commit https://github.com/discourse/discourse/commit/4af77f1e3854c564c8404e08b83e786fd25a7bfe for topic_timers, we use duration_minutes instead. Also removing the duration key from Topic.set_or_create_timer. The only plugin to use this was discourse-solved, which doesn't use it any longer since https://github.com/discourse/discourse-solved/commit/c722b94a97b9710b9e43b40cd879c0af605085bd --- app/models/topic.rb | 28 +++---------------- ..._drop_duration_column_from_topic_timers.rb | 17 +++++++++++ 2 files changed, 21 insertions(+), 24 deletions(-) create mode 100644 db/post_migrate/20210628035905_drop_duration_column_from_topic_timers.rb diff --git a/app/models/topic.rb b/app/models/topic.rb index 00a3eacaa8..9ad202b533 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1319,19 +1319,13 @@ class Topic < ActiveRecord::Base # * by_user: User who is setting the topic's status update. # * based_on_last_post: True if time should be based on timestamp of the last post. # * category_id: Category that the update will apply to. - # * duration: TODO(2021-06-01): DEPRECATED - do not use # * duration_minutes: The duration of the timer in minutes, which is used if the timer is based # on the last post or if the timer type is delete_replies. # * silent: Affects whether the close topic timer status change will be silent or not. - def set_or_create_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id, duration: nil, duration_minutes: nil, silent: nil) - return delete_topic_timer(status_type, by_user: by_user) if time.blank? && duration_minutes.blank? && duration.blank? + def set_or_create_timer(status_type, time, by_user: nil, based_on_last_post: false, category_id: SiteSetting.uncategorized_category_id, duration_minutes: nil, silent: nil) + return delete_topic_timer(status_type, by_user: by_user) if time.blank? && duration_minutes.blank? duration_minutes = duration_minutes ? duration_minutes.to_i : 0 - - # TODO(2021-06-01): deprecated - remove this when plugins calling set_or_create_timer - # have been fixed to use duration_minutes - duration = duration ? duration.to_i : 0 - public_topic_timer = !!TopicTimer.public_types[status_type] topic_timer_options = { topic: self, public_type: public_topic_timer } topic_timer_options.merge!(user: by_user) unless public_topic_timer @@ -1347,29 +1341,15 @@ class Topic < ActiveRecord::Base end if topic_timer.based_on_last_post - if duration > 0 || duration_minutes > 0 + if duration_minutes > 0 last_post_created_at = self.ordered_posts.last.present? ? self.ordered_posts.last.created_at : time_now - - # TODO(2021-06-01): deprecated - remove this when plugins calling set_or_create_timer - # have been fixed to use duration_minutes - if duration > 0 - duration_minutes = duration * 60 - end - topic_timer.duration_minutes = duration_minutes topic_timer.execute_at = last_post_created_at + duration_minutes.minutes topic_timer.created_at = last_post_created_at end elsif topic_timer.status_type == TopicTimer.types[:delete_replies] - if duration > 0 || duration_minutes > 0 + if duration_minutes > 0 first_reply_created_at = (self.ordered_posts.where("post_number > 1").minimum(:created_at) || time_now) - - # TODO(2021-06-01): deprecated - remove this when plugins calling set_or_create_timer - # have been fixed to use duration_minutes - if duration > 0 - duration_minutes = duration * 60 * 24 - end - topic_timer.duration_minutes = duration_minutes topic_timer.execute_at = first_reply_created_at + duration_minutes.minutes topic_timer.created_at = first_reply_created_at diff --git a/db/post_migrate/20210628035905_drop_duration_column_from_topic_timers.rb b/db/post_migrate/20210628035905_drop_duration_column_from_topic_timers.rb new file mode 100644 index 0000000000..f8e8ee2c73 --- /dev/null +++ b/db/post_migrate/20210628035905_drop_duration_column_from_topic_timers.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class DropDurationColumnFromTopicTimers < ActiveRecord::Migration[6.1] + DROPPED_COLUMNS ||= { + topic_timers: %i{duration} + } + + def up + DROPPED_COLUMNS.each do |table, columns| + Migration::ColumnDropper.execute_drop(table, columns) + end + end + + def down + add_column :topic_timers, :duration, :string + end +end From 5af0636d831013f57b4c4cd9d09f7a89d378bf54 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 29 Jun 2021 02:40:29 +0200 Subject: [PATCH 216/403] DEV: Fix a leaky test (#13559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error was: ``` ↪ Unit | Model | topic::recover [✔] ↪ Unit | Utility | emoji::emojiUnescape [✔] ↪ Unit | Utility | pretty-text::quoting a quote [✔] ↪ Unit | Utility | click-track::routes to internal urlsUnhandled request in test environment: /forum/t/1234/recover (PUT) Error: Unhandled request in test environment: /forum/t/1234/recover (PUT) at Pretender.server.unhandledRequest (discourse/tests/setup-tests:173:15) at Pretender.handleRequest (pretender:400:14) at FakeRequest.send (pretender:169:21) at Object.send (jquery:10100:10) at Function.ajax (jquery:9683:15) at performAjax (discourse/app/lib/ajax:174:19) at eval (discourse/app/lib/ajax:183:11) at invokeCallback (ember:63104:17) at publish (ember:63087:9) at eval (ember:57463:16) [✘] ``` * DEV: Don't duplicate a function --- app/assets/javascripts/discourse/app/controllers/topic.js | 7 ++----- .../javascripts/discourse/tests/unit/models/topic-test.js | 5 +++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js index c27f4bab82..b86f9e5570 100644 --- a/app/assets/javascripts/discourse/app/controllers/topic.js +++ b/app/assets/javascripts/discourse/app/controllers/topic.js @@ -6,7 +6,7 @@ import { isEmpty, isPresent } from "@ember/utils"; import { later, next, schedule } from "@ember/runloop"; import { AUTO_DELETE_PREFERENCES } from "discourse/models/bookmark"; import Composer from "discourse/models/composer"; -import EmberObject from "@ember/object"; +import EmberObject, { action } from "@ember/object"; import I18n from "I18n"; import Post from "discourse/models/post"; import { Promise } from "rsvp"; @@ -949,10 +949,6 @@ export default Controller.extend(bufferedProperty("model"), { }); }, - recoverTopic() { - this.model.recover(); - }, - makeBanner() { this.model.makeBanner(); }, @@ -1424,6 +1420,7 @@ export default Controller.extend(bufferedProperty("model"), { return spinnerHTML; }, + @action recoverTopic() { this.model.recover(); }, diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js index f903fd6b69..45c5f9a6ca 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js @@ -109,7 +109,7 @@ discourseModule("Unit | Model | topic", function () { assert.equal(topic.get("category"), category); }); - test("recover", function (assert) { + test("recover", async function (assert) { const user = User.create({ username: "eviltrout" }); const topic = Topic.create({ id: 1234, @@ -117,7 +117,8 @@ discourseModule("Unit | Model | topic", function () { deleted_by: user, }); - topic.recover(); + await topic.recover(); + assert.blank(topic.get("deleted_at"), "it clears deleted_at"); assert.blank(topic.get("deleted_by"), "it clears deleted_by"); }); From a6363170e9c91384292bd4fa5f497803a4eae906 Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Tue, 29 Jun 2021 11:17:49 +1000 Subject: [PATCH 217/403] FIX: flaky search-spec More precise expectations for search spec --- spec/lib/search_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/lib/search_spec.rb b/spec/lib/search_spec.rb index 86e9992d44..6ed833283e 100644 --- a/spec/lib/search_spec.rb +++ b/spec/lib/search_spec.rb @@ -157,11 +157,11 @@ describe Search do SearchIndexer.index(user2, force: true) result = Search.execute("test", guardian: Guardian.new(user2)) - expect(result.users.first.custom_data).to eq([ + expect(result.users.find { |u| u.id == user.id }.custom_data).to eq([ { name: "custom field", value: "test" }, { name: "another custom field", value: "longer test" } ]) - expect(result.users.last.custom_data).to eq([ + expect(result.users.find { |u| u.id == user2.id }.custom_data).to eq([ { name: "another custom field", value: "second user test" } ]) end From a69839689da85f29ff697969e225f81f20b9eede Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Tue, 29 Jun 2021 16:29:25 +1000 Subject: [PATCH 218/403] FEATURE: add multiselect user custom field (#13560) New user custom field similar to dropdown but allowing users to select multiple options. --- .../admin/addon/models/user-field.js | 1 + .../components/user-fields/multiselect.hbs | 19 +++++++++++++++++++ config/locales/client.en.yml | 1 + 3 files changed, 21 insertions(+) create mode 100644 app/assets/javascripts/discourse/app/templates/components/user-fields/multiselect.hbs diff --git a/app/assets/javascripts/admin/addon/models/user-field.js b/app/assets/javascripts/admin/addon/models/user-field.js index 495be7a08d..53b92a63fd 100644 --- a/app/assets/javascripts/admin/addon/models/user-field.js +++ b/app/assets/javascripts/admin/addon/models/user-field.js @@ -15,6 +15,7 @@ UserField.reopenClass({ UserFieldType.create({ id: "text" }), UserFieldType.create({ id: "confirm" }), UserFieldType.create({ id: "dropdown", hasOptions: true }), + UserFieldType.create({ id: "multiselect", hasOptions: true }), ]; } diff --git a/app/assets/javascripts/discourse/app/templates/components/user-fields/multiselect.hbs b/app/assets/javascripts/discourse/app/templates/components/user-fields/multiselect.hbs new file mode 100644 index 0000000000..f81fa36fd4 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/user-fields/multiselect.hbs @@ -0,0 +1,19 @@ + + +
    + {{multi-select + id=(concat "user-" this.elementId) + content=this.field.options + valueProperty=null + nameProperty=null + value=this.value + none=this.noneLabel + onChange=(action (mut this.value)) + }} +
    {{html-safe this.field.description}}
    +
    diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 7b2b26969d..6d3f72afab 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5095,6 +5095,7 @@ en: text: "Text Field" confirm: "Confirmation" dropdown: "Dropdown" + multiselect: "Multiselect" site_text: description: "You can customize any of the text on your forum. Please start by searching below:" From 23930738a725449123aecd9b866cca7a6dcc94c8 Mon Sep 17 00:00:00 2001 From: Discourse Translator Bot Date: Tue, 29 Jun 2021 16:02:02 +0200 Subject: [PATCH 219/403] Update translations (#13565) --- config/locales/client.ar.yml | 1554 +++++---- config/locales/client.be.yml | 4 - config/locales/client.bg.yml | 9 +- config/locales/client.bs_BA.yml | 15 +- config/locales/client.ca.yml | 15 +- config/locales/client.cs.yml | 9 +- config/locales/client.da.yml | 16 +- config/locales/client.de.yml | 1707 +++++----- config/locales/client.el.yml | 13 +- config/locales/client.es.yml | 535 +-- config/locales/client.et.yml | 9 +- config/locales/client.fa_IR.yml | 16 +- config/locales/client.fi.yml | 129 +- config/locales/client.fr.yml | 613 ++-- config/locales/client.gl.yml | 15 +- config/locales/client.he.yml | 25 +- config/locales/client.hu.yml | 12 +- config/locales/client.hy.yml | 11 +- config/locales/client.id.yml | 51 +- config/locales/client.it.yml | 360 +-- config/locales/client.ja.yml | 2868 ++++++++--------- config/locales/client.ko.yml | 28 +- config/locales/client.lt.yml | 8 +- config/locales/client.lv.yml | 4 +- config/locales/client.nb_NO.yml | 10 +- config/locales/client.nl.yml | 15 +- config/locales/client.pl_PL.yml | 16 +- config/locales/client.pt.yml | 14 +- config/locales/client.pt_BR.yml | 15 - config/locales/client.ro.yml | 13 +- config/locales/client.ru.yml | 49 +- config/locales/client.sk.yml | 10 +- config/locales/client.sl.yml | 15 +- config/locales/client.sq.yml | 4 +- config/locales/client.sr.yml | 5 +- config/locales/client.sv.yml | 25 +- config/locales/client.sw.yml | 9 +- config/locales/client.te.yml | 3 +- config/locales/client.th.yml | 8 +- config/locales/client.tr_TR.yml | 71 +- config/locales/client.uk.yml | 15 +- config/locales/client.ur.yml | 11 +- config/locales/client.vi.yml | 15 +- config/locales/client.zh_CN.yml | 1339 ++++---- config/locales/client.zh_TW.yml | 11 +- config/locales/server.ar.yml | 3 +- config/locales/server.be.yml | 1 - config/locales/server.ca.yml | 1 - config/locales/server.da.yml | 1 - config/locales/server.de.yml | 723 +++-- config/locales/server.el.yml | 1 - config/locales/server.es.yml | 837 +++-- config/locales/server.fa_IR.yml | 1 - config/locales/server.fi.yml | 2005 ++++++------ config/locales/server.fr.yml | 281 +- config/locales/server.gl.yml | 1 - config/locales/server.he.yml | 10 +- config/locales/server.hy.yml | 1 - config/locales/server.it.yml | 1 - config/locales/server.ja.yml | 1528 +++++---- config/locales/server.ko.yml | 25 +- config/locales/server.nl.yml | 1 - config/locales/server.pl_PL.yml | 6 +- config/locales/server.pt.yml | 1 - config/locales/server.pt_BR.yml | 1 - config/locales/server.ro.yml | 1 - config/locales/server.ru.yml | 30 +- config/locales/server.sk.yml | 1 - config/locales/server.sr.yml | 1 - config/locales/server.sv.yml | 10 +- config/locales/server.sw.yml | 1 - config/locales/server.tr_TR.yml | 4 +- config/locales/server.uk.yml | 1 - config/locales/server.ur.yml | 1 - config/locales/server.vi.yml | 1 - config/locales/server.zh_CN.yml | 1241 ++++--- config/locales/server.zh_TW.yml | 1 - .../config/locales/client.ja.yml | 4 +- .../config/locales/server.ja.yml | 2 +- .../config/locales/client.ja.yml | 20 +- .../config/locales/server.ja.yml | 2 +- .../config/locales/client.ja.yml | 4 +- .../config/locales/server.de.yml | 8 +- .../config/locales/server.es.yml | 60 +- .../config/locales/server.fr.yml | 14 +- .../config/locales/server.ja.yml | 46 +- .../config/locales/server.ru.yml | 2 +- plugins/poll/config/locales/client.de.yml | 8 +- plugins/poll/config/locales/client.es.yml | 4 +- plugins/poll/config/locales/client.ja.yml | 62 +- plugins/poll/config/locales/server.es.yml | 2 +- plugins/poll/config/locales/server.ja.yml | 52 +- .../styleguide/config/locales/client.de.yml | 4 +- .../styleguide/config/locales/client.es.yml | 6 +- .../styleguide/config/locales/client.ja.yml | 24 +- .../styleguide/config/locales/server.de.yml | 2 +- .../styleguide/config/locales/server.ja.yml | 4 +- public/403.ja.html | 2 +- public/422.ja.html | 2 +- public/500.ja.html | 10 +- public/503.ja.html | 6 +- 101 files changed, 8347 insertions(+), 8424 deletions(-) diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index 4a25d68bf6..7ce407f8f7 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -48,157 +48,157 @@ ar: tiny: half_a_minute: "< 1د" less_than_x_seconds: - zero: "< %{count}ثا" - one: "< %{count}ثا" - two: "< %{count}ثا" - few: "< %{count}ثا" - many: "< %{count}ثا" - other: "< %{count}ثا" + zero: "< %{count} ث" + one: "< %{count} ث" + two: "< %{count} ث" + few: "< %{count} ث" + many: "< %{count} ث" + other: "< %{count} ث" x_seconds: - zero: "%{count}ثا" - one: "%{count}ثا" - two: "%{count}ثا" - few: "%{count}ثا" - many: "%{count}ثا" - other: "%{count}ثا" + zero: "%{count} ث" + one: "%{count} ث" + two: "%{count} ث" + few: "%{count} ث" + many: "%{count} ث" + other: "%{count} ث" less_than_x_minutes: - zero: "< %{count}دق" - one: "< %{count}دق" - two: "< %{count}دق" - few: "< %{count}دق" - many: "< %{count}دق" - other: "< %{count}دق" + zero: "< %{count} د" + one: "< %{count} د" + two: "< %{count} د" + few: "< %{count} د" + many: "< %{count} د" + other: "< %{count} د" x_minutes: - zero: "%{count}دق" - one: "%{count}دق" - two: "%{count}دق" - few: "%{count}دق" - many: "%{count}دق" - other: "%{count}دق" + zero: "%{count} د" + one: "%{count} د" + two: "%{count} د" + few: "%{count} د" + many: "%{count} د" + other: "%{count} د" about_x_hours: - zero: "%{count}سا" - one: "%{count}سا" - two: "%{count}سا" - few: "%{count}سا" - many: "%{count}سا" - other: "%{count}سا" + zero: "%{count} س" + one: "%{count} س" + two: "%{count} س" + few: "%{count} س" + many: "%{count} س" + other: "%{count} س" x_days: - zero: "%{count}يوم" - one: "%{count}يوم" - two: "%{count}يوم" - few: "%{count}يوم" - many: "%{count}يوم" - other: "%{count}يوم" + zero: "%{count} ي" + one: "%{count} ي" + two: "%{count} ي" + few: "%{count} ي" + many: "%{count} ي" + other: "%{count} ي" x_months: - zero: "%{count}شهر" - one: "%{count}شهر" - two: "%{count}شهر" - few: "%{count}شهر" - many: "%{count}شهر" - other: "%{count}شهر" + zero: "%{count} ش" + one: "%{count} ش" + two: "%{count} ش" + few: "%{count} ش" + many: "%{count} ش" + other: "%{count} ش" about_x_years: - zero: "%{count}سنة" - one: "%{count}سنة" - two: "%{count}سنة" - few: "%{count}سنة" - many: "%{count}سنة" - other: "%{count}سنة" + zero: "%{count} ع" + one: "%{count} ع" + two: "%{count} ع" + few: "%{count} ع" + many: "%{count} ع" + other: "%{count} ع" over_x_years: - zero: "> %{count}سنة" - one: "> %{count}سنة" - two: "> %{count}سنة" - few: "> %{count}سنة" - many: "> %{count}سنة" - other: "> %{count}سنة" + zero: "> %{count} ع" + one: "> %{count} ع" + two: "> %{count} ع" + few: "> %{count} ع" + many: "> %{count} ع" + other: "> %{count} ع" almost_x_years: - zero: "%{count}سنة" - one: "%{count}سنة" - two: "%{count}سنة" - few: "%{count}سنة" - many: "%{count}سنة" - other: "%{count}سنة" + zero: "%{count} ع" + one: "%{count} ع" + two: "%{count} ع" + few: "%{count} ع" + many: "%{count} ع" + other: "%{count} ع" date_month: "D ‏MMMM" date_year: "MMMM ‏YYYY" medium: x_minutes: - zero: "أقلّ من دقيقة" - one: "دقيقة واحدة" - two: "دقيقتان" + zero: "%{count} دقيقة" + one: "دقيقة واحدة (%{count})" + two: "دقيقتان (%{count})" few: "%{count} دقائق" many: "%{count} دقيقة" other: "%{count} دقيقة" x_hours: - zero: "أقلّ من ساعة" - one: "ساعة واحدة" - two: "ساعتان" + zero: "%{count} ساعة" + one: "ساعة واحدة (%{count})" + two: "ساعتان (%{count})" few: "%{count} ساعات" many: "%{count} ساعة" other: "%{count} ساعة" x_days: - zero: "أقلّ من يوم" - one: "يوم واحد" - two: "يومان" + zero: "%{count} يوم" + one: "يوم واحد (%{count})" + two: "يومان (%{count})" few: "%{count} أيام" many: "%{count} يومًا" other: "%{count} يوم" date_year: "D ‏MMMM ‏YYYY" medium_with_ago: x_minutes: - zero: "قبل أقلّ من دقيقة" - one: "قبل دقيقة واحدة" - two: "قبل دقيقتين" - few: "قبل %{count} دقائق" - many: "قبل %{count} دقيقة" - other: "قبل %{count} دقيقة" + zero: "منذ %{count} دقيقة" + one: "منذ دقيقة واحدة (%{count})" + two: "منذ دقيقتين (%{count})" + few: "منذ %{count} دقائق" + many: "منذ %{count} دقيقة" + other: "منذ %{count} دقيقة" x_hours: - zero: "قبل أقلّ من ساعة" - one: "قبل ساعة واحدة" - two: "قبل ساعتين" - few: "قبل %{count} ساعات" - many: "قبل %{count} ساعة" - other: "قبل %{count} ساعة" + zero: "منذ %{count} ساعة" + one: "منذ ساعة واحدة (%{count})" + two: "منذ ساعتين (%{count})" + few: "منذ %{count} ساعات" + many: "منذ %{count} ساعة" + other: "منذ %{count} ساعة" x_days: - zero: "قبل أقلّ من يوم" - one: "قبل يوم واحد" - two: "قبل يومين" - few: "قبل %{count} أيام" - many: "قبل %{count} يومًا" - other: "قبل %{count} يوم" + zero: "منذ %{count} يوم" + one: "منذ يوم واحد (%{count})" + two: "منذ يومين (%{count})" + few: "منذ %{count} أيام" + many: "منذ %{count} يومًا" + other: "منذ %{count} يوم" x_months: - zero: "قبل أقلّ من شهر" - one: "قبل شهر واحد" - two: "قبل شهرين" - few: "قبل %{count} أشهر" - many: "قبل %{count} شهرًا" - other: "قبل %{count} شهر" + zero: "منذ %{count} شهر" + one: "منذ شهر واحد (%{count})" + two: "منذ شهرين (%{count})" + few: "منذ %{count} أشهر" + many: "منذ %{count} شهرًا" + other: "منذ %{count} شهر" x_years: - zero: "قبل أقلّ من سنة" - one: "قبل سنة واحدة" - two: "قبل سنتين" - few: "قبل %{count} سنوات" - many: "قبل %{count} سنة" - other: "قبل %{count} سنة" + zero: "منذ %{count} عام" + one: "منذ عام واحد (%{count})" + two: "منذ عامين (%{count})" + few: "منذ %{count} أعوام" + many: "منذ %{count} عامًا" + other: "منذ %{count} عام" later: x_days: - zero: "بعد أقلّ من يوم" - one: "بعد يوم واحد" - two: "بعد يومين" + zero: "بعد %{count} يوم" + one: "بعد يوم واحد (%{count})" + two: "بعد يومين (%{count})" few: "بعد %{count} أيام" many: "بعد %{count} يومًا" other: "بعد %{count} يوم" x_months: - zero: "بعد أقلّ من شهر" - one: "بعد شهر واحد" - two: "بعد شهرين" + zero: "بعد %{count} شهر" + one: "بعد شهر واحد (%{count})" + two: "بعد شهرين (%{count})" few: "بعد %{count} أشهر" many: "بعد %{count} شهرًا" other: "بعد %{count} شهر" x_years: - zero: "بعد أقلّ من سنة" - one: "بعد سنة واحدة" - two: "بعد سنتين" - few: "بعد %{count} سنوات" - many: "بعد %{count} سنة" + zero: "بعد %{count} عام" + one: "بعد عام واحد (%{count})" + two: "بعد عامين (%{count})" + few: "بعد %{count} أعوام" + many: "بعد %{count} عامًا" other: "بعد %{count} سنة" previous_month: "الشهر الماضي" next_month: "الشهر القادم" @@ -237,8 +237,8 @@ ar: enabled: "تم تثبيته عموميًا في %{when}" disabled: "تم إلغاء تثبيته في %{when}" visible: - enabled: "أُدرج %{when}" - disabled: "أُزال إدراجه %{when}" + enabled: "تم الإدراج في %{when}" + disabled: "تم إلغاء الإدراج في %{when}" banner: enabled: "حوَّل هذا الموضوع إلى بانر في %{when}. سيظهر أعلى كل صفحة حتى يُزيله المستخدم." disabled: "أزال هذا البانر في %{when}. ولن يظهر بعد الآن في أعلى كل صفحة." @@ -249,13 +249,13 @@ ar: software_update_prompt: dismiss: "تجاهل" bootstrap_mode_enabled: - zero: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن لا ينضمّ أيّ مستخدم." - one: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن ينضمّ مستخدم واحد." - two: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن ينضمّ مستخدمين." - few: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن ينضمّ %{count} مستخدمين." - many: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن ينضمّ %{count} مستخدمًا." - other: "ضُبط الموقع على الوضع التمهيدي لتسهيل إطلاق موقعك الجديد. سيُمنح كلّ المستخدمين الجدد مستوى الثقة 1 كما وستُفعّل لهم رسائل الخُلاصة عبر البريد تلقائيًا. سيُلغى هذا الوضع تلقائيًا ما إن ينضمّ %{count} مستخدم." - bootstrap_mode_disabled: "سيتوقف الوضع التمهيدي خلال 24 ساعة." + zero: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام %{count} مستخدم." + one: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام مستخدم واحد (%{count})." + two: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام مستخدمين (%{count})." + few: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام %{count} مستخدمين." + many: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام %{count} مستخدمًا." + other: "أنت في وضع تمهيد التشغيل لتسهيل إطلاق موقعك الجديد. سيتم منح جميع المستخدمين الجُدد مستوى الثقة 1 وتفعيل رسائل البريد الإلكتروني التلخيصية لهم. وسيتم إيقاف هذا الوضع تلقائيًا عند انضمام %{count} مستخدم." + bootstrap_mode_disabled: "سيتم تعطيل وضع تمهيد التشغيل خلال 24 ساعة." themes: default_description: "المبدئي" broken_theme_alert: "قد لا يعمل موقعك الإلكتروني كما ينبغي بسبب وجود أخطاء في السمة/المكوِّن %{theme}. ويمكنك تعطيله من المسار %{path}." @@ -289,21 +289,20 @@ ar: submit: "إرسال" generic_error: "عذرًا، حدث خطأ." generic_error_with_reason: "حدث خطأ: %{error}" - go_ahead: "انطلق" sign_up: "الاشتراك" log_in: "تسجيل الدخول" age: "العمر" joined: "تاريخ الانضمام" - admin_title: "المدير" + admin_title: "المسؤول" show_more: "عرض المزيد" - show_help: "خيارات" - links: "روابط" + show_help: "الخيارات" + links: "الروابط" links_lowercase: - zero: "الروابط" - one: "الروابط" - two: "الروابط" - few: "الروابط" - many: "الروابط" + zero: "رابط" + one: "رابط واحد" + two: "رابطان" + few: "روابط" + many: "روابط" other: "روابط" faq: "الأسئلة الشائعة" guidelines: "الإرشادات" @@ -314,13 +313,12 @@ ar: conduct: "قواعد السلوك" mobile_view: "العرض على الجوَّال" desktop_view: "العرض على سطح المكتب" - you: "انت" + you: "أنت" or: "أو" now: "منذ لحظات" read_more: "قراءة المزيد" - more: "أكثر" - less: "أقل" - never: "أبدا" + more: "المزيد" + never: "أبدًا" every_30_minutes: "كلّ 30 دقيقة" every_hour: "كلّ ساعة" daily: "يوميًا" @@ -328,13 +326,12 @@ ar: every_month: "كلّ شهر" every_six_months: "كلّ ستة أشهر" max_of_count: "%{count} كحدٍ أقصى" - alternation: "أو" character_count: - zero: "لا محارف" - one: "محرف واحد" - two: "محرفان" - few: "%{count} محارف" - many: "%{count} محرفًا" + zero: "%{count} حرف" + one: "حرف واحد (%{count})" + two: "حرفان (%{count})" + few: "%{count} أحرف" + many: "%{count} حرفًا" other: "%{count} محرف" related_messages: title: "الرسائل ذات الصلة" @@ -357,14 +354,13 @@ ar: user_count: "المستخدمون" active_user_count: "المستخدمون النشطون" contact: "تواصل معنا" - contact_info: "في حال حدوث مشكلة حرجة أو أمر عاجل يؤثّر على الموقع، من فضلك راسلنا على %{contact_info}." + contact_info: "في حال حدوث مشكلة خطيرة أو أمر عاجل يؤثر على الموقع، يُرجى مراسلتنا على %{contact_info}." bookmarked: title: "وضع إشارة مرجعية" clear_bookmarks: "مسح الإشارات المرجعية" help: bookmark: "انقر لإضافة إشارة مرجعية على المنشور الأول في هذا الموضوع" unbookmark: "انقر لإزالة كل الإشارات المرجعية في هذا الموضوع" - unbookmark_with_reminder: "انقر لإزالة كل الإشارات المرجعية والتذكيرات في هذا الموضوع. لديك تذكير في %{reminder_at} لهذا الموضوع." bookmarks: created: "لقد وضعت إشارة مرجعية على هذا المنشور. %{name}" not_bookmarked: "وضع إشارة مرجعية على هذا المنشور" @@ -393,39 +389,39 @@ ar: copy_codeblock: copied: "تم النسخ!" drafts: - resume: "أكمل" + resume: "استئناف" remove: "إزالة" - remove_confirmation: "أمتأكّد من حذف هذه المسودّة؟" + remove_confirmation: "هل تريد بالتأكيد حذف هذه المسودة؟" new_topic: "مسودة موضوع جديد" new_private_message: "مسودة رسالة خاصة جديدة" topic_reply: "مسودة الرد" topic_count_latest: - zero: "ما من مواضيع جديدة أو محدّثة لمطالعتها" - one: "طالِع الموضوع الجديد أو المحدّث" - two: "طالِع الموضوعين الجديدين أو المحدّثين" - few: "طالِع %{count} مواضيع جديدة أو محدّثة" - many: "طالِع %{count} موضوعًا جديدًا أو محدّثًا" - other: "طالِع %{count} موضوع جديد أو محدّث" + zero: "عرض %{count} موضوع جديد أو محدَّث" + one: "عرض موضوع واحد (%{count}) جديد أو محدَّث" + two: "عرض موضوعين (%{count}) جديدين أو محدَّثين" + few: "عرض %{count} موضوعات جديدة أو محدَّثة" + many: "عرض %{count} موضوعًا جديدًا أو محدَّثًا" + other: "عرض %{count} موضوع جديد أو محدَّث" topic_count_unread: - zero: "ما من مواضيع غير مقروءة لمطالعتها" - one: "طالِع الموضوع غير المقروء" - two: "طالِع الموضوعين غير المقروءين" - few: "طالِع %{count} مواضيع غير مقروءة" - many: "طالِع %{count} موضوعًا غير مقروء" - other: "طالِع %{count} موضوع غير مقروء" + zero: "عرض %{count} موضوع غير مقروء" + one: "عرض موضوع واحد (%{count}) غير مقروء" + two: "عرض موضوعين (%{count}) غير مقروءين" + few: "عرض %{count} موضوعات غير مقروءة" + many: "عرض %{count} موضوعًا غير مقروء" + other: "عرض %{count} موضوع غير مقروء" topic_count_new: - zero: "ما من مواضيع جديدة لمطالعتها" - one: "طالِع الموضوع الجديد" - two: "طالِع الموضوعين الجديدين" - few: "طالِع %{count} مواضيع جديدة" - many: "طالِع %{count} موضوعًا جديدًا" - other: "طالِع %{count} موضوع جديد" - preview: "معاينة" + zero: "عرض %{count} موضوع جديد" + one: "عرض موضوع واحد (%{count}) جديد" + two: "عرض موضوعين (%{count}) جديدين" + few: "عرض %{count} موضوعات جديدة" + many: "عرض %{count} موضوعًا جديدًا" + other: "عرض %{count} موضوع جديد" + preview: "المعاينة" cancel: "إلغاء" - deleting: "يحذف..." + deleting: "جارٍ الحذف..." save: "حفظ التغييرات" saving: "جارٍ الحفظ..." - saved: "حُفظت!" + saved: "تم الحفظ!" upload: "تحميل" uploading: "جارٍ التحميل..." uploading_filename: "جارٍ تحميل: %{filename}..." @@ -438,8 +434,8 @@ ar: undo: "تراجَع" revert: "عودة" failed: "فشل" - switch_to_anon: "ادخل وضع التّخفي" - switch_from_anon: "اخرج من وضع التّخفي" + switch_to_anon: "دخول وضع التخفي" + switch_from_anon: "الخروج من وضع التخفي" banner: close: "إزالة هذا البانر" edit: "تعديل هذا البانر >>" @@ -454,64 +450,64 @@ ar: none_found: "لم يتم العثور على أي رسالة." title: search: "البحث عن رسالة" - placeholder: "اكتب عنوان الرسالة أو عنوان URL أو المعرف هنا" + placeholder: "اكتب عنوان الرسالة أو عنوان URL أو المُعرِّف هنا" review: order_by: "الترتيب حسب" in_reply_to: "ردًا على" explain: - why: "شرح سبب انتهاء هذا العنصر في قائمة الانتظار" - title: "نقاط قابلة للمراجعة" - formula: "صيغة" - subtotal: "المجموع الفرعي" - total: "مجموع" - min_score_visibility: "الحد الأدنى من نقاط الرؤية" - score_to_hide: "النتيجة لإخفاء المنشور" + why: "اشرح سبب دخول هذا العنصر في قائمة الانتظار" + title: "النقاط القابلة للمراجعة" + formula: "المعادلة" + subtotal: "الإجمالي الفرعي" + total: "الإجمالي" + min_score_visibility: "الحد الأدنى من النقاط للإظهار" + score_to_hide: "النقاط اللازمة لإخفاء المنشور" take_action_bonus: - name: "اتخذ إجراء" - title: "وعندما يختار الموظف اتخاذ إجراء ما، تمنح العَلَم مكافأة." + name: "اتخذ إجراءً" + title: "عندما يختار أحد أعضاء الطاقم اتخاذ إجراء، يتم منح مكافأة على العلامة." user_accuracy_bonus: name: "دقة المستخدم" - title: "ويحصل المستخدمون الذين تم الاتفاق على أعلامهم تاريخيا على مكافأة." + title: "يحصل المستخدمون الذين تم التحقُّق من صحة علاماتهم بشكلٍ متكرر على مكافأة." trust_level_bonus: name: "مستوى الثقة" - title: "العناصر القابلة للمراجعة التي تم إنشاؤها من قبل مستخدمي مستوى ثقة أعلى لديهم نتيجة أعلى." + title: "تحظى العناصر القابلة للمراجعة التي أنشأها مستخدمون ذوو مستوى ثقة أعلى بنقاط أعلى." type_bonus: - name: "نوع المكافأة" - title: "يمكن تعيين مكافأة معينة من الأنواع القابلة للمراجعة من قبل الموظفين لجعلها أولوية أعلى." + name: "مكافأة النوع" + title: "يمكن لطاقم العمل تخصيص مكافأة لبعض الأنواع القابلة للمراجعة لمنحها أولوية أعلى." claim_help: optional: "يمكنك المطالبة بهذا العنصر لمنع الآخرين من مراجعته." required: "يجب عليك المطالبة بالعناصر قبل أن تتمكن من مراجعتها." claimed_by_you: "لقد طالبت بهذا العنصر ويمكنك مراجعته." - claimed_by_other: "لا يمكن مراجعة هذا العنصر إلا من قِبل %{username}." + claimed_by_other: "لا يمكن مراجعة هذا العنصر إلا بواسطة %{username}." claim: title: "المطالبة بهذا الموضوع" unclaim: help: "إزالة هذه المطالبة" - awaiting_approval: "بأنتضار موافقة" - delete: "أحذف" + awaiting_approval: "في انتظار الموافقة" + delete: "حذف" settings: - saved: "تم حفظهُ" - save_changes: "احفظ التعديلات" - title: "إعدادات" + saved: "تم الحفظ" + save_changes: "حفظ التغييرات" + title: "الإعدادات" priorities: title: "الأولويات القابلة للمراجعة" - moderation_history: "تاريخ الادارة" - view_all: "اظهار الكل" - grouped_by_topic: "مجمعة حسب الموضوع" + moderation_history: "تاريخ الإشراف" + view_all: "عرض الكل" + grouped_by_topic: "مجمَّعة حسب الموضوع" none: "لا توجد عناصر لمراجعتها." - view_pending: "اعرض المُرجأ" + view_pending: "عرض قائمة الانتظار" topic_has_pending: - zero: "ما من مشاركات تنتظر الموافقة في هذا الموضوع" - one: "في هذا الموضوع مشاركة واحدة تنتظر الموافقة" - two: "في هذا الموضوع مشاركتين تنتظرين الموافقة" - few: "في هذا الموضوع %{count} مشاركات تنتظر الموافقة" - many: "في هذا الموضوع %{count} مشاركة تنتظر الموافقة" - other: "في هذا الموضوع %{count} مشاركة تنتظر الموافقة" - title: "مراجعة" + zero: "هذا الموضوع به %{count} منشور في انتظار الموافقة" + one: "هذا الموضوع به منشور واحد (%{count}) في انتظار الموافقة" + two: "هذا الموضوع به منشورين (%{count}) في انتظار الموافقة" + few: "هذا الموضوع به %{count} منشورات في انتظار الموافقة" + many: "هذا الموضوع به %{count} منشورًا في انتظار الموافقة" + other: "هذا الموضوع به %{count} منشور في انتظار الموافقة" + title: "المراجعة" topic: "الموضوع:" - filtered_topic: "لقد تمت تصفيتها إلى محتوى قابل للمراجعة في موضوع واحد." - filtered_user: "مستخدم" - filtered_reviewed_by: "راجَعها" + filtered_topic: "تمت التصفية لعرض المحتوى القابل للمراجعة في موضوع واحد." + filtered_user: "المستخدم" + filtered_reviewed_by: "تمت المراجعة بواسطة" show_all_topics: "عرض كل الموضوعات" deleted_post: "(تم حذف المنشور)" deleted_user: "(تم حذف المستخدم)" @@ -522,51 +518,51 @@ ar: email: "البريد الإلكتروني" name: "الاسم" fields: "الحقول" - reject_reason: "سبب" + reject_reason: "السبب" user_percentage: agreed: - zero: "%{count}% مُوافق عليها" - one: "%{count}% مُوافق عليها" - two: "%{count}% مُوافق عليها" - few: "%{count}% مُوافق عليها" - many: "%{count}% مُوافق عليها" - other: "%{count}% مُوافق عليها" + zero: "%{count}% اتفقوا" + one: "%{count}% اتفقوا" + two: "%{count}% اتفقوا" + few: "%{count}% اتفقوا" + many: "%{count}% اتفقوا" + other: "%{count}% اتفقوا" disagreed: - zero: "%{count}% مرفوضة" - one: "%{count}% مرفوضة" - two: "%{count}% مرفوضة" - few: "%{count}% مرفوضة" - many: "%{count}% مرفوضة" - other: "%{count}% مرفوضة" + zero: "%{count}% لم يتفقوا" + one: "%{count}% لم يتفقوا" + two: "%{count}% لم يتفقوا" + few: "%{count}% لم يتفقوا" + many: "%{count}% لم يتفقوا" + other: "%{count}% لم يتفقوا" ignored: - zero: "%{count}% مُتجاهلة" - one: "%{count}% مُتجاهلة" - two: "%{count}% مُتجاهلة" - few: "%{count}% مُتجاهلة" - many: "%{count}% مُتجاهلة" - other: "%{count}% مُتجاهلة" + zero: "%{count}% تجاهلوا" + one: "%{count}% تجاهلوا" + two: "%{count}% تجاهلوا" + few: "%{count}% تجاهلوا" + many: "%{count}% تجاهلوا" + other: "%{count}% تجاهلوا" topics: - topic: "موضوع" - reviewable_count: "العد" - reported_by: "تم عمل تقرير بواسطة" + topic: "الموضوع" + reviewable_count: "العدد" + reported_by: "تم الإبلاغ بواسطة" deleted: "[تم حذف الموضوع]" original: "(الموضوع الأصلي)" details: "التفاصيل" unique_users: - zero: "ما من مستخدمين" - one: "مستخدم واحد" - two: "مستخدمان" + zero: "%{count} مستخدم" + one: "مستخدم واحد (%{count})" + two: "مستخدمان (%{count})" few: "%{count} مستخدمين" many: "%{count} مستخدمًا" other: "%{count} مستخدم" replies: - zero: "ما من ردود" - one: "ردّ واحد" - two: "ردّان" + zero: "%{count} رد" + one: "رد واحد (%{count})" + two: "ردَّان (%{count})" few: "%{count} ردود" many: "%{count} ردًا" - other: "%{count} ردّ" - edit: "عدّل" + other: "%{count} رد" + edit: "تعديل" save: "حفظ" cancel: "إلغاء" new_topic: "ستؤدي الموافقة على هذا العنصر إلى إنشاء موضوع جديد" @@ -578,14 +574,14 @@ ar: minimum_score: "الحد الأدنى من النقاط:" refresh: "تحديث" status: "الحالة" - category: "تصنيف" + category: "الفئة" orders: - score: "نقاط" - score_asc: "النتيجة (عكسي)" + score: "النقاط" + score_asc: "النقاط (عكسية)" created_at: "تاريخ الإنشاء" created_at_asc: "تاريخ الإنشاء (عكسي)" priority: - title: "الحد الأدنى من الأولوية" + title: "الحد الأدنى للأولوية" any: "(أي)" low: "منخفض" medium: "متوسطة" @@ -593,70 +589,70 @@ ar: conversation: view_full: "عرض المحادثة الكاملة" scores: - about: "يتم احتساب هذه الدرجة بناءً على مستوى ثقة المراسل، ودقة علاماته السابقة، وأولوية العنصر الذي يتم الإبلاغ عنه." - score: "نقاط" + about: "يتم احتساب هذه النقاط بناءً على مستوى الثقة للمُبلِغ، وصحة علاماته السابقة، وأولوية العنصر الذي يتم الإبلاغ عنه." + score: "النقاط" date: "التاريخ" type: "النوع" status: "الحالة" - submitted_by: "مقدّم من" - reviewed_by: "تمت المراجعة من قِبل" + submitted_by: "تم الإرسال بواسطة" + reviewed_by: "تمت المراجعة بواسطة" statuses: pending: title: "قيد الانتظار" approved: - title: "موافق عليه" + title: "تمت الموافقة" rejected: - title: "مرفوض" + title: "تم الرفض" ignored: - title: "تم تجاهله" + title: "تم التجاهل" deleted: - title: "محذوف" + title: "تم الحذف" reviewed: - title: "(جميعها مراجعة)" + title: "(تمت مراجعة الكل)" all: title: "(كل شيء)" types: reviewable_flagged_post: - title: "المشاركات المبلغ عنها" - flagged_by: "تم وضع علامة بواسطة" + title: "منشور تم وضع علامة عليه" + flagged_by: "تم وضع العلامة بواسطة" reviewable_queued_topic: title: "موضوع في قائمة الانتظار" reviewable_queued_post: - title: "في قائمة الانتظار" + title: "منشور في قائمة الانتظار" reviewable_user: title: "المستخدم" reviewable_post: - title: "مشاركة" + title: "المنشور" approval: - title: "المنشور يحتاج موافقة" + title: "المنشور بحاجة للموافقة" description: "لقد استلمنا منشورك الجديد، لكنه بحاجة إلى موافقة أحد المشرفين عليه قبل ظهوره. يُرجى الانتظار." pending_posts: - zero: "ما من مشاركات تنتظر الموافقة منك." - one: "مشاركة واحدة تنتظر الموافقة منك." - two: "مشاركتان تنتظران الموافقة منك." - few: "%{count} مشاركات تنتظر الموافقة منك." - many: "%{count} مشاركة تنتظر الموافقة منك." - other: "%{count} مشاركة تنتظر الموافقة منك." + zero: "لديك %{count} منشور قيد الانتظار." + one: "لديك منشور واحد (%{count}) قيد الانتظار." + two: "لديك منشوران (%{count}) قيد الانتظار." + few: "لديك %{count} منشورات قيد الانتظار." + many: "لديك %{count} منشورًا قيد الانتظار." + other: "لديك %{count} منشور قيد الانتظار." ok: "حسنًا" example_username: "اسم المستخدم" relative_time_picker: days: - zero: "أيام" - one: "يوم" - two: "أيام" + zero: "يوم" + one: "يوم واحد" + two: "يومان" few: "أيام" many: "أيام" other: "أيام" time_shortcut: - later_today: "خلال هذا اليوم" + later_today: "لاحقًا اليوم" next_business_day: "يوم العمل التالي" tomorrow: "غدًا" - post_local_date: "تاريخ المشاركة" - later_this_week: "خلال هذا الأسبوع" + post_local_date: "التاريخ في المنشور" + later_this_week: "لاحقًا هذا الأسبوع" start_of_next_business_week: "الاثنين" start_of_next_business_week_alt: "الاثنين القادم" next_month: "الشهر القادم" - custom: "تاريخ ووقت مخصّصين" + custom: "تاريخ ووقت مخصَّصان" user_action: user_posted_topic: "نشر %{user} ‏الموضوع" you_posted_topic: "أنت نشرت ‏الموضوع" @@ -677,11 +673,11 @@ ar: title: "المستخدمون" likes_given: "المعطاة" likes_received: "المتلقاة" - topics_entered: "المُشاهدة" - topics_entered_long: "المواضيع التي تمت مشاهدتها" + topics_entered: "المعروضة" + topics_entered_long: "الموضوعات المعروضة" time_read: "وقت القراءة" - topic_count: "المواضيع" - topic_count_long: "المواضيع المنشورة" + topic_count: "الموضوعات" + topic_count_long: "الموضوعات المنشأة" post_count: "الردود" post_count_long: "الردود المنشورة" no_results: "لم يتم العثور على أي نتيجة." @@ -689,62 +685,54 @@ ar: days_visited_long: "أيام الزيارة" posts_read: "المقروءة" posts_read_long: "المنشورات المقروءة" - last_updated: "اخر تحديث" + last_updated: "آخر تحديث:" total_rows: - zero: "ما من أعضاء" - one: "عضو واحد" - two: "عضوان" - few: "%{count} أعضاء" - many: "%{count} عضوًا" - other: "%{count} عضو" + zero: "%{count} مستخدم" + one: "مستخدم واحد (%{count})" + two: "مستخدمان (%{count})" + few: "%{count} مستخدمين" + many: "%{count} مستخدمًا" + other: "%{count} مستخدم" edit_columns: - save: "احفظ" - reset_to_default: "صفّر إلى المبدئي" + save: "حفظ" + reset_to_default: "إعادة التعيين إلى الافتراضي" group: all: "كل المجموعات" group_histories: actions: change_group_setting: "تغيير إعدادات المجموعة" - add_user_to_group: "إضافة عضو" - remove_user_from_group: "حذف العضو" - make_user_group_owner: "تعيين كمالك" - remove_user_as_group_owner: "سحب صلاحية المالك" + add_user_to_group: "إضافة مستخدم" + remove_user_from_group: "إزالة مستخدم" + make_user_group_owner: "التعيين كمالك" + remove_user_as_group_owner: "سحب الملكية" groups: - member_added: "تم الإضافة" + member_added: "تمت الإضافة" member_requested: "تاريخ الطلب" add_members: - title: "إضافة أعضاء إلى %{group_name}" - description: "يمكنك أيضًا لصق قائمة مفصولة بفاصلات." - usernames_or_emails: - title: "أدخِل أسماء المستخدمين أو عناوين البريد" - input_placeholder: "أسماء المستخدمين أو عناوين البريد" - usernames: - input_placeholder: "أسماء المستخدمين" - notify_users: "إخطار المستخدمين" + notify_users: "إشعار المستخدمين" requests: title: "الطلبات" - reason: "سبب" + reason: "السبب" accept: "قبول" - accepted: "مقبول" + accepted: "تم القبول" deny: "رفض" - denied: "مرفوض" - undone: "طلب التراجع" + denied: "تم الرفض" + undone: "تم التراجع عن الطلب" handle: "التعامل مع طلب العضوية" manage: title: "إدارة" - name: "الإسم" + name: "الاسم" full_name: "الاسم بالكامل" - add_members: "أضِف أعضاء" delete_member_confirm: "هل تريد إزالة \"%{username}\" من المجموعة \"%{group}\"؟" profile: title: الملف الشخصي interaction: - title: تفاعل - posting: نشر - notification: اشعار + title: التفاعل + posting: النشر + notification: الإشعارات email: title: "البريد الإلكتروني" - status: "مزامنة %{old_emails} / %{total_emails} رسائل البريد الإلكتروني عبر IMAP." + status: "تمت مزامنة %{old_emails}/%{total_emails} من رسائل البريد الإلكتروني عبر IMAP." credentials: title: "بيانات الاعتماد" smtp_server: "خادم SMTP" @@ -756,54 +744,54 @@ ar: username: "اسم المستخدم" password: "كلمة السر" settings: - title: "إعدادات" + title: "الإعدادات" mailboxes: synchronized: "صندوق البريد المتزامن" none_found: "لم يتم العثور على صناديق بريد في حساب البريد الإلكتروني هذا." membership: title: العضوية - access: صلاحية + access: الوصول categories: title: الفئات long_title: "الإشعارات الافتراضية للفئة" description: "عند إضافة مستخدمين إلى هذه المجموعة، سيتم ضبط إعدادات إشعارات الفئة لديهم على تلك القيم. ويمكنهم تغييرها بعد ذلك." - watched_categories_instructions: "ستراقب آليا كل المواضيع في هذه التصنيفات. ستصل لمجموعة الأعضاء إشعارات بالمنشورات والمشاركات الجديدة، وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - tracked_categories_instructions: "ستتابع آليا كل موضوعات هذا القسم. وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - watching_first_post_categories_instructions: "سيتم إخطار المستخدمين بأول مشاركة في كل موضوع جديد في هذه التصنيفات." - regular_categories_instructions: "إذا تم كتم هذه الفئات، فلن يتم كتمها عن أعضاء المجموعة. سيتم إخطار المستخدمين إذا تم ذكرهم أو رد شخص ما عليهم." - muted_categories_instructions: "لن يتم إخطار المستخدمين بأي شيء يتعلق بالمواضيع الجديدة في هذه التصنيفات، ولن يظهروا في التصنيفات أو صفحات الموضوعات الأخيرة." + watched_categories_instructions: "يمكنك مراقبة كل الموضوعات في هذه الفئات تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_categories_instructions: "يمكنك تتبُّع كل الموضوعات في هذه الفئات تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." + watching_first_post_categories_instructions: "سيتلقى المستخدمون إشعارًا بأول منشور في كل موضوع جديد في هذه الفئات." + regular_categories_instructions: "إذا تم كتم هذه الفئات، فلن يتم كتمها لأعضاء المجموعة. سيتم إرسال إشعار إلى المستخدمين إذا تمت الإشارة إليهم أو رد شخص ما عليهم." + muted_categories_instructions: "لن يتلقى المستخدمون أي إشعارات أبدًا بخصوص الموضوعات الجديدة في هذه الفئات، ولن تظهر في الفئات أو صفحات أحدث الموضوعات." tags: title: الوسوم long_title: "الإشعارات الافتراضية للوسوم" - description: "عند إضافة مستخدمين إلى هذه المجموعة، سيتم تعيين إعدادات أوسمة الإشعار الخاصة بهم على الإعدادات الافتراضية. بعد ذلك، يمكنهم تغييرها." - watched_tags_instructions: "ستراقب آليا كل المواضيع في هذه الأوسمة. ستصل إشعارات لمجموعة الأعضاء بالمنشورات والمشاركات الجديدة، وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - tracked_tags_instructions: "ستتابع آليا كل موضوعات هذه الأوسمة. وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." + description: "عند إضافة مستخدمين إلى هذه المجموعة، سيتم تعيين الإعدادات المتعلقة بإشعارات الوسوم لديهم على الإعدادات الافتراضية. ويمكنهم تغييرها بعد ذلك." + watched_tags_instructions: "يمكنك مراقبة كل الموضوعات التي تحمل هذه الوسوم تلقائيًا. سيتلقى أعضاء المجموعة إشعارات بالمنشورات والموضوعات الجديدة، وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + tracked_tags_instructions: "يمكنك تتبُّع كل الموضوعات التي تحمل هذه الوسوم تلقائيًا. وسيظهر عدد المنشورات الجديدة بجانب الموضوع." watching_first_post_tags_instructions: "سيتم إرسال إشعار للمستخدمين بأول منشور في كل موضوع يحمل هذه الوسوم." - regular_tags_instructions: "إذا تم كتم كتم هذه العلامات، فسيتم إلغاء الكتم عن مستخدمين المجموعة. سيتم إخطار المستخدمين إذا تم ذكرهم أو رد شخص ما عليهم." - muted_tags_instructions: "لن يتم إخطار المستخدمين بأي شيء يتعلق بالمواضيع الجديدة لهذه الأوسمة، ولن تظهر في الأحدث." + regular_tags_instructions: "إذا تم كتم هذه الوسوم، فلن يتم كتمها لأعضاء المجموعة. سيتم إرسال إشعارات إلى المستخدمين إذا تمت الإشارة إليهم أو رد شخص ما عليهم." + muted_tags_instructions: "لن يتلقى المستخدمون أي إشعارات أبدًا بخصوص الموضوعات الجديدة التي تحمل هذه الوسوم، ولن تظهر في أحدث الموضوعات." logs: - title: "السّجلّات" - when: "متى" - action: "إجراء" - acting_user: "العضو المسؤول" + title: "السجلات" + when: "التوقيت" + action: "الإجراء" + acting_user: "المستخدم المتخذ للإجراء" target_user: "العضو المستهدف" subject: "الموضوع" - details: "تفاصيل" + details: "التفاصيل" from: "من" to: "إلى" permissions: title: "الأذونات" none: "لا توجد فئات مرتبطة بهذه المجموعة." description: "يمكن لأعضاء هذه المجموعة الوصول إلى هذه الفئات" - public_admission: "السماح للاعضاء بالانضمام إلى المجموعة بحرية (يتطلب أن تكون المجموعة مرئية للجميع )" - public_exit: "السماح للأعضاء بمغادرة المجموعة بحرية" + public_admission: "السماح للمستخدمين بالانضمام إلى المجموعة بحرية (يلزم أن تكون المجموعة عامة)" + public_exit: "السماح للمستخدمين بمغادرة المجموعة بحرية" empty: - posts: "لا منشورات من أعضاء هذه المجموعة." - members: "لا أعضاء في هذه المجموعة." + posts: "لا توجد منشورات من أعضاء هذه المجموعة." + members: "لا يوجد أعضاء في هذه المجموعة." requests: "لا توجد طلبات عضوية لهذه المجموعة." - mentions: "لم يُشِر أحد إلى هذه المجموعة." - messages: "لا رسائل لهذه المجموعة." - topics: "لا موضوعات من أعضاء هذه المجموعة." + mentions: "لا توجد إشارات إلى هذه المجموعة." + messages: "لا توجد رسائل لهذه المجموعة." + topics: "لا توجد موضوعات من أعضاء هذه المجموعة." logs: "لا توجد سجلات لهذه المجموعة." add: "إضافة" join: "انضمام" @@ -815,12 +803,12 @@ ar: membership_request_template: "قالب مخصَّص يتم عرضه للمستخدمين عند إرسال طلب عضوية" membership_request: submit: "إرسال طلب" - title: "اطلب الانضمام للمجموعة @%{group_name}" - reason: "دع مدراء المجموعة يعرفون لماذا انت تنتمي لهذه المجموعة" + title: "طلب الانضمام إلى @%{group_name}" + reason: "أخبر مديري المجموعة بسبب انتمائك إلى هذه المجموعة" membership: "العضوية" name: "الاسم" group_name: "اسم المجموعة" - user_count: "الأعضاء" + user_count: "المستخدمون" bio: "نبذة عن المجموعة" selector_placeholder: "أدخِل اسم المستخدم" owner: "المالك" @@ -829,42 +817,42 @@ ar: all: "كلّ المجموعات" empty: "لا توجد مجموعات مرئية." filter: "التصفية حسب نوع المجموعة" - owner_groups: "مجموعاتي" + owner_groups: "المجموعات التي أملكها" close_groups: "المجموعات المُغلقة" - automatic_groups: "مجموعات تلقائية" - automatic: "تلقائي" - closed: "مغلق" + automatic_groups: "المجموعات التلقائية" + automatic: "تلقائية" + closed: "مغلقة" public: "عامة" - private: "خاص" + private: "خاصة" public_groups: "المجموعات العامة" automatic_group: مجموعة تلقائية - close_group: المجموعات المُغلقة + close_group: مجموعة مغلقة my_groups: "مجموعاتي" group_type: "نوع المجموعة" is_group_user: "عضو" - is_group_owner: "المالك" + is_group_owner: "مالك" title: zero: "المجموعات" - one: "المجموعة" + one: "مجموعة واحدة" two: "المجموعتان" - few: "المجموعات" - many: "المجموعات" - other: "المجموعات" + few: "مجموعات" + many: "مجموعات" + other: "مجموعات" activity: "النشاط" members: title: "الأعضاء" filter_placeholder_admin: "اسم المستخدم أو البريد الإلكتروني" filter_placeholder: "اسم المستخدم" - remove_member: "حذف عضو" + remove_member: "إزالة العضو" remove_member_description: "إزالة %{username} من هذه المجموعة" make_owner: "تعيين كمالك" make_owner_description: "جعل %{username} أحد مالكي هذه المجموعة" - remove_owner: "حذف كمالك" - remove_owner_description: "إزالة %{username} من مالكي هذه المجموعة" - owner: "المالك" + remove_owner: "إزالة كمالك" + remove_owner_description: "إزالة %{username} كمالك هذه المجموعة" + owner: "مالك" forbidden: "غير مسموح لك بعرض الأعضاء." topics: "الموضوعات" - posts: "المشاركات" + posts: "المنشورات" mentions: "الإشارات" messages: "الرسائل" notification_level: "مستوى الإشعارات الافتراضي لرسائل المجموعات" @@ -875,40 +863,40 @@ ar: only_admins: "المسؤولون فقط" mods_and_admins: "المسؤولون والمشرفون فقط" members_mods_and_admins: "أعضاء المجموعة والمسؤولون والمشرفون فقط" - owners_mods_and_admins: "مالكو المجموعات، الإداريون والمشرفون فقط" + owners_mods_and_admins: "مالكو المجموعات، والمشرفون، والمسؤولون فقط" everyone: "الجميع" notifications: watching: - title: "مُراقبة" + title: "المراقبة" description: "سنُرسل إليك إشعارًا بكل منشور جديد في كل رسالة، وسترى عدد الردود الجديدة." watching_first_post: - title: "مراقبة اول منشور" - description: "سيتم إعلامك برسائل جديدة في هذه المجموعة ولكن ليس الردود على الرسائل." + title: "مراقبة أول منشور" + description: "ستتقلى إشعارات بالرسائل الجديدة في هذه المجموعة ولكن ليس الردود على الرسائل." tracking: - title: "مُتابع" + title: "التتبُّع" description: "سنُرسل إليك إشعارًا إذا أشار أحد إلى اسمك باستخدام الرمز @ أو ردَّ عليك، وسترى عدد الردود الجديدة." regular: title: "عادي" description: "سنُرسل إليك إشعارًا إذا أشار أحد إلى اسمك باستخدام الرمز @ أو ردَّ عليك." muted: - title: "مكتوم" - description: "لن يتم إخطارك بأي شيء بخصوص الرسائل من هذه المجموعة." + title: "الكتم" + description: "لن تتلقى أي إشعارات أبدًا بخصوص الرسائل من هذه المجموعة." flair_url: "الصورة الرمزية المميزة" - flair_upload_description: "استعمل صورًا مربّعة الشكل مقاسها الأدنى هو 20 بكسل × 20 بكسل." - flair_bg_color: "لون خلفية الصورة الرمزية" - flair_bg_color_placeholder: "(إختياري) اللون بترميز Hexadecimal" - flair_color: "لون الصورة الرمزية" - flair_color_placeholder: "(إختياري) اللون بترميز Hexadecimal" + flair_upload_description: "استخدم صورًا مربعة الشكل لا يقل حجمها عن 20 بكسل × 20 بكسل." + flair_bg_color: "لون خلفية الصورة الرمزية المميزة" + flair_bg_color_placeholder: "(اختياري) القيمة السداسية للون" + flair_color: "لون الصورة الرمزية المميزة" + flair_color_placeholder: "(اختياري) القيمة السداسية للون" flair_preview_icon: "معاينة الأيقونة" flair_preview_image: "معاينة الصورة" flair_type: icon: "اختيار أيقونة" image: "تحميل صورة" user_action_groups: - "1": "الإعجابات المعطاة" - "2": "الإعجابات المتلقاة" + "1": "مرات الإعجاب" + "2": "مرات الإعجاب" "3": "الإشارات المرجعية" - "4": "المواضيع" + "4": "الموضوعات" "5": "الردود" "6": "الردود" "7": "الإشارات" @@ -921,7 +909,7 @@ ar: categories: all: "كل الفئات" all_subcategories: "الكل" - no_subcategory: "لا شيء" + no_subcategory: "لا يوجد" category: "الفئة" category_list: "عرض قائمة الفئات" reorder: @@ -929,52 +917,52 @@ ar: title_long: "إعادة تنظيم قائمة الفئات" save: "احفظ الترتيب" apply_all: "طبّق" - position: "مكان" + position: "الترتيب" posts: "المشاركات" topics: "الموضوعات" - latest: "آخر المواضيع" - toggle_ordering: "تبديل التحكم في الترتيب" - subcategories: "أقسام فرعية" + latest: "الحديثة" + toggle_ordering: "تشغيل التحكم في الترتيب" + subcategories: "الفئات الفرعية" muted: "الفئات المكتومة" topic_sentence: - zero: "ما من مواضيع" + zero: "%{count} موضوع" one: "موضوع واحد" two: "موضوعان" - few: "%{count} مواضيع" + few: "%{count} موضوعات" many: "%{count} موضوعًا" other: "%{count} موضوع" topic_stat_unit: week: "أسبوع" month: "شهر" topic_stat_sentence_week: - zero: "ما من مواضيع جديدة خلال الأسبوع الماضي." - one: "فُتح موضوع واحد جديد خلال الأسبوع الماضي." - two: "فُتح موضوعين جديدين خلال الأسبوع الماضي." - few: "فُتحت %{count} مواضيع جديدة خلال الأسبوع الماضي." - many: "فُتح %{count} موضوعًا جديدًا خلال الأسبوع الماضي." - other: "فُتح %{count} موضوع جديد خلال الأسبوع الماضي." + zero: "%{count} موضوع جديد خلال الأسبوع الماضي." + one: "موضوع واحد (%{count}) جديد خلال الأسبوع الماضي." + two: "موضوعان جديدان (%{count}) خلال الأسبوع الماضي." + few: "%{count} موضوعات جديدة خلال الأسبوع الماضي." + many: "%{count} موضوعًا جديدًا خلال الأسبوع الماضي." + other: "%{count} موضوع جديد خلال الأسبوع الماضي." topic_stat_sentence_month: - zero: "ما من مواضيع جديدة خلال الشهر الماضي." - one: "فُتح موضوع واحد جديد خلال الشهر الماضي." - two: "فُتح موضوعين جديدين خلال الشهر الماضي." - few: "فُتحت %{count} مواضيع جديدة خلال الشهر الماضي." - many: "فُتح %{count} موضوعًا جديدًا خلال الشهر الماضي." - other: "فُتح %{count} موضوع جديد خلال الشهر الماضي." + zero: "%{count} موضوع جديد خلال الشهر الماضي." + one: "موضوع واحد (%{count}) جديد خلال الشهر الماضي." + two: "موضوعان (%{count}) جديدان خلال الشهر الماضي." + few: "%{count} موضوعات جديدة خلال الشهر الماضي." + many: "%{count} موضوعًا جديدًا خلال الشهر الماضي." + other: "%{count} موضوع جديد خلال الشهر الماضي." n_more: "الفئات (%{count} أكثر)..." ip_lookup: - title: جدول عناوين الIP + title: البحث عن عناوين IP hostname: اسم المضيف location: الموقع الجغرافي - location_not_found: (غير معرف) - organisation: المنظمات + location_not_found: (غير معروف) + organisation: المؤسسة phone: الهاتف other_accounts: "الحسابات الأخرى بعنوان IP هذا:" delete_other_accounts: "حذف %{count}" username: "اسم المستخدم" trust_level: "مستوى الثقة" read_time: "وقت القراءة" - topics_entered: " مواضيع فُتحت" - post_count: "# المنشورات" + topics_entered: "الموضوعات المدخلة" + post_count: "# منشور" confirm_delete_other_accounts: "هل تريد بالتأكيد حذف هذه الحسابات؟" powered_by: "باستخدام MaxMindDB" copied: "تم النسخ" @@ -992,7 +980,7 @@ ar: success: "لقد بدأ التنزيل، وسنُرسل إليك رسالة إشعار عند اكتمال العملية." rate_limit_error: "يمكن تنزيل المنشورات مرة واحدة يوميًا. يُرجى إعادة المحاولة غدًا." new_private_message: "رسالة جديدة" - private_message: "رسالة خاصة" + private_message: "رسالة" private_messages: "الرسائل" user_notifications: filters: @@ -1004,18 +992,18 @@ ar: ignore_duration_username: "اسم المستخدم" ignore_duration_when: "المدة:" ignore_duration_save: "تجاهل" - ignore_duration_note: "يرجى ملاحظة أن جميع عمليات التجاهل يتم إزالتها تلقائياً بعد انتهاء مدة التجاهل" - ignore_duration_time_frame_required: "الرجاء تحديد إطار زمني" - ignore_no_users: "ليس لديك أي مستخدمين متجاهلين." - ignore_option: "تم تجاهله" - ignore_option_title: "لن تتلقى إشعارات ذات صلة بهذا المستخدم وسيتم إخفاء جميع مواضيعه وردوده." + ignore_duration_note: "يُرجى العلم بأن جميع عمليات التجاهل يتم إزالتها تلقائيًا بعد انتهاء مدة التجاهل." + ignore_duration_time_frame_required: "يُرجى تحديد إطار زمني" + ignore_no_users: "ليس لديك أي مستخدمين تم تجاهلهم." + ignore_option: "التجاهل" + ignore_option_title: "لن تتلقى إشعارات ذات صلة بهذا المستخدم وسيتم إخفاء جميع موضوعاته وردوده." add_ignored_user: "إضافة..." - mute_option: "مكتومة" + mute_option: "الكتم" mute_option_title: "لن تتلقى أي إشعارات ذات صلة بهذا المستخدم." normal_option: "عادي" normal_option_title: "سنُرسل إليك إشعارًا في حال ردَّ هذا المستخدم عليك أو اقتبس كلامك أو أشار إليك." notification_schedule: - none: "لا شيء" + none: "لا يوجد" monday: "الاثنين" to: "إلى" activity_stream: "النشاط" @@ -1036,122 +1024,122 @@ ar: bookmarks: "الإشارات المرجعية" bio: "نبذة عني" timezone: "المنطقة الزمنية" - invited_by: "مدعو من قبل" + invited_by: "تمت الدعوة بواسطة" trust_level: "مستوى الثقة" notifications: "الإخطارات" - statistics: "الأحصائيات" + statistics: "الإحصاءات" desktop_notifications: - label: "الإشعارات الحية" + label: "الإشعارات الفورية" not_supported: "عذرًا، لا يدعم هذا المتصفح الإشعارات." perm_default: "تفعيل الإشعارات" perm_denied_btn: "‏‏تم رفض الإذن" perm_denied_expl: "لقد رفضت منح الإذن بإرسال الإشعارات. يمكنك السماح بالإشعارات في إعدادات المتصفح." disable: "تعطيل الإشعارات" enable: "تفعيل الإشعارات" - each_browser_note: 'ملاحظة: يجب عليك تغيير هذا الإعداد في كل متصفح تستخدمه. سيتم تعطيل جميع الإشعارات في "الرجاء عدم الإزعاج" ، بغض النظر عن هذا الإعداد.' - consent_prompt: "هل تريد إشعارات مباشرة عندما يرد الأشخاص على مشاركاتك؟" + each_browser_note: 'ملاحظة: عليك تغيير هذا الإعداد في كل متصفح تستخدمه. سيتم تعطيل جميع الإشعارات في وضع "عدم الإزعاج"، بغض النظر عن هذا الإعداد.' + consent_prompt: "هل تريد تلقي إشعارات فورية عند رد الأشخاص على منشوراتك؟" dismiss: "تجاهل" dismiss_notifications: "تجاهل الكل" dismiss_notifications_tooltip: "وضع علامة مقروءة على كل الإشعارات غير المقروءة" first_notification: "أول إشعار تستلمه! اضغط عليه للبدء." dynamic_favicon: "عرض الأعداد على أيقونة المتصفح" skip_new_user_tips: - description: "تخطي نصائح وتهيئة المستخدم الجديد والشارات" + description: "تخطي نصائح وشارات تهيئة المستخدم الجديد" not_first_time: "ليست المرة الأولى لك؟" skip_link: "تخطي هذه النصائح" theme_default_on_all_devices: "جعل هذه السمة الافتراضية على كل أجهزتي" - color_scheme_default_on_all_devices: "تعيين مخطط (أنظمة) الألوان الافتراضية على جميع أجهزتي" - color_scheme: "المخطط اللوني" + color_scheme_default_on_all_devices: "تعيين نظام (أنظمة) الألوان الافتراضية على جميع أجهزتي" + color_scheme: "نظام الألوان" color_schemes: - disable_dark_scheme: "نفس العادية" + disable_dark_scheme: "مثل العادي" dark_instructions: "يمكنك معاينة نظام ألوان الوضع الداكن عن طريق تفعيل الوضع الداكن لجهازك." undo: "إعادة التعيين" regular: "العادي" dark: "الوضع الداكن" default_dark_scheme: "(الوضع الافتراضي للموقع)" dark_mode: "الوضع الداكن" - dark_mode_enable: "تمكين نظام الألوان في الوضع المظلم تلقائيًا" - text_size_default_on_all_devices: "اجعل هذا الحجم الافتراضي للنص على جميع أجهزتي" - allow_private_messages: "السماح للمستخدمين الآخرين بإرسال رسائل خاصة لي" + dark_mode_enable: "تفعيل نظام الألوان في الوضع الداكن تلقائيًا" + text_size_default_on_all_devices: "جعل هذا الحجم الافتراضي للنص على جميع أجهزتي" + allow_private_messages: "السماح للمستخدمين الآخرين بإرسال رسائل شخصية إليَّ" external_links_in_new_tab: "فتح كل الروابط الخارجية في علامة تبويب جديدة" - enable_quoting: "فعل خاصية إقتباس النصوص المظللة" - enable_defer: "تمكين التأجيل لوضع علامة على المواضيع الغير مقروءة" - change: "غيّر" + enable_quoting: "تفعيل الرد باقتباس للنص المميز" + enable_defer: "تفعيل التأجيل لوضع علامة على الموضوعات كغير مقروءة" + change: "تغيير" featured_topic: "موضوع مميَّز" moderator: "‏%{user} مشرف في الموقع" admin: "‏%{user} مسؤول في الموقع" moderator_tooltip: "هذا المستخدم مشرف في الموقع" admin_tooltip: "هذا المستخدم مسؤول في الموقع" silenced_tooltip: "تم إسكات هذا المستخدم" - suspended_notice: "هذا المستخدم موقوف حتى تاريخ %{date}" - suspended_permanently: "هذا العضو موقوف." - suspended_reason: "السبب:" + suspended_notice: "هذا المستخدم معلَّق حتى تاريخ %{date}." + suspended_permanently: "هذا المستخدم معلَّق." + suspended_reason: "السبب: " github_profile: "Github" email_activity_summary: "خلاصة النشاط" mailing_list_mode: label: "وضع القائمة البريدية" - enabled: "فعّل وضع القائمة البريدية" + enabled: "تفعيل وضع القائمة البريدية" instructions: | يُلغي هذا الإعداد "ملخص النشاط".
    لا تشمل هذه الرسائل الإلكترونية الموضوعات والفئات المكتومة. - individual: "أرسل لي رسالة لكل منشور جديد" - individual_no_echo: "أرسل رسالة لكل منشور جديد عدا منشوراتي" - many_per_day: "أرسل لي رسالة لكل منشور جديد (تقريبا %{dailyEmailEstimate} يوميا)" - few_per_day: "أرسل لي رسالة لكل منشور جديد (تقريبا إثنتان يوميا)" - warning: "تم تمكين وضع القائمة البريدية. تم تجاوز إعدادات إشعار البريد الإلكتروني." + individual: "المراسلة عبر البريد الإلكتروني لكل منشور جديد" + individual_no_echo: "المراسلة عبر البريد الإلكتروني لكل منشور جديد عدا منشوراتي" + many_per_day: "المراسلة عبر البريد الإلكتروني لكل منشور جديد (%{dailyEmailEstimate} في اليوم تقريبًا)" + few_per_day: "المراسلة عبر البريد الإلكتروني لكل منشور جديد (اثنان في اليوم تقريبًا)" + warning: "تم تفعيل وضع القائمة البريدية. تم تجاوز الإعدادات المتعلقة بإشعارات البريد الإلكتروني." tag_settings: "الوسوم" - watched_tags: "مراقب" - watched_tags_instructions: "ستراقب آليا كل المواضيع التي تستخدم هذه الأوسمة. ستصلك إشعارات بالمنشورات و الموضوعات الجديدة، وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - tracked_tags: "متابع" - tracked_tags_instructions: "ستتابع آليا كل الموضوعات التي تستخدم هذه الأوسمة. وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - muted_tags: "مكتوم" - muted_tags_instructions: "لن يتم إشعارك بأي جديد بالموضوعات التي تستخدم هذه الأوسمة، ولن تظهر موضوعات هذه الوسوم في قائمة الموضوعات المنشورة مؤخراً." - watched_categories: "مراقب" - watched_categories_instructions: "ستراقب آليا كل موضوعات هذا القسم. ستصلك إشعارات بالمنشورات و الموضوعات الجديدة، وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." - tracked_categories: "متابع" - tracked_categories_instructions: "ستتابع آليا كل موضوعات هذا القسم. وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." + watched_tags: "المُراقَبة" + watched_tags_instructions: "ستراقب تلقائيًا كل الموضوعات التي تحمل هذه الوسوم. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." + tracked_tags: "المتتبَّعة" + tracked_tags_instructions: "ستتتبَّع تلقائيًا كل الموضوعات التي تحمل هذه الوسوم. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." + muted_tags: "المكتومة" + muted_tags_instructions: "لن تتلقى أي إشعارات أبدًا بخصوص الموضوعات الجديدة التي تحمل هذه الوسوم، ولن تظهر في قائمة أحدث الموضوعات." + watched_categories: "المُراقَبة" + watched_categories_instructions: "ستراقب تلقائيًا كل الموضوعات في هذه الفئات. وستتلقى إشعارات بكل المنشورات والموضوعات الجديدة، وسيظهر أيضا عدد المنشورات الجديدة بجانب الموضوع." + tracked_categories: "المتتبَّعة" + tracked_categories_instructions: "ستتتبَّع تلقائيًا كل الموضوعات في هذه الفئات. وسيظهر أيضًا عدد المنشورات الجديدة بجانب الموضوع." watched_first_post_categories: "مراقبة أول منشور" - watched_first_post_categories_instructions: "سيصلك إشعار بأول منشور في كل موضوع بهذا القسم." + watched_first_post_categories_instructions: "ستتلقى إشعارًا بأول منشور في كل موضوع جديد في هذه الفئات." watched_first_post_tags: "مراقبة أول منشور" - watched_first_post_tags_instructions: "سيصلك إشعار بأول منشور في كل موضوع يستخدم هذة الأوسمة." - muted_categories: "مكتوم" - muted_categories_instructions: "لن يتم إخطار بأي شيء يتعلق بالمواضيع الجديدة في هذه التصنيفات، ولن تظهر في التصيفات أو صفحة الأحدث." - muted_categories_instructions_dont_hide: "لن يتم إخطارك بأي شيء حول المواضيع الجديدة في هذه التصنيفات." - regular_categories: "عادي" + watched_first_post_tags_instructions: "ستتلقى إشعارًا بأول منشور في كل موضوع جديد يحمل هذه الوسوم." + muted_categories: "المكتومة" + muted_categories_instructions: "لن تتلقى أي إشعارات أبدًا بخصوص الموضوعات الجديدة في هذه الفئات، ولن تظهر في الفئات أو صفحات أحدث الموضوعات." + muted_categories_instructions_dont_hide: "لن تتلقى أي إشعارات أبدًا بخصوص الموضوعات الجديدة في هذه الفئات." + regular_categories: "العادية" regular_categories_instructions: "سترى هذه الفئات في قوائم الموضوعات \"الحديثة\" و\"الأكثر نشاطًا\"." - no_category_access: "كمشرف لديك صلاحيات وصول محدودة للأقسام, الحفظ معطل" - delete_account: "أحذف الحسابي" - delete_account_confirm: "أمتأكّد من حذف حسابك للأبد؟ هذا إجراء لا عودة فيه!" - deleted_yourself: "حُذف حسابك بنجاح." + no_category_access: "كمشرف لديك صلاحيات وصول محدودة للفئات، فالحفظ معطَّل." + delete_account: "حذف حسابي" + delete_account_confirm: "هل تريد بالتأكيد حذف حسابك للأبد؟ لا يمكن التراجع عن هذا الإجراء!" + deleted_yourself: "تم حذف حسابك بنجاح." delete_yourself_not_allowed: "يُرجى التواصل مع أحد أعضاء الطاقم إذا أردت حذف حسابك." unread_message_count: "الرسائل" admin_delete: "حذف" users: "المستخدمون" muted_users: "المكتومون" - muted_users_instructions: "منع جميع الإشعارات والرسائل الخاصة من هؤلاء المستخدمين." - allowed_pm_users: "سماح" - allowed_pm_users_instructions: "السماح فقط بالرسائل الخاصة لهؤلاء المستخدمين." - allow_private_messages_from_specific_users: "السماح لمستخدمين محددين فقط بإرسال رسائل خاصة لي" - ignored_users: "تم تجاهله" - ignored_users_instructions: "منع جميع المشاركات، الإشعارات والرسائل الخاصة من هؤلاء المستخدمين." + muted_users_instructions: "منع جميع الإشعارات والرسائل الشخصية من هؤلاء المستخدمين" + allowed_pm_users: "السماح" + allowed_pm_users_instructions: "السماح بالرسائل الشخصية من هؤلاء المستخدمين فقط" + allow_private_messages_from_specific_users: "السماح لمستخدمين محدَّدين فقط بإرسال الرسائل الشخصية إليَّ" + ignored_users: "التجاهل" + ignored_users_instructions: "منع جميع المشاركات والإشعارات والرسائل الشخصية من هؤلاء المستخدمين" tracked_topics_link: "إظهار" automatically_unpin_topics: "إلغاء تثبيت الموضوعات تلقائيًا عند وصولي إلى نهايتها." apps: "التطبيقات" revoke_access: "سحب الوصول" undo_revoke_access: "التراجع عن سحب الوصول" - api_approved: "موافق عليه:" + api_approved: "تمت الموافقة عليه:" api_last_used_at: "آخر استخدام في:" - theme: "الواجهة" - save_to_change_theme: 'سيتم تحديث المظهر بعد النقر على "%{save_text}"' + theme: "السمة" + save_to_change_theme: 'سيتم تحديث السمة بعد النقر على "%{save_text}"' home: "الصفحة الرئيسية المبدئية" - staged: "مهيأ" + staged: "مؤقت" staff_counters: - flags_given: "البلاغات المفيدة" - flagged_posts: "المنشورات المبلغ عنها " - deleted_posts: "المنشورات المحذوفة" - suspensions: "موقوفون" + flags_given: "علامات مفيدة" + flagged_posts: "منشورات عليها علامة" + deleted_posts: "منشورات محذوفة" + suspensions: "حالات التعليق" warnings_received: "تحذيرات" - rejected_posts: "المشاركات المرفوضة" + rejected_posts: "منشورات مرفوضة" messages: all: "الكلّ" inbox: "صندوق الوارد" @@ -1166,53 +1154,53 @@ ar: tags: "الوسوم" preferences_nav: account: "الحساب" - security: "الأمن" + security: "الأمان" profile: "الملف الشخصي" - emails: "البريد الإلكتروني" - notifications: "التنبيهات" + emails: "رسائل البريد الإلكتروني" + notifications: "الإشعارات" categories: "الفئات" - users: "الأعضاء" + users: "المستخدمون" tags: "الوسوم" - interface: "واجهة المستخدم" + interface: "الواجهة" apps: "التطبيقات" change_password: - success: "(تم إرسال الرسالة)" - in_progress: "(يتم إرسال الرسالة)" + success: "(تم إرسال رسالة البريد الإلكتروني)" + in_progress: "(جارٍ إرسال رسالة البريد الإلكتروني)" error: "(خطأ)" - emoji: "قفل الرموز التعبيرية" + emoji: "قفل الرمز التعبيري" action: "إرسال رسالة إلكترونية لإعادة تعيين كلمة المرور" set_password: "تعيين كلمة المرور" choose_new: "اختيار كلمة مرور جديدة" choose: "اختيار كلمة مرور" second_factor_backup: - title: "رموز النسخ الاحتياطي لـ الاستيثاق بعوامل عدة" + title: "الرموز الاحياطية للمصادقة الثنائية" regenerate: "أعِد التوليد" disable: "تعطيل" enable: "تفعيل" enable_long: "تفعيل الرموز الاحتياطية" manage: - zero: "أدِر الرموز الاحتياطية. لم تبقَ أيّ رموز احتياطية." - one: "أدِر الرموز الاحتياطية. بقيَ رمز احتياطي واحد." - two: "أدِر الرموز الاحتياطية. بقيَ رمزين احتياطيين." - few: "أدِر الرموز الاحتياطية. بقيت %{count} رموز احتياطية." - many: "أدِر الرموز الاحتياطية. بقيَ %{count} رمزًا احتياطيًا." - other: "أدِر الرموز الاحتياطية. بقيَ %{count} رمز احتياطي." + zero: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك %{count} رمز احتياطي." + one: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك رمز احتياطي واحد (%{count})." + two: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك رمزان احتياطيان (%{count})." + few: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك %{count} رموز احتياطية." + many: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك %{count} رمزًا احتياطيًا." + other: "يمكنك إدارة الرموز الاحتياطية. متبقٍ لديك %{count} رمز احتياطي." copy_to_clipboard: "انسخ إلى الحافظة" copy_to_clipboard_error: "حدث خطأ في نسخ البيانات إلى الحافظة" copied_to_clipboard: "تم النسخ إلى الحافظة" download_backup_codes: "تنزيل الرموز الاحتياطية" remaining_codes: - zero: "لم تبقَ أيّ رموز احتياطية." - one: "بقيَ رمز احتياطي واحد." - two: "بقيَ رمزين احتياطيين." - few: "بقيت %{count} رموز احتياطية." - many: "بقيَ %{count} رمزًا احتياطيًا." - other: "بقيَ %{count} رمز احتياطي." - use: "استخدم رمزًا احتياطيًا" - enable_prerequisites: "يجب عليك تمكين طريقة أساسية لـ (الاستيثاق بعوامل عدة) قبل إنشاء الرموز الاحتياطية." + zero: "متبقٍ لديك %{count} رمز احتياطي." + one: "متبقٍ لديك رمز احتياطي واحد (%{count})." + two: "متبقٍ لديك رمزان احتياطيان (%{count})." + few: "متبقٍ لديك %{count} رموز احتياطية." + many: "متبقٍ لديك %{count} رمزًا احتياطيًا." + other: "متبقٍ لديك %{count} رمز احتياطي." + use: "استخدام رمز احتياطي" + enable_prerequisites: "يجب عليك تفعيل طريقة أساسية للمصادقة الثنائية قبل إنشاء الرموز الاحتياطية." codes: - title: "تم إنشاء رموز النسخ الاحتياطي" - description: "يمكن استخدام كل من هذه الرموز الاحتياطية مرة واحدة فقط. احتفظ بها في مكان آمن وتستطيع الوصول إليها." + title: "تم إنشاء الرموز الاحتياطية" + description: "يمكن استخدام كل واحد من هذه الرموز الاحتياطية مرة واحدة فقط. احتفظ بها في مكان آمن وتستطيع الوصول إليه." second_factor: title: "المصادقة الثنائية" enable: "إدارة المصادقة الثنائية" @@ -1224,186 +1212,186 @@ ar: rate_limit: "يُرجى الانتظار قبل تجربة رمز مصادقة آخر." enable_description: | امسح رمز QR ضوئيًا في أحد التطبيقات المدعومة (Android وiOS) وأدخِل رمز المصادقة. - disable_description: "يرجى ادخال رمز التوثيق من التطبيق الخاص بك" + disable_description: "يُرجى إدخال رمز المصادقة من تطبيقك" show_key_description: "الإدخال يدويًا" short_description: | احمِ حسابك برموز أمان تُستخدَم لمرة واحدة. extended_description: | - تضيف المصادقة ثنائية العوامل أمانًا إضافيًا إلى حسابك من خلال طلب رمز مميز لمرة واحدة بالإضافة إلى كلمة المرور الخاصة بك. الرموز يمكن إنشاؤها على الروبوت و دائرة الرقابة الداخلية الأجهزة. + تضيف المصادقة الثنائية أمانًا إضافيًا إلى حسابك من خلال طلب رمز مميز لمرة واحدة بالإضافة إلى كلمة مرورك. يمكن إنشاء الرموز المميزة على الأجهزة التي تعمل بنظام التشغيل Android وiOS. oauth_enabled_warning: "يُرجى العلم بأنه سيتم تعطيل تسجيل الدخول بحسابات التواصل الاجتماعي بعد تفعيل المصادقة الثنائية على حسابك." - use: "استخدم تطبيق Authenticator" - enforced_notice: "أنت مطالب بتمكين المصادقة ذات العاملين قبل الوصول إلى هذا الموقع." + use: "استخدام تطبيق المصادقة" + enforced_notice: "يلزم تفعيل المصادقة الثنائية قبل الوصول إلى هذا الموقع." disable: "تعطيل" disable_confirm: "هل تريد بالتأكيد تعطيل جميع وسائل المصادقة الثنائية؟" save: "حفظ" - edit: "عدّل" - edit_title: "تحرير المصادقة" - edit_description: "اسم الموثق" + edit: "تعديل" + edit_title: "تعديل تطبيق المصادقة" + edit_description: "اسم تطبيق المصادقة" enable_security_key_description: | - عندما يكون لديك مفتاح أمان الأجهزة ، اضغط على زر التسجيل أدناه. + عندما يكون مفتاح الأمان المحمول لديك جاهزًا، اضغط على الزر "تسجيل" أدناه. totp: - title: "المصادقة المعتمدة على الرموز" - add: "إضافة برنامج مصادقة" - default_name: "مصادقتي" - name_and_code_required_error: "يجب عليك تقديم اسم ورمز من تطبيق المصادقة." + title: "تطبيقات المصادقة المعتمدة على الرموز المميزة" + add: "إضافة تطبيق مصادقة" + default_name: "تطبيق المصادقة الخاص بي" + name_and_code_required_error: "يجب عليك إدخال اسم، والرمز من تطبيق المصادقة." security_key: register: "تسجيل" title: "مفاتيح الأمان" add: "إضافة مفتاح أمان" default_name: "مفتاح الأمان الرئيسي" not_allowed_error: "انتهت مهلة عملية تسجيل مفتاح الأمان أو تم إلغاؤها." - already_added_error: "لقد سجلت بالفعل مفتاح الأمان هذا. ليس عليك تسجيله مرة أخرى." - edit: "تحرير مفتاح الأمان" - save: "احفظ" + already_added_error: "لقد سجَّلت مفتاح الأمان هذا بالفعل. ليس عليك تسجيله مرة أخرى." + edit: "تعديل مفتاح الأمان" + save: "حفظ" edit_description: "اسم مفتاح الأمان" - name_required_error: "يجب عليك تقديم اسم لمفتاح الأمان." + name_required_error: "يجب عليك إدخال اسم لمفتاح الأمان." change_about: - title: "تعديل عني" - error: "حدث عطل أثناء تغيير هذه القيمة." + title: "تغيير \"نبذة عني\"" + error: "حدث خطأ في أثناء تغيير هذه القيمة." change_username: title: "تغيير اسم المستخدم" confirm: "هل تريد بالتأكيد تغيير اسم المستخدم؟" taken: "عذرًا، اسم المستخدم هذا مسجَّل بالفعل." - invalid: "اسم المستخدم غير صالح. يمكنه احتواء احرف و ارقام انجليزية فحسب" + invalid: "اسم المستخدم غير صالح. يجب أن يتضمَّن أرقامًا وحروفًا فقط" add_email: - title: "أضف بريد إلكتروني" - add: "أضف" + title: "إضافة بريد إلكتروني" + add: "إضافة" change_email: - title: "غيّر البريد الإلكتروني" + title: "تغيير البريد الإلكتروني" taken: "عذرًا، هذا البريد الإلكتروني غير متوفر." - error: "حدث عطل أثناء تغيير البريد الإلكتروني. ربما هناك من يستخدم هذا العنوان بالفعل؟" - success: "لقد أرسلنا بريد إلكتروني إلى هذا العنوان. من فضلك اتّبع تعليمات التأكيد." - success_via_admin: "لقد أرسلنا بريدًا إلكترونيًا إلى هذا العنوان. سيحتاج المستخدم إلى اتباع تعليمات التأكيد الواردة في البريد الإلكتروني." - success_staff: "لقد قمنا بأرسال بريدا إلكتروني الى عنوانك الحالي, رجاء اتبع تعليمات التأكيد." + error: "حدث خطأ في أثناء تغيير البريد الإلكتروني. ربما يكون هذا العنوان مستخدمًا بالفعل؟" + success: "لقد أرسلنا رسالة إلكترونية إلى هذا العنوان. يُرجى اتباع تعليمات التأكيد." + success_via_admin: "لقد أرسلنا رسالة إلكترونية إلى هذا العنوان. سيحتاج المستخدم إلى اتباع تعليمات التأكيد الواردة في رسالة البريد الإلكتروني." + success_staff: "لقد أرسلنا رسالة إلكترونية إلى عنوانك الحالي. يُرجى اتباع تعليمات التأكيد." change_avatar: - title: "غيّر صورة الملفك الشخصي" - gravatar: "%{gravatarName}، استناداً إلى" - gravatar_title: "تغيير الصورة الرمزية الخاصة بك على موقع %{gravatarName}" + title: "تغيير صورة ملفك الشخصي" + gravatar: "%{gravatarName}، بناءً على" + gravatar_title: "تغيير صورتك الرمزية على موقع %{gravatarName} الإلكتروني" gravatar_failed: "لم نتمكن من العثور على %{gravatarName} بعنوان البريد الإلكتروني هذا." - refresh_gravatar_title: "قم بتحديث %{gravatarName}" + refresh_gravatar_title: "تحديث %{gravatarName}" letter_based: "صورة الملف الشخصي الافتراضية" - uploaded_avatar: "صورة مخصصة" - uploaded_avatar_empty: "اضافة صورة مخصصة" - upload_title: "ارفع صورتك " - image_is_not_a_square: "تحذير: لقد قصصنا صورتك، لأن عرضها وارتفاعها غير متساويان." + uploaded_avatar: "صورة مخصَّصة" + uploaded_avatar_empty: "إضافة صورة مخصَّصة" + upload_title: "تحميل صورتك" + image_is_not_a_square: "تحذير: لقد قصصنا صورتك؛ لأن عرضها وارتفاعها لم يكونا متساويين." change_profile_background: title: "رأس الملف الشخصي" - instructions: "سيتم وضع صورة الخلفية في المنتصف بعرض 590px" + instructions: "سيتم توسيط رؤوس الملفات الشخصية وسيكون عرضها الافتراضي 1110 بكسل." change_card_background: - title: "خلفية بطاقة العضو" - instructions: "سيتم وضع صورة الخلفية في المنتصف بعرض 590px" + title: "خلفية بطاقة المستخدم" + instructions: "سيتم توسيط صور الخلفية وسيكون عرضها الافتراضي 590 بكسل." change_featured_topic: - title: "الموضوع المميّز" - instructions: "سيكون رابط هذا الموضوع على بطاقة المستخدم والملف الشخصي الخاصين بك." + title: "الموضوع المميز" + instructions: "سيتم وضع رابط إلى هذا الموضوع على بطاقة المستخدم والملف الشخصي الخاصين بك." email: - title: "البريد الإلكتروني" - primary: "البريد الإلكتروني الأساسي" - secondary: "البريد الإلكتروني الثانوي" - primary_label: "الأساسي" - unconfirmed_label: "غير مؤكد" + title: "عنوان البريد الإلكتروني" + primary: "عنوان البريد الإلكتروني الرئيسي" + secondary: "عناوين البريد الإلكتروني الثانوية" + primary_label: "الرئيسي" + unconfirmed_label: "غير مؤكَّد" resend_label: "إعادة إرسال رسالة التأكيد" resending_label: "جارٍ الإرسال..." resent_label: "تم إرسال الرسالة" - update_email: "غيّر البريد الإلكتروني" - set_primary: "تعيين البريد الإلكتروني الأساسي" - destroy: "إزالة البريد الإلكتروني" + update_email: "تغيير عنوان البريد الإلكتروني" + set_primary: "تعيين عنوان البريد الإلكتروني الرئيسي" + destroy: "إزالة عنوان البريد الإلكتروني" add_email: "إضافة بريد إلكتروني بديل" - no_secondary: "لا توجد رسائل في البريد الإلكتروني الثانوي" + no_secondary: "لا توجد رسائل بريد إلكتروني ثانوية" instructions: "لا يظهر للعامة أبدًا." - admin_note: "ملاحظة: يشير تغيير المستخدم الإداري للبريد الإلكتروني لمستخدم آخر غير إداري إلى أن المستخدم قد فقد الوصول إلى حساب البريد الإلكتروني الأصلي، لذلك سيتم إرسال بريد إلكتروني لإعادة تعيين كلمة المرور إلى عنوانه الجديد. لن يتغير البريد الإلكتروني للمستخدم حتى يكمل عملية إعادة تعيين كلمة المرور." - ok: "سنرسل لك بريدا للتأكيد" + admin_note: "ملاحظة: يشير تغيير المستخدم المسؤول لعنوان البريد الإلكتروني لمستخدم آخر غير مسؤول إلى أن المستخدم قد فقد الوصول إلى حساب البريد الإلكتروني الأصلي؛ لذلك ستتم مراسلته عبر البريد الإلكتروني لإعادة تعيين كلمة المرور إلى عنوانه الجديد. ولن يتغير عنوان البريد الإلكتروني للمستخدم حتى يكمل عملية إعادة تعيين كلمة المرور." + ok: "سنُرسل إليك رسالة إلكترونية للتأكيد" required: "يُرجى إدخال عنوان بريد إلكتروني" - invalid: "من فضلك أدخل بريدا إلكترونيا صالحا" - authenticated: "تم توثيق بريدك الإلكتروني بواسطة %{provider}" - frequency_immediately: "سيتم ارسال رسالة الكترونية فورا في حال أنك لم تقرأ الرسائل السابقة التي كنا نرسلها لك." + invalid: "يُرجى إدخال عنوان بريد إلكتروني صالح" + authenticated: "تمت مصادقة عنوان بريدك الإلكتروني بواسطة %{provider}" + frequency_immediately: "سنُرسل إليك رسالة إلكترونية فورًا إذا كنت لم تقرأ الشيء الذي نُرسل إليك بشأنه." frequency: - zero: "سنُراسلك على بريدك فقط في حال مضت أقلّ من دقيقة على آخر زيارة لك للموقع." - one: "سنُراسلك على بريدك فقط في حال مضت دقيقة واحدة على آخر زيارة لك للموقع." - two: "سنُراسلك على بريدك فقط في حال مضت دقيقتين على آخر زيارة لك للموقع." - few: "سسنُراسلك على بريدك فقط في حال مضت %{count} دقائق على آخر زيارة لك للموقع." - many: "سسنُراسلك على بريدك فقط في حال مضت %{count} دقيقة على آخر زيارة لك للموقع." - other: "سسنُراسلك على بريدك فقط في حال مضت %{count} دقيقة على آخر زيارة لك للموقع." + zero: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي %{count} دقيقة على آخر زيارة لك للموقع." + one: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي دقيقة واحدة (%{count}) على آخر زيارة لك للموقع." + two: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي دقيقتين (%{count}) على آخر زيارة لك للموقع." + few: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي %{count} دقائق على آخر زيارة لك للموقع." + many: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي %{count} دقيقة على آخر زيارة لك للموقع." + other: "لن نراسلك عبر البريد الإلكتروني إلا في حال مُضي %{count} دقيقة على آخر زيارة لك للموقع." associated_accounts: title: "الحسابات المرتبطة" - connect: "الاتصال" - revoke: "تعطيل" - cancel: "ألغِ" - not_connected: "(غير متصل)" - confirm_modal_title: "قم بتوصيل حساب %{provider}" + connect: "ربط" + revoke: "إبطال" + cancel: "إلغاء" + not_connected: "(غير مرتبط)" + confirm_modal_title: "ربط حساب %{provider}" confirm_description: - account_specific: "سيتم استخدام حسابك %{provider} '%{account_description}' للمصادقة." - generic: "سيتم استخدام حساب %{provider} الخاص بك للمصادقة." + account_specific: "سيتم استخدام حسابك على %{provider} \"%{account_description}\" للمصادقة." + generic: "سيتم استخدام حسابك على %{provider} للمصادقة." name: title: "الاسم" instructions: "اسمك بالكامل (اختياري)" instructions_required: "اسمك بالكامل" required: "يُرجى إدخال اسم" - too_short: "اسمك قصير جدا" - ok: "يبدو اسمك جيدا" + too_short: "اسمك قصير جدًا" + ok: "اسمك يبدو جيدًا" username: title: "اسم المستخدم" - instructions: "باللغة الإنجليزية و دون مسافات و قصير و غير مكرر" + instructions: "مميز، وبلا مسافات، وقصير" short_instructions: "يمكن للأشخاص الإشارة إليك هكذا: ⁨@%{username}⁩" - available: "اسم المستخدم متاح" - not_available: "غير متاح. جرّب %{suggestion} ؟" + available: "اسم المستخدم متوفر" + not_available: "غير متوفر. هل تريد تجربة %{suggestion}؟" not_available_no_suggestion: "غير متاح" - too_short: "اسم المستخدم قصير جدًّا" - too_long: "اسم المستخدم طويل جدًّا" - checking: "يتم التاكد من توفر اسم المستخدم..." - prefilled: "البريد الالكتروني مطابق لـ اسم المستخدم المسّجل." + too_short: "اسم المستخدم قصير جدًا" + too_long: "اسم المستخدم طويل جدًا" + checking: "جارٍ التحقُّق من توفُّر اسم المستخدم..." + prefilled: "هذا البريد الالكتروني مطابق لاسم المستخدم المسجَّل هذا" required: "يُرجى إدخال اسم مستخدم" locale: title: "لغة الواجهة" - instructions: "لغة واجهة المستخدم. ستتغيّر عندما تحدث الصفحة." + instructions: "لغة واجهة المستخدم. ستتغير عند تحديث الصفحة." default: "(الافتراضية)" any: "أي" password_confirmation: title: "تكرار كلمة المرور" invite_code: title: "رمز الدعوة" - instructions: "تسجيل الحساب يتطلب رمز الدعوة" + instructions: "يتطلب تسجيل الحساب رمز دعوة" auth_tokens: title: "الأجهزة المستخدمة حديثًا" - details: "تفاصيل" + details: "التفاصيل" log_out_all: "تسجيل الخروج من كل الأجهزة" not_you: "ليس أنت؟" show_all: "عرض الكل (%{count})" show_few: "عرض أقل" was_this_you: "هل كان هذا أنت؟" - was_this_you_description: "إذا لم تكن أنت، ننصحك بتغيير كلمة المرور وتسجيل الخروج في كل مكان." + was_this_you_description: "إذا لم يكن ذلك أنت، فإننا ننصحك بتغيير كلمة المرور وتسجيل الخروج على جميع الأجهزة." browser_and_device: "‏%{browser} على %{device}" secure_account: "تأمين حسابي" - latest_post: "آخر مرة قمت بنشر…" + latest_post: "المرة الأخيرة التي نشرت فيها…" device_location: '%{device} ‏–‏ %{location}' browser_active: '‏%{browser} | نشط الآن' browser_last_seen: "‏%{browser} |‏ %{date}" last_posted: "آخر مشاركة" - last_emailed: "اخر ما تم ارساله" - last_seen: "كان هنا" + last_emailed: "آخر رسالة عبر البريد الإلكتروني" + last_seen: "آخر ظهور" created: "تاريخ الانضمام" log_out: "تسجيل الخروج" location: "الموقع الجغرافي" - website: "الموقع الكتروني" + website: "الموقع الإلكتروني" email_settings: "البريد الإلكتروني" hide_profile_and_presence: "ميزتا إخفاء ملفي الشخصي العام وإخفاء الظهور" enable_physical_keyboard: "تفعيل دعم لوحة المفاتيح الفعلية على iPad" text_size: title: "حجم النص" - smallest: "أصغر" + smallest: "الأصغر" smaller: "أصغر" normal: "عادي" larger: "أكبر" largest: "الأكبر" title_count_mode: - title: "يعرض عنوان صفحة الخلفية عدد من:" + title: "يعرض عنوان صفحة الخلفية عدد:" notifications: "الإشعارات الجديدة" contextual: "محتوى الصفحة الجديد" like_notification_frequency: - title: "أرسل إشعارا عند الإعجاب" - always: "دوما" - first_time_and_daily: "أول إعجاب بالمنشور يوميا" - first_time: "أول إعجاب بالمنشور" - never: "أبدا" + title: "إرسال إشعار عند تسجيل الإعجاب" + always: "دائمًا" + first_time_and_daily: "أول مرة إعجاب بالمنشور ويوميًا" + first_time: "أول مرة إعجاب بالمنشور" + never: "أبدًا" email_previous_replies: title: "تضمين الردود السابقة في أسفل الرسائل الإلكترونية" unless_emailed: "ما لم يتم إرسالها مسبقًا" @@ -1418,13 +1406,13 @@ ar: every_month: "كلّ شهر" every_six_months: "كل ستة أشهر" email_level: - title: "إرسال رسالة إلكترونية إليَّ عندما يقتبس مني شخص ما، أو يرد على منشوري، أو يذكر اسم المستخدم @username الخاص بي، أو يدعوني إلى موضوع" + title: "مراسلتي عبر البريد الإلكتروني عندما يقتبس مني شخص ما، أو يرد على منشوري، أو يشير إلى اسم المستخدم @username الخاص بي، أو يدعوني إلى موضوع" always: "دائمًا" only_when_away: "عندما أكون بعيدًا فقط" never: "أبدًا" - email_messages_level: "أرسل إلي رسالة إلكترونية عندما يبعث أحدهم رسالة إلي" - include_tl0_in_digests: "ارفق محتوى الاعضاء الجدد في رسائل الملخص" - email_in_reply_to: "ارفق مقتطف من الرد على الموضوع في رسائل البريد الالكتروني" + email_messages_level: "مراسلتي عبر البريد الإلكتروني عند إرسال رسالة إليَّ" + include_tl0_in_digests: "تضمين المحتوى من المستخدمين الجُدد في الرسائل الإلكترونية التلخيصية" + email_in_reply_to: "تضمين مقتطف من الرد على المنشور في الرسائل الإلكترونية" other_settings: "أخرى" categories_settings: "الفئات" new_topic_duration: @@ -1435,50 +1423,50 @@ ar: after_2_days: "تم إنشاؤها في اليومين الماضيين" after_1_week: "تم إنشاؤها في الأسبوع الماضي" after_2_weeks: "تم إنشاؤها في الأسبوعين الماضيين" - auto_track_topics: "تابع آليا الموضوعات التي افتحها" + auto_track_topics: "تتبُّع الموضوعات التي أدخلها تلقائيًا" auto_track_options: never: "أبدًا" - immediately: "حالًا" + immediately: "فورًا" after_30_seconds: "بعد 30 ثانية" after_1_minute: "بعد دقيقة واحدة" after_2_minutes: "بعد دقيقتين" - after_3_minutes: "بعد ثلاث دقائق" - after_4_minutes: "بعد اربع دقائق" - after_5_minutes: "بعد خمس دقائق" + after_3_minutes: "بعد 3 دقائق" + after_4_minutes: "بعد 4 دقائق" + after_5_minutes: "بعد 5 دقائق" after_10_minutes: "بعد 10 دقائق" - notification_level_when_replying: "إذا نشرت في موضوع ما، اجعله" + notification_level_when_replying: "عندما أنشر في موضوع، تعيين ذلك الموضوع إلى" invited: title: "الدعوات" pending_tab: "قيد الانتظار" - pending_tab_with_count: "معلق (%{count})" - redeemed_tab: "محررة" - redeemed_tab_with_count: "(%{count}) محررة" - invited_via: "دعوة" + pending_tab_with_count: "قيد الانتظار (%{count})" + redeemed_tab: "تم استردادها" + redeemed_tab_with_count: "تم استردادها (%{count})" + invited_via: "الدعوة" groups: "المجموعات" - topic: "موضوع" - expires_at: "تنتهي" - edit: "عدّل" - remove: "أزِل" - reinvited: "اعادة ارسال الدعوة" + topic: "الموضوع" + expires_at: "وقت الانتهاء" + edit: "تعديل" + remove: "إزالة" + reinvited: "تمت إعادة إرسال الدعوة" search: "اكتب للبحث في الدعوات..." - user: "المستخدمين المدعويين" + user: "المستخدم المدعو" none: "لا توجد دعوات لعرضها." truncated: - zero: "ما من دعوات لعرضها." - one: "ترى الآن أوّل دعوة فقط." - two: "ترى الآن أوّل دعوتين فقط." - few: "ترى الآن أوّل {count} دعوات فقط." - many: "ترى الآن أوّل {count} دعوة فقط." - other: "ترى الآن أوّل {count} دعوة فقط." - redeemed: "دعوات محررة" - redeemed_at: "محررة" - pending: "دعوات قيد الإنتضار" - topics_entered: " موضوعات شُوهِدت" - posts_read_count: "منشورات قرات" - expired: "الدعوة انتهت صلاحيتها " - remove_all: "أزِل الدعوات المنقضية" - removed_all: "أُزيلت كلّ الدعوات المنقضية!" - remove_all_confirm: "أمتأكّد من إزالة كلّ الدعوات المنقضية؟" + zero: "يتم عرض أول %{count} دعوة." + one: "يتم عرض أول دعوة (%{count})." + two: "يتم عرض أول دعوتين (%{count})." + few: "يتم عرض أول %{count} دعوات." + many: "يتم عرض أول %{count} دعوة." + other: "يتم عرض أول %{count} دعوة." + redeemed: "الدعوات المستردة" + redeemed_at: "وقت الاسترداد" + pending: "الدعوات قيد الانتظار" + topics_entered: "الموضوعات المعروضة" + posts_read_count: "المنشورات المقروءة" + expired: "لقد انتهت مدة هذه الدعوة." + remove_all: "إزالة الدعوات المنتهية" + removed_all: "تمت إزالة جميع الدعوات المنتهية!" + remove_all_confirm: "هل تريد بالتأكيد إزالة جميع الدعوات المنتهية؟" reinvite_all_confirm: "هل تريد بالتأكيد إعادة إرسال كل الدعوات؟" time_read: "وقت القراءة" days_visited: "أيام الزيارة" @@ -1486,19 +1474,19 @@ ar: create: "دعوة" generate_link: "إنشاء رابط دعوة" link_generated: "إليك رابط الدعوة!" - valid_for: "رابط الدعوة صالح للبريد الإلكترونيّ هذا فقط: %{email}" - single_user: "دعوة عن طريق البريد الإلكتروني" - multiple_user: "دعوة عن طريق الرابط" + valid_for: "رابط الدعوة صالح لعنوان البريد الإلكتروني هذا فقط: %{email}" + single_user: "الدعوة بالبريد الإلكتروني" + multiple_user: "الدعوة برابط" invite_link: title: "رابط الدعوة" - success: "وُلّد رابط الدعوة بنجاح!" + success: "تم إنشاء رابط الدعوة بنجاح!" error: "حدث خطأ في أثناء إنشاء رابط الدعوة" max_redemptions_allowed_label: "كم شخصًا يمكنه التسجيل باستخدام هذا الرابط؟" expires_at: "متى تنتهي صلاحية رابط الدعوة هذا؟" bulk_invite: none: "لا توجد دعوات لعرضها على هذه الصفحة." - text: "دعوة مجمعة" - error: "معذرةً، يجب أن يكون الملف بنسق CSV." + text: "دعوة جماعية" + error: "عذرًا، يجب أن يكون الملف بتنسيق CSV." password: title: "كلمة السر" too_short: "كلمة مرورك قصيرة جدًا." @@ -1506,41 +1494,41 @@ ar: same_as_username: "كلمة السر تُطابق اسم المستخدم." same_as_email: "كلمة السر تُطابق عنوان البريد الإلكتروني." ok: "تبدو كلمة مرورك جيدة." - instructions: "علي الأقل %{count} حرفا" + instructions: "يجب ألا يقل عن %{count} حرف" required: "يُرجى إدخال كلمة المرور" summary: title: "الملخص" stats: "الإحصاءات" time_read: "وقت القراءة" - recent_time_read: "وقت القراءة الحديث" + recent_time_read: "وقت آخر قراءة" topic_count: - zero: "المواضيع المفتوحة" - one: "المواضيع المفتوحة" - two: "المواضيع المفتوحة" - few: "المواضيع المفتوحة" - many: "المواضيع المفتوحة" - other: "المواضيع المفتوحة" + zero: "موضوع تم إنشاؤه" + one: "موضوع واحد تم إنشاؤه" + two: "موضوعان تم إنشاؤهما" + few: "موضوعات تم إنشاؤها" + many: "موضوعات تم إنشاؤها" + other: "موضوعات تم إنشاؤها" post_count: - zero: "المشاركات المنشورة" - one: "المشاركات المنشورة" - two: "المشاركات المنشورة" - few: "المشاركات المنشورة" - many: "المشاركات المنشورة" - other: "المشاركات المنشورة" + zero: "منشور تم إنشاؤه" + one: "منشور واحد تم إنشاؤه" + two: "منشوران تم إنشاؤهما" + few: "منشورات تم إنشاؤها" + many: "منشورات تم إنشاؤها" + other: "منشورات تم إنشاؤها" likes_given: - zero: "المُهداة" - one: "المُهداة" - two: "المُهداة" - few: "المُهداة" - many: "المُهداة" - other: "المُهداة" + zero: "المعطاة" + one: "المعطاة" + two: "المعطاة" + few: "المعطاة" + many: "المعطاة" + other: "المعطاة" likes_received: - zero: "المُستلمة" - one: "المُستلمة" - two: "المُستلمة" - few: "المُستلمة" - many: "المُستلمة" - other: "المُستلمة" + zero: "المتلقاة" + one: "المتلقاة" + two: "المتلقاة" + few: "المتلقاة" + many: "المتلقاة" + other: "المتلقاة" days_visited: zero: "أيام الزيارة" one: "أيام الزيارة" @@ -1563,12 +1551,12 @@ ar: many: "المشاركات المقروءة" other: "المشاركات المقروءة" bookmark_count: - zero: "العلامات" - one: "العلامات" - two: "العلامات" - few: "العلامات" - many: "العلامات" - other: "العلامات" + zero: "إشارة مرجعية" + one: "إشارة مرجعية واحدة" + two: "إشارتان مرجعيتان" + few: "إشارات مرجعية" + many: "إشارات مرجعية" + other: "إشارات مرجعية" top_replies: "أهم الردود" no_replies: "لا توجد ردود بعد." more_replies: "المزيد من الردود" @@ -1580,127 +1568,127 @@ ar: more_badges: "المزيد من الشارات" top_links: "أهم الروابط" no_links: "لا توجد روابط بعد." - most_liked_by: "أكثر المعجبين به" - most_liked_users: "أكثر من أعجبه" - most_replied_to_users: "أكثر من رد عليه" + most_liked_by: "الأكثر تسجيلًا للإعجاب" + most_liked_users: "الأكثر تلقيًا للإعجاب" + most_replied_to_users: "الأكثر تلقيًا للردود" no_likes: "لا توجد مرات إعجاب بعد." top_categories: "أهم الفئات" topics: "الموضوعات" replies: "الردود" ip_address: - title: "عنوان IP الأخير" + title: "آخر عنوان IP" registration_ip_address: - title: "عنوان IP التسجيل" + title: "عنوان IP للتسجيل" avatar: title: "صورة الملف الشخصي" header_title: "الملف الشخصي والرسائل والإشارات المرجعية والتفضيلات" title: - title: "عنوان" - none: "(لا شيء)" + title: "العنوان" + none: "(لا يوجد)" primary_group: - title: "المجموعة الأساسية" - none: "(لا شيء)" + title: "المجموعة الرئيسية" + none: "(لا يوجد)" filters: all: "الكل" stream: - posted_by: "نُشرت بواسطة" - sent_by: " أرسلت بواسطة" + posted_by: "تم النشر بواسطة" + sent_by: "تم الإرسال بواسطة" private_message: "رسالة" the_topic: "الموضوع" loading: "جارٍ التحميل..." errors: - prev_page: "اثناء محاولة التحميل" + prev_page: "في أثناء محاولة التحميل" reasons: network: "خطأ في الشبكة" server: "خطأ في الخادم" - forbidden: "الوصول غير مصرح" + forbidden: "تم رفض الوصول" unknown: "خطأ" - not_found: "الصفحة غير متوفرة" + not_found: "الصفحة غير موجودة" desc: - network: "من فضلك تحقق من اتصالك." - network_fixed: "أنت الآن متصل بالانترنت" + network: "يُرجى التحقُّق من اتصالك." + network_fixed: "تمت استعادة الاتصال." server: "رمز الخطأ: %{status}" - forbidden: "ليس مسموحًا لك عرض هذا." + forbidden: "غير مسموح لك بعرض هذا." not_found: "عذرًا، حاول التطبيق تحميل عنوان URL غير موجود." - unknown: "حدث خطب ما." + unknown: "حدث خطأ ما." buttons: back: "الرجوع" - again: "أعد المحاولة" - fixed: "حمل الصفحة" + again: "إعادة المحاولة" + fixed: "تحميل الصفحة" modal: - close: "أغلق" + close: "إغلاق" dismiss_error: "تجاهل الخطأ" - close: "اغلق" - logout: "لقد تم تسجيل خروجك." + close: "إغلاق" + logout: "تم تسجيل خروجك." refresh: "تحديث" home: "الصفحة الرئيسية" read_only_mode: enabled: "هذا الموقع في وضع القراءة فقط. نأمل أن تواصل تصفُّحه، لكن الرد وتسجيل الإعجاب وغيرهما من الإجراءات ستكون معطَّلة حاليًا." - login_disabled: "تسجيل الدخول معطل في حال كان الموقع في وضع القراءة فقط." + login_disabled: "يكون تسجيل الدخول معطلًا في حال كان الموقع في وضع القراءة فقط." logout_disabled: "يتم تعطيل تسجيل الخروج عندما يكون الموقع في وضع القراءة فقط." too_few_topics_and_posts_notice_MF: >- - لنبدأ المناقشة ! هناك {currentTopics, plural, zero {} one {هو # الموضوع} two { # موضوعا} few { # موضوعا} many { # موضوعا} other { # موضوعا}} و {currentPosts, plural, one {# آخر} two {# وظيفة} few {# وظيفة} many {# وظيفة} other {# وظيفة}}. يحتاج الزوار إلى المزيد من القراءة والرد عليها - نوصي على الأقل {requiredTopics, plural, one {# الموضوع} two {# موضوعا} few {# موضوعا} many {# موضوعا} other {# موضوعا}} و {requiredPosts, plural, one {# آخر} two {# وظيفة} few {# وظيفة} many {# وظيفة} other {# وظيفة}}. فقط الموظفين يمكنهم رؤية هذه الرسالة. + لنبدأ المناقشة! هناك {currentTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}} و{currentPosts, plural, zero {# منشور} one {منشور واحد (#)} two {منشوران (#)} few {# منشورات} many {# منشورًا} other {# منشور}}. يحتاج الزوار إلى المزيد ليقرؤوه ويردوا عليه. إننا نقترح {requiredTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}} و{currentPosts, plural, zero {# منشور} one {منشور واحد (#)} two {منشوران (#)} few {# منشورات} many {# منشورًا} other {# منشور}}. يمكن لطاقم العمل فقط رؤية هذه الرسالة. too_few_topics_notice_MF: >- - هيًا بنا لنبدأ نقاشًا! هناك حاليًا {currentTopics, plural, zero {} one {موضوع واحد} two {موضوعين اثنين} few {# مواضيع} many {# موضوعًا} other {# موضوع}}. يحتاج الزوّار إلى أكثر لقراءته والردّ عليه، ولذلك ننصح بوجود {requiredTopics, plural, one {موضوع واحد} two {موضوعين اثنين} few {# مواضيع} many {# موضوعًا} other {# موضوع}} على الأقل. يرى طاقم الموقع فقط هذه الرسالة. + لنبدأ المناقشة! هناك {currentTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}}. يحتاج الزوار إلى المزيد ليقرؤوه ويردوا عليه. إننا نقترح {requiredTopics, plural, zero {# موضوع} one {موضوع واحد (#)} two {موضوعان (#)} few {# موضوعات} many {# موضوعًا} other {# موضوع}}. يمكن لطاقم العمل فقط رؤية هذه الرسالة. too_few_posts_notice_MF: >- - هيًا بنا لنبدأ نقاشًا! هناك حاليًا {currentPosts, plural, zero {} one {مشاركة واحدة} two {مشاركتين اثنتين} few {# مشاركات} many {# مشاركةً} other {# مشاركة}}. يحتاج الزوّار إلى أكثر لقراءتها والردّ عليها، ولذلك ننصح بوجود {requiredPosts, plural, one {مشاركة واحدة} two {مشاركتين اثنتين} few {# مشاركات} many {# مشاركةً} other {# مشاركة}} على الأقل. يرى طاقم الموقع فقط هذه الرسالة. + لنبدأ المناقشة! هناك {currentPosts, plural, zero {# منشور} one {منشور واحد (#)} two {منشوران (#)} few {# منشورات} many {# منشورًا} other {# منشور}}. يمكن لطاقم العمل فقط رؤية هذه الرسالة. logs_error_rate_notice: - reached_hour_MF: "{relativeAge} - {rate, plural, zero {} one {# خطأ / ساعة} two {# أخطاء / ساعة} few {# أخطاء / ساعة} many {# أخطاء / ساعة} other {# أخطاء / ساعة}} وصلت إلى حد إعداد الموقع {limit, plural, one {# خطأ / ساعة} two {# أخطاء / ساعة} few {# أخطاء / ساعة} many {# أخطاء / ساعة} other {# أخطاء / ساعة}}." - reached_minute_MF: "{relativeAge} - {rate, plural, zero {} one {# خطأ / دقيقة} two {# أخطاء / دقيقة} few {# أخطاء / دقيقة} many {# أخطاء / دقيقة} other {# أخطاء / دقيقة}} وصلت إلى حد إعداد الموقع {limit, plural, one {# خطأ / دقيقة} two {# أخطاء / دقيقة} few {# أخطاء / دقيقة} many {# أخطاء / دقيقة} other {# أخطاء / دقيقة}}." - exceeded_hour_MF: "{relativeAge} - {rate, plural, zero {} one {# خطأ / ساعة} two {# أخطاء / ساعة} few {# أخطاء / ساعة} many {# أخطاء / ساعة} other {# أخطاء / ساعة}} تجاوز حد إعداد الموقع {limit, plural, one {# خطأ / ساعة} two {# أخطاء / ساعة} few {# أخطاء / ساعة} many {# أخطاء / ساعة} other {# أخطاء / ساعة}}." - exceeded_minute_MF: "{relativeAge} - {rate, plural, zero {} one {# خطأ / دقيقة} two {# أخطاء / دقيقة} few {# أخطاء / دقيقة} many {# أخطاء / دقيقة} other {# أخطاء / دقيقة}} تجاوز حد إعداد الموقع {limit, plural, one {# خطأ / دقيقة} two {# أخطاء / دقيقة} few {# أخطاء / دقيقة} many {# أخطاء / دقيقة} other {# أخطاء / دقيقة}}." - learn_more: "اطّلع على المزيد..." + reached_hour_MF: "{relativeAge} – لقد بلغ معدل الخطأ {rate, plural, zero {# خطأ/الساعة} one {خطأ واحد (#)/الساعة} two {خطآن (#)/الساعة} few {# أخطاء/الساعة} many {# خطأ/الساعة} other {# خطأ/الساعة}} حد إعدادات الموقع البالغ {limit, plural, zero {# خطأ/الساعة} one {خطأ واحد (#)/الساعة} two {خطآن (#)/الساعة} few {# أخطاء/الساعة} many {# خطأ/الساعة} other {# خطأ/الساعة}}." + reached_minute_MF: "{relativeAge} – لقد بلغ معدل الخطأ {rate, plural, zero {# خطأ/الدقيقة} one {خطأ واحد (#)/الدقيقة} two {خطآن (#)/الدقيقة} few {# أخطاء/الدقيقة} many {# خطأ/الدقيقة} other {# خطأ/الدقيقة}} حد إعدادات الموقع البالغ {limit, plural, zero {# خطأ/الدقيقة} one {خطأ واحد (#)/الدقيقة} two {خطآن (#)/الدقيقة} few {# أخطاء/الدقيقة} many {# خطأ/الدقيقة} other {# خطأ/الدقيقة}}." + exceeded_hour_MF: "{relativeAge} – لقد تجاوز معدل الخطأ {rate, plural, zero {# خطأ/الساعة} one {خطأ واحد (#)/الساعة} two {خطآن (#)/الساعة} few {# أخطاء/الساعة} many {# خطأ/الساعة} other {# خطأ/الساعة}} حد إعدادات الموقع البالغ {limit, plural, zero {# خطأ/الساعة} one {خطأ واحد (#)/الساعة} two {خطآن (#)/الساعة} few {# أخطاء/الساعة} many {# خطأ/الساعة} other {# خطأ/الساعة}}." + exceeded_minute_MF: "{relativeAge} – لقد تجاوز معدل الخطأ {rate, plural, zero {# خطأ/الدقيقة} one {خطأ واحد (#)/الدقيقة} two {خطآن (#)/الدقيقة} few {# أخطاء/الدقيقة} many {# خطأ/الدقيقة} other {# خطأ/الدقيقة}} حد إعدادات الموقع البالغ {limit, plural, zero {# خطأ/الدقيقة} one {خطأ واحد (#)/الدقيقة} two {خطآن (#)/الدقيقة} few {# أخطاء/الدقيقة} many {# خطأ/الدقيقة} other {# خطأ/الدقيقة}}." + learn_more: "معرفة المزيد..." first_post: أول منشور mute: كتم unmute: إلغاء الكتم - last_post: آخر مشاركة + last_post: آخر منشور local_time: "التوقيت المحلي" - time_read: المقروءة - time_read_recently: "حديثًا %{time_read}" + time_read: وقت القراءة + time_read_recently: "%{time_read} حديثًا" time_read_tooltip: "إجمالي وقت القراءة هو %{time_read}" - time_read_recently_tooltip: "إجمالي وقت القراءة هو %{time_read} (منها %{recent_time_read} خلال الستّين يومًا الماضية)" + time_read_recently_tooltip: "إجمالي وقت القراءة هو %{time_read} (%{recent_time_read} خلال 60 يومًا الماضية)" last_reply_lowercase: آخر رد replies_lowercase: - zero: الردود - one: الردود - two: الردود - few: الردود - many: الردود - other: الردود + zero: رد + one: رد واحد + two: ردَّان + few: ردود + many: ردود + other: ردود signup_cta: - sign_up: "إنشاء حساب" - hide_session: "ذكرني غدا" - hide_forever: "لا شكرا" - intro: "مرحبا! يبدو أنك تستمتع بالمناقشة، لكنك لم تسجل للحصول على حساب جديد حتى الآن." + sign_up: "الاشتراك" + hide_session: "تذكيري غدًا" + hide_forever: "لا، شكرًا" + intro: "مرحبًا! يبدو أنك تستمتع بالمناقشة، لكنك لم تشترك للحصول على حساب حتى الآن." value_prop: "عند إنشاء حساب، فإننا نتذكَّر ما قرأته بالضبط؛ حتى تتمكن دائمًا من العودة والمتابعة من حيث توقفت. ستتلقَّى أيضًا الإشعارات هنا وعبر البريد الإلكتروني كلما ردَّ أحد عليك. ويمكنك تسجيل إعجابك بالمنشورات لمشاركة مشاعر الود. :heartpulse:" summary: - enabled_description: "أنت تطالع ملخّصًا لهذا الموضوع، أي أكثر المنشورات الجديرة بالاهتمام حسب نظرة المجتمع." - enable: "لخّص هذا الموضوع" + enabled_description: "أنت تعرض ملخصًا لهذا الموضوع: المنشورات الأكثر إثارة للاهتمام وفقًا للمجتمع." + enable: "تلخيص هذا الموضوع" disable: "عرض كل المنشورات" deleted_filter: - enabled_description: "في هذا الموضوع مشاركات محذوفة قد أُخفيت." - disabled_description: "المشاركات المحذوفة في الموضوع معروضة." - enable: "أخفِ المشاركات المحذوفة" + enabled_description: "يتتضمَّن هذا الموضوع منشورات محذوفة، والتي تم إخفاؤها." + disabled_description: "يتم عرض المنشورات المحذوفة في الموضوع." + enable: "إخفاء المنشورات المحذوفة" disable: "عرض المنشورات المحذوفة" private_message_info: title: "رسالة" invite: "دعوة الآخرين..." edit: "إضافة أو إزالة..." remove: "إزالة..." - add: "أضِف..." - leave_message: "أمتأكّد من ترك هذه الرسالة؟" - remove_allowed_user: "أمتأكّد من إزالة %{name} من هذه الرّسالة؟" - remove_allowed_group: "أمتأكّد من إزالة %{name} من هذه الرّسالة؟" + add: "إضافة..." + leave_message: "هل تريد حقًا مغادرة هذه الرسالة؟" + remove_allowed_user: "هل تريد حقًا إزالة %{name} من هذه الرسالة؟" + remove_allowed_group: "هل تريد حقًا إزالة %{name} من هذه الرسالة؟" email: "البريد الإلكتروني" username: "اسم المستخدم" - last_seen: "كان هنا" - created: "انشات" - created_lowercase: "انشات" + last_seen: "آخر ظهور" + created: "تاريخ الإنشاء" + created_lowercase: "تاريخ الإنشاء" trust_level: "مستوى الثقة" - search_hint: "اسم المستخدم أو البريد إلكتروني أو عنوان الـ IP" + search_hint: "اسم المستخدم أو البريد إلكتروني أو عنوان IP" create_account: header_title: "مرحبًا!" - disclaimer: "بالتسجيل، أنت توافق على سياسة الخصوصية و شروط الخدمة." + disclaimer: "يشير التسجيل إلى موافقتك على سياسة الخصوصية وشروط الخدمة." failed: "حدث خطأ ما. قد يكون هذا البريد الإلكتروني مسجلًا بالفعل. جرِّب رابط نسيان كلمة المرور" forgot_password: title: "إعادة تعيين كلمة المرور" @@ -1709,66 +1697,66 @@ ar: reset: "إعادة تعين كلمة المرور" complete_username: "إذا تطابق أحد الحسابات مع اسم المستخدم %{username}، فستتلقى سريعًا رسالة إلكترونية تتضمَّن تعليمات عن كيفية إعادة تعيين كلمة المرور." complete_email: "إذا تطابق أحد الحسابات مع %{email}، فستتلقى سريعًا رسالة إلكترونية تتضمَّن تعليمات عن كيفية إعادة تعيين كلمة المرور." - complete_username_found: "وجدنا حسابًا يُطابق اسم المستخدم %{username}. ستستلم قريبًا بريدًا بإرشادات آلية تصفير كلمة السر." - complete_email_found: "وجدنا حسابًا يُطابق %{email}. ستستلم قريبًا بريدًا بإرشادات آلية تصفير كلمة السر." - complete_username_not_found: "لا يوجد حساب يطابق اسم المستخدم %{username}" - complete_email_not_found: "لا حساب يطابق %{email}" - help: "ألم يصل البريد بعد؟ تفحّص مجلد السخام أولًا.

    ألست متأكّدًا من العنوان الذي استعملته؟ أدخِل عنوان البريد الإلكتروني وسنُعلمك لو كان موجودًا هنا.

    إن فقدت إمكانية الدخول على عنوان البريد المرتبط بحسابك، فمن فضلك راسِل طاقمنا الجاهز دومًا لمساعدتك.

    " - button_ok: "حسنا" - button_help: "مساعدة" + complete_username_found: "لقد عثرنا على حساب مطابق لاسم المستخدم %{username}. ومن المفترض أن تتلقى رسالة إلكترونية بإرشادات إعادة تعيين كلمة المرور قريبًا." + complete_email_found: "لقد عثرنا على حساب مطابق لعنوان البريد الإلكتروني %{email}. ومن المفترض أن تتلقى رسالة إلكترونية بإرشادات إعادة تعيين كلمة المرور قريبًا." + complete_username_not_found: "لا يوجد حساب مطابق لاسم المستخدم %{username}" + complete_email_not_found: "لا يوجد حساب مطابق لعنوان البريد الإلكتروني %{email}" + help: "لم تصلك الرسالة الإلكترونية بعد؟ احرص على التحقُّق من مجلد البريد العشوائي أولًا.

    لست متأكدًا من عنوان البريد الإلكتروني الذي استخدمته؟ أدخِل عنوان البريد الإلكتروني وسنخبرك إذا كان موجودًا هنا.

    إذا فقدت الوصول إلى عنوان البريد الإلكتروني المرتبط بحسابك، يُرجى التواصل مع طاقم عملنا لمساعدتك.

    " + button_ok: "حسنًا" + button_help: "المساعدة" email_login: - link_label: "أبرِدني رابط ولوج" - button_label: "مع البريد الإلكتروني" - emoji: "قفل الرموز التعبيرية" - complete_username: "إن تطابق أحد الحسابات مع اسم المستخدم %{username} فسوف تستلم بريدًا قريبًا بإرشادات آلية تسجيل الدخول." - complete_email: "إذا تطابق أحد الحسابات مع %{email}، فمن المفترض أن تتلقى بريدًا إلكترونيًا برابط تسجيل الدخول قريبًا." - complete_username_found: "وجدنا حسابًا يُطابق اسم المستخدم %{username}. ستستلم قريبًا بريدًا بإرشادات آلية تسجيل الدخول." - complete_email_found: "وجدنا حسابًا يستخدم نفس البريد الإلكتروني %{email}. ستستلم قريبًا بريدًا بإرشادات آلية تسجيل الدخول." - complete_username_not_found: "لا يوجد حساب يطابق اسم المستخدم %{username}" - complete_email_not_found: "لا حساب يطابق %{email}" - confirm_title: تابع إلى %{site_name} - logging_in_as: تسجيل الدخول باسم %{email} + link_label: "مراسلتي عبر البريد الإلكتروني برابط تسجيل الدخول" + button_label: "عبر البريد الإلكتروني" + emoji: "رمز قفل" + complete_username: "إذا تطابق أحد الحسابات مع اسم المستخدم %{username}، فمن المفترض أن تتلقى رسالة إلكترونية برابط تسجيل الدخول قريبًا." + complete_email: "إذا تطابق أحد الحسابات مع عنوان البريد الإلكتروني %{email}، فمن المفترض أن تتلقى رسالة إلكترونية برابط تسجيل الدخول قريبًا." + complete_username_found: "لقد عثرنا على حساب مطابق لاسم المستخدم %{username}. ومن المفترض أن تتلقى رسالة إلكتروني برابط تسجيل الدخول قريبًا." + complete_email_found: "لقد عثرنا على حساب مطابق لعنوان البريد الإلكتروني %{email}. ومن المفترض أن تتلقى رسالة إلكترونية برابط تسجيل الدخول قريبًا." + complete_username_not_found: "لا يوجد حساب مطابق لاسم المستخدم %{username}" + complete_email_not_found: "لا يوجد حساب مطابق لعنوان البريد الإلكتروني %{email}" + confirm_title: المتابعة إلى %{site_name} + logging_in_as: تسجيل الدخول بعنوان البريد الإلكتروني %{email} confirm_button: إنهاء تسجيل الدخول login: - header_title: "أهلًا بك من جديد" - subheader_title: "لِج إلى حسابك" - username: "اسم المستخدم" - password: "كلمة السر" - second_factor_title: "الاستيثاق بخطوتين" - second_factor_description: "من فضلك أدخِل رمز الاستيثاق من التطبيق:" - second_factor_backup: "لِج باستعمال رمز احتياطي" - second_factor_backup_title: "النسخ الاحتياطي لـ الاستيثاق بعوامل عدة" - second_factor_backup_description: "الرجاء إدخال أحد الرموز الاحتياطية الخاصة بك:" - second_factor: "تسجيل الدخول باستخدام تطبيق Authenticator" - security_key_description: "عند تجهيز مفتاح الأمان المادي، اضغط على زر المصادقة باستخدام مفتاح الأمان أدناه." - security_key_alternative: "جرب طريقة أخرى" - security_key_authenticate: "المصادقة باستخدام مفتاح الأمان" - security_key_not_allowed_error: "انتهت مهلة عملية تسجيل مفتاح الأمان أو تم إلغاؤها." - security_key_no_matching_credential_error: "لا يمكن العثور على بيانات اعتماد مطابقة في مفتاح الأمان المقدم." - security_key_support_missing_error: "جهازك الحالي أو متصفحك لا يدعم استخدام مفاتيح الأمان. يرجى استخدام طريقة مختلفة." - caps_lock_warning: "مفتاح Caps Lock مفعّل" - error: "خطأ مجهول" - cookies_error: "يبدو أن ملفات تعريف الارتباط في متصفّحك معطلة. قد لا تكون قادراً على تسجيل الدخول دون تمكينهم أولاً." - rate_limit: "رجاء انتظر قبل تسجيل دخول مرة أخرى." - blank_username: "من فضلك أدخِل بريدك الإلكتروني أو اسم المستخدم." + header_title: "مرحبًا بعودتك" + subheader_title: "تسجيل الدخول إلى حسابك" + username: "المستخدم" + password: "كلمة المرور" + second_factor_title: "المصادقة الثنائية" + second_factor_description: "يُرجى إدخال رمز المصادقة الثنائية من تطبيقك:" + second_factor_backup: "تسجيل الدخول باستخدام رمز احتياطي" + second_factor_backup_title: "الرمز الاحتياطي للمصادقة الثنائية" + second_factor_backup_description: "يُرجى إدخال أحد الرموز الاحتياطية:" + second_factor: "تسجيل الدخول باستخدام تطبيق المصادقة" + security_key_description: "عندما يكون مفتاح الأمان المادي جاهزًا، اضغط على زر \"المصادقة باستخدام مفتاح الأمان\" أدناه." + security_key_alternative: "تجربة طريقة أخرى" + security_key_authenticate: "المصادقة باستخدام مفتاح أمان" + security_key_not_allowed_error: "انتهت مهلة عملية المصادقة باستخدام مفتاح أمان أو تم إلغاؤها." + security_key_no_matching_credential_error: "تعذَّر العثور على بيانات اعتماد مطابقة في مفتاح الأمان المقدَّم." + security_key_support_missing_error: "لا يدعم جهازك أو متصفحك الحالي استخدام مفاتيح الأمان. يُرجى استخدام طريقة مختلفة." + caps_lock_warning: "مفتاح Caps Lock مفعَّل" + error: "خطأ غير معروف" + cookies_error: "يبدو أن ملفات تعريف الارتباط في متصفحك معطَّلة. قد لا تتمكن من تسجيل الدخول قبل تفعيلها أولًا." + rate_limit: "يُرجى الانتظار قبل محاولة تسجيل الدخول مرة أخرى." + blank_username: "يُرجى إدخال بريدك الإلكتروني أو اسم المستخدم." blank_username_or_password: "يُرجى إدخال بريدك الإلكتروني أو اسم المستخدم، وكلمة المرور." reset_password: "إعادة تعين كلمة المرور" - logging_in: "تلج الآن..." - or: "أو " - authenticating: "يوثق..." - awaiting_activation: "ما زال حسابك غير مفعّل، استخدم رابط نسيان كلمة المرور لإرسال بريد إلكتروني تفعيلي آخر." - awaiting_approval: "لم يوافق أي من أعضاء طاقم العمل على حسابك بعد. سيُرسل إليك بريد إلكتروني حالما يتمّ ذلك." - requires_invite: "معذرةً، دخول هذا المنتدى بالدعوات فقط." - not_activated: "لا يمكنك تسجيل دخول بعد. لقد أرسلنا سابقًا بريدًا إلى %{sentTo}. رجاء اتّبع الإرشادات فيه لتفعيل حسابك." - not_allowed_from_ip_address: "لا يمكنك الولوج من عنوان IP هذا." - admin_not_allowed_from_ip_address: "لا يمكنك تسجيل دخول كمدير من عنوان IP هذا." - resend_activation_email: "انقر هنا لإرسال رسالة التفعيل مرّة أخرى." - omniauth_disallow_totp: "فعّلت المصادقة بخطوتين على حسابك. من فضلك لِج باستعمال كلمة السر." - resend_title: "اعد ارسال رسالة التفعيل" - change_email: "غير عنوان البريد الالكتروني" - provide_new_email: "ادخل عنوان بريد الكتروني جديد و سنقوم بأرسال لك بريد التأكيد." - submit_new_email: "حدث عنوان البريد الإلكتروني" - sent_activation_email_again: "لقد أرسلنا بريد تفعيل آخر إلى %{currentEmail}. قد يستغرق وصوله بضعة دقائق. تحقّق من مجلّد السبام." + logging_in: "جارٍ تسجيل الدخول..." + or: "أو" + authenticating: "جارٍ المصادقة..." + awaiting_activation: "ما زال حسابك بانتظار التفعيل، استخدم رابط نسيان كلمة المرور لإرسال رسالة إلكترونية أخرى للتفعيل." + awaiting_approval: "لم يوافق أي من أعضاء طاقم العمل على حسابك بعد. سنُرسل إليك رسالة إلكترونية عند الموافقة عليه." + requires_invite: "عذرًا، الوصول إلى هذا المنتدى مقصور على أصحاب الدعوات فقط." + not_activated: "لا يمكنك تسجيل الدخول بعد. لقد أرسلنا سابقًا رسالة إلكترونية للتفعيل إلى %{sentTo}. يُرجى اتباع الإرشادات الواردة في هذه الرسالة لتفعيل حسابك." + not_allowed_from_ip_address: "لا يمكنك تسجيل الدخول من عنوان IP هذا." + admin_not_allowed_from_ip_address: "لا يمكنك تسجيل الدخول كمسؤول من عنوان IP هذا." + resend_activation_email: "انقر هنا لإرسال الرسالة الإلكترونية للتفعيل مرة أخرى." + omniauth_disallow_totp: "لقد فعَّلت المصادقة الثنائية على حسابك. يُرجى تسجيل الدخول بكلمة المرور." + resend_title: "إعادة إرسال الرسالة الإلكترونية للتفعيل" + change_email: "تغيير عنوان البريد الإلكتروني" + provide_new_email: "أدخِل عنوانًا جديدًا وسنعيد إرسال الرسالة الإلكترونية للتأكيد إليك." + submit_new_email: "تحديث عنوان البريد الإلكتروني" + sent_activation_email_again: "لقد أرسلنا رسالة إلكترونية أخرى للتفعيل إلى %{currentEmail}. قد يستغرق وصولها بضع دقائق؛ لذا احرص على التحقُّق من مجلد البريد العشوائي." sent_activation_email_again_generic: "لقد أرسلنا بريد تفعيل آخر إلى. قد يستغرق وصوله بضعة دقائق. تأكد من مراجعة مجلّد السبام." to_continue: "رجاء سجل دخول" preferences: "عليك تسجل الدخول لتغيير تفضيلاتك الشخصية." @@ -1792,7 +1780,7 @@ ar: name: "دِسكورد" title: "مع ديسكورد" second_factor_toggle: - totp: "استخدام تطبيق مصادقة بدلاً من ذلك" + totp: "استخدام تطبيق مصادقة بدلًا من ذلك" backup_code: "استخدام رمز النسخ الاحتياطي بدلاً من ذلك" invites: accept_title: "دعوة" @@ -1802,7 +1790,7 @@ ar: social_login_available: "يمكنك أيضًا تسجيل الدخول بأي حساب تواصل اجتماعي باستخدام هذا البريد الإلكتروني." your_email: "عنوان البريد الإلكتروني لحسابك هو %{email}." accept_invite: "قبول الدعوة" - success: "لقد تم إنشاء حسابك وتسجيل دخولك." + success: "تم إنشاء حسابك وتسجيل دخولك." name_label: "الاسم" password_label: "كلمة المرور" optional_description: "(اختياري)" @@ -1826,7 +1814,7 @@ ar: shift: "Shift" ctrl: "Ctrl" alt: "Alt" - enter: "أدخل" + enter: "Enter" conditional_loading_section: loading: جارٍ التحميل... category_row: @@ -1924,7 +1912,7 @@ ar: many: "تعني بالإشارة إلى %{group} إخطار %{count} شخصًا. أمتأكّد؟" other: "تعني بالإشارة إلى %{group} إخطار %{count} شخص– أمتأكّد؟" cannot_see_mention: - category: "لقد أشرت إلى %{username}، ولكن لن يتم إشعاره لأنه لا يملك إذن الوصول إلى هذه الفئة. عليك إضافته إلى إحدى المجموعات التي تملك إذن الوصول إلى هذه الفئة." + category: "لقد أشرت إلى %{username}، لكنه لن يتلقي إشعارًا لأنه لا يملك إذن الوصول إلى هذه الفئة. عليك إضافته إلى إحدى المجموعات التي تملك إذن الوصول إلى هذه الفئة." private: "لقد أشرت إلى %{username}، ولكن لن يتم إشعاره لأنه لا يمكنه رؤية هذه الرسالة الشخصية. عليك دعوته إلى هذه الرسالة الشخصية." duplicate_link: "يظهر بأن الرابط الذي يُشير إلى %{domain} نشره @%{username} في الموضوع فعلًا في ردّ بتاريخ %{ago}. أمتأكّد من نشره ثانيةً؟" reference_topic_title: "بخصوص: %{title}" @@ -1994,7 +1982,7 @@ ar: collapse: "تصغير لوحة الكتابة" open: "فتح لوحة الكتابة" abandon: "أغلِق لوحة الكتابة وأهمِل المسودّة" - enter_fullscreen: "لوحة الكتابة تملأ الشاشة" + enter_fullscreen: "فتح أداة الإنشاء في وضع ملء الشاشة" exit_fullscreen: "الخروج من ملء الشاشة" show_toolbar: "عرض شريط أدوات الكتابة" hide_toolbar: "إخفاء شريط أدوات الكتابة" @@ -2153,7 +2141,7 @@ ar: sort_by: "رتب حسب" relevance: "الملاءمة" latest_post: "آخر المنشورات" - latest_topic: "آخر الموضوعات" + latest_topic: "أحدث موضوع" most_viewed: "الأكثر مشاهدة" most_liked: "الأكثر إعجابا" select_all: "أختر الكل" @@ -2516,10 +2504,10 @@ ar: title: "عادي" description: "سنُرسل إليك إشعارًا إذا أشار أحد إلى اسمك باستخدام الرمز @ أو ردَّ عليك." muted_pm: - title: "مكتوم" + title: "الكتم" description: "لن نُرسل إليك أي إشعارات أبدًا بخصوص هذه الرسالة." muted: - title: "مكتوم" + title: "الكتم" description: "لن نُرسل أي إشعارات أبدًا بخصوص هذا الموضوع، ولن يظهر في الموضوعات الحديثة." actions: title: "الإجراءات" @@ -2888,7 +2876,7 @@ ar: lock_post_description: "امنع كاتب المنشور من تعديله" unlock_post: "إلغاء قفل المنشور" unlock_post_description: "اسمح لكاتب المنشور بتعديله" - delete_topic_disallowed_modal: "ليس لديك الإذن بحذف هذا الموضوع. إذا كنت تريد حقًا حذفه، فأرسِل بلاغًا إلى أحد المشرفين مع السبب." + delete_topic_disallowed_modal: "ليس لديك الإذن بحذف هذا الموضوع. إذا كنت تريد حقًا حذفه، فضَع علامة للفت انتباه أحد المشرفين مع ذكر السبب." delete_topic_disallowed: "ليس لديك الإذن بحذف هذا الموضوع" delete_topic_confirm_modal_yes: "نعم، احذف الموضوع" delete_topic_confirm_modal_no: "لا، أبقِ الموضوع" @@ -3104,7 +3092,7 @@ ar: watching: title: "مُراقبة" watching_first_post: - title: "يُراقب فيها أول مشاركة" + title: "مراقبة أول منشور" description: "سيتم إعلامك بمواضيع جديدة في هذا الوسم ولكن ليس الرد على الموضوعات." tracking: title: "مُتابعة" @@ -3112,7 +3100,7 @@ ar: title: "منتظم" description: "ستستقبل إشعارًا إن أشار أحد إلى @اسمك أو ردّ عليك." muted: - title: "مكتومة" + title: "الكتم" search_priority: label: "أولوية البحث" options: @@ -3156,8 +3144,8 @@ ar: title: "اتخذ اجراء" details: "الوصول إلى الحد الأقصى للبلاغات دون انتظار بلاغات أكثر من أعضاء الموقع." suspend: - title: "عضو موقوف" - details: "الوصول إلى الحد الأعلى للبلاغات، و إيقاف حساب المستخدم" + title: "تعليق المستخدم" + details: "الوصول إلى الحد الأدنى للبلاغات وتعليق المستخدم" silence: title: "كتم المستخدم" details: "الوصول إلى الحد الأعلى للبلاغات، وكتم المستخدم" @@ -3621,7 +3609,7 @@ ar: title: "مُراقب" description: "ستراقب آليا كل المواضيع التي تستخدم هذه الأوسمة. ستصلك إشعارات بالمنشورات و الموضوعات الجديدة، وسيظهر أيضا عدّاد للمنشورات الجديدة بجانب كل موضوع." watching_first_post: - title: "يُراقب فيه أول مشاركة" + title: "مراقبة أول منشور" description: "سيتم إعلامك بمواضيع جديدة في هذا الوسم ولكن ليس الرد على الموضوعات." tracking: title: "مُتابع" @@ -3630,7 +3618,7 @@ ar: title: "موضوع عادي" description: "ستستقبل إشعارًا إن أشار أحد إلى @اسمك أو ردّ على مشاركتك." muted: - title: "مكتوم" + title: "الكتم" description: "لن يتم إخطارك بأي شيء يتعلق بالمواضيع الجديدة بهذ الوسم، ولن تظهر في علامة التبويب \"غير المقروءة\"." groups: title: "مجموعات الوسوم" @@ -3717,7 +3705,7 @@ ar: no_problems: "لا مشاكل." moderators: "المشرفون:" admins: "المدراء:" - suspended: "موقوفون:" + suspended: "معلَّق:" private_messages_short: "الرسائل" private_messages_title: "الرسائل" mobile_title: "متنقل" @@ -4233,7 +4221,7 @@ ar: moderation_history: actions: delete_user: "حُذف المستخدم." - suspend_user: "العضو موقوف." + suspend_user: "تم تعليق المستخدم" silence_user: "أُسكت المستخدم" delete_post: "حُذفت المشاركة" delete_topic: "حُذف الموضوع" @@ -4280,8 +4268,8 @@ ar: change_theme: "غيّر السمة" delete_theme: "أحذف السمة" change_site_text: "تغيير نص الموقع." - suspend_user: "حظر المستخدم" - unsuspend_user: "رفع الحظر " + suspend_user: "تعليق المستخدم" + unsuspend_user: "إلغاء تعليق المستخدم" grant_badge: "منح شارة" revoke_badge: "حذف الشعار" check_email: "التحقق من البريد" @@ -4346,7 +4334,7 @@ ar: logster: title: "سجلات الخطأ." watched_words: - title: "مراقب الكلمات" + title: "الكلمات المُراقَبة" search: "بحث" clear_filter: "امسح" download: حمل @@ -4388,7 +4376,7 @@ ar: new: "الجدد" active: "النشطون" staff: "الطاقم" - suspended: "موقوف" + suspended: "المعلَّقون" staged: "مهيأ" approved: "موافقة؟" titles: @@ -4403,7 +4391,7 @@ ar: staff: "الطاقم" admins: "الأعضاء المدراء" moderators: "المشرفون" - suspended: "أعضاء موقوفين" + suspended: "المستخدمون المعلَّقون" not_verified: "لم يتم التحقق" check_email: title: "اكشف عنوان البريد الإلكتروني لهذا العضو" @@ -4411,21 +4399,21 @@ ar: check_sso: text: "إظهار" user: - suspend_failed: "حدث خطب ما أثناء إيقاف هذا المستخدم %{error}" - unsuspend_failed: "حدث خطب ما أثناء إلغاء إيقاف هذا المستخدم %{error}" - suspend_duration: "ما المدّة التي سيُوقف هذا المستخدم خلالها؟" - suspend_reason_label: "لماذا هل أنت عالق؟ هذا النص سيكون ظاهراً للكل على صفحة تعريف هذا العضو, وسيكون ظاهراً للعضو عندما يحاول تسجل الدخول. احفظها قصيرة." - suspend_reason_hidden_label: "لماذا انت موقوف؟ هذا النص سيظهر للعضو حين يحاول الولوج. اجعله قصيراً." + suspend_failed: "حدث خطأ في أثناء تعليق هذا المستخدم %{error}" + unsuspend_failed: "حدث خطأ في أثناء إلغاء تعليق هذا المستخدم %{error}" + suspend_duration: "ما المدة التي سيتم تعليق المستخدم خلالها؟" + suspend_reason_label: "ما سبب التعليق؟ سيكون هذا النص مرئيًا للجميع على صفحة الملف الشخصي لهذا المستخدم، وسيظهر للمستخدم عند محاولته تسجيل الدخول. احرص على أن يكون موجزًا." + suspend_reason_hidden_label: "ما سبب التعليق؟ سيظهر هذا النص للمستخدم عند محاولته تسجيل الدخول. احرص على أن يكون موجزًا." suspend_reason: "سبب" - suspend_reason_title: "سبب التوقيف" + suspend_reason_title: "سبب التعليق" suspend_message: "رسالة بريد الكتروني" - suspend_message_placeholder: "اختياري. وفر المزيد من المعلومات حول التوقيف و سيتم ارساله عبر البريد الالكتروني الى العضو." - suspended_by: "محظور من قبل" + suspend_message_placeholder: "قدِّم المزيد من المعلومات عن التعليق بشكلٍ اختياري وسيتم إرسالها عبر البريد الالكتروني إلى المستخدم." + suspended_by: "تم التعليق بواسطة" silence_reason: "السبب" silence_modal_title: "كتم المستخدم" silence_message: "رسالة بريد الكتروني" suspended_until: "(حتى %{until})" - cant_suspend: "لا يمكن ايقاف هذا العضو" + cant_suspend: "لا يمكن تعليق هذا المستخدم." delete_all_posts: "احذف كل مشاركاته" delete_posts_progress: "يحذف المشاركات..." delete_posts_failed: "حدث عُطل أثناء حذف المشاركات." @@ -4436,7 +4424,7 @@ ar: silenced: "صامت؟" moderator: "مراقب؟" admin: "مدير؟" - suspended: "معلّق؟" + suspended: "معلَّق؟" staged: "تنظيم؟" show_admin_profile: "مدير" show_public_profile: "عرض الملف العام." @@ -4450,8 +4438,8 @@ ar: grant_admin_confirm: "لقد ارسلنا بريداً الكترونياً لتأكيد المدير الجديد. رجاء افتح الرسالة و اتبع التعلميات." revoke_moderation: "سحب المراقبة" grant_moderation: "منحة مراقبة" - unsuspend: "إلقاء التعليق" - suspend: "علّق" + unsuspend: "إلغاء التعليق" + suspend: "تعليق" reputation: شهرة permissions: التّصاريح activity: أنشطة @@ -4558,7 +4546,7 @@ ar: days: "أيام" topics_replied_to: "مواضيع للردود" topics_viewed: "المواضيع شوهدت" - topics_viewed_all_time: "المواضيع المعروضة(جميع الأوقات)" + topics_viewed_all_time: "الموضوعات المعروضة (طوال الوقت)" posts_read: "المنشورات المقروءة" posts_read_all_time: "المشاركات المقروءة (جميع الاوقات)" flagged_posts: "المشاركات المبلغ عنها " diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index 3f900894cb..10810c335d 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -95,7 +95,6 @@ be: submit: "Адправіць" generic_error: "Выбачайце, здарылася памылка." generic_error_with_reason: "Паўстала памылка: %{error}" - go_ahead: "Далей" sign_up: "Рэгістрацыя" log_in: "Увайсці" age: "Узрост" @@ -122,7 +121,6 @@ be: now: "толькі што" read_more: "чытаць далей" more: "больш" - less: "меньш" never: "ніколі" every_30_minutes: "кожныя 30 хвілін" every_hour: "кожную гадзіну" @@ -131,7 +129,6 @@ be: every_month: "кожны месяц" every_six_months: "кожныя паўгады" max_of_count: "Не больш за %{count}" - alternation: "або" character_count: one: "%{Count} сімвал" few: "%{Count} сімвалы" @@ -306,7 +303,6 @@ be: manage: name: "Імя" full_name: "поўнае Імя" - add_members: "Дадаць удзельнікаў" delete_member_confirm: "Выдаліць '%{username}' з групы '%{group}'?" profile: title: Профіль diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index 3e66eee686..a79f1a0954 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -189,7 +189,6 @@ bg: submit: "Изпрати" generic_error: "Съжаляваме, възникна грешка." generic_error_with_reason: "Грешка: %{error}" - go_ahead: "Продължете напред" sign_up: "Регистрация" log_in: "Вход" age: "Години" @@ -215,7 +214,6 @@ bg: now: "преди малко" read_more: "прочетете повече" more: "Повече" - less: "По-малко" never: "никога" every_30_minutes: "на всеки 30 минути" every_hour: "на всеки час" @@ -224,7 +222,6 @@ bg: every_month: "всеки месец" every_six_months: "на всеки шест месеца" max_of_count: "максимално от %{count}" - alternation: "или" character_count: one: "%{count} символ" other: "%{count} символа" @@ -256,7 +253,6 @@ bg: help: bookmark: "Щракнете за да добавите в отметки първата публикация в тази тема" unbookmark: "Щракнете тук за да изтриите всички отметки в тази тема" - unbookmark_with_reminder: "Натиснете за да премахнете всички отметки в тази тема. Имате напомняне %{reminder_at} за тази тема." bookmarks: not_bookmarked: "добавете тази публикация в Отметки" remove: "Премахнете отметката" @@ -474,9 +470,6 @@ bg: remove_user_as_group_owner: "Премахни като собственик" groups: member_added: "Добавено" - add_members: - usernames: - input_placeholder: "Потебителско име" requests: title: "Заявки" reason: "Причина" @@ -485,7 +478,7 @@ bg: title: "Управление" name: "Име" full_name: "Пълно име" - add_members: "Добавете потребители" + invite_members: "Покана" delete_member_confirm: "Премахнете '%{username}' от група '%{group}' ?" profile: title: Профил diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index d76991d0cf..e296ae82ef 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -215,7 +215,6 @@ bs_BA: submit: "Potvrdi" generic_error: "Uff, došlo je do greške." generic_error_with_reason: "Došlo je do greške: %{error}" - go_ahead: "Idi naprijed" sign_up: "Učlani se" log_in: "Prijavi se" age: "Godište" @@ -246,7 +245,6 @@ bs_BA: one: "Još %{count}" few: "Još %{count}" other: "Još %{count}" - less: "Manje" never: "nikada" every_30_minutes: "svakih 30 minuta" every_hour: "svaki sat" @@ -255,7 +253,6 @@ bs_BA: every_month: "svaki mjesec" every_six_months: "svakih šest mjeseci" max_of_count: "maksimalno %{count}" - alternation: "ili" character_count: one: "%{count} karakter" few: "%{count} karaktera" @@ -288,7 +285,6 @@ bs_BA: help: bookmark: "Klikni kako bi dodao zabilješku na prvu objavu u temi" unbookmark: "Klikni za uklanjanje svih zabilješki sa ove teme" - unbookmark_with_reminder: "Kliknite da biste uklonili sve zabilješke i podsjetnike iz ove teme. Za ovu temu imate podsjetnik %{reminder_at}." bookmarks: created: "Zabilježili ste ovu objavu. %{name}" not_bookmarked: "zabilježi objavu" @@ -598,13 +594,6 @@ bs_BA: member_added: "Doodano" member_requested: "zatraženo na" add_members: - title: "Dodaj članove u %{group_name}" - description: "Također možete zalijepiti i kao listu/popis razdvojen zarezom." - usernames_or_emails: - title: "Unesite korisnička imena ili adrese e-pošte" - input_placeholder: "Korisnička imena ili e-adrese" - usernames: - input_placeholder: "Korisnička imena" notify_users: "Obavijesti korisnike" requests: title: "Zahtjevi" @@ -619,7 +608,7 @@ bs_BA: title: "Uredi" name: "Ime" full_name: "Puno ime" - add_members: "Dodaj članove" + invite_members: "Invite" delete_member_confirm: "Ukloni '%{username}' iz grupe '%{group}'?" profile: title: Profil @@ -3219,8 +3208,6 @@ bs_BA: available: "Ime grupe je dostupno" not_available: "Naziv grupe nije dostupan" blank: "Naziv grupe ne može biti prazan" - add_members: - as_owner: "Postavite korisnika kao vlasnike ove grupe" manage: interaction: email: Email diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index de6669015d..da2d8f9798 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -193,7 +193,6 @@ ca: submit: "Envia" generic_error: "Ho sentim, s'ha produït un error." generic_error_with_reason: "Hi ha hagut un error: %{error}" - go_ahead: "Avant" sign_up: "Registre" log_in: "Inicia sessió" age: "Edat" @@ -222,7 +221,6 @@ ca: x_more: one: "%{count} Més" other: "%{count} Més" - less: "Menys" never: "mai" every_30_minutes: "cada 30 minuts" every_hour: "cada hora" @@ -231,7 +229,6 @@ ca: every_month: "cada mes" every_six_months: "cada sis mesos" max_of_count: "màxim de %{count}" - alternation: "o" character_count: one: "%{count} caràcter" other: "%{count} caràcters" @@ -263,7 +260,6 @@ ca: help: bookmark: "Feu clic per a marcar com a preferit la primera publicació d'aquest tema" unbookmark: "Feu clic per a eliminar tots els preferits d'aquest tema" - unbookmark_with_reminder: "Feu clic per a eliminar tots els marcadors i recordatoris d’aquest tema. Teniu un recordatori establert %{reminder_at} per a aquest tema." bookmarks: created: "Heu marcat com a preferida aquesta publicació. %{name}" not_bookmarked: "marca aquesta publicació com a preferit" @@ -557,13 +553,6 @@ ca: member_added: "Afegit" member_requested: "Sol·licitat" add_members: - title: "Afegeix membres a %{group_name}" - description: "També podeu enganxar una llista separada per comes." - usernames_or_emails: - title: "Introduïu noms d’usuari o adreces de correu electrònic" - input_placeholder: "Noms d’usuari o correus electrònics" - usernames: - input_placeholder: "Noms d'usuari" notify_users: "Notifiqueu els usuaris" requests: title: "Sol·licituds" @@ -578,7 +567,7 @@ ca: title: "Gestiona" name: "Nom" full_name: "Nom complet" - add_members: "Afegeix membres" + invite_members: "Convida" delete_member_confirm: "Voleu eliminar '%{username}' del grup '%{group}'?" profile: title: Perfil @@ -2984,8 +2973,6 @@ ca: available: "El nom de grup és disponible" not_available: "El nom de grup no és disponible" blank: "El nom de grup no pot restar en blanc" - add_members: - as_owner: "Estableix usuaris com a propietaris d'aquest grup" manage: interaction: email: Correu electrònic diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index 65a36d244d..c2a2099524 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -239,7 +239,6 @@ cs: submit: "Odeslat" generic_error: "Bohužel nastala chyba." generic_error_with_reason: "Nastala chyba: %{error}" - go_ahead: "Pokračuj" sign_up: "Registrace" log_in: "Přihlásit se" age: "Věk" @@ -272,7 +271,6 @@ cs: few: "%{count} další" many: "%{count} Více" other: "%{count} Více" - less: "Méně" never: "nikdy" every_30_minutes: "každých 30 minut" every_hour: "každou hodinu" @@ -281,7 +279,6 @@ cs: every_month: "každý měsíc" every_six_months: "každých šest měsíců" max_of_count: "max z" - alternation: "nebo" character_count: one: "%{count} znak" few: "%{count} znaky" @@ -315,7 +312,6 @@ cs: help: bookmark: "Kliknutím vložíte záložku na první příspěvek tohoto tématu" unbookmark: "Kliknutím odstraníte všechny záložky v tématu" - unbookmark_with_reminder: "Kliknutím odstraníte všechny záložky a připomenutí v tomto tématu. Máte nastavenou upomínku %{reminder_at} pro toto téma." bookmarks: created: "Tento příspěvek jste přidali do záložek. %{name}" not_bookmarked: "přidat záložku k příspěvku" @@ -563,9 +559,6 @@ cs: remove_user_as_group_owner: "Odstraň vlastníka" groups: member_added: "Přidáno" - add_members: - usernames: - input_placeholder: "Uživatelská jména" requests: reason: "Reason" accepted: "přijato" @@ -573,7 +566,7 @@ cs: title: "Spravovat" name: "Název" full_name: "Celé název" - add_members: "Přidat členy" + invite_members: "Pozvat" delete_member_confirm: "Odstranit '%{username}' ze '%{group}' skupiny?" profile: title: Profil diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index dfc20e44bf..38ecc46ae8 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -200,7 +200,6 @@ da: submit: "Udfør" generic_error: "Beklager, der opstod en fejl." generic_error_with_reason: "Der opstod en fejl: %{error}" - go_ahead: "Fortsæt" sign_up: "Tilmeld" log_in: "Log ind" age: "Alder" @@ -229,7 +228,6 @@ da: x_more: one: "%{count} Mere" other: "%{count} Flere" - less: "Mindre" never: "aldrig" every_30_minutes: "hver halve time" every_hour: "hver time" @@ -238,7 +236,6 @@ da: every_month: "hver måned" every_six_months: "hvert halvår" max_of_count: "maks af %{count}" - alternation: "eller" character_count: one: "%{count} tegn" other: "%{count} tegn" @@ -273,7 +270,6 @@ da: help: bookmark: "Klik for at sætte et bogmærke i det første indlæg i denne tråd" unbookmark: "Klik for at fjerne alle bogmærker i dette emne" - unbookmark_with_reminder: "Klik for at fjerne alle bogmærker og påmindelser i dette emne. Du har en påmindelse sat %{reminder_at} for dette emne." bookmarks: created: "Du har sat et bogmærke for dette indlæg. %{name}" not_bookmarked: "bogmærk dette indlæg" @@ -605,14 +601,6 @@ da: member_added: "Tilføjet" member_requested: "Anmodet kl" add_members: - title: "Tilføj medlemmer til %{group_name}" - description: "Du kan også indsætte en kommasepareret liste." - usernames_or_emails: - title: "Indtast brugernavne eller e-mail adresser" - input_placeholder: "Brugernavne eller e-mails" - usernames: - title: "Indtast brugernavne" - input_placeholder: "Brugernavne" notify_users: "Giv brugerne besked" requests: title: "Anmodninger" @@ -627,7 +615,7 @@ da: title: "Administrér" name: "Navn" full_name: "Fulde Navn" - add_members: "Tilføj Medlemmer" + invite_members: "Invitér" delete_member_confirm: "Fjern '%{username}' fra '%{group}' gruppen?" profile: title: Profil @@ -3573,8 +3561,6 @@ da: available: "Gruppenavnet er tilgængeligt" not_available: "Gruppenavnet er ikke tilgængelig" blank: "Gruppenavnet kan ikke være tomt" - add_members: - as_owner: "Angiv bruger(e) som ejer(e) af denne gruppe" manage: interaction: email: Email diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index 289ac904f3..8f2839c66b 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -22,8 +22,8 @@ de: mb: MB tb: TB short: - thousands: "%{number}k" - millions: "%{number}M" + thousands: "%{number} T." + millions: "%{number} M." dates: time: "HH:mm" time_with_zone: "HH:mm (z)" @@ -44,35 +44,35 @@ de: tiny: half_a_minute: "< 1 Min." less_than_x_seconds: - one: "< %{count}s" - other: "< %{count}s" + one: "< %{count} s" + other: "< %{count} s" x_seconds: - one: "%{count}s" - other: "%{count}s" + one: "%{count} s" + other: "%{count} s" less_than_x_minutes: - one: "< %{count}min" - other: "< %{count}min" + one: "< %{count} min" + other: "< %{count} min" x_minutes: - one: "%{count}min" - other: "%{count}min" + one: "%{count} min" + other: "%{count} min" about_x_hours: - one: "%{count}h" - other: "%{count}h" + one: "%{count} h" + other: "%{count} h" x_days: - one: "%{count}d" - other: "%{count}d" + one: "%{count} d" + other: "%{count} d" x_months: - one: "%{count}m" - other: "%{count}m" + one: "%{count} m" + other: "%{count} m" about_x_years: - one: "%{count}a" - other: "%{count}a" + one: "%{count} a" + other: "%{count} a" over_x_years: - one: "> %{count}a" - other: "> %{count}a" + one: "> %{count} a" + other: "> %{count} a" almost_x_years: - one: "%{count}a" - other: "%{count}a" + one: "%{count} a" + other: "%{count} a" date_month: "D. MMM" date_year: "MMM 'YY" medium: @@ -159,20 +159,20 @@ de: wizard_required: "Willkommen bei deinem neuen Discourse! Lass uns mit dem Setup-Assistenten ✨ starten" emails_are_disabled: "Die ausgehende E-Mail-Kommunikation wurde von einem Administrator global deaktiviert. Es werden keinerlei Benachrichtigungen per E-Mail verschickt." software_update_prompt: - message: "Wir haben diese Seite aktualisiert, bitte aktualisiere, da es sonst zu unerwartetem Verhalten kommen kann." + message: "Wir haben ein Update dieser Website durchgeführt. Bitte aktualisiere, da es sonst zu unerwartetem Verhalten kommen kann." dismiss: "Verwerfen" bootstrap_mode_enabled: - one: "Damit du mit deiner Seite einfacher loslegen kannst, befindest du dich im Starthilfe-Modus. Alle neuen Benutzer erhalten die Vertrauensstufe 1 und bekommen eine tägliche E-Mail-Zusammenfassung. Der Modus wird automatisch deaktiviert, sobald sich mehr als %{count} Benutzer registriert hat." - other: "Damit du mit deiner Seite einfacher loslegen kannst, befindest du dich im Starthilfe-Modus. Alle neuen Benutzer erhalten die Vertrauensstufe 1 und bekommen eine tägliche E-Mail-Zusammenfassung. Der Modus wird automatisch deaktiviert, sobald sich mehr als %{count} Benutzer registriert haben." + one: "Damit du mit deiner Website einfacher loslegen kannst, befindest du dich im Starthilfe-Modus. Alle neuen Benutzer erhalten die Vertrauensstufe 1 und bekommen eine tägliche E-Mail-Zusammenfassung. Der Modus wird automatisch deaktiviert, sobald sich mehr als %{count} Benutzer registriert hat." + other: "Damit du mit deiner Website einfacher loslegen kannst, befindest du dich im Starthilfe-Modus. Alle neuen Benutzer erhalten die Vertrauensstufe 1 und bekommen eine tägliche E-Mail-Zusammenfassung. Der Modus wird automatisch deaktiviert, sobald sich mehr als %{count} Benutzer registriert haben." bootstrap_mode_disabled: "Der Starthilfe-Modus wird in den nächsten 24 Stunden deaktiviert." themes: default_description: "Standard" - broken_theme_alert: "Deine Seite funktioniert vielleicht nicht, weil Theme/Komponente %{theme} Fehler hat. Deaktiviere es in %{path}." + broken_theme_alert: "Deine Website funktioniert vielleicht nicht, weil Theme/Komponente %{theme} Fehler hat. Deaktiviere es in %{path}." s3: regions: ap_northeast_1: "Asien-Pazifik (Tokio)" ap_northeast_2: "Asien-Pazifik (Seoul)" - ap_east_1: "Asien-Pazifik (Hong Kong)" + ap_east_1: "Asien-Pazifik (Hongkong)" ap_south_1: "Asien-Pazifik (Mumbai)" ap_southeast_1: "Asien-Pazifik (Singapur)" ap_southeast_2: "Asien-Pazifik (Sydney)" @@ -200,7 +200,6 @@ de: submit: "Absenden" generic_error: "Entschuldigung, ein Fehler ist aufgetreten." generic_error_with_reason: "Ein Fehler ist aufgetreten: %{error}" - go_ahead: "Fortfahren" sign_up: "Registrieren" log_in: "Anmelden" age: "Alter" @@ -220,7 +219,7 @@ de: rules: "Regeln" conduct: "Verhaltenskodex" mobile_view: "Mobile Ansicht" - desktop_view: "Desktop Ansicht" + desktop_view: "Desktop-Ansicht" you: "Du" or: "oder" now: "gerade eben" @@ -229,7 +228,6 @@ de: x_more: one: "%{count} weiterer" other: "%{count} weitere" - less: "Weniger" never: "nie" every_30_minutes: "alle 30 Minuten" every_hour: "jede Stunde" @@ -237,14 +235,13 @@ de: weekly: "wöchentlich" every_month: "jeden Monat" every_six_months: "alle sechs Monate" - max_of_count: "von max. %{count}" - alternation: "oder" + max_of_count: "max. %{count}" character_count: one: "%{count} Zeichen" other: "%{count} Zeichen" related_messages: title: "Verwandte Nachrichten" - see_all: 'Siehe alle Nachrichten von @%{username}...' + see_all: 'Siehe alle Nachrichten von @%{username} …' suggested_topics: title: "Vorgeschlagene Themen" pm_title: "Vorgeschlagene Nachrichten" @@ -266,14 +263,13 @@ de: user_count: "Benutzer" active_user_count: "Aktive Benutzer" contact: "Kontaktiere uns" - contact_info: "Im Falle eines kritischen Problems oder einer dringenden Sache, die diese Website betrifft, kontaktiere uns bitte unter %{contact_info}." + contact_info: "Im Falle eines kritischen Problems oder einer dringenden Angelegenheit, die diese Website betrifft, kontaktiere uns bitte unter %{contact_info}." bookmarked: title: "Lesezeichen setzen" clear_bookmarks: "Lesezeichen entfernen" help: bookmark: "Klicke hier, um den ersten Beitrag in diesem Thema mit einem Lesezeichen zu versehen." unbookmark: "Klicke hier, um alle Lesezeichen in diesem Thema zu entfernen." - unbookmark_with_reminder: "Klicke hier, um alle Lesezeichen und Erinnerungen in diesem Thema zu löschen. Die nächste Erinnerung ist %{reminder_at} geplant." bookmarks: created: "Du hast diesen Beitrag mit einem Lesezeichen versehen. %{name}" not_bookmarked: "diesen Beitrag mit Lesezeichen versehen" @@ -300,14 +296,14 @@ de: at_time: "am %{date_time}" existing_reminder: "Du wirst %{at_date_time} an dieses Lesezeichen erinnert." copy_codeblock: - copied: "kopiert!" + copied: "Kopiert!" drafts: resume: "Fortsetzen" remove: "Entfernen" - remove_confirmation: "Bist du sicher, dass du diese Vorlage löschen möchtest?" - new_topic: "Neues Thema Entwurf" - new_private_message: "Neuer Entwurf für private Nachrichten" - topic_reply: "Antwort Entwurf" + remove_confirmation: "Bist du sicher, dass du diesen Entwurf löschen möchtest?" + new_topic: "Neuer Entwurf für Thema" + new_private_message: "Neuer Entwurf für private Nachricht" + topic_reply: "Entwurf für Antwort" abandon: confirm: "Du hast einen Entwurf für dieses Thema in Arbeit. Was würdest du gerne damit machen?" yes_value: "Verwerfen" @@ -323,17 +319,17 @@ de: other: "Zeige %{count} neue Themen" preview: "Vorschau" cancel: "Abbrechen" - deleting: "Löschen..." - save: "Speichern" - saving: "Speichere…" + deleting: "Löschen …" + save: "Änderungen speichern" + saving: "Speichern …" saved: "Gespeichert!" upload: "Hochladen" - uploading: "Wird hochgeladen…" - uploading_filename: "wird hochgeladen: %{filename}..." - processing_filename: "wird verarbeitet: %{filename}..." + uploading: "Wird hochgeladen …" + uploading_filename: "Wird hochgeladen: %{filename} …" + processing_filename: "Wird verarbeitet: %{filename} …" clipboard: "Zwischenablage" uploaded: "Hochgeladen!" - pasting: "Einfügen..." + pasting: "Einfügen …" enable: "Aktivieren" disable: "Deaktivieren" continue: "Weiter" @@ -344,24 +340,24 @@ de: switch_from_anon: "Anonymen Modus deaktivieren" banner: close: "Dieses Banner ausblenden." - edit: "Diesen Ankündigungsbanner bearbeiten >>" + edit: "Dieses Banner bearbeiten >>" pwa: install_banner: "Möchtest du %{title} auf diesem Gerät installieren?" choose_topic: none_found: "Keine Themen gefunden." title: search: "Suche nach einem Thema" - placeholder: "Gebe denn Thema Titel, URL oder id hier ein" + placeholder: "gib hier den Titel, die URL oder die ID des Themas ein" choose_message: none_found: "Keine Nachrichten gefunden." title: search: "Suche nach einer Nachricht" - placeholder: "Gebe denn Nachrichten Titel, URL oder id hier ein" + placeholder: "gib hier den Titel, die URL oder die ID der Nachricht ein" review: order_by: "Sortieren nach" in_reply_to: "Antwort auf" explain: - why: "Erkläre, warum dieses Element in der Warteschlange gelandet ist" + why: "erkläre, warum dieses Element in der Warteschlange gelandet ist" title: "Überprüfbares Scoring" formula: "Formel" subtotal: "Zwischensumme" @@ -370,38 +366,38 @@ de: score_to_hide: "Score, um den Beitrag zu verbergen" take_action_bonus: name: "Maßnahme ergriffen" - title: "Wenn ein Teammitglied entscheidet, eine Maßnahme zu ergreifen, bekommt das Kennzeichen einen Bonus." + title: "Wenn ein Team-Mitglied entscheidet, eine Maßnahme zu ergreifen, erhält die Meldung einen Bonus." user_accuracy_bonus: name: "Benutzer-Genauigkeit" - title: "Benutzer, deren Kennzeichen in Vergangenheit mit einem gewährten Bonus übereinstimmten" + title: "Benutzer, deren Meldungen in der Vergangenheit zugestimmt wurde, erhalten einen Bonus." trust_level_bonus: name: "Vertrauensstufe" title: "Überprüfbare Elemente, die von Benutzern höherer Vertrauensstufen angelegt wurden, haben einen höheren Score." type_bonus: - name: "Bonus Typ" + name: "Bonustyp" title: "Bestimmte überprüfbare Typen können vom Team mit einem Bonus ausgestattet werden, damit sie höher priorisiert sind." - stale_help: "Diese Überprüfung wurde von einer anderen Person behoben." + stale_help: "Dieses überprüfbare Element wurde von einer anderen Person bearbeitet." claim_help: optional: "Du kannst dieses Element reservieren, damit andere es nicht überprüfen." required: "Du musst Elemente reservieren, bevor du sie überprüfen kannst." claimed_by_you: "Du hast dieses Element reserviert und kannst es überprüfen." claimed_by_other: "Dieses Element kann nur von %{username} überprüft werden." claim: - title: "Reserviere dieses Thema" + title: "dieses Thema reservieren" unclaim: - help: "Entferne diese Reservierung" + help: "diese Reservierung entfernen" awaiting_approval: "Wartet auf Genehmigung" delete: "Löschen" settings: saved: "Gespeichert" - save_changes: "Speichern" + save_changes: "Änderungen speichern" title: "Einstellungen" priorities: - title: "Überprüfbare Prioritäten" + title: "Prioritäten für überprüfbare Elemente" moderation_history: "Moderationsverlauf" view_all: "Alle anzeigen" grouped_by_topic: "Gruppiert nach Thema" - none: "Es sind keine Einträge zur Überprüfung vorhanden." + none: "Es sind keine Elemente zur Überprüfung vorhanden." view_pending: "zeige verbleibende" topic_has_pending: one: "Dieses Thema hat %{count} Beitrag, der auf Genehmigung wartet." @@ -411,7 +407,7 @@ de: filtered_topic: "Du hast nach zu prüfenden Inhalten in einem einzelnen Thema gefiltert." filtered_user: "Benutzer" filtered_reviewed_by: "Überprüft von" - show_all_topics: "Zeige alle Themen" + show_all_topics: "alle Themen zeigen" deleted_post: "(Beitrag gelöscht)" deleted_user: "(Benutzer gelöscht)" user: @@ -424,17 +420,17 @@ de: reject_reason: "Grund" user_percentage: summary: - one: "%{agreed}, %{disagreed}, %{ignored} (des letzten Flags)" - other: "%{agreed}, %{disagreed}, %{ignored} (der letzten %{count} Flags)" + one: "%{agreed}, %{disagreed}, %{ignored} (der letzten Meldung)" + other: "%{agreed}, %{disagreed}, %{ignored} (der letzten %{count} Meldungen)" agreed: - one: "%{count}% zugestimmt" - other: "%{count}% zugestimmt" + one: "%{count} % zugestimmt" + other: "%{count} % zugestimmt" disagreed: - one: "%{count}% abgelehnt" - other: "%{count}% abgelehnt" + one: "%{count} % abgelehnt" + other: "%{count} % abgelehnt" ignored: - one: "%{count}% ignoriert" - other: "%{count}% ignoriert" + one: "%{count} % ignoriert" + other: "%{count} % ignoriert" topics: topic: "Thema" reviewable_count: "Anzahl" @@ -457,26 +453,26 @@ de: type: title: "Typ" all: "(alle Arten)" - minimum_score: "Minimale Bewertung:" + minimum_score: "Minimaler Score:" refresh: "Aktualisieren" status: "Status" category: "Kategorie" orders: - score: "Wertung" - score_asc: "Punktzahl (umgekehrt)" + score: "Score" + score_asc: "Score (umgekehrt)" created_at: "Erstellt am" created_at_asc: "Erstellt am (umgekehrt)" priority: title: "Minimale Priorität" - any: "(irgendein)" + any: "(beliebig)" low: "Niedrig" medium: "Mittel" high: "Hoch" conversation: view_full: "zeige vollständige Unterhaltung" scores: - about: "Dieser Wert wird basierend auf der Vertrauensstufe des Meldenden, der Richtigkeit der vorhergehenden Meldungen sowie der Priorität des gemeldeten Elements berechnet." - score: "Wertung" + about: "Dieser Score wird basierend auf der Vertrauensstufe des Meldenden, der Richtigkeit der vorhergehenden Meldungen sowie der Priorität des gemeldeten Elements berechnet." + score: "Score" date: "Datum" type: "Typ" status: "Status" @@ -516,7 +512,7 @@ de: one: "Du hast %{count} verbleibenden Beitrag." other: "Du hast %{count} verbleibende Beiträge." ok: "OK" - example_username: "benutzername" + example_username: "Benutzername" reject_reason: title: "Warum lehnst du diesen Benutzer ab?" send_email: "Ablehnungs-E-Mail senden" @@ -552,7 +548,7 @@ de: last_custom: "Letztes benutzerdefiniertes Datum" user_action: user_posted_topic: "%{user} hat das Thema verfasst" - you_posted_topic: "Du hast das Thema verfasst" + you_posted_topic: "Du hast das Thema verfasst" user_replied_to_post: "%{user} hat auf %{post_number} geantwortet" you_replied_to_post: "Du hast auf %{post_number} geantwortet" user_replied_to_topic: "%{user} hat auf das Thema geantwortet" @@ -566,7 +562,7 @@ de: sent_by_you: "Von dir gesendet" directory: username: "Benutzername" - filter_name: "nach Benutzername filtern" + filter_name: "nach Benutzernamen filtern" title: "Benutzer" likes_given: "Gegeben" likes_received: "Erhalten" @@ -582,7 +578,7 @@ de: days_visited_long: "Besuchstage" posts_read: "Gelesen" posts_read_long: "Gelesene Beiträge" - last_updated: "zuletzt aktualisiert" + last_updated: "Zuletzt aktualisiert:" total_rows: one: "%{count} Benutzer" other: "%{count} Benutzer" @@ -603,14 +599,6 @@ de: member_added: "Hinzugefügt" member_requested: "Angefragt am" add_members: - title: "Mitglieder zu %{group_name} hinzufügen" - description: "Du kannst auch eine durch Kommas getrennte Liste einfügen." - usernames_or_emails: - title: "Benutzernamen oder E-Mail-Adressen eingeben" - input_placeholder: "Benutzernamen oder E-Mails" - usernames: - title: "Benutzernamen eingeben" - input_placeholder: "Benutzernamen" notify_users: "Benutzer benachrichtigen" requests: title: "Anfragen" @@ -625,17 +613,16 @@ de: title: "Verwalten" name: "Name" full_name: "Vollständiger Name" - add_members: "Mitglieder hinzufügen" delete_member_confirm: "Entferne „%{username}“ aus der Gruppe „%{group}“?" profile: - title: Profi + title: Profil interaction: title: Interaktion posting: Beiträge notification: Benachrichtigung email: title: "E-Mail" - status: "Synchronisiert %{old_emails} / %{total_emails} E-Mails über IMAP." + status: "%{old_emails}/%{total_emails} E-Mails über IMAP synchronisiert." enable_smtp: "SMTP aktivieren" enable_imap: "IMAP aktivieren" test_settings: "Test-Einstellungen" @@ -643,34 +630,34 @@ de: settings_required: "Alle Einstellungen sind erforderlich, bitte fülle alle Felder vor der Validierung aus." smtp_settings_valid: "SMTP-Einstellungen gültig." smtp_title: "SMTP" - smtp_instructions: "Wenn Du SMTP für die Gruppe aktivierst, werden alle ausgehenden E-Mails, die aus dem Posteingang der Gruppe gesendet werden, über die hier angegebenen SMTP-Einstellungen anstelle des E-Mail-Servers gesendet, der für andere von Ihrem Forum gesendete E-Mails konfiguriert ist." + smtp_instructions: "Wenn du SMTP für die Gruppe aktivierst, werden alle ausgehenden E-Mails, die aus dem Posteingang der Gruppe gesendet werden, über die hier angegebenen SMTP-Einstellungen anstelle des E-Mail-Servers gesendet, der für andere von deinem Forum gesendete E-Mails konfiguriert ist." imap_title: "IMAP" imap_additional_settings: "Weitere Einstellungen" - imap_instructions: 'Wenn Du IMAP für die Gruppe aktivierst, werden E-Mails zwischen dem Gruppenposteingang und dem bereitgestellten IMAP-Server und -Postfach synchronisiert. SMTP muss mit gültigen und getesteten Anmeldeinformationen aktiviert werden, bevor IMAP aktiviert werden kann. Der für SMTP verwendete E-Mail-Benutzername und das Passwort werden für IMAP verwendet. Weitere Informationen finden Sie unter Feature-Ankündigung auf Discourse Meta.' + imap_instructions: 'Wenn du IMAP für die Gruppe aktivierst, werden E-Mails zwischen dem Gruppenposteingang und dem bereitgestellten IMAP-Server und -Postfach synchronisiert. SMTP muss mit gültigen und getesteten Anmeldeinformationen aktiviert werden, bevor IMAP aktiviert werden kann. Der für SMTP verwendete E-Mail-Benutzername und das Passwort werden für IMAP verwendet. Weitere Informationen findest du unter Feature-Ankündigung auf Discourse Meta.' imap_alpha_warning: "Warnung: Dies ist eine Alpha-Phasen-Funktion. Nur Gmail wird offiziell unterstützt. Benutzung auf eigene Gefahr!" imap_settings_valid: "IMAP-Einstellungen gültig." - smtp_disable_confirm: "Wenn Du SMTP deaktivierst, werden alle SMTP- und IMAP-Einstellungen zurückgesetzt und die zugehörigen Funktionen deaktiviert. Bist Du sicher, dass Du fortfahren möchtest?" - imap_disable_confirm: "Wenn Du IMAP deaktivierst, werden alle IMAP-Einstellungen zurückgesetzt und die zugehörigen Funktionen deaktiviert. Bist Du sicher, dass Du fortfahren möchtest?" + smtp_disable_confirm: "Wenn du SMTP deaktivierst, werden alle SMTP- und IMAP-Einstellungen zurückgesetzt und die zugehörigen Funktionen deaktiviert. Bist du sicher, dass du fortfahren möchtest?" + imap_disable_confirm: "Wenn du IMAP deaktivierst, werden alle IMAP-Einstellungen zurückgesetzt und die zugehörigen Funktionen deaktiviert. Bist du sicher, dass du fortfahren möchtest?" imap_mailbox_not_selected: "Du musst ein Postfach für diese IMAP-Konfiguration auswählen, sonst werden keine Postfächer synchronisiert!" prefill: title: "Vorbefüllen mit Einstellungen für:" gmail: "GMail" credentials: - title: "Zugangsdaten" - smtp_server: "SMTP Server" - smtp_port: "SMTP Port" + title: "Anmeldeinformationen" + smtp_server: "SMTP-Server" + smtp_port: "SMTP-Port" smtp_ssl: "SSL für SMTP verwenden" - imap_server: "IMAP Server" - imap_port: "IMAP Port" + imap_server: "IMAP-Server" + imap_port: "IMAP-Port" imap_ssl: "SSL für IMAP verwenden" username: "Benutzername" password: "Passwort" settings: title: "Einstellungen" - allow_unknown_sender_topic_replies: "Unbekannte Absender Antworten auf ein Thema erlauben." + allow_unknown_sender_topic_replies: "Unbekannten Absendern das Antworten auf Gruppenthemen erlauben." mailboxes: synchronized: "Synchronisierte Mailbox" - none_found: "In diesem E-Mail Konto wurden keine Postfächer gefunden." + none_found: "In diesem E-Mail-Konto wurden keine Postfächer gefunden." disabled: "Deaktiviert" membership: title: Mitgliedschaft @@ -679,22 +666,22 @@ de: title: Kategorien long_title: "Standardbenachrichtigungen für Kategorien" description: "Wenn Benutzer zu dieser Gruppe hinzugefügt werden, werden ihre Einstellungen für die Kategoriebenachrichtigung auf diese Standardeinstellungen festgelegt. Danach können sie sie ändern." - watched_categories_instructions: "Beobachte automatisch alle Themen in diesen Kategorien. Gruppenmitglieder werden über alle neuen Beiträge und Themen informiert, und neben dem Thema wird auch eine Anzahl neuer Beiträge angezeigt." - tracked_categories_instructions: "Verfolge automatisch alle Themen in diesen Kategorien. Neben dem Thema wird eine Anzahl neuer Beiträge angezeigt." + watched_categories_instructions: "Beobachte automatisch alle Themen in diesen Kategorien. Gruppenmitglieder werden über alle neuen Beiträge sowie Themen informiert und neben dem Thema wird auch die Anzahl neuer Beiträge angezeigt." + tracked_categories_instructions: "Verfolge automatisch alle Themen in diesen Kategorien. Neben dem Thema wird die Anzahl neuer Beiträge angezeigt." watching_first_post_categories_instructions: "Benutzer werden über den ersten Beitrag in jedem neuen Thema in diesen Kategorien informiert." regular_categories_instructions: "Wenn diese Kategorien stummgeschaltet sind, werden sie für Gruppenmitglieder nicht stummgeschaltet. Benutzer werden benachrichtigt, wenn sie erwähnt werden oder jemand auf sie antwortet." - muted_categories_instructions: "Benutzer werden nicht über neue Themen in diesen Kategorien informiert und sie werden nicht auf den Kategorien oder den Seiten mit den neuesten Themen angezeigt." + muted_categories_instructions: "Benutzer werden nicht über neue Themen in diesen Kategorien informiert und sie werden nicht auf den Seiten der Kategorien oder der neuesten Themen angezeigt." tags: title: Schlagwörter - long_title: "Tags Standardbenachrichtigungen" - description: "Wenn Benutzer zu dieser Gruppe hinzugefügt werden, werden ihre Einstellungen für die Tag-Benachrichtigung auf diese Standardeinstellungen festgelegt. Danach können sie sie ändern." - watched_tags_instructions: "Beobachte automatisch alle Themen mit diesen Tags. Gruppenmitglieder werden über alle neuen Beiträge und Themen informiert, und neben dem Thema wird auch eine Anzahl neuer Beiträge angezeigt." - tracked_tags_instructions: "Verfolge automatisch alle Themen mit diesen Tags. Neben dem Thema wird eine Anzahl neuer Beiträge angezeigt." - watching_first_post_tags_instructions: "Benutzer werden mit diesen Tags über den ersten Beitrag in jedem neuen Thema informiert." - regular_tags_instructions: "Wenn diese Tags stummgeschaltet sind, werden sie für Gruppenmitglieder nicht stummgeschaltet. Benutzer werden benachrichtigt, wenn sie erwähnt werden oder jemand auf sie antwortet." - muted_tags_instructions: "Benutzer werden mit diesen Tags nicht über neue Themen informiert, und sie werden nicht unter Aktuell angezeigt." + long_title: "Standardbenachrichtigungen für Schlagwörter" + description: "Wenn Benutzer zu dieser Gruppe hinzugefügt werden, werden ihre Einstellungen für die Schlagwort-Benachrichtigung auf diese Standardeinstellungen festgelegt. Danach können sie sie ändern." + watched_tags_instructions: "Beobachte automatisch alle Themen mit diesen Schlagwörtern. Gruppenmitglieder werden über alle neuen Beiträge sowie Themen informiert und neben dem Thema wird auch die Anzahl neuer Beiträge angezeigt." + tracked_tags_instructions: "Verfolge automatisch alle Themen mit diesen Schlagwörtern. Neben dem Thema wird die Anzahl neuer Beiträge angezeigt." + watching_first_post_tags_instructions: "Benutzer werden über den ersten Beitrag in jedem neuen Thema mit diesen Schlagwörtern informiert." + regular_tags_instructions: "Wenn diese Schlagwörter stummgeschaltet sind, werden sie für Gruppenmitglieder nicht stummgeschaltet. Benutzer werden benachrichtigt, wenn sie erwähnt werden oder jemand auf sie antwortet." + muted_tags_instructions: "Benutzer werden nicht über neue Themen mit diesen Schlagwörtern benachrichtigt und sie werden nicht unter „Aktuell“ angezeigt." logs: - title: "Protokoll" + title: "Protokolle" when: "Wann" action: "Aktion" acting_user: "Ausführender Benutzer" @@ -707,28 +694,28 @@ de: title: "Berechtigungen" none: "Dieser Gruppe sind keine Kategorien zugeordnet." description: "Mitglieder dieser Gruppe können auf diese Kategorien zugreifen" - public_admission: "Erlaube Benutzern, die Gruppe eigenständig zu betreten (Erfordert, dass die Gruppe öffentlich sichtbar ist)" + public_admission: "Erlaube Benutzern, die Gruppe eigenständig zu betreten (erfordert, dass die Gruppe öffentlich sichtbar ist)" public_exit: "Erlaube Benutzern, die Gruppe eigenständig zu verlassen" empty: posts: "Es gibt keine Beiträge von Mitgliedern dieser Gruppe." members: "Es gibt keine Mitglieder in dieser Gruppe." requests: "Keine Mitgliedschaftsanfragen für diese Gruppe." - mentions: "Es gibt keine Erwähnungen in dieser Gruppe." + mentions: "Es gibt keine Erwähnungen dieser Gruppe." messages: "Es gibt keine Nachrichten für diese Gruppe." topics: "Es gibt keine Themen von Mitgliedern dieser Gruppe." - logs: "Es gibt keine Protokolleinträge für diese Gruppe." + logs: "Es gibt keine Protokolle für diese Gruppe." add: "Hinzufügen" join: "Beitreten" leave: "Verlassen" request: "Anfrage" message: "Nachricht" confirm_leave: "Willst du die Gruppe wirklich verlassen?" - allow_membership_requests: "Erlaube Benutzern, Mitgliedschaftsanfragen an Gruppenbesitzer zu senden (erfordert, öffentlich sichtbare Gruppen)" - membership_request_template: "Benutzerdefinierte Vorlage, das Benutzern angezeigt wird, die eine Mitgliedschaftsanfrage senden" + allow_membership_requests: "Erlaube Benutzern, Mitgliedschaftsanfragen an Gruppeneigentümer zu senden (erfordert öffentlich sichtbare Gruppen)" + membership_request_template: "Individuelle Vorlage, die Benutzern angezeigt wird, welche eine Mitgliedschaftsanfrage senden" membership_request: submit: "Anfrage abschicken" - title: "Anfrage, um @%{group_name} beizutreten" - reason: "Lass’ die Gruppenbesitzer wissen, warum du in diese Gruppe gehörst" + title: "Beitrittsanfrage für @%{group_name}" + reason: "Lass die Gruppeneigentümer wissen, warum du in diese Gruppe gehörst" membership: "Mitgliedschaft" name: "Name" group_name: "Gruppenname" @@ -742,12 +729,12 @@ de: empty: "Es gibt keine sichtbaren Gruppen." filter: "Filtern nach Art der Gruppe" owner_groups: "Gruppen, deren Eigentümer ich bin" - close_groups: "Geschlossene Gruppe" + close_groups: "Geschlossene Gruppen" automatic_groups: "Automatische Gruppen" automatic: "Automatisch" closed: "Geschlossen" public: "Öffentlich" - private: "Geheim" + private: "Privat" public_groups: "Öffentliche Gruppen" automatic_group: Automatische Gruppe close_group: Gruppe schließen @@ -765,23 +752,23 @@ de: filter_placeholder: "Benutzername" remove_member: "Mitglied entfernen" remove_member_description: "Entferne %{username} aus dieser Gruppe" - make_owner: "Als Eigentümer hinzufügen" - make_owner_description: "Füge %{username} als Eigentümer dieser Gruppe hinzu" + make_owner: "Zum Eigentümer machen" + make_owner_description: "Mache %{username} zu einem Eigentümer dieser Gruppe" remove_owner: "Als Eigentümer entfernen" remove_owner_description: "Entferne %{username} als Eigentümer dieser Gruppe" - make_primary: "als primär einstellen" - make_primary_description: "Dies zur primären Gruppe für %{username} machen" - remove_primary: "Als Primär entfernen" - remove_primary_description: "Entferne dies als primäre Gruppe für %{username}" + make_primary: "Als primäre Gruppe festlegen" + make_primary_description: "Mache diese Gruppe zur primären Gruppe für %{username}" + remove_primary: "Als primäre Gruppe entfernen" + remove_primary_description: "Entferne diese Gruppe als primäre Gruppe für %{username}" remove_members: "Mitglieder entfernen" remove_members_description: "Ausgewählte Benutzer aus dieser Gruppe entfernen" - make_owners: "Als Eigentümer hinzufügen" - make_owners_description: "Ausgewählte Benutzer Besitzer dieser Gruppe machen" - remove_owners: "Besitzer entfernen" - remove_owners_description: "Ausgewählte Benutzer als Besitzer dieser Gruppe entfernen" - make_all_primary: "Alle als primär machen" - make_all_primary_description: "Dies zur primären Gruppe für alle ausgewählten Benutzer machen" - remove_all_primary: "Als Primär entfernen" + make_owners: "Zu Eigentümern machen" + make_owners_description: "Mache ausgewählte Benutzer zu Eigentümern dieser Gruppe" + remove_owners: "Eigentümer entfernen" + remove_owners_description: "Ausgewählte Benutzer als Eigentümer dieser Gruppe entfernen" + make_all_primary: "Für alle als primäre Gruppe festlegen" + make_all_primary_description: "Mache diese Gruppe zur primären Gruppe für alle ausgewählten Benutzer" + remove_all_primary: "Als primäre Gruppe entfernen" remove_all_primary_description: "Diese Gruppe als primäre Gruppe entfernen" owner: "Eigentümer" primary: "Primär" @@ -792,7 +779,7 @@ de: messages: "Nachrichten" notification_level: "Standard-Benachrichtigungsstufe für Gruppen-Nachrichten" alias_levels: - mentionable: "Wer kann diese Gruppe @erwähnen?" + mentionable: "Wer kann diese Gruppe mit @ erwähnen?" messageable: "Wer kann dieser Gruppe eine Nachricht schicken?" nobody: "Niemand" only_admins: "Nur Administratoren" @@ -809,20 +796,20 @@ de: description: "Du wirst über neue Nachrichten in dieser Gruppe informiert, aber nicht über Antworten auf diese Nachrichten." tracking: title: "Verfolgen" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet, und die Anzahl neuer Antworten wird angezeigt." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet, und die Anzahl neuer Antworten wird angezeigt." regular: title: "Normal" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." muted: title: "Stummgeschaltet" description: "Du erhältst keine Benachrichtigungen im Zusammenhang mit Nachrichten in dieser Gruppe." - flair_url: "Avatar-Flair Bild" + flair_url: "Avatar-Flair-Bild" flair_upload_description: "Verwende quadratische Bilder, die nicht kleiner als 20 x 20 Pixel sind." - flair_bg_color: "Avatar-Flair Hintergrundfarbe" + flair_bg_color: "Avatar-Flair-Hintergrundfarbe" flair_bg_color_placeholder: "(Optional) Hex-Farbwert" - flair_color: "Avatar-Flair Farbe" - flair_color_placeholder: "(Optoinal) Hex-Farbwert" - flair_preview_icon: "Vorschau Symbol" + flair_color: "Avatar-Flair-Farbe" + flair_color_placeholder: "(Optional) Hex-Farbwert" + flair_preview_icon: "Vorschau des Symbols" flair_preview_image: "Vorschaubild" flair_type: icon: "Wähle ein Symbol" @@ -842,7 +829,7 @@ de: "14": "Ausstehend" "15": "Entwürfe" categories: - all: "Alle Kategorien" + all: "alle Kategorien" all_subcategories: "alle" no_subcategory: "keine" category: "Kategorie" @@ -855,7 +842,7 @@ de: position: "Position" posts: "Beiträge" topics: "Themen" - latest: "Aktuelle Themen" + latest: "Aktuelle" toggle_ordering: "Reihenfolge ändern" subcategories: "Unterkategorien" muted: "Stummgeschaltete Kategorien" @@ -877,7 +864,7 @@ de: topic_stat_sentence_month: one: "%{count} neues Thema im letzten Monat." other: "%{count} neue Themen im letzten Monat." - n_more: "Kategorien (%{count} weitere)..." + n_more: "Kategorien (%{count} weitere) …" ip_lookup: title: IP-Adressen-Abfrage hostname: Hostname @@ -897,7 +884,7 @@ de: copied: "kopiert" user_fields: none: "(wähle eine Option aus)" - required: 'Bitte gib einen Wert für "%{name}" ein' + required: 'Bitte gib einen Wert für „%{name}“ ein' user: said: "%{username}:" profile: "Profil" @@ -926,7 +913,7 @@ de: ignore_no_users: "Du hast keine Benutzer ignoriert." ignore_option: "Ignoriert" ignore_option_title: "Du wirst keine Benachrichtigungen zu dieser Person erhalten und ihre Themen und Antworten werden ausgeblendet." - add_ignored_user: "Hinzufügen..." + add_ignored_user: "Hinzufügen …" mute_option: "Stummgeschaltet" mute_option_title: "Du wirst keine Benachrichtigungen zu dieser Person bekommen." normal_option: "Normal" @@ -934,9 +921,9 @@ de: notification_schedule: title: "Zeitplan für Benachrichtigungen" label: "Aktiviere den benutzerdefinierten Zeitplan für Benachrichtigungen" - tip: "Außerhalb dieser Zeiten wird automatisch der \"Nicht stören\"-Modus aktiviert." + tip: "Außerhalb dieser Zeiten wird automatisch der „Nicht stören“-Modus aktiviert." midnight: "Mitternacht" - none: "keine" + none: "Keine" monday: "Montag" tuesday: "Dienstag" wednesday: "Mittwoch" @@ -955,8 +942,8 @@ de: search_label: "Suche nach einem Thema anhand der Überschrift" save: "Speichern" clear: - title: "Filter zurücksetzen" - warning: "Willst du wirklich dein Thema entfernen?" + title: "Löschen" + warning: "Bist du sicher, dass du dein hervorgehobenes Thema löschen möchtest?" use_current_timezone: "Aktuelle Zeitzone verwenden" profile_hidden: "Das öffentliche Profil des Benutzers ist ausgeblendet." expand_profile: "Erweitern" @@ -976,21 +963,21 @@ de: perm_denied_expl: "Du hast das Anzeigen von Benachrichtigungen verboten. Aktiviere die Benachrichtigungen über deine Browser-Einstellungen." disable: "Benachrichtigungen deaktivieren" enable: "Benachrichtigungen aktivieren" - each_browser_note: 'Hinweis: Du musst diese Einstellung in jedem von Dir verwendeten Browser ändern. Alle Benachrichtigungen werden deaktiviert, wenn "Nicht stören" angezeigt wird, unabhängig von dieser Einstellung.' + each_browser_note: 'Hinweis: Du musst diese Einstellung in jedem von dir verwendeten Browser ändern. Alle Benachrichtigungen werden deaktiviert, wenn der „Nicht stören“-Modus verwendet wird, unabhängig von dieser Einstellung.' consent_prompt: "Möchtest du Live-Benachrichtigungen erhalten, wenn jemand auf deine Beiträge antwortet?" dismiss: "Alles gelesen" dismiss_notifications: "Alles gelesen" dismiss_notifications_tooltip: "Alle ungelesenen Benachrichtigungen als gelesen markieren" no_messages_title: "Du hast keine Nachrichten" no_messages_body: > - Benötigst Du ein direktes persönliches Gespräch mit jemandem außerhalb des normalen Gesprächsflusses? Sende ihnen eine Nachricht, indem Sie ihren Avatar auswählst und die %{icon} Nachrichtentaste verwendest.

    Wenn Du Hilfe benötigst, kannst Du Nachricht an einen Mitarbeiter senden. + Benötigst du ein direktes persönliches Gespräch mit jemandem außerhalb des normalen Gesprächsflusses? Sende der Person eine Nachricht, indem du ihren Avatar auswählst und die Schaltfläche %{icon} verwendest.

    Wenn du Hilfe benötigst, kannst du eine Nachricht an ein Team-Mitglied senden. no_bookmarks_title: "Du hast noch nichts mit einem Lesezeichen versehen" no_bookmarks_body: > Beginne Lesezeichen von Beiträgen mit der Schaltfläche %{icon} zu setzen und sie werden hier zum einfachen Nachschlagen aufgeführt. Du kannst auch eine Erinnerung vereinbaren! no_notifications_title: "Du hast noch keine Benachrichtigungen" no_notifications_body: > - Du wirst in diesem Panel über für Dich direkt relevante Aktivitäten benachrichtigt einschließlich Antworten auf Deine Themen, wenn jemand mit @mentions Dich erwähnt oder auf Dich antwortet bei Themen, die Du beobachtest. Benachrichtigungen werden auch an Deine E-Mail gesendet, wenn Du dich eine Weile nicht eingeloggt hast.

    Suche nach der %{icon} -Taste, um zu entscheiden, bei welchen Themen, Kategorien und Schlagwörter Du neachrichtigt werden möchtest. Für weiteres schaue in die Benutzereinstellungen. - first_notification: "Deine erste Benachrichtigung! Wähle sie aus um fortzufahren." + Du wirst in diesem Panel über für dich direkt relevante Aktivitäten benachrichtigt, einschließlich Antworten auf deine Themen und Beiträge, wenn dich jemand mit @ erwähnt oder zitiert und wenn jemand auf Themen antwortet, die du beobachtest. Benachrichtigungen werden auch an deine E-Mail-Adresse gesendet, wenn du dich eine Weile nicht eingeloggt hast.

    Suche nach %{icon}, um zu entscheiden, bei welchen Themen, Kategorien und Schlagwörtern du benachrichtigt werden möchtest. Weiteres findest du in den Benachrichtigungseinstellungen. + first_notification: "Deine erste Benachrichtigung! Wähle sie aus, um fortzufahren." dynamic_favicon: "Zeige Anzahl im Browser-Icon" skip_new_user_tips: description: "Keine Tipps für neue Benutzer anzeigen und nicht über den Erhalt von „Erste Schritte“-Abzeichen informieren" @@ -1001,20 +988,20 @@ de: color_scheme_default_on_all_devices: "Standard-Farbschema(s) auf allen meinen Geräten festlegen" color_scheme: "Farbschema" color_schemes: - default_description: "Theme Standard" - disable_dark_scheme: "gleich wie regulär" - dark_instructions: "Du kannst das dunkle Farbschema ausprobieren, indem du das dunkle Design auf deinem Gerät einschaltest." + default_description: "Theme-Standard" + disable_dark_scheme: "Gleich wie regulär" + dark_instructions: "Du kannst das dunkle Farbschema ausprobieren, indem du den dunklen Modus auf deinem Gerät einschaltest." undo: "Zurücksetzen" regular: "Regulär" dark: "Dunkler Modus" - default_dark_scheme: "(Standard)" + default_dark_scheme: "(Website-Standard)" dark_mode: "Dunkler Modus" - dark_mode_enable: "Dunkles Design automatisch aktivieren" + dark_mode_enable: "Dunkles Farbschema automatisch aktivieren" text_size_default_on_all_devices: "Mache diese Schriftgröße zum Standard für alle meine Geräte" allow_private_messages: "Anderen Benutzern erlauben, mir persönliche Nachrichten zu schicken" external_links_in_new_tab: "Öffne alle externen Links in einem neuen Tab" enable_quoting: "Aktiviere Zitatantwort mit dem hervorgehobenen Text" - enable_defer: "Aktiviere Verzögerung für das ungelesen Markieren von Themen" + enable_defer: "Zurückstellen aktivieren, um Themen als ungelesen zu markieren" change: "ändern" featured_topic: "Hervorgehobenes Thema" moderator: "%{user} ist ein Moderator" @@ -1046,7 +1033,7 @@ de: muted_tags: "Stummgeschaltet" muted_tags_instructions: "Du erhältst keine Benachrichtigungen über neue Themen mit diesen Schlagwörtern und die Themen werden auch nicht in der Liste der aktuellen Themen erscheinen." watched_categories: "Beobachtet" - watched_categories_instructions: "Du wirst automatisch alle neuen Themen in diesen Kategorien beobachten. Du wirst über alle neuen Beiträge und Themen benachrichtigt und die Anzahl der neuen Antworten wird bei den betroffenen Themen angezeigt." + watched_categories_instructions: "Du wirst automatisch alle neuen Themen in diesen Kategorien beobachten. Du wirst über alle neuen Beiträge und Themen benachrichtigt und die Anzahl der neuen Beiträge wird bei den betroffenen Themen angezeigt." tracked_categories: "Verfolgt" tracked_categories_instructions: "Du wirst automatisch alle Themen in diesen Kategorien verfolgen. Die Anzahl der neuen Beiträge wird neben dem Thema erscheinen." watched_first_post_categories: "Ersten Beitrag beobachten" @@ -1054,34 +1041,34 @@ de: watched_first_post_tags: "Ersten Beitrag beobachten" watched_first_post_tags_instructions: "Du erhältst eine Benachrichtigung für den ersten Beitrag in jedem neuen Thema mit diesen Schlagwörtern." muted_categories: "Stummgeschaltet" - muted_categories_instructions: "Du erhältst keine Benachrichtigungen über neue Themen in dieser Kategorie und die Themen werden auch nicht in der Liste der Kategorien oder der aktuellen Themen erscheinen." - muted_categories_instructions_dont_hide: "Du bekommst keine Benachrichtigung über irgendetwas an neuen Themen in diesen Kategorien." + muted_categories_instructions: "Du erhältst keine Benachrichtigungen über neue Themen in diesen Kategorien und sie werden auch nicht in der Liste der Kategorien oder der aktuellen Themen erscheinen." + muted_categories_instructions_dont_hide: "Du erhältst keine Benachrichtigungen über neue Themen in diesen Kategorien." regular_categories: "Stammgast" regular_categories_instructions: "Diese Kategorien werden in den Themenlisten „Neueste“ und „Top“ angezeigt." - no_category_access: "Moderaturen haben eingeschränkte Kategorien-Berechtigungen, Speichern ist nicht verfügbar." + no_category_access: "Moderatoren haben eingeschränkte Kategorien-Berechtigungen, Speichern ist nicht verfügbar." delete_account: "Lösche mein Benutzerkonto" delete_account_confirm: "Möchtest du wirklich dein Benutzerkonto permanent löschen? Diese Aktion kann nicht rückgängig gemacht werden!" deleted_yourself: "Dein Benutzerkonto wurde erfolgreich gelöscht." - delete_yourself_not_allowed: "Bitte kontaktiere das Team, wenn du möchtest, dass dein Konto gelöscht wird." + delete_yourself_not_allowed: "Bitte kontaktiere ein Team-Mitglied, wenn du möchtest, dass dein Konto gelöscht wird." unread_message_count: "Nachrichten" admin_delete: "Löschen" users: "Benutzer" muted_users: "Stummgeschaltet" - muted_users_instructions: "Alle Benachrichtigungen und PMs von diesen Benutzern unterdrücken." + muted_users_instructions: "Alle Benachrichtigungen und persönlichen Nachrichten von diesen Benutzern unterdrücken." allowed_pm_users: "Erlaubt" - allowed_pm_users_instructions: "Nur PMs von diesen Benutzern zulassen." + allowed_pm_users_instructions: "Nur persönliche Nachrichten von diesen Benutzern zulassen." allow_private_messages_from_specific_users: "Nur bestimmten Benutzern erlauben, mir persönliche Nachrichten zu senden" ignored_users: "Ignoriert" ignored_users_instructions: "Alle Beiträge, Benachrichtigungen und persönlichen Nachrichten von diesen Benutzern unterdrücken." tracked_topics_link: "Anzeigen" automatically_unpin_topics: "Angeheftete Themen automatisch loslösen, wenn ich deren letzten Beitrag gelesen habe." apps: "Apps" - revoke_access: "Entziehe Zugriffsrecht" + revoke_access: "Zugriffsrecht entziehen" undo_revoke_access: "Zugriffsrecht wiederherstellen" api_approved: "Genehmigt:" api_last_used_at: "Zuletzt benutzt am:" - theme: "Design" - save_to_change_theme: 'Das Thema wird aktualisiert, wenn du auf "%{save_text}“ geklickt hast.' + theme: "Theme" + save_to_change_theme: 'Das Theme wird aktualisiert, nachdem du auf „%{save_text}“ geklickt hast.' home: "Standard-Startseite" staged: "Vorbereitet" staff_counters: @@ -1149,7 +1136,7 @@ de: enable: "Zwei-Faktor-Authentifizierung verwalten" disable_all: "Alle deaktivieren" forgot_password: "Passwort vergessen?" - confirm_password_description: "Bitte bestätige dein Passwort um fortzufahren" + confirm_password_description: "Bitte bestätige dein Passwort, um fortzufahren" name: "Name" label: "Code" rate_limit: "Bitte warte ein wenig, bevor du es mit einem anderen Authentifizierungscode versuchst." @@ -1160,7 +1147,7 @@ de: short_description: | Schütze dein Konto mit Einweg-Sicherheitscodes. extended_description: | - Zwei-Faktor-Authentifizierung erhöht die Sicherheit deines Kontos, indem zusätzlich zum Passwort ein einmalig gültiger Code erforderlich ist. Codes können auf Android und iOS Geräten generiert werden. + Zwei-Faktor-Authentifizierung erhöht die Sicherheit deines Kontos, indem zusätzlich zum Passwort ein einmalig gültiger Code erforderlich ist. Codes können auf Android- und iOS-Geräten generiert werden. oauth_enabled_warning: "Bitte beachte, dass soziale Anmeldemethoden deaktiviert werden, sobald die Zwei-Faktor-Authentifizierung für dein Konto aktiviert wurde." use: "Benutze die Authentifizierungs-App" enforced_notice: "Du musst die Zwei-Faktor-Authentifizierung aktivieren, um auf die Website zugreifen zu können." @@ -1168,26 +1155,26 @@ de: disable_confirm: "Sollen wirklich alle Methoden zur Zwei-Faktor-Authentifizierung deaktiviert werden?" save: "Speichern" edit: "Bearbeiten" - edit_title: "Authenticator bearbeiten" + edit_title: "Authentifikator bearbeiten" edit_description: "Authentifikator-Name" enable_security_key_description: | - Wenn du deinen Hardware-Sicherheitsschlüssel vorbereitet hast, klicke unten auf Registrieren. + Wenn du deinen Hardware-Sicherheitsschlüssel vorbereitet hast, klicke unten auf „Registrieren“. totp: title: "Token-basierte Authentifikatoren" add: "Authentifikator hinzufügen" default_name: "Mein Authentifikator" - name_and_code_required_error: "Du musst einen Namen und den Code aus deiner Authentifizierungs-App angeben." + name_and_code_required_error: "Du musst einen Namen und den Code aus deiner Authentifikator-App angeben." security_key: register: "Registrieren" title: "Sicherheitsschlüssel" add: "Sicherheitsschlüssel hinzufügen" default_name: "Hauptsicherheitsschlüssel" not_allowed_error: "Der Registrierungsvorgang für den Sicherheitsschlüssel ist abgelaufen oder wurde abgebrochen." - already_added_error: "Du hast diesen Sicherheitsschlüssel bereits registriert. Du musst Ihn nicht erneut registrieren." + already_added_error: "Du hast diesen Sicherheitsschlüssel bereits registriert. Du musst ihn nicht erneut registrieren." edit: "Bearbeite den Sicherheitsschlüssel" save: "Speichern" edit_description: "Name des Sicherheitsschlüssels" - name_required_error: "Ein wird ein Name für den Sicherheitsschlüssel benötigt." + name_required_error: "Du musst einen Namen für deinen Sicherheitsschlüssel angeben." change_about: title: "„Über mich“ ändern" error: "Beim Ändern dieses Wertes ist ein Fehler aufgetreten." @@ -1198,7 +1185,7 @@ de: invalid: "Der Benutzernamen ist nicht zulässig. Er darf nur Zahlen und Buchstaben enthalten." add_email: title: "E-Mail hinzufügen" - add: "Hinzufügen" + add: "hinzufügen" change_email: title: "E-Mail-Adresse ändern" taken: "Entschuldige, diese E-Mail-Adresse ist nicht verfügbar." @@ -1219,11 +1206,11 @@ de: image_is_not_a_square: "Achtung: Wir haben dein Bild zugeschnitten, weil Höhe und Breite nicht übereingestimmt haben." logo_small: "Das kleine Logo der Website. Es wird standardmäßig verwendet." change_profile_background: - title: "Profil Kopfzeile" - instructions: "Profil Kopfzeilen werden zentriert und haben eine Standardbreite von 1110px." + title: "Profil-Kopfzeile" + instructions: "Profil-Kopfzeilen werden zentriert und haben eine Standardbreite von 1110 px." change_card_background: title: "Benutzerkarten-Hintergrund" - instructions: "Hintergrundbilder werden zentriert und haben eine Standardbreite von 590px." + instructions: "Hintergrundbilder werden zentriert und haben eine Standardbreite von 590 px." change_featured_topic: title: "Hervorgehobenes Thema" instructions: "Eine Verknüpfung zu diesem Thema wird auf deiner Benutzerkarte und deinem Profil sein." @@ -1231,24 +1218,24 @@ de: title: "E-Mail" primary: "Primäre E-Mail-Adresse" secondary: "Weitere E-Mail-Adressen" - primary_label: "erste" + primary_label: "primär" unconfirmed_label: "unbestätigt" - resend_label: "Bestätigungs E-Mail erneut senden" - resending_label: "sende..." + resend_label: "Bestätigungs-E-Mail erneut senden" + resending_label: "sende …" resent_label: "E-Mail gesendet" update_email: "E-Mail-Adresse ändern" - set_primary: "Primäre E-Mail Adresse festlegen" + set_primary: "Primäre E-Mail-Adresse festlegen" destroy: "E-Mail entfernen" - add_email: "Alternative E-Mail Adresse hinzufügen" + add_email: "Alternative E-Mail-Adresse hinzufügen" auth_override_instructions: "E-Mails können vom Authentifizierungsanbieter aktualisiert werden." no_secondary: "Keine weiteren E-Mail-Adressen" instructions: "Nie öffentlich gezeigt." - admin_note: "Hinweis: Ein Administrator, der die E-Mail-Adresse eines anderen Nicht-Administratorbenutzers ändert, zeigt an, dass der Benutzer den Zugriff auf sein ursprüngliches E-Mail-Konto verloren hat. Daher wird eine E-Mail zum Zurücksetzen des Kennworts an seine neue Adresse gesendet. Die E-Mail-Adresse des Benutzers ändert sich erst, wenn der Vorgang zum Zurücksetzen des Kennworts abgeschlossen ist." + admin_note: "Hinweis: Ein Administrator, der die E-Mail-Adresse eines anderen Nicht-Administratorbenutzers ändert, zeigt an, dass der Benutzer den Zugriff auf sein ursprüngliches E-Mail-Konto verloren hat. Daher wird eine E-Mail zum Zurücksetzen des Passworts an seine neue Adresse gesendet. Die E-Mail-Adresse des Benutzers ändert sich erst, wenn der Vorgang zum Zurücksetzen des Passworts abgeschlossen ist." ok: "Wir senden dir zur Bestätigung eine E-Mail" - required: "Bitte gib eine E-Mail Adresse ein" + required: "Bitte gib eine E-Mail-Adresse ein" invalid: "Bitte gib eine gültige E-Mail-Adresse ein" - authenticated: "Deine E-Mail-Adresse wurde von %{provider} bestätigt" - invite_auth_email_invalid: "Ihre Einladungs-E-Mail stimmt nicht mit der von %{provider} authentifizierten E-Mail überein" + authenticated: "Deine E-Mail wurde von %{provider} authentifiziert" + invite_auth_email_invalid: "Deine Einladungs-E-Mail stimmt nicht mit der von %{provider} authentifizierten E-Mail überein" authenticated_by_invite: "Deine E-Mail wurde durch die Einladung authentifiziert" frequency_immediately: "Wir werden dir sofort eine E-Mail senden, wenn du die betroffenen Inhalte noch nicht gelesen hast." frequency: @@ -1260,10 +1247,10 @@ de: revoke: "Entziehen" cancel: "Abbrechen" not_connected: "(nicht verbunden)" - confirm_modal_title: "Verbinde %{provider} Konto" + confirm_modal_title: "Verbinde „%{provider}“-Konto" confirm_description: - account_specific: "Dein %{provider} Konto '%{account_description}' wird für die Authentifizierung genutzt werden." - generic: "Dein %{provider} Konto wird für die Authentifzierung genutzt werden." + account_specific: "Dein „%{provider}“-Konto „%{account_description}“ wird für die Authentifizierung genutzt." + generic: "Dein „%{provider}“-Konto wird für die Authentifizierung genutzt." name: title: "Name" instructions: "dein vollständiger Name (optional)" @@ -1280,10 +1267,10 @@ de: not_available_no_suggestion: "Nicht verfügbar" too_short: "Dein Benutzername ist zu kurz" too_long: "Dein Benutzername ist zu lang" - checking: "Verfügbarkeit wird geprüft…" + checking: "Verfügbarkeit des Benutzernamens wird geprüft …" prefilled: "E-Mail-Adresse entspricht diesem registrierten Benutzernamen" required: "Bitte einen Benutzernamen eingeben" - edit: "Benutzername bearbeiten" + edit: "Benutzernamen bearbeiten" locale: title: "Oberflächensprache" instructions: "Die Sprache der Forumsoberfläche. Diese Änderung tritt nach dem Neuladen der Seite in Kraft." @@ -1302,10 +1289,10 @@ de: show_all: "Alle anzeigen (%{count})" show_few: "Weniger anzeigen" was_this_you: "Warst das du?" - was_this_you_description: "Wenn du das gewesen bist, empfehlen wir dir, dein Passwort zu ändern und dich überall abzumelden." + was_this_you_description: "Falls du es nicht warst, empfehlen wir dir, dein Passwort zu ändern und dich überall abzumelden." browser_and_device: "%{browser} auf %{device}" secure_account: "Mein Konto absichern" - latest_post: "Dein letzter Beitrag…" + latest_post: "Dein letzter Beitrag …" device_location: '%{device} – %{location}' browser_active: '%{browser} | gerade aktiv' browser_last_seen: "%{browser} | %{date}" @@ -1321,7 +1308,7 @@ de: enable_physical_keyboard: "Unterstützung für physische Tastatur auf dem iPad aktivieren" text_size: title: "Schriftgröße" - smallest: "Kleinster" + smallest: "Am kleinsten" smaller: "Kleiner" normal: "Normal" larger: "Größer" @@ -1332,10 +1319,10 @@ de: contextual: "Neuer Seiteninhalt" like_notification_frequency: title: "Benachrichtigung für erhaltene Likes anzeigen" - always: "immer" - first_time_and_daily: "für den ersten Like sowie maximal täglich" - first_time: "nur für den ersten Like eines Beitrags" - never: "nie" + always: "Immer" + first_time_and_daily: "Für den ersten Like sowie maximal täglich" + first_time: "Nur für den ersten Like eines Beitrags" + never: "Nie" email_previous_replies: title: "Füge vorherige Beiträge ans Ende von E-Mails an" unless_emailed: "sofern noch nicht gesendet" @@ -1350,12 +1337,12 @@ de: every_month: "jeden Monat" every_six_months: "alle sechs Monate" email_level: - title: "Sende mir eine E-Mail, wenn mich jemand zitiert, auf meine Beiträge antwortet, meinen @Namen erwähnt oder mich zu einem Thema einlädt." + title: "Sende mir eine E-Mail, wenn mich jemand zitiert, auf meine Beiträge antwortet, meinen Benutzernamen mit @ erwähnt oder mich zu einem Thema einlädt." always: "immer" only_when_away: "nur bei Abwesenheit" never: "nie" email_messages_level: "Sende mir eine E-Mail, wenn mir jemand eine Nachricht sendet." - include_tl0_in_digests: "Inhalte neuer Benutzer in E-Mail-Zusammenfassung einschließen" + include_tl0_in_digests: "Inhalte neuer Benutzer in E-Mail-Zusammenfassung einschließen." email_in_reply_to: "Einen Auszug aus dem beantworteten Beitrag in E-Mails einfügen." other_settings: "Andere" categories_settings: "Kategorien" @@ -1367,12 +1354,12 @@ de: after_2_days: "in den letzten 2 Tagen erstellt" after_1_week: "in der letzten Woche erstellt" after_2_weeks: "in den letzten 2 Wochen erstellt" - auto_track_topics: "Betrachtete Themen automatisch folgen" + auto_track_topics: "Betrachtete Themen automatisch verfolgen" auto_track_options: never: "nie" immediately: "sofort" after_30_seconds: "nach 30 Sekunden" - after_1_minute: "nach 1 Minute" + after_1_minute: "nach einer Minute" after_2_minutes: "nach 2 Minuten" after_3_minutes: "nach 3 Minuten" after_4_minutes: "nach 4 Minuten" @@ -1388,7 +1375,7 @@ de: redeemed_tab: "Angenommen" redeemed_tab_with_count: "Angenommen (%{count})" invited_via: "Einladung" - invited_via_link: "Link %{key} (%{count} / %{max} eingelöst)" + invited_via_link: "Link %{key} (%{count}/%{max} angenommen)" groups: "Gruppen" topic: "Thema" sent: "Erstellt/Zuletzt gesendet" @@ -1399,7 +1386,7 @@ de: reinvite: "E-Mail erneut senden" reinvited: "Einladung erneut gesendet" removed: "Entfernt" - search: "zum Suchen nach Einladungen hier eingeben…" + search: "zum Suchen nach Einladungen hier eingeben …" user: "Eingeladener Benutzer" none: "Keine Einladungen anzuzeigen." truncated: @@ -1415,7 +1402,7 @@ de: removed_all: "Alle abgelaufenen Einladungen entfernt!" remove_all_confirm: "Sollen alle abgelaufenen Einladungen entfernt werden?" reinvite_all: "Alle Einladungen erneut senden" - reinvite_all_confirm: "Bist Du dir sicher alle Einladungen nochmals zu senden?" + reinvite_all_confirm: "Bist du sicher, dass du alle Einladungen erneut versenden möchtest?" reinvited_all: "Alle Einladungen gesendet!" time_read: "Lesezeit" days_visited: "Besuchstage" @@ -1423,7 +1410,7 @@ de: create: "Einladen" generate_link: "Einladungslink erstellen" link_generated: "Hier ist dein Einladungslink!" - valid_for: "Der Einladungslink ist nur für die Adresse %{email} gültig" + valid_for: "Der Einladungslink ist nur für die E-Mail-Adresse %{email} gültig" single_user: "Einladen per E-Mail" multiple_user: "Per Link einladen" invite_link: @@ -1435,7 +1422,7 @@ de: invite: new_title: "Einladung erstellen" edit_title: "Einladung bearbeiten" - instructions: "Teile diesen Link, um sofort Zugriff auf diese Seite zu gewähren" + instructions: "Teile diesen Link, um sofort Zugriff auf diese Website zu gewähren" copy_link: "Link kopieren" expires_in_time: "Läuft in %{time} ab" expired_at_time: "Abgelaufen um %{time}" @@ -1444,7 +1431,7 @@ de: restrict_email: "Auf eine E-Mail-Adresse beschränken" max_redemptions_allowed: "Max. Verwendungen" add_to_groups: "Zu Gruppen hinzufügen" - invite_to_topic: "Kommen Sie zu diesem Thema" + invite_to_topic: "Dieses Thema aufrufen" expires_at: "Verfällt nach" custom_message: "Optionale persönliche Nachricht" send_invite_email: "Speichern und E-Mail senden" @@ -1456,9 +1443,9 @@ de: text: "Massen-Einladung" instructions: |

    Lade eine Liste von Benutzern ein, um deine Community schnell in Gang zu bringen. Bereite eine CSV-Datei mit mindestens einer Zeile pro E-Mail-Adresse vor, die du einladen möchtest. Die folgenden kommaseparierten Informationen können zur Verfügung gestellt werden, wenn du Personen zu Gruppen hinzufügen oder sie bei der ersten Anmeldung an ein bestimmtes Thema senden möchtest.

    -
    max@mustermann.de,erster_gruppenname;zweiter_gruppenname,topic_id
    +
    john@smith.com,first_group_name;second_group_name,topic_id

    An jede E-Mail-Adresse in deiner hochgeladenen CSV-Datei wird eine Einladung gesendet. Du kannst diese später verwalten.

    - progress: "Hochgeladen %{progress}%..." + progress: "%{progress} % hochgeladen …" success: "Die Datei wurde erfolgreich hochgeladen. Du erhältst eine Nachricht, sobald der Vorgang abgeschlossen ist." error: "Die Datei sollte im CSV Format vorliegen." password: @@ -1471,7 +1458,7 @@ de: instructions: "mindestens %{count} Zeichen" required: "Bitte Passwort eingeben" summary: - title: "Übersicht" + title: "Zusammenfassung" stats: "Statistiken" time_read: "Lesezeit" recent_time_read: "aktuelle Lesezeit" @@ -1501,13 +1488,13 @@ de: other: "Lesezeichen" top_replies: "Die besten Beiträge" no_replies: "Noch keine Antworten." - more_replies: "weitere Beiträge" - top_topics: "Die besten Themen" + more_replies: "Weitere Antworten" + top_topics: "Angesagte Themen" no_topics: "Noch keine Themen." - more_topics: "weitere Themen" + more_topics: "Weitere Themen" top_badges: "Die besten Abzeichen" no_badges: "Noch keine Abzeichen." - more_badges: "weitere Abzeichen" + more_badges: "Weitere Abzeichen" top_links: "Die besten Links" no_links: "Noch keine Links." most_liked_by: "Häufigste Likes von" @@ -1524,14 +1511,14 @@ de: avatar: title: "Profilbild" header_title: "Profil, Nachrichten, Lesezeichen und Einstellungen" - name_and_description: "%{name} - %{description}" + name_and_description: "%{name} – %{description}" edit: "Profilbild bearbeiten" title: title: "Titel" none: "(keiner)" primary_group: title: "Hauptgruppe" - none: "(keiner)" + none: "(keine)" filters: all: "Alle" stream: @@ -1539,7 +1526,7 @@ de: sent_by: "Gesendet von" private_message: "Senden" the_topic: "das Thema" - loading: "Wird geladen…" + loading: "Wird geladen …" errors: prev_page: "während des Ladens" reasons: @@ -1550,39 +1537,39 @@ de: not_found: "Seite nicht gefunden" desc: network: "Bitte überprüfe deine Netzwerkverbindung." - network_fixed: "Sieht aus, als wäre es wieder da." + network_fixed: "Sieht aus, als wäre sie wieder da." server: "Fehlercode: %{status}" forbidden: "Du darfst das nicht ansehen." - not_found: "Hoppla! Die Anwendung hat versucht eine URL zu laden, die nicht existiert." - unknown: "Etwas ist schief gelaufen." + not_found: "Hoppla! Die Anwendung hat versucht, eine URL zu laden, die nicht existiert." + unknown: "Etwas ist schiefgelaufen." buttons: back: "Zurück" again: "Erneut versuchen" fixed: "Seite laden" modal: - close: "Schließen" + close: "schließen" dismiss_error: "Fehler ignorieren" close: "Schließen" - assets_changed_confirm: "Diese Seite hat gerade ein Software-Upgrade erhalten. Jetzt die neueste Version bekommen?" + assets_changed_confirm: "Diese Website hat gerade ein Software-Upgrade erhalten. Jetzt die neueste Version abrufen?" logout: "Du wurdest abgemeldet." refresh: "Aktualisieren" home: "Startseite" read_only_mode: enabled: "Diese Website befindet sich im Nur-Lesen-Modus. Du kannst weiterhin Inhalte lesen, aber das Erstellen von Beiträgen, Vergeben von Likes und Durchführen einiger weiterer Aktionen ist derzeit nicht möglich." - login_disabled: "Die Anmeldung ist deaktiviert während sich die Website im Nur-Lesen-Modus befindet." - logout_disabled: "Die Abmeldung ist deaktiviert während sich die Website im Nur-Lesen-Modus befindet." + login_disabled: "Die Anmeldung ist deaktiviert, während sich die Website im Nur-Lesen-Modus befindet." + logout_disabled: "Die Abmeldung ist deaktiviert, während sich die Website im Nur-Lesen-Modus befindet." too_few_topics_and_posts_notice_MF: >- - Lass die Diskussion beginnen! Es {currentTopics, plural, one {ist # Thema} other {sind # Themen}} und {currentPosts, plural, one {# Beitrag} other {# Beiträge}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredTopics, plural, one {# Thema} other {# Themen}} und {requiredPosts, plural, one {# Beitrag} other {# Beiträge}}. Dieser Hinweis wird nur Teammitgliedern angezeigt. + Lass die Diskussion beginnen! Es {currentTopics, plural, one {ist # Thema} other {sind # Themen}} und {currentPosts, plural, one {# Beitrag} other {# Beiträge}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredTopics, plural, one {# Thema} other {# Themen}} und {requiredPosts, plural, one {# Beitrag} other {# Beiträge}}. Dieser Hinweis wird nur Team-Mitgliedern angezeigt. too_few_topics_notice_MF: >- - Lass die Diskussion beginnen! Es {currentTopics, plural, one {ist # Thema} other {sind # Themen}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredTopics, plural, one {# Thema} other {# Themen}}. Dieser Hinweis wird nur Teammitgliedern angezeigt. + Lass die Diskussion beginnen! Es {currentTopics, plural, one {ist # Thema} other {sind # Themen}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredTopics, plural, one {# Thema} other {# Themen}}. Dieser Hinweis wird nur Team-Mitgliedern angezeigt. too_few_posts_notice_MF: >- - Lass die Diskussion beginnen! Es {currentPosts, plural, one {ist # Beitrag} other {sind # Beiträge}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredPosts, plural, one {# Beitrag} other {# Beiträge}}. Dieser Hinweis wird nur Teammitgliedern angezeigt. + Lass die Diskussion beginnen! Es {currentPosts, plural, one {ist # Beitrag} other {sind # Beiträge}} vorhanden. Besucher brauchen mehr zum Lesen und Beantworten – wir empfehlen mindestens {requiredPosts, plural, one {# Beitrag} other {# Beiträge}}. Dieser Hinweis wird nur Team-Mitgliedern angezeigt. logs_error_rate_notice: - reached_hour_MF: "{relativeAge} – {rate, plural, one {# Fehler/Stunde} other {# errors/hour}} hat die Grenze der Webseiten-Einstellung von {limit, plural, one {# Fehler/Stunde} other {# Fehler/Stunde}} erreicht." - reached_minute_MF: "{relativeAge} – {rate, plural, one {# Fehler/Minute} other {# Fehler/Minute}} hat die Grenze der Webseiten-Einstellung von {limit, plural, one {# Fehler/Minute} other {# Fehler/Minute}} erreicht." - exceeded_hour_MF: "{relativeAge} – {rate, plural, one {# Fehler/Stunde} other {# Fehler/Stunde}} hat die Grenze der Webseiten-Einstellung von {limit, plural, one {# Fehler/Stunde} other {# Fehler/Stunde}} überschritten." - exceeded_minute_MF: "{relativeAge} – {rate, plural, one {# Fehler/Minute} other {# Fehler/Minute}} hat die Grenze der Webseiten-Einstellung von {limit, plural, one {# Fehler/Minute} other {# Fehler/Minute}} überschritten." - learn_more: "mehr erfahren…" + reached_hour_MF: "{relativeAge} – {rate, plural, one {# Fehler/Stunde} other {# Fehler/Stunde}} hat die Grenze der Website-Einstellung von {limit, plural, one {# Fehler/Stunde} other {# Fehlern/Stunde}} erreicht." + reached_minute_MF: "{relativeAge} – {rate, plural, one {# Fehler/Minute} other {# Fehler/Minute}} hat die Grenze der Website-Einstellung von {limit, plural, one {# Fehler/Minute} other {# Fehlern/Minute}} erreicht." + exceeded_hour_MF: "{relativeAge} – {rate, plural, one {# Fehler/Stunde} other {# Fehler/Stunde}} hat die Grenze der Website-Einstellung von {limit, plural, one {# Fehler/Stunde} other {# Fehlern/Stunde}} überschritten." + exceeded_minute_MF: "{relativeAge} – {rate, plural, one {# Fehler/Minute} other {# Fehler/Minute}} hat die Grenze der Website-Einstellung von {limit, plural, one {# Fehler/Minute} other {# Fehlern/Minute}} überschritten." + learn_more: "mehr erfahren …" first_post: Erster Beitrag mute: Stummschalten unmute: Stummschaltung aufheben @@ -1601,7 +1588,7 @@ de: hide_session: "Erinnere mich morgen" hide_forever: "Nein danke" hidden_for_session: "In Ordnung, wir fragen dich morgen wieder. Du kannst dir auch jederzeit unter „Anmelden“ ein Benutzerkonto erstellen." - intro: "Hallo! Es sieht so aus, als ob dir diese Diskussion gefällt, aber Du hast bislang noch kein Konto angelegt." + intro: "Hallo! Es sieht so aus, als ob dir diese Diskussion gefällt, aber du hast bislang noch kein Konto angelegt." value_prop: "Wenn du ein Benutzerkonto anlegst, merken wir uns, was du gelesen hast, damit du immer dort fortsetzen kannst, wo du aufgehört hast. Du kannst auch Benachrichtigungen – hier oder per E-Mail – erhalten, wenn jemand auf deine Beiträge antwortet. Beiträge, die dir gefallen, kannst du mit einem Like versehen und diese Freude mit allen teilen. :heartpulse:" summary: enabled_description: "Du siehst gerade eine Zusammenfassung des Themas: die interessantesten Beiträge, die von der Community bestimmt wurden." @@ -1618,13 +1605,13 @@ de: disable: "Gelöschte Beiträge anzeigen" private_message_info: title: "Nachricht" - invite: "Lade andere ein ..." - edit: "Hinzufügen oder Entfernen ..." - remove: "Entfernen..." - add: "Hinzufügen..." - leave_message: "Möchtest du diese Nachricht wirklich verlassen?" - remove_allowed_user: "Willst du %{name} wirklich aus dieser Unterhaltung entfernen?" - remove_allowed_group: "Willst du %{name} wirklich aus dieser Unterhaltung entfernen?" + invite: "Lade andere ein …" + edit: "Hinzufügen oder entfernen …" + remove: "Entfernen …" + add: "Hinzufügen …" + leave_message: "Möchtest du diese Nachricht wirklich hinterlassen?" + remove_allowed_user: "Willst du %{name} wirklich aus dieser Nachricht entfernen?" + remove_allowed_group: "Willst du %{name} wirklich aus dieser Nachricht entfernen?" email: "E-Mail-Adresse" username: "Benutzername" last_seen: "Zuletzt gesehen" @@ -1637,14 +1624,14 @@ de: subheader_title: "Lass uns dein Konto erstellen" disclaimer: "Mit der Registrierung stimmst du der Datenschutzerklärung und den Nutzungsbedingungen zu." title: "Erstelle dein Konto" - failed: "Etwas ist fehlgeschlagen. Vielleicht ist diese E-Mail-Adresse bereits registriert. Versuche den 'Passwort vergessen'-Link." + failed: "Etwas ist schiefgelaufen. Vielleicht ist diese E-Mail-Adresse bereits registriert. Versuche es mit dem „Passwort vergessen“-Link." forgot_password: title: "Passwort zurücksetzen" action: "Ich habe mein Passwort vergessen" invite: "Gib deinen Benutzernamen oder deine E-Mail-Adresse ein. Wir senden dir eine E-Mail zum Zurücksetzen des Passworts." reset: "Passwort zurücksetzen" complete_username: "Wenn ein Benutzerkonto dem Benutzernamen %{username} entspricht, solltest du in Kürze eine E-Mail mit Anweisungen zum Zurücksetzen deines Passwortes erhalten." - complete_email: "Wenn ein Benutzerkonto der E-Mail %{email} entspricht, solltest du in Kürze eine E-Mail mit Anweisungen zum Zurücksetzen deines Passwortes erhalten." + complete_email: "Wenn ein Benutzerkonto der E-Mail-Adresse %{email} entspricht, solltest du in Kürze eine E-Mail mit Anweisungen zum Zurücksetzen deines Passwortes erhalten." complete_username_found: "Wir haben ein zum Benutzernamen %{username} gehörendes Konto gefunden. Du solltest in Kürze eine E-Mail mit Anweisungen zum Zurücksetzen deines Passwortes erhalten." complete_email_found: "Wir haben ein zu %{email} gehörendes Benutzerkonto gefunden. Du solltest in Kürze eine E-Mail mit Anweisungen zum Zurücksetzen deines Passwortes erhalten." complete_username_not_found: "Es gibt kein Konto mit dem Benutzernamen %{username}" @@ -1655,13 +1642,13 @@ de: email_login: link_label: "Sende mir einen Anmeldelink" button_label: "über E-Mail" - login_link: "Überspringe das Passwort; Schicke mir einen Login-Link per E-Mail" + login_link: "Überspringe das Passwort; sende mir einen Anmeldelink" emoji: "Emoji sperren" - complete_username: "Sofern ein Benutzerkonto mit dem Benutzername %{username} existiert, solltest du in Kürze eine E-Mail mit einem Anmeldelink erhalten." + complete_username: "Sofern ein Benutzerkonto mit dem Benutzernamen %{username} existiert, solltest du in Kürze eine E-Mail mit einem Anmeldelink erhalten." complete_email: "Sofern ein Benutzerkonto für %{email} existiert, solltest du in Kürze eine E-Mail mit einem Anmeldelink erhalten." - complete_username_found: "Wir haben ein Benutzerkonto mit dem Benutzername %{username} gefunden, du solltest in Kürze einen Anmeldelink erhalten." + complete_username_found: "Wir haben ein Benutzerkonto mit dem Benutzernamen %{username} gefunden, du solltest in Kürze einen Anmeldelink erhalten." complete_email_found: "Wir haben ein Benutzerkonto für %{email} erhalten, du solltest in Kürze einen Anmeldelink erhalten." - complete_username_not_found: "Es existiert kein Benutzerkonto mit dem Benutzername %{username}." + complete_username_not_found: "Es existiert kein Benutzerkonto mit dem Benutzernamen %{username}." complete_email_not_found: "Es existiert kein Benutzerkonto für %{email}." confirm_title: Weiter zu %{site_name} logging_in_as: Anmeldung als %{email} @@ -1675,37 +1662,37 @@ de: second_factor_title: "Zwei-Faktor-Authentifizierung" second_factor_description: "Bitte gib den Authentifizierungscode aus deiner App ein:" second_factor_backup: "Anmeldung mit einem Wiederherstellungscode" - second_factor_backup_title: "Zwei-Faktor-Backup" + second_factor_backup_title: "Zwei-Faktor-Back-up" second_factor_backup_description: "Bitte gib einen deiner Wiederherstellungscodes ein:" second_factor: "Anmeldung mit einer Authentifizierungs-App" - security_key_description: "Wenn Du Deinen physischen Sicherheitsschlüssel vorbereitet hast, klicke unten auf die Schaltfläche \"Mit Sicherheitsschlüssel authentifizieren\"." - security_key_alternative: "Versuche einen anderen Weg" + security_key_description: "Wenn du deinen physischen Sicherheitsschlüssel vorbereitet hast, klicke unten auf die Schaltfläche „Mit Sicherheitsschlüssel authentifizieren“." + security_key_alternative: "Versuche es anderweitig" security_key_authenticate: "Mit Sicherheitsschlüssel authentifizieren" security_key_not_allowed_error: "Der Authentifizierungsprozess für den Sicherheitsschlüssel ist abgelaufen oder wurde abgebrochen." security_key_no_matching_credential_error: "Im angegebenen Sicherheitsschlüssel wurden keine übereinstimmenden Anmeldeinformationen gefunden." security_key_support_missing_error: "Dein aktuelles Gerät oder Browser unterstützt die Verwendung von Sicherheitsschlüsseln nicht. Bitte verwende eine andere Methode." - email_placeholder: "E-Mail / Benutzername" + email_placeholder: "E-Mail/Benutzername" caps_lock_warning: "Feststelltaste ist aktiviert" error: "Unbekannter Fehler" - cookies_error: "Dein Browser scheint Cookies deaktiviert zu haben. Du kannst dich möglicherweise nicht anmelden, ohne diese zuerst zu aktivieren" - rate_limit: "Warte bitte ein wenig, bevor du erneut versuchst dich anzumelden." + cookies_error: "Dein Browser scheint Cookies deaktiviert zu haben. Du kannst dich möglicherweise nicht anmelden, ohne diese zuerst zu aktivieren." + rate_limit: "Warte bitte ein wenig, bevor du erneut versuchst, dich anzumelden." blank_username: "Bitte gib deine E-Mail-Adresse oder deinen Benutzernamen ein." blank_username_or_password: "Bitte gib deine E-Mail-Adresse oder deinen Benutzernamen und dein Passwort ein." reset_password: "Passwort zurücksetzen" - logging_in: "Anmeldung läuft…" + logging_in: "Anmeldung läuft …" or: "Oder" - authenticating: "Authentifiziere…" - awaiting_activation: "Dein Konto ist noch nicht aktiviert. Verwende den 'Passwort vergessen'-Link, um eine weitere E-Mail mit Anweisungen zur Aktivierung zu erhalten." - awaiting_approval: "Dein Konto wurde noch nicht von einem Teammitglied genehmigt. Du bekommst eine E-Mail, sobald das geschehen ist." + authenticating: "Authentifiziere …" + awaiting_activation: "Dein Konto ist noch nicht aktiviert. Verwende den „Passwort vergessen“-Link, um eine weitere E-Mail mit Anweisungen zur Aktivierung zu erhalten." + awaiting_approval: "Dein Konto wurde noch nicht von einem Team-Mitglied genehmigt. Du bekommst eine E-Mail, sobald das geschehen ist." requires_invite: "Entschuldige, der Zugriff auf dieses Forum ist nur mit einer Einladung möglich." not_activated: "Du kannst dich noch nicht anmelden. Wir haben dir schon eine E-Mail zur Aktivierung an %{sentTo} geschickt. Bitte folge den Anweisungen in dieser E-Mail, um dein Benutzerkonto zu aktivieren." - not_allowed_from_ip_address: "Von dieser IP-Adresse darfst du dich nicht anmelden." - admin_not_allowed_from_ip_address: "Von dieser IP-Adresse darfst du dich nicht als Administrator anmelden." + not_allowed_from_ip_address: "Von dieser IP-Adresse aus darfst du dich nicht anmelden." + admin_not_allowed_from_ip_address: "Von dieser IP-Adresse aus darfst du dich nicht als Administrator anmelden." resend_activation_email: "Klicke hier, um eine neue Aktivierungsmail zu schicken." omniauth_disallow_totp: "Für dein Benutzerkonto ist die Zwei-Faktor-Authentifizierung aktiviert. Bitte melde dich mit deinem Passwort an." resend_title: "Aktivierungsmail erneut senden" change_email: "E-Mail-Adresse ändern" - provide_new_email: "Gib’ eine neue Adresse an und wir senden deine Bestätigungsmail erneut." + provide_new_email: "Gib eine neue Adresse an und wir senden deine Bestätigungsmail erneut." submit_new_email: "E-Mail-Adresse aktualisieren" sent_activation_email_again: "Wir haben dir eine weitere E-Mail zur Aktivierung an %{currentEmail} geschickt. Es könnte ein paar Minuten dauern, bis diese ankommt; sieh auch im Spam-Ordner nach." sent_activation_email_again_generic: "Wir haben eine weitere Aktivierungsmail verschickt. Es könnte ein paar Minuten dauern, bis diese ankommt; schaue auch im Spam-Ordner nach." @@ -1735,11 +1722,11 @@ de: backup_code: "Benutze stattdessen einen Wiederherstellungscode" invites: accept_title: "Einladung" - emoji: "Umschlag Emoji" + emoji: "Umschlag-Emoji" welcome_to: "Willkommen bei %{site_name}!" invited_by: "Du wurdest eingeladen von:" - social_login_available: "Du wirst dich auch über andere sozialen Netzwerken mit dieser E-Mail-Adresse anmelden können." - your_email: "Die E-Mail-Adresse deines Benutzerkontos ist %{email}" + social_login_available: "Du wirst dich auch über andere soziale Netzwerke mit dieser E-Mail-Adresse anmelden können." + your_email: "Die E-Mail-Adresse deines Benutzerkontos ist %{email}." accept_invite: "Einladung annehmen" success: "Dein Konto wurde erstellt und du bist jetzt angemeldet." name_label: "Name" @@ -1748,15 +1735,15 @@ de: password_reset: continue: "Weiter zu %{site_name}" emoji_set: - apple_international: "Apple" + apple_international: "Apple/International" google: "Google" twitter: "Twitter" win10: "Windows 10" google_classic: "Google Classic" facebook_messenger: "Facebook Messenger" category_page_style: - categories_only: "nur Kategorien" - categories_with_featured_topics: "Kategorien mit empfohlenen Themen" + categories_only: "Nur Kategorien" + categories_with_featured_topics: "Kategorien mit hervorgehobenen Themen" categories_and_latest_topics: "Kategorien und aktuelle Themen" categories_and_top_topics: "Kategorien und angesagte Themen" categories_boxes: "Boxen mit Unterkategorien" @@ -1767,7 +1754,7 @@ de: alt: "Alt" enter: "Enter" conditional_loading_section: - loading: Wird geladen… + loading: Wird geladen … category_row: topic_count: one: "%{count} Thema in dieser Kategorie" @@ -1780,12 +1767,12 @@ de: other: "+ %{count} Unterkategorien" select_kit: filter_by: "Filtern nach: %{name}" - select_to_filter: "Wählen Sie einen zu filternden Wert aus" - default_header_text: Auswählen… + select_to_filter: "Wähle einen zu filternden Wert aus" + default_header_text: Auswählen … no_content: Keine Treffer gefunden - filter_placeholder: Suchen… - filter_placeholder_with_any: Suchen oder erzeugen - create: "Erstelle: '%{content}'" + filter_placeholder: Suchen … + filter_placeholder_with_any: Suchen oder erstellen … + create: "Erstelle: „%{content}“" max_content_reached: one: "Du kannst nur %{count} Eintrag auswählen." other: "Du kannst nur %{count} Einträge auswählen." @@ -1794,10 +1781,10 @@ de: other: "Wähle mindestens %{count} Einträge aus." invalid_selection_length: one: "Die Auswahl muss mindestens %{count} Zeichen umfassen." - other: "Die Auswahl muss aus mindestens %{count} Zeichen bestehen." + other: "Die Auswahl muss mindestens %{count} Zeichen umfassen." components: tag_drop: - filter_for_more: Filtern nach mehr... + filter_for_more: Filtern nach mehr … categories_admin_dropdown: title: "Kategorien verwalten" date_time_picker: @@ -1809,7 +1796,7 @@ de: people_&_body: Menschen und Körper animals_&_nature: Tiere und Natur food_&_drink: Essen und Getränke - travel_&_places: Verkehr und Orte + travel_&_places: Reisen und Orte activities: Tätigkeiten objects: Objekte symbols: Symbole @@ -1823,7 +1810,7 @@ de: dark_tone: Dunkle Hautfarbe default: Benutzerdefinierte Emojis shared_drafts: - title: "Gemeinsame Vorlagen" + title: "Gemeinsame Entwürfe" notice: "Dieses Thema ist nur für diejenigen sichtbar, die gemeinsame Entwürfe veröffentlichen können." destination_category: "Ziel-Kategorie" publish: "Gemeinsame Vorlage veröffentlichen" @@ -1831,7 +1818,7 @@ de: publishing: "Thema wird veröffentlicht …" composer: emoji: "Emoji :)" - more_emoji: "mehr…" + more_emoji: "mehr …" options: "Optionen" whisper: "flüstern" unlist: "unsichtbar" @@ -1840,7 +1827,7 @@ de: toggle_unlisted: "Sichtbarkeit umschalten" posting_not_on_topic: "Auf welches Thema möchtest du antworten?" saved_local_draft_tip: "lokal gespeichert" - similar_topics: "Dein Thema hat Ähnlichkeit mit…" + similar_topics: "Dein Thema hat Ähnlichkeit mit …" drafts_offline: "Entwürfe offline" edit_conflict: "Konflikt bearbeiten" group_mentioned_limit: @@ -1850,9 +1837,9 @@ de: one: "Indem du %{group} erwähnst, bist du dabei, %{count} Person zu benachrichtigen – bist du dir sicher?" other: "Indem du %{group} erwähnst, bist du dabei, %{count} Personen zu benachrichtigen – bist du dir sicher?" cannot_see_mention: - category: "Du hast %{username} erwähnt, aber diese Person wird nicht benachrichtigt da sie keinen Zugriff auf diese Kategorie hat. Füge sie einer Gruppe hinzu die Zugriff auf diese Kategorie hat." - private: "Du hast %{username} erwähnt, aber diese Person wird nicht benachrichtigt da sie diese persönliche Nachricht nicht sehen kann. Lade sie zu dieser PM ein." - duplicate_link: "Es sieht so aus als wäre dein Link auf %{domain} in dem Thema bereits geschrieben worden von @%{username} in einer Antwort vor %{ago} – bist du sicher, dass du ihn noch einmal schreiben möchtest?" + category: "Du hast %{username} erwähnt, aber diese Person wird nicht benachrichtigt, da sie keinen Zugriff auf diese Kategorie hat. Füge sie einer Gruppe hinzu, die Zugriff auf diese Kategorie hat." + private: "Du hast %{username} erwähnt, aber diese Person wird nicht benachrichtigt, da sie diese persönliche Nachricht nicht sehen kann. Lade sie zu dieser persönlichen Nachricht ein." + duplicate_link: "Es sieht so aus, als ob dein Link zu %{domain} bereits im Thema von @%{username} in einer Antwort vor %{ago} geteilt wurde – bist du sicher, dass du ihn noch einmal teilen möchtest?" reference_topic_title: "AW: %{title}" error: title_missing: "Titel ist erforderlich" @@ -1872,55 +1859,55 @@ de: one: "Du musst mindestens %{count} Schlagwort wählen." other: "Du musst mindestens %{count} Schlagwörter wählen." topic_template_not_modified: "Bitte füge Details und Spezifikationen zu deinem Thema hinzu, indem du die Themenvorlage anpasst." - save_edit: "Speichern" + save_edit: "Bearbeitung speichern" overwrite_edit: "Bearbeitung überschreiben" - reply_original: "Auf das ursprünglichen Thema antworten" + reply_original: "Auf das ursprüngliche Thema antworten" reply_here: "Hier antworten" reply: "Antworten" cancel: "Abbrechen" create_topic: "Thema erstellen" create_pm: "Nachricht" create_whisper: "Flüstern" - create_shared_draft: "Erstelle gemeinsame Vorlage" - edit_shared_draft: "Gemeinsame Vorlage bearbeiten" - title: "Oder drücke Strg+Eingabetaste" + create_shared_draft: "Gemeinsamen Entwurf erstellen" + edit_shared_draft: "Gemeinsamen Entwurf bearbeiten" + title: "Oder drücke Strg + Eingabetaste" users_placeholder: "Benutzer hinzufügen" title_placeholder: "Um was geht es in dieser Diskussion? Schreib einen kurzen Satz." title_or_link_placeholder: "Gib einen Titel ein oder füge einen Link ein" - edit_reason_placeholder: "Warum bearbeitest du?" - topic_featured_link_placeholder: "Gib einen Link, der mit dem Titel angezeigt wird." + edit_reason_placeholder: "Welchen Grund gibt es für die Bearbeitung?" + topic_featured_link_placeholder: "Link zum Titel eingeben." remove_featured_link: "Link aus Thema entfernen." reply_placeholder: "Schreib hier. Verwende Markdown, BBCode oder HTML zur Formatierung. Füge Bilder ein oder ziehe sie herein." reply_placeholder_no_images: "Schreib hier. Verwende Markdown, BBCode oder HTML zur Formatierung." - reply_placeholder_choose_category: "Wähle vor dem Tippen eine Kategorie." - view_new_post: "Sieh deinen neuen Beitrag an." + reply_placeholder_choose_category: "Wähle vor der Eingabe eine Kategorie aus." + view_new_post: "Schau dir deinen neuen Beitrag an." saving: "Wird gespeichert" saved: "Gespeichert!" saved_draft: "Beitrags-Entwurf vorhanden. Tippen, um fortzusetzen." - uploading: "Wird hochgeladen…" + uploading: "Wird hochgeladen …" show_preview: "Vorschau zeigen" hide_preview: "Vorschau ausblenden" quote_post_title: "Ganzen Beitrag zitieren" bold_label: "F" - bold_title: "Fettgedruckt" - bold_text: "Fettgedruckter Text" + bold_title: "Fett gedruckt" + bold_text: "fett gedruckter Text" italic_label: "K" italic_title: "Betonung" - italic_text: "Betonter Text" + italic_text: "betonter Text" link_title: "Link" link_description: "gib hier eine Link-Beschreibung ein" link_dialog_title: "Link einfügen" - link_optional_text: "Optionaler Titel" + link_optional_text: "optionaler Titel" link_url_placeholder: "Füge eine URL ein oder tippe, um die Themen zu durchsuchen" - blockquote_title: "Blockquote" - blockquote_text: "Blockquote" + blockquote_title: "Blockzitat" + blockquote_text: "Blockzitat" code_title: "Vorformatierter Text" code_text: "vorformatierten Text mit 4 Leerzeichen einrücken" - paste_code_text: "Tippe oder füge den Code hier ein" + paste_code_text: "tippe oder füge den Code hier ein" upload_title: "Upload" upload_description: "gib hier eine Beschreibung des Uploads ein" olist_title: "Nummerierte Liste" - ulist_title: "Liste" + ulist_title: "Liste mit Aufzählungszeichen" list_item: "Listenelement" toggle_direction: "Schreibrichtung wechseln" help: "Hilfe zur Markdown-Formatierung" @@ -1929,48 +1916,48 @@ de: abandon: "Editor schließen und Entwurf verwerfen" enter_fullscreen: "Vollbild-Editor öffnen" exit_fullscreen: "Vollbild-Editor verlassen" - show_toolbar: "Composer-Werkzeugleiste anzeigen" - hide_toolbar: "Composer-Werkzeugleiste ausblenden" + show_toolbar: "Editor-Werkzeugleiste anzeigen" + hide_toolbar: "Editor-Werkzeugleiste ausblenden" modal_ok: "OK" modal_cancel: "Abbrechen" cant_send_pm: "Entschuldige, aber du kannst keine Nachricht an %{username} senden." yourself_confirm: - title: "Hast du vergessen Empfänger hinzuzufügen?" + title: "Hast du vergessen, Empfänger hinzuzufügen?" body: "Im Augenblick wird diese Nachricht nur an dich selbst gesendet!" slow_mode: - error: "Dieses Thema befindet sich im langsamen Modus. Du hast bereits kürzlich gepostet. Du kannst erneut in %{timeLeft}posten." + error: "Dieses Thema befindet sich im langsamen Modus. Du hast bereits kürzlich gepostet. Du kannst erneut in %{timeLeft} posten." admin_options_title: "Optionale Team-Einstellungen für dieses Thema" composer_actions: reply: Antworten draft: Entwurf edit: Bearbeiten reply_to_post: - label: Auf einen Beitrag von %{postUsername}antworten + label: Auf einen Beitrag von %{postUsername} antworten desc: Antworte auf einen bestimmten Beitrag reply_as_new_topic: - label: Antworte als verknüpftes Thema + label: Mit verknüpftem Thema antworten desc: Erstelle ein neues Thema, das auf dieses Thema verweist - confirm: Du hast einen neuen Themen-Entwurf gespeichert. Wenn du ein verlinktes Thema erstellst, wird er überschrieben. + confirm: Du hast einen neuen Themen-Entwurf gespeichert. Wenn du ein verknüpftes Thema erstellst, wird er überschrieben. reply_as_new_group_message: label: Als neue Gruppen-Nachricht antworten - desc: Erstellen Sie eine neue private Nachricht mit denselben Empfängern + desc: Erstelle eine neue private Nachricht mit denselben Empfängern reply_as_private_message: label: Neue Nachricht - desc: Erstelle eine neue Nachricht + desc: Erstelle eine neue persönliche Nachricht reply_to_topic: - label: Antworte auf Thema + label: Auf Thema antworten desc: Antworte auf das Thema, nicht auf einen bestimmten Beitrag toggle_whisper: label: Flüstermodus umschalten - desc: Ein geflüsterter Beitrag ist nur für Teammitglieder sichtbar + desc: Ein geflüsterter Beitrag ist nur für Team-Mitglieder sichtbar create_topic: label: "Neues Thema" shared_draft: - label: "Gemeinsame Vorlage" + label: "Gemeinsamer Entwurf" desc: "Entwurf eines Themas, das nur für erlaubte Benutzer sichtbar ist" toggle_topic_bump: - label: "Bump des Themas umschalten" - desc: "Antworten ohne das Datum der neuesten Antwort zu ändern" + label: "„Nach oben verschieben“ für Thema umschalten" + desc: "Antworten, ohne das Datum der neuesten Antwort zu ändern" reload: "Neu laden" ignore: "Ignorieren" notifications: @@ -1982,9 +1969,9 @@ de: one: "%{count} ungelesene Nachricht" other: "%{count} ungelesene Nachrichten" high_priority: - one: "%{count} ungelesene Benachrichtigungen hoher Priorität" - other: "%{count} ungelesene Benachrichtigungen mit hoher Priorität" - title: "Benachrichtigung über @Name-Erwähnungen, Antworten auf deine Beiträge und Themen, Nachrichten, usw." + one: "%{count} ungelesene Benachrichtigung hoher Priorität" + other: "%{count} ungelesene Benachrichtigungen hoher Priorität" + title: "Benachrichtigungen über namentliche Erwähnungen mit @, Antworten auf deine Beiträge und Themen, Nachrichten usw." none: "Die Benachrichtigungen können derzeit nicht geladen werden." empty: "Keine Benachrichtigungen gefunden." post_approved: "Dein Beitrag wurde genehmigt." @@ -2011,28 +1998,28 @@ de: invitee_accepted: "%{username} hat deine Einladung angenommen" moved_post: "%{username} hat %{description} verschoben" linked: "%{username} %{description}" - granted_badge: "Du hast '%{description}' verliehen bekommen" + granted_badge: "Du hast „%{description}“ verliehen bekommen" topic_reminder: "%{username} %{description}" - watching_first_post: "New Topic %{description}" - membership_request_accepted: "Mitgliedschaft akzeptiert in '%{group_name}' " + watching_first_post: "Neues Thema %{description}" + membership_request_accepted: "Mitgliedschaft akzeptiert in „%{group_name}“" membership_request_consolidated: one: "%{count} offene Mitgliedschaftsanfrage für '%{group_name}'" - other: "%{count} offene Mitgliedschaftsanfragen für '%{group_name}'" + other: "%{count} offene Mitgliedschaftsanfragen für „%{group_name}“" reaction: "%{username} %{description}" reaction_2: "%{username}, %{username2} %{description}" - votes_released: "%{description} - abgeschlossen" + votes_released: "%{description} – abgeschlossen" group_message_summary: - one: "%{count} Nachricht in deinem %{group_name} Posteingang" - other: "%{count} Nachrichten in deinem %{group_name} Posteingang" + one: "%{count} Nachricht in deinem „%{group_name}“-Posteingang" + other: "%{count} Nachrichten in deinem „%{group_name}“-Posteingang" popup: - mentioned: '%{username} hat dich in "%{topic}" - %{site_title} erwähnt' - group_mentioned: '%{username} hat dich in "%{topic}" - %{site_title} erwähnt' - quoted: '%{username} hat dich in "%{topic}" - %{site_title} zitiert' - replied: '%{username} hat dir in "%{topic}" - %{site_title} geantwortet' - posted: '%{username} hat in "%{topic}" - %{site_title} einen Beitrag verfasst' + mentioned: '%{username} hat dich erwähnt in „%{topic}“ – %{site_title}' + group_mentioned: '%{username} hat dich erwähnt in „%{topic}“ – %{site_title}' + quoted: '%{username} hat dich zitiert in „%{topic}“ – %{site_title}' + replied: '%{username} hat dir geantwortet in „%{topic}“ – %{site_title}' + posted: '%{username} hat geschrieben in „%{topic}“ – %{site_title}' private_message: '%{username} hat dir eine Nachricht geschickt in „%{topic}“ – %{site_title}' - linked: '%{username} hat in "%{topic}" - %{site_title} einen Beitrag von dir verlinkt' - watching_first_post: '%{username} hat ein neues Thema "%{topic}" - %{site_title} erstellt' + linked: '%{username} hat deinen Beitrag verlinkt in „%{topic}“ – %{site_title}' + watching_first_post: '%{username} hat ein neues Thema „%{topic}“ erstellt – %{site_title}' confirm_title: "Benachrichtigungen aktiviert – %{site_title}" confirm_body: "Erfolgreich! Benachrichtigungen wurden aktiviert." custom: "Benachrichtigung von %{username} auf %{site_title}" @@ -2058,7 +2045,7 @@ de: topic_reminder: "Themen-Erinnerung" liked_consolidated: "neue „Gefällt mir“-Angaben" post_approved: "Beitrag genehmigt" - membership_request_consolidated: "Neue Gruppenmitgliedschaftsanfragen" + membership_request_consolidated: "Neue Mitgliedschaftsanfragen" reaction: "neue Reaktion" votes_released: "Abstimmung wurde freigegeben" upload_selector: @@ -2079,7 +2066,7 @@ de: search: sort_by: "Sortieren nach" relevance: "Relevanz" - latest_post: "letzter Beitrag" + latest_post: "Neuester Beitrag" latest_topic: "Neuestes Thema" most_viewed: "Anzahl der Aufrufe" most_liked: "Anzahl der Likes" @@ -2092,12 +2079,12 @@ de: title: "suche nach Themen, Beiträgen, Benutzern oder Kategorien" full_page_title: "suche nach Themen oder Beiträgen" no_results: "Keine Ergebnisse gefunden." - no_more_results: "Es wurde keine weiteren Ergebnisse gefunden." + no_more_results: "Es wurden keine weiteren Ergebnisse gefunden." post_format: "#%{post_number} von %{username}" - results_page: "Suchergebnisse für '%{term}'" + results_page: "Suchergebnisse für „%{term}“" more_results: "Es gibt mehr Ergebnisse. Bitte grenze deine Suchkriterien weiter ein." cant_find: "Nicht gefunden, wonach du suchst?" - start_new_topic: "Wie wär’s mit einem neuen Thema?" + start_new_topic: "Wie wärs mit einem neuen Thema?" or_search_google: "Oder versuche stattdessen mit Google zu suchen:" search_google: "Versuche stattdessen mit Google zu suchen:" search_google_button: "Google" @@ -2122,12 +2109,12 @@ de: label: Mit dem Schlagwort filters: label: 'Themen/Beiträge einschränken:' - title: mit Treffer im Titel + title: Mit Treffer im Titel likes: Themen/Beiträge, die mir gefallen posted: Themen mit Beiträgen von mir - created: von mir erstellt + created: Von mir erstellt watching: Themen, die ich beobachte - tracking: Themen, denen ich folge + tracking: Themen, die ich verfolge private: Beiträge in meinen Unterhaltungen bookmarks: von mir mit Lesezeichen versehen first: Erste Beiträge in Themen @@ -2141,7 +2128,7 @@ de: label: 'Themen einschränken:' open: Offene Themen closed: Geschlossene Themen - public: sind öffentlich + public: Öffentliche Themen archived: Archivierte Themen noreplies: Themen ohne Antwort single_user: Themen mit nur einem Benutzer @@ -2171,7 +2158,7 @@ de: topics: new_messages_marker: "letzter Besuch" bulk: - select_all: "Wähle alle aus" + select_all: "Alle auswählen" clear_all: "Auswahl aufheben" unlist_topics: "Themen unsichtbar machen" relist_topics: "Themen neu auflisten" @@ -2179,11 +2166,11 @@ de: delete: "Themen löschen" dismiss: "Ignorieren" dismiss_read: "Blende alle ungelesenen Beiträge aus" - dismiss_button: "Ignorieren..." + dismiss_button: "Ignorieren …" dismiss_tooltip: "Nur die neuen Beiträge ignorieren oder Themen nicht mehr verfolgen" also_dismiss_topics: "Diese Themen nicht mehr verfolgen, sodass mir diese nicht mehr als ungelesen angezeigt werden" dismiss_new: "Neue Themen ignorieren" - toggle: "zu Massenoperationen auf Themen umschalten" + toggle: "Massenauswahl von Themen umschalten" actions: "Massenoperationen" change_category: "Kategorie auswählen" close_topics: "Themen schließen" @@ -2193,17 +2180,17 @@ de: change_notification_level: "Benachrichtigungsstufe ändern" choose_new_category: "Neue Kategorie für die gewählten Themen:" selected: - one: "Du hast ein Thema ausgewählt." + one: "Du hast %{count} Thema ausgewählt." other: "Du hast %{count} Themen ausgewählt." change_tags: "Schlagwörter ersetzen" append_tags: "Schlagwörter hinzufügen" - choose_new_tags: "Neue Schlagwörter für die gewählten Themen wählen:" + choose_new_tags: "Neue Schlagwörter für diese Themen wählen:" choose_append_tags: "Wähle neue Schlagwörter, die diesen Themen hinzugefügt werden sollen:" - changed_tags: "Die Schlagwörter der gewählten Themen wurden geändert." - remove_tags: "Alle Tags entfernen" + changed_tags: "Die Schlagwörter dieser Themen wurden geändert." + remove_tags: "Alle Schlagwörter entfernen" confirm_remove_tags: - one: "Alle Tags werden aus diesem Thema entfernt. Sind Sie sicher?" - other: "Alle Tags werden aus %{count} Themen entfernt. Sind Sie sicher?" + one: "Alle Schlagwörter werden aus diesem Thema entfernt. Bist du sicher?" + other: "Alle Schlagwörter werden aus %{count} Themen entfernt. Bist du sicher?" progress: one: "Fortschritt: %{count} Thema" other: "Fortschritt: %{count} Themen" @@ -2212,13 +2199,13 @@ de: new: "Es gibt für dich keine neuen Themen." read: "Du hast noch keine Themen gelesen." posted: "Du hast noch keine Beiträge verfasst." - latest: "Ihr seid alle eingeholt!" + latest: "Du bist auf dem Laufenden!" bookmarks: "Du hast noch keine Themen mit einem Lesezeichen versehen." category: "Es gibt keine Themen in %{category}." - top: "Es gibt keine Top-Themen." + top: "Es gibt keine angesagten Themen." educate: - new: '

    Ihre neuen Themen werden hier angezeigt. Standardmäßig gelten Themen als neu und zeigen einen Indikator an, wenn sie in den letzten 2 Tagen erstellt wurden.

    Besuchen Sie Ihre Einstellungen , um dies zu ändern.

    ' - unread: "

    Hier werden deine ungelesenen Themen angezeigt.

    Die Anzahl der ungelesenen Beiträge wird standardmäßig als 1 neben den Themen angezeigt, wenn du:

    • das Thema erstellt hast
    • auf das Thema geantwortet hast
    • das Thema länger als 4 Minuten gelesen hast

    Außerdem werden jene Themen berücksichtigt, die du in den Benachrichtigungseinstellungen eines jeden Themas mittels der \U0001F514 ausdrücklich auf Beobachten oder Verfolgen gesetzt hast.

    Du kannst das in deinen Einstellungen ändern.

    " + new: '

    Hier werden deine neuen Themen angezeigt. Standardmäßig werden Themen als neu erachtet und mit einem -Indikator dargestellt, wenn sie in den letzten 2 Tagen erstellt wurden.

    Besuche deine Einstellungen, um dies zu ändern.

    ' + unread: "

    Hier werden deine ungelesenen Themen angezeigt.

    Standardmäßig werden Themen als ungelesen erachtet und mit einem Zähler für ungelesene Beiträge 1 dargestellt, wenn du:

    • das Thema erstellt hast;
    • auf das Thema geantwortet hast;
    • das Thema für mehr als 4 Minuten gelesen hast.

    Oder falls du das Thema über \U0001F514 ausdrücklich auf „Verfolgen“ oder „Beobachten“ eingestellt hast.

    Besuche deine Einstellungen, um dies zu ändern.

    " bottom: latest: "Das waren die aktuellen Themen." posted: "Das waren alle Themen." @@ -2226,7 +2213,7 @@ de: new: "Das waren alle neuen Themen." unread: "Das waren alle ungelesen Themen." category: "Das waren alle Themen in der Kategorie „%{category}“." - tag: "Das waren alle Themen in der Kategorie „%{tag}“." + tag: "Das waren alle Themen mit dem Schlagwort „%{tag}“." top: "Das waren alle angesagten Themen." bookmarks: "Das waren alle Themen, die du mit einem Lesezeichen versehen hast." topic: @@ -2244,7 +2231,7 @@ de: title: "In Posteingang verschieben" help: "Nachricht in den Posteingang zurück verschieben" edit_message: - help: "Bearbeite ersten Beitrag dieser Nachricht" + help: "Ersten Beitrag dieser Unterhaltung bearbeiten" title: "Bearbeiten" defer: help: "Als ungelesen markieren" @@ -2254,8 +2241,8 @@ de: title: "Auf Profil hervorheben" remove_from_profile: warning: "Dein Profil hat bereits ein hervorgehobenes Thema. Wenn du fortfährst, wird dieses das bestehende Thema ersetzen." - help: "Entferne diesen Link zum Thema aus deinem Profil" - title: "Vom Profil entfernen" + help: "Entferne den Link zu diesem Thema aus deinem Benutzerprofil" + title: "Aus Profil entfernen" list: "Themen" new: "neues Thema" unread: "ungelesen" @@ -2272,21 +2259,21 @@ de: login_required: "Du musst dich anmelden, damit du dieses Thema sehen kannst." server_error: title: "Thema konnte nicht geladen werden" - description: "Entschuldige, wir konnten das Thema, wahrscheinlich wegen eines Verbindungsfehlers, nicht laden. Bitte versuche es erneut. Wenn das Problem bestehen bleibt, gib uns Bescheid." + description: "Entschuldige, wir konnten dieses Thema nicht laden, möglicherweise aufgrund eines Verbindungsproblems. Bitte versuche es erneut. Wenn das Problem weiterhin besteht, lass es uns wissen." not_found: title: "Thema nicht gefunden" description: "Entschuldige, wir konnten dieses Thema nicht finden. Wurde es vielleicht von einem Moderator entfernt?" total_unread_posts: - one: "du hast einen ungelesenen Beitrag in diesem Thema" + one: "du hast %{count} ungelesenen Beitrag in diesem Thema" other: "du hast %{count} ungelesene Beiträge in diesem Thema" unread_posts: - one: "Du hast einen ungelesenen, alten Beitrag zu diesem Thema" - other: "Du hast %{count} ungelesene, alte Beiträge zu diesem Thema" + one: "Du hast %{count} ungelesenen alten Beitrag in diesem Thema" + other: "Du hast %{count} ungelesene alte Beiträge in diesem Thema" new_posts: - one: "Es gibt einen neuen Beitrag zu diesem Thema seit du es das letzte Mal gelesen hast" - other: "Es gibt %{count} neue Beiträge zu diesem Thema seit du es das letzte Mal gelesen hast" + one: "Es gibt %{count} neuen Beitrag in diesem Thema, seit du es das letzte Mal gelesen hast" + other: "Es gibt %{count} neue Beiträge in diesem Thema, seit du es das letzte Mal gelesen hast" likes: - one: "Es gibt ein Like in diesem Thema" + one: "Es gibt %{count} Like in diesem Thema" other: "Es gibt %{count} Likes in diesem Thema" back_to_list: "Zurück zur Themenliste" options: "Themen-Optionen" @@ -2299,7 +2286,7 @@ de: read_more_MF: "Du {UNREAD, plural, =0 {} one {hast ein ungelesenes Thema } other {hast # ungelesene Themen } } {NEW, plural, =0 {} one { {BOTH, select, true{und } false {hast } other{}} ein neues Thema} other { {BOTH, select, true{und } false {hast } other{}} # neue Themen} }. Oder {CATEGORY, select, true {entdecke andere Themen in {catLink}} false {{latestLink}} other {}}" bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}" browse_all_categories: Alle Kategorien durchsehen - browse_all_tags: Alle Tags durchsuchen + browse_all_tags: Alle Schlagwörter durchsuchen view_latest_topics: aktuelle Themen anzeigen jump_reply_up: zur vorherigen Antwort springen jump_reply_down: zur nachfolgenden Antwort springen @@ -2328,20 +2315,20 @@ de: 24_hours: "24 Stunden" custom: "Benutzerdefinierte Dauer" slow_mode_notice: - duration: "Bitte zwischen dem Verfassen von Beiträgen %{duration} warten." + duration: "Bitte warte zwischen dem Verfassen von Beiträgen in diesem Thema %{duration}." topic_status_update: - title: "Zeitschaltuhren" + title: "Zeitschaltuhr" save: "Zeitschaltuhr aktivieren" num_of_hours: "Stunden:" - num_of_days: "Anzahl der Tage:" + num_of_days: "Tage:" remove: "Zeitschaltuhr deaktivieren" publish_to: "Veröffentlichen in:" when: "Wann:" time_frame_required: "Bitte wähle einen Zeitrahmen aus" - min_duration: "Die Dauer muss größer als 0 sein" + min_duration: "Dauer muss mehr als 0 betragen" max_duration: "Dauer muss weniger als 20 Jahre betragen" auto_update_input: - none: "Wähle einen Zeitbereich aus" + none: "Wähle einen Zeitrahmen aus" now: "Jetzt" later_today: "Im Laufe des Tages" tomorrow: "Morgen" @@ -2390,35 +2377,35 @@ de: auto_bump: "Dieses Thema wird automatisch nach oben verschoben %{timeLeft}." auto_reminder: "Du wirst über dieses Thema erinnert %{timeLeft}." auto_delete_replies: "Antworten zu diesem Thema werden nach %{duration} automatisch gelöscht." - auto_close_title: "Automatisches Schließen" + auto_close_title: "Einstellungen für automatisches Schließen" auto_close_immediate: - one: "Der letzte Beitrag in diesem Thema ist bereits eine Stunde alt. Das Thema wird daher sofort geschlossen." + one: "Der letzte Beitrag in diesem Thema ist bereits %{count} Stunde alt. Das Thema wird daher sofort geschlossen." other: "Der letzte Beitrag in diesem Thema ist bereits %{count} Stunden alt. Das Thema wird daher sofort geschlossen." auto_close_momentarily: - one: "Der letzte Beitrag im Thema ist bereits %{count} Stunden alt, daher wird das Thema vorübergehend geschlossen." - other: "Der letzte Beitrag im Thema ist bereits %{count} Stunden alt, daher wird das Thema kurzzeitig geschlossen." + one: "Der letzte Beitrag im Thema ist bereits %{count} Stunde alt, daher wird das Thema augenblicklich geschlossen." + other: "Der letzte Beitrag im Thema ist bereits %{count} Stunden alt, daher wird das Thema augenblicklich geschlossen." timeline: back: "Zurück" back_description: "Gehe zurück zum letzten ungelesenen Beitrag" - replies_short: "%{current} / %{total}" + replies_short: "%{current}/%{total}" progress: title: Themen-Fortschritt go_top: "Anfang" go_bottom: "Ende" go: "Los" jump_bottom: "springe zum letzten Beitrag" - jump_prompt: "springe zu..." + jump_prompt: "springe zu …" jump_prompt_of: one: "von %{count} Beitrag" other: "von %{count} Beiträgen" - jump_prompt_long: "Zu ... springen" + jump_prompt_long: "Zu … springen" jump_bottom_with_number: "springe zu Beitrag %{post_number}" jump_prompt_to_date: "zu Datum" jump_prompt_or: "oder" total: Beiträge insgesamt current: aktueller Beitrag notifications: - title: Ändere wie häufig du zu diesem Thema benachrichtigt wirst + title: ändere, wie häufig du zu diesem Thema benachrichtigt wirst reasons: mailing_list_mode: "Du hast den Mailinglisten-Modus aktiviert, daher wirst du über Antworten zu diesem Thema per E-Mail benachrichtigt" "3_10": "Du wirst Benachrichtigungen erhalten, weil du ein Schlagwort an diesem Thema beobachtest." @@ -2429,13 +2416,13 @@ de: "3_2": "Du wirst Benachrichtigungen erhalten, weil du dieses Thema beobachtest." "3_1": "Du wirst Benachrichtigungen erhalten, weil du dieses Thema erstellt hast." "3": "Du wirst Benachrichtigungen erhalten, weil du dieses Thema beobachtest." - "2_8": "Du wirst eine Anzahl neuer Antworten sehen, weil du diese Kategorie verfolgst." - "2_8_stale": "Du wirst eine Anzahl neuer Antworten sehen, weil du diese Kategorie verfolgt hast." - "2_4": "Du wirst eine Anzahl neuer Antworten sehen, weil du einen Beitrag in diesem Thema geschrieben hast." - "2_2": "Du wirst eine Anzahl neuer Antworten sehen, weil du dieses Thema verfolgst." - "2": 'Du wirst eine Anzahl neuer Antworten sehen, weil du dieses Thema gelesen hast.' - "1_2": "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." - "1": "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + "2_8": "Du wirst die Anzahl neuer Antworten sehen, weil du diese Kategorie verfolgst." + "2_8_stale": "Du wirst die Anzahl neuer Antworten sehen, weil du diese Kategorie verfolgt hast." + "2_4": "Du wirst die Anzahl neuer Antworten sehen, weil du einen Beitrag in diesem Thema geschrieben hast." + "2_2": "Du wirst die Anzahl neuer Antworten sehen, weil du dieses Thema verfolgst." + "2": 'Du wirst die Anzahl neuer Antworten sehen, weil du dieses Thema gelesen hast.' + "1_2": "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." + "1": "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." "0_7": "Du ignorierst alle Benachrichtigungen dieser Kategorie." "0_2": "Du ignorierst alle Benachrichtigungen dieses Themas." "0": "Du ignorierst alle Benachrichtigungen dieses Themas." @@ -2447,50 +2434,50 @@ de: description: "Du wirst über jeden neuen Beitrag in diesem Thema benachrichtigt und die Anzahl der neuen Antworten wird angezeigt." tracking_pm: title: "Verfolgen" - description: "Die Anzahl der neuen Antworten wird bei dieser Unterhaltung angezeigt. Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deine Nachricht antwortet." + description: "Die Anzahl der neuen Antworten wird bei dieser Unterhaltung angezeigt. Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." tracking: title: "Verfolgen" - description: "Die Anzahl der neuen Antworten wird bei diesem Thema angezeigt. Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + description: "Die Anzahl der neuen Antworten wird bei diesem Thema angezeigt. Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." regular: title: "Normal" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." regular_pm: title: "Normal" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deine Nachricht antwortet." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." muted_pm: title: "Stummgeschaltet" description: "Du erhältst keine Benachrichtigungen im Zusammenhang mit dieser Unterhaltung." muted: title: "Stummgeschaltet" - description: "Du erhältst keine Benachrichtigungen über neue Aktivitäten in diesem Thema und es wird auch nicht mehr in der Liste der letzten Beiträge erscheinen." + description: "Du erhältst keine Benachrichtigungen über neue Aktivitäten in diesem Thema und es wird auch nicht mehr in der Liste der aktuellen Themen erscheinen." actions: title: "Aktionen" recover: "Löschen rückgängig machen" delete: "Thema löschen" open: "Thema öffnen" close: "Thema schließen" - multi_select: "Beiträge auswählen..." + multi_select: "Beiträge auswählen …" slow_mode: "Aktiviere langsamen Modus" - timed_update: "Zeitschaltuhren…" - pin: "Thema anheften..." - unpin: "Thema loslösen..." + timed_update: "Zeitschaltuhren …" + pin: "Thema anheften …" + unpin: "Thema loslösen …" unarchive: "Thema aus Archiv holen" archive: "Thema archivieren" invisible: "Unsichtbar machen" visible: "Sichtbar machen" reset_read: "„Gelesen“ zurücksetzen" make_public: "Umwandeln in öffentliches Thema" - make_private: "in Nachricht umwandeln" - reset_bump_date: "Bump-Datum zurücksetzen" + make_private: "In persönliche Nachricht umwandeln" + reset_bump_date: "„Nach oben verschieben“-Datum zurücksetzen" feature: pin: "Thema anheften" unpin: "Thema loslösen" pin_globally: "Thema global anheften" - make_banner: "Ankündigungsbanner" - remove_banner: "Ankündigungsbanner entfernen" + make_banner: "Thema des Ankündigungsbanners" + remove_banner: "Thema des Ankündigungsbanners entfernen" reply: title: "Antworten" - help: "beginne damit eine Antwort auf dieses Thema zu verfassen" + help: "beginne damit, eine Antwort auf dieses Thema zu verfassen" clear_pin: title: "Loslösen" help: "Dieses Thema von der Themenliste loslösen, sodass es nicht mehr am Anfang der Liste steht." @@ -2509,21 +2496,21 @@ de: invite_users: "Einladen" print: title: "Drucken" - help: "Öffne eine Druckfreundliche Version dieses Themas" + help: "Öffne eine druckfreundliche Version dieses Themas" flag_topic: title: "Melden" help: "Dieses Thema den Moderatoren melden oder eine Nachricht senden." success_message: "Du hast dieses Thema erfolgreich gemeldet." make_public: - title: "Konvertiere zum öffentlichen Thema" + title: "In öffentliches Thema umwandeln" choose_category: "Bitte wähle eine Kategorie für das öffentliche Thema:" feature_topic: title: "Thema hervorheben" - pin: "Dieses Thema am Anfang der %{categoryLink} Kategorie anzeigen bis" - unpin: "Dieses Thema vom Anfang der %{categoryLink} Kategorie loslösen." - unpin_until: "Dieses Thema vom Anfang der %{categoryLink} Kategorie loslösen oder bis %{until} warten." + pin: "Dieses Thema am Anfang der „%{categoryLink}“-Kategorie anzeigen bis" + unpin: "Dieses Thema vom Anfang der „%{categoryLink}“-Kategorie loslösen." + unpin_until: "Dieses Thema vom Anfang der „%{categoryLink}“-Kategorie loslösen oder bis %{until} warten." pin_note: "Benutzer können das Thema für sich selbst loslösen." - pin_validation: "Ein Datum wird benötigt um diesen Beitrag anzuheften." + pin_validation: "Ein Datum wird benötigt, um diesen Beitrag anzuheften." not_pinned: "Es sind in %{categoryLink} keine Themen angeheftet." already_pinned: one: "Momentan in %{categoryLink} angeheftete Themen: %{count}" @@ -2539,12 +2526,12 @@ de: already_pinned_globally: one: "Momentan global angeheftete Themen: %{count}" other: "Momentan global angeheftete Themen: %{count}" - make_banner: "Macht das Thema zu einem Ankündigungsbanner, welcher am Anfang aller Seiten angezeigt wird." + make_banner: "Macht das Thema zu einem Ankündigungsbanner, welches am Anfang aller Seiten angezeigt wird." remove_banner: "Entfernt das Ankündigungsbanner vom Anfang aller Seiten." banner_note: "Benutzer können das Ankündigungsbanner schließen und so für sich selbst dauerhaft ausblenden. Es kann zu jeder Zeit höchstens ein Thema ein Banner sein." - no_banner_exists: "Es gibt kein Ankündigungsbanner." - banner_exists: "Es gibt bereits ein anderes Ankündigungsbanner." - inviting: "Einladungen werden gesendet…" + no_banner_exists: "Es gibt kein Thema für das Ankündigungsbanner." + banner_exists: "Es gibt bereits ein anderes Thema für das Ankündigungsbanner." + inviting: "Einladungen werden gesendet …" automatically_add_to_groups: "Diese Einladung beinhaltet auch Zugang zu den folgenden Gruppen:" invite_private: title: "Zu einer Unterhaltung einladen" @@ -2552,7 +2539,7 @@ de: email_or_username_placeholder: "E-Mail-Adresse oder Benutzername" action: "Einladen" success: "Wir haben den Benutzer gebeten, sich an dieser Unterhaltung zu beteiligen." - success_group: "Wir haben die Gruppe eingeladen, an dieser Nachricht mitzuwirken." + success_group: "Wir haben die Gruppe gebeten, sich an dieser Unterhaltung zu beteiligen." error: "Entschuldige, es gab einen Fehler beim Einladen des Benutzers." not_allowed: "Dieser Benutzer kann leider nicht eingeladen werden." group_name: "Gruppenname" @@ -2562,14 +2549,14 @@ de: username_placeholder: "Benutzername" action: "Einladung versenden" help: "per E-Mail oder Benachrichtigung weitere Personen zu diesem Thema einladen" - to_forum: "Wir senden eine kurze E-Mail, damit dein/e Freund/in sofort beitreten kann, indem die Person auf einen Link klickt." + to_forum: "Wir werden eine kurze E-Mail versenden, die einen direkten Beitritt per Link ermöglicht." discourse_connect_enabled: "Gib den Benutzernamen der Person ein, die du zu diesem Thema einladen möchtest." - to_topic_blank: "Gib den Benutzername oder die E-Mail-Adresse der Person ein, die du zu diesem Thema einladen willst." + to_topic_blank: "Gib den Benutzernamen oder die E-Mail-Adresse der Person ein, die du zu diesem Thema einladen möchtest." to_topic_email: "Du hast eine E-Mail-Adresse eingegeben. Wir werden eine Einladung versenden, die ein direktes Antworten auf dieses Thema ermöglicht." to_topic_username: "Du hast einen Benutzernamen eingegeben. Wir werden eine Benachrichtigung versenden und mit einem Link zur Teilnahme an diesem Thema einladen." - to_username: "Gib den Benutzername der Person ein, die du einladen möchtest. Wir werden eine Benachrichtigung versenden und mit einem Link zur Teilnahme an diesem Thema einladen." + to_username: "Gib den Benutzernamen der Person ein, die du einladen möchtest. Wir werden eine Benachrichtigung versenden und mit einem Link zur Teilnahme an diesem Thema einladen." email_placeholder: "name@example.com" - success_email: "Wir haben eine Einladung an %{invitee}gesendet. Wir werden dich benachrichtigen, wenn die Einladung eingelöst wird. Überprüfe die Registerkarte Einladungen auf deiner Benutzerseite, um deine Einladungen zu verfolgen." + success_email: "Wir haben eine Einladung an %{invitee} gesendet. Wir werden dich benachrichtigen, wenn die Einladung angenommen wird. Überprüfe die Registerkarte „Einladungen“ auf deiner Benutzerseite, um deine Einladungen zu verfolgen." success_username: "Wir haben den Benutzer gebeten, sich an diesem Thema zu beteiligen." error: "Es tut uns leid, wir konnten diese Person nicht einladen. Wurde diese Person vielleicht schon eingeladen? (Einladungen sind in ihrer Zahl beschränkt)" success_existing_email: "Ein Benutzer mit der E-Mail-Adresse %{emailOrUsername} existiert bereits. Wir haben den Benutzer eingeladen, sich an diesem Thema zu beteiligen." @@ -2601,22 +2588,22 @@ de: one: "Bitte wähle das Thema, in welches du den Beitrag verschieben möchtest." other: "Bitte wähle das Thema, in welches du die %{count} Beiträge verschieben möchtest." move_to_new_message: - title: "Verschiebe in neue Nachricht" - action: "verschiebe in neue Nachricht" - message_title: "Neue Überschrift der Nachricht" - radio_label: "Neue Nachricht" + title: "Verschieben in neue Unterhaltung" + action: "verschieben in neue Unterhaltung" + message_title: "Neuer Titel der Unterhaltung" + radio_label: "Neue Unterhaltung" participants: "Teilnehmer" instructions: - one: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit dem ausgewählten Beitrag zu befüllen." - other: "Du bist dabei, eine neue Nachricht zu erstellen und sie mit den %{count} ausgewählten Beiträgen zu befüllen." + one: "Du bist dabei, eine neue Unterhaltung zu erstellen und sie mit dem ausgewählten Beitrag zu befüllen." + other: "Du bist dabei, eine neue Unterhaltung zu erstellen und sie mit den %{count} ausgewählten Beiträgen zu befüllen." move_to_existing_message: - title: "Verschiebe in vorhandene Nachricht" - action: "in eine vorhandene Nachricht verschieben" - radio_label: "Vorhandene Nachricht" + title: "Verschieben in vorhandene Unterhaltung" + action: "in eine vorhandene Unterhaltung verschieben" + radio_label: "Vorhandene Unterhaltung" participants: "Teilnehmer" instructions: - one: "Bitte wähle die Nachricht, in welche du den Beitrag versschieben möchtest." - other: "Bitte wähle die Nachricht, in welche du die %{count} verschieben möchtest." + one: "Bitte wähle die Unterhaltung, in welche du den Beitrag verschieben möchtest." + other: "Bitte wähle die Unterhaltung, in welche du die %{count} Beiträge verschieben möchtest." merge_posts: title: "Ausgewählte Beiträge zusammenführen" action: "ausgewählte Beiträge zusammenführen" @@ -2644,14 +2631,14 @@ de: one: "Bitte wähle einen neuen Eigentümer für den Beitrag von @%{old_user}" other: "Bitte wähle einen neuen Eigentümer für %{count} Beiträge von @%{old_user}" instructions_without_old_user: - one: "Bitte wählen Sie einen neuen Eigentümer für den Beitrag" + one: "Bitte wähle einen neuen Eigentümer für den Beitrag" other: "Bitte wähle einen neuen Eigentümer für %{count} Beiträge" change_timestamp: - title: "Zeitstempel ändern…" - action: "Erstelldatum ändern" - invalid_timestamp: "Das Erstelldatum kann nicht in der Zukunft liegen." - error: "Beim Ändern des Erstelldatums des Themas ist ein Fehler aufgetreten." - instructions: "Wähle bitte ein neues Erstelldatum für das Thema aus. Alle Beitrage im Thema werden unter Beibehaltung der Zeitdifferenz ebenfalls angepasst." + title: "Zeitstempel änden …" + action: "Zeitstempel ändern" + invalid_timestamp: "Zeitstempel kann nicht in der Zukunft liegen." + error: "Beim Ändern des Zeitstempels des Themas ist ein Fehler aufgetreten." + instructions: "Wähle bitte einen neuen Zeitstempel für das Thema aus. Alle Beiträge im Thema werden unter Beibehaltung der Zeitdifferenz ebenfalls angepasst." multi_select: select: "auswählen" selected: "ausgewählt (%{count})" @@ -2666,11 +2653,11 @@ de: title: "Beitrag und alle Antworten zu Auswahl hinzufügen" select_below: label: "auswählen inkl. folgender" - title: "Beitrag und alle folgende zu Auswahl hinzufügen" + title: "Beitrag und alles danach zu Auswahl hinzufügen" delete: ausgewählte löschen cancel: Auswahlvorgang abbrechen select_all: alle auswählen - deselect_all: keine auswählen + deselect_all: alle abwählen description: one: Du hast %{count} Beitrag ausgewählt. other: "Du hast %{count} Beiträge ausgewählt." @@ -2691,26 +2678,26 @@ de: show_hidden: "Ignorierte Inhalte anzeigen." deleted_by_author_simple: "(Beitrag vom Autor gelöscht)" collapse: "zuklappen" - expand_collapse: "erweitern/minimieren" - locked: "Ein Teammitglied hat diesen Beitrag für die weitere Bearbeitung gesperrt" + expand_collapse: "erweitern/zuklappen" + locked: "ein Team-Mitglied hat diesen Beitrag für die weitere Bearbeitung gesperrt" gap: - one: "einen versteckten Beitrag anzeigen" + one: "%{count} versteckten Beitrag anzeigen" other: "%{count} versteckte Beiträge anzeigen" notice: - new_user: "Dies ist der erste Beitrag von %{user} — lasst uns das neue Mitglied in unserer Community willkommen hei­ßen!" - returning_user: "Es ist eine Weile her, dass wir %{user} gesehen haben. — Der letzter Beitrag war %{time}." + new_user: "Dies ist der erste Beitrag von %{user} – lasst uns das neue Mitglied in unserer Community willkommen heißen!" + returning_user: "Es ist eine Weile her, dass wir %{user} gesehen haben – der letzte Beitrag ist vom %{time}." unread: "Beitrag ist ungelesen" has_replies: one: "%{count} Antwort" other: "%{count} Antworten" has_replies_count: "%{count}" - unknown_user: "(unbekannt/gelöschter Benutzer)" + unknown_user: "(unbekannter/gelöschter Benutzer)" has_likes_title: one: "dieser Beitrag gefällt %{count} Person" other: "dieser Beitrag gefällt %{count} Personen" has_likes_title_only_you: "dir gefällt dieser Beitrag" has_likes_title_you: - one: "dir und einer weiteren Person gefällt dieser Beitrag" + one: "dir und %{count} weiteren Person gefällt dieser Beitrag" other: "dir und %{count} weiteren Personen gefällt dieser Beitrag" filtered_replies_hint: one: "Diesen Beitrag und seine Antwort ansehen" @@ -2724,23 +2711,23 @@ de: create: "Entschuldige, es gab einen Fehler beim Anlegen des Beitrags. Bitte versuche es noch einmal." edit: "Entschuldige, es gab einen Fehler beim Bearbeiten des Beitrags. Bitte versuche es noch einmal." upload: "Entschuldige, es gab einen Fehler beim Hochladen der Datei. Bitte versuche es noch einmal." - file_too_large: "Entschuldigung, die Datei ist zu groß (maximale Größe ist %{max_size_kb}kb). Wie wäre es, die Datei in einen Cloud Sharing Service hochzuladen und dann den Link einzufügen?" + file_too_large: "Entschuldige, die Datei ist zu groß (die maximale Größe beträgt %{max_size_kb} KB). Wie wäre es, die Datei bei einem Cloud-Sharing-Service hochzuladen und dann den Link einzufügen?" too_many_uploads: "Entschuldige, du darfst immer nur eine Datei hochladen." too_many_dragged_and_dropped_files: one: "Es kann leider nur %{count} Datei gleichzeitig hochgeladen werden." other: "Es können leider nur %{count} Dateien gleichzeitig hochgeladen werden." - upload_not_authorized: "Entschuldigung, die Datei die du hochladen möchtest ist nicht erlaubt (erlaubte Dateiendungen sind: %{authorized_extensions})." + upload_not_authorized: "Entschuldige, die Datei, die du hochladen möchtest, ist nicht erlaubt (erlaubte Dateiendungen sind: %{authorized_extensions})." image_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Bilder hochladen." - attachment_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Dateien hochladen." - attachment_download_requires_login: "Entschuldige, du musst angemeldet sein, um Dateien herunterladen zu können." + attachment_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Anhänge hochladen." + attachment_download_requires_login: "Entschuldige, du musst angemeldet sein, um Anhänge herunterladen zu können." cancel_composer: confirm: "Was würdest du gerne mit deinem Beitrag machen?" discard: "Verwerfen" save_draft: "Entwurf für später speichern" keep_editing: "Weiter bearbeiten" via_email: "dieser Beitrag ist per E-Mail eingetroffen" - via_auto_generated_email: "dieser Beitrag ist als automatisch generierte E-Mail eingegangen" - whisper: "Dieser Beitrag ist Privat für Moderatoren." + via_auto_generated_email: "dieser Beitrag ist per automatisch generierter E-Mail eingegangen" + whisper: "dies ist ein privater geflüsterter Beitrag für Moderatoren" wiki: about: "dieser Beitrag ist ein Wiki" archetypes: @@ -2761,13 +2748,13 @@ de: share: "Link zu diesem Beitrag teilen" more: "Mehr" delete_replies: - confirm: "Möchtest du auch die Antworten auf diese Beiträge löschen?" + confirm: "Möchtest du auch die Antworten auf diesen Beitrag löschen?" direct_replies: - one: "Ja, und eine direkte Antwort" - other: "Ja, und %{count} direkte Antworten" + one: "Ja und %{count} direkte Antwort" + other: "Ja und %{count} direkte Antworten" all_replies: - one: "Ja, und eine Antwort" - other: "Ja, und %{count} Antworten" + one: "Ja und %{count} Antwort" + other: "Ja und alle %{count} Antworten" just_the_post: "Nein, nur diesen Beitrag" admin: "Administrative Aktionen" wiki: "Wiki erstellen" @@ -2782,21 +2769,21 @@ de: lock_post: "Beitrag sperren" lock_post_description: "verhindern, dass der Autor den Beitrag bearbeitet" unlock_post: "Beitrag entsperren" - unlock_post_description: "erlaube, dass der Autor den Beitrag bearbeitet" + unlock_post_description: "erlauben, dass der Autor den Beitrag bearbeitet" delete_topic_disallowed_modal: "Du hast keine Berechtigung, dieses Thema zu löschen. Wenn du wirklich möchtest, dass es gelöscht wird, melde es mit einer Begründung, um einen Moderator darauf aufmerksam zu machen." delete_topic_disallowed: "du hast keine Berechtigung, dieses Thema zu löschen" delete_topic_confirm_modal: - one: "Dieses Thema hat derzeit mehr als %{count} Aufruf und kann ein beliebtes Suchziel sein. Sind Sie sicher, dass Sie dieses Thema vollständig löschen wollen, anstatt es zu bearbeiten, um es zu verbessern?" - other: "Dieses Thema hat derzeit mehr als %{count} Aufrufe und kann ein beliebtes Suchziel sein. Sind Sie sicher, dass Sie dieses Thema vollständig löschen wollen, anstatt es zu bearbeiten, um es zu verbessern?" + one: "Dieses Thema hat derzeit mehr als %{count} Aufruf und kann ein beliebtes Suchziel sein. Bist du sicher, dass du dieses Thema komplett löschen möchtest, anstatt es zu bearbeiten, um es zu verbessern?" + other: "Dieses Thema hat derzeit mehr als %{count} Aufrufe und kann ein beliebtes Suchziel sein. Bist du sicher, dass du dieses Thema komplett löschen möchtest, anstatt es zu bearbeiten, um es zu verbessern?" delete_topic_confirm_modal_yes: "Ja, dieses Thema löschen" delete_topic_confirm_modal_no: "Nein, dieses Thema behalten" delete_topic_error: "Beim Löschen dieses Themas ist ein Fehler aufgetreten" delete_topic: "Thema löschen" - add_post_notice: "Team Notiz hinzufügen" - change_post_notice: "Team Notiz ändern" - delete_post_notice: "Team Notiz löschen" - remove_timer: "Stoppuhr entfernen" - edit_timer: "Timer bearbeiten" + add_post_notice: "Team-Notiz hinzufügen" + change_post_notice: "Team-Notiz ändern" + delete_post_notice: "Team-Notiz löschen" + remove_timer: "Zeitschaltuhr deaktivieren" + edit_timer: "Zeitschaltuhr bearbeiten" actions: people: like: @@ -2814,7 +2801,7 @@ de: by_you: off_topic: "Du hast das als „am Thema vorbei“ gemeldet" spam: "Du hast das als Spam gemeldet" - inappropriate: "Du hast das als Unangemessen gemeldet" + inappropriate: "Du hast das als „unangemessen“ gemeldet" notify_moderators: "Du hast dies den Moderatoren gemeldet" notify_user: "Du hast diesem Benutzer eine Nachricht gesendet" delete: @@ -2833,9 +2820,9 @@ de: last: "Letzte Überarbeitung" hide: "Überarbeitung verstecken" show: "Überarbeitung anzeigen" - revert: "Auf Revision %{revision} zurücksetzen" - edit_wiki: "Bearbeite Wiki" - edit_post: "Bearbeite Beitrag" + revert: "Auf Überarbeitung %{revision} zurücksetzen" + edit_wiki: "Wiki bearbeiten" + edit_post: "Beitrag bearbeiten" comparing_previous_to_current_out_of_total: "%{previous} %{icon} %{current} / %{total}" displays: inline: @@ -2845,7 +2832,7 @@ de: title: "Zeige die Änderungen nebeneinander an" button: "HTML" side_by_side_markdown: - title: "Zeige die Originaltexte zum Vergleich nebeneinander an" + title: "Zeige die Quelltexte zum Vergleich nebeneinander an" button: "Quelltext" raw_email: displays: @@ -2877,8 +2864,8 @@ de: name: "Lesezeichen anheften" description: "Das Lesezeichen anheften, damit es oben auf der Liste der Lesezeichen erscheint." unpin_bookmark: - name: "Lesezeichen lösen" - description: "Löse das Lesezeichen. Es wird nicht mehr oben in Deiner Lesezeichenliste angezeigt." + name: "Lesezeichen loslösen" + description: "Löse das Lesezeichen los. Es wird nicht mehr oben in deiner Lesezeichenliste angezeigt." filtered_replies: viewing_posts_by: "Zeige %{post_count} Beiträge von" viewing_subset: "Einige Antworten sind reduziert" @@ -2886,13 +2873,13 @@ de: post_number: "%{username}, Beitrag #%{post_number}" show_all: "Alle anzeigen" category: - can: "kann… " + can: "kann … " none: "(keine Kategorie)" all: "Alle Kategorien" - choose: "Kategorie…" + choose: "Kategorie …" edit: "Bearbeiten" edit_dialog_title: "Bearbeiten: %{categoryName}" - view: "Zeige Themen dieser Kategorie" + view: "Zeige Themen in Kategorie" back: "Zurück zur Kategorie" general: "Allgemeines" settings: "Einstellungen" @@ -2901,33 +2888,33 @@ de: tags_allowed_tags: "Schlagwörter auf diese Kategorie einschränken:" tags_allowed_tag_groups: "Schlagwortgruppen auf diese Kategorie einschränken:" tags_placeholder: "(Optional) Liste erlaubter Schlagwörter" - tags_tab_description: "Die oben spezifizierten Tags und Tag-Gruppen werden nur in dieser Kategorie und anderen Kategorien, für die sie ebenfalls spezifiziert sind, verfügbar sein. Darüber hinaus werden sie nicht in weiteren Kategorien verwendbar sein." - tag_groups_placeholder: "(Optional) Liste erlaubter Schlagwort-Gruppen" - manage_tag_groups_link: "Schlagwort-Gruppen verwalten" + tags_tab_description: "Die oben spezifizierten Schlagwörter und Schlagwortgruppen werden nur in dieser Kategorie und anderen Kategorien, für die sie ebenfalls spezifiziert sind, verfügbar sein. Darüber hinaus werden sie nicht in weiteren Kategorien verwendbar sein." + tag_groups_placeholder: "(Optional) Liste erlaubter Schlagwortgruppen" + manage_tag_groups_link: "Schlagwortgruppen verwalten" allow_global_tags_label: "Erlaube auch andere Schlagwörter." - tag_group_selector_placeholder: "(Optional) Tag Gruppe" - required_tag_group_description: "Neue Themen müssen Tags von einer Tag Gruppe haben:" - min_tags_from_required_group_label: "Num Tags:" - required_tag_group_label: "Tag Gruppe:" + tag_group_selector_placeholder: "(Optional) Schlagwortgruppe" + required_tag_group_description: "Neue Themen müssen Schlagwörter aus einer Schlagwortgruppe haben:" + min_tags_from_required_group_label: "Anz. Schlagwörter:" + required_tag_group_label: "Schlagwortgruppe:" topic_featured_link_allowed: "Erlaube hervorgehobene Links in dieser Kategorie" delete: "Kategorie löschen" create: "Neue Kategorie" create_long: "Eine neue Kategorie erstellen" save: "Kategorie speichern" slug: "Sprechender Name für URL der Kategorie" - slug_placeholder: "Bindestrich getrennte Wörter für URL" + slug_placeholder: "(Optional) durch Bindestrich getrennte Wörter für URL" creation_error: Beim Erstellen der Kategorie ist ein Fehler aufgetreten. save_error: Beim Speichern der Kategorie ist ein Fehler aufgetreten. name: "Name der Kategorie" description: "Beschreibung" - topic: "Themenkategorie" + topic: "Thema der Kategorie" logo: "Logo für Kategorie" background_image: "Hintergrundbild für Kategorie" - badge_colors: "Farben von Abzeichen" + badge_colors: "Farben des Abzeichens" background_color: "Hintergrundfarbe" foreground_color: "Vordergrundfarbe" name_placeholder: "Ein oder maximal zwei Wörter" - color_placeholder: "Irgendeine Web-Farbe" + color_placeholder: "Beliebige Web-Farbe" delete_confirm: "Möchtest du wirklich diese Kategorie löschen?" delete_error: "Beim Löschen der Kategorie ist ein Fehler aufgetreten." list: "Kategorien auflisten" @@ -2939,37 +2926,37 @@ de: permissions: group: "Gruppe" see: "Ansehen" - reply: "Antworte" - create: "Erstelle" - no_groups_selected: "Es wurde keinen Gruppen Zugriff gewährt. Diese Kategorie ist nur für Mitarbeiter sichtbar." - everyone_has_access: 'Diese Kategorie ist öffentlich, jeder kann Beiträge sehen, beantworten und erstellen. Entferne eine oder mehrere der Berechtigungen, die der Gruppe "Jeder" erteilt wurden, um die Berechtigungen einzuschränken.' + reply: "Antworten" + create: "Erstellen" + no_groups_selected: "Es wurde keinen Gruppen Zugriff gewährt. Diese Kategorie ist nur für das Team sichtbar." + everyone_has_access: 'Diese Kategorie ist öffentlich, jeder kann Beiträge sehen, beantworten und erstellen. Entferne eine oder mehrere der Berechtigungen, die der Gruppe „Jeder“ erteilt wurden, um die Berechtigungen einzuschränken.' toggle_reply: "Antwortberechtigung umschalten" toggle_full: "Erstellungsberechtigung umschalten" - inherited: 'Diese Berechtigung wird von "jedem" vererbt' - special_warning: "Warnung: Diese Kategorie wurde bei der Installation angelegt. Die Sicherheitseinstellungen können daher nicht verändert werden. Wenn du diese Kategorie nicht benötigst, dann solltest du sie löschen anstatt sie für andere Zwecke zu verwenden." + inherited: 'Diese Berechtigung wird von „Jeder“ vererbt' + special_warning: "Warnung: Diese Kategorie wurde bei der Installation angelegt. Die Sicherheitseinstellungen können daher nicht verändert werden. Wenn du diese Kategorie nicht benötigst, dann solltest du sie löschen, anstatt sie für andere Zwecke zu verwenden." uncategorized_security_warning: "Diese Kategorie ist etwas Spezielles. Sie dient als Bereich für Themen, die keine Kategorie haben und besitzt keine Sicherheitseinstellungen." - uncategorized_general_warning: 'Diese Kategorie ist etwas Spezielles. Sie wird als Standard-Kategorie für neue Themen genutzt, für die keine Kategorie ausgewählt wurde. Deaktiviere diese Einstellung, wenn dieses Verhalten verhindert und eine Kategoriewahl erzwungen werden soll. Der Name und die Beschreibung können unter Anpassen / Textinhalte geändert werden.' + uncategorized_general_warning: 'Diese Kategorie ist etwas Spezielles. Sie wird als Standard-Kategorie für neue Themen genutzt, für die keine Kategorie ausgewählt wurde. Deaktiviere diese Einstellung, wenn dieses Verhalten verhindert und eine Kategoriewahl erzwungen werden soll. Der Name und die Beschreibung können unter Anpassen/Textinhalte geändert werden.' pending_permission_change_alert: "Du hast %{group} nicht zu dieser Kategorie hinzugefügt; klicke diese Schaltfläche, um sie zuzufügen." images: "Bilder" email_in: "Benutzerdefinierte Adresse für eingehende E-Mails:" email_in_allow_strangers: "Akzeptiere E-Mails von anonymen Benutzern." email_in_disabled: "Das Erstellen von neuen Themen per E-Mail ist in den Website-Einstellungen deaktiviert. Um das Erstellen von neuen Themen per E-Mail zu erlauben, " - email_in_disabled_click: 'aktiviere die Einstellung „email in“.' + email_in_disabled_click: 'Aktiviere die Einstellung „email in“.' mailinglist_mirror: "Kategorie spiegelt eine Mailingliste" - show_subcategory_list: "Zeige Liste von Unterkategorien oberhalb von Themen in dieser Kategorie" + show_subcategory_list: "Zeige Liste von Unterkategorien oberhalb von Themen in dieser Kategorie." read_only_banner: "Bannertext, wenn ein Benutzer kein Thema in dieser Kategorie erstellen kann:" - num_featured_topics: "Anzahl der Themen, die auf der Kategorien-Seite angezeigt werden" - subcategory_num_featured_topics: "Anzahl beworbener Themen, die auf der Seite der übergeordneten Kategorie angezeigt werden:" + num_featured_topics: "Anzahl der Themen, die auf der Kategorien-Seite angezeigt werden:" + subcategory_num_featured_topics: "Anzahl der hervorgehobenen Themen, die auf der Seite der übergeordneten Kategorie angezeigt werden:" all_topics_wiki: "Mache neue Themen standardmäßig zu Wikis." - allow_unlimited_owner_edits_on_first_post: "Zulassen unbegrenzter Besitzerbearbeitungen beim ersten Beitrag" - subcategory_list_style: "Listenstil für Unterkategorien" + allow_unlimited_owner_edits_on_first_post: "Erlaube unbegrenzte Änderungen des Eigentümers am ersten Beitrag" + subcategory_list_style: "Listenstil für Unterkategorien:" sort_order: "Themenliste sortieren nach:" - default_view: "Standard-Themenliste" - default_top_period: "Standard-Zeitraum für Top-Beiträge:" + default_view: "Standard-Themenliste:" + default_top_period: "Standard-Zeitraum für angesagte Beiträge:" default_list_filter: "Standard-Listenfilter:" allow_badges_label: "Erlaube das Verleihen von Abzeichen in dieser Kategorie." edit_permissions: "Berechtigungen bearbeiten" - reviewable_by_group: "Zusätzlich zu den Mitarbeitern können Inhalte in dieser Kategorie auch überprüft werden von:" + reviewable_by_group: "Zusätzlich zum Team können Inhalte in dieser Kategorie auch überprüft werden von:" review_group_name: "Gruppenname" require_topic_approval: "Erfordere Moderator-Genehmigung für alle neuen Themen" require_reply_approval: "Erfordere Moderator-Genehmigung für alle neuen Antworten" @@ -2978,9 +2965,9 @@ de: default_position: "Standardposition" position_disabled: "Kategorien werden in der Reihenfolge der Aktivität angezeigt. Um die Reihenfolge von Kategorien in Listen zu steuern, " position_disabled_click: 'aktiviere die Einstellung „fixed category positions“.' - minimum_required_tags: "Minimal Anzahl an Schlagwörtern, die ein Thema erfordert" + minimum_required_tags: "Mindestanzahl an Schlagwörtern, die ein Thema erfordert" parent: "Übergeordnete Kategorie" - num_auto_bump_daily: "Anzahl der offenen Themen, die automatisch täglich nach oben verschoben werden" + num_auto_bump_daily: "Anzahl der offenen Themen, die automatisch täglich nach oben verschoben werden:" navigate_to_first_post_after_read: "Gehe zum ersten Beitrag, nachdem Themen gelesen wurden" notifications: watching: @@ -2991,13 +2978,13 @@ de: description: "Du wirst über neue Themen in dieser Kategorie benachrichtigt, aber nicht über Antworten auf diese Themen." tracking: title: "Verfolgen" - description: "Du wirst automatisch alle Themen in dieser Kategorie verfolgen. Du wirst benachrichtigt, wenn jemand deinen @name erwähnt oder dir antwortet, und die Anzahl der neuen Antworten wird angezeigt." + description: "Du wirst automatisch alle Themen in dieser Kategorie verfolgen. Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder dir antwortet, und die Anzahl der neuen Antworten wird angezeigt." regular: title: "Normal" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." muted: title: "Stummgeschaltet" - description: "Du erhältst nie mehr Benachrichtigungen über neue Themen in dieser Kategorie und die Themen werden auch nicht in der Liste der letzten Themen erscheinen." + description: "Du erhältst nie mehr Benachrichtigungen über neue Themen in dieser Kategorie und die Themen werden auch nicht in der Liste der aktullen Themen erscheinen." search_priority: label: "Suchpriorität" options: @@ -3032,25 +3019,25 @@ de: list_filters: all: "alle Themen" none: "keine Unterkategorien" - colors_disabled: "Sie können keine Farben auswählen, da Sie keinen Kategoriestil haben." + colors_disabled: "Du kannst keine Farben auswählen, weil du keinen Kategoriestil verwendest." flagging: title: "Danke für deine Mithilfe!" action: "Beitrag melden" - take_action: "Reagieren..." + take_action: "Reagieren …" take_action_options: default: title: "Reagieren" details: "Den Meldungsschwellenwert sofort erreichen, anstatt auf weitere Meldungen aus der Community zu warten." suspend: title: "Benutzer sperren" - details: "Erreichen Sie den Kennzeichnungsschwellenwert und sperren Sie den Benutzer" + details: "Erreiche den Meldungsschwellenwert und sperre den Benutzer" silence: title: "Benutzer stummschalten" - details: "Erreichen Sie den Kennzeichnungsschwellenwert und schalten Sie den Benutzer stumm" + details: "Erreiche den Meldungsschwellenwert und schalte den Benutzer stumm" notify_action: "Nachricht" official_warning: "Offizielle Warnung" delete_spammer: "Spammer löschen" - flag_for_review: "Warteschlange zur Überprüfung" + flag_for_review: "Zur Überprüfung einreihen" delete_confirm_MF: "Du wirst {POSTS, plural, one {einen Beitrag} other {# Beiträge}} und {TOPICS, plural, one {ein Thema} other {# Themen}} von diesem Benutzer löschen, sein Konto entfernen, seine IP-Adresse {ip_address} für Neuanmeldungen sperren und die E-Mail-Adresse {email} auf eine permanente Sperrliste setzen. Bist du dir sicher, dass dieser Benutzer wirklich ein Spammer ist?" yes_delete_spammer: "Ja, lösche den Spammer" ip_address_missing: "(nicht verfügbar)" @@ -3058,7 +3045,7 @@ de: submit_tooltip: "Private Meldung abschicken" take_action_tooltip: "Den Meldungsschwellenwert sofort erreichen, anstatt auf weitere Meldungen aus der Community zu warten." cant: "Entschuldige, du kannst diesen Beitrag derzeit nicht melden." - notify_staff: "Team nicht-öffentlich benachrichtigen" + notify_staff: "Team privat benachrichtigen" formatted_name: off_topic: "Es ist am Thema vorbei" inappropriate: "Es ist unangemessen" @@ -3067,13 +3054,13 @@ de: custom_placeholder_notify_moderators: "Bitte lass uns wissen, was genau dich beunruhigt. Verweise, wenn möglich, auf relevante Links und Beispiele." custom_message: at_least: - one: "gib mindestens ein Zeichen ein" + one: "gib mindestens %{count} Zeichen ein" other: "gib mindestens %{count} Zeichen ein" more: - one: "eine weitere…" - other: "%{count} weitere…" + one: "%{count} weitere …" + other: "%{count} weitere …" left: - one: "eine übrig" + one: "%{count} übrig" other: "%{count} übrig" flagging_topic: title: "Danke für deine Mithilfe!" @@ -3083,14 +3070,14 @@ de: title: "Zusammenfassung des Themas" participants_title: "Autoren vieler Beiträge" links_title: "Beliebte Links" - links_shown: "mehr Links anzeigen…" + links_shown: "mehr Links anzeigen …" clicks: one: "%{count} Klick" other: "%{count} Klicks" post_links: about: "weitere Links für diesen Beitrag aufklappen" title: - one: "ein weiterer" + one: "%{count} weiterer" other: "%{count} weitere" topic_statuses: warning: @@ -3108,7 +3095,7 @@ de: help: "Dieses Thema ist für dich losgelöst; es wird in der normalen Reihenfolge angezeigt" pinned_globally: title: "Global angeheftet" - help: "Dieses Thema ist global angeheftet; es wird immer am Anfang der Liste der letzten Beiträgen und in seiner Kategorie auftauchen" + help: "Dieses Thema ist global angeheftet; es wird immer am Anfang der Liste der aktuellen Themen und in seiner Kategorie auftauchen" pinned: title: "Angeheftet" help: "Dieses Thema ist für dich angeheftet; es wird immer am Anfang seiner Kategorie auftauchen" @@ -3143,7 +3130,7 @@ de: one: "Benutzer" other: "Benutzer" category_title: "Kategorie" - history: "Verlauf, letzte 100 Revisionen" + history: "Verlauf, letzte 100 Überarbeitungen" changed_by: "von %{author}" raw_email: title: "Eingegangene E-Mail" @@ -3218,20 +3205,20 @@ de: this_week: "Woche" today: "Heute" other_periods: "siehe oben:" - browser_update: 'Leider ist Ihr Browser zu alt, um auf dieser Seite zu arbeiten. Bitte aktualisieren Sie Ihren Browser , um Inhalte korrekt anzuzeigen, einloggen und antworten zu können.' + browser_update: 'Leider ist dein Browser zu alt, um auf dieser Website zu funktionieren. Bitte aktualisiere deinen Browser, um Inhalte korrekt anzuzeigen, dich anzumelden und zu antworten.' permission_types: - full: "Erstellen / Antworten / Ansehen" - create_post: "Antworten / Ansehen" + full: "Erstellen/Antworten/Ansehen" + create_post: "Antworten/Ansehen" readonly: "Ansehen" lightbox: download: "herunterladen" - previous: "Vorhergehend (linke Pfeiltaste)" - next: "Nächste (rechte Pfeiltaste)" + previous: "Zurück (linke Pfeiltaste)" + next: "Weiter (rechte Pfeiltaste)" counter: "%curr% von %total%" close: "Schließen (Esc)" content_load_error: 'Der Inhalt konnte nicht geladen werden.' image_load_error: 'Das Bild konnte nicht geladen werden.' - cannot_render_video: Dieses Video kann nicht gerendert werden, da Dein Browser den Codec nicht unterstützt. + cannot_render_video: Dieses Video kann nicht gerendert werden, da dein Browser den Codec nicht unterstützt. keyboard_shortcuts_help: shortcut_key_delimiter_comma: ", " shortcut_key_delimiter_plus: "+" @@ -3347,22 +3334,22 @@ de: name: Andere posting: name: Schreiben - favorite_max_reached: "Du kannst keine weiteren Abzeichen favorieren." + favorite_max_reached: "Du kannst keine weiteren Abzeichen favorisieren." favorite_max_not_reached: "Markiere dieses Abzeichen als Favorit" favorite_count: "%{count}/%{max} Abzeichen als Favorit markiert" tagging: all_tags: "Alle Schlagwörter" other_tags: "Sonstige Schlagwörter" - selector_all_tags: "Alle Schlagwörter" + selector_all_tags: "alle Schlagwörter" selector_no_tags: "keine Schlagwörter" changed: "Geänderte Schlagwörter:" tags: "Schlagwörter" choose_for_topic: "optionale Schlagwörter" info: "Info" - default_info: "Dieses Schlagwort ist nicht auf irgendwelche Kategorien beschränkt und hat keine Synonyme. Um Einschränkungen hinzuzufügen, füge dieses Schlagwort einer Schlagwort-Gruppe hinzu." - category_restricted: "Dieser Tag ist auf Kategorien begrenzt, für die du keine Zugriffsberechtigung hast." + default_info: "Dieses Schlagwort ist nicht auf bestimmte Kategorien beschränkt und hat keine Synonyme. Um Einschränkungen hinzuzufügen, füge dieses Schlagwort einer Schlagwortgruppe hinzu." + category_restricted: "Dieses Schlagwort ist auf Kategorien beschränkt, für die du keine Zugriffsberechtigung hast." synonyms: "Synonyme" - synonyms_description: "Wenn die folgenden Schlagwörter verwendet werden, werden sie durch %{base_tag_name} ersetzt." + synonyms_description: "Wenn die folgenden Schlagwörter verwendet werden, werden sie durch %{base_tag_name} ersetzt." tag_groups_info: one: 'Dieses Schlagwort gehört zur Gruppe „%{tag_groups}”.' other: "Dieses Schlagwort gehört zu diesen Gruppen: %{tag_groups}" @@ -3374,13 +3361,13 @@ de: add_synonyms: "Hinzufügen" add_synonyms_explanation: one: "Jede Stelle, die dieses Schlagwort aktuell verwendet, wird auf die Verwendung von %{tag_name} geändert. Bist du sicher, dass diese Änderung erfolgen soll?" - other: "Jede Stelle, die dieses Schlagwort aktuell verwendet, wird auf die Verwendung von %{tag_name} geändert. Bist du sicher, dass diese Änderung erfolgen soll?" - add_synonyms_failed: "Die folgenden Schlagwörter konnten nicht als Synonyme hinzugefügt werden: %{tag_names} . Stellen Sie sicher, dass sie keine Synonyme und keine Synonyme eines anderen Schlagwortes bereits haben." + other: "Jede Stelle, die diese Schlagwörter aktuell verwendet, wird auf die Verwendung von %{tag_name} geändert. Bist du sicher, dass diese Änderung erfolgen soll?" + add_synonyms_failed: "Die folgenden Schlagwörter konnten nicht als Synonyme hinzugefügt werden: %{tag_names}. Stelle sicher, dass sie keine Synonyme haben und keine Synonyme eines anderen Schlagworts sind." remove_synonym: "Synonym entfernen" - delete_synonym_confirm: 'Bist du dir sicher das du das folgende Synonym entfernen möchtest "%{tag_name}" ?' - delete_tag: "Schlagwört löschen" + delete_synonym_confirm: 'Bist du sicher, dass du das Synonym „%{tag_name}“ löschen möchtest?' + delete_tag: "Schlagwort löschen" delete_confirm: - one: "Bist du sicher, dass du dieses Schlagwort löschen und von einem zugeordneten Thema entfernen möchtest?" + one: "Bist du sicher, dass du dieses Schlagwort löschen und von %{count} zugeordneten Thema entfernen möchtest?" other: "Bist du sicher, dass du dieses Schlagwort löschen und von %{count} zugeordneten Themen entfernen möchtest?" delete_confirm_no_topics: "Bist du sicher, dass du dieses Schlagwort löschen möchtest?" delete_confirm_synonyms: @@ -3391,11 +3378,11 @@ de: sort_by: "Sortieren nach:" sort_by_count: "Anzahl" sort_by_name: "Name" - manage_groups: "Schlagwort-Gruppen verwalten" + manage_groups: "Schlagwortgruppen verwalten" manage_groups_description: "Gruppen definieren, um Schlagwörter zu organisieren" upload: "Schlagwörter hochladen" upload_description: "Lade eine CSV-Datei hoch, um mehrere Schlagwörter auf einmal zu erstellen" - upload_instructions: "Eine pro Zeile, optional mit einer Schlagwortgruppe nach dem Schema 'schlagwort_name,schlagwort_gruppe'." + upload_instructions: "Ein Schlagwort pro Zeile, optional mit einer Schlagwortgruppe nach dem Schema „tag_name,tag_group“." upload_successful: "Schlagwörter erfolgreich hochgeladen" delete_unused_confirmation: one: "%{count} Schlagwort wird gelöscht: %{tags}" @@ -3412,7 +3399,7 @@ de: without_category: "%{filter} %{tag} Themen" with_category: "%{filter} %{tag} Themen in %{category}" untagged_without_category: "%{filter} Themen ohne Schlagwörter" - untagged_with_category: "%{filter} ohne Schlagwörter in %{category}" + untagged_with_category: "%{filter} Themen ohne Schlagwörter in %{category}" notifications: watching: title: "Beobachten" @@ -3422,38 +3409,38 @@ de: description: "Du wirst über neue Themen mit diesem Schlagwort benachrichtigt, aber nicht über Antworten auf diese Themen." tracking: title: "Verfolgen" - description: "Du wirst automatisch allen Themen mit diesem Schlagwort folgen. Die Anzahl der ungelesenen und neuen Beiträge wird neben dem Thema erscheinen." + description: "Du wirst automatisch alle Themen mit diesem Schlagwort verfolgen. Die Anzahl der ungelesenen und neuen Beiträge wird neben dem Thema erscheinen." regular: title: "Allgemein" - description: "Du wirst benachrichtigt, wenn jemand deinen @Namen erwähnt oder auf deinen Beitrag antwortet." + description: "Du wirst benachrichtigt, wenn jemand deinen Namen mit @ erwähnt oder auf deinen Beitrag antwortet." muted: title: "Stummgeschaltet" - description: "Du wirst nicht über neue Themen mit diesem Schlagwort benachrichtigt und sie werden nicht in deinen ungelesenen Beiträgen auftauchen." + description: "Du wirst nicht über neue Themen mit diesem Schlagwort benachrichtigt und sie werden nicht unter „Ungelesen“ auftauchen." groups: - title: "Schlagwort-Gruppen" - about_heading: "Wähle eine Tag-Gruppe oder erstelle eine neue." - about_heading_empty: "Erstelle eine neue Tag-Gruppe um loszulegen" - about_description: "Tag-Gruppen helfen Dir, Berechtigungen für viele Tags an einem Ort zu verwalten." + title: "Schlagwortgruppen" + about_heading: "Wähle eine Schlagwortgruppe oder erstelle eine neue." + about_heading_empty: "Erstelle eine neue Schlagwortgruppe, um loszulegen" + about_description: "Schlagwortgruppen helfen dir, Berechtigungen für viele Schlagwörter an einem Ort zu verwalten." new: "Neue Gruppe" new_title: "Neue Gruppe erstellen" - edit_title: "Tag-Gruppe bearbeiten" + edit_title: "Schlagwortgruppe bearbeiten" tags_label: "Schlagwörter in dieser Gruppe" parent_tag_label: "Übergeordnetes Schlagwort" parent_tag_description: "Schlagwörter aus dieser Gruppe können nur verwendet werden, wenn das übergeordnete Schlagwort vorhanden ist." one_per_topic_label: "Beschränke diese Gruppe auf ein Schlagwort pro Thema" - new_name: "Neue Schlagwort-Gruppe" + new_name: "Neue Schlagwortgruppe" name_placeholder: "Name" save: "Speichern" delete: "Löschen" - confirm_delete: "Möchtest du wirklich diese Schlagwort-Gruppe löschen?" + confirm_delete: "Möchtest du wirklich diese Schlagwortgruppe löschen?" everyone_can_use: "Schlagwörter können von jedem verwendet werden" usable_only_by_groups: "Schlagwörter sind für jeden sichtbar, aber nur die folgenden Gruppen können sie benutzen" - visible_only_to_groups: "Tags sind nur für folgende Gruppen sichtbar" - cannot_save: "Schlagwort-Gruppe kann nicht gespeichert werden. Stelle sicher, dass mindestens ein Schlagwort vorhanden ist, der Name der Schlagwort-Gruppe nicht leer ist und eine Gruppe für die Schlagwort-Berechtigung ausgewählt ist." - tags_placeholder: "Tags suchen oder erstellen" + visible_only_to_groups: "Schlagwörter sind nur für folgende Gruppen sichtbar" + cannot_save: "Schlagwortgruppe kann nicht gespeichert werden. Stelle sicher, dass mindestens ein Schlagwort vorhanden ist, der Name der Schlagwortgruppe nicht leer ist und eine Gruppe für die Schlagwort-Berechtigung ausgewählt ist." + tags_placeholder: "Schlagwörter suchen oder erstellen" parent_tag_placeholder: "Optional" - select_groups_placeholder: "Gruppen auswählen..." - disabled: "Tagging ist deaktiviert. " + select_groups_placeholder: "Gruppen auswählen …" + disabled: "Verwendung von Schlagwörtern ist deaktiviert. " topics: none: unread: "Du hast keine ungelesenen Themen." @@ -3462,7 +3449,7 @@ de: posted: "Du hast noch keine Beiträge verfasst." latest: "Es gibt keine aktuellen Themen." bookmarks: "Du hast noch keine Themen, die du mit einem Lesezeichen versehen hast." - top: "Es gibt keine Top-Themen." + top: "Es gibt keine angesagten Themen." invite: custom_message: "Gestalte deine Einladung etwas persönlicher, indem du eine eigene Nachricht schreibst." custom_message_placeholder: "Gib deine persönliche Nachricht ein" @@ -3470,17 +3457,17 @@ de: custom_message_template_forum: "Hey, du solltest diesem Forum beitreten!" custom_message_template_topic: "Hey, ich dachte, dir könnte dieses Thema gefallen!" forced_anonymous: "Aufgrund einer außergewöhnlichen Last wird dies vorübergehend so angezeigt, wie es ein nicht angemeldeter Benutzer sehen würde." - forced_anonymous_login_required: "Die Website ist extrem belastet und kann derzeit nicht geladen werden. Versuchen Sie es in ein paar Minuten erneut." + forced_anonymous_login_required: "Die Seite befindet sich unter extremer Belastung und kann zu diesem Zeitpunkt nicht geladen werden. Versuche es in ein paar Minuten erneut." footer_nav: back: "Zurück" forward: "Vorwärts" share: "Teilen" dismiss: "Alles gelesen" safe_mode: - enabled: "Der geschützte Modus ist aktiviert. Schliesse das Browserfenster um ihn zu verlassen." + enabled: "Der abgesicherte Modus ist aktiviert. Schließe das Browserfenster, um ihn zu verlassen." image_removed: "(Bild entfernt)" do_not_disturb: - title: "Nicht stören für..." + title: "Nicht stören für …" label: "Nicht stören" remaining: "%{remaining} verbleibend" options: @@ -3492,27 +3479,27 @@ de: set_schedule: "Zeitplan für Benachrichtigungen festlegen" trust_levels: names: - newuser: "Neuer Benutzer" + newuser: "neuer Benutzer" basic: "Anwärter" member: "Mitglied" regular: "Stammgast" leader: "Anführer" detailed_name: "%{level}: %{name}" admin_js: - type_to_filter: "zum Filtern hier eingeben…" + type_to_filter: "zum Filtern hier eingeben …" admin: title: "Discourse-Administrator" moderator: "Moderator" tags: remove_muted_tags_from_latest: always: "immer" - only_muted: "wenn allein oder mit anderen stummgeschalteten Tags verwendet" + only_muted: "wenn allein oder mit anderen stummgeschalteten Schlagwörtern verwendet" never: "nie" reports: title: "Verfügbare Berichte" dashboard: - title: "Übersicht" - last_updated: "Übersicht aktualisiert:" + title: "Dashboard" + last_updated: "Dashboard aktualisiert:" discourse_last_updated: "Discourse aktualisiert:" version: "Version" up_to_date: "Du verwendest die neueste Version!" @@ -3542,13 +3529,13 @@ de: space_used: "%{usedSize} verwendet" space_used_and_free: "%{usedSize} (%{freeSize} frei)" uploads: "Uploads" - backups: "Backups" + backups: "Back-ups" backup_count: - one: "%{count} Backup auf %{location}" - other: "%{count} Backups auf %{location}" + one: "%{count} Back-up auf %{location}" + other: "%{count} Back-ups auf %{location}" lastest_backup: "Letztes: %{date}" traffic_short: "Traffic" - traffic: "Web Requests der Applikation" + traffic: "Webanfragen der Anwendung" page_views: "Seitenaufrufe" page_views_short: "Seitenaufrufe" show_traffic_report: "Zeige detaillierten Traffic-Bericht" @@ -3563,9 +3550,9 @@ de: reports_tab: "Berichte" report_filter_any: "alle" disabled: Deaktiviert - timeout_error: Entschuldige bitte, die Warteschlange ist zu lang, bitte wähle ein kürzeres Intervall - exception_error: Entschuldige, ein Fehler während der Ausführung des Query ist aufgetreten - too_many_requests: Du hast diese Aktion zu oft ausgeführt. Bitte warte bevor Du es erneut probierst. + timeout_error: Entschuldige, die Abfrage dauert zu lange, bitte wähle ein kürzeres Intervall + exception_error: Entschuldige, ein Fehler ist beim Ausführen der Abfrage aufgetreten + too_many_requests: Du hast diese Aktion zu oft ausgeführt. Bitte warte, bevor du es erneut probierst. not_found_error: Entschuldige bitte, dieser Bericht existiert nicht filter_reports: Berichte filtern reports: @@ -3588,11 +3575,11 @@ de: groups: "Alle Gruppen" disabled: "Dieser Bericht ist deaktiviert" totals_for_sample: "Insgesamt für Stichprobe" - average_for_sample: "Durchschnitt für Beispiel" - total: "Gesamt aller Zeiten" + average_for_sample: "Durchschnitt für Stichprobe" + total: "Gesamt (vollständiger Zeitraum)" no_data: "Keine Daten anzuzeigen." trending_search: - more: 'Suchprotokoll' + more: 'Suchprotokolle' disabled: 'Der Bericht über beliebte Suchen ist deaktiviert. Aktiviere Suchanfragen protokollieren, um die Daten zu erheben.' filters: file_extension: @@ -3604,7 +3591,7 @@ de: include_subcategories: label: "Unterkategorien einschließen" commits: - latest_changes: "Letzte Änderungen: bitte häufig updaten!" + latest_changes: "Letzte Änderungen: Bitte häufig updaten!" by: "von" groups: new: @@ -3613,12 +3600,10 @@ de: name: too_short: "Gruppenname ist zu kurz" too_long: "Gruppenname ist zu lang" - checking: "Verfügbarkeit des Gruppennamens wird geprüft..." + checking: "Verfügbarkeit des Gruppennamens wird geprüft …" available: "Gruppenname ist verfügbar" not_available: "Gruppenname ist nicht verfügbar" blank: "Gruppenname darf nicht leer sein" - add_members: - as_owner: "Mache Benutzer zu Eigentümern dieser Gruppe" manage: interaction: email: E-Mail @@ -3631,7 +3616,7 @@ de: logged_on_users: "Angemeldete Benutzer" members: "Gruppeneigentümer, Mitglieder und Moderatoren" staff: "Gruppeneigentümer und Moderatoren" - owners: "Gruppenbesitzer" + owners: "Gruppeneigentümer" description: "Administratoren können alle Gruppen sehen." members_visibility_levels: title: "Wer kann die Mitglieder dieser Gruppe sehen?" @@ -3644,12 +3629,12 @@ de: trust_levels_none: "Keine" automatic_membership_email_domains: "Benutzer, deren E-Mail-Domain mit einem der folgenden Listeneinträge genau übereinstimmt, werden automatisch zu dieser Gruppe hinzugefügt:" automatic_membership_user_count: - one: "%{count} Benutzer hat die neuen E-Mail-Domänen und wird der Gruppe hinzugefügt." - other: "%{count} Benutzer haben die neuen E-Mail-Domänen und werden der Gruppe hinzugefügt." - primary_group: "Automatisch als Hauptgruppe festlegen" + one: "%{count} Benutzer hat die neuen E-Mail-Domains und wird der Gruppe hinzugefügt." + other: "%{count} Benutzer haben die neuen E-Mail-Domains und werden der Gruppe hinzugefügt." + primary_group: "Automatisch als primäre Gruppe festlegen" name_placeholder: "Gruppenname, keine Leerzeichen, gleiche Regel wie beim Benutzernamen" - primary: "Hauptgruppe" - no_primary: "(keine Hauptgruppe)" + primary: "Primäre Gruppe" + no_primary: "(keine primäre Gruppe)" title: "Gruppen" edit: "Gruppen bearbeiten" refresh: "Aktualisieren" @@ -3658,11 +3643,11 @@ de: delete: "Löschen" delete_confirm: "Diese Gruppe löschen?" delete_with_messages_confirm: - one: "Wenn Sie diese Gruppe löschen, wird %{count} Nachricht verwaist, Gruppenmitglieder haben keinen Zugriff mehr darauf.

    Bist du sicher?" - other: "Wenn Sie diese Gruppe löschen, werden %{count} Nachrichten verwaist, Gruppenmitglieder haben keinen Zugriff mehr auf sie.

    Bist du sicher?" + one: "Wenn du diese Gruppe löschst, wird %{count} Nachricht verwaist sein, die Gruppenmitglieder haben keinen Zugriff mehr darauf.

    Bist du dir sicher?" + other: "Wenn du diese Gruppe löschst, werden %{count} Nachrichten verwaist sein, die Gruppenmitglieder haben keinen Zugriff mehr darauf.

    Bist du dir sicher?" delete_failed: "Gruppe konnte nicht gelöscht werden. Wenn dies eine automatische Gruppe ist, kann sie nicht gelöscht werden." - delete_automatic_group: Dies ist eine automatische Gruppe und kann nicht gelöscht werden. - delete_owner_confirm: "Eigentümerrechte für '%{username}' entfernen?" + delete_automatic_group: Dies ist eine automatische Gruppe und sie kann nicht gelöscht werden. + delete_owner_confirm: "Eigentümerrechte für „%{username}“ entfernen?" add: "Hinzufügen" custom: "Benutzerdefiniert" automatic: "Automatisch" @@ -3673,8 +3658,8 @@ de: none_selected: "Wähle eine Gruppe, um loszulegen" no_custom_groups: "Erstelle eine neue benutzerdefinierte Gruppe" api: - generate_master: "Master API Schlüssel erzeugen" - none: "Es gibt momentan keine aktiven API Schlüssel" + generate_master: "Master-API-Schlüssel erzeugen" + none: "Es gibt momentan keine aktiven API-Schlüssel." user: "Benutzer" title: "API" key: "Schlüssel" @@ -3686,19 +3671,19 @@ de: undo_revoke: "Widerruf rückgängig machen" revoke: "Widerrufen" all_users: "Alle Benutzer" - active_keys: "Aktive API Schlüssel" + active_keys: "Aktive API-Schlüssel" manage_keys: Schlüssel verwalten show_details: Details description: Beschreibung no_description: (keine Beschreibung) - all_api_keys: Alle API Schlüssel + all_api_keys: Alle API-Schlüssel user_mode: Benutzerrang impersonate_all_users: Als jeder Benutzer ausgeben single_user: "Einzelbenutzer" user_placeholder: Benutzernamen eingeben description_placeholder: Wofür wird dieser Schlüssel verwendet werden? save: Speichern - new_key: Neuer API Schlüssel + new_key: Neuer API-Schlüssel revoked: Widerrufen delete: Endgültig löschen not_shown_again: Dieser Schlüssel wird nicht noch einmal angezeigt. Stelle sicher, dass du eine Kopie hast, bevor du fortfährst. @@ -3706,9 +3691,9 @@ de: use_global_key: Globaler Schlüssel (erlaubt alle Aktionen) scopes: description: | - Wenn Sie Bereiche verwenden, können Sie einen API-Schlüssel auf einen bestimmten Satz von Endpunkten beschränken. - Sie können auch festlegen, welche Parameter zulässig sind. Verwenden Sie Kommas, um mehrere Werte zu trennen. - title: Geltungsbereiche + Wenn du Bereiche verwendest, kannst du einen API-Schlüssel auf einen bestimmten Satz von Endpunkten beschränken. + Du kannst auch definieren, welche Parameter erlaubt sind. Verwende Kommas, um mehrere Werte zu trennen. + title: Bereiche resource: Ressource action: Aktion allowed_parameters: Zulässige Parameter @@ -3717,16 +3702,16 @@ de: allowed_urls: Zulässige URLs descriptions: topics: - read: Lesen Sie ein Thema oder einen bestimmten Beitrag darin. RSS wird ebenfalls unterstützt. - write: Erstellen Sie ein neues Thema oder posten Sie es in einem vorhandenen Thema. - read_lists: Lesen Sie Themenlisten wie Top, Neu, Aktuell usw. RSS wird ebenfalls unterstützt. - wordpress: Notwendig, damit das WordPress-WP-Diskurs-Plugin funktioniert. + read: Lies ein Thema oder einen bestimmten Beitrag darin. RSS wird ebenfalls unterstützt. + write: Erstelle ein neues Thema oder schreibe einen Beitrag zu einem bestehenden. + read_lists: Lies Themenlisten wie „Angesagt“, „Neu“, „Aktuell“ usw. RSS wird ebenfalls unterstützt. + wordpress: Notwendig, damit das „wp-discourse“-Plug-in für WordPress funktioniert. users: - bookmarks: Benutzer-Lesezeichen auflisten. Es gibt Lesezeichenerinnerungen zurück, wenn Sie das ICS-Format verwenden. + bookmarks: Listet die Lesezeichen des Benutzers auf. Gibt Lesezeichen-Erinnerungen zurück, wenn du das ICS-Format verwendest. sync_sso: Synchronisiere einen Benutzer mit DiscourseConnect. show: Informationen über einen Benutzer abrufen. check_emails: Benutzer-E-Mails auflisten. - update: Benutzerprofil Informationen aktualisieren. + update: Benutzerprofil-Informationen aktualisieren. log_out: Alle Sitzungen für einen Benutzer abmelden. anonymize: Benutzerkonten anonymisieren. delete: Benutzerkonten löschen. @@ -3735,8 +3720,8 @@ de: web_hooks: title: "Webhooks" none: "Aktuell gibt es keine Webhooks." - instruction: "Webhooks erlauben es Discourse externe Dienste zu benachrichtigen, wenn bestimmte Ereignisse auf der Plattform auftreten. Wird ein Webhook getriggert, so wird ein POST Request an die angegebenen URLs gesendet." - detailed_instruction: "Wenn das ausgewählte Ereignis eintritt wird ein POST Request an die angegebenen URLs versandt." + instruction: "Webhooks erlauben es Discourse, externe Dienste zu benachrichtigen, wenn bestimmte Ereignisse auf der Plattform auftreten. Wird ein Webhook getriggert, so wird eine POST-Anfrage an die angegebenen URLs gesendet." + detailed_instruction: "Wenn das ausgewählte Ereignis eintritt, wird eine POST-Anfrage an die angegebene URL verschickt." new: "Neuer Webhook" create: "Erstellen" save: "Speichern" @@ -3744,9 +3729,9 @@ de: description: "Beschreibung" controls: "Kontrollelemente" go_back: "Zurück zur Liste" - payload_url: "Payload URL" + payload_url: "Payload-URL" payload_url_placeholder: "https://example.com/postreceive" - warn_local_payload_url: "Es sieht so aus als wenn du versuchst einen Webhook an eine lokale URL einzurichten. Ereignisse die an eine lokale Adresse ausgeliefert werden können möglicherweise Nebeneffekte oder unvorhergesehenes Verhalten auslösen. Möchtest du fortfahren?" + warn_local_payload_url: "Es scheint, dass du versuchst, den Webhook für eine lokale URL einzurichten. Ein Ereignis, das an eine lokale Adresse übermittelt wird, kann einen Nebeneffekt oder ein unerwartetes Verhalten verursachen. Möchtest du fortfahren?" secret_invalid: "Das Geheimnis darf keine Leerzeichen enthalten." secret_too_short: "Das Geheimnis sollte mindestens 12 Zeichen enthalten." secret_placeholder: "Eine optionale Zeichenkette für die Signatur" @@ -3756,24 +3741,24 @@ de: event_chooser: "Welche Ereignisse sollen diesen Webhook auslösen?" wildcard_event: "Sende mir alles." individual_event: "Wähle einzelne Ereignisse aus." - verify_certificate: "Überprüfe das TLS Zertifikat der Payload URL" + verify_certificate: "Überprüfe das TLS-Zertifikat der Payload-URL" active: "Aktiv" - active_notice: "Wir werden Ereignisdetails übermitteln wenn es eintritt." - categories_filter_instructions: "In Frage kommende Webhooks werden nur dann getriggert wenn das Ereignis in Verbindung mit den angegebenen Kategorien steht. Lasse den Wert frei um den Webhook für alle Kategorien auszuführen." - categories_filter: "Getriggerte Kategorien" - tags_filter_instructions: "Relevante Web-Hooks werden nur ausgelöst, wenn das Ereignis mit den angegebenen Schlagwörtern verbunden ist. Ein leerer Wert löst Web-Hooks für alle Schlagwörter aus." + active_notice: "Wir werden Ereignisdetails beim Eintritt desselben übermitteln." + categories_filter_instructions: "Relevante Webhooks werden nur ausgelöst, wenn das Ereignis mit den angegebenen Kategorien verbunden ist. Ein leerer Wert löst Webhooks für alle Kategorien aus." + categories_filter: "Ausgelöste Kategorien" + tags_filter_instructions: "Relevante Webhooks werden nur ausgelöst, wenn das Ereignis mit den angegebenen Schlagwörtern verbunden ist. Ein leerer Wert löst Webhooks für alle Schlagwörter aus." tags_filter: "Ausgelöste Schlagwörter" - groups_filter_instructions: "In Frage kommende Webhooks werden nur dann getriggert wenn das Ereignis in Verbindung mit den angegebenen Gruppen steht. Lasse den Wert frei um den Webhook für alle Gruppen auszuführen." - groups_filter: "Getriggerte Gruppen" + groups_filter_instructions: "Relevante Webhooks werden nur ausgelöst, wenn das Ereignis mit den angegebenen Gruppen verbunden ist. Ein leerer Wert löst Webhooks für alle Gruppen aus." + groups_filter: "Ausgelöste Gruppen" delete_confirm: "Diesen Webhook löschen?" topic_event: - name: "Thema Ereignis" + name: "Themenereignis" details: "Wenn ein neues Thema erstellt, bearbeitet oder gelöscht wird." post_event: - name: "Beitrag Ereignis" + name: "Beitragsereignis" details: "Wenn auf einen Beitrag geantwortet wird oder der Beitrag geändert, gelöscht oder wiederhergestellt wird." user_event: - name: "Benutzer Ereignis" + name: "Benutzer-Ereignis" details: "Wenn sich ein Benutzer anmeldet, abmeldet, seine E-Mail bestätigt, erstellt, genehmigt oder aktualisiert wird." group_event: name: "Gruppen-Ereignis" @@ -3785,20 +3770,20 @@ de: name: "Schlagwort-Ereignis" details: "Wenn ein Schlagwort erstellt, aktualisiert oder gelöscht wird." reviewable_event: - name: "Überpüfbares Ereignis" + name: "Überprüfbares Ereignis" details: "Wenn ein neuer Eintrag zur Überprüfung bereit ist und wenn sich sein Status ändert." notification_event: name: "Benachrichtigungs-Ereignis" details: "Wenn ein Benutzer eine Benachrichtigung in seinem Feed erhält." user_badge_event: - name: "Abzeichen-Verleihungs-Event" + name: "Abzeichen-Verleihungs-Ereignis" details: "Wenn ein Benutzer ein Abzeichen erhält." group_user_event: - name: "Gruppenbenutzerereignis" + name: "Gruppenbenutzer-Ereignis" details: "Wenn ein Benutzer in einer Gruppe hinzugefügt oder entfernt wird." like_event: - name: "Event liken" - details: "Wenn ein Benutzer einen Beitrag mag." + name: "Like-Ereignis" + details: "Wenn ein Benutzer einen Beitrag mit einem Like versieht." delivery_status: title: "Versandstatus" inactive: "Inaktiv" @@ -3807,16 +3792,16 @@ de: disabled: "Deaktiviert" events: none: "Es gibt keine verwandten Ereignisse." - redeliver: "Neu zustellen" + redeliver: "Erneut übertragen" incoming: - one: "Es gibt ein neues Ereignis" + one: "Es gibt ein neues Ereignis." other: "Es gibt %{count} neue Ereignisse." completed_in: one: "In %{count} Sekunde fertiggestellt." other: "In %{count} Sekunden fertiggestellt." - request: "Request" - response: "Response" - redeliver_confirm: "Bist du sicher dass du denselben Payload noch einmal übertragen möchtest?" + request: "Anfrage" + response: "Antwort" + redeliver_confirm: "Bist du sicher, dass du denselben Payload noch einmal übertragen möchtest?" headers: "Headers" payload: "Payload" body: "Body" @@ -3824,10 +3809,10 @@ de: go_details: "Webhook bearbeiten" go_events: "Gehe zu Ereignissen" ping: "Ping" - status: "Status Code" + status: "Statuscode" event_id: "ID" timestamp: "Erstellt" - completion: "Zeit" + completion: "Fertigstellungszeit" actions: "Aktionen" plugins: title: "Plug-ins" @@ -3841,72 +3826,72 @@ de: change_settings: "Einstellungen ändern" change_settings_short: "Einstellungen" howto: "Wie installiere ich Plug-ins?" - official: "Offizielles Plugin" + official: "Offizielles Plug-in" backups: - title: "Backups" + title: "Back-ups" menu: - backups: "Backups" - logs: "Logs" - none: "Kein Backup verfügbar." + backups: "Back-ups" + logs: "Protokolle" + none: "Kein Back-up verfügbar." read_only: enable: title: "Nur-Lesen-Modus aktivieren" - label: "Nur-Lesen-Modus aktivieren" + label: "„Nur Lesen“ aktivieren" confirm: "Möchtest du wirklich den Nur-Lesen-Modus aktivieren?" disable: title: "Nur-Lesen-Modus deaktivieren" - label: "Nur-Lesen-Modus deaktivieren" + label: "„Nur Lesen“ deaktivieren" logs: - none: "Noch keine Protokolleinträge verfügbar…" + none: "Noch keine Protokolle verfügbar …" columns: filename: "Dateiname" size: "Größe" upload: label: "Hochladen" - title: "Eine Sicherung zu dieser Instanz hochladen" - uploading: "Wird hochgeladen…" - uploading_progress: "Wird hochgeladen... %{progress}%" - success: "'%{filename}' wurde erfolgreich hochgeladen. Die Datei wird nun verarbeitet. Es kann bis zu einer Minute dauern, bis sie in der Liste erscheint." - error: "Beim Hochladen der Datei '%{filename}' ist ein Fehler aufgetreten: %{message}" + title: "Ein Back-up zu dieser Instanz hochladen" + uploading: "Wird hochgeladen …" + uploading_progress: "Wird hochgeladen … %{progress} %" + success: "„%{filename}“ wurde erfolgreich hochgeladen. Die Datei wird nun verarbeitet. Es kann bis zu eine Minute dauern, bis sie in der Liste erscheint." + error: "Beim Hochladen der Datei „%{filename}“ ist ein Fehler aufgetreten: %{message}" operations: - is_running: "Ein Vorgang läuft gerade…" - failed: "Der Vorgang '%{operation}' ist fehlgeschlagen. Bitte überprüfe die Logs." + is_running: "Ein Vorgang läuft gerade …" + failed: "Der Vorgang „%{operation}“ ist fehlgeschlagen. Bitte überprüfe die Protokolle." cancel: label: "Abbrechen" title: "Den aktuellen Vorgang abbrechen" confirm: "Möchtest du wirklich den aktuellen Vorgang abbrechen?" backup: - label: "Sichern" - title: "Ein Backup erstellen" - confirm: "Willst du ein neues Backup starten?" + label: "Back-up" + title: "Ein Back-up erstellen" + confirm: "Möchtest du ein neues Back-up starten?" without_uploads: "Ja (ohne Dateien)" download: label: "Herunterladen" title: "Sende E-Mail mit Download-Link" - alert: "Ein Link zum Download dieses Backups wurde dir per E-Mail geschickt." + alert: "Ein Link zum Download dieses Back-ups wurde dir per E-Mail geschickt." destroy: - title: "Das Backup löschen" - confirm: "Möchtest du wirklich das Backup löschen?" + title: "Das Back-up löschen" + confirm: "Möchtest du wirklich das Back-up löschen?" restore: is_disabled: "Wiederherstellung ist in den Website-Einstellungen deaktiviert." label: "Wiederherstellen" - title: "Das Backup wiederherstellen" - confirm: "Bist du sicher, dass du dieses Backup wiederherstellen möchtest?" + title: "Das Back-up wiederherstellen" + confirm: "Bist du sicher, dass du dieses Back-up wiederherstellen möchtest?" rollback: label: "Zurücksetzen" title: "Die Datenbank auf den letzten funktionierenden Zustand zurücksetzen" confirm: "Möchtest du wirklich die Datenbank auf den letzten funktionierenden Stand zurücksetzen?" location: - local: "Lokaler Speicherplatz" + local: "Lokaler Speicher" s3: "S3" - backup_storage_error: "Zugriff auf Backup-Speicher fehlgeschlagen: %{error_message}" + backup_storage_error: "Zugriff auf Back-up-Speicher fehlgeschlagen: %{error_message}" export_csv: success: "Der Export wurde gestartet. Du erhältst eine Nachricht, sobald der Vorgang abgeschlossen ist." - failed: "Der Export ist fehlgeschlagen. Bitte überprüfe die Logs." + failed: "Der Export ist fehlgeschlagen. Bitte überprüfe die Protokolle." button_text: "Exportieren" button_title: user: "Vollständige Benutzerliste im CSV-Format exportieren." - staff_action: "Vollständiges Moderations-Protokoll im CSV-Format exportieren." + staff_action: "Vollständiges Team-Aktionen-Protokoll im CSV-Format exportieren." screened_email: "Vollständige Liste der gefilterten E-Mail-Adressen im CSV-Format exportieren." screened_ip: "Vollständige Liste der gefilterten IP-Adressen im CSV-Format exportieren." screened_url: "Vollständige Liste der gefilterten URLs im CSV-Format exportieren." @@ -3917,15 +3902,15 @@ de: button_title: "Einladungen versenden" customize: title: "Anpassen" - long_title: "Anpassungen" + long_title: "Website-Anpassungen" preview: "Vorschau" - explain_preview: "Seite mit diesem Theme anschauen" + explain_preview: "Website mit diesem Theme anschauen" save: "Speichern" new: "Neu" new_style: "Neuer Style" install: "Installieren" delete: "Löschen" - delete_confirm: 'Möchtest du wirklich "%{theme_name}" löschen? ' + delete_confirm: 'Möchtest du wirklich „%{theme_name}“ löschen?' color: "Farbe" opacity: "Transparenz" copy: "Kopieren" @@ -3947,20 +3932,20 @@ de: theme_name: "Theme-Name" component_name: "Komponenten-Name" themes_intro: "Wähle ein bestehendes Theme oder installiere ein neues, um anzufangen" - themes_intro_emoji: "Künstlerin Emoji" + themes_intro_emoji: "Künstlerin-Emoji" beginners_guide_title: "Leitfaden für Einsteiger zur Verwendung von Discourse-Themes" - developers_guide_title: "Leitfaden für Entwickler zur Verwendung von Discourse-Themes" + developers_guide_title: "Entwickler-Leitfaden für Discourse-Themes" browse_themes: "Community-Themes durchsuchen" customize_desc: "Anpassen:" - title: "Designs" + title: "Themes" create: "Erstelle" create_type: "Typ" create_name: "Name" - long_title: "Farben, CSS und HTML-Inhalte deiner Seite erweitern" + long_title: "Farben, CSS und HTML-Inhalte deiner Website erweitern" edit: "Bearbeiten" edit_confirm: "Dies ist ein Remote-Theme, wenn du CSS/HTML bearbeitest, werden deine Änderungen zurückgesetzt, wenn du das Theme das nächste Mal aktualisierst." - update_confirm: "Folgendes wird vom Update geändert. Bist du sicher, dass du fortfahren willst?" - update_confirm_yes: "Ja, fahrte mit dem Update fort" + update_confirm: "Diese lokalen Änderungen werden durch das Update gelöscht. Bist du sicher, dass du fortfahren möchtest?" + update_confirm_yes: "Ja, mit dem Update fortfahren" common: "Gemeinsam" desktop: "Desktop" mobile: "Mobil" @@ -3971,46 +3956,46 @@ de: extra_files_upload: "Theme exportieren, um diese Dateien anzuzeigen." extra_files_remote: "Theme exportieren oder das Git-Repository überprüfen, um diese Dateien anzuzeigen." preview: "Vorschau" - show_advanced: "erweiterte Felder anzeigen" - hide_advanced: "erweiterte Felder verbergen" - hide_unused_fields: "unbenutzte Felder verbergen" + show_advanced: "Erweiterte Felder anzeigen" + hide_advanced: "Erweiterte Felder verbergen" + hide_unused_fields: "Unbenutzte Felder verbergen" is_default: "Theme ist standardmäßig aktiviert" user_selectable: "Theme kann von Benutzern ausgewählt werden" - color_scheme_user_selectable: "Das Farbschema kann vom Benutzer ausgewählt werden" + color_scheme_user_selectable: "Farbschema kann von Benutzern ausgewählt werden" auto_update: "Automatisch aktualisieren, wenn Discourse aktualisiert wird" color_scheme: "Farbpalette" default_light_scheme: "Hell (Standard)" color_scheme_select: "Wähle Farben für dieses Theme" custom_sections: "Benutzerdefinierte Abschnitte:" theme_components: "Theme-Komponenten" - add_all_themes: "Alle Themen hinzufügen" + add_all_themes: "Alle Themes hinzufügen" convert: "Umwandeln" - convert_component_alert: "Bist Du sicher, dass du diese Komponente in ein Theme umwandeln möchtest? Sie wird als Komponente entfernt von %{relatives}." + convert_component_alert: "Bist du sicher, dass du diese Komponente in ein Theme umwandeln möchtest? Sie wird als Komponente von %{relatives} entfernt." convert_component_tooltip: "Wandle diese Komponente in ein Theme um" - convert_component_alert_generic: "Bist du sicher, dass du diese Komponente in das Theme konvertieren möchten?" - convert_theme_alert: "Bist Du sicher, dass du dieses Theme in eine Komponente umwandeln möchtest? Sie wird als übergeordnetes Element entfernt von %{relatives}." - convert_theme_alert_generic: "Bist du sicher, dass du dieses Theme in Komponente konvertieren möchten?" + convert_component_alert_generic: "Bist du sicher, dass du diese Komponente in ein Theme umwandeln möchtest?" + convert_theme_alert: "Bist du sicher, dass du dieses Theme in eine Komponente umwandeln möchtest? Sie wird als übergeordnetes Element von %{relatives} entfernt." + convert_theme_alert_generic: "Bist du sicher, dass du dieses Theme in eine Komponente umwandeln möchtest?" convert_theme_tooltip: "Wandle dieses Theme in eine Komponente um" inactive_themes: "Inaktive Themes:" inactive_components: "Nicht verwendete Komponenten:" broken_theme_tooltip: "Dieses Theme hat fehlerhaftes CSS, HTML oder YAML." disabled_component_tooltip: "Diese Komponente wurde deaktiviert" - default_theme_tooltip: "Dieses Theme ist das Standardtheme der Seite." + default_theme_tooltip: "Dieses Theme ist das Standardtheme der Website." updates_available_tooltip: "Updates sind verfügbar für dieses Theme" and_x_more: "und %{count} mehr." collapse: Zuklappen uploads: "Uploads" no_uploads: "Du kannst Medieninhalte hochladen, die zu deinem Theme gehören, wie etwa Schriftarten und Bilder." add_upload: "Upload hinzufügen" - upload_file_tip: "Wähle einen hochzuladenen Medieninhalt aus (png, woff2, usw.)" - variable_name: "SCSS Variablen-Name:" + upload_file_tip: "Wähle einen hochzuladenden Medieninhalt aus (png, woff2 usw.)" + variable_name: "SCSS-Variablen-Name:" variable_name_invalid: "Ungültiger Variablenname. Nur alphanumerische Zeichen sind erlaubt. Muss mit einem Buchstaben beginnen. Muss eindeutig sein." variable_name_error: invalid_syntax: "Ungültiger Variablenname. Nur alphanumerische Zeichen erlaubt. Muss mit einem Buchstaben beginnen." no_overwrite: "Ungültiger Variablenname. Darf keine existierende Variable überschreiben." must_be_unique: "Ungültiger Variablenname. Muss eindeutig sein." upload: "Hochladen" - select_component: "Komponente auswählen..." + select_component: "Komponente auswählen …" unsaved_changes_alert: "Du hast deine Änderungen noch nicht gespeichert, möchtest du sie verwerfen und weitermachen?" unsaved_parent_themes: "Du hast die Komponente keinen Themes zugewiesen, willst du fortfahren?" discard: "Verwerfen" @@ -4019,24 +4004,24 @@ de: edit_css_html: "Bearbeite CSS/HTML" edit_css_html_help: "Du hast kein CSS oder HTML bearbeitet" delete_upload_confirm: "Upload löschen? (Theme-CSS funktioniert eventuell nicht mehr!)" - component_on_themes: "Komponente zu diesen Themen hinzufügen" - included_components: "Enthaltene Komponente" + component_on_themes: "Komponente zu diesen Themes hinzufügen" + included_components: "Enthaltene Komponenten" add_all: "Alle hinzufügen" import_web_tip: "Repository mit dem Theme" - direct_install_tip: "Möchtest Du wirklich %{name} aus dem unten aufgeführten Repository installieren?" - import_web_advanced: "Erweitert..." - import_file_tip: ".tar.gz, .zip, oder .dcstyle.json Datei, die ein Theme enthält" + direct_install_tip: "Möchtest du wirklich %{name} aus dem unten aufgeführten Repository installieren?" + import_web_advanced: "Erweitert …" + import_file_tip: ".tar.gz-, .zip- oder .dcstyle.json-Datei, die ein Theme enthält" is_private: "Theme ist in einem privaten Git-Repository" remote_branch: "Branch-Name (optional)" public_key: "Gewähre dem folgenden öffentlichen Schlüssel den Zugriff auf das Repository:" - public_key_note: "Nach Eingabe einer gültigen privaten Repository-URL wird hier ein SSH-Schlüssel generiert und hier angezeigt." + public_key_note: "Nach Eingabe einer gültigen privaten Repository-URL wird ein SSH-Schlüssel generiert und hier angezeigt." install: "Installieren" installed: "Installiert" install_popular: "Beliebt" install_upload: "Von deinem Gerät" install_git_repo: "Aus einem Git-Repository" install_create: "Neu erstellen" - duplicate_remote_theme: "Die Theme-Komponente „%{name}“ ist bereits installiert. Möchtest Du wirklich eine weitere Kopie installieren?" + duplicate_remote_theme: "Die Theme-Komponente „%{name}“ ist bereits installiert. Möchtest du wirklich eine weitere Kopie installieren?" about_theme: "Über uns" license: "Lizenz" version: "Version:" @@ -4049,41 +4034,41 @@ de: disabled_by: "Diese Komponente wurde deaktiviert von" required_version: error: "Dieses Theme wurde automatisch deaktiviert, weil es mit dieser Discourse-Version nicht kompatibel ist." - minimum: "Erfordert Discourse-Version %{version} oder höher" + minimum: "Erfordert Discourse-Version %{version} oder höher." maximum: "Erfordert Discourse-Version %{version} oder niedriger." component_of: "Komponente von:" update_to_latest: "Aktualisieren auf neueste Version" check_for_updates: "Nach Aktualisierungen suchen" - updating: "Wird aktualisiert..." + updating: "Wird aktualisiert …" up_to_date: "Theme ist aktuell, zuletzt geprüft:" has_overwritten_history: "Die aktuelle Version des Themes existiert nicht mehr, da die Git-Historie durch einen erzwungenen Push überschrieben wurde." add: "Hinzufügen" theme_settings: "Theme-Einstellungen" no_settings: "Dieses Theme hat keine Einstellungen." theme_translations: "Theme-Übersetzungen" - empty: "Keine Teile" + empty: "Keine Elemente" commits_behind: one: "Theme liegt %{count} Commit zurück!" other: "Theme liegt %{count} Commits zurück!" - compare_commits: "(Siehe neue Beiträge)" - remote_theme_edits: "Wenn Du dieses Template bearbeiten möchtest, musst Du eine Änderung in seinem Projektarchiv einreichen" + compare_commits: "(Siehe neue Commits)" + remote_theme_edits: "Wenn du dieses Theme bearbeiten möchtest, musst du eine Änderung in seinem Repository einreichen" repo_unreachable: "Das Git-Repository dieses Themes konnte nicht kontaktiert werden. Fehlermeldung:" - imported_from_archive: "Das Design wurde von einer .zip Datei importiert" + imported_from_archive: "Dieses Theme wurde aus einer .zip-Datei importiert" scss: text: "CSS" - title: "Gib benutzerdefiniertes CSS ein, wir akzeptieren alle gültigen CSS und SCSS-Stile" + title: "Gib benutzerdefiniertes CSS ein, wir akzeptieren alle gültigen CSS- und SCSS-Styles" header: text: "Kopfzeile" - title: "Gib HTML ein, das oberhalb der Seiten-Kopfzeile angezeigt wird" + title: "Gib HTML ein, das oberhalb der Website-Kopfzeile angezeigt wird" after_header: text: "Nach der Kopfzeile" - title: "Gib HTML ein, das auf allen Seiten unterhalb der Seiten-Kopfzeile angezeigt wird" + title: "Gib HTML ein, das auf allen Seiten unterhalb der Kopfzeile angezeigt wird" footer: text: "Fußzeile" title: "Gib HTML ein, das in der Seiten-Fußzeile angezeigt wird" embedded_scss: text: "Eingebettetes CSS" - title: "Gib benutzerdefiniertes CSS ein, das mit eingebetten Versionen von Kommentaren übermittelt wird" + title: "Gib benutzerdefiniertes CSS ein, das mit eingebetteten Versionen von Kommentaren übermittelt wird" color_definitions: text: "Farbdefinitionen" title: "Benutzerdefinierte Farbdefinitionen eingeben (nur für fortgeschrittene Benutzer)" @@ -4095,30 +4080,30 @@ de: %{example} - Eigenschaften sollten mit einem Präfix versehen werden, um Konflikte mit Discourse und Plugins zu vermeiden. + Eigenschaften sollten mit einem Präfix versehen werden, um Konflikte mit Discourse und/oder Plug-ins zu vermeiden. head_tag: text: "" - title: "HTML, das vor dem -Befehl eingefügt wird" + title: "HTML, das vor dem -Tag eingefügt wird" body_tag: text: "" - title: "HTML, das vor dem -Befehl eingefügt wird" + title: "HTML, das vor dem -Tag eingefügt wird" yaml: text: "YAML" title: "Definiere Theme-Einstellungen im YAML-Format" - scss_color_variables_warning: 'Die Verwendung von Core-SCSS-Farbvariablen in Themes ist veraltet. Bitte verwende stattdessen CSS-benutzerdefinierte Eigenschaften. Siehe diese Anleitung für weitere Details.' + scss_color_variables_warning: 'Die Verwendung von Core-SCSS-Farbvariablen in Themes ist veraltet. Bitte verwende stattdessen benutzerdefinierte CSS-Eigenschaften. Siehe diese Anleitung für weitere Details.' scss_warning_inline: "Die Verwendung von Core-SCSS-Farbvariablen in Themes ist veraltet." colors: select_base: - title: "Wähle Basis-Farbschema" - description: "Basis-Schema:" + title: "Wähle Basis-Farbpalette" + description: "Basis-Palette:" title: "Farben" - edit: "Farbpalette bearbeiten" + edit: "Farbpaletten bearbeiten" long_title: "Farbpaletten" about: "Bearbeite die Farben, die von deinem Theme verwendet werden. Beginne mit dem Erstellen einer neuen Farbpalette." new_name: "Neue Farbpalette" copy_name_prefix: "Kopie von" delete_confirm: "Diese Farbpalette löschen?" - undo: "rückgängig" + undo: "rückgängig machen" undo_title: "Die seit dem letzten Speichern an dieser Farbe vorgenommenen Änderungen rückgängig machen." revert: "verwerfen" revert_title: "Diese Farbe auf den Standardwert aus der Farbpalette zurücksetzen." @@ -4126,9 +4111,9 @@ de: name: "erste" description: "Die meisten Texte, Bilder und Ränder." primary-medium: - name: "primär mittel" + name: "erste (mittel)" primary-low-mid: - name: "primär niedrig-mittel" + name: "erste (niedrig/mittel)" secondary: name: "zweite" description: "Die Haupthintergrundfarbe und Textfarbe einiger Schaltflächen." @@ -4139,20 +4124,20 @@ de: name: "vierte" description: "Navigations-Links" header_background: - name: "Hintergrund Kopfbereich" + name: "Kopfzeile (Hintergrund)" description: "Hintergrundfarbe des Kopfbereichs der Website." header_primary: - name: "primärer Kopfbereich" + name: "Kopfzeile (erste)" description: "Text und Symbole im Kopfbereich der Website." highlight: name: "hervorheben" - description: "Die Hintergrundfarbe von hervorgehobenen Elementen, wie etwa Beiträge und Themen." + description: "Die Hintergrundfarbe von hervorgehobenen Elementen auf der Seite, z. B. Beiträge und Themen." highlight-high: - name: "Highlight-hoch" + name: "hervorheben (hoch)" highlight-medium: - name: "Highlight-Medium" + name: "hervorheben (mittel)" highlight-low: - name: "Highlight-niedrig" + name: "hervorheben (niedrig)" danger: name: "Gefahr" description: "Hervorhebungsfarbe für Aktionen wie Löschen von Beiträgen und Themen." @@ -4161,25 +4146,25 @@ de: description: "Zeigt an, dass eine Aktion erfolgreich war." love: name: "Liebe" - description: "Die Farbe des Like-Buttons." + description: "Die Farbe der Like-Schaltfläche." robots: - title: "Überschreibe die robots.txt Datei deiner Seite:" - warning: "Dies wird sämtliche zugehörigen Seiteneinstellungen dauerhaft überschreiben." - overridden: Die robots.txt Standard-Datei deiner Seite ist überschrieben. + title: "Überschreibe die robots.txt-Datei deiner Website:" + warning: "Dies wird sämtliche zugehörigen Website-Einstellungen dauerhaft überschreiben." + overridden: Die robots.txt-Standard-Datei deiner Website wird überschrieben. email_style: - title: "E-Mail Stil" - heading: "E-Mail Stil anpassen" - html: "HTML Vorlage" + title: "E-Mail-Stil" + heading: "E-Mail-Stil anpassen" + html: "HTML-Vorlage" css: "CSS" reset: "Auf Standard zurücksetzen" - reset_confirm: "Bist du sicher, dass du auf Standard %{fieldName} zurücksetzen und die Änderungen verlieren willst?" + reset_confirm: "Bist du sicher, dass du %{fieldName} auf den Standard zurücksetzen und die Änderungen verlieren willst?" save_error_with_reason: "Deine Änderungen wurden nicht gespeichert. %{error}" - instructions: "Passe die Vorlage an, mit der alle HTML E-Mails erstellt werden, und den Stil per CSS." + instructions: "Passe die Vorlage an, mit der alle HTML-E-Mails erstellt werden, und den Stil per CSS." email: title: "E-Mails" settings: "Einstellungen" templates: "Vorlagen" - preview_digest: "Vorschau auf Neuigkeiten" + preview_digest: "Vorschau der Zusammenfassung" advanced_test: title: "Erweiterter Test" desc: "Erfahre, wie Discourse erhaltene E-Mails verarbeitet. Um eine E-Mail korrekt zu verarbeiten, kopiere unten bitte die ganze ursprüngliche E-Mail-Nachricht hinein:" @@ -4187,9 +4172,9 @@ de: run: "Test ausführen" text: "Ausgewählter Textinhalt" elided: "Ausgelassener Text" - sending_test: "Versende Test-E-Mail…" - error: "FEHLER - %{server_error}" - test_error: "Es gab ein Problem beim Senden der Test-E-Mail. Bitte überprüfe nochmals deine E-Mail-Einstellungen, stelle sicher dass dein Anbieter keine E-Mail-Verbindungen blockiert und probiere es erneut." + sending_test: "Versende Test-E-Mail …" + error: "FEHLER – %{server_error}" + test_error: "Es gab ein Problem beim Senden der Test-E-Mail. Bitte überprüfe nochmals deine E-Mail-Einstellungen, stelle sicher, dass dein Host keine E-Mail-Verbindungen blockiert, und probiere es erneut." sent: "Gesendet" skipped: "Übersprungen" bounced: "Unzustellbar" @@ -4204,17 +4189,17 @@ de: send_test: "Test-E-Mail senden" sent_test: "Gesendet!" delivery_method: "Versandmethode" - preview_digest_desc: "Vorschau der Neuigkeiten, die als E-Mail an inaktive Nutzer gesendet werden." + preview_digest_desc: "Vorschau des Inhalts der Zusammenfassungs-E-Mails, die an inaktive Benutzer gesendet werden." refresh: "Aktualisieren" send_digest_label: "Sende das Ergebnis an:" send_digest: "Senden" - sending_email: "Sende E-Mail..." + sending_email: "Sende E-Mail …" format: "Format" html: "HTML" text: "Text" html_preview: "Vorschau des E-Mail-Inhalts" - last_seen_user: "Benutzer zuletzt gesehen:" - no_result: "Für diesen Digest wurden keine Ergebnisse gefunden." + last_seen_user: "Zuletzt gesehener Benutzer:" + no_result: "Für diese Zusammenfassung wurden keine Ergebnisse gefunden." reply_key: "Antwort-Schlüssel" skipped_reason: "Grund des Überspringens" incoming_emails: @@ -4230,20 +4215,20 @@ de: headers: "Kopfzeilen" subject: "Betreff" body: "Nachrichtentext" - rejection_message: "Zurückweisung-E-Mail" + rejection_message: "Zurückweisungs-E-Mail" filters: - from_placeholder: "von@example.com" - to_placeholder: "an@example.com" + from_placeholder: "from@example.com" + to_placeholder: "to@example.com" cc_placeholder: "cc@example.com" - subject_placeholder: "Betreff…" + subject_placeholder: "Betreff …" error_placeholder: "Fehler" logs: - none: "Keine Protokolleinträge gefunden." + none: "Keine Protokolle gefunden." filters: title: "Filter" user_placeholder: "Benutzername" address_placeholder: "name@example.com" - type_placeholder: "zusammenfassen, registrieren…" + type_placeholder: "zusammenfassen, registrieren …" reply_key_placeholder: "Antwort-Schlüssel" moderation_history: performed_by: "Durchgeführt von" @@ -4256,7 +4241,7 @@ de: delete_topic: "Thema gelöscht" post_approved: "Beitrag genehmigt" logs: - title: "Logs" + title: "Protokolle" action: "Aktion" created_at: "Erstellt" last_match_at: "Letzter Treffer" @@ -4293,10 +4278,10 @@ de: delete_user: "Benutzer löschen" change_trust_level: "Vertrauensstufe ändern" change_username: "Benutzernamen ändern" - change_site_setting: "Einstellungen ändern" + change_site_setting: "Website-Einstellungen ändern" change_theme: "Theme wechseln" delete_theme: "Theme löschen" - change_site_text: "Text ändern" + change_site_text: "Website-Text ändern" suspend_user: "Benutzer sperren" unsuspend_user: "Benutzer entsperren" removed_suspend_user: "Benutzer sperren (entfernt)" @@ -4321,7 +4306,7 @@ de: revoke_admin: "Administration entziehen" grant_moderation: "Moderation gewähren" revoke_moderation: "Moderation entziehen" - backup_create: "Backup erstellen" + backup_create: "Back-up erstellen" deleted_tag: "Schlagwort gelöscht" deleted_unused_tags: "nicht verwendete Schlagwörter gelöscht" renamed_tag: "Schlagwort umbenannt" @@ -4331,15 +4316,15 @@ de: activate_user: "Benutzer aktivieren" deactivate_user: "Benutzer deaktivieren" change_readonly_mode: "Nur-Lesen-Modus ändern" - backup_download: "Backup herunterladen" - backup_destroy: "Backup löschen" + backup_download: "Back-up herunterladen" + backup_destroy: "Back-up löschen" reviewed_post: "Geprüfter Beitrag" - custom_staff: "plugin-spezifische Aktion" + custom_staff: "Plug-in-spezifische Aktion" post_locked: "Beitrag gesperrt" post_edit: "Beitrag bearbeitet" post_unlocked: "Beitrag entsperrt" - check_personal_message: "Nachrichten geprüft" - disabled_second_factor: "Zwei-Faktor-Authentifizierung deaktiviert" + check_personal_message: "persönliche Nachricht überprüfen" + disabled_second_factor: "Zwei-Faktor-Authentifizierung deaktivieren" topic_published: "Thema veröffentlicht" post_approved: "Beitrag genehmigt" post_rejected: "Beitrag abgelehnt" @@ -4348,40 +4333,40 @@ de: delete_badge: "Abzeichen löschen" merge_user: "Benutzer zusammenführen" entity_export: "Entität exportieren" - change_name: "Name ändern" + change_name: "Namen ändern" topic_timestamps_changed: "Themen-Zeitstempel geändert" - approve_user: "genehmigter Benutzer" + approve_user: "Benutzer genehmigt" web_hook_create: "Webhook anlegen" web_hook_update: "Webhook aktualisieren" web_hook_destroy: "Webhook entfernen" web_hook_deactivate: "Webhook deaktivieren" - embeddable_host_create: "Einbettbaren Host erstellen" - embeddable_host_update: "Einbettbaren Host aktualisieren" - embeddable_host_destroy: "Einbettbaren Host entfernen" - change_theme_setting: "Theme Einstellung ändern" + embeddable_host_create: "einbettbaren Host erstellen" + embeddable_host_update: "einbettbaren Host aktualisieren" + embeddable_host_destroy: "einbettbaren Host entfernen" + change_theme_setting: "Theme-Einstellung ändern" disable_theme_component: "Theme-Komponente deaktivieren" enable_theme_component: "Theme-Komponente aktivieren" revoke_title: "Titel widerrufen" change_title: "Titel ändern" - api_key_create: "Api Schlüssel erstellen" - api_key_update: "Api Schlüssel aktualisieren" - api_key_destroy: "Api Schlüssel zerstören" + api_key_create: "API-Schlüssel erstellen" + api_key_update: "API-Schlüssel aktualisieren" + api_key_destroy: "API-Schlüssel zerstören" override_upload_secure_status: "Hochladesicherheitsstatus überschreiben" page_published: "Seite veröffentlicht" page_unpublished: "Veröffentlichung von Seite aufgehoben" add_email: "E-Mail hinzufügen" update_email: "E-Mail aktualisieren" - destroy_email: "E-Mail zerstören" + destroy_email: "E-Mail entfernen" topic_closed: "Thema geschlossen" topic_opened: "Thema geöffnet" topic_archived: "Thema archiviert" - topic_unarchived: "Thema nicht archiviert" - post_staff_note_create: "team Notiz hinzufügen" - post_staff_note_destroy: "team Notiz löschen" + topic_unarchived: "Thema aus dem Archiv geholt" + post_staff_note_create: "Team-Notiz hinzufügen" + post_staff_note_destroy: "Team-Notiz entfernen" delete_group: "Gruppe löschen" screened_emails: title: "Gefilterte E-Mails" - description: "Wenn jemand ein Konto erstellt, werden die folgenden E-Mail-Adressen überprüft und es wird die Anmeldung blockiert oder eine andere Aktion ausgeführt." + description: "Wenn jemand ein Konto erstellt, werden die folgenden E-Mail-Adressen überprüft und es wird die Registrierung blockiert oder eine andere Aktion ausgeführt." email: "E-Mail-Adresse" actions: allow: "Erlauben" @@ -4392,7 +4377,7 @@ de: domain: "Domain" screened_ips: title: "Gefilterte IPs" - description: 'IP-Adressen, die überwacht werden. Verwende "Zulassen", um IP-Adressen zuzulassen.' + description: 'IP-Adressen, die überwacht werden. Verwende „Erlauben“, um IP-Adressen zuzulassen.' delete_confirm: "Möchtest du wirklich die Regel für %{ip_address} entfernen?" roll_up_confirm: "Möchtest du wirklich die häufig gefilterten IP-Adressen zu Subnetzen zusammenfassen?" rolled_up_some_subnets: "Die geblockten IP-Adressen wurden erfolgreich zu diesen Subnetzen zusammengefasst: %{subnets}" @@ -4408,7 +4393,7 @@ de: filter: "Suche" roll_up: text: "Zusammenfassen" - title: "Erzeugt neue Einträge zum Blockieren von Subnetzen, wenn mindestens 'min_ban_entries_for_roll_up' Einträge vorhanden sind." + title: "Erzeugt neue Subnetz-Block-Einträge, wenn mindestens „min_ban_entries_for_roll_up“ Einträge vorhanden sind." search_logs: title: "Suchprotokolle" term: "Suchbegriff" @@ -4419,24 +4404,24 @@ de: header: "Überschrift" full_page: "Desktop-Seite" click_through_only: "Alle (nur durcklicken)" - header_search_results: "Suchergebnis-Kopfzeile" + header_search_results: "Suchergebnis (Kopfzeile)" logster: title: "Fehlerprotokolle" watched_words: title: "Beobachtete Wörter" - search: "Suchen" + search: "suchen" clear_filter: "Zurücksetzen" show_words: one: "zeige %{count} Wort" other: "zeige %{count} Wörter" download: Herunterladen - clear_all: Auswahl aufheben - clear_all_confirm: "Bist Du sicher, dass Du alle beobachteten Wörter für die %{action} Aktion löschen möchtest?" + clear_all: Alle löschen + clear_all_confirm: "Bist du sicher, dass du alle beobachteten Wörter für die Aktion %{action} löschen möchtest?" invalid_regex: 'Das beobachtete Wort „%{word}“ ist ein ungültiger regulärer Ausdruck.' actions: block: "Blockieren" censor: "Zensieren" - require_approval: "Erfordert Genehmigung" + require_approval: "Genehmigung erfordern" flag: "Melden" replace: "Ersetzen" tag: "Schlagwort" @@ -4448,12 +4433,12 @@ de: require_approval: "Beiträge mit diesen Wörtern werden eine Genehmigung vom Team erfordern, bevor sie sichtbar sind." flag: "Erlaube Beiträge mit diesen Wörtern, aber markiere sie als unangemessen, damit Moderatoren sie überprüfen können." replace: "Wörter in Beiträgen durch andere Wörter ersetzen" - tag: "Markieren Sie Themen automatisch basierend auf dem ersten Beitrag" - silence: "Erste Beiträge von Benutzern, die diese Wörter enthalten, müssen von den Team Mitgliedern genehmigt werden, bevor sie gesehen werden können, und der Benutzer wird automatisch stummgeschaltet." + tag: "Themen basierend auf dem ersten Beitrag automatisch mit Schlagwort versehen" + silence: "Erste Beiträge von Benutzern, die diese Wörter enthalten, müssen vom Team genehmigt werden, bevor sie gesehen werden können, und der Benutzer wird automatisch stummgeschaltet." link: "Wörter in Beiträgen durch Links ersetzen" form: label: "Hat Wort oder Wortgruppe" - placeholder: "Geben Sie ein Wort oder eine Phrase ein (* ist ein Platzhalter)" + placeholder: "Wort oder Ausdruck eingeben (* ist ein Platzhalter)" placeholder_regexp: "regulärer Ausdruck" replace_label: "Ersatz" replace_placeholder: "Beispiel" @@ -4469,9 +4454,9 @@ de: button_label: "Test" description: "Füge unten Text ein, um auf Übereinstimmungen mit beobachteten Wörtern zu prüfen" found_matches: "Gefundene Übereinstimmungen:" - no_matches: "Keine Treffer gefunden" + no_matches: "Keine Übereinstimmungen gefunden" impersonate: - title: "Als Benutzer ausgeben" + title: "Nutzersicht" help: "Benutze dieses Werkzeug, um zur Fehlersuche in die Rolle eines anderen Benutzers zu schlüpfen. Du musst dich abmelden, wenn du fertig bist." not_found: "Der Benutzer wurde nicht gefunden." invalid: "Entschuldige, du darfst nicht in die Rolle dieses Benutzers schlüpfen." @@ -4496,7 +4481,7 @@ de: active: "Aktive Benutzer" new: "Neue Benutzer" pending: "Benutzer mit ausstehender Genehmigung" - newuser: "Benutzer mit Vertrauensstufe 0 (Neuer Benutzer)" + newuser: "Benutzer mit Vertrauensstufe 0 (neuer Benutzer)" basic: "Benutzer mit Vertrauensstufe 1 (Anwärter)" member: "Benutzer mit Vertrauensstufe 2 (Mitglied)" regular: "Benutzer mit Vertrauensstufe 3 (Stammgast)" @@ -4506,44 +4491,44 @@ de: moderators: "Moderatoren" silenced: "Stummgeschaltete Benutzer" suspended: "Gesperrte Benutzer" - staged: "Vorbereite Benutzer" + staged: "Vorbereitete Benutzer" not_verified: "Nicht überprüft" check_email: title: "E-Mail-Adresse des Benutzers anzeigen" text: "Anzeigen" check_sso: - title: "SSO-Nutzlast anzeigen" + title: "SSO-Payload anzeigen" text: "Anzeigen" user: - suspend_failed: "Beim Sperren dieses Benutzers ist etwas schief gegangen %{error}" - unsuspend_failed: "Beim Entsperren dieses Benutzers ist etwas schief gegangen %{error}" + suspend_failed: "Beim Sperren dieses Benutzers ist etwas schiefgegangen %{error}" + unsuspend_failed: "Beim Entsperren dieses Benutzers ist etwas schiefgegangen %{error}" suspend_duration: "Wie lange soll dieser Benutzer gesperrt werden?" - suspend_reason_label: "Warum sperrst du? Dieser Text ist auf der Profilseite des Benutzers für jeden sichtbar und wird dem Benutzer angezeigt, wenn sich dieser anmelden will. Bitte kurz halten." - suspend_reason_hidden_label: "Warum sperrst du? Dieser Text wird dem Benutzer angezeigt, wenn er versucht, sich anzumelden. Fasse dich kurz." + suspend_reason_label: "Was ist der Grund für die Sperre? Dieser Text ist auf der Profilseite des Benutzers für jeden sichtbar und wird dem Benutzer angezeigt, wenn sich dieser anmelden will. Fasse dich kurz." + suspend_reason_hidden_label: "Was ist der Grund für die Sperre? Dieser Text wird dem Benutzer angezeigt, wenn er versucht, sich anzumelden. Fasse dich kurz." suspend_reason: "Grund" suspend_reason_title: "Grund der Sperre" suspend_reasons: - not_listening_to_staff: "Würde nicht auf Team Feedback hören" + not_listening_to_staff: "Hat nicht auf Team-Feedback gehört" consuming_staff_time: "Hat überproportional viel Zeit des Teams verbraucht" - combative: "Zu kämpferisch" + combative: "Zu streitlustig" in_wrong_place: "An der falschen Stelle" - no_constructive_purpose: "Kein konstruktiver Zweck für ihre Handlungen, außer Dissens innerhalb der Gemeinschaft zu erzeugen" - custom: "Benutzerdefiniert..." + no_constructive_purpose: "Kein konstruktiver Zweck der Handlungen, außer Dissens innerhalb der Gemeinschaft zu erzeugen" + custom: "Benutzerdefiniert …" suspend_message: "E-Mail-Nachricht" - suspend_message_placeholder: "Optional: Gib weitere Informationen über deine Sperrung an und sie wird dem Benutzer per E-Mail geschickt." + suspend_message_placeholder: "Optional: Gib weitere Informationen zur Sperre an und sie werden dem Benutzer per E-Mail geschickt." suspended_by: "Gesperrt von" silence_reason: "Grund" silenced_by: "Stummgeschaltet von" silence_modal_title: "Benutzer stummschalten" - silence_duration: "Wie lange wird der Benutzer/die Benutzerin stummgeschaltet?" - silence_reason_label: "Warum schaltest du diesen Benutzer/diese Benutzerin stumm?" + silence_duration: "Wie lange wird der Benutzer stummgeschaltet?" + silence_reason_label: "Warum schaltest du diesen Benutzer stumm?" silence_reason_placeholder: "Grund der Stummschaltung" silence_message: "E-Mail-Nachricht" - silence_message_placeholder: "(leer lassen um Standardnachricht zu schicken)" + silence_message_placeholder: "(leer lassen, um Standardnachricht zu schicken)" suspended_until: "(bis %{until})" cant_suspend: "Der Benutzer kann nicht gesperrt werden." delete_all_posts: "Lösche alle Beiträge" - delete_posts_progress: "Beiträge werden gelöscht..." + delete_posts_progress: "Beiträge werden gelöscht …" delete_posts_failed: "Es gab ein Problem beim Löschen der Beiträge." penalty_post_actions: "Was möchtest du mit dem zugehörigen Beitrag machen?" penalty_post_delete: "Beitrag löschen" @@ -4553,7 +4538,7 @@ de: penalty_count: "Anzahl der Strafen" clear_penalty_history: title: "Lösche die Strafenhistorie" - description: "Benutzer mit Strafen können TL3 nicht erreichen" + description: "Benutzer mit Strafen können VS3 nicht erreichen" delete_all_posts_confirm_MF: "Du wirst {POSTS, plural, one {# Beitrag} other {# Beiträge}} und {TOPICS, plural, one {# Thema} other {# Themen}} löschen. Bist du dir sicher?" silence: "Stummschalten" unsilence: "Stummschaltung aufheben" @@ -4565,7 +4550,7 @@ de: show_admin_profile: "Administration" show_public_profile: "Zeige öffentliches Profil" impersonate: "Nutzersicht" - action_logs: "Aktionsprotokoll" + action_logs: "Aktionsprotokolle" ip_lookup: "IP-Abfrage" log_out: "Abmelden" logged_out: "Der Benutzer wurde auf allen Geräten abgemeldet" @@ -4582,7 +4567,7 @@ de: reputation: Reputation permissions: Berechtigungen activity: Aktivität - like_count: Abgegebene / erhaltene Likes + like_count: Abgegebene/Erhaltene Likes last_100_days: "in den letzten 100 Tagen" private_topics_count: Private Themen posts_read_count: Gelesene Beiträge @@ -4592,16 +4577,16 @@ de: flags_given_count: Gemachte Meldungen flags_received_count: Erhaltene Meldungen warnings_received_count: Warnungen erhalten - flags_given_received_count: "Gemachte / erhaltene Meldungen" + flags_given_received_count: "Gemachte/Erhaltene Meldungen" approve: "Genehmigen" approved_by: "genehmigt von" approve_success: "Benutzer wurde genehmigt und eine E-Mail mit Anweisungen zur Aktivierung wurde gesendet." approve_bulk_success: "Erfolgreich! Alle ausgewählten Benutzer wurden genehmigt und benachrichtigt." time_read: "Lesezeit" anonymize: "Benutzer anonymisieren" - anonymize_confirm: "Willst du dieses Konto wirklich anonymisieren? Dadurch werden der Benutzername und die E-Mail-Adresse unkenntlich gemacht und alle Informationen im Profil entfernt." - anonymize_yes: "Ja, diesen Benutzer anonymisieren" - anonymize_failed: "Beim Anonymisieren des Benutzers ist ein Fehler aufgetreten." + anonymize_confirm: "Möchtest du dieses Konto WIRKLICH anonymisieren? Dadurch werden der Benutzername und die E-Mail-Adresse geändert und alle Informationen im Profil zurückgesetzt." + anonymize_yes: "Ja, dieses Konto anonymisieren" + anonymize_failed: "Beim Anonymisieren des Kontos ist ein Fehler aufgetreten." delete: "Benutzer löschen" delete_posts: progress: @@ -4609,55 +4594,55 @@ de: merge: button: "Zusammenführen" prompt: - title: "Übertragen & Löschen @%{username}" + title: "@%{username} übertragen und löschen" description: |

    Bitte wähle einen neuen Eigentümer für die Inhalte von @%{username}.

    Alle von @%{username} erstellten Themen, Beiträge, Nachrichten und sonstigen Inhalte werden übertragen.

    target_username_placeholder: "Benutzername des neuen Eigentümers" - transfer_and_delete: "Übertragen & Löschen @%{username}" + transfer_and_delete: "@%{username} übertragen und löschen" cancel: "Abbrechen" progress: title: "Fortschritt der Zusammenführung" confirmation: - title: "Übertragen & Löschen @%{username}" + title: "@%{username} übertragen und löschen" description: | -

    Alle Inhalte von @%{username} werden übertragen und @%{targetUsername}zugeschrieben. Nachdem der Inhalt übertragen wurde, wird das Konto von @%{username} gelöscht.

    +

    Alle Inhalte von @%{username} werden übertragen und @%{targetUsername} zugeschrieben. Nachdem die Inhalte übertragen wurden, wird das Konto von @%{username} gelöscht.

    Dies kann nicht rückgängig gemacht werden!

    -

    Um fortzufahren gib bitte Folgendes ein: %{text}

    +

    Um fortzufahren, gib bitte Folgendes ein: %{text}

    text: "Übertrage @%{username} auf @%{targetUsername}" - transfer_and_delete: "Übertragen & Löschen @%{username}" + transfer_and_delete: "@%{username} übertragen und löschen" cancel: "Abbrechen" - merging_user: "Benutzer zusammenführen ..." + merging_user: "Benutzer zusammenführen …" merge_failed: "Beim Zusammenführen der Benutzer ist ein Fehler aufgetreten." delete_forbidden_because_staff: "Administratoren und Moderatoren können nicht gelöscht werden." delete_posts_forbidden_because_staff: "Löschen aller Beiträge von Administratoren und Moderatoren ist nicht möglich." delete_forbidden: - one: "Benutzer können nicht gelöscht werden, wenn diese Beiträge haben. Lösche zuerst all dessen Beiträge, bevor du versuchst einen Benutzer zu löschen. (Beiträge, die älter als %{count} Tag sind, können nicht gelöscht werden.)" - other: "Benutzer können nicht gelöscht werden, wenn sie Beiträge haben. Lösche zuerst alle Beiträge, bevor du versuchst einen Benutzer zu löschen. (Beiträge älter als %{count} Tage können nicht gelöscht werden.)" + one: "Benutzer können nicht gelöscht werden, wenn diese Beiträge haben. Lösche zuerst alle seine Beiträge, bevor du versuchst, einen Benutzer zu löschen. (Beiträge, die älter als %{count} Tag sind, können nicht gelöscht werden.)" + other: "Benutzer können nicht gelöscht werden, wenn diese Beiträge haben. Lösche zuerst alle seine Beiträge, bevor du versuchst, einen Benutzer zu löschen. (Beiträge, die älter als %{count} Tage sind, können nicht gelöscht werden.)" cant_delete_all_posts: - one: "Nicht alle Beiträge können gelöscht werden. Einige Beiträge sind älter als %{count} Tag (die „delete_user_max_post_age“ Einstellung)." - other: "Nicht alle Beiträge können gelöscht werden. Einige Beiträge sind älter als %{count} Tage (die „delete_user_max_post_age“ Einstellung)." + one: "Nicht alle Beiträge können gelöscht werden. Einige Beiträge sind älter als %{count} Tag. (Siehe die Einstellung „delete_user_max_post_age“.)" + other: "Nicht alle Beiträge können gelöscht werden. Einige Beiträge sind älter als %{count} Tage. (Siehe die Einstellung „delete_user_max_post_age“.)" cant_delete_all_too_many_posts: - one: "Nicht alle Beiträge konnten gelöscht werden, da der Benutzer mehr als %{count} Beitrag hat (die „delete_all_posts_max“ Einstellung)." - other: "Es können nicht alle Beiträge gelöscht werden, da der Benutzer mehr als %{count} Beiträge hat (siehe „delete_all_posts_max“ Einstellung)." - delete_confirm: "Es ist grundsätzlich vorzuziehen, Benutzer zu anonymisieren statt sie zu löschen, um zu vermeiden, dass Inhalt aus bestehenden Diskussionen entfernt wird.

    Bist du SICHER, dass du diesen Benutzer löschen möchtest? Dies lässt sich nicht rückgängig machen." + one: "Nicht alle Beiträge können gelöscht werden, da der Benutzer mehr als %{count} Beitrag hat. (Siehe die Einstellung „delete_all_posts_max“.)" + other: "Nicht alle Beiträge können gelöscht werden, da der Benutzer mehr als %{count} Beiträge hat. (Siehe die Einstellung „delete_all_posts_max“.)" + delete_confirm: "Es ist grundsätzlich vorzuziehen, Benutzer zu anonymisieren, statt sie zu löschen, um zu vermeiden, dass Inhalt aus bestehenden Diskussionen entfernt wird.

    Bist du SICHER, dass du diesen Benutzer löschen möchtest? Dies lässt sich nicht rückgängig machen." delete_and_block: "Löschen und diese E-Mail-Adresse und IP-Adresse blockieren" delete_dont_block: "Nur löschen" - deleting_user: "Benutzer wird gelöscht…" + deleting_user: "Benutzer wird gelöscht …" deleted: "Der Benutzer wurde gelöscht." delete_failed: "Beim Löschen des Benutzers ist ein Fehler aufgetreten. Stelle sicher, dass dieser Benutzer keine Beiträge mehr hat." - send_activation_email: "Aktivierungsmail senden" - activation_email_sent: "Die Aktivierungsmail wurde gesendet." - send_activation_email_failed: "Beim Senden der Aktivierungsmail ist ein Fehler aufgetreten. %{error}" + send_activation_email: "Aktivierungs-E-Mail senden" + activation_email_sent: "Eine Aktivierungs-E-Mail wurde gesendet." + send_activation_email_failed: "Beim Senden der Aktivierungs-E-Mail ist ein Fehler aufgetreten. %{error}" activate: "Benutzer aktivieren" activate_failed: "Beim Aktivieren des Benutzers ist ein Fehler aufgetreten." deactivate_account: "Benutzer deaktivieren" deactivate_failed: "Beim Deaktivieren des Benutzers ist ein Fehler aufgetreten." unsilence_failed: "Beim Aufheben der Stummschaltung ist ein Fehler aufgetreten." - silence_failed: "Bei der Stummschaltung des Benutzers/der Benutzerin ist ein Fehler aufgetreten." + silence_failed: "Bei der Stummschaltung des Benutzers ist ein Fehler aufgetreten." silence_confirm: "Bist du sicher, dass du diesen Benutzer stummschalten möchtest? Der Benutzer wird keine Möglichkeit mehr haben, Themen oder Beiträge zu erstellen." silence_accept: "Ja, diesen Benutzer stummschalten" bounce_score: "Anzahl unzustellbarer E-Mails" @@ -4675,7 +4660,7 @@ de: threshold_reached: "Zu viele unzustellbare E-Mails an diese Adresse." trust_level_change_failed: "Beim Wechsel der Vertrauensstufe ist ein Fehler aufgetreten." suspend_modal_title: "Benutzer sperren" - confirm_cancel_penalty: "Sind Du sicher, dass Du die Strafe verwerfen möchtest?" + confirm_cancel_penalty: "Bist du sicher, dass du die Strafe verwerfen willst?" trust_level_2_users: "Benutzer mit Vertrauensstufe 2" trust_level_3_requirements: "Anforderungen für Vertrauensstufe 3" trust_level_locked_tip: "Die Vertrauensstufe ist gesperrt. Das System wird den Benutzer nicht befördern oder zurückstufen." @@ -4693,9 +4678,9 @@ de: days: "Tage" topics_replied_to: "Auf Themen geantwortet" topics_viewed: "Betrachtete Themen" - topics_viewed_all_time: "Betrachtete Themen (gesamte Zeit)" + topics_viewed_all_time: "Betrachtete Themen (gesamt)" posts_read: "Gelesene Beiträge" - posts_read_all_time: "Gelesene Beiträge (gesamte Zeit)" + posts_read_all_time: "Gelesene Beiträge (gesamt)" flagged_posts: "Gemeldete Beiträge" flagged_by_users: "Von Benutzern gemeldet" likes_given: "Abgegebene Likes" @@ -4712,20 +4697,20 @@ de: locked_will_not_be_promoted: "Vertrauensstufe ist gesperrt. Wird nie befördert werden." locked_will_not_be_demoted: "Vertrauensstufe ist gesperrt. Wird nie zurückgestuft werden." discourse_connect: - title: "DiscourseConnect Single Sign On" + title: "DiscourseConnect Single Sign-on" external_id: "Externe ID" external_username: "Benutzername" external_name: "Name" external_email: "E-Mail" - external_avatar_url: "Profilbild URL" - last_payload: "Letzte Nutzlast" + external_avatar_url: "Profilbild-URL" + last_payload: "Letzter Payload" delete_sso_record: "SSO-Datensatz löschen" - confirm_delete: "Bist du sicher, dass du diesen DiscourseConnect-Eintrag löschen möchtest?" + confirm_delete: "Bist du sicher, dass du diesen DiscourseConnect-Datensatz löschen möchtest?" user_fields: title: "Benutzerfelder" help: "Füge Felder hinzu, welche deine Benutzer ausfüllen können." create: "Benutzerfeld erstellen" - untitled: "Unbetitelt" + untitled: "Ohne Titel" name: "Feldname" type: "Feldtyp" description: "Feldbeschreibung" @@ -4749,8 +4734,8 @@ de: disabled: "wird im Profil nicht angezeigt" show_on_user_card: title: "Auf Benutzerkarte anzeigen?" - enabled: "Wird auf Benutzerkarte angezeigt" - disabled: "Wird nicht auf Benutzerkarte angezeigt" + enabled: "wird auf Benutzerkarte angezeigt" + disabled: "wird nicht auf Benutzerkarte angezeigt" searchable: title: "Durchsuchbar?" enabled: "durchsuchbar" @@ -4758,7 +4743,7 @@ de: field_types: text: "Textfeld" confirm: "Bestätigung" - dropdown: "Dropdown-Liste" + dropdown: "Drop-down-Liste" site_text: description: "Du kannst jeden Text deines Forums anpassen. Benutze dazu die Suche:" search: "Suche nach dem Text, den du bearbeiten möchtest" @@ -4770,16 +4755,16 @@ de: recommended: "Wir empfehlen, dass du den folgenden Text an deine Bedürfnisse anpasst:" show_overriden: "Nur geänderte Texte anzeigen" locale: "Sprache:" - fallback_locale_warning: "Du bearbeitest eine Sprache basierend auf %{fallback}. Benutzer, die %{fallback} als Sprache ihrer Schnittstelle wählen, werden deine Änderungen nicht sehen." + fallback_locale_warning: "Du bearbeitest eine Sprache basierend auf %{fallback}. Benutzer, die %{fallback} als Sprache ihrer Oberfläche wählen, werden deine Änderungen nicht sehen." more_than_50_results: "Es gibt mehr als 50 Ergebnisse. Bitte grenze deine Suche weiter ein." settings: - show_overriden: "Nur geänderte anzeigen" + show_overriden: "Nur geänderte Texte anzeigen" history: "Änderungsverlauf ansehen" reset: "zurücksetzen" none: "keine" site_settings: emoji_list: - invalid_input: "Emoji-Liste sollte nur gültige Emoji-Namen enthalten, z. B.: hugs" + invalid_input: "Emoji-Liste sollte nur gültige Emoji-Namen enthalten, z. B.: Umarmung" add_emoji_button: label: "Emoji hinzufügen" title: "Einstellungen" @@ -4816,9 +4801,9 @@ de: embedding: "Einbettung" legal: "Rechtliches" api: "API-Schnittstelle" - user_api: "Benutzer API" + user_api: "Benutzer-API" uncategorized: "Sonstiges" - backups: "Backups" + backups: "Back-ups" login: "Anmeldung" plugins: "Plug-ins" user_preferences: "Benutzereinstellungen" @@ -4829,11 +4814,11 @@ de: secret_list: invalid_input: "Eingabefelder können nicht leer sein oder vertikale Balkenzeichen enthalten." default_categories: - modal_description: "Soll diese Änderung rückwirkend gelten? Das ändert die Voreinstellungen für %{count} bestehende Benutzer." + modal_description: "Soll diese Änderung rückwirkend gelten? Das ändert die Einstellungen für %{count} bestehende Benutzer." modal_yes: "Ja" modal_no: "Nein, die Änderung soll sich nur zukünftig auswirken" simple_list: - add_item: "Element hinzufügen..." + add_item: "Element hinzufügen …" json_schema: edit: Editor starten modal_title: "%{name} bearbeiten" @@ -4865,7 +4850,7 @@ de: granted_badges: Verliehene Abzeichen grant: Verleihen no_user_badges: "%{name} wurden keine Abzeichen verliehen." - no_badges: Es gibt keine Abzeichen die verliehen werden können. + no_badges: Es gibt keine Abzeichen, die verliehen werden können. none_selected: "Wähle ein Abzeichen aus, um loszulegen" allow_title: Abzeichen darf als Titel verwendet werden multiple_grant: Kann mehrfach verliehen werden @@ -4874,26 +4859,26 @@ de: icon: Symbol image: Bild graphic: Grafik - icon_help: "Gib einen Font Awesome Icon Namen ein (benutze für reguläre Icons das Präfix 'far-' und für Marken-Icons 'fab-')" + icon_help: "Gib einen „Font Awesome“-Symbolnamen ein (benutze für reguläre Symbole das Präfix „far-“ und für Markensymbole „fab-“)" image_help: "Das Hochladen eines Bildes überschreibt das Symbolfeld, wenn beide gesetzt sind." select_an_icon: "Wähle ein Symbol" upload_an_image: "Lade ein Bild hoch" - read_only_setting_help: "Anpassen von Text" + read_only_setting_help: "Text anpassen" query: Abzeichen-Abfrage (SQL) target_posts: Abfrage betrifft Beiträge auto_revoke: Führe die Abfrage zum Widerruf täglich aus show_posts: Den für die Verleihung des Abzeichens verantwortlichen Beitrag auf der Abzeichenseite anzeigen - trigger: Trigger + trigger: Auslöser trigger_type: none: "Täglich aktualisieren" post_action: "Wenn ein Benutzer auf einen Beitrag reagiert" post_revision: "Wenn ein Benutzer einen Beitrag bearbeitet oder erstellt" trust_level_change: "Wenn sich die Vertrauensstufe eines Benutzers ändert" - user_change: "Wenn ein Benutzer bearbeitet oder erstellt wird." + user_change: "Wenn ein Benutzer bearbeitet oder erstellt wird" post_processed: "Nach der Verarbeitung eines Beitrags" preview: link_text: "Vorschau auf verliehene Abzeichen" - plan_text: "Vorschau mit Query Plan" + plan_text: "Vorschau mit Abfrageplan" modal_title: "Vorschau für Abzeichen-Abfrage" sql_error_header: "Es gab einen Fehler mit der SQL-Abfrage." error_help: "Unter den nachfolgenden Links findest du Hilfe zu Abzeichen-Abfragen." @@ -4912,54 +4897,54 @@ de: with_time: %{username} um %{time} badge_intro: title: "Wähle ein bestehendes Abzeichen oder erstelle ein neues, um loszulegen" - emoji: "Studentin Emoji" + emoji: "Studentin-Emoji" what_are_badges_title: "Was sind Abzeichen?" badge_query_examples_title: "Beispiele für Abzeichen-Abfragen" mass_award: title: Massen-Auszeichnung description: Das gleiche Abzeichen vielen Benutzern auf einmal verleihen. - no_badge_selected: Bitte wähle ein Abzeichen, um anzufangen. + no_badge_selected: Bitte wähle ein Abzeichen aus, um loszulegen. perform: "Verleihe Abzeichen an Benutzer" - upload_csv: Lade eine CSV-Datei mit entweder den E-Mail Adressen oder den Benutzernamen hoch - aborted: Bitte lade eine CSV hoch, die entweder Benutzer-E-Mails oder Benutzernamen enthält - success: Deine CSV Datei wurde empfangen und die Benutzer werden ihr Abzeichen in Kürze bekommen. + upload_csv: Lade eine CSV-Datei mit entweder den E-Mail-Adressen oder den Benutzernamen hoch + aborted: Bitte lade eine CSV-Datei hoch, die entweder E-Mail-Adressen oder Benutzernamen enthält + success: Deine CSV-Datei wurde empfangen und die Benutzer werden ihr Abzeichen in Kürze erhalten. replace_owners: Entferne das Abzeichen von vorherigen Eigentümern emoji: title: "Emoji" - help: "Neues Emoji hinzufügen, das für alle verfügbar sein wird. (Tipp: per Drag & Drop kannst du gleichzeitig mehrere Dateien hinzufügen)" + help: "Neues Emoji hinzufügen, das für alle verfügbar sein wird. (TIPP: Per Drag-and-drop kannst du gleichzeitig mehrere Dateien hinzufügen)" add: "Neues Emoji hinzufügen" - uploading: "Wird hochgeladen…" + uploading: "Wird hochgeladen …" name: "Name" group: "Gruppe" image: "Bild" - alt: "benutzerdefinierte Emoji-Vorschau" - delete_confirm: "Möchtest du wirklich das :%{name}: Emoji löschen?" + alt: "Vorschau des benutzerdefinierten Emojis" + delete_confirm: "Möchtest du wirklich das „:%{name}:“-Emoji löschen?" embedding: - get_started: "Wenn du Discourse in einer anderen Website einbetten möchtest, beginne mit dem hinzufügen des Host." + get_started: "Wenn du Discourse in einer anderen Website einbetten möchtest, beginne mit dem Hinzufügen des Hosts." confirm_delete: "Möchtest du wirklich diesen Host löschen?" - sample: "Benutze den folgenden HTML code für deine Site um Discourse Beiträge zu erstellen und einzubetten. Ersetze REPLACE_ME mit der URL der Site in die du sie einbetten möchtest." + sample: "Verwende den folgenden HTML-Code auf deiner Website, um Discourse-Themen zu erstellen und einzubetten. Ersetze REPLACE_ME mit der URL der Seite, auf der du ihn einbettest." title: "Einbettung" host: "Erlaubte Hosts" class_name: "Klassenname" allowed_paths: "Pfad-Zulassungsliste" edit: "bearbeiten" - category: "In Kategorie Beitrag schreiben" + category: "In Kategorie posten" add_host: "Host hinzufügen" settings: "Einbettungseinstellungen" crawling_settings: "Crawler-Einstellungen" - crawling_description: "Wenn Discourse Themen für deine Beiträge erstellt wird es falls kein RSS/ATOM-Feed verfügbar ist versuchen, den Inhalt aus dem HTML-Code zu extrahieren. Dies ist teilweise schwierig, weshalb hier CSS-Regeln angegeben werden können, die die Extraktion erleichtern." - embed_by_username: "Benutzername für Beitragserstellung" + crawling_description: "Wenn Discourse Themen für deine Beiträge erstellt, wird es – falls kein RSS-/ATOM-Feed verfügbar ist – versuchen, den Inhalt aus dem HTML-Code zu extrahieren. Dies ist teilweise schwierig, weshalb hier CSS-Regeln angegeben werden können, die die Extraktion erleichtern." + embed_by_username: "Benutzername für Themenerstellung" embed_post_limit: "Maximale Anzahl der Beiträge, welche eingebettet werden" - embed_title_scrubber: "Regulärer Ausdruck (Regex) um den Titel eines Beitrags zu bereinigen" + embed_title_scrubber: "Regulärer Ausdruck (Regex), um den Titel eines Beitrags zu bereinigen" embed_truncate: "Kürze die eingebetteten Beiträge" embed_unlisted: "Importierte Themen werden nicht aufgelistet, bis eine Antwort eingeht." - allowed_embed_selectors: "CSS Selektor für Elemente, die in Einbettungen erlaubt sind." - blocked_embed_selectors: "CSS Selektor für Elemente, die in Einbettungen entfernt werden." + allowed_embed_selectors: "CSS-Selektor für Elemente, die in Einbettungen erlaubt sind." + blocked_embed_selectors: "CSS-Selektor für Elemente, die in Einbettungen entfernt werden." allowed_embed_classnames: "Erlaubte CSS-Klassennamen" save: "Einbettungseinstellungen speichern" permalink: - title: "Permanentlinks" - description: "Weiterleitungen zur Aktivierung von URLs, die dem Forum nicht bekannt sind." + title: "Permalinks" + description: "Weiterleitungen für URLs, die dem Forum nicht bekannt sind." url: "URL" topic_id: "Themen-ID" topic_title: "Thema" @@ -4967,18 +4952,18 @@ de: post_title: "Beitrag" category_id: "Kategorie-ID" category_title: "Kategorie" - tag_name: "Verschlagworte den Namen" + tag_name: "Name des Schlagworts" external_url: "Externe oder relative URL" destination: "Ziel" - copy_to_clipboard: "Kopiere den Permalink in die Zwischenablage" - delete_confirm: Möchtest du wirklich diesen Permanentlink löschen? + copy_to_clipboard: "Permalink in die Zwischenablage kopieren" + delete_confirm: Möchtest du wirklich diesen Permalink löschen? form: label: "Neu:" add: "Hinzufügen" filter: "Suche (URL oder externe URL)" reseed: action: - label: "Text ersetzen..." + label: "Text ersetzen …" title: "Text der Kategorien und Themen mit Übersetzungen ersetzen" modal: title: "Text ersetzen" @@ -4994,21 +4979,21 @@ de: next: "Nächstes" step: "%{current} von %{total}" upload: "Hochladen" - uploading: "Hochladen..." + uploading: "Wird hochgeladen …" upload_error: "Entschuldige, es gab einen Fehler beim Hochladen der Datei. Bitte versuche es noch einmal." quit: "Vielleicht später" staff_count: - one: "Deine Community hat %{count} Teammitglied (du)." - other: "Deine Community hat %{count} Teammitglieder (einschließlich dir)." + one: "Deine Community hat %{count} Team-Mitglied (du)." + other: "Deine Community hat %{count} Team-Mitglieder (einschließlich dir)." invites: add_user: "Hinzufügen" - none_added: "Du hast bisher kein Team eingeladen. Möchtest du wirklich fortfahren?" + none_added: "Du hast bisher keine Team-Mitglieder eingeladen. Möchtest du wirklich fortfahren?" roles: admin: "Administration" moderator: "Moderator" - regular: "Normaler Anwender" + regular: "Normaler Benutzer" previews: topic_title: "Diskussionsthema" - font_title: "%{font} Schriftart" + font_title: "Schriftart „%{font}“" share_button: "Teilen" reply_button: "Antworten" diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index 36f62affe3..1ecddc29f8 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -194,7 +194,6 @@ el: submit: "Υποβολή" generic_error: "Λυπάμαι, προέκυψε κάποιο σφάλμα." generic_error_with_reason: "Προέκυψε ένα σφάλμα: %{error}" - go_ahead: "Προχωρήστε" sign_up: "Εγγραφή" log_in: "Σύνδεση" age: "Ηλικία" @@ -223,7 +222,6 @@ el: x_more: one: "%{count} Περισσότερο" other: "%{count} Περισσότερο" - less: "Λιγότερα" never: "ποτέ" every_30_minutes: "κάθε 30 λεπτά" every_hour: "κάθε ώρα" @@ -232,7 +230,6 @@ el: every_month: "κάθε μήνα" every_six_months: "κάθε έξι μήνες" max_of_count: "μέγιστο %{count}" - alternation: "ή" character_count: one: "%{count} χαρακτήρα" other: "%{count} χαρακτήρες" @@ -264,7 +261,6 @@ el: help: bookmark: "Πάτήστε εδώ για να μπει σελιδοδείκτης στην πρώτη ανάρτηση του θέματος." unbookmark: "Πατήστε εδώ για να αφαιρεθούν όλοι οι σελιδοδείκτες από αυτό το θέμα." - unbookmark_with_reminder: "Κάντε κλικ για να αφαιρέσετε όλους τους σελιδοδείκτες και τις υπενθυμίσεις από αυτό το θέμα. Έχετε ορίσει μια υπενθύμιση στις %{reminder_at} για αυτό το θέμα." bookmarks: created: "Έχετε προσθέσει σελιδοδείκτη σε αυτήν την ανάρτηση. %{name}" not_bookmarked: "προσθέστε σελιδοδείκτη σε αυτήν την ανάρτηση" @@ -565,11 +561,6 @@ el: member_added: "Προστέθηκε" member_requested: "Ζητήθηκε στις" add_members: - title: "Προσθήκη μελών στο %{group_name}" - description: "Μπορείτε επίσης να επικολλήσετε σε μια λίστα διαχωρισμένη με κόμμα." - usernames_or_emails: - title: "Εισαγάγετε ονόματα χρηστών ή διευθύνσεις email" - input_placeholder: "Ονόματα χρηστών ή email" notify_users: "Ειδοποίηση χρηστών" requests: title: "Αιτήματα" @@ -584,7 +575,7 @@ el: title: "Διαχειριστείτε" name: "Όνομα" full_name: "Πλήρες Όνομα" - add_members: "Προσθήκη Μελών" + invite_members: "Πρόσκληση" delete_member_confirm: "Να αφαιρεθεί ο/η '%{username}' από την ομάδα '%{group}' ;" profile: title: Προφίλ @@ -3226,8 +3217,6 @@ el: available: "Το όνομα ομάδας είναι διαθέσιμο" not_available: "Το όνομα ομάδας δεν είναι διαθέσιμο" blank: "Το όνομα ομάδας δε μπορεί να είναι κενό" - add_members: - as_owner: "Ορισμός χρηστών ως ιδιοκτητών αυτής της ομάδας" manage: interaction: email: Διεύθυνση Email diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index be142096e9..302a2fbd71 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -200,7 +200,6 @@ es: submit: "Enviar" generic_error: "Lo sentimos, ha ocurrido un error." generic_error_with_reason: "Ha ocurrido un error: %{error}" - go_ahead: "Continuar" sign_up: "Registrarse" log_in: "Iniciar sesión" age: "Edad" @@ -229,7 +228,6 @@ es: x_more: one: "%{count} más" other: "%{count} más" - less: "Menos" never: "nunca" every_30_minutes: "cada 30 minutos" every_hour: "cada hora" @@ -238,7 +236,6 @@ es: every_month: "cada mes" every_six_months: "cada seis meses" max_of_count: "máximo de %{count}" - alternation: "o" character_count: one: "%{count} carácter" other: "%{count} caracteres" @@ -269,11 +266,13 @@ es: contact_info: "En caso de un problema crítico o urgente que esté afectando este sitio, contáctanos a través de %{contact_info}." bookmarked: title: "Marcador" + edit_bookmark: "Editar marcador" clear_bookmarks: "Quitar marcadores" help: bookmark: "Haz clic para añadir a marcadores la primera publicación de este tema" + edit_bookmark: "Haga clic para editar el marcador sobre este tema" unbookmark: "Haz clic para eliminar todos los marcadores de este tema" - unbookmark_with_reminder: "Haz clic para quitar todos los marcadores y recordatorios en este tema. Tienes un recordatorio puesto %{reminder_at} para este tema." + unbookmark_with_reminder: "Haz clic para eliminar todos los marcadores y recordatorios de este tema." bookmarks: created: "Has guardado esta publicación en marcadores. %{name}" not_bookmarked: "añadir esta publicación a marcadores" @@ -543,9 +542,12 @@ es: tomorrow: "Mañana" post_local_date: "Fecha en la publicación" later_this_week: "Más tarde esta semana" + this_weekend: "Este fin de semana" start_of_next_business_week: "Lunes" start_of_next_business_week_alt: "El próximo lunes" + two_weeks: "Dos semanas" next_month: "El próximo mes" + six_months: "Seis meses" custom: "Fecha y hora personalizadas" relative: "Tiempo relativo" none: "No se necesita" @@ -603,15 +605,10 @@ es: member_added: "Añadido" member_requested: "Solicitado el" add_members: - title: "Añadir miembros a %{group_name}" - description: "También puedes pegar una lista separada por comas." - usernames_or_emails: - title: "Introduce nombres de usuario o direcciones de correo electrónico" - input_placeholder: "Nombres de usuario o correos electrónicos" - usernames: - title: "Escribe nombres de usuario" - input_placeholder: "Nombres de usuario" + title: "Añadir usuarios a %{group_name}" + description: "Introduce la lista de usuarios a los que quieres invitar al grupo o pega una lista separada por comas:" notify_users: "Notificar usuarios" + set_owner: "Establecer usuarios como propietarios de este grupo" requests: title: "Solicitudes" reason: "Motivo" @@ -625,7 +622,8 @@ es: title: "Gestionar" name: "Nombre" full_name: "Nombre completo" - add_members: "Añadir miembros" + add_members: "Añadir usuarios" + invite_members: "Invitar" delete_member_confirm: "¿Eliminar a «%{username}» del grupo «%{group}»?" profile: title: Perfil @@ -640,12 +638,20 @@ es: enable_imap: "Activar INAP" test_settings: "Probar ajustes" save_settings: "Guardar ajustes" + last_updated: "Última actualización:" + last_updated_by: "por" settings_required: "Todos los ajustes son obligatorios. Completa todos los campos antes de validar." smtp_settings_valid: "Ajustes de SMTP válidos." smtp_title: "SMTP" + smtp_instructions: "Cuando activas SMTP para el grupo, todos los correos salientes enviados desde la bandeja de entrada del grupo se enviarán mediante la configuración SMTP especificada aquí en lugar del servidor de correo configurado para otros correos enviados por su foro." imap_title: "IMAP" imap_additional_settings: "Ajustes adicionales" + imap_instructions: 'Cuando activas IMAP para el grupo, los correos electrónicos se sincronizan entre la bandeja de entrada del grupo y el servidor IMAP y el buzón proporcionados. El SMTP debe estar habilitado con credenciales válidas y probadas antes de activar IMAP. El nombre de usuario y la contraseña de correo electrónico utilizados para SMTP se utilizarán para IMAP. Para más información, consulta anuncio de características en Discourse Meta.' + imap_alpha_warning: "Advertencia: Esta es una característica en fase alfa. Solo Gmail es oficialmente compatible. ¡Úsala bajo tu propio riesgo!" imap_settings_valid: "Ajustes de IMAP válidos." + smtp_disable_confirm: "Si desactivas SMTP, todos los ajustes SMTP e IMAP se restablecerán y la funcionalidad asociada se desactivará. ¿Seguro que quieres continuar?" + imap_disable_confirm: "Si desactivas IMAP, todos los ajustes IMAP se restablecerán y la funcionalidad asociada se desactivará. ¿Seguro que quieres continuar?" + imap_mailbox_not_selected: "¡Debes seleccionar un buzón para esta configuración IMAP o no se sincronizarán los buzones de correo!" prefill: title: "Rellenar con los ajustes de:" gmail: "GMail" @@ -662,6 +668,7 @@ es: settings: title: "Ajustes" allow_unknown_sender_topic_replies: "Permitir respuestas a temas de remitentes desconocidos." + allow_unknown_sender_topic_replies_hint: "Permite a remitentes desconocidos responder a temas de grupo. Si esto no está activado, las respuestas de direcciones de correo electrónico no invitadas todavía al tema crearán un nuevo tema." mailboxes: synchronized: "Bandeja de correo sincronizada" none_found: "No se han encontrado bandejas de correo para esta cuenta de correo electrónico." @@ -1097,6 +1104,7 @@ es: failed_to_move: "No se han podido mover los mensajes seleccionados (podrías estar teniendo problemas de conexión)" select_all: "Seleccionar todo" tags: "Etiquetas" + warnings: "Advertencias oficiales" preferences_nav: account: "Cuenta" security: "Seguridad" @@ -2067,6 +2075,7 @@ es: hint: "(también puedes arrastrar y soltar al editor para adjuntar)" hint_for_supported_browsers: "puedes también arrastrar o pegar imágenes en el editor" uploading: "Subiendo" + processing: "Procesando carga" select_file: "Seleccionar archivo" default_image_alt_text: imagen supported_formats: "formatos aceptados" @@ -2173,10 +2182,19 @@ es: delete: "Eliminar temas" dismiss: "Descartar" dismiss_read: "Descartar todos los temas sin leer" + dismiss_read_with_selected: + one: "Descartar %{count} sin leer" + other: "Descartar %{count} sin leer" dismiss_button: "Descartar..." + dismiss_button_with_selected: + one: "Descartar (%{count})…" + other: "Descartar (%{count})…" dismiss_tooltip: "Descartar solamente las nuevas publicaciones o dejar de seguir los temas" also_dismiss_topics: "Dejar de seguir estos temas para que no aparezcan más en mis mensajes no leídos" dismiss_new: "Ignorar nuevos" + dismiss_new_with_selected: + one: "Descartar nuevo (%{count})" + other: "Descartar nuevos (%{count})" toggle: "activar selección de temas en bloque" actions: "Acciones en bloque" change_category: "Cambiar categoría" @@ -2290,10 +2308,12 @@ es: read_more_in_category: "¿Quieres leer más? Consulta otros temas en %{catLink} o %{latestLink}." read_more: "¿Quieres leer más? %{catLink} o %{latestLink}." unread_indicator: "Ningún miembro ha leído todavía la última publicación de este tema." + read_more_MF: "Hay { UNREAD, plural, =0 {} one { # no leído } other { # no leídos } } { NEW, plural, =0 {} one { {BOTH, select, true{y } false { } other{}} # nuevo tema} other { {BOTH, select, true{y } false { } other{}} # nuevos temas} } restantes, o {CATEGORY, select, true {explora otros temas en {catLink}} false {{latestLink}} other {}}" bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}" browse_all_categories: Ver todas las categorías browse_all_tags: Ver todas las etiquetas view_latest_topics: ver los temas recientes + suggest_create_topic: '¿Listo para iniciar una nueva conversación?' jump_reply_up: saltar a la primera respuesta jump_reply_down: saltar a la última respuesta deleted: "El tema ha sido eliminado" @@ -2415,12 +2435,15 @@ es: reasons: mailing_list_mode: "El modo lista de correo se encuentra activado, por lo que se te notificarán las respuestas a este tema por correo electrónico." "3_10": "Recibirás notificaciones porque estás vigilando una etiqueta de este tema." + "3_10_stale": "Recibirás notificaciones porque vigilaste una etiqueta de este tema en el pasado." "3_6": "Recibirás notificaciones porque estás vigilando esta categoría." + "3_6_stale": "Recibirás notificaciones porque vigilaste esta categoría en el pasado." "3_5": "Recibirás notificaciones porque has empezado a vigilar este tema automáticamente." "3_2": "Recibirás notificaciones porque estás vigilando este tema." "3_1": "Recibirás notificaciones porque creaste este tema." "3": "Recibirás notificaciones porque estás vigilando este tema." "2_8": "Verás el número de respuestas nuevas porque estás siguiendo esta categoría." + "2_8_stale": "Verás el número de respuestas nuevas porque seguiste esta categoría en el pasado." "2_4": "Verás el número de respuestas nuevas porque has publicado una respuesta en este tema." "2_2": "Verás el número de respuestas nuevas porque estás siguiendo este tema." "2": 'Verás un contador con el número de nuevas respuestas porque has leído este tema.' @@ -3036,7 +3059,7 @@ es: details: "Alcanzar el umbral de denuncias y suspender al usuario" silence: title: "Silenciar al usuario" - details: "Alcanzar el umbral del reporte y silenciar al usuario" + details: "Alcanzar el umbral de denuncia y silenciar al usuario" notify_action: "Mensaje" official_warning: "Advertencia oficial" delete_spammer: "Eliminar spammer" @@ -3045,9 +3068,9 @@ es: yes_delete_spammer: "Sí, eliminar spammer" ip_address_missing: "(N/D)" hidden_email_address: "(oculto)" - submit_tooltip: "Enviar el reporte privado" - take_action_tooltip: "Alcanzar el umbral de reportes inmediatamente en vez de esperar más reportes de la comunidad" - cant: "Lo sentimos, no puedes reportar esta publicación en este momento." + submit_tooltip: "Enviar la denuncia privada" + take_action_tooltip: "Alcanzar el umbral de denuncias inmediatamente en vez de esperar más denuncias de la comunidad" + cant: "Lo sentimos, no puedes denunciar esta publicación en este momento." notify_staff: "Notificar a los administradores de forma privada" formatted_name: off_topic: "No tiene relación con el tema" @@ -3067,7 +3090,7 @@ es: other: "%{count} restantes" flagging_topic: title: "¡Gracias por ayudar a mantener una comunidad civilizada!" - action: "Reportar tema" + action: "Denunciar tema" notify_action: "Mensaje" topic_map: title: "Resumen de temas" @@ -3136,7 +3159,7 @@ es: history: "Últimas 100 ediciones" changed_by: "por %{author}" raw_email: - title: "Correo electrónicos entrantes" + title: "Correo electrónico entrante" not_available: "¡No disponible!" categories_list: "Lista de categorías" filters: @@ -3166,12 +3189,12 @@ es: other: "%{count} sin leer" new: lower_title_with_count: - one: "%{count} tema nuevo" + one: "%{count} nuevo" other: "%{count} nuevos" lower_title: "nuevo" title: "Nuevo" title_with_count: - one: "Nuevos (%{count})" + one: "Nuevo (%{count})" other: "Nuevos (%{count})" help: "temas creados en los últimos días" posted: @@ -3223,7 +3246,7 @@ es: image_load_error: 'La imagen no se pudo cargar.' cannot_render_video: Este vídeo no se puede mostrar porque tu navegador no es compatible con el códec. keyboard_shortcuts_help: - shortcut_key_delimiter_comma: "," + shortcut_key_delimiter_comma: ", " shortcut_key_delimiter_plus: "+" shortcut_delimiter_or: "%{shortcut1} o %{shortcut2}" shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}" @@ -3260,6 +3283,7 @@ es: show_incoming_updated_topics: "%{shortcut} Mostrar temas actualizados" search: "%{shortcut} Buscar" help: "%{shortcut} Abrir guía de atajos de teclado" + dismiss_new: "%{shortcut} Descartar nuevas" dismiss_topics: "%{shortcut} Descartar temas" log_out: "%{shortcut} Cerrar sesión" composing: @@ -3297,33 +3321,33 @@ es: mark_muted: "%{shortcut} Silenciar tema" mark_regular: "%{shortcut} Seguimiento normal del tema normal (por defecto)" mark_tracking: "%{shortcut} Seguir tema" - mark_watching: "%{shortcut} Vigilar Tema" + mark_watching: "%{shortcut} Vigilar tema" print: "%{shortcut} Imprimir tema" defer: "%{shortcut} Aplazar el tema" topic_admin_actions: "%{shortcut} Abrir acciones de administrador del tema" search_menu: - title: "Buscar Menu" + title: "Menú de búsqueda" prev_next: "%{shortcut} Mover selección arriba y abajo" insert_url: "%{shortcut} Insertar selección dentro del editor abierto" badges: earned_n_times: - one: "Ganó esta medalla %{count} vez" - other: "Medalla ganada %{count} veces" + one: "Insignia ganada %{count} vez" + other: "Insignia ganada %{count} veces" granted_on: "Concedido hace %{date}" - others_count: "Otras personas con esta medalla (%{count})" - title: Medallas - allow_title: "Puedes usar esta medalla como título" - multiple_grant: "Puedes ganar esta medalla varias veces" + others_count: "Otras personas con esta insignia (%{count})" + title: Insignias + allow_title: "Puedes usar esta insignia como título" + multiple_grant: "Puedes ganar esta insignia varias veces" badge_count: - one: "%{count} medalla" - other: "%{count} medallas" + one: "%{count} insignia" + other: "%{count} insignias" more_badges: one: "+%{count} Más" other: "+%{count} Más" granted: - one: "%{count} concedido" + one: "%{count} concedida" other: "%{count} concedidas" - select_badge_for_title: Selecciona una medalla para utilizar como tu título + select_badge_for_title: Selecciona una insignia para utilizar como tu título none: "(ninguna)" successfully_granted: "%{badge} concedida exitosamente a %{username}" badge_grouping: @@ -3337,9 +3361,9 @@ es: name: Miscelánea posting: name: Publicaciones - favorite_max_reached: "No puedes marcar más medallas como favoritas." - favorite_max_not_reached: "Marcar esta medalla como favorita" - favorite_count: "%{count}/%{max} medallas marcadas como favoritas" + favorite_max_reached: "No puedes marcar más insignias como favoritas." + favorite_max_not_reached: "Marcar esta insignia como favorita" + favorite_count: "%{count}/%{max} insignias marcadas como favoritas" tagging: all_tags: "Todas las etiquetas" other_tags: "Otras etiquetas" @@ -3363,23 +3387,23 @@ es: add_synonyms_label: "Añadir sinónimos:" add_synonyms: "Añadir" add_synonyms_explanation: - one: "Se cambiarán la etiqueta en cualquier sitio que esté en uso, sustituyéndose por %{tag_name} en su lugar. ¿Seguro que quieres hacer el cambio?" + one: "Se cambiará la etiqueta en cualquier sitio que esté en uso, sustituyéndose por %{tag_name} en su lugar. ¿Seguro que quieres hacer el cambio?" other: "Se cambiarán las etiquetas en todos los lugares en los que estén en uso y se sustituirán por %{tag_name} en su lugar. ¿Seguro de que quieres hacer este cambio?" add_synonyms_failed: "No se han podido añadir las siguientes etiquetas como sinónimos: %{tag_names}. Asegúrate de que no tienen sinónimos y de que no son sinónimos de otra etiqueta." remove_synonym: "Quitar sinónimo" - delete_synonym_confirm: '¿Estás seguro de que quieres eliminar el sinónimo «%{tag_name}»?' + delete_synonym_confirm: '¿Seguro que quieres eliminar el sinónimo «%{tag_name}»?' delete_tag: "Eliminar etiqueta" delete_confirm: - one: "¿Estás seguro de querer borrar esta etiqueta y eliminarla de %{count} tema asignado?" - other: "¿Estás seguro de que quieres eliminar esta etiqueta y quitarla de los %{count} temas a los que está asignada?" - delete_confirm_no_topics: "¿Estás seguro de que quieres eliminar esta etiqueta?" + one: "¿Seguro que quieres borrar esta etiqueta y eliminarla de %{count} tema asignado?" + other: "¿Seguro que quieres eliminar esta etiqueta y eliminarla de los %{count} temas a los que está asignada?" + delete_confirm_no_topics: "¿Seguro que quieres eliminar esta etiqueta?" delete_confirm_synonyms: one: "Su sinónimo también se eliminará" other: "Sus %{count} sinónimos también se eliminarán." - rename_tag: "Renombrar etiqueta" + rename_tag: "Cambiar nombre de etiqueta" rename_instructions: "Elige un nuevo nombre para la etiqueta:" sort_by: "Ordenar por:" - sort_by_count: "contador" + sort_by_count: "recuento" sort_by_name: "nombre" manage_groups: "Administrar grupos de etiquetas" manage_groups_description: "Definir grupos para organizar etiquetas" @@ -3428,8 +3452,8 @@ es: new_title: "Crear nuevo grupo" edit_title: "Editar grupo de etiquetas" tags_label: "Etiquetas en este grupo" - parent_tag_label: "Etiqueta padre" - parent_tag_description: "Las etiquetas de este grupo solo se podrán usar si la etiqueta padre está presente." + parent_tag_label: "Etiqueta principal" + parent_tag_description: "Las etiquetas de este grupo solo se podrán usar si la etiqueta principal está presente." one_per_topic_label: "Limitar las etiquetas de este grupo a utilizarse solo una vez por tema" new_name: "Nuevo grupo de etiquetas" name_placeholder: "Nombre" @@ -3438,11 +3462,11 @@ es: confirm_delete: "¿Estás seguro de que quieres eliminar este grupo de etiquetas?" everyone_can_use: "Todos pueden utilizar las etiquetas" usable_only_by_groups: "Las etiquetas son visibles para todo el mundo, pero solo los siguientes grupos las pueden usar" - visible_only_to_groups: "Las etiquetas sólo son visibles para los siguientes grupos" + visible_only_to_groups: "Las etiquetas solo son visibles para los siguientes grupos" cannot_save: "No se ha podido guardar el grupo de etiquetas. Asegúrate de que tenga al menos una etiqueta, el nombre no esté vacío y hayas seleccionado un grupo para los permisos." tags_placeholder: "Buscar o crear etiquetas" parent_tag_placeholder: "Opcional" - select_groups_placeholder: "Seleccionar grupos ..." + select_groups_placeholder: "Seleccionar grupos..." disabled: "El etiquetado está desactivado. " topics: none: @@ -3455,10 +3479,10 @@ es: top: "No hay temas destacados." invite: custom_message: "Dale a tu invitación un toque personal escribiendo un mensaje personalizado." - custom_message_placeholder: "Ingresa tu mensaje personalizado" + custom_message_placeholder: "Introduce tu mensaje personalizado" approval_not_required: "El usuario será aprobado automáticamente en cuanto acepte esta invitación." - custom_message_template_forum: "¡Hey, deberías unirte a este foro!" - custom_message_template_topic: "¡Hey, creemos que este tema te va a encantar!" + custom_message_template_forum: "¡Eh, deberías unirte a este foro!" + custom_message_template_topic: "¡Eh, creemos que este tema te va a encantar!" forced_anonymous: "Debido a una carga extrema, esto se está mostrando temporalmente a todos como lo vería un usuario que no haya iniciado sesión." forced_anonymous_login_required: "El sistema está soportando un pico de carga excepcional y no puede responder en este momento. Inténtalo de nuevo más tarde." footer_nav: @@ -3496,7 +3520,7 @@ es: tags: remove_muted_tags_from_latest: always: "siempre" - only_muted: "cuando es usado solo o con otras etiquetas silenciadas" + only_muted: "cuando se usa solo o con otras etiquetas silenciadas" never: "nunca" reports: title: "Lista de informes disponibles" @@ -3532,13 +3556,13 @@ es: space_used: "%{usedSize} usado" space_used_and_free: "%{usedSize} (%{freeSize} libre)" uploads: "Archivos subidos" - backups: "Copias de respaldo" + backups: "Copias de seguridad" backup_count: - one: "%{count} copia de respaldo en %{location}" - other: "%{count} copias de respaldo en %{location}" + one: "%{count} copia de seguridad en %{location}" + other: "%{count} copias de seguridad en %{location}" lastest_backup: "Recientes: %{date}" traffic_short: "Tráfico" - traffic: "Peticiones web de la app" + traffic: "Solicitudes web de la app" page_views: "Vistas de página" page_views_short: "Vistas de página" show_traffic_report: "Mostrar informe detallado del tráfico" @@ -3556,7 +3580,7 @@ es: timeout_error: Lo sentimos, la solicitud está durando demasiado. Por favor, selecciona un periodo más corto exception_error: Lo sentimos, se produjo un error al ejecutar la consulta too_many_requests: Has realizado esta acción demasiadas veces. Por favor, espera antes de intentarlo de nuevo. - not_found_error: Lo sentimos, este reporte no existe + not_found_error: Lo sentimos, este informe no existe filter_reports: Filtrar informes reports: trend_title: "%{percent} de cambio. Actualmente %{current}, era %{prev} en el período previo." @@ -3571,19 +3595,20 @@ es: view_table: "tabla" view_graph: "gráfico" refresh_report: "Actualizar informe" - daily: Día - monthly: Mes + daily: Diariamente + monthly: Mensualmente weekly: Semana dates: "Fechas (UTC)" groups: "Todos los grupos" - disabled: "Este reporte está desactivado" + disabled: "Este informe está desactivado" totals_for_sample: "Total para la muestra" average_for_sample: "Promedio por muestra" total: "Total en todo el tiempo" no_data: "No hay datos que mostrar." trending_search: more: 'Registros de búsqueda' - disabled: 'El reporte de tendencias de búsqueda está desactivado. Activa la opción registrar consultas de búsquedas para recolectar datos.' + disabled: 'El informe de tendencias de búsqueda está desactivado. Activa la opción registrar consultas de búsquedas para recopilar datos.' + average_chart_label: Promedio filters: file_extension: label: Extensión del archivo @@ -3607,13 +3632,11 @@ es: available: "El nombre del grupo está disponible" not_available: "El nombre del grupo no está disponible" blank: "El nombre del grupo no puede estar vacío" - add_members: - as_owner: "Convertir usuario(s) en propietario(s) de este grupo" manage: interaction: email: Correo electrónico incoming_email: "Dirección de correo electrónico entrante personalizada" - incoming_email_placeholder: "ingresa dirección de correo electrónico" + incoming_email_placeholder: "introduce la dirección de correo electrónico" visibility: Visibilidad visibility_levels: title: "¿Quién puede ver este grupo?" @@ -3643,7 +3666,7 @@ es: title: "Grupos" edit: "Editar grupos" refresh: "Actualizar" - about: "Edita la membresía de tu grupo y sus miembros aquí" + about: "Edita la membresía de tu grupo y sus nombres aquí" group_members: "Miembros del grupo" delete: "Eliminar" delete_confirm: "¿Eliminar este grupo?" @@ -3651,13 +3674,13 @@ es: one: "Al borrar este grupo, %{count} mensaje se quedará huérfano, y los miembros del grupo dejarán de tener acceso a él.

    ¿Continuar?" other: "Al borrar este grupo, %{count} mensajes se quedará huérfanos, y los miembros del grupo dejarán de tener acceso a ellos.

    ¿Continuar?" delete_failed: "No se pudo eliminar el grupo. Si este es un grupo automático, no se puede destruir." - delete_automatic_group: Este grupo ha sido creado automáticamente y no puede ser eliminado. + delete_automatic_group: Este grupo se ha creado automáticamente y no se puede eliminar. delete_owner_confirm: "¿Quitar los privilegios de propietario a «%{username}»?" add: "Añadir" custom: "Personalizado" automatic: "Automático" default_title: "Título por defecto" - default_title_description: "será aplicado a todos los usuarios en el grupo" + default_title_description: "se aplicará a todos los usuarios en el grupo" group_owners: Propietarios add_owners: Añadir propietarios none_selected: "Selecciona un grupo para empezar" @@ -3696,8 +3719,8 @@ es: use_global_key: Clave global (permite todas las acciones) scopes: description: | - Al usar scopes, puedes restringir una clave de API a una lista de endpoints específicos. También puedes definir qué parámetros permitir. Usa comas para separar varios valores. - title: Scopes + Al usar ámbitos, puedes restringir una clave de API a una lista de terminales específicos. También puedes definir qué parámetros permitir. Usa comas para separar varios valores. + title: Ámbitos resource: Recurso action: Acción allowed_parameters: Parámetros permitidos @@ -3710,6 +3733,8 @@ es: write: Crear un nuevo tema o publicar en uno existente. read_lists: Lea listas de temas como destacados, nuevos, recientes, etc. También se admite RSS. wordpress: Necesario para que el plugin para WordPress wp-discourse funcione. + posts: + edit: Edita cualquier publicación o una específica. users: bookmarks: Listar marcadores de usuario. Devuelve recordatorios de marcadores cuando se utiliza el formato ICS. sync_sso: Sincroniza un usuario usando DiscourseConnect. @@ -3720,7 +3745,7 @@ es: anonymize: Anonimizar cuentas de usuario. delete: Eliminar cuentas de usuario. email: - receive_emails: Combinar este scope con el scope mail-receiver para procesar correos entrantes. + receive_emails: Combinar este ámbito con el ámbito mail-receiver para procesar correos entrantes. web_hooks: title: "Webhooks" none: "Ahora mismo no hay webhooks." @@ -3734,7 +3759,7 @@ es: controls: "Controles" go_back: "Volver a la lista" payload_url: "URL a la que enviar el webhook" - payload_url_placeholder: "https://example.com/postreceive" + payload_url_placeholder: "https://ejemplo.com/postreceive" warn_local_payload_url: "Parece que estás intentando configurar el webhook para una URL local. Los eventos enviados a direcciones locales pueden tener efectos colaterales o comportamientos inesperados. ¿Continuar?" secret_invalid: "El secreto no puede tener espacios en blanco." secret_too_short: "El secreto debería tener al menos 12 caracteres." @@ -3780,8 +3805,8 @@ es: name: "Evento de notificación" details: "Cuando un usuario recibe una notificación." user_badge_event: - name: "Evento por obtención de medalla" - details: "Cuando un usuario recibe una medalla." + name: "Evento por obtención de insignia" + details: "Cuando un usuario recibe una insignia." group_user_event: name: "Evento de usuario de grupo" details: "Cuando se agrega o elimina un usuario en un grupo." @@ -3799,16 +3824,16 @@ es: redeliver: "Reenviar" incoming: one: "Hay un nuevo evento." - other: "Hay %{count} eventos. nuevos." + other: "Hay %{count} eventos nuevos." completed_in: one: "Completado en %{count} segundo." other: "Completado en %{count} segundos." - request: "Petición" + request: "Solicitud" response: "Respuesta" - redeliver_confirm: "¿Estás seguro de que quieres reenviar el mismo payload?" - headers: "Headers" + redeliver_confirm: "¿Seguro que quieres reenviar el mismo payload?" + headers: "Encabezados" payload: "Payload" - body: "Body" + body: "Cuerpo" go_list: "Ir a la lista" go_details: "Editar webhook" go_events: "Ir a eventos" @@ -3830,18 +3855,18 @@ es: change_settings: "Cambiar preferencias" change_settings_short: "Ajustes" howto: "¿Cómo instalo plugins?" - official: "Plugin Oficial" + official: "Plugin oficial" backups: - title: "Copia de respaldo" + title: "Copia de seguridad" menu: - backups: "Copia de respaldo" + backups: "Copia de seguridad" logs: "Registros" - none: "Ninguna copia de respaldo disponible." + none: "Ninguna copia de seguridad disponible." read_only: enable: title: "Activar el modo de solo lectura" - label: "Activar solo-lecutra" - confirm: "¿Estás seguro de que quieres activar el modo de solo lectura?" + label: "Activar solo lectura" + confirm: "¿Seguro que quieres activar el modo de solo lectura?" disable: title: "Desactivar el modo de solo lectura" label: "Desactivar solo lectura" @@ -3852,10 +3877,10 @@ es: size: "Tamaño" upload: label: "Subir" - title: "Subir un respaldo a esta instancia" + title: "Subir una copia de seguridad a esta instancia" uploading: "Subiendo..." uploading_progress: "Subiendo... %{progress}%" - success: "«%{filename}» se subió exitosamente. El archivo se procesará ahora y tomará hasta un minuto para que se muestre en la lista." + success: "«%{filename}» se subió exitosamente. El archivo se procesará ahora y tardará hasta un minuto en mostrarse en la lista." error: "Se produjo un error al subir el archivo «%{filename}»: %{message}" operations: is_running: "Hay una operación en proceso actualmente..." @@ -3863,28 +3888,28 @@ es: cancel: label: "Cancelar" title: "Cancelar la operación actual" - confirm: "¿Estás seguro de que quieres cancelar la operación actual?" + confirm: "¿Seguro que quieres cancelar la operación actual?" backup: - label: "Copia de respaldo" - title: "Crear una copia de respaldo" - confirm: "¿Quieres iniciar una nueva copia de respaldo?" + label: "Copia de seguridad" + title: "Crear una copia de seguridad" + confirm: "¿Quieres iniciar una nueva copia de seguridad?" without_uploads: "Sí (no incluir archivos subidos)" download: label: "Descargar" title: "Enviar correo electrónico con enlace de descarga" - alert: "El enlace para descargar esta copia de respaldo se te envió por correo electrónico." + alert: "El enlace para descargar esta copia de seguridad se te envió por correo electrónico." destroy: - title: "Quitar la copia de respaldo" - confirm: "¿Estás seguro de que quieres destruir esta copia de respaldo?" + title: "Quitar la copia de seguridad" + confirm: "¿Seguro que quieres destruir esta copia de seguridad?" restore: is_disabled: "Restaurar está desactivado en la configuración del sitio." label: "Restaurar" - title: "Restaurar la copia de respaldo" - confirm: "¿Estás seguro de que quieres restaurar esta copia de respaldo?" + title: "Restaurar la copia de seguridad" + confirm: "¿Seguro que quieres restaurar esta copia de seguridad?" rollback: label: "Revertir" title: "Regresar la base de datos al estado funcional anterior" - confirm: "¿Estás seguro de que quieres regresar la base de datos al estado funcional previo?" + confirm: "¿Seguro que quieres regresar la base de datos al estado funcional previo?" location: local: "Almacenamiento local" s3: "S3" @@ -3914,7 +3939,7 @@ es: new_style: "Nuevo estilo" install: "Instalar" delete: "Eliminar" - delete_confirm: '¿Estás seguro de que quieres eliminar «%{theme_name}»?' + delete_confirm: '¿Seguro que quieres eliminar «%{theme_name}»?' color: "Color" opacity: "Opacidad" copy: "Copiar" @@ -3928,7 +3953,7 @@ es: multiple_subjects: "Esta plantilla de correo electrónico tiene múltiples asuntos." body: "Cuerpo del correo electrónico" revert: "Revertir los cambios" - revert_confirm: "¿Estás seguro de que quieres revertir los cambios?" + revert_confirm: "¿Seguro que quieres revertir los cambios?" theme: theme: "Tema" component: "Componente" @@ -3948,7 +3973,7 @@ es: long_title: "Modificar los colores, CSS y contenidos HTML de su sitio" edit: "Editar" edit_confirm: "Este es un tema remoto, si editas CSS/HTML, los cambios se eliminarán la próxima vez que actualices el tema." - update_confirm: "Estos cambios locales se eliminarán por la actualización. ¿Estás seguro de que quieres continuar?" + update_confirm: "Estos cambios locales se eliminarán por la actualización. ¿Seguro que quieres continuar?" update_confirm_yes: "Sí, continuar con la actualización" common: "Común" desktop: "Escritorio" @@ -3965,7 +3990,7 @@ es: hide_unused_fields: "Ocultar campos sin uso" is_default: "El tema está activado por defecto" user_selectable: "El tema puede ser seleccionado por usuarios" - color_scheme_user_selectable: "Permitir que pueda ser seleccionada por los usuarios" + color_scheme_user_selectable: "Permitir que pueda ser seleccionada por usuarios" auto_update: "Actualizar automáticamente junto a Discourse" color_scheme: "Paleta de color" default_light_scheme: "Claro (predeterminado)" @@ -3974,30 +3999,30 @@ es: theme_components: "Componentes del tema" add_all_themes: "Agregar todos los temas" convert: "Convertir" - convert_component_alert: "¿Estás seguro de que quieres convertir este componente en tema? Se eliminará como componente en %{relatives}." + convert_component_alert: "¿Seguro que quieres convertir este componente en tema? Se eliminará como componente en %{relatives}." convert_component_tooltip: "Convertir este componente en tema" convert_component_alert_generic: "¿Seguro que quieres convertir este componente en tema?" - convert_theme_alert: "¿Estás seguro de que quieres convertir este tema en componente? Se eliminará como primario en %{relatives}." + convert_theme_alert: "¿Seguro que quieres convertir este tema en componente? Se eliminará como primario en %{relatives}." convert_theme_alert_generic: "¿Seguro que quieres convertir este tema en componente?" convert_theme_tooltip: "Convertir este tema en componente" - inactive_themes: "Tema inactivos:" + inactive_themes: "Temas inactivos:" inactive_components: "Componentes sin usar:" broken_theme_tooltip: "Este tema tiene errores en CSS, HTML o YAML" disabled_component_tooltip: "Este componente ha sido desactivado" - default_theme_tooltip: "Este tema es el tema por defecto del sitio" + default_theme_tooltip: "Este es el tema por defecto del sitio" updates_available_tooltip: "Hay actualizaciones disponibles para este tema" and_x_more: "y %{count} más." collapse: Colapsar uploads: "Subidos" no_uploads: "Puedes subir recursos asociados con tu tema como fuentes e imágenes " - add_upload: "Agregar archivo" + add_upload: "Añadir subida" upload_file_tip: "Selecciona el recurso que subirás (png, woff2, etc...)" variable_name: "Nombre de variable SCSS:" - variable_name_invalid: "Nombre de variable inválido. Solo se permiten caracteres alfanuméricos. Debe comenzar por una letra. Debe ser único." + variable_name_invalid: "Nombre de variable no válido. Solo se permiten caracteres alfanuméricos. Debe comenzar por una letra. Debe ser único." variable_name_error: - invalid_syntax: "Nombre de variable inválido. Solo se permiten caracteres alfanuméricos. Debe comenzar por una letra." - no_overwrite: "Nombre de variable inválido. No debe sobrescribir una variable existente. " - must_be_unique: "Nombre de variable inválido. Debe ser único." + invalid_syntax: "Nombre de variable no válido. Solo se permiten caracteres alfanuméricos. Debe comenzar por una letra." + no_overwrite: "Nombre de variable no válido. No debe sobrescribir una variable existente." + must_be_unique: "Nombre de variable no válido. Debe ser único." upload: "Subir" select_component: "Seleccionar un componente..." unsaved_changes_alert: "No has guardado los cambios aún, ¿los quieres descartar y seguir adelante?" @@ -4016,7 +4041,7 @@ es: import_web_advanced: "Avanzado..." import_file_tip: "archivo .tar.gz, .zip o .dcstyle.json que contiene un tema" is_private: "El tema está en un repositorio privado de git" - remote_branch: "Nombre del Branch (opcional)" + remote_branch: "Nombre de la rama (opcional)" public_key: "Conceda la siguiente clave pública de acceso al repositorio:" public_key_note: "Después de poner una URL de repositorio privado válida, se generará una clave SSH y se mostrará aquí." install: "Instalar" @@ -4045,8 +4070,8 @@ es: check_for_updates: "Buscar actualizaciones" updating: "Actualizando..." up_to_date: "El tema está actualizado. Última comprobación:" - has_overwritten_history: "La versión actual del tema ya no existe, porque el historial de Git ha sido sobreescrito por un force push." - add: "Agregar" + has_overwritten_history: "La versión actual del tema ya no existe porque el historial de Git ha sido sobrescrito por un force push." + add: "Añadir" theme_settings: "Ajustes del tema" no_settings: "Este tema no tiene ajustes." theme_translations: "Traducciones del tema" @@ -4060,19 +4085,19 @@ es: imported_from_archive: "Este tema se importó desde un archivo .zip" scss: text: "CSS" - title: "Ingresa el CSS personalizado, aceptamos todos los estilos válidos de CSS y SCSS" + title: "Introduce el CSS personalizado, aceptamos todos los estilos válidos de CSS y SCSS" header: text: "Encabezado" - title: "Ingresa HTML para mostrar en el encabezado" + title: "Introduce el HTML para mostrar en el encabezado" after_header: text: "Después del encabezado" - title: "Ingresa HTML para mostrar en todas las páginas después del encabezado" + title: "Introduce el HTML para mostrar en todas las páginas después del encabezado" footer: text: "Pie de página" - title: "Ingresa HTML para mostrar en el pie de página" + title: "Introduce el HTML para mostrar en el pie de página" embedded_scss: text: "CSS incrustado" - title: "Ingresar CSS personalizado para mostrar en la versión insertada de los comentarios" + title: "Introduce el CSS personalizado para mostrar en la versión insertada de los comentarios" color_definitions: text: "Definiciones de color" title: "Introducir definiciones de colores personalizadas (para usuarios avanzados)" @@ -4094,10 +4119,11 @@ es: yaml: text: "YAML" title: "Definir ajustes al tema en formato YAML" + scss_color_variables_warning: 'El uso de las variables de color SCSS de núcleo en los temas está obsoleto. Utiliza propiedades CSS personalizadas en su lugar. Consulta esta guía para más detalles.' scss_warning_inline: "El uso de variables de color SCSS del sistema en los temas está obsoleto." colors: select_base: - title: "Selecciona paleta base de color" + title: "Seleccionar paleta base de color" description: "Paleta base:" title: "Colores" edit: "Editar paletas de color" @@ -4146,22 +4172,22 @@ es: description: "Color del resaltado para acciones como eliminar temas o publicaciones." success: name: "éxito" - description: "Para indicar que una acción se realizó exitosamente." + description: "Para indicar que una acción se realizó con éxito." love: name: "me gusta" description: "El color del botón de me gusta" robots: - title: "Anula el archivo robots.txt de tu sitio:" - warning: "Esto anulará permanentemente cualquier configuración del sitio relacionada." - overridden: El archivo robots.txt predeterminado de tu sitio está anulado. + title: "Sobrescribe el archivo robots.txt de tu sitio:" + warning: "Esto sobrescribirá permanentemente cualquier configuración del sitio relacionada." + overridden: El archivo robots.txt predeterminado de tu sitio está sobrescrito. email_style: title: "Estilo de correo electrónico" heading: "Personalizar estilo de correo electrónico" html: "Plantilla HTML" css: "CSS" reset: "Restablecer ajustes predeterminados" - reset_confirm: "¿Estás seguro de que deseas restablecer los ajustes predeterminados de %{fieldName} y perder todos los cambios?" - save_error_with_reason: "Sus cambios no se guardaron. %{error}" + reset_confirm: "¿Seguro que deseas restablecer los ajustes predeterminados de %{fieldName} y perder todos los cambios?" + save_error_with_reason: "Tus cambios no se guardaron. %{error}" instructions: "Personaliza la plantilla sobre la que se crean todos los correos HTML, y aplica estilos usando CSS." email: title: "Correos" @@ -4177,14 +4203,14 @@ es: elided: "Texto elidido" sending_test: "Enviando correo electrónico de prueba..." error: "ERROR - %{server_error}" - test_error: "Se produjo un error al enviar el correo electrónico de prueba. Por favor, revisa la configuración de correo, verifica que tu servicio de alojamiento no esté bloqueando los puertos de conexión de correo y prueba de nuevo." - sent: "Enviados" - skipped: "Omitidos" + test_error: "Se produjo un error al enviar el correo electrónico de prueba. Revisa la configuración de correo, verifica que tu servicio de alojamiento no esté bloqueando los puertos de conexión de correo y prueba de nuevo." + sent: "Enviado" + skipped: "Omitido" bounced: "Rebotado" received: "Recibidos" rejected: "Rechazados" - sent_at: "Fecha" - time: "Fecha" + sent_at: "Enviado a las" + time: "Hora" user: "Usuario" email_type: "Tipo de correo" to_address: "Destino" @@ -4204,7 +4230,7 @@ es: last_seen_user: "Último usuario visto:" no_result: "No se han encontrado resultados para el resumen." reply_key: "Clave de respuesta" - skipped_reason: "Saltar motivo" + skipped_reason: "Omitir motivo" incoming_emails: from_address: "De" to_addresses: "Para" @@ -4250,7 +4276,7 @@ es: last_match_at: "Última coincidencia" match_count: "Coincidencias" ip_address: "IP" - topic_id: "ID del Tema" + topic_id: "ID del tema" post_id: "ID de la publicación" category_id: "ID de la categoría" delete: "Eliminar" @@ -4262,7 +4288,7 @@ es: staff_actions: all: "todas" filter: "Filtrar:" - title: "Acciones del staff" + title: "Acciones del personal" clear_filters: "Mostrar todo" staff_user: "Usuario" target_user: "Usuario destino" @@ -4288,9 +4314,9 @@ es: suspend_user: "suspender usuario" unsuspend_user: "desbloquear usuario" removed_suspend_user: "suspender usuario (eliminado)" - removed_unsuspend_user: "desbloquear usuario (eliminado)" - grant_badge: "conceder medalla" - revoke_badge: "retirar medalla" + removed_unsuspend_user: "anular suspensión del usuario (eliminado)" + grant_badge: "conceder insignia" + revoke_badge: "retirar insignia" check_email: "comprobar correo electrónico" delete_topic: "eliminar tema" recover_topic: "recuperar tema" @@ -4304,12 +4330,12 @@ es: silence_user: "silenciar usuario" unsilence_user: "dejar de silenciar usuario" removed_silence_user: "silenciar usuario (eliminado)" - removed_unsilence_user: "dejar de silenciar usuario (eliminado)" + removed_unsilence_user: "dejar de silenciar al usuario (eliminado)" grant_admin: "conceder administración" revoke_admin: "revocar administración" grant_moderation: "conceder moderación" revoke_moderation: "revocar moderación" - backup_create: "crear copia de respaldo" + backup_create: "crear copia de seguridad" deleted_tag: "etiqueta eliminada" deleted_unused_tags: "etiquetas sin usar eliminadas" renamed_tag: "etiqueta renombrada" @@ -4319,8 +4345,8 @@ es: activate_user: "activar un usuario" deactivate_user: "desactivar un usuario" change_readonly_mode: "cambiar a modo de solo lectura" - backup_download: "descargar copia de respaldo" - backup_destroy: "destruir copia de respaldo" + backup_download: "descargar copia de seguridad" + backup_destroy: "destruir copia de seguridad" reviewed_post: "publicaciones revisadas" custom_staff: "acción personalizada de plugin" post_locked: "publicación bloqueada" @@ -4331,9 +4357,9 @@ es: topic_published: "tema publicado" post_approved: "publicación aprobada" post_rejected: "publicación rechazada" - create_badge: "crear medalla" - change_badge: "cambiar medalla" - delete_badge: "eliminar medalla" + create_badge: "crear insignia" + change_badge: "cambiar insignia" + delete_badge: "eliminar insignia" merge_user: "fusionar usuario" entity_export: "entidad exportadora" change_name: "cambiar nombre" @@ -4364,8 +4390,8 @@ es: topic_opened: "tema abierto" topic_archived: "tema archivado" topic_unarchived: "tema desarchivado" - post_staff_note_create: "añadir aviso del staff" - post_staff_note_destroy: "destruir nota del staff" + post_staff_note_create: "añadir aviso del personal" + post_staff_note_destroy: "destruir aviso del personal" delete_group: "eliminar grupo" screened_emails: title: "Correos bloqueados" @@ -4380,9 +4406,9 @@ es: domain: "Dominio" screened_ips: title: "Direcciones IP bloqueadas" - description: 'Direcciones IP que se están vigilando. Usar "Allow" para añadir direcciones a la lista de permitidas.' - delete_confirm: "¿Estás seguro de que quieres quitar el bloqueo para %{ip_address}?" - roll_up_confirm: "¿Estás seguro de que quieres agrupar las direcciones IP bloqueadas vistas con frecuencia en subredes?" + description: 'Direcciones IP que se están vigilando. Usar «Allow» para añadir direcciones a la lista de permitidas.' + delete_confirm: "¿Seguro que quieres quitar el bloqueo para %{ip_address}?" + roll_up_confirm: "¿Seguro que quieres agrupar las direcciones IP bloqueadas vistas con frecuencia en subredes?" rolled_up_some_subnets: "Se han agrupado con éxito las entradas de IP bloqueadas a estas subredes: %{subnets}." rolled_up_no_subnet: "No había nada para agrupar." actions: @@ -4421,11 +4447,12 @@ es: clear_all: Limpiar todo clear_all_confirm: "¿Quitar todas las palabras vigiladas para la acción %{action}?" invalid_regex: 'La palabra vigilada «%{word}» no es una expresión regular válida.' + regex_warning: 'Las palabras vigiladas son expresiones regulares y no incluyen automáticamente los límites de las palabras. Si quieres que la expresión regular coincida con palabras enteras, incluye \b al principio y al final de tu expresión regular.' actions: block: "Bloquear" censor: "Censurar" require_approval: "Requiere aprobación" - flag: "Reportar" + flag: "Denunciar" replace: "Reemplazar" tag: "Etiqueta" silence: "Silenciar" @@ -4433,11 +4460,11 @@ es: action_descriptions: block: "Impedir la publicación de mensajes que contengan estas palabras. El usuario verá un mensaje de error cuando trate de publicar su mensaje." censor: "Permitir mensajes que contengan estas palabras, pero reemplazar estas palabras por caracteres que las censuren." - require_approval: "Las publicaciones que contengan estas palabras deberán ser aprobadas por el staff antes de que se puedan visualizar." - flag: "Permitir publicaciones que contengan estas palabras, pero reportar el mensaje como inapropiado para que los moderadores puedan revisarlo." + require_approval: "Las publicaciones que contengan estas palabras deberán ser aprobadas por el personal antes de que se puedan visualizar." + flag: "Permitir publicaciones que contengan estas palabras, pero denunciar el mensaje como inapropiado para que los moderadores puedan revisarlo." replace: "Reemplazar palabras en publicaciones por otras palabras" - tag: "Etiquetas temas automáticamente basándose en su primer mensaje." - silence: "Las primeras publicaciones de usuarios que contengan estas palabras deberán ser aprobadas por el staff antes de que se puedan ver, y el usuario será silenciado automáticamente." + tag: "Etiquetar temas automáticamente basándose en su primer mensaje." + silence: "Las primeras publicaciones de usuarios que contengan estas palabras deberán ser aprobadas por el personal antes de que se puedan ver, y el usuario será silenciado automáticamente." link: "Reemplazar palabras en publicaciones por enlaces" form: label: "Contiene palabra o frase" @@ -4448,13 +4475,14 @@ es: tag_label: "Etiqueta" link_label: "Enlace" link_placeholder: "https://ejemplo.com" - add: "Agregar" + add: "Añadir" success: "Éxito" exists: "Ya existe" - upload: "Agregar desde archivo" - upload_successful: "Subida completada. Las palabras se han agregado." + upload: "Añadir desde archivo" + upload_successful: "Subida completada. Las palabras se han añadido." test: button_label: "Prueba" + modal_title: "%{action}: Prueba de palabras vigiladas" description: "Escribe el texto más abajo para buscar coincidencias con las palabras vigiladas" found_matches: "Coincidencias encontradas:" no_matches: "No se encontraron coincidencias" @@ -4465,7 +4493,7 @@ es: invalid: "Lo sentimos, no puedes suplantar a ese usuario." users: title: "Usuarios" - create: "Agregar usuario administrador" + create: "Añadir usuario administrador" last_emailed: "Último correo enviado" not_found: "Lo sentimos, ese usuario no existe en nuestro sistema." id_not_found: "Lo sentimos, esa ID de usuario no existe en nuestro sistema." @@ -4475,7 +4503,7 @@ es: nav: new: "Nuevos" active: "Activos" - staff: "Staff" + staff: "Personal" suspended: "Suspendidos" silenced: "Silenciados" staged: "Temporal" @@ -4489,7 +4517,7 @@ es: member: "Usuarios con nivel de confianza 2 (Miembro)" regular: "Usuarios con nivel de confianza 3 (Habitual)" leader: "Usuarios con nivel de confianza 4 (Líder)" - staff: "Staff" + staff: "Personal" admins: "Administradores" moderators: "Moderadores" silenced: "Usuarios silenciados" @@ -4505,10 +4533,10 @@ es: user: suspend_failed: "Algo salió mal al suspender a este usuario %{error}" unsuspend_failed: "Algo salió mal al desbloquear a este usuario %{error}" - suspend_duration: "¿Durante cuánto tiempo este usuario estará suspendido?" - suspend_reason_label: "¿Por qué lo suspendes? Este texto será visible para todos en la página de perfil del usuario y se mostrará al usuario cuando intente iniciar sesión. Sé conciso." - suspend_reason_hidden_label: "¿Por qué estás suspendiendo? Este texto se mostrará al usuario cuando trate de iniciar sesión. Sé conciso." - suspend_reason: "Causa" + suspend_duration: "¿Durante cuánto tiempo estará suspendido este usuario?" + suspend_reason_label: "¿Por qué se le suspende? Este texto será visible para todos en la página de perfil del usuario y se mostrará al usuario cuando intente iniciar sesión. Sé conciso." + suspend_reason_hidden_label: "¿Por qué se le suspende? Este texto se mostrará al usuario cuando trate de iniciar sesión. Sé conciso." + suspend_reason: "Motivo" suspend_reason_title: "Motivo de la suspensión" suspend_reasons: not_listening_to_staff: "No escucha indicaciones del personal" @@ -4520,12 +4548,12 @@ es: suspend_message: "Mensaje por correo electrónico" suspend_message_placeholder: "Opcionalmente, brinda más información sobre la suspensión y esta se enviará por correo electrónico al usuario." suspended_by: "Suspendido por" - silence_reason: "Razón" + silence_reason: "Motivo" silenced_by: "Silenciado por" silence_modal_title: "Silenciar usuario" silence_duration: "¿Cuánto tiempo estará silenciado el usuario?" - silence_reason_label: "¿Por qué estás silenciando a este usuario?" - silence_reason_placeholder: "Razón de silenciar" + silence_reason_label: "¿Por qué se silencia a este usuario?" + silence_reason_placeholder: "Motivo para silenciar" silence_message: "Mensaje por correo electrónico" silence_message_placeholder: "(dejar en blanco para enviar mensaje por defecto)" suspended_until: "(hasta %{until})" @@ -4535,14 +4563,14 @@ es: delete_posts_failed: "Hubo un problema al eliminar los mensajes." penalty_post_actions: "¿Qué te gustaría hacer con la publicación asociada?" penalty_post_delete: "Eliminar la publicación" - penalty_post_delete_replies: "Eliminar la publicación+ sus respuestas" + penalty_post_delete_replies: "Eliminar la publicación + sus respuestas" penalty_post_edit: "Editar la publicación" penalty_post_none: "No hacer nada" penalty_count: "Contador de faltas" clear_penalty_history: title: "Eliminado historial de faltas" description: "usuarios con faltas no pueden alcanzar NC3" - delete_all_posts_confirm_MF: "Estás a punto de eliminar {POSTS, plural, one {# publicación} other {# publicaciones}} y {TOPICS, plural, one {# tema} other {# temas}}. ¿Estás seguro?" + delete_all_posts_confirm_MF: "Estás a punto de eliminar {POSTS, plural, one {# publicación} other {# publicaciones}} y {TOPICS, plural, one {# tema} other {# temas}}. ¿Estás seguro/a?" silence: "Silenciar" unsilence: "Dejar de silenciar" silenced: "¿Silenciado?" @@ -4564,9 +4592,9 @@ es: grant_moderation: "Conceder moderación" unsuspend: "Desbloquear" suspend: "Suspender" - show_flags_received: "Mostrar reportes recibidos" - flags_received_by: "Reportes recibidos de %{username}" - flags_received_none: "Este usuario no ha recibido ningún reporte." + show_flags_received: "Mostrar denuncias recibidas" + flags_received_by: "Denuncias recibidas de %{username}" + flags_received_none: "Este usuario no ha recibido ninguna denuncia." reputation: Reputación permissions: Permisos activity: Actividad @@ -4577,17 +4605,19 @@ es: post_count: Publicaciones publicadas second_factor_enabled: activar autenticación de dos factores topics_entered: Temas vistos - flags_given_count: Reportes enviados - flags_received_count: Reportes recibidos + flags_given_count: Denuncias enviadas + flags_received_count: Denuncias recibidas warnings_received_count: Advertencias recibidas - flags_given_received_count: "Reportes enviados / recibidos" + warnings_list_warning: | + Como moderador, es posible que no puedas ver todos estos temas. Si es necesario, pide a un administrador o al moderador emisor que dé acceso al mensaje a @moderadores. + flags_given_received_count: "Denuncias enviadas / recibidas" approve: "Aprobar" approved_by: "aprobado por" approve_success: "Usuario aprobado y correo electrónico enviado con instrucciones para la activación." approve_bulk_success: "¡Perfecto! Todos los usuarios seleccionados han sido aprobados y notificados." time_read: "Tiempo de lectura" anonymize: "Anonimizar usuario" - anonymize_confirm: "¿SEGURO de que quieres hacer anónima esta cuenta? Esto cambiará el nombre de usuario y el correo electrónico y restablecerá toda la información del perfil." + anonymize_confirm: "¿SEGURO que quieres hacer anónima esta cuenta? Esto cambiará el nombre de usuario y el correo electrónico y restablecerá toda la información del perfil." anonymize_yes: "Sí, hacer anónima esta cuenta." anonymize_failed: "Hubo un problema al hacer anónima la cuenta." delete: "Eliminar usuario" @@ -4595,18 +4625,18 @@ es: progress: title: "Progreso de la eliminación de publicaciones" merge: - button: "Juntar" + button: "Fusionar" prompt: title: "Transferir y eliminar @%{username}" description: | -

    Por favor, escoge un nuevo dueño para el contenido de @%{username}.

    +

    Por favor, escoge un nuevo propietario para el contenido de @%{username}.

    Todos los temas, publicaciones, mensajes y otros contenidos creados por @%{username} serán transferidos.

    - target_username_placeholder: "Nombre de usuario del nuevo dueño" + target_username_placeholder: "Nombre de usuario del nuevo propietario" transfer_and_delete: "Transferir y eliminar @%{username}" cancel: "Cancelar" progress: - title: "Progreso de fusión" + title: "Progreso de la fusión" confirmation: title: "Transferir y eliminar @%{username}" description: | @@ -4618,20 +4648,20 @@ es: text: "transferir @%{username} a @%{targetUsername}" transfer_and_delete: "Transferir y eliminar @%{username}" cancel: "Cancelar" - merging_user: "Juntando usuario..." - merge_failed: "Ha ocurrido un error al juntar los usuarios." + merging_user: "Fusionando usuario..." + merge_failed: "Ha ocurrido un error al fusionar los usuarios." delete_forbidden_because_staff: "Los administradores y moderadores no se pueden eliminar." delete_posts_forbidden_because_staff: "No se pueden eliminar todos las publicaciones de administradores y moderadores." delete_forbidden: one: "Los usuarios no se pueden borrar si han sido registrados hace más de %{count} día, o si tienen publicaciones. Borra todas publicaciones antes de tratar de borrar un usuario." other: "Los usuarios no se pueden eliminar si tienen publicaciones. Elimina todas las publicaciones antes de tratar de eliminar a un usuario. (No se pueden eliminar las publicaciones que se hayan creado hace más de %{count} días)" cant_delete_all_posts: - one: "No se pueden eliminar todos los posts. Algunos tienen más de %{count} día de antigüedad. (Ver la opción delete_user_max_post_age )" - other: "No se pueden eliminar todas las publicaciones. Algunas publicaciones tienen más de %{count} días de antigüedad. (Ver la opción delete_user_max_post_age)" + one: "No se pueden eliminar todas las publicaciones. Algunas tienen más de %{count} día de antigüedad. (Ver la opción delete_user_max_post_age )" + other: "No se pueden eliminar todas las publicaciones. Algunas tienen más de %{count} días de antigüedad. (Ver la opción delete_user_max_post_age)" cant_delete_all_too_many_posts: - one: "No se pueden eliminar todos los posts porque el usuario tiene más de %{count}. (delete_all_posts_max)" + one: "No se pueden eliminar todas las publicaciones porque el usuario tiene más de %{count}. (delete_all_posts_max)" other: "No se pueden eliminar todas las publicaciones porque el usuario tiene más de %{count}. (delete_all_posts_max)" - delete_confirm: "Por lo general es preferible anonimizar usuarios en vez de eliminarlos para evitar quitar contenido de debates existentes.

    ¿Estás SEGURO de que quieres eliminar este usuario? ¡Esta acción es permanente!" + delete_confirm: "Por lo general es preferible anonimizar usuarios en vez de eliminarlos para evitar quitar contenido de debates existentes.

    ¿SEGURO que quieres eliminar este usuario? ¡Esta acción es permanente!" delete_and_block: "Eliminar y bloquear este correo electrónico y esta dirección IP" delete_dont_block: "Solo eliminar" deleting_user: "Eliminando usuario..." @@ -4646,14 +4676,14 @@ es: deactivate_failed: "Hubo un problema al desactivar el usuario." unsilence_failed: "Hubo un problema al dejar de silenciar al usuario." silence_failed: "Hubo un problema al silenciar al usuario." - silence_confirm: "¿Estás seguro de que quieres silenciar este usuario? No podrá crear temas ni mensajes nuevos." - silence_accept: "Sí, silenciar este usuario" + silence_confirm: "¿Seguro que quieres silenciar este usuario? No podrá crear temas ni mensajes nuevos." + silence_accept: "Sí, silenciar a este usuario" bounce_score: "Puntuación de rebote" reset_bounce_score: label: "Restablecer" title: "Restablecer la puntuación de rebote a 0" visit_profile: "Visita la página de preferencias de este usuario para editar su perfil" - deactivate_explanation: "Un usuario desactivado debe revalidar su correo electrónico." + deactivate_explanation: "Un usuario desactivado debe volver a validar su correo electrónico." suspended_explanation: "Un usuario suspendido no puede iniciar sesión." silence_explanation: "Un usuario silenciado no puede crear mensajes ni temas." staged_explanation: "Un usuario temporal solo puede publicar por correo electrónico en temas específicos." @@ -4662,21 +4692,21 @@ es: some: "Se han recibido algunos rebotes recientemente desde este correo electrónico." threshold_reached: "Se recibieron demasiados rebotes desde ese correo electrónico." trust_level_change_failed: "Hubo un problema al cambiar el nivel de confianza del usuario." - suspend_modal_title: "Suspender Usuario" + suspend_modal_title: "Suspender usuario" confirm_cancel_penalty: "¿Seguro que quieres descartar la falta?" trust_level_2_users: "Usuarios con nivel de confianza 2" - trust_level_3_requirements: "Requerimientos para nivel de confianza 3" - trust_level_locked_tip: "el nivel de confianza esta bloqueado, el sistema no promoverá o degradara al usuario." - trust_level_unlocked_tip: "el nivel de confianza esta desbloqueado, el sistema podrá promover o degradar al usuario." + trust_level_3_requirements: "Requisitos para el nivel de confianza 3" + trust_level_locked_tip: "el nivel de confianza está bloqueado, el sistema no promoverá o degradará al usuario." + trust_level_unlocked_tip: "el nivel de confianza está desbloqueado, el sistema podrá promoverá o degradará al usuario." lock_trust_level: "Bloquear nivel de confianza" unlock_trust_level: "Desbloquear nivel de confianza" tl3_requirements: - title: "Requerimientos para el nivel de confianza 3" + title: "Requisitos para el nivel de confianza 3" table_title: one: "En el último día:" other: "En los últimos %{count} días:" value_heading: "Valor" - requirement_heading: "Requerimiento" + requirement_heading: "Requisito" visits: "Visitas" days: "días" topics_replied_to: "Temas en los que ha comentado" @@ -4684,16 +4714,16 @@ es: topics_viewed_all_time: "Temas vistos (desde siempre)" posts_read: "Publicaciones leídas" posts_read_all_time: "Publicaciones leídas (desde siempre)" - flagged_posts: "Publicaciones reportadas" - flagged_by_users: "Usuarios que lo reportaron" + flagged_posts: "Publicaciones denunciadas" + flagged_by_users: "Usuarios que denunciaron" likes_given: "Me gusta dados" likes_received: "Me gusta recibidos" likes_received_days: "Me gusta recibidos: días únicos" likes_received_users: "Me gusta recibidos: usuarios únicos." suspended: "Suspendido (últimos 6 meses)" silenced: "Silenciado (últimos 6 meses)" - qualifies: "Califica para el nivel de confianza 3." - does_not_qualify: "No califica para el nivel de confianza 3." + qualifies: "Cumple los requisitos para el nivel de confianza 3." + does_not_qualify: "No cumple los requisitos para el nivel de confianza 3." will_be_promoted: "Será promovido pronto." will_be_demoted: "Será degradado pronto." on_grace_period: "Actualmente en periodo de gracia de promoción, no será degradado." @@ -4721,7 +4751,7 @@ es: edit: "Editar" delete: "Eliminar" cancel: "Cancelar" - delete_confirm: "¿Estás seguro de que quieres eliminar este campo de usuario?" + delete_confirm: "¿Seguro que quieres eliminar este campo de usuario?" options: "Opciones" required: title: "¿Obligatorio rellenarlo al registrarse?" @@ -4746,20 +4776,21 @@ es: field_types: text: "Campo de texto" confirm: "Confirmación" - dropdown: "Lista" + dropdown: "Desplegable" + multiselect: "Selección múltiple" site_text: description: "Puedes personalizar cualquier texto de tu foro. Empieza buscando:" search: "Busca el texto que te gustaría editar" title: "Texto" edit: "editar" revert: "Deshacer cambios" - revert_confirm: "¿Estás seguro de que quieres deshacer tus cambios?" + revert_confirm: "¿Seguro que quieres deshacer tus cambios?" go_back: "Volver a la búsqueda" recommended: "Te recomendamos que ajustes los siguientes textos a tu comunidad:" show_overriden: "Solo mostrar modificados" locale: "Idioma:" fallback_locale_warning: "Estás editando un idioma basado en %{fallback}. Los usuarios que seleccionen %{fallback} como su idioma de interfaz no verán tus cambios." - more_than_50_results: "Hay más de 50 resultados. Por favor, afina tu busqueda." + more_than_50_results: "Hay más de 50 resultados. Por favor, afina tu búsqueda." settings: show_overriden: "Ver solo cambiados" history: "Ver historial de cambios" @@ -4767,9 +4798,9 @@ es: none: "ninguno" site_settings: emoji_list: - invalid_input: "Las listas de emojis solo pueden contener nombres válidos de emojis. Ej.: hugs" + invalid_input: "Las listas de emojis solo pueden contener nombres válidos de emojis. Ej.: abrazos" add_emoji_button: - label: "Añadir Emoticono" + label: "Añadir emoji" title: "Ajustes" no_results: "No se encontró ningún resultado." more_than_30_results: "Hay más de 30 resultados. Por favor, refina tu búsqueda o selecciona una categoría." @@ -4806,7 +4837,7 @@ es: api: "API" user_api: "API de usuario" uncategorized: "Otros" - backups: "Copias de respaldo" + backups: "Copias de seguridad" login: "Inicio de sesión" plugins: "Plugins" user_preferences: "Preferencias de usuario" @@ -4823,75 +4854,75 @@ es: simple_list: add_item: "Añadir elemento..." json_schema: - edit: Lanzar editor + edit: Abrir editor modal_title: "Editar %{name}" badges: - title: Medallas - new_badge: Nueva medalla + title: Insignias + new_badge: Nueva insignia new: Crear name: Nombre - badge: Medalla + badge: Insignia display_name: Nombre que se muestra description: Descripción long_description: Descripción completa - badge_type: Tipo de medalla + badge_type: Tipo de insignia badge_grouping: Grupo badge_groupings: - modal_title: Grupos de medallas + modal_title: Grupos de insignias granted_by: Concedido por granted_at: Concedido en reason_help: (Enlace a una publicación o tema) save: Guardar delete: Eliminar - delete_confirm: '¿Estás seguro de que quieres eliminar esta medalla?' + delete_confirm: '¿Estás seguro de que quieres eliminar esta insignia?' revoke: Revocar reason: Motivo expand: Expandir … - revoke_confirm: '¿Estás seguro de que quieres revocar esta medalla?' - edit_badges: Editar medallas - grant_badge: Conceder medalla - granted_badges: Medallas concedidas + revoke_confirm: '¿Estás seguro de que quieres revocar esta insignia?' + edit_badges: Editar insignias + grant_badge: Conceder insignia + granted_badges: Insignias concedidas grant: Conceder - no_user_badges: "%{name} no ha recibido ninguna medalla." - no_badges: No hay medallas que puedan ser concedidas. - none_selected: "Selecciona una medalla para empezar" - allow_title: Permitir que se use la medalla como título - multiple_grant: Puede ser concedida varias veces - listable: Mostrar medalla en la página pública de medallas - enabled: Activar medalla + no_user_badges: "%{name} no ha recibido ninguna insignia." + no_badges: No hay insignias que puedan ser concedidas. + none_selected: "Selecciona una insignia para empezar" + allow_title: Permitir que se use la insignia como título + multiple_grant: Se puede conceder varias veces + listable: Mostrar insignia en la página pública de insignias + enabled: Activar insignias icon: Icono image: Imagen graphic: Gráfico - icon_help: "ingresa un nombre de icono de Font Awesome (usa el prefijo 'far-' para iconos regulares y 'fab-' para iconos de marca)" - image_help: "Subir una imagen sobreescribirá el campo del icono si ambos tienen valores." + icon_help: "Introduce un nombre de icono de Font Awesome (usa el prefijo «far-» para iconos normales y «fab-» para iconos de marca)" + image_help: "Subir una imagen sobrescribirá el campo del icono si ambos tienen valores." select_an_icon: "Seleccionar un icono" upload_an_image: "Subir una imagen" read_only_setting_help: "Personalizar texto" - query: Consulta de medalla (SQL) + query: Consulta de insignia (SQL) target_posts: Publicaciones destino de la consulta auto_revoke: Ejecutar diariamente la consulta de revocación - show_posts: Mostrar la publicación por la cual se concedió la medalla en la página de medallas - trigger: Disparador + show_posts: Mostrar la publicación por la cual se concedió la insignia en la página de insignias + trigger: Activador trigger_type: none: "Actualizar diariamente" post_action: "Cuando un usuario interactúa con una publicación" post_revision: "Cuando un usuario edita o crea una publicación" - trust_level_change: "Cuando cambia el nivel de confianza de un usuario" + trust_level_change: "Cuando un usuario cambia el nivel de confianza" user_change: "Cuando se edita o se crea un usuario" post_processed: "Después de procesar una publicación" preview: - link_text: "Vista previa de las medallas concedidas" + link_text: "Vista previa de las insignias concedidas" plan_text: "Vista previa con el plan de ejecución de tu consulta" - modal_title: "Vista previa de la consulta para la medalla" + modal_title: "Vista previa de la consulta para la insignia" sql_error_header: "Ocurrió un error con la consulta." - error_help: "Mira los siguientes enlaces para ayudarte con las solicitudes de las medallas" + error_help: "Mira los siguientes enlaces para ayudarte con las solicitudes de las insignias" bad_count_warning: header: "¡ADVERTENCIA!" - text: "Faltan algunas muestras muestras de concesiones. Esto ocurre cuando la solicitud de la medalla devuelve ID de usuarios o de publicaciones que no existen. Esto podría causar resultados inesperados más tarde - por favor, revisa de nuevo tu solicitud." - no_grant_count: "No hay medallas para asignar." + text: "Faltan algunas muestras de concesiones. Esto ocurre cuando la solicitud de la insignia devuelve ID de usuarios o de publicaciones que no existen. Esto podría causar resultados inesperados más tarde. Revisa de nuevo tu solicitud." + no_grant_count: "No hay insignias para asignar." grant_count: - one: "%{count} medalla para conceder." - other: "%{count} medallas para conceder." + one: "%{count} insignia para conceder." + other: "%{count} insignias para conceder." sample: "Muestra:" grant: with: %{username} @@ -4899,19 +4930,19 @@ es: with_post_time: %{username} por la publicación en %{link} a las %{time} with_time: %{username} a las %{time} badge_intro: - title: "Selecciona una medalla existente o crea una para empezar" + title: "Selecciona una insignia existente o crea una para empezar" emoji: "emoji de mujer estudiante" - what_are_badges_title: "¿Qué son las medallas?" - badge_query_examples_title: "Ejemplos de consultas de medallas" + what_are_badges_title: "¿Qué son las insignias?" + badge_query_examples_title: "Ejemplos de consultas de insignias" mass_award: title: Conceder en masa - description: Concede la misma medalla a muchos usuarios a la vez. - no_badge_selected: Por favor, selecciona una medalla para empezar. - perform: "Conceder medalla a los usuarios" + description: Concede la misma insignia a muchos usuarios a la vez. + no_badge_selected: Selecciona una insignia para empezar. + perform: "Conceder insignia a los usuarios" upload_csv: Sube un archivo CSV con correos electrónicos o nombres de usuario - aborted: Por favor, sube un archivo CSV que contenga correos electrónicos o nombres de usuario - success: Se recibió el CSV y los usuarios recibirán la medalla dentro de poco. - replace_owners: Quitar medalla de los anteriores portadores + aborted: Sube un archivo CSV que contenga correos electrónicos o nombres de usuario + success: Se recibió el CSV y los usuarios recibirán la insignia dentro de poco. + replace_owners: Quitar insignia de los anteriores propietarios emoji: title: "Emoji" help: "Añade emojis nuevos que estarán disponibles para todos. (CONSEJO: arrasta varios archivos a la vez)" @@ -4921,11 +4952,11 @@ es: group: "Grupo" image: "Imagen" alt: "previsualización de emoji personalizado" - delete_confirm: "¿Estás seguro de que quieres eliminar el emoji :%{name}:?" + delete_confirm: "¿Seguro que quieres eliminar el emoji :%{name}:?" embedding: get_started: "Si quieres insertar Discourse en otro sitio web, empieza por añadir su host." - confirm_delete: "¿Estás seguro de que quieres eliminar este host?" - sample: "Usa el siguiente código HTML en tu sitio para crear e insertar temas. Reempalza REPLACE_ME con la URL canónica de la página donde quieres insertar." + confirm_delete: "¿Seguro que quieres eliminar este host?" + sample: "Usa el siguiente código HTML en tu sitio para crear e insertar temas. Reempalza REPLACE_ME con la URL canónica de la página donde lo quieres incrustar." title: "Incrustado" host: "Hosts permitidos" class_name: "Nombre de clase" @@ -4947,7 +4978,7 @@ es: save: "Guardar ajustes de incrustado" permalink: title: "Enlaces permanentes" - description: "Redirecciones para URLs que no existan ya en el foro." + description: "Redirecciones para URL que no existan ya en el foro." url: "URL" topic_id: "ID del tema" topic_title: "Tema" @@ -4959,7 +4990,7 @@ es: external_url: "URL externa o relativa" destination: "Destino" copy_to_clipboard: "Copiar enlace permanente al portapapeles" - delete_confirm: '¿Estás seguro de que quieres eliminar este enlace permanente?' + delete_confirm: '¿Seguro que quieres eliminar este enlace permanente?' form: label: "Nuevo:" add: "Añadir" @@ -4986,11 +5017,11 @@ es: upload_error: "Lo sentimos, se produjo un error al subir este archivo. Por favor, inténtalo de nuevo." quit: "Tal vez más tarde" staff_count: - one: "Tu comunidad tiene %{count} staff (tú). " - other: "Tu comunidad tiene %{count} miembros del staff, tú incluido." + one: "Tu comunidad tiene %{count} miembro del personal (tú)." + other: "Tu comunidad tiene %{count} miembros del personal, tú incluido." invites: add_user: "añadir" - none_added: "No has invitado a nadie como staff. ¿Estás seguro de que quieres continuar?" + none_added: "No has invitado a ningún miembro del equipo. ¿Seguro que quieres continuar?" roles: admin: "Administrador" moderator: "Moderador" diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index cf0e18be5a..ece59a7ee9 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -183,7 +183,6 @@ et: submit: "Saada" generic_error: "Vabandust, tekkis viga." generic_error_with_reason: "Tekkis viga: %{error}" - go_ahead: "Jätka" sign_up: "Liitu" log_in: "Logi sisse" age: "Vanus" @@ -212,7 +211,6 @@ et: x_more: one: "%{count} Veel" other: "Veel %{count}" - less: "Peida" never: "mitte kunagi" every_30_minutes: "iga 30 minuti järel" every_hour: "iga tund" @@ -221,7 +219,6 @@ et: every_month: "iga kuu" every_six_months: "iga kuue kuu tagant" max_of_count: "maksimum %{count}-st" - alternation: "või" character_count: one: "%{count} sümbol" other: "%{count} sümbolit" @@ -253,7 +250,6 @@ et: help: bookmark: "Kliki selle teema esimesele postitusele järjehoidja lisamiseks" unbookmark: "Kliki selle teema kõigi järjehoidjate eemaldamiseks" - unbookmark_with_reminder: "Klõpsa et eemaldada teemalt kõik järjehoidjad ja meeldetuletused. Sul on siin teemas meeldetuletus %{reminder_at}." bookmarks: created: "Oled selle postituse järjehoidjatesse lisanud. %{name}" not_bookmarked: "lisa sellele postitusele järjehoidja" @@ -484,9 +480,6 @@ et: remove_user_as_group_owner: "Eemalda omanik" groups: member_added: "Lisatud" - add_members: - usernames: - input_placeholder: "Kasutajanimed" requests: reason: "Põhjus" accepted: "aktsepteeritud" @@ -494,7 +487,7 @@ et: title: "Halda" name: "Nimi" full_name: "Täisnimi" - add_members: "Lisa liikmeid" + invite_members: "Kutsu" delete_member_confirm: "Kas eemaldada '%{username}' grupist '%{group}'?" profile: title: Profiil diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index 782e95af3a..22f56a0c95 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -196,7 +196,6 @@ fa_IR: submit: "ارسال" generic_error: "متأسفیم، خطایی روی داده." generic_error_with_reason: "خطایی روی داد: %{error}" - go_ahead: "ادامه دهید" sign_up: "ثبت نام" log_in: "ورود" age: "سن" @@ -225,7 +224,6 @@ fa_IR: x_more: one: "%{count} بیشتر" other: "بیش از %{count}" - less: "کمتر" never: "هرگز" every_30_minutes: "هر 30 دقیقه" every_hour: "هر ساعت" @@ -234,7 +232,6 @@ fa_IR: every_month: "هر ماه" every_six_months: "هر شش ماه" max_of_count: "حداکثر %{count}" - alternation: "یا" character_count: one: "%{count} نویسه" other: "%{count} نویسه" @@ -269,7 +266,6 @@ fa_IR: help: bookmark: "کلیک کنید تا به اولین نوشته این موضوع نشانک بزنید" unbookmark: "کلیک کنید تا همهٔ نشانک‌های این موضوع را حذف کنید" - unbookmark_with_reminder: "برای حذف همه نشانک‌ها و یادآوری‌های این موضوع کلیک کنید. شما یک مجموعه یادآوری %{reminder_at} برای این موضوع دارید." bookmarks: created: "شما این نوشته را نشانک کردید. %{name}" not_bookmarked: "این نوشته را نشانک بزنید" @@ -595,14 +591,6 @@ fa_IR: member_added: "اضافه شده" member_requested: "درخواست شده در" add_members: - title: "اعضا را به %{group_name} اضافه کنید" - description: "شما همچنین قابلیت جداسازی در لیست با کاما را دارید." - usernames_or_emails: - title: "نام های کاربری یا آدرس ایمیل ها را وارد کنید" - input_placeholder: "نام‌کاربری یا ایمیل" - usernames: - title: "نام‌کاربری را وارد کنید" - input_placeholder: "نام های کاربری" notify_users: "اطلاع رسانی به کاربران" requests: title: "درخواست ها" @@ -617,7 +605,7 @@ fa_IR: title: "مدیریت" name: "نام" full_name: "نام کامل" - add_members: "افزودن اعضا" + invite_members: "دعوت" delete_member_confirm: "کاربر '%{username}' از گروه '%{group}' حذف شود؟" profile: title: مشخصات کاربر @@ -3123,8 +3111,6 @@ fa_IR: available: "نام گروه در دسترس است" not_available: "نام گروه در دسترس نیست" blank: "نام گروه نمی‌تواند خالی باشد" - add_members: - as_owner: "قراردادن کاربر(ها) به‌عنوان مالک(مالکان) این گروه" manage: interaction: email: ایمیل diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index 91fe754e24..7d904bd358 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -200,7 +200,6 @@ fi: submit: "Lähetä" generic_error: "On tapahtunut virhe." generic_error_with_reason: "Tapahtui virhe: %{error}" - go_ahead: "Jatka" sign_up: "Rekisteröidy" log_in: "Kirjaudu" age: "Ikä" @@ -229,7 +228,6 @@ fi: x_more: one: "%{count} muu" other: "%{count} muuta" - less: "Vähemmän" never: "ei koskaan" every_30_minutes: "30 minuutin välein" every_hour: "tunnin välein" @@ -238,7 +236,6 @@ fi: every_month: "kuukausittain" every_six_months: "kuuden kuukauden välein" max_of_count: "korkeintaan %{count}" - alternation: "tai" character_count: one: "%{count} merkki" other: "%{count} merkkiä" @@ -273,7 +270,6 @@ fi: help: bookmark: "Klikkaa lisätäksesi ketjun ensimmäisen viestin kirjanmerkkeihin" unbookmark: "Klikkaa poistaaksesi kaikki tämän ketjun kirjanmerkit" - unbookmark_with_reminder: "Poista kaikki kirjanmerkkisi ja muistutuksesi tästä ketjusta klikkaamalla. Olet asettanut muistutuksen tästä ketjusta ajankohtaan %{reminder_at}." bookmarks: created: "Olet kirjanmerkinnyt tämän viestin. %{name}" not_bookmarked: "lisää viesti kirjanmerkkeihin" @@ -601,14 +597,6 @@ fi: member_added: "Lisättiin" member_requested: "Pyydetty" add_members: - title: "Lisää jäseniä ryhmään %{group_name}" - description: "Voit myös liittää pilkuin erotellun luettelon." - usernames_or_emails: - title: "Anna käyttäjätunnukset tai sähköpostiosoitteet" - input_placeholder: "Käyttäjätunnukset tai sähköpostit" - usernames: - title: "Syötä käyttäjätunnukset" - input_placeholder: "Käyttäjätunnukset" notify_users: "Ilmoita käyttäjille" requests: title: "Pyynnöt" @@ -623,7 +611,6 @@ fi: title: "Hallinnoi" name: "Nimi" full_name: "Koko nimi" - add_members: "Lisää jäseniä" delete_member_confirm: "Poistetaanko '%{username}' ryhmästä '%{group}'?" profile: title: Profiili @@ -3441,9 +3428,9 @@ fi: trust_levels: names: newuser: "tulokas" - basic: "peruskäyttäjä" - member: "jäsen" - regular: "tavallinen" + basic: "haastaja" + member: "konkari" + regular: "mestari" leader: "johtaja" admin_js: type_to_filter: "kirjoita suodattaaksesi..." @@ -3564,8 +3551,6 @@ fi: available: "Ryhmän nimi on käytettävissä" not_available: "Ryhmän nimi ei ole käytettävissä" blank: "Ryhmällä on oltava nimi" - add_members: - as_owner: "Aseta käyttäjiä tämän ryhmän omistajiksi" manage: interaction: email: Sähköposti @@ -4437,9 +4422,9 @@ fi: new: "Uudet käyttäjät" pending: "Hyväksymistä odottavat käyttäjät" newuser: "Luottamustason 0 käyttäjät (Tulokas)" - basic: "Luottamustason 1 käyttäjät (Peruskäyttäjä)" - member: "Luottamustason 2 käyttäjät (Jäsen)" - regular: "Luottamustason 3 käyttäjät (Tavallinen)" + basic: "Luottamustason 1 käyttäjät (Haastaja)" + member: "Luottamustason 2 käyttäjät (Konkari)" + regular: "Luottamustason 3 käyttäjät (Mestari)" leader: "Luottamustason 4 käyttäjät (Johtaja)" staff: "Henkilökunta" admins: "Ylläpitäjät" @@ -4820,64 +4805,64 @@ fi: upload_an_image: "Lataa kuva" read_only_setting_help: "Mukauta tekstiä" query: Kunniamerkkikysely (SQL) - target_posts: Tietokantakyselyn kohdeviestit - auto_revoke: Aja kumoamis-ajo päivittäin - show_posts: Näytä ansiomerkin tuonut viesti ansiomerkkisivulla + target_posts: Kysely on kohdennettu viesteihin + auto_revoke: Suorita peruutuskysely päivittäin + show_posts: Näytä kunniamerkin tuonut viesti kunniamerkkisivulla trigger: Laukaisija trigger_type: none: "Päivitä päivittäin" post_action: "Kun käyttäjä toimii viestin suhteen" post_revision: "Kun käyttäjä muokkaa viestiä tai luo viestin" trust_level_change: "Kun käyttäjän luottamustaso vaihtuu" - user_change: "Kun käyttäjä luodaan tai sitä muokataan" - post_processed: "Sen jälkeen, kun viesti on käsitelty" + user_change: "Kun käyttäjä luodaan tai käyttäjää muokataan" + post_processed: "Viestin käsittelyn jälkeen" preview: - link_text: "Esikatsele myönnettäviä ansiomerkkejä" - plan_text: "Esikatsele query plan" - modal_title: "Ansiomerkin tietokantakyselyn esikatselu" - sql_error_header: "Kyselyn käsittelyssä tapahtui virhe." - error_help: "Apua ansiomerkkien tietokantakyselyihin saat seuraavista linkeistä." + link_text: "Esikatsele myönnettäviä kunniamerkkejä" + plan_text: "Esikatsele kyselysuunnitelmalla" + modal_title: "Kunniamerkin kyselyn esikatselu" + sql_error_header: "Kyselyssä tapahtui virhe." + error_help: "Apua kunniamerkkikyselyihin saat seuraavista linkeistä." bad_count_warning: header: "VAROITUS!" - text: "Myöntöjen näytteitä puuttuu. Tämä tapahtuu, kun ansiomerkin kysely palauttaa käyttäjä-ID:n tai viesti-ID:n, jota ei ole olemassa. Tämä voi johtaa odottamattomiin seurauksiin myöhemmin - tarkista kysely uudestaan." - no_grant_count: "Ei ansiomerkkejä myönnettäväksi" + text: "Antoesimerkkejä puuttuu. Tämä tapahtuu, kun kunniamerkkikysely palauttaa käyttäjätunnuksia tai viestien tunnuksia, joita ei ole olemassa. Tämä voi johtaa odottamattomiin seurauksiin myöhemmin – tarkista kyselysi." + no_grant_count: "Ei kunniamerkkejä myönnettäväksi." grant_count: - one: "%{count} ansiomerkki odottaa myöntämistä." - other: "%{count} ansiomerkkiä odottaa myöntämistä." + one: "%{count} myönnettävä kunniamerkki." + other: "%{count} myönnettävää kunniamerkkiä." sample: "Esimerkki:" grant: with: %{username} with_post: %{username} viestille ketjussa %{link} - with_post_time: %{username} viestille ketjussa %{link} %{time} - with_time: %{username} %{time} + with_post_time: %{username} viestille ketjussa %{link} klo %{time} + with_time: %{username} klo %{time} badge_intro: - title: "Aloita valitsemalla olemassa oleva ansiomerkki tai luomalla uusi" + title: "Aloita valitsemalla olemassa oleva kunniamerkki tai luomalla uusi" emoji: "naisopiskelijaemoji" - what_are_badges_title: "Mitä ansiomerkit ovat?" - badge_query_examples_title: "Esimerkkejä ansiomerkin tietokantakyselyistä" + what_are_badges_title: "Mitä kunniamerkit ovat?" + badge_query_examples_title: "Esimerkkejä kunniamerkkikyselyistä" mass_award: - title: Myönnä useille - description: Myönnä sama ansiomerkki useille käyttäjille samalla kertaa. - no_badge_selected: Aloita valitsemalla ansiomerkki. - perform: "Myönnä ansiomerkki käyttäjille" - upload_csv: Lataa CSV, jossa on joko käyttäjien sähköpostiosoitteet tai käyttäjänimet - aborted: Lataa CSV, jossa on joko käyttäjien sähköpostiosoitteet tai käyttäjänimet - success: CSV tuli perille ja käyttäjät saavat ansiomerkkinsä pian. - replace_owners: Poista ansiomerkki aiemmilta omistajilta + title: Joukkomyöntäminen + description: Myönnä sama kunniamerkki useille käyttäjille kerralla. + no_badge_selected: Aloita valitsemalla kunniamerkki. + perform: "Myönnä kunniamerkki käyttäjille" + upload_csv: Lataa CSV, jossa on joko käyttäjien sähköpostiosoitteita tai käyttäjätunnuksia + aborted: Lataa CSV, joka sisältää joko käyttäjien sähköpostiosoitteita tai käyttäjätunnuksia + success: CSV tuli perille ja käyttäjät saavat kunniamerkkinsä pian. + replace_owners: Poista kunniamerkki aiemmilta omistajilta emoji: title: "Emoji" - help: "Lisää uusi emoji joka on kaikkien käytettävissä. (Voit raahata useita tiedostoja kerralla)" + help: "Lisää uusi emoji, joka on kaikkien käytettävissä. (Vinkki: voit vetää ja pudottaa useita tiedostoja kerralla)" add: "Lisää uusi emoji" - uploading: "Lähettää..." + uploading: "Ladataan..." name: "Nimi" group: "Ryhmä" image: "Kuva" alt: "mukautetun emojin esikatselu" delete_confirm: "Oletko varma, että haluat poistaa emojin :%{name}:?" embedding: - get_started: "Jos haluat upottaa Discoursen toiselle sivustolle, aloita lisäämällä isäntä." + get_started: "Jos haluat upottaa Discoursen toiselle sivustolle, aloita lisäämällä sen isäntä." confirm_delete: "Oletko varma, että haluat poistaa tämän isännän?" - sample: "Käytä alla olevaa HTML-koodia sivustollasi jotta voit luoda ja upottaa discourse-ketjuja. Korvaa REPLACE_ME upotettavan sivun kanonisella URL-osoitteella." + sample: "Käytä alla olevaa HTML-koodia sivustollasi, jotta voit luoda ja upottaa discourse-ketjuja. Korvaa REPLACE_ME upotettavan sen sivun kanonisella URL-osoitteella, jolle upotat." title: "Upottaminen" host: "Sallitut isännät" class_name: "Luokan nimi" @@ -4885,33 +4870,33 @@ fi: edit: "muokkaa" category: "Julkaise alueelle" add_host: "Lisää isäntä" - settings: "Upotuksen asetukset" - crawling_settings: "Crawlerin asetukset" - crawling_description: "Kun Discourse aloittaa ketjuja kirjoituksistasi, se yrittää jäsentää kirjoitustesi sisältöä HTML:stä, jos RSS/ATOM-syötettä ei ole tarjolla, Joskus kirjoitusten sisällön poimiminen on haastavaa, joten tarjoamme mahdollisuuden määrittää CSS-sääntöjä sen helpottamiseksi." - embed_by_username: "Käyttäjänimi, jonka nimissä ketjut aloitetaan" + settings: "Upotusasetukset" + crawling_settings: "Hakurobottiasetukset" + crawling_description: "Kun Discourse aloittaa ketjuja viesteistäsi, se yrittää jäsentää kirjoitustesi sisältöä HTML:stä, jos RSS-/ATOM-syötettä ei ole tarjolla. Joskus kirjoitusten sisällön poimiminen on haastavaa, joten tarjoamme mahdollisuuden määrittää CSS-sääntöjä sen helpottamiseksi." + embed_by_username: "Käyttäjätunnus, jonka nimissä ketjut aloitetaan" embed_post_limit: "Upotettavien viestien maksimimäärä" embed_title_scrubber: "Säännöllinen lauseke, jolla riisutaan viestien otsikkoja" embed_truncate: "Typistä upotetut viestit" - embed_unlisted: "Tuodut ketjut ovat piilotettuna kunnes niihin vastataan." + embed_unlisted: "Tuodut ketjut ovat piilotettuna, kunnes niihin vastataan." allowed_embed_selectors: "CSS-valitsin elementeille, jotka ovat sallittuja upotuksissa" blocked_embed_selectors: "CSS-valitsin elementeille, jotka poistetaan upotuksista" allowed_embed_classnames: "Sallitut CSS-luokkien nimet" save: "Tallenna upotusasetukset" permalink: - title: "Ikilinkit" + title: "Pysyvät linkit" description: "Aseta uudelleenohjauksia URL-osoitteille, joita sivusto ei tunne." url: "URL" - topic_id: "Ketjun ID" + topic_id: "Ketjun tunnus" topic_title: "Ketju" - post_id: "Viestin ID" + post_id: "Viestin tunnus" post_title: "Viesti" - category_id: "Alueen ID" + category_id: "Alueen tunnus" category_title: "Alue" tag_name: "Tunnisteen nimi" external_url: "Ulkoinen tai suhteellinen URL-osoite" destination: "Kohde" - copy_to_clipboard: "Kopioi ikilinkki leikepöydälle" - delete_confirm: Oletko varma, että haluat poistaa tämän ikilinkin? + copy_to_clipboard: "Kopioi pysyvä linkki leikepöydälle" + delete_confirm: Oletko varma, että haluat poistaa tämän pysyvän linkin? form: label: "Uusi:" add: "Lisää" @@ -4922,33 +4907,33 @@ fi: title: "Korvaa alueiden ja ketjujen tekstejä käännöksillä" modal: title: "Korvaa teksti" - subtitle: "Korvaa systeemin luomien alueiden ja ketjujen tekstejä viimeisimmillä käännöksillä" - categories: "Keskustelualueet" - topics: "Ketjuja" + subtitle: "Korvaa järjestelmän luomien alueiden ja ketjujen tekstejä viimeisimmillä käännöksillä" + categories: "Alueet" + topics: "Ketjut" replace: "Korvaa" wizard_js: wizard: done: "Valmis" finish: "Valmis" - back: "Edellinen" + back: "Takaisin" next: "Seuraava" step: "%{current}/%{total}" upload: "Lataa" uploading: "Ladataan..." - upload_error: "Tiedoston lähetys valitettavasti epäonnistui. Ole hyvä ja yritä uudelleen." + upload_error: "Tiedoston lataaminen epäonnistui. Yritä uudelleen." quit: "Ehkä myöhemmin" staff_count: - one: "Henkilökuntalaisia on %{count} (eli sinä)" - other: "Henkilökuntalaisia on %{count}, sinä mukaan lukien." + one: "Henkilökuntalaisia on yhteisössä %{count} (eli sinä)" + other: "Henkilökuntalaisia on yhteisössä %{count}, sinä mukaan lukien." invites: add_user: "lisää" - none_added: "Et ole kutsunut lainkaan henkilökuntaa. Haluatko todella jatkaa?" + none_added: "Et ole kutsunut henkilökuntaa. Oletko varma, että haluat jatkaa?" roles: admin: "Ylläpitäjä" moderator: "Valvoja" regular: "Tavallinen käyttäjä" previews: topic_title: "Keskusteluketju" - font_title: "%{font} -fontti" + font_title: "Fontti %{font}" share_button: "Jaa" reply_button: "Vastaa" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index ea869c3986..de3d3000ae 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -124,36 +124,36 @@ fr: email: "Envoyer par courriel" url: "Copier et partager l'URL" action_codes: - public_topic: "a rendu ce sujet public le %{when}" - private_topic: "a fait de ce sujet un message direct le %{when}" - split_topic: "a scindé ce sujet le %{when}" - invited_user: "a invité %{who} le %{when}" - invited_group: "a invité %{who} le %{when}" - user_left: "%{who} a quitté cette conversation le %{when}" - removed_user: "a retiré %{who} le %{when}" - removed_group: "a retiré %{who} le %{when}" - autobumped: "remonté automatiquement dans la liste le %{when}" + public_topic: "a rendu ce sujet public %{when}" + private_topic: "a fait de ce sujet un message direct %{when}" + split_topic: "a scindé ce sujet %{when}" + invited_user: "a invité %{who} %{when}" + invited_group: "a invité %{who} %{when}" + user_left: "%{who} a quitté cette conversation %{when}" + removed_user: "a retiré %{who} %{when}" + removed_group: "a retiré %{who} %{when}" + autobumped: "remonté automatiquement dans la liste %{when}" autoclosed: - enabled: "fermé automatiquement le %{when}" - disabled: "ouvert automatiquement le %{when}" + enabled: "fermé automatiquement %{when}" + disabled: "ouvert automatiquement %{when}" closed: - enabled: "a fermé ce sujet le %{when}" - disabled: "a ouvert ce sujet le %{when}" + enabled: "a fermé ce sujet %{when}" + disabled: "a ouvert ce sujet %{when}" archived: - enabled: "a archivé ce sujet le %{when}" - disabled: "a désarchivé ce sujet le %{when}" + enabled: "a archivé ce sujet %{when}" + disabled: "a désarchivé ce sujet %{when}" pinned: - enabled: "a épinglé ce sujet le %{when}" - disabled: "a désépinglé ce sujet le %{when}" + enabled: "a épinglé ce sujet %{when}" + disabled: "a désépinglé ce sujet %{when}" pinned_globally: - enabled: "a épinglé ce sujet globalement le %{when}" - disabled: "a désépinglé ce sujet le %{when}" + enabled: "a épinglé ce sujet globalement %{when}" + disabled: "a désépinglé ce sujet %{when}" visible: - enabled: "a rendu ce sujet visible le %{when}" - disabled: "a rendu ce sujet invisible le %{when}" + enabled: "a rendu ce sujet visible %{when}" + disabled: "a rendu ce sujet invisible %{when}" banner: - enabled: "a mis à la une le %{when}. Il sera affiché en haut de chaque page jusqu'à ce qu'il soit ignoré par un utilisateur." - disabled: "a supprimé de la une le %{when}. Il ne sera plus affiché en haut de chaque page." + enabled: "a mis à la une %{when}. Il sera affiché en haut de chaque page jusqu'à ce qu'il soit ignoré par un utilisateur." + disabled: "a supprimé de la une %{when}. Il ne sera plus affiché en haut de chaque page." forwarded: "a transmis le courriel ci-dessus" topic_admin_menu: "actions du sujet" wizard_required: "Bienvenue sur votre nouveau Discourse ! Démarrons par l'assistant de configuration ✨" @@ -200,7 +200,6 @@ fr: submit: "Envoyer" generic_error: "Nous sommes désolés, une erreur est survenue." generic_error_with_reason: "Une erreur est survenue : %{error}" - go_ahead: "Continuer" sign_up: "S'inscrire" log_in: "Se connecter" age: "Âge" @@ -229,7 +228,6 @@ fr: x_more: one: "%{count} autre" other: "%{count} autres" - less: "Moins" never: "jamais" every_30_minutes: "toutes les 30 minutes" every_hour: "chaque heure" @@ -238,7 +236,6 @@ fr: every_month: "chaque mois" every_six_months: "tous les six mois" max_of_count: "maximum sur %{count}" - alternation: "ou" character_count: one: "%{count} caractère" other: "%{count} caractères" @@ -273,7 +270,6 @@ fr: help: bookmark: "Cliquez pour mettre un signet sur le premier message de ce sujet" unbookmark: "Cliquez pour retirer tous les signets de ce sujet" - unbookmark_with_reminder: "Cliquez pour supprimer tous les signets et rappels de ce sujet. Vous avez un rappel pour %{reminder_at} sur ce sujet." bookmarks: created: "Vous avez mis un signet à ce message. %{name}" not_bookmarked: "mettre un signet à ce message" @@ -297,8 +293,8 @@ fr: reminders: today_with_time: "aujourd'hui à %{time}" tomorrow_with_time: "demain à %{time}" - at_time: "à %{date_time}" - existing_reminder: "Vous avez configuré un rappel pour ce signet qui sera envoyé le %{at_date_time}" + at_time: "le %{date_time}" + existing_reminder: "Vous avez configuré un rappel pour ce signet qui sera envoyé %{at_date_time}" copy_codeblock: copied: "copié !" drafts: @@ -369,7 +365,7 @@ fr: min_score_visibility: "Score minimum pour la visibilité" score_to_hide: "Score pour masquer le message" take_action_bonus: - name: "intervenu" + name: "est intervenu(e)" title: "Lorsqu'un responsable choisit d'agir, le signalement reçoit un bonus." user_accuracy_bonus: name: "fiabilité de l’utilisateur" @@ -425,16 +421,16 @@ fr: user_percentage: summary: one: "%{agreed}, %{disagreed}, %{ignored} (du dernier signalement)" - other: "%{agreed}, %{disagreed}, %{ignored} (des derniers %{count} signalements)" + other: "%{agreed}, %{disagreed}, %{ignored} (des %{count} derniers signalements)" agreed: - one: "%{count}% accepté" - other: "%{count}% acceptés" + one: "%{count} % accepté" + other: "%{count} % acceptés" disagreed: - one: "%{count}% rejeté" - other: "%{count}% rejetés" + one: "%{count} % rejeté" + other: "%{count} % rejetés" ignored: - one: "%{count}% ignoré" - other: "%{count}% ignorés" + one: "%{count} % ignoré" + other: "%{count} % ignorés" topics: topic: "Sujet" reviewable_count: "Nombre" @@ -451,7 +447,7 @@ fr: edit: "Modifier" save: "Enregistrer" cancel: "Annuler" - new_topic: "Approuver cet élément créera un nouveau sujet" + new_topic: "L'approbation de cet élément créera un nouveau sujet" filters: all_categories: "(toutes les catégories)" type: @@ -469,7 +465,7 @@ fr: priority: title: "Priorité minimum" any: "(toutes)" - low: "Basse" + low: "Faible" medium: "Moyenne" high: "Élevée" conversation: @@ -541,7 +537,7 @@ fr: later_today: "Plus tard dans la journée" next_business_day: "Au prochain jour ouvré" tomorrow: "Demain" - post_local_date: "À la date dans le message" + post_local_date: "Date indiquée dans le message" later_this_week: "Plus tard dans la semaine" start_of_next_business_week: "Lundi" start_of_next_business_week_alt: "Lundi prochain" @@ -558,7 +554,7 @@ fr: user_replied_to_topic: "%{user} a répondu à ce sujet" you_replied_to_topic: "Vous avez répondu à ce sujet" user_mentioned_user: "%{user} a mentionné %{another_user}" - user_mentioned_you: "Vous avez été mentionné par %{user}" + user_mentioned_you: "Vous avez été mentionné(e) par %{user}" you_mentioned_user: "Vous avez mentionné %{another_user}" posted_by_user: "Rédigé par %{user}" posted_by_you: "Rédigé par vous" @@ -603,14 +599,6 @@ fr: member_added: "Ajouté" member_requested: "Demandé à" add_members: - title: "Ajouter des membres à %{group_name}" - description: "Vous pouvez également insérer une liste séparée par des virgules." - usernames_or_emails: - title: "Saisissez des noms d'utilisateur ou des adresses de courriel" - input_placeholder: "Noms d'utilisateur ou courriels" - usernames: - title: "Saisissez des noms d'utilisateur" - input_placeholder: "Noms d'utilisateur" notify_users: "Notifications aux utilisateurs" requests: title: "Demandes" @@ -625,7 +613,6 @@ fr: title: "Gérer" name: "Nom" full_name: "Nom complet" - add_members: "Ajouter des membres" delete_member_confirm: "Supprimer %{username} du groupe « %{group} » ?" profile: title: Profil @@ -635,7 +622,7 @@ fr: notification: Notification email: title: "Courriel" - status: "%{old_emails} / %{total_emails} courriels synchronisés via IMAP." + status: "%{old_emails}/%{total_emails} courriels synchronisés via IMAP." enable_smtp: "Activer le SMTP" enable_imap: "Activer l'IMAP" test_settings: "Tester les paramètres" @@ -646,14 +633,14 @@ fr: smtp_instructions: "Quand le SMTP est activé au niveau du groupe, tous les courriels envoyés au nom de ce groupe seront expédiés en utilisant les paramètres SMTP indiqués ici plutôt qu'avec les paramètres SMTP globaux du site." imap_title: "IMAP" imap_additional_settings: "Paramètres additionnels" - imap_instructions: 'Quand l''IMAP est activé au niveau du groupe, les courriels sont synchronisés entre la boîte de réception de ce groupe et la boîte aux lettres IMAP indiquée ici. Avant de pouvoir activer l''IMAP, le SMTP doit d''abord être activé, et des paramètres SMTP valides doivent être renseignés. L''identifiant et le mot de passe du serveur SMTP seront utilisés pour l''authentification auprès du serveur IMAP. Pour en savoir plus, consultez l''annonce de cette fonctionnalité sur Discourse Meta (en anglais).' - imap_alpha_warning: "Attention :cette fonctionnalité est en alpha-test. Seul Gmail est pris en charge officiellement. Son utilisation se fait à vos risques et périls !" + imap_instructions: 'Quand l''IMAP est activé au niveau du groupe, les courriels sont synchronisés entre la boîte de réception de ce groupe et la boîte de réception IMAP indiquée ici. Avant de pouvoir activer l''IMAP, le SMTP doit d''abord être activé, et des paramètres SMTP valides doivent être renseignés. L''identifiant et le mot de passe du serveur SMTP seront utilisés pour l''authentification auprès du serveur IMAP. Pour en savoir plus, consultez l''annonce de cette fonctionnalité sur Discourse Meta (en anglais).' + imap_alpha_warning: "Attention : cette fonctionnalité est en test alpha. Seul Gmail est pris en charge officiellement. Son utilisation se fait à vos risques et périls !" imap_settings_valid: "Paramètres IMAP valides." - smtp_disable_confirm: "En désactivant le SMTP, tous les paramètres SMTP et IMAP seront remis à zéro et ces fonctionnalités seront désactivées. Souhaitez-vous continuer ?" - imap_disable_confirm: "En désactivant l'IMAP, tous les paramètres IMAP seront remis à zéro et cette fonctionnalité sera désactivée. Souhaitez-vous continuer ?" - imap_mailbox_not_selected: "Vous devez indiquer une boîte aux lettres dans ces paramètres IMAP, autrement aucune boîte aux lettres ne sera synchronisée !" + smtp_disable_confirm: "En désactivant le SMTP, tous les paramètres SMTP et IMAP seront réinitialisés et ces fonctionnalités seront désactivées. Souhaitez-vous continuer ?" + imap_disable_confirm: "En désactivant l'IMAP, tous les paramètres IMAP seront réinitialisés et cette fonctionnalité sera désactivée. Souhaitez-vous continuer ?" + imap_mailbox_not_selected: "Vous devez indiquer une boîte de réception dans ces paramètres IMAP, autrement aucune boîte de réception ne sera synchronisée !" prefill: - title: "Renseigner les paramètres pour :" + title: "Renseigner les paramètres pour :" gmail: "Gmail" credentials: title: "Identifiants" @@ -667,11 +654,11 @@ fr: password: "Mot de passe" settings: title: "Paramètres" - allow_unknown_sender_topic_replies: "Autoriser des réponses par un expéditeur inconnu." + allow_unknown_sender_topic_replies: "Autoriser les réponses par un expéditeur inconnu." mailboxes: - synchronized: "Boîte aux lettres à synchroniser" - none_found: "Aucune boîte aux lettres n'a été trouvée pour ce compte de courriel." - disabled: "(Désactivé)" + synchronized: "Boîte de réception à synchroniser" + none_found: "Aucune boîte de réception n'a été trouvée pour ce compte de courriel." + disabled: "Désactivée" membership: title: Adhésion access: Accès @@ -682,7 +669,7 @@ fr: watched_categories_instructions: "Surveiller automatiquement tous les sujets de ces catégories. Les membres du groupe seront notifiés de tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté de leur sujet." tracked_categories_instructions: "Suivre automatiquement tous les sujets dans ces catégories. Le nombre de nouveaux messages apparaîtra à côté de leur sujet." watching_first_post_categories_instructions: "Les utilisateurs seront notifiés du premier message de chaque sujet dans ces catégories." - regular_categories_instructions: "Si ces catégories sont mises sous silence, elles ne le seront pas pour les membres de groupe. Les utilisateurs seront avertis s'ils sont mentionnés ou si quelqu'un leur répond." + regular_categories_instructions: "Si ces catégories sont désactivées, elles ne le seront pas pour les membres du groupe. Les utilisateurs seront avertis s'ils sont mentionnés ou si quelqu'un leur répond." muted_categories_instructions: "Les utilisateurs ne seront pas notifiés des nouveaux sujets dans ces catégories et ces sujets n'apparaîtront pas sur les pages des catégories et des sujets récents." tags: title: Étiquettes @@ -691,7 +678,7 @@ fr: watched_tags_instructions: "Surveiller automatiquement tous les sujets avec ces étiquettes. Les membres du groupe seront notifiés de tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté de leur sujet." tracked_tags_instructions: "Suivre automatiquement tous les sujets avec ces étiquettes. Le nombre de nouveaux messages apparaîtra à côté de leur sujet." watching_first_post_tags_instructions: "Les utilisateurs seront notifiés du premier message de chaque sujet avec ces étiquettes." - regular_tags_instructions: "Si ces étiquettes sont mises sous silence, elles ne le seront pas pour les membres de groupe. Les utilisateurs seront avertis s'ils sont mentionnés ou si quelqu'un leur répond." + regular_tags_instructions: "Si ces étiquettes sont désactivées, elles ne le seront pas pour les membres du groupe. Les utilisateurs seront avertis s'ils sont mentionnés ou si quelqu'un leur répond." muted_tags_instructions: "Les utilisateurs ne seront pas notifiés des nouveaux sujets avec ces étiquettes et ces sujets n'apparaîtront pas sur la page des sujets récents." logs: title: "Journaux" @@ -710,31 +697,31 @@ fr: public_admission: "Autoriser les utilisateurs à rejoindre le groupe librement (nécessite que le groupe soit visible)" public_exit: "Autoriser les utilisateurs à quitter librement le groupe" empty: - posts: "Il n'y a aucun message de membres de ce groupe." + posts: "Aucun message n'a été envoyé par les membres de ce groupe." members: "Il n' y a aucun membre dans ce groupe." requests: "Il n'y a aucune demande pour rejoindre ce groupe" mentions: "Il n'y a aucune mention de ce groupe." messages: "Il n'y a aucun message pour ce groupe." - topics: "Il n'y a aucun sujet par des membres de ce groupe." + topics: "Aucun sujet n'a été créé par les membres de ce groupe." logs: "Il n'y a aucun journal pour ce groupe." add: "Ajouter" join: "Rejoindre" leave: "Quitter" request: "Demander à rejoindre" message: "Message" - confirm_leave: "Êtes-vous sûr de vouloir quitter ce groupe ?" - allow_membership_requests: "Autoriser les utilisateurs à envoyer des demandes d'adhésion aux propriétaires de groupe (nécessite que le groupe soit visible publiquement)" - membership_request_template: "Modèle personnalisé à afficher aux utilisateurs lors de l'envoi d'une demande d'adhésion" + confirm_leave: "Voulez-vous vraiment quitter ce groupe ?" + allow_membership_requests: "Autoriser les utilisateurs à envoyer des demandes d'adhésion aux propriétaires du groupe (nécessite que le groupe soit visible publiquement)" + membership_request_template: "Modèle personnalisé à afficher lors de l'envoi d'une demande d'adhésion par un utilisateur" membership_request: submit: "Soumettre la demande" title: "Demander à rejoindre @%{group_name}" - reason: "Expliquez au propriétaire du groupe pourquoi vous avez votre place dans ce groupe" + reason: "Expliquez aux propriétaires du groupe pourquoi vous pensez appartenir à ce groupe" membership: "Adhésion" name: "Nom" group_name: "Nom du groupe" user_count: "Utilisateurs" bio: "À propos du groupe" - selector_placeholder: "entrez un nom d'utilisateur" + selector_placeholder: "saisissez un nom d'utilisateur" owner: "propriétaire" index: title: "Groupes" @@ -742,13 +729,13 @@ fr: empty: "Il n'y a aucun groupe visible." filter: "Filtrer par type de groupe" owner_groups: "Mes groupes (propriétaire)" - close_groups: "Fermés" - automatic_groups: "Automatiques" + close_groups: "Groupes fermés" + automatic_groups: "Groupes automatiques" automatic: "Automatique" closed: "Fermé" public: "Public" private: "Privé" - public_groups: "Publics" + public_groups: "Groupes publics" automatic_group: Groupe automatique close_group: Fermer le groupe my_groups: "Mes groupes (membre)" @@ -763,29 +750,29 @@ fr: title: "Membres" filter_placeholder_admin: "nom d'utilisateur ou courriel" filter_placeholder: "nom d'utilisateur" - remove_member: "Enlever le membre" - remove_member_description: "Enlever %{username} de ce groupe" - make_owner: "Rendre propriétaire" - make_owner_description: "Rendre %{username} propriétaire de ce groupe" - remove_owner: "Enlever comme propriétaire" - remove_owner_description: "Enlever %{username} comme propriétaire de ce groupe" - make_primary: "Rendre Principal" - make_primary_description: "Faire de ce groupe le principal pour %{username}" - remove_primary: "Retirer en tant que principal" - remove_primary_description: "Retirer ce groupe comme étant principal pour %{username}" - remove_members: "Enlever les membres" - remove_members_description: "Enlever les utilisateurs sélectionnés de ce groupe" - make_owners: "Rendre propriétaires" - make_owners_description: "Rendre les utilisateurs sélectionnés propriétaires de ce groupe" - remove_owners: "Enlever comme propriétaires" - remove_owners_description: "Retirer aux utilisateurs sélectionnés l'attribut propriétaire de ce groupe" - make_all_primary: "Rendre Principal pour tous" - make_all_primary_description: "En faire le groupe principal pour tous les utilisateurs sélectionnés" - remove_all_primary: "Retirer en tant que principal" - remove_all_primary_description: "Supprimer ce groupe en tant que principal" + remove_member: "Supprimer le membre" + remove_member_description: "Supprimer %{username} de ce groupe" + make_owner: "Attribuer le statut de propriétaire" + make_owner_description: "Attribuer à %{username} le statut de propriétaire de ce groupe" + remove_owner: "Supprimer le statut de propriétaire" + remove_owner_description: "Supprimer %{username} des propriétaires de ce groupe" + make_primary: "Attribuer le statut de groupe principal" + make_primary_description: "Faire de ce groupe le groupe principal de %{username}" + remove_primary: "Supprimer le statut de groupe principal" + remove_primary_description: "Supprimer ce groupe des groupes principaux de %{username}" + remove_members: "Supprimer les membres" + remove_members_description: "Supprimer les utilisateurs sélectionnés de ce groupe" + make_owners: "Attribuer le statut de propriétaire" + make_owners_description: "Attribuer le statut de propriétaire de ce groupe aux utilisateurs sélectionnés" + remove_owners: "Supprimer le statut de propriétaire" + remove_owners_description: "Supprimer les utilisateurs sélectionnés des propriétaires de ce groupe" + make_all_primary: "Attribuer le statut de groupe principal" + make_all_primary_description: "Attribuer le statut de groupe principal pour tous les utilisateurs sélectionnés" + remove_all_primary: "Supprimer le statut de groupe principal" + remove_all_primary_description: "Supprimer le statut de groupe principal de ce groupe" owner: "Propriétaire" primary: "Principal" - forbidden: "Vous n'êtes pas autorisé à voir les membres." + forbidden: "Vous n'avez pas l'autorisation de voir les membres." topics: "Sujets" posts: "Messages" mentions: "Mentions" @@ -803,21 +790,21 @@ fr: notifications: watching: title: "Surveiller" - description: "Vous serez notifié de chaque nouvelle réponse dans chaque message, et le nombre de nouvelles réponses sera affiché." + description: "Vous recevrez une notification pour chaque nouvelle réponse, et le nombre de nouvelles réponses sera affiché." watching_first_post: title: "Surveiller les nouveaux sujets" - description: "Vous serez averti des nouveaux messages dans ce groupe mais pas des réponses aux messages." + description: "Vous recevrez une notification pour chaque nouveau message publié dans ce groupe, mais pas pour les réponses aux messages." tracking: title: "Suivre" - description: "Vous serez notifié si quelqu'un vous mentionne ou vous répond, et le nombre de nouvelles réponses sera affiché." + description: "Vous recevrez une notification lorsque quelqu'un vous mentionnera ou vous répondra, et le nombre de nouvelles réponses sera affiché." regular: title: "Normal" - description: "Vous serez notifié si quelqu'un vous mentionne ou vous répond." + description: "Vous recevrez une notification lorsque quelqu'un vous mentionnera ou vous répondra." muted: - title: "Silencieux" + title: "Désactivée" description: "Vous ne recevrez aucune notification concernant les messages de ce groupe." flair_url: "Image de la vignette d'avatar" - flair_upload_description: "Utilisez des images carrées d'une taille minimum de 20px par 20px." + flair_upload_description: "Utilisez des images carrées d'une taille minimale de 20px par 20px." flair_bg_color: "Couleur de l'arrière-plan de la vignette d'avatar" flair_bg_color_placeholder: "(Facultatif) Couleur en valeur hexadécimale" flair_color: "Couleur de la vignette d'avatar" @@ -828,8 +815,8 @@ fr: icon: "Sélectionner une icône" image: "Insérer une image" user_action_groups: - "1": "J'aime donnés" - "2": "J'aime reçus" + "1": "« J'aime » donnés" + "2": "« J'aime » reçus" "3": "Signets" "4": "Sujets" "5": "Réponses" @@ -842,7 +829,7 @@ fr: "14": "En attente" "15": "Brouillons" categories: - all: "catégories" + all: "toutes les catégories" all_subcategories: "toutes" no_subcategory: "aucune" category: "Catégorie" @@ -858,13 +845,13 @@ fr: latest: "Récents" toggle_ordering: "modifier le mode du tri" subcategories: "Sous-catégories" - muted: "Catégories mises sous silence" + muted: "Catégories mises sous en sourdine" topic_sentence: one: "%{count} sujet" other: "%{count} sujets" topic_stat: - one: "%{number} / %{unit}" - other: "%{number} / %{unit}" + one: "%{number}/%{unit}" + other: "%{number}/%{unit}" topic_stat_unit: week: "semaine" month: "mois" @@ -872,18 +859,18 @@ fr: one: "%{number} au total" other: "%{number} au total" topic_stat_sentence_week: - one: "%{count} nouveau sujet dans la dernière semaine." - other: "%{count} nouveaux sujets dans la dernière semaine." + one: "%{count} nouveau sujet au cours de la dernière semaine." + other: "%{count} nouveaux sujets au cours de la dernière semaine." topic_stat_sentence_month: - one: "%{count} nouveau sujet dans le dernier mois." - other: "%{count} nouveaux sujets dans le dernier mois." + one: "%{count} nouveau sujet au cours du dernier mois." + other: "%{count} nouveaux sujets au cours du dernier mois." n_more: "Catégories (%{count} autres)…" ip_lookup: title: Localisation de l'adresse IP hostname: Nom de l'hôte location: Localisation location_not_found: (inconnu) - organisation: Société + organisation: Organisation phone: Téléphone other_accounts: "Autres comptes avec cette adresse IP :" delete_other_accounts: "Supprimer %{count}" @@ -892,21 +879,21 @@ fr: read_time: "temps de lecture" topics_entered: "sujets visités" post_count: "# messages" - confirm_delete_other_accounts: "Êtes-vous sûr de vouloir supprimer ces comptes ?" + confirm_delete_other_accounts: "Voulez-vous vraiment supprimer ces comptes ?" powered_by: "Informations fournies par MaxMindDB" copied: "copié" user_fields: none: "(choisir une option)" - required: 'Veuillez entrer une valeur pour « %{name} »' + required: 'Veuillez saisir une valeur pour « %{name} »' user: said: "%{username} :" profile: "Profil" - mute: "Silencieux" + mute: "Désactiver" edit: "Modifier les préférences" download_archive: button_text: "Tout télécharger" - confirm: "Êtes-vous sûr de vouloir télécharger vos messages ?" - success: "Le téléchargement a démarré ; vous serez notifié par message lorsqu'il sera terminé." + confirm: "Voulez-vous vraiment télécharger vos messages ?" + success: "Le téléchargement a démarré ; vous recevrez une notification lorsqu'il sera terminé." rate_limit_error: "Les messages peuvent être téléchargés une fois par jour, veuillez ressayer demain." new_private_message: "Créer un message direct" private_message: "Message direct" @@ -927,14 +914,14 @@ fr: ignore_option: "Ignoré" ignore_option_title: "Vous ne recevrez pas de notifications en lien avec cet utilisateur et ses messages et réponses seront masqués." add_ignored_user: "Ajouter…" - mute_option: "Silencieux" + mute_option: "En sourdine" mute_option_title: "Vous ne recevrez pas de notifications en lien avec cet utilisateur." normal_option: "Normal" - normal_option_title: "Vous serez notifié si cet utilisateur vous répond, vous cite ou vous mentionne." + normal_option_title: "Vous recevrez une notification si cet utilisateur vous répond, vous cite ou vous mentionne." notification_schedule: - title: "Planning des notifications" + title: "Planification des notifications" label: "Activer la planification personnalisée des notifications" - tip: "Vous basculerez automatiquement en mode \"Ne pas déranger\" en-dehors de ces horaires." + tip: "Vous basculerez automatiquement en mode « Ne pas déranger » en-dehors de ces horaires." midnight: "Minuit" none: "Jamais" monday: "Lundi" @@ -956,7 +943,7 @@ fr: save: "Enregistrer" clear: title: "Vider" - warning: "Êtes-vous sûr de vouloir masquer votre sujet vedette ?" + warning: "Voulez-vous vraiment effacer votre sujet vedette ?" use_current_timezone: "Utiliser le fuseau horaire actuel" profile_hidden: "Le profil public de cet utilisateur est caché." expand_profile: "Développer" @@ -964,33 +951,33 @@ fr: bookmarks: "Signets" bio: "À propos de moi" timezone: "Fuseau horaire" - invited_by: "Invité par" + invited_by: "Invité(e) par" trust_level: "Niveau de confiance" notifications: "Notifications" statistics: "Statistiques" desktop_notifications: label: "Notifications instantanées" - not_supported: "Les notifications ne sont pas supportées sur ce navigateur. Désolé." + not_supported: "Nous sommes désolés, les notifications ne sont pas supportées sur ce navigateur." perm_default: "Activer les notifications" perm_denied_btn: "Permission refusée" - perm_denied_expl: "Vous n'avez pas autorisé les notifications. Autorisez-les depuis les paramètres de votre navigateur." + perm_denied_expl: "Vous n'avez pas autorisé les notifications. Autorisez-les à partir des paramètres de votre navigateur." disable: "Désactiver les notifications" enable: "Activer les notifications" - each_browser_note: 'Remarque : Vous devez modifier ce paramètre sur chaque navigateur que vous utilisez. Toutes les notifications seront désactivées lorsque vous êtes en mode « Ne pas déranger », quel que soit ce paramètre.' - consent_prompt: "Souhaitez-vous recevoir des notifications en temps réel quand on répond à vos messages ?" + each_browser_note: 'Remarque : vous devez modifier ce paramètre sur chaque navigateur que vous utiliserez. Toutes les notifications seront désactivées lorsque vous serez en mode « Ne pas déranger », quel que soit la valeur de ce paramètre.' + consent_prompt: "Souhaitez-vous recevoir des notifications en temps réel en cas de réponse à vos messages ?" dismiss: "Vu" dismiss_notifications: "Tout vu" dismiss_notifications_tooltip: "Marquer les notifications comme lues" no_messages_title: "Vous n'avez aucun message" no_messages_body: > - Besoin d'échanger directement avec une personne, en-dehors de la conversation principale ? Sélectionnez leur avatar et utilisez le bouton %{icon} pour leur écrire un message.

    Pour obtenir de l'aide, écrivez à un des responsables du site. + Besoin d'échanger directement avec une personne en-dehors de la conversation principale ? Sélectionnez son avatar et utilisez le bouton %{icon} pour lui écrire un message.

    Pour obtenir de l'aide, adressez-vous à l'un des responsables du site. no_bookmarks_title: "Vous n'avez pas encore créé de signet" no_bookmarks_body: > - Le bouton %{icon} vous permet de marquer d'un signet les messages qui vous intéressent particulièrement — ils s'afficheront alors ici, et vous les aurez sous la main. Pour vous organiser, vous pourrez même planifier une alerte associée à un signet ! + Le bouton %{icon} vous permet de marquer d'un signet les messages qui vous intéressent particulièrement. Ils seront ensuite affichés ici pour faciliter leur consultation. Vous pouvez également planifier un rappel associé à un signet ! no_notifications_title: "Vous n'avez pas encore reçu de notifications" no_notifications_body: > - Des notifications s'afficheront ici pour vous informer de l'activité qui vous concerne directement sur le forum, telle que : des réponses qui vous sont faites, des personnes qui citent vos messages ou qui mentionnent votre @pseudo, et des nouveaux messages publiés dans des sujets que vous suivez. Si vous ne vous êtes pas connecté au forum depuis un moment, vous recevrez aussi ces notifications par courriel.

    Un peu partout sur le forum, le bouton %{icon} vous permettra de choisir les sujets, les catégories et les étiquettes pour lesquels vous recevez des notifications. Vous trouverez aussi des réglages dans la section Notifications de votre page de préférences. - first_notification: "Votre première notification ! Cliquez-la pour démarrer." + Des notifications s'afficheront ici pour vous informer de l'activité qui vous concerne directement sur le forum, telle que les réponses qui vous sont adressées, les personnes qui citent vos messages ou qui mentionnent votre pseudo, et les nouveaux messages publiés dans les sujets que vous suivez. Si vous ne vous êtes pas connecté(e) au forum depuis un moment, vous recevrez aussi ces notifications par courriel.

    Le bouton %{icon} vous permettra de choisir les sujets, les catégories et les étiquettes pour lesquels vous souhaitez recevoir des notifications. Pour en savoir plus, consultez également vos préférences de notification. + first_notification: "Votre première notification ! Sélectionnez-la pour commencer." dynamic_favicon: "Afficher un compteur de notifications sur l'icône du navigateur" skip_new_user_tips: description: "Ignorer les badges et les conseils d'intégration des nouveaux utilisateurs." @@ -998,7 +985,7 @@ fr: skip_link: "Ignorer ces conseils" read_later: "J'y jetterai un œil plus tard." theme_default_on_all_devices: "En faire mon thème par défaut sur tous mes périphériques" - color_scheme_default_on_all_devices: "Définir comme jeux de couleurs par défaut sur tous mes appareils" + color_scheme_default_on_all_devices: "Définir comme jeu(x) de couleurs par défaut sur tous mes appareils" color_scheme: "Jeu de couleurs" color_schemes: default_description: "Couleur du thème" @@ -1017,11 +1004,11 @@ fr: enable_defer: "Activer le bouton pour reporter des sujets à plus tard en les marquant comme non lus" change: "modifier" featured_topic: "Sujet vedette" - moderator: "%{user} est un modérateur" - admin: "%{user} est un administrateur" - moderator_tooltip: "Cet utilisateur est un modérateur" - admin_tooltip: "Cet utilisateur est un administrateur" - silenced_tooltip: "Cet utilisateur est mis sous silence" + moderator: "%{user} a le rôle de modérateur(rice)" + admin: "%{user} a le rôle d'administrateur(rice)" + moderator_tooltip: "Cet utilisateur a le rôle de modérateur(rice)" + admin_tooltip: "Cet utilisateur a le rôle d'administrateur(rice)" + silenced_tooltip: "Cet utilisateur est mis en sourdine" suspended_notice: "L'utilisateur est suspendu jusqu'au %{date}." suspended_permanently: "Cet utilisateur est suspendu." suspended_reason: "Raison : " @@ -1031,8 +1018,8 @@ fr: label: "Liste de diffusion" enabled: "Activer la liste de diffusion" instructions: | - Ce paramètre remplace le résumé d'activités.
    - Les sujets et catégories passés en silencieux ne sont pas inclus dans ces courriels. + Ce paramètre remplace le résumé d'activité.
    + Les sujets et catégories mis en sourdine ne sont pas inclus dans ces courriels. individual: "Envoyer un courriel pour chaque nouveau message" individual_no_echo: "Envoyer un courriel pour chaque nouveau message sauf les miens" many_per_day: "M'envoyer un courriel pour chaque nouveau message (environ %{dailyEmailEstimate} par jour)" @@ -1040,34 +1027,34 @@ fr: warning: "Mode liste de diffusion activé. Les paramètres de notification par courriel sont remplacés." tag_settings: "Étiquettes" watched_tags: "Surveillées" - watched_tags_instructions: "Vous surveillerez automatiquement tous les sujets avec ces étiquettes. Vous serez notifié de tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté du sujet." + watched_tags_instructions: "Vous surveillerez automatiquement tous les sujets marqués par ces étiquettes. Vous recevrez des notifications pour tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté du sujet." tracked_tags: "Suivies" - tracked_tags_instructions: "Vous suivrez automatiquement tous les sujets avec ces étiquettes. Le nombre de nouveaux messages apparaîtra à côté du sujet." - muted_tags: "Silencieuses" - muted_tags_instructions: "Vous ne recevrez aucune notification concernant les nouveaux sujets avec ces étiquettes et ces sujets n'apparaîtront pas sur la page des sujets récents." + tracked_tags_instructions: "Vous suivrez automatiquement tous les sujets marqués par ces étiquettes. Le nombre de nouveaux messages apparaîtra à côté du sujet." + muted_tags: "En sourdine" + muted_tags_instructions: "Vous ne recevrez aucune notification concernant les nouveaux sujets marqués par ces étiquettes et ces sujets n'apparaîtront pas sur la page des sujets récents." watched_categories: "Surveillées" - watched_categories_instructions: "Vous surveillerez automatiquement tous les sujets de ces catégories. Vous serez notifié de tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté du sujet." + watched_categories_instructions: "Vous surveillerez automatiquement tous les sujets de ces catégories. Vous recevrez des notifications pour tous les nouveaux messages et sujets, et le nombre de nouveaux messages apparaîtra à coté du sujet." tracked_categories: "Suivies" - tracked_categories_instructions: "Vous suivrez automatiquement tous les sujets dans ces catégories. Le nombre de nouveaux messages apparaîtra à côté du sujet." + tracked_categories_instructions: "Vous suivrez automatiquement tous les sujets repris dans ces catégories. Le nombre de nouveaux messages apparaîtra à côté du sujet." watched_first_post_categories: "Surveiller les nouveaux sujets" - watched_first_post_categories_instructions: "Vous serez notifié du premier message de chaque sujet dans ces catégories." + watched_first_post_categories_instructions: "Vous recevrez une notification concernant le premier message de chaque sujet repris dans ces catégories." watched_first_post_tags: "Surveiller les nouveaux sujets" - watched_first_post_tags_instructions: "Vous serez notifié du premier message de chaque sujet avec ces étiquettes." - muted_categories: "Silencieuses" - muted_categories_instructions: "Vous ne recevrez aucune notification concernant les nouveaux sujets dans ces catégories et ces sujets n'apparaîtront pas sur les pages des catégories et des sujets récents." - muted_categories_instructions_dont_hide: "Vous ne recevrez aucune notification concernant les nouveaux sujets dans ces catégories." + watched_first_post_tags_instructions: "Vous recevrez une notification concernant le premier message de chaque sujet marqué par ces étiquettes." + muted_categories: "En sourdine" + muted_categories_instructions: "Vous ne recevrez aucune notification concernant les nouveaux sujets repris dans ces catégories et ces sujets n'apparaîtront pas sur les pages des catégories et des sujets récents." + muted_categories_instructions_dont_hide: "Vous ne recevrez aucune notification concernant les nouveaux sujets repris dans ces catégories." regular_categories: "Normal" - regular_categories_instructions: "Vous verrez ces catégories dans les listes de sujets « Récents » et « Top »." - no_category_access: "Du fait de votre statut de modérateur, votre accès aux catégories est limité. L'enregistrement est désactivé." + regular_categories_instructions: "Vous verrez ces catégories dans les listes « Sujets récents » et « Meilleurs sujets »." + no_category_access: "Du fait de votre statut de modérateur(rice), votre accès aux catégories est limité. L'enregistrement est désactivé." delete_account: "Supprimer mon compte" - delete_account_confirm: "Êtes-vous sûr de vouloir supprimer définitivement votre compte ? Cette action n'est pas réversible !" + delete_account_confirm: "Voulez-vous vraiment supprimer définitivement votre compte ? Cette action est irréversible !" deleted_yourself: "Votre compte a été supprimé avec succès." delete_yourself_not_allowed: "Veuillez contacter un responsable si vous souhaitez supprimer votre compte." unread_message_count: "Messages" admin_delete: "Supprimer" users: "Utilisateurs" - muted_users: "Silencieux" - muted_users_instructions: "Ignorer toutes les notifications et messages directs provenant de ces utilisateurs." + muted_users: "En sourdine" + muted_users_instructions: "Ignorer toutes les notifications et tous les messages directs provenant de ces utilisateurs." allowed_pm_users: "Autorisés" allowed_pm_users_instructions: "Autoriser uniquement les messages directs de ces utilisateurs." allow_private_messages_from_specific_users: "Autoriser uniquement des utilisateurs spécifiques à m'envoyer des messages directs" @@ -1100,7 +1087,7 @@ fr: bulk_select: "Sélectionner des messages" move_to_inbox: "Déplacer dans la boîte de réception" move_to_archive: "Archiver" - failed_to_move: "Impossible de déplacer les messages sélectionnés (peut-être que votre connexion est coupée)" + failed_to_move: "Impossible de déplacer les messages sélectionnés (votre connexion est peut-être en panne)" select_all: "Tout sélectionner" tags: "Étiquettes" preferences_nav: @@ -1124,7 +1111,7 @@ fr: choose_new: "Choisissez un nouveau mot de passe" choose: "Choisissez un mot de passe" second_factor_backup: - title: "Codes de secours de la validation en deux étapes" + title: "Codes de secours de l'authentification à deux facteurs" regenerate: "Régénérer" disable: "Désactiver" enable: "Activer" @@ -1140,43 +1127,43 @@ fr: one: "Il vous reste %{count} code de secours." other: "Il vous reste %{count} codes de secours." use: "Utiliser un code de secours" - enable_prerequisites: "Vous devez activer une validation principale en deux étapes avant de générer des codes de secours." + enable_prerequisites: "Vous devez activer une méthode d'authentification principale à deux facteurs avant de générer des codes de secours." codes: title: "Codes de secours générés" - description: "Chaque code de secours ne peut être utilisé qu'une seule fois. Garder les dans un endroit sûr mais accessible." + description: "Chaque code de secours ne peut être utilisé qu'une seule fois. Conservez-les dans un endroit sûr et accessible." second_factor: - title: "Validation en deux étapes" - enable: "Gérer la validation en deux étapes" + title: "Authentification à deux facteurs" + enable: "Gérer l'authentification à deux facteurs" disable_all: "Tout désactiver" forgot_password: "Mot de passe oublié ?" confirm_password_description: "Merci de confirmer votre mot de passe pour continuer" name: "Nom" label: "Code" - rate_limit: "Veuillez patienter avant d'essayer un autre code d'identification." + rate_limit: "Veuillez patienter avant d'essayer un autre code d'authentification." enable_description: | - Scannez ce code QR en utilisant une application compatible (Android– iOS) et entrez votre code d'authentification. + Scannez ce code QR en utilisant une application compatible (Android– iOS) et saisissez votre code d'authentification. disable_description: "Veuillez saisir le code d'authentification généré par l'application" show_key_description: "Saisir manuellement" short_description: | Protéger votre compte avec des codes de sécurité à usage unique. extended_description: | - La validation en deux étapes ajoute une sécurité supplémentaire à votre compte en exigeant un jeton unique en plus de votre mot de passe. Les jetons peuvent être générés sur les appareils Android et iOS. - oauth_enabled_warning: "Veuillez noter que les connexions aux réseaux sociaux seront désactivées à l'activation de la validation en deux étapes sur votre compte." + L'authentification à deux facteurs ajoute une sécurité supplémentaire à votre compte en exigeant un jeton unique en plus de votre mot de passe. Les jetons peuvent être générés sur les appareils Android et iOS. + oauth_enabled_warning: "Veuillez noter que les connexions aux réseaux sociaux seront désactivées une fois l'authentification à deux facteurs activée sur votre compte." use: "Utiliser l'application Authenticator" - enforced_notice: "Vous devez activer la validation en deux étapes avant d'accéder à ce site." + enforced_notice: "Vous devez activer l'authentification à deux facteurs avant d'accéder à ce site." disable: "Désactiver" - disable_confirm: "Êtes-vous sûr de vouloir désactiver toutes les validations en deux étapes ?" + disable_confirm: "Voulez-vous vraiment désactiver toutes les méthodes d'authentification à deux facteurs ?" save: "Enregistrer" edit: "Modifier" edit_title: "Gérer l'application d'authentification" edit_description: "Nom de l'application d'authentification" enable_security_key_description: | - Dès que votre clé de sécurité physique est prête, appuyer sur le bouton Enregistrer ci-dessous. + Dès que votre clé de sécurité physique est prête, appuyez sur le bouton « Enregistrer » ci-dessous. totp: - title: "Authentificateurs à base de jetons" + title: "Applications d'authentification basées sur les jetons" add: "Ajouter une application d'authentification" default_name: "Mon application d'authentification" - name_and_code_required_error: "Vous devez donner un nom et le code de votre application d'authentification." + name_and_code_required_error: "Vous devez donner le nom et le code de votre application d'authentification." security_key: register: "Enregistrer" title: "Clés de sécurité" @@ -1189,44 +1176,44 @@ fr: edit_description: "Nom de la clé de sécurité" name_required_error: "Vous devez donner un nom à votre clé de sécurité." change_about: - title: "Modifier À propos de moi" - error: "Il y a eu une erreur lors de la modification de cette valeur." + title: "Modifier la rubrique « À propos de moi »" + error: "Une erreur s'est produite lors de la modification de cette valeur." change_username: title: "Modifier le nom d'utilisateur" - confirm: "Êtes-vous sûr de vouloir modifier votre nom d'utilisateur ?" - taken: "Désolé, ce nom d'utilisateur est déjà pris." + confirm: "Voulez-vous vraiment modifier votre nom d'utilisateur ?" + taken: "Nous sommes désolés, ce nom d'utilisateur est déjà utilisé." invalid: "Ce nom d'utilisateur est invalide. Il ne doit être composé que de lettres et de chiffres." add_email: title: "Ajouter une adresse courriel" add: "ajouter" change_email: title: "Modifier l'adresse courriel" - taken: "Désolé, cette adresse courriel est indisponible." - error: "Il y a eu une erreur lors du changement de l'adresse courriel. Cette adresse est peut-être déjà utilisée ?" + taken: "Nous sommes désolés, cette adresse courriel est indisponible." + error: "Une erreur est survenue lors de la modification de l'adresse courriel. Cette adresse est peut-être déjà utilisée ?" success: "Nous avons envoyé un courriel à cette adresse. Merci de suivre les instructions." - success_via_admin: "Nous avons envoyé un courriel à cette adresse. L'utilisateur devra suivre les instructions de confirmation indiquées dans le courriel." - success_staff: "Nous avons envoyé un courriel à votre adresse actuelle. Merci de suivre les instructions." + success_via_admin: "Nous avons envoyé un courriel à cette adresse. L'utilisateur devra suivre les instructions de confirmation qui y sont indiquées." + success_staff: "Nous avons envoyé un courriel à votre adresse actuelle. Merci de suivre les instructions qui y figurent." change_avatar: title: "Modifier votre image de profil" gravatar: "%{gravatarName}, associé à" gravatar_title: "Modifier votre avatar sur le site de %{gravatarName}" - gravatar_failed: "Nous n'avons pas trouvé un %{gravatarName} associé à cette adresse courriel." + gravatar_failed: "Nous n'avons pas trouvé de %{gravatarName} associé à cette adresse courriel." refresh_gravatar_title: "Actualiser votre %{gravatarName}" letter_based: "Image de profil attribuée par le système" uploaded_avatar: "Avatar personnalisé" uploaded_avatar_empty: "Ajouter un avatar personnalisé" - upload_title: "Envoyer votre avatar" - image_is_not_a_square: "Attention : nous avons découpé votre image ; la largeur et la hauteur n'étaient pas égales." + upload_title: "Envoyer votre photo" + image_is_not_a_square: "Attention : nous avons découpé votre image. La largeur et la hauteur n'étaient pas égales." logo_small: "Logo du site en petit format. Sera utilisé par défaut." change_profile_background: title: "Arrière-plan du profil" instructions: "Les arrière-plans du profil seront centrés avec une largeur par défaut de 1110 pixels." change_card_background: title: "Arrière-plan de la carte de l'utilisateur" - instructions: "Les arrière-plans de la carte utilisateur seront centrés avec une largeur par défaut de 590 pixels." + instructions: "Les arrière-plans de la carte de l'utilisateur seront centrés avec une largeur par défaut de 590 pixels." change_featured_topic: title: "Sujet vedette" - instructions: "Un lien vers ce sujet sera ajouté sur votre carte d'utilisateur et votre profil." + instructions: "Un lien vers ce sujet sera ajouté à votre carte d'utilisateur et votre profil." email: title: "Courriel" primary: "Adresse courriel principale" @@ -1243,17 +1230,17 @@ fr: auth_override_instructions: "Le courriel peut être mis à jour à partir du fournisseur d'authentification." no_secondary: "Aucune adresse courriel secondaire" instructions: "Ne sera pas visible publiquement." - admin_note: "Remarque : un utilisateur administrateur modifiant l'adresse courriel d'un autre utilisateur non administrateur indique que l'utilisateur a perdu l'accès à son compte de messagerie d'origine, donc un courriel de réinitialisation du mot de passe sera envoyé à sa nouvelle adresse. L'adresse courriel de l'utilisateur ne changera pas tant qu'il n'aura pas terminé le processus de réinitialisation du mot de passe." + admin_note: "Remarque : un utilisateur administrateur modifiant l'adresse courriel d'un autre utilisateur non administrateur indique que l'utilisateur a perdu l'accès à son compte de messagerie d'origine. Un courriel de réinitialisation du mot de passe sera donc envoyé à sa nouvelle adresse. L'adresse courriel de l'utilisateur ne changera pas tant qu'il n'aura pas terminé le processus de réinitialisation du mot de passe." ok: "Nous vous enverrons un courriel de confirmation" - required: "Veuillez entrer une adresse courriel" - invalid: "Veuillez entrer une adresse courriel valide" + required: "Veuillez saisir une adresse courriel" + invalid: "Veuillez saisir une adresse courriel valide" authenticated: "Votre adresse courriel a été authentifiée par %{provider}" invite_auth_email_invalid: "Votre e-mail d'invitation ne correspond pas à l'e-mail authentifié par %{provider}" authenticated_by_invite: "Votre adresse de courriel a été authentifiée par l'invitation" frequency_immediately: "Nous vous enverrons un courriel immédiatement si vous n'avez pas lu le contenu en question." frequency: - one: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu sur le site dans la dernière minute." - other: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu sur le site dans les %{count} dernières minutes." + one: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu(e) sur le site au cours de la dernière minute." + other: "Nous vous enverrons des courriels seulement si nous ne vous avons pas vu(e) sur le site au cours des %{count} dernières minutes." associated_accounts: title: "Comptes associés" connect: "Connecter" @@ -1268,12 +1255,12 @@ fr: title: "Nom" instructions: "votre nom complet (facultatif)" instructions_required: "Votre nom complet" - required: "Veuillez entrer un nom" + required: "Veuillez saisir un nom" too_short: "Votre nom est trop court" ok: "Votre nom a l'air correct" username: title: "Nom d'utilisateur" - instructions: "unique, sans espaces, court" + instructions: "unique, sans espace, court" short_instructions: "Les utilisateurs peuvent vous mentionner avec @%{username}" available: "Votre nom d'utilisateur est disponible" not_available: "Indisponible. Essayez %{suggestion} ?" @@ -1315,9 +1302,9 @@ fr: created: "Inscrit" log_out: "Se déconnecter" location: "Localisation" - website: "Site internet" + website: "Site Web" email_settings: "Courriel" - hide_profile_and_presence: "Cacher mon profil public et statistiques" + hide_profile_and_presence: "Cacher mon profil public et mes statistiques" enable_physical_keyboard: "Activer le support du clavier physique sur iPad" text_size: title: "Taille du texte" @@ -1331,10 +1318,10 @@ fr: notifications: "Nouvelles notifications" contextual: "Nouveau contenu de la page" like_notification_frequency: - title: "Notifier lors d'un J'aime" + title: "Envoyer une notification si un « J'aime » est attribué" always: "Toujours" - first_time_and_daily: "La première fois qu'un message est aimé puis quotidiennement" - first_time: "La première fois qu'un message est aimé" + first_time_and_daily: "La première fois qu'un message reçoit un « J'aime » puis quotidiennement" + first_time: "La première fois qu'un message reçoit un « J'aime »" never: "Jamais" email_previous_replies: title: "Inclure les réponses précédentes en bas des courriels" @@ -1352,7 +1339,7 @@ fr: email_level: title: "M'envoyer un courriel quand quelqu'un me cite, répond à l'un de mes messages, me mentionne ou m'invite à rejoindre un sujet." always: "toujours" - only_when_away: "seulement si absent" + only_when_away: "seulement en cas d'absence" never: "jamais" email_messages_level: "M'envoyer un courriel quand quelqu'un m'envoie un message direct." include_tl0_in_digests: "Inclure les contributions des nouveaux utilisateurs dans les résumés par courriel" @@ -1364,9 +1351,9 @@ fr: not_viewed: "je ne les ai pas encore vus" last_here: "créés depuis ma dernière visite" after_1_day: "créés depuis hier" - after_2_days: "créés durant les 2 derniers jours" - after_1_week: "créés durant les 7 derniers jours" - after_2_weeks: "créés durant les 2 dernières semaines" + after_2_days: "créés au cours des 2 derniers jours" + after_1_week: "créés au cours des 7 derniers jours" + after_2_weeks: "créés au cours des 2 dernières semaines" auto_track_topics: "Suivre automatiquement les sujets que je consulte" auto_track_options: never: "jamais" @@ -1388,10 +1375,10 @@ fr: redeemed_tab: "Acceptées" redeemed_tab_with_count: "Invitations acceptées (%{count})" invited_via: "Invitation" - invited_via_link: "lien %{key} (%{count} / %{max} utilisés)" + invited_via_link: "lien %{key} (%{count}/%{max} utilisés)" groups: "Groupes" topic: "Sujet" - sent: "Date de création/d'envoi précédent" + sent: "Date de création/de dernier envoi" expires_at: "Expire le" edit: "Modifier" remove: "Supprimer" @@ -1399,8 +1386,8 @@ fr: reinvite: "Renvoyer un courriel" reinvited: "Invitation renvoyée" removed: "Supprimé" - search: "commencer à saisir pour rechercher vos invitations…" - user: "Utilisateurs" + search: "commencez votre saisie pour rechercher vos invitations…" + user: "Utilisateur(rice) invité(e)" none: "Aucune invitation à afficher." truncated: one: "Afficher la première invitation." @@ -1413,9 +1400,9 @@ fr: expired: "Cette invitation a expiré." remove_all: "Supprimer les invitations arrivées à expiration" removed_all: "Toutes les invitations arrivées à expiration ont été supprimées !" - remove_all_confirm: "Êtes-vous sûr de vouloir supprimer toutes les invitations arrivées à expiration ?" + remove_all_confirm: "Voulez-vous vraiment supprimer toutes les invitations arrivées à expiration ?" reinvite_all: "Renvoyer toutes les invitations" - reinvite_all_confirm: "Êtes-vous sûr de renvoyer toutes les invitations ?" + reinvite_all_confirm: "Voulez-vous vraiment renvoyer toutes les invitations ?" reinvited_all: "Toutes les invitations ont été envoyées !" time_read: "Temps de lecture" days_visited: "Ratio de présence" @@ -1442,7 +1429,7 @@ fr: show_advanced: "Afficher les options avancées" hide_advanced: "Masquer les options avancées" restrict_email: "Restreindre l'invitation à une adresse de courriel donnée" - max_redemptions_allowed: "Nb. max. d'utilisations" + max_redemptions_allowed: "Nombre max. d'utilisations" add_to_groups: "Ajouter la personne invitée à ces groupes" invite_to_topic: "Diriger vers ce sujet" expires_at: "Ce lien expirera dans" @@ -1457,26 +1444,26 @@ fr: instructions: |

    Pour mettre votre communauté sur pied rapidement, invitez une liste d'utilisateurs : composez un fichier CSV contenant l'adresse de chaque personne à inviter, en disposant une adresse par ligne. Pour ajouter certaines personnes à des groupes particuliers, ou pour les diriger automatiquement vers un sujet particulier lors de leur première connexion, vous pouvez faire figurer les éléments suivants.

    jean@dupont.fr,nom_dun_groupe;nom_dun_autre_groupe,identifiant_numérique_du_sujet
    -

    Un courriel d'invitation sera envoyé à chaque adresse contenue dans ce fichier CSV, et vous pourrez modifier son contenu après l'avoir envoyé sur le serveur.

    - progress: "Envoi en cours : %{progress}%…" - success: "Le fichier a été téléversé. Une notification vous signalera la fin de son importation effective." - error: "Désolé, le fichier doit être au format CSV." +

    Un courriel d'invitation sera envoyé à chaque adresse reprise dans ce fichier CSV, et vous pourrez modifier son contenu après l'avoir envoyé sur le serveur.

    + progress: "Envoi en cours : %{progress} %…" + success: "Le fichier a été envoyé avec succès. Vous recevrez un message de notification lorsque le processus sera terminé." + error: "Nous sommes désolés, le fichier doit être au format CSV." password: title: "Mot de passe" too_short: "Votre mot de passe est trop court." common: "Ce mot de passe est trop commun." - same_as_username: "Votre mot de passe est le même que votre nom d'utilisateur." - same_as_email: "Votre mot de passe est le même que votre adresse courriel." + same_as_username: "Votre mot de passe est identique à votre nom d'utilisateur." + same_as_email: "Votre mot de passe est identique à votre adresse courriel." ok: "Votre mot de passe semble correct." instructions: "au moins %{count} caractères" - required: "Veuillez entrer un mot de passe" + required: "Veuillez saisir un mot de passe" summary: title: "Résumé" stats: "Statistiques" time_read: "de lecture" recent_time_read: "de lecture récente" topic_count: - one: "sujets créés" + one: "sujet créé" other: "sujets créés" post_count: one: "message créé" @@ -1500,20 +1487,20 @@ fr: one: "signet" other: "signets" top_replies: "Meilleures réponses" - no_replies: "Pas encore de réponses." + no_replies: "Pas encore de réponse." more_replies: "Plus de réponses" top_topics: "Meilleurs sujets" - no_topics: "Pas encore de sujets." + no_topics: "Pas encore de sujet." more_topics: "Plus de sujets" top_badges: "Meilleurs badges" - no_badges: "Pas encore de badges." + no_badges: "Pas encore de badge." more_badges: "Plus de badges" top_links: "Meilleurs liens" - no_links: "Pas encore de liens." + no_links: "Pas encore de lien." most_liked_by: "Le plus aimé par" most_liked_users: "A le plus aimé" most_replied_to_users: "A le plus répondu à" - no_likes: "Pas encore de J'aime." + no_likes: "Pas encore de « J'aime »." top_categories: "Meilleures catégories" topics: "Sujets" replies: "Réponses" @@ -1550,9 +1537,9 @@ fr: not_found: "Page introuvable" desc: network: "Veuillez vérifier votre connexion." - network_fixed: "On dirait que c'est revenu." + network_fixed: "Le problème semble résolu." server: "Code d'erreur : %{status}" - forbidden: "Vous n'êtes pas autorisé à voir cela." + forbidden: "Vous n'avez pas l'autorisation de voir ça." not_found: "Oups, l'application a essayé de charger une URL qui n'existe pas." unknown: "Une erreur est survenue." buttons: @@ -1564,19 +1551,19 @@ fr: dismiss_error: "Ignorer l'erreur" close: "Fermer" assets_changed_confirm: "Une mise à jour est disponible pour ce site. Souhaitez-vous obtenir la dernière version ?" - logout: "Vous avez été déconnecté." + logout: "Vous avez été déconnecté(e)." refresh: "Actualiser" home: "Accueil" read_only_mode: - enabled: "Le site est en mode lecture seule. Vous pouvez continuer à naviguer, mais les réponses, J'aime et autre interactions sont désactivées pour l'instant." + enabled: "Le site est en mode lecture seule. Vous pouvez continuer à le parcourir, mais les réponses, « J'aime » et autres interactions sont désactivées pour l'instant." login_disabled: "La connexion est désactivée quand le site est en lecture seule." logout_disabled: "La déconnexion est désactivée quand le site est en lecture seule." too_few_topics_and_posts_notice_MF: >- - Commençons la discussion ! Il y a {currentTopics, plural, one {# sujet} other {# sujets}} et {currentPosts, plural, one {# message} other {# messages}}. Il en faudrait davantage pour les visiteurs – nous recommandons au moins {requiredTopics, plural, one {# sujet} other {# sujets}} et {requiredPosts, plural, one {# message} other {# messages}}. Ce message n'est visible que par les responsables. + Commençons la discussion ! Il y a {currentTopics, plural, one {# sujet} other {# sujets}} et {currentPosts, plural, one {# message} other {# messages}}. Il en faudrait davantage pour les visiteurs : nous recommandons au moins {requiredTopics, plural, one {# sujet} other {# sujets}} et {requiredPosts, plural, one {# message} other {# messages}}. Seuls les responsables peuvent voir ce message. too_few_topics_notice_MF: >- - Commençons la discussion ! Il y a {currentTopics, plural, one {# sujet} other {# sujets}}. Il en faudrait davantage pour les visiteurs – nous recommandons au moins {requiredTopics, plural, one {# sujet} other {# sujets}}. Ce message n'est visible que par les responsables. + Commençons la discussion ! Il y a {currentTopics, plural, one {# sujet} other {# sujets}}. Il en faudrait davantage pour les visiteurs : nous recommandons au moins {requiredTopics, plural, one {# sujet} other {# sujets}}. Seuls les responsables peuvent voir ce message. too_few_posts_notice_MF: >- - Commençons la discussion ! Il y a {currentPosts, plural, one {# message} other {# messages}}. Il en faudrait davantage pour les visiteurs – nous recommandons au moins {requiredPosts, plural, one {# message} other {# messages}}. Ce message n'est visible que par les responsables. + Commençons la discussion ! Il y a {currentPosts, plural, one {# message} other {# messages}}. Il en faudrait davantage pour les visiteurs : nous recommandons au moins {requiredPosts, plural, one {# message} other {# messages}}. Seuls les responsables peuvent voir ce message. logs_error_rate_notice: reached_hour_MF: "{relativeAge} – {rate, plural, one {# erreur/heure} other {# erreurs/heure}} arrive à la limite paramétrée de {limit, plural, one {# erreur/heure} other {# erreurs/heure}}." reached_minute_MF: "{relativeAge} – {rate, plural, one {# erreur/minute} other {# erreurs/minute}} arrive à la limite paramétrée de {limit, plural, one {# erreur/minute} other {# erreurs/minute}}." @@ -1591,8 +1578,8 @@ fr: time_read: Temps de lecture time_read_recently: "%{time_read} récemment" time_read_tooltip: "%{time_read} temps de lecture total" - time_read_recently_tooltip: "%{time_read} temps de lecture total (%{recent_time_read} durant les 60 derniers jours)" - last_reply_lowercase: réponse + time_read_recently_tooltip: "%{time_read} temps de lecture total (%{recent_time_read} au cours des 60 derniers jours)" + last_reply_lowercase: dernière réponse replies_lowercase: one: réponse other: réponses @@ -1600,9 +1587,9 @@ fr: sign_up: "S'inscrire" hide_session: "Me le rappeler demain" hide_forever: "non merci" - hidden_for_session: "Très bien, nous vous demanderons demain. Vous pouvez aussi utiliser le bouton « Se connecter » pour vous créer un compte." + hidden_for_session: "Très bien, nous vous le demanderons demain. Vous pouvez aussi utiliser le bouton « Se connecter » pour vous créer un compte." intro: "Bonjour ! Vous semblez apprécier la discussion, mais n'avez pas encore créé de compte." - value_prop: "Quand vous créez un compte, nous retenons ce que vous avez lu pour qu'à votre retour vous puissiez continuer là on vous vous êtes arrêté. Vous recevez aussi des notifications, ici et par courriel, dès que quelqu'un vous répond. Et vous pouvez aimer les messages pour partager vos coups de cœurs. :heartpulse:" + value_prop: "Quand vous créez un compte, nous retenons ce que vous lisez pour vous permettre de reprendre là où vous en étiez à votre retour. Vous recevez aussi des notifications, ici et par courriel, dès que quelqu'un vous répond. Et vous pouvez attribuer un « J'aime » aux messages pour partager vos coups de cœurs. :heartpulse:" summary: enabled_description: "Vous visualisez un résumé de ce sujet : les messages les plus intéressants choisis par la communauté." description: @@ -1622,9 +1609,9 @@ fr: edit: "Ajouter ou supprimer…" remove: "Supprimer…" add: "Ajouter…" - leave_message: "Êtes-vous sûr de vouloir quitter cette conversation ?" - remove_allowed_user: "Êtes-vous sûr de vouloir supprimer %{name} de ce message direct ?" - remove_allowed_group: "Êtes-vous sûr de vouloir supprimer %{name} de ce message direct ?" + leave_message: "Voulez-vous vraiment quitter cette conversation ?" + remove_allowed_user: "Voulez-vous vraiment supprimer %{name} de ce message direct ?" + remove_allowed_group: "Voulez-vous vraiment supprimer %{name} de ce message direct ?" email: "Courriel" username: "Nom d'utilisateur" last_seen: "Vu" @@ -1637,19 +1624,19 @@ fr: subheader_title: "Créons votre compte" disclaimer: "En vous inscrivant, vous acceptez la politique de confidentialité et les conditions générales d'utilisation." title: "Créer votre compte" - failed: "Un problème est survenu. Peut-être que cette adresse courriel est déjà enregistrée. Essayez le lien d'oubli du mot de passe." + failed: "Un problème est survenu. Cette adresse courriel est peut-être déjà enregistrée. Essayez le lien d'oubli du mot de passe." forgot_password: title: "Réinitialisation du mot de passe" action: "J'ai oublié mon mot de passe" - invite: "Entrez votre nom d'utilisateur ou votre adresse courriel, et vous recevrez un nouveau mot de passe par courriel." + invite: "Saisissez votre nom d'utilisateur ou votre adresse courriel et vous recevrez un nouveau mot de passe par courriel." reset: "Réinitialiser votre mot de passe" - complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel avec les instructions pour réinitialiser votre mot de passe." - complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel avec les instructions pour réinitialiser votre mot de passe." - complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}. Vous devriez recevoir rapidement un courriel avec les instructions pour réinitialiser votre mot de passe." - complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}. Vous devriez recevoir rapidement un courriel avec les instructions pour réinitialiser votre mot de passe." + complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." + complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." + complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}. Vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." + complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}. Vous devriez recevoir rapidement un courriel contenant les instructions permettant de réinitialiser votre mot de passe." complete_username_not_found: "Aucun compte ne correspond au nom d'utilisateur %{username}" complete_email_not_found: "Aucun compte ne correspond à %{email}" - help: "Le courriel n'est pas arrivé ? Pensez bien à vérifier dans votre dossier de spam.

    Vous n'êtes pas sûr de l'adresse courriel que vous avez utilisée ? Saisissez une adresse courriel et nous vous dirons si elle existe ici.

    Si vous n'avez plus accès à l'adresse courriel de votre compte, merci de contacter nos responsables serviables.

    " + help: "Le courriel n'est pas arrivé ? N'oubliez pas de consulter votre dossier de courrier indésirable.

    Vous avez des doutes concernant l'adresse courriel que vous avez utilisée ? Saisissez une adresse courriel et nous vous dirons si elle existe ici.

    Si vous n'avez plus accès à l'adresse courriel de votre compte, merci de contacter nos responsables serviables.

    " button_ok: "OK" button_help: "Aide" email_login: @@ -1657,10 +1644,10 @@ fr: button_label: "par courriel" login_link: "Ignorez le mot de passe ; envoyez-moi un lien de connexion" emoji: "émoji de cadenas" - complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel avec un lien pour vous connecter." - complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel avec un lien pour vous connecter." - complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel avec un lien pour vous connecter." - complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}, vous devriez recevoir rapidement un courriel avec un lien pour vous connecter." + complete_username: "Si un compte correspond au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." + complete_email: "Si un compte correspond à l'adresse courriel %{email}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." + complete_username_found: "Nous avons trouvé un compte correspondant au nom d'utilisateur %{username}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." + complete_email_found: "Nous avons trouvé un compte correspondant au courriel %{email}, vous devriez recevoir rapidement un courriel contenant un lien pour vous connecter." complete_username_not_found: "Aucun compte ne correspond au nom d'utilisateur %{username}" complete_email_not_found: "Aucun compte ne correspond à %{email}" confirm_title: Continuer vers %{site_name} @@ -1672,22 +1659,22 @@ fr: title: "Se connecter" username: "Utilisateur" password: "Mot de passe" - second_factor_title: "Validation en deux étapes" + second_factor_title: "Authentification à deux facteurs" second_factor_description: "Veuillez saisir le code d'authentification généré par votre application :" second_factor_backup: "Se connecter avec un code de secours" second_factor_backup_title: "Authentification à deux facteurs (code de secours)" - second_factor_backup_description: "Veuillez entrer un de vos codes de secours :" + second_factor_backup_description: "Veuillez saisir un de vos codes de secours :" second_factor: "Se connecter avec une application" - security_key_description: "Dès que votre clé de sécurité physique est prête, appuyer sur le bouton S'authentifier avec une clé de sécurité ci-dessous." + security_key_description: "Dès que votre clé de sécurité physique est prête, appuyez sur le bouton « S'authentifier avec une clé de sécurité » ci-dessous." security_key_alternative: "Essayer une autre méthode" security_key_authenticate: "S'authentifier avec une clé de sécurité" security_key_not_allowed_error: "La procédure d'authentification de la clé de sécurité a expiré ou a été annulée." security_key_no_matching_credential_error: "Aucun identifiant correspondant n'a pu être trouvé dans la clé de sécurité donnée." - security_key_support_missing_error: "Votre appareil ou navigateur actuel ne supporte pas l'utilisation de clés de sécurité. Veuillez utiliser une autre méthode." + security_key_support_missing_error: "Votre appareil ou navigateur actuel ne prend pas en charge l'utilisation des clés de sécurité. Veuillez utiliser une autre méthode." email_placeholder: "Courriel ou nom d'utilisateur" caps_lock_warning: "Majuscules verrouillées" error: "Erreur inconnue" - cookies_error: "Les cookies de votre navigateur semblent désactiver. Vous ne pourrez pas vous connecter sans les activer." + cookies_error: "Les cookies de votre navigateur semblent désactivées. Vous ne pourrez pas vous connecter sans les activer." rate_limit: "Merci de patienter avant de vous reconnecter." blank_username: "Veuillez saisir votre courriel ou votre nom d'utilisateur." blank_username_or_password: "Veuillez saisir votre courriel ou votre nom d'utilisateur et votre mot de passe." @@ -1695,23 +1682,23 @@ fr: logging_in: "Connexion en cours…" or: "ou" authenticating: "Authentification…" - awaiting_activation: "Votre compte est en attente d'activation, utilisez le lien mot de passe oublié pour envoyer un autre courriel d'activation." + awaiting_activation: "Votre compte est en attente d'activation, utilisez le lien de mot de passe oublié pour envoyer un autre courriel d'activation." awaiting_approval: "Votre compte n'a pas encore été approuvé par un modérateur. Vous recevrez une confirmation par courriel lors de l'activation." - requires_invite: "Désolé, l'accès à ce forum est sur invitation seulement." - not_activated: "Vous ne pouvez pas vous encore vous connecter. Nous avons envoyé un courriel d'activation à %{sentTo}. Veuillez suivre les instructions afin d'activer votre compte." - not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter depuis cette adresse IP." - admin_not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter comme administrateur depuis cette adresse IP." - resend_activation_email: "Cliquez ici pour envoyer à nouveau le courriel d'activation." + requires_invite: "Nous sommes désolés, l'accès à ce forum est réservé sur invitation seulement." + not_activated: "Vous ne pouvez pas encore vous connecter. Nous avons envoyé un courriel d'activation à %{sentTo}. Veuillez suivre les instructions qui y figurent afin d'activer votre compte." + not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter à partir de cette adresse IP." + admin_not_allowed_from_ip_address: "Vous ne pouvez pas vous connecter en tant qu'administrateur à partir de cette adresse IP." + resend_activation_email: "Cliquez ici pour renvoyer le courriel d'activation." omniauth_disallow_totp: "L'authentification à deux facteurs est activée sur votre compte. Veuillez vous connecter avec votre mot de passe." resend_title: "Renvoyer le courriel d'activation" change_email: "Changer l'adresse courriel" - provide_new_email: "Donnez une nouvelle adresse et nous allons renvoyer votre courriel de confirmation." + provide_new_email: "Fournissez une nouvelle adresse et nous renverrons votre courriel de confirmation." submit_new_email: "Mettre à jour l'adresse courriel" - sent_activation_email_again: "Nous venons d'envoyer un nouveau courriel d'activation à %{currentEmail}. Il peut prendre quelques minutes à arriver ; n'oubliez pas de vérifier votre répertoire spam." - sent_activation_email_again_generic: "Nous avons envoyé un autre courriel d'activation. Il se peut que cela prenne quelques minutes pour arriver ; vérifier aussi votre spam." + sent_activation_email_again: "Nous venons d'envoyer un nouveau courriel d'activation à %{currentEmail}. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." + sent_activation_email_again_generic: "Nous avons envoyé un autre courriel d'activation. Vous devriez le recevoir dans quelques minutes. N'oubliez pas de consulter votre dossier de courrier indésirable." to_continue: "Veuillez vous connecter" - preferences: "Vous devez être connecté pour modifier vos préférences utilisateur." - not_approved: "Votre compte n'a pas encore été approuvé. Vous serez notifié par courriel lorsque vous pourrez vous connecter." + preferences: "Vous devez être connecté(e) pour modifier vos préférences utilisateur." + not_approved: "Votre compte n'a pas encore été approuvé. Vous recevrez une notification par courriel lorsque vous pourrez vous connecter." google_oauth2: name: "Google" title: "via Google" @@ -1729,7 +1716,7 @@ fr: title: "via GitHub" discord: name: "Discord" - title: "avec Discord" + title: "via Discord" second_factor_toggle: totp: "Utilisez plutôt une application d'authentification" backup_code: "Utilisez plutôt un code de secours" @@ -1737,11 +1724,11 @@ fr: accept_title: "Invitation" emoji: "émoji d'enveloppe" welcome_to: "Bienvenue sur %{site_name} !" - invited_by: "Vous avez été invité par :" - social_login_available: "Vous pourrez aussi vous connecter avec un réseau social utilisant cette adresse." + invited_by: "Vous avez été invité(e) par :" + social_login_available: "Vous pourrez aussi vous connecter avec un compte de réseau social utilisant cette adresse." your_email: "L'adresse courriel de votre compte est %{email}." accept_invite: "Accepter l'invitation" - success: "Votre compte a été créé et vous êtes maintenant connecté." + success: "Votre compte a été créé et vous êtes maintenant connecté(e)." name_label: "Nom" password_label: "Mot de passe" optional_description: "(facultatif)" @@ -1790,8 +1777,8 @@ fr: one: "Vous ne pouvez sélectionner que %{count} élément." other: "Vous ne pouvez sélectionner que %{count} éléments." min_content_not_reached: - one: "Sélectionner au moins %{count} élément." - other: "Sélectionner au moins %{count} éléments." + one: "Sélectionnez au moins %{count} élément." + other: "Sélectionnez au moins %{count} éléments." invalid_selection_length: one: "La sélection doit contenir au minimum %{count} caractère." other: "La sélection doit contenir au minimum %{count} caractères." @@ -1827,7 +1814,7 @@ fr: notice: "Ce sujet n'est visible que par ceux qui peuvent publier des brouillons partagés." destination_category: "Catégorie de destination" publish: "Publier le brouillon partagé" - confirm_publish: "Êtes-vous sûr de vouloir publier ce brouillon ?" + confirm_publish: "Voulez-vous vraiment publier ce brouillon ?" publishing: "Sujet en cours de publication…" composer: emoji: "Émoji :)" @@ -1844,11 +1831,11 @@ fr: drafts_offline: "brouillons hors ligne" edit_conflict: "conflit de modification" group_mentioned_limit: - one: "Attention ! Vous avez mentionné %{group}, cependant ce groupe a plus de membres que le nombre de mentions limite de %{count} utilisateur configuré par l'administrateur. Personne ne sera notifié." - other: "Attention ! Vous avez mentionné %{group}, cependant ce groupe a plus de membres que le nombre de mentions limite de %{count} utilisateurs configuré par l'administrateur. Personne ne sera notifié." + one: "Attention ! Vous avez mentionné %{group}, cependant ce groupe comprend plus de membres que le nombre limite de mentions de %{count} utilisateur configuré par l'administrateur. Personne ne sera notifié." + other: "Attention ! Vous avez mentionné %{group}, cependant ce groupe comprend plus de membres que le nombre limite de mentions de %{count} utilisateurs configuré par l'administrateur. Personne ne sera notifié." group_mentioned: - one: "En mentionnant %{group}, vous êtes sur le point de notifier %{count} personne – êtes-vous sûr ?" - other: "En mentionnant %{group}, vous êtes sur le point de notifier %{count} personnes – êtes-vous sûr ?" + one: "En mentionnant %{group}, vous êtes sur le point de notifier %{count} personne. Voulez-vous continuer ?" + other: "En mentionnant %{group}, vous êtes sur le point de notifier %{count} personnes. Voulez-vous continuer ?" cannot_see_mention: category: "Vous avez mentionné %{username} mais il ne sera pas notifié car il n'a pas accès à cette catégorie. Vous devez ajouter cet utilisateur à un groupe ayant accès à cette catégorie." private: "Vous avez mentionné %{username} mais il ne sera pas notifié car il ne peut pas voir ce message direct. Vous devez inviter cet utilisateur à la discussion." @@ -2041,7 +2028,7 @@ fr: replied: "nouvelle réponse" quoted: "cité" edited: "modifiés" - liked: "nouveau J'aime" + liked: "nouveau « J'aime »" private_message: "nouveau message direct" invited_to_private_message: "invité au message direct" invitee_accepted: "invitation acceptée" @@ -2056,7 +2043,7 @@ fr: group_message_summary: "nouveaux messages de groupe" watching_first_post: "nouveau sujet" topic_reminder: "rappel de sujet" - liked_consolidated: "nouveaux J'aime" + liked_consolidated: "nouveaux « J'aime »" post_approved: "message approuvé" membership_request_consolidated: "nouvelles demandes d'adhésion" reaction: "nouvelle réaction" @@ -2064,12 +2051,12 @@ fr: upload_selector: title: "Ajouter une image" title_with_attachments: "Ajouter une image ou un fichier" - from_my_computer: "Depuis mon appareil" - from_the_web: "Depuis le web" + from_my_computer: "À partir de mon appareil" + from_the_web: "À partir du Web" remote_tip: "lien vers l'image" remote_tip_with_attachments: "indiquez un lien vers une image ou un autre type de fichier disponible en ligne" - local_tip: "choisissez des images stockées dans votre appareil" - local_tip_with_attachments: "choisissez des images ou d'autres fichiers stockés dans votre appareil" + local_tip: "choisissez des images stockées sur votre appareil" + local_tip_with_attachments: "choisissez des images ou d'autres fichiers stockés sur votre appareil" hint: "(vous pouvez aussi glisser-déposer vos fichiers dans le champ de saisie)" hint_for_supported_browsers: "vous pouvez aussi glisser-déposer ou coller des images dans l'éditeur" uploading: "En cours d'envoi" @@ -2723,15 +2710,15 @@ fr: errors: create: "Désolé, il y a eu une erreur lors de la publication de votre message. Veuillez réessayer." edit: "Désolé, il y a eu une erreur lors de la modification de votre message. Veuillez réessayer." - upload: "Désolé, il y a eu une erreur lors de l'envoi du fichier. Veuillez réessayer." - file_too_large: "Désolé, ce fichier est trop volumineux (la taille maximale autorisée est de %{max_size_kb} Ko). Nous vous suggérons de stocker votre fichier sur un service d'hébergement extérieur au forum et de coller ensuite un lien dans votre message." - too_many_uploads: "Désolé, vous ne pouvez envoyer qu'un seul fichier à la fois." + upload: "Nous sommes désolés, une erreur est survenue lors de l'envoi du fichier. Veuillez réessayer." + file_too_large: "Nous sommes désolés, ce fichier est trop volumineux (la taille maximale autorisée est de %{max_size_kb} Ko). Nous vous suggérons de stocker votre fichier sur un service d'hébergement extérieur au forum et de coller ensuite un lien dans votre message." + too_many_uploads: "Nous sommes désolés, vous ne pouvez envoyer qu'un seul fichier à la fois." too_many_dragged_and_dropped_files: - one: "Désolé, vous ne pouvez envoyer que %{count} fichier à la fois." - other: "Désolé, vous ne pouvez envoyer que %{count} fichiers à la fois." - upload_not_authorized: "Désolé, le fichier que vous essayez d'envoyer n'est pas autorisé (extensions autorisées : %{authorized_extensions})." - image_upload_not_allowed_for_new_user: "Désolé, les nouveaux utilisateurs ne peuvent pas envoyer d'images." - attachment_upload_not_allowed_for_new_user: "Désolé, les nouveaux utilisateurs ne peuvent pas envoyer de fichiers." + one: "Nous sommes désolés, vous ne pouvez envoyer que %{count} fichier à la fois." + other: "Nous sommes désolés, vous ne pouvez envoyer que %{count} fichiers à la fois." + upload_not_authorized: "Nous sommes désolés, le fichier que vous essayez d'envoyer n'est pas autorisé (extensions autorisées : %{authorized_extensions})." + image_upload_not_allowed_for_new_user: "Nous sommes désolés, les nouveaux utilisateurs ne peuvent pas envoyer d'images." + attachment_upload_not_allowed_for_new_user: "Nous sommes désolés, les nouveaux utilisateurs ne peuvent pas envoyer de fichiers." attachment_download_requires_login: "Désolé, vous devez être connecté pour télécharger une pièce jointe." cancel_composer: confirm: "Que souhaitez-vous faire de votre message ?" @@ -2745,13 +2732,13 @@ fr: about: "ce message est un wiki" archetypes: save: "Enregistrer les options" - few_likes_left: "Merci de partager votre amour ! Vous n'avez plus que quelques J'aime à distribuer pour aujourd'hui." + few_likes_left: "Merci de partager votre amour ! Vous n'avez plus que quelques « J'aime » à attribuer aujourd'hui." controls: reply: "commencer à répondre à ce message" - like: "J'aime ce message" + like: "attribuer un « J'aime » à ce message" has_liked: "vous avez aimé ce message" read_indicator: "Membres ayant lu cette publication" - undo_like: "annuler le J'aime" + undo_like: "annuler le « J'aime »" edit: "modifier ce message" edit_action: "Modifier" edit_anonymous: "Désolé, mais vous devez être connecté pour modifier ce message." @@ -3009,8 +2996,8 @@ fr: very_high: "Très élevée" sort_options: default: "par défaut" - likes: "J'aime" - op_likes: "J'aime du premier message" + likes: "« J'aime »" + op_likes: "Nombre de « J'aime » du message original" views: "Vues" posts: "Messages" activity: "Activité" @@ -3119,10 +3106,10 @@ fr: help: "Ce sujet est un message direct" posts: "Messages" posts_likes_MF: | - Ce sujet a {count, plural, one {# réponse} other {# réponses}} {ratio, select, - low {avec un ratio élevé de J'aime par message} - med {avec un ratio très élevé de J'aime par message} - high {avec un ratio extrêmement élevé de J'aime par message} + Ce sujet comprend {count, plural, one {# réponse} other {# réponses}} {ratio, select, + low {avec un taux élevé de « J'aime » par message} + med {avec un taux très élevé de « J'aime » par message} + high {avec un taux extrêmement élevé de « J'aime » par message} other {}} original_post: "Message original" views: "Vues" @@ -3395,7 +3382,7 @@ fr: manage_groups_description: "Définir des groupes pour organiser les étiquettes" upload: "Envoyer des étiquettes" upload_description: "Envoyer un fichier CSV pour créer des étiquettes en masse" - upload_instructions: "Une par ligne, avec optionnellement un groupe d'étiquettes sous la forme « tag_name,tag_group »." + upload_instructions: "Une par ligne, éventuellement avec un groupe d'étiquettes sous la forme « tag_name,tag_group »." upload_successful: "Étiquettes envoyées avec succès" delete_unused_confirmation: one: "%{count} étiquette sera supprimée : %{tags}" @@ -3617,8 +3604,6 @@ fr: available: "Nom de groupe disponible" not_available: "Nom de groupe indisponible" blank: "Le nom du groupe ne peut être vide" - add_members: - as_owner: "Rendre des utilisateurs propriétaires de ce groupe" manage: interaction: email: Courriel @@ -3797,8 +3782,8 @@ fr: name: "Évènement d'appartenance à un groupe" details: "Lorsqu'un utilisateur est ajouté ou supprimé d'un groupe." like_event: - name: "Évènement de réaction « J'aime »" - details: "Lorsqu'un utilisateur donne un J'aime" + name: "Événement de réaction « J'aime »" + details: "Lorsqu'un utilisateur attribue un « J'aime »" delivery_status: title: "État de l'envoi" inactive: "Inactif" @@ -3865,9 +3850,9 @@ fr: label: "Envoyer" title: "Envoyer une sauvegarde vers cette instance" uploading: "Envoi en cours…" - uploading_progress: "Envoi en cours… %{progress}%" - success: "« %{filename} » a été envoyé avec succès. Le fichier est en cours de traitement et il faudra jusqu'à une minute pour qu'il apparaisse dans la liste." - error: "Il y a eu une erreur lors de l'envoi de « %{filename} » : %{message}" + uploading_progress: "Envoi en cours… %{progress} %" + success: "Le fichier « %{filename} » a été envoyé avec succès. Le fichier est en cours de traitement et il faudra jusqu'à une minute pour qu'il apparaisse dans la liste." + error: "Une erreur est survenue lors de l'envoi du fichier « %{filename} » : %{message}" operations: is_running: "Une opération est en cours d'exécution…" failed: "L'opération %{operation} a échoué. Veuillez consulter les journaux." @@ -4033,7 +4018,7 @@ fr: install: "Installer" installed: "Installé" install_popular: "Populaire" - install_upload: "Depuis votre appareil" + install_upload: "À partir de votre appareil" install_git_repo: "Depuis un dépôt Git" install_create: "Créer un nouveau" duplicate_remote_theme: "Le composant de thème « %{name} » est déjà installé, êtes-vous sûr de vouloir en installer une autre copie ?" @@ -4161,7 +4146,7 @@ fr: description: "Utilisée pour indiquer qu'une action a réussi." love: name: "aimer" - description: "La couleur du bouton J'aime." + description: "La couleur du bouton « J'aime »." robots: title: "Remplacez le fichier robots.txt de votre site :" warning: "Cela remplacera définitivement tous les paramètres associés." @@ -4463,7 +4448,7 @@ fr: add: "Ajouter" success: "Succès" exists: "Existe déjà" - upload: "Ajouter depuis un fichier" + upload: "Ajouter à partir d'un fichier" upload_successful: "Envoi effectué avec succès. Les mots et expressions à surveiller ont été ajoutés." test: button_label: "Tester" @@ -4582,7 +4567,7 @@ fr: reputation: Réputation permissions: Permissions activity: Activité - like_count: J'aime donnés / reçus + like_count: '« J''aime » donnés/reçus' last_100_days: "dans les 100 derniers jours" private_topics_count: Sujets privés posts_read_count: Messages lus @@ -4698,10 +4683,10 @@ fr: posts_read_all_time: "Messages lus (depuis le début)" flagged_posts: "Messages signalés" flagged_by_users: "Utilisateurs signalés" - likes_given: "J'aime donnés" - likes_received: "J'aime reçus" - likes_received_days: "J'aime reçus : par jour" - likes_received_users: "J'aime reçus : par utilisateur" + likes_given: "« J'aime » donnés" + likes_received: "« J'aime » reçus" + likes_received_days: "« J'aime » reçus : un même jour" + likes_received_users: "« J'aime » reçus : par un même utilisateur" suspended: "Suspensions (6 derniers mois)" silenced: "Mises sous silence (6 derniers mois)" qualifies: "Admissible au niveau de confiance 3." @@ -4794,7 +4779,7 @@ fr: empty: "Il n'y a aucune image pour le moment. Veuillez en envoyer une." upload: label: "Envoyer" - title: "Envoyer des images" + title: "Envoyer une ou plusieurs images" selectable_avatars: title: "Liste d'avatars que les utilisateurs peuvent choisir" categories: @@ -4875,9 +4860,9 @@ fr: image: Image graphic: Image icon_help: "Entrez un nom d'icône Font Awesome (utilisez le préfixe « far- » pour les icônes classiques et « fab- » pour les icônes de marques)" - image_help: "Téléverser une image remplace le champ d'icône si les deux sont définis." + image_help: "L'envoi d'une image remplace le champ d'icône si les deux sont définis." select_an_icon: "Sélectionnez une icône" - upload_an_image: "Téléverser une image" + upload_an_image: "Envoyer une image" read_only_setting_help: "Personnaliser le texte" query: Requête du badge (SQL) target_posts: Requête sur les messages @@ -4920,7 +4905,7 @@ fr: description: Accordez le même badge à plusieurs utilisateurs à la fois. no_badge_selected: Veuillez sélectionner un badge pour commencer. perform: "Décerner ce badge à ces utilisateurs" - upload_csv: Envoyez un fichier CSV contenant les adresses de courriel ou les noms d'utilisateur des concernés + upload_csv: Envoyez un fichier CSV contenant les adresses courriel ou les noms d'utilisateur aborted: Envoyez d'abord un fichier CSV qui contienne des adresses de courriel ou des noms d'utilisateur success: Votre fichier CSV a été envoyé, les utilisateurs obtiendront leur badge d'ici peu de temps. replace_owners: Retirer ce badge à ses détenteurs actuels non-listés dans le fichier @@ -4995,7 +4980,7 @@ fr: step: "%{current} sur %{total}" upload: "Envoi" uploading: "Envoi en cours…" - upload_error: "Désolé, il y a eu une erreur à l'envoi de ce fichier. Veuillez réessayer." + upload_error: "Nous sommes désolés, une erreur est survenue lors de l'envoi de ce fichier. Veuillez réessayer." quit: "Peut-être plus tard" staff_count: one: "Votre communauté a %{count} responsable (vous)." diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index 2f398ec57e..89a0430893 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -199,7 +199,6 @@ gl: submit: "Enviar" generic_error: "Sentímolo pero produciuse un erro." generic_error_with_reason: "Produciuse un erro: %{error}" - go_ahead: "Avanzar" sign_up: "Crear unha conta" log_in: "Iniciar sesión" age: "Idade" @@ -228,7 +227,6 @@ gl: x_more: one: "%{count} Máis" other: "%{count} Máis" - less: "Menos" never: "nunca" every_30_minutes: "cada 30 minutos" every_hour: "cada hora" @@ -237,7 +235,6 @@ gl: every_month: "cada mes" every_six_months: "cada seis meses" max_of_count: "máx. de %{count}" - alternation: "ou" character_count: one: "%{count} carácter" other: "%{count} caracteres" @@ -269,7 +266,6 @@ gl: help: bookmark: "Prema para engadir aos marcadores a publicación inicial deste tema" unbookmark: "Prema para retirar todos os marcadores deste tema" - unbookmark_with_reminder: "Prema para eliminar todos os marcadores e recordatorios deste tema. Configurou o recordatorio %{reminder_at} para este tema." bookmarks: created: "Fixou como marcador esta publicación. %{name}" not_bookmarked: "marcar esta publicación" @@ -598,13 +594,6 @@ gl: member_added: "Engadido" member_requested: "Solicitado o" add_members: - title: "Engadir membros a %{group_name}" - description: "Tamén pode pegalos nunha lista separada por comas." - usernames_or_emails: - title: "Escriba nomes de usuario e enderezos de correo" - input_placeholder: "Nomes de usuario ou enderezos de correo" - usernames: - input_placeholder: "Nomes de usuario" notify_users: "Notificar os usuarios" requests: title: "Peticións" @@ -619,7 +608,7 @@ gl: title: "Xestionar" name: "Nome" full_name: "Nome completo" - add_members: "Engadir membros" + invite_members: "Convidar" delete_member_confirm: "Quere eliminar a «%{username}» do grupo «%{group}»?" profile: title: Perfil @@ -3516,8 +3505,6 @@ gl: available: "O nome do grupo está dispoñíbel" not_available: "O nome do grupo non está dispoñíbel" blank: "O nome do grupo non pode quedar baleiro" - add_members: - as_owner: "Estabelecer usuario(s) como propietario(s) deste grupo" manage: interaction: email: Correo electrónico diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index 81f9009b88..ee15ba4139 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -246,7 +246,6 @@ he: submit: "שליחה" generic_error: "ארעה שגיאה, עמך הסליחה." generic_error_with_reason: "ארעה שגיאה: %{error}" - go_ahead: "קדימה" sign_up: "הרשמה" log_in: "כניסה" age: "גיל" @@ -279,7 +278,6 @@ he: two: "עוד %{count}" many: "עוד %{count}" other: "עוד %{count}" - less: "צמצום" never: "אף פעם" every_30_minutes: "כל 30 דקות" every_hour: "כל שעה" @@ -288,7 +286,6 @@ he: every_month: "כל חודש" every_six_months: "כל שישה חודשים" max_of_count: "%{count} לכל היותר" - alternation: "או" character_count: one: "תו אחד" two: "%{count} תווים" @@ -325,8 +322,9 @@ he: clear_bookmarks: "מחיקת סימניות" help: bookmark: "יש ללחוץ כדי ליצור סימנייה לפוסט הראשון בנושא זה" + edit_bookmark: "יש ללחוץ כדי לערוך את הסימניה בנושא זה" unbookmark: "יש ללחוץ כדי להסיר את כל הסימניות בנושא זה" - unbookmark_with_reminder: "יש ללחוץ כדי להסיר את כל הסימניות והתזכורות בנושא הזה. יש לך תזכורת שמוגדרת ל־%{reminder_at} עבור הנושא הזה." + unbookmark_with_reminder: "יש ללחוץ כדי להסיר את כל הסימניות והתזכורות בנושא זה." bookmarks: created: "הוספת את הפוסט הזה לסימניות. %{name}" not_bookmarked: "סמנו פוסט זה עם סימנייה" @@ -692,15 +690,10 @@ he: member_added: "הוסיף" member_requested: "בקשה התקבלה ב־" add_members: - title: "הוספת חברים אל %{group_name}" - description: "ניתן גם להדביק ברשימה מופרדת בפסיקים" - usernames_or_emails: - title: "נא למלא שמות משתמשים או כתובות דוא״ל" - input_placeholder: "שמות משתמשים או כתובות דוא״ל" - usernames: - title: "נא להקליד שמות משתמשים" - input_placeholder: "שמות משתמשים" + title: "הוספת משתמשים אל %{group_name}" + description: "נא למלא רשימת משתמשים אותם ברצונך להזמין לקבוצה או להדביק רשימה מופרדת בפסיקים:" notify_users: "להודיע למשתמשים" + set_owner: "הגדרת משתמשים כבעלי הקבוצה הזו" requests: title: "בקשות" reason: "סיבה" @@ -714,7 +707,8 @@ he: title: "ניהול" name: "שם" full_name: "שם מלא" - add_members: "הוספת חברים" + add_members: "הוספת משתמשים" + invite_members: "הזמנה" delete_member_confirm: "להסיר את ‚%{username}’ מהקבוצה ‚%{group}’?" profile: title: פרופיל @@ -1555,6 +1549,7 @@ he: restrict_email: "להגביל לכתובת דוא״ל אחת" max_redemptions_allowed: "כמות שימושים מרבית" add_to_groups: "הוספה לקבוצות" + invite_to_topic: "נחיתה בנושא הזה" expires_at: "תפוג לאחר" custom_message: "הודעה אישית כרשות" send_invite_email: "לשמור ולשלח דוא״ל" @@ -2237,6 +2232,7 @@ he: hint: "(ניתן גם לגרור לעורך להעלאה)" hint_for_supported_browsers: "תוכלו גם לגרור או להדביק תמונות לעורך" uploading: "מעלה" + processing: "ההעלאה עוברת עיבוד" select_file: "בחירת קובץ" default_image_alt_text: תמונה supported_formats: "תצורות קבצים נתמכות" @@ -3919,8 +3915,6 @@ he: available: "שם הקבוצה זמין" not_available: "שם הקבוצה אינו זמין" blank: "שם הקבוצה לא יכול להיות ריק" - add_members: - as_owner: "הגדרת משתמשים כבעלים של הקבוצה הזו" manage: interaction: email: דוא״ל @@ -5084,6 +5078,7 @@ he: text: "שדה טקסט" confirm: "אישור" dropdown: "נגלל" + multiselect: "מגוון בחירות" site_text: description: "ניתן להתאים כל טקסט בפורום שלך. נא להתחיל בחיפוש שלהלן:" search: "חפשו טקסט שברצונכם לערוך" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index eb122a985f..b6fe3f3fc2 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -198,7 +198,6 @@ hu: submit: "Beküldés" generic_error: "Sajnos hiba történt." generic_error_with_reason: "Hiba történt: %{error}" - go_ahead: "Folytatás" sign_up: "Regisztráció" log_in: "Bejelentkezés" age: "Életkor" @@ -227,7 +226,6 @@ hu: x_more: one: "Még %{count}" other: "Még %{count}" - less: "Kevesebb" never: "soha" every_30_minutes: "30 percenként" every_hour: "óránként" @@ -236,7 +234,6 @@ hu: every_month: "havonta" every_six_months: "hat havonta" max_of_count: "legfeljebb %{count}" - alternation: "vagy" character_count: one: "%{count} karakter" other: "%{count} karakter" @@ -268,7 +265,6 @@ hu: help: bookmark: "Kattintson a téma első bejegyzésének könyvjelzőzéséhez" unbookmark: "Kattintson a téma valamennyi könyvjelzőjének törléséhez" - unbookmark_with_reminder: "Kattintson ide minden könyvjelző és emlékeztető törléséhez ebben a témában. Ezt az emlékeztetőt állította be a témához: %{reminder_at} ." bookmarks: created: "Könyvjelzőzte ezt a bejegyzést. %{name}" not_bookmarked: "bejegyzés könyvjelzőzése" @@ -576,12 +572,6 @@ hu: member_added: "Hozzáadva" member_requested: "Kérve" add_members: - title: "Tagok hozzáadása a(z) %{group_name} csoporthoz" - usernames_or_emails: - title: "Adja meg a felhasználóneveket vagy e-mail-címeket" - input_placeholder: "Felhasználónév vagy e-mail-cím" - usernames: - input_placeholder: "Felhasználónevek" notify_users: "Felhasználók értesítése" requests: title: "Kérelmek" @@ -596,7 +586,7 @@ hu: title: "Kezelés" name: "Név" full_name: "Teljes név" - add_members: "Tagok hozzáadása" + invite_members: "Meghívás" delete_member_confirm: "Eltávolítja „%{username}” felhasználót a(z) „%{group}” csoportból?" profile: title: Profil diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index 742803ecd4..5be1e41713 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -190,7 +190,6 @@ hy: submit: "Հաստատել" generic_error: "Տեղի է ունեցել սխալ, ներողություն:" generic_error_with_reason: "Տեղի է ունեցել սխալ՝ %{error}" - go_ahead: "Գնալ առաջ" sign_up: "Գրանցվել" log_in: "Մուտք" age: "Տարիք" @@ -219,7 +218,6 @@ hy: x_more: one: "ևս %{count}" other: "ևս %{count}" - less: "Կրճատ" never: "երբեք" every_30_minutes: "30 րոպեն մեկ" every_hour: "ժամը մեկ" @@ -228,7 +226,6 @@ hy: every_month: "ամիսը մեկ" every_six_months: "վեց ամիսը մեկ" max_of_count: "առավելագույնը %{count}" - alternation: "կամ" character_count: one: "%{count} սիմվոլ" other: "%{count} սիմվոլ" @@ -260,7 +257,6 @@ hy: help: bookmark: "Սեղմեք՝ այս թեմայի առաջին գրառումն էջանշելու համար" unbookmark: "Սեղմեք՝ այս թեմայի բոլոր էջանշանները ջնջելու համար" - unbookmark_with_reminder: "Բոլոր էջանշանները և այդ թեմայի հիշեցումները ջնջելու համար սեղմել: Դուք արդեն ունեք հիշեցում այդ թեմայի համար, որը կարգավորված է %{reminder_at}." bookmarks: not_bookmarked: "Էջանշել այս գրառումը" remove: "Հեռացնել էջանշանը" @@ -533,9 +529,6 @@ hy: groups: member_added: "Ավելացված" member_requested: "Հարցով " - add_members: - usernames: - input_placeholder: "Օգտանուններ" requests: title: "Հարցումներ " reason: "Պատճառ" @@ -549,7 +542,7 @@ hy: title: "Կառավարել" name: "Անուն" full_name: "Անուն Ազգանուն" - add_members: "Ավելացնել Անդամներ" + invite_members: "Հրավիրել" delete_member_confirm: "Հեռացնե՞լ '%{username}' օգտանունը '%{group}' խմբից:" profile: title: Պրոֆիլ @@ -3027,8 +3020,6 @@ hy: available: "Խմբի անունը հասանելի է" not_available: "Խմբի անունը հասանելի չէ" blank: "Խմբի անունը չի կարող դատարկ լինել" - add_members: - as_owner: "Սահմանել օգտատիրոջ(երի) որպես այս խմբի սեփականատեր(եր)" manage: interaction: email: Էլ. հասցե diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index c96871979d..bbc1860181 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -147,6 +147,7 @@ id: regions: ap_northeast_1: "Asia Pacific (Tokyo)" ap_northeast_2: "Asia Pacific (Seoul)" + ap_east_1: "Asia Pasifik (Hong Kong)" ap_south_1: "Asia Pasifik (Mumbai)" ap_southeast_1: "Asia Pacific (Singapore)" ap_southeast_2: "Asia Pacific (Sydney)" @@ -173,7 +174,6 @@ id: submit: "Kirimkan" generic_error: "Maaf, terjadi kesalahan." generic_error_with_reason: "Terjadi kesalahan: %{error}" - go_ahead: "Lanjutkan" sign_up: "Daftar" log_in: "Masuk" age: "Umur" @@ -198,7 +198,6 @@ id: now: "baru saja" read_more: "baca selengkapnya" more: "Selengkapnya" - less: "Lebih Singkat" never: "tidak pernah" every_30_minutes: "setiap 30 menit" every_hour: "setiap jam" @@ -207,7 +206,6 @@ id: every_month: "setiap bulan" every_six_months: "setiap enam bulan" max_of_count: "maksimal dari %{count}" - alternation: "atau" character_count: other: "%{count} karakter" related_messages: @@ -225,6 +223,9 @@ id: moderators: "Moderator" stat: all_time: "Sepanjang Waktu" + last_day: "24 jam terakhir" + last_7_days: "7 hari terakhir" + last_30_days: "30 hari terakhir" like_count: "Suka" topic_count: "Topik" post_count: "Pos" @@ -261,6 +262,9 @@ id: new_topic: "Konsep topik baru" new_private_message: "Konsep pesan pribadi baru" topic_reply: "Balasan konsep" + abandon: + yes_value: "Batalkan" + no_value: "Lanjut menyunting" topic_count_latest: other: "Lihat %{count} topik baru atau diperbarui" topic_count_unread: @@ -276,6 +280,7 @@ id: upload: "Unggah" uploading: "Mengunggah..." uploading_filename: "Sedang mengunggah %{filename}..." + processing_filename: "Memproses: %{filename}..." clipboard: "clipboard" uploaded: "Sudah Diunggah!" pasting: "Menempelkan..." @@ -425,6 +430,8 @@ id: title: "Tulisan Antre" reviewable_user: title: "Pengguna" + reviewable_post: + title: "Postingan" approval: title: "Pesan Membutuhkan Persetujuan" description: "Kami telah menerima posting baru Anda, tetapi diperlukan persetujuan dari moderator sebelum posting tersebut tampil. Mohon kesabarannya. Terima kasih!" @@ -432,14 +439,34 @@ id: other: "Anda memiliki %{count} pos sedang menunggu." ok: "OK" example_username: "username" + reject_reason: + title: "Mengapa Anda menolak pengguna ini?" + send_email: "Kirim surel penolakan" + relative_time_picker: + minutes: + other: "menit" + hours: + other: "jam" + days: + other: "hari" + months: + other: "bulan" + years: + other: "tahun" + relative: "Relatif" time_shortcut: later_today: "Nanti hari ini" next_business_day: "Hari kerja berikutnya" tomorrow: "Besok" + later_this_week: "Nanti pekan ini" + this_weekend: "Akhir pekan ini" start_of_next_business_week: "Senin" start_of_next_business_week_alt: "Senin Depan" + two_weeks: "Dua pekan" next_month: "Bulan depan" + six_months: "Enam bulan" custom: "Tanggal dan waktu tersuai" + none: "Tidak dibutuhkan" user_action: user_posted_topic: "%{user} memposting topik" you_posted_topic: "Anda memposting the topic" @@ -488,9 +515,6 @@ id: member_added: "Ditambahkan" member_requested: "Diminta pada" add_members: - title: "Tambahkan anggota ke %{group_name}" - usernames: - input_placeholder: "Nama Pengguna" notify_users: "Beritahu pengguna" requests: title: "Permintaan" @@ -503,7 +527,6 @@ id: title: "Mengelola" name: "Nama" full_name: "Nama Lengkap" - add_members: "Menambahkan Anggota" delete_member_confirm: "Hapus '%{username}' dari grup '%{group}'?" profile: title: Profil @@ -513,11 +536,24 @@ id: notification: Pemberitahuan email: title: "Email" + save_settings: "Simpan Pengaturan" + last_updated: "Terakhir diperbarui:" + last_updated_by: "oleh" + smtp_settings_valid: "Pengaturan SMTP valid." + smtp_title: "SMTP" + imap_title: "IMAP" + imap_additional_settings: "Pengaturan Tambahan" + imap_settings_valid: "Pengaturan IMAP valid." + prefill: + gmail: "GMail" credentials: + title: "Kredensial" smtp_server: "Server SMTP" smtp_port: "Port SMTP" + smtp_ssl: "Gunakan SSL untuk SMTP" imap_server: "Server IMAP" imap_port: "Port IMAP" + imap_ssl: "Gunakan SSL untuk IMAP" username: "Nama Pengguna" password: "Kata Sandi" settings: @@ -1638,6 +1674,7 @@ id: all: "Semua" trending_search: disabled: 'Laporan pencarian sedang tren dinonaktifkan. Aktifkan kueri penelusuran log untuk mengumpulkan data.' + average_chart_label: Rata-rata filters: category: label: Kategori diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 094853b2ef..bc20981092 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -9,7 +9,7 @@ it: number: format: separator: "," - delimiter: " '" + delimiter: " ." human: storage_units: format: "%n %u" @@ -25,7 +25,7 @@ it: thousands: "%{number}k" millions: "%{number}M" dates: - time: "h:mm a" + time: "HH:mm" time_with_zone: "HH:mm (z)" time_short_day: "ddd, HH:mm" timeline_date: "MMM YYYY" @@ -130,7 +130,7 @@ it: invited_user: "ha invitato %{who} %{when}" invited_group: "ha invitato %{who} %{when}" user_left: "%{who} ha rimosso se stesso da questo messaggio %{when}" - removed_user: "rimosso %{who} %{when}" + removed_user: "ha rimosso %{who} %{when}" removed_group: "rimosso %{who} %{when}" autobumped: "riproposto automaticamente %{when}" autoclosed: @@ -149,15 +149,15 @@ it: enabled: "appuntato globalmente %{when}" disabled: "spuntato %{when}" visible: - enabled: "listato %{when}" - disabled: "delistato %{when}" + enabled: "visibile in elenco %{when}" + disabled: "invisibile in elenco %{when}" banner: enabled: "lo ha reso un annuncio il %{when}. Apparirà in cima ad ogni pagina finché non verrà chiuso dall'utente. " disabled: "ha rimosso questo annuncio il %{when}. Non apparirà più in cima ad ogni pagina." forwarded: "ha inoltrato l'email sopra" topic_admin_menu: "azioni argomento" wizard_required: "Benvenuto al tuo nuovo sito Discourse! Inizia la procedura guidata di configurazione ✨" - emails_are_disabled: "Tutte le email in uscita sono state disabilitate a livello globale da un amministratore. Non sarà inviato nessun tipo di notifica via email." + emails_are_disabled: "Tutte le e-mail in uscita sono state disabilitate a livello globale da un amministratore. Non sarà inviato nessun tipo di notifica via e-mail." software_update_prompt: message: "Abbiamo aggiornato il sito, si prega di aggiornare o potrebbe verificarsi un comportamento imprevisto." dismiss: "Ignora" @@ -166,7 +166,7 @@ it: other: "Per rendere più facile il lancio del nuovo sito, sei in modalità bootstrap. A tutti i nuovi utenti verrà concesso il livello di esperienza 1 e verranno attivate le email di riepilogo giornaliere. Questa modalità verrà disattivata automaticamente quando %{count} utenti si saranno uniti." bootstrap_mode_disabled: "La modalità bootstrap sarà disattivata entro 24 ore." themes: - default_description: "Default" + default_description: "Predefinito" broken_theme_alert: "Il tuo sito potrebbe non funzionare perché il tema / componente %{theme} contiene degli errori. Disabilitalo qui %{path}." s3: regions: @@ -177,16 +177,16 @@ it: ap_southeast_1: "Asia Pacifico (Singapore)" ap_southeast_2: "Asia Pacifico (Sidney)" ca_central_1: "Canada (Centrale)" - cn_north_1: "Cina (Beijing)" + cn_north_1: "Cina (Pechino)" cn_northwest_1: "Cina" eu_central_1: "Europa (Francoforte)" - eu_north_1: "EU (Stoccolma)" + eu_north_1: "Europa (Stoccolma)" eu_west_1: "Europa (Irlanda)" - eu_west_2: "EU (Londra)" - eu_west_3: "UE (Parigi)" + eu_west_2: "Europa (Londra)" + eu_west_3: "Europa (Parigi)" sa_east_1: "Sud America (San Paolo)" us_east_1: "Stati Uniti Est (Virginia del Nord)" - us_east_2: "USA Est (Ohio)" + us_east_2: "Stati Uniti Est (Ohio)" us_gov_east_1: "AWS GovCloud (USA Est)" us_gov_west_1: "AWS GovCloud (US-West)" us_west_1: "Stati Uniti Ovest (California del Nord)" @@ -198,11 +198,10 @@ it: no_value: "No" yes_value: "Sì" submit: "Invia" - generic_error: "Spiacenti, c'è stato un problema." + generic_error: "Spiacenti, c'è stato un errore." generic_error_with_reason: "Si è verificato un errore: %{error}" - go_ahead: "Continua" sign_up: "Iscriviti" - log_in: "Connetti" + log_in: "Accedi" age: "Età" joined: "Iscritto" admin_title: "Amministrazione" @@ -214,22 +213,21 @@ it: other: "collegamenti" faq: "FAQ" guidelines: "Linee Guida" - privacy_policy: "Tutela Privacy" + privacy_policy: "Informativa sulla privacy" privacy: "Privacy" tos: "Termini di Servizio" rules: "Regole" conduct: "Codice di Condotta" - mobile_view: "Visualizzazione Mobile" + mobile_view: "Visualizzazione cellulari" desktop_view: "Visualizzazione Desktop" you: "Tu" or: "oppure" now: "proprio ora" - read_more: "continua" - more: "Più" + read_more: "altre informazioni" + more: "Altro" x_more: one: "%{count} altro" other: "%{count} altri" - less: "Meno" never: "mai" every_30_minutes: "ogni 30 minuti" every_hour: "ogni ora" @@ -238,7 +236,6 @@ it: every_month: "ogni mese" every_six_months: "ogni sei mesi" max_of_count: "massimo di %{count}" - alternation: "o" character_count: one: "%{count} carattere" other: "%{count} caratteri" @@ -273,15 +270,14 @@ it: help: bookmark: "Clicca per aggiungere un segnalibro al primo messaggio di questo argomento" unbookmark: "Clicca per rimuovere tutti i segnalibri a questo argomento" - unbookmark_with_reminder: "Fare clic per rimuovere tutti i segnalibri e i promemoria in questo argomento. Hai un promemoria impostato %{reminder_at} per questo argomento." bookmarks: created: "Hai aggiunto questo messaggio ai segnalibri %{name}" - not_bookmarked: "aggiungi ai segnalibri" + not_bookmarked: "aggiungi messaggio ai segnalibri" created_with_reminder: "Hai aggiunto ai segnalibri questo messaggio con un promemoria %{date}. %{name}" remove: "Rimuovi Segnalibro" delete: "Cancella Segnalibro" - confirm_delete: "Sicuro di voler eliminare questo segnalibro? Anche il promemoria verrà eliminato." - confirm_clear: "Sei certo di voler rimuovere tutti i segnalibri da questo argomento?" + confirm_delete: "Vuoi eliminare questo segnalibro? Anche il promemoria verrà eliminato." + confirm_clear: "Vuoi rimuovere tutti i segnalibri da questo argomento?" save: "Salva" no_timezone: 'Non hai ancora impostato un fuso orario. Non sarai in grado di impostare promemoria. Creane uno nel tuo profilo .' invalid_custom_datetime: "La data e l'ora fornite non sono valide, riprova." @@ -331,20 +327,20 @@ it: uploading: "In caricamento..." uploading_filename: "In caricamento: %{filename}..." processing_filename: "In elaborazione: %{filename}..." - clipboard: "clipboard" + clipboard: "appunti" uploaded: "Caricato!" - pasting: "Incollando..." + pasting: "Operazione incolla in corso..." enable: "Attiva" disable: "Disattiva" continue: "Continua" undo: "Annulla" revert: "Ripristina" - failed: "Fallito" + failed: "Errore" switch_to_anon: "Avvia Modalità Anonima" - switch_from_anon: "Esci Modalità Anonima" + switch_from_anon: "Esci da Modalità Anonima" banner: - close: "Nascondi questo annuncio." - edit: "Modifica questo annuncio >>" + close: "Ignora questo banner." + edit: "Modifica questo banner >>" pwa: install_banner: "Vuoi installare %{title} su questo dispositivo?" choose_topic: @@ -362,7 +358,7 @@ it: in_reply_to: "in risposta a" explain: why: "spiega perché questo oggetto è stato aggiunto alla coda" - title: "Punteggio revisionabile" + title: "Punteggio rivedibile" formula: "Formula" subtotal: "Totale parziale" total: "Totale" @@ -396,7 +392,7 @@ it: save_changes: "Salva le modifiche" title: "Impostazioni" priorities: - title: "Priorità Revisionabili" + title: "Priorità Rivedibili" moderation_history: "Storico Moderazione" view_all: "Vedi tutti" grouped_by_topic: "Raggruppati per Argomento" @@ -510,7 +506,7 @@ it: title: "Messaggio" approval: title: "Messaggio Da Approvare" - description: "Abbiamo ricevuto il tuo messaggio ma prima che appaia è necessario che venga approvato da un moderatore. Per favore sii paziente." + description: "Abbiamo ricevuto il tuo messaggio ma prima che appaia deve essere approvato da un moderatore. Attendi." pending_posts: one: "Hai%{count} messaggio in attesa." other: "Hai%{count} messaggi in attesa." @@ -601,14 +597,6 @@ it: member_added: "Aggiunto" member_requested: "Richiesto alle" add_members: - title: "Aggiungi membri a %{group_name}" - description: "Puoi anche incollare in un elenco separato da virgole." - usernames_or_emails: - title: "Inserisci nomi utente o indirizzi email" - input_placeholder: "Nomi utente o email" - usernames: - title: "Immettere i nomi utente" - input_placeholder: "Nome utente" notify_users: "Notifica gli utenti" requests: title: "Richieste" @@ -623,7 +611,7 @@ it: title: "Gestisci" name: "Nome" full_name: "Nome Completo" - add_members: "Aggiungi Membri" + add_members: "Aggiungi utenti" delete_member_confirm: "Rimuovere '%{username}' dal gruppo '%{group}'?" profile: title: Profilo @@ -694,7 +682,7 @@ it: title: "Log" when: "Quando" action: "Azione" - acting_user: "Attante" + acting_user: "Autore azione" target_user: "Utente destinatario" subject: "Oggetto" details: "Dettagli" @@ -719,7 +707,7 @@ it: leave: "Abbandona" request: "Richiesta" message: "Messaggio" - confirm_leave: "Sei sicuro di voler abbandonare questo gruppo?" + confirm_leave: "Vuoi abbandonare questo gruppo?" allow_membership_requests: "Consenti agli utenti di inviare richieste di ammissione ai proprietari dei gruppi (richiede un gruppo visibile pubblicamente)" membership_request_template: "Modello personalizzato da mostrare agli utenti quando inviano una richiesta di adesione" membership_request: @@ -758,7 +746,7 @@ it: activity: "Attività" members: title: "Membri" - filter_placeholder_admin: "nome utente o email" + filter_placeholder_admin: "nome utente o e-mail" filter_placeholder: "nome utente" remove_member: "Rimuovi Membro" remove_member_description: "Rimuovi %{username} da questo gruppo" @@ -877,19 +865,19 @@ it: n_more: "Categorie (%{count} di più)..." ip_lookup: title: Ricerca Indirizzo IP - hostname: Hostname + hostname: Nome host location: Località location_not_found: (sconosciuto) organisation: Organizzazione phone: Telefono other_accounts: "Altri account con questo indirizzo IP:" - delete_other_accounts: "Cancella %{count}" + delete_other_accounts: "Cancella %{count} account" username: "nome utente" trust_level: "TL" read_time: "tempo lettura" topics_entered: "argomenti visualizzati" post_count: "n° messaggi" - confirm_delete_other_accounts: "Sicuro di voler cancellare questi account?" + confirm_delete_other_accounts: "Vuoi cancellare questi account?" powered_by: "tramiteMaxMindDB" copied: "copiato" user_fields: @@ -898,11 +886,11 @@ it: user: said: "%{username}:" profile: "Profilo" - mute: "Ignora" + mute: "Silenzia" edit: "Modifica opzioni" download_archive: button_text: "Scarica Tutto" - confirm: "Sei sicuro di voler scaricare i tuoi messaggi?" + confirm: "Vuoi scaricare i tuoi messaggi?" success: "Esportazione iniziata, verrai avvertito con un messaggio al termine del processo." rate_limit_error: "I messaggi possono essere scaricati una volta al giorno, riprova domani." new_private_message: "Nuovo Messaggio" @@ -957,16 +945,16 @@ it: use_current_timezone: "Usa Fuso Orario Corrente" profile_hidden: "Il profilo pubblico di questo utente è nascosto." expand_profile: "Espandi" - collapse_profile: "Raggruppa" + collapse_profile: "Comprimi" bookmarks: "Segnalibri" - bio: "Su di me" + bio: "Info su di me" timezone: "Fuso orario" invited_by: "Invitato Da" - trust_level: "Livello Esperienza" + trust_level: "Livello di attendibilità" notifications: "Notifiche" statistics: "Statistiche" desktop_notifications: - label: "Notifiche Desktop" + label: "Notifiche in tempo reale" not_supported: "Spiacenti, le notifiche non sono supportate su questo browser." perm_default: "Attiva Notifiche" perm_denied_btn: "Permesso Negato" @@ -992,7 +980,7 @@ it: not_first_time: "Non è la tua prima volta?" skip_link: "Ignora questi consigli" read_later: "Lo leggerò più tardi." - theme_default_on_all_devices: "Rendi questo il tema di default su tutti i miei dispositivi." + theme_default_on_all_devices: "Rendi questo il tema predefinito su tutti i miei dispositivi." color_scheme_default_on_all_devices: "Imposta questa combinazione (o combinazioni) di colori come predefinita/e su tutti i miei dispositivi" color_scheme: "Combinazione di colori" color_schemes: @@ -1005,11 +993,11 @@ it: default_dark_scheme: "(predefinito del sito)" dark_mode: "Modo scuro" dark_mode_enable: "Attiva lo schema di colori automatico del modo scuro" - text_size_default_on_all_devices: "Rendi questa dimensione del testo di default su tutti i miei dispositivi" + text_size_default_on_all_devices: "Rendi questa la dimensione del testo predefinita su tutti i miei dispositivi" allow_private_messages: "Consenti agli altri utenti di inviarmi messaggi personali" external_links_in_new_tab: "Apri tutti i link esterni in nuove schede" - enable_quoting: "Abilita \"rispondi quotando\" per il testo evidenziato" - enable_defer: "Abilita il rinvio per contrassegnare gli argomenti come non letti" + enable_quoting: "Abilita \"rispondi citando\" il testo evidenziato" + enable_defer: "Abilita opzione di contrassegnare gli argomenti da leggere in seguito" change: "cambia" featured_topic: "Argomento in primo piano" moderator: "%{user} è un moderatore" @@ -1021,7 +1009,7 @@ it: suspended_permanently: "Questo utente è sospeso." suspended_reason: "Motivo: " github_profile: "GitHub" - email_activity_summary: "Riassunto Attività" + email_activity_summary: "Riepilogo Attività" mailing_list_mode: label: "Modalità Mailing list" enabled: "Abilita la modalità mailing list" @@ -1035,7 +1023,7 @@ it: warning: "Modalità Mailing List attiva. Le impostazioni per la notifica via email verranno ignorate." tag_settings: "Etichette" watched_tags: "Osservate" - watched_tags_instructions: "Osserverai automaticamente tutti gli argomenti con questi tag. Verrai avvertito di tutti i nuovi messaggi e argomenti, e accanto all'argomento apparirà anche un conteggio dei nuovi messaggi." + watched_tags_instructions: "Osserverai automaticamente tutti gli argomenti con queste etichette. Verrai avvertito di tutti i nuovi messaggi e argomenti, e accanto all'argomento apparirà anche un conteggio dei nuovi messaggi." tracked_tags: "Seguite" tracked_tags_instructions: "Seguirai automaticamente tutti gli argomenti con queste etichette. Accanto all'argomento apparirà il conteggio dei nuovi messaggi." muted_tags: "Silenziati" @@ -1044,9 +1032,9 @@ it: watched_categories_instructions: "Osserverai automaticamente tutti gli argomenti in queste categorie. Riceverai notifiche su tutti i nuovi messaggi e argomenti e, accanto all'argomento, apparirà il conteggio dei nuovi messaggi." tracked_categories: "Seguite" tracked_categories_instructions: "Seguirai automaticamente tutti gli argomenti appartenenti a queste categorie. Accanto all'argomento comparirà il conteggio dei nuovi messaggi." - watched_first_post_categories: "Osserva Primo Messaggio" + watched_first_post_categories: "Osservazione Primo Messaggio" watched_first_post_categories_instructions: "Riceverai la notifica per il primo messaggio di ogni nuovo argomento in queste categorie." - watched_first_post_tags: "Osserva Primo Messaggio" + watched_first_post_tags: "Osservazione Primo Messaggio" watched_first_post_tags_instructions: "Riceverai la notifica per il primo messaggio di ogni nuovo argomento con queste etichette." muted_categories: "Silenziate" muted_categories_instructions: "Non riceverai notifiche riguardanti i contenuti di queste categorie, e non appariranno nelle pagine delle Categorie o dei Recenti." @@ -1055,9 +1043,9 @@ it: regular_categories_instructions: "Vedrai queste categorie nelle liste di argomenti \"Recenti\" e \"Popolari\"." no_category_access: "Come moderatore hai accesso limitato alla categoria, il salvataggio è disabilitato." delete_account: "Cancella il mio account" - delete_account_confirm: "Sei sicuro di voler cancellare il tuo account in modo permanente? Questa azione non può essere annullata!" + delete_account_confirm: "Vuoi cancellare il tuo account in modo permanente? Questa azione non può essere annullata!" deleted_yourself: "Il tuo account è stato eliminato con successo." - delete_yourself_not_allowed: "Si prega di contattare un membro dello staff se si desidera che il proprio account venga eliminato" + delete_yourself_not_allowed: "Contattare un membro dello staff per richiedere l'eliminazione del proprio account" unread_message_count: "Messaggi" admin_delete: "Cancella" users: "Utenti" @@ -1069,7 +1057,7 @@ it: ignored_users: "Ignorato" ignored_users_instructions: "Elimina tutti i post, le notifiche e i PM da questi utenti." tracked_topics_link: "Mostra" - automatically_unpin_topics: "Spunta automaticamente gli argomenti quando arrivi in fondo." + automatically_unpin_topics: "Sblocca automaticamente gli argomenti quando arrivi in fondo." apps: "Applicazioni" revoke_access: "Revoca Accesso" undo_revoke_access: "Annullare Revoca Accesso" @@ -1089,11 +1077,11 @@ it: messages: all: "Tutti" inbox: "In arrivo" - sent: "Spediti" + sent: "Inviati" archive: "Archiviati" groups: "I Miei Gruppi" bulk_select: "Seleziona messaggi" - move_to_inbox: "Sposta in arrivo" + move_to_inbox: "Sposta nella posta in arrivo" move_to_archive: "Archivia" failed_to_move: "Errore nello spostare i messaggi selezionati (forse la tua connessione non è attiva)" select_all: "Seleziona Tutti" @@ -1128,8 +1116,8 @@ it: one: "Gestisci i codici di backup. Ti è rimasto %{count} codice di backup." other: "Gestisci i codici di backup. Ti sono rimasti %{count} codici di backup." copy_to_clipboard: "Copia negli Appunti" - copy_to_clipboard_error: "Errore durante la copia nella Clipboard" - copied_to_clipboard: "Copiato nella Clipboard" + copy_to_clipboard_error: "Errore durante la copia nella appunti" + copied_to_clipboard: "Copiato negli Appunti" download_backup_codes: "Scarica i codici di backup" remaining_codes: one: "Ti è rimasto %{count} codice di backup." @@ -1144,7 +1132,7 @@ it: enable: "Gestione autenticazione a due fattori" disable_all: "Disattiva tutto" forgot_password: "Ha dimenticato la password?" - confirm_password_description: "Per favore conferma la tua password per continuare" + confirm_password_description: "Conferma la tua password per continuare" name: "Nome" label: "Codice" rate_limit: "Attendi prima di provare un altro codice di autenticazione." @@ -1173,9 +1161,9 @@ it: default_name: "Il mio Authenticator" name_and_code_required_error: "Devi fornire un nome e il codice dalla tua app di autenticazione." security_key: - register: "Registrare" + register: "Registra" title: "Chiave di Sicurezza" - add: "Modifica Chiave di Sicurezza" + add: "Aggiungi Chiave di Sicurezza" default_name: "Chiave di Sicurezza principale" not_allowed_error: "Il processo di registrazione della Chiave di Sicurezza è scaduto o è stato annullato." already_added_error: "Hai già registrato questa Chiave di Sicurezza. Non è necessario registrarla di nuovo." @@ -1187,7 +1175,7 @@ it: title: "Modifica i dati personali" error: "Si è verificato un errore durante la modifica del valore." change_username: - title: "Cambia Utente" + title: "Cambia Nome utente" confirm: "Sei sicuro di voler davvero cambiare il tuo nome utente?" taken: "Spiacenti, questo nome utente è già riservato." invalid: "Nome utente non valido: usa solo lettere e cifre" @@ -1312,7 +1300,7 @@ it: location: "Località" website: "Sito Web" email_settings: "Email" - hide_profile_and_presence: "Nascondi il mio profilo pubblico e la mia presenza" + hide_profile_and_presence: "Funzioni Nascondi il mio profilo pubblico e la mia presenza" enable_physical_keyboard: "Attiva supporto alla tastiera fisica su iPad" text_size: title: "Dimensioni del testo" @@ -1337,7 +1325,7 @@ it: always: "sempre" never: "mai" email_digests: - title: "Quando non visito il sito, inviami un'email riepilogativa degli argomenti e delle risposte di successo" + title: "Quando non visito il sito, inviami un'email riepilogativa degli argomenti e delle risposte più popolari" every_30_minutes: "ogni 30 minuti" every_hour: "ogni ora" daily: "ogni giorno" @@ -1356,7 +1344,7 @@ it: categories_settings: "Categorie" new_topic_duration: label: "Considera un argomento \"nuovo\" se" - not_viewed: "non ancora visti" + not_viewed: "non l'ho ancora visto" last_here: "è stato creato dopo la mia ultima visita" after_1_day: "creato nell'ultimo giorno" after_2_days: "creato negli ultimi 2 giorni" @@ -1392,7 +1380,7 @@ it: remove: "Rimuovi" copy_link: "Ottieni link" reinvite: "Reinvia Email" - reinvited: "Invito rinviato" + reinvited: "Invito spedito di nuovo" removed: "Rimosso" search: "digita per cercare inviti..." user: "Utente Invitato" @@ -1410,7 +1398,7 @@ it: removed_all: "Tutti gli inviti scaduti sono stati rimossi!" remove_all_confirm: "Sei sicuro di voler rimuovere tutti gli inviti scaduti?" reinvite_all: "Rispedisci tutti gli Inviti" - reinvite_all_confirm: "Sei sicuro di voler inviare nuovamente tutti gli inviti?" + reinvite_all_confirm: "Vuoi inviare nuovamente tutti gli inviti?" reinvited_all: "Tutti gli inviti sono stati spediti!" time_read: "Ora di Lettura" days_visited: "Giorni di Frequenza" @@ -1460,7 +1448,7 @@ it: title: "Riepilogo" stats: "Statistiche" time_read: "tempo di lettura" - recent_time_read: "tempo di lettura recente" + recent_time_read: "orario di lettura recente" topic_count: one: "argomento creato" other: "argomenti creati" @@ -1496,7 +1484,7 @@ it: more_badges: "Altri Distintivi" top_links: "Migliori Collegamenti" no_links: "Ancora nessun collegamento." - most_liked_by: "Ricevuto più \"Mi Piace\" da" + most_liked_by: "Con più \"Mi Piace\" da" most_liked_users: "Con più \"Mi Piace\"" most_replied_to_users: "Più Risposte A" no_likes: "Ancora nessun \"Mi piace\"." @@ -1535,8 +1523,8 @@ it: unknown: "Errore" not_found: "Pagina Non Trovata" desc: - network: "Per favore controlla la connessione." - network_fixed: "Sembra essere tornato." + network: "Controlla la connessione." + network_fixed: "Sembra di nuovo disponibile." server: "Codice di errore: %{status}" forbidden: "Non hai i permessi per visualizzarlo." not_found: "Ops, l'applicazione ha cercato di caricare una URL inesistente." @@ -1551,14 +1539,14 @@ it: close: "Chiudi" assets_changed_confirm: "Questo sito ha appena ricevuto un aggiornamento software. Vuoi scaricare l'ultima versione ora?" logout: "Sei stato disconnesso." - refresh: "Ricarica" + refresh: "Aggiorna" home: "Home" read_only_mode: enabled: "Questo sito è in modalità di sola lettura. Puoi continuare a navigare nel sito, ma le risposte, i \"Mi piace\" e altre azioni sono per il momento disabilitate." - login_disabled: "La connessione è disabilitata quando il sito è in modalità di sola lettura." + login_disabled: "L'accesso è disabilitato quando il sito è in modalità di sola lettura." logout_disabled: "La disconnessione è disabilitata quando il sito è in modalità di sola lettura." too_few_topics_and_posts_notice_MF: >- - Cominciamo la discussione! {currentTopics, plural, one {C'è # argomento} other {Ci sono # argomenti}} e {currentPosts, plural, one {# messaggio} other {# messaggi}}. I visitatori hanno bisogno di più per leggere e rispondere: – consigliamo almeno {requiredTopics, plural, one {# argomento} other {# argomenti}} and {requiredPosts, plural, one {# messaggio} other {# messaggi}}. Solo lo staff può vedere questo messaggio. + Cominciamo la discussione! {currentTopics, plural, one {C'è # argomento} other {Ci sono # argomenti}} e {currentPosts, plural, one {# messaggio} other {# messaggi}}. I visitatori hanno bisogno d'altro per leggere e rispondere: consigliamo almeno {requiredTopics, plural, one {# argomento} other {# argomenti}} e {requiredPosts, plural, one {# messaggio} other {# messaggi}}. Solo lo staff può vedere questo messaggio. too_few_topics_notice_MF: >- Cominciamo la discussione! {currentTopics, plural, one {C'è # argomento} other {Ci sono# argomenti}}. I visitatori hanno bisogno di più per leggere e rispondere: – consigliamo almeno {requiredTopics, plural, one {# argomento} other {# argomenti}}. Solo lo staff può vedere questo messaggio. too_few_posts_notice_MF: >- @@ -1571,7 +1559,7 @@ it: learn_more: "per saperne di più..." first_post: Primo messaggio mute: Ignora - unmute: Attiva + unmute: Riattiva last_post: Ultimo messaggio local_time: "Ora Locale" time_read: Letti @@ -1616,7 +1604,7 @@ it: last_seen: "Ultima visita" created: "Creato" created_lowercase: "creato" - trust_level: "Livello Esperienza" + trust_level: "Livello di attendibilità" search_hint: "nome utente, email o indirizzo IP" create_account: header_title: "Benvenuti!" @@ -1627,7 +1615,7 @@ it: forgot_password: title: "Reimposta Password" action: "Ho dimenticato la password" - invite: "Inserisci il nome utente o l'indirizzo email. Ti manderemo un'email per l'azzeramento della password." + invite: "Inserisci il nome utente o l'indirizzo email. Ti manderemo un'email per reimpostare la password." reset: "Reimposta Password" complete_username: "Se un account corrisponde al nome utente %{username}, a breve dovresti ricevere un'email con le istruzioni per ripristinare la tua password." complete_email: "Se un account corrisponde a %{email}, a breve dovresti ricevere un'email contenente le istruzioni per ripristinare la password." @@ -1635,7 +1623,7 @@ it: complete_email_found: "C'è un account che corrisponde a %{email}. A breve dovresti ricevere una email con le istruzioni per reimpostare la tua password. " complete_username_not_found: "Nessun account corrisponde al nome utente %{username}" complete_email_not_found: "Nessun account corrisponde a %{email}" - help: "Le email non arrivano? Per prima cosa assicurati di controllare la cartella spam.

    Non sei sicuro di quale indirizzo email hai usato? Inserisci un indirizzo email e ti faremo sapere se esiste.

    Se non hai più accesso all'indirizzo email del tuo account, per favore contatta il nostro staff.

    " + help: "Le email non arrivano? Per prima cosa controlla la cartella di posta indesiderata.

    Non sei sicuro di quale indirizzo email hai usato? Inserisci un indirizzo email e ti faremo sapere se esiste.

    Se non hai più accesso all'indirizzo email del tuo account, per favore contatta il nostro staff.

    " button_ok: "OK" button_help: "Aiuto" email_login: @@ -1646,7 +1634,7 @@ it: complete_username: "Se un account corrisponde al nome utente %{username} , a breve dovresti ricevere un'email con un collegamento per l'accesso." complete_email: "Se un account corrisponde a %{email}, a breve dovresti ricevere un'email con un collegamento per l'accesso." complete_username_found: "Abbiamo trovato un account che corrisponde al nome utente %{username}, a breve dovresti ricevere un'email con un collegamento per l'accesso." - complete_email_found: "Abbiamo trovato un account che corrisponde a %{email}, a breve dovresti ricevere una email con un collegamento per l'accesso." + complete_email_found: "Abbiamo trovato un account che corrisponde a %{email}, a breve dovresti ricevere un'email con un collegamento per l'accesso." complete_username_not_found: "Nessun account con nome utente %{username}" complete_email_not_found: "Nessun account con email %{email}" confirm_title: Procedi su %{site_name} @@ -1659,10 +1647,10 @@ it: username: "Utente" password: "Password" second_factor_title: "Autenticazione a Due Fattori" - second_factor_description: "Per favore, inserisci il codice di autenticazione della tua app:" + second_factor_description: "Inserisci il codice di autenticazione dalla tua app:" second_factor_backup: "Accedi utilizzando un codice di backup" second_factor_backup_title: "Backup per l'Autenticazione a Due Fattori" - second_factor_backup_description: "Per favore, inserisci uno dei tuoi codici di backup:" + second_factor_backup_description: "Inserisci uno dei tuoi codici di backup:" second_factor: "Accedi utilizzando l'app Authenticator" security_key_description: "Quando hai preparato la chiave di sicurezza fisica, premi il pulsante Autentica con Chiave di Sicurezza qui sotto." security_key_alternative: "Prova in un altro modo" @@ -1673,12 +1661,12 @@ it: email_placeholder: "Email / Nome Utente" caps_lock_warning: "Il Blocco Maiuscole è attivo" error: "Errore sconosciuto" - cookies_error: "Sembra che il tuo browser abbia i cookie disabilitati. Potresti non riuscire a connetterti senza abilitarli." + cookies_error: "Sembra che il tuo browser abbia i cookie disabilitati. Senza abilitarli potrai avere problemi di accesso." rate_limit: "Attendi prima di provare nuovamente a connetterti." - blank_username: "Per favore, inserisci la tua email o il tuo nome utente." - blank_username_or_password: "Per favore inserisci la tua email o il tuo nome utente, e la password." - reset_password: "Azzera Password" - logging_in: "Connessione in corso..." + blank_username: "Inserisci la tua email o il tuo nome utente." + blank_username_or_password: "Inserisci la tua email o il tuo nome utente, e la password." + reset_password: "Reimposta Password" + logging_in: "Accesso in corso..." or: "Oppure" authenticating: "Autenticazione..." awaiting_activation: "Il tuo account è in attesa di attivazione, utilizza il collegamento per la password dimenticata per ricevere un'altra email di attivazione." @@ -1686,7 +1674,7 @@ it: requires_invite: "Spiacenti, l'accesso a questo forum e solo ad invito." not_activated: "Non puoi ancora connetterti. Abbiamo inviato un'email di attivazione a %{sentTo}. Per favore segui le istruzioni contenute nell'email per attivare l'account." not_allowed_from_ip_address: "Non puoi accedere da quell'indirizzo IP." - admin_not_allowed_from_ip_address: "Non puoi connetterti come amministratore da quell'indirizzo IP." + admin_not_allowed_from_ip_address: "Non puoi accedere come amministratore da quell'indirizzo IP." resend_activation_email: "Clicca qui per inviare nuovamente l'email di attivazione." omniauth_disallow_totp: "Il tuo account ha l'autenticazione a due fattori abilitata. Accedi con la tua password." resend_title: "Invia Nuovamente Email Attivazione" @@ -1695,8 +1683,8 @@ it: submit_new_email: "Aggiorna Indirizzo Email" sent_activation_email_again: "Ti abbiamo mandato un'altra email di attivazione su %{currentEmail}. Potrebbero essere necessari alcuni minuti di attesa; assicurati di controllare anche la cartella spam." sent_activation_email_again_generic: "Abbiamo inviato un'altra email di attivazione. Potrebbero passare alcuni minuti prima che arrivi; assicurati di controllare la tua cartella spam." - to_continue: "Per favore Connettiti" - preferences: "Devi essere connesso per cambiare le tue impostazioni." + to_continue: "Effettua l'accesso" + preferences: "Devi effettuare l'accesso per cambiare le tue impostazioni." not_approved: "Il tuo account non è ancora stato approvato. Verrai avvertito via email quando potrai collegarti." google_oauth2: name: "Google" @@ -1724,10 +1712,10 @@ it: emoji: "emoji envelope" welcome_to: "Benvenuto su %{site_name}!" invited_by: "Sei stato invitato da:" - social_login_available: "Sarai anche in grado di accedere con qualsiasi login social usando questa email." + social_login_available: "Sarai anche in grado di accedere con qualsiasi account social usando questa email." your_email: "L'indirizzo email del tuo account è %{email}." accept_invite: "Accetta Invito" - success: "Il tuo account è stato creato e ora sei connesso." + success: "Il tuo account è stato creato e ora hai effettuato l'accesso." name_label: "Nome" password_label: "Password" optional_description: "(opzionale)" @@ -1800,13 +1788,13 @@ it: objects: Oggetti symbols: Simboli flags: Bandiere - recent: Usate recentemente - default_tone: Nessun tono della pelle - light_tone: Tono della pelle chiaro - medium_light_tone: Tono della pelle medio chiaro - medium_tone: Tono della pelle medio - medium_dark_tone: Tono della pelle medio scuro - dark_tone: Tono della pelle scuro + recent: Usati recentemente + default_tone: Carnagione di colorito neutro + light_tone: Carnagione di tonalità chiara + medium_light_tone: Carnagione di tonalità medio-chiara + medium_tone: Carnagione di tonalità media + medium_dark_tone: Carnagione di tonalità medio-scura + dark_tone: Carnagione di tonalità scura default: Emoji personalizzate shared_drafts: title: "Bozze condivise" @@ -1819,7 +1807,7 @@ it: emoji: "Emoji :)" more_emoji: "altro..." options: "Opzioni" - whisper: "sussurra" + whisper: "sussurro" unlist: "invisibile" add_warning: "Questo è un avvertimento ufficiale." toggle_whisper: "Attiva/Disattiva Sussurri" @@ -1869,9 +1857,9 @@ it: create_whisper: "Sussurro" create_shared_draft: "Crea Bozza condivisa" edit_shared_draft: "Modifica Bozza condivisa" - title: "O premi Ctrl+Enter" + title: "Oppure premi Ctrl+Invio" users_placeholder: "Aggiunti un utente" - title_placeholder: "In breve, di cosa tratta questo argomento?" + title_placeholder: "In breve, di cosa tratta questa discussione?" title_or_link_placeholder: "Digita il titolo o incolla qui il collegamento" edit_reason_placeholder: "perché stai scrivendo?" topic_featured_link_placeholder: "Inserisci il collegamento mostrato con il titolo." @@ -1891,8 +1879,8 @@ it: bold_title: "Grassetto" bold_text: "testo in grassetto" italic_label: "I" - italic_title: "Italic" - italic_text: "testo italic" + italic_title: "Corsivo" + italic_text: "testo in corsivo" link_title: "Collegamento" link_description: "inserisci qui la descrizione del collegamento" link_dialog_title: "Inserisci il collegamento" @@ -1901,20 +1889,20 @@ it: blockquote_title: "Citazione" blockquote_text: "Citazione" code_title: "Testo preformattato" - code_text: "rientra il testo preformattato di 4 spazi" + code_text: "fai rientrare il testo preformattato di 4 spazi" paste_code_text: "digita o incolla il codice qui" upload_title: "Carica" upload_description: "inserisci qui la descrizione del caricamento" olist_title: "Elenco Numerato" ulist_title: "Elenco Puntato" list_item: "Elemento lista" - toggle_direction: "Commuta Direzione" + toggle_direction: "Attiva Direzione" help: "Aiuto Inserimento Markdown" collapse: "minimizza il pannello del composer" open: "Apri il pannello del composer" abandon: "chiudi il composer e scarta la bozza" enter_fullscreen: "Espandi il composer a tutto schermo" - exit_fullscreen: "Lascia il composer a tutto schermo" + exit_fullscreen: "esci dal composer a tutto schermo" show_toolbar: "mostra la barra degli strumenti del Composer" hide_toolbar: "nascondi la barra degli strumenti del Composer" modal_ok: "OK" @@ -1947,7 +1935,7 @@ it: label: Rispondi all'argomento desc: Rispondi all'argomento, non a uno specifico messaggio toggle_whisper: - label: Commuta Sussurri + label: Interruttore Sussurri desc: I Sussurri sono visibili solo ai membri dello staff create_topic: label: "Nuovo Argomento" @@ -1955,7 +1943,7 @@ it: label: "Bozza Condivisa" desc: "Crea una bozza di argomento che sarà visibile solo agli utenti autorizzati" toggle_topic_bump: - label: "Commuta riproposizione argomenti" + label: "Interruttore riproposizione argomenti" desc: "Rispondi senza cambiare la data dell'ultima risposta" reload: "Ricarica" ignore: "Ignora" @@ -2020,7 +2008,7 @@ it: linked: '%{username} ha aggiunto un collegamento a un tuo messaggio da "%{topic}" - %{site_title}' watching_first_post: '%{username} ha creato il nuovo argomento "%{topic}" - %{site_title}' confirm_title: "Notifiche abilitate - %{site_title}" - confirm_body: "Successo! Le notifiche sono state abilitate." + confirm_body: "Operazione riuscita! Le notifiche sono state abilitate." custom: "Notifica di %{username} su %{site_title}" titles: mentioned: "menzionato" @@ -2055,7 +2043,7 @@ it: remote_tip: "collegamento all'immagine" local_tip: "seleziona immagini dal tuo dispositivo" hint: "(puoi anche trascinare e rilasciare nell'editor per caricare)" - hint_for_supported_browsers: "puoi fare il \"trascina e rilascia\" o incollare immagini nell'editor" + hint_for_supported_browsers: "puoi trascinare o incollare immagini nell'editor" uploading: "In caricamento" select_file: "Seleziona File" default_image_alt_text: immagine @@ -2079,11 +2067,11 @@ it: no_more_results: "Nessun altro risultato trovato." post_format: "n°%{post_number} da %{username}" results_page: "Risultati della ricerca per '%{term}'" - more_results: "Ci sono più risultati. Restringi i criteri di ricerca." + more_results: "Ci sono troppi risultati. Restringi i criteri di ricerca." cant_find: "Non riesci a trovare quello che stai cercando?" start_new_topic: "Forse vuoi iniziare un nuovo argomento?" - or_search_google: "O prova a cercare con Google invece:" - search_google: "Prova a cercare con Google invece:" + or_search_google: "Oppure prova a cercare con Google:" + search_google: "Prova a cercare con Google:" search_google_button: "Google" search_button: "Cerca" context: @@ -2097,7 +2085,7 @@ it: posted_by: label: Pubblicato da in_category: - label: Classificate + label: Per categorie in_group: label: Nel Gruppo with_badge: @@ -2115,9 +2103,9 @@ it: private: nei miei messaggi privati bookmarks: ho aggiunto ai segnalibri first: sono il primissimo post - pinned: sono appuntati - seen: messaggi che ho letto - unseen: non ho letto + pinned: sono bloccati + seen: che ho letto + unseen: che non ho letto wiki: sono wiki images: che includono immagini all_tags: Tutte le etichette sopra @@ -2149,7 +2137,7 @@ it: hamburger_menu: "vai ad un'altra lista di argomenti o categoria" new_item: "nuovo" go_back: "indietro" - not_logged_in_user: "pagina utente con riassunto delle attività correnti e delle impostazioni" + not_logged_in_user: "pagina utente con riepilogo delle attività correnti e delle impostazioni" current_user: "vai alla pagina utente" view_all: "visualizza tutti i %{tab}" topics: @@ -2157,17 +2145,17 @@ it: bulk: select_all: "Seleziona Tutto" clear_all: "Deseleziona Tutto" - unlist_topics: "Rendi invisibili" + unlist_topics: "Rendi argomenti invisibili" relist_topics: "Ripubblica Argomenti" reset_read: "Reimposta stato lettura" delete: "Elimina argomenti" dismiss: "Letti" - dismiss_read: "Nascondi tutti i non letti" + dismiss_read: "Ignora tutti i non letti" dismiss_button: "Ignora…" dismiss_tooltip: "Ignora solo i nuovi messaggi o smetti di seguire gli argomenti" also_dismiss_topics: "Smetti di seguire questi argomenti in modo che non vengano più visualizzati come non letti per me" dismiss_new: "Ignora i nuovi messaggi" - toggle: "commuta la selezione multipla degli argomenti" + toggle: "interruttore di selezione multipla degli argomenti" actions: "Azioni Multiple" change_category: "Imposta categoria" close_topics: "Chiudi argomenti" @@ -2179,7 +2167,7 @@ it: selected: one: "Hai selezionato %{count} argomento." other: "Hai selezionato %{count} argomenti." - change_tags: "Sostituire Etichette" + change_tags: "Sostituisci Etichette" append_tags: "Aggiungi Etichette" choose_new_tags: "Scegli nuove etichette per i seguenti argomenti:" choose_append_tags: "Scegli nuove etichette da aggiungere a questi argomenti:" @@ -2199,7 +2187,7 @@ it: latest: "Non hai nuovi messaggi da leggere!" bookmarks: "Non hai ancora argomenti nei segnalibri." category: "Non ci sono argomenti in %{category}." - top: "Non ci sono argomenti di punta." + top: "Non ci sono argomenti popolari." educate: new: '

    I tuoi nuovi argomenti appariranno qui. Di default, gli argomenti sono considerati nuovi e mostreranno un indicatore se sono stati creati negli ultimi 2 giorni.

    Visita le tue preferenze per cambiarle.

    ' unread: "

    I tuoi argomenti non letti appaiono qui.

    Di default, gli argomenti sono considerati non letti e mostreranno conteggi non letti 1 se tu:

    • Hai creato l'argomento
    • Hai risposto all'argomento
    • Leggi l'argomento per più di 4 minuti

    O hai esplicitamente impostato l'argomento su Seguito o Osservato tramite \U0001F514 in ogni argomento.

    Visita le tue preferenze per cambiare queste impostazioni.

    " @@ -2208,10 +2196,10 @@ it: posted: "Non ci sono altri argomenti pubblicati." read: "Non ci sono altri argomenti letti." new: "Non ci sono altri argomenti nuovi." - unread: "Non ci sono altri argomenti non letti" + unread: "Non ci sono altri argomenti non letti." category: "Non ci sono altri argomenti nella categoria %{category}." tag: "Non ci sono altri argomenti con l'etichetta %{tag}." - top: "Non ci sono altri argomenti di punta." + top: "Non ci sono altri argomenti popolari." bookmarks: "Non ci sono ulteriori argomenti nei segnalibri." topic: filter_to: @@ -2225,7 +2213,7 @@ it: help: "Sposta il messaggio nel tuo archivio" title: "Archivia" move_to_inbox: - title: "Sposta in arrivo" + title: "Sposta in posta in arrivo" help: "Sposta il messaggio di nuovo nella posta in arrivo" edit_message: help: "Modifica la prima versione del Messaggio" @@ -2253,7 +2241,7 @@ it: invalid_access: title: "L'argomento è privato" description: "Spiacenti, non puoi accedere a questo argomento!" - login_required: "Devi connetterti per vedere questo argomento." + login_required: "Devi accedere per vedere questo argomento." server_error: title: "Errore di caricamento dell'argomento" description: "Spiacenti, non è stato possibile caricare questo argomento, probabilmente per un errore di connessione. Per favore riprova. Se il problema persiste, faccelo sapere." @@ -2272,7 +2260,7 @@ it: likes: one: "c'è %{count} \"Mi piace\" in questo argomento" other: "ci sono %{count} \"Mi piace\" in questo argomento" - back_to_list: "Torna alla Lista Argomenti" + back_to_list: "Torna all'Elenco argomenti" options: "Opzioni Argomento" show_links: "mostra i collegamenti in questo argomento" collapse_details: "comprimi i dettagli dell'argomento" @@ -2319,11 +2307,11 @@ it: remove: "Rimuovi Timer" publish_to: "Pubblica Su:" when: "Quando:" - time_frame_required: "Per favore, seleziona un lasso di tempo" + time_frame_required: "Seleziona un intervallo di tempo" min_duration: "La durata deve essere maggiore di 0" max_duration: "La durata deve essere inferiore a 20 anni" auto_update_input: - none: "Seleziona un lasso di tempo" + none: "Seleziona un intervallo di tempo" now: "Adesso" later_today: "Più tardi oggi" tomorrow: "Domani" @@ -2349,10 +2337,10 @@ it: temp_close: title: "Chiudi Temporaneamente" auto_close: - title: "Chiudi Automaticamente" + title: "Chiudi Automaticamente argomento" label: "Chiude automaticamente l'argomento dopo:" error: "Per favore inserisci un valore valido." - based_on_last_post: "Non chiudere finché l'ultimo messaggio nell'argomento non ha questa anzianità." + based_on_last_post: "Non chiudere finché l'ultimo messaggio nell'argomento non ha raggiunto questa durata." auto_close_after_last_post: title: "Chiudi automaticamente l'argomento dopo l'ultimo messaggio" auto_delete: @@ -2366,7 +2354,7 @@ it: status_update_notice: auto_open: "Questo argomento verrà automaticamente aperto %{timeLeft}" auto_close: "Questo argomento si chiuderà automaticamente in %{timeLeft}." - auto_publish_to_category: "Questo argomento verrà pubblicato su #%{categoryName}%{timeLeft}" + auto_publish_to_category: "Questo argomento verrà pubblicato su #%{categoryName} %{timeLeft}" auto_close_after_last_post: "Questo Argomento si chiuderà %{duration} dopo l'ultima risposta." auto_delete: "Questo argomento verrà automaticamente cancellato %{timeLeft}." auto_bump: "Questo argomento sarà riproposto automaticamente %{timeLeft}." @@ -2393,8 +2381,8 @@ it: jump_prompt_of: one: "di %{count} messaggio" other: "di %{count} messaggi" - jump_prompt_long: "Salta a..." - jump_bottom_with_number: "Passa al messaggio %{post_number}" + jump_prompt_long: "Vai a..." + jump_bottom_with_number: "Vai al messaggio %{post_number}" jump_prompt_to_date: "ad oggi" jump_prompt_or: "o" total: totale messaggi @@ -2420,10 +2408,10 @@ it: "0_2": "Stai ignorando tutte le notifiche di questo argomento." "0": "Stai ignorando tutte le notifiche di questo argomento." watching_pm: - title: "Osservato" + title: "In osservazione" description: "Riceverai una notifica per ogni nuova risposta a questo messaggio, e comparirà un conteggio delle nuove risposte." watching: - title: "Osservato" + title: "In osservazione" description: "Riceverai una notifica per ogni nuova risposta in questo argomento, e comparirà un conteggio delle nuove risposte." tracking_pm: title: "Seguito" @@ -2439,10 +2427,10 @@ it: description: "Riceverai una notifica se qualcuno menziona il tuo @nome o ti risponde." muted_pm: title: "Silenziato" - description: "Non ti verrà notificato nulla per questo messaggio." + description: "Non riceverai mai notifiche di alcun tipo per questo messaggio." muted: title: "Silenziato" - description: "Non riceverai mai notifiche o altro circa questo argomento e non apparirà nei recenti." + description: "Non riceverai mai notifiche di alcun tipo su questo argomento, che non apparirà tra i più recenti." actions: title: "Azioni" recover: "Ripristina Argomento" @@ -2452,28 +2440,28 @@ it: multi_select: "Seleziona Messaggi..." slow_mode: "Imposta modalità lenta" timed_update: "Imposta Timer..." - pin: "Appunta Argomento..." - unpin: "Spunta Argomento..." - unarchive: "De-archivia Argomento" + pin: "Blocca Argomento..." + unpin: "Sblocca Argomento..." + unarchive: "Annulla archiviazione Argomento" archive: "Archivia Argomento" invisible: "Rendi Invisibile" visible: "Rendi Visibile" - reset_read: "Reimposta Dati Letti" + reset_read: "Reimposta Dati di lettura" make_public: "Rendi Argomento Pubblico" make_private: "Rendi Messaggio Personale" reset_bump_date: "Reimposta la data di riproposizione" feature: - pin: "Appunta Argomento" - unpin: "Spunta Argomento" - pin_globally: "Appunta Argomento Globalmente" - make_banner: "Argomento Annuncio" - remove_banner: "Rimuovi Argomento Annuncio" + pin: "Blocca Argomento" + unpin: "Sblocca Argomento" + pin_globally: "Blocca Argomento Globalmente" + make_banner: "Argomento banner" + remove_banner: "Rimuovi Argomento banner" reply: title: "Rispondi" help: "inizia a comporre una risposta a questo argomento" clear_pin: - title: "Spunta" - help: "Rimuovi la spunta da questo argomento, così non comparirà più in cima alla lista degli argomenti" + title: "Rimuovi blocco" + help: "Rimuovi la spunta di blocco da questo argomento, così non comparirà più in cima alla lista degli argomenti" share: title: "Condividi" extended_title: "Condividi un collegamento" @@ -2489,7 +2477,7 @@ it: invite_users: "Invita" print: title: "Stampa" - help: "Apri una versione da stampabile di questo argomento" + help: "Apri una versione facilmente stampabile di questo argomento" flag_topic: title: "Segnala" help: "segnala questo argomento o invia una notifica privata" @@ -2502,8 +2490,8 @@ it: pin: "Poni questo argomento in cima alla categoria %{categoryLink} fino a" unpin: "Rimuovi questo argomento dalla cima della categoria %{categoryLink}." unpin_until: "Rimuovi questo argomento dalla cima della categoria %{categoryLink} o attendi fino a %{until}." - pin_note: "Gli utenti possono spuntare gli argomenti individualmente per loro stessi." - pin_validation: "È richiesta una data per appuntare questo argomento." + pin_note: "Gli utenti possono sbloccare gli argomenti individualmente per loro stessi." + pin_validation: "È richiesta una data per bloccare questo argomento." not_pinned: "Non ci sono argomenti appuntati in %{categoryLink}." already_pinned: one: "Argomenti attualmente appuntati in %{categoryLink}: %{count}" @@ -2512,18 +2500,18 @@ it: confirm_pin_globally: one: "Hai già %{count} argomento puntato globalmente. Troppi argomenti puntati possono rappresentare un peso per gli utenti nuovi e anonimi. Sei sicuro di voler puntare un altro argomento globalmente?" other: "Hai già %{count} argomenti puntati globalmente. Troppi argomenti puntati possono rappresentare un peso per gli utenti nuovi e anonimi. Sei sicuro di voler puntare un altro argomento globalmente?" - unpin_globally: "Togli questo argomento dalla cima degli altri argomenti." + unpin_globally: "Togli questo argomento dalla cima di tutti gli elenchi di argomenti." unpin_globally_until: "Rimuovi questo argomento dalla cima di tutte le liste di argomenti o attendi fino a %{until}." - global_pin_note: "Gli utenti possono spuntare gli argomenti autonomamente per loro stessi." + global_pin_note: "Gli utenti possono sbloccare gli argomenti individualmente per loro stessi." not_pinned_globally: "Non ci sono argomenti appuntati globalmente." already_pinned_globally: one: "Argomenti attualmente appuntati globalmente: %{count}" other: "Argomenti attualmente appuntati globalmente: %{count}" - make_banner: "Rendi questo argomento un annuncio che apparirà in cima a tutte le pagine." - remove_banner: "Rimuovi lo striscione che appare in cima a tutte le pagine." - banner_note: "Gli utenti possono nascondere l'annuncio chiudendolo. Solo un argomento per volta può diventare un annuncio." - no_banner_exists: "Non c'è alcun argomento annuncio." - banner_exists: "C'è attualmente un argomento annuncio." + make_banner: "Rendi questo argomento un banner che apparirà in cima a tutte le pagine." + remove_banner: "Rimuovi il banner che appare in cima a tutte le pagine." + banner_note: "Gli utenti possono nascondere il banner chiudendolo. Solo un argomento per volta può diventare un banner." + no_banner_exists: "Non c'è alcun argomento banner." + banner_exists: "C'è attualmente un argomento banner." inviting: "Sto invitando..." automatically_add_to_groups: "Questo invito include anche l'accesso ai seguenti gruppi:" invite_private: @@ -2553,7 +2541,7 @@ it: success_username: "Abbiamo invitato l'utente a partecipare all'argomento." error: "Spiacenti, non siamo riusciti ad invitare questa persona. E' stata per caso già invitata (gli inviti sono limitati)? " success_existing_email: "Esiste già un utente con email %{emailOrUsername}. Lo abbiamo invitato a partecipare a questo argomento." - login_reply: "Connettiti per Rispondere" + login_reply: "Accedi per Rispondere" filters: n_posts: one: "%{count} post" @@ -2575,7 +2563,7 @@ it: merge_topic: title: "Sposta in Argomento Esistente" action: "sposta in un argomento esistente" - error: "Si è verificato un errore nello spostare i messaggi nell'argomento." + error: "Si è verificato un errore spostando i messaggi nell'argomento." radio_label: "Argomento Esistente" instructions: one: "Per favore scegli l'argomento dove spostare il messaggio." @@ -2629,9 +2617,9 @@ it: change_timestamp: title: "Cambia Marca Temporale..." action: "cambia marca temporale" - invalid_timestamp: "Il timestamp non può essere nel futuro." - error: "Errore durante la modifica del timestamp dell'argomento." - instructions: "Seleziona il nuovo timestamp per l'argomento. I messaggi nell'argomento saranno aggiornati in modo che abbiano lo stesso intervallo temporale." + invalid_timestamp: "La marca temporale non può essere nel futuro." + error: "Errore durante la modifica della marca temporale dell'argomento." + instructions: "Seleziona la nuova marca temporale per l'argomento. I messaggi nell'argomento saranno aggiornati in modo che abbiano lo stesso intervallo temporale." multi_select: select: "scegli" selected: "selezionati (%{count})" @@ -2668,10 +2656,10 @@ it: continue_discussion: "Continua la discussione da %{postLink}:" follow_quote: "vai al messaggio citato" show_full: "Mostra Messaggio Completo" - show_hidden: "Visualizza contenuto ignorato" + show_hidden: "Visualizza contenuto ignorato." deleted_by_author_simple: "(messaggio eliminato dall'autore)" - collapse: "raggruppa" - expand_collapse: "espandi/raggruppa" + collapse: "comprimi" + expand_collapse: "espandi/comprimi" locked: "un membro dello Staff ha bloccato le modifiche a questo messaggio" gap: one: "visualizza %{count} risposta nascosta" @@ -2712,7 +2700,7 @@ it: upload_not_authorized: "Spiacenti, il file che stai cercando di caricare non è autorizzato (estensioni autorizzate: %{authorized_extensions})." image_upload_not_allowed_for_new_user: "Spiacenti, i nuovi utenti non possono caricare immagini." attachment_upload_not_allowed_for_new_user: "Spiacenti, i nuovi utenti non possono caricare allegati." - attachment_download_requires_login: "Spiacenti, devi essere connesso per poter scaricare gli allegati." + attachment_download_requires_login: "Spiacenti, devi effettuare l'accesso per poter scaricare gli allegati." cancel_composer: confirm: "Cosa vorresti fare con il tuo messaggio?" discard: "Elimina" @@ -2725,7 +2713,7 @@ it: about: "questo messaggio è una wiki" archetypes: save: "Opzioni di salvataggio" - few_likes_left: "Grazie per aver condiviso l'amore! Hai ancora pochi \"Mi piace\" rimasti per oggi." + few_likes_left: "Grazie per aver condiviso affetto! Hai ancora pochi \"Mi piace\" rimasti per oggi." controls: reply: "inizia a comporre una risposta a questo messaggio" like: "metti \"Mi piace\" al messaggio" @@ -2734,12 +2722,12 @@ it: undo_like: "rimuovi il \"Mi piace\"" edit: "modifica questo messaggio" edit_action: "Modifica" - edit_anonymous: "Spiacenti, devi essere connesso per poter modificare questo messaggio." + edit_anonymous: "Spiacenti, devi effettuare l'accesso per poter modificare questo messaggio." flag: "segnala privatamente questo messaggio o invia una notifica privata" delete: "cancella questo messaggio" undelete: "recupera questo messaggio" share: "condividi un collegamento a questo messaggio" - more: "Di più" + more: "Altro" delete_replies: confirm: "Vuoi eliminare anche le risposte a questo messaggio?" direct_replies: @@ -2755,7 +2743,7 @@ it: convert_to_moderator: "Aggiungi Colore Staff" revert_to_regular: "Rimuovi Colore Staff" rebake: "Ricrea HTML" - publish_page: "Pubblicazione Pagine" + publish_page: "Pubblicazione Pagina" unhide: "Mostra nuovamente" change_owner: "Cambia Proprietà" grant_badge: "Assegna Distintivo" @@ -3591,8 +3579,6 @@ it: available: "Il nome del gruppo è disponibile" not_available: "Il nome del gruppo non è disponibile" blank: "Il nome del Gruppo non può essere vuoto" - add_members: - as_owner: "Imposta l'utente(i) come proprietario di questo gruppo" manage: interaction: email: Email diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index 8253c7c07b..bf55490b9b 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -24,70 +24,70 @@ ja: thousands: "%{number}k" millions: "%{number}M" dates: - time: "h:mm a" - time_with_zone: "hh:mm a (z)" - time_short_day: "ddd, h:mm a" - timeline_date: "MMM YYYY" - long_no_year: "MMM D, h:mm a" - long_no_year_no_time: "MMM D" - full_no_year_no_time: "MMMM Do" - long_with_year: "MMM D, YYYY h:mm a" - long_with_year_no_time: "YYYY年M月D日" - full_with_year_no_time: "YYYY年M月D日" - long_date_with_year: "'YY/MM/DD LT" - long_date_without_year: "M月D日 LT" - long_date_with_year_without_time: "'YY/MM/DD" - long_date_without_year_with_linebreak: "M月D日
    LT" - long_date_with_year_with_linebreak: "'YY/MM/DD
    LT" + time: "a h:mm" + time_with_zone: "a hh:mm (z)" + time_short_day: "ddd a h:mm" + timeline_date: "YYYY 年 MMM" + long_no_year: "MMM D 日 a h:mm" + long_no_year_no_time: "MMM D 日" + full_no_year_no_time: "MMMM D 日" + long_with_year: "YYYY 年 MMM D 日 a h:mm" + long_with_year_no_time: "YYYY 年 MMM D 日" + full_with_year_no_time: "YYYY 年 MMMM D 日" + long_date_with_year: "'YY 年 MMM D 日 LT" + long_date_without_year: "MMM D 日 LT" + long_date_with_year_without_time: "'YY 年 MMM D 日" + long_date_without_year_with_linebreak: "MMM D 日
    LT" + long_date_with_year_with_linebreak: "'YY 年 MMM D 日
    LT" wrap_ago: "%{date}前" tiny: - half_a_minute: "1分未満" + half_a_minute: "1 分未満" less_than_x_seconds: - other: "%{count}秒未満" + other: "%{count} 秒未満" x_seconds: - other: "%{count}秒" + other: "%{count} 秒" less_than_x_minutes: - other: "%{count}分未満" + other: "%{count} 分未満" x_minutes: - other: "%{count}分" + other: "%{count} 分" about_x_hours: - other: "%{count}時間" + other: "%{count} 時間" x_days: - other: "%{count}日" + other: "%{count} 日" x_months: - other: "%{count}か月" + other: "%{count} か月" about_x_years: - other: "%{count}年" + other: "%{count} 年" over_x_years: - other: "%{count}年以上" + other: "%{count} 年以上" almost_x_years: - other: "約%{count}年" - date_month: "M月D日" - date_year: "'YY年M月" + other: "約 %{count} 年" + date_month: "MMM D 日" + date_year: "'YY 年 MMM" medium: x_minutes: - other: "%{count}分" + other: "%{count} 分" x_hours: - other: "%{count}時間" + other: "%{count} 時間" x_days: - other: "%{count}日" - date_year: "'YY年M月D日" + other: "%{count} 日" + date_year: "'YY 年 MMM D 日" medium_with_ago: x_minutes: - other: "%{count}分前" + other: "%{count} 分前" x_hours: - other: "%{count}時間前" + other: "%{count} 時間前" x_days: - other: "%{count}日前" + other: "%{count} 日前" x_months: - other: "%{count}か月前" + other: "%{count} か月前" x_years: - other: "%{count}年前" + other: "%{count} 年前" later: x_days: - other: "%{count}日後" + other: "%{count} 日後" x_months: - other: "%{count}か月後" + other: "%{count} か月後" x_years: other: "%{count} 年後" previous_month: "前月" @@ -107,16 +107,16 @@ ja: split_topic: "このトピックを分割しました: %{when}" invited_user: "%{who} を招待しました: %{when}" invited_group: "%{who} を招待しました: %{when}" - user_left: "%{who} はこのメッセージから退出しました: %{when}" + user_left: "%{who} は %{when} にこのメッセージから退出しました" removed_user: "%{who} を削除しました: %{when}" removed_group: "%{who} を削除しました: %{when}" autobumped: "自動的にトップに上げられました: %{when}" autoclosed: - enabled: "閉じられました: %{when}" - disabled: "開かれました: %{when}" + enabled: "クローズされました: %{when}" + disabled: "オープンされました: %{when}" closed: - enabled: "閉じられました: %{when}" - disabled: "開かれました: %{when}" + enabled: "クローズされました: %{when}" + disabled: "オープンされました: %{when}" archived: enabled: "アーカイブされました: %{when}" disabled: "アーカイブを解除されました: %{when}" @@ -130,20 +130,20 @@ ja: enabled: "表示: %{when}" disabled: "非表示: %{when}" banner: - enabled: "これを%{when}にバナーにしました。ユーザーが閉じるまで各ページの上部に表示されます。" + enabled: "%{when}にこれをバナーにしました。ユーザーが閉じるまで各ページの上部に表示されます。" disabled: "%{when}にこのバナーを削除しました。今後ページの上部に表示されることはありません。" forwarded: "上記のメールを転送しました" - topic_admin_menu: "トピックアクション" - wizard_required: "Discourseへようこそ! セットアップウィザードから始めましょう" + topic_admin_menu: "トピックの操作" + wizard_required: "Discourse へようこそ! セットアップウィザードから始めましょう" emails_are_disabled: "メールの送信は管理者によって無効化されています。メール通知は一切送信されません。" software_update_prompt: dismiss: "閉じる" bootstrap_mode_enabled: - other: "新しいサイトを簡単に立ち上げられるように、ブートストラップモードになっています。すべての新規ユーザーの信頼レベルは1となり、要約メールを毎日受け取るように設定されています。この設定は%{count}名のユーザーが参加すると自動的に無効になります。" - bootstrap_mode_disabled: "ブートストラップモードは24時間以内に無効になります。" + other: "新しいサイトを簡単に立ち上げられるように、ブートストラップモードになっています。すべての新規ユーザーの信頼レベルは 1 となり、要約メールを毎日受け取るように設定されています。この設定は %{count} 名のユーザーが参加すると自動的に無効になります。" + bootstrap_mode_disabled: "ブートストラップモードは 24 時間以内に無効になります。" themes: default_description: "デフォルト" - broken_theme_alert: "テーマ/コンポーネントの%{theme}にエラーがあるため、サイトが動作しない可能性があります。%{path}で無効にしてください。" + broken_theme_alert: "テーマ/コンポーネントの %{theme} にエラーがあるため、サイトが動作しない可能性があります。%{path} で無効にしてください。" s3: regions: ap_northeast_1: "アジア・太平洋 (東京)" @@ -175,7 +175,6 @@ ja: submit: "送信" generic_error: "申し訳ありません。エラーが発生しました。" generic_error_with_reason: "エラーが発生しました: %{error}" - go_ahead: "どうぞ" sign_up: "アカウントを作成" log_in: "ログイン" age: "年齢" @@ -202,7 +201,6 @@ ja: more: "もっと" x_more: other: "他 %{count}" - less: "減らす" never: "なし" every_30_minutes: "30 分毎" every_hour: "1 時間毎" @@ -211,7 +209,6 @@ ja: every_month: "毎月" every_six_months: "6 か月毎" max_of_count: "最大 %{count}" - alternation: "または" character_count: other: "%{count} 文字" related_messages: @@ -221,15 +218,15 @@ ja: title: "推奨トピック" pm_title: "推奨メッセージ" about: - simple_title: "このサイトについて" + simple_title: "サイト情報" title: "%{title}について" stats: "サイトの統計" our_admins: "管理者" our_moderators: "モデレーター" moderators: "モデレーター" stat: - all_time: "すべて" - like_count: "いいね" + all_time: "全期間" + like_count: "「いいね!」数" topic_count: "トピック" post_count: "投稿" user_count: "ユーザー" @@ -242,7 +239,6 @@ ja: help: bookmark: "クリックしてこのトピックの最初の投稿をブックマークします" unbookmark: "クリックしてこのトピック内のすべてのブックマークを削除します" - unbookmark_with_reminder: "クリックしてこのトピック内のすべてのブックマークとリマインダーを削除します。このトピックには%{reminder_at}のリマインダーが設定されています。" bookmarks: created: "この投稿をブックマークしました。%{name}" not_bookmarked: "この投稿をブックマークする" @@ -320,7 +316,7 @@ ja: placeholder: "ここにメッセージのタイトル、URL、または ID を入力してください" review: order_by: "並べ替え順" - in_reply_to: "次への返信:" + in_reply_to: "返信" explain: why: "この項目が、キューに追加された理由を説明してください" title: "レビュー待ち項目スコア" @@ -330,7 +326,7 @@ ja: min_score_visibility: "表示する最低スコア" score_to_hide: "投稿を非表示にするスコア" take_action_bonus: - name: "とった行動" + name: "対応済み" title: "スタッフが対応すると、フラグにボーナスが与えられます。" user_accuracy_bonus: name: "ユーザーの正確性" @@ -378,7 +374,7 @@ ja: username: "ユーザー名" email: "メール" name: "名前" - fields: "広場" + fields: "フィールド" reject_reason: "理由" user_percentage: agreed: @@ -451,9 +447,9 @@ ja: title: "通報された投稿" flagged_by: "通報者:" reviewable_queued_topic: - title: "キューに登録されたトピック" + title: "待機中のトピック" reviewable_queued_post: - title: "キューに登録された投稿" + title: "待機中の投稿" reviewable_user: title: "ユーザー" reviewable_post: @@ -466,7 +462,7 @@ ja: ok: "OK" example_username: "ユーザー名" reject_reason: - title: "このユーザーを拒否する理由は何ですか?" + title: "なぜこのユーザーを拒否しますか?" send_email: "拒否メールを送信" relative_time_picker: minutes: @@ -476,7 +472,7 @@ ja: days: other: "日" months: - other: "月" + other: "か月" time_shortcut: later_today: "今日の後程" next_business_day: "翌営業日" @@ -490,40 +486,40 @@ ja: relative: "相対時間" none: "不要" user_action: - user_posted_topic: "%{user} が トピック を作成" - you_posted_topic: "あなた が トピック を作成" - user_replied_to_post: "%{user} が %{post_number} に返信" - you_replied_to_post: "あなた が %{post_number} に返信" - user_replied_to_topic: "%{user} が トピック に返信" - you_replied_to_topic: "あなた が トピック に返信" - user_mentioned_user: "%{user} が %{another_user} をタグ付けしました" - user_mentioned_you: "%{user} が あなた を をタグ付けしました" - you_mentioned_user: "あなた が %{another_user} をタグ付けしました" - posted_by_user: "%{user} が投稿" - posted_by_you: "あなた が投稿" - sent_by_user: "%{user} が送信" - sent_by_you: "あなた が送信" + user_posted_topic: "%{user} がトピックを作成しました" + you_posted_topic: "あなたがトピックを作成しました" + user_replied_to_post: "%{user} が %{post_number} に返信しました" + you_replied_to_post: "あなた が %{post_number} に返信しました" + user_replied_to_topic: "%{user} がトピックに返信しました" + you_replied_to_topic: "あなた がトピックに返信しました" + user_mentioned_user: "%{user} が %{another_user} をメンションしました" + user_mentioned_you: "%{user} があなたをメンションしました" + you_mentioned_user: "あなたが %{another_user} をメンションしました" + posted_by_user: "投稿者: %{user}" + posted_by_you: "投稿者: あなた" + sent_by_user: "送信者: %{user}" + sent_by_you: "送信者: あなた" directory: - username: "ユーザ名" + username: "ユーザー名" filter_name: "ユーザー名でフィルタ" title: "ユーザー" - likes_given: "与えた" - likes_received: "もらった" + likes_given: "した" + likes_received: "された" topics_entered: "閲覧数" - topics_entered_long: "トピックの閲覧数" + topics_entered_long: "閲覧したトピック数" time_read: "読んだ時間" topic_count: "トピック" - topic_count_long: "作成されたトピックの数" + topic_count_long: "作成したトピック数" post_count: "返信" post_count_long: "投稿の返信数" - no_results: "結果はありませんでした" - days_visited: "閲覧数" - days_visited_long: "閲覧された日数" + no_results: "結果はありませんでした。" + days_visited: "アクセス" + days_visited_long: "アクセス日数" posts_read: "既読" - posts_read_long: "投稿の閲覧数" - last_updated: "最終更新:" + posts_read_long: "既読の投稿数" + last_updated: "最終更新:" total_rows: - other: "%{count}人のユーザー" + other: "ユーザー: %{count} 人" edit_columns: save: "保存" group: @@ -534,34 +530,26 @@ ja: add_user_to_group: "ユーザーを追加" remove_user_from_group: "ユーザーを削除" make_user_group_owner: "オーナーにする" - remove_user_as_group_owner: "オーナーから削除する" + remove_user_as_group_owner: "オーナーを取り消す" groups: member_added: "追加済み" - member_requested: "リクエスト日時:" + member_requested: "リクエスト日:" add_members: - title: "メンバーを %{group_name}に追加" - description: "カンマ区切りのリストに貼り付けることもできます。" - usernames_or_emails: - title: "ユーザー名またはメールアドレスを入力してください" - input_placeholder: "ユーザー名またはメール" - usernames: - input_placeholder: "ユーザー名" notify_users: "ユーザーに通知" requests: title: "リクエスト" reason: "理由" - accept: "受け入れ" - accepted: "受け入れられました" + accept: "承諾" + accepted: "承諾" deny: "拒否" - denied: "拒否されました" + denied: "拒否" undone: "リクエストの取り消し" handle: "メンバーシップリクエストを処理する" manage: title: "管理" name: "名前" full_name: "フルネーム" - add_members: "メンバーを加える" - delete_member_confirm: "%{username}をグループ %{group} から削除しますか?" + delete_member_confirm: "%{username} を %{group} グループから削除しますか?" profile: title: プロフィール interaction: @@ -569,23 +557,23 @@ ja: posting: 投稿 notification: 通知 email: - title: "Eメール" - status: "IMAP経由で %{old_emails} / %{total_emails} のメールを同期しました。" + title: "メール" + status: "IMAP 経由で %{old_emails} / %{total_emails} のメールを同期しました。" credentials: title: "資格情報" - smtp_server: "SMTPサーバー" - smtp_port: "SMTPポート" + smtp_server: "SMTP サーバー" + smtp_port: "SMTP ポート" smtp_ssl: "SMTP に SSL を使用する" - imap_server: "IMAP サーバ" + imap_server: "IMAP サーバー" imap_port: "IMAP ポート" - imap_ssl: "IMAPにSSLを使用する" + imap_ssl: "IMAP に SSL を使用する" username: "ユーザー名" password: "パスワード" settings: title: "設定" - allow_unknown_sender_topic_replies: "送信者不明のトピックの返信を許可。" + allow_unknown_sender_topic_replies: "不明な送信者によるトピックの返信を許可します。" mailboxes: - synchronized: "同期されたメールボックス" + synchronized: "同期メールボックス" none_found: "このメールアカウントにはメールボックスが見つかりませんでした。" membership: title: メンバーシップ @@ -593,57 +581,57 @@ ja: categories: title: カテゴリ long_title: "カテゴリのデフォルト通知" - description: "ユーザーがこのグループに追加されると、ユーザーのカテゴリ通知設定はこれらのデフォルトに設定されます。その後、彼らはそれらを変更することができます。" - watched_categories_instructions: "自動的にカテゴリー内の全てのトピックをウォッチします。グループのメンバーに全ての新規投稿と新規トピックが通知されまた、新規投稿数がトピックの隣に表示されます。" - tracked_categories_instructions: "自動的にカテゴリー内の全てのトピックを追跡します。新規投稿数がトピックの隣に表示されます。" - watching_first_post_categories_instructions: "ユーザーにカテゴリー内の新規トピックに対する最初の投稿が通知されます。" + description: "ユーザーがこのグループに追加されると、ユーザーのカテゴリ通知設定はこれらのデフォルトに設定されます。ユーザーは後でその設定を変更することができます。" + watched_categories_instructions: "カテゴリ内のすべてのトピックを自動的にウォッチします。グループのメンバーにすべての新しい投稿とトピックが通知され、トピックの隣に新しい投稿の件数が表示されます。" + tracked_categories_instructions: "カテゴリ内のすべてのトピックを自動的に追跡します。トピックの隣に新しい投稿の件数が表示されます。" + watching_first_post_categories_instructions: "これらのカテゴリの新規トピックに最初の投稿があった場合、それがユーザーに通知されます。" regular_categories_instructions: "これらのカテゴリがミュートされている場合、グループメンバーのミュートは解除されます。ユーザーがメンションされたり、誰かが返信したりすると、ユーザーに通知されます。" - muted_categories_instructions: "これらのカテゴリの新しいトピックについては通知されず、カテゴリや最新のページにも表示されません。" + muted_categories_instructions: "これらのカテゴリの新しいトピックに関する通知はユーザーに送信されません。また、カテゴリや最新のトピックページにも表示されません。" tags: title: タグ long_title: "タグのデフォルト通知" - description: "ユーザーがこのグループに追加されると、そのユーザーのカテゴリ通知設定はこのグループのデフォルトに設定されます。この設定は、各自で変更することができます。" - watched_tags_instructions: "自動的にカテゴリー内の全てのトピックをウォッチします。グループのメンバーに全ての新規投稿と新規トピックが通知され、新規投稿数がトピックの隣に表示されます。" - tracked_tags_instructions: "これらのタグのすべてのトピックを自動的に追跡します。 新しい投稿の数がトピックの横に表示されます。" - watching_first_post_tags_instructions: "これらのタグが付加された新規トピック内の初めての投稿は通知されます。" - regular_tags_instructions: "これらのタグがミュートされている場合、グループメンバーにはミュートが解除されます。ユーザーが言及されたり、誰かがそれに返信したりすると、ユーザーに通知されます。" - muted_tags_instructions: "このタグが付いている新しいトピックについては、ユーザーには何も通知されず、最新のものには表示されません。" + description: "ユーザーがこのグループに追加されると、そのユーザーのタグ通知設定はこれらのデフォルトに設定されます。ユーザーは後でその設定を変更することができます。" + watched_tags_instructions: "これらのタグが付いたすべてのトピックを自動的にウォッチします。グループのメンバーにすべての新しい投稿とトピックが通知され、トピックの隣に新しい投稿の件数が表示されます。" + tracked_tags_instructions: "これらのタグが付いたすべてのトピックを自動的に追跡します。トピックの横に新しい投稿の件数が表示されます。" + watching_first_post_tags_instructions: "これらのタグが付いた新しいトピック内の最初の投稿が通知されます。" + regular_tags_instructions: "これらのタグがミュートされている場合、グループメンバーにはミュートが解除されます。ユーザーがメンションされたり、誰かが返信したりすると、ユーザーに通知されます。" + muted_tags_instructions: "これらのタグが付いている新規トピックについては、ユーザーには何も通知されず、最新情報には表示されません。" logs: title: "ログ" - when: "いつ" - action: "アクション" - acting_user: "活動中のユーザー" - target_user: "ターゲットユーザー" + when: "日時" + action: "操作" + acting_user: "代理ユーザー" + target_user: "対象ユーザー" subject: "件名" details: "詳細" - from: "発信元" - to: "宛先" + from: "開始" + to: "終了" permissions: title: "権限" none: "このグループに関連付けられているカテゴリはありません。" - description: "このグループのメンバはこれらのカテゴリにアクセスできます" - public_admission: "ユーザーがグループへ自由に参加できるようにする (一般公開グループである必要があります)" - public_exit: "ユーザーがグループから自由に離脱できるようにする" + description: "このグループのメンバーはこれらのカテゴリにアクセスできます" + public_admission: "ユーザーがグループに自由に参加することを許可する (一般公開グループである必要があります)" + public_exit: "ユーザーがグループから自由に退出することを許可する" empty: posts: "このグループのメンバーによる投稿はありません。" members: "このグループにはメンバーがいません。" - requests: "このグループへのメンバーシップリクエストはありません" - mentions: "このグループに対するメンションはありません。" + requests: "このグループへのメンバーシップリクエストはありません。" + mentions: "このグループのメンションはありません。" messages: "このグループのメッセージはありません。" topics: "このグループのメンバーによるトピックはありません。" logs: "このグループに関するログはありません。" add: "追加" join: "参加" - leave: "脱退" + leave: "退出" request: "リクエスト" message: "メッセージ" - confirm_leave: "このグループから脱退してもよろしいですか?" - allow_membership_requests: "ユーザーがグループの所有者にメンバーシップリクエストを送信できるようにする (公開されているグループが必要)" - membership_request_template: "メンバーシップリクエスト送信時に表示するカスタムテンプレート" + confirm_leave: "このグループから退出してもよろしいですか?" + allow_membership_requests: "ユーザーがグループのオーナーにメンバーシップリクエストを送信することを許可する (一般公開グループである必要があります)" + membership_request_template: "メンバーシップリクエスト送信時にユーザーに表示するカスタムテンプレート" membership_request: submit: "リクエストを送信" - title: "参加をリクエスト @%{group_name}" - reason: "グループに参加したい旨をグループオーナーに伝える" + title: "@%{group_name} への参加をリクエストする" + reason: "グループに属する理由をグループオーナーに知らせる" membership: "メンバーシップ" name: "名前" group_name: "グループ名" @@ -654,20 +642,20 @@ ja: index: title: "グループ" all: "すべてのグループ" - empty: "表示するグループはありません。" - filter: "グループの種類でフィルタ" + empty: "公開グループはありません。" + filter: "グループのタイプでフィルタ" owner_groups: "自分が所有するグループ" close_groups: "クローズされたグループ" - automatic_groups: "自動で作成されたグループ" + automatic_groups: "自動作成グループ" automatic: "自動" - closed: "閉鎖" + closed: "クローズ" public: "公開" private: "非公開" public_groups: "公開グループ" - automatic_group: 自動作成されたグループ + automatic_group: 自動作成グループ close_group: クローズされたグループ my_groups: "自分のグループ" - group_type: "グループ種類" + group_type: "グループのタイプ" is_group_user: "メンバー" is_group_owner: "オーナー" title: @@ -675,129 +663,129 @@ ja: activity: "アクティビティ" members: title: "メンバー" - filter_placeholder_admin: "ユーザー名かメール" + filter_placeholder_admin: "ユーザー名またはメール" filter_placeholder: "ユーザー名" - remove_member: "メンバーを削除する" - remove_member_description: "グループから %{username} を削除する" - make_owner: "オーナーを作成する" + remove_member: "メンバーを削除" + remove_member_description: "このグループから %{username} を削除する" + make_owner: "オーナーにする" make_owner_description: "%{username} をこのグループのオーナーにする" remove_owner: "オーナーから削除" - remove_owner_description: "%{username} をこのグループから削除します" - make_primary: "優先にする" - make_primary_description: "これを %{username}のプライマリグループにします" + remove_owner_description: "%{username} をこのグループのオーナーから削除する" + make_primary: "プライマリにする" + make_primary_description: "これを %{username} のプライマリグループにする" remove_primary: "プライマリとして削除" - remove_primary_description: "%{username}のプライマリグループとしてこれを削除" - remove_members: "メンバーを削除する" - remove_members_description: "このグループから選択したユーザーを削除する" + remove_primary_description: "これを %{username} のプライマリグループとして削除する" + remove_members: "メンバーを削除" + remove_members_description: "選択したユーザーをこのグループから削除する" make_owners: "オーナーにする" make_owners_description: "選択したユーザーをこのグループのオーナーにする" - remove_owners: "オーナーから削除" - remove_owners_description: "このグループのオーナーとして、選択したユーザーを削除" + remove_owners: "オーナーを削除" + remove_owners_description: "このグループのオーナーとして、選択したユーザーを削除する" make_all_primary: "すべてをプライマリにする" - make_all_primary_description: "これを選択したすべてのユーザーのプライマリグループにします" + make_all_primary_description: "これを選択したすべてのユーザーのプライマリグループにする" remove_all_primary: "プライマリとして削除" - remove_all_primary_description: "このグループをプライマリとして削除します" + remove_all_primary_description: "このグループをプライマリとして削除する" owner: "オーナー" - forbidden: "メンバーの閲覧は許可されていません" + forbidden: "メンバーの表示は許可されていません。" topics: "トピック" posts: "投稿" mentions: "メンション" messages: "メッセージ" notification_level: "グループメッセージのデフォルト通知レベル" alias_levels: - mentionable: "誰がこのグループに@メンションを送れますか?" + mentionable: "誰がこのグループに @メンションを送れますか?" messageable: "誰がこのグループにメッセージを送れますか?" - nobody: "無し" + nobody: "なし" only_admins: "管理者のみ" - mods_and_admins: "管理者とモデレータのみ" - members_mods_and_admins: "管理者、モデレータ、グループメンバーのみ" - owners_mods_and_admins: "管理者、モデレータ、グループメンバーのみ" - everyone: "だれでも" + mods_and_admins: "モデレーターと管理者のみ" + members_mods_and_admins: "グループメンバー、モデレーター、管理者のみ" + owners_mods_and_admins: "グループオーナー、モデレーター、管理者のみ" + everyone: "全員" notifications: watching: title: "ウォッチ中" - description: "全てのメッセージについて通知があり、新規返信数が表示されます。" + description: "すべてのメッセージの新規投稿について通知があり、新しい返信の件数が表示されます。" watching_first_post: - title: "最初の投稿をウォッチする" + title: "最初の投稿をウォッチ中" description: "このグループの新着メッセージは通知されますが、メッセージへの返信は通知されません。" tracking: title: "追跡中" - description: "誰かがあなた宛に返信をすると通知があり、新規返信数が表示されます。" + description: "誰かが @ユーザー名であなたをメンションするか返信すると通知され、新しい返信の件数が表示されます。" regular: - title: "デフォルト" - description: "他のユーザからメンションされた場合か、メッセージ内の投稿に返信された場合に通知されます。" + title: "通常" + description: "誰かが @ユーザー名であなたをメンションした場合やあなたに返信した場合に、通知されます。" muted: title: "ミュート" description: "このグループのすべてのメッセージは通知されません。" flair_url: "アバター画像" - flair_upload_description: "20px x 20px以上の正方形の画像を使用してください。" + flair_upload_description: "20 px x 20 px 以上の正方形の画像を使用してください。" flair_bg_color: "アバターの背景色" - flair_bg_color_placeholder: "(オプション) HEXカラー値" - flair_color: "アバター色" - flair_color_placeholder: "(オプション) HEXカラー値" - flair_preview_icon: "アイコンプレビュー" - flair_preview_image: "画像プレビュー" + flair_bg_color_placeholder: "(オプション) 16 進カラー値" + flair_color: "アバターの色" + flair_color_placeholder: "(オプション) 16 進カラー値" + flair_preview_icon: "アイコンのプレビュー" + flair_preview_image: "画像のプレビュー" flair_type: - icon: "アイコンを選択してください" - image: "画像のアップロード" + icon: "アイコンを選択する" + image: "画像をアップロードする" user_action_groups: - "1": "「いいね!」 " - "2": "「いいね!」 された" + "1": "「いいね!」した数" + "2": "「いいね!」された数" "3": "ブックマーク" "4": "トピック" "5": "返信" - "6": "反応" - "7": "タグ付け" + "6": "回答" + "7": "メンション" "9": "引用" "11": "編集" "12": "送信済み項目" - "13": "受信ボックス" + "13": "受信トレイ" "14": "保留" "15": "下書き" categories: all: "すべてのカテゴリ" all_subcategories: "すべて" - no_subcategory: "サブカテゴリなし" + no_subcategory: "なし" category: "カテゴリ" category_list: "カテゴリリストを表示" reorder: - title: "カテゴリの並び替え" - title_long: "カテゴリリストを並べ直します" + title: "カテゴリの並べ替え" + title_long: "カテゴリリストを並べ変える" save: "順番を保存" apply_all: "適用" position: "位置" posts: "投稿" topics: "トピック" - latest: "最近の投稿" + latest: "最新" toggle_ordering: "カテゴリの並び替えモードを切り替え" - subcategories: "サブカテゴリ:" + subcategories: "サブカテゴリ" muted: "ミュートされたカテゴリ" topic_sentence: - other: "%{count}トピック群" + other: "%{count}トピック" topic_stat_unit: week: "週" month: "月" topic_stat_sentence_week: - other: "先週 %{count} の新しいトピックが投稿されました。" + other: "先週、新しいトピックが %{count} 件投稿されました。" topic_stat_sentence_month: - other: "先月 %{count} 個の新しいトピックが投稿されました。" - n_more: "カテゴリ (%{count} 個以上)..." + other: "先月、新しいトピックが %{count} 件投稿されました。" + n_more: "カテゴリ (その他 %{count} 個)..." ip_lookup: - title: IPアドレスを検索 + title: IP アドレスを検索 hostname: ホスト名 - location: 現在地 - location_not_found: '(不明)' + location: 所在地 + location_not_found: (不明) organisation: 組織 phone: 電話 - other_accounts: "同じIPアドレスを持つアカウント:" - delete_other_accounts: "%{count}件削除" + other_accounts: "同じ IP アドレスを持つほかのアカウント:" + delete_other_accounts: "%{count} 件削除" username: "ユーザー名" - trust_level: "トラストレベル" - read_time: "読んだ時間" - topics_entered: "入力したトピック" - post_count: "# 投稿" + trust_level: "信頼レベル" + read_time: "閲覧時間" + topics_entered: "閲覧したトピック" + post_count: "投稿数" confirm_delete_other_accounts: "これらのアカウントを削除してもよろしいですか?" - powered_by: "MaxMindDBを使用する" + powered_by: "MaxMindDB を使用する" copied: "コピーしました" user_fields: none: "(オプションを選択)" @@ -809,30 +797,30 @@ ja: edit: "プロフィールを編集" download_archive: button_text: "すべてダウンロード" - confirm: "本当にご自身の投稿をダウンロードしたいですか。" - success: "ダウンロードが始まると、処理完了時に通知されます。" - rate_limit_error: "投稿者は1日1回ダウンロードすることが可能です、明日再度お試しください。" - new_private_message: "メッセージを作成" + confirm: "投稿をダウンロードしてもよろしいですか?" + success: "ダウンロードが始まりました。処理が完了するとメッセージで通知されます。" + rate_limit_error: "投稿のダウンロードは 1 日に 1 回のみ可能です。明日再度お試しください。" + new_private_message: "新規メッセージ" private_message: "メッセージ" private_messages: "メッセージ" user_notifications: filters: - filter_by: "フィルタで絞り込む" + filter_by: "フィルタ基準:" all: "すべて" read: "既読" unread: "未読" - ignore_duration_title: "ユーザを無視" + ignore_duration_title: "ユーザーを無視" ignore_duration_username: "ユーザー名" - ignore_duration_when: "期間:" + ignore_duration_when: "期間:" ignore_duration_save: "無視する" - ignore_duration_note: "無視の期間が過ぎると自動的に削除されますので、ご注意ください。" + ignore_duration_note: "無視の期間が過ぎると、無視したすべての項目は自動的に削除されることに注意してください。" ignore_duration_time_frame_required: "時間枠を選択してください" - ignore_no_users: "無視するユーザーはいません。" - ignore_option: "無視する" - ignore_option_title: "このユーザーに関連する通知は受信されず、それらのすべてのトピックと返信が非表示になります。" + ignore_no_users: "無視されたユーザーはいません。" + ignore_option: "無視" + ignore_option_title: "このユーザーに関連する通知は送信されません。またこのユーザーのすべてのトピックと返信は非表示になります。" add_ignored_user: "追加..." - mute_option: "通知しない" - mute_option_title: "このユーザーに関連する通知は届きません。" + mute_option: "ミュート" + mute_option_title: "このユーザーに関連する通知は送信されません。" normal_option: "通常" normal_option_title: "このユーザーがあなたに返信したり、引用したり、メンションすると通知されます。" notification_schedule: @@ -848,7 +836,7 @@ ja: friday: "金曜日" saturday: "土曜日" sunday: "日曜日" - to: "宛先" + to: "終了:" activity_stream: "アクティビティ" read: "既読" read_help: "最近読んだトピック" @@ -860,124 +848,124 @@ ja: save: "保存" clear: title: "クリア" - warning: "おすすめのトピックをクリアしてもよろしいですか?" + warning: "注目のトピックをクリアしてもよろしいですか?" use_current_timezone: "現在のタイムゾーンを使用" - profile_hidden: "このユーザーの公開プロフィールは非公開です" - expand_profile: "開く" + profile_hidden: "このユーザーの公開プロフィールは非公開です。" + expand_profile: "展開" collapse_profile: "折りたたむ" bookmarks: "ブックマーク" bio: "自己紹介" timezone: "タイムゾーン" - invited_by: "招待した人: " - trust_level: "トラストレベル" - notifications: "お知らせ" + invited_by: "招待した人:" + trust_level: "信頼レベル" + notifications: "通知" statistics: "統計" desktop_notifications: label: "ライブ通知" - not_supported: "申し訳ありません。そのブラウザは通知をサポートしていません。" + not_supported: "このブラウザでは通知がサポートされていません。" perm_default: "通知を有効にする" perm_denied_btn: "アクセス拒否" perm_denied_expl: "通知へのアクセスが拒否されました。ブラウザの設定から通知を許可してください。" disable: "通知を無効にする" enable: "通知を有効にする" - each_browser_note: '注:この設定は、使用するすべてのブラウザーで変更する必要があります。この設定に関係なく、「おやすみモード」では、すべての通知が無効になります。' + each_browser_note: '注意: 使用するすべてのブラウザでこの設定を変更する必要があります。「おやすみモード」では、この設定に関係なくすべての通知が無効になります。' consent_prompt: "あなたの投稿に返信があったときライブ通知しますか?" dismiss: "閉じる" - dismiss_notifications: "すべて既読にする" - dismiss_notifications_tooltip: "全ての未読の通知を既読にします" - first_notification: "最初の通知です! 始めるために選択してください。" + dismiss_notifications: "すべて閉じる" + dismiss_notifications_tooltip: "すべての未読の通知を既読にします" + first_notification: "最初の通知です!選択して開始してください。" dynamic_favicon: "ブラウザのアイコンに件数を表示する" skip_new_user_tips: - description: "新規ユーザーのヒントとバッジをスキップする" - not_first_time: "初めてではありませんか?" + description: "新規ユーザー向けオンボーディングのヒントとバッジをスキップする" + not_first_time: "初めてのご利用ではありませんか?" skip_link: "これらのヒントをスキップ" - theme_default_on_all_devices: "これをすべてのデバイスのデフォルトテーマにする" - color_scheme_default_on_all_devices: "すべてのデバイスでデフォルトの配色を設定する" - color_scheme: "配色" + theme_default_on_all_devices: "これをすべてのデバイスのデフォルトのテーマにする" + color_scheme_default_on_all_devices: "デフォルトの色スキームをすべてのデバイスに設定する" + color_scheme: "色スキーム" color_schemes: - default_description: "既定のテーマ" + default_description: "デフォルトのテーマ" disable_dark_scheme: "通常と同じ" - dark_instructions: "デバイスをダークモードに切り替えることで、ダークモードの配色をプレビューできます。" + dark_instructions: "デバイスをダークモードに切り替えると、ダークモードの色スキームをプレビューできます。" undo: "リセット" regular: "通常" dark: "ダークモード" default_dark_scheme: "(サイトのデフォルト)" dark_mode: "ダークモード" - dark_mode_enable: "自動ダークモード配色を有効にする" - text_size_default_on_all_devices: "これをすべての端末のデフォルトのテキストサイズにする" - allow_private_messages: "他のユーザーに私への個人メッセージの送信を許可する" - external_links_in_new_tab: "外部リンクをすべて別のタブで開く" + dark_mode_enable: "ダークモードの色スキームを自動的に有効にする" + text_size_default_on_all_devices: "これをすべてのデバイスのデフォルトのテキストサイズにする" + allow_private_messages: "ほかのユーザーが私に個人メッセージを送信することを許可する" + external_links_in_new_tab: "すべての外部リンクを新しいタブで開く" enable_quoting: "選択したテキストを引用して返信する" - enable_defer: "トピックを未読としてマークするために延期を有効にする" + enable_defer: "トピックを未読にマークして延期を有効にする" change: "変更" featured_topic: "注目のトピック" - moderator: "%{user} はモデレータです" + moderator: "%{user} はモデレーターです" admin: "%{user} は管理者です" - moderator_tooltip: "このユーザーはモデレータです" + moderator_tooltip: "このユーザーはモデレーターです" admin_tooltip: "このユーザーは管理者です" - silenced_tooltip: "このユーザーは消音状態です" - suspended_notice: "このユーザーは %{date} まで凍結状態です。" - suspended_permanently: "このユーザーは凍結されています" + silenced_tooltip: "このユーザーは投稿を禁止されています" + suspended_notice: "このユーザーは %{date} まで凍結されています。" + suspended_permanently: "このユーザーは凍結されています。" suspended_reason: "理由: " github_profile: "GitHub" - email_activity_summary: "アクティビティの情報" + email_activity_summary: "アクティビティの概要" mailing_list_mode: label: "メーリングリストモード" enabled: "メーリングリストモードを有効にする" instructions: | - この設定は、アクティビティの情報機能を無効化します。
    + この設定は、アクティビティの概要を無効化します。
    ミュートしているトピックやカテゴリはこれらのメールには含まれません。 - individual: "新しい投稿がある場合にメールで送る" - individual_no_echo: "自分以外の新しい投稿がある場合にメールで送る" - many_per_day: "投稿される度にメールで通知を受け取る (日に %{dailyEmailEstimate} 回程度)" - few_per_day: "投稿される度にメールで通知を受け取る (日に2回程度)" - warning: "メーリングリストモードです。メール通知設定が上書きされます。" + individual: "新しい投稿があるたびにメールで送る" + individual_no_echo: "自分以外の新しい投稿があるたびにメールで送る" + many_per_day: "新しい投稿があるたびにメールを受け取る (1 日 %{dailyEmailEstimate} 回程度)" + few_per_day: "新しい投稿があるたびにメールを受け取る (1日 2 回程度)" + warning: "メーリングリストモードです。メール通知設定が無効になります。" tag_settings: "タグ" watched_tags: "ウォッチ中" - watched_tags_instructions: "自動的にこれらのタグが付いた全てのトピックをウォッチします。全ての新規投稿と新規トピックが通知されまた、新規投稿数がトピックの隣に表示されます。" + watched_tags_instructions: "これらのタグが付いたすべてのトピックを自動的にウォッチします。すべての新しい投稿とトピックが通知され、トピックの隣に新しい投稿の件数が表示されます。" tracked_tags: "追跡" - tracked_tags_instructions: "これらのタグのすべてのトピックを自動的に追跡します。 新しい投稿の数がトピックの横に表示されます。" - muted_tags: "通知しない" - muted_tags_instructions: "これらのタグが付いたトピックについて何も通知されず、また新着にも表示されません。" + tracked_tags_instructions: "これらのタグが付いたすべてのトピックを自動的に追跡します。トピックの横に新しい投稿の件数が表示されます。" + muted_tags: "ミュート" + muted_tags_instructions: "これらのカテゴリの新しいトピックについては通知されず、最新にも表示されません。" watched_categories: "ウォッチ中" - watched_categories_instructions: "自動的にカテゴリー内の全てのトピックをウォッチします。全ての新規投稿と新規トピックが通知されまた、新規投稿数がトピックの隣に表示されます。" + watched_categories_instructions: "これらのカテゴリ内のすべてのトピックを自動的にウォッチします。すべての新しい投稿とトピックが通知され、トピックの隣に新しい投稿の件数が表示されます。" tracked_categories: "追跡中" - tracked_categories_instructions: "自動的にカテゴリー内の全てのトピックを追跡します。新規投稿数がトピックの隣に表示されます。" - watched_first_post_categories: "最初の投稿をウォッチする" - watched_first_post_categories_instructions: "カテゴリー内の新規トピックに対する最初の投稿が通知されます。" - watched_first_post_tags: "最初の投稿をウォッチする" - watched_first_post_tags_instructions: "これらのタグの新規トピック内の新規投稿は通知されます。" - muted_categories: "通知しない" - muted_categories_instructions: "これらのカテゴリの新しいトピックについては通知されず、カテゴリや最新のページにも表示されません。" - muted_categories_instructions_dont_hide: "グループ内の新規トピックについては何も通知されません。" + tracked_categories_instructions: "これらのカテゴリ内のすべてのトピックを自動的に追跡します。トピックの隣に新しい投稿の件数が表示されます。" + watched_first_post_categories: "最初の投稿をウォッチ中" + watched_first_post_categories_instructions: "これらのカテゴリの新規トピックに最初の投稿があった場合、通知されます。" + watched_first_post_tags: "最初の投稿をウォッチ中" + watched_first_post_tags_instructions: "これらのタグが付いた新しいトピック内の最初の投稿が通知されます。" + muted_categories: "ミュート" + muted_categories_instructions: "これらのカテゴリの新しいトピックに関する通知はユーザーに送信されません。また、カテゴリや最新のページにも表示されません。" + muted_categories_instructions_dont_hide: "これらのカテゴリの新しいトピックに関する通知は送信されません。" regular_categories: "通常" - regular_categories_instructions: "これらのカテゴリは「最新」と「トップ」のトピックリストに表示されます。" - no_category_access: "モデレータとしてカテゴリーへのアクセスが制限されたので、保存できませんでした。" - delete_account: "アカウントを削除する" - delete_account_confirm: "アカウントを削除してもよろしいですか?削除されたアカウントは復元できません。" - deleted_yourself: "あなたのアカウントは削除されました。" - delete_yourself_not_allowed: "もしアカウントを削除したい場合はスタッフに連絡をしてください" + regular_categories_instructions: "これらのカテゴリは「最新」と「人気」のトピックリストに表示されます。" + no_category_access: "カテゴリへのアクセスはモデレーターとして制限されているため、保存できません。" + delete_account: "アカウントを削除" + delete_account_confirm: "アカウントを永久に削除してもよろしいですか?この操作は元に戻せません!" + deleted_yourself: "あなたのアカウントは正常に削除されました。" + delete_yourself_not_allowed: "アカウントの削除を希望する場合は、スタッフメンバーに連絡をしてください。" unread_message_count: "メッセージ" admin_delete: "削除" users: "ユーザー" muted_users: "ミュート" - muted_users_instructions: "これらのユーザーからのすべての通知とPMを抑えます。" + muted_users_instructions: "これらのユーザーからのすべての通知と PM を抑制します。" allowed_pm_users: "許可" - allowed_pm_users_instructions: "これらのユーザーからのPMのみを許可します。" - allow_private_messages_from_specific_users: "特定のユーザーのみに私への個人メッセージの送信を許可する" - ignored_users: "無視する" - ignored_users_instructions: "これらのユーザーからのすべての投稿、通知、およびPMを抑えます。" - tracked_topics_link: "詳しく見る" - automatically_unpin_topics: "最後まで読んだら自動的に固定表示を解除する。" + allowed_pm_users_instructions: "これらのユーザーからの PM のみを許可します。" + allow_private_messages_from_specific_users: "特定のユーザーのみが私に個人メッセージを送信することを許可する" + ignored_users: "無視" + ignored_users_instructions: "これらのユーザーからのすべての投稿、通知、および PM を抑制します。" + tracked_topics_link: "表示" + automatically_unpin_topics: "最後に到達したら、自動的にトピックの固定表示を解除する。" apps: "アプリ連携" - revoke_access: "アクセス解除" - undo_revoke_access: "アクセス解除をやめる" - api_approved: "承認されました:" - api_last_used_at: "最終利用:" + revoke_access: "アクセスを取り消す" + undo_revoke_access: "アクセスの取り消しを元に戻す" + api_approved: "承認:" + api_last_used_at: "最終使用時間:" theme: "テーマ" save_to_change_theme: '「%{save_text}」をクリックするとテーマが更新されます' - home: "デフォルトホームページ" - staged: "段階" + home: "デフォルトのホームページ" + staged: "ステージング" staff_counters: flags_given: "役に立った通報" flagged_posts: "通報された投稿" @@ -987,12 +975,12 @@ ja: rejected_posts: "拒否された投稿" messages: all: "すべて" - inbox: "受信箱" + inbox: "受信トレイ" sent: "送信済み" archive: "アーカイブ" groups: "自分のグループ" bulk_select: "メッセージを選択" - move_to_inbox: "受信箱へ移動" + move_to_inbox: "受信トレイへ移動" move_to_archive: "アーカイブ" failed_to_move: "選択されたメッセージを移動できませんでした (ネットワークがダウンしている可能性があります)" select_all: "すべて選択" @@ -1009,204 +997,204 @@ ja: interface: "表示設定" apps: "アプリ連携" change_password: - success: "(メールを送信しました)" - in_progress: "(メールを送信中)" + success: "(メール送信済み)" + in_progress: "(メール送信中)" error: "(エラー)" emoji: "絵文字をロックする" - action: "パスワードリセット用メールを送信する" - set_password: "パスワードを設定する" - choose_new: "新規パスワードを決定" - choose: "パスワードを決定" + action: "パスワードのリセットメールを送信" + set_password: "パスワードを設定" + choose_new: "新しいパスワードを選択する" + choose: "パスワードを選択する" second_factor_backup: - title: "2要素バックアップコード" - regenerate: "API キーを再生成" - disable: "利用できません" - enable: "利用できます" - enable_long: "バックアップコードを有効にします" + title: "二要素認証のバックアップコード" + regenerate: "再生成" + disable: "無効化" + enable: "有効化" + enable_long: "バックアップコードを有効にする" manage: other: "バックアップコードを管理してください。 %{count} 個のバックアップコードが残っています。" copy_to_clipboard: "クリップボードにコピー" copy_to_clipboard_error: "クリップボードにコピーする際にエラーが発生しました" copied_to_clipboard: "クリップボードにコピーしました" - download_backup_codes: "バックアップコードのダウンロード" + download_backup_codes: "バックアップコードをダウンロードする" remaining_codes: other: "%{count} 個のバックアップコードが残っています。" - use: "バックアップコードを使用" - enable_prerequisites: "バックアップコードを生成する前に、最初の2 要素方式を有効にする必要があります。" + use: "バックアップコードを使用する" + enable_prerequisites: "バックアップコードを生成する前に、最初の二要素認証を有効にする必要があります。" codes: - title: "バックアップコードが作られました" - description: "これらのバックアップコードはそれぞれ1回しか使用できません。それらを安全でアクセス可能な場所に保管してください。" + title: "バックアップコードが生成されました" + description: "これらのバックアップコードはそれぞれ 1 回しか使用できません。アクセス可能な安全な場所に保管してください。" second_factor: - title: "2段階認証" - enable: "2要素認証の管理" - disable_all: "すべて無効" + title: "二要素認証" + enable: "二要素認証を管理" + disable_all: "すべて無効化" forgot_password: "パスワードを忘れましたか?" - confirm_password_description: "続行するにはパスワードを入力してください" + confirm_password_description: "続行するにはパスワードを確認してください" name: "名前" label: "コード" rate_limit: "別の認証コードを試す前に、しばらくお待ちください。" enable_description: | - サポートされているアプリ (Android – iOS) でこのQRコードをスキャンし、認証コードを入力します。 + サポートされているアプリ (Android – iOS) でこの QR コードをスキャンし、認証コードを入力します。 disable_description: "アプリから認証コードを入力してください" - show_key_description: "手動入力" + show_key_description: "手動で入力する" short_description: | - 1回限りのセキュリティコードでアカウントを保護します。 + 1 回限りのセキュリティコードでアカウントを保護します。 extended_description: | - 2要素認証は、パスワードに加えてワンタイムトークンを必要とすることで、アカウントに追加のセキュリティを追加します。 トークンは Android および iOS で生成できます。 - oauth_enabled_warning: "アカウントで2段階認証が有効になると、ソーシャルログインは無効になります。" - use: "認証アプリを使用" - enforced_notice: "このサイトにアクセスするには2段階認証を有効にする必要があります。" - disable: "利用できません" - disable_confirm: "すべての2要素認証を無効にしてもよろしいですか?" + 二要素認証は、パスワードに加えてワンタイムトークンを必要とすることで、アカウントにさらにセキュリティを追加します。トークンは Android および iOS デバイスで生成できます。 + oauth_enabled_warning: "アカウントで二要素認証が有効になると、ソーシャルログインは無効になります。" + use: "認証アプリを使用する" + enforced_notice: "このサイトにアクセスするには二要素認証を有効にする必要があります。" + disable: "無効化" + disable_confirm: "すべての二要素認証を無効にしてもよろしいですか?" save: "保存" edit: "編集" - edit_title: "認証器の編集" - edit_description: "認証器名" + edit_title: "認証アプリの編集" + edit_description: "認証アプリ名" enable_security_key_description: | - ハードウェアセキュリティキー 準備したら、下の[登録]ボタンを押します。 + ハードウェアセキュリティキーを準備したら、下の [登録] ボタンを押します。 totp: - title: "トークンベースの認証" - add: "認証器の追加" - default_name: "自分の認証器" + title: "トークンベースの認証アプリ" + add: "認証アプリを追加" + default_name: "自分の認証アプリ" name_and_code_required_error: "認証アプリから名前とコードを入力する必要があります。" security_key: register: "登録" title: "セキュリティキー" - add: "セキュリティキーの追加" + add: "セキュリティキーを追加" default_name: "メインセキュリティキー" - not_allowed_error: "セキュリティキーの登録プロセスがタイムアウトしたか、キャンセルされました。" + not_allowed_error: "セキュリティキーの登録プロセスがタイムアウトしたかキャンセルされました。" already_added_error: "このセキュリティキーはすでに登録されています。再度登録する必要はありません。" - edit: "セキュリティキーの編集" + edit: "セキュリティキーを編集" save: "保存" edit_description: "セキュリティキー名" - name_required_error: "セキュリティキーの名前を指定する必要があります。" + name_required_error: "セキュリティキーの名前を入力する必要があります。" change_about: - title: "プロフィールを変更" - error: "変更中にエラーが発生しました。" + title: "自己紹介を変更" + error: "この値を変更中にエラーが発生しました。" change_username: title: "ユーザー名を変更" confirm: "ユーザー名を変更してもよろしいですか?" taken: "このユーザー名は既に使われています。" - invalid: "このユーザー名は無効です。英数字のみ利用可能です。" + invalid: "このユーザー名は無効です。英数字のみを使用できます" add_email: title: "メールアドレスを追加" add: "追加" change_email: title: "メールアドレスを変更" - taken: "このメールアドレスは既に使われています。" - error: "メールアドレス変更中にエラーが発生しました。既にこのアドレスが使われているのかもしれません。" - success: "このアドレスにメールを送信しました。メールの指示に従って確認処理を行ってください。" - success_via_admin: "そのアドレスにメールを送信しました。ユーザーはメールの確認手順に従う必要があります。" - success_staff: "現在のメールアドレスにメールを送信しました。メールに従い手続きをおこなってください。" + taken: "このメールアドレスを使用できません。" + error: "メールアドレスを変更中にエラーが発生しました。このアドレスは既に使用されている可能性があります。" + success: "このアドレスにメールを送信しました。確認手順に従ってください。" + success_via_admin: "このアドレスにメールを送信しました。ユーザーはメールに記載の確認手順に従う必要があります。" + success_staff: "現在のメールアドレスにメールを送信しました。確認手順に従ってください。" change_avatar: - title: "プロフィール画像を変更" - gravatar: "%{gravatarName} に基づいて" + title: "プロフィール画像を変更する" + gravatar: "%{gravatarName} 取得場所:" gravatar_title: "%{gravatarName} のウェブサイトでアバターを変更する" - gravatar_failed: "そのメールアドレスで %{gravatarName} が見つかりませんでした。" + gravatar_failed: "このメールアドレスの %{gravatarName} は見つかりませんでした。" refresh_gravatar_title: "%{gravatarName} を更新" letter_based: "システムプロフィール画像" uploaded_avatar: "カスタム画像" - uploaded_avatar_empty: "カスタム画像を追加" - upload_title: "画像をアップロード" - image_is_not_a_square: "警告: アップロードされた画像は高さと幅が違うため切り落されました。" + uploaded_avatar_empty: "カスタム画像を追加する" + upload_title: "画像をアップロードする" + image_is_not_a_square: "警告: 幅と高さが等しくないため、画像をトリミングしました。" change_profile_background: title: "プロフィールヘッダー" - instructions: "プロフィールヘッダーは中央に配置され、デフォルトの幅は1110pxになります。" + instructions: "プロフィールヘッダーは中央揃えで、デフォルトの幅は 1110 px です。" change_card_background: - title: "ユーザーカードの背景画像" - instructions: "背景画像は、幅590pxで中央揃えになります" + title: "ユーザーカードの背景" + instructions: "背景画像は中央揃えで、デフォルトの幅は 590 px です。" change_featured_topic: title: "注目のトピック" - instructions: "このトピックへのリンクは、あなたのユーザーカードとプロフィールにあります。" + instructions: "このトピックへのリンクは、あなたのユーザーカードとプロフィールに表示されます。" email: title: "メールアドレス" - primary: "主要なメールアドレス" - secondary: "その他のメールアドレス" - primary_label: "プライマリー" + primary: "プライマリメールアドレス" + secondary: "セカンダリメールアドレス" + primary_label: "プライマリ" unconfirmed_label: "未確認" - resend_label: "確認メールを再送信" + resend_label: "確認メールを再送信する" resending_label: "送信中..." resent_label: "メールを送信しました" update_email: "メールアドレスを変更" - set_primary: "プライマリメールアドレスの設定" + set_primary: "プライマリメールアドレスを設定" destroy: "メールアドレスを削除" add_email: "代替メールアドレスを追加" - auth_override_instructions: "メールは、外部認証サービスから更新できます。" - no_secondary: "その他のメールアドレスはありません" - instructions: "他人に公開はされません" - admin_note: "注:管理者ユーザーが別の非管理者ユーザーの電子メールを変更すると、そのユーザーは元の電子メールアカウントでアクセスできなくなるため、パスワードのリセットメールが新しいアドレスに送信されます。ユーザーのメールアドレスは、パスワードのリセットプロセスが完了するまで変更されません。" - ok: "確認用メールを送信します" + auth_override_instructions: "メールアドレスは認証プロバイダーから更新できます。" + no_secondary: "セカンダリメールアドレスはありません" + instructions: "絶対に公開されません。" + admin_note: "注意: 管理者ユーザーが別の非管理者ユーザーのメールを変更すると、そのユーザーは元のメールアカウントにアクセスできなくなるため、パスワードのリセットメールが新しいアドレスに送信されます。ユーザーのメールアドレスは、パスワードのリセットプロセスが完了するまで変更されません。" + ok: "確認のためにメールを送信します" required: "メールアドレスを入力してください" invalid: "正しいメールアドレスを入力してください" - authenticated: "あなたのメールアドレスは %{provider} によって認証されています" - frequency_immediately: "あなたがまだ読んでいない未送信分の内容について今すぐメールを送ります。" + authenticated: "あなたのメールアドレスは %{provider} によって認証されました" + frequency_immediately: "メールの内容をまだ読んでいない場合は、今すぐメールを送信します。" frequency: - other: "最後に利用されてから%{count}分以上経過した場合にメールを送ります。" + other: "最後のアクセスから %{count} 分以上アクセスがない場合にのみメールを送信します。" associated_accounts: - title: "関連アカウント" + title: "リンクされているアカウント" connect: "接続" - revoke: "解除" + revoke: "取り消す" cancel: "キャンセル" - not_connected: "(接続されていない)" - confirm_modal_title: "%{provider} アカウントに接続" + not_connected: "(未接続)" + confirm_modal_title: "%{provider} アカウントを接続" confirm_description: - account_specific: "あなたの %{provider} アカウント '%{account_description}' は認証に使用されます。" - generic: "あなたの %{provider} アカウントは認証に使用されます。" + account_specific: "あなたの %{provider} アカウント '%{account_description}' が認証に使用されます。" + generic: "あなたの %{provider} アカウントが認証に使用されます。" name: title: "名前" - instructions: "氏名(任意)" + instructions: "氏名 (オプション)" instructions_required: "氏名" required: "名前を入力してください" - too_short: "名前が短いです" - ok: "問題ありません" + too_short: "名前が短かすぎます" + ok: "その名前で良さそうです" username: title: "ユーザー名" - instructions: "他人と被らず、空白を含まず、短い名前を入力してください。" - short_instructions: "@%{username} であなたにメンションを送ることができます" - available: "ユーザー名は利用可能です" - not_available: "利用できない名前です。 %{suggestion} などはどうでしょうか?" - not_available_no_suggestion: "そのユーザー名は利用できません" + instructions: "一意の短い名前、スペースの使用不可" + short_instructions: "@%{username} であなたをメンションできます" + available: "ユーザー名を使用できます" + not_available: "使用できません。%{suggestion} はいかがですか?" + not_available_no_suggestion: "使用できません" too_short: "ユーザー名が短すぎます" too_long: "ユーザー名が長すぎます" - checking: "ユーザー名が利用可能か確認しています..." - prefilled: "この登録ユーザー名にマッチするメールアドレスが見つかりました" + checking: "ユーザー名を使用できるか確認しています..." + prefilled: "メールアドレスはこの登録ユーザー名に一致しています" required: "ユーザー名を入力してください" edit: "ユーザー名を編集" locale: - title: "表示する言語" - instructions: "ユーザインターフェイスの言語です。ページを再読み込みした際に変更されます。" + title: "インターフェースの言語" + instructions: "ユーザーインターフェースの言語です。ページを更新すると変更されます。" default: "(デフォルト)" - any: "任意" + any: "すべて" password_confirmation: - title: "もう一度パスワードを入力してください。" + title: "パスワードを再入力" invite_code: title: "招待コード" instructions: "アカウント登録には招待コードが必要です" auth_tokens: - title: "最近使用した端末" + title: "最近使用したデバイス" details: "詳細" log_out_all: "すべてログアウトする" - not_you: "あなたではない?" - show_all: "すべてを見る(%{count})" - show_few: "表示件数を少なくする" + not_you: "あなたではありませんか?" + show_all: "すべて表示 (%{count})" + show_few: "表示を減らす" was_this_you: "これはあなたですか?" was_this_you_description: "あなたではない場合は、パスワードを変更しログアウトすることをお勧めします。" - browser_and_device: "%{browser} on %{device}" + browser_and_device: "%{device} の %{browser}" secure_account: "アカウントの保護" latest_post: "最後の投稿…" device_location: '%{device} – %{location}' browser_active: '%{browser} | 現在アクティブ' browser_last_seen: "%{browser} | %{date}" last_posted: "最後の投稿" - last_emailed: "最終メール" - last_seen: "最後のアクティビティ" + last_emailed: "最終メール送信日" + last_seen: "アクセス" created: "参加日" log_out: "ログアウト" - location: "場所" + location: "所在地" website: "ウェブサイト" email_settings: "メール" hide_profile_and_presence: "公開プロフィールとプレゼンス機能を非表示にする" - enable_physical_keyboard: "iPadで物理キーボードのサポートを有効にする" + enable_physical_keyboard: "iPad で物理キーボードのサポートを有効にする" text_size: title: "テキストサイズ" smallest: "最小" @@ -1218,61 +1206,61 @@ ja: notifications: "新しい通知" contextual: "新しいページのコンテンツ" like_notification_frequency: - title: "いいねされた時に通知" + title: "「いいね!」された時に通知する" always: "常時" - first_time_and_daily: "投稿の最初の「いいね」と毎日" - first_time: "最初の「いいね!」" + first_time_and_daily: "投稿の最初の「いいね!」と毎日" + first_time: "投稿の最初の「いいね!」" never: "通知しない" email_previous_replies: - title: "メールの文章の下部に以前の返信を含める" + title: "メールの下部に前の返信を含める" unless_emailed: "最初だけ" always: "常に含める" never: "含めない" email_digests: - title: "ここを訪れていないとき、人気のトピックと返信のサマリーをメールで送信する" - every_30_minutes: "30分毎" - every_hour: "1時間毎" + title: "ここを訪れない場合、人気のトピックと返信の要約をメールで送信する" + every_30_minutes: "30 分毎" + every_hour: "1 時間毎" daily: "毎日" weekly: "毎週" every_month: "毎月" - every_six_months: "6ヶ月毎" + every_six_months: "6 か月毎" email_level: - title: "誰かが投稿を引用した時、投稿に返信があった時、私のユーザ名にメンションがあった時、またはトピックへの招待があった時にメールで通知を受け取る。" - always: "常に含める" + title: "誰かが自分の投稿を引用またはそれに返信したとき、@ユーザー名で自分をメンションしたとき、または自分をトピックに招待したときにメールでその通知を受け取る" + always: "常に" only_when_away: "離れている時のみ" - never: "追跡しない" + never: "通知しない" email_messages_level: "メッセージを受け取ったときにメールで通知を受け取る" - include_tl0_in_digests: "サマリーをメールする際、新しいユーザーのコンテンツを含める" + include_tl0_in_digests: "要約メールに新規ユーザーのコンテンツを含める" email_in_reply_to: "メールに投稿への返信の抜粋を含める" other_settings: "その他" - categories_settings: "カテゴリの設定" + categories_settings: "カテゴリ" new_topic_duration: - label: "以下の条件でトピックを新規と見なす" - not_viewed: "未読" + label: "以下の場合、トピックを新規と見なす" + not_viewed: "未読のもの" last_here: "ログアウトした後に投稿されたもの" - after_1_day: "昨日投稿されたもの" - after_2_days: "2日前に投稿されたもの" - after_1_week: "先週投稿されたもの" - after_2_weeks: "2週間前に投稿されたもの" - auto_track_topics: "自動でトピックを追跡する" + after_1_day: "前日以降に投稿されたもの" + after_2_days: "2 日以内に投稿されたもの" + after_1_week: "先週以内に投稿されたもの" + after_2_weeks: "2 週間以内に投稿されたもの" + auto_track_topics: "閲覧したトピックを自動的に追跡する" auto_track_options: never: "追跡しない" - immediately: "今すぐ" - after_30_seconds: "30秒後" - after_1_minute: "1分後" - after_2_minutes: "2分後" - after_3_minutes: "3分後" - after_4_minutes: "4分後" - after_5_minutes: "5分後" - after_10_minutes: "10分後" - notification_level_when_replying: "トピックに投稿するときは、トピックを" + immediately: "すぐに" + after_30_seconds: "30 秒後" + after_1_minute: "1 分後" + after_2_minutes: "2 分後" + after_3_minutes: "3 分後" + after_4_minutes: "4 分後" + after_5_minutes: "5 分後" + after_10_minutes: "10 分後" + notification_level_when_replying: "トピックに投稿するときは、トピックを次に設定する" invited: title: "招待" pending_tab: "保留中" pending_tab_with_count: "保留中 (%{count})" - redeemed_tab: "確認済み" - redeemed_tab_with_count: "確認済み (%{count})" - invited_via: "招待" + redeemed_tab: "承諾済み" + redeemed_tab_with_count: "承諾済み (%{count})" + invited_via: "招待状" groups: "グループ" topic: "トピック" expires_at: "有効期限" @@ -1280,101 +1268,101 @@ ja: remove: "削除" reinvited: "再度招待しました" search: "招待履歴を検索" - user: "招待したユーザ" + user: "招待したユーザー" none: "表示する招待はありません。" truncated: - other: "%{count} 件の招待を表示しています。" - redeemed: "招待を確認する" - redeemed_at: "確認済み" + other: "最初の %{count} 件の招待を表示しています。" + redeemed: "承諾済みの招待" + redeemed_at: "承諾済み" pending: "保留中の招待" topics_entered: "閲覧したトピックの数" - posts_read_count: "読んだ投稿" - expired: "この招待の有効期限が切れました。" + posts_read_count: "既読の投稿数" + expired: "この招待は期限切れになりました。" remove_all: "期限切れの招待を削除" removed_all: "期限切れの招待はすべて削除されました!" remove_all_confirm: "期限切れの招待をすべて削除してもよろしいですか?" - reinvite_all_confirm: "本当に全ての招待を再送したいですか?" - time_read: "読んだ時間" - days_visited: "閲覧された日数" + reinvite_all_confirm: "すべての招待を再送してもよろしいですか?" + time_read: "閲覧時間" + days_visited: "アクセス日数" account_age_days: "アカウント有効日数" create: "招待" - generate_link: "招待リンクの作成" - link_generated: "これがあなたの招待リンクです!" - valid_for: "招待リンクはこのメールアドレスにだけ有効です。%{email}" + generate_link: "招待リンクを作成" + link_generated: "こちらが招待リンクです!" + valid_for: "招待リンクは次のメールアドレスのみで有効です: %{email}" single_user: "メールで招待" multiple_user: "リンクで招待" invite_link: title: "招待リンク" success: "招待リンクが生成されました!" error: "招待リンクの生成中にエラーが発生しました" - max_redemptions_allowed_label: "このリンクを使用して登録できる人数は何人ですか?" - expires_at: "この招待リンクの有効期限はいつまでですか?" + max_redemptions_allowed_label: "このリンクを使用して登録できるのは何人ですか?" + expires_at: "この招待リンクはいつまで有効ですか?" bulk_invite: none: "このページに表示する招待はありません。" text: "一括招待" - error: "申し訳ありません、ファイルはCSV形式である必要があります。" + error: "ファイルは CSV 形式である必要があります。" password: title: "パスワード" too_short: "パスワードが短すぎます。" - common: "このパスワードは他のユーザが使用している可能性があります。" - same_as_username: "パスワードがユーザ名と一致しています" - same_as_email: "パスワードとメールアドレスが一致しています" - ok: "最適なパスワードです。" - instructions: "最低 %{count} 文字以上" + common: "このパスワードは非常に一般的です。" + same_as_username: "パスワードとユーザー名が同じです。" + same_as_email: "パスワードとメールアドレスが同じです。" + ok: "そのパスワードで良さそうです。" + instructions: "%{count} 文字以上" required: "パスワードを入力してください" summary: - title: "サマリー" + title: "要約" stats: "統計" - time_read: "読んだ時間" - recent_time_read: "現在の読んだ時間" + time_read: "閲覧時間" + recent_time_read: "最近の閲覧時間" topic_count: - other: "つのトピックを作成" + other: "作成したトピック数" post_count: - other: "つの投稿" + other: "作成した投稿数" likes_given: - other: "与えた" + other: "「いいね!」した数" likes_received: - other: "もらった" + other: "「いいね!」された数" days_visited: - other: "訪問日数" + other: "アクセス日数" topics_entered: - other: "トピックの閲覧" + other: "閲覧したトピック数" posts_read: - other: "読んだ投稿" + other: "既読の投稿数" bookmark_count: - other: "つのブックマーク" - top_replies: "返信上位" + other: "ブックマーク数" + top_replies: "人気の返信" no_replies: "まだ返信はありません。" more_replies: "その他の返信" - top_topics: "トピック上位" + top_topics: "人気のトピック" no_topics: "まだトピックはありません。" more_topics: "その他のトピック" - top_badges: "最近ゲットしたバッジ" + top_badges: "人気のバッジ" no_badges: "まだバッジはありません。" - more_badges: "バッジをもっと見る" - top_links: "リンク上位" + more_badges: "その他のバッジ" + top_links: "人気のリンク" no_links: "まだリンクはありません。" - most_liked_by: "最も「いいね!」してくれた" - most_liked_users: "最も「いいね!」した" - most_replied_to_users: "最も返信した" - no_likes: "「いいね!」はまだありません。" - top_categories: "トップカテゴリ" + most_liked_by: "最も「いいね!」したユーザー" + most_liked_users: "最も「いいね!」されたユーザー" + most_replied_to_users: "最も多く返信したユーザー" + no_likes: "まだ「いいね!」はありません。" + top_categories: "人気のカテゴリ" topics: "トピック" replies: "返信" ip_address: - title: "最後のIPアドレス" + title: "最後の IP アドレス" registration_ip_address: - title: "登録時のIPアドレス" + title: "登録時の IP アドレス" avatar: title: "プロフィール画像" header_title: "プロフィール、メッセージ、ブックマーク、設定" - edit: "プロフィール画像の編集" + edit: "プロフィール画像を編集" title: - title: "肩書き" - none: "(なし)" + title: "タグライン" + none: "(なし)" primary_group: title: "プライマリーグループ" - none: "(なし)" + none: "(なし)" filters: all: "すべて" stream: @@ -1384,7 +1372,7 @@ ja: the_topic: "トピック" loading: "読み込み中..." errors: - prev_page: "右記の項目をロード中に発生: " + prev_page: "読み込み中" reasons: network: "ネットワークエラー" server: "サーバーエラー" @@ -1393,160 +1381,160 @@ ja: not_found: "ページが見つかりません" desc: network: "インターネット接続を確認してください。" - network_fixed: "ネットワーク接続が回復しました。" + network_fixed: "ネットワーク接続が回復したようです。" server: "エラーコード : %{status}" - forbidden: "閲覧する権限がありません" - not_found: "アプリケーションが存在しないURLを読み込もうとしました。" + forbidden: "閲覧する権限がありません。" + not_found: "アプリケーションは存在しない URL を読み込もうとしました。" unknown: "エラーが発生しました。" buttons: back: "戻る" again: "やり直す" - fixed: "ページロード" + fixed: "ページを読み込む" modal: close: "閉じる" dismiss_error: "エラーを閉じる" close: "閉じる" - logout: "ログアウトしました" + logout: "ログアウトしました。" refresh: "更新" home: "ホーム" read_only_mode: - enabled: "このサイトは閲覧専用モードになっています。閲覧し続けられますが、返信したりいいねを付けるなどのアクションは現在出来ません。" + enabled: "このサイトは閲覧専用モードになっています。閲覧し続けられますが、返信したり「いいね!」を付けるなどの操作は現在できません。" login_disabled: "閲覧専用モードのため、ログインできません。" logout_disabled: "閲覧専用モードのため、ログアウトできません。" too_few_topics_and_posts_notice_MF: >- - 議論を開始しましょう! そこには {currentTopics, plural, other { # トピック}} と {currentPosts, plural, other {# 投稿}} があります。訪問者はよく読んで返信する必要があります。最低でも {requiredTopics, plural, other {# トピック}} と {requiredPosts, plural, other {# 投稿}} をおすすめします。このメッセージを見ることができるのはスタッフだけです。 + ディスカッションを開始しましょう! 現在、{currentTopics, plural, one {# 件のトピック} other {# 件のトピック}}と{currentPosts, plural, one {# 件の投稿} other {# 件の投稿}}があります。訪問者が読んで返信できる項目がもっと必要です。少なくとも {requiredTopics, plural, one {# 件のトピック} other {# 件のトピック}}と{requiredPosts, plural, one {# 件の投稿} other {# 件の投稿}}を作成することをお勧めします。このメッセージはスタッフのみに表示されます。 too_few_topics_notice_MF: >- - 議論を開始しましょう! そこには {currentTopics, plural, other { # トピック}} があります。訪問者はよく読んで返信する必要があります。少なくとも {requiredTopics, plural, other {# トピック}}をおすすめします。このメッセージを見ることができるのはスタッフだけです。 + ディスカッションを開始しましょう! 現在、{currentTopics, plural, one {# 件のトピック} other {# 件のトピック}}があります。訪問者が読んで返信できる項目がもっと必要です。少なくとも {requiredTopics, plural, one {# 件のトピック} other {# 件のトピック}}を作成することをお勧めします。このメッセージはスタッフのみに表示されます。 too_few_posts_notice_MF: >- - 議論を始めましょう! そこには {currentPosts, plural, other { # 投稿}}があります。訪問者はよく読んで返信する必要があります。少なくとも {requiredPosts, plural, other {# 投稿}}をおすすめします。このメッセージを見ることができるのはスタッフだけです。 + ディスカッションを開始しましょう! 現在、{currentPosts, plural, one {# 件の投稿} other {# 件の投稿}}があります。訪問者が読んで返信できる項目がもっと必要です。少なくとも {requiredPosts, plural, one {# 件の投稿} other {# 件の投稿}}を作成することをお勧めします。このメッセージはスタッフのみに表示されます。 logs_error_rate_notice: - reached_hour_MF: "{relativeAge} – {rate, plural, other {# エラー/時間}} がサイト設定制限の {limit, plural, other {# エラー/時間}} に達しました。" - reached_minute_MF: "{relativeAge} – {rate, plural, other {# エラー/分}} がサイト設定制限の {limit, plural, other {#エラー/分}} に達しました 。" - exceeded_hour_MF: "{relativeAge} – {rate, plural, other {# エラー/時間}}サイト設定制限の {limit, plural, other {# エラー/時間}} を超えました 。" - exceeded_minute_MF: "{relativeAge} – {rate, plural, other {#エラー/分}} サイト設定制限の {limit, plural, other {#エラー/分}} 超えました。" - learn_more: "より詳しく..." + reached_hour_MF: "{relativeAge} – {rate, plural, other {1 時間当たりのエラー件数 (#)}} がサイトで設定されている{limit, plural, other {エラー件数 (#)}} の制限に達しました。" + reached_minute_MF: "{relativeAge} – {rate, plural, other {1 分当たりのエラー件数 (#)}} がサイトで設定されている{limit, plural, other {エラー件数 (#)}} の制限に達しました。" + exceeded_hour_MF: "{relativeAge} – {rate, plural, other {1 時間当たりのエラー件数 (#)}} がサイトで設定されている{limit, plural, other {エラー件数 (#)}} の制限を超えました。" + exceeded_minute_MF: "{relativeAge} – {rate, plural, other {1 分当たりのエラー件数 (#)}} がサイトで設定されている{limit, plural, other {エラー件数 (#)}} の制限を超えました。" + learn_more: "もっと詳しく..." first_post: 最初の投稿 mute: ミュート unmute: ミュート解除 last_post: 最終投稿 local_time: "現地時間" time_read: 既読 - time_read_recently: "最近の読んだ時間 %{time_read}" - time_read_tooltip: "合計読んだ時間 %{time_read}" - time_read_recently_tooltip: "合計読んだ時間 %{time_read} (過去 60 日間で%{recent_time_read})" + time_read_recently: "最近の閲覧時間 %{time_read}" + time_read_tooltip: "合計閲覧時間 %{time_read}" + time_read_recently_tooltip: "合計閲覧時間 %{time_read} (過去 60 日間: %{recent_time_read})" last_reply_lowercase: 最後の返信 replies_lowercase: other: 返信 signup_cta: - sign_up: "新しいアカウントを作成" - hide_session: "明日私に思い出させてください。" + sign_up: "サインアップ" + hide_session: "明日リマインダーを通知する" hide_forever: "いいえ、結構です" - intro: "こんにちは! :heart_eyes: エンジョイしていますね。 ですが、サインアップしていないようです。" - value_prop: "アカウントを作成した後、いま読んでいるページへ戻ります。また、新しい投稿があった場合はこことメールにてお知らせします。 いいね!を使って好きな投稿をみんなに教えましょう。 :heartbeat:" + intro: "こんにちは! ディスカッションを楽しんでいるようですね。ですが、サインアップはまだのようです。" + value_prop: "アカウントを作成した後、今読んでいるページへ戻ります。また、新しい投稿があった場合はそのことをメールでお知らせします。 「いいね!」を使って気に入った投稿をみんなに教えましょう。:heartpulse:" summary: - enabled_description: "トピックのまとめを表示されています。" + enabled_description: "このトピックの要約を閲覧しています。コミュニティが最も面白いとした投稿のまとめです。" description: - other: "%{count}件の返信があります。" + other: "%{count} 件の返信があります。" enable: "このトピックを要約する" - disable: "すべての投稿を表示する" + disable: "すべての投稿を表示" deleted_filter: enabled_description: "削除された投稿は非表示になっています。" - disabled_description: "削除された投稿は表示しています。" - enable: "削除された投稿を非表示にする" - disable: "削除された投稿を表示する" + disabled_description: "削除された投稿は表示されています。" + enable: "削除された投稿を非表示" + disable: "削除された投稿を表示" private_message_info: title: "メッセージ" - invite: "他の人を招待する..." - edit: "追加、削除" + invite: "他の人を招待..." + edit: "追加または削除..." remove: "削除..." add: "追加..." - leave_message: "本当にメッセージから離れますか?" + leave_message: "このメッセージを閉じてもよろしいですか?" remove_allowed_user: "このメッセージから %{name} を削除してもよろしいですか?" remove_allowed_group: "このメッセージから %{name} を削除してもよろしいですか?" email: "メール" - username: "ユーザ名" - last_seen: "最終アクティビティ" + username: "ユーザー名" + last_seen: "アクセス" created: "作成" - created_lowercase: "投稿者" - trust_level: "トラストレベル" - search_hint: "ユーザ名、メールアドレスまたはIPアドレス" + created_lowercase: "作成" + trust_level: "信頼レベル" + search_hint: "ユーザー名、メールアドレス、または IPアドレス" create_account: header_title: "ようこそ!" subheader_title: "アカウントを作成しましょう" - disclaimer: "登録するとプライバシーポリシーと利用規約に同意することになります。" + disclaimer: "登録すると、プライバシーポリシーと利用規約に同意することになります。" title: "アカウントの作成" - failed: "エラーが発生しました。既にこのメールアドレスは使用中かもしれません。「パスワードを忘れました」リンクを試してみてください" + failed: "エラーが発生しました。このメールアドレスは使用中かもしれません。「パスワードを忘れました」リンクを試してみてください" forgot_password: title: "パスワードをリセット" action: "パスワードを忘れました" - invite: "ユーザ名かメールアドレスを入力してください。パスワードリセット用のメールを送信します。" + invite: "ユーザー名またはメールアドレスを入力してください。パスワードのリセットメールを送信します。" reset: "パスワードをリセット" - complete_username: "%{username},アカウントにパスワード再設定メールを送りました。" - complete_email: "%{email}宛にパスワード再設定メールを送信しました。" - complete_username_found: "%{username} に一致するアカウントが見つかりました。まもなく、パスワードのリセット方法が記載されたメールが届きます。" + complete_username: "アカウントがユーザー名 %{username} に一致する場合、まもなく、パスワードのリセット方法が記載されたメールが届きます。" + complete_email: "アカウントと %{email} が一致する場合、まもなく、パスワードのリセット方法が記載されたメールが届きます。" + complete_username_found: "ユーザー名 %{username} に一致するアカウントが見つかりました。まもなく、パスワードのリセット方法が記載されたメールが届きます。" complete_email_found: "%{email} に一致するアカウントが見つかりました。まもなく、パスワードのリセット方法が記載されたメールが届きます。" - complete_username_not_found: " %{username}は見つかりませんでした" - complete_email_not_found: "%{email}で登録したアカウントがありません。" - help: "メールが届かない?最初に迷惑メールフォルダを確認してください。

    使用したメールアドレスがわかりませんか?メールアドレスを入力すると、存在するかどうかをお知らせします。

    アカウントのメールアドレスにアクセスできなくなった場合は、スタッフにご連絡ください。

    " + complete_username_not_found: "ユーザー名 %{username} に一致するアカウントはありません" + complete_email_not_found: "%{email} に一致するアカウントはありません" + help: "メールが届きませんか?まずは迷惑メールフォルダを確認してください。

    使用したメールアドレスがわかりませんか?メールアドレスを入力すると、存在するかどうかをお知らせします。

    アカウントのメールアドレスにアクセスできなくなった場合は、スタッフにご連絡ください。

    " button_ok: "OK" button_help: "ヘルプ" email_login: link_label: "ログインリンクをメールする" button_label: "メール" emoji: "絵文字をロックする" - complete_username: "アカウントがユーザー名 %{username}と一致する場合、まもなくログインリンクが記載されたメールが届きます。" - complete_email: "アカウントが %{email}と一致する場合、まもなくログインリンクが記載されたメールが届きます。" + complete_username: "アカウントがユーザー名 %{username} と一致する場合、まもなくログインリンクが記載されたメールが届きます。" + complete_email: "アカウントが %{email} と一致する場合、まもなくログインリンクが記載されたメールが届きます。" complete_username_found: "%{username} に一致するアカウントが見つかりました。まもなく、ログインリンクが記載されたメールが届きます。" complete_email_found: "%{email} に一致するアカウントが見つかりました。まもなく、ログインリンクが記載されたメールが届きます。" - complete_username_not_found: " %{username}は見つかりませんでした" - complete_email_not_found: "%{email} にアカウントはありません" - confirm_title: '%{site_name}へ' + complete_username_not_found: "ユーザー名 %{username} に一致するアカウントはありません" + complete_email_not_found: "%{email} に一致するアカウントはありません" + confirm_title: '%{site_name} に進む' logging_in_as: '%{email} としてログイン' confirm_button: ログイン完了 login: subheader_title: "アカウントにログインする" - username: "ユーザ名" + username: "ユーザー" password: "パスワード" - second_factor_title: "2要素認証" + second_factor_title: "二要素認証" second_factor_description: "アプリから認証コードを入力してください:" - second_factor_backup: "バックアップ コードを使用してログイン" - second_factor_backup_title: "2段階バックアップ" - second_factor_backup_description: "バックアップコードを入力: " - second_factor: "認証アプリを使用してログイン" - security_key_description: "物理的なセキュリティキーを用意している場合は、以下のセキュリティキーで認証ボタンを押します。" + second_factor_backup: "バックアップ コードを使用してログインする" + second_factor_backup_title: "二要素バックアップ" + second_factor_backup_description: "バックアップコードを入力してください:" + second_factor: "認証アプリを使用してログインする" + security_key_description: "物理的なセキュリティキーの準備ができたら、[セキュリティキーで認証] ボタンを押します。" security_key_alternative: "別の方法を試してください" security_key_authenticate: "セキュリティキーで認証" security_key_not_allowed_error: "セキュリティキー認証プロセスがタイムアウトしたか、キャンセルされました。" security_key_no_matching_credential_error: "提供されたセキュリティキーに一致する資格情報が見つかりませんでした。" security_key_support_missing_error: "現在のデバイスまたはブラウザは、セキュリティキーの使用をサポートしていません。別の方法を使用してください。" - email_placeholder: "メールアドレス / ユーザ名" - caps_lock_warning: "Caps Lockがオンになっています。" + email_placeholder: "メール / ユーザー名" + caps_lock_warning: "Caps Lock がオンになっています" error: "不明なエラー" - cookies_error: "お使いのブラウザでCookieが無効になっているようです。最初に有効にしないとログインできない場合があります。" + cookies_error: "お使いのブラウザで Cookie が無効になっているようです。有効でない場合、ログインできないことがあります。" rate_limit: "しばらく待ってから再度ログインをお試しください。" - blank_username: "あなたのメールアドレスかユーザー名を入力してください。" - blank_username_or_password: "あなたのメールアドレスかユーザ名、そしてパスワードを入力して下さい。" + blank_username: "あなたのメールまたはユーザー名を入力してください。" + blank_username_or_password: "あなたのメールまたはユーザー名、およびパスワードを入力してください。" reset_password: "パスワードをリセット" - logging_in: "ログイン中..." + logging_in: "サインイン中..." or: "または" authenticating: "認証中..." awaiting_activation: "あなたのアカウントはアクティベーション待ちの状態です。もう一度アクティベーションメールを送信するには「パスワードを忘れました」リンクをクリックしてください。" - awaiting_approval: "アカウントはまだスタッフに承認されていません。承認され次第メールにてお知らせいたします。" - requires_invite: "申し訳ありませんが、このフォーラムは招待制です。" - not_activated: "まだログインできません。%{sentTo} にアクティベーションメールを送信しております。メールの指示に従ってアカウントのアクティベーションを行ってください。" - not_allowed_from_ip_address: "このIPアドレスでログインできません" - admin_not_allowed_from_ip_address: "そのIPアドレスからは管理者としてログインできません" - resend_activation_email: "再度アクティベーションメールを送信するにはここをクリックしてください。" - omniauth_disallow_totp: "あなたのアカウントは2段階認証が有効になっています。ログインにはパスワードが必要です。" - resend_title: "アクティベーションメールの再送" - change_email: "メールアドレスの変更" - provide_new_email: "新しいメールアドレスを設定します。このあと、確認のメールを送信します。" - submit_new_email: "メールアドレスの更新" - sent_activation_email_again: "%{currentEmail} にアクティベーションメールを再送信しました。メールが届くまで数分掛かることがあります。 (メールが届かない場合は、迷惑メールフォルダの中をご確認ください)。" - sent_activation_email_again_generic: "アクティベーションメールを送りました。届くのに少し時間がかかるかもしれません。スパムフォルダーも確認してください。" + awaiting_approval: "アカウントはまだスタッフメンバーに承認されていません。承認され次第メールでお知らせします。" + requires_invite: "このフォーラムは招待制です。" + not_activated: "まだログインできません。%{sentTo} にアクティベーションメールを送信済みです。メールの指示に従ってアカウントのアクティベーションを行ってください。" + not_allowed_from_ip_address: "この IP アドレスでログインできません。" + admin_not_allowed_from_ip_address: "その IP アドレスからは管理者としてログインできません。" + resend_activation_email: "ここをクリックすると、再度アクティベーションメールを送信します。" + omniauth_disallow_totp: "あなたのアカウントは二要素認証が有効になっています。ログインにはパスワードが必要です。" + resend_title: "アクティベーションメールを再送" + change_email: "メールアドレスを変更" + provide_new_email: "新しいメールアドレスを入力すると、確認メールを再送します。" + submit_new_email: "メールアドレスを更新" + sent_activation_email_again: "%{currentEmail} にアクティベーションメールを再送しました。メールが届くまで数分掛かることがあります。迷惑メールフォルダも確認してください。" + sent_activation_email_again_generic: "アクティベーションメールを再送しました。メールが届くまで数分掛かることがあります。迷惑メールフォルダも確認してください。" to_continue: "ログインしてください" preferences: "ユーザー設定を変更するには、ログインする必要があります。" - not_approved: "あなたのアカウントはまだ承認されていません。ログイン可能になった際にメールで通知いたします。" + not_approved: "あなたのアカウントはまだ承認されていません。ログインできるようになったら、メールで通知します。" google_oauth2: name: "Google" title: "Google" @@ -1570,16 +1558,16 @@ ja: backup_code: "代わりにバックアップコードを使用する" invites: accept_title: "招待" - emoji: "封筒の絵文字" - welcome_to: "%{site_name}へようこそ!" - invited_by: "あなたは招待されました:" + emoji: "絵文字をエンベロープ" + welcome_to: "%{site_name} へようこそ!" + invited_by: "あなたは次の人から招待されました:" social_login_available: "このメールアドレスを使ってソーシャルログインすることも可能です。" your_email: "あなたのアカウントのメールアドレスは %{email} です。" - accept_invite: "招待に応じる" - success: "あなたのアカウントは作成されて、ログインされました。" - name_label: "氏名" + accept_invite: "招待を承諾" + success: "あなたのアカウントが作成され、ログインしました。" + name_label: "名前" password_label: "パスワード" - optional_description: "(任意)" + optional_description: "(オプション)" password_reset: continue: "%{site_name} に進む" emoji_set: @@ -1588,12 +1576,12 @@ ja: twitter: "Twitter" win10: "Win10" google_classic: "Google Classic" - facebook_messenger: "Facebookメッセンジャー" + facebook_messenger: "Facebook メッセンジャー" category_page_style: categories_only: "カテゴリのみ" - categories_with_featured_topics: "おすすめのトピックのカテゴリ" + categories_with_featured_topics: "注目のトピックのカテゴリ" categories_and_latest_topics: "カテゴリと最新トピック" - categories_and_top_topics: "カテゴリーと人気トピック" + categories_and_top_topics: "カテゴリと人気トピック" categories_boxes: "サブカテゴリのあるボックス" categories_boxes_with_topics: "注目のトピックのあるボックス" shortcut_modifier_key: @@ -1605,39 +1593,39 @@ ja: loading: 読み込み中... category_row: topic_count: - other: "このカテゴリ内の %{count} 件のトピック" + other: "このカテゴリの %{count} 件のトピック" plus_subcategories_title: other: "%{name} および %{count} 個のサブカテゴリ" plus_subcategories: other: "+ %{count} 個のサブカテゴリ" select_kit: - filter_by: "%{name} でフィルター" - select_to_filter: "フィルタする値を選択" + filter_by: "フィルタ: %{name}" + select_to_filter: "フィルタする値を選択する" default_header_text: 選択... no_content: 一致する項目が見つかりませんでした filter_placeholder: 検索... - filter_placeholder_with_any: 検索 または 新規作成 - create: "作成:'%{content}'" + filter_placeholder_with_any: 検索または作成... + create: "作成: '%{content}'" max_content_reached: other: "%{count} 項目まで選択できます。" min_content_not_reached: other: "少なくとも %{count} 項目を選択してください。" invalid_selection_length: - other: "選択は%{count}文字以上必要です。" + other: "選択は %{count} 文字以上である必要があります。" components: categories_admin_dropdown: title: "カテゴリの管理" date_time_picker: - from: 発信元 - to: 宛先 + from: 開始 + to: 終了 emoji_picker: filter_placeholder: 絵文字を探す - smileys_&_emotion: スマイリーと感情 + smileys_&_emotion: スマイルと感情 people_&_body: 人と体 animals_&_nature: 動物と自然 - food_&_drink: 食べ物と飲み物 + food_&_drink: 食べ物とドリンク travel_&_places: 旅行と場所 - activities: 活動 + activities: アクティビティ objects: オブジェクト symbols: シンボル flags: 旗 @@ -1650,10 +1638,10 @@ ja: dark_tone: ダークスキントーン default: カスタム絵文字 shared_drafts: - title: "共有ドラフト" + title: "共有の下書き" notice: "このトピックは、共有の下書きを公開できるユーザーのみに表示されます。" destination_category: "宛先カテゴリ" - publish: "共有ドラフトの公開" + publish: "共有の下書きを公開" confirm_publish: "この下書きを公開してもよろしいですか?" publishing: "トピック公開中..." composer: @@ -1661,37 +1649,37 @@ ja: more_emoji: "もっと..." options: "オプション" whisper: "ささやき" - unlist: "リストから非表示" + unlist: "非表示" add_warning: "これは運営スタッフからの警告です。" - toggle_whisper: "ささやき機能の切り替え" - toggle_unlisted: "表示非表示を切り替える" - posting_not_on_topic: "どのトピックに返信しますか?" + toggle_whisper: "ささやきを切り替える" + toggle_unlisted: "非表示を切り替える" + posting_not_on_topic: "どのトピックに返信しますか?" saved_local_draft_tip: "ローカルに保存しました" - similar_topics: "このトピックに似ているものは..." + similar_topics: "これに似たトピックは..." drafts_offline: "オフラインの下書き" - edit_conflict: "競合の編集" + edit_conflict: "競合を編集する" group_mentioned_limit: - other: "警告! %{group}にメンションしましたが、このグループには、管理者が設定したメンション制限の %{count} 人のユーザーよりも多くのメンバーがいます。そのため誰にも通知されません。" + other: "警告! %{group} をメンションしましたが、このグループのメンバー数は、管理者がメンション数制限として設定した %{count} 人を超えています。そのため誰にも通知されません。" group_mentioned: - other: "%{group}へのメンションで、 約%{count} 人 へ通知されます。よろしいですか?" + other: "%{group} をメンションすると、%{count} 人 へ通知されます。よろしいですか?" cannot_see_mention: - category: "%{username}にメンションしましたが、このカテゴリへのアクセス権がないため通知されません。このカテゴリへのアクセス権があるグループにメンバー追加してください。" - private: "%{username}にメンションしましたが、このパーソナルメッセージを見ることができないため通知されません。このパーソナルメッセージに招待してください。" - duplicate_link: "%{domain} については、すでに@%{username} が %{ago} 前に投稿しています。再び投稿してよろしいですか?" + category: "%{username} をメンションしましたが、このカテゴリへのアクセス権がないため通知されません。このカテゴリにアクセスできるグループに追加してください。" + private: "%{username} をメンションしましたが、この個人メッセージを見ることができないため通知されません。この個人メッセージに招待してください。" + duplicate_link: "%{domain} へのリンクは、すでに @%{username} が %{ago} 前に投稿しています。もう一度投稿してもよろしいですか?" reference_topic_title: "RE: %{title}" error: title_missing: "タイトルを入力してください。" title_too_short: - other: "タイトルは%{count}文字以上必要です。" + other: "タイトルは %{count} 文字以上である必要があります" title_too_long: other: "タイトルは %{count} 文字以上にすることはできません" post_missing: "空白の投稿はできません" post_length: - other: "投稿は%{count}文字以上必要です。" - try_like: " %{heart} ボタンは試しましたか?" - category_missing: "カテゴリを選択してください。" + other: "投稿は %{count} 文字以上である必要があります" + try_like: "%{heart} ボタンは試しましたか?" + category_missing: "カテゴリを選択してください" tags_missing: - other: "少なくとも %{count} つのタグが必要です。" + other: "少なくとも %{count} 個のタグが必要です" topic_template_not_modified: "トピックテンプレートを編集して、トピックに詳細を追加してください。" save_edit: "編集内容を保存" overwrite_edit: "上書き編集" @@ -1699,56 +1687,56 @@ ja: reply_here: "ここに返信" reply: "返信" cancel: "キャンセル" - create_topic: "トピックを作る" + create_topic: "トピックを作成" create_pm: "メッセージ" create_whisper: "ささやき" - create_shared_draft: "共有ドラフトを作成" - edit_shared_draft: "共有ドラフトの編集" - title: "Ctrl+Enterでも投稿できます" - users_placeholder: "追加するユーザ" - title_placeholder: "トピックのタイトルを入力してください。" - title_or_link_placeholder: "タイトルを記入するか、リンクを貼ってください" - edit_reason_placeholder: "編集する理由は何ですか?" - topic_featured_link_placeholder: "タイトルにリンクを入力してください。" - remove_featured_link: "トピックからリンクを削除する。" - reply_placeholder: "文章を入力してください。 Markdown, BBコード, HTMLが使用出来ます。 画像はドラッグアンドドロップで貼り付けられます。" - reply_placeholder_no_images: "文章を入力してください。 Markdown, BBコード, HTMLが使用出来ます。" - reply_placeholder_choose_category: "ここで入力する前にカテゴリを選んでください。" - view_new_post: "新しい投稿を見る。" + create_shared_draft: "共有の下書きを作成" + edit_shared_draft: "共有の下書きを編集" + title: "Ctrl+Enter でも投稿できます" + users_placeholder: "ユーザーを追加" + title_placeholder: "トピックのタイトルを入力してください" + title_or_link_placeholder: "タイトルを入力するか、リンクを貼り付けてください" + edit_reason_placeholder: "編集する理由は?" + topic_featured_link_placeholder: "タイトルに表示されるリンクを入力してください。" + remove_featured_link: "トピックからリンクを削除してください。" + reply_placeholder: "ここに入力してください。 Markdown、BBCode、HTML を使用できます。画像はドラッグか貼り付けできます。" + reply_placeholder_no_images: "ここに入力してください。 Markdown、BBCode、HTML を使用できます。" + reply_placeholder_choose_category: "カテゴリを選択してから、ここに入力してください。" + view_new_post: "新しい投稿を表示します。" saving: "保存中" - saved: "保存しました!" + saved: "保存しました!" saved_draft: "下書きを投稿中です。タップして再開します。" uploading: "アップロード中..." quote_post_title: "投稿全体を引用" bold_label: "B" bold_title: "太字" - bold_text: "太字にしたテキスト" - italic_label: "斜体" + bold_text: "太字テキスト" + italic_label: "I" italic_title: "斜体" - italic_text: "斜体のテキスト" + italic_text: "斜体テキスト" link_title: "ハイパーリンク" link_description: "リンクの説明をここに入力" - link_dialog_title: "ハイパーリンクの挿入" - link_optional_text: "タイトル(オプション)" - link_url_placeholder: "トピックを検索するためにURLを貼り付けるか入力してください" - blockquote_title: "引用" - blockquote_text: "引用" + link_dialog_title: "ハイパーリンクを挿入" + link_optional_text: "オプションのタイトル" + link_url_placeholder: "URL を貼り付けるか入力してトピックを検索します" + blockquote_title: "ブロック引用" + blockquote_text: "ブロック引用" code_title: "整形済みテキスト" - code_text: "4文字スペースでインデント" - paste_code_text: "コードをここに入力" + code_text: "4 文字スペースでインデント" + paste_code_text: "コードをここに入力または貼り付け" upload_title: "アップロード" - upload_description: "アップロード内容の説明文をここに入力" + upload_description: "アップロードの説明をここに入力" olist_title: "番号付きリスト" ulist_title: "箇条書き" list_item: "リスト項目" help: "Markdown 編集のヘルプ" modal_ok: "OK" modal_cancel: "キャンセル" - cant_send_pm: "%{username}へメッセージを送ることはできません。" + cant_send_pm: "%{username} にメッセージを送ることはできません。" yourself_confirm: - title: "受信者の追加を忘れましたか?" - body: "たった今このメッセージはあなただけに送られました。" - admin_options_title: "このトピックの詳細設定" + title: "受信者を追加し忘れましたか?" + body: "現時点では、このメッセージは自分にしか送信されません!" + admin_options_title: "このトピックのオプションのスタッフ設定" composer_actions: reply: 返信 draft: 下書き @@ -1756,33 +1744,33 @@ ja: reply_to_post: desc: 特定の投稿に返信する reply_as_new_topic: - label: トピックのリンクを付けて返信 + label: リンクトピックとして返信 desc: このトピックにリンクしている新しいトピックを作成する - confirm: 新しいトピックの下書きが保存されていますが、リンクされたトピックを作成すると上書きされます。 + confirm: 新しいトピックの下書きが保存されていますが、リンクトピックを作成すると上書きされます。 reply_as_private_message: - label: メッセージを作成 - desc: 新しいパーソナルメッセージを作成する + label: 新規メッセージ + desc: 新しい個人メッセージを作成する reply_to_topic: label: トピックへ返信 desc: 特定の投稿ではなく、トピックに返信する create_topic: label: "新規トピック" shared_draft: - label: "共有ドラフト" + label: "共有の下書き" reload: "再読み込み" ignore: "無視する" notifications: tooltip: regular: - other: "%{count}件の未読通知" + other: "%{count} 件の未読通知" message: - other: "%{count}件の未読メッセージ" + other: "%{count} 件の未読メッセージ" high_priority: - other: "%{count}件の未読の優先度の高い通知" + other: "%{count} 件の優先度の高い未読通知" title: "@ユーザー名のメンション、投稿やトピックへの返信、メッセージなどの通知" - none: "通知を読み込むことができませんでした。" + none: "現在、通知を読み込めません。" empty: "通知はありません。" - post_approved: "あなたの投稿は承認されました" + post_approved: "あなたの投稿が承認されました" reviewable_items: "レビューが必要な項目" mentioned: "%{username} %{description}" group_mentioned: "%{username} %{description}" @@ -1792,87 +1780,87 @@ ja: posted: "%{username} %{description}" edited: "%{username} %{description}" liked: "%{username} %{description}" - liked_2: "%{username}, %{username2} %{description}" + liked_2: "%{username}、%{username2} %{description}" liked_many: - other: "%{username}, %{username2} 、他 %{count} 人 %{description}" + other: "%{username}、%{username2}、他 %{count} 人 %{description}" liked_consolidated: "%{username} %{description}" private_message: "%{username} %{description}" invited_to_private_message: "

    %{username} %{description}" invited_to_topic: "%{username} %{description}" - invitee_accepted: "%{username} があなたの招待を受理しました" - moved_post: "%{username} は %{description} を移動しました" + invitee_accepted: "%{username} があなたの招待を承諾しました" + moved_post: "%{username} が %{description} を移動しました" linked: "%{username} %{description}" - granted_badge: "'%{description}'バッジをゲット!" + granted_badge: "「%{description}」をゲット!" topic_reminder: "%{username} %{description}" - watching_first_post: "新しいトピック %{description}" + watching_first_post: "新規トピック %{description}" reaction: "%{username} %{description}" - reaction_2: "%{username}, %{username2} %{description}" + reaction_2: "%{username}、%{username2} %{description}" votes_released: "%{description} - 完了" group_message_summary: - other: "%{group_name} 宛のメッセージが %{count} 通あります" + other: "%{group_name} の受信トレイに %{count} 件のメッセージがあります" popup: - mentioned: '"%{topic}"で%{username} にメンションされました - %{site_title}' - group_mentioned: '"%{topic}"で%{username} にメンションされました - %{site_title}' - quoted: '"%{topic}"で%{username}が引用しました - %{site_title}' - replied: '%{username}が"%{topic}"へ返信しました - %{site_title}' - posted: '%{username} が投稿しました "%{topic}" - %{site_title}' - private_message: '%{username} が "%{topic}" - %{site_title} であなたに個人的なメッセージを送信しました' - linked: '"%{topic}"にあるあなたの投稿に%{username}がリンクしました - %{site_title}' - watching_first_post: '%{username} が "%{topic}" - %{site_title}を投稿しました' + mentioned: '「%{topic}」で %{username} にメンションされました - %{site_title}' + group_mentioned: '「%{topic}」で %{username} にメンションされました - %{site_title}' + quoted: '「%{topic}」で %{username} に引用されました - %{site_title}' + replied: '「%{topic}」で %{username} があなたに返信しました - %{site_title}' + posted: '%{username} が「%{topic}」に投稿しました - %{site_title}' + private_message: '「%{topic}」で %{username} があなたに個人メッセージを送信しました - %{site_title}' + linked: '%{username} が 「%{topic}」のあなたの投稿にリンクしました - %{site_title}' + watching_first_post: '%{username} が新規トピック「%{topic}」を作成しました - %{site_title}' confirm_title: "通知を有効にしました - %{site_title}" - confirm_body: "成功!通知を可能にしました!" + confirm_body: "成功!通知を有効にしました!" titles: - mentioned: "メンション済み" + mentioned: "メンション" replied: "新しい返信" - quoted: "引用された" - edited: "編集済み" - private_message: "新しいプライベートメッセージ" - invited_to_private_message: "プライベートメッセージに招待" - invitee_accepted: "招待が承認されました" + quoted: "引用" + edited: "編集" + private_message: "新しい個人メッセージ" + invited_to_private_message: "個人メッセージに招待" + invitee_accepted: "招待が承諾されました" posted: "新しい投稿" moved_post: "投稿が移動されました" - granted_badge: "バッジが付与されました" - invited_to_topic: "トピックに招待済み" + granted_badge: "バッジを獲得しました" + invited_to_topic: "トピックに招待されました" group_mentioned: "グループがメンションされました" group_message_summary: "新着のグループメッセージ" watching_first_post: "新着トピック" topic_reminder: "トピックのリマインダー" upload_selector: - title: "画像のアップロード" - title_with_attachments: "画像/ファイルをアップロード" - from_my_computer: "このデバイスから" + title: "画像の追加" + title_with_attachments: "画像/ファイルの追加" + from_my_computer: "自分のデバイスから" from_the_web: "Web から" remote_tip: "画像へのリンク" - local_tip: "ローカルからアップロードする画像を選択" - hint_for_supported_browsers: "ドラッグ&ドロップあるいはエディター内に画像を貼り付けてください。" + local_tip: "デバイスの画像を選択します" + hint_for_supported_browsers: "画像をエディタ内にドラッグ&ドロップするか貼り付けることもできます" uploading: "アップロード中" select_file: "ファイル選択" default_image_alt_text: 画像 search: sort_by: "並べ替え" - relevance: "一番関連しているもの" - latest_post: "最近の投稿" - latest_topic: "最近のトピック" - most_viewed: "最も閲覧されている順" - most_liked: "「いいね!」されている順" - select_all: "すべて選択する" + relevance: "関連性の高い項目" + latest_post: "最新の投稿" + latest_topic: "最新のトピック" + most_viewed: "最も閲覧されている項目" + most_liked: "「いいね!」の多い項目" + select_all: "すべて選択" clear_all: "すべてクリア" too_short: "検索文字が短すぎます。" - title: "トピック、投稿、ユーザ、カテゴリを探す" + title: "トピック、投稿、ユーザー、カテゴリを検索" no_results: "何も見つかりませんでした。" no_more_results: "検索結果は以上です。" - post_format: "#%{post_number} %{username}から" + post_format: "%{username} の #%{post_number}" more_results: "検索結果が多数あります。検索条件を絞ってください。" cant_find: "探しているものが見つかりませんか?" start_new_topic: "新しいトピックを始めてみては?" - or_search_google: "あるいは、Google検索で試してみてください:" - search_google: "Google検索で試してみてください:" + or_search_google: "または Google で検索してみてください:" + search_google: "Google で検索してみてください:" search_google_button: "Google" search_button: "検索" context: - user: "@%{username}の投稿を検索" - category: "#%{category} から検索する" - topic: "このトピックを探す" + user: "@%{username} の投稿を検索" + category: "#%{category} カテゴリを検索" + topic: "このトピックを検索" private_messages: "メッセージを検索" advanced: title: 高度な検索 @@ -1888,7 +1876,7 @@ ja: label: タグ filters: title: タイトルが一致するもの - likes: '「いいね!」したもの' + likes: '「いいね!」した項目' posted: 投稿したもの watching: ウォッチ中 tracking: 追跡中 @@ -1917,50 +1905,50 @@ ja: after: 以降 views: label: 表示回数 - hamburger_menu: "他のトピック一覧やカテゴリを見る" - new_item: "新しい" + hamburger_menu: "他のトピックリストやカテゴリに移動" + new_item: "新着" go_back: "戻る" not_logged_in_user: "現在のアクティビティと設定に関するユーザーの概要ページ" - current_user: "ユーザページに移動" + current_user: "ユーザーページに移動" topics: new_messages_marker: "最後の訪問" bulk: - select_all: "すべて選択する" + select_all: "すべて選択" clear_all: "すべてクリア" - unlist_topics: "トピックをリストから非表示にする" - relist_topics: "トピックをリストに再表示する" + unlist_topics: "トピックを非表示" + relist_topics: "トピックを表示" reset_read: "未読に設定" delete: "トピックを削除" - dismiss: "既読" - dismiss_read: "未読をすべて既読にする" - dismiss_button: "既読..." - dismiss_tooltip: "新規投稿を既読にしてトピックの追跡を停止" - also_dismiss_topics: "トピックの追跡を停止して、未読として表示されないようにする" - dismiss_new: "既読にする" - toggle: "選択したトピックを切り替え" - actions: "操作" - change_category: "カテゴリを設定する" - close_topics: "トピックをクローズする" - archive_topics: "アーカイブトピック" - move_messages_to_inbox: "受信箱へ移動" + dismiss: "無視" + dismiss_read: "未読をすべて無視する" + dismiss_button: "無視..." + dismiss_tooltip: "新規投稿のみを無視するかトピックの追跡を停止します" + also_dismiss_topics: "これらのトピックの追跡を停止して、未読として表示されないようにする" + dismiss_new: "新規を無視" + toggle: "トピックの一括選択を切り替える" + actions: "一括操作" + change_category: "カテゴリを設定" + close_topics: "トピックをクローズ" + archive_topics: "トピックをアーカイブ" + move_messages_to_inbox: "受信トレイへ移動" notification_level: "通知" - choose_new_category: "このトピックの新しいカテゴリを選択してください" + choose_new_category: "このトピックの新しいカテゴリを選択:" selected: - other: "あなたは %{count} トピックを選択しました。" - change_tags: "タグを付け替える" - append_tags: "タグを追加する" - choose_new_tags: "これらのトピックの新しいタグを選択してください" - choose_append_tags: "これらのトピックに新しいタグを追加してください" + other: "%{count} 件のトピックを選択しました。" + change_tags: "タグを置換" + append_tags: "タグを追加" + choose_new_tags: "これらのトピックに新しいタグを選択:" + choose_append_tags: "これらのトピックに追加する新しいタグを選択:" changed_tags: "トピックのタグが変更されました。" none: unread: "未読のトピックはありません。" new: "新しいトピックはありません。" read: "まだトピックを一つも読んでいません。" - posted: "トピックは一つもありません。" - latest: "コンテンツは以上です!" + posted: "まだトピックを一つも投稿していません。" + latest: "すべて既読です!" bookmarks: "ブックマークしたトピックはありません。" - category: "%{category} トピックはありません。" - top: "トップトピックはありません。" + category: "%{category} のトピックはありません。" + top: "人気のトピックはありません。" educate: new: '

    新しいトピックがここに表示されます。デフォルトでは、2 日以内に作成されたトピックは新しいトピックとみなされ、 が表示されます。

    この設定はユーザー設定で変更できます。

    ' bottom: @@ -1969,25 +1957,25 @@ ja: read: "既読のトピックは以上です。" new: "新着トピックは以上です。" unread: "未読のトピックは以上です。" - category: "%{category}トピックは以上です。" - tag: "%{tag}が付加されたトピックは以上です。" - top: "トップトピックはこれ以上ありません。" - bookmarks: "ブックマーク済みのトピックはこれ以上ありません。" + category: "%{category} のトピックは以上です。" + tag: "%{tag} のトピックは以上です。" + top: "人気のトピックは以上です。" + bookmarks: "ブックマーク済みのトピックは以上です。" topic: filter_to: - other: "トピック内の %{count} 件を表示" + other: "トピックの %{count} 件の投稿" create: "新規トピック" create_long: "新しいトピックの作成" open_draft: "下書きを開く" private_message: "メッセージを書く" archive_message: - help: "アーカイブにメセージを移しました" + help: "アーカイブにメセージを移動する" title: "アーカイブ" move_to_inbox: - title: "受信ボックスへ移動" - help: "受信ボックスへメッセージを戻しました" + title: "受信トレイに移動" + help: "受信トレイにメッセージを戻す" edit_message: - help: "メッセージの最初の投稿を編集" + help: "メッセージの最初の投稿を編集する" title: "編集" defer: help: "未読にする" @@ -1997,68 +1985,68 @@ ja: title: "プロフィールの機能" remove_from_profile: warning: "あなたのプロフィールにはすでに注目のトピックがあります。続行すると、このトピックが既存のトピックに置き換わります。" - help: "ユーザープロファイルでこのトピックへのリンクを削除します" + help: "ユーザープロフィールからこのトピックへのリンクを削除する" title: "プロファイルから削除" list: "トピック" new: "新着トピック" unread: "未読" new_topics: - other: "%{count}個の新着トピック" + other: "%{count} 件の新着トピック" unread_topics: - other: "%{count}個の未読トピック" + other: "%{count} 件の未読トピック" title: "トピック" invalid_access: title: "トピックはプライベートです" - description: "申し訳ありませんが、このトピックへのアクセスは許可されていません。" - login_required: "ポストを閲覧するには、ログインする必要があります" + description: "このトピックへのアクセスは許可されていません!" + login_required: "トピックを閲覧するには、ログインする必要があります" server_error: title: "トピックの読み込みに失敗しました" - description: "申し訳ありませんが、トピックの読み込みに失敗しました。もう一度試してください。もし問題が継続する場合はお知らせください。" + description: "トピックを読み込めませんでした。接続に問題があるようです。もう一度試してください。もし問題が継続する場合はお知らせください。" not_found: title: "トピックが見つかりませんでした" - description: "申し訳ありませんがトピックが見つかりませんでした。モデレータによって削除された可能性があります。" + description: "トピックが見つかりませんでした。モデレーターによって削除された可能性があります。" total_unread_posts: - other: "このトピックに未読のポストが%{count}つあります。" + other: "このトピックに %{count} 件の未読の投稿があります" unread_posts: - other: "このトピックに未読のポストが%{count}つあります。" + other: "このトピックに %{count} 件の古い未読の投稿があります" new_posts: - other: "前回閲覧時より、このトピックに新しいポストが%{count}個投稿されています" + other: "このトピックには最後に閲覧してから %{count} 件が投稿されています" likes: - other: "このトピックには%{count}個「いいね!」がついています" + other: "このトピックには %{count} 個の「いいね!」があります" back_to_list: "トピックリストに戻る" - options: "トピックオプション" - show_links: "このトピック内のリンクを表示" - read_more_in_category: "もっと読みたいですか?%{catLink}あるいは%{latestLink}の他のトピックを見てみてください。" - read_more: "もっと見たいですか?%{catLink}あるいは%{latestLink}があります。" + options: "トピックのオプション" + show_links: "このトピック内のリンクを表示する" + read_more_in_category: "もっと読みますか?%{catLink} または %{latestLink} での他のトピックを閲覧できます。" + read_more: "もっと読みますか?%{catLink} または %{latestLink}。" unread_indicator: "このトピックの最後の投稿を読んだメンバーはまだいません。" - browse_all_categories: 全てのカテゴリを閲覧する - browse_all_tags: すべてのタグを参照 - view_latest_topics: 最新のトピックを見る + browse_all_categories: すべてのカテゴリを閲覧する + browse_all_tags: すべてのタグを閲覧する + view_latest_topics: 最新のトピックを表示する jump_reply_up: 以前の返信へジャンプ jump_reply_down: 以後の返信へジャンプ deleted: "トピックは削除されました" slow_mode_update: - select: "ユーザーは、このトピックに一度だけ投稿できます。" - enable: "有効にする" - remove: "無効にする" + select: "ユーザーはこのトピックに次の頻度でのみ投稿できます。" + enable: "有効化" + remove: "無効化" durations: - 10_minutes: "10分" + 10_minutes: "10 分間" 15_minutes: "15分" 30_minutes: "30分" 45_minutes: "45分" 1_hour: "1時間" 2_hours: "2時間" topic_status_update: - save: "タイマーをセットする" - num_of_hours: "時間数" - remove: "タイマーをはずす" + save: "タイマーをセット" + num_of_hours: "時間:" + remove: "タイマーを削除" publish_to: "公開先:" when: "公開時間:" time_frame_required: "時間枠を選択してください" auto_update_input: - later_today: "今日中" + later_today: "今日の後程" tomorrow: "明日" - later_this_week: "今週中" + later_this_week: "今週の後半" this_weekend: "今週末" next_week: "来週" next_month: "来月" @@ -2069,197 +2057,197 @@ ja: temp_open: title: "一時的にオープン" auto_reopen: - title: "自動的にオープン" + title: "トピックを自動的にオープン" temp_close: title: "一時的にクローズ" auto_close: - title: "自動的にクローズ" - error: "正しい値を入力してください。" - based_on_last_post: "最終投稿時間より前でクローズしない" + title: "トピックを自動的にクローズ" + error: "有効な値を入力してください。" + based_on_last_post: "トピックの最後の投稿が古くなるまでクローズしない。" auto_delete: - title: "自動的にトピックを削除" + title: "トピックを自動的に削除" reminder: - title: "リマインド" + title: "リマインダー" status_update_notice: - auto_open: "このトピックはあと%{timeLeft}で自動的にオープンします。" - auto_close: "このトピックはあと%{timeLeft}で自動的にクローズします。" - auto_publish_to_category: "このトピックはあと %{timeLeft} で、#%{categoryName} にて公開されます。" - auto_close_after_last_post: "このトピックは最後の返信から%{duration} 後にクローズされます" - auto_delete: "このトピックはあと%{timeLeft}で自動的に削除されます。" - auto_reminder: "このトピックについて、%{timeLeft} 後にリマインドします。" - auto_close_title: "オートクローズの設定" + auto_open: "このトピックは後 %{timeLeft}で自動的にオープンします。" + auto_close: "このトピックは後 %{timeLeft}で自動的にクローズします。" + auto_publish_to_category: "このトピックは後 %{timeLeft}で #%{categoryName} に公開されます。" + auto_close_after_last_post: "このトピックは最後の返信から%{duration}後にクローズされます。" + auto_delete: "このトピックは後 %{timeLeft}で自動的に削除されます。" + auto_reminder: "このトピックについて %{timeLeft}後にリマインダーを通知します。" + auto_close_title: "自動クローズの設定" auto_close_immediate: - other: "このトピックは最終投稿からすでに%{count}時間経過しているため、すぐにクローズされます。" + other: "このトピックは最終投稿からすでに %{count} 時間経過しているため、すぐにクローズされます。" timeline: back: "戻る" - back_description: "未読の最終投稿へ戻る" + back_description: "最後の未読の投稿に戻る" replies_short: "%{current} / %{total}" progress: - title: トピック進捗 + title: トピックの進捗 go_top: "上" go_bottom: "下" - go: "へ" - jump_bottom: "最後の投稿へ" - jump_prompt: "ジャンプする..." + go: "移動" + jump_bottom: "最後の投稿へジャンプ" + jump_prompt: "ジャンプ..." jump_prompt_of: - other: "%{count} 投稿" - jump_prompt_long: "ジャンプする..." - jump_bottom_with_number: "%{post_number}番へジャンプ" + other: "/ %{count} の投稿" + jump_prompt_long: "ジャンプ..." + jump_bottom_with_number: "%{post_number} 番へジャンプ" jump_prompt_or: "または" - total: 投稿の合計 + total: 全投稿 current: 現在の投稿 notifications: - title: このトピックに関する通知頻度を変更 + title: このトピックに関する通知頻度の変更 reasons: - mailing_list_mode: "メーリングリストモードになっているため、このトピックへの返信はメールで送られます。" + mailing_list_mode: "メーリングリストモードになっているため、このトピックへの返信はメールで通知されます。" "3_10": "このトピックのタグをウォッチしているため通知されます。" "3_6": "このカテゴリをウォッチしているため通知されます。" - "3_5": "このトピックをウォッチし始めたため通知されます。" + "3_5": "このトピックを自動的にウォッチし始めたため通知されます。" "3_2": "このトピックをウォッチしているため通知されます。" "3_1": "このトピックを作成したため通知されます。" "3": "このトピックをウォッチしているため通知されます。" - "2_8": "このカテゴリを追跡しているので、新たな返信のカウント数が表示されます。" - "2_4": "このトピックに返信したので、新たな返信のカウント数が表示されます。" - "2_2": "このトピックを追跡しているので、新たな返信のカウント数が表示されます。" + "2_8": "このカテゴリを追跡しているため、新しい返信の件数が表示されます。" + "2_4": "このトピックに返信したので、新しい返信の件数が表示されます。" + "2_2": "このトピックを追跡しているので、新しい返信の件数が表示されます。" "2": 'You will see a count of new replies because you read this topic.' - "1_2": "他のユーザからメンションされた場合か、メッセージ内の投稿に返信された場合に通知されます。" - "1": "他のユーザからメンションされた場合か、メッセージ内の投稿に返信された場合に通知されます。" - "0_7": "このカテゴリに関して一切通知を受け取りません。" - "0_2": "このトピックに関して一切通知を受け取りません。" - "0": "このトピックに関して一切通知を受け取りません。" + "1_2": "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" + "1": "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" + "0_7": "このカテゴリのすべての通知を無視しています。" + "0_2": "このトピックのすべての通知を無視しています。" + "0": "このトピックのすべての通知を無視しています。" watching_pm: title: "ウォッチ中" - description: "未読件数と新しい投稿がトピックの横に表示されます。このトピックに対して新しい投稿があった場合に通知されます。" + description: "このメッセージに返信があるたびに通知され、新しい返信の件数が表示されます。" watching: title: "ウォッチ中" - description: "未読件数と新しい投稿がトピックの横に表示されます。このトピックに対して新しい投稿があった場合に通知されます。" + description: "このトピックに返信があるたびに通知され、新しい返信の件数が表示されます。" tracking_pm: title: "追跡中" - description: "新しい返信の数がこのメッセージに表示されます。他のユーザから@ユーザ名でメンションされた場合や、投稿へ返信された場合に通知されます。" + description: "このメッセージの新しい返信の件数が表示されます。誰かが@ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" tracking: title: "追跡中" - description: "新しい返信の数がこのトピックに表示されます。他のユーザから@ユーザ名でメンションされた場合や、投稿へ返信された場合に通知されます。" + description: "このトピックの新しい返信の件数が表示されます。誰かが@ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" regular: - title: "デフォルト" - description: "他のユーザからメンションされた場合か、投稿に返信された場合に通知されます。" + title: "通常" + description: "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" regular_pm: - title: "デフォルト" - description: "他のユーザからメンションされた場合か、メッセージ内の投稿に返信された場合に通知されます。" + title: "通常" + description: "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" muted_pm: - title: "ミュートされました" - description: "このメッセージについての通知を受け取りません。" + title: "ミュート" + description: "このメッセージに関する通知を受け取りません。" muted: title: "ミュート" - description: "このトピックに関するお知らせをすべて受け取りません。また、未読のタブにも通知されません。" + description: "このトピックについて何も通知されず、最新にも表示されません。" actions: - title: "アクション" + title: "操作" recover: "トピックの削除を取り消す" delete: "トピックを削除" open: "トピックをオープン" - close: "トピックをクローズする" - multi_select: "投稿を選択" + close: "トピックをクローズ" + multi_select: "投稿を選択..." timed_update: "トピックにタイマーをセット..." - pin: "トピックを固定表示" - unpin: "トピックの固定表示を解除" - unarchive: "トピックのアーカイブ解除" - archive: "トピックのアーカイブ" - invisible: "リストから非表示にする" + pin: "トピックを固定..." + unpin: "トピックを固定解除..." + unarchive: "トピックをアーカイブ解除" + archive: "トピックをアーカイブ" + invisible: "非表示にする" visible: "リストに表示" - reset_read: "読み込みデータをリセット" - make_public: "公開トピックへ変更" - make_private: "ダイレクトメッセージを作成" + reset_read: "閲覧データをリセット" + make_public: "公開トピックにする" + make_private: "個人メッセージにする" feature: - pin: "トピックを固定表示" - unpin: "トピックの固定表示を解除" - pin_globally: "トピックを全体で固定表示する" + pin: "トピックを固定" + unpin: "トピックを固定解除" + pin_globally: "トピックを全体に固定" make_banner: "バナートピック" remove_banner: "バナートピックを削除" reply: - title: "返信する" - help: "このトピックに返信する" + title: "返信" + help: "このトピックへの返信を作成する" clear_pin: - title: "固定表示を解除する" - help: "このトピックの固定表示を解除し、トピックリストの先頭に表示されないようにする" + title: "固定をクリア" + help: "このトピックの固定ステータスを解除し、トピックリストの先頭に表示されないようにする" share: - title: "シェア" - help: "このトピックのリンクをシェアする" + title: "共有" + help: "このトピックのリンクを共有する" invite_users: "招待" print: title: "印刷" - help: "印刷用の表示にする" + help: "このトピックの印刷バージョンを開く" flag_topic: title: "通報" - help: "このトピックを通報するか、またはプライベート通知を送る" + help: "このトピックを非公開に通報するか、または非公開の通知を送信する" success_message: "このトピックを通報しました。" feature_topic: - title: "トピックを特集" - pin: "%{categoryLink} カテゴリのトップに表示する" - unpin: "%{categoryLink} カテゴリのトップからトピックを削除" - unpin_until: "%{categoryLink} カテゴリのトップからこのトピックを削除するか、%{until} まで待つ。" - pin_note: "ユーザはトピックを個別に固定表示解除することができます。" - pin_validation: "このトピックを固定表示するためには日付を指定してください。" - not_pinned: "%{categoryLink}で固定表示されているトピックはありません。" + title: "これを注目のトピックにする" + pin: "このトピックを %{categoryLink} カテゴリのトップに次の期間表示する:" + unpin: "このトピックを %{categoryLink} カテゴリのトップから削除する" + unpin_until: "このトピックを %{categoryLink} カテゴリのトップから削除するか、%{until} まで待つ。" + pin_note: "ユーザーはトピックごとに固定表示を解除できます。" + pin_validation: "このトピックを固定するには日付が必要です。" + not_pinned: "%{categoryLink} で固定されているトピックはありません。" already_pinned: - other: "%{categoryLink}で固定表示されているトピック: %{count}" + other: "%{categoryLink}で固定されているトピック: %{count} 件" pin_globally: "このトピックをすべてのトピックリストのトップに表示する" confirm_pin_globally: - other: "全体で既に %{count} トピックを固定表示しています。固定表示が多すぎると、新規あるいは匿名ユーザの負担になる場合があります。このカテゴリにおいてさらに固定表示設定してもよいですか?" - unpin_globally: "トピック一覧のトップからこのトピックを削除します" + other: "すでに %{count} 個のトピックをサイト全体に固定表示しています。固定されたトピックが多すぎると、新規ユーザーや匿名ユーザーの負担になる可能性があります。別のトピックをさらに固定してもよいですか?" + unpin_globally: "このトピックをトピックリストのトップから削除します。" unpin_globally_until: "このトピックをすべてのトピックリストのトップから削除するか、%{until} まで待つ。" - global_pin_note: "ユーザはトピックを個別に固定表示解除することができます。" - not_pinned_globally: "全体で固定表示されているトピックはありません。" + global_pin_note: "ユーザーはトピックごとに固定表示を解除できます。" + not_pinned_globally: "全体に固定されているトピックはありません。" already_pinned_globally: - other: "全体で固定表示されているトピック: %{count}" - make_banner: "このトピックを全てのページのバナーに表示します" - remove_banner: "全てのページのバナーを削除します" - banner_note: "ユーザはバナーを閉じることができます。常に1つのトピックだけがバナー表示されます" + other: "全体に固定されているトピック: %{count} 件" + make_banner: "このトピックをすべてのページの上部に表示されるバナーにします。" + remove_banner: "すべてのページの上部に表示されるバナーを削除します。" + banner_note: "ユーザーはバナーを閉じることができます。任意のタイミングでバナー表示できるのは1つのトピックだけです。" no_banner_exists: "バナートピックはありません。" - banner_exists: "これらが現在のバナートピックです。" + banner_exists: "現在、バナートピックがあります。" inviting: "招待中..." - automatically_add_to_groups: "この招待によって、リストされたグループに参加することができます。" + automatically_add_to_groups: "この招待によって、次のグループにもアクセスできます。" invite_private: - title: "プライベートメッセージへ招待する" - email_or_username: "招待するユーザのメールアドレスまたはユーザ名" - email_or_username_placeholder: "メールアドレスまたはユーザ名" + title: "メッセージに招待" + email_or_username: "招待者のメールまたはユーザー名" + email_or_username_placeholder: "メールアドレスまたはユーザー名" action: "招待" - success: "ユーザをこのメッセージへ招待しました。" + success: "ユーザーをこのメッセージへ招待しました。" success_group: "グループをこのメッセージへ招待しました。" - error: "申し訳ありません、ユーザ招待中にエラーが発生しました。" + error: "ユーザーを招待中にエラーが発生しました。" group_name: "グループ名" - controls: "オプション" + controls: "トピックの管理" invite_reply: title: "招待" - username_placeholder: "ユーザ名" - action: "招待を送る" - help: "このトピックに他のユーザをメールまたは通知で招待する。" - discourse_connect_enabled: "このトピックに招待したい人のユーザ名を入れてください" - to_topic_blank: "このトピックに招待したい人のユーザ名かメールアドレスを入れてください" + username_placeholder: "ユーザー名" + action: "招待状を送信" + help: "メールまたは通知で、ほかのユーザーをこのトピックに招待する" + discourse_connect_enabled: "このトピックに招待する人のユーザー名を入力してください。" + to_topic_blank: "このトピックに招待する人のユーザー名またはメールアドレスを入力してください。" to_topic_email: "あなたはメールアドレスを入力しました。フレンドがすぐにこのトピックへ返信できるようにメールで招待します。" - to_topic_username: "ユーザ名を入力しました。このトピックへの招待リンクの通知を送信します。" - to_username: "招待したい人のユーザ名を入れてください。このトピックへの招待リンクの通知を送信します。" + to_topic_username: "ユーザー名を入力しました。このトピックへの招待リンクを記載した通知を送信します。" + to_username: "招待する人のユーザ名を入れてください。このトピックへの招待リンクを記載した通知を送信します。" email_placeholder: "name@example.com" - success_email: "%{invitee}に招待を送信しました。招待が受理されたらお知らせします。招待の状態は、ユーザページの招待タブにて確認できます。" - success_username: "ユーザをこのトピックへ招待しました。" - error: "申し訳ありません。その人を招待できませんでした。すでに招待を送信していませんか? (招待できる数には限りがあります)" - success_existing_email: "%{emailOrUsername}はすでにこのトピックへ招待済みです。" + success_email: "%{invitee} に招待状を送信しました。招待が承諾されたらお知らせします。招待のステータスは、ユーザーページの招待タブで確認できます。" + success_username: "ユーザーをこのトピックへ招待しました。" + error: "その人を招待できませんでした。すでに招待を送信していませんか? (招待できる数には限りがあります)" + success_existing_email: "%{emailOrUsername} のユーザーはすでに存在します。このトピックに参加するように、このユーザーを招待しました。" login_reply: "ログインして返信" filters: n_posts: - other: "%{count} 件" - cancel: "フィルター削除" + other: "%{count} 件の投稿" + cancel: "フィルタを削除" split_topic: title: "新規トピックに移動" action: "新規トピックに移動" radio_label: "新規トピック" - error: "投稿の新規トピックへの移動中にエラーが発生しました。" + error: "投稿を新規トピックに移動する際にエラーが発生しました。" instructions: - other: "新たにトピックを作成し、選択した%{count}個の投稿をこのトピックに移動しようとしています。" + other: "新しいトピックを作成し、それに選択した %{count} 件の投稿を移動しようとしています。" merge_topic: - title: "既存トピックに移動" - action: "既存トピックに移動" + title: "既存のトピックに移動" + action: "既存のトピックに移動" error: "指定されたトピックへの投稿移動中にエラーが発生しました。" instructions: - other: "%{count}個の投稿をどのトピックに移動するか選択してください。" + other: "%{count} 件の投稿を移動するトピックを選択してください。" move_to_new_message: title: "新しいメッセージに移動" action: "新しいメッセージに移動" @@ -2272,129 +2260,129 @@ ja: radio_label: "既存のメッセージ" participants: "参加者" merge_posts: - title: "選択した投稿を統合" - action: "選択した投稿を統合" - error: "選択した投稿の統合に失敗しました。" + title: "選択した投稿をマージ" + action: "選択した投稿をマージ" + error: "選択した投稿をマージ中にエラーが発生しました。" publish_page: public: "公開" change_owner: action: "オーナーシップを変更" - error: "オーナーの変更ができませんでした。" + error: "投稿のオーナーシップを変更中にエラーが発生しました。" placeholder: "新しいオーナーのユーザー名" change_timestamp: - title: "タイムスタンプを変更してください。" + title: "タイムスタンプを変更..." action: "タイムスタンプを変更" - invalid_timestamp: "タイムスタンプは未来時刻にすることが出来ません。" - error: "トピックのタイムスタンプ変更にエラーがありました。" - instructions: "トピックの新たなタイムスタンプを設定してください。トピック内の各投稿時間はその時間を起点に再計算されます。" + invalid_timestamp: "タイムスタンプを未来の時刻にすることはできません。" + error: "トピックのタイムスタンプを変更中にエラーが発生しました。" + instructions: "トピックに新しいタイムスタンプを設定してください。トピックの各投稿の時間はそのタイムスタンプを起点に再計算されます。" multi_select: select: "選択" - selected: "選択中 (%{count})" + selected: "選択済み (%{count})" select_post: label: "選択" select_replies: - label: "返信と選択" - delete: 選択中のものを削除 - cancel: 選択を外す - select_all: すべて選択する - deselect_all: すべて選択を外す + label: "複数の返信を選択" + delete: 選択済みを削除 + cancel: 選択をキャンセル + select_all: すべて選択 + deselect_all: すべて選択解除 description: - other: "%{count}個の投稿を選択中。" + other: "%{count} 件の投稿を選択しました。" post: quote_reply: "引用" - quote_share: "シェア" + quote_share: "共有" edit_reason: "理由: " - post_number: "投稿%{number}" + post_number: "投稿 %{number}" ignored: "無視したコンテンツ" - reply_as_new_topic: "トピックのリンクを付けて返信" - reply_as_new_private_message: "同じ受信者へ新しいメッセージとして返信" - continue_discussion: "%{postLink} から:" + reply_as_new_topic: "リンクトピックとして返信" + reply_as_new_private_message: "同じ受信者に新規メッセージとして返信する" + continue_discussion: "%{postLink} からディスカッションを続行:" follow_quote: "引用した投稿に移動" - show_full: "全て表示" - show_hidden: "無視したコンテンツを見る" + show_full: "投稿全文を表示" + show_hidden: "無視したコンテンツを表示します。" collapse: "折りたたむ" - expand_collapse: "開く/折りたたむ" + expand_collapse: "展開/折りたたむ" gap: - other: "%{count}個の返信をすべて表示する" + other: "%{count} 件の非表示の返信を表示する" notice: - new_user: "%{user} が投稿するのはこれが初めてです—私たちのコミュニティに彼らを歓迎しましょう!" - unread: "未読の投稿" + new_user: "%{user} が投稿するのはこれが初めてです。コミュニティで歓迎しましょう!" + unread: "投稿は未読です" has_replies: other: "%{count} 件の返信" has_likes_title: - other: "%{count}人のユーザがこの返信に「いいね!」しました" - has_likes_title_only_you: "あなたがいいねした投稿" + other: "%{count} 人がこの投稿に「いいね!」しました" + has_likes_title_only_you: "この投稿に「いいね!」しました" has_likes_title_you: - other: "あなたと%{count} 人の人がこの投稿に「いいね!」しました。" + other: "あなたと他 %{count} 人がこの投稿に「いいね!」しました" errors: - create: "申し訳ありませんが、投稿中にエラーが発生しました。もう一度やり直してください。" - edit: "申し訳ありませんが、投稿の編集中にエラーが発生しました。もう一度やり直してください。" - upload: "申し訳ありません、ファイルのアップロード中にエラーが発生しました。再度お試しください。" - too_many_uploads: "申し訳ありませんが、複数のファイルは同時にアップロードできません。" - upload_not_authorized: "すみません、アップロードしようとしているファイルは許可されていません。 (許可された拡張子は: %{authorized_extensions}です。)" - image_upload_not_allowed_for_new_user: "申し訳ありませんが、新規ユーザは画像のアップロードができません。" - attachment_upload_not_allowed_for_new_user: "申し訳ありませんが、新規ユーザはファイルの添付ができません。" - attachment_download_requires_login: "ファイルをダウンロードするには、ログインする必要があります" - via_email: "メールで投稿されました。" - whisper: "この投稿はモデレーターへのプライベートメッセージです" + create: "投稿を作成中にエラーが発生しました。もう一度やり直してください。" + edit: "投稿を編集中にエラーが発生しました。もう一度やり直してください。" + upload: "ファイルのアップロード中にエラーが発生しました。もう一度やり直してください。" + too_many_uploads: "複数のファイルを同時にアップロードすることはできません。" + upload_not_authorized: "アップロードしようとしているファイルは許可されていません (許可されている拡張子: %{authorized_extensions})。" + image_upload_not_allowed_for_new_user: "新規ユーザーは画像をアップロードできません。" + attachment_upload_not_allowed_for_new_user: "新規ユーザーはファイルを添付できません。" + attachment_download_requires_login: "添付ファイルをダウンロードするには、ログインする必要があります。" + via_email: "これはメールで投稿されました" + whisper: "この投稿はモデレーターの非公開のささやきです" wiki: - about: "この投稿はWiki形式です" + about: "この投稿はウィキです" archetypes: save: "保存オプション" controls: - reply: "この投稿の返信を編集" + reply: "この投稿の返信を作成する" like: "この投稿に「いいね!」する" - has_liked: "この投稿に「いいね!」しました。" + has_liked: "この投稿に「いいね!」しました" undo_like: "「いいね!」を取り消す" edit: "この投稿を編集" edit_action: "編集" - edit_anonymous: "投稿を編集するには、ログインする必要があります" - flag: "この投稿を通報する、またはプライベート通知を送る" - delete: "投稿を削除する" - undelete: "投稿を元に戻す" - share: "投稿のリンクを共有する" - more: "もっと読む" + edit_anonymous: "この投稿を編集するには、ログインする必要があります。" + flag: "この投稿を非公開に通報するか、または非公開の通知を送信する" + delete: "この投稿を削除する" + undelete: "この投稿の削除を取り消す" + share: "この投稿へのリンクを共有する" + more: "もっと" delete_replies: - just_the_post: "投稿のみを削除する" - admin: "投稿の管理" - wiki: "wiki投稿にする" - unwiki: "wiki投稿から外す" - convert_to_moderator: "スタッフカラーを追加" - revert_to_regular: "スタッフカラーを外す" - rebake: "HTMLを再構成" - unhide: "表示する" + just_the_post: "いいえ、この投稿のみ" + admin: "投稿の管理者操作" + wiki: "ウィキにする" + unwiki: "ウィキから削除" + convert_to_moderator: "スタッフの色を追加" + revert_to_regular: "スタッフの色を削除" + rebake: "HTML を再作成" + unhide: "表示" change_owner: "オーナーシップを変更" grant_badge: "バッジを付与" - delete_topic: "トピック削除" + delete_topic: "トピックを削除" actions: people: like: - other: "「いいね!」する" + other: "「いいね!」しました" by_you: off_topic: "関係のない話題として通報しました" - spam: "スパム報告として通報しました" - inappropriate: "不適切であると報告されています" + spam: "迷惑として通報しました" + inappropriate: "不適切として通報しました" notify_moderators: "スタッフによる確認が必要として通報しました" - notify_user: "このユーザにメッセージを送信しました" + notify_user: "このユーザーにメッセージを送信しました" merge: confirm: - other: "これらの%{count}の投稿を統合したいですか?" + other: "これらの %{count} 件の投稿をマージしてよろしいですか?" revisions: controls: first: "最初のリビジョン" - previous: "一つ前のリビジョン" + previous: "前のリビジョン" next: "次のリビジョン" last: "最後のリビジョン" - hide: "リビジョンを隠す" + hide: "リビジョンを非表示" show: "リビジョンを表示" - edit_wiki: "Wikiを編集" + edit_wiki: "ウィキを編集" edit_post: "投稿を編集" displays: inline: title: "追加・削除箇所をインラインで表示" button: "HTML" side_by_side: - title: "差分を横に並べて表示" + title: "出力の差分を横に並べて表示" button: "HTML" side_by_side_markdown: title: "ソースの差分を横に並べて表示" @@ -2402,90 +2390,90 @@ ja: raw_email: displays: raw: - title: "メールをソース表示" - button: "Raw" + title: "メールのソースを表示" + button: "ソース" text_part: title: "メールのテキスト部分を表示" - button: "Text" + button: "テキスト" html_part: - title: "メールのHTML部分を表示" + title: "メールの HTML 部分を表示" button: "HTML" bookmarks: - created: "作成者" + created: "作成" name: "名前" category: - can: "can… " + can: "できること… " none: "(カテゴリなし)" - all: "全てのカテゴリ" - choose: "カテゴリを選択…" + all: "すべてのカテゴリ" + choose: "カテゴリ…" edit: "編集" - edit_dialog_title: "%{categoryName}を編集" - view: "カテゴリ内のトピックを見る" + edit_dialog_title: "編集: %{categoryName}" + view: "カテゴリのトピックを表示" general: "一般" settings: "設定" topic_template: "トピックテンプレート" tags: "タグ" tags_placeholder: "(オプション) 許可されたタグのリスト" tag_groups_placeholder: "(オプション) 許可されたタググループのリスト" - delete: "カテゴリを削除する" + delete: "カテゴリを削除" create: "新規カテゴリ" - create_long: "新しくカテゴリを作ります" - save: "カテゴリを保存する" + create_long: "新しいカテゴリを作成する" + save: "カテゴリを保存" slug: "カテゴリのスラッグ" - slug_placeholder: "(任意) URLで使用されます" - creation_error: カテゴリの作成に失敗しました。 - save_error: カテゴリの保存に失敗しました。 + slug_placeholder: "(オプション) URL 用のダッシュ区切りの語" + creation_error: カテゴリを作成中にエラーが発生しました。 + save_error: カテゴリを保存中にエラーが発生しました。 name: "カテゴリ名" - description: "カテゴリ内容" + description: "説明" topic: "カテゴリトピック" - logo: "カテゴリロゴ画像" + logo: "カテゴリのロゴの画像" background_image: "カテゴリの背景画像" badge_colors: "バッジの色" background_color: "背景色" - foreground_color: "文字表示色" - name_placeholder: "簡単な名前にしてください。" - color_placeholder: "任意の Web カラー" + foreground_color: "前景色" + name_placeholder: "簡単な名前にしてください" + color_placeholder: "すべてのウェブ色" delete_confirm: "このカテゴリを削除してもよろしいですか?" - delete_error: "カテゴリ削除に失敗しました。" - list: "カテゴリをリストする" - no_description: "このカテゴリの説明はありません。" - change_in_category_topic: "カテゴリ内容を編集" - already_used: "この色は他のカテゴリで利用しています" + delete_error: "カテゴリを削除中にエラーが発生しました。" + list: "カテゴリをリスト表示" + no_description: "このカテゴリの説明を追加してください。" + change_in_category_topic: "説明を編集" + already_used: "この色は他のカテゴリで使用されています。" security: "セキュリティ" permissions: group: "グループ" - see: "閲覧できる" + see: "閲覧" reply: "返信" images: "画像" - email_in: "カスタムメールアドレス:" - email_in_allow_strangers: "登録されていないユーザからメールを受け取ります" - email_in_disabled: "メールでの新規投稿は、サイトの設定で無効になっています。メールでの新規投稿を有効にするには" - email_in_disabled_click: '"email in"設定を有効にしてください' - allow_badges_label: "このカテゴリでバッジが付与されることを許可" - edit_permissions: "パーミッションを編集" + email_in: "カスタム受信メールアドレス:" + email_in_allow_strangers: "登録されていない匿名のユーザーからのメールを受け取る" + email_in_disabled: "メールによる新しいトピックの投稿は、サイトの設定で無効になっています。メールによる新しいトピックの投稿を有効にするには、 " + email_in_disabled_click: '"email in" 設定を有効にする。' + allow_badges_label: "このカテゴリでバッジの付与を許可する" + edit_permissions: "権限を編集" review_group_name: "グループ名" this_year: "今年" - default_position: "デフォルトポジション" - position_disabled: "カテゴリはアクティビティで並び替えられます。カテゴリ一覧の順番を制御するには" - position_disabled_click: '「固定されたカテゴリーの位置付け」の設定を有効にしてください。' + default_position: "デフォルトの位置" + position_disabled: "カテゴリはアクティビティ順で表示されます。リスト内のカテゴリの順番を管理するには、 " + position_disabled_click: '「カテゴリの位置の固定」の設定を有効にしてください。' parent: "親カテゴリ" notifications: watching: title: "ウォッチ中" watching_first_post: - title: "最初の投稿をウォッチする" + title: "最初の投稿をウォッチ中" tracking: title: "追跡中" regular: - title: "デフォルト" - description: "他のユーザからメンションされた場合や、投稿に返信された場合に通知されます。" + title: "通常" + description: "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" muted: - title: "ミュート中" + title: "ミュート" search_priority: options: normal: "通常" - ignore: "無視する" - high: "高い" + ignore: "無視" + high: "高" sort_options: default: "デフォルト" likes: "いいね!" @@ -2494,57 +2482,57 @@ ja: activity: "アクティビティ" posters: "投稿者" category: "カテゴリ" - created: "作成者" + created: "作成" settings_sections: general: "一般" - email: "Eメール" + email: "メール" flagging: title: "報告していただきありがとうございます。" action: "投稿を通報" take_action_options: default: - title: "アクションをする" - details: "誰かが通報するのを待つのではなく、通報しましょう。" + title: "対応する" + details: "誰かの通報を待たずに、今すぐ通報する" suspend: - title: "凍結中のユーザ" + title: "ユーザーを凍結" notify_action: "メッセージ" official_warning: "運営スタッフからの警告" - delete_spammer: "スパムの削除" - yes_delete_spammer: "はい、スパムを削除する" - ip_address_missing: "(N/A)" - hidden_email_address: "(hidden)" - submit_tooltip: "プライベートの通報を送信する" - take_action_tooltip: "誰かが通報するのを待つのではなく、通報しましょう。" + delete_spammer: "迷惑行為者を削除" + yes_delete_spammer: "はい、迷惑行為者を削除する" + ip_address_missing: "(該当なし)" + hidden_email_address: "(非表示)" + submit_tooltip: "非公開の通報を送信する" + take_action_tooltip: "誰かの通報を待たずに、今すぐ通報する" cant: "現在、この投稿を通報することはできません。" - notify_staff: "スタッフに通報" + notify_staff: "非公開でスタッフに通報" formatted_name: - off_topic: "オフトピック" + off_topic: "話題に関係ない" inappropriate: "不適切" - spam: "スパム" - custom_placeholder_notify_user: "具体的に、建設的に、そして常に親切にしましょう。" - custom_placeholder_notify_moderators: "特にどのような問題が発生しているか記入して下さい。可能なら、関連するリンクなどを教えて下さい。" + spam: "迷惑コンテンツ" + custom_placeholder_notify_user: "具体的に、建設的に、そして常に親切に説明しましょう。" + custom_placeholder_notify_moderators: "具体的にどのような問題が発生しているか説明してください。可能なら、関連するリンクや例を含めてください。" custom_message: at_least: - other: "少なくとも%{count}文字入力してください" + other: "%{count} 文字以上を入力してください" more: - other: "あと%{count}文字..." + other: "あと %{count}..." left: - other: "残り%{count}文字" + other: "残り %{count}" flagging_topic: title: "報告していただきありがとうございます。" action: "トピックを通報" notify_action: "メッセージ" topic_map: - title: "トピックの情報" + title: "トピックの要約" participants_title: "よく投稿する人" links_title: "人気のリンク" - links_shown: "さらにリンクを表示する" + links_shown: "リンクをもっと表示..." clicks: other: "%{count} クリック" post_links: - about: "この投稿のリンクをもっと見る" + about: "この投稿のリンクをもっと表示" title: - other: "%{count} リンク" + other: "他 %{count}" topic_statuses: warning: help: "これは運営スタッフからの警告です。" @@ -2555,69 +2543,69 @@ ja: archived: help: "このトピックはアーカイブされています。凍結状態のため一切の変更ができません" locked_and_archived: - help: "このトピックは閉じられ、アーカイブされます。新しい返信を受け入れず、変更することはできません" + help: "このトピックはクローズされ、アーカイブされています。新しい返信を受け入れず、変更することはできません" unpinned: - title: "固定表示されていません" - help: "このトピックは固定表示されていません。 既定の順番で表示されます" + title: "固定解除" + help: "このトピックは固定解除されています。 通常の順番で表示されます" pinned_globally: - title: "全体で固定表示されました" - help: "このトピックは全体で固定表示されています。常に新着とカテゴリのトップに表示されます" + title: "全体に固定" + help: "このトピックは全体に固定されています。常に最新とカテゴリのトップに表示されます" pinned: - title: "固定表示" - help: "このトピックは固定表示されています。常にカテゴリのトップに表示されます" + title: "固定" + help: "このトピックは固定されています。常にカテゴリのトップに表示されます" unlisted: - help: "このトピックはリストされていません。トピックリストには表示されません。直接リンクでのみアクセス可能です" + help: "このトピックは非表示です。トピックリストには表示されません。直リンクでのみアクセス可能です" posts: "投稿" - original_post: "オリジナルの投稿" - views: "閲覧" + original_post: "元の投稿" + views: "表示" views_lowercase: - other: " 閲覧" + other: "表示" replies: "返信" views_long: - other: "このトピックは%{number}回閲覧されました" + other: "このトピックは %{number} 回表示されました" activity: "アクティビティ" likes: "いいね!" likes_lowercase: other: "いいね!" - users: "ユーザ" + users: "ユーザー" users_lowercase: - other: "ユーザ" + other: "ユーザー" category_title: "カテゴリ" - changed_by: "by %{author}" + changed_by: "%{author}" raw_email: title: "受信メール" - not_available: "利用不可" - categories_list: "カテゴリ一覧" + not_available: "利用できません!" + categories_list: "カテゴリリスト" filters: with_topics: "%{filter} トピック" with_category: "%{filter} %{category} トピック" latest: title: "最新" title_with_count: - other: "最新の投稿 (%{count})" - help: "最新のトピック" + other: "最新 (%{count})" + help: "最近の投稿のあるトピック" read: title: "既読" - help: "既読のトピックを、最後に読んだ順に表示" + help: "既読のトピックを最後に読んだ順に表示する" categories: title: "カテゴリ" title_in: "カテゴリ - %{categoryName}" - help: "カテゴリ別全トピック" + help: "カテゴリ別トピック" unread: title: "未読" title_with_count: other: "未読 (%{count})" - help: "未読投稿のあるトピック" + help: "未読の投稿のあるウォッチ中または追跡中のトピック" lower_title_with_count: - other: "%{count} 未読" + other: "未読 %{count}" new: lower_title_with_count: - other: "%{count}件" - lower_title: "NEW" + other: "新着 %{count}" + lower_title: "新着" title: "新着" title_with_count: other: "新着 (%{count})" - help: "最近投稿されたトピック" + help: "数日以内に作成されたトピック" posted: title: "自分の投稿" help: "投稿したトピック" @@ -2630,30 +2618,30 @@ ja: other: "%{categoryName} (%{count})" help: "%{categoryName} カテゴリの最新トピック" top: - title: "トップ" - help: "過去年間、月間、週間及び日間のアクティブトピック" + title: "人気" + help: "昨年、先月、先週、または昨日で最もアクティブだったトピック" all: - title: "すべて" + title: "全期間" yearly: - title: "年ごと" + title: "年間" quarterly: - title: "3ヶ月おき" + title: "四半期" monthly: - title: "毎月" + title: "月間" weekly: - title: "毎週" + title: "週間" daily: - title: "毎日" - all_time: "すべて" - this_year: "年" + title: "日間" + all_time: "全期間" + this_year: "今年" this_quarter: "今季" - this_month: "月" - this_week: "週" + this_month: "今月" + this_week: "今週" today: "今日" permission_types: full: "作成 / 返信 / 閲覧" create_post: "返信 / 閲覧" - readonly: "閲覧できる" + readonly: "閲覧" lightbox: download: "ダウンロード" counter: "%curr% / %total%" @@ -2666,65 +2654,65 @@ ja: new: "%{shortcut} 新着" unread: "%{shortcut} 未読" categories: "%{shortcut} カテゴリ" - top: "%{shortcut} トップ" + top: "%{shortcut} トップへ" bookmarks: "%{shortcut} ブックマーク" profile: "%{shortcut} プロフィール" messages: "%{shortcut} メッセージ" navigation: title: "ナビゲーション" - jump: "%{shortcut} # 投稿へ" + jump: "%{shortcut} 投稿へ移動" back: "%{shortcut} 戻る" up_down: "%{shortcut} 選択を移動 ↑ ↓" - open: "%{shortcut} トピックへ" - next_prev: "%{shortcut} 選択を次/前へ移動" + open: "%{shortcut} 選択したトピックを開く" + next_prev: "%{shortcut} 次/前のセクション" application: title: "アプリケーション" create: "%{shortcut} 新しいトピックを作成" - notifications: "%{shortcut} お知らせを開く" - hamburger_menu: "%{shortcut} メニューを開く" + notifications: "%{shortcut} 通知を開く" + hamburger_menu: "%{shortcut} ハンバーガーメニューを開く" user_profile_menu: "%{shortcut} ユーザーメニューを開く" show_incoming_updated_topics: "%{shortcut} 更新されたトピックを表示する" search: "%{shortcut} 検索" help: "%{shortcut} キーボードヘルプを表示する" - dismiss_topics: "%{shortcut} トピックを既読にする" - log_out: "%{shortcut} 次のセクション/前のセクション" + dismiss_topics: "%{shortcut} トピックを閉じる" + log_out: "%{shortcut} ログアウト" actions: - title: "アクション" - bookmark_topic: "%{shortcut} トピックのブックマークを切り替え" - pin_unpin_topic: "%{shortcut}トピックを 固定表示/解除" - share_topic: "%{shortcut} トピックをシェア" - share_post: "%{shortcut} 投稿をシェアする" - reply_as_new_topic: "%{shortcut} トピックへリンクして返信" + title: "操作" + bookmark_topic: "%{shortcut} ブックマークのトピックを切り替える" + pin_unpin_topic: "%{shortcut}トピックを固定/固定解除" + share_topic: "%{shortcut} トピックを共有" + share_post: "%{shortcut} 投稿を共有" + reply_as_new_topic: "%{shortcut} リンクトピックとして返信" reply_topic: "%{shortcut} トピックに返信" reply_post: "%{shortcut} 投稿に返信" - quote_post: "%{shortcut} 投稿を引用する" - like: "%{shortcut} 投稿を「いいね!」する" + quote_post: "%{shortcut} 投稿を引用" + like: "%{shortcut} 投稿に「いいね!」する" flag: "%{shortcut} 投稿を通報" - bookmark: "%{shortcut} 投稿をブックマークする" + bookmark: "%{shortcut} 投稿をブックマーク" edit: "%{shortcut} 投稿を編集" - delete: "%{shortcut} 投稿を削除する" - mark_muted: "%{shortcut} トピックをミュートする" - mark_regular: "%{shortcut} レギュラー(デフォルト)トピックにする" - mark_tracking: "%{shortcut} トピックを追跡する" - mark_watching: "%{shortcut} トピックをウォッチする" + delete: "%{shortcut} 投稿を削除" + mark_muted: "%{shortcut} トピックをミュート" + mark_regular: "%{shortcut} レギュラー (デフォルト) トピック" + mark_tracking: "%{shortcut} トピックを追跡" + mark_watching: "%{shortcut} トピックをウォッチ" print: "%{shortcut} トピックを印刷" badges: - granted_on: "%{date}にゲット!" - others_count: "その他のバッジ所有者(%{count})" + granted_on: "%{date} にゲット!" + others_count: "このバッジを獲得した他のユーザー (%{count})" title: バッジ badge_count: - other: "%{count}個のバッジ" + other: "%{count} 個のバッジ" more_badges: - other: "+%{count} リンク" - select_badge_for_title: プロフィールの肩書きに付けるバッジを選んでください - none: "(なし)" + other: "他 %{count}" + select_badge_for_title: タグラインとして使用するバッジを選択 + none: "(なし)" badge_grouping: getting_started: name: はじめの一歩 community: name: コミュニティ trust_level: - name: トラストレベル + name: 信頼レベル other: name: その他 posting: @@ -2736,17 +2724,17 @@ ja: selector_no_tags: "タグなし" changed: "タグを変更しました:" tags: "タグ" - choose_for_topic: "タグ(オプション)" + choose_for_topic: "オプションのタグ" add_synonyms: "追加" delete_tag: "タグを削除" rename_tag: "タグの名前を変更" - rename_instructions: "このタグの新しい名前を選択:" + rename_instructions: "タグの新しい名前を選択:" sort_by: "並べ替え:" - sort_by_count: "カウント" + sort_by_count: "件数" sort_by_name: "名前" manage_groups: "タグのグループを管理" - upload_successful: "タグのアップロードに成功しました" - delete_unused: "使われていないタグを削除" + upload_successful: "タグを正常にアップロードしました" + delete_unused: "未使用のタグを削除" cancel_delete_unused: "キャンセル" filters: without_category: "%{filter} %{tag} トピック" @@ -2754,35 +2742,35 @@ ja: watching: title: "ウォッチ中" watching_first_post: - title: "最初の投稿をウォッチする" - description: "このタグが付けられた新しいトピックは通知されますが、トピックへの返信は通知されません。" + title: "最初の投稿をウォッチ中" + description: "このタグの新着トピックは通知されますが、トピックへの返信は通知されません。" tracking: title: "追跡中" regular: title: "通常" - description: "他ユーザからメンションをされた場合、またはあなたのポストに回答が付いた場合に通知されます。" + description: "誰かが @ユーザー名であなたをメンションしたり、あなたに返信したりすると通知が送信されます。" muted: - title: "通知しない" + title: "ミュート" groups: title: "タグのグループ" new: "新しいグループ" new_name: "新しいタグのグループ" - save: "保存する" - delete: "削除する" + save: "保存" + delete: "削除" confirm_delete: "このタググループを削除してもよろしいですか?" everyone_can_use: "全員がタグを使用できます。" topics: none: unread: "未読のトピックはありません。" new: "新着トピックはありません。" - read: "あなたはまだ、どのトピックも読んでいません。" - posted: "あなたはまだ、どのトピックにも投稿していません。" + read: "まだトピックを一つも読んでいません。" + posted: "まだトピックを一つも投稿していません。" latest: "最新のトピックはありません。" bookmarks: "ブックマークしたトピックはありません。" - top: "トップトピックはありません。" + top: "人気のトピックはありません。" invite: - custom_message: "カスタムメッセージを作成して、あなたの招待状をもう少し個人的なものにします。" - custom_message_placeholder: "メッセージを入力" + custom_message: "カスタムメッセージで、招待状をもう少し個人的にします。" + custom_message_placeholder: "カスタムメッセージを入力" footer_nav: back: "戻る" share: "共有" @@ -2790,9 +2778,9 @@ ja: do_not_disturb: title: "おやすみモード..." label: "おやすみモード" - remaining: "残り%{remaining}" + remaining: "残り %{remaining}" options: - half_hour: "30分" + half_hour: "30 分" one_hour: "1時間" two_hours: "2時間" tomorrow: "明日まで" @@ -2800,30 +2788,30 @@ ja: set_schedule: "通知スケジュールを設定する" trust_levels: names: - newuser: "新しいユーザー" - basic: "ベーシックユーザー" + newuser: "新規ユーザー" + basic: "基本ユーザー" member: "メンバー" regular: "レギュラー" leader: "リーダー" admin_js: - type_to_filter: "設定項目を検索..." + type_to_filter: "フィルタの種類..." admin: - title: "Discourseの管理者" - moderator: "モデレータ" + title: "Discourse の管理者" + moderator: "モデレーター" tags: remove_muted_tags_from_latest: - always: "常に含める" - never: "追跡しない" + always: "常に" + never: "削除しない" dashboard: title: "ダッシュボード" - version: "Version" + version: "バージョン" up_to_date: "最新のバージョンです!" - critical_available: "重要度の高いアップデートが存在します。" - updates_available: "アップデートが存在します。" - please_upgrade: "今すぐアップデートしてください!" - no_check_performed: "アップデートの確認が正しく動作していません。sidekiq が起動していることを確認してください。" - stale_data: "更新のチェックが最近行われておりません。Sidekiqが動作しているか確認してください。" - version_check_pending: "アップロードしたてです。素晴らしいです! " + critical_available: "重大なアップデートがあります。" + updates_available: "アップデートがあります。" + please_upgrade: "アップグレードしてください!" + no_check_performed: "アップデートの確認が行われていません。sidekiq が起動していることを確認してください。" + stale_data: "最近、更新のチェックが行われていません。sidekiq が動作しているか確認してください。" + version_check_pending: "最近、アップグレードしましたね。素晴らしいです!" installed_version: "インストール済み" latest_version: "最新" problems_found: "現在のサイト設定に基づいたアドバイス" @@ -2832,7 +2820,7 @@ ja: last_checked: "最終チェック" refresh_problems: "更新" no_problems: "問題は見つかりませんでした。" - moderators: "モデレータ:" + moderators: "モデレーター:" admins: "管理者:" suspended: "停止中:" private_messages_short: "メッセージ" @@ -2841,14 +2829,14 @@ ja: uploads: "アップロード" backups: "バックアップ" traffic_short: "トラフィック" - traffic: "Application web requests" - page_views: "閲覧数" - page_views_short: "閲覧数" + traffic: "アプリケーション Web リクエスト" + page_views: "ページビュー" + page_views_short: "ページビュー" show_traffic_report: "詳細なトラフィックレポートを表示" general_tab: "一般" security_tab: "セキュリティ" - report_filter_any: "任意" - disabled: 利用不可 + report_filter_any: "すべて" + disabled: 無効化 reports: today: "今日" yesterday: "昨日" @@ -2871,89 +2859,89 @@ ja: category: label: カテゴリ commits: - latest_changes: "最新の更新内容:" - by: "by" + latest_changes: "最新の更新: 頻繁に更新してください!" + by: "コミット:" groups: new: title: "新しいグループ" name: too_short: "グループ名が短すぎます" - blank: "グループ名は空白にできません" + blank: "グループ名は空にできません" manage: interaction: - email: Eメール - incoming_email: "カスタムメールアドレス" + email: メール + incoming_email: "カスタム受信メールアドレス" incoming_email_placeholder: "メールアドレスを入力" - visibility: 可視性 + visibility: 表示状態 visibility_levels: title: "誰がこのグループを閲覧できますか?" - public: "だれでも" + public: "全員" membership: automatic: 自動 - trust_levels_title: "メンバーが追加されたときにメンバーに自動で付与されるトラストレベル:" + trust_levels_title: "メンバーが追加されたときにメンバーに自動で付与される信頼トレベル:" trust_levels_none: "なし" - automatic_membership_email_domains: "このリストにあるものと完全に一致するメー​​ルドメインで登録したユーザーは、自動的にこのグループに追加されます。" + automatic_membership_email_domains: "メールのドメインがこのリストにあるものと完全に一致する場合、そのメールのユーザーは自動的にこのグループに追加されます。" primary_group: "自動的にプライマリーグループとして設定する" name_placeholder: "グループ名、スペースなし、ユーザー名のルールと同じ" primary: "プライマリーグループ" - no_primary: "(プライマリーグループなし)" + no_primary: "(プライマリーグループなし)" title: "グループ" edit: "グループの編集" refresh: "更新" - about: "グループメンバーとグループ名を編集" + about: "グループのメンバーシップとグループ名を編集" group_members: "グループメンバー" delete: "削除" - delete_confirm: "このグループを削除しますか?" - delete_failed: "グループの削除に失敗しました。自動作成グループを削除することはできません。" + delete_confirm: "このグループを削除しますか?" + delete_failed: "グループを削除できません。自動作成グループを削除することはできません。" add: "追加" custom: "カスタム" - automatic: "自動で作成されたグループ" - default_title: "デフォルトタイトル" + automatic: "自動作成" + default_title: "デフォルトのタイトル" default_title_description: "グループ内のすべてのユーザーに適用されます" group_owners: オーナー add_owners: オーナーを追加 api: - generate_master: "マスターAPIキーを生成" - none: "現在アクティブなAPIキーが存在しません。" - user: "ユーザ" + generate_master: "マスター API キーを生成" + none: "現在アクティブな API キーが存在しません。" + user: "ユーザー" title: "API" - key: "Key" - created: 作成者 - generate: "API キーを生成" - revoke: "無効化" - all_users: "全てのユーザ" + key: "キー" + created: 作成 + generate: "生成" + revoke: "取り消す" + all_users: "すべてのユーザー" show_details: 詳細 description: 説明 save: 保存 - continue: 続く + continue: 続行 scopes: - action: アクション + action: 操作 web_hooks: - title: "Webhooks" - none: "現在、Webhooksはありません。" - save: "セーブ" - destroy: "削除する" + title: "Webhook" + none: "現在、Webhook はありません。" + save: "保存" + destroy: "削除" description: "説明" active: "アクティブ" delivery_status: failed: "失敗" - disabled: "利用不可" + disabled: "無効化" events: request: "リクエスト" - headers: "ヘッダ" + headers: "ヘッダー" body: "本文" ping: "Ping" - timestamp: "作成者" - actions: "アクション" + timestamp: "作成" + actions: "操作" plugins: title: "プラグイン" installed: "インストール済みプラグイン" name: "名前" - none_installed: "インストール済みのプラグインはありません" + none_installed: "インストール済みのプラグインはありません。" version: "バージョン" - enabled: "状態" - is_enabled: "有効" - not_enabled: "無効" + enabled: "有効化?" + is_enabled: "はい" + not_enabled: "いいえ" change_settings: "設定を変更" change_settings_short: "設定" howto: "プラグインをインストールするには?" @@ -2962,17 +2950,17 @@ ja: menu: backups: "バックアップ" logs: "ログ" - none: "バックアップはありません" + none: "バックアップはありません。" read_only: enable: title: "閲覧専用モードを有効にする" - label: "閲覧専用モードを有効" + label: "閲覧専用モードを有効化" confirm: "閲覧専用モードを有効にしてもよろしいですか?" disable: title: "閲覧専用モードを無効にする" - label: "閲覧専用モードを無効" + label: "閲覧専用モードを無効化" logs: - none: "ログがありません" + none: "まだログがありません..." columns: filename: "ファイル名" size: "サイズ" @@ -2981,71 +2969,71 @@ ja: title: "このインスタンスにバックアップをアップロード" uploading: "アップロード中..." uploading_progress: "アップロード中... %{progress}%" - success: "'%{filename}'のアップロードに成功しました。このファイルは現在処理中で、リストに表示されるまでに最大数分程度かかることがあります。" - error: "ファイル '%{filename}'のアップロード中にエラーが発生しました: %{message}" + success: "'%{filename}' のアップロードに成功しました。このファイルは現在処理中で、リストに表示されるまでに数分程度かかることがあります。" + error: "ファイル '%{filename}' のアップロード中にエラーが発生しました: %{message}" operations: is_running: "バックアップ作業を実行中..." - failed: "%{operation}失敗しました。ログをチェックください。" + failed: "%{operation}に失敗しました。ログを確認してください。" cancel: label: "キャンセル" title: "バックアップ作業をキャンセルする" confirm: "実行中のバックアップをキャンセルしてもよろしいですか?" backup: label: "バックアップ" - title: "バックアップを行います" - confirm: "新しくバックアップを行ってもよろしいですか?" - without_uploads: "はい(アップロードデータを含めない)" + title: "バックアップの作成" + confirm: "新しいバックアップを開始しますか?" + without_uploads: "はい (アップロードを含めない)" download: label: "ダウンロード" title: "ダウンロードリンクをメールで送る" alert: "バックアップのダウンロードリンクがメールで送られました。" destroy: - title: "バックアップを削除" - confirm: "このバックアップを削除しますか?" + title: "バックアップの削除" + confirm: "このバックアップを削除してもよろしいですか?" restore: - is_disabled: "バックアップ復元を無効にされています。" + is_disabled: "復元はサイト設定で無効になっています。" label: "復元" - title: "バックアップを復元" + title: "バックアップの復元" confirm: "バックアップを復元してもよろしいですか?" rollback: label: "ロールバック" - title: "データベースを前回の状態に戻します" + title: "データベースを前回の状態に戻す" export_csv: - success: "エクスポートを開始しました。処理が完了した後、メッセージでお知らせします。" - failed: "出力失敗。詳しくはログに参考してください。" + success: "エクスポートを開始しました。処理が完了したら、メッセージでお知らせします。" + failed: "エクスポートに失敗しました。ログを確認してください。" button_text: "エクスポート" button_title: - user: "全てのユーザをCSV出力" - staff_action: "スタッフの操作ログをCSV出力" - screened_email: "全てのスクリーンメールアドレスをCSV出力" - screened_ip: "全てのスクリーンIPリストをCSV出力" - screened_url: "全てのスクリーンURLリストをCSV出力" + user: "ユーザーの全リストを CSV 形式でエクスポートします。" + staff_action: "スタッフの全操作ログを CSV 形式でエクスポートします。" + screened_email: "スクリーンメールアドレスの全リストを CSV 形式でエクスポートします。" + screened_ip: "スクリーン IP の全リストを CSV 形式でエクスポートします。" + screened_url: "スクリーン URL の全リストを CSV 形式でエクスポートします。" export_json: button_text: "エクスポート" invite: - button_text: "招待を送信" - button_title: "招待を送信" + button_text: "招待状を送信" + button_title: "招待状を送信" customize: title: "カスタマイズ" long_title: "サイトのカスタマイズ" preview: "プレビュー" save: "保存" new: "新規" - new_style: "新しいスタイル" + new_style: "新規スタイル" delete: "削除" - color: "カラー" + color: "色" opacity: "透明度" copy: "コピー" copy_to_clipboard: "クリップボードにコピー" copied_to_clipboard: "クリップボードにコピーしました" - copy_to_clipboard_error: "クリップボードにコピーする際にエラーが発生しました" + copy_to_clipboard_error: "データをクリップボードにコピーする際にエラーが発生しました" email_templates: - title: "Eメール" + title: "メール" subject: "件名" - multiple_subjects: "このメールのテンプレートは複数の件名があります。" + multiple_subjects: "このメールテンプレートには複数の件名があります。" body: "本文" revert: "変更を元に戻す" - revert_confirm: "本当に変更を元に戻しますか?" + revert_confirm: "変更を元に戻してよろしいですか?" theme: theme: "テーマ" customize_desc: "カスタマイズ:" @@ -3061,62 +3049,62 @@ ja: upload: "アップロード" installed: "インストール済み" install_popular: "人気" - about_theme: "このサイトについて" + about_theme: "テーマについて" license: "ライセンス" - version: "Version:" - enable: "利用できます" - disable: "利用できません" + version: "バージョン:" + enable: "有効化" + disable: "無効化" check_for_updates: "アップデートを確認" updating: "アップデート中..." add: "追加" scss: text: "CSS" header: - text: "ヘッダ" + text: "ヘッダー" footer: text: "フッター" head_tag: text: "" - title: "タグの前に挿入されるHTML" + title: " タグの前に挿入される HTML" body_tag: text: "" - title: "タグの前に挿入されるHTML" + title: " タグの前に挿入される HTML" colors: - title: "カラー" - copy_name_prefix: "のコピー" - undo: "取り消す" - undo_title: "変更を元に戻して、前回保存されたカラーにします" - revert: "取り戻す" + title: "色" + copy_name_prefix: "コピー元:" + undo: "元に戻す" + undo_title: "変更を元に戻して、前回保存された色にします。" + revert: "戻す" primary: name: "プライマリー" - description: "テキスト、アイコンと枠の色" + description: "ほとんどのテキスト、アイコン、ボーダー。" secondary: name: "セカンダリー" - description: "メイン背景とボタンのテキスト色" + description: "メインの背景色とボタンのテキスト色。" tertiary: - name: "ターシャリ" - description: "リンク、いくつかのボタン、お知らせ、アクセントカラー" + name: "ターシャリー" + description: "リンク、一部のボタン、通知、およびアクセントカラー。" quaternary: - name: "クォータナリ" - description: "ナビゲーションリンク" + name: "クォータナリー" + description: "ナビゲーションリンク。" header_background: name: "ヘッダー背景" - description: "ヘッダー背景色" + description: "サイトのヘッダー背景色。" header_primary: - name: "ヘッダープライマリー" - description: "サイトヘッダーのテキストとアイコン" + name: "ヘッダーのプライマリー" + description: "サイトのヘッダーのテキストとアイコン。" highlight: name: "ハイライト" - description: "ページのハイライトされた部分(投稿やトピックなど)" + description: "投稿やトピックなど、ページのハイライトされた要素の背景色。" danger: name: "危険" - description: "削除された投稿やトピックのハイライトカラー" + description: "投稿やトピックの削除といった操作の強調色。" success: name: "成功" - description: "操作が成功したことを示すために使用します" + description: "操作が成功したことを示すために使用します。" love: - name: "love" - description: "「いいね!」ボタンの色" + name: "ラブ" + description: "「いいね!」ボタンの色。" email_style: css: "CSS" email: @@ -3124,30 +3112,30 @@ ja: settings: "設定" templates: "テンプレート" sending_test: "テストメールを送信中..." - error: "ERROR - %{server_error}" - test_error: "テストメールを送れませんでした。メール設定、またはホストをメールコネクションをブロックされていないようを確認してください。" - sent: "送信したメール" - skipped: "スキップ済み" + error: "エラー - %{server_error}" + test_error: "テストメールを送信中に問題が発生しました。メール設定を再確認し、ホストがメール接続をブロックしていないことを確認してから、もう一度お試しください。" + sent: "送信" + skipped: "スキップ" bounced: "バウンス" - received: "受信したメール" - rejected: "拒否されたメール" + received: "受信" + rejected: "拒否" sent_at: "送信時間" - time: "日付" - user: "ユーザ" + time: "時間" + user: "ユーザー" email_type: "メールタイプ" - to_address: "送信先アドレス" - test_email_address: "テスト用メールアドレス" + to_address: "宛先アドレス" + test_email_address: "テストするメールアドレス" send_test: "テストメールを送る" - sent_test: "送信完了!" + sent_test: "送信完了!" delivery_method: "送信方法" refresh: "更新" - send_digest_label: "この結果を送信:" + send_digest_label: "この結果の送信先:" send_digest: "送信" - sending_email: "メールを送信しています..." - format: "フォーマット" + sending_email: "メールを送信中..." + format: "形式" html: "html" - text: "text" - last_seen_user: "ユーザが最後にサイトを訪れた日:" + text: "テキスト" + last_seen_user: "最終アクセスユーザー:" reply_key: "返信キー" skipped_reason: "スキップの理由" incoming_emails: @@ -3160,7 +3148,7 @@ ja: modal: title: "受信メールの詳細" error: "エラー" - headers: "ヘッダ" + headers: "ヘッダー" subject: "件名" body: "本文" filters: @@ -3170,23 +3158,23 @@ ja: subject_placeholder: "件名..." error_placeholder: "エラー" logs: - none: "ログなし" + none: "ログはありません。" filters: - title: "フィルター" - user_placeholder: "ユーザ名" + title: "フィルタ" + user_placeholder: "ユーザー名" address_placeholder: "name@example.com" - type_placeholder: "まとめ、サインアップ..." + type_placeholder: "ダイジェスト、サインアップ..." reply_key_placeholder: "返信キー" logs: title: "ログ" - action: "アクション" + action: "操作" created_at: "作成" - last_match_at: "最終マッチ" - match_count: "マッチ" + last_match_at: "最終一致" + match_count: "一致" ip_address: "IP" - topic_id: "トピックID" - post_id: "投稿ID" - category_id: "カテゴリID" + topic_id: "トピック ID" + post_id: "投稿 ID" + category_id: "カテゴリ ID" delete: "削除" edit: "編集" save: "保存" @@ -3196,88 +3184,88 @@ ja: staff_actions: all: "すべて" filter: "フィルタ:" - title: "スタッフ操作" - clear_filters: "すべて表示する" + title: "スタッフの操作" + clear_filters: "すべて表示" staff_user: "ユーザー" - target_user: "対象ユーザ" + target_user: "対象ユーザー" subject: "対象" when: "日時" - context: "コンテンツ" + context: "コンテキスト" details: "詳細" previous_value: "変更前" new_value: "変更後" - diff: "差分を見る" - show: "詳しく見る" + diff: "差分" + show: "表示" modal_title: "詳細" no_previous: "変更前の値がありません。" deleted: "変更後の値がありません。レコードが削除されました。" actions: - delete_user: "ユーザを削除" - change_trust_level: "トラストレベルを変更" - change_username: "ユーザ名変更" - change_site_setting: "サイトの設定を変更" - change_theme: "テーマを変更" - delete_theme: "テーマを削除" - change_site_text: "サイトテキストを変更" - suspend_user: "ユーザを凍結する" - unsuspend_user: "ユーザの凍結を解除する" - grant_badge: "バッジを付与" - revoke_badge: "バッジを取り消す" - check_email: "メールアドレスを表示する" + delete_user: "ユーザーの削除" + change_trust_level: "信頼レベルの変更" + change_username: "ユーザー名の変更" + change_site_setting: "サイトの設定の変更" + change_theme: "テーマの変更" + delete_theme: "テーマの削除" + change_site_text: "サイトのテキストの変更" + suspend_user: "ユーザー凍結" + unsuspend_user: "ユーザーの凍結解除" + grant_badge: "バッジ付与" + revoke_badge: "バッジ取り消し" + check_email: "メール確認" delete_topic: "トピック削除" delete_post: "投稿削除" - impersonate: "なりすまし" - anonymize_user: "匿名ユーザ" - roll_up: "IPブロックをロールアップ" - change_category_settings: "カテゴリ設定を変更" - delete_category: "カテゴリを削除する" - create_category: "カテゴリを作る" - grant_admin: "管理者権限を付与" - revoke_admin: "管理者権限を剥奪" - grant_moderation: "モデレータ権限を付与" - revoke_moderation: "モデレータ権限を剥奪" - backup_create: "バックアップを生成" - deleted_tag: "タグを削除" - renamed_tag: "タグ名変更" - change_readonly_mode: "閲覧専用モードに変更する" - backup_download: "バックアップをダウンロード" - backup_destroy: "バックアップを消去" + impersonate: "代理操作" + anonymize_user: "ユーザーの匿名化" + roll_up: "IP ブロックのロールアップ" + change_category_settings: "カテゴリ設定の変更" + delete_category: "カテゴリの削除" + create_category: "カテゴリの作成" + grant_admin: "管理者権限の付与" + revoke_admin: "管理者権限の取り消し" + grant_moderation: "モデレーター権限の付与" + revoke_moderation: "モデレーター権限の取り消し" + backup_create: "バックアップの作成" + deleted_tag: "タグの削除" + renamed_tag: "タグ名の変更" + change_readonly_mode: "閲覧専用モードに変更" + backup_download: "バックアップのダウンロード" + backup_destroy: "バックアップの消去" screened_emails: - title: "ブロック対象アドレス" - description: "新規アカウント作成時、次のメールアドレスからの登録をブロックする。" + title: "スクリーン対象メール" + description: "誰かが新規アカウントを作成すると、次のメールアドレスがチェックされるか、登録がブロックされるか、ほかの操作が実行されます。" email: "メールアドレス" actions: - allow: "許可する" + allow: "許可" screened_urls: - title: "ブロック対象URL" - description: "記入されているURLは、スパムユーザとして認識されているユーザの投稿に使用されています。" + title: "スクリーン対象 URL" + description: "ここにリストされている URL は、スパムユーザーとして認識されているユーザーの投稿に使用されています。" url: "URL" domain: "ドメイン" screened_ips: - title: "スクリーン対象IP" + title: "スクリーン対象 IP" delete_confirm: "%{ip_address} のルールを削除してもよろしいですか?" - roll_up_confirm: "Are you sure you want to roll up commonly screened IP addresses into subnets?" - rolled_up_some_subnets: "Successfully rolled up IP ban entries to these subnets: %{subnets}." - rolled_up_no_subnet: "There was nothing to roll up." + roll_up_confirm: "共通スクリーン対象 IP アドレスをサブネットにロールアップしてもよろしいですか?" + rolled_up_some_subnets: "IP ブロックエントリを次のサブネットにロールアップしました: %{subnets}。" + rolled_up_no_subnet: "ロールアップするものがありません。" actions: block: "ブロック" do_nothing: "許可" - allow_admin: "Allow Admin" + allow_admin: "管理者を許可" form: label: "新規:" - ip_address: "IPアドレス" + ip_address: "IP アドレス" add: "追加" - filter: "Search" + filter: "検索" roll_up: - text: "Roll up" - title: "Creates new subnet ban entries if there are at least 'min_ban_entries_for_roll_up' entries." + text: "ロールアップ" + title: "少なくとも 'min_ban_entries_for_roll_up' エントリがある場合、新しいサブネットブロックエントリを作成します。" search_logs: types: - header: "ヘッダ" + header: "ヘッダー" logster: title: "エラーログ" watched_words: - title: "監視ワード" + title: "ウォッチ中の言葉" search: "検索" clear_filter: "クリア" download: ダウンロード @@ -3287,21 +3275,21 @@ ja: flag: "通報" link: "リンク" action_descriptions: - flag: "これらの言葉を含む投稿を許可しますが、仲裁者がこれをレビューできるよう、不適切であると通報されます。" + flag: "これらの言葉を含む投稿を許可しますが、モデレーターがこれをレビューできるよう、不適切として通報されます。" form: placeholder_regexp: "正規表現" link_label: "リンク" add: "追加" success: "成功" - exists: "既に存在します。" - upload_successful: "アップロードが成功し、単語が追加されました。" + exists: "すでに存在します" + upload_successful: "アップロードが成功し、言葉が追加されました。" test: no_matches: "一致する項目が見つかりませんでした" impersonate: - title: "このユーザに切り替える" - help: "Use this tool to impersonate a user account for debugging purposes. You will have to log out once finished." - not_found: "ユーザが見つかりませんでした。" - invalid: "申し訳ありませんがこのユーザとして使えません。" + title: "代理操作" + help: "このツールを使用して、デバッグ目的でユーザーアカウントを代理操作します。作業を終了したら、ログアウトしてください。" + not_found: "ユーザーが見つかりません。" + invalid: "このユーザーを代理して操作できません。" users: title: "ユーザー" create: "管理者を追加" @@ -3314,164 +3302,164 @@ ja: active: "アクティブ" staff: "スタッフ" suspended: "凍結" - staged: "段階" + staged: "ステージング" approved: "承認済み?" titles: - active: "アクティブユーザ" - new: "新しいユーザ" - pending: "保留中のユーザ" - newuser: "トラストレベル0のユーザ (新しいユーザ)" - basic: "トラストレベル1のユーザ (ベーシックユーザ)" - member: "トラストレベル2のユーザ (メンバー)" - regular: "トラストレベル3のユーザ (レギュラー)" - leader: "トラストレベル4のユーザ (リーダー)" + active: "アクティブユーザー" + new: "新規ユーザー" + pending: "レビュー待ちのユーザー" + newuser: "信頼レベル 0 のユーザー (新規ユーザー)" + basic: "信頼レベル 1 のユーザー (基本ユーザー)" + member: "信頼レベル 2 のユーザー (メンバー)" + regular: "信頼レベル 3 のユーザー (レギュラー)" + leader: "信頼レベル 4 のユーザー (リーダー)" staff: "スタッフ" - admins: "管理者ユーザ" - moderators: "モデレータ" - suspended: "凍結中のユーザ" - not_verified: "検証されていません" + admins: "管理者ユーザー" + moderators: "モデレーター" + suspended: "凍結中のユーザー" + not_verified: "未確認" check_email: - title: "メールアドレスを表示する" - text: "表示する" + title: "このユーザーのメールアドレスを表示する" + text: "表示" check_sso: - text: "詳しく見る" + text: "表示" user: - suspend_failed: "ユーザの凍結に失敗しました: %{error}" - unsuspend_failed: "ユーザの凍結解除に失敗しました: %{error}" - suspend_duration: "ユーザを何日間凍結しますか?" - suspend_reason_label: "アカウントを凍結する理由を説明してください。ここに書いた理由は、このユーザのプロファイルページにおいて全員が閲覧可能な状態で公開されます。またこのユーザがログインを試みた際にも表示されます。" + suspend_failed: "このユーザーを凍結中にエラーが発生しました: %{error}" + unsuspend_failed: "このユーザーの凍結を解除中にエラーが発生しました: %{error}" + suspend_duration: "ユーザーをどれくらいの期間凍結しますか?" + suspend_reason_label: "凍結する理由を説明してください。ここに書いた理由は、このユーザーのプロファイルページで全員に閲覧可能となります。またこのユーザーがログインを試みた際にも表示されます。手短に説明してください。" suspend_reason: "理由" - suspend_reason_title: "凍結理由" + suspend_reason_title: "凍結の理由" suspend_message: "メールメッセージ" - suspended_by: "凍結したユーザ" + suspended_by: "凍結者:" silence_reason: "理由" - silenced_by: "消音化したユーザ" - silence_duration: "ユーザーはどのくらいの間、消音化されますか?" - silence_reason_label: "なぜあなたはこのユーザーを消音化させますか?" - silence_reason_placeholder: "消音化の理由" + silenced_by: "投稿禁止設定者:" + silence_duration: "ユーザーをどれくらいの期間、投稿禁止にしますか?" + silence_reason_label: "このユーザーの投稿を禁止する理由を説明してください。" + silence_reason_placeholder: "投稿禁止の理由" silence_message: "メールメッセージ" silence_message_placeholder: "(デフォルトのメッセージを送信する場合は空白のままにします)" - suspended_until: "(%{until}まで)" - cant_suspend: "このユーザーは凍結できません。" - delete_all_posts: "全ての投稿を削除" - moderator: "モデレータ権限の所有" - admin: "管理者権限の所有" - suspended: "凍結状態" - staged: "ステージドモードの状態" - show_admin_profile: "アカウントの管理" - show_public_profile: "パブリックプロフィールを見る" - impersonate: "このユーザになりすます" - action_logs: "アクションログ" - ip_lookup: "IPアドレスを検索" + suspended_until: "(%{until} まで)" + cant_suspend: "このユーザーを凍結できません。" + delete_all_posts: "すべての投稿を削除する" + moderator: "モデレーター?" + admin: "管理者?" + suspended: "凍結?" + staged: "ステージング?" + show_admin_profile: "管理者" + show_public_profile: "公開プロフィールを表示" + impersonate: "代理操作" + action_logs: "操作ログ" + ip_lookup: "IP アドレスを検索" log_out: "ログアウト" - logged_out: "すべてのデバイスでログアウトしました" - revoke_admin: "管理者権限を剥奪" + logged_out: "すべてのデバイスからログアウトしました" + revoke_admin: "管理者権限を取り消す" grant_admin: "管理者権限を付与" - revoke_moderation: "モデレータ権限を剥奪" + revoke_moderation: "モデレータ権限を取り消す" grant_moderation: "モデレータ権限を付与" - unsuspend: "凍結解除" + unsuspend: "凍結を解除" suspend: "凍結" - reputation: レピュテーション - permissions: パーミッション + reputation: 評判 + permissions: 権限 activity: アクティビティ - like_count: '「いいね!」付けた/もらった数' - last_100_days: "過去100日に" - private_topics_count: プライベートトピックの数 - posts_read_count: 読んだ投稿数 + like_count: '「いいね!」した数/された数' + last_100_days: "過去 100 日間" + private_topics_count: 非公開トピック + posts_read_count: 既読の投稿数 post_count: 作成した投稿数 - topics_entered: 閲覧したトピックの数 + topics_entered: 閲覧したトピック数 flags_given_count: 通報した数 flags_received_count: 通報された数 - warnings_received_count: 警告されました - flags_given_received_count: "通報をした / された" + warnings_received_count: 警告された数 + flags_given_received_count: "通報した / された数" approve: "承認" - approved_by: "承認したユーザ" - approve_success: "ユーザが承認され、アクティベーション方法を記載したメールが送信されました。" - approve_bulk_success: "成功!!選択したユーザ全員が承認され、メールが送信されました。" - time_read: "読んだ時間" - anonymize: "匿名ユーザ" - anonymize_confirm: "このアカウントと匿名化してよいですか?ユーザ名、メールアドレスが変更され、全てのプロフィール情報がリセットされます" - anonymize_yes: "はい。アカウントを匿名化してください" - anonymize_failed: "アカウントの匿名化中に問題が発生しました" - delete: "ユーザを削除" + approved_by: "承認者:" + approve_success: "ユーザーは承認され、アクティベーション方法を記載したメールが送信されました。" + approve_bulk_success: "成功!選択したすべてのユーザーは承認され、通知が送信されました。" + time_read: "閲覧時間" + anonymize: "ユーザーを匿名化" + anonymize_confirm: "このアカウントを匿名化してよろしいですか?ユーザー名とメールアドレスが変更され、すべてのプロフィール情報がリセットされます。" + anonymize_yes: "はい。このアカウントを匿名化する" + anonymize_failed: "アカウントの匿名化中に問題が発生しました。" + delete: "ユーザーを削除" merge: prompt: cancel: "キャンセル" confirmation: cancel: "キャンセル" - delete_forbidden_because_staff: "管理者およびモデレータアカウントは削除できません。" - delete_posts_forbidden_because_staff: "管理者、モデレータの全ての投稿は削除できません" + delete_forbidden_because_staff: "管理者およびモデレーターアカウントは削除できません。" + delete_posts_forbidden_because_staff: "管理者とモデレーターのすべての投稿を削除できません。" delete_forbidden: - other: "投稿済のユーザは削除できません。ユーザを削除する前に全ての投稿を削除してください。(%{count}日以上経過した投稿は削除できません )" + other: "投稿が存在するユーザーを削除できません。ユーザーを削除する前にすべての投稿を削除してください。(%{count} 日以上経過した投稿は削除できません)" cant_delete_all_posts: - other: "全ての投稿を削除できませんでした。%{count}日以上経過した投稿があります。(設定: delete_user_max_post_age)" + other: "すべての投稿を削除できません。%{count} 日以上経過した投稿があります。(delete_user_max_post_age の設定)" cant_delete_all_too_many_posts: - other: "全ての投稿を削除できませんでした。ユーザは%{count} 件以上投稿しています。(delete_all_posts_max)" - delete_and_block: "アカウントを削除し、同一メールアドレス及びIPアドレスからのサインアップをブロックします" - delete_dont_block: "削除する" - deleted: "ユーザが削除されました。" - delete_failed: "ユーザの削除中にエラーが発生しました。このユーザの全投稿を削除したことを確認してください。" + other: "すべての投稿を削除することはできませんでした。ユーザーは %{count} 件以上投稿しています。(delete_all_posts_max)" + delete_and_block: "このメールと IP アドレスを削除してブロックする" + delete_dont_block: "削除のみ" + deleted: "ユーザーが削除されました。" + delete_failed: "ユーザーを削除中にエラーが発生しました。このユーザーのすべての投稿を削除したことを確認してから、ユーザーを削除してください。" send_activation_email: "アクティベーションメールを送信" activation_email_sent: "アクティベーションメールが送信されました。" - send_activation_email_failed: "アクティベーションメールの送信に失敗しました。 %{error}" - activate: "アカウントのアクティベート" - activate_failed: "ユーザのアクティベートに失敗しました。" - deactivate_account: "アカウントのアクティベート解除" - deactivate_failed: "ユーザのアクティベート解除に失敗しました。" + send_activation_email_failed: "別のアクティベーションメールの送信中に問題が発生しました。 %{error}" + activate: "アカウントを有効化" + activate_failed: "ユーザーのアクティベーションに問題が発生しました。" + deactivate_account: "アカウントを無効化" + deactivate_failed: "ユーザーのアクティベーション解除に問題が発生しました。" bounce_score: "バウンススコア" reset_bounce_score: label: "リセット" - title: "バウンススコアを0にリセット" + title: "バウンススコアを 0 にリセット" visit_profile: "このユーザーのプロフィールは、このユーザーの設定ページで編集します" - deactivate_explanation: "アクティベート解除されたユーザは、メールで再アクティベートする必要があります。" - suspended_explanation: "凍結中のユーザはログインできません。" - staged_explanation: "ステージドユーザは特定のトピック宛に、メールを経由して投稿する事が出来ます。" + deactivate_explanation: "アクティベーションを解除されたユーザーは、メールアドレスを確認し直す必要があります。" + suspended_explanation: "凍結中のユーザーはログインできません。" + staged_explanation: "ステージングユーザーは特定のトピックにメールでのみ投稿することができます。" bounce_score_explanation: - none: "直近ではバウンスメールは受け取っていません。" - some: "最近、メールからバウンスが受信されました。" - threshold_reached: "そのメールから何度もバウンスを受信しています。" - trust_level_change_failed: "ユーザのトラストレベル変更に失敗しました。" - suspend_modal_title: "凍結中のユーザ" - trust_level_2_users: "トラストレベル2のユーザ" - trust_level_3_requirements: "トラストレベル3の条件" - trust_level_locked_tip: "トラストレベルはロックされています。システムがユーザを昇格、降格させることはありません" - trust_level_unlocked_tip: "トラストレベルはアンロックされています。システムはユーザを昇格、降格させます" - lock_trust_level: "トラストレベルをロック" - unlock_trust_level: "トラストレベルをアンロック" + none: "このメールから最近受信したバウンスメールはありません。" + some: "このメールから最近受信したメールがいくつかあります。" + threshold_reached: "そのメールから多数のバウンスを受信しました。" + trust_level_change_failed: "ユーザーの信頼レベルを変更中に問題が発生しました。" + suspend_modal_title: "ユーザーを凍結" + trust_level_2_users: "信頼レベル 2 のユーザー" + trust_level_3_requirements: "信頼レベル 3 の要件" + trust_level_locked_tip: "信頼レベルはロックされています。システムがユーザーを昇格または降格することはありません" + trust_level_unlocked_tip: "信頼レベルのロックは解除されています。システムがユーザーを昇格または降格することがあります" + lock_trust_level: "信頼レベルをロック" + unlock_trust_level: "信頼レベルをロック解除" tl3_requirements: - title: "トラストレベル3の条件" + title: "信頼レベル 3 の要件" value_heading: "値" - requirement_heading: "条件" + requirement_heading: "要件" visits: "アクセス" days: "日" - topics_replied_to: "返信したトピック" - topics_viewed: "閲覧したトピックの数" - topics_viewed_all_time: "閲覧したトピック" + topics_replied_to: "返信するトピック" + topics_viewed: "閲覧したトピック" + topics_viewed_all_time: "閲覧したトピック (全期間)" posts_read: "閲覧した投稿数" - posts_read_all_time: "閲覧した投稿数" - flagged_posts: "通報されている投稿" - flagged_by_users: "通報されたユーザ" - likes_given: "与えた「いいね!」" - likes_received: "もらった「いいね!」" - likes_received_days: "「いいね!」数:日別" - likes_received_users: "「いいね!」数: ユーザ別" - qualifies: "トラストレベル3の条件を満たしています。" - does_not_qualify: "トラストレベル3の条件を満たしていません。" - will_be_promoted: "もうすぐ昇格します" - will_be_demoted: "もうすぐ降格します" - on_grace_period: "現在の昇格期間中は、降格されません" - locked_will_not_be_promoted: "トラストレベルはロックされています。昇格することはありません" - locked_will_not_be_demoted: "トラストレベルはロックされています。降格することはありません" + posts_read_all_time: "閲覧した投稿数 (全期間)" + flagged_posts: "通報された投稿" + flagged_by_users: "通報したユーザー" + likes_given: "「いいね!」した数" + likes_received: "「いいね!」された数" + likes_received_days: "「いいね!」された数: 日別" + likes_received_users: "「いいね!」された数: ユニークユーザー" + qualifies: "信頼レベル 3 の要件を満たしています。" + does_not_qualify: "信頼レベル 3 の要件を満たしていません。" + will_be_promoted: "もうすぐ昇格です。" + will_be_demoted: "もうすぐ降格です。" + on_grace_period: "現在の昇格猶予期間中です。降格されません。" + locked_will_not_be_promoted: "信頼レベルはロックされました。今後昇格することはありません。" + locked_will_not_be_demoted: "信頼レベルはロックされました。今後降格することはありません。" discourse_connect: - external_id: "External ID" + external_id: "外部 ID" external_username: "ユーザー名" external_name: "名前" - external_email: "Eメール" - external_avatar_url: "プロフィール画像URL" + external_email: "メール" + external_avatar_url: "プロフィール画像 URL" user_fields: - title: "ユーザフィールド" - help: "ユーザが記入する項目(フィールド)を追加します" - create: "ユーザフィールド作成" + title: "ユーザーフィールド" + help: "ユーザーが入力できるフィールドを追加します。" + create: "ユーザーフィールドを作成" untitled: "無題" name: "フィールド名" type: "フィールドタイプ" @@ -3480,22 +3468,22 @@ ja: edit: "編集" delete: "削除" cancel: "キャンセル" - delete_confirm: "ユーザフィールドを削除してもよいですか?" + delete_confirm: "このユーザーフィールドを削除してもよろしいですか?" options: "オプション" required: title: "サインアップ時の必須にしますか?" enabled: "必須" - disabled: "任意" + disabled: "オプション" editable: title: "サインアップ後に編集可能にしますか?" enabled: "編集可能" disabled: "編集不可" show_on_profile: - title: "パブリックプロフィールに表示しますか?" + title: "公開プロフィールに表示しますか?" enabled: "プロフィールに表示" disabled: "プロフィール非表示" show_on_user_card: - title: "ユーザカードに表示しますか?" + title: "ユーザーカードに表示しますか?" enabled: "ユーザーカードに表示する" disabled: "ユーザーカードに表示しない" field_types: @@ -3503,47 +3491,47 @@ ja: confirm: "確認" dropdown: "ドロップダウン" site_text: - description: "フォーラム上のテキストを編集することができます。下のフォームで検索する事ができます:" - search: "編集したいものを入力して検索" - title: "Text" + description: "フォーラム上のテキストを編集することができます。まずは、以下で検索してください。" + search: "編集するテキストを検索" + title: "テキスト" edit: "編集" - revert: "変更を元に戻す" - revert_confirm: "本当に変更を元に戻しますか?" - go_back: "戻る" - recommended: "それぞれにあわせて、文章を変えることをオススメします:" + revert: "変更を戻す" + revert_confirm: "変更を戻してよろしいですか?" + go_back: "検索に戻る" + recommended: "ニーズに合わせて次のテキストをカスタマイズすることをお勧めします。" show_overriden: "上書き部分のみ表示" settings: - show_overriden: "既に設定済の項目のみ表示" + show_overriden: "上書き部分のみ表示" reset: "リセット" none: "なし" site_settings: title: "設定" no_results: "何も見つかりませんでした。" clear_filter: "クリア" - add_url: "URL追加" + add_url: "URL を追加" add_host: "ホストを追加" uploaded_image_list: upload: label: "アップロード" categories: - all_results: "全て" - required: "必須設定" + all_results: "すべて" + required: "必須" basic: "基本設定" - users: "ユーザ" + users: "ユーザー" posting: "投稿" email: "メール" files: "ファイル" - trust: "トラストレベル" + trust: "信頼レベル" security: "セキュリティ" onebox: "Onebox" seo: "SEO" - spam: "スパム" + spam: "迷惑" rate_limits: "投稿制限" - developer: "開発者向け" - embedding: "埋め込む" - legal: "法律に基づく情報" + developer: "開発者" + embedding: "埋め込み" + legal: "法関連" api: "API" - user_api: "ユーザーAPI" + user_api: "ユーザー API" uncategorized: "その他" backups: "バックアップ" login: "ログイン" @@ -3559,105 +3547,105 @@ ja: title: バッジ new_badge: 新しいバッジ new: 新規 - name: バッジの名前 + name: 名前 badge: バッジ - display_name: バッジの表示名 - description: バッジの説明 + display_name: 表示名 + description: 説明 long_description: 詳しい説明 - badge_type: バッジの種類 + badge_type: バッジタイプ badge_grouping: グループ badge_groupings: modal_title: バッジのグループ granted_by: 付与者 granted_at: 付与日 - reason_help: (投稿かトピックへのリンク) - save: バッジを保存する + reason_help: (投稿またはトピックへのリンク) + save: 保存 delete: 削除 delete_confirm: このバッジを削除してもよろしいですか? revoke: 取り消す reason: 理由 - expand: '&hellipを展開' - revoke_confirm: このバッジを取り消しますか? - edit_badges: バッジを編集する + expand: 展開… + revoke_confirm: このバッジを取り消してもよろしいですか? + edit_badges: バッジを編集 grant_badge: バッジを付与 - granted_badges: 付けられたバッジ - grant: 付ける - no_user_badges: "%{name}はバッジを付けられていません。" - no_badges: 付けられるバッジがありません - none_selected: "バッジを選択して開始" - allow_title: バッジを肩書きとして使用されることを許可する - multiple_grant: 何度もゲットできるようにする - listable: 公開されるバッジページにバッジを表示する + granted_badges: 付与されたバッジ + grant: 付与 + no_user_badges: "%{name} はバッジを付与されていません。" + no_badges: 付与できるバッジがありません。 + none_selected: "開始するにはバッジを選択してください" + allow_title: バッジをタグラインとして使用することを許可する + multiple_grant: 何度でも付与できる + listable: バッジの公開ページにバッジを表示する enabled: バッジを有効にする icon: アイコン image: 画像 - query: バッジクエリ(SQL) - target_posts: 投稿を対象 - auto_revoke: 毎日クエリの取り消しを実行 - show_posts: バッジページでバッジを取得したことを投稿する + query: バッジクエリ (SQL) + target_posts: クエリターゲット投稿 + auto_revoke: クエリの取り消しを毎日実行する + show_posts: バッジページでバッジ付与の投稿を表示する trigger: トリガー trigger_type: none: "毎日更新する" - post_action: "ユーザが投稿に影響を与えたとき" - post_revision: "ユーザが投稿、投稿の編集をした時" - trust_level_change: "ユーザのトラストレベルが変わったとき" - user_change: "ユーザを作成、編集したとき" - post_processed: "投稿の処理後" + post_action: "ユーザーが投稿に影響を与えたとき" + post_revision: "ユーザーが投稿を編集または作成したとき" + trust_level_change: "ユーザーの信頼レベルが変わったとき" + user_change: "ユーザーが編集または作成されたとき" + post_processed: "投稿が処理された後" preview: - link_text: "付与するバッジをプレビュー" - plan_text: "クエリ計画をプレビュー" - modal_title: "バッジクエリをプレビュー" - sql_error_header: "クエリにエラーがあります" - error_help: "バッジクエリのヘルプについては、以下のリンクを参照してください" + link_text: "付与されたバッジをプレビュー" + plan_text: "クエリプランでプレビュー" + modal_title: "バッジクエリのプレビュー" + sql_error_header: "クエリにエラーがあります。" + error_help: "バッジクエリのヘルプについては、以下のリンクをご覧ください。" bad_count_warning: - header: "WARNING!" - text: "There are missing grant samples. This happens when the badge query returns user IDs or post IDs that do not exist. This may cause unexpected results later on - please double-check your query." - no_grant_count: "付与されたバッジはありません" + header: "警告!" + text: "付与のサンプルがありません。これは、バッジクエリが存在しないユーザー ID または投稿 ID を返す場合に発生します。後で、予期しない結果が発生する可能性があります。クエリを再確認してください。" + no_grant_count: "割り当てるバッジはありません。" grant_count: - other: "%{count}個のバッジが付与されています" - sample: "サンプル:" + other: "%{count} 個のバッジが割り当てられます。" + sample: "サンプル:" grant: with: %{username} - with_post: %{username} for post in %{link} - with_post_time: %{username} for post in %{link} at %{time} - with_time: %{username} at %{time} + with_post: '%{link} で行われた投稿の %{username}' + with_post_time: '%{link} で %{time} に行われた投稿の %{username}' + with_time: %{time} の %{username} emoji: title: "絵文字" - help: "各ユーザが利用できる絵文字を追加します。 (ちょっとしたコツ: ドラッグアンドドロップで複数のファイルを一度にアップロードできます)" + help: "みんなが利用できる新しい絵文字を追加します。(ヒント: 複数のファイルを一度にドラッグアンドドロップできます)" add: "新しい絵文字を追加" uploading: "アップロード中..." name: "名前" group: "グループ" image: "画像" - delete_confirm: "Are you sure you want to delete the :%{name}: emoji?" + delete_confirm: ":%{name}: 絵文字を削除してもよろしいですか?" embedding: - confirm_delete: "本当にこのホストを削除してもよろしいですか?" + confirm_delete: "このホストを削除してもよろしいですか?" title: "埋め込み" host: "許可されたホスト" class_name: "クラス名" edit: "編集" category: "カテゴリへ投稿" - add_host: "ホストの追加" + add_host: "ホストを追加" settings: "埋め込みの設定" - embed_truncate: "埋め込まれた投稿を削除する" - allowed_embed_selectors: "埋め込みで許可される要素のCSSセレクタ" - blocked_embed_selectors: "埋め込みから削除された要素のCSSセレクタ" - allowed_embed_classnames: "許可されたCSSクラス名" + embed_truncate: "埋め込まれた投稿を切り捨てる" + allowed_embed_selectors: "埋め込みで許可される要素の CSS セレクタ" + blocked_embed_selectors: "埋め込みから削除された要素の CSS セレクタ" + allowed_embed_classnames: "許可された CSS クラス名" save: "埋め込みの設定を保存" permalink: title: "パーマリンク" url: "URL" - topic_id: "トピックID" + topic_id: "トピック ID" topic_title: "トピック" - post_id: "投稿ID" + post_id: "投稿 ID" post_title: "投稿" - category_id: "カテゴリID" + category_id: "カテゴリ ID" category_title: "カテゴリ" delete_confirm: このパーマリンクを削除してもよろしいですか? form: label: "新規:" add: "追加" - filter: "検索(URL または 外部URL)" + filter: "検索 (URL または外部 URL)" reseed: modal: categories: "カテゴリ" @@ -3672,7 +3660,7 @@ ja: upload: "アップロード" uploading: "アップロード中..." upload_error: "ファイルのアップロード中にエラーが発生しました。もう一度お試しください。" - quit: "後で" + quit: "後で実行" staff_count: other: "あなたのコミュニティには、あなたを含めて %{count} 名のスタッフがいます。" invites: @@ -3680,8 +3668,8 @@ ja: none_added: "あなたはスタッフを招待していません。 続行してもよろしいですか?" roles: admin: "管理者" - moderator: "モデレータ" - regular: "正規ユーザー" + moderator: "モデレーター" + regular: "レギュラーユーザー" previews: topic_title: "ディスカッショントピック" share_button: "共有" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index db04eeac45..af5ca90fe2 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -177,7 +177,6 @@ ko: submit: "확인" generic_error: "죄송합니다. 오류가 발생했습니다." generic_error_with_reason: "오류가 발생했습니다: %{error}" - go_ahead: "계속하기" sign_up: "회원가입" log_in: "로그인" age: "나이" @@ -204,7 +203,6 @@ ko: more: "더 보기" x_more: other: "%{count}개 더보기" - less: "덜" never: "전혀" every_30_minutes: "30분마다" every_hour: "매 시간마다" @@ -213,7 +211,6 @@ ko: every_month: "매달" every_six_months: "6개월마다" max_of_count: "최대 %{count}" - alternation: "또는" character_count: other: "%{count} 글자" related_messages: @@ -247,8 +244,9 @@ ko: clear_bookmarks: "북마크 취소" help: bookmark: "이 글의 첫 번째 게시물을 북마크하려면 클릭하세요." + edit_bookmark: "이 글에 대한 북마크를 편집하려면 클릭하십시오." unbookmark: "이 항목의 모든 북마크를 제거하려면 클릭하십시오." - unbookmark_with_reminder: "이 항목의 모든 북마크 및 미리 알림을 제거하려면 클릭하십시오. 이 글에 대해 알림이 %{reminder_at} 으로 설정되었습니다." + unbookmark_with_reminder: "이 항목의 모든 북마크 및 미리 알림을 제거하려면 클릭하십시오." bookmarks: created: "이 글을 북마크했습니다. %{name}" not_bookmarked: "이 글을 북마크" @@ -564,15 +562,10 @@ ko: member_added: "추가됨" member_requested: "요청됨" add_members: - title: "%{group_name}에 구성원 추가" - description: "쉼표로 구분해 붙여 넣을 수도 있습니다." - usernames_or_emails: - title: "사용자 이름 또는 이메일 주소를 입력하세요." - input_placeholder: "사용자 이름 또는 이메일" - usernames: - title: "아이디를 입력하세요" - input_placeholder: "아이디" + title: "%{group_name}에 사용자 추가" + description: "그룹에 초대 할 사용자 목록을 입력하거나 쉼표로 구분해 목록에 붙여 넣으십시오." notify_users: "사용자에게 알림" + set_owner: "사용자를 이 그룹의 소유자로 설정" requests: title: "요청" reason: "이유" @@ -586,7 +579,8 @@ ko: title: "관리" name: "이름" full_name: "전체 이름" - add_members: "회원 추가" + add_members: "사용자 추가" + invite_members: "초대" delete_member_confirm: "'%{group}'그룹에서 '%{username}' 사용자를 제거 하시겠습니까?" profile: title: 프로필 @@ -2219,6 +2213,7 @@ ko: browse_all_categories: 모든 카테고리 보기 browse_all_tags: 모든 태그 보기 view_latest_topics: 최근 글 보기 + suggest_create_topic: 새 대화를 시작할 준비가 되셨습니까? jump_reply_up: 이전 답글로 이동 jump_reply_down: 이후 답글로 이동 deleted: "글이 삭제되었습니다." @@ -3135,6 +3130,7 @@ ko: show_incoming_updated_topics: "%{shortcut} 갱신된 토픽 보기" search: "%{shortcut} 를 사용하여 검색" help: "%{shortcut} 키보드 도움말 열기" + dismiss_new: "%{shortcut} 새 글을 읽은 상태로 표시하기" dismiss_topics: "%{shortcut} 토픽 무시하기" log_out: "%{shortcut} 로그아웃" composing: @@ -3447,6 +3443,7 @@ ko: trending_search: more: '검색 로그' disabled: '인기 검색 보고서가 비활성화되었습니다. 로그 검색 쿼리 를 사용하여 데이터를 수집하십시오.' + average_chart_label: 평균 filters: file_extension: label: 파일 확장자 @@ -3470,8 +3467,6 @@ ko: available: "사용가능한 그룹 이름" not_available: "사용 불가능한 그룹 이름" blank: "그룹 이름은 공백이 될 수 없습니다" - add_members: - as_owner: "사용자를 이 그룹의 소유자로 설정" manage: interaction: email: 이메일 @@ -4316,6 +4311,7 @@ ko: upload_successful: "성공적으로 업로드되었습니다. 단어가 추가되었습니다." test: button_label: "테스트" + modal_title: "%{action}: 감시 단어 테스트" description: "시청 한 단어와 일치하는지 확인하려면 아래에 텍스트를 입력하십시오" found_matches: "일치하는 결과 :" no_matches: "일치하는 결과가 없습니다" @@ -4441,6 +4437,8 @@ ko: flags_given_count: 작성한 신고 flags_received_count: 받은 신고 warnings_received_count: 받은 경고 + warnings_list_warning: | + 중재자로서 이러한 글을 모두 보지 못할 수도 있습니다. 필요한 경우 관리자에게 @moderators 권한을 요청하십시오. flags_given_received_count: "준/받은 신고" approve: "승인" approved_by: "승인자" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index e000746616..b93a2b0ca9 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -236,7 +236,6 @@ lt: submit: "Pateikti" generic_error: "Atsiprašome, įvyko klaida." generic_error_with_reason: "Įvyko klaida: %{error}" - go_ahead: "Pirmyn" sign_up: "Registruotis" log_in: "Prisijungti" age: "Amžius" @@ -269,7 +268,6 @@ lt: few: "%{count} Daugiau" many: "%{count} Daugiau" other: "%{count} Daugiau" - less: "Mažiau" never: "niekada" every_30_minutes: "kas 30 minučių" every_hour: "kiekvieną valandą" @@ -278,7 +276,6 @@ lt: every_month: "kiekvieną mėnesį" every_six_months: "kas šešis mėnesius" max_of_count: "daugiausia iš %{count}" - alternation: "arba" character_count: one: "%{count} simbolis" few: "%{count} simboliai" @@ -509,9 +506,6 @@ lt: remove_user_as_group_owner: "Atšaukti valdytoją" groups: member_added: "Pridėta" - add_members: - usernames: - input_placeholder: "Slapyvardžiai" requests: reason: "Priežastis" accepted: "patvirtinta" @@ -519,7 +513,7 @@ lt: title: "Redaguoti" name: "Vardas" full_name: "Vardas ir pavardė" - add_members: "Pridėti narių" + invite_members: "Kviesti" delete_member_confirm: "Pašalinti '%{username}' iš '%{group}' grupės?" profile: title: Profilis diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 80c77cf2e3..f2a465f731 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -212,7 +212,6 @@ lv: zero: "Vēl %{count}" one: "Vēl %{count}" other: "Vēl %{count}" - less: "Mazāk" never: "nekad" every_30_minutes: "katras 30 minūtes" every_hour: "katru stundu" @@ -221,7 +220,6 @@ lv: every_month: "katru mēnesi" every_six_months: "katrus sešus mēnešus" max_of_count: "ne vairāk kā %{count}" - alternation: "vai" character_count: zero: "%{count} zīmes" one: "%{count} zīme" @@ -409,7 +407,7 @@ lv: manage: name: "Vārds" full_name: "Pilns vārds" - add_members: "Pievienot dalībniekus" + invite_members: "Ielūgt" delete_member_confirm: "Izņemt '%{username}' no grupas '%{group}'?" profile: title: Profils diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index e96a86f8f4..62e0f56e65 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -182,7 +182,6 @@ nb_NO: submit: "Utfør" generic_error: "Beklager, det har oppstått en feil." generic_error_with_reason: "Det oppstod et problem: %{error}" - go_ahead: "Fortsett" sign_up: "Registrer deg" log_in: "Logg inn" age: "Alder" @@ -207,7 +206,6 @@ nb_NO: now: "akkurat nå" read_more: "les mer" more: "Mer" - less: "Mindre" never: "aldri" every_30_minutes: "hvert 30. minutt" every_hour: "hver time" @@ -216,7 +214,6 @@ nb_NO: every_month: "hver måned" every_six_months: "hver sjette måned" max_of_count: "maksimum av %{count}" - alternation: "eller" character_count: one: "%{count} tegn" other: "%{count} tegn" @@ -453,9 +450,6 @@ nb_NO: make_user_group_owner: "Gjør til eier" remove_user_as_group_owner: "Trekk tilbake eierstatus" groups: - add_members: - usernames: - input_placeholder: "Brukernavn" requests: title: "Forespørsler" reason: "Begrunnelse" @@ -465,7 +459,7 @@ nb_NO: title: "Behandle" name: "Navn" full_name: "Fullt navn" - add_members: "Legg til medlemmer" + invite_members: "Inviter" delete_member_confirm: "Fjern '%{username}' fra gruppen '%{group}'?" profile: title: Profil @@ -2581,8 +2575,6 @@ nb_NO: available: "Gruppenavn tilgjengelig" not_available: "Gruppenavn ikke tilgjengelig" blank: "Gruppenavnet kan ikke være tomt" - add_members: - as_owner: "Sett bruker(e) som eier(e) av denne gruppen" manage: interaction: email: E-post diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index befbaed2c7..c5e3ade712 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -200,7 +200,6 @@ nl: submit: "Versturen" generic_error: "Sorry, er is iets fout gegaan." generic_error_with_reason: "Er is iets fout gegaan: %{error}" - go_ahead: "Ga uw gang" sign_up: "Registreren" log_in: "Aanmelden" age: "Leeftijd" @@ -229,7 +228,6 @@ nl: x_more: one: "%{count} Meer" other: "%{count} Meer" - less: "Minder" never: "nooit" every_30_minutes: "elke 30 minuten" every_hour: "elk uur" @@ -238,7 +236,6 @@ nl: every_month: "elke maand" every_six_months: "elke zes maanden" max_of_count: "max. %{count}" - alternation: "of" character_count: one: "%{count} teken" other: "%{count} tekens" @@ -273,7 +270,6 @@ nl: help: bookmark: "Klik om een bladwijzer voor het eerste bericht van dit topic te maken" unbookmark: "Klik om alle bladwijzers in dit topic te verwijderen" - unbookmark_with_reminder: "Klik om alle bladwijzers en herinneringen in dit topic te verwijderen. U hebt voor dit topic een herinnering ingesteld voor %{reminder_at}." bookmarks: created: "U hebt een bladwijzer voor dit bericht gemaakt. %{name}" not_bookmarked: "bladwijzer voor dit bericht maken" @@ -604,13 +600,6 @@ nl: member_added: "Toegevoegd" member_requested: "Aangevraagd:" add_members: - title: "Leden toevoegen aan %{group_name}" - description: "U kunt ook in een door komma's gescheiden lijst plakken." - usernames_or_emails: - title: "Voer gebruikersnamen of e-mailadressen in" - input_placeholder: "Gebruikersnamen of e-mailadressen" - usernames: - input_placeholder: "Gebruikersnamen" notify_users: "Gebruikers een melding sturen" requests: title: "Aanvragen" @@ -625,7 +614,7 @@ nl: title: "Beheren" name: "Naam" full_name: "Volledige naam" - add_members: "Leden toevoegen" + invite_members: "Uitnodigen" delete_member_confirm: "'%{username}' uit de groep '%{group}' verwijderen?" profile: title: Profiel @@ -3560,8 +3549,6 @@ nl: available: "Groepsnaam is beschikbaar" not_available: "Groepsnaam is niet beschikbaar" blank: "Groepsnaam mag niet leeg zijn" - add_members: - as_owner: "Gebruiker(s) als eigenaar(s) van deze groep instellen" manage: interaction: email: E-mail diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index ebe7841fa8..05cb27d55a 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -246,7 +246,6 @@ pl_PL: submit: "Prześlij" generic_error: "Przepraszamy, wystąpił błąd." generic_error_with_reason: "Wystąpił błąd: %{error}" - go_ahead: "Idź dalej" sign_up: "Rejestracja" log_in: "Logowanie" age: "Wiek" @@ -279,7 +278,6 @@ pl_PL: few: "%{count} Więcej" many: "%{count} Więcej" other: "%{count} Więcej" - less: "Mniej" never: "nigdy" every_30_minutes: "co 30 minut" every_hour: "co godzinę" @@ -288,7 +286,6 @@ pl_PL: every_month: "co miesiąc" every_six_months: "co 6 miesięcy" max_of_count: "max z %{count}" - alternation: "lub" character_count: one: "%{count} znak" few: "%{count} znaki" @@ -326,7 +323,6 @@ pl_PL: help: bookmark: "Kliknij, aby dodać pierwszy post tematu do zakładek" unbookmark: "Kliknij, aby usunąć wszystkie zakładki z tego tematu" - unbookmark_with_reminder: "Kliknij, aby usunąć wszystkie zakładki i przypomnienia w tym temacie. Masz przypomnienie ustawione na %{reminder_at} dla tego tematu." bookmarks: created: "Dodano post do zakładek. %{name}" not_bookmarked: "dodaj do zakładek" @@ -691,14 +687,6 @@ pl_PL: member_added: "Dodano" member_requested: "Poproszono o" add_members: - title: "Dodaj członków do %{group_name}" - description: "Możesz również wkleić listę oddzieloną przecinkami." - usernames_or_emails: - title: "Wprowadź nazwy użytkowników lub adresy e-mail" - input_placeholder: "Nazwy użytkowników lub e-maile" - usernames: - title: "Wprowadź nazwy użytkowników" - input_placeholder: "Nazwy użytkowników" notify_users: "Powiadom użytkowników" requests: title: "Prośby" @@ -713,7 +701,7 @@ pl_PL: title: "Zarządzaj" name: "Nazwa" full_name: "Pełna nazwa" - add_members: "Dodaj członków" + invite_members: "Zaproś" delete_member_confirm: "Usunąć '%{username}' z grupy '%{group}'?" profile: title: Profil @@ -3891,8 +3879,6 @@ pl_PL: available: "Nazwa grupy jest dostępna" not_available: "Nazwa grupy jest niedostępna" blank: "Nazwa grupy nie może być pusta" - add_members: - as_owner: "Ustaw użytkownika/ów jako właściciel/i tej grupy" manage: interaction: email: Email diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index b45ca6ef9c..f7e4814ea2 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -200,7 +200,6 @@ pt: submit: "Submeter" generic_error: "Pedimos desculpa, ocorreu um erro." generic_error_with_reason: "Ocorreu um erro: %{error}" - go_ahead: "Continuar" sign_up: "Inscrever-se" log_in: "Entrar" age: "Idade" @@ -229,7 +228,6 @@ pt: x_more: one: "%{count} Mais" other: "%{count} Mais" - less: "Menos" never: "nunca" every_30_minutes: "a cada 30 minutos" every_hour: "a cada hora" @@ -238,7 +236,6 @@ pt: every_month: "cada mês" every_six_months: "a cada seis meses" max_of_count: "máximo de %{count}" - alternation: "ou" character_count: one: "%{count} caracter" other: "%{count} caracteres" @@ -273,7 +270,6 @@ pt: help: bookmark: "Clique para adicionar um marcador à primeira publicação neste tópico" unbookmark: "Clique para remover todos os marcadores deste tópico" - unbookmark_with_reminder: "Clique para remover todos os favoritos e lembretes deste tópico. Você tem um lembrete definido %{reminder_at} para este tópico." bookmarks: created: "Adicionou este post aos favoritos. %{name}" not_bookmarked: "adicionar esta mensagem aos marcadores" @@ -604,14 +600,6 @@ pt: member_added: "Adicionado(a)" member_requested: "Solicitado em" add_members: - title: "Adicionar membros a %{group_name}" - description: "Você também pode colar numa lista separada por vírgulas." - usernames_or_emails: - title: "Digite nomes de utilizador ou endereços de e-mail" - input_placeholder: "Nomes de utilizador ou e-mails" - usernames: - title: "Introduzir nomes de utilizador" - input_placeholder: "Nomes de Utilizador" notify_users: "Notificar utilizadores" requests: title: "Pedidos" @@ -626,7 +614,7 @@ pt: title: "Gerir" name: "Nome" full_name: "Nome Completo" - add_members: "Adicionar Membros" + invite_members: "Convidar" delete_member_confirm: "Remover '%{username}' do grupo '%{group}'?" profile: title: Perfil diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index 0b9d702050..2eab637e12 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -200,7 +200,6 @@ pt_BR: submit: "Enviar" generic_error: "Pedimos desculpa, ocorreu um erro." generic_error_with_reason: "Ocorreu um erro: %{error}" - go_ahead: "Continue" sign_up: "Cadastrar-se" log_in: "Entrar" age: "Idade" @@ -229,7 +228,6 @@ pt_BR: x_more: one: "Mais %{count}" other: "Mais %{count}" - less: "Menos" never: "nunca" every_30_minutes: "a cada 30 minutos" every_hour: "a cada hora" @@ -238,7 +236,6 @@ pt_BR: every_month: "a cada mês" every_six_months: "a cada seis meses" max_of_count: "máx de %{count}" - alternation: "ou" character_count: one: "%{count} caracter" other: "%{count} caracteres" @@ -273,7 +270,6 @@ pt_BR: help: bookmark: "Clique para adicionar o primeiro post deste tópico aos favoritos" unbookmark: "Clique para remover todos os favoritos neste tópico" - unbookmark_with_reminder: "Clique para remover todos os favoritos e lembretes deste tópico. Você tem um lembrete definido %{reminder_at} para este tópico." bookmarks: created: "Você marcou esta mensagem como favorita. %{name}" not_bookmarked: "marcar postagem como favorita" @@ -602,14 +598,6 @@ pt_BR: member_added: "Adicionado" member_requested: "Solicitado em" add_members: - title: "Adicionar membros a %{group_name}" - description: "Você também pode colocar aqui uma lista de usuários separados por vírgulas." - usernames_or_emails: - title: "Digite nomes de usuários ou endereços de email" - input_placeholder: "Nomes de usuário ou endereços de email" - usernames: - title: "Insira os nomes de usuário" - input_placeholder: "Nomes de usuários" notify_users: "Notificar usuários" requests: title: "Solicitações" @@ -624,7 +612,6 @@ pt_BR: title: "Gerenciar" name: "Nome" full_name: "Nome Completo" - add_members: "Adicionar membros" delete_member_confirm: "Remover '%{username}' do '%{group}' grupo?" profile: title: Perfil @@ -3292,8 +3279,6 @@ pt_BR: available: "O nome do grupo está disponível" not_available: "O nome do grupo não está disponível" blank: "O nome do grupo não pode ficar em branco" - add_members: - as_owner: "Definir usuário (s) como proprietário (s) deste grupo" manage: interaction: email: E-mail diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index d7169feab7..8a2e4f74a5 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -218,7 +218,6 @@ ro: submit: "Trimite" generic_error: "A apărut o eroare." generic_error_with_reason: "A apărut o eroare: %{error}" - go_ahead: "Mai departe" sign_up: "Înregistrare" log_in: "Autentificare" age: "Vârsta" @@ -249,7 +248,6 @@ ro: one: "Mai e %{count}" few: "Mai e %{count}" other: "Mai e %{count}" - less: "Mai puțin" never: "Niciodată" every_30_minutes: "La fiecare 30 de minute" every_hour: "La fiecare oră" @@ -258,7 +256,6 @@ ro: every_month: "în fiecare lună" every_six_months: "la fiecare șase luni" max_of_count: "max din %{count}" - alternation: "sau" character_count: one: "Un caracter" few: "%{count} caractere" @@ -294,7 +291,6 @@ ro: help: bookmark: "Click pentru a adăuga semn de carte la subiectul acesta" unbookmark: "Click pentru a șterge semnele de carte" - unbookmark_with_reminder: "Faceți clic pentru a elimina toate marcajele și mementourile din acest subiect. Aveți un set de memento %{reminder_at} pentru acest subiect." bookmarks: created: "Ai marcat acestă postare %{name} ca semn de carte" not_bookmarked: "marchează ca semn de carte acest post" @@ -607,13 +603,6 @@ ro: member_added: "Adăugat" member_requested: "Solicitat la" add_members: - title: "Adaugă membri la %{group_name}" - description: "De asemenea, poți insera o listă separată prin virgulă." - usernames_or_emails: - title: "Introdu nume de utilizator sau adrese de e-mail" - input_placeholder: "Nume de utilizator sau e-mailuri" - usernames: - input_placeholder: "Nume de utilizatori" notify_users: "Notifică utilizatorii" requests: title: "Cereri" @@ -628,7 +617,7 @@ ro: title: "Gestionează" name: "Nume" full_name: "Nume și prenume" - add_members: "Adaugă membri" + invite_members: "Invită" delete_member_confirm: "Elimini „%{username}” din grupul „%{group}”?" profile: title: Profil diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index aa24fb5d57..8706540356 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -246,7 +246,6 @@ ru: submit: "Отправить" generic_error: "Извините, произошла ошибка." generic_error_with_reason: "Произошла ошибка: %{error}" - go_ahead: "Продолжить" sign_up: "Регистрация" log_in: "Вход" age: "Возраст" @@ -279,7 +278,6 @@ ru: few: "Ещё %{count}" many: "Ещё %{count}" other: "Ещё %{count}" - less: "Меньше" never: "никогда" every_30_minutes: "каждые 30 минут" every_hour: "каждый час" @@ -288,7 +286,6 @@ ru: every_month: "каждый месяц" every_six_months: "каждые шесть месяцев" max_of_count: "%{count} макс." - alternation: "или" character_count: one: "%{count} буква" few: "%{count} буквы" @@ -325,8 +322,9 @@ ru: clear_bookmarks: "Очистить закладки" help: bookmark: "Добавить в закладки первое сообщение этой темы" + edit_bookmark: "Изменить закладку в этой теме" unbookmark: "Удалить все закладки в этой теме" - unbookmark_with_reminder: "Нажмите для удаления всех закладок и напоминаний в этой теме. У вас уже есть напоминание для этой темы, настроенное на %{reminder_at}." + unbookmark_with_reminder: "Удалить все закладки и напоминания из этой темы." bookmarks: created: "Вы добавили это сообщение в закладки под именем '%{name}'" not_bookmarked: "Добавить сообщение в закладки" @@ -693,15 +691,10 @@ ru: member_added: "Добавлено" member_requested: "По запросу на" add_members: - title: "Добавить участников в группу %{group_name}" - description: "Вы также можете вставить список участников (значения, разделённые запятыми)." - usernames_or_emails: - title: "Введите псевдонимы или адреса электронной почты" - input_placeholder: "Псевдонимы или электронные адреса" - usernames: - title: "Введите псевдонимы" - input_placeholder: "Псевдонимы" + title: "Добавить пользователей в группу %{group_name}" + description: "Введите список пользователей, которых вы хотите пригласить в группу, или вставьте список значений, разделенных запятыми:" notify_users: "Уведомить пользователей" + set_owner: "Установить пользователей в качестве владельцев этой группы" requests: title: "Запросы" reason: "Причина" @@ -715,7 +708,8 @@ ru: title: "Управление группой" name: "Имя" full_name: "Полное имя" - add_members: "Добавить участника" + add_members: "Добавить пользователей" + invite_members: "Пригласить" delete_member_confirm: "Удалить '%{username}' из группы '%{group}' ?" profile: title: Профиль @@ -1556,9 +1550,9 @@ ru: show_advanced: "Показать дополнительные параметры" hide_advanced: "Скрыть дополнительные параметры" restrict_email: "Ограничить одним адресом электронной почты" - max_redemptions_allowed: "Максимальное количество использований" - add_to_groups: "Добавить в группы" - invite_to_topic: "Перейти в эту тему" + max_redemptions_allowed: "Максимальное количество использований ссылки" + add_to_groups: "Добавить приглашённых в группы" + invite_to_topic: "После перехода на сайт открыть эту тему" expires_at: "Срок действия приглашения истечёт после" custom_message: "Необязательное личное сообщение" send_invite_email: "Сохранить и отправить приглашение" @@ -2245,6 +2239,7 @@ ru: hint: "(вы также можете перетащить объект в окно редактора для его загрузки)" hint_for_supported_browsers: "вы также можете перетащить или скопировать изображения в редактор" uploading: "Загрузка" + processing: "Обработка загружаемого контента" select_file: "Выбрать файл" default_image_alt_text: изображение supported_formats: "Поддерживаемые форматы" @@ -2510,6 +2505,7 @@ ru: browse_all_categories: Просмотреть все разделы browse_all_tags: Просмотреть все теги view_latest_topics: Просмотреть последние темы + suggest_create_topic: Готовы начать новое обсуждение? jump_reply_up: Перейти к более ранним ответам jump_reply_down: Перейти к более поздним ответам deleted: "Тема удалена" @@ -2519,7 +2515,7 @@ ru: description: "Чтобы способствовать вдумчивому обсуждению в активных или спорных дискуссиях, пользователи должны подождать определённое время, прежде чем публиковать очередное сообщение в этой теме." enable: "Включить" update: "Обновить" - enabled_until: "Включён до:" + enabled_until: "Режим включён до:" remove: "Отключить" hours: "Часы:" minutes: "Минуты:" @@ -2928,7 +2924,7 @@ ru: follow_quote: "Перейти к цитируемому сообщению" show_full: "Показать полный текст" show_hidden: "Просмотр игнорируемого содержимого." - deleted_by_author_simple: "(тема удалена автором)" + deleted_by_author_simple: "(сообщение удалено автором)" collapse: "Свернуть" expand_collapse: "Развернуть/Свернуть" locked: "Модератор заблокировал это сообщение для редактирования" @@ -3213,7 +3209,7 @@ ru: reply: "Ответ" create: "Создание" no_groups_selected: "Доступ не предоставлен ни одной группе; этот раздел будет виден только персоналу." - everyone_has_access: 'Эта общедоступный раздел; все пользователи могут просматривать, создавать сообщения, а также отвечать на них. Чтобы ограничить разрешения, удалите одно или несколько разрешений, предоставленных группе ''все''.' + everyone_has_access: 'Это общедоступный раздел; все пользователи могут просматривать, создавать сообщения, а также отвечать на них. Чтобы ограничить разрешения, удалите одно или несколько разрешений, предоставленных группе ''все''.' toggle_reply: "Переключить разрешение 'Ответ'" toggle_full: "Переключить разрешение 'Создание'" inherited: 'Это разрешение унаследовано от группы ''все''' @@ -3247,7 +3243,7 @@ ru: this_year: "за год" position: "Позиция на странице раздела:" default_position: "Позиция по умолчанию" - position_disabled: "Разделы будут показаны в порядке активности. Чтобы настроить порядок разделов," + position_disabled: "Разделы будут отображаться в порядке активности. Чтобы настроить порядок разделов, " position_disabled_click: 'включите настройку "fixed category positions".' minimum_required_tags: "Минимальное количество тегов, требуемых в теме:" parent: "Родительский раздел" @@ -3848,7 +3844,7 @@ ru: version_check_pending: "Вы недавно обновились. Замечательно!" installed_version: "Установленная версия" latest_version: "Последняя версия" - problems_found: "Несколько советов по вашим текущим настройкам сайта" + problems_found: "Несколько советов по текущим настройкам сайта" new_features: title: "\U0001F381 Новые возможности" dismiss: "Отклонить" @@ -3944,8 +3940,6 @@ ru: available: "Название группы доступно" not_available: "Название группы недоступно" blank: "Название группы не может быть пустым" - add_members: - as_owner: "Установить пользователя (ей) в качестве владельца (ов) этой группы" manage: interaction: email: Email @@ -4766,15 +4760,16 @@ ru: clear_all: Удалить слова clear_all_confirm: "Вы действительно хотите удалить все контролируемые слова, используемые на вкладке '%{action}'?" invalid_regex: 'Контролируемое слово "%{word}" не является допустимым регулярным выражением.' + regex_warning: 'Контролируемые слова представляют собой регулярные выражения, не определяющие границы слов. Если вы хотите, чтобы регулярное выражение соответствовало целым словам, добавьте \ b в начало и конец регулярного выражения.' actions: block: "Блокировка" censor: "Цензура" require_approval: "Требующие одобрения" flag: "Жалобы" - replace: "Замена" + replace: "Замены" tag: "Теги" silence: "Блокировка первых сообщений" - link: "Ссылка" + link: "Ссылки" action_descriptions: block: "Запретить публикацию сообщений, содержащих эти слова. Пользователь увидит сообщение об ошибке при попытке отправить своё сообщение." censor: "Разрешить сообщения, содержащие эти слова, но заменять их символами, которые скрывают нецензурные выражения." @@ -4800,6 +4795,7 @@ ru: upload_successful: "Загрузка прошла успешна. Слова добавлены." test: button_label: "Тестирование слов" + modal_title: "%{action}: Тестирование контролируемых слов" description: "Введите текст, чтобы проверить совпадения с контролируемыми словами:" found_matches: "Найденные совпадения:" no_matches: "Совпадений не найдено" @@ -5102,6 +5098,7 @@ ru: text: "Текстовое поле" confirm: "Подтверждение" dropdown: "Выпадающий список" + multiselect: "Мультивыбор" site_text: description: "Вы можете отредактировать любой текст на форуме. Начните с поиска:" search: "Найдите текст, который вы хотите отредактировать" @@ -5188,7 +5185,7 @@ ru: badge: Награда display_name: Отображаемое название description: Описание - long_description: Длинное описание + long_description: Подробное описание badge_type: Тип награды badge_grouping: Группа badge_groupings: diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 6f6e759186..3eca6fb6d2 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -217,7 +217,6 @@ sk: submit: "Odoslať" generic_error: "Ľutujeme, nastala chyba." generic_error_with_reason: "Nastala chyba: %{error}" - go_ahead: "Pokračujte" sign_up: "Registrácia" log_in: "Prihlásenie" age: "Vek" @@ -249,7 +248,6 @@ sk: few: "%{count} Ďalší" many: "%{count} Ďalší" other: "%{count} Ďalší" - less: "Menej" never: "nikdy" every_30_minutes: "každých 30 mintút" every_hour: "každú hodinu" @@ -257,7 +255,6 @@ sk: weekly: "týždenne" every_month: "každý mesiac" max_of_count: "najviac %{count}" - alternation: "alebo" character_count: one: "%{count} znak" few: "%{count} znakov" @@ -462,16 +459,13 @@ sk: remove_user_as_group_owner: "Odobrať vlastníka" groups: member_added: "Pridané" - add_members: - usernames: - input_placeholder: "Používateľské mená" requests: reason: "Dôvod" manage: title: "Spravovať" name: "Názov" full_name: "Celé meno" - add_members: "Pridať používateľov" + invite_members: "Pozvi" delete_member_confirm: "Odstrániť '%{username}' zo skupiny '%{group}'?" profile: title: Profil @@ -2366,8 +2360,6 @@ sk: available: "Názov skupiny je k dispozícii" not_available: "Názov skupiny nie je k dispozícii" blank: "Názov skupiny nesmie byť prázdny" - add_members: - as_owner: "Nastaviť používateľa/ov ako vlastníka/ov skupiny" manage: interaction: email: Email diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index 5326ac4d5c..bd4b13f772 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -239,7 +239,6 @@ sl: submit: "Išči" generic_error: "Ups, prišlo je do napake." generic_error_with_reason: "Napaka: %{error}" - go_ahead: "Nadaljuj" sign_up: "Registracija" log_in: "Prijava" age: "Starost" @@ -272,7 +271,6 @@ sl: two: "%{count} Več" few: "%{count} Več" other: "%{count} Več" - less: "Manj" never: "nikoli" every_30_minutes: "vsakih 30 minut" every_hour: "vsako uro" @@ -281,7 +279,6 @@ sl: every_month: "vsak mesec" every_six_months: "vsakih 6 mesecev" max_of_count: "največ od %{count}" - alternation: "ali" character_count: one: "%{count} znak" two: "%{count} znaka" @@ -318,7 +315,6 @@ sl: help: bookmark: "ustvari zaznamek prvega prispevka v tej temi" unbookmark: "odstrani vse zaznamke v tej temi" - unbookmark_with_reminder: "Kliknite, če želite odstraniti vse zaznamke in opomnike v tej temi. Za to temo imate opomnik nastavljen %{reminder_at}." bookmarks: created: "To objavo ste dodali med zaznamke. %{name}" not_bookmarked: "zaznamuj prispevek" @@ -622,13 +618,6 @@ sl: member_added: "Dodano" member_requested: "Zahtevano ob" add_members: - title: "Dodaj člane v %{group_name}" - description: "Sem lahko prilepite tudi seznam, ločen z vejicami." - usernames_or_emails: - title: "Vnesi uporabniška imena ali e-poštne naslove" - input_placeholder: "Uporabniki ali e-poštni naslovi" - usernames: - input_placeholder: "Uporabniška imena" notify_users: "Obvesti uporabnike" requests: title: "Zahtevki" @@ -643,7 +632,7 @@ sl: title: "Upravljaj" name: "Ime skupine" full_name: "Polno ime" - add_members: "Dodaj člane" + invite_members: "Povabi" delete_member_confirm: "Odstrani '%{username}' iz skupine '%{group}'?" profile: title: Profil @@ -3533,8 +3522,6 @@ sl: available: "Ime skupine je na voljo" not_available: "Ime skupine ni na voljo" blank: "Ime skupine ne more biti prazno" - add_members: - as_owner: "Dodaj uporabnika(-e) in skrbnika(-e) za to skupino" manage: interaction: email: E-naslov diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index e6cc8d3119..e0372e68c1 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -190,14 +190,12 @@ sq: x_more: one: "Edhe %{count}" other: "Edhe %{count}" - less: "Më pak" never: "asnjëherë" every_30_minutes: "çdo 30 minuta" every_hour: "çdo orë" daily: "çdo ditë" weekly: "çdo javë" max_of_count: "max i %{count}" - alternation: "ose" character_count: one: "%{count} shkronjë" other: "%{count} shkronja" @@ -363,7 +361,7 @@ sq: manage: name: "Emri" full_name: "Emri i plotë" - add_members: "Shto anëtarë" + invite_members: "Fto" delete_member_confirm: "Do të heqësh '%{username}' nga grupi '%{group}'?" profile: title: Profili diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 5066c280e8..06b32f1d25 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -208,7 +208,6 @@ sr: submit: "Potvrdi" generic_error: "Izvinite, pojavila se greška." generic_error_with_reason: "Došlo je do greške: %{error}" - go_ahead: "Nastavi" sign_up: "Registrujte se" log_in: "Prijavi se" age: "Godine" @@ -239,7 +238,6 @@ sr: one: "još %{count} član" few: "još %{count} člana" other: "još %{count} članova" - less: "Manje" never: "nikad" every_30_minutes: "svakih 30 minuta" every_hour: "svakog sata" @@ -248,7 +246,6 @@ sr: every_month: "svaki mesec" every_six_months: "svakih šest meseci" max_of_count: "maksimalno od %{count}" - alternation: "ili" character_count: one: "%{count} karakter" few: "%{count} karaktera" @@ -281,7 +278,6 @@ sr: help: bookmark: "Klikni da markiraš prvi post u ovoj temi" unbookmark: "Klikni da bi obirsao sve markere sa ove teme" - unbookmark_with_reminder: "Kliknite da biste uklonili sve obeleživače i podsetnike u ovoj temi. Imate podsetnik %{reminder_at} za ovu temu." bookmarks: created: "Obeležili ste ovu poruku. %{name}" not_bookmarked: "obeleži ovu poruku" @@ -506,6 +502,7 @@ sr: manage: name: "Ime foruma" full_name: "Ime" + invite_members: "Pozovi" delete_member_confirm: "Uklonite '%{username}' iz '%{group}' grupe?" profile: title: Profil diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index f5abf065d1..f70948e09a 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -200,7 +200,6 @@ sv: submit: "Skicka" generic_error: "Tyvärr, ett fel har inträffat." generic_error_with_reason: "Ett fel inträffade: %{error}" - go_ahead: "Gå vidare" sign_up: "Registrera" log_in: "Logga in" age: "Ålder" @@ -229,7 +228,6 @@ sv: x_more: one: "Ytterligare %{count}" other: "Ytterligare %{count}" - less: "Mindre" never: "aldrig" every_30_minutes: "var 30:e minut" every_hour: "varje timme" @@ -238,7 +236,6 @@ sv: every_month: "varje månad" every_six_months: "var sjätte månad" max_of_count: "max av %{count}" - alternation: "eller" character_count: one: "%{count} tecken" other: "%{count} tecken" @@ -273,8 +270,9 @@ sv: clear_bookmarks: "Töm bokmärken" help: bookmark: "Klicka för att bokmärka första inlägget i ämnet" + edit_bookmark: "Klicka för att redigera bokmärket på detta ämne" unbookmark: "Klicka för att radera alla bokmärken i ämnet" - unbookmark_with_reminder: "Klicka för att ta bort alla bokmärken samt påminnelser för detta ämne. Du har en påminnelse inställd till %{reminder_at} för detta ämne." + unbookmark_with_reminder: "Klicka för att ta bort alla bokmärken och påminnelser i detta ämne." bookmarks: created: "Du har bokmärkt detta inlägg. %{name}" not_bookmarked: "bokmärk detta inlägg" @@ -607,15 +605,10 @@ sv: member_added: "Tillagd" member_requested: "Begärd vid" add_members: - title: "Lägg till medlemmar till %{group_name}" - description: "Du kan även klistra in i en kommaseparerad lista." - usernames_or_emails: - title: "Ange användarnamn eller e-postadresser" - input_placeholder: "Användarnamn eller e-post" - usernames: - title: "Ange användarnamn" - input_placeholder: "Användarnamn" + title: "Lägg till användare till %{group_name}" + description: "Ange en lista över användare som du vill bjuda in till gruppen eller klistra in i en kommaseparerad lista:" notify_users: "Meddela användare" + set_owner: "Ange användare som ägare till den här gruppen" requests: title: "Ansökningar" reason: "Anledning" @@ -629,7 +622,8 @@ sv: title: "Hantera" name: "Namn" full_name: "Fullständigt namn" - add_members: "Lägg till medlemmar" + add_members: "Lägg till användare" + invite_members: "Inbjudan" delete_member_confirm: "Vill du ta bort '%{username}' från gruppen '%{group}'?" profile: title: Profil @@ -2081,6 +2075,7 @@ sv: hint: "(du kan också dra och släppa i redigeraren för att ladda upp)" hint_for_supported_browsers: "du kan också dra och släppa eller klistra in bilder i redigeraren" uploading: "Laddar upp" + processing: "Bearbetar uppladdning" select_file: "Välj fil" default_image_alt_text: bild supported_formats: "format som stöds" @@ -3637,8 +3632,6 @@ sv: available: "Gruppnamn är tillgängligt" not_available: "Gruppnamn är inte tillgängligt" blank: "Gruppnamn kan inte vara tomt" - add_members: - as_owner: "Ställ in användare som ägare av denna grupp" manage: interaction: email: E-post @@ -4455,6 +4448,7 @@ sv: clear_all: Rensa alla clear_all_confirm: "Är du säker på att du vill rensa alla bevakade ord för åtgärden %{action}?" invalid_regex: 'Det bevakade ordet "%{word}" är ett ogiltigt reguljärt uttryck.' + regex_warning: 'Bevakade ord är reguljära uttryck och de omfattar inte automatiskt ordgränser. Om du vill att det reguljära uttrycket ska matcha hela ord, anger du \b i början och slutet av ditt reguljära uttryck.' actions: block: "Blockera" censor: "Censur" @@ -4784,6 +4778,7 @@ sv: text: "Textfält" confirm: "Bekräftelse" dropdown: "Rullgardinsmeny" + multiselect: "Multival" site_text: description: "Du kan anpassa all text på ditt forum. Börja genom att söka här nedan:" search: "Sök efter texten som du vill redigera" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index d3deab1264..edd6dbe0ba 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -193,14 +193,12 @@ sw: now: "sasa hivi" read_more: "soma zaidi" more: "Zaidi" - less: "Punguza" never: "kamwe" every_30_minutes: "kila dakika 30" every_hour: "kila saa" daily: "kila siku" weekly: "kila wiki" max_of_count: "kiwango cha juu cha %{count}" - alternation: "au" character_count: one: "Herufi %{count}" other: "Herufi %{count}" @@ -384,9 +382,6 @@ sw: make_user_group_owner: "Mfanye awe mmiliki" remove_user_as_group_owner: "Ondoa mmiliki" groups: - add_members: - usernames: - input_placeholder: "Majina la watumiaji" requests: reason: "Sababu" accepted: "imeruhusiwa" @@ -394,7 +389,7 @@ sw: title: "Simamia" name: "Jina" full_name: "Jina Lote" - add_members: "Ongeza Wanachama" + invite_members: "Mualiko" delete_member_confirm: "Ondoa '%{username}' kwenye kikundi '%{group}'?" profile: title: Umbo @@ -2374,8 +2369,6 @@ sw: available: "Jina la kikundi lipo" not_available: "Jina la kikundi halipo" blank: "Jina la kikundi haliwezi kuwa wazi" - add_members: - as_owner: "Fanya w(m)umiaji kuwa w(m)amiliki wa kikundi hiki" manage: interaction: email: Barua Pepe diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index 061bc30f94..4cdffe8f8b 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -132,13 +132,11 @@ te: now: "ఇప్పుడే" read_more: "మరింత చదువు" more: "మరింత" - less: "తక్కువ" never: "ఎప్పటికీ వద్దు" every_hour: "ప్రతి గంట" daily: "ప్రతిరోజూ" weekly: "ప్రతీవారం" max_of_count: "%{count} గరిష్టం" - alternation: "లేదా" character_count: one: "%{count} అక్షరం" other: "%{count} అక్షరాలు" @@ -266,6 +264,7 @@ te: manage: name: "పేరు" full_name: "పూర్తి పేరు" + invite_members: "ఆహ్వానించు" delete_member_confirm: " '%{group}' గుంపు నుండి '%{username}' ను తొలగించాలా?" profile: title: ప్రవర diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index 969b69e7bd..22f1f9d6e1 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -163,7 +163,6 @@ th: submit: "ตกลง" generic_error: "ขออภัย เกิดข้อผิดพลาดขึ้น" generic_error_with_reason: "เกิดข้อผิดพลาดขึ้น: %{error}" - go_ahead: "ไปต่อ" sign_up: "สมัครสมาชิก" log_in: "เข้าสู่ระบบ" age: "อายุ" @@ -190,7 +189,6 @@ th: more: "เพิ่มเติม" x_more: other: "อีก %{count}" - less: "น้อย" never: "ไม่เคย" every_30_minutes: "ทุก 30 นาที" every_hour: "ทุกชั่วโมง" @@ -199,7 +197,6 @@ th: every_month: "ทุกเดือน" every_six_months: "ทุกหกเดือน" max_of_count: "สูงสุดของ %{count}" - alternation: "หรือ" character_count: other: "%{count} ตัวอักษร" related_messages: @@ -460,9 +457,6 @@ th: groups: member_added: "เพิ่ม" member_requested: "ร้องขอเมื่อ" - add_members: - usernames: - input_placeholder: "ชื่อผู้ใช้" requests: title: "คำร้องขอ" reason: "เหตุผล" @@ -474,7 +468,7 @@ th: title: "จัดการ" name: "ชื่อ" full_name: "ชื่อ-นามสกุล" - add_members: "เพิ่มสมาชิก" + invite_members: "เชิญ" delete_member_confirm: "ลบ '%{username}' ออกจากกลุ่ม '%{group}' ใช่ไหม" profile: title: โปรไฟล์ diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index 37002f4c31..a190eb8402 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -169,6 +169,7 @@ tr_TR: regions: ap_northeast_1: "Asya Pasifik (Tokyo)" ap_northeast_2: "Asya Pasifik (Seul)" + ap_east_1: "Asya Pasifik (Hong Kong)" ap_south_1: "Asya Pasifik (Mumbai)" ap_southeast_1: "Asya Pasifik (Singapur)" ap_southeast_2: "Asya Pasifik (Sidney)" @@ -196,7 +197,6 @@ tr_TR: submit: "Gönder" generic_error: "Üzgünüz, bir hata oluştu." generic_error_with_reason: "Bir hata oluştu: %{error}" - go_ahead: "Devam edin" sign_up: "Kayıt Ol" log_in: "Giriş Yap" age: "Yaş" @@ -225,7 +225,6 @@ tr_TR: x_more: one: "%{count} Daha" other: "%{count} Daha" - less: "Daha az" never: "asla" every_30_minutes: "her 30 dakikada bir" every_hour: "her saat" @@ -234,7 +233,6 @@ tr_TR: every_month: "her ay" every_six_months: "her altı ayda bir" max_of_count: "maksimum %{count}" - alternation: "ya da" character_count: one: "%{count} karakter" other: "%{count} karakter" @@ -254,6 +252,8 @@ tr_TR: stat: all_time: "Tüm Zamanlar" last_day: "Son 24 saat" + last_7_days: "Son 7 gün" + last_30_days: "Son 30 gün" like_count: "Beğeniler" topic_count: "Konular" post_count: "Gönderiler" @@ -268,7 +268,6 @@ tr_TR: help: bookmark: "Bu konudaki ilk gönderiyi işaretlemek için tıkla" unbookmark: "Bu konudaki tüm işaretlenenleri kaldırmak için tıkla" - unbookmark_with_reminder: "Bu konudaki tüm yer imlerini ve hatırlatıcıları kaldırmak için tıklayın. Bu konu için %{reminder_at} bir hatırlatıcı ayarınız var." bookmarks: created: " %{name} Başlıklı gönderiye yer işareti koydunuz." not_bookmarked: "bu yazıya yer işareti koy" @@ -304,7 +303,9 @@ tr_TR: new_private_message: "Yeni özel mesaj taslağı" topic_reply: "Cevap taslağı" abandon: + confirm: "Bu konu için devam eden bir taslağınız var. Bununla ne yapmak istersiniz?" yes_value: "At" + no_value: "Düzenlemeye devam et" topic_count_latest: one: "%{count} yeni ya da güncellenmiş konu." other: "%{count} yeni ya da güncellenmiş konu." @@ -323,6 +324,7 @@ tr_TR: upload: "Yükle" uploading: "Yükleniyor..." uploading_filename: "Yükleniyor: %{filename}..." + processing_filename: "İşleniyor: %{filename}..." clipboard: "pano" uploaded: "Yüklendi!" pasting: "Yapıştırılıyor..." @@ -516,6 +518,7 @@ tr_TR: days: one: "gün" other: "günler" + relative: "Göreceli" time_shortcut: later_today: "Bugün ilerleyen saatlerde" next_business_day: "Bir sonraki iş günü" @@ -531,6 +534,7 @@ tr_TR: custom: "Özel tarih ve saat" relative: "Göreli zaman" none: "Hiçbiri" + last_custom: "En son belirlenen özel tarih/saat" user_action: user_posted_topic: "%{user} konuyu açtı" you_posted_topic: "konuyu sen açtın" @@ -585,14 +589,9 @@ tr_TR: member_requested: "İstenen" add_members: title: "%{group_name} grubuna üye ekleyin" - description: "Ayrıca virgülle ayrılmış bir liste de yapıştırabilirsiniz." - usernames_or_emails: - title: "Kullanıcı adlarını veya e-posta adreslerini girin" - input_placeholder: "Kullanıcı adı ya da e-posta" - usernames: - title: "Kullanıcı adlarını girin" - input_placeholder: "Kullanıcı adları" + description: "Gruba davet etmek istediğiniz kullanıcıları birer birer veya virgülle ayrılmış bir liste olarak yapıştırarak girin :" notify_users: "Kullanıcıları bilgilendir" + set_owner: "Kullanıcıları bu grubun sahipleri olarak ayarla" requests: title: "İstekler" reason: "Sebep" @@ -606,7 +605,8 @@ tr_TR: title: "Yönet" name: "İsim" full_name: "Tam İsim" - add_members: "Üyeleri ekle" + add_members: "Kullanıcı ekle" + invite_members: "Davet et" delete_member_confirm: "'%{username}' adlı kullanıcıyı '%{group}' grubundan çıkart?" profile: title: Profil @@ -617,8 +617,27 @@ tr_TR: email: title: "Eposta" status: "IMAP üzerinden senkronize edilmiş e-postalar %{old_emails} / %{total_emails} " + enable_smtp: "SMTP'yi etkinleştir" + enable_imap: "IMAP'i etkinleştir" + test_settings: "Test Ayarları" + save_settings: "Ayarları kaydet" last_updated: "Son güncelleme:" last_updated_by: "tarafından güncellendi" + settings_required: "Tüm ayarlar gereklidir, lütfen doğrulamadan önce tüm alanları doldurun." + smtp_settings_valid: "SMTP ayarları uygun." + smtp_title: "SMTP" + smtp_instructions: "Grup için SMTP'yi etkinleştirdiğinizde, grubun gelen kutusundan gönderilen tüm e-postalar, forumunuz tarafından gönderilen diğer e-postalar için yapılandırılmış posta sunucusu yerine burada belirtilen SMTP ayarları gözetilerek gönderilir." + imap_title: "IMAP" + imap_additional_settings: "Ek ayarlar" + imap_instructions: 'Grup için IMAP''i etkinleştirdiğinizde, e-postalar gruba ait gelen kutusu, sağlanan IMAP sunucusu ve posta kutusu arasında eşitlenir. IMAP''in etkinleştirilebilmesi için SMTP''nin geçerli ve test edilmiş kimlik bilgileriyle etkinleştirilmesi gerekir. SMTP için kullanılan e-posta kullanıcı adı ve şifre IMAP için kullanılacaktır. Daha fazla bilgi için Discourse Meta özellik duyurusuna bakın.' + imap_alpha_warning: "Uyarı: Bu bir alfa aşaması özelliğidir. Resmi olarak yalnızca Gmail desteklenmektedir. Kendi sorumluluğunuzda kullanın!" + imap_settings_valid: "IMAP ayarları uygun." + smtp_disable_confirm: "SMTP'yi devre dışı bırakırsanız, tüm SMTP ve IMAP ayarları sıfırlanacak ve ilgili işlevler devre dışı bırakılacaktır. Devam etmek istediğine emin misiniz?" + imap_disable_confirm: "IMAP'i devre dışı bırakırsanız, tüm IMAP ayarları sıfırlanacak ve ilgili işlevler devre dışı bırakılacaktır. Devam etmek istediğine emin misin?" + imap_mailbox_not_selected: "Bu IMAP yapılandırması için bir Posta Kutusu seçmelisiniz, yoksa hiçbir posta kutusu senkronize edilmeyecektir!" + prefill: + title: "Şu ayarlarla önceden doldurun:" + gmail: "Gmail" credentials: title: "Kimlik bilgileri" smtp_server: "SMTP sunucusu" @@ -632,9 +651,11 @@ tr_TR: settings: title: "Ayarlar" allow_unknown_sender_topic_replies: "Bilinmeyen gönderen konu yanıtlarına izin ver." + allow_unknown_sender_topic_replies_hint: "Bilinmeyen gönderenlerin grup konularına cevap vermesine izin verir. Bu ayar etkinleştirilmezse, konuya davet edilmemiş e-posta adreslerinden gelen yanıtlar yeni bir konu oluşturacaktır." mailboxes: synchronized: "Senkronize Posta Kutusu" none_found: "Bu e-posta hesabında hiçbir posta kutusu bulunamadı." + disabled: "Devre dışı" membership: title: Üyelik access: Erişim @@ -942,6 +963,14 @@ tr_TR: dismiss_notifications: "Tümünü Yoksay" dismiss_notifications_tooltip: "Tüm okunmamış bildirileri okunmuş olarak işaretle" no_messages_title: "Hiç mesajınız yok" + no_messages_body: > + Normal konuşma akışının dışında, biriyle doğrudan kişisel bir konuşma yapmanız mı gerekiyor? Avatarlarını seçip %{icon} mesaj düğmesini kullanarak onlara mesaj gönderin.

    Yardıma ihtiyacınız varsa, bir sorumluya mesaj gönderebilirsiniz. + no_bookmarks_title: "Henüz hiçbir şeye yer işareti koymadınız" + no_bookmarks_body: > + %{icon} düğmesiyle gönderileri işaretlemeye başlayın ve bunlar kolayca erişilebilecek şekilde burada listelensin. Ayrıca bir hatırlatıcı da ekleyebilirsiniz! + no_notifications_title: "Henüz herhangi bir bildiriminiz yok" + no_notifications_body: > + Konu ve iletilerinize verilen yanıtlar da dahil olmak üzere, doğrudan sizle alâkalı etkinlik olduğunda, biri size @mention attığında veya sizi alıntıladığında bu panelde bildirilecektir. Bir süredir giriş yapmadığınızda da bildirimler e-posta olarak gönderilecektir.

    Hangi konular, kategoriler ve etiketler hakkında bilgilendirilmek istediğinize karar vermek için %{icon} ikonuna bakın. Daha fazlası için bildirim tercihlerinize bakın. first_notification: "İlk bildirimin! Başlamak için seç. " dynamic_favicon: "Sayacı, tarayıcı ikonunda göster" skip_new_user_tips: @@ -1339,12 +1368,17 @@ tr_TR: redeemed_tab: "Kabul Edildi" redeemed_tab_with_count: "Kabul edildi (%{count})" invited_via: "Davet" + invited_via_link: "bağlantı %{key} (%{count} / %{max} kullanıldı)" groups: "Gruplar" topic: "Konu" + sent: "Oluşturulan/Son Gönderilen" expires_at: "Bitiş tarihi" edit: "Düzenle" remove: "Kaldır" + copy_link: "Bağlantı adresini al" + reinvite: "E-postayı yeniden gönder" reinvited: "Davet tekrar gönderildi" + removed: "Kaldırıldı" search: "davet etmek için yaz..." user: "Davet Edilen Kullanıcı" none: "Görüntülenebilecek bir davet mevcut değil." @@ -1360,7 +1394,9 @@ tr_TR: remove_all: "Süresi Dolan Davetleri Kaldır" removed_all: "Tüm Süresi Dolmuş Davetiyeler kaldırıldı!" remove_all_confirm: "Süresi dolmuş tüm davetiyeleri kaldırmak istediğinizden emin misiniz?" + reinvite_all: "Tüm davetleri tekrar gönder" reinvite_all_confirm: "Tüm davetleri tekrar göndermek istediğine emin misin?" + reinvited_all: "Tüm davetler gönderildi!" time_read: "Okunma Zamanı" days_visited: "Ziyaret Edilen Gün" account_age_days: "Günlük hesap yaşı" @@ -1377,8 +1413,17 @@ tr_TR: max_redemptions_allowed_label: "Bu bağlantıyı kullanarak kaç kişinin kaydolmasına izin verilsin?" expires_at: "Bu davet bağlantısının süresi ne zaman dolacak?" invite: + new_title: "Davet oluştur" + edit_title: "Daveti düzenle" + instructions: "Bu siteye anında erişim vermek için bu bağlantıyı paylaşın" + copy_link: "bağlantıyı kopyala" + expires_in_time: "Süresi %{time} içinde dolacak:" + expired_at_time: "Şu zamanda süresi doldu: %{time}" show_advanced: "Gelişmiş Seçenekleri Göster" hide_advanced: "Gelişmiş Seçenekleri Gizle" + restrict_email: "Bir e-posta adresiyle sınırla" + max_redemptions_allowed: "Maksimum kullanım" + add_to_groups: "Gruplara ekle" bulk_invite: none: "Bu sayfada görüntülenecek davetiye yok." text: "Toplu Davet" @@ -3385,8 +3430,6 @@ tr_TR: available: "Grup adı uygun" not_available: "Grup adı mevcut değil" blank: "Grup adı boş olamaz" - add_members: - as_owner: "Kullanıcıyı/Kullanıcıları, bu grubun sahibi/sahipleri olarak ayarla" manage: interaction: email: E-posta diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index 0e2763497d..d287cb062d 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -246,7 +246,6 @@ uk: submit: "Надіслати" generic_error: "Даруйте, виникла помилка." generic_error_with_reason: "Виникла помилка: %{error}" - go_ahead: "Вперед" sign_up: "Зареєструватись" log_in: "Увійти" age: "Вік" @@ -279,7 +278,6 @@ uk: few: "Ще %{count}" many: "Ще %{count}" other: "Ще %{count}" - less: "Менше" never: "ніколи" every_30_minutes: "кожні 30 хвилин" every_hour: "щогодини" @@ -288,7 +286,6 @@ uk: every_month: "щомісяця" every_six_months: "що шість місяців" max_of_count: "не більше %{count}" - alternation: "або" character_count: one: "%{count} символ" few: "%{count} символи" @@ -325,7 +322,6 @@ uk: help: bookmark: "Натисніть, щоб закласти перший допис у цій темі" unbookmark: "Натисніть, щоб видалити усі закладки у цій темі" - unbookmark_with_reminder: "Клацніть, щоб видалити всі закладки та нагадування в цій темі. Ви маєте набір нагадувань %{reminder_at} для цієї теми." bookmarks: created: "Ви додали повідомлення в закладки. %{name}" not_bookmarked: "додати цей допис до закладок" @@ -689,13 +685,6 @@ uk: member_added: "Додано" member_requested: "Запит подано" add_members: - title: "Додати учасників до %{group_name}" - description: "Ви також можете додати список учасників – значення, розділені комами." - usernames_or_emails: - title: "Введіть імена користувачів або адреси електронної пошти" - input_placeholder: "Ім'я користувачів або адреси електронної пошти" - usernames: - input_placeholder: "Імена користувачів" notify_users: "Сповістити користувачів" requests: title: "Запити" @@ -710,7 +699,7 @@ uk: title: "Керувати" name: "Ім'я" full_name: "Повне ім'я" - add_members: "Додати учасників" + invite_members: "Запрошення" delete_member_confirm: "Вилучити '%{username}' з групи '%{group}'?" profile: title: Профіль @@ -3867,8 +3856,6 @@ uk: available: "Назва групи є" not_available: "Назва групи недоступно" blank: "Ім’я групи не може бути порожнім" - add_members: - as_owner: "Встановити користувача (їй) в якості власника (ів) цієї групи" manage: interaction: email: Електронна пошта diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index 159c36a1ac..3ce5339475 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -194,7 +194,6 @@ ur: submit: "شائع" generic_error: "معذرت، ایک تکنیکی خرابی کا سامنا کرنا پڑا ہے۔" generic_error_with_reason: "ایک تکنیکی خرابی پیش آئی: %{error}" - go_ahead: "آگے بڑھیے" sign_up: "سائن اپ" log_in: "لاگ ان" age: "عمر " @@ -223,7 +222,6 @@ ur: x_more: one: "%{count} مزید" other: "%{count} مزید" - less: "کم " never: "کبھی نہیں " every_30_minutes: "ہر 30 منٹ" every_hour: "ہر گھنٹے" @@ -232,7 +230,6 @@ ur: every_month: "ہر مہینے" every_six_months: "ہر چھ ماہ" max_of_count: "زیادہ سے زیادہ %{count}" - alternation: "یا " character_count: one: "%{count} حرف" other: "%{count} حروف" @@ -267,7 +264,6 @@ ur: help: bookmark: "اِس ٹاپک کی پہلی پوسٹ بُک مارک کرنے کے لئے کلک کریں" unbookmark: "اِس ٹاپک کے تمام بُک مارک ہٹانے کے لئے کلک کریں" - unbookmark_with_reminder: "اس عنوان میں موجود تمام بُک مارکس اور یاد دہانیوں کو دور کرنے کے لئے کلک کریں۔ اس عنوان میں آپ نے یہ %{reminder_at} یاد دہانی رکھی ہےـ" bookmarks: created: "آپ نے اس %{name} پوسٹ کو بک مارک کیا ہے۔" not_bookmarked: "اِس پوسٹ کو بُک مارک کریں" @@ -586,9 +582,6 @@ ur: groups: member_added: "شامل کر دیا گیا" member_requested: "درخواست کیا گیا" - add_members: - usernames: - input_placeholder: "صارف نام" requests: title: "درخواستیں" reason: "وجہ" @@ -602,7 +595,7 @@ ur: title: "مَینَیج" name: "نام" full_name: "پورا نام" - add_members: "ممبران شامل کریں" + invite_members: "دعوت دیں" delete_member_confirm: "'%{username}' کو '%{group}' گروپ سے ہٹائیں؟" profile: title: پروفائل @@ -3180,8 +3173,6 @@ ur: available: "گروپ کا نام دستیاب ہے" not_available: "گروپ کا نام دستیاب نہیں ہے" blank: "گروپ کا نام خالی نہیں ہو سکتا" - add_members: - as_owner: "صارف(ین) کو اِس گروپ کے مالک(ین) کے طور پر مقرر کریں" manage: interaction: email: اِی میل diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index 1e481e3827..fb6d6a4c9c 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -174,7 +174,6 @@ vi: submit: "Gửi đi" generic_error: "Rất tiếc, đã có lỗi xảy ra." generic_error_with_reason: "Đã xảy ra lỗi: %{error}" - go_ahead: "Lên đầu" sign_up: "Đăng ký" log_in: "Đăng nhập" age: "Tuổi" @@ -201,7 +200,6 @@ vi: more: "Nhiều hơn" x_more: other: "%{count} Thêm" - less: "Ít hơn" never: "không bao giờ" every_30_minutes: "mỗi 30 phút" every_hour: "mỗi giờ" @@ -210,7 +208,6 @@ vi: every_month: "hàng tháng" every_six_months: "mỗi sáu tháng" max_of_count: "tối đa trong %{count}" - alternation: "hoặc" character_count: other: "%{count} ký tự" related_messages: @@ -241,7 +238,6 @@ vi: help: bookmark: "Chọn bài viết đầu tiên của chủ đề cho vào dấu trang" unbookmark: "Chọn để xoá toàn bộ dấu trang trong chủ đề này" - unbookmark_with_reminder: "Nhấp để xóa tất cả dấu trang và lời nhắc trong chủ đề này. Bạn có lời nhắc đặt %{reminder_at} cho chủ đề này." bookmarks: created: "Bạn đã đánh dấu bài đăng này. %{name}" not_bookmarked: "đánh dấu bài viết này" @@ -537,13 +533,6 @@ vi: member_added: "Đã thêm" member_requested: "Yêu cầu tại" add_members: - title: "Thêm thành viên vào %{group_name}" - description: "Bạn cũng có thể dán vào danh sách được phân tách bằng dấu phẩy." - usernames_or_emails: - title: "Nhập tên người dùng hoặc địa chỉ email" - input_placeholder: "Tên người dùng hoặc email" - usernames: - input_placeholder: "Tên người dùng" notify_users: "Thông báo cho người dùng" requests: title: "Những yêu cầu" @@ -558,7 +547,7 @@ vi: title: "Quản lý" name: "Tên" full_name: "Tên đầy đủ" - add_members: "Thêm thành viên" + invite_members: "Mời" delete_member_confirm: "Xóa %{username}ra khỏi nhóm %{group}?" profile: title: Hồ sơ @@ -3242,8 +3231,6 @@ vi: available: "Tên nhóm dùng được" not_available: "Tên nhóm đã sử dụng" blank: "Tên nhóm không thể trống" - add_members: - as_owner: "Đặt user làm chủ sở hữu của nhóm này" manage: interaction: email: Email diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index 5ea7885bcf..44428e5bd0 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -27,18 +27,18 @@ zh_CN: time: "HH:mm" time_with_zone: "HH:mm (z)" time_short_day: "ddd, HH:mm" - timeline_date: "YYYY[年]M[月]" - long_no_year: "M[月]D[日] HH:mm" - long_no_year_no_time: "M[月]D[日]" - full_no_year_no_time: "M[月]D[日]" - long_with_year: "YYYY[年]M[月]D[日] HH:mm" - long_with_year_no_time: "YYYY[年]M[月]D[日]" - full_with_year_no_time: "YYYY[年]M[月]D[日]" - long_date_with_year: "YY[年]M[月]D[日] LT" - long_date_without_year: "M[月]D[日] LT" - long_date_with_year_without_time: "YY[年]M[月]D[日]" - long_date_without_year_with_linebreak: "M[月]D[日]
    LT" - long_date_with_year_with_linebreak: "YY[年]M[月]D[日]
    LT" + timeline_date: "YYYY 年 M 月" + long_no_year: "M 月 D 日 HH:mm" + long_no_year_no_time: "M 月 D 日" + full_no_year_no_time: "M 月 D 日" + long_with_year: "YYYY 年 M 月 D 日 HH:mm" + long_with_year_no_time: "YYYY 年 M 月 D 日" + full_with_year_no_time: "YYYY 年 M 月 D 日" + long_date_with_year: "YY 年 M 月 D 日 LT" + long_date_without_year: "M 月 D 日 LT" + long_date_with_year_without_time: "YY 年 M 月 D 日" + long_date_without_year_with_linebreak: "M 月 D 日
    LT" + long_date_with_year_with_linebreak: "YY 年 M 月 D 日
    LT" wrap_ago: "%{date} 前" tiny: half_a_minute: "< 1 分钟" @@ -57,13 +57,13 @@ zh_CN: x_months: other: "%{count} 个月" about_x_years: - other: "%{count}y" + other: "%{count} 年" over_x_years: - other: "> %{count}y" + other: "> %{count} 年" almost_x_years: - other: "%{count}y" - date_month: "M[月]D[日]" - date_year: "YY[年]M[月]" + other: "%{count} 年" + date_month: "M 月 D 日" + date_year: "YY 年 M 月" medium: x_minutes: other: "%{count} 分钟" @@ -71,12 +71,12 @@ zh_CN: other: "%{count} 小时" x_days: other: "%{count} 天" - date_year: "YY[年]M[月]D[日]" + date_year: "YY 年 M 月 D 日" medium_with_ago: x_minutes: other: "%{count} 分钟前" x_hours: - other: "%{count}小时前" + other: "%{count} 小时前" x_days: other: "%{count} 天前" x_months: @@ -102,49 +102,49 @@ zh_CN: email: "通过电子邮件发送" url: "复制并分享网址" action_codes: - public_topic: "于 %{when} 将此话题设为公开" - private_topic: "于 %{when} 将该话题转换为私信" - split_topic: "于 %{when} 拆分了此话题" - invited_user: "于 %{when} 邀请了 %{who}" - invited_group: "于 %{when} 邀请了 %{who}" - user_left: "%{who} 于 %{when} 离开了该私信" - removed_user: "于 %{when} 移除了 %{who}" - removed_group: "于 %{when} 移除了 %{who}" - autobumped: "于 %{when} 自动顶帖" + public_topic: "%{when}将此话题设为公开" + private_topic: "%{when}将此话题转换为个人消息" + split_topic: "%{when}拆分了此话题" + invited_user: "%{when}邀请了 %{who}" + invited_group: "%{when}邀请了 %{who}" + user_left: "%{who} 在 %{when}将自己从此消息中移除" + removed_user: "%{when}移除了 %{who}" + removed_group: "%{when}移除了 %{who}" + autobumped: "%{when}自动顶帖" autoclosed: - enabled: "于 %{when} 关闭" - disabled: "于 %{when} 打开" + enabled: "%{when}关闭" + disabled: "%{when}打开" closed: - enabled: "于 %{when} 关闭" - disabled: "于 %{when} 打开" + enabled: "%{when}关闭" + disabled: "%{when}打开" archived: - enabled: "于 %{when} 归档" - disabled: "于 %{when} 取消归档" + enabled: "%{when}归档" + disabled: "%{when}取消归档" pinned: - enabled: "于 %{when} 置顶" - disabled: "于 %{when} 取消置顶" + enabled: "%{when}置顶" + disabled: "%{when}取消置顶" pinned_globally: - enabled: "于 %{when} 全站置顶" - disabled: "于 %{when} 取消全站置顶" + enabled: "%{when}全站置顶" + disabled: "%{when}取消全站置顶" visible: - enabled: "于 %{when} 显示" - disabled: "于 %{when} 隐藏" + enabled: "%{when}公开" + disabled: "%{when}取消公开" banner: - enabled: "于 %{when} 将此设置为横幅,在用户关闭前将会在每个页面的顶部显示。" - disabled: "于 %{when} 移除了该横幅,将不再显示在每个页面的顶部。" - forwarded: "转发上述邮件" - topic_admin_menu: "操作话题" - wizard_required: "欢迎来到你全新的Discourse!让我们跟随安装向导开始吧✨" - emails_are_disabled: "所有的出站邮件都已被管理员全局,任何类型的邮件提醒都不会发出。" + enabled: "%{when}将此设置为横幅。在用户关闭前,它将显示在每个页面的顶部。" + disabled: "%{when}移除了此横幅。它将不再显示在每个页面的顶部。" + forwarded: "转发了上述电子邮件" + topic_admin_menu: "话题操作" + wizard_required: "欢迎来到全新的 Discourse!让我们跟随设置向导开始吧 ✨" + emails_are_disabled: "所有外发电子邮件已被管理员全局禁用。任何类型的电子邮件通知都不会发出。" software_update_prompt: - message: "我们已经更新了网站,请 刷新页面,否则可能会遇到未知故障。" - dismiss: "忽略" + message: "我们已经更新此站点,请 刷新页面,否则可能会遇到意外行为。" + dismiss: "关闭" bootstrap_mode_enabled: - other: "为了使你的新站点更易启用,现正处于引导模式中,所有的新用户的信任等级都将设为 1,并为他们启用每日摘要邮件。引导模式会在达到 %{count} 个用户时自动关闭。" - bootstrap_mode_disabled: "引导模式将会在 24 小时内关闭。" + other: "为了更轻松地启动您的新站点,您现在处于引导模式。所有新用户都将被授予信任等级 1,并且已启用每日摘要电子邮件。引导模式会在达到 %{count} 个用户时自动关闭。" + bootstrap_mode_disabled: "引导模式将在 24 小时内禁用。" themes: default_description: "默认" - broken_theme_alert: "因为主题或组件 %{theme} 有错误,你的站点可能无法正常运行, 在 %{path} 它。" + broken_theme_alert: "因为主题/组件 %{theme} 有错误,您的站点可能无法正常运行,在%{path}下将其禁用。" s3: regions: ap_northeast_1: "亚太地区(东京)" @@ -161,50 +161,48 @@ zh_CN: eu_west_1: "欧洲(爱尔兰)" eu_west_2: "欧洲(伦敦)" eu_west_3: "欧洲(巴黎)" - sa_east_1: "南美(圣保罗)" - us_east_1: "美国东部(N. Virginia)" + sa_east_1: "南美洲(圣保罗)" + us_east_1: "美国东部(北弗吉尼亚)" us_east_2: "美国东部(俄亥俄州)" - us_gov_east_1: "AWS 政府云(US-East)" - us_gov_west_1: "AWS 政府云(US-West)" - us_west_1: "美国西部(N. California)" - us_west_2: "美国西部(Oregon)" - clear_input: "清空输入" - edit: "编辑该话题的标题和分类" + us_gov_east_1: "AWS GovCloud(美国东部)" + us_gov_west_1: "AWS GovCloud(美国西部)" + us_west_1: "美国西部(北加利福尼亚州)" + us_west_2: "美国西部(俄勒冈州)" + clear_input: "清除输入" + edit: "编辑此话题的标题和分类" expand: "展开" - not_implemented: "非常抱歉,这个功能仍在开发中!" + not_implemented: "抱歉,该功能尚未实现!" no_value: "否" yes_value: "是" submit: "提交" - generic_error: "抱歉,出了点小问题。" + generic_error: "抱歉,出错了。" generic_error_with_reason: "出错了:%{error}" - go_ahead: "继续" sign_up: "注册" log_in: "登录" age: "年龄" - joined: "加入于" - admin_title: "管理" - show_more: "显示更多" + joined: "加入日期:" + admin_title: "管理员" + show_more: "展开" show_help: "选项" links: "链接" links_lowercase: other: "链接" - faq: "常见问题" - guidelines: "指引" + faq: "常见问题解答" + guidelines: "准则" privacy_policy: "隐私政策" privacy: "隐私" tos: "服务条款" rules: "规则" conduct: "行为准则" - mobile_view: "移动版" - desktop_view: "桌面版" - you: "你" - or: "或" - now: "刚才" - read_more: "阅读更多" + mobile_view: "移动版视图" + desktop_view: "桌面版视图" + you: "您" + or: "或者" + now: "刚刚" + read_more: "阅读更多内容" more: "更多" x_more: - other: "%{count} 更多" - less: "更少" + other: "其他 %{count} 人" never: "从未" every_30_minutes: "每 30 分钟" every_hour: "每小时" @@ -212,25 +210,24 @@ zh_CN: weekly: "每周" every_month: "每月" every_six_months: "每六个月" - max_of_count: "不超过 %{count}" - alternation: "或" + max_of_count: "最多 %{count} 个" character_count: other: "%{count} 个字符" related_messages: title: "相关消息" - see_all: '查看来自 @%{username} 的所有消息 ...' + see_all: '查看来自 @%{username} 的所有消息…' suggested_topics: - title: "推荐话题" - pm_title: "推荐消息" + title: "建议的话题" + pm_title: "建议的消息" about: simple_title: "关于" title: "关于 %{title}" - stats: "站点统计" + stats: "站点统计信息" our_admins: "我们的管理员" our_moderators: "我们的版主" moderators: "版主" stat: - all_time: "不限时间" + all_time: "所有时间" last_day: "过去 24 小时" last_7_days: "过去 7 天" last_30_days: "过去 30 天" @@ -240,161 +237,160 @@ zh_CN: user_count: "用户" active_user_count: "活跃用户" contact: "联系我们" - contact_info: "如果出现影响到此站点的关键问题或紧急事项,请通过 %{contact_info} 联系我们。" + contact_info: "如果出现影响此站点的关键问题或紧急事项,请联系我们:%{contact_info}。" bookmarked: - title: "收藏" - clear_bookmarks: "取消收藏" + title: "加入书签" + clear_bookmarks: "清除书签" help: - bookmark: "点击收藏该话题的第一个帖子" - unbookmark: "点击移除本话题的所有收藏" - unbookmark_with_reminder: "点击以移除该话题上的所有收藏和提醒,你在该话题中设定了一个于 %{reminder_at} 的提醒。" + bookmark: "点击以将此话题的第一个帖子加入书签" + unbookmark: "点击以移除此话题中的所有书签" bookmarks: - created: "你已收藏了该帖子,%{name}" - not_bookmarked: "收藏此帖" - created_with_reminder: "你已收藏了该帖子并设置了一个于 %{date} 的提醒,%{name}" - remove: "取消收藏" - delete: "删除收藏" - confirm_delete: "你确定要删除该收藏吗?你所设置的提醒也会被一并删除。" - confirm_clear: "你确定要清空这个话题中的所有收藏?" + created: "您已将此帖子加入书签。%{name}" + not_bookmarked: "将此帖子加入书签" + created_with_reminder: "您已将此帖子加入书签,并于 %{date}设置了一个提醒。%{name}" + remove: "移除书签" + delete: "删除书签" + confirm_delete: "确定要删除此书签吗?提醒也将一并删除。" + confirm_clear: "确定要清除此话题中的所有书签吗?" save: "保存" - no_timezone: '你尚未设置时区,将无法设置提醒,在 你的个人资料中设置。' - invalid_custom_datetime: "你所提供的日期和时间无效,请重试。" - list_permission_denied: "你没有权限查看该用户的收藏。" - no_user_bookmarks: "你没有已收藏的帖子,收藏可让你快速定位指定的帖子。" + no_timezone: '您尚未设置时区,将无法设置提醒,在您的个人资料中进行设置。' + invalid_custom_datetime: "您提供的日期和时间无效,请重试。" + list_permission_denied: "您没有权限查看此用户的书签。" + no_user_bookmarks: "您没有已加入书签的帖子;书签让您可以快速定位指定帖子。" auto_delete_preference: label: "自动删除" never: "从不" - when_reminder_sent: "发送提醒时" - on_owner_reply: "在我回复此话题之后" - search_placeholder: "按名称、话题标题或帖子内容搜索收藏内容" + when_reminder_sent: "发送提醒后" + on_owner_reply: "在我回复此话题后" + search_placeholder: "按名称、话题标题或帖子内容搜索书签" search: "搜索" reminders: today_with_time: "今天 %{time}" tomorrow_with_time: "明天 %{time}" - at_time: "于 %{date_time}" - existing_reminder: "你为该收藏所设定的提醒将在 %{at_date_time} 发出" + at_time: "%{date_time}" + existing_reminder: "您为此书签设置的提醒将在%{at_date_time}发送" copy_codeblock: copied: "已复制!" drafts: resume: "恢复" remove: "移除" - remove_confirmation: "你确定要删除草稿吗?" - new_topic: "新建话题草稿" - new_private_message: "新建私信草稿" + remove_confirmation: "确定要删除此草稿吗?" + new_topic: "新话题草稿" + new_private_message: "新私信草稿" topic_reply: "草稿回复" abandon: - confirm: "你正在为这个话题起草一个草稿,你想对它做什么?" - yes_value: "丢弃" + confirm: "您在此话题下有一个进行中的草稿。您想对它做什么?" + yes_value: "舍弃" no_value: "恢复编辑" topic_count_latest: - other: "有 %{count} 个新的或更新的话题" + other: "查看 %{count} 个新的或更新的话题" topic_count_unread: - other: "有 %{count} 个未读话题" + other: "查看 %{count} 个未读话题" topic_count_new: - other: "有 %{count} 个新话题" + other: "查看 %{count} 个新话题" preview: "预览" cancel: "取消" - deleting: "删除中..." - save: "保存更改" - saving: "保存中…" + deleting: "正在删除…" + save: "保存变更" + saving: "正在保存…" saved: "已保存!" upload: "上传" - uploading: "上传中…" - uploading_filename: "上传中:%{filename}..." + uploading: "正在上传…" + uploading_filename: "正在上传:%{filename}…" processing_filename: "正在处理:%{filename}…" clipboard: "剪贴板" - uploaded: "上传成功!" - pasting: "粘贴中…" + uploaded: "已上传!" + pasting: "正在粘贴…" enable: "启用" disable: "禁用" continue: "继续" - undo: "重做" - revert: "撤销" + undo: "撤消" + revert: "还原" failed: "失败" switch_to_anon: "进入匿名模式" switch_from_anon: "退出匿名模式" banner: - close: "隐藏横幅。" - edit: "编辑该横幅 >>" + close: "关闭此横幅。" + edit: "编辑此横幅 >>" pwa: - install_banner: "你想要安装 %{title} 在此设备上吗?" + install_banner: "要在此设备上安装 %{title} 吗?" choose_topic: - none_found: "没有找到话题。" + none_found: "未找到话题。" title: search: "搜索话题" - placeholder: "在此处输入话题标题、链接或 ID" + placeholder: "在此处输入话题标题、网址或 ID" choose_message: - none_found: "没有找到消息" + none_found: "未找到消息。" title: search: "搜索消息" - placeholder: "在此处输入消息的标题、链接或ID" + placeholder: "在此处输入消息标题、网址或 ID" review: order_by: "排序依据" in_reply_to: "回复给" explain: - why: "解释为什么该条目最终进入队列" - title: "需审核评分" + why: "解释为什么此条目最终进入队列" + title: "可审查对象评分" formula: "公式" subtotal: "小计" total: "总计" - min_score_visibility: "可见的最低评分" - score_to_hide: "隐藏帖子的评分" + min_score_visibility: "公开范围的最低分数" + score_to_hide: "隐藏帖子的触发分数" take_action_bonus: - name: "立即执行" - title: "当工作人员选择采取行动时,会给标记加分。" + name: "采取行动" + title: "当管理人员选择采取行动时,举报会获得加分。" user_accuracy_bonus: name: "用户准确性" - title: "先前已同意其标记的用户将获得奖励。" + title: "所提交的举报一直都获得同意的用户将获得加分。" trust_level_bonus: name: "信任等级" - title: "待审阅条目由较高信任级别且具有较高分数的用户创建的。" + title: "由较高信任级别的用户创建的可审查条目具有较高的分数。" type_bonus: - name: "奖励类型" - title: "某些可审核类型可以由管理人员加权,以使其具有更高的优先级。" - stale_help: "这个可审查对象已被其他人解决。" + name: "奖励加分" + title: "某些可审查类型可以由管理人员分配加分,以使其具有更高的优先级。" + stale_help: "此可审查对象已被其他人解决。" claim_help: - optional: "你可以认领此条目以避免被他人审核。" - required: "在你审核之前你必须认领此条目。" - claimed_by_you: "你已认领此条目现在可以审核了。" - claimed_by_other: "此条目仅可被 %{username} 审核。" + optional: "您可以认领此条目以避免被他人审查。" + required: "您必须先认领条目才能进行审查。" + claimed_by_you: "您已认领此条目,可以进行审查。" + claimed_by_other: "此条目仅可以由 %{username} 审查。" claim: - title: "认领该话题" + title: "认领此话题" unclaim: - help: "移除该认领" - awaiting_approval: "需要审核" + help: "移除此认领" + awaiting_approval: "需要审批" delete: "删除" settings: saved: "已保存!" - save_changes: "保存更改" + save_changes: "保存变更" title: "设置" priorities: - title: "需审核优先级" - moderation_history: "管理日志" + title: "可审查对象优先级" + moderation_history: "审核历史记录" view_all: "查看全部" grouped_by_topic: "按话题分组" - none: "没有需要审核的条目" - view_pending: "查看待审核" + none: "没有要审查的条目" + view_pending: "查看待审查条目" topic_has_pending: - other: "该话题中有 %{count} 个帖子等待审核" - title: "审核" + other: "此话题有 %{count} 个帖子等待审批" + title: "审查" topic: "话题:" - filtered_topic: "您正在选择性地查看这一话题中的可审核内容。" + filtered_topic: "您已筛选到一个话题下的可审查内容。" filtered_user: "用户" - filtered_reviewed_by: "审核者" + filtered_reviewed_by: "审查者" show_all_topics: "显示所有话题" - deleted_post: "(已删除的帖子)" - deleted_user: "(已删除的用户)" + deleted_post: "(帖子已删除)" + deleted_user: "(用户已删除)" user: - bio: "简介" + bio: "个人简介" website: "网站" username: "用户名" - email: "邮箱" - name: "名称" + email: "电子邮件" + name: "姓名" fields: "字段" reject_reason: "原因" user_percentage: summary: - other: "%{agreed},%{disagreed},%{ignored} (共 %{count} 个标记)" + other: "%{agreed},%{disagreed},%{ignored}(共 %{count} 个举报)" agreed: other: "%{count}% 同意" disagreed: @@ -403,11 +399,11 @@ zh_CN: other: "%{count}% 忽略" topics: topic: "话题" - reviewable_count: "总数" + reviewable_count: "计数" reported_by: "举报人" - deleted: "[已删除的话题]" + deleted: "[话题已删除]" original: "(原话题)" - details: "详情" + details: "详细信息" unique_users: other: "%{count} 位用户" replies: @@ -415,19 +411,19 @@ zh_CN: edit: "编辑" save: "保存" cancel: "取消" - new_topic: "批准此条目将会创建一个新的话题" + new_topic: "批准此条目将创建一个新话题" filters: - all_categories: "(所有分类)" + all_categories: "(所有类别)" type: title: "类型" - all: "(全部类型)" + all: "(所有类型)" minimum_score: "最低分:" refresh: "刷新" status: "状态" - category: "分类" + category: "类别" orders: - score: "评分" - score_asc: "评分(倒序)" + score: "分数" + score_asc: "分数(倒序)" created_at: "创建时间" created_at_asc: "创建时间(倒序)" priority: @@ -439,50 +435,50 @@ zh_CN: conversation: view_full: "查看完整对话" scores: - about: "该分数是根据举报者的信任等级、该用户以往举报的准确性以及被举报条目的优先级计算得出的。" - score: "评分" + about: "此分数是根据举报者的信任等级、该用户以往举报的准确性以及被举报条目的优先级计算得出的。" + score: "分数" date: "日期" type: "类型" status: "状态" - submitted_by: "提交人" - reviewed_by: "审核者" + submitted_by: "提交者" + reviewed_by: "审查者" statuses: pending: - title: "待定" + title: "待处理" approved: title: "已批准" rejected: - title: "已驳回" + title: "已拒绝" ignored: title: "已忽略" deleted: title: "已删除" reviewed: - title: "(所有已审核)" + title: "(所有已审查)" all: title: "(全部)" types: reviewable_flagged_post: - title: "被标记的帖子" - flagged_by: "标记者" + title: "被举报的帖子" + flagged_by: "举报者" reviewable_queued_topic: - title: "队列中话题" + title: "已加入的话题" reviewable_queued_post: - title: "队列中帖子" + title: "已加入的帖子" reviewable_user: title: "用户" reviewable_post: title: "帖子" approval: - title: "需要批准的帖子" - description: "我们已经收到了你的发帖,不过帖子需要由版主审核才能显示,请耐心等待。" + title: "帖子需要审批" + description: "我们已收到您的帖子,不过需要由版主批准才能显示。请耐心等待。" pending_posts: - other: "你有 %{count} 个帖子在等待审核。" + other: "您有 %{count} 个帖子正在审批。" ok: "确认" example_username: "用户名" reject_reason: - title: "你为什么驳回这个用户?" - send_email: "发送驳回邮件" + title: "您为什么拒绝此用户?" + send_email: "发送拒绝电子邮件" relative_time_picker: minutes: other: "分钟" @@ -491,15 +487,15 @@ zh_CN: days: other: "天" months: - other: "月" + other: "个月" years: other: "年" - relative: "相对的" + relative: "相对" time_shortcut: later_today: "今天晚些时候" next_business_day: "下一个工作日" tomorrow: "明天" - post_local_date: "发布日期" + post_local_date: "帖子中的日期" later_this_week: "本周晚些时候" start_of_next_business_week: "星期一" start_of_next_business_week_alt: "下周一" @@ -509,19 +505,19 @@ zh_CN: none: "不需要" last_custom: "上次自定义日期时间" user_action: - user_posted_topic: "%{user}发表了该话题" - you_posted_topic: "你发表了该话题" - user_replied_to_post: "%{user}回复了%{post_number}" - you_replied_to_post: "你回复了%{post_number}" - user_replied_to_topic: "%{user}回复了该话题" - you_replied_to_topic: "你回复了该话题" - user_mentioned_user: "%{user}提到了%{another_user}" - user_mentioned_you: "%{user}提到了你" - you_mentioned_user: "你提到了%{another_user}" - posted_by_user: "由%{user}发表" - posted_by_you: "由你发表" - sent_by_user: "由%{user}发送" - sent_by_you: "由 你发送" + user_posted_topic: "%{user} 发布了该话题" + you_posted_topic: "您 发布了该话题" + user_replied_to_post: "%{user} 回复了 %{post_number}" + you_replied_to_post: "您 回复了 %{post_number}" + user_replied_to_topic: "%{user} 回复了该话题" + you_replied_to_topic: "您 回复了该话题" + user_mentioned_user: "%{user} 提到了 %{another_user}" + user_mentioned_you: "%{user} 提到了您" + you_mentioned_user: "您提到了 %{another_user}" + posted_by_user: "由 %{user} 发布" + posted_by_you: "由您发布" + sent_by_user: "由 %{user} 发送" + sent_by_you: "由您发送" directory: username: "用户名" filter_name: "按用户名筛选" @@ -532,90 +528,81 @@ zh_CN: topics_entered_long: "已浏览话题" time_read: "阅读时长" topic_count: "话题" - topic_count_long: "话题已创建" + topic_count_long: "已创建话题" post_count: "回复" - post_count_long: "回帖数" + post_count_long: "回帖" no_results: "没有找到结果。" days_visited: "访问" days_visited_long: "访问天数" posts_read: "阅读" - posts_read_long: "看帖" + posts_read_long: "已读帖子" last_updated: "最近更新:" total_rows: other: "%{count} 位用户" edit_columns: save: "保存" - reset_to_default: "重置为默认" + reset_to_default: "重置为默认值" group: all: "所有群组" group_histories: actions: change_group_setting: "更改群组设置" - add_user_to_group: "增加用户" + add_user_to_group: "添加用户" remove_user_from_group: "移除用户" make_user_group_owner: "设为所有者" remove_user_as_group_owner: "撤销所有者" groups: member_added: "已添加" - member_requested: "请求于" + member_requested: "请求时间" add_members: - title: "添加成员到 %{group_name}" - description: "你也可以粘贴一个以逗号分隔的列表。" - usernames_or_emails: - title: "输入用户名或电子邮件地址" - input_placeholder: "用户名或电子邮件" - usernames: - title: "输入用户名" - input_placeholder: "用户名" notify_users: "通知用户" requests: title: "请求" - reason: "理由" + reason: "原因" accept: "接受" accepted: "已接受" deny: "拒绝" denied: "已拒绝" - undone: "撤销请求" - handle: "处理成员请求" + undone: "请求已撤消" + handle: "处理成员资格请求" manage: title: "管理" - name: "名字" + name: "名称" full_name: "全名" - add_members: "添加成员" - delete_member_confirm: "要从群组 %{group} 中移除用户 %{username} 吗?" + delete_member_confirm: "要从群组 '%{group}' 中移除用户 '%{username}' 吗?" profile: title: 个人资料 interaction: - title: 交互 + title: 互动 posting: 发帖 notification: 通知 email: - title: "邮箱" - status: "已通过 IMAP 同步了 %{old_emails} / %{total_emails} 封邮件。" + title: "电子邮件" + status: "已通过 IMAP 同步了 %{old_emails}/%{total_emails} 封邮件。" enable_smtp: "启用 SMTP" enable_imap: "启用 IMAP" test_settings: "测试设置" save_settings: "保存设置" - settings_required: "所有设置均为必填项,请在验证前填写所有字段。" + settings_required: "所有设置均为必填项,请先填写所有字段,然后再进行验证。" smtp_settings_valid: "SMTP 设置有效。" smtp_title: "SMTP" - smtp_instructions: "当您为此群组启用 SMTP 时, 从群组收件箱发送的所有出站邮件将通过此处指定的 SMTP 设置而不是为您的论坛发送的其他邮件配置的邮件服务器发送。" + smtp_instructions: "当您为群组启用 SMTP 时,从群组收件箱发送的所有出站电子邮件都将通过此处指定的 SMTP 设置,而不是为您的论坛发送的其他电子邮件配置的邮件服务器发送。" imap_title: "IMAP" imap_additional_settings: "附加设置" - imap_instructions: '当您为群组启用 IMAP 时,邮件将在群组收件箱和被提供的 IMAP 服务器和邮箱之间同步. 在启用 IMAP 之前,SMTP 必须启用并且凭证经过验证与测试。 SMTP 使用的电子邮件用户名和密码将用于 IMAP。 欲了解更多信息,请访问 Discourse Meta 功能公告。' - imap_alpha_warning: "警告:这是处于A测的功能。只官方支持 Gmail,风险自负!" + imap_instructions: '当您为群组启用 IMAP 时,电子邮件将在群组收件箱和提供的 IMAP 服务器与邮箱之间同步。在启用 IMAP 之前,必须启用 SMTP 并验证和测试凭据。SMTP 使用的电子邮件用户名和密码将用于 IMAP。有关详情,请参阅 Discourse Meta 功能公告。' + imap_alpha_warning: "警告:此功能仍处于 Alpha 版阶段。只官方支持 Gmail。风险自负!" imap_settings_valid: "IMAP 设置有效。" - smtp_disable_confirm: "如果你禁用SMTP,所有SMTP和 IMAP 设置将被重置,相关功能将被禁用,你确定要继续吗?" - imap_disable_confirm: "如果你禁用 IMAP,所有 IMAP 设置将被重置,相关功能将被禁用,你确定要继续吗?" - imap_mailbox_not_selected: "你必须为此 IMAP 配置选择一个邮箱,否则不会同步任何邮箱!" + smtp_disable_confirm: "如果您禁用 SMTP,所有 SMTP 和 IMAP 设置都将被重置,关联功能将被禁用。确定要继续吗?" + imap_disable_confirm: "如果您禁用 IMAP,所有 IMAP 设置都将被重置,关联功能将被禁用。确定要继续吗?" + imap_mailbox_not_selected: "您必须为此 IMAP 配置选择一个邮箱,否则将不会同步任何邮箱!" prefill: - title: "以此设置预填:" - gmail: "GMail" + title: "使用设置预填充:" + gmail: "Gmail" credentials: - title: "认证" + title: "凭据" smtp_server: "SMTP 服务器" smtp_port: "SMTP 端口" - smtp_ssl: "使用 SSL 连接 SMTP" + smtp_ssl: "为 SMTP 使用 SSL" imap_server: "IMAP 服务器" imap_port: "IMAP 端口" imap_ssl: "为 IMAP 使用 SSL" @@ -623,73 +610,73 @@ zh_CN: password: "密码" settings: title: "设置" - allow_unknown_sender_topic_replies: "允许从未知发件人话题的回复。" + allow_unknown_sender_topic_replies: "允许未知发件人话题回复。" mailboxes: synchronized: "已同步的邮箱" none_found: "未在此电子邮件帐户中找到任何邮箱。" disabled: "已禁用" membership: - title: 成员 + title: 成员资格 access: 访问 categories: - title: 分类 - long_title: "分类默认通知" - description: "将用户添加到此组时,其分类通知将默认设置为此,在这之后,他们还可以自行修改。" - watched_categories_instructions: "自动关注这些分类中的所有话题,组成员将收到所有新帖子和话题的通知,话题旁还会显示新帖子的数量。" - tracked_categories_instructions: "自动跟踪这些分类中的所有话题,新帖子的数量将显示在话题旁边。" - watching_first_post_categories_instructions: "用户将收到这些分类中每个新话题的第一篇文章的通知。" - regular_categories_instructions: "如果这些分类已经被静音,将为群组成员取消静音,用户被提及到或被回复时会被通知。" - muted_categories_instructions: "用户不会收到有关这些类别中新话题的任何通知,也不会出现在分类或最新话题页面上。" + title: 类别 + long_title: "类别默认通知" + description: "将用户添加到此群组时,其类别通知设置将被设置为这些默认值。之后,他们可以进行更改。" + watched_categories_instructions: "自动关注这些类别中的所有话题。组成员将收到所有新帖子和话题的通知,话题旁还会显示新帖子的数量。" + tracked_categories_instructions: "自动跟踪这些类别中的所有话题。新帖子的数量将显示在话题旁边。" + watching_first_post_categories_instructions: "用户将收到这些类别中每个新话题的第一个帖子的通知。" + regular_categories_instructions: "如果已被设为免打扰,这些类别将对群组成员取消免打扰。当用户被提及或其他人回复他们时,他们将收到通知。" + muted_categories_instructions: "用户不会收到有关这些类别中新话题的任何通知,也不会出现在类别或最新话题页面上。" tags: title: 标签 long_title: "标签默认通知" - description: "将用户添加到此组时,其标签的通知将默认为这些设置。在这之后,还可以修改。" - watched_tags_instructions: "自动关注带有这些标签的所有话题。组成员将收到所有新帖子和话题的通知,话题旁边还会显示新帖子的数量。" - tracked_tags_instructions: "自动跟踪有这些标签的所有话题,新帖子的数量将显示在话题旁边。" - watching_first_post_tags_instructions: "用户将收到带有这些标签的每个新话题的第一篇文章的通知。" - regular_tags_instructions: "如果这些标签已经被静音,将为群组成员取消静音,用户被提及到或被回复时会被通知。" - muted_tags_instructions: "用户不会收到有关这些标签的新话题的任何通知,也不会出现在最新话题中。" + description: "将用户添加到此群组时,其标签通知设置将被设置为这些默认值。之后,他们可以进行更改。" + watched_tags_instructions: "自动关注带有这些标签的所有话题。组成员将收到所有新帖子和话题的通知,话题旁还会显示新帖子的数量。" + tracked_tags_instructions: "自动跟踪带有这些标签的所有话题。新帖子的数量将显示在话题旁边。" + watching_first_post_tags_instructions: "用户将收到带有这些标签的每个新话题的第一个帖子的通知。" + regular_tags_instructions: "如果已被设为免打扰,这些标签将对群组成员取消免打扰。当用户被提及或其他人回复他们时,他们将收到通知。" + muted_tags_instructions: "用户不会收到有关带有这些标签的新话题的任何通知,也不会出现在最新话题页面上。" logs: title: "日志" - when: "何时" + when: "时间" action: "操作" acting_user: "操作用户" target_user: "目标用户" - subject: "话题" - details: "详情" + subject: "主题" + details: "详细信息" from: "从" - to: "到" + to: "至" permissions: title: "权限" - none: "没有分类关联到这个群组。" - description: "这个群组的成员可以访问这些分类。" - public_admission: "允许用户自由加入群组(需要群组公开可见)" + none: "没有类别与此群组关联。" + description: "此群组的成员可以访问这些类别" + public_admission: "允许用户自由加入群组(要求群组公开可见)" public_exit: "允许用户自由离开群组" empty: - posts: "群组成员没有发布帖子。" - members: "群组没有成员。" - requests: "没有请求加入此群组的请求。" - mentions: "群组从未被提及过。" - messages: "群组从未发送过私信。" - topics: "群组的成员从未发表话题。" - logs: "没有关于群组的日志。" + posts: "此群组的成员没有发布帖子。" + members: "此群组没有成员。" + requests: "没有加入此群组的成员资格请求。" + mentions: "此群组从未被提及。" + messages: "此群组没有消息。" + topics: "此群组的成员从未发表话题。" + logs: "此群组没有日志。" add: "添加" join: "加入" leave: "离开" request: "请求" message: "消息" - confirm_leave: "你确定要离开这个群组吗?" - allow_membership_requests: "允许用户向群组所有者发送成员申请(需要公开可见的群组)" - membership_request_template: "用户发送成员申请时向其显示的自定义模板" + confirm_leave: "确定要离开此群组吗?" + allow_membership_requests: "允许用户向群组所有者发送成员资格请求(要求群组公开可见)" + membership_request_template: "在用户发送成员资格请求时向其显示的自定义模板" membership_request: - submit: "提交申请" - title: "申请加入 %{group_name}" - reason: "向群组所有者说明你为什么要进入这个群组" + submit: "提交请求" + title: "请求加入 %{group_name}" + reason: "告诉群组所有者您为什么要加入此群组" membership: "成员资格" name: "名称" - group_name: "群组名" + group_name: "群组名称" user_count: "用户" - bio: "关于群组" + bio: "群组简介" selector_placeholder: "输入用户名" owner: "所有者" index: @@ -698,13 +685,13 @@ zh_CN: empty: "没有可见的群组。" filter: "根据群组类型筛选" owner_groups: "我拥有的群组" - close_groups: "已关闭群组" + close_groups: "已关闭的群组" automatic_groups: "自动群组" automatic: "自动" closed: "已关闭" public: "公开" - private: "私密" - public_groups: "公开的群组" + private: "不公开" + public_groups: "公开群组" automatic_group: 自动群组 close_group: 关闭群组 my_groups: "我的群组" @@ -719,103 +706,103 @@ zh_CN: filter_placeholder_admin: "用户名或电子邮件" filter_placeholder: "用户名" remove_member: "移除成员" - remove_member_description: "从群组中移除 %{username}" + remove_member_description: "从此群组中移除 %{username}" make_owner: "设为所有者" - make_owner_description: "使 %{username} 成为群组所有者" - remove_owner: "撤销所有者" - remove_owner_description: "把 %{username} 从群组所有者中移除" - make_primary: "设为主要成员" - make_primary_description: "设为 %{username} 的主要群组" - remove_primary: "移除主要" - remove_primary_description: "为 %{username} 移除这个主要群组" + make_owner_description: "将 %{username} 设为此群组的所有者" + remove_owner: "移除所有者身份" + remove_owner_description: "将 %{username} 从此群组的所有者中移除" + make_primary: "设为主要群组" + make_primary_description: "将此群组设为 %{username} 的主要群组" + remove_primary: "移除主要群组状态" + remove_primary_description: "移除此群组作为 %{username} 的主要群组的状态" remove_members: "移除成员" - remove_members_description: "从该群组中删除选定的用户" + remove_members_description: "从此群组中移除所选用户" make_owners: "设为所有者" - make_owners_description: "使选定的用户成为该群组的所有者" + make_owners_description: "将所选用户设为此群组的所有者" remove_owners: "移除所有者" - remove_owners_description: "从该群组中移除选定的所有者" - make_all_primary: "全部设为主要" - make_all_primary_description: "为选定的用户设置这个主要群组" - remove_all_primary: "移除主要" - remove_all_primary_description: "移除这个主要群组" + remove_owners_description: "将所选用户从此群组的所有者中移除" + make_all_primary: "设为所有用户的主要群组" + make_all_primary_description: "将此群组设为全部所选用户的主要群组" + remove_all_primary: "移除主要群组状态" + remove_all_primary_description: "将此群组从主要群组中移除" owner: "所有者" primary: "主要" - forbidden: "你不可以查看成员列表。" + forbidden: "您无权查看成员列表。" topics: "话题" posts: "帖子" mentions: "提及" messages: "消息" - notification_level: "群组消息的默认通知等级" + notification_level: "群组消息的默认通知级别" alias_levels: - mentionable: "谁能 @提及 该群组" - messageable: "谁能向此群组发送消息" + mentionable: "谁能提及 (@) 此群组" + messageable: "谁能向此群组发送消息?" nobody: "没有人" only_admins: "仅管理员" mods_and_admins: "仅版主和管理员" - members_mods_and_admins: "仅组员、版主与管理员" - owners_mods_and_admins: "仅所有者、版主与管理员" + members_mods_and_admins: "仅组员、版主和管理员" + owners_mods_and_admins: "仅所有者、版主和管理员" everyone: "任何人" notifications: watching: title: "关注" - description: "你将会在该消息中的每个新帖子发布后收到通知,并且会显示新回复数量。" + description: "您将在每个消息有新帖子时收到通知,并且会显示新回复数量。" watching_first_post: - title: "关注新帖子" - description: "你将收到有关此组中新消息的通知,但不会回复消息。" + title: "关注第一个帖子" + description: "您将收到此群组中新消息的通知,但不会收到消息回复。" tracking: title: "跟踪" - description: "你会在别人@你或回复你时收到通知,并且新帖数量也将在这些话题后显示。" + description: "您会在别人 @ 您或回复您时收到通知,并且会显示新回复数量。" regular: title: "常规" - description: "如果有人@你或回复你,将通知你。" + description: "您会在别人 @ 您或回复您时收到通知。" muted: - title: "静音" - description: "你不会收到有关此组中消息的任何通知。" + title: "已设为免打扰" + description: "您不会收到有关此组中消息的任何通知。" flair_url: "头像图片" flair_upload_description: "使用边长不小于 20px 的正方形图片。" flair_bg_color: "头像背景颜色" - flair_bg_color_placeholder: "(可选)十六进制色彩值" + flair_bg_color_placeholder: "(可选)十六进制颜色值" flair_color: "头像颜色" - flair_color_placeholder: "(可选)十六进制色彩值" + flair_color_placeholder: "(可选)十六进制颜色值" flair_preview_icon: "预览图标" flair_preview_image: "预览图片" flair_type: icon: "选择图标" image: "上传图片" user_action_groups: - "1": "点赞" - "2": "获得赞" - "3": "收藏" + "1": "赞" + "2": "赞" + "3": "书签" "4": "话题" "5": "回复" "6": "回应" "7": "提及" "9": "引用" "11": "编辑" - "12": "发送" + "12": "发送的条目" "13": "收件箱" - "14": "待定" + "14": "待处理" "15": "草稿" categories: - all: "所有分类" + all: "所有类别" all_subcategories: "全部" no_subcategory: "无" - category: "分类" - category_list: "显示分类列表" + category: "类别" + category_list: "显示类别列表" reorder: - title: "分类排序" - title_long: "重新对分类列表进行排序" - save: "保存排序" + title: "对类别重新排序" + title_long: "重新组织类别列表" + save: "保存顺序" apply_all: "应用" position: "位置" - posts: "新帖" + posts: "帖子" topics: "话题" latest: "最新" - toggle_ordering: "排序控制" - subcategories: "子分类" - muted: "已静音分类" + toggle_ordering: "切换排序控制" + subcategories: "子类别" + muted: "已设为免打扰的类别" topic_sentence: - other: "%{count} 话题" + other: "%{count} 个话题" topic_stat: other: "%{number} / %{unit}" topic_stat_unit: @@ -827,7 +814,7 @@ zh_CN: other: "过去一周有 %{count} 个新话题。" topic_stat_sentence_month: other: "过去一个月有 %{count} 个新话题。" - n_more: "分类 (还有 %{count} 个分类)" + n_more: "类别 (其他 %{count} 个)…" ip_lookup: title: IP 地址查询 hostname: 主机名 @@ -835,56 +822,56 @@ zh_CN: location_not_found: '(未知)' organisation: 组织 phone: 电话 - other_accounts: "使用此 IP 地址的其他用户:" - delete_other_accounts: "删除 %{count}" + other_accounts: "使用此 IP 地址的其他帐户:" + delete_other_accounts: "删除 %{count} 个" username: "用户名" - trust_level: "信任等级" + trust_level: "信任级别" read_time: "阅读时间" topics_entered: "进入的话题" - post_count: "# 帖子" - confirm_delete_other_accounts: "确定要删除这些账户?" + post_count: "帖子数量" + confirm_delete_other_accounts: "确定要删除这些帐户吗?" powered_by: "使用 MaxMindDB" copied: "已复制" user_fields: - none: "(选择一项)" - required: '请为 “%{name}” 输入一个值。' + none: "(选择一个选项)" + required: '请为“%{name}”输入一个值。' user: said: "%{username}:" profile: "个人资料" - mute: "静音" - edit: "修改设置" + mute: "忽略" + edit: "编辑偏好设置" download_archive: button_text: "全部下载" - confirm: "你确定要下载你的帖子吗?" - success: "下载开始,完成后将有私信通知你。" + confirm: "确定要下载您的帖子吗?" + success: "下载已开始,完成后将通过消息通知您。" rate_limit_error: "帖子只能每天下载一次,请明天再试。" - new_private_message: "新建消息" - private_message: "私信" - private_messages: "私信" + new_private_message: "新消息" + private_message: "消息" + private_messages: "消息" user_notifications: filters: - filter_by: "筛选" + filter_by: "筛选依据" all: "全部" read: "已读" unread: "未读" - ignore_duration_title: "忽略的用户" + ignore_duration_title: "忽略用户" ignore_duration_username: "用户名" ignore_duration_when: "持续时间:" ignore_duration_save: "忽略" - ignore_duration_note: "请注意所有忽略的项目会在忽略的时间段过去后被自动移除" + ignore_duration_note: "请注意,所有忽略都会在忽略持续时间到期后被自动移除。" ignore_duration_time_frame_required: "请选择时间范围" - ignore_no_users: "你没有忽略任何用户" + ignore_no_users: "您没有忽略任何用户。" ignore_option: "已忽略" - ignore_option_title: "你将不会收到关于此用户的通知并且隐藏其所有帖子及回复。" - add_ignored_user: "添加..." - mute_option: "已静音" - mute_option_title: "你不会收到任何关于此用户的通知" - normal_option: "普通" - normal_option_title: "如果用户回复、引用或提到你,你将会收到消息。" + ignore_option_title: "您将不会收到与此用户相关的通知,并且他们的所有话题和回复都将被隐藏。" + add_ignored_user: "添加…" + mute_option: "已设为免打扰" + mute_option_title: "您不会收到与此用户相关的任何通知。" + normal_option: "常规" + normal_option_title: "如果此用户回复、引用或提到您,您将收到通知。" notification_schedule: - title: "定时通知" - label: "启用自定义定时通知" - tip: "在这些时间之外,你将自动进入 “勿扰” 状态。" + title: "通知时间表" + label: "启用自定义通知时间表" + tip: "在这些时间之外,您将自动进入“勿扰”模式。" midnight: "午夜" none: "无" monday: "星期一" @@ -894,11 +881,11 @@ zh_CN: friday: "星期五" saturday: "星期六" sunday: "星期日" - to: "到" + to: "至" activity_stream: "活动" read: "阅读" read_help: "最近阅读的话题" - preferences: "设置" + preferences: "偏好设置" feature_topic_on_profile: open_search: "选择一个新话题" title: "选择一个话题" @@ -906,139 +893,139 @@ zh_CN: save: "保存" clear: title: "清除" - warning: "你确定要清除精选话题吗?" - use_current_timezone: "使用现在的时区" - profile_hidden: "此用户公共信息已被隐藏。" + warning: "确定要清除精选话题吗?" + use_current_timezone: "使用当前时区" + profile_hidden: "此用户的公开个人资料已被隐藏。" expand_profile: "展开" collapse_profile: "收起" - bookmarks: "收藏" - bio: "关于我" + bookmarks: "书签" + bio: "自我介绍" timezone: "时区" invited_by: "邀请人" - trust_level: "信任等级" + trust_level: "信任级别" notifications: "通知" - statistics: "统计" + statistics: "统计信息" desktop_notifications: label: "实时通知" - not_supported: "通知功能暂不支持该浏览器,抱歉。" + not_supported: "抱歉,此浏览器不支持通知。" perm_default: "启用通知" - perm_denied_btn: "拒绝授权" - perm_denied_expl: "你拒绝了通知提醒的权限,设置浏览器以启用通知提醒。" - disable: "通知" + perm_denied_btn: "权限被拒绝" + perm_denied_expl: "您拒绝了通知的权限。通过您的浏览器设置允许通知。" + disable: "禁用通知" enable: "启用通知" - each_browser_note: '注意:你必须在你使用的每个浏览器上更改此设置,在 “勿扰” 状态下,所有通知都将被,无视具体设置。' - consent_prompt: "有回复时是否接收通知?" - dismiss: "忽略" - dismiss_notifications: "忽略所有" - dismiss_notifications_tooltip: "标记所有未读通知为已读" - no_messages_title: "你没有消息" + each_browser_note: '注意:您必须在您使用的每个浏览器上更改此设置。在“勿扰”模式下,所有通知都将被禁用,无论此设置如何。' + consent_prompt: "当其他人回复您的帖子时是否接收实时通知?" + dismiss: "关闭" + dismiss_notifications: "全部关闭" + dismiss_notifications_tooltip: "将所有未读通知标记为已读" + no_messages_title: "您没有任何消息" no_messages_body: > - 想要直接与某人对话而不是公开的讨论?选择他的头像后点击 %{icon} 私信按钮。

    如果你需要帮助,你可以 联系管理员。 - no_bookmarks_title: "你还没有任何收藏" + 需要直接与某人对话而不是公开讨论?选择他的头像并点击 %{icon} 消息按钮,向他们发送消息。

    如果您需要帮助,可以向管理人员发送消息。 + no_bookmarks_title: "您还没有将任何内容加入书签" no_bookmarks_body: > - 用 %{icon} 按钮收藏帖子,它们将出现在这里,便于参考。你也可以安排一个提醒! - no_notifications_title: "你还没有任何通知" - first_notification: "你的第一个通知!选中它以开始。" + 使用 %{icon} 按钮开始将帖子加入书签,它们将出现在这里,便于参考。您也可以安排提醒! + no_notifications_title: "您还没有任何通知" + first_notification: "您的第一个通知!选中它以开始。" dynamic_favicon: "在浏览器图标上显示数量" skip_new_user_tips: - description: "跳过新用户流程提示和徽章" + description: "跳过新用户入门提示和徽章" not_first_time: "不是第一次?" skip_link: "跳过这些提示" - read_later: "稍后再读" + read_later: "稍后再读。" theme_default_on_all_devices: "将其设为我所有设备上的默认主题" - color_scheme_default_on_all_devices: "为我所有的设备上设置默认配色方案" + color_scheme_default_on_all_devices: "在我的所有设备上设置默认配色方案" color_scheme: "配色方案" color_schemes: - default_description: "主题默认" + default_description: "默认主题" disable_dark_scheme: "与常规相同" - dark_instructions: "你可以通过切换你的设备的深色模式来预览深色模式的配色方案。" + dark_instructions: "您可以通过切换设备的深色模式来预览深色模式配色方案。" undo: "重置" - regular: "常规的" + regular: "常规" dark: "深色模式" - default_dark_scheme: "(站点默认值)" + default_dark_scheme: "(站点默认)" dark_mode: "深色模式" - dark_mode_enable: "自动启用深色模式配色方案" - text_size_default_on_all_devices: "将其设为我所有设备上的默认字体大小" - allow_private_messages: "允许其他用户发送私信给我" - external_links_in_new_tab: "在新标签页打开外部链接" - enable_quoting: "在选择文字时显示引用回复按钮" - enable_defer: "启用延迟以标记未读话题" - change: "修改" + dark_mode_enable: "启用自动深色模式配色方案" + text_size_default_on_all_devices: "将其设为我所有设备上的默认文本大小" + allow_private_messages: "允许其他用户向我发送个人消息" + external_links_in_new_tab: "在新标签页中打开所有外部链接" + enable_quoting: "为突出显示的文字启用引用回复" + enable_defer: "启用延迟以将话题标记为未读" + change: "更改" featured_topic: "精选话题" moderator: "%{user} 是版主" admin: "%{user} 是管理员" moderator_tooltip: "此用户是版主" admin_tooltip: "此用户是管理员" - silenced_tooltip: "该用户已被禁言。" - suspended_notice: "该用户已被封禁到 %{date}。" - suspended_permanently: "该用户被封禁了。" - suspended_reason: "原因: " + silenced_tooltip: "此用户已被禁言" + suspended_notice: "此用户已被封禁到 %{date}。" + suspended_permanently: "此用户已被封禁。" + suspended_reason: "原因:" github_profile: "GitHub" email_activity_summary: "活动摘要" mailing_list_mode: - label: "邮件列表模式" - enabled: "启用邮件列表模式" + label: "邮寄名单模式" + enabled: "启用邮寄名单模式" instructions: | 此设置将覆盖活动摘要。
    - 已静音话题和分类不包含在这些邮件中。 - individual: "为每个新帖发送一封邮件通知" - individual_no_echo: "为每个除了我发表的新帖发送一封邮件通知" - many_per_day: "为每个新帖给我发送邮件(大约每天 %{dailyEmailEstimate} 封)" - few_per_day: "为每个新帖给我发送邮件(大约每天 2 封)" - warning: "邮件列表模式启用,邮件通知设置被覆盖。" + 已设为免打扰的话题和类别将不会被包含在这些电子邮件中。 + individual: "为每个新帖子发送一封电子邮件" + individual_no_echo: "为除了我发表的新帖子之外的每个新帖子发送一封电子邮件" + many_per_day: "每当有新帖子时都向我发送电子邮件(每天大约 %{dailyEmailEstimate} 封)" + few_per_day: "每当有新帖子时都向我发送电子邮件(每天大约 2 封)" + warning: "邮寄名单模式已启用。电子邮件通知设置被覆盖。" tag_settings: "标签" watched_tags: "已关注" - watched_tags_instructions: "你将自动关注所有含有这些标签的话题。你会收到所有新帖子和话题的通知,且新帖子的数量也会显示在话题旁边。" + watched_tags_instructions: "您将自动关注所有包含这些标签的话题。您会收到所有新帖子和话题的通知,话题旁还会显示新帖子的数量。" tracked_tags: "已跟踪" - tracked_tags_instructions: "你将自动跟踪所有含有这些标签的话题,新帖数量将会显示在话题旁边。" - muted_tags: "静音" - muted_tags_instructions: "你将不会收到有这些标签的新话题任何通知,它们也不会出现在最新话题中。" + tracked_tags_instructions: "您将自动跟踪带有这些标签的所有话题。新帖子的数量将显示在话题旁边。" + muted_tags: "已设为免打扰" + muted_tags_instructions: "您不会收到有关带有这些标签的新话题的任何通知,也不会出现在最新话题页面上。" watched_categories: "已关注" - watched_categories_instructions: "你将自动关注这些分类中的所有话题,你会收到所有新帖子和新话题的通知,新帖数量也会显示在话题旁边。" + watched_categories_instructions: "您将自动关注这些类别中的所有话题。您会收到所有新帖子和话题的通知,话题旁还会显示新帖子的数量。" tracked_categories: "已跟踪" - tracked_categories_instructions: "你将自动跟踪这些分类中的所有话题,新帖数量将会显示在话题旁边。" - watched_first_post_categories: "关注新贴" - watched_first_post_categories_instructions: "这些分类里面每一个新话题的第一帖会通知你。" - watched_first_post_tags: "关注新贴" - watched_first_post_tags_instructions: "有这些标签的每一个新话题第一帖会通知你。" - muted_categories: "已静音" - muted_categories_instructions: "你不会收到这些分类中任何关于新话题的通知,并且这些新话题也不会出现在分类或最新的页面上。" - muted_categories_instructions_dont_hide: "你将不会收到在这些分类中的新话题通知。" - regular_categories: "活跃用户" - regular_categories_instructions: "你会在“最新”和“热门”话题列表中看到这些分类。" - no_category_access: "无法保存,作为审核者你仅具有受限的分类访问权限" - delete_account: "删除我的账户" - delete_account_confirm: "你真的要永久删除自己的账户吗?删除之后无法恢复!" - deleted_yourself: "你的账户已被删除。" - delete_yourself_not_allowed: "想删除账户请联系管理人员。" + tracked_categories_instructions: "您将自动跟踪这些类别中的所有话题。新帖子的数量将显示在话题旁边。" + watched_first_post_categories: "关注第一个帖子" + watched_first_post_categories_instructions: "您将收到这些类别中每个新话题的第一个帖子的通知。" + watched_first_post_tags: "关注第一个帖子" + watched_first_post_tags_instructions: "您将收到带有这些标签的每个新话题的第一个帖子的通知。" + muted_categories: "已设为免打扰" + muted_categories_instructions: "您不会收到有关这些类别中新话题的任何通知,也不会出现在类别或最新话题页面上。" + muted_categories_instructions_dont_hide: "您不会收到有关这些类别中新话题的任何通知。" + regular_categories: "常规" + regular_categories_instructions: "您将在“最新”和“热门”话题列表中看到这些类别。" + no_category_access: "作为版主,您具有有限的类别访问权限,保存已被禁用。" + delete_account: "删除我的帐户" + delete_account_confirm: "确定要永久删除您的帐户吗?此操作无法撤消!" + deleted_yourself: "您的帐户已被成功删除。" + delete_yourself_not_allowed: "如果您希望删除您的帐户,请联系管理人员。" unread_message_count: "消息" admin_delete: "删除" users: "用户" - muted_users: "已静音" - muted_users_instructions: "屏蔽来自这些用户的所有通知以及私信。" + muted_users: "已设为免打扰" + muted_users_instructions: "屏蔽来自这些用户的所有通知和私信。" allowed_pm_users: "已允许" allowed_pm_users_instructions: "仅允许来自这些用户的私信。" - allow_private_messages_from_specific_users: "仅允许特定用户向我发送私信" + allow_private_messages_from_specific_users: "仅允许特定用户向我发送个人消息" ignored_users: "已忽略" - ignored_users_instructions: "屏蔽这些用户的所有帖子、通知和私信。" + ignored_users_instructions: "屏蔽来自这些用户的所有帖子、通知和私信。" tracked_topics_link: "显示" - automatically_unpin_topics: "当我完整阅读了话题后自动取消置顶。" + automatically_unpin_topics: "当我到达底部时自动取消固定话题。" apps: "应用" - revoke_access: "撤销访问权限" - undo_revoke_access: "重做撤销访问权限" + revoke_access: "撤消访问权限" + undo_revoke_access: "撤消撤消访问权限" api_approved: "已批准:" - api_last_used_at: "最后使用于:" + api_last_used_at: "最后使用时间:" theme: "主题" - save_to_change_theme: '主题会在你点击“%{save_text}”后被更新。' - home: "默认主页" + save_to_change_theme: '主题会在您点击“%{save_text}”后更新。' + home: "默认首页" staged: "暂存" staff_counters: - flags_given: "采纳标记" - flagged_posts: "被标记" - deleted_posts: "已删除" + flags_given: "有用的举报" + flagged_posts: "被举报的帖子" + deleted_posts: "已删除的帖子" suspensions: "封禁" warnings_received: "警告" - rejected_posts: "被驳回的帖子" + rejected_posts: "被拒绝的帖子" messages: all: "所有" inbox: "收件箱" @@ -1048,334 +1035,334 @@ zh_CN: bulk_select: "选择消息" move_to_inbox: "移动到收件箱" move_to_archive: "归档" - failed_to_move: "移动选中消息失败(可能你的网络出问题了)" + failed_to_move: "无法移动所选消息(可能您的网络已掉线)" select_all: "全选" tags: "标签" preferences_nav: - account: "账户" - security: "安全" - profile: "个人信息" - emails: "邮件" + account: "帐户" + security: "安全性" + profile: "个人资料" + emails: "电子邮件" notifications: "通知" - categories: "分类" + categories: "类别" users: "用户" tags: "标签" interface: "界面" apps: "应用" change_password: - success: "(邮件已发送)" - in_progress: "(正在发送邮件)" + success: "(电子邮件已发送)" + in_progress: "(正在发送电子邮件)" error: "(错误)" - emoji: "挂锁表情符号" - action: "发送密码重置邮件" + emoji: "锁定表情符号" + action: "发送密码重置电子邮件" set_password: "设置密码" - choose_new: "输入新密码" - choose: "输入密码" + choose_new: "选择一个新密码" + choose: "选择一个密码" second_factor_backup: - title: "双重认证备份码" + title: "双重备份代码" regenerate: "重新生成" disable: "禁用" enable: "启用" - enable_long: "启用备份码" + enable_long: "启用备份代码" manage: - other: "管理备份码,你还剩下 %{count} 个可用的备份码。" + other: "管理备份代码。您还有 %{count} 个备份代码。" copy_to_clipboard: "复制到剪贴板" copy_to_clipboard_error: "复制到剪贴板时出错" copied_to_clipboard: "已复制到剪贴板" - download_backup_codes: "下载备份码" + download_backup_codes: "下载备份代码" remaining_codes: - other: "你还剩下 %{count} 个可用的备份码。" - use: "使用备份码" - enable_prerequisites: "在生成备份码之前你必须选用一个双重验证方式。" + other: "您还有 %{count} 个备份代码。" + use: "使用备份代码" + enable_prerequisites: "您必须先启用一个主要双重方法,然后才能生成备份代码。" codes: - title: "备份码生成" - description: "每个备份码只能使用一次,请存放于安全可读的地方。" + title: "已生成备份代码" + description: "每个备份代码只能使用一次。请将其存放于安全但易于存取的地方。" second_factor: - title: "双重认证" - enable: "管理双重认证" + title: "双重身份验证" + enable: "管理双重身份验证" disable_all: "全部禁用" - forgot_password: "忘记密码?" - confirm_password_description: "请确认密码后继续" + forgot_password: "忘记密码了?" + confirm_password_description: "请确认您的密码以继续" name: "名称" label: "代码" - rate_limit: "请等待另一个验证码。" + rate_limit: "请稍候,然后再尝试其他身份验证代码。" enable_description: | - 使用我们支持的应用 (Android – iOS) 扫描此二维码并输入您的授权码。 - disable_description: "请输入来自应用的验证码" + 在支持的应用 (Android – iOS) 中扫描此 QR 码并输入您的身份验证代码。 + disable_description: "请输入应用中的身份验证代码" show_key_description: "手动输入" short_description: | - 使用一次性安全码保护你的账户。 + 使用一次性安全码保护您的帐户。 extended_description: | - 双重验证要求你的密码之外的一次性令牌,从而为你的账户增加额外的安全性。可以在Android和iOS 设备上生成令牌。 - oauth_enabled_warning: "请注意,一旦你的账户启用了双重验证,社交登录将被停用。" + 双重身份验证会要求您提供除密码之外的一次性令牌,从而为您的帐户增加额外的安全性。可以在 Android 和 iOS 设备上生成令牌。 + oauth_enabled_warning: "请注意,一旦您为帐户启用双重身份验证,社交登录将被禁用。" use: "使用身份验证器应用" - enforced_notice: "在访问站点之前,你需要启用双重认证。" + enforced_notice: "在访问此站点之前,您需要启用双重身份验证。" disable: "禁用" - disable_confirm: "你确定要禁用所有的双重认证措施吗?" + disable_confirm: "确定要禁用所有的双重方法吗?" save: "保存" edit: "编辑" - edit_title: "添加认证器" - edit_description: "认证器名称" + edit_title: "添加身份验证器" + edit_description: "身份验证器名称" enable_security_key_description: | - 当你准备好物理安全密钥后,请按下面的“注册”按钮。 + 准备好硬件安全密钥后,请按下面的“注册”按钮。 totp: title: "基于令牌的身份验证器" add: "添加身份验证器" default_name: "我的身份验证器" - name_and_code_required_error: "你必须提供你的身份验证器应用的名称和代码。" + name_and_code_required_error: "您必须提供名称和身份验证器应用中的代码。" security_key: register: "注册" title: "安全密钥" add: "添加安全密钥" default_name: "主要安全密钥" not_allowed_error: "安全密钥注册过程已超时或被取消。" - already_added_error: "你已注册此安全密钥,无需再次注册。" + already_added_error: "您已注册此安全密钥,无需再次注册。" edit: "编辑安全密钥" save: "保存" edit_description: "安全密钥名称" - name_required_error: "你必须提供安全密钥的名称。" + name_required_error: "您必须为您的安全密钥提供一个名称。" change_about: - title: "更改个人信息" - error: "修改时出错了" + title: "更改自我介绍" + error: "更改此值时出错。" change_username: - title: "更换用户名" - confirm: "你确定要更改用户名吗?" - taken: "抱歉,此用户名已经有人使用了。" - invalid: "此用户名不合法,用户名只能包含字母和数字" + title: "更改用户名" + confirm: "确定要更改您的用户名吗?" + taken: "抱歉,该用户名已被使用。" + invalid: "该用户名无效,用户名只能包含字母和数字" add_email: - title: "添加邮箱" + title: "添加电子邮件" add: "添加" change_email: - title: "更换邮箱" - taken: "抱歉,此邮箱不可用。" - error: "修改你的邮箱时出错了,可能邮箱已经被使用了?" - success: "我们已经发送了一封确认信到该邮箱,请按照邮箱内指示完成确认。" - success_via_admin: "我们已经向该地址发送了一封邮件。请按照邮件中的说明完成确认。" - success_staff: "我们已经发送了一封确认信到你现在的邮箱,请按照邮件内指示完成确认。" + title: "更改电子邮件" + taken: "抱歉,该电子邮件不可用。" + error: "更改您的电子邮件时出错。也许该地址已被使用?" + success: "我们已向该地址发送一封电子邮件。请按照确认说明进行操作。" + success_via_admin: "我们已向该地址发送一封电子邮件。用户需要按照电子邮件中的确认说明进行操作。" + success_staff: "我们已向您当前的地址发送一封电子邮件。请按照确认说明进行操作。" change_avatar: - title: "更换头像" + title: "更改个人资料照片" gravatar: "%{gravatarName},基于" - gravatar_title: "在 %{gravatarName} 网站修改你的头像" - gravatar_failed: "我们无法找到此电子邮件的 %{gravatarName}。" - refresh_gravatar_title: "刷新你的 %{gravatarName}" - letter_based: "默认头像" + gravatar_title: "在 %{gravatarName} 的网站上更改您的头像" + gravatar_failed: "我们无法找到使用该电子邮件地址的 %{gravatarName}。" + refresh_gravatar_title: "刷新您的 %{gravatarName}" + letter_based: "系统分配的个人资料照片" uploaded_avatar: "自定义图片" uploaded_avatar_empty: "上传自定义图片" upload_title: "上传图片" - image_is_not_a_square: "注意:图片不是正方形的,我们裁剪了部分图像。" - logo_small: "网站的小徽标,默认情况下使用。" + image_is_not_a_square: "警告:我们已经裁剪了您的图片;宽度和高度不相等。" + logo_small: "网站的小徽标。默认情况下使用。" change_profile_background: - title: "个人资料顶部" - instructions: "个人资料的顶部会被居中显示且默认宽度为1110px。" + title: "个人资料标题" + instructions: "个人资料标题将居中且默认宽度为 1110px。" change_card_background: title: "用户卡片背景" - instructions: "显示在用户卡片中,上传的图片将被居中且默认宽度为 590px。" + instructions: "背景图片将居中且默认宽度为 590px。" change_featured_topic: title: "精选话题" - instructions: "此话题的链接会显示在你的用户卡片和资料中。" + instructions: "此话题的链接将显示在您的用户卡片和个人资料中。" email: - title: "邮箱" - primary: "主要邮箱" - secondary: "次要邮箱" - primary_label: "主要" + title: "电子邮件" + primary: "常用电子邮件" + secondary: "辅助电子邮件" + primary_label: "常用" unconfirmed_label: "未确认" - resend_label: "重新发送确认邮件" - resending_label: "发送中..." - resent_label: "邮件已发送" - update_email: "更换邮箱" - set_primary: "设置为主要邮箱" - destroy: "删除邮箱" - add_email: "添加次要邮箱" - auth_override_instructions: "身份验证提供商可以更新电子邮件。" - no_secondary: "没有次要邮箱" - instructions: "绝不会被公开显示" - admin_note: "注意:一位管理员用户更改另一位非管理员用户的电子邮件,则表明该用户已失去了其原始电子邮件帐户的访问权限,因此重置密码的电子邮件将发送到其新邮箱。在用户完成重置密码流程之前,用户的电子邮件不会被更改。" - ok: "将通过邮件验证确认" - required: "请输入一个邮箱地址" - invalid: "请填写正确的邮箱地址" - authenticated: "你的邮箱已被 %{provider} 验证过了" + resend_label: "重新发送确认电子邮件" + resending_label: "正在发送…" + resent_label: "电子邮件已发送" + update_email: "更改电子邮件" + set_primary: "设置常用电子邮件" + destroy: "移除电子邮件" + add_email: "添加备用电子邮件" + auth_override_instructions: "可以通过身份验证服务提供商更新电子邮件。" + no_secondary: "没有辅助电子邮件" + instructions: "绝不会向公众显示。" + admin_note: "注意:管理员用户更改非管理员用户的电子邮件,则说明后者无法访问其原始电子邮件帐户,因此将向后者的新地址发送一封重置密码的电子邮件。在用户完成密码重置流程之前,他们的电子邮件不会更改。" + ok: "我们将向您发送电子邮件以确认" + required: "请输入一个电子邮件地址" + invalid: "请输入有效的电子邮件地址" + authenticated: "您的电子邮件已被 %{provider} 验证" invite_auth_email_invalid: "您的邀请电子邮件与 %{provider} 验证的电子邮件不匹配" - authenticated_by_invite: "你的邮箱已被此邀请验证过了" - frequency_immediately: "如果你没有阅读过摘要邮件中的相关内容,将立即发送电子邮件给你。" + authenticated_by_invite: "您的电子邮件已被此邀请验证" + frequency_immediately: "如果您还没有阅读我们通过电子邮件发送给您的内容,我们会立即给您发送电子邮件。" frequency: - other: "仅在 %{count} 分钟内没有访问时发送邮件给你。" + other: "只有在过去 %{count} 分钟内没有见到您,我们才会向您发送电子邮件。" associated_accounts: - title: "关联账户" + title: "关联帐户" connect: "连接" - revoke: "撤销" + revoke: "撤消" cancel: "取消" - not_connected: "(没有连接)" - confirm_modal_title: "连接 %{provider} 帐号" + not_connected: "(未连接)" + confirm_modal_title: "连接 %{provider} 帐户" confirm_description: - account_specific: "你的 %{provider} 帐号“%{account_description}”会被用作认证。" - generic: "你的 %{provider} 帐号会被用作认证。" + account_specific: "您的 %{provider} 帐户 '%{account_description}' 将被用于身份验证。" + generic: "您的 %{provider} 帐户将被用于身份验证。" name: - title: "名称" - instructions: "你的全名(可选)" - instructions_required: "你的全名" - required: "请输入一个名称" - too_short: "名称过短" - ok: "名称可用" + title: "姓名" + instructions: "您的全名(可选)" + instructions_required: "您的全名" + required: "请输入姓名" + too_short: "您的姓名过短" + ok: "您的姓名没有问题" username: title: "用户名" instructions: "独一无二,没有空格,简短" - short_instructions: "其他人可以用 @%{username} 来提及你" - available: "用户名可用" - not_available: "不可用,试试 %{suggestion} ?" + short_instructions: "其他人可以使用 @%{username} 来提及您" + available: "您的用户名可用" + not_available: "不可用,试试 %{suggestion}?" not_available_no_suggestion: "不可用" - too_short: "用户名过短" - too_long: "用户名过长" - checking: "查看用户名是否可用…" - prefilled: "邮箱与用户匹配成功" + too_short: "您的用户名过短" + too_long: "您的用户名过长" + checking: "正在检查用户名是否可用…" + prefilled: "电子邮件与此注册用户名匹配" required: "请输入一个用户名" edit: "编辑用户名" locale: title: "界面语言" - instructions: "用户界面语言,将在你刷新页面后改变。" - default: "默认" + instructions: "用户界面语言,将在您刷新页面后更改。" + default: "(默认)" any: "任意" password_confirmation: - title: "重复密码" + title: "再次输入密码" invite_code: title: "邀请码" - instructions: "账户注册需要邀请码" + instructions: "注册帐户需要邀请码" auth_tokens: title: "最近使用的设备" - details: "详情" - log_out_all: "退出所有登录" - not_you: "不是你?" - show_all: "显示所有(%{count})" + details: "详细信息" + log_out_all: "全部退出" + not_you: "不是您?" + show_all: "显示全部 (%{count})" show_few: "显示部分" - was_this_you: "这是你吗?" - was_this_you_description: "如果不是你,我们建议你更改密码并退出所有登录。" - browser_and_device: "%{browser} 在 %{device}" - secure_account: "保护我的账户" - latest_post: "你上次发布了......" + was_this_you: "这是您吗?" + was_this_you_description: "如果不是您,我们建议您更改密码并退出所有登录。" + browser_and_device: "%{browser},在 %{device} 上" + secure_account: "保护我的帐户" + latest_post: "您上次发布了…" device_location: '%{device} – %{location}' browser_active: '%{browser} | 正在使用' browser_last_seen: "%{browser} | %{date}" last_posted: "最后发帖" last_emailed: "最后邮寄" last_seen: "最后活动" - created: "加入时间" - log_out: "登出" - location: "地点" - website: "网址" - email_settings: "邮箱" + created: "加入日期:" + log_out: "退出" + location: "位置" + website: "网站" + email_settings: "电子邮件" hide_profile_and_presence: "隐藏我的公开个人资料和状态功能" enable_physical_keyboard: "在 iPad 上启用物理键盘支持" text_size: title: "文本大小" smallest: "最小" smaller: "更小" - normal: "普通" + normal: "正常" larger: "更大" largest: "最大" title_count_mode: title: "背景页面标题显示数量:" notifications: "新通知" - contextual: "新建页面内容" + contextual: "新页面内容" like_notification_frequency: title: "被赞时通知" always: "始终" - first_time_and_daily: "每天帖子首个被赞" + first_time_and_daily: "每日帖子第一次被赞" first_time: "帖子第一次被赞" never: "从不" email_previous_replies: - title: "邮件底部包含历史回复" - unless_emailed: "之前未发送过" + title: "在电子邮件底部包含以前的回复" + unless_emailed: "除非之前发送过" always: "始终" never: "从不" email_digests: - title: "长期未访问时发送热门话题和回复的摘要邮件" - every_30_minutes: "每半小时" + title: "当我不访问这里时,向我发送热门话题和回复的电子邮件摘要" + every_30_minutes: "每 30 分钟" every_hour: "每小时" daily: "每天" weekly: "每周" every_month: "每月" every_six_months: "每六个月" email_level: - title: "当有人引用和回复我的帖子、@我或邀请我至话题时,给我发送邮件" + title: "当有人引用我的话、回复我的帖子、提及我的用户名或邀请我参加某个话题时,给我发送电子邮件" always: "始终" only_when_away: "只在离开时" never: "从不" - email_messages_level: "有人给我发送消息时给我发送邮件" - include_tl0_in_digests: "摘要邮件中包含新用户的内容" - email_in_reply_to: "在邮件中包含回复内容的节选" - other_settings: "其它" - categories_settings: "分类" + email_messages_level: "当有人给我发消息时给我发送电子邮件" + include_tl0_in_digests: "在摘要电子邮件中包含来自新用户的内容" + email_in_reply_to: "在电子邮件中包含帖子回复节选" + other_settings: "其他" + categories_settings: "类别" new_topic_duration: - label: "何时被当作新话题" - not_viewed: "我没有看过的" - last_here: "上次访问后发布" - after_1_day: "一天内发布" - after_2_days: "两天内发布" - after_1_week: "一周内发布" - after_2_weeks: "两周内发布" - auto_track_topics: "自动跟踪我浏览的话题" + label: "何时视为新话题" + not_viewed: "我还没看过" + last_here: "在我上次访问后创建" + after_1_day: "在过去一天内创建" + after_2_days: "在过去 2 天内创建" + after_1_week: "在过去一周内创建" + after_2_weeks: "在过去 2 周内创建" + auto_track_topics: "自动跟踪我进入的话题" auto_track_options: never: "从不" immediately: "立即" - after_30_seconds: "30秒后" - after_1_minute: "1分钟后" - after_2_minutes: "2分钟后" - after_3_minutes: "3分钟后" - after_4_minutes: "4分钟后" - after_5_minutes: "5分钟后" - after_10_minutes: "10分钟后" - notification_level_when_replying: "当我在话题中回复后,将话题设置至" + after_30_seconds: "30 秒后" + after_1_minute: "1 分钟后" + after_2_minutes: "2 分钟后" + after_3_minutes: "3 分钟后" + after_4_minutes: "4 分钟后" + after_5_minutes: "5 分钟后" + after_10_minutes: "10 分钟后" + notification_level_when_replying: "当我在话题中发帖后,将该话题设置为" invited: title: "邀请" - pending_tab: "待确认" - pending_tab_with_count: "待确认(%{count})" + pending_tab: "待处理" + pending_tab_with_count: "待处理 (%{count})" expired_tab: "已过期" - expired_tab_with_count: "已过期(%{count})" + expired_tab_with_count: "已过期 (%{count})" redeemed_tab: "已确认" - redeemed_tab_with_count: "已确认(%{count})" + redeemed_tab_with_count: "已确认 (%{count})" invited_via: "邀请" - invited_via_link: "链接 %{key}(%{count} / %{max} 已激活)" + invited_via_link: "链接 %{key}(已确认 %{count}/%{max})" groups: "群组" topic: "话题" sent: "创建/发送时间" - expires_at: "过期" + expires_at: "到期时间" edit: "编辑" remove: "移除" copy_link: "获取链接" reinvite: "重新发送电子邮件" - reinvited: "邀请已重新发送" + reinvited: "已重新发送邀请" removed: "已移除" search: "输入以搜索邀请…" user: "邀请用户" - none: "无邀请显示。" + none: "没有要显示的邀请。" truncated: - other: "只显示前 %{count} 个邀请。" - redeemed: "确认邀请" + other: "正在显示前 %{count} 个邀请。" + redeemed: "已确认的邀请" redeemed_at: "已确认" - pending: "待确认邀请" - topics_entered: "已查看话题" + pending: "待处理邀请" + topics_entered: "已浏览话题" posts_read_count: "已读帖子" - expired: "邀请已过期。" + expired: "此邀请已过期。" remove_all: "移除过期的邀请" removed_all: "已移除所有已过期的邀请!" - remove_all_confirm: "你确定要移除所有已过期的邀请吗?" + remove_all_confirm: "确定要移除所有已过期的邀请吗?" reinvite_all: "重新发送所有邀请" - reinvite_all_confirm: "确定要重发这些邀请吗?" - reinvited_all: "所有的邀请都已发出!" + reinvite_all_confirm: "确定要重新发送所有邀请吗?" + reinvited_all: "已发送所有邀请!" time_read: "阅读时间" days_visited: "访问天数" - account_age_days: "账户建立天数" + account_age_days: "帐户启用时长(天)" create: "邀请" generate_link: "创建邀请链接" - link_generated: "这是你的邀请链接!" - valid_for: "邀请链接只对这个邮件地址有效:%{email}" + link_generated: "这是您的邀请链接!" + valid_for: "邀请链接只对此电子邮件地址有效:%{email}" single_user: "通过电子邮件邀请" multiple_user: "通过链接邀请" invite_link: title: "邀请链接" - success: "邀请链接生成成功!" + success: "邀请链接已成功生成!" error: "生成邀请链接时出错" - max_redemptions_allowed_label: "允许多少人通过这个链接注册?" - expires_at: "邀请链接多久失效?" + max_redemptions_allowed_label: "允许多少人通过此链接注册?" + expires_at: "此邀请链接何时到期?" invite: new_title: "创建邀请" edit_title: "编辑邀请" @@ -1390,42 +1377,42 @@ zh_CN: none: "此页面没有要显示的邀请。" text: "批量邀请" instructions: | -

    邀请用户可以加速你的社区的发展,准备一个 CSV 文件,每行至少含有一个你想要邀请的邮件地址。如果你想要将他们添加到群组或者在首次登录时引导至特定的话题中,下列由逗号分隔的信息可选填。

    +

    邀请用户以快速壮大您的社区。准备一个 CSV 文件,每行至少包含一个您想要邀请的电子邮件地址。如果您想要将他们添加到群组或者在他们首次登录时将其引导至特定话题,可以提供由逗号分隔的信息,如下所示。

    john@smith.com,first_group_name;second_group_name,topic_id
    -

    你所上传的 CSV 文件中的每一个电子邮件地址都将被发送一个邀请,你可以稍后进行管理。

    - progress: "已上传 %{progress}%..." - success: "文件上传成功,操作完成时会通过私信通知你。" - error: "抱歉,文件必须是CSV格式。" +

    将向您上传的 CSV 文件中的每一个电子邮件地址发送邀请,您可以稍后进行管理。

    + progress: "已上传 %{progress}%…" + success: "文件上传成功。该过程完成后,您将收到消息通知。" + error: "抱歉,文件应为 CSV 格式。" password: title: "密码" - too_short: "密码过短" - common: "密码过于常见" - same_as_username: "密码不能与用户名相同" - same_as_email: "密码不能与邮箱相同" - ok: "密码符合要求" + too_short: "您的密码过短。" + common: "该密码过于常见。" + same_as_username: "您的密码与您的用户名相同。" + same_as_email: "您的密码与您的电子邮件相同。" + ok: "您的密码没有问题。" instructions: "至少 %{count} 个字符" - required: "请输入一个密码" + required: "请输入密码" summary: - title: "概要" - stats: "统计" + title: "摘要" + stats: "统计信息" time_read: "阅读时间" - recent_time_read: "近期阅读时间" + recent_time_read: "最近阅读时间" topic_count: other: "已创建话题" post_count: - other: "已发表帖子" + other: "已创建帖子" likes_given: - other: "已发出" + other: "已送出" likes_received: other: "已收到" days_visited: other: "访问天数" topics_entered: - other: "已阅话题" + other: "已浏览话题" posts_read: other: "已读帖子" bookmark_count: - other: "收藏" + other: "书签" top_replies: "热门回复" no_replies: "暂无回复。" more_replies: "更多回复" @@ -1441,7 +1428,7 @@ zh_CN: most_liked_users: "赞谁最多" most_replied_to_users: "最多回复至" no_likes: "暂无赞。" - top_categories: "热门分类" + top_categories: "热门类别" topics: "话题" replies: "回复" ip_address: @@ -1449,10 +1436,10 @@ zh_CN: registration_ip_address: title: "注册 IP 地址" avatar: - title: "头像" - header_title: "个人信息、消息、收藏和设置" + title: "个人资料照片" + header_title: "个人资料、消息、书签和偏好设置" name_and_description: "%{name} - %{description}" - edit: "编辑个人资料图片" + edit: "编辑个人资料照片" title: title: "头衔" none: "(无)" @@ -1462,62 +1449,62 @@ zh_CN: filters: all: "全部" stream: - posted_by: "发送人" - sent_by: "发送时间" - private_message: "私信" - the_topic: "本话题" - loading: "载入中…" + posted_by: "发帖人" + sent_by: "发送人" + private_message: "消息" + the_topic: "话题" + loading: "正在加载…" errors: - prev_page: "无法载入" + prev_page: "尝试加载时" reasons: network: "网络错误" - server: "服务器出错" - forbidden: "禁止访问" + server: "服务器错误" + forbidden: "访问被拒绝" unknown: "错误" - not_found: "页面不存在" + not_found: "找不到页面" desc: - network: "请检查网络状态" - network_fixed: "网络似乎恢复正常了" + network: "请检查您的连接。" + network_fixed: "网络似乎已恢复正常" server: "错误代码:%{status}" - forbidden: "好像不能进行此操作" - not_found: "没有这个页面" - unknown: "出了点小问题" + forbidden: "您无权查看。" + not_found: "糟糕,应用程序试图加载一个不存在的 URL。" + unknown: "出了点问题。" buttons: back: "返回" again: "重试" - fixed: "载入" + fixed: "加载页面" modal: close: "关闭" dismiss_error: "忽略错误" close: "关闭" - assets_changed_confirm: "该站点刚才收到了一个软件更新,是否马上获取最新版本?" - logout: "你已登出。" + assets_changed_confirm: "此站点刚才收到了一个软件更新。是否立即获取最新版本?" + logout: "您已退出。" refresh: "刷新" home: "首页" read_only_mode: - enabled: "站点处于只读模式,你可以继续浏览,但是回复、点赞和其它操作暂时被禁用。" - login_disabled: "只读模式下不允许登录。" - logout_disabled: "站点处于只读模式时退出登录被禁用。" + enabled: "此站点处于只读模式。您可以继续浏览,但是回复、点赞和其他操作暂时被禁用。" + login_disabled: "当站点处于只读模式时,登录被禁用。" + logout_disabled: "当站点处于只读模式时,退出被禁用。" too_few_topics_and_posts_notice_MF: >- - 让我们开始讨论吧!现 {currentTopics, plural, one {仅有 # 个话题} other {共有 # 个话题}}和{currentPosts, plural, one { # 个帖子} other { # 个帖子}}。访客需要更多的阅读和回复——我们建议至少 {requiredTopics, plural, one { # 个话题} other { # 个话题}}和{requiredPosts, plural, one { # 个帖子} other { # 个帖子}}。此消息仅管理人员可见。 + 让我们开始讨论吧!现在有 {currentTopics, plural, one {# 个话题} other {# 个话题}}和 {currentPosts, plural, one {# 个帖子} other {# 个帖子}}。访客需要阅读和回复更多 - 我们建议至少 {requiredTopics, plural, one {# 个话题} other {# 个话题}}和 {requiredPosts, plural, one {# 个帖子} other {# 个帖子}}。只有管理人员可以看到此消息。 too_few_topics_notice_MF: >- - 让我们开始讨论吧!现{currentTopics, plural, one {仅有# 个话题} other { 共有 # 个话题}}。访客需要更多的阅读和回复——我们建议至少 {requiredTopics, plural, one {#个话题} other {# 个话题}}。此消息仅管理人员可见。 + 让我们开始讨论吧!现在有 {currentTopics, plural, one {# 个话题} other {# 个话题}}。访客需要阅读和回复更多 - 我们建议至少 {requiredTopics, plural, one {# 个话题} other {# 个话题}}。只有管理人员可以看到此消息。 too_few_posts_notice_MF: >- - 让我们开始讨论吧!现 {currentPosts, plural, one {仅有 # 个帖子} other {共有 # 个帖子}}。访客需要更多的阅读和回复——我们建议至少 {requiredPosts, plural, one {# 个帖子} other {# 个帖子}}。此消息仅管理人员可见。 + 让我们开始讨论吧!现在有 {currentPosts, plural, one {# 个帖子} other {# 个帖子}}。访客需要阅读和回复更多 - 我们建议至少 {requiredPosts, plural, one {# 个帖子} other {# 个帖子}}。只有管理人员可以看到此消息。 logs_error_rate_notice: - reached_hour_MF: "{relativeAge} – {rate, plural, one {# 错误/小时} other {# 错误/小时}}达到了站点设置中的限制{limit, plural, one {# error/hour} other {# errors/hour}}。" - reached_minute_MF: "{relativeAge}1 – {rate, plural, one {# 错误/分钟} other {# 错误/分钟}}已经达到站点设置限制 {limit, plural, one {# 错误/分钟} other {# 错误/分钟}}。" - exceeded_hour_MF: "{relativeAge} – {rate, plural, one {# 错误/小时} other {# 错误/小时}}超出了站点设置中的限制{limit, plural, one {# 错误/小时} other {# 错误/小时}}。" - exceeded_minute_MF: "{relativeAge}1 – {rate, plural, one {# 错误/分钟} other {# 错误/分钟}}已经超出站点设置限制 {limit, plural, one {# 错误/分钟} other {# 错误/分钟}}。" + reached_hour_MF: "{relativeAge} – {rate, plural, one {# 个错误/小时} other {# 个错误/小时}}达到了站点设置中 {limit, plural, one {# 个错误/小时} other {# 个错误/小时}}的限制。" + reached_minute_MF: "{relativeAge} – {rate, plural, one {# 个错误/分钟} other {# 个错误/分钟}}达到了站点设置中 {limit, plural, one {# 个错误/分钟} other {# 个错误/分钟}}的限制。" + exceeded_hour_MF: "{relativeAge} – {rate, plural, one {# 个错误/小时} other {# 个错误/小时}}达到了站点设置中 {limit, plural, one {# 个错误/小时} other {# 个错误/小时}}的限制。" + exceeded_minute_MF: "{relativeAge} – {rate, plural, one {# 个错误/分钟} other {# 个错误/分钟}}超过了站点设置中 {limit, plural, one {# 个错误/分钟} other {# 个错误/分钟}}的限制。" learn_more: "了解更多…" - first_post: 最早帖子 - mute: 静音 - unmute: 取消静音 - last_post: 最后发帖 - local_time: "本地时间" + first_post: 第一个帖子 + mute: 免打扰 + unmute: 取消免打扰 + last_post: 已发帖 + local_time: "当地时间" time_read: 阅读 time_read_recently: "最近 %{time_read}" - time_read_tooltip: "合计阅读时间 %{time_read}" + time_read_tooltip: "总阅读时间 %{time_read}" time_read_recently_tooltip: "总阅读时间 %{time_read}(最近60天 %{recent_time_read})" last_reply_lowercase: 最后回复 replies_lowercase: @@ -1526,8 +1513,8 @@ zh_CN: sign_up: "注册" hide_session: "明天提醒我" hide_forever: "不,谢谢" - hidden_for_session: "好的,我们会在明天提醒你,不过你随时都可以使用“登录”来创建账户。" - intro: "你好!看起来你正在享受讨论,但还没有注册一个账户。" + hidden_for_session: "好的,我们会在明天问你。您也可以随时使用“登录”来创建帐户。" + intro: "您好!看起来您很喜欢讨论,但您还没有注册帐户。" value_prop: "当你创建了账户,我们就可以准确地记录你的阅读进度,你再次访问时就可以回到之前离开的地方。当有人回复你,你可以通过这里或电子邮件收到通知,并且你还可以通过点赞帖子向他人分享你的喜爱之情。:heartpulse:" summary: enabled_description: "你正在查看话题的精简摘要版本:一些社区公认有意思的帖子。" @@ -1601,7 +1588,7 @@ zh_CN: second_factor_description: "请输入来自应用的验证码:" second_factor_backup: "使用备用码登录" second_factor_backup_title: "双重认证备份" - second_factor_backup_description: "请输入你的备份码:" + second_factor_backup_description: "请输入你的备份代码:" second_factor: "使用身份验证器应用登录" security_key_description: "当你准备好物理安全密钥后,请按下面的“使用安全密钥进行身份验证”按钮。" security_key_alternative: "尝试另一种方式" @@ -1657,7 +1644,7 @@ zh_CN: title: "Discord 登录" second_factor_toggle: totp: "改用身份验证应用" - backup_code: "使用备份码" + backup_code: "使用备份代码" invites: accept_title: "邀请" emoji: "信封表情符号" @@ -1956,7 +1943,7 @@ zh_CN: linked: "链接" bookmark_reminder: "收藏提醒" bookmark_reminder_with_name: "收藏提醒 - %{name}" - granted_badge: "勋章授予" + granted_badge: "已授予徽章" invited_to_topic: "邀请到话题" group_mentioned: "群组提及" group_message_summary: "新建群组消息" @@ -2135,7 +2122,7 @@ zh_CN: filter_to: other: "话题中 %{count} 个帖子" create: "新建话题" - create_long: "创建新的话题" + create_long: "创建新话题" open_draft: "打开草稿" private_message: "开始发私信" archive_message: @@ -2402,7 +2389,7 @@ zh_CN: title: "打印" help: "打开该话题的印刷版本" flag_topic: - title: "标记" + title: "举报" help: "私下标记此话题以警告,或发送一个关于它的私人通知" success_message: "你已经成功标记该话题。" make_public: @@ -2560,7 +2547,7 @@ zh_CN: quote_share: "分享" edit_reason: "理由:" post_number: "帖子 %{number}" - ignored: "已忽略内容" + ignored: "已忽略的内容" wiki_last_edited_on: "维基最后编辑于 %{dateTime}" last_edited_on: "帖子最后编辑于 %{dateTime}" reply_as_new_topic: "回复为联结话题" @@ -2568,7 +2555,7 @@ zh_CN: continue_discussion: "自 %{postLink} 继续讨论:" follow_quote: "转到所引用的帖子" show_full: "显示完整帖子" - show_hidden: "显示已忽略内容。" + show_hidden: "查看已忽略的内容。" deleted_by_author_simple: "(帖子已被作者删除)" collapse: "折叠" expand_collapse: "展开/折叠" @@ -3032,7 +3019,7 @@ zh_CN: lower_title: "近期" title: "近期" title_with_count: - other: "近期(%{count})" + other: "新 (%{count})" help: "最近几天里创建的话题" posted: title: "我的帖子" @@ -3167,7 +3154,7 @@ zh_CN: badges: earned_n_times: other: "已获得此徽章 %{count} 次" - granted_on: "授予于%{date}" + granted_on: "%{date}授予" others_count: "其他有该徽章的人(%{count})" title: 徽章 allow_title: "你可以将该徽章设为头衔" @@ -3440,8 +3427,6 @@ zh_CN: available: "群组名可用" not_available: "群组名不可用" blank: "群组名不能为空" - add_members: - as_owner: "设置用户为此群组拥有者" manage: interaction: email: 邮箱 @@ -4402,7 +4387,7 @@ zh_CN: like_count: 点赞 / 获赞 last_100_days: "在最近 100 天" private_topics_count: 私有话题数量 - posts_read_count: 已阅帖子数量 + posts_read_count: 已阅帖子 post_count: 发表的帖子数量 second_factor_enabled: 双重认证已启用 topics_entered: 已查看的话题数量 diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index 51d302fb0b..2cda68fc50 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -164,7 +164,6 @@ zh_TW: submit: "送出" generic_error: "抱歉,發生錯誤。" generic_error_with_reason: "發生錯誤: %{error}" - go_ahead: "下一步" sign_up: "註冊" log_in: "登入" age: "已建立" @@ -191,7 +190,6 @@ zh_TW: more: "更多" x_more: other: "%{count} 更多" - less: "較少" never: "永不" every_30_minutes: "每 30 分鐘" every_hour: "每小時" @@ -200,7 +198,6 @@ zh_TW: every_month: "每個月" every_six_months: "每半年" max_of_count: "(最大 %{count})" - alternation: "或" character_count: other: "%{count} 個字元" related_messages: @@ -231,7 +228,6 @@ zh_TW: help: bookmark: "點擊以將此話題的第一篇貼文加入書籤" unbookmark: "點擊以移除此話題所有書籤" - unbookmark_with_reminder: "按一下以移除本主題中的所有書籤和提醒事項。此主題的提醒設定為 %{reminder_at} 。" bookmarks: not_bookmarked: "將此貼文加入書籤" remove: "移除書籤" @@ -463,9 +459,6 @@ zh_TW: groups: member_added: "已新增" member_requested: "請求" - add_members: - usernames: - input_placeholder: "使用者名稱" requests: title: "請求" reason: "原因" @@ -479,7 +472,7 @@ zh_TW: title: "管理" name: "名稱" full_name: "全名" - add_members: "新增成員" + invite_members: "邀請" delete_member_confirm: "確定要從群組「\\b%{group}」中移除「%{username}'」嗎?" profile: title: 個人檔案 @@ -2712,8 +2705,6 @@ zh_TW: available: "可以使用此群組名稱" not_available: "無法使用此群組名稱" blank: "群組名稱不能空白" - add_members: - as_owner: "將使用者設為此群組的擁有者" manage: interaction: email: Email diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index f5bc778f4c..9ba0583b9b 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -1045,7 +1045,6 @@ ar: tl4_additional_likes_per_day_multiplier: "زيادة حد الإعجابات باليوم لـمستوى الثقة 4 (قائد) بالضرب بهذا الرقم" traditional_markdown_linebreaks: "استعمل السطور التالفه التقليديه في Markdown, التي تتطلب مساحتين بيضاوين للسطور التالفه" post_undo_action_window_mins: "عدد الدقائق التي يسمح فيها للأعضاء بالتراجع عن آخر إجراءاتهم على المنشور (إعجاب، اشارة، إلخ...)" - pending_users_reminder_delay: "نبه المشرفين إذا وجد اعضاء ينتظرون الموافقة لمدة اطول من الساعات ، قم بوضع الخيار -1 لايقاف التنبيهات ." maximum_session_age: "سيظل المستخدم مسجل دخول لهذا العدد من الساعات منذ زيارته اﻷخيرة." cors_origins: "اسمح بالأصول للطلبات عبر المنشأ (CORS). كل أصل يجب أن يتضمن http:// أو https://. متغير env لـ DISCOURSE_ENABLE_CORS يجب أن يعين إلى true ليعمل CORS." use_admin_ip_allowlist: "المدير فقط يمكنه تسجيل الدخول إذا كانت عناوين IP لهم معرفة في قائمة IP المحجوبة. (المدير > السجلات > IP المحجوبة)" @@ -1072,7 +1071,7 @@ ar: log_out_strict: "عند الخروج، اخرج من كلّ جلسات المستخدم في كلّ الأجهزة" new_version_emails: "إرسال بريد إلكتروني إلى عنوان contact_email عندما نسخة جديدة من ديسكورس هو متاح." invite_expiry_days: "مدة صلاحية مفاتيح دعوة عضو، بالأيام." - invite_only: "يجب أن تتم دعوة جميع المستخدمين الجُدد بشكلٍ صريح من قِبل المستخدمين الموثوق بهم أو طاقم العمل. التسجيل العام معطَّل." + invite_only: "يجب أن تتم دعوة جميع المستخدمين الجُدد بشكلٍ صريح بواسطة المستخدمين الموثوق بهم أو طاقم العمل. التسجيل العام معطَّل." login_required: "اطلب تسجيل دخول لقرائة محتوي هذا الموقع." min_username_length: "أدنى طول لاسم المستخدم (بالمحارف). تحذير: إن كان هناك أيّة مستخدمين أو مجموعات تملك أسماء أقصر من هذه، فسيعطب الموقع!" min_password_length: "أدنى طول لكلمة السّرّ." diff --git a/config/locales/server.be.yml b/config/locales/server.be.yml index e4bd76309c..450ff3feb3 100644 --- a/config/locales/server.be.yml +++ b/config/locales/server.be.yml @@ -962,7 +962,6 @@ be: traditional_markdown_linebreaks: "Выкарыстоўвайце традыцыйныя разрывы радкоў у Markdown, якія патрабуюць два хваставых прабелаў для разрыву радка." markdown_linkify_tlds: "Спіс даменаў верхняга ўзроўню, якія атрымліваюць аўтаматычна разглядаюцца як спасылкі" post_undo_action_window_mins: "Колькасць хвілін карыстальнікаў дазволена адмяніць апошнія дзеянні на пасадзе (напрыклад, сцяг, і г.д.)." - pending_users_reminder_delay: "Апавяшчаць мадэратараў, калі новыя карыстальнікі чакалі адабрэння даўжэй, чым гэта шмат гадзін. Усталюйце на -1, каб адключыць паведамлення." maximum_session_age: "Карыстальнік будзе заставацца ў сістэме для рускіх гадзін з моманту апошняга наведвання" blocked_ip_blocks: "Спіс прыватных IP-блокаў, якія ніколі не павінны сканіравацца дыскурсе" allowed_internal_hosts: "Спіс ўнутраных хастоў, дыскурс можа бяспечна паўзці для oneboxing і іншых мэтаў" diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml index c141884ff8..e3f94094b0 100644 --- a/config/locales/server.ca.yml +++ b/config/locales/server.ca.yml @@ -1290,7 +1290,6 @@ ca: markdown_typographer_quotation_marks: "Llista de parelles de reemplaçament de cometes simples i dobles" post_undo_action_window_mins: "Nombre de minuts durant els quals es permet als usuaris desfer accions recents sobre una publicació (M'agrada, bandera, etc.)." must_approve_users: "L'equip responsable ha d'aprovar tots els comptes d'usuari nous abans que puguin accedir al lloc web." - pending_users_reminder_delay: "Notifica als moderadors si hi ha usuaris nous esperant l'aprovació més enllà d'aquest nombre d'hores. -1 per a desactivar les notificacions." maximum_session_age: "L'usuari romandrà amb la sessió iniciada durant n hores des de la darrera visita" enable_escaped_fragments: "Torna a l'API Ajax-Crawling de Google si no es detecta cap rastrejador web. Consulteu https://developers.google.com/webmasters/ajax-crawling/docs/learn-more." cors_origins: "Orígens permesos per a peticions d'origen encreuat (CORS). Cada origen ha d'incloure http:// o https://. La variable DISCOURSE_ENABLE_CORS ha de ser \"true\" per a habilitar CORS." diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml index e6f6530dbf..2fa68cb823 100644 --- a/config/locales/server.da.yml +++ b/config/locales/server.da.yml @@ -1423,7 +1423,6 @@ da: must_approve_users: "Personalet skal godkende alle nye brugerkonti, før de får adgang til webstedet." invite_code: "Brugeren skal skrive denne kode for at kunne registrere en konto, ignoreret når den er tom (ingen forskel på store og små bogstaver)" approve_suspect_users: "Føj mistænkelige brugere til gennemgangskøen. Mistænkelige brugere har indtastet en bio/et websted, men har ingen læseaktivitet." - pending_users_reminder_delay: "Underret moderatorer hvis nye brugere har ventet på godkendelse i længere end så mange timer. Skriv -1 for at deaktivere notifikationer." persistent_sessions: "Brugere vil forblive logget ind, når webbrowseren er lukket" maximum_session_age: "Bruger vil forblive logget in for n hours siden sidste besøg" ga_version: "Version af Google Universal Analytics der skal benyttes: v3 (analytics.js), v4 (gtag)" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 0818a36c66..d87efe8c1a 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -46,45 +46,45 @@ de: log_in: "Anmelden" submit: "Absenden" purge_reason: "Verlassenes, deaktiviertes Konto automatisch gelöscht" - disable_remote_images_download_reason: "Der Download von Bildern wurde deaktiviert, weil nicht mehr genug Plattenplatz vorhanden war." + disable_remote_images_download_reason: "Download von Remote-Bildern wurde deaktiviert, da nicht genügend Speicherplatz zur Verfügung stand." anonymous: "Anonym" remove_posts_deleted_by_author: "Gelöscht vom Verfasser" - redirect_warning: "Wir konnten nicht überprüfen, dass der ausgewählte Link tatsächlich im Forum geschrieben wurde. Wenn du trotzdem fortfahren möchtest, wähle den Link unten aus." + redirect_warning: "Wir konnten nicht überprüfen, dass der ausgewählte Link tatsächlich im Forum geteilt wurde. Wenn du trotzdem fortfahren möchtest, wähle den Link unten aus." on_another_topic: "Zu einem anderen Thema" inline_oneboxer: topic_page_title_post_number: "#%{post_number}" topic_page_title_post_number_by_user: "#%{post_number} von %{username}" themes: bad_color_scheme: "Kann Theme nicht aktualisieren, weil die Farbpalette ungültig ist" - other_error: "Etwas ist schief gelaufen beim Aktualisieren des Theme" + other_error: "Beim Aktualisieren des Themes ist ein Fehler aufgetreten" ember_selector_error: "Tut uns leid, die Verwendung von #ember- oder .ember-view-CSS-Selektoren ist nicht zulässig, da diese zur Laufzeit dynamisch generiert werden und sich im Laufe der Zeit ändern werden, was schließlich zu defektem CSS führt. Probiere einen anderen Selektor." compile_error: unrecognized_extension: "Unbekannte Dateiendung: %{extension}" import_error: generic: Beim Importieren dieses Themes ist ein Fehler aufgetreten - about_json: "Importfehler: about.json existiert nicht oder ist ungültig. Sind Sie sicher, dass dies ein Discourse Theme ist?" + about_json: "Importfehler: about.json existiert nicht oder ist ungültig. Bist du dir sicher, dass dies ein Discourse-Theme ist?" about_json_values: "about.json enthält ungültige Werte: %{errors}" - modifier_values: "about.json Modifikatoren enthalten ungültige Werte: %{errors}" - git: "Fehler beim Klonen des git-Repository, Zugriff verboten oder Repository nicht gefunden." + modifier_values: "about.json-Modifikatoren enthalten ungültige Werte: %{errors}" + git: "Fehler beim Klonen des Git-Repository, Zugriff verweigert oder Repository nicht gefunden." git_ref_not_found: "Git-Referenz kann nicht ausgecheckt werden: %{ref}" unpack_failed: "Fehler beim Entpacken der Datei" file_too_big: "Die unkomprimierte Datei ist zu groß." unknown_file_type: "Die Datei, die du hochgeladen hast, scheint kein gültiges Discourse-Theme zu sein." not_allowed_theme: "`%{repo}` ist nicht in der Liste der zulässigen Themes (überprüfe die globale Einstellung `allowed_theme_repos`)." errors: - component_no_user_selectable: "Theme Komponenten können nicht Benutzer auswählbar sein" - component_no_default: "Theme Komponenten können nicht Standard Theme sein" + component_no_user_selectable: "Theme-Komponenten können nicht vom Benutzer auswählbar sein" + component_no_default: "Theme-Komponenten können nicht Standard-Theme sein" component_no_color_scheme: "Theme-Komponenten können keine Farbpalette haben" no_multilevels_components: "Themes mit Unter-Themes können nicht selber Unter-Themes sein" - optimized_link: Optimierte Bild-Verknüpfungen sind kurzlebig und sollten nicht in Theme-Quellcode enthalten sein. + optimized_link: Optimierte Bildlinks sind flüchtig und sollten nicht in den Theme-Quellcode eingebunden werden. settings_errors: invalid_yaml: "Der YAML-Code ist ungültig." - data_type_not_a_number: "Das Setzen des Typs \"%{name}\" wird nicht unterstützt. Unterstützte Typen sind \"Integer\", \"Bool\", \"List\", \"Enum\" und \"Upload\"" + data_type_not_a_number: "Typ der Einstellung `%{name}` wird nicht unterstützt. Unterstützte Typen sind `integer`, `bool`, `list`, `enum` und `upload`" name_too_long: "Es gibt eine Einstellung mit einem zu langen Namen. Die maximale Länge beträgt 255 Zeichen." default_value_missing: "Einstellung `%{name}` hat keinen Standardwert." default_not_match_type: "Bei der Einstellung `%{name}` stimmt der Datentyp des Standardwerts nicht mit dem Datentyp der Einstellung überein." default_out_range: "Bei der Einstellung `%{name}` liegt der Standardwert außerhalb des Wertebereichs." - enum_value_not_valid: "Der ausgewählte Wert gehört nicht zur Wertemenge des ENUM-Aufzählungstyps." + enum_value_not_valid: "Ausgewählter Wert ist nicht eine der „enum“-Auswahlmöglichkeiten." number_value_not_valid: "Der neue Wert liegt außerhalb des erlaubten Wertebereichs." number_value_not_valid_min_max: "Er muss zwischen %{min} und %{max} liegen." number_value_not_valid_min: "Er muss größer oder gleich %{min} sein." @@ -94,36 +94,36 @@ de: string_value_not_valid_min: "Er muss mindestens %{min} Zeichen lang sein." string_value_not_valid_max: "Er darf maximal %{max} Zeichen lang sein." locale_errors: - top_level_locale: "Der Schlüssel auf der höchsten Ebene muss mit dem Locale-Name übereinstimmen." + top_level_locale: "Der Top-Level-Schlüssel in einer Locale-Datei muss mit dem Locale-Namen übereinstimmen" invalid_yaml: "Das YAML der Übersetzung ist ungültig." emails: incoming: default_subject: "Dieses Thema benötigt einen Titel" show_trimmed_content: "Zeige gekürzten Inhalt" - maximum_staged_user_per_email_reached: "Maximale Anzahl vorbereiteter Benutzer erreicht, die automatisch per E-Mail erstellt wurden." + maximum_staged_user_per_email_reached: "Maximale Anzahl vorbereiteter Benutzer erreicht, die per E-Mail erstellt wurden." no_subject: "(kein Betreff)" no_body: "(kein Inhalt)" errors: - empty_email_error: "Passiert wenn die empfangene E-Mail leer war." - no_message_id_error: "Passiert wenn in der E-Mail die Kopfzeile 'Message-Id' fehlt." - auto_generated_email_error: "Passiert wenn die Kopfzeile 'precedence' folgendes enthält: list, junk, bulk oder auto_reply, oder eine der anderen Kopfzeilen enthält: auto-submitted, auto-replied oder auto-generated." + empty_email_error: "Passiert, wenn die empfangene E-Mail leer war." + no_message_id_error: "Passiert, wenn die E-Mail keinen „Message-Id“-Header hat." + auto_generated_email_error: "Passiert, wenn der „precedence“-Header auf list, junk, bulk oder auto_reply gesetzt ist oder wenn irgendein anderer Header auto-submitted, auto-replied oder auto-generated enthält." no_body_detected_error: "Passiert, wenn wir keinen Textkörper extrahieren konnten und keine Anhänge gefunden wurden." - no_sender_detected_error: "Kommt vor, wenn wir keine gültige E-Mail-Adresse im From-Header finden konnten." - from_reply_by_address_error: "Passiert wenn der From-Header mit der Antwort E-Mail-Adresse übereinstimmt." - inactive_user_error: "Passiert wenn der Sender nicht aktiv ist." - silenced_user_error: "Kommt vor, wenn der Absender stummgeschaltet wurde." - bad_destination_address: "Geschieht, wenn keine der E-Mail Adressen in An/CC Feldern einer konfigurierten eingehenden E-Mail Adresse entsprechen." - strangers_not_allowed_error: "Passiert wenn ein Benutzer versuchte ein neues Thema in einer Kategorie ohne Mitgliedschaft zu erstellen." - insufficient_trust_level_error: "Passiert wenn ein Benutzer versuchte ein neues Thema in einer Kategorie zu erstellen, ohne die erforderliche Vertrauensstufe dafür zu haben." - reply_user_not_matching_error: "Passiert wenn die E-Mail-Adresse der Antwort von der Adresse abweicht, an die gesendet wurde." - topic_not_found_error: "Passiert wenn eine Antwort eintraf aber das verbundene Thema gelöscht wurde." - topic_closed_error: "Passiert wenn eine Antwort eintraf aber das verbundene Thema geschlossen wurde." - bounced_email_error: "E-Mail ist ein zurückgekommener Zustellungsbericht." + no_sender_detected_error: "Passiert, wenn wir keine gültige E-Mail-Adresse im From-Header finden konnten." + from_reply_by_address_error: "Passiert, wenn der From-Header mit der Antwort-E-Mail-Adresse übereinstimmt." + inactive_user_error: "Passiert, wenn der Sender nicht aktiv ist." + silenced_user_error: "Passiert, wenn der Absender stummgeschaltet wurde." + bad_destination_address: "Passiert, wenn keine der E-Mail-Adressen in den An-/Cc-Feldern mit einer konfigurierten eingehenden E-Mail-Adresse übereinstimmt." + strangers_not_allowed_error: "Passiert, wenn ein Benutzer versucht, ein neues Thema in einer Kategorie zu erstellen, in der er nicht Mitglied ist." + insufficient_trust_level_error: "Passiert, wenn ein Benutzer versucht, ein neues Thema in einer Kategorie zu erstellen, für die er nicht die erforderliche Vertrauensstufe hat." + reply_user_not_matching_error: "Passiert, wenn die E-Mail-Adresse der Antwort von der Adresse abweicht, an welche die Benachrichtigung gesendet wurde." + topic_not_found_error: "Passiert, wenn eine Antwort eingeht, aber das zugehörige Thema gelöscht wurde." + topic_closed_error: "Passiert, wenn eine Antwort eingeht, aber das zugehörige Thema geschlossen wurde." + bounced_email_error: "E-Mail ist ein Bericht über eine unzustellbare E-Mail." screened_email_error: "Passiert, wenn die Absenderadresse schon einmal gefiltert wurde." - unsubscribe_not_allowed: "Kommt vor, wenn die Abbestellung per E-Mail für diesen Benutzer nicht erlaubt ist." - email_not_allowed: "Kommt vor, wenn die E-Mail-Adresse nicht auf der Positivliste oder auf der Negativliste ist." + unsubscribe_not_allowed: "Passiert, wenn die Abbestellung per E-Mail für diesen Benutzer nicht erlaubt ist." + email_not_allowed: "Passiert, wenn die E-Mail-Adresse nicht auf der Zulassungsliste oder auf der Negativliste ist." unrecognized_error: "Unbekannter Fehler" - secure_media_placeholder: "Redigiert: Auf dieser Site sind sichere Medien aktiviert. Besuchen Sie das Thema oder klicken Sie auf Medien anzeigen, um die angehängten Medien anzuzeigen." + secure_media_placeholder: "Redigiert: Auf dieser Website sind sichere Medien aktiviert, besuche das Thema oder klicke auf „Medien anzeigen“, um die angehängten Medien zu sehen." view_redacted_media: "Medien anzeigen" errors: &errors format: ! "%{attribute} %{message}" @@ -145,11 +145,11 @@ de: has_already_been_used: "wird bereits verwendet" inclusion: ist nicht in der Liste enthalten invalid: ist ungültig - is_invalid: "scheint unklar, ist das ein ganzer Satz?" - is_invalid_meaningful: "scheint unklar zu sein, die meisten Wörter enthalten immer wieder die gleichen Buchstaben?" - is_invalid_unpretentious: "scheint unklar zu sein, ein oder mehrere Worte ist sehr lang?" - is_invalid_quiet: "scheint unklar zu sein, wolltest Du es in GROSSBUCHSTABEN eingeben?" - invalid_timezone: "'%{tz}' ist keine gültige Zeitzone" + is_invalid: "Scheint unklar zu sein, ist das ein ganzer Satz?" + is_invalid_meaningful: "Scheint unklar zu sein, die meisten Wörter enthalten immer wieder die gleichen Buchstaben." + is_invalid_unpretentious: "Scheint unklar zu sein, eines oder mehrere Wörter sind sehr lang." + is_invalid_quiet: "Scheint unklar zu sein, wolltest du das in GROSSBUCHSTABEN eingeben?" + invalid_timezone: "„%{tz}“ ist keine gültige Zeitzone" contains_censored_words: "enthält die folgenden nicht erlaubten Wörter: %{censored_words}" less_than: muss weniger als %{count} sein less_than_or_equal_to: muss weniger oder gleich %{count} sein @@ -185,211 +185,211 @@ de: invalid_category_id: "Du hast eine Kategorie angegeben, die nicht existiert" invalid_choice: one: "Du hast die ungültige Auswahl %{name} getroffen" - other: "Du hast die ungültige Auswahl %{name} getroffen" + other: "Du hast die ungültigen Auswahlen %{name} getroffen" default_categories_already_selected: "Du kannst keine Kategorie auswählen, welche bereits in einer anderen Liste benutzt wird." - default_tags_already_selected: "Du kannst keinen Tag wählen, der in einer anderen Liste verwendet wird." - s3_upload_bucket_is_required: "Uploads auf Amazon S3 können nicht aktiviert werden, bevor der 's3_upload_bucket' eingetragen wurde." + default_tags_already_selected: "Du kannst kein Schlagwort wählen, das in einer anderen Liste verwendet wird." + s3_upload_bucket_is_required: "Uploads auf S3 können nicht aktiviert werden, bevor der „s3_upload_bucket“ eingetragen wurde." enable_s3_uploads_is_required: "Der S3-Bestand setzt voraus, dass S3-Uploads aktiviert sind." page_publishing_requirements: "Die Veröffentlichung von Seiten kann nicht aktiviert werden, wenn sichere Medien aktiviert sind." - s3_backup_requires_s3_settings: "Du kannst S3 nicht als Backup-Ort verwenden, solange du die Einstellung '%{setting_name}' nicht angegeben hast." - s3_bucket_reused: "Du kannst nicht den gleichen Bucket für 's3_upload_bucket' und 's3_backup_bucket' verwenden. Bitte verwende einen anderen Bucket oder verwende für jeden Bucket einen anderen Pfad." - secure_media_requirements: "S3 Uploads müssen vor dem Aktivieren sicherer Medien aktiviert sein." - share_quote_facebook_requirements: "Sie müssen eine Facebook-App-ID festlegen, um das Teilen von Zitaten für Facebook zu aktivieren." - second_factor_cannot_enforce_with_socials: "Sie können 2FA nicht erzwingen, wenn soziale Logins aktiviert sind. Sie müssen zuerst die Anmeldung deaktivieren über: %{auth_provider_names}" - second_factor_cannot_be_enforced_with_disabled_local_login: "Du kannst kein 2FA erzwingen, wenn lokale Anmeldungen deaktiviert sind." + s3_backup_requires_s3_settings: "Du kannst S3 nicht als Back-up-Ort verwenden, solange du „%{setting_name}“ nicht festgelegt hast." + s3_bucket_reused: "Du kannst nicht denselben Bucket für „s3_upload_bucket“ und „s3_backup_bucket“ verwenden. Bitte verwende einen anderen Bucket oder verwende für jeden Bucket einen anderen Pfad." + secure_media_requirements: "S3-Uploads müssen vor dem Aktivieren sicherer Medien aktiviert sein." + share_quote_facebook_requirements: "Du musst eine Facebook-App-ID festlegen, um das Teilen von Zitaten für Facebook zu aktivieren." + second_factor_cannot_enforce_with_socials: "Du kannst 2FA nicht erzwingen, wenn die Anmeldung über soziale Netzwerke aktiviert ist. Du musst zuerst die Anmeldung über %{auth_provider_names} deaktivieren" + second_factor_cannot_be_enforced_with_disabled_local_login: "Du kannst 2FA nicht erzwingen, wenn lokale Anmeldungen deaktiviert sind." second_factor_cannot_be_enforced_with_discourse_connect_enabled: "Du kannst 2FA nicht erzwingen, wenn DiscourseConnect aktiviert ist." - local_login_cannot_be_disabled_if_second_factor_enforced: "Du kannst lokale Anmeldung nicht deaktivieren, wenn 2FA erzwungen wird. Deaktiviere erzwungenes 2FA vor dem Deaktivieren lokaler Anmeldungen." - cannot_enable_s3_uploads_when_s3_enabled_globally: "Du kannst S3 Uploads nicht aktivieren, weil S3 Uploads bereits global aktiviert sind. Aktivieren auf Seiten-Ebene kann zu kritischen Problemen mit Uploads führen" - cors_origins_should_not_have_trailing_slash: "Sie sollten den abschließenden Schrägstrich (/) nicht zu CORS-Ursprüngen hinzufügen." + local_login_cannot_be_disabled_if_second_factor_enforced: "Du kannst die lokale Anmeldung nicht deaktivieren, wenn 2FA erzwungen wird. Deaktiviere die erzwungene 2FA vor dem Deaktivieren lokaler Anmeldungen." + cannot_enable_s3_uploads_when_s3_enabled_globally: "Du kannst S3-Uploads nicht aktivieren, weil S3-Uploads bereits global aktiviert sind. Aktivieren auf Website-Ebene kann zu kritischen Problemen mit Uploads führen" + cors_origins_should_not_have_trailing_slash: "Du solltest den abschließenden Schrägstrich (/) nicht zu CORS-Ursprüngen hinzufügen." conflicting_google_user_id: 'Die Google-Konto-ID für dieses Konto hat sich geändert; das Einschreiten des Teams ist aus Sicherheitsgründen erforderlich. Bitte kontaktiere das Team und verweise es auf
    https://meta.discourse.org/t/76575' onebox: - invalid_address: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da der Server '%{hostname}' nicht gefunden werden konnte. Statt einer Vorschau erscheint nur ein Link in Deinem Beitrag. :cry:" - error_response: "Es tut uns leid, wir konnten keine Vorschau für diese Webseite erstellen, da der Webserver einen Fehlercode von %{status_code} zurückgegeben hat. Statt einer Vorschau erscheint nur ein Link in Deinem Beitrag. :cry:" + invalid_address: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da der Server „%{hostname}“ nicht gefunden werden konnte. Statt einer Vorschau erscheint nur ein Link in deinem Beitrag. :cry:" + error_response: "Leider konnten wir keine Vorschau für diese Webseite erstellen, weil der Webserver den Fehlercode %{status_code} zurückgegeben hat. Statt einer Vorschau erscheint nur ein Link in deinem Beitrag. :cry:" missing_data: - one: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da das folgende oEmbed / OpenGraph Tag nicht gefunden werden konnte: %{missing_attributes}" - other: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da die folgenden oEmbed / OpenGraph Tags nicht gefunden werden konnten: %{missing_attributes}" + one: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da das folgende oEmbed-/OpenGraph-Tag nicht gefunden werden konnte: %{missing_attributes}" + other: "Leider konnten wir keine Vorschau für diese Webseite erstellen, da die folgenden oEmbed-/OpenGraph-Tags nicht gefunden werden konnten: %{missing_attributes}" word_connector: comma: ", " invite: expired: "Dein Einladungstoken ist abgelaufen. Bitte kontaktiere das Team." not_found: "Dein Einladungstoken ist ungültig. Bitte kontaktiere das Team." not_found_json: "Dein Einladungstoken ist ungültig. Bitte kontaktiere das Team." - not_matching_email: "Deine E-Mail-Adresse und die mit dem Einladungstoken verknüpfte E-Mail-Adresse stimmen nicht überein. Bitte kontaktiere das Support Team." + not_matching_email: "Deine E-Mail-Adresse und die mit dem Einladungstoken verknüpfte E-Mail-Adresse stimmen nicht überein. Bitte kontaktiere das Team." not_found_template: | -

    Deine Einladung zu %{site_name} wurde bereits zurückgezogen.

    +

    Deine Einladung zu %{site_name} wurde bereits angenommen.

    Wenn du dein Passwort noch weißt, kannst du dich anmelden.

    Ansonsten kannst du dein Passwort zurücksetzen.

    not_found_template_link: | -

    Die Einladung an %{site_name} kann nicht mehr eingelöst werden. Bitte frage die Person, die Dich eingeladen hat, Dir eine neue Einladung zu senden.

    - user_exists: "Es ist nicht nötig, %{email} einzuladen, er/sie hat schon ein Konto!" - invite_exists: "Du hast bereits %{email}eingeladen." - invalid_email: "%{email}' ist keine gültige E-Mail-Adresse." +

    Die Einladung zu %{site_name} kann nicht mehr angenommen werden. Bitte die Person, die dich eingeladen hat, dir eine neue Einladung zu schicken.

    + user_exists: "Es ist nicht nötig, %{email} einzuladen, die Person hat schon ein Konto!" + invite_exists: "Du hast %{email} bereits eingeladen." + invalid_email: "%{email} ist keine gültige E-Mail-Adresse." rate_limit: - one: "Du hast am letzten Tag bereits %{count} Einladung verschickt, bitte warte %{time_left} bevor du es erneut versuchst." - other: "Du hast am letzten Tag bereits %{count} Einladungen gesendet, bitte warte %{time_left} bevor du es erneut versuchst." - confirm_email: "

    Du bist fast fertig! Wir haben eine Aktivierungsmail an deine E-Mail-Adresse geschickt. Bitte folge den Anweisungen in der E-Mail, um dein Konto zu aktivieren.

    Wenn keine E-Mail ankommt, überprüfe bitte deinen Spam-Ordner.

    " - cant_invite_to_group: "Sie sind nicht berechtigt, Benutzer zu bestimmten Gruppe(n) einzuladen. Stellen Sie sicher, dass Sie Eigentümer der Gruppe(n) sind, in die Sie einladen möchten." + one: "Du hast in den letzten 24 Stunden bereits %{count} Einladung verschickt, bitte warte %{time_left}, bevor du es erneut versuchst." + other: "Du hast in den letzten 24 Stunden bereits %{count} Einladungen verschickt, bitte warte %{time_left}, bevor du es erneut versuchst." + confirm_email: "

    Du bist fast fertig! Wir haben eine Aktivierungs-E-Mail an deine E-Mail-Adresse geschickt. Bitte folge den Anweisungen in der E-Mail, um dein Konto zu aktivieren.

    Wenn keine E-Mail ankommt, überprüfe bitte deinen Spam-Ordner.

    " + cant_invite_to_group: "Du darfst keine Benutzer in die angegebene(n) Gruppe(n) einladen. Vergewissere dich, dass du der Eigentümer der Gruppe(n) bist, in die du einladen möchtest." disabled_errors: discourse_connect_enabled: "Einladungen sind deaktiviert, da DiscourseConnect aktiviert ist." invalid_access: "Du hast nicht die Erlaubnis, die angeforderte Ressource zu betrachten." bulk_invite: - file_should_be_csv: "Die hochzuladende Datei sollte im CSV-Format vorliegen." - max_rows: "Die ersten %{max_bulk_invites} Einladungen wurden versandt. Versuche, die Datein in kleinere Teile aufzuspalten." + file_should_be_csv: "Die hochgeladene Datei sollte im CSV-Format vorliegen." + max_rows: "Die ersten %{max_bulk_invites} Einladungen wurden versandt. Versuche, die Datei in kleinere Teile aufzuteilen." error: "Es gab einen Fehler beim Hochladen dieser Datei. Bitte versuche es später noch einmal." invite_link: - email_taken: "Diese E-Mail-Adresse wird schon verwendet. Wenn Sie bereits ein Konto haben, melden Sie sich bitte an oder setzen Sie das Passwort zurück." - max_redemptions_limit: "sollte zwischen 2 und %{max_limit} sein." + email_taken: "Diese E-Mail-Adresse wird schon verwendet. Falls du bereits ein Konto hast, melde dich bitte an oder setze dein Passwort zurück." + max_redemptions_limit: "Sollte zwischen 2 und %{max_limit} liegen." topic_invite: failed_to_invite: "Der Benutzer kann nicht in dieses Thema eingeladen werden, ohne Mitglied in einer der folgenden Gruppen zu sein: %{group_names}" user_exists: "Entschuldige, dieser Benutzer ist bereits eingeladen worden. Du kannst einen Benutzer nur einmal zu einem Thema einladen." - muted_invitee: "Entschuldigung, dieser Benutzer hat dich stummgeschaltet." - muted_topic: "Entschuldigung, dieser Benutzer hat dieses Thema stummgeschaltet." - receiver_does_not_allow_pm: "Entschuldigung, dieser Benutzer erlaubt es dir nicht, ihm private Nachrichten zu senden." - sender_does_not_allow_pm: "Entschuldigung, du erlaubst diesem Benutzer nicht, dir private Nachrichten zu senden." + muted_invitee: "Entschuldige, dieser Benutzer hat dich stummgeschaltet." + muted_topic: "Entschuldige, dieser Benutzer hat dieses Thema stummgeschaltet." + receiver_does_not_allow_pm: "Entschuldige, dieser Benutzer erlaubt es dir nicht, ihm private Nachrichten zu senden." + sender_does_not_allow_pm: "Entschuldige, du erlaubst diesem Benutzer nicht, dir private Nachrichten zu senden." user_cannot_see_topic: "%{username} kann das Thema nicht sehen." backup: - operation_already_running: "Eine Arbeitsschritt wird momentan bearbeitet. Im Moment kann kein neuer Vorgang gestartet werden." - backup_file_should_be_tar_gz: "Die Sicherungsdatei sollte ein .tar.gz-Archiv sein." - not_enough_space_on_disk: "Es gibt nicht genügend freien Festplattenspeicher, um dieses Backup hochzuladen." - invalid_filename: "Der Dateiname für das Backup enthält ungültige Zeichen. Gültig sind: a-z 0-9 . - _." - file_exists: "Die Datei, die du versucht hast hochzuladen, existiert bereits." + operation_already_running: "Ein Arbeitsschritt wird momentan bearbeitet. Im Moment kann kein neuer Vorgang gestartet werden." + backup_file_should_be_tar_gz: "Die Back-up-Datei sollte ein .tar.gz-Archiv sein." + not_enough_space_on_disk: "Es ist nicht genug Speicherplatz auf der Festplatte frei, um dieses Back-up hochzuladen." + invalid_filename: "Der Dateiname für das Back-up enthält ungültige Zeichen. Gültig sind: a–z 0–9 . - _." + file_exists: "Die Datei, die du hochzuladen versuchst, existiert bereits." invalid_params: "Du hast bei der Anfrage ungültige Parameter angegeben: %{message}" not_logged_in: "Dazu musst du angemeldet sein." not_found: "Die angeforderte URL oder Ressource konnte nicht gefunden werden." - invalid_access: "Du hast nicht die Erlaubnis, die angeforderte Ressource zu betrachten." - authenticator_not_found: "Authentifizierungsmethode existiert nicht oder wurde deaktiviert" + invalid_access: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen." + authenticator_not_found: "Authentifizierungsmethode existiert nicht oder wurde deaktiviert." invalid_api_credentials: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der API-Benutzername oder -Schlüssel ist ungültig." - provider_not_enabled: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsprovider ist nicht aktiviert." - provider_not_found: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsprovider existiert nicht." - read_only_mode_enabled: "Die Seite befindet sich im Nur-Lesen Modus. Änderungen sind deaktiviert." - invalid_grant_badge_reason_link: "Externe oder ungültige Discourse-Links sind nicht erlaubt in der Abzeichen-Begründung." - email_template_cant_be_modified: "Diese E-Mail-Vorlag kann nicht bearbeitet werden." - invalid_whisper_access: "Enweder ist Flüstern nicht aktiviert oder du hast keinen Zugang zu Flüster-Beiträgen" + provider_not_enabled: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsanbieter ist nicht aktiviert." + provider_not_found: "Du bist nicht berechtigt, die angeforderte Ressource anzuzeigen. Der Authentifizierungsanbieter existiert nicht." + read_only_mode_enabled: "Die Website befindet sich im Nur-Lesen-Modus. Änderungen sind deaktiviert." + invalid_grant_badge_reason_link: "Externe oder ungültige Discourse-Links sind in der Abzeichen-Begründung nicht erlaubt." + email_template_cant_be_modified: "Diese E-Mail-Vorlage kann nicht bearbeitet werden." + invalid_whisper_access: "Entweder sind geflüsterte Beiträge nicht aktiviert oder du hast keinen Zugang, um geflüsterte Beiträge zu erstellen" not_in_group: - title_topic: "Um dieses Thema zu sehen, musst du die Mitgliedschaft der Gruppe '%{group}' beantragen." + title_topic: "Um dieses Thema zu sehen, musst du die Mitgliedschaft in der Gruppe „%{group}“ anfordern." request_membership: "Mitgliedschaft anfordern" join_group: "Gruppe beitreten" deleted_topic: "Ups! Dieses Thema wurde gelöscht und ist nicht mehr verfügbar." - delete_topic_failed: "Es kam zu einem Fehler beim Löschen des Themas. Bitte kontaktiere den Administrator dieser Seite." + delete_topic_failed: "Es kam zu einem Fehler beim Löschen des Themas. Bitte kontaktiere den Administrator der Website." reading_time: "Lesezeit" likes: "Likes" too_many_replies: - one: "Entschuldigung, aber neue Benutzer sind vorübergehend auf eine Antwort pro Thema beschränkt." + one: "Entschuldigung, aber neue Benutzer sind vorübergehend auf %{count} Antwort pro Thema beschränkt." other: "Entschuldigung, aber neue Benutzer sind vorübergehend auf %{count} Antworten pro Thema beschränkt." max_consecutive_replies: - one: "Es ist nicht mehr als %{count} aufeinanderfolgende Antwort erlaubt. Bitte bearbeite stattdessen deine letzte Antwort oder warte, bis dir jemand antwortet." + one: "Es sind keine aufeinanderfolgenden Antworten erlaubt. Bitte bearbeite stattdessen deine letzte Antwort oder warte, bis dir jemand antwortet." other: "Es sind nicht mehr als %{count} aufeinanderfolgende Antworten erlaubt. Bitte bearbeite stattdessen deine letzte Antwort oder warte, bis dir jemand antwortet." embed: start_discussion: "Diskussion beginnen" continue: "Diskussion fortsetzen" error: "Fehler bei der Einbettung" referer: "Referrer:" - error_topics: "Die 'bette Themenliste ein' Webseiten-Einstellung ist nicht aktiv" + error_topics: "Die Website-Einstellung `embed topics list` war nicht aktiviert" mismatch: "Der Referrer wurde entweder nicht mitgesendet oder entsprach keinem der folgenden Hostnamen:" no_hosts: "Es wurden keine Hostnamen für die Einbettung konfiguriert." configure: "Einbettung konfigurieren" more_replies: one: "%{count} weitere Antwort" other: "%{count} weitere Antworten" - loading: "Lade Diskussion..." - permalink: "Permanenter Link" + loading: "Lade Diskussion …" + permalink: "Permalink" imported_from: "Dies ist ein Begleitthema zum ursprünglichen Beitrag unter %{link}" in_reply_to: "▶ %{username}" replies: one: "%{count} Antwort" other: "%{count} Antworten" likes: - one: "%{count} \"Gefällt mir\"-Angabe" - other: "%{count} \"Gefällt mir\"-Angaben" - last_reply: "letzte Antwort" + one: "%{count} Like" + other: "%{count} Likes" + last_reply: "Letzte Antwort" created: "Erstellt" new_topic: "Erstelle neues Thema" - no_mentions_allowed: "Entschuldigung, du kannst keine Benutzer erwähnen." + no_mentions_allowed: "Entschuldige, du kannst keine anderen Benutzer erwähnen." too_many_mentions: - one: "Entschuldigung, du kannst nur einen anderen Benutzer in einem Beitrag erwähnen." - other: "Entschuldigung, du kannst nur %{count} Benutzer in einem Beitrag erwähnen." - no_mentions_allowed_newuser: "Entschuldigung, neue Benutzer können andere Benutzer nicht erwähnen." + one: "Entschuldige, du kannst nur einen anderen Benutzer in einem Beitrag erwähnen." + other: "Entschuldige, du kannst nur %{count} Benutzer in einem Beitrag erwähnen." + no_mentions_allowed_newuser: "Entschuldige, neue Benutzer können andere Benutzer nicht erwähnen." too_many_mentions_newuser: one: "Entschuldige, neue Benutzer können nur einen anderen Benutzer in einem Beitrag erwähnen." other: "Entschuldige, neue Benutzer können nur %{count} Benutzer in einem Beitrag erwähnen." - no_embedded_media_allowed_trust: "Leider können Sie keine Medienelemente in einen Beitrag einbetten." - no_embedded_media_allowed: "Es tut uns leid, neue Benutzer können keine Medienelemente in Beiträge einbinden." + no_embedded_media_allowed_trust: "Entschuldige, du kannst keine Medienelemente in einen Beitrag einbetten." + no_embedded_media_allowed: "Entschuldige, neue Benutzer können keine Medienelemente in Beiträge einbetten." too_many_embedded_media: - one: "Es tut uns leid, neue Benutzer können nur ein eingebettetes Medienelement in einen Beitrag stellen." - other: "Entschuldigung, neue Benutzer können nur %{count} eingebettete Medienelemente in einen Beitrag stellen." + one: "Entschuldige, neue Benutzer können leider nur ein eingebettetes Medienelement in einen Beitrag einfügen." + other: "Entschuldige, neue Benutzer können leider nur %{count} eingebettete Medienelemente in einen Beitrag einfügen." no_attachments_allowed: "Entschuldige, neue Benutzer können Beiträgen keine Dateien hinzufügen." too_many_attachments: one: "Entschuldige, neue Benutzer können Beiträgen höchstens eine Datei hinzufügen." other: "Entschuldige, neue Benutzer können Beiträgen höchstens %{count} Dateien hinzufügen." no_links_allowed: "Entschuldige, neue Benutzer können Beiträgen keine Links hinzufügen." - links_require_trust: "Entschuldige, du kannst keine Links in einen Beitrag einfügen." + links_require_trust: "Entschuldige, du kannst keine Links in deinen Beiträgen einfügen." too_many_links: one: "Entschuldige, neue Benutzer können Beiträgen höchstens einen Link hinzufügen." other: "Entschuldige, neue Benutzer können Beiträgen höchstens %{count} Links hinzufügen." contains_blocked_word: "Dein Beitrag enthält ein nicht erlaubtes Wort: %{word}" - contains_blocked_words: "Dein Beitrag enthält mehrere Worte, die nicht erlaubt sind: %{words}" - spamming_host: "Entschuldigung, du kannst keine Links zu diesem Webserver posten." + contains_blocked_words: "Dein Beitrag enthält mehrere Wörter, die nicht erlaubt sind: %{words}" + spamming_host: "Entschuldige, leider kannst du keinen Link zu diesem Host posten." user_is_suspended: "Gesperrte Benutzer dürfen keine Beiträge schreiben." - topic_not_found: "Etwas ist schief gelaufen. Wurde das Thema eventuell geschlossen oder gelöscht, während du es angeschaut hast?" + topic_not_found: "Etwas ist schiefgelaufen. Wurde das Thema eventuell geschlossen oder gelöscht, während du es angeschaut hast?" not_accepting_pms: "Entschuldige, %{username} akzeptiert gerade keine Nachrichten." - max_pm_recipients: "Sorry, du kannst eine Nachricht nur an maximal %{recipients_limit} Emfpänger senden." - pm_reached_recipients_limit: "Entschuldige, aber Du kannst nicht mehr als %{recipients_limit} Empfänger in einer Nachricht haben." + max_pm_recipients: "Entschuldige, du kannst eine Nachricht nur an maximal %{recipients_limit} Empfänger senden." + pm_reached_recipients_limit: "Entschuldige, du kannst nicht mehr als %{recipients_limit} Empfänger in einer Nachricht haben." removed_direct_reply_full_quotes: "Automatisch entferntes Zitat des gesamten vorherigen Beitrags." - watched_words_auto_tag: "Thema automatisch markiert" - secure_upload_not_allowed_in_public_topic: "Entschuldigung, der/die folgende/n sichere Upload(s) konnte nicht in einem öffentlichen Thema verwendet werden: %{upload_filenames}." - create_pm_on_existing_topic: "Entschuldigung, du kannst keine PM für ein exisiterendes Thema erstellen." + watched_words_auto_tag: "Thema automatisch mit Schlagwort versehen" + secure_upload_not_allowed_in_public_topic: "Entschuldige, die folgenden sicheren Uploads können nicht in einem öffentlichen Thema verwendet werden: %{upload_filenames}." + create_pm_on_existing_topic: "Entschuldige, du kannst keine private Nachricht zu einem bestehenden Thema erstellen." slow_mode_enabled: "Dieses Thema befindet sich im langsamen Modus." - just_posted_that: "ist einer einer vor Kurzem von dir geschriebenen Nachricht zu ähnlich" + just_posted_that: "ist einer vor Kurzem von dir geschriebenen Nachricht zu ähnlich" invalid_characters: "enthält ungültige Zeichen" - is_invalid: "scheint unklar, ist das ein ganzer Satz?" + is_invalid: "Scheint unklar zu sein, ist das ein ganzer Satz?" next_page: "nächste Seite →" prev_page: "← vorherige Seite" page_num: "Seite %{num}" home_title: "Startseite" - topics_in_category: "Themen in der Kategorie '%{category}'" - rss_posts_in_topic: "RSS-Feed von '%{topic}'" - rss_topics_in_category: "RSS-Feed von Themen in der Kategorie '%{category}'" + topics_in_category: "Themen in der Kategorie „%{category}“" + rss_posts_in_topic: "RSS-Feed von „%{topic}“" + rss_topics_in_category: "RSS-Feed von Themen in der Kategorie „%{category}“" rss_num_posts: one: "%{count} Beitrag" other: "%{count} Beiträge" rss_num_participants: one: "%{count} Teilnehmer" other: "%{count} Teilnehmer" - read_full_topic: "Lese das vollständige Thema" + read_full_topic: "Vollständiges Thema lesen" private_message_abbrev: "Nachricht" rss_description: latest: "Aktuelle Themen" - top: "Die besten Themen" - top_all: "Top-Themen aller Zeiten" - top_yearly: "Top-Themen der letzten 12 Monate" - top_quarterly: "Top-Themen der letzten drei Monate" - top_monthly: "Top-Themen der letzten 30 Tage" - top_weekly: "Top-Themen der letzten Woche" - top_daily: "Top-Themen der letzten 24 Stunden" + top: "Angesagte Themen" + top_all: "Angesagte Themen (gesamt)" + top_yearly: "Angesagte Themen (Jahr)" + top_quarterly: "Angesagte Themen (Quartal)" + top_monthly: "Angesagte Themen (Monat)" + top_weekly: "Angesagte Themen (Woche)" + top_daily: "Angesagte Themen (Tag)" posts: "Letzte Beiträge" - private_posts: "Neueste Nachrichten" + private_posts: "Neueste persönliche Nachrichten" group_posts: "Neueste Beiträge von %{group_name}" - group_mentions: "Neueste Nennungen von %{group_name}" + group_mentions: "Neueste Erwähnungen von %{group_name}" user_posts: "Neueste Beiträge von @%{username}" user_topics: "Neueste Themen von @%{username}" tag: "Themen mit Schlagwörtern" - badge: "%{display_name} Abzeichen auf %{site_title}" + badge: "„%{display_name}“-Abzeichen auf %{site_title}" too_late_to_edit: "Dieser Beitrag wurde vor zu langer Zeit erstellt. Er kann nicht mehr bearbeitet oder gelöscht werden." edit_conflict: "Dieser Beitrag wurde von einem anderen Benutzer bearbeitet und deine Änderungen können nicht länger gespeichert werden." revert_version_same: "Die aktuelle Version entspricht der Version, zu der du zurückkehren möchtest." - cannot_edit_on_slow_mode: "Dieses Thema befindet sich im langsamen Modus. Um nachdenkliche, überlegte Diskussionen anzuregen, ist das Bearbeiten alter Beiträge in diesem Thema während des langsamen Modus nicht zulässig." + cannot_edit_on_slow_mode: "Dieses Thema befindet sich im langsamen Modus. Um durchdachte, überlegte Diskussionen anzuregen, ist das Bearbeiten alter Beiträge in diesem Thema während des langsamen Modus nicht zulässig." excerpt_image: "Bild" bookmarks: errors: already_bookmarked_post: "Du kannst denselben Beitrag nicht zweimal zu deinen Lesezeichen hinzufügen." - too_many: "Leider kannst Du nicht mehr als %{limit} Lesezeichen hinzufügen. Besuche %{user_bookmarks_url} , um einige zu entfernen." - cannot_set_past_reminder: "Sie können in der Vergangenheit keine Lesezeichenerinnerung festlegen." + too_many: "Entschuldige, leider kannst du nicht mehr als %{limit} Lesezeichen hinzufügen. Besuche %{user_bookmarks_url}, um welche zu entfernen." + cannot_set_past_reminder: "Du kannst keine Lesezeichen-Erinnerung in der Vergangenheit setzen." cannot_set_reminder_in_distant_future: "Sie können eine Lesezeichenerinnerung nicht länger als 10 Jahre in der Zukunft festlegen." time_must_be_provided: "die Zeit muss für alle Erinnerungen angegeben werden" reminders: - at_desktop: "Nächstes mal wenn ich an meinem PC bin" + at_desktop: "Nächstes Mal, wenn ich an meinem PC bin" later_today: "Im Laufe des Tages" next_business_day: "Nächster Werktag" tomorrow: "Morgen" @@ -402,23 +402,23 @@ de: one: "%{count} Benutzer wurde zur Gruppe hinzugefügt." other: "%{count} Benutzer wurden zur Gruppe hinzugefügt." errors: - grant_trust_level_not_valid: "'%{trust_level}' ist keine gültige Vertrauensstufe" + grant_trust_level_not_valid: "„%{trust_level}“ ist keine gültige Vertrauensstufe." can_not_modify_automatic: "Du kannst eine automatische Gruppe nicht bearbeiten" member_already_exist: - one: "'%{username}' ist bereits Mitglied dieser Gruppe." + one: "„%{username}“ ist bereits Mitglied dieser Gruppe." other: "Die folgenden Benutzer sind bereits Mitglieder dieser Gruppe: %{username}" - invalid_domain: "'%{domain}' ist keine gültige Domain." - invalid_incoming_email: "'%{email}' ist keine gültige E-Mail-Adresse." - email_already_used_in_group: "'%{email}' wird bereits von der Gruppe '%{group_name}' verwendet." - email_already_used_in_category: "'%{email}' wird bereits von der Kategorie '%{category_name}' verwendet." + invalid_domain: "„%{domain}“ ist keine gültige Domain." + invalid_incoming_email: "„%{email}“ ist keine gültige E-Mail-Adresse." + email_already_used_in_group: "„%{email}“ wird bereits von der Gruppe „%{group_name}“ verwendet." + email_already_used_in_category: "„%{email}“ wird bereits von der Kategorie „%{category_name}“ verwendet." cant_allow_membership_requests: "Du kannst Mitgliedschaftsanfragen nicht für eine Gruppe ohne Eigentümer erlauben." - already_requested_membership: "Du hast die Gruppenmitgliedschaft für diese Gruppe bereits angefragt." + already_requested_membership: "Du hast die Mitgliedschaft für diese Gruppe bereits angefragt." adding_too_many_users: one: "Maximal %{count} Benutzer kann auf einmal hinzugefügt werden" other: "Maximal %{count} Benutzer können auf einmal hinzugefügt werden" usernames_or_emails_required: "Benutzernamen oder E-Mails müssen vorhanden sein" no_invites_with_discourse_connect: "Du kannst nur registrierte Benutzer einladen, wenn DiscourseConnect aktiviert ist" - no_invites_without_local_logins: "Sie können nur registrierte Benutzer einladen, wenn lokale Anmeldungen deaktiviert sind" + no_invites_without_local_logins: "Du kannst nur registrierte Benutzer einladen, wenn lokale Anmeldungen deaktiviert sind" default_names: everyone: "jeder" admins: "admins" @@ -440,41 +440,41 @@ de: one: "%{count} Beitrag" other: "%{count} Beiträge" "new-topic": | - Willkommen bei %{site_name} — **danke, für das Erstellen eines neuen Themas!** + Willkommen bei %{site_name} — **danke für das Erstellen eines neuen Themas!** - Klingt der Titel interessant, wenn du ihn laut liest? Ist er eine gute Zusammenfassung? - - Wer würde sich für dieses Thema interessieren? Warum ist es wichtig? Welche Art Antworten wünschst du dir? + - Wer würde sich für dieses Thema interessieren? Warum ist es wichtig? Welche Art von Antworten wünschst du dir? - - Verwende gebräuchliche, gut definierte Wörter, damit andere das Thema *finden* können. Wähle eine Kategorie (oder einen Tag), um es mit ähnlichen Themen zu gruppieren. + - Verwende gebräuchliche Wörter, damit andere das Thema *finden* können. Wähle eine Kategorie (oder ein Schlagwort), um es mit ähnlichen Themen zu gruppieren. Mehr Tipps findest du in [unseren Community-Richtlinien](%{base_path}/guidelines). Dieses Panel wird nur bei deinen ersten %{education_posts_text} angezeigt. "new-reply": | - Willkommen bei %{site_name} — **Danke für deinen Beitrag!** + Willkommen bei %{site_name} — **danke für deinen Beitrag!** - Sei nett zu deinen Community-Mitgliedern. - Verbessert dein Beitrag das Gespräch? - - Konstruktive Kritik ist willkommen, aber kritisiere *Ideen*, nicht Menschen. + - Konstruktive Kritik ist erwünscht, aber kritisiere *Ideen*, nicht Menschen. - Beachte bitte auch [unsere Richtlinien](%{base_path}/guidelines). Dieser Hilfetext wird nur bei deinen ersten %{education_posts_text} angezeigt. + Beachte bitte auch [unsere Community-Richtlinien](%{base_path}/guidelines). Dieser Hilfetext wird nur bei deinen ersten %{education_posts_text} angezeigt. avatar: | - ### Wie wär’s mit einem Bild für dein Konto? + ### Wie wärs mit einem Bild für dein Konto? Du hast schon ein paar Themen und Antworten geschrieben, aber dein Profilbild ist noch nicht so einzigartig wie du – es ist nur ein Buchstabe. - Hast du schon darüber nachgedacht **[dein Benutzerprofil zu besuchen](%{profile_path})** und ein Bild hochzuladen, das dich repräsentiert? + Hast du schon darüber nachgedacht, **[dein Benutzerprofil zu besuchen](%{profile_path})** und ein Bild hochzuladen, das dich repräsentiert? Es ist einfacher, Diskussionen zu folgen und in Unterhaltungen interessante Personen zu finden, wenn jeder ein einzigartiges Profilbild hat! sequential_replies: | ### Denke darüber nach, auf mehrere Beiträge gleichzeitig zu antworten - Statt mehrere aufeinanderfolgende Antworten zu einem Thema zu schreiben, denke bitte darüber nach, eine einzige Antwort zu schreiben, die Zitate mehrerer vorheriger Beiträge oder mehrere Erwähnungen wie @name enthält. + Statt mehrere aufeinanderfolgende Antworten zu einem Thema zu schreiben, denke bitte darüber nach, eine einzige Antwort zu schreiben, die Zitate mehrerer vorheriger Beiträge oder mehrere Erwähnungen von Benutzernamen mit @ enthält. Du kannst deine letzte Antwort bearbeiten, um ein Zitat hinzuzufügen, indem du den Text auswählst und auf die erscheinende Schaltfläche Zitat klickst. - Es ist für alle einfacher, Themen zu lesen, die wenige tiefgehende Antworten haben statt vielen kleinen und einzelnen Antworten. + Es ist für alle einfacher, Themen zu lesen, die wenige umfassende Antworten statt viele kleine und einzelne Antworten haben. dominating_topic: | ### Lassen Sie andere an der Konversation teilnehmen @@ -490,9 +490,9 @@ de: Und vergiss nicht, wenn du ein Gespräch mit dieser bestimmten Person ausserhalb der Öffentlichkeit ausführlich fortsetzen möchtest, [schicke ihr eine persönliche Nachricht](%{base_path}/u/ %{reply_username}). too_many_replies: | - ### Du hast das Antwort-Beschränkung für dieses Thema erreicht + ### Du hast das Antwort-Limit für dieses Thema erreicht - Entschuldige, aber neue Benutzer sind vorübergehend auf %{newuser_max_replies_per_topic} Antworten im gleichen Thema beschränkt. + Entschuldige, aber neue Benutzer sind vorübergehend auf %{newuser_max_replies_per_topic} Antworten im selben Thema beschränkt. Anstatt eine weitere Antwort hinzuzufügen, denke darüber nach, deine vorherigen Antworten zu bearbeiten oder andere Themen zu besuchen. reviving_old_topic: | @@ -518,31 +518,31 @@ de: topic: attributes: base: - warning_requires_pm: "Du kannst Warnungen nur bei Nachrichten hinzufügen." - too_many_users: "Du kannst eine Warnung nur an einen Benutzer zugleich anhängen." - cant_send_pm: "Entschuldige, du kannst keine Nachricht an diese Person verschicken." + warning_requires_pm: "Du kannst Warnungen nur an persönliche Nachrichten anhängen." + too_many_users: "Du kannst immer nur an einen Benutzer gleichzeitig Warnungen senden." + cant_send_pm: "Entschuldige, du kannst keine persönliche Nachricht an diesen Benutzer verschicken." no_user_selected: "Du musst einen gültigen Benutzer auswählen." reply_by_email_disabled: "Antwort per E-Mail ist deaktiviert." send_to_email_disabled: "Entschuldige, du kannst keine persönlichen Nachrichen an E-Mail senden." target_user_not_found: "Einer der Empfänger dieser Nachricht konnte nicht gefunden werden." unable_to_update: "Beim Aktualisieren dieses Themas ist ein Fehler aufgetreten." - unable_to_tag: "Beim Markieren des Themas ist ein Fehler aufgetreten." + unable_to_tag: "Beim Versehen des Themas mit einem Schlagwort ist ein Fehler aufgetreten." featured_link: invalid: "ist ungültig. URL sollte http:// oder https:// enthalten." invalid_category: "kann in dieser Kategorie nicht bearbeitet werden." user: attributes: password: - common: "ist eines der 10000 meist verwendeten Passwörter. Bitte verwende ein sichereres Passwort." + common: "ist eines der 10.000 am meisten verwendeten Passwörter. Bitte verwende ein sichereres Passwort." same_as_username: "ist mit deinem Benutzernamen identisch. Bitte verwende ein sichereres Passwort." same_as_email: "ist mit deiner E-Mail-Adresse identisch. Bitte verwende ein sichereres Passwort." - same_as_current: "entspricht deinem aktuellen Passwort." - same_as_name: "entspricht deinem Namen." + same_as_current: "ist mit deinem aktuellen Passwort identisch." + same_as_name: "ist mit deinem Namen identisch." unique_characters: "hat zu viele sich wiederholende Zeichen. Bitte verwende ein sichereres Passwort." username: - same_as_password: "entspricht deinem Passwort." + same_as_password: "ist mit deinem Passwort identisch." name: - same_as_password: "entspricht deinem Passwort." + same_as_password: "ist mit deinem Passwort identisch." ip_address: signup_not_allowed: "Eine Registrierung ist von diesem Konto nicht erlaubt." user_profile: @@ -552,14 +552,14 @@ de: user_email: attributes: user_id: - reassigning_primary_email: "Eine primäre E-Mail Adresse einem weiteren Benutzer zuzuordnen ist nicht erlaubt." + reassigning_primary_email: "Das Zuweisen einer primären E-Mail-Adresse an einen anderen Benutzer ist nicht erlaubt." color_scheme_color: attributes: hex: invalid: "ist keine gültige Farbe" post_reply: base: - different_topic: "Beitrag und Antwort müssen zum gleichen Thema gehören." + different_topic: "Beitrag und Antwort müssen zum selben Thema gehören." web_hook: attributes: payload_url: @@ -578,7 +578,7 @@ de: translation_overrides: attributes: value: - invalid_interpolation_keys: 'Die folgenden Erweiterungsschlüssel sind ungültig: "%{keys}"' + invalid_interpolation_keys: 'Die folgenden Erweiterungsschlüssel sind ungültig: „%{keys}“' watched_word: attributes: word: @@ -586,13 +586,13 @@ de: base: invalid_url: "Ersatz-URL ist ungültig" <<: *errors - uncategorized_category_name: "Unkategorisiert" + uncategorized_category_name: "Nicht kategorisiert" vip_category_name: "Lounge" vip_category_description: "Eine Kategorie exklusiv für Mitglieder mit Vertrauensstufe 3 oder höher." meta_category_name: "Feedback" meta_category_description: "Diskussionen über dieses Forum, seine Organisation, wie es funktioniert und wie wir es verbessern können." staff_category_name: "Team" - staff_category_description: "Geschützte Kategorie für Team-Diskussionen. Themen sind nur für Administratoren und Moderatoren sichtbar." + staff_category_description: "Private Kategorie für Team-Diskussionen. Themen sind nur für Administratoren und Moderatoren sichtbar." discourse_welcome_topic: title: "Willkommen bei Discourse" body: |2 @@ -625,51 +625,51 @@ de: * auf die private Lounge-Kategorie zugreifen, die für Benutzer mit Vertrauensstufe 3 oder höher sichtbar ist * Spam durch eine einzige Meldung ausblenden - Hier ist die [aktuelle Liste aller Stammgäste](%{base_path}/badges/3/regular). Vergiss nicht, hallo zu sagen! + Hier ist die [aktuelle Liste aller Stammgäste](%{base_path}/badges/3/regular). Vergiss nicht, Hallo zu sagen! Vielen Dank dafür, dass du ein wichtiger Teil dieser Community bist! (Wenn du mehr über Vertrauensstufen wissen möchtest, kannst du [dieses Thema lesen][trust]. Beachte bitte, dass nur jene Mitglieder Stammgäste bleiben, die auch im Laufe der Zeit die Anforderungen erfüllen.) [trust]: https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/ - admin_quick_start_title: "ZUERST LESEN: Admin Schnellstart Lotse" + admin_quick_start_title: "ZUERST LESEN: Administrator-Kurzanleitung" category: topic_prefix: "Über die Kategorie %{category}" - replace_paragraph: "(Ersetze diesen ersten Absatz mit einer kurzen Beschreibung deiner neuen Kategorie. Diese Orientierung wird in der Kategorienauswahl angezeigt, versuche also weniger als 200 Zeichen zu benutzen.)" - post_template: "%{replace_paragraph}\n\nBenutze die folgenden Abschnitte für eine ausführlichere Beschreibung oder um Richtlinien oder Regeln für diese Kategorie festzulegen:\n\n- Warum sollten Benutzer diese Kategorie nutzen? Wofür ist sie gedacht?\n\n- Wie genau unterscheidet sie sich von den Kategorien, die es bereits gibt?\n\n- Welche Themen sollte diese Kategorie im Allgemeinen beinhalten?\n\n- Brauchen wir diese Kategorie? Können wie sie mit einer anderen Kategorie oder Unter-Kategorie zusammenführen?\n" + replace_paragraph: "(Ersetze diesen ersten Absatz mit einer kurzen Beschreibung deiner neuen Kategorie. Diese Orientierung wird in der Kategorienauswahl angezeigt, versuche also, weniger als 200 Zeichen zu benutzen.)" + post_template: "%{replace_paragraph}\n\nBenutze die folgenden Abschnitte für eine ausführlichere Beschreibung oder um Richtlinien oder Regeln für diese Kategorie festzulegen:\n\n- Warum sollten Benutzer diese Kategorie nutzen? Wofür ist sie gedacht?\n\n- Wie genau unterscheidet sie sich von den Kategorien, die es bereits gibt?\n\n- Was sollten Themen in dieser Kategorie generell beinhalten?\n\n- Brauchen wir diese Kategorie? Können wir sie mit einer anderen Kategorie oder Unter-Kategorie zusammenführen?\n" errors: not_found: "Kategorie nicht gefunden!" - uncategorized_parent: "Die \"unkategorisiert\" Kategorie kann keine Elternkategorie haben." + uncategorized_parent: "„Nicht kategorisiert“ kann keine übergeordnete Kategorie haben." self_parent: "Eine Kategorie kann nicht sich selbst untergeordnet werden." depth: "Unterkategorien können nicht ineinander verschachtelt werden." - invalid_email_in: "'%{email}' ist keine gültige E-Mail-Adresse." - email_already_used_in_group: "'%{email}' wird bereits von der Gruppe '%{group_name}' verwendet." - email_already_used_in_category: "'%{email}' wird bereits von der Kategorie '%{category_name}' verwendet." + invalid_email_in: "„%{email}“ ist keine gültige E-Mail-Adresse." + email_already_used_in_group: "„%{email}“ wird bereits von der Gruppe „%{group_name}“ verwendet." + email_already_used_in_category: "„%{email}“ wird bereits von der Kategorie „%{category_name}“ verwendet." description_incomplete: "Die Kategorie-Beschreibung muss mindestens einen Absatz enthalten." permission_conflict: "Jede Gruppe, die Zugriff auf eine Unterkategorie hat, muss auch Zugriff auf die übergeordnete Kategorie erhalten. Folgende Gruppen haben Zugriff auf eine der Unterkategorien, aber keinen Zugriff auf die übergeordnete Kategorie: %{group_names}." - disallowed_topic_tags: "Dieses Thema hat Tags, die in dieser Kategorie nicht erlaubt sind: '%{tags}'" - disallowed_tags_generic: "Dieses Thema hat unerlaubte Tags." + disallowed_topic_tags: "Dieses Thema hat Schlagwörter, die in dieser Kategorie nicht erlaubt sind: „%{tags}“" + disallowed_tags_generic: "Dieses Thema hat unerlaubte Schlagwörter." cannot_delete: - uncategorized: "Diese Kategorie ist etwas Spezielles. Sie dient als Bereich für Themen, die keine Kategorie haben und kann nicht gelöscht werden." - has_subcategories: "Diese Kategorie kann nicht gelöscht werden, weil sie Unterkategorien besitzt." + uncategorized: "Diese Kategorie ist etwas Spezielles. Sie dient als Bereich für Themen, die keine Kategorie haben, und kann nicht gelöscht werden." + has_subcategories: "Diese Kategorie kann nicht gelöscht werden, weil sie Unterkategorien enthält." topic_exists: - one: "Diese Kategorie kann nicht gelöscht werden, weil sie %{count} Themen enthält. Das älteste Thema ist %{topic_link}." + one: "Diese Kategorie kann nicht gelöscht werden, weil sie %{count} Thema enthält. Das älteste Thema ist %{topic_link}." other: "Diese Kategorie kann nicht gelöscht werden, weil sie %{count} Themen enthält. Das älteste Thema ist %{topic_link}." - topic_exists_no_oldest: "Diese Kategorie kann nicht gelöscht werden, weil sie %{count} Themen enthält." - uncategorized_description: "Themen welche keine Kategorie benötigen oder in keine existierende Kategorie passen." + topic_exists_no_oldest: "Diese Kategorie kann nicht gelöscht werden, da die Anzahl der Themen %{count} beträgt." + uncategorized_description: "Themen, welche keine Kategorie benötigen oder in keine existierende Kategorie passen." trust_levels: admin: "Administrator" staff: "Team" - change_failed_explanation: "Du wolltest %{user_name} auf '%{new_trust_level}' zurückstufen. Die Vertrauensstufe ist jedoch bereits '%{current_trust_level}'. %{user_name} verbleibt auf '%{current_trust_level}'. Wenn du den Benutzer zurückstufen möchtest, musst du zuerst seine Vertrauensstufe sperren." + change_failed_explanation: "Du wolltest %{user_name} auf „%{new_trust_level}“ zurückstufen. Die Vertrauensstufe ist jedoch bereits „%{current_trust_level}“. %{user_name} verbleibt auf „%{current_trust_level}“. Wenn du den Benutzer zurückstufen möchtest, musst du zuerst die Vertrauensstufe sperren." post: image_placeholder: broken: "Dieses Bild ist beschädigt" has_likes: one: "%{count} Like" - other: "%{count} Like" + other: "%{count} Likes" rate_limiter: slow_down: "Du hast diese Aktion zu oft durchgeführt. Bitte versuche es später noch einmal." - too_many_requests: "Du hast diese Aktion zu häufig ausgeführt. Bitte warte %{time_left}, ehe du es erneut versuchst." + too_many_requests: "Du hast diese Aktion zu oft durchgeführt. Bitte warte %{time_left}, ehe du es erneut versuchst." by_type: first_day_replies_per_day: "Wir bedanken uns für Deine Begeisterung, mach Sie weiter so! Aus Sicherheitsgründen hast Du jedoch die maximale Anzahl von Antworten erreicht, die ein neuer Benutzer am ersten Tag erstellen kann. Bitte warte %{time_left}, dann kannst Du weitere Antworten erstellen." first_day_topics_per_day: "Wir bedanken uns für Deine Begeisterung! Aus Sicherheitsgründen hast Du jedoch die maximale Anzahl von Themen erreicht, die ein neuer Benutzer am ersten Tag erstellen kann. Bitte warte %{time_left}, dann kannst Du weitere neue Themen erstellen." @@ -682,8 +682,8 @@ de: create_like: "Beeindruckend! Du hast viel Liebe geteilt! Du hast die maximalen täglichen Likes für heute erreicht, aber wenn du Vertrauensstufen gewinnst, wirst du mehr tägliche Likes verdienen. Du wirst Beiträge in %{time_left}wieder mögen können." create_bookmark: "Du hast die maximale Anzahl täglicher Lesezeichen erreicht. Du kannst weitere Lesezeichen in %{time_left}erstellen." edit_post: "Du hast die maximale Anzahl täglicher Bearbeitungen erreicht. Du kannst weitere Bearbeitungen in %{time_left}einreichen." - live_post_counts: "Du forderst die Live-Anzahl der Antworten zu schnell neu an. Bitte warte %{time_left}, bis du es wieder versuchst." - unsubscribe_via_email: "Du hast die maximale Anzahl an Abbestellungen per E-Mail erreicht. Bitte warte %{time_left}, vor einem neuen Versuch." + live_post_counts: "Du forderst die Live-Anzahl der Antworten zu schnell neu an. Bitte warte %{time_left}, ehe du es erneut versuchst." + unsubscribe_via_email: "Du hast die maximale Anzahl an Abbestellungen per E-Mail erreicht. Bitte warte %{time_left}, ehe du es erneut versuchst." topic_invitations_per_day: "Du hast die maximale Anzahl von Themeneinladungen erreicht. Du kannst weitere Einladungen in %{time_left}senden." hours: one: "%{count} Stunde" @@ -697,16 +697,16 @@ de: short_time: "ein paar Sekunden" datetime: distance_in_words: - half_a_minute: "< 1min" + half_a_minute: "< 1 Min." less_than_x_seconds: - one: "< %{count}s" - other: "< %{count}s" + one: "< %{count} s" + other: "< %{count} s" x_seconds: - one: "%{count}s" - other: "%{count}s" + one: "%{count} s" + other: "%{count} s" less_than_x_minutes: one: "< %{count} Min." - other: "< %{count}min" + other: "< %{count} Min." x_minutes: one: "%{count} Min." other: "%{count} Min." @@ -735,43 +735,43 @@ de: half_a_minute: "gerade eben" less_than_x_seconds: "gerade eben" x_seconds: - one: "vor einer Sekunde" + one: "vor %{count} Sekunde" other: "vor %{count} Sekunden" less_than_x_minutes: - one: "vor weniger als einer Minute" + one: "vor weniger als %{count} Minute" other: "vor weniger als %{count} Minuten" x_minutes: - one: "vor einer Minute" + one: "vor %{count} Minute" other: "vor %{count} Minuten" about_x_hours: - one: "vor einer Stunde" + one: "vor %{count} Stunde" other: "vor %{count} Stunden" x_days: - one: "vor einem Tag" + one: "vor %{count} Tag" other: "vor %{count} Tagen" about_x_months: - one: "vor etwa einem Monat" + one: "vor etwa %{count} Monat" other: "vor etwa %{count} Monaten" x_months: - one: "vor einem Monat" + one: "vor %{count} Monat" other: "vor %{count} Monaten" about_x_years: - one: "vor etwa einem Jahr" + one: "vor etwa %{count} Jahr" other: "vor etwa %{count} Jahren" over_x_years: - one: "vor über einem Jahr" + one: "vor über %{count} Jahr" other: "vor über %{count} Jahren" almost_x_years: - one: "vor fast einem Jahr" + one: "vor fast %{count} Jahr" other: "vor fast %{count} Jahren" password_reset: - no_token: "Entschuldige, aber der Link zum Zurücksetzen des Passworts ist zu alt. Wähle 'Ich habe mein Passwort vergessen' um einen neuen Link zu erhalten." + no_token: "Entschuldige, aber der Link zum Ändern des Passworts ist zu alt. Wähle die Anmelden-Schaltfläche und nutze „Ich habe mein Passwort vergessen“, um einen neuen Link zu erhalten." choose_new: "Wähle ein neues Passwort" choose: "Wähle ein Passwort" update: "Passwort aktualisieren" save: "Passwort festlegen" title: "Passwort zurücksetzen" - success: "Dein Passwort wurde erfolgreich geändert, und du bist nun angemeldet." + success: "Dein Passwort wurde erfolgreich geändert und du bist nun angemeldet." success_unapproved: "Dein Passwort wurde erfolgreich geändert." email_login: invalid_token: "Entschuldige, aber der Link zum Anmelden ist zu alt. Wähle die Anmelden-Schaltfläche und nutze „Ich habe mein Passwort vergessen“, um einen neuen Link zu erhalten." @@ -779,7 +779,7 @@ de: user_auth_tokens: browser: chrome: "Google Chrome" - discoursehub: "DiscouseHub App" + discoursehub: "DiscouseHub-App" edge: "Microsoft Edge" firefox: "Firefox" ie: "Internet Explorer" @@ -806,7 +806,7 @@ de: windows: "Microsoft Windows" unknown: "unbekanntes Betriebssystem" change_email: - wrong_account_error: "Du bist mit dem falschen Account angemeldet. Bitte melde dich ab und versuche es noch einmal." + wrong_account_error: "Du bist mit dem falschen Konto angemeldet. Bitte melde dich ab und versuche es noch einmal." confirmed: "Deine E-Mail-Adresse wurde aktualisiert." please_continue: "Weiter zu %{site_name}" error: "Es gab einen Fehler beim Ändern deiner E-Mail-Adresse. Wird vielleicht diese Adresse bereits verwendet?" @@ -816,31 +816,31 @@ de: confirm: "Bestätigen" max_secondary_emails_error: "Sie haben das maximal zulässige Limit für sekundäre E-Mails erreicht." authorizing_new: - title: "Bestätige deine neue E-Mail" + title: "Bestätige deine neue E-Mail-Adresse" description: "Bitte bestätigen Sie, dass Ihre E-Mail-Adresse geändert werden soll in:" description_add: "Bitte bestätigen Sie, dass Sie eine alternative E-Mail-Adresse hinzufügen möchten:" authorizing_old: - title: "Ändere deine E-Mail Adresse" - description: "Bitte bestätige die Änderung deiner E-Mail Adresse" + title: "Ändere deine E-Mail-Adresse" + description: "Bitte bestätige die Änderung deiner E-Mail-Adresse" description_add: "Bitte bestätigen Sie, dass Sie eine alternative E-Mail-Adresse hinzufügen möchten:" old_email: "Alte Adresse: %{email}" new_email: "Neue Adresse: %{email}" - almost_done_title: "bestätige neue E-Mail Adresse" + almost_done_title: "Bestätige neue E-Mail-Adresse" almost_done_description: "Wir haben eine E-Mail an deine neue Adresse gesendet, um die Änderung zu bestätigen!" associated_accounts: revoke_failed: "Das Widerrufen deines Kontos bei %{provider_name} ist fehlgeschlagen." connected: "(verbunden)" activation: - action: "Klicke hier, um deinen Account zu aktivieren" + action: "Klicke hier, um dein Konto zu aktivieren" already_done: "Entschuldige, dieser Link zur Aktivierung des Benutzerkontos ist nicht mehr gültig. Ist dein Konto schon aktiviert?" please_continue: "Dein neues Konto ist jetzt bestätigt; du wirst auf die Startseite weitergeleitet." continue_button: "Weiter zu %{site_name}" welcome_to: "Willkommen bei %{site_name}!" approval_required: "Bevor du auf das Forum zugreifen kannst, muss dein neues Konto noch von einem Moderator genehmigt werden. Du erhältst eine E-Mail, sobald dies geschehen ist!" missing_session: "Wir können nicht feststellen, ob dein Konto erstellt wurde. Bitte stelle sicher, dass du Cookies aktiviert hast." - activated: "Bitte entschuldige, dieses Konto wurde bereits aktiviert." + activated: "Entschuldige, dieses Konto wurde bereits aktiviert." admin_confirm: - title: "Admnistrator-Konto bestätigen" + title: "Administrator-Konto bestätigen" description: "Soll %{target_username} (%{target_email}) wirklich ein Administrator werden?" grant: "Administrationsrechte vergeben" complete: "%{target_username} ist jetzt ein Administrator." @@ -851,17 +851,17 @@ de: post_action_types: off_topic: title: "Am Thema vorbei" - description: "Dieser Beitrag hat nichts mit dem Thema zu tun wie es im Titel und ersten Beitrag steht. Deshalb sollte er woanders hin verschoben werden." + description: "Dieser Beitrag ist nicht relevant für die aktuelle Diskussion im Sinne des Titels und des ersten Beitrags und sollte wohl verschoben werden." short_description: "Nicht relevant für die Diskussion" spam: - title: "Reklame" + title: "Spam" description: "Dieser Beitrag stellt Werbung oder Vandalismus dar. Er ist für das aktuelle Thema weder nützlich noch relevant." short_description: "Dies ist Werbung oder Vandalismus." - email_title: '"%{title}" wurde als Spam markiert' + email_title: '„%{title}“ wurde als Spam markiert' email_body: "%{link}\n\n%{message}" inappropriate: title: "Unangemessen" - description: 'Dieser Beitrag enthält Inhalte, die eine vernünftige Person als anstößig, beleidigend oder unsere Richtlinien verletzend auffassen würde.' + description: 'Dieser Beitrag enthält Inhalte, die eine vernünftige Person als anstößig, beleidigend oder unsere Community-Richtlinien verletzend auffassen würde.' short_description: 'Ein Verstoß gegen unsere Community-Richtlinien' notify_user: title: "Schreibe @%{username} eine Nachricht" @@ -871,18 +871,18 @@ de: email_body: "%{link}\n\n%{message}" notify_moderators: title: "Irgendetwas anderes" - description: "Auf diesen Beitrag sollte aus einem oben nicht aufgeführten Grund ein Moderator aufmerksam gemacht werden." + description: "Dieser Beitrag erfordert die Aufmerksamkeit des Teams aus einem anderen Grund, der oben nicht aufgeführt ist." short_description: "Erfordert aus einem anderen Grund die Aufmerksamkeit des Teams" - email_title: 'Ein Beitrag in "%{title}" sollte von einem Moderator begutachtet werden' + email_title: 'Ein Beitrag in „%{title}“ erfordert die Aufmerksamkeit des Teams' email_body: "%{link}\n\n%{message}" bookmark: title: "Lesezeichen" - description: "Lesezeichen auf diesen Beitrag setzen" - short_description: "Lesezeichen auf diesen Beitrag setzen" + description: "Diesen Beitrag mit Lesezeichen versehen" + short_description: "Diesen Beitrag mit Lesezeichen versehen" like: - title: "Gefällt mir" - description: "Dieser Beitrag gefällt mir" - short_description: "Dieser Beitrag gefällt mir" + title: "Liken" + description: "Diesen Beitrag liken" + short_description: "Diesen Beitrag liken" draft: sequence_conflict_error: title: "Fehler beim Entwurf" @@ -895,7 +895,7 @@ de: self: "Du hast noch keine Aktivität." others: "Keine Aktivität." no_bookmarks: - self: "Du hast keine Beiträge mit Lesezeichen; Lesezeichen erlauben es Dir schnell zu einem spezifischen Beitrag zu referenzieren." + self: "Du hast noch keine Beiträge mit einem Lesezeichen versehen. Lesezeichen erlauben dir den schnellen Zugriff auf bestimmte Beiträge." search: "Keine Lesezeichen mit der angegebenen Suchanfrage gefunden." others: "Keine Lesezeichen." no_likes_given: @@ -1006,7 +1006,7 @@ de: logging_in_as: Anmeldung als %{username} confirm_button: Anmeldung fertigstellen no_trust_level: "Entschuldige, du hast nicht die erforderliche Vertrauensstufe, um die Benutzer API zu nutzen." - generic_error: "Entschuldige, wir können keinen Benutzer API Schlüssel erstellen. Dieses Feature ist möglicherweise vom Site Administrator deaktiviert worden." + generic_error: "Entschuldige, wir können keinen Benutzer API-Schlüssel erstellen. Dieses Feature ist möglicherweise vom Site Administrator deaktiviert worden." scopes: message_bus: "Live-Aktualisierungen" notifications: "Benachrichtigungen lesen und leeren" @@ -1044,7 +1044,7 @@ de: agreed_flags: Akzeptierte Meldungen disagreed_flags: Abgelehnte Meldungen ignored_flags: Ignorierte Meldungen - score: Wertung + score: Score description: "Benutzer sortiert nach dem Verhältnis der Team-Reaktionen auf Meldungen (abgelehnt zu angenommen)" moderators_activity: title: "Moderator-Aktivität" @@ -1348,13 +1348,13 @@ de: dashboard: rails_env_warning: "Dein Server läuft im %{env}-Modus." host_names_warning: "Deine config/database.yml-Datei verwendet localhost als Hostname. Trage hier den Hostnamen deiner Website ein." - sidekiq_warning: 'Sidekiq läuft nicht. Viele Aufgaben, wie zum Beispiel das Versenden von E-Mails, werden asynchron durch Sidekiq ausgeführt. Bitte stell sicher, dass mindestens ein Sidekiq-Prozess läuft. Mehr über Sidekiq erfährst du hier (en).' + sidekiq_warning: 'Sidekiq läuft nicht. Viele Aufgaben – wie zum Beispiel das Versenden von E-Mails – werden asynchron durch Sidekiq ausgeführt. Bitte stell sicher, dass mindestens ein Sidekiq-Prozess läuft. Mehr über Sidekiq erfährst du hier.' queue_size_warning: "Eine hohe Anzahl an Aufgaben (%{queue_size}) befindet sich in der Warteschlange. Dies könnte auf ein Problem mit Sidekiq hinweisen oder du musst zusätzliche Sidekiq Worker starten." memory_warning: "Dein Server läuft mit weniger als 1 GB Hauptspeicher. Mindestens 1 GB Hauptspeicher werden empfohlen." google_oauth2_config_warning: 'Der Server ist für Anmeldung und Login mit Google OAuth2 (enable_google_oauth2_logins) konfiguriert, aber die Client-ID und das Client-Geheimwerte sind nicht gesetzt. Trage diese in den Einstellung ein. Eine Anleitung zu diesem Thema findest du hier.' - facebook_config_warning: 'Der Server erlaubt die Anmeldung mit Facebook (enable_facebook_logins), aber die App ID und der Geheimcode sind nicht gesetzt. Besuche die Einstellungen um die fehlenden Einträge hinzuzufügen. Besuche den Leitfaden um mehr zu erfahren.' - twitter_config_warning: 'Der Server erlaubt die Anmeldung mit Twitter (enable_twitter_logins), aber der Schlüssel und der Geheimcode sind nicht gesetzt. Besuche die Einstellungen um die fehlenden Einträge hinzuzufügen. Besuche den Leitfaden um mehr zu erfahren.' - github_config_warning: 'Der Server erlaubt die Anmeldung mit GitHub (enable_github_logins), aber die Kunden-ID und der Geheimcode sind nicht gesetzt. Besuche die Einstellungen um die fehlenden Einträge hinzuzufügen. Besuche den Leitfaden um mehr zu erfahren.' + facebook_config_warning: 'Der Server ist so konfiguriert, dass er die Registrierung und die Anmeldung mit Facebook erlaubt (enable_facebook_logins), aber die App-ID und die geheimen Werte der App sind nicht festgelegt. Gehe zu den Website-Einstellungen und aktualisiere sie. Erfahre in dieser Anleitung mehr dazu.' + twitter_config_warning: 'Der Server ist so konfiguriert, dass er die Registrierung und die Anmeldung mit Twitter erlaubt (enable_twitter_logins), aber der Schlüssel und die geheimen Werte sind nicht festgelegt. Gehe zu den Website-Einstellungen und aktualisiere sie. Erfahre in dieser Anleitung mehr dazu.' + github_config_warning: 'Der Server ist so konfiguriert, dass er die Registrierung und die Anmeldung mit GitHub erlaubt (enable_github_logins), aber die Client-ID und die geheimen Werte sind nicht festgelegt. Gehe zu den Website-Einstellungen und aktualisiere sie. Erfahre in dieser Anleitung mehr dazu.' s3_config_warning: 'Der Server ist so eingestellt, dass Dateien auf S3 hochgeladen werden, aber mindestens eine der folgenden Einstellungen ist nicht gesetzt: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_upload_bucket. Gehe zu den Seiten-Einstellungen und aktualisiere die Einstellungen. Siehe „How to set up image uploads to S3?“ um mehr zu erfahren.' s3_backup_config_warning: 'Der Server ist so eingestellt, dass Backups auf S3 hochgeladen werden, aber mindestens eine der folgenden Einstellungen ist nicht gesetzt: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_backup_bucket. Gehe zu den Seiten-Einstellungen und aktulisiere die Einstellungen. Siehe „How to set up image uploads to S3?“ um mehr zu erfahren.' s3_cdn_warning: 'Der Server ist so konfiguriert, dass er Dateien nach S3 hochlädt, aber es ist kein S3 CDN konfiguriert. Dies kann zu teuren S3-Kosten und langsamerer Seitenleistung führen. Siehe "Objektspeicher für Uploads verwenden", um mehr zu erfahren.' @@ -1362,8 +1362,8 @@ de: failing_emails_warning: "%{num_failed_jobs} E-Mails konnten nicht versendet werden. Überprüfe deine app.yml und stelle sicher, dass die E-Mail Servereinstellungen korrekt gesetzt sind. \nSieh dir hier die nicht versendeten E-Mails an." subfolder_ends_in_slash: "Deine Installation in einem Pfad ist nicht korrekt, DISCOURSE_RELATIVE_URL_ROOT endet mit einem Schrägstrich." email_polling_errored_recently: - one: "Beim Abrufen von E-Mails ist in den letzten 24 Stunden ein Fehler aufgetreten. Weitere Informationen findest du im Fehlerprotokoll." - other: "Beim Abrufen von E-Mails sind in den letzten 24 Stunden %{count} Fehler aufgetreten. Weitere Informationen findest du im Fehlerprotokoll." + one: "Beim Abrufen von E-Mails ist in den letzten 24 Stunden ein Fehler aufgetreten. Weitere Informationen findest du in den Protokollen." + other: "Beim Abrufen von E-Mails sind in den letzten 24 Stunden %{count} Fehler aufgetreten. Weitere Informationen findest du in den Protokollen." missing_mailgun_api_key: "Der Server ist so eingestellt, dass E-Mails über Mailgun versendet werden, aber du hast keinen API-Schlüssel angegeben, um Webhook-Nachrichten zu überprüfen." bad_favicon_url: "Das Favicon lässt sich nicht laden. Prüfe die Favicon-Einstellung in den Einstellungen." poll_pop3_timeout: "Die Verbindung zum POP3-Server schlägt mit einer Zeitüberschreitung fehl. Eingehende E-Mails konnten nicht abgerufen werden. Überprüfe deine POP3-Einstellungen." @@ -1406,9 +1406,9 @@ de: allow_duplicate_topic_titles_category: "Lassen Sie Themen mit identischen, doppelten Titeln zu, wenn die Kategorie unterschiedlich ist. allow_duplicate_topic_titles muss false sein." unique_posts_mins: "Minuten, nach denen ein Benutzer denselben Inhalt noch einmal schreiben kann." educate_until_posts: "Zeige das Hilfe-Panel im Editor sobald ein Benutzer einen seiner ersten (n) Beiträge zu schreiben beginnt." - title: "Der Name dieser Site, wird für das Title-Tag verwendet." - site_description: "Beschreibe diese Site in einem Satz. Wird für das \"description\" Meta-Tag verwendet." - short_site_description: "Kurze Beschreibung, die im Title-Tag auf der Webseite verwendet wird." + title: "Der Name dieser Website. Wird für das „title“-Tag verwendet." + site_description: "Beschreibe diese Website in einem Satz. Wird für das „description“-Meta-Tag verwendet." + short_site_description: "Kurze Beschreibung, die im „title“-Tag auf der Homepage verwendet wird." contact_url: "Kontakt-URL für diese Seite. Angezeigt auf der /about Seite für dringende Fragen." crawl_images: "Lade Bilder von fremden URLs herunter, um ihre Höhe und Breite zu bestimmen." download_remote_images_to_local: "Lade eine Kopie von extern gehosteten Bildern herunter und ersetze Links in Beiträgen entsprechend; dies verhindert defekte Bilder." @@ -1439,7 +1439,7 @@ de: enable_inline_onebox_on_all_domains: "Ignoriere die `inline_onebox_domain_allowlist`-Seiteneinstellung und erlaube Inline-Oneboxen für alle Domains" force_custom_user_agent_hosts: "Hosts, bei denen der spezifische Onebox Useragent für alle Anfragen genutzt wird. (Besonders hilfreich bei Hosts, die Zugriffe per Useragent limitieren)." max_oneboxes_per_post: "Maximale Anzahl von Oneboxes in einem Beitrag." - facebook_app_access_token: "Ein Token, das aus Ihrer Facebook-App-ID und Ihrem Geheimnis generiert wurde. Wird verwendet, um Instagram-Oneboxen zu generieren." + facebook_app_access_token: "Ein Token, das aus deiner Facebook-App-ID und deinem Geheimnis generiert wird. Wird verwendet, um Instagram-Oneboxes zu generieren." logo: "Das Logo oben links auf deiner Seite. Verwende ein breites rechteckiges Bild mit einer Höhe von 120 Pixeln und einem Seitenverhältnis von mindestens 3:1. Wenn leer, wird die Seitenüberschrift angezeigt." logo_small: "Kleine Logo-Grafik oben links auf der Seite, die beim Herunterscrollen angezeigt wird. Verwende ein quadratisches 120 × 120 Bild . Falls leer wird ein \"Home\" Zeichen angezeigt." digest_logo: "Das alternative Logo oben in den E-Mail-Zusammenfassungen deiner Seite. Verwende ein breites rechteckiges Bild. Verwende kein SVG-Bild. Wenn leer, wird das Bild aus der `logo`-Einstellung verwendet." @@ -1458,10 +1458,10 @@ de: email_custom_headers: "Eine durch senkrechte Striche getrennte Liste von eigenen E-Mail Headerzeilen" email_subject: "Anpassbares Betreff-Format für Standard-E-Mails. Siehe https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "Liefert mehr Details für Benutzer darüber, warum sie keinen Zugriff auf ein spezielles Thema haben. Hinweis: Dies ist weniger sicher, weil Benutzer so herausfinden, ob eine URL zu einem existierenden Thema führt." - enforce_second_factor: "Zwingt die Benutzer, die Zwei-Faktor-Authentifizierung zu aktivieren. Wähle \"all\", um sie für alle Benutzer zu erzwingen. Wähle \"staff\", um sie nur für Teammitglieder zu erzwingen." + enforce_second_factor: "Zwingt die Benutzer, die Zwei-Faktor-Authentifizierung zu aktivieren. Wähle \"all\", um sie für alle Benutzer zu erzwingen. Wähle \"staff\", um sie nur für Team-Mitglieder zu erzwingen." force_https: "Erzwinge HTTPS für deine Site. ACHTUNG: Aktiviere dies nicht, bevor HTTPS nicht vollständig eingerichtet ist und auf jeden Fall überall funktioniert! Hast du alle CDN-Netzwerke, alle Logins über Soziale Netzwerke, alle externe Logos / Abhängigkeiten geprüft, um sicherzustellen, dass sie auch alle HTTPS-kompatibel sind?" same_site_cookies: "Benutze dieselben Site-Cookies, sie entfernen alle Vektoren Cross Site Request Forgery auf unterstützten Browsern (Lax oder Strict). Warnung: Strict funktioniert nur auf Seiten, die das Login erzwingen und eine externe Authentifizierungsmethode verwenden." - summary_score_threshold: "Mindestpunktzahl, die ein Beitrag benötigt, um in der \"Thema zusammenfassen\"-Ansicht zu erscheinen." + summary_score_threshold: "Mindestscore, den ein Beitrag benötigt, um in der „Thema zusammenfassen“-Ansicht zu erscheinen." summary_posts_required: "Minimale Beiträge in einem Thema, bevor „Dieses Thema zusammenfassen“ aktiviert ist. Änderungen dieser Einstellung werden rückwirkend innerhalb einer Woche angewendet." summary_likes_required: "Minimale Likes in einem Thema, bevor „Dieses Thema zusammenfassen“ aktiviert ist. Änderungen dieser Einstellung werden rückwirkend innerhalb einer Woche angewendet." summary_percent_filter: "Zeige die besten (n)% der Beiträge eines Themas in der \"Thema zusammenfassen\"-Ansicht." @@ -1502,18 +1502,17 @@ de: invite_code: "Der Benutzer muss diesen Code eingeben, damit die Kontoregistrierung zugelassen wird. Wird ignoriert, wenn er leer ist (Groß-/Kleinschreibung wird nicht beachtet)" approve_suspect_users: "Fügen Sie verdächtige Benutzer zur Warteschlange hinzu. Verdächtige Benutzer haben eine Biografie/Website eingegeben, haben aber keine Leseaktivität." review_every_post: "Alle Beiträge müssen überprüft werden. WARNUNG! NICHT FÜR STARK FREQUENTIERTE WEBSITES EMPFOHLEN." - pending_users_reminder_delay: "Benachrichtige die Moderatoren, falls neue Benutzer mehr als so viele Stunden auf ihre Genehmigung gewartet haben. Stelle -1 ein, um diese Benachrichtigungen zu deaktivieren." persistent_sessions: "Benutzer bleiben angemeldet, wenn der Webbrowser geschlossen wird" maximum_session_age: "Benutzer bleiben (n) Stunden nach ihrem letzten Besuch angemeldet" - ga_version: "Zu verwendende Version von Google Universal Analytics: v3 (analyse.js), v4 (gtag)" + ga_version: "Zu verwendende Version von Google Universal Analytics: v3 (analytics.js), v4 (gtag)" ga_universal_tracking_code: "Google Universal Analytics Tracking-Code-ID, z. B.: UA-12345678-9; siehe https://google.com/analytics" ga_universal_domain_name: "Google Universal Analytics Domainname, z. B.: mysite.com; siehe https://google.com/analytics" ga_universal_auto_link_domains: "Aktiviere die domänenübergreifende Verfolgung von Google Universal Analytics. Bei ausgehenden Links zu diesen Domänen wird die Client-ID hinzugefügt. Siehe Googles Cross-Domain-Tracking-Handbuch." - gtm_container_id: "Google Tag Manager Container-ID. z.B.: GTM-ABCDEF.
    Hinweis: Skripte von Drittanbietern, die von GTM geladen werden, müssten in der 'content security policy script src' Liste erlaubt werden." + gtm_container_id: "Google-Tag-Manager-Container-ID. Zum Beispiel: GTM-ABCDEF.
    Hinweis: Skripte von Drittanbietern, die von GTM geladen werden, müssen unter Umständen in der Liste „content security policy script src“ erlaubt werden („allowlisted“)." enable_escaped_fragments: "Als Fallback die Ajax-Crawling-API von Google verwenden, wenn keine Suchmaschine deaktiviert wurde. Siehe https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" moderators_manage_categories_and_groups: "Moderatoren erlauben, Kategorien und Gruppen zu verwalten" cors_origins: "Erlaubte Adressen für Cross-Origin-Requests (CORS). Jede Adresse muss http:// oder https:// enthalten. Die Umgebungsvariable DISCOURSE_ENABLE_CORS muss gesetzt sein, um CORS zu aktivieren." - use_admin_ip_allowlist: "Administratoren können sich nur anmelden, wenn sie sich an einer IP-Adresse befinden, die in der Liste „Abgeprüfte IP-Adressen“ („Admin“ > „Protokolle“ > „Abgeschirmte Ips“) definiert ist." + use_admin_ip_allowlist: "Administratoren können sich nur anmelden, wenn sie eine IP-Adresse nutzen, die in der Liste der gefilterten IPs definiert ist (Administration > Protokolle > Gefilterte IPs)." blocked_ip_blocks: "Eine Liste von privaten IP-Blöcken, die nie von Discourse indiziert werden sollen" allowed_internal_hosts: "Eine Liste interner Hostnamen, die Discourse sicher abrufen kann für Oneboxing und andere Zwecke" allowed_onebox_iframes: "Eine Liste der iframe src-Domänen, die über Onebox-Einbettungen zulässig sind. `*` erlaubt alle Standard-Onebox-Engines." @@ -1542,8 +1541,8 @@ de: remove_full_quote: "Automatisch vollständige Zitate aus direkten Antworten entfernen." suppress_reply_when_quoting: "Verstecke das erweiterbare „Antwort auf“-Feld in einem Beitrag, wenn der Beitrag den beantworteten Beitrag zitiert." max_reply_history: "Maximale Anzahl an Antworten beim Ausklappen von in-reply-to" - topics_per_period_in_top_summary: "Anzahl der Themen, die in der Top-Themübersicht angezeigt werden." - topics_per_period_in_top_page: "Anzahl der Themen, die in der mit \"Mehr zeigen\" erweiterten Top-Themenübersicht angezeigt werden." + topics_per_period_in_top_summary: "Anzahl der Themen, die in der Standard-Zusammenfassung der angesagten Themen angezeigt werden." + topics_per_period_in_top_page: "Anzahl der Themen, die unter „Mehr anzeigen“ für angesagte Themen angezeigt werden." redirect_users_to_top_page: "Verweise neue und länger abwesende Benutzer automatisch zur Angesagt-Seite" top_page_default_timeframe: "Standardzeitfenster für die oberste, angezeigte Seite." moderators_view_emails: "Erlaube Moderatoren, Benutzer E-Mails einzusehen." @@ -1608,9 +1607,9 @@ de: enable_facebook_logins: "Facebook-Authentifizierung aktivieren, erfordert facebook_api_id und facebook_app_secret. Siehe Configuring Facebook login for Discourse." facebook_app_id: "App-ID für die Facebook-Authentifizierung und -Freigabe, registriert unter https://developers.facebook.com/apps" facebook_app_secret: "App-Secret für Facebook-Authentifizierung, registriert unter https://developers.facebook.com/apps" - enable_github_logins: "GitHub-Authentifizierung aktivieren, erfordert github_client_id und github_client_secret. Siehe Configuring GitHub login for Discourse." - github_client_id: "Client-ID für GitHub-Authentifizierung, registriert auf https://github.com/settings/developers" - github_client_secret: "Client-Secret für GitHub-Authentifizierung, registriert auf https://github.com/settings/developers" + enable_github_logins: "Aktiviere die GitHub-Authentifizierung, erfordert github_client_id und github_client_secret. Siehe GitHub-Anmeldung für Discourse konfigurieren." + github_client_id: "Client-ID für die GitHub-Authentifizierung, registriert unter https://github.com/settings/developers" + github_client_secret: "Client-Geheimnis für die GitHub-Authentifizierung, registriert unter https://github.com/settings/developers" enable_discord_logins: "Benutzern erlauben, sich mit Discord zu authentifizieren?" discord_client_id: 'Discord Client ID (benötigst du eine? besuchedas Discord Entwickler Portal)' discord_secret: "Discord geheimer Schlüssel" @@ -1621,7 +1620,7 @@ de: automatic_backups_enabled: "Automatische Backups aktivieren. Die Backups werden im eingestellten Zeitintervall erstellt." backup_frequency: "Die Anzahl von Tagen zwischen Backups." s3_backup_bucket: "Der entfernte Speicherort für Ihre Sicherungen. WARNUNG: Stellen Sie sicher, dass es sich um einen privaten Speicherort handelt." - s3_endpoint: "Dieser Endpunkt kann so angepasst werden, dass er die Sicherung an einen S3-kompatiblen Service wie DigitalOcean Spaces oder Minio übertragt. WARNUNG: Verwende den Standard bei Verwendung von AWS S3." + s3_endpoint: "Der Endpunkt kann so angepasst werden, dass er das Back-up an einen S3-kompatiblen Service wie DigitalOcean Spaces oder Minio überträgt. WARNUNG: Bei Verwendung von AWS S3 leer lassen." s3_configure_tombstone_policy: "Aktiviere die automatische Löschregel für Grabstein-Uploads. WICHTIG: Wenn deaktiviert, wird kein Speicherplatz freigegeben, wenn Uploads gelöscht werden." s3_disable_cleanup: "Verhindern Sie das Entfernen alter Sicherungen aus S3, wenn mehr Sicherungen als maximal zulässig vorhanden sind." enable_s3_inventory: "Erstelle Berichte und überprüfe Uploads mit Amazon S3-Bestand. WICHTIG: Benötigt gültige S3-Anmeldeinformationen (sowohl access_key_id als auch secret_access_key)." @@ -1633,9 +1632,9 @@ de: active_user_rate_limit_secs: "Anzahl der Sekunden, nach denen das 'last_seen_at'-Feld aktualisiert wird." verbose_localization: "Erweiterte Lokalisierungstipps in der Benutzeroberfläche anzeigen" previous_visit_timeout_hours: "Anzahl der Stunden, die ein Besuch dauert, bevor er als 'früherer' Besuch gezählt wird." - top_topics_formula_log_views_multiplier: "Wert von Log Ansichten Multiplikator (n) in Top Themen Formel: `log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_first_post_likes_multiplier: "Wert für den Multiplikator (n) für die ersten „Gefällt mir“-Angaben pro Beitrag in der Formel für die besten Beiträge: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_least_likes_per_post_multiplier: "Wert für den Multiplikator (n) für die wenigsten „Gefällt mir“-Angaben pro Beitrag in der Formel für die besten Beiträge: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" + top_topics_formula_log_views_multiplier: "Wert des log-Ansichten-Multiplikators (n) in der Formel für angesagte Themen: `log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" + top_topics_formula_first_post_likes_multiplier: "Wert des Multiplikators (n) für die „Gefällt mir“-Angaben des ersten Beitrags in der Formel für angesagte Themen: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" + top_topics_formula_least_likes_per_post_multiplier: "Wert des Multiplikators (n) für die wenigsten „Gefällt mir“-Angaben pro Beitrag in der Formel für angesagte Themen: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" enable_safe_mode: "Erlaube Benutzern, den abgesicherten Modus zu betreten, um Plugins zu debuggen." rate_limit_create_topic: "Nach Erstellen eines Themas muss ein Benutzer (n) Sekunden warten, bevor ein weiteres Thema erstellt werden kann." rate_limit_create_post: "Nach Schreiben eines Beitrags muss ein Benutzer (n) Sekunden warten, bevor ein weiterer Beitrag erstellt werden kann." @@ -1668,7 +1667,7 @@ de: purge_unactivated_users_grace_period_days: "Frist (in Tagen), bevor ein Benutzer gelöscht wird, der sein Konto nicht aktiviert hat. Setze sie auf 0, um nicht aktivierte Benutzer nie zu bereinigen." enable_s3_uploads: "Speichere hochgeladene Dateien auf Amazon S3. WICHTIG: benötigt gültige S3 Anmeldedaten (sowohl access_key_id als auch secret_access_key)." s3_use_iam_profile: 'Verwende ein AWS EC2 Instanz-Profil, um Zugriff auf den S3-Bucket zu gewähren. BEACHTE: Das Aktivieren setzt voraus, dass Discourse in einer entsprechend konfigurierten EC2-Instanz läuft, und überschreibt die Einstellungen „s3 access key id“ und „s3 secret access key”.' - s3_upload_bucket: "Der Name des Amazon S3 Buckets, in dem hochgeladene Dateien gespeichert werden sollen. ACHTUNG: nur Kleinbuchstaben, keine Punkte, keine Unterstriche." + s3_upload_bucket: "Der Name des Amazon-S3-Buckets, in dem hochgeladene Dateien gespeichert werden sollen. ACHTUNG: nur Kleinbuchstaben, keine Punkte, keine Unterstriche." s3_access_key_id: "Die Amazon S3 Zugangsschlüssel-ID, die für den Upload von Bildern, Anhängen und Backups verwendet wird." s3_secret_access_key: "Die Amazon S3 geheime Zugangsschlüssel-ID, die für den Upload von Bildern, Anhängen und Backups verwendet wird." s3_region: "Der Name der Amazon S3 Region, die für das Hochladen von Bildern und Sicherungen verwendet wird." @@ -1678,7 +1677,7 @@ de: external_system_avatars_url: "URL des externen Profilbild-Dienstes. Erlaubte Platzhalter sind {username} {first_letter} {color} {size}" external_emoji_url: "URL des externen Dienstes für Emoji-Bilder. Lasse das Feld leer, um es zu deaktivieren." use_site_small_logo_as_system_avatar: "Verwende das kleine Logo der Website anstelle des Avatars des Systembenutzers. Das Logo muss vorhanden sein." - restrict_letter_avatar_colors: "Eine Liste 6stelliger hexadezimaler Farbwerte für den Letter Avatar Hintergrund." + restrict_letter_avatar_colors: "Eine Liste von 6-stelligen hexadezimalen Farbwerten, die für den Letter-Avatar-Hintergrund verwendet werden." selectable_avatars_enabled: "Forciere Benutzer, ein Avatar aus der Liste auszuwählen." selectable_avatars: "Liste von Avataren, aus der Benutzer wählen können." allow_all_attachments_for_group_messages: "Erlaube alle E-Mail-Anhänge für Gruppen-Nachrichten." @@ -1816,7 +1815,7 @@ de: display_name_on_email_from: "Zeige vollständige Namen im Absender-Feld von E-Mails" unsubscribe_via_email: "Erlaube es Benutzern eine E-Mail mit dem Betreff oder Text: \"unsubscribe\" zum Abbestellen der E-Mails zu senden." unsubscribe_via_email_footer: "Füge einen `mailto:`-Link zum Abbestellen im Fußbereich ausgehender E-Mails hinzu" - delete_email_logs_after_days: "Lösche E-Mail Logs nach (N) Tagen. 0 um sie für immer zu behalten." + delete_email_logs_after_days: "Lösche E-Mail-Protokolle nach (N) Tagen. Wähle 0, um sie für immer zu behalten." disallow_reply_by_email_after_days: "Verbiete Antworten per E-Mail nach (N) Tagen. 0 um es unbegrenzt zu lassen." max_emails_per_day_per_user: "Maximale Zahl an E-Mails, die Benutzern gesendet werden. 0 zum Deaktivieren der Grenze." enable_staged_users: "Erstelle automatisch vorbereitete Benutzer, wenn eingehende E-Mails verarbeitet werden." @@ -1891,7 +1890,7 @@ de: detect_custom_avatars: "Aktiviere diese Option, um zu überprüfen, ob Benutzer eigene Profilbilder hochgeladen haben." max_daily_gravatar_crawls: "Wie oft pro Tag Discourse höchstens auf Gravatar nach benuterdefinierten Avataren suchen soll." public_user_custom_fields: "Eine Liste von benutzerdefinertern Feldern, die über die API-Schnittstelle abgerufen werden können." - staff_user_custom_fields: "Eine Liste von benutzerdefinertern Feldern, die von Teammitgliedern über die API-Schnittstelle abgerufen werden können." + staff_user_custom_fields: "Eine Liste von benutzerdefinertern Feldern, die von Team-Mitgliedern über die API-Schnittstelle abgerufen werden können." enable_user_directory: "Aktiviert ein durchsuchbares Benutzerverzeichnis" enable_group_directory: "Aktiviert ein durchsuchbares Gruppenverzeichnis" enable_category_group_moderation: "Gruppen erlauben, Inhalte in bestimmten Kategorien zu moderieren" @@ -1908,7 +1907,7 @@ de: ignored_users_count_message_threshold: "Informiere die Moderatoren wenn ein bestimmter Benutzer von so vielen anderen Benutzern ignoriert wird." ignored_users_message_gap_days: "Wie lange gewartet werden soll, bevor die Moderatoren erneut über einen Benutzer informiert werden, der von vielen anderen ignoriert wird." clean_up_inactive_users_after_days: "Anzahl von Tagen, nach denen ein inaktiver Benutzer (Vertrauensstufe 0 ohne irgendwelche Beiträge) entfernt wird. Zum Deaktivieren auf 0 setzen." - clean_up_unused_staged_users_after_days: "Anzahl der Tage, bevor ein nicht verwendeter bereitgestellter Benutzer (ohne Beiträge) entfernt wird. Zum Deaktivieren der Bereinigung auf 0 setzen." + clean_up_unused_staged_users_after_days: "Anzahl der Tage, bevor ein inaktiver vorbereiteter Benutzer (ohne Beiträge) entfernt wird. Zum Deaktivieren der Bereinigung auf 0 setzen." user_selected_primary_groups: "Erlaube Benutzern, ihre primäre Gruppe selbst zu setzen" max_notifications_per_user: "Die maximale Anzahl von Benachrichtigungen pro Benutzer, wenn diese Anzahl überschritten wird, werden alte Benachrichtigungen gelöscht. Wöchentlich erzwungen. Zum Deaktivieren auf 0 setzen" allowed_user_website_domains: "Die Website des Nutzers wird gegen diese Domains verifiziert. Pipe-getrennte Liste." @@ -1916,21 +1915,21 @@ de: sequential_replies_threshold: "Anzahl an Beiträgen die ein Benutzer machen muss, um benachrichtigt zu werden, dass er zu viele aufeinanderfolgende Antworten schreibt." get_a_room_threshold: "Anzahl an Beiträgen, die ein Benutzer im gleichen Thema an die gleiche Person schreiben muss, bevor er gewarnt wird." enable_mobile_theme: "Mobilgeräte verwenden eine mobile Darstellung mit der Möglichkeit zur vollständigen Site zu wechseln. Deaktiviere diese Option, wenn du ein eigenes Full-Responsive-Stylesheet verwenden möchtest." - dominating_topic_minimum_percent: "Anteil der Nachrichten eines Themas in Prozent, die ein einzelner Benutzer verfassen darf, bevor dieser Benutzer darauf hingewiesen wird, dass er dieses Thema dominiert." + dominating_topic_minimum_percent: "Anteil der Beiträge eines Themas in Prozent, die ein einzelner Benutzer verfassen darf, bevor dieser Benutzer darauf hingewiesen wird, dass er dieses Thema dominiert." disable_avatar_education_message: "Deaktiviert den Hinweis für Benutzer, dass sie ihr Profilbild ändern können" suppress_uncategorized_badge: "Zeige kein Abzeichen für unkategorisierte Themen in der Themenliste." header_dropdown_category_count: "Wie viele Kategorien im der Header Dropdown-Liste angezeigt werden können." - permalink_normalizations: "Diesen regulären Ausdruck anwenden, bevor Permalinks verarbeitet werden; Beispiel: /(topic.*)\\?.*/\\1 wird Query-Strings von Themen-Routen entfernen. Format: regulärer Ausdruck + String, benutze \\1 usw. um Teilausdrücke zu verwenden" + permalink_normalizations: "Diesen regulären Ausdruck anwenden, bevor Permalinks verarbeitet werden; Beispiel: /(topic.*)\\?.*/\\1 wird Query-Strings von Themen-Routen entfernen. Format: regulärer Ausdruck + String, benutze \\1 usw., um Teilausdrücke zu verwenden" global_notice: "Zeigt allen Besuchern eine DRINGENDE NOTFALL-Meldung in Form eines nicht ausblendbaren, global sichtbaren Banners an. Leere den Inhalt, um sie wieder auszublenden (HTML ist erlaubt)." disable_system_edit_notifications: "Unterdrückt Bearbeitungshinweise durch den System-Benutzer, wenn die 'download_remote_images_to_local' Einstellung aktiviert ist." notification_consolidation_threshold: "Anzahl von Like- oder Mitgliedschaftsanfragen-Benachrichtigungen, bevor die Benachrichtigungen zu einer einzelnen zusammengefasst werden. Zum Deaktivieren 0 setzen." likes_notification_consolidation_window_mins: "Zeitfenster in Minuten, in dem mehrere Like-Benachrichtigungen zu einer einzelnen Benachrichtigung zusammengefasst werden, sobald der Schwellenwert erreicht wird. Der Schwellenwert kann mittels `SiteSetting.notification_consolidation_threshold` eingestellt werden." automatically_unpin_topics: "Themen automatisch loslösen, wenn ein Benutzer das Ende erreicht." read_time_word_count: "Wörter pro Minute für die Berechnung der geschätzten Lesezeit." - topic_page_title_includes_category: "Themen-Seite title tag enthält einen Kategorienamen." + topic_page_title_includes_category: "„title“-Tag der Themen-Seite enthält einen Kategorienamen." native_app_install_banner_ios: "Zeigt das DiscourseHub Applikationsbanner auf iOS Geräten für Stammgäste (Vertrauensstufe 1 und höher) an." native_app_install_banner_android: "Zeigt das DiscourseHub Applikationsbanner auf Android Geräten für Stammgäste (Vertrauensstufe 1 und höher) an." - app_association_android: "Inhalte vom .well-known/assetlinks.json Endpunkt, für Googles Digital Asset Links API verwendet." + app_association_android: "Inhalte des Endpunkts .well-known/assetlinks.json, verwendet für die Digital Asset Links API von Google." app_association_ios: "Inhalte vom apple-app-site-association Endpunkt, zum Anlegen von Universal Links zwischen dieser Sieite und iOS Apps verwendet." share_anonymized_statistics: "Anonymisierte Nutzungsdaten teilen." auto_handle_queued_age: "Bearbeite Einträge automatisch, die so viele Tage auf Überprüfung warten. Meldungen werden ignorieren. Beiträge und Benutzer in der Warteschlange werden zurückgewiesen. Setze den Wert auf 0, um diese Funktion zu deaktivieren." @@ -1941,7 +1940,7 @@ de: display_name_on_posts: "Zeige zusätzlich zum @Benutzernamen auch den vollen Namen des Benutzers bei seinen Beiträgen." show_time_gap_days: "Wenn zwei Beiträge eine bestimmte Anzahl an Tagen auseinander liegen, zeige die Zeitdifferenz im Beitrag an." short_progress_text_threshold: "Sobald die Anzahl an Beiträgen in einem Thema diese Nummer übersteigt, zeigt der Fortschrittsbalken nur noch die aktuelle Beitragsnummer. Dieser Wert sollte angepasst werden, falls die die Breite des Fortschrittsbalkens verändert wird." - default_code_lang: "Standard Syntax Highlighting, dass auf GitHub Code Blöcke angewendet wird. (auto, nohighlight, ruby, python etc.)" + default_code_lang: "Standardmäßiges Syntax-Highlighting für GitHub-Codeblöcke (auto, nohighlight, ruby, python usw.)" warn_reviving_old_topic_age: "Wenn jemand beginnt auf ein Thema zu antworten, dessen letzte Antwort älter als diese Anzahl an Tagen ist, wird eine Warnung angezeigt. Deaktiviere dies durch setzen auf 0." autohighlight_all_code: "Erzwinge Syntaxhervorhebung für alle Quellcode-Blöcke, auch dann wenn keine Sprache angeben wurde." highlighted_languages: "Aktivierter Regeln für das Syntax-Hervorhebung. (Warnung: Das Aktivieren zu vieler Sprachen kann die Leistung beeinträchtigen) siehe: https://highlightjs.org/static/demo für eine Demonstration" @@ -2017,35 +2016,35 @@ de: default_categories_watching_first_post: "Liste von Kategorien, in denen der erste Beiträge in jedem neuen Thema automatisch beobachtet wird." default_categories_regular: "Liste der Kategorien, die nicht standardmäßig stumm sind. Nützlich, wenn `mute_all_categories_by_default` Einstellung aktiviert ist." mute_all_categories_by_default: "Setze die Standard E-Mail Benachrichtigungs-Stufe für alle Kategorien auf stummgeschaltet. Benötigt die Zustimmung der Benutzer, damit sie in \"neueste\" und Kategorien-Seiten erscheinen. Um den Standard für anonyme Benutzer anzupassen, müssen 'default_categories_' Einstellungen gesetzt werden." - default_tags_watching: "Liste der Tags, die standardmäßig beobachtet werden." - default_tags_tracking: "Liste der Tags, die standardmäßig verfolgt werden." - default_tags_muted: "Liste der Tags, die standardmäßig stummgeschaltet werden." - default_tags_watching_first_post: "Liste der Tags, deren erster Beitrag pro Thema standardmäßig beobachtet wird." + default_tags_watching: "Liste der Schlagwörter, die standardmäßig beobachtet werden." + default_tags_tracking: "Liste der Schlagwörter, die standardmäßig verfolgt werden." + default_tags_muted: "Liste der Schlagwörter, die standardmäßig stummgeschaltet werden." + default_tags_watching_first_post: "Liste der Schlagwörter, deren erster Beitrag pro Thema standardmäßig beobachtet wird." default_text_size: "Schriftgröße, die standardmäßig ausgewählt ist" default_title_count_mode: "Standardmodus für den Seitentitel-Zähler" retain_web_hook_events_period_days: "Anzahl an Tagen, die Web-Hook-Ereigniseinträge aufbewahrt werden" retry_web_hook_events: "Fehlgeschlagene Web-Hook-Ereignisse automatisch 4-mal erneut versuchen. Die Wartezeiten zwischen den Versuchen betragen 1, 5, 25 und 125 MInuten." - revoke_api_keys_days: "Anzahl der Tage, bevor ein nicht verwendeter API Schlüssel automatisch zurückgezogen wird (0 für nie)" + revoke_api_keys_days: "Anzahl der Tage, bevor ein nicht verwendeter API-Schlüssel automatisch zurückgezogen wird (0 für nie)" allow_user_api_keys: "Erlaube das Generieren von Benutzer-API-Schlüsseln" allow_user_api_key_scopes: "Liste erlaubter Scopes für Benutzer-API-Schlüssel" - min_trust_level_for_user_api_key: "Erforderliche Vertrauensstufe für die Generierung von Benutzer API Schlüsseln" + min_trust_level_for_user_api_key: "Erforderliche Vertrauensstufe für die Generierung von Benutzer API-Schlüsseln" allowed_user_api_auth_redirects: "Zulässige URL für Authentifizierungs-Umleitung für Benutzer-API-Schlüssel. Das Platzhaltersymbol * kann verwendet werden, um jeden Bestandteil der Adresse zu ersetzen (z.B. www.example.com/*)." allowed_user_api_push_urls: "Erlaubte URL für Server-Push zur Benutzer API" - expire_user_api_keys_days: "Anzahl Tage, bevor ein Benutzer API Schlüssel automatisch abläuft. (0 für niemals)" - tagging_enabled: "Schlagwörter für Themen aktivieren" + expire_user_api_keys_days: "Anzahl Tage, bevor ein Benutzer API-Schlüssel automatisch abläuft. (0 für niemals)" + tagging_enabled: "Schlagwörter für Themen aktivieren?" min_trust_to_create_tag: "Minimale Vertrauensstufe, um ein Schlagwort zu erstellen." max_tags_per_topic: "Maximale Anzahl an Schlagwörtern, die einem Thema hinzugefügt werden können." max_tag_length: "Die maximale Anzahl von Zeichen, die in einem Schlagwort verwendet werden kann." max_tag_search_results: "Maximale Ergebnisanzahl bei der Suche nach Schlagwörtern." - max_tags_in_filter_list: "Maximale Anzahl von Schlagwörtern, die in einer Dropdown-Liste angezeigt werden. Es werden die am häufigsten verwendeten Schlagwörter angezeigt." + max_tags_in_filter_list: "Maximale Anzahl von Schlagwörtern, die in der Filter-Drop-down-Liste angezeigt werden. Es werden die am häufigsten verwendeten Schlagwörter angezeigt." tags_sort_alphabetically: "Zeige Schlagwörter in alphabetischer Reihenfolge. Standardmäßig werden sie nach Beliebtheit sortiert." - tags_listed_by_group: "Zeige Schlagwörter auf der Schlagwort-Seite geordnet nach Schlagwort-Gruppe an." + tags_listed_by_group: "Zeige Schlagwörter auf der Schlagwort-Seite geordnet nach Schlagwortgruppe an." tag_style: "Visueller Stil für Schlagwort-Abzeichen." - allow_staff_to_tag_pms: "Erlaube Team-Mitgliedern, jede Nachricht mit einem Schlagwort zu markieren." + allow_staff_to_tag_pms: "Erlaube Team-Mitgliedern, jede Nachricht mit einem Schlagwort zu versehen." min_trust_level_to_tag_topics: "Minimale Vertrauensstufe, um Schlagwörter zu Themen hinzuzufügen." - suppress_overlapping_tags_in_list: "Schlagwort nicht zeigen, wenn es genau so im Thementitel vorkommt" + suppress_overlapping_tags_in_list: "Schlagwort nicht zeigen, wenn es genauso im Thementitel vorkommt" remove_muted_tags_from_latest: "Themen, die nur mit stummgeschalteten Tags versehen sind, nicht in der Liste der aktuellen Themen anzeigen" - force_lowercase_tags: "Erzwinge alle neuen tags in Kleinschreibung." + force_lowercase_tags: "Kleinschreibung für alle neuen Schlagwörter erzwingen." company_name: "Firmenname" governing_law: "Anzuwendendes Recht" city_for_disputes: "Stadt für Rechtsstreitigkeiten" @@ -2091,7 +2090,7 @@ de: user_locale_not_enabled: "Du musst zuerst 'allow user locale' aktivieren bevor du dies aktivierst" invalid_regex: "Regulärer Ausdruck ist ungültig oder nicht erlaubt." email_editable_enabled: "Du musst die Einstellung 'email editable' deaktivieren, bevor du diese Einstellung aktivierst." - staged_users_disabled: "Du musst erst 'vorbereitete Benutzer' aktivieren, bevor du diese Einstellung aktivierst." + staged_users_disabled: "Du musst erst „vorbereitete Benutzer“ aktivieren, bevor du diese Einstellung aktivierst." reply_by_email_disabled: "Du musst erst 'Antwort per E-Mail' aktivieren, bevor du diese Einstellung aktivierst." discourse_connect_url_is_empty: "Du musst eine „Discourse Connect URL“ festlegen, bevor du diese Einstellung aktivierst." discourse_connect_invite_only: "Du kannst DiscourseConnect nicht gleichzeitig aktivieren und einladen." @@ -2100,7 +2099,7 @@ de: min_username_length_range: "Sie können nicht das Minimum größer setzen als das Maximum." max_username_length_exists: "Sie können nicht die maximale Länge der Benutzernamen kleiner setzen als der längste Benutzername ist (%{username})." max_username_length_range: "Sie können nicht das Maximum kleiner setzen als das Minimum." - invalid_hex_value: "Farben müssen als 6stelliger Hexadezimalcode angegeben werden." + invalid_hex_value: "Farbwerte müssen als 6-stellige Hexadezimalcodes angegeben werden." empty_selectable_avatars: "Sie müssen zuerst mindestens zwei auswählbare Avatare hochladen, bevor Sie diese Einstellung aktivieren." category_search_priority: low_weight_invalid: "Du kannst das Gewicht nicht auf größer oder gleich 1 setzen." @@ -2250,7 +2249,7 @@ de: omniauth_error_unknown: "Während des Anmeldens ist etwas schief gelaufen, bitte versuche es noch einmal." omniauth_confirm_title: "Anmeldung per %{provider}" omniauth_confirm_button: "Weiter" - authenticator_error_no_valid_email: "E-Mail-Adressen mit %{account} sind nicht erlaubt. Du musst möglicherweise dein Konto mit einer anderen E-Mail-Adresse einrichten." + authenticator_error_no_valid_email: "E-Mail-Adressen mit Bezug zu %{account} sind nicht erlaubt. Du musst möglicherweise dein Konto mit einer anderen E-Mail-Adresse einrichten." new_registrations_disabled: "Leider können derzeit keine neuen Konten registriert werden." password_too_long: "Passwörter sind beschränkt auf 200 Zeichen." email_too_long: "Die von dir eingegebene E-Mail-Adresse ist zu lang. Der Teil vor dem @ darf maximal 254 Zeichen lang sein und Domain-Namen maximal 253 Zeichen." @@ -2269,7 +2268,7 @@ de: missing_second_factor_name: "Bitte geben Sie einen Namen an." missing_second_factor_code: "Bitte geben Sie einen Code an." second_factor_toggle: - totp: "Verwenden Sie stattdessen eine Authentifizierungs-App oder einen Sicherheitsschlüssel" + totp: "Verwende stattdessen eine Authentifizierungs-App oder einen Sicherheitsschlüssel" backup_code: "Benutze stattdessen einen Backup Code" admin: email: @@ -2277,7 +2276,7 @@ de: user: merge_user: updating_username: "Aktualisiere Benutzername..." - changing_post_ownership: "Ändern des Beitragbesitzers ..." + changing_post_ownership: "Ändern des Beitragseigentümers …" merging_given_daily_likes: "Zusammenführen gegebener täglicher Likes ..." merging_post_timings: "Beitragszeiten zusammenführen ..." merging_user_visits: "Zusammenführen von Benutzerbesuchen ..." @@ -2320,7 +2319,7 @@ de: domain_not_allowed: "Webseite ist ungültig. Erlaubte Domains sind: %{domains}" auto_rejected: "Automatisch abgelehnt aufgrund des Alters. Siehe Seiteneinstellung auto_handle_queued_age." destroy_reasons: - unused_staged_user: "Unbenutztes, vorbereiteter Benutzerkonto" + unused_staged_user: "Inaktives vorbereitetes Benutzerkonto" fixed_primary_email: "Primäre E-Mail-Adresse für vorbereitetes Benutzerkonto korrigiert" same_ip_address: "Gleiche IP-Adresse (%{ip_address}) wie andere Benutzer" inactive_user: "Deaktivierter Benutzer" @@ -2549,7 +2548,7 @@ de: Dieser Beitrag wurde wegen Meldungen der Community verborgen, deshalb prüfe bitte, wie du ihn überarbeiten kannst, um ihrem Feedback zu entsprechen. **Du kannst deinen Beitrag nach %{edit_delay} Minuten bearbeiten, und er wird automatisch wieder sichtbar.** - Falls er jedoch noch einmal von der Community verborgen wird, bleibt er dies, bis ein Teammitglied ihn prüft. + Falls er jedoch noch einmal von der Community verborgen wird, bleibt er dies, bis ein Team-Mitglied ihn prüft. Für zusätzliche Hinweise informiere dich bitte über unsere [Community Richtlinien](%{base_url}/guidelines). post_hidden_again: @@ -2564,7 +2563,7 @@ de: %{flag_reason} - Die Community hat den Beitrag gemeldet und er ist nun verborgen. **Da er mehr als einmal verborgen wurde, wird er es bleiben, bis ein Teammitglied ihn prüft.** + Die Community hat den Beitrag gemeldet und er ist nun verborgen. **Da er mehr als einmal verborgen wurde, wird er es bleiben, bis ein Team-Mitglied ihn prüft.** Für zusätzliche Hinweise informiere dich bitte über unsere [Community Richtlinien](%{base_url}/guidelines). queued_by_staff: @@ -2577,7 +2576,7 @@ de: <%{base_url}%{url}> - Dein Beitrag bleibt verborgen, bis ein Teammitglied ihn überprüft. + Dein Beitrag bleibt verborgen, bis ein Team-Mitglied ihn überprüft. Weitere Informationen findest du in unseren [community guidelines](%{base_url}/guidelines). flags_disagreed: @@ -2588,7 +2587,7 @@ de: dies ist eine automatische Nachricht von %{site_name} um dich zu informieren, dass [dein Beitrag](%{base_url}%{url}) wiederhergestellt worden ist. - Dieser Beitrag wurde von der Community gemeldet, und ein Teammitglied hat entschieden, ihn wiederherzustellen. + Dieser Beitrag wurde von der Community gemeldet, und ein Team-Mitglied hat entschieden, ihn wiederherzustellen. [details="Hier klicken um wiederhergestellten Beitrag auszuklappen"] ``` markdown @@ -2673,9 +2672,9 @@ de: title: "Backup erfolgreich" subject_template: "Sicherung erfolgreich abgeschlossen" text_body_template: | - Backup erfolgreich erstellt. + Back-up erfolgreich erstellt. - Besuche [admin > backup section](%{base_url}/admin/backups) um das neue Backup herunterzuladen. + Besuche [Administration > Back-ups](%{base_url}/admin/backups), um das neue Back-up herunterzuladen. Hier ist das Protokoll: @@ -2686,7 +2685,7 @@ de: title: "Backup fehlgeschlagen" subject_template: "Sicherung fehlgeschlagen" text_body_template: | - Die Sicherung ist fehlgeschlagen. + Back-up ist fehlgeschlagen. Hier ist das Protokoll: @@ -2754,7 +2753,7 @@ de: csv_export_failed: title: "CSV-Export fehlgeschlagen" subject_template: "Datenexport fehlgeschlagen" - text_body_template: "Es tut uns leid, aber dein Datenexport ist fehlgeschlagen. Bitte überprüfe die Logs oder [kontaktiere ein Team-Mitglied](%{base_url}/about)." + text_body_template: "Es tut uns leid, aber dein Datenexport ist fehlgeschlagen. Bitte überprüfe die Protokolle oder [kontaktiere ein Team-Mitglied](%{base_url}/about)." email_reject_insufficient_trust_level: title: "E-Mail abgelehnt weil Vertrauensstufe unzureichend" subject_template: "[%{email_prefix}] E-Mail-Probme -- Vertrauensstufe nicht ausreichend" @@ -2965,7 +2964,7 @@ de: text_body_template: | Es tut uns leid aber wir haben Probleme Dich per E-Mail zu erreichen. Unsere letzten E-Mails an Dich sind alle als unzustellbar zurück gekommen. - Bitte stelle sicher, dass [E-Mail Adresse](%{base_url}/my/preferences/email) gültig und aktiv ist? Füge bitte unsere E-Mail Adresse in Deinem Adressbuch hinzu, damit die Zustellbarkeit verbessert wird. + Bitte stelle sicher, dass [E-Mail-Adresse](%{base_url}/my/preferences/email) gültig und aktiv ist? Füge bitte unsere E-Mail-Adresse in Deinem Adressbuch hinzu, damit die Zustellbarkeit verbessert wird. email_bounced: | Die Nachricht an %{email} konnte nicht zugestellt werden. @@ -3066,11 +3065,11 @@ de: title: "Dashboard-Probleme" subject_template: "Neue Ratschläge auf deinem Website-Dashboard" text_body_template: | - Basierend auf deinen aktuellen Seiteneinstellungen haben wir einige neue Ratschläge und Empfehlungen für dich. + Basierend auf deinen aktuellen Website-Einstellungen haben wir einige neue Ratschläge und Empfehlungen für dich. - [Besuche das Dashboard deiner Seite](%{base_url}/admin), um sie zu sehen. + [Besuche das Dashboard deiner Website](%{base_url}/admin), um sie zu sehen. - Sollte im Dashboard nichts sichtbar sein, hat bereits ein anderes Teammitglied auf die Ratschläge reagiert. Eine Liste der Team Handlungen finden sich in den [Team Handlungs Logs](%{base_url}/admin/logs/staff_action_logs). + Sollte im Dashboard nichts sichtbar sein, hat bereits ein anderes Team-Mitglied auf die Ratschläge reagiert. Eine Liste der Team-Aktionen findest du in den [Protokollen für Team-Aktionen](%{base_url}/admin/logs/staff_action_logs). new_user_of_the_month: title: "Du bist ein neuer Benutzer des Monats!" subject_template: "Du bist ein neuer Benutzer des Monats!" @@ -3178,7 +3177,7 @@ de: %{respond_instructions} user_invited_to_private_message_pm_staged: - title: "Benutzer zu Nachricht eingeladen (automatisch)" + title: "Benutzer zu Nachricht eingeladen (vorbereitet)" subject_template: "[%{email_prefix}] %{username} hat dich zu einer Unterhaltung '%{topic_title}' eingeladen" text_body_template: | %{header_instructions} @@ -3306,7 +3305,7 @@ de: %{respond_instructions} user_posted_pm_staged: - title: "Benutzer hat Nachricht geschrieben (automatisch)" + title: "Benutzer hat Nachricht geschrieben (vorbereitet)" subject_template: "%{optional_re}%{topic_title}" text_body_template: |2 @@ -3421,7 +3420,7 @@ de: Wenn Sie diese Änderung nicht angefordert haben, wenden Sie sich bitte an einen [site admin] (%{base_url}/ ungefähr). confirm_new_email_via_admin: - title: "Neue E-Mail Adresse bestätigen" + title: "Neue E-Mail-Adresse bestätigen" subject_template: "[%{email_prefix}] Bestätige deine neue E-Mail-Adresse" text_body_template: | Bestätigen Sie Ihre neue E-Mail-Adresse für %{site_name} indem Sie auf den folgenden Link klicken: @@ -3433,18 +3432,18 @@ de: title: "E-Mail-Adresse bestätigen (an alte)" subject_template: "[%{email_prefix}] Bestätige deine aktuelle E-Mail-Adresse" text_body_template: | - Bevor wir die E-Mail Adresse ändern können, benötigen wir deine Bestätigung, dass du den aktuellen E-Mail Account kontrollierst. Sobald du das getan hast, schicken wir dir eine Anfrage, die neue E-Mail Adresse zu bestätigen. + Bevor wir die E-Mail-Adresse ändern können, benötigen wir deine Bestätigung, dass du den aktuellen E-Mail Account kontrollierst. Sobald du das getan hast, schicken wir dir eine Anfrage, die neue E-Mail-Adresse zu bestätigen. - Bestätige deine aktuelle E-Mail Adresse für %{site_name}, indem du auf den folgenden Link klickst: + Bestätige deine aktuelle E-Mail-Adresse für %{site_name}, indem du auf den folgenden Link klickst: %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email_add: title: "Alte E-Mail bestätigen (Hinzufügen)" subject_template: "[%{email_prefix}] Bestätige deine aktuelle E-Mail-Adresse" text_body_template: | - Bevor wir die E-Mail Adresse hinzufügen können, benötigen wir deine Bestätigung, dass du den aktuellen E-Mail Account kontrollierst. Sobald du das getan hast, schicken wir dir eine Anfrage, die neue E-Mail Adresse zu bestätigen. + Bevor wir die E-Mail-Adresse hinzufügen können, benötigen wir deine Bestätigung, dass du den aktuellen E-Mail Account kontrollierst. Sobald du das getan hast, schicken wir dir eine Anfrage, die neue E-Mail-Adresse zu bestätigen. - Bestätige deine aktuelle E-Mail Adresse für %{site_name}, indem du auf den folgenden Link klickst: + Bestätige deine aktuelle E-Mail-Adresse für %{site_name}, indem du auf den folgenden Link klickst: %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: @@ -3602,7 +3601,7 @@ de: sender_text_part_body_blank: "text_part.body ist leer" sender_body_blank: "Textkörper ist leer" sender_post_deleted: "Beitrag wurde gelöscht" - sender_message_to_invalid: "Der Empfänger hat eine ungültige E-Mail Adresse" + sender_message_to_invalid: "Der Empfänger hat eine ungültige E-Mail-Adresse" sender_topic_deleted: "Thema wurde gelöscht" color_schemes: base_theme_name: "Basis" @@ -3641,99 +3640,99 @@ de: ## [Dies ist ein zivilisierter Ort für öffentliche Diskussionen](#civilized) - Bitte behandel dieses Diskussionsforum mit demselben Respekt, den Du auch einem öffentlichen Park entgegenbringen würden. Auch wir sind eine gemeinschaftliche Ressource — ein Ort, um Fähigkeiten, Wissen und Interessen durch fortlaufende Konversation zu teilen. + Bitte behandle dieses Diskussionsforum mit demselben Respekt, den du auch einem öffentlichen Park entgegenbringen würdest. Auch wir sind eine gemeinschaftliche Ressource — ein Ort, um Fähigkeiten, Wissen und Interessen durch fortlaufende Konversation zu teilen. - Dies sind keine harten und schnellen Regeln. Sie sind Richtlinien, die das menschliche Urteilsvermögen unserer Gemeinschaft unterstützen und dafür sorgen, dass dies ein freundlicher Ort für einen zivilisierten öffentlichen Diskurs bleibt. + Dies sind keine verbindlichen Regeln. Sie sind Richtlinien, die das menschliche Urteilsvermögen unserer Gemeinschaft unterstützen und dafür sorgen, dass dies ein freundlicher Ort für einen zivilisierten öffentlichen Diskurs bleibt. - ## [Die Diskussion verbessern](#improve) + ## [Verbessere die Diskussion](#improve) - Hilf uns, dies zu einem großartigen Ort der Diskussion zu machen, indem Du immer etwas Positives zur Diskussion beiträgst, wie klein auch immer. Wenn Du Dir nicht sicher bist, ob Dein Beitrag zur Diskussion beiträgt, überlege Dir, was Du sagen willst und versuche es später noch einmal. + Hilf uns, dies zu einem großartigen Ort der Diskussion zu machen, indem du immer etwas Positives zur Diskussion beiträgst, wie klein auch immer der Beitrag sein mag. Wenn du dir nicht sicher bist, ob dein Beitrag zur Diskussion beiträgt, überlege dir, was du sagen willst, und versuche es später noch einmal. - Eine Möglichkeit, die Diskussion zu verbessern, besteht darin, die Themen zu entdecken, die bereits im Gange sind. Verbringe Zeit damit, die Themen hier zu durchstöbern, bevor Du antwortest oder einen eigenen Beitrag beginnst. Du hast dann eine bessere Chance, andere zu treffen, die Deine Interessen teilen. + Eine Möglichkeit, die Diskussion zu verbessern, besteht darin, die Themen zu entdecken, die bereits im Gange sind. Verbringe Zeit damit, die Themen hier zu durchstöbern, bevor du antwortest oder einen eigenen Beitrag beginnst. Du hast dann eine bessere Chance, andere zu treffen, die deine Interessen teilen. - Die Themen, die hier diskutiert werden, sind uns wichtig, und wir möchten, dass Du so handelst, als ob sie Dir auch wichtig wären. Sei respektvoll gegenüber den Themen und den Personen, die sie diskutieren, auch wenn Du mit einigen der Aussagen nicht einverstanden bist. + Die Themen, die hier diskutiert werden, sind uns wichtig und wir möchten, dass du so handelst, als ob sie dir auch wichtig wären. Sei respektvoll gegenüber den Themen und den Personen, die sie diskutieren, auch wenn du mit einigen der Aussagen nicht einverstanden bist. - ## [Sei zustimmend, auch wenn Du nicht einverstanden bist] (#agreeable) + ## [Verhalte dich umgänglich, auch wenn du nicht einverstanden bist] (#agreeable) - Vielleicht möchtest Du antworten, indem Du nicht zustimmst. Das ist in Ordnung. Aber denke daran _Ideen zu kritisieren, nicht Personen_. Bitte vermeide: + Vielleicht möchtest du deinen Dissens ausdrücken. Das ist in Ordnung. Aber denke daran, _Ideen zu kritisieren, nicht Personen_. Bitte vermeide Folgendes: * Beschimpfungen * Ad-hominem-Angriffe - * Ein Reagieren auf den Tonfall eines Beitrags anstatt auf den eigentlichen Inhalt + * Reagieren auf den Tonfall eines Beitrags anstatt auf den eigentlichen Inhalt * Widerspruch unter der Gürtellinie - Stelle stattdessen durchdachte Erkenntnisse zur Verfügung, die die Konversation verbessern. + Stelle stattdessen durchdachte Erkenntnisse zur Verfügung, die die Unterhaltung verbessern. ## [Deine Teilnahme zählt](#participate) - Die Unterhaltungen, die wir hier führen, geben den Ton für jeden Neuankömmling an. Hilf uns, die Zukunft dieser Gemeinschaft zu beeinflussen, indem Du dich an Diskussionen beteiligen, die dieses Forum zu einem interessanten Ort machen — und diejenigen meiden, die das nicht tun. + Die Unterhaltungen, die wir hier führen, geben den Ton für jeden Neuankömmling an. Hilf uns, die Zukunft dieser Gemeinschaft zu beeinflussen, indem du dich an Diskussionen beteiligst, die dieses Forum zu einem interessanten Ort machen — und diejenigen meidest, die das nicht tun. - Discourse bietet Werkzeuge, die es der Gemeinschaft ermöglichen, gemeinsam die besten (und schlechtesten) Beiträge zu identifizieren: Lesezeichen, Likes, Flaggen, Antworten, Bearbeitungen, Beobachten, Stummschalten und so weiter. Nutze diese Werkzeuge, um Deine eigene Erfahrung zu verbessern und die aller anderen auch. + Discourse bietet Werkzeuge, die es der Gemeinschaft ermöglichen, gemeinsam die besten (und schlechtesten) Beiträge zu identifizieren: Lesezeichen, Likes, Meldungen, Antworten, Bearbeitungen, das Beobachten, das Stummschalten und so weiter. Nutze diese Werkzeuge, um deine eigene Erfahrung zu verbessern und die aller anderen auch. - Lasse uns unsere Community besser verlassen, als wir sie vorgefunden haben. + Verbessern wir gemeinsam unsere Community. - ## [Wenn Du ein Problem siehst, markiere es](#flag-problems) + ## [Falls du ein Problem siehst, melde es](#flag-problems) - Moderatoren haben besondere Befugnisse; sie sind für dieses Forum verantwortlich. Aber das bist Du auch. Mit Deiner Hilfe können die Moderatoren die Gemeinschaft fördern und sind nicht nur Hausmeister oder Polizisten. + Moderatoren haben besondere Befugnisse; sie sind für dieses Forum verantwortlich. Aber das bist du auch. Mit deiner Hilfe können die Moderatoren die Gemeinschaft fördern und sind nicht nur Hausmeister oder Polizisten. - Wenn Du schlechtes Verhalten siehst, antworte nicht. Antworten ermutigt schlechtes Verhalten, indem es bestätigt wird, verbraucht Deine Energie und verschwendet die Zeit von allen. Markiere es _einfach_ mit dem Flaggensymbol. Wenn sich genug Markierungen ansammeln, werden Maßnahmen ergriffen, entweder automatisch oder durch das Eingreifen eines Moderators. + Wenn du schlechtes Verhalten siehst, antworte nicht. Antworten ermutigt schlechtes Verhalten, indem es bestätigt wird, verbraucht deine Energie und verschwendet die Zeit von allen. _Melde es einfach_. Wenn sich genug Meldungen ansammeln, werden Maßnahmen ergriffen, entweder automatisch oder durch das Eingreifen eines Moderators. - Um unsere Gemeinschaft aufrechtzuerhalten, behalten sich die Moderatoren das Recht vor, jeden Inhalt und jedes Benutzerkonto aus irgendeinem Grund zu jeder Zeit zu entfernen. Die Moderatoren führen keine Vorabansicht neuer Beiträge aus; die Moderatoren und Seitenbetreiber übernehmen keine Verantwortung für die von der Community eingestellten Inhalte. + Um unsere Gemeinschaft aufrechtzuerhalten, behalten sich die Moderatoren das Recht vor, jeden Inhalt und jedes Benutzerkonto aus irgendeinem Grund zu jeder Zeit zu entfernen. Die Moderatoren führen keine Vorabansicht neuer Beiträge durch; die Moderatoren und Websitebetreiber übernehmen keine Verantwortung für die von der Community eingestellten Inhalte. - ## [Immer respektvoll sein](#be-civil) + ## [Sei stets respektvoll](#be-civil) - Nichts sabotiert eine gesunde Konversation so sehr wie Unhöflichkeit: + Nichts sabotiert eine gesunde Unterhaltung so sehr wie Unhöflichkeit: * Sei höflich. Veröffentliche nichts, was eine vernünftige Person als beleidigend, ausfallend oder als Hassrede ansehen würde. - * Halte es sauber. Poste nichts Obszönes oder sexuell Anzügliches. - * Respektiert euch gegenseitig. Belästige niemanden, gebe dich nicht als jemand anderes aus und gebe keine privaten Informationen preis. + * Beherrsche dich. Poste nichts Obszönes oder sexuell Anzügliches. + * Respektiert euch gegenseitig. Belästige niemanden, gib dich nicht als jemand anderes aus und gib keine privaten Informationen preis. * Respektiere unser Forum. Veröffentliche keinen Spam und verunstalte nicht das Forum. - Dies sind keine konkreten Begriffe mit genauen Definitionen — vermeide auch nur den _Anschein_ eines dieser Dinge. Wenn Du unsicher bist, frage dich, wie Du dich fühlen würden, wenn Dein Beitrag auf der Titelseite einer großen Nachrichtenseite erscheinen würde. + Dies sind keine konkreten Begriffe mit genauen Definitionen — vermeide auch nur den _Anschein_ eines dieser Dinge. Wenn du unsicher bist, frage dich, wie du dich fühlen würdest, wenn dein Beitrag auf der Titelseite einer großen Nachrichtenseite erscheinen würde. - Dies ist ein öffentliches Forum und Suchmaschinen indizieren diese Diskussionen. Halte die Sprache, Links und Bilder sicher für Familie und Freunde. + Dies ist ein öffentliches Forum und Suchmaschinen indizieren diese Diskussionen. Halte die Sprache, Links und Bilder angemessen für Familie und Freunde. - ## [Halte es ordentlich](#keep-tidy) + ## [Sei ordentlich](#keep-tidy) - Mache Dir die Mühe, die Dinge an den richtigen Platz zu stellen, so dass wir mehr Zeit mit dem Diskutieren und weniger mit dem Aufräumen verbringen können. Also: + Mach dir die Mühe, alles an den richtigen Platz zu packen, sodass wir mehr Zeit mit dem Diskutieren und weniger mit dem Aufräumen verbringen können: * Fange kein Thema in der falschen Kategorie an; lies bitte die Kategorie-Definitionen. - * Poste nicht dasselbe in mehreren Themen. + * Poste nicht dieselben Inhalte in mehreren Themen. * Poste keine inhaltslosen Antworten. - * Lenke nicht von einem Thema ab, indem Du es mittendrin änderst. - * Unterschreibe Deine Beiträge nicht — jeder Beitrag ist mit Deinen Profilinformationen versehen. + * Lenke nicht von einem Thema ab, indem du es mittendrin änderst. + * Unterschreibe deine Beiträge nicht — jeder Beitrag ist mit deinen Profilinformationen versehen. - Anstatt "+1" oder "Einverstanden" zu posten, verwende den Like-Button. Anstatt ein bestehendes Thema in eine radikal andere Richtung zu lenken, verwende "Antworten" als verlinktes Thema. + Anstatt „+1“ oder „Einverstanden“ zu posten, verwende den Like-Button. Anstatt ein bestehendes Thema in eine radikal andere Richtung zu lenken, verwende „Antworten als verknüpftes Thema“. - ## [Nur eigene Sachen posten] (#stealing) + ## [Poste nur eigene Inhalte] (#stealing) - Du darfst ohne Erlaubnis nichts Digitales posten, das jemand anderem gehört. Du darfst keine Beschreibungen, Links oder Methoden posten, die das geistige Eigentum (Software, Video, Audio, Bilder) von jemandem stehlen oder ein anderes Gesetz brechen. + Du darfst ohne Erlaubnis nichts Digitales posten, das jemand anderem gehört. Du darfst keine Beschreibungen, Links oder Methoden posten, die dem Diebstahl des geistigen Eigentums (Software, Video, Audio, Bilder) von jemandem dienen oder anderweitig illegales Verhalten beschreiben. - ## [Angetrieben von Dir](#power) + ## [Angetrieben von dir](#power) - Diese Seite wird von Deinem [freundlichen Moderations-Team](%{base_path}/about) und von *Euch*, der Community, betrieben. Wenn Du weitere Fragen dazu hast, wie die Dinge hier funktionieren sollen, öffne ein neues Thema in der Kategorie [Seiten-Feedback](%{base_path}/c/site-feedback) und lasse uns diskutieren! Wenn es ein kritisches oder dringendes Problem gibt, das nicht durch ein Meta-Thema oder eine Flagge behandelt werden kann, kontaktiere uns über die [Moderations-Team Seite](%{base_path}/about). + Diese Website wird von deinem [freundlichen Moderations-Team](%{base_path}/about) und von *euch*, der Community, betrieben. Wenn du weitere Fragen dazu hast, wie alles hier funktionieren soll, öffne ein neues Thema in der [Kategorie Website-Feedback](%{base_path}/c/site-feedback) und lass uns diskutieren! Wenn es ein kritisches oder dringendes Problem gibt, das nicht durch ein Meta-Thema oder eine Meldung behandelt werden kann, kontaktiere uns über die [Seite des Moderations-Teams](%{base_path}/about). ## [Nutzungsbedingungen](#tos) - Ja, Juristensprache ist langweilig, aber wir müssen uns – und damit auch Dich und Deine Daten – vor unfreundlichen Leuten schützen. Wir haben [Nutzungsbedingungen](%{base_path}/tos), die Dein (und unser) Verhalten und Deine Rechte in Bezug auf Inhalte, Datenschutz und Gesetze beschreibt. Um diesen Dienst zu nutzen, musst Du zustimmen, unsere [Nutzungsbedingungen](%{base_path}/tos) einzuhalten. + Ja, Juristensprache ist langweilig, aber wir müssen uns – und damit auch dich und deine Daten – vor unfreundlichen Leuten schützen. Wir haben [Nutzungsbedingungen](%{base_path}/tos), die dein (und unser) Verhalten und deine Rechte in Bezug auf Inhalte, Datenschutz und Gesetze beschreiben. Um diesen Dienst zu nutzen, musst du zustimmen, unsere [Nutzungsbedingungen](%{base_path}/tos) einzuhalten. tos_topic: title: "Nutzungsbedingungen" body: | @@ -3915,11 +3914,11 @@ de: ## [Welche Informationen sammeln wir?](#collect) - Wir sammeln Informationen von dir, wenn du dich auf unserer Seite registrierst, und erfassen Daten, wenn du dich hier am Forum durch Lesen, Schreiben und Bewertung der geteilten Inhalte beteiligst. + Wir sammeln Informationen von dir, wenn du dich auf unserer Website registrierst, und wir erfassen Daten, wenn du dich hier am Forum durch Lesen, Schreiben und Bewertung der geteilten Inhalte beteiligst. - Wenn du dich auf unserer Seite registrierst, wirst du möglicherweise gebeten, deinen Namen und deine E-Mail-Adresse einzugeben. Du kannst unsere Seite jedoch auch ohne Registrierung besuchen. Deine E-Mail-Adresse wird durch eine E-Mail verifiziert, die einen eindeutigen Link enthält. Wenn der Link aufgerufen wird, wissen wir, dass du die Kontrolle über die E-Mail-Adresse hast. + Wenn du dich auf unserer Website registrierst, wirst du möglicherweise gebeten, deinen Namen und deine E-Mail-Adresse einzugeben. Du kannst unsere Website jedoch auch ohne Registrierung besuchen. Deine E-Mail-Adresse wird durch eine E-Mail verifiziert, die einen eindeutigen Link enthält. Wenn der Link aufgerufen wird, wissen wir, dass du die Kontrolle über die E-Mail-Adresse hast. - Wenn du registrierst bist und etwas schreibst, erfassen wir die IP-Adresse, von der der Beitrag stammt. Wir behalten möglicherweise Serverprotokolle, die die IP-Adresse jeder Anfrage an unseren Server enthält. + Wenn du registrierst bist und etwas schreibst, erfassen wir die IP-Adresse, von der der Beitrag stammt. Wir speichern möglicherweise Serverprotokolle, die die IP-Adresse jeder Anfrage an unseren Server enthalten. @@ -3928,9 +3927,9 @@ de: Jede der Informationen, die wir von dir sammeln, kann auf eine der folgenden Arten genutzt werden: * Um deine Erfahrung zu personalisieren — deine Information hilft uns dabei, besser auf deine individuellen Bedürfnisse einzugehen. - * Um unsere Seite zu verbessern — wir streben kontinuierlich dazu, das Angebot unserer Seite auf Grundlage der Informationen und des Feedbacks zu verbessern, das wir von dir erhalten. + * Um unsere Website zu verbessern — wir bemühen uns, das Angebot unserer Website auf Grundlage der Informationen und des Feedbacks zu verbessern, das wir von dir erhalten. * Um unseren Kundendienst zu verbessern — deine Information hilft uns, effizienter auf deine Anfragen an den Kundendienst und deine Hilfsbedürfnisse einzugehen. - * Um wiederkehrende E-Mails zu senden — Die E-Mail-Adresse, die du angibst, kann genutzt werden, um dir Informationen und Benachrichtigungen zu senden, die du zu Änderungen an Themen oder in Bezug auf deinen Benutzernamen angefordert hast, um dir auf Nachfragen, Anfragen oder Fragen zu antworten. + * Um wiederkehrende E-Mails zu senden — die E-Mail-Adresse, die du angibst, kann genutzt werden, um dir Informationen und Benachrichtigungen zu senden, die du zu Änderungen an Themen oder in Bezug auf deinen Benutzernamen angefordert hast, sowie um dir auf Nachfragen, Anfragen oder Fragen zu antworten. @@ -3951,45 +3950,45 @@ de: ## [Verwenden wir Cookies?](#cookies) - Ja. Cookies sind kleine Dateien, die eine Seite bzw. ihr Dienstleister über einen Browser auf die Festplatte deines Computers überträgt (sofern du zustimmst). Diese Cookies erlauben es der Seite, deinen Browser wiederzuerkennen und, sofern du ein registriertes Konto hast, diesen deinem registrierten Konto zuzuordnen. + Ja. Cookies sind kleine Dateien, die eine Website bzw. ihr Dienstleister über einen Browser auf die Festplatte deines Computers überträgt (sofern du zustimmst). Diese Cookies erlauben es der Website, deinen Browser wiederzuerkennen und, sofern du ein registriertes Konto hast, diesen deinem registrierten Konto zuzuordnen. - Wir verwenden Cookies, um deine Einstellungen für deine nächsten Besuche zu verstehen und zu speichern und um aggregierte Daten über unseren Internetverkehr und Interaktionen auf der Seite zu berechnen, damit wir in der Zukunft eine bessere Seitenerfahrung und bessere Werkzeuge anbieten können. Wir beauftragen möglicherweise Drittanbieter, die uns dabei unterstützen, unsere Seitenbesucher besser zu verstehen. Diese Dienstleister sind nicht befugt, unsere gesammelten Informationen anders zu verwenden als zur Unterstützung und Verbesserung unseres Geschäfts. + Wir verwenden Cookies, um deine Einstellungen für deine nächsten Besuche zu verstehen und zu speichern und um aggregierte Daten über unseren Website-Traffic und Interaktionen auf der Website zu erfassen, damit wir in der Zukunft eine bessere Website-Erfahrung und bessere Werkzeuge anbieten können. Wir beauftragen möglicherweise Drittanbieter, die uns dabei unterstützen, unsere Website-Besucher besser zu verstehen. Diese Dienstleister sind nicht befugt, unsere gesammelten Informationen anders zu verwenden als zur Unterstützung und Verbesserung unseres Geschäfts. ## [Geben wir irgendwelche Informationen an Dritte weiter?](#disclose) - Weder verkaufen oder handeln wir deine personenbezogenen Daten noch übermitteln wir diese an Dritte. Dies schließt nicht Drittanbieter ein, die uns beim Betrieb unserer Seite, bei der Durchführung unseres Geschäfts oder dir helfen, solange diese Parteien einwilligen, diese Informationen vertraulich zu behandeln. Wir geben deine Informationen möglicherweise frei, wenn wir glauben, dass die Freigabe zweckmäßig ist, um das Gesetz einzuhalten, unsere Nutzungsbedingungen durchzusetzen oder unsere oder fremde Rechte, Werte oder Sicherheit zu schützen. Nicht-personenbezogene Daten werden möglicherweise anderen Parteien für Marketing, Werbung und andere Zwecke zugänglich gemacht. + Weder verkaufen oder handeln wir deine personenbezogenen Daten noch übermitteln wir diese an Dritte. Dies schließt nicht Drittanbieter ein, die uns beim Betrieb unserer Website, bei der Durchführung unseres Geschäfts oder bei der Erbringung unserer Dienstleistungen für dich helfen, solange diese Parteien einwilligen, diese Informationen vertraulich zu behandeln. Wir geben deine Informationen möglicherweise frei, wenn wir glauben, dass die Freigabe zweckmäßig ist, um das Gesetz einzuhalten, unsere Nutzungsbedingungen durchzusetzen oder unsere oder fremde Rechte, Werte oder Sicherheit zu schützen. Nicht-personenbezogene Daten werden möglicherweise anderen Parteien für Marketing, Werbung und andere Zwecke zugänglich gemacht. - ## [Links auf fremde Webseiten](#third-party) + ## [Links auf fremde Websites](#third-party) - Gelegentlich können wir Produkte oder Dienste von Dritten nach unserer Wahl auf unserer Seite einbinden oder darauf bewerben. Diese Drittanbieter haben eigene und unabhängige Datenschutzrichtlinien. Wir übernehmen daher keine Verantwortung oder Haftung für den Inhalt und die Aktivitäten dieser verknüpften Seite. Dennoch sind wir bemüht, die Integrität unserer Seite zu schützen und freuen uns über jedes Feedback zu diesen Seiten. + Gelegentlich können wir Produkte oder Dienste von Dritten nach unserer Wahl auf unserer Website einbinden oder darauf bewerben. Diese Drittanbieter-Websites haben eigene und unabhängige Datenschutzerklärungen. Wir übernehmen daher keine Verantwortung oder Haftung für den Inhalt und die Aktivitäten dieser verlinkten Websites. Dennoch sind wir bemüht, die Integrität unserer Website zu schützen und freuen uns über jedes Feedback zu diesen Websites. ## [Gesetz zum Schutz der Privatsphäre von Kindern im Internet](#coppa) - Unsere Seite, Produkte und Dienstleistungen richten sich alle an Menschen, die mindestens 13 Jahre oder älter sind. Wenn sich dieser Server in der USA befindet und du unter 13 Jahre alt bist, so benutze diese Seite gemäß der Anforderungen des COPPA-Gesetzes ([Children's Online Privacy Protection Act](https://de.wikipedia.org/wiki/Children%27ss_Online_Privacy_Protection_Act)) nicht. + Unsere Website, Produkte und Dienstleistungen richten sich alle an Menschen, die mindestens 13 Jahre oder älter sind. Wenn sich dieser Server in der USA befindet und du unter 13 Jahre alt bist, so benutze diese Website gemäß den Anforderungen von COPPA ([Children's Online Privacy Protection Act](https://de.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act)) nicht. - ## [Online-Datenschutzrichtlinie](#online) + ## [Online-Datenschutzerklärung](#online) - Diese Online-Datenschutzrichtlinie gilt nur für Informationen, die durch unsere Seite gesammelt werden, und nicht für offline erfasste Informationen. + Diese Online-Datenschutzerklärung gilt nur für Informationen, die durch unsere Website gesammelt werden, und nicht für offline erfasste Informationen. ## [Deine Zustimmung](#consent) - Indem du unsere Seite benutzt, stimmst du der Datenschutzrichtlinie unserer Webseite zu. + Indem du unsere Website benutzt, stimmst du der Datenschutzerklärung unserer Website zu. - ## [Änderungen unserer Datenschutzrichtlinie](#changes) + ## [Änderungen unserer Datenschutzerklärung](#changes) - Wenn wir uns dazu entscheiden, unsere Datenschutzrichtlinie zu ändern, werden wir diese Änderungen auf dieser Seite veröffentlichen. + Wenn wir uns dazu entscheiden, unsere Datenschutzerklärung zu ändern, werden wir diese Änderungen auf dieser Seite veröffentlichen. Dieses Dokument ist als CC-BY-SA lizenziert. Es wurde zuletzt aktualisiert am 31. Mai 2013. badges: @@ -4250,25 +4249,25 @@ de: initial_topic_title: Berichte zur Websitegeschwindigkeit tags: title: "Schlagwörter" - restricted_tag_disallowed: 'Sie können das Schlagwort "%{tag} " nicht anwenden.' - restricted_tag_remove_disallowed: 'Sie können das Schlagwort "%{tag} " nicht entfernen.' + restricted_tag_disallowed: 'Du kannst das Schlagwort „%{tag}“ nicht anwenden.' + restricted_tag_remove_disallowed: 'Du kannst das Schlagwort „%{tag}“ nicht entfernen.' minimum_required_tags: one: "Du muss mindestens %{count} Schlagwort auswählen." other: "Du musst mindestens %{count} Schlagwörter auswählen." upload_row_too_long: "Die CSV-Datei sollte ein Schlagwort pro Zeile haben. Optional kann das Schlagwort von einem Komma und dem Namen einer Schlagwortgruppe gefolgt sein." forbidden: invalid: - one: "Der ausgewählte Tag kann nicht genutzt werden." - other: "Keiner der ausgewählten Tags kann genutzt werden" - in_this_category: '"%{tag_name}" kann in dieser Kategorie nicht verwendet werden' + one: "Das ausgewählte Schlagwort kann nicht genutzt werden." + other: "Keines der ausgewählten Schlagwörter kann genutzt werden" + in_this_category: '„%{tag_name}“ kann in dieser Kategorie nicht verwendet werden' restricted_to: - one: '"%{tag_name}" ist beschränkt auf die Kategorie "%{category_names}"' - other: '"%{tag_name}" ist beschränkt auf folgende Kategorien: %{category_names}' - synonym: 'Synonyme sind nicht erlaubt. Verwenden Sie stattdessen "%{tag_name}".' - has_synonyms: '"%{tag_name}" kann nicht verwendet werden, da es Synonyme hat.' + one: '„%{tag_name}“ ist beschränkt auf die Kategorie „%{category_names}“' + other: '„%{tag_name}“ ist beschränkt auf folgende Kategorien: %{category_names}' + synonym: 'Synonyme sind nicht erlaubt. Verwende stattdessen „%{tag_name}“.' + has_synonyms: 'Schlagwort „%{tag_name}“ kann nicht verwendet werden, da es Synonyme hat.' required_tags_from_group: - one: "Du musst mindestens %{count} %{tag_group_name} Schlagwort hinzufügen. Die Schlagworte in dieser Gruppe sind: %{tags}." - other: "Du musst mindestens %{count} %{tag_group_name} Schlagwörter einschließen. Die Schlagwörter in dieser Gruppe sind: %{tags}." + one: "Du musst mindestens %{count} „%{tag_group_name}“-Schlagwort hinzufügen. Die Schlagwörter in dieser Gruppe sind: %{tags}." + other: "Du musst mindestens %{count} „%{tag_group_name}“-Schlagwörter hinzufügen. Die Schlagwörter in dieser Gruppe sind: %{tags}." invalid_target_tag: "kann kein Synonym für ein Synonym sein" synonyms_exist: "ist nicht erlaubt, solange Synonyme existieren" rss_by_tag: "Themen mit dem Schlagwort %{tag}" @@ -4451,7 +4450,7 @@ de: unknown: "unbekannt" user_merged: "%{username} wurde mit diesem Konto zusammengeführt" user_delete_self: "Selbst gelöscht von %{url}" - webhook_deactivation_reason: "Dein Webhook wurde automatisch deaktiviert. Wir bekommen zahlreiche '%{status}' fehlgeschlagene HTTP Status Antworten." + webhook_deactivation_reason: "Dein Webhook wurde automatisch deaktiviert. Wir haben mehrere „%{status}“-HTTP-Statusfehlerantworten erhalten." api_key: automatic_revoked: one: "Automatisch widerrufen, letzte Aktivität vor mehr als %{count} Tag" @@ -4481,7 +4480,7 @@ de: fast_typer: "Ein neuer Benutzer hat seinen ersten Beitrag verdächtig schnell getippt, Verdacht auf Bot oder Spammer-Verhalten. Siehe `min_first_post_typing_time`." auto_silence_regexp: "Neuer Benutzer, dessen erster Beitrag mit der Einstellung „auto_silence_first_post_regex“ übereinstimmt." watched_word: "Dieser Beitrag enthielt ein beobachtetes Wort. Siehe deine Liste der beobachteten Wörter." - staged: "Neue Themen und Beiträge für aufgeführte Benutzer müssen von Team-Mitgliedern genehmigt werden. Siehe `approve_unless_staged`." + staged: "Neue Themen und Beiträge für vorbereitete Benutzer müssen von Team-Mitgliedern genehmigt werden. Siehe `approve_unless_staged`." category: "Beiträge in dieser Kategorie benötigen manuelle Genehmigung von Team-Mitgliedern. Siehe in den Kategorie-Einstellungen." must_approve_users: "Alle neuen Benutzer müssen von Team-Mitgliedern bestätigt werden. Siehe `must_approve_users`. " invite_only: "Alle neuen Benutzer müssen eingeladen werden. Siehe `invite_only`. " @@ -4552,7 +4551,7 @@ de: description: "Der Benutzer wird aus dem Forum gelöscht." block: title: "Lösche und blockiere Benutzer" - description: "Der Benutzer wird gelöscht, und wir blockieren seine IP und E-Mail Adresse." + description: "Der Benutzer wird gelöscht, und wir blockieren seine IP und E-Mail-Adresse." reject: title: "Ablehnen" bundle_title: "Ablehnen..." diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index fc48b66dab..ee34320ceb 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -978,7 +978,6 @@ el: notify_mods_when_user_silenced: "Αν ένας χρήστης σιγηθεί αυτόματα, στείλε ένα μήνυμα σε όλους τους συντονιστές." traditional_markdown_linebreaks: "Χρήση παραδοσιακών αλλαγών γραμμών στη Markdown, η οποία απαιτεί δύο κενά διαστήματα για μια αλλαγή γραμμής." post_undo_action_window_mins: "Αριθμός των λεπτών όπου οι χρήστες δικαιούνται να αναιρέσουν πρόσφατες ενέργειες πάνω σε ένα θέμα (μου αρέσει, επισήμανση, κτλ) " - pending_users_reminder_delay: "Ειδοποίηση των συντονιστών αν υπάρχουν νέοι χρήστες που περιμένουν για αποδοχή του λογαριασμού τους για μεγαλύτερο απο αυτό το χρονικό διάστημα. Όρισέ το στο -1 για να απενεργοποιηθούν οι ειδοποιήσεις." maximum_session_age: "Ο χρήστης θα παραμείνει συνδεδεμένος για n ώρες από την τελευταία του επίσκεψη" cors_origins: "Επιτρεπόμενες πηγές για αιτήσεις πολλαπλής προέλευσης (cross-origin requests, CORS). Η κάθε προέλευση πρέπει να περιέχει http:// or https://. Η env μεταβλητή DISCOURSE_ENABLE_CORS πρέπει να οριστεί σε αληθινή για να ενεργοποιηθεί το CORS." use_admin_ip_allowlist: "Οι διαχειριστές μπορούν να συνδέονται μόνο αν βρίσκονται σε μια διευθυνση IP καθορισμένη στη λίστα Screened IPs (Admin > Logs > Screened Ips)." diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 407436176f..8c08167365 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -33,8 +33,8 @@ es: - Diciembre <<: *datetime_formats time: - am: "am" - pm: "pm" + am: "a.m." + pm: "p.m." <<: *datetime_formats title: "Discourse" topics: "Temas" @@ -62,11 +62,11 @@ es: unrecognized_extension: "Extensión de archivo no reconocida: %{extension}" import_error: generic: Ha ocurrido un error al importar el tema - about_json: "Error de importación: about.json no existe o no es válido. ¿Estás seguro de que este es un tema de Discourse?" - about_json_values: "about.json contiene valores inválidos: %{errors}" - modifier_values: "los modificadores about.json contienen valores inválidos: %{errors}" + about_json: "Error de importación: about.json no existe o no es válido. ¿Seguro que este es un tema de Discourse?" + about_json_values: "about.json contiene valores no válidos: %{errors}" + modifier_values: "los modificadores about.json contienen valores no válidos: %{errors}" git: "Error al clonar el repositorio git, se deniega el acceso o no se encuentra el repositorio" - git_ref_not_found: "No se ha podido hacer checkout de git reference: %{ref}" + git_ref_not_found: "No se ha podido hacer el checkout de la referencia git: %{ref}" unpack_failed: "Error al descomprimir el archivo" file_too_big: "El archivo sin comprimir es demasiado grande." unknown_file_type: "El archivo que cargaste no parece ser un tema de Discourse válido." @@ -78,7 +78,7 @@ es: no_multilevels_components: "Los temas con temas secundarios no pueden ser temas secundarios en sí mismos." optimized_link: Los enlaces de imagen optimizados son efímeros y no deben incluirse en el código fuente del tema. settings_errors: - invalid_yaml: "El YAML provisto es inválido." + invalid_yaml: "El YAML provisto no es válido." data_type_not_a_number: "Tipo de ajuste `%{name}` no soportado. Los tipos soportados son `integer`, `bool`, `list`, `enum` y `upload`" name_too_long: "Hay un ajuste con un nombre muy largo. La cantidad máxima de caracteres es 255" default_value_missing: "El ajuste «%{name}» no tiene valor por defecto" @@ -102,7 +102,11 @@ es: show_trimmed_content: "Mostrar contenido recortado" maximum_staged_user_per_email_reached: "Se alcanzó el número máximo de usuarios provisionales creados por correo electrónico." no_subject: "(sin título)" - no_body: "(sin mensaje)" + no_body: "(sin cuerpo)" + missing_attachment: "(Falta el adjunto %{filename})" + continuing_old_discussion: + one: "Continuando la discusión de [%{title}](%{url}), porque fue creada hace más de %{count} día." + other: "Continuando la discusión de [%{title}](%{url}), porque fue creada hace más de %{count} días." errors: empty_email_error: "Sucede cuando el texto en bruto del correo electrónico que recibimos está en blanco." no_message_id_error: "Sucede cuando el correo electrónico no tiene el encabezado de «ID del mensaje»." @@ -118,7 +122,7 @@ es: reply_user_not_matching_error: "Sucede cuando una respuesta viene de una dirección de correo electrónico diferente a la que se le envió la notificación." topic_not_found_error: "Sucede cuando entra una respuesta pero el tema relacionado ha sido eliminado." topic_closed_error: "Sucede cuando entra una respuesta pero el tema relacionado ha sido cerrado. " - bounced_email_error: "El correo electrónico es un reporte de correo electrónico rebotado." + bounced_email_error: "El correo electrónico es un informe de correo electrónico rebotado." screened_email_error: "Sucede cuando la dirección de correo electrónico del remitente ya ha sido bloqueada." unsubscribe_not_allowed: "Sucede cuando no se le permite a este usuario anular la subscripción por correo electrónico." email_not_allowed: "Ocurre cuando la dirección de correo electrónico no está en la lista de permitidos o está en la lista de bloqueo." @@ -129,7 +133,7 @@ es: format: ! "%{attribute} %{message}" format_with_full_message: "%{attribute}: %{message}" messages: - too_long_validation: "está limitado a %{max} caracteres; has ingresado %{length}." + too_long_validation: "está limitado a %{max} caracteres; has introducido %{length}." invalid_boolean: "Booleano no válido." taken: "ya ha sido escogido" accepted: debe ser aceptado @@ -144,7 +148,7 @@ es: greater_than_or_equal_to: debe ser mayor o igual que %{count} has_already_been_used: "ya se ha utilizado" inclusion: no está incluido en la lista - invalid: es inválido + invalid: no es válido is_invalid: "parece poco claro, ¿es una oración completa?" is_invalid_meaningful: "parece poco claro, la mayoría de las palabras contienen las mismas letras una y otra vez" is_invalid_unpretentious: "parece poco claro, una o más palabras es muy larga" @@ -201,7 +205,7 @@ es: local_login_cannot_be_disabled_if_second_factor_enforced: "No puede deshabilitar el inicio de sesión local si se aplica 2FA. Deshabilite 2FA antes de deshabilitar los inicios de sesión locales." cannot_enable_s3_uploads_when_s3_enabled_globally: "No puedes habilitar las cargas S3 porque las cargas S3 ya están habilitadas globalmente, y habilitar este nivel de sitio podría causar problemas críticos con las cargas." cors_origins_should_not_have_trailing_slash: "No deberías añadir una barra al final (/) a los CORS origins." - conflicting_google_user_id: 'El ID de la cuenta Google para esta cuenta ha cambiado; el staff debe intervenir por razones de seguridad. Por favor, ponte en contacto con el staff y envía esta referencia
    https://meta.discourse.org/t/76575' + conflicting_google_user_id: 'El ID de la cuenta Google para esta cuenta ha cambiado; el personal debe intervenir por razones de seguridad. Ponte en contacto con el personal y envía esta referencia
    https://meta.discourse.org/t/76575' onebox: invalid_address: "Lo sentimos, no hemos podido generar una vista previa para esta página web porque el servidor '%{hostname}' no se ha encontrado. En lugar de una vista previa, solo aparecerá un enlace en tu publicación. :cry:" error_response: "Lo sentimos, no hemos podido generar una vista previa para esta página web porque el servidor web ha devuelto un código de error %{status_code}. En lugar de una vista previa, solo aparecerá un enlace en tu publicación. :cry:" @@ -212,15 +216,15 @@ es: comma: ", " invite: expired: "Su token de invitación ha caducado. Por favor, póngase en contacto con el personal." - not_found: "El código de invitación es inválido. Por favor, ponte en contacto con nuestro equipo." - not_found_json: "El código de invitación es inválido. Por favor, ponte en contacto con nuestro equipo." + not_found: "El código de invitación no es válido. Por favor, ponte en contacto con nuestro equipo." + not_found_json: "El código de invitación no es válido. Por favor, ponte en contacto con nuestro equipo." not_matching_email: "Tu dirección de correo y la dirección asociada a la invitación no coinciden. Por favor, contacta con la administración del sitio." not_found_template: |

    Tu invitación a %{site_name} ya ha sido utilizada.

    Si te acuerdas de tu contraseña puedes iniciar sesión.

    -

    Si no, por favor, restablece tu contraseña.

    +

    Si no, restablece tu contraseña.

    not_found_template_link: |

    La invitación a %{site_name} ya no se puede usar. Por favor, pídele a la persona que te invitó que te vuelva a mandar una.

    user_exists: "No hay necesidad de invitar a %{email}, ¡ya tienen una cuenta!" @@ -251,11 +255,11 @@ es: user_cannot_see_topic: "%{username} no puede ver el tema." backup: operation_already_running: "Actualmente se está ejecutando una operación. No se puede iniciar un nuevo trabajo en este momento." - backup_file_should_be_tar_gz: "El archivo de la copia de respaldo debería ser formato .tar.gz" - not_enough_space_on_disk: "No hay espacio suficiente en el disco para subir esta copia de respaldo." - invalid_filename: "El nombre de archivo del respaldo contiene caracteres inválidos. Los válidos son a-z 0-9 . - _." + backup_file_should_be_tar_gz: "El archivo de la copia de seguridad debería ser formato .tar.gz" + not_enough_space_on_disk: "No hay espacio suficiente en el disco para subir esta copia de seguridad." + invalid_filename: "El nombre de archivo del respaldo contiene caracteres no válidos. Los válidos son a-z 0-9 . - _." file_exists: "El archivo que estás intentando subir ya existe." - invalid_params: "Proporcionaste parámetros inválidos para la solicitud: %{message}" + invalid_params: "Has proporcionado parámetros no válidos para la solicitud: %{message}" not_logged_in: "Tienes que iniciar sesión para hacer eso." not_found: "No se pudo encontrar la URL o recurso solicitado." invalid_access: "No tienes permitido ver el recurso solicitado." @@ -264,11 +268,12 @@ es: provider_not_enabled: "No tienes permitido visualizar el recurso solicitado. El proveedor de autenticación no está habilitado." provider_not_found: "No tienes permitido visualizar el recurso solicitado. El proveedor de autenticación no existe." read_only_mode_enabled: "El sitio está en modo solo lectura. Las interacciones están deshabilitadas." - invalid_grant_badge_reason_link: "El enlace externo o inválido de discourse no está permitido como motivo de la medalla." + invalid_grant_badge_reason_link: "El enlace externo o inválido de discourse no está permitido como motivo de la insignia." email_template_cant_be_modified: "Esta plantilla de correo electrónico no se puede modificar" invalid_whisper_access: "O los susurros no están habilitados o no tienes permitido crear susurros" not_in_group: title_topic: "Necesitas solicitar formar parte del grupo «%{group}» para ver este tema" + title_category: "Necesitas solicitar formar parte del grupo «%{group}» para ver esta categoría." request_membership: "Solicitar membresía" join_group: "Unirse al grupo" deleted_topic: "¡Ups! Este tema se ha eliminado y ya no está disponible." @@ -285,7 +290,7 @@ es: start_discussion: "Empezar discusión" continue: "Continuar discusión" error: "Error al insertar" - referer: "Referente:" + referer: "Remisor:" error_topics: "La el ajuste «embed topics list» no está activado." mismatch: "El remitente no se envió o no coincide con ninguno de los siguientes hosts:" no_hosts: "No se han definido hosts para el incrustado." @@ -342,7 +347,7 @@ es: create_pm_on_existing_topic: "Lo sentimos, no puedes crear un mensaje privado en un tema existente." slow_mode_enabled: "Este tema está en modo lento." just_posted_that: "es demasiado parecido a lo que has publicado recientemente" - invalid_characters: "contiene caracteres inválidos" + invalid_characters: "contiene caracteres no válidos" is_invalid: "parece poco claro, ¿es una oración completa?" next_page: "página siguiente →" prev_page: "← página anterior" @@ -375,7 +380,7 @@ es: user_posts: "Publicaciones recientes de @%{username}" user_topics: "Temas recientes de @%{username}" tag: "Temas etiquetados" - badge: "%{display_name} medalla en %{site_title}" + badge: "%{display_name} insignia en %{site_title}" too_late_to_edit: "Esa publicación se publicó hace demasiado tiempo. No se puede editar ni eliminar." edit_conflict: "La publicación ha sido editada por otro usuario y tus cambios no se pueden guardar." revert_version_same: "La versión actual es la misma que la versión a la que intentas volver." @@ -423,7 +428,7 @@ es: everyone: "todos" admins: "administradores" moderators: "moderadores" - staff: "staff" + staff: "personal" trust_level_0: "nivel_de_confianza_0" trust_level_1: "nivel_de_confianza_1" trust_level_2: "nivel_de_confianza_2" @@ -437,8 +442,8 @@ es: Tu solicitud para entrar al grupo @%{group_name} ha sido aceptada y ahora eres parte de él. education: until_posts: - one: "%{count} mensaje" - other: "%{count} mensajes" + one: "%{count} publicación" + other: "%{count} publicaciones" "new-topic": | Te damos la bienvenida a %{site_name}. **¡Gracias por empezar un nuevo tema!** @@ -448,7 +453,7 @@ es: - Incluye palabras comúnmente utilizadas en tu tema para que los demás puedan **encontrarlo**. Para agrupar el tema con otros relacionados, selecciona una categoría (o etiqueta). - Para más información [consulta nuestra nuestra guía](%{base_path}/guidelines). Este panel solo aparecera para tus primeros %{education_posts_text} mensajes. + Para más información [consulta nuestra nuestra guía](%{base_path}/guidelines). Este panel solo aparecerá para tus primeras %{education_posts_text} publicaciones. "new-reply": | Te damos la bienvenida a %{site_name}. **¡Gracias por contribuir!** @@ -458,7 +463,7 @@ es: - Las críticas constructivas son bienvenidas, pero recuerda criticar las *ideas*, y no a las personas. - Para más información [consulta nuestra guía](%{base_path}/guidelines). Este panel solo aparecerá para tus primeros %{education_posts_text} mensajes. + Para más información [consulta nuestra guía](%{base_path}/guidelines). Este panel solo aparecerá para tus primeras %{education_posts_text} publicaciones. avatar: | ### ¿Qué tal si eliges una imagen para tu cuenta? @@ -518,17 +523,17 @@ es: topic: attributes: base: - warning_requires_pm: "Solamente puedes enviar advertencias por mensajes privado." + warning_requires_pm: "Solamente puedes enviar advertencias por mensajes privados." too_many_users: "Solamente puedes enviar advertencias a un usuario a la vez." - cant_send_pm: "Lo sentimos, no puedes enviar un mensaje personal a este usuario." + cant_send_pm: "Lo sentimos, no puedes enviar un mensaje privado a este usuario." no_user_selected: "Debes seleccionar un usuario válido." reply_by_email_disabled: "Las respuestas por correo electrónico han sido desactivadas." - send_to_email_disabled: "Lo sentimos, no puedes enviar mensajes personales a direcciones de correo." + send_to_email_disabled: "Lo sentimos, no puedes enviar mensajes privados a direcciones de correo." target_user_not_found: "No se pudo encontrar a uno de los usuarios a los que envías este mensaje." unable_to_update: "Hubo un error actualizando ese tema." unable_to_tag: "Hubo un error al etiquetar el tema." featured_link: - invalid: "es inválido. El URL debería incluir http:// o https://." + invalid: "no es válido. La URL debería incluir http:// o https://." invalid_category: "no se puede editar en esta categoría." user: attributes: @@ -591,7 +596,7 @@ es: vip_category_description: "Una categoría exclusiva para miembros con un nivel de confianza de 3 o más." meta_category_name: "Sugerencias sobre el sitio" meta_category_description: "Debate sobre este sitio, su organización, cómo funciona y cómo podemos mejorarlo." - staff_category_name: "Staff" + staff_category_name: "Personal" staff_category_description: "Categoría privada para debates entre moderadores y administradores. Los temas solo serán visibles para miembros del staff." discourse_welcome_topic: title: "Te damos la bienvenida a Discourse" @@ -636,7 +641,7 @@ es: category: topic_prefix: "Acerca de la categoría %{category}" replace_paragraph: "(Sustituye este párrafo con una descripción corta de tu nueva categoría. Este texto aparecerá en el área de selección de categoría, así que intenta que no supere los 200 caracteres.)" - post_template: "%{replace_paragraph}\n\nUtiliza los siguientes párrafos para una descripción más detallada, o establece las directrices o reglas de la categoría::\n\n- ¿Para qué la gente debería usar esta categoría? ¿Para qué se usa?\n\n- ¿Exactamente cómo se distingue de las otras categorías ya existentes?\n\n- ¿Qué temas debería contener esta categoría normalmente?\n\n- ¿Necesitamos esta categoría? ¿Podría fusionarse con otra categoría o subcategoría?\n" + post_template: "%{replace_paragraph}\n\nUtiliza los siguientes párrafos para una descripción más detallada, o establece las directrices o reglas de la categoría:\n\n- ¿Por qué debería la gente usar esta categoría? ¿Para qué se usa?\n\n- ¿Exactamente cómo se distingue de las otras categorías ya existentes?\n\n- ¿Qué temas debería contener esta categoría normalmente?\n\n- ¿Necesitamos esta categoría? ¿Podría fusionarse con otra categoría o subcategoría?\n" errors: not_found: "¡Categoría no encontrada!" uncategorized_parent: "Sin categoría no puede tener categoría primaria" @@ -671,8 +676,8 @@ es: slow_down: "Has realizado esta acción demasiadas veces. Por favor, inténtalo de nuevo más tarde." too_many_requests: "Has realizado esta acción demasiadas veces. Por favor, espera %{time_left} antes de intentar nuevamente." by_type: - first_day_replies_per_day: "Te agradecemos el entusiasmo, ¡sigue así! Dicho esto, has llegado al límite máximo de respuestas que por seguridad un usuario nuevo puede publicar en su primer día. Por favor, espera %{time_left} para seguir respondiendo." - first_day_topics_per_day: "¡Te agradecemos el entusiasmo! Dicho esto, has llegado al límite de temas que los usuarios nuevos pueden por seguridad crear en su primer día. Por favor, espera %{time_left}, y podrás seguir creando nuevos temas." + first_day_replies_per_day: "Te agradecemos el entusiasmo, ¡sigue así! Dicho esto, has llegado al límite máximo de respuestas que, por seguridad, un usuario nuevo puede publicar en su primer día. Por favor, espera %{time_left} para seguir respondiendo." + first_day_topics_per_day: "¡Te agradecemos el entusiasmo! Dicho esto, has llegado al límite de temas que los usuarios nuevos pueden, por seguridad, crear en su primer día. Por favor, espera %{time_left}, y podrás seguir creando nuevos temas." create_topic: "Estás creando temas un poco rápido. Por favor, espera %{time_left} antes de intentarlo de nuevo." create_post: "Estás respondiendo un poco rápido. Por favor, espera %{time_left} antes de volverlo a intentar." delete_post: "Estás borrando publicaciones un poco rápido. Por favor, espera %{time_left} antes de volverlo a intentar." @@ -717,10 +722,10 @@ es: one: "%{count}d" other: "%{count} d" about_x_months: - one: "%{count}mon" + one: "%{count} mes" other: "%{count} m" x_months: - one: "%{count}mon" + one: "%{count} mes" other: "%{count} meses" about_x_years: one: "%{count}y" @@ -841,7 +846,7 @@ es: activated: "Disculpa, esta cuenta ya fue activada." admin_confirm: title: "Confirmar cuenta de administrador" - description: "¿Estás seguro de que querer convertir a %{target_username} (%{target_email}) en un administrador?" + description: "¿Seguro que quieres convertir a %{target_username} (%{target_email}) en administrador?" grant: "Conceder acceso de administrador" complete: "%{target_username} ahora es un administrador." back_to: "Volver a %{title}" @@ -857,7 +862,7 @@ es: title: "Spam" description: "Esta publicación es publicidad o vandalismo. No es útil o relevante para el tema." short_description: "Esto es publicidad o vandalismo" - email_title: '«%{title}» fue reportado como spam' + email_title: '«%{title}» fue denunciado como spam' email_body: "%{link}\n\n%{message}" inappropriate: title: "Inapropiado" @@ -871,9 +876,9 @@ es: email_body: "%{link}\n\n%{message}" notify_moderators: title: "Notificar a los moderadores" - description: "Este mensaje requiere la atención del equipo de moderación por una razón no especificada arriba." + description: "Esta publicación requiere la atención del equipo de moderación por una razón no especificada arriba." short_description: "Requiere atención del staff por otra razón" - email_title: 'Un mensaje en «%{title}» requiere atención del staff' + email_title: 'Una publicación en «%{title}» requiere atención del personal' email_body: "%{link}\n\n%{message}" bookmark: title: "Marcador" @@ -906,9 +911,21 @@ es: others: "No hay respuestas." no_drafts: self: "No tienes ningún borrador; empieza a escribir una respuesta en cualquier tema y se guardará automáticamente como nuevo borrador." + email_settings: + pop3_authentication_error: "Ha habido un problema con las credenciales POP3 proporcionadas, comprueba el nombre de usuario y la contraseña e inténtalo de nuevo." + imap_authentication_error: "Ha habido un problema con las credenciales IMAP proporcionadas, comprueba el nombre de usuario y la contraseña e inténtalo de nuevo." + imap_no_response_error: "Se ha producido un error al comunicarse con el servidor IMAP. %{message}" + smtp_authentication_error: "Ha habido un problema con las credenciales SMTP proporcionadas, comprueba el nombre de usuario y la contraseña e inténtalo de nuevo." + authentication_error_gmail_app_password: 'Se requiere una contraseña específica para la aplicación. Obtén más información en este artículo de la Ayuda de Google' + smtp_server_busy_error: "El servidor SMTP está ocupado actualmente, inténtalo de nuevo más tarde." + smtp_unhandled_error: "Se ha producido un error no gestionado al comunicarse con el servidor SMTP. %{message}" + imap_unhandled_error: "Se ha producido un error no gestionado al comunicarse con el servidor IMAP. %{message}" + connection_error: "Ha habido un problema de conexión con el servidor, comprueba el nombre del servidor y el puerto e inténtalo de nuevo." + timeout_error: "La conexión con el servidor se ha interrumpido, comprueba el nombre y el puerto del servidor e inténtalo de nuevo." + unhandled_error: "Error no controlado al probar la configuración del correo electrónico. %{message}" webauthn: validation: - invalid_type_error: "El proveedor tipo webauthn provisto es inválido. Los tipos válidos son webauthn.get y webauthn.create." + invalid_type_error: "El proveedor tipo webauthn provisto no es válido. Los tipos válidos son webauthn.get y webauthn.create." challenge_mismatch_error: "El desafío provisto no coincide con el desafío generado por el servidor de autenticación." invalid_origin_error: "El origen de la solicitud de autenticación no coincide con el origen del servidor." malformed_attestation_error: "Se produjo un error al decodificar los datos de certificación." @@ -935,13 +952,13 @@ es: notify_moderators: title: "Notificar a los moderadores" description: 'Este tema necesita ser revisado por por un motivo relacionado con las directrices, Términos de servicio o por otra razón no mencionada anteriormente.' - long_form: "reportado para ser atendido por los moderadores" + long_form: "denunciado para ser atendido por los moderadores" short_description: "Requiere atención del staff por otro motivo" email_title: 'El tema «%{title}» requiere la atención de un moderador' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

    Tu publicación fue reportada por la comunidad. Por favor, revisa tus mensajes.

    ' - user_must_edit: "

    Esta publicación fue reportada por la comunidad y se encuentra oculta temporalmente.

    " + you_must_edit: '

    Tu publicación fue denunciada por la comunidad. Por favor, revisa tus mensajes.

    ' + user_must_edit: "

    Esta publicación fue denunciada por la comunidad y se encuentra oculta temporalmente.

    " ignored: hidden_content: "

    Contenido ignorado

    " archetypes: @@ -1006,10 +1023,10 @@ es: bookmarks_calendar: "Leer recordatorios de marcadores" invalid_public_key: "Lo sentimos, la clave pública no es válida." invalid_auth_redirect: "Lo sentimos, este host auth_redirect no está permitido." - invalid_token: "El token es inválido, ha expirado o no se puede conseguir." + invalid_token: "El token no es válido, ha caducado o no se encuentra" flags: errors: - already_handled: "El reporte ya ha sido atendido" + already_handled: "La denuncia ya ha sido atendida" reports: default: labels: @@ -1026,27 +1043,27 @@ es: edit_reason: Motivo description: "Número de ediciones nuevas de publicaciones." user_flagging_ratio: - title: "Proporción de reportes del usuario" + title: "Proporción de denuncias del usuario" labels: user: Usuario - agreed_flags: Reportes de acuerdo - disagreed_flags: Reportes en desacuerdo - ignored_flags: Reportes ignorados + agreed_flags: Denuncias aceptadas + disagreed_flags: Denuncias rechazadas + ignored_flags: Denuncias ignoradas score: Puntuación - description: "Lista de usuarios ordenada de acuerdo a la proporción de respuestas a sus reportes por parte del staff (de acuerdo a en desacuerdo)." + description: "Lista de usuarios ordenada de acuerdo a la proporción de respuestas a sus denuncias por parte del personal (de acuerdo a en desacuerdo)." moderators_activity: title: "Actividad de moderación" labels: moderator: Moderador - flag_count: Reportes revisados + flag_count: Denuncias revisadas time_read: Tiempo de lectura topic_count: Temas creados post_count: Publicaciones creadas pm_count: Mensajes privados creados revision_count: Revisiones - description: Lista de actividad de moderación que incluye los reportes revisados, tiempo de lectura, temas creados, publicaciones creadas, mensajes personales creados y revisiones. + description: Lista de actividad de moderación que incluye las denuncias revisadas, tiempo de lectura, temas creados, publicaciones creadas, mensajes personales creados y revisiones. flags_status: - title: "Estado de reportes" + title: "Estado de denuncias" values: agreed: De acuerdo disagreed: En desacuerdo @@ -1056,9 +1073,9 @@ es: flag: Tipo assigned: Asignado poster: Autor - flagger: Reportado por + flagger: Denunciado por time_to_resolution: Tiempo de resolución - description: "Lista de estados de reporte que incluye el tipo de reporte, usuario que lo publica, usuario que reporta y el tiempo de resolución." + description: "Lista de estados de denuncia que incluye el tipo de denuncia, el autor, el usuario que denuncia y el tiempo de resolución." visits: title: "Visitas de usuario" xaxis: "Día" @@ -1073,7 +1090,7 @@ es: title: "Colaboradores nuevos" xaxis: "Día" yaxis: "Número de colaboradores nuevos" - description: "Número de usuarios que han publicado su primer mensaje durante este periodo." + description: "Número de usuarios que han publicado su primera publicación durante este periodo." trust_level_growth: title: "Crecimiento de nivel de confianza" xaxis: @@ -1095,7 +1112,7 @@ es: post: Publicación editor: Editor author: Autoría - edit_reason: Razón + edit_reason: Motivo dau_by_mau: title: "UAD/UAM" xaxis: "Día" @@ -1127,15 +1144,15 @@ es: yaxis: "Número de nuevos me gusta" description: "Número de nuevos me gusta." flags: - title: "Reportes" + title: "Denuncias" xaxis: "Día" - yaxis: "Número de reportes" - description: "Número de reportes nuevos." + yaxis: "Número de denuncias" + description: "Número de denuncias nuevas." bookmarks: title: "Marcadores" xaxis: "Día" yaxis: "Número de nuevos marcadores" - description: "Número de temas y mensajes nuevos guardados en marcadores." + description: "Número de temas y publicaciones nuevas guardadas en marcadores." users_by_trust_level: title: "Usuarios por nivel de confianza" xaxis: "Nivel de confianza" @@ -1172,32 +1189,32 @@ es: title: "Usuario a usuario (respuestas excluidas)" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de mensajes personales nuevos iniciados." + description: "Número de mensajes privados nuevos iniciados." user_to_user_private_messages_with_replies: title: "Usuario a usuario (con respuestas)" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de todos los mensajes personales y respuestas." + description: "Número de todos los mensajes privados y respuestas." system_private_messages: title: "Sistema" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de mensajes personales enviados automáticamente por el sistema." + description: "Número de mensajes privados enviados automáticamente por el sistema." moderator_warning_private_messages: title: "Advertencias de moderación" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de advertencias enviadas a través de mensaje personal por moderadores." + description: "Número de advertencias enviadas a través de mensaje privado por moderadores." notify_moderators_private_messages: title: "Notificaciones a moderadores" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de veces que los moderadores han sido notificados de forma privada por un reporte." + description: "Número de veces que los moderadores han sido notificados de forma privada por una denuncia." notify_user_private_messages: title: "Notificaciones a usuarios" xaxis: "Día" yaxis: "Número de mensajes" - description: "Número de veces que se le ha notificado a los usuarios de forma privada debido a un reporte." + description: "Número de veces que se le ha notificado a los usuarios de forma privada debido a una denuncia." top_referrers: title: "Aquellos que realizan la mayor cantidad de recomendaciones" xaxis: "Usuario" @@ -1336,18 +1353,18 @@ es: dashboard: rails_env_warning: "Tu servidor está funcionando en modo de %{env}." host_names_warning: "Tu archivo config/database.yml está utilizando el hostname localhost predeterminado. Actualízalo para usar el hostname de tu sitio." - sidekiq_warning: 'Sidekiq no está funcionando. Muchas tareas, tal como el envío de correos electrónicos, están siendo realizadas desincronizadamente por sidekiq. Por favor, asegúrate de que por lo menos un proceso de sidekiq está funcionando. Aprende sobre Sidekiq aquí.' + sidekiq_warning: 'Sidekiq no está funcionando. Muchas tareas, tal como el envío de correos electrónicos, se están ejecutando de forma desincronizada por sidekiq. Asegúrate de que por lo menos un proceso de sidekiq está funcionando. Puedes obtener más información sobre Sidekiq aquí.' queue_size_warning: "El número de tareas en cola es %{queue_size}, que es una cifra alta. Esto podría indicar un problema en el proceso (o procesos) de Sidekiq, o tal vez deberías añadir más trabajadores de Sidekiq." memory_warning: "Tu servidor está funcionando con menos de 1 GB de memoria total. Se recomienda una memoria de al menos 1 GB de capacidad." google_oauth2_config_warning: 'El servidor está configurado para permitir registrarse e iniciar sesión con Google OAuth2 (enable_google_oauth2_logins), pero la ID de cliente y el secreto no están configurados. Ve a los ajustes del sitio y actualízalos. Mira esta guía para más información.' facebook_config_warning: 'El servidor está configurado para permitir registrarse e iniciar sesión con Facebook (enable_facebook_logins), pero la ID de cliente y el secreto no están configurados. Ve a los ajustes del sitio y actualízalos.Mira esta guía para más información.' twitter_config_warning: 'El servidor está configurado para permitir registrarse e iniciar sesión con Twitter (enable_twitter_logins), pero la ID de cliente y el secreto no están configurados. Ve a los ajustes del sitio y actualízalos.Mira esta guía para más información.' github_config_warning: 'El servidor está configurado para permitir registrarse e iniciar sesión con GitHub (enable_github_logins), pero la ID de cliente y el secreto no están configurados. Ve a los ajustes del sitio y actualízalos. Mira esta guía para más información.' - s3_config_warning: 'El servidor está configurado para permitir subir archivos a S3, pero por al menos una de las siguientes configuraciones no se ha establecido: s3_access_key_id, s3_secret_access_key o s3_upload_bucket. Ve a los ajustes del sitio y actualiza la configuración. Revisa «How to set up image uploads to S3?» para aprender más.' - s3_backup_config_warning: 'El servidor está configurado para permitir subir los respaldos a S3, pero por al menos una de las siguientes configuraciones no se ha establecido: : s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, o s3_backup_bucket. Ve a los ajustes del sitio y actualiza la configuración. Revisa «How to set up image uploads to S3?» para aprender más.' - s3_cdn_warning: 'El servidor está configurado para cargar los archivos a S3, pero no hay CDN de S3 configurado. Esto puede llevar a grandes costes de S3 y mal rendimiento del sitio. Ver "Using Object Storage for Uploads" para más detalles.' + s3_config_warning: 'El servidor está configurado para permitir subir archivos a S3, pero por al menos una de las siguientes configuraciones no se ha establecido: s3_access_key_id, s3_secret_access_key o s3_upload_bucket. Ve a los ajustes del sitio y actualiza la configuración. Consulta «How to set up image uploads to S3?» para obtener más información.' + s3_backup_config_warning: 'El servidor está configurado para permitir subir copias de seguridad a S3, pero al menos una de las siguientes configuraciones no se ha establecido: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, o s3_backup_bucket. Ve a los ajustes del sitio y actualiza la configuración. Consulta «How to set up image uploads to S3?» para obtener más información.' + s3_cdn_warning: 'El servidor está configurado para cargar los archivos a S3, pero no hay CDN de S3 configurado. Esto puede llevar a grandes costes de S3 y mal rendimiento del sitio. Consulta «Using Object Storage for Uploads» para obtener más detalles.' image_magick_warning: 'El servidor está configurado para permitir miniaturas de imágenes grandes, pero ImageMagick no está instalado. Instala ImageMagick usando tu administrador de paquetes favorito o descarga la última versión.' - failing_emails_warning: 'Hay %{num_failed_jobs} tareas de correo electrónico que fallaron. Revisa tu app.yml y asegúrate de que la configuración del servidor de correo es correcta. Mira las tareas que fallaron en Sidekiq.' + failing_emails_warning: 'Hay %{num_failed_jobs} tareas de correo electrónico que fallaron. Revisa tu app.yml y asegúrate de que la configuración del servidor de correo es correcta. Consulta las tareas que fallaron en Sidekiq.' subfolder_ends_in_slash: "La configuación del subdirectorio no es correcta; el campo DISCOURSE_RELATIVE_URL_ROOT termina con una barra." email_polling_errored_recently: one: "El email polling ha generado un error en las pasadas 24 horas. Mira en los logs para más detalles." @@ -1359,6 +1376,7 @@ es: force_https_warning: "Tu sitio web está usando SSL. Pero «force_https» no está habilitado todavía en la configuración de tu sitio." out_of_date_themes: "Hay actualizaciones disponibles para los siguientes temas:" unreachable_themes: "No pudimos verificar actualizaciones de los siguientes temas:" + watched_word_regexp_error: "La expresión regular para %{action} palabras vigiladas no es válida. Comprueba tus Ajustes de palabras vigiladas, o desactiva el ajuste del sitio «expresiones regulares de palabras vigiladas»." site_settings: display_local_time_in_user_card: "Mostrar la hora local basada en el la zona horaria cuando se abra la tarjeta de un usuario." censored_words: "Las palabras serán reemplazadas con ■■■■" @@ -1396,6 +1414,7 @@ es: title: "El nombre de este sitio, utilizada en la etiqueta título." site_description: "Describe este sitio en una frase, utilizada en la etiqueta meta description." short_site_description: "Descripción breve, utilizada como el título de la etiqueta en la página principal." + contact_email: "Dirección de correo electrónico del contacto clave responsable de este sitio. Se utiliza para notificaciones críticas, y también se muestra en /about para asuntos urgentes." contact_url: "Dirección URL de contacto para el sitio. Se mostrará en la página de /about (acerca de) para temas urgentes." crawl_images: "Recuperar imágenes desde URLs remotas para insertarlas con las dimensiones correctas de ancho y de largo." download_remote_images_to_local: "Convertir imágenes remotas a imágenes locales descargándolas; esto previene imágenes rotas." @@ -1414,7 +1433,7 @@ es: responsive_post_image_sizes: "Cambiar el tamaño de las imágenes de vista previa de lightbox para permitir su visualización en pantallas de alto DPI de las siguientes proporciones de píxeles. Eliminar todos los valores para deshabilitar las imágenes responsivas." fixed_category_positions: "Si está marcada, podrás organizar las categorías en un orden determinado. Si no, las categorías se mostrarán según su actividad reciente." fixed_category_positions_on_create: "Si está marcada, el orden de las categorías se mantendrá en el diálogo de creación de temas (requiere fixed_category_positions)." - add_rel_nofollow_to_user_content: 'Añadir la etiqueta rel nofollow a todo el contenido ingresado por los usuarios, excepto por los enlaces internos (incluyendo dominios padre). Si cambias esto, deberás hacer un rebake de todos las publicaciones con: «rake posts:rebake»' + add_rel_nofollow_to_user_content: 'Añadir la etiqueta rel nofollow a todo el contenido introducido por los usuarios, excepto por los enlaces internos (incluidos dominios principales). Si cambias esto, deberás hacer un rebake de todos las publicaciones con: «rake posts:rebake»' exclude_rel_nofollow_domains: "Una lista de dominios a cuyos enlaces no se les añadirá la etiqueta nofollow. ejemplo.com permitirá automáticamente sub.ejemplo.com también. Como mínimo, deberías añadir el dominio de este sitio para ayudar a los rastreadores web a encontrar todo el contenido. Si otras partes de este sitio web están en otros dominios, añádelos también." post_excerpt_maxlength: "Extensión máxima del resumen / extracto de una publicación." topic_excerpt_maxlength: "Longitud máxima de un extracto o resumen de tema, generado a partir de la primera publicación de un tema." @@ -1437,22 +1456,23 @@ es: large_icon: "Imagen utilizada como base para otros iconos de metadatos. Lo ideal sería que fuera mayor que 512 x 512. Si se deja en blanco, se utilizará logo_small." manifest_icon: "Imagen utilizada como logo/imagen de bienvenida en Android. Se cambiará automáticamente el tamaño a 512 × 512. Si se deja en blanco, se utilizará large_icon." manifest_screenshots: "Capturas de pantalla para mostrar las funcionalidades del sitio web en la pantalla de instalación. Todas las imágenes deben ser alojadas localmente y tener las mismas dimensiones." - favicon: "Un favicon para su sitio, ve https://es.wikipedia.org/wiki/Favicon. Para trabajar correctamente sobre un CDN debe ser un png. Se cambiará automáticamente el tamaño a 32x32. Si se deja en blanco, se utilizará large_icon." + favicon: "Un favicon para su sitio, ve https://es.wikipedia.org/wiki/Favicon. Para que funcione correctamente sobre un CDN debe ser un png. Se cambiará automáticamente el tamaño a 32x32. Si se deja en blanco, se utilizará large_icon." apple_touch_icon: "Icono utilizado para dispositivos táctiles de Apple. Se redimensionará automáticamente a 180x180. Si se deja en blanco, se utilizará el icono grande." opengraph_image: "Imagen de gráfico abierto predeterminada, utilizada cuando la página no tiene otra imagen adecuada. Si se deja en blanco, se utilizará large_icon" twitter_summary_large_image: "La «imagen grande de resumen» de la tarjeta de Twitter (debe tener al menos 280 de anchura y al menos 150 de altura). Si se deja en blanco, se generan metadatos de tarjetas normales utilizando opengraph_image." notification_email: "La dirección de correo electrónico remitente utilizada al enviar todos los correos electrónicos esenciales de sistema. El dominio especificado debe tener correctamente configurados los registros SPF, DKIM y PTR inversos para que los correos electrónicos se reciban correctamente." email_custom_headers: "Lista de correos electrónicos separados por barras" - email_subject: "Formato de asunto personalizable para correos electrónicos estándar. Revisa https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + email_subject: "Formato de asunto personalizable para correos electrónicos estándar. Consulta https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "Proporciona más detalles a los usuarios acerca de por qué no pueden acceder a un tema en particular. Nota: esto es menos seguro dado que los usuarios sabrán si una URL enlaza a un tema válido." enforce_second_factor: "Obligar a usar la autenticación de dos factores. Seleccionar «all» para obligar a todos y «staff» para obligar sólo a usuarios del personal." force_https: "Forzar al sitio a utilizar solo HTTPS. AVISO: ¡NO actives esta opción a menos que verifiques completamente la configuración y funcione correctamente en todas partes! ¿Has verificado también que el CDN, los inicios de sesión a través de redes sociales y cualquier logo externo / dependencia son compatibles con HTTPS?" + same_site_cookies: "Usar las mismas cookies del sitio, eliminan todos los vectores CSRF (falsificación de petición en sitios cruzados) de los navegadores soportados (Lax o Strict). Advertencia: Strict solo funcionará en sitios que fuercen el inicio de sesión y usen un método de autenticación externo." summary_score_threshold: "La puntuación mínima requerida para que una publicación se incluya en el «resumen de este tema»" summary_posts_required: "Cantidad mínima de publicaciones antes de que 'Resumen de este tema' esté habilitado. Los cambios a esta configuración se aplicarán retroactivamente dentro de una semana." summary_likes_required: "Cantidad mínina de me gusta en un tema para habilitar «resumen de este tema». Los cambios a esta configuración se aplicarán retroactivamente dentro de una semana." summary_percent_filter: "Cuando un usuario hace clic en «resumen de este tema», mostrar el % de las publicaciones destacadas" summary_max_results: "Cantidad máxima de publicaciones devueltas en «resumen de este tema»" - enable_personal_messages: "Permitir a los usuarios con nivel de confianza 1 (configurable a través del mínimo nivel de confianza para enviar mensajes) crear mensajes y responder a ellos. Ten en cuenta que el staff siempre puede enviar mensajes pase lo que pase." + enable_personal_messages: "Permitir a los usuarios con nivel de confianza 1 (configurable a través del mínimo nivel de confianza para enviar mensajes) crear mensajes y responder a ellos. Ten en cuenta que el personal siempre puede enviar mensajes pase lo que pase." enable_system_message_replies: "Permite a los usuarios responder a los mensajes del sistema, incluso si los mensajes privados están inhabilitados." enable_long_polling: "Los mensajes usados para notificaciones pueden usar el long polling" enable_chunked_encoding: "Activar respuestas en lotes del servidor. Esta funcionalidad debería funcionar en casi todos los entornos, pero algunos proxies pueden causar que las respuestas tarden" @@ -1461,20 +1481,23 @@ es: polling_interval: "Cuando no este en long polling, frecuencia con la que los clientes con sesión iniciada hacen poll en milisegundos" anon_polling_interval: "Frecuencia en milisegundos con la que los clientes anónimos hacen poll" background_polling_interval: "Frecuencia en milisegundos con la que los clientes con sesión iniciada hacen poll (mientras que la ventana está en segundo plano)" - hide_post_sensitivity: "La probabilidad de que una publicación reportada se oculte" - silence_new_user_sensitivity: "La probabilidad de que un usuario nuevo sea silenciado con base en reportes de spam" - auto_close_topic_sensitivity: "La probabilidad de que un tema reportado se cierre automáticamente" - cooldown_minutes_after_hiding_posts: "Número de minutos que un usuario debe esperar para poder editar una publicación oculta debido a reportes de la comunidad" + hide_post_sensitivity: "La probabilidad de que una publicación denunciada se oculte" + silence_new_user_sensitivity: "La probabilidad de que un usuario nuevo sea silenciado basándose en denuncias de spam" + auto_close_topic_sensitivity: "La probabilidad de que un tema denunciado se cierre automáticamente" + cooldown_minutes_after_hiding_posts: "Número de minutos que un usuario debe esperar para poder editar una publicación oculta debido a denuncias de la comunidad" max_topics_in_first_day: "El número máximo de temas que un usuario puede crear durante las 24 horas posteriores a su primera publicación" max_replies_in_first_day: "El número máximo de respuestas que un usuario puede crear durante las 24 horas posteriores a su primera publicación" tl2_additional_likes_per_day_multiplier: "Incrementar el límite de me gusta por día para los usuarios con nivel de confianza 2 (miembros) multiplicándolo por este número" - tl3_additional_likes_per_day_multiplier: "Incrementar el límite de me gusta por día para los usuarios con nivel de confianza 3 (regular) multiplicándolo por este número" + tl3_additional_likes_per_day_multiplier: "Incrementar el límite de me gusta por día para los usuarios con nivel de confianza 3 (habitual) multiplicándolo por este número" tl4_additional_likes_per_day_multiplier: "Incrementar el límite de me gusta por día para los usuarios con nivel de confianza 4 (líder) multiplicándolo por este número" - num_users_to_silence_new_user: "Si las publicaciones de un usuario nuevo se reportan como spam por num_spam_flags_to_silence_new_user usuarios diferentes, ocultar todas sus publicaciones y evitar que publique en el futuro. Establece el valor en 0 para deshabilitar." - num_tl3_flags_to_silence_new_user: "Si las publicaciones de un usuario nuevo obtienen este número de reportes de num_tl3_users_to_silence_new_user diferentes usuarios con nivel de confianza 3, se ocultarán todas sus publicaciones y no podrá publicar más en un futuro. Establece el valor en 0 para deshabilitar." - num_tl3_users_to_silence_new_user: "Si las publicaciones de un usuario nuevo reciben num_tl3_flags_to_silence_new_user reportes de este número de usuarios de nivel 3 de confianza, ocultar todas sus publicaciones y prevenir futuras publicaciones. Establece el valor en 0 para deshabilitar." + tl2_additional_edits_per_day_multiplier: "Incrementar el límite de ediciones por día para los usuarios con nivel de confianza 2 (miembros) multiplicándolo por este número" + tl3_additional_edits_per_day_multiplier: "Incrementar el límite de ediciones por día para los usuarios con nivel de confianza 3 (normal) multiplicándolo por este número" + tl4_additional_edits_per_day_multiplier: "Incrementar el límite de ediciones por día para los usuarios con nivel de confianza 4 (líder) multiplicándolo por este número" + num_users_to_silence_new_user: "Si las publicaciones de un usuario nuevo se denuncian como spam por num_spam_flags_to_silence_new_user usuarios diferentes, ocultar todas sus publicaciones y evitar que publique en el futuro. Establece el valor en 0 para deshabilitar." + num_tl3_flags_to_silence_new_user: "Si las publicaciones de un usuario nuevo obtienen este número de denuncias de num_tl3_users_to_silence_new_user diferentes usuarios con nivel de confianza 3, se ocultarán todas sus publicaciones y no podrá publicar más en un futuro. Establece el valor en 0 para deshabilitar." + num_tl3_users_to_silence_new_user: "Si las publicaciones de un usuario nuevo reciben num_tl3_flags_to_silence_new_user denuncias de este número de usuarios de nivel 3 de confianza, ocultar todas sus publicaciones y prevenir futuras publicaciones. Establece el valor en 0 para deshabilitar." notify_mods_when_user_silenced: "Si un usuario es silenciado automáticamente, enviar un mensaje a todos los moderadores." - flag_sockpuppets: "Si un usuario nuevo responde a un tema desde la misma dirección de IP que el usuario que inició el tema, reportar las publicaciones de ambos spam en potencia." + flag_sockpuppets: "Si un usuario nuevo responde a un tema desde la misma dirección de IP que el usuario que inició el tema, denunciar las publicaciones de ambos spam en potencia." traditional_markdown_linebreaks: "Utilizar saltos de línea tradicionales en Markdown, los cuales requieren dos espacios al final para un salto de línea." enable_markdown_typographer: "Utilizar reglas de tipografía para mejorar la legibilidad de texto: remplaza citas rectas «con comillas», (c) (tm) con símbolos, -- con guion largo –, etc" enable_markdown_linkify: "Tratar automáticamente el texto que parezca un enlace como un enlace: www.ejemplo.com y https://ejemplo.com se vincularán automáticamente" @@ -1482,10 +1505,10 @@ es: markdown_typographer_quotation_marks: "Lista de pares de reemplazo de comillas dobles y simples" post_undo_action_window_mins: "Número de minutos durante los cuales los usuarios pueden deshacer sus acciones recientes en una publicación (me gusta, reportes, etc)." must_approve_users: "El staff debe aprobar todas las cuentas nuevas antes de que se les permita acceder al sitio." - invite_code: "El usuario debe ingresar este código para que se le permita el registro de la cuenta, ignorado cuando está vacío (no distingue entre mayúsculas y minúsculas)" + invite_code: "El usuario debe introducir este código para que se le permita el registro de la cuenta, ignorado cuando está vacío (no distingue entre mayúsculas y minúsculas)" approve_suspect_users: "Mandar usuarios sospechosos a la lista de revisión. Se consideran usuarios sospechosos aquellos que hayan introducido una biografía o página web, pero que no hayan registrado ninguna actividad de lectura." review_every_post: "Todas las publicaciones deben ser revisadas. ¡ADVERTENCIA! NO SE RECOMIENDA PARA SITIOS CON MUCHA ACTIVIDAD." - pending_users_reminder_delay: "Notificar a los moderadores si hay usuarios nuevos que hayan estado esperando aprobación durante más de esta cantidad de horas. Usa -1 para desactivar estas notificaciones." + pending_users_reminder_delay_minutes: "Notificar a los moderadores si hay usuarios nuevos que hayan estado esperando aprobación durante más de esta cantidad de minutos. Usa -1 para desactivar estas notificaciones." persistent_sessions: "Los usuarios permanecerán con la sesión abierta aunque cierren su navegador" maximum_session_age: "El usuario permanecerá con su sesión iniciada n horas desde su última visita" ga_version: "Versión de Google Universal Analytics a usar: v3 (analytics.js), v4 (gtag)" @@ -1499,20 +1522,23 @@ es: use_admin_ip_allowlist: "Los administradores solo pueden iniciar sesión si están en una dirección IP definida en la lista de direcciones IP revisadas (Administrados > Registros > Direcciones IP)." blocked_ip_blocks: "Lista de direcciones IP privadas en las que Discourse no debería hacer crawling" allowed_internal_hosts: "Lista de hosts internos sobre los que discourse puede hacer crawl de forma segura para oneboxes y otras finalidades." + allowed_onebox_iframes: "Una lista de dominios iframe src que se permiten a través de incrustaciones de Onebox. «*» permitirá todos los motores Onebox por defecto." allowed_iframes: "Una lista de prefijos de dominios para iframe src que discourse puede permitir de forma segura en publicaciones" allowed_crawler_user_agents: "Agentes de usuario de crawlers a los que se debe permitir el acceso al sitio. ¡ADVERTENCIA! ¡CONFIGURAR ESTO INHABILITARÁ A TODOS LOS CRAWLERS QUE NO SE ENCUENTRAN AQUÍ!" + blocked_crawler_user_agents: "Palabra única insensible a mayúsculas y minúsculas en la cadena del agente de usuario que identifica a los que no se debe permitir el acceso al sitio. No se aplica si se define una lista de permitidos." slow_down_crawler_user_agents: "Agentes de usuario de crawlers a los que se debe aplicar una cuota limitada en robots.txt utilizando la directiva de retardo de rastreo" slow_down_crawler_rate: "Si slow_down_crawler_user_agents es especificado, esta proporción aplicará para todos los crawlers (número de segundos de demora entre solicitudes)" content_security_policy: "Activar la política de seguridad de contenido (CSP)" content_security_policy_report_only: "Activar solo el informe de la política de seguridad de contenido (CPS)" - content_security_policy_collect_reports: "Habilitar la recolección de reportes de violación de CSP en /csp_reports" + content_security_policy_collect_reports: "Habilitar la recolección de denuncias de violación de CSP en /csp_reports" content_security_policy_frame_ancestors: "Restringir quién puede incrustar este sitio en iframes a través de CSP. Controla los hosts permitidos en Insertado" + content_security_policy_script_src: "Fuentes de script adicionales en la lista de permitidos. El host actual y CDN se incluyen por defecto. Leer Mitigar ataques XSS con la Política de Seguridad de Contenidos." invalidate_inactive_admin_email_after_days: "Las cuentas administrativas que no hayan visitado la página en este número de días deberán validar de nuevo su dirección de correo electrónico antes de iniciar sesión. Establecer a 0 para desactivar." top_menu: "Determinar los elementos que aparecen en el menú de navegación de la página de inicio y su orden. Ejemplo últimos|nuevos|no leídos|categorías|destacados|leídos|publicados|marcadores" - post_menu: "Determinar los elementos que aparecen en el menú de publicación y su orden. Ejemplo: me gusta|editar|reportar|eliminar|compartir|guardar en marcadores|responder" + post_menu: "Determinar los elementos que aparecen en el menú de publicación y su orden. Ejemplo: me gusta|editar|denunciar|eliminar|compartir|guardar en marcadores|responder" post_menu_hidden_items: "Los elementos del menú que se ocultan por defecto en el menú de publicación a menos que se haga clic en el botón para expandir las opciones." share_links: "Determinar los elementos que aparecen en el cuadro de compartir y su orden." - site_contact_username: "Nombre de usuario de miembro del staff desde donde se enviarán todos los mensajes automáticos. Si se deja en blanco, se utilizará la cuenta por defecto del sistema." + site_contact_username: "Nombre de usuario de miembro del personal desde donde se enviarán todos los mensajes automáticos. Si se deja en blanco, se utilizará la cuenta por defecto del sistema." site_contact_group_name: "Un nombre de grupo válido para que sea invitado a todos los mensajes automáticos." send_welcome_message: "Enviar a todos los usuarios nuevos un mensaje de bienvenida con una guía rápida de inicio." send_tl1_welcome_message: "Enviar a los nuevos usuarios con nivel de confianza 1 un mensaje de bienvenida." @@ -1531,7 +1557,8 @@ es: enable_rich_text_paste: "Habilitar conversión automática de HTML a Markdown al pegar texto en el compositor. (Experimental)" send_old_credential_reminder_days: "Recordar credenciales antiguas después de días" email_token_valid_hours: "Los tokens para restablecer contraseña olvidada / activar cuenta son válidos durante (n) horas." - enable_badges: "Activar el sistema de medallas" + enable_badges: "Activar el sistema de insignias" + max_favorite_badges: "Número máximo de insignias que el usuario puede seleccionar" enable_whispers: "Permitir que los miembros del staff se comuniquen entre ellos en privado dentro de los temas." allow_index_in_robots_txt: "Especificar en robots.txt que se permite que este sitio sea indexado por los motores de búsqueda en Internet. En casos excepcionales, puedes sobrescribir robots.txt permanentemente." blocked_email_domains: "Lista de dominios de correo electrónico que no pueden ser utilizados para registrarse. Ejemplo: mailinator.com|trashmail.net" @@ -1560,10 +1587,11 @@ es: enable_discourse_connect_provider: "Implementar el proveedor de protocolo DiscourseConnect (antiguamente «Discourse SSO») en la ruta /session/sso_provider, discourse_connect_provider_secrets debe estar rellenado." discourse_connect_url: "URL del endpoint de DiscourseConnect (debe incluir http:// o https://)" discourse_connect_secret: "Secreto usado para autenticar criptográficamente la información de DiscourseConnect, asegúrate de que tiene 10 o más caracteres de longitud" + discourse_connect_provider_secrets: "Una lista de pares de dominio-secreto que utiliza Discourse como proveedor de SSO. Asegúrate de que la clave secreta SSO tenga de 10 caracteres o más. El símbolo de comodín * se puede usar para hacer coincidir cualquier dominio o solo una parte de él (por ejemplo, *.ejemplo.com)." discourse_connect_overrides_bio: "Sobreescribir la biografía del usuario y evita que los usuarios la cambien" discourse_connect_overrides_groups: "Sincronizar todas las membresías manuales a grupos con los grupos especificados en el atributo groups (CUIDADO: si indicas grupos, todas las membresías manuales del usuario serán borradas)" auth_overrides_email: "Sobreescribir la dirección de correo local con la del sitio externo en cada inicio de sesión, y evitar cambios locales. Aplica a todos los proveedores de autenticación (CUIDADO: puede haber diferencias por la normalización de direcciones locales de correo)" - auth_overrides_username: "Sobreescribir nombre de usuari locale con el del sitio externo en cada inicio de sesión, y evitar cambios locales. Aplica a todos los proveedores de autenticación. (CUIDADO: puede haber diferencias si hay distintos requisitos en los nombres de usuarios, como los caracteres o la longitud)" + auth_overrides_username: "Sobrescribe el nombre de usuario local con el del sitio externo en cada inicio de sesión, y evita cambios locales. Se aplica a todos los proveedores de autenticación. (CUIDADO: puede haber diferencias si hay distintos requisitos en los nombres de usuarios, como los caracteres o la longitud)" auth_overrides_name: "Sobreescribir nombre completo local con el del sitio externo en cada inicio de sesión, y evitar cambios locales. Aplica a todos los proveedores de autenticación." discourse_connect_overrides_avatar: "Sobreescribir el avatar del usuario por el dado por DiscourseConnect. Si se activa, los usuarios no podrán cambiar su avatar ni subir nuevos desde Discourse." discourse_connect_overrides_location: "Sobreescribir la ubicación del usuario con la dada por DiscourseConnect, y evitar cambios locales." @@ -1576,12 +1604,12 @@ es: enable_local_logins_via_email: "Permitir a los usuarios que soliciten un enlace de inicio de sesión único que se les enviará por correo electrónico." allow_new_registrations: "Permitir el registro de usuarios nuevos. Desactiva esta opción para que nadie pueda crear una cuenta nueva." enable_signup_cta: "Mostrar un aviso a los usuarios anónimos que vuelvan a visitar el sitio animándoles a registrarse." - enable_google_oauth2_logins: "Habilita la autenticación con Google Oauth2. Este es el método de autenticación que Google soporta actualmente. Requiere de una clave de cliente y una clave secreta. Ver configuración del inicio de sesión mediante para Discourse." + enable_google_oauth2_logins: "Habilita la autenticación con Google Oauth2. Este es el método de autenticación que Google soporta actualmente. Requiere de una clave de cliente y una clave secreta. Consulta configuración del inicio de sesión mediante para Discourse." google_oauth2_client_id: "ID de cliente de tu aplicación de Google." google_oauth2_client_secret: "Clave secreta del cliente de tu aplicación de Google." google_oauth2_prompt: "Una lista opcional delimitada por valores de cadena que especifica si el servidor de autorizaciones solicita al usuario la reautenticación y el consentimiento. Ver https://developers.google.com/identity/protocols/OpenIDConnect#prompt para los valores posibles." google_oauth2_hd: "Un dominio opcional hospedado por Google Apps limitará el inicio de sesión. Ver https://developers.google.com/identity/protocols/OpenIDConnect#hd-param para más detalles." - enable_twitter_logins: "Activar autenticación mediante Twitter, requiere una twitter_consumer_key y un twitter_consumer_secret. Ver configuración del inicio de sesión mediante Twitter (e inserciones) para Discourse." + enable_twitter_logins: "Activar autenticación mediante Twitter requiere una twitter_consumer_key y un twitter_consumer_secret. Consulta configuración del inicio de sesión mediante Twitter (e inserciones) para Discourse." twitter_consumer_key: "Clave del consumidor para la autenticación mediante Twitter, registrado en https://developer.twitter.com/apps" twitter_consumer_secret: "Secreto del consumidor para la autenticación mediante Twitter, registrado en https://developer.twitter.com/apps" enable_facebook_logins: "Activar autenticación mediante Facebook, requiere una facebook_app_id y un facebook_app_secret. Ver configuración del inicio de sesión mediante Facebook para Discourse." @@ -1595,20 +1623,20 @@ es: discord_secret: "Clave secreta de Discord" discord_trusted_guilds: 'Solo permitir iniciar sesión con Discord a miembros de estas guilds de Discord. Usa la ID numérica del guild. Para más información, mira las instrucciones aquí. Deja en blanco para permitir cualquier guild.' enable_backups: "Permitir a los administradores crear respaldos del foro" - allow_restore: "Permitir restaurar, ¡lo cual puede remplazar TODOS los datos del sitio! Dejar en falso a menos de que tengas planeado recuperar los datos desde una copia de respaldo. " - maximum_backups: "La cantidad máxima de copias de respaldo que se mantendrán en el disco. Las copias de respaldo más antiguas se eliminan automáticamente" + allow_restore: "Permitir restaurar, ¡lo cual puede remplazar TODOS los datos del sitio! Dejar en falso a menos de que tengas planeado recuperar los datos desde una copia de seguridad." + maximum_backups: "La cantidad máxima de copias de seguridad que se mantendrán en el disco. Las copias de seguridad más antiguas se eliminan automáticamente" automatic_backups_enabled: "Ejecutar respaldos automáticos definidos por la opción de frecuencia de respaldos" backup_frequency: "El número de días entre respaldos." - s3_backup_bucket: "El bucket remoto para mantener copias de respaldo. AVISO: Asegúrate de que es un bucket privado." - s3_endpoint: "El endpoint se puede modificar para realizar una copia de respaldo en un servicio compatible con S3 como DigitalOcean Spaces o Minio. ADVERTENCIA: dejar en blanco si usas AWS S3." + s3_backup_bucket: "El bucket remoto para mantener copias de seguridad. AVISO: Asegúrate de que es un bucket privado." + s3_endpoint: "El terminal se puede modificar para realizar una copia de seguridad en un servicio compatible con S3 como DigitalOcean Spaces o Minio. ADVERTENCIA: dejar en blanco si usas AWS S3." s3_configure_tombstone_policy: "Habilitar la política de eliminación automática para cargas tombstone. IMPORTANTE: si está deshabilitado, no se reclamará ningún espacio después de eliminar las cargas." s3_disable_cleanup: "Evite la eliminación de copias de seguridad antiguas de S3 cuando hay más copias de seguridad que el máximo permitido." enable_s3_inventory: "Generar informes y verificar las cargas utilizando el inventario de Amazon S3. IMPORTANTE: requiere credenciales S3 válidas (tanto la clave de acceso como la clave de acceso secreta)." backup_time_of_day: "Hora UTC del día cuando debería ejecutarse el respaldo." - backup_with_uploads: "Incluir archivos subidos en los respaldos programados. Si esta opción está deshabilitada, tan solo se ejecutará una copia de respaldo de la base de datos." - backup_location: "Ubicación donde se almacenan las copias de respaldo. IMPORTANTE: S3 requiere credenciales S3 válidas ingresadas en la configuración de archivos." + backup_with_uploads: "Incluir archivos subidos en las copias de seguridad programadas. Si esta opción está deshabilitada, tan solo se ejecutará una copia de seguridad de la base de datos." + backup_location: "Ubicación donde se almacenan las copias de respaldo. IMPORTANTE: S3 requiere credenciales S3 válidas introducidas en la configuración de archivos." backup_gzip_compression_level_for_uploads: "Nivel de compresión Gzip utilizado para comprimir archivos subidos." - include_thumbnails_in_backups: "Incluir miniaturas generadas en las copias de respaldo. Deshabilitar esto hará que las copias de respaldo sean más pequeñas, pero requiere el rebake de todas las publicaciones después de una restauración." + include_thumbnails_in_backups: "Incluir miniaturas generadas en las copias de seguridad. Deshabilitar esto hará que las copias de seguridad sean más pequeñas, pero requiere el rebake de todas las publicaciones después de una restauración." active_user_rate_limit_secs: "Con qué frecuencia actualizaremos el campo «last_seen_at», en segundos" verbose_localization: "Mostrar sugerencias de localización extendida en la interface de usuario " previous_visit_timeout_hours: "Tiempo que debe pasar antes de que una visita sea considerada la «visita previa», en horas" @@ -1621,11 +1649,11 @@ es: rate_limit_new_user_create_topic: "Después de crear un tema, los nuevos usuarios deben esperar (n) segundos antes de crear otro tema." rate_limit_new_user_create_post: "Después de realizar una publicación, los nuevos usuarios deben esperar (n) segundos antes de crear otra publicación." max_likes_per_day: "Máximo número de me gusta por usuario por día." - max_flags_per_day: "Máximo número de reportes por usuario por día." + max_flags_per_day: "Máximo número de denuncias por usuario por día." max_bookmarks_per_day: "Máximo número de marcadores por usuario por día." max_edits_per_day: "Máximo número de ediciones por usuario por día." max_topics_per_day: "Máximo número de temas que un usuario puede crear al día." - max_personal_messages_per_day: "Número máximo de nuevos mensajes personales que un usuario puede crear al día." + max_personal_messages_per_day: "Número máximo de nuevos mensajes privados que un usuario puede crear al día." max_invites_per_day: "Máximo número de invitaciones que un usuario puede enviar al día." max_topic_invitations_per_day: "Máximo número de invitaciones a un tema que un usuario puede enviar por día." max_logins_per_ip_per_hour: "Máximo número de inicios de sesión permitidos por dirección IP por hora." @@ -1633,7 +1661,7 @@ es: max_post_deletions_per_minute: "Número máximo de publicaciones que un usuario puede borrar por minuto. Poner a 0 para no dejar borrar publicaciones." max_post_deletions_per_day: "Número máximo de publicaciones que un usuario puede borrar al día. Poner a 0 para no permitir borrar publicaciones." invite_link_max_redemptions_limit: "Los canjes máximos permitidos para los enlaces de invitación no pueden ser más que este valor." - invite_link_max_redemptions_limit_users: "El número máximo de usuarios que pueden registrarse desde un enlace de invitación generado por usuarios regulares no puede sobrepasar este valor." + invite_link_max_redemptions_limit_users: "El número máximo de usuarios que pueden registrarse desde un enlace de invitación generado por usuarios normales no puede sobrepasar este valor." alert_admins_if_errors_per_minute: "Número de errores por minuto que activa la alerta a los administradores. Un valor de 0 desactiva esta funcionalidad. NOTA: requiere reiniciar." alert_admins_if_errors_per_hour: "Número de errores por hora que activa la alerta a los administradores. Un valor de 0 desactiva esta funcionalidad. NOTA: requiere reiniciar." categories_topics: "Número de temas que se muestran en la pagina de /categories. Si estableces el valor en 0, automáticamente intentará conseguir un valor para mantener las dos columnas simétricas (categorías y temas)." @@ -1646,9 +1674,9 @@ es: purge_deleted_uploads_grace_period_days: "Período de gracia (en horas) antes de que un archivo subido huérfano se elimine permanentemente." purge_unactivated_users_grace_period_days: "Periodo de gracia (en días) antes de eliminar a un usuario que no ha activado su cuenta. Establecer el valor en 0 para que nunca se purgue a usuarios no activados." enable_s3_uploads: "Coloca los archivos subidos en el almacén Amazon S3. IMPORTANTE: requiere credenciales de S3 validas. (tanto clave de acceso y clave de acceso secreta)." - s3_use_iam_profile: 'Utilice un perfil de instancia de AWS EC2 para otorgar acceso al S3 bucket. NOTA: habilitar esto requiere que Discourse se ejecute en una instancia EC2 configurada apropiadamente, y anula las configuraciones de «clave de acceso s3» y «clave secreta s3».' + s3_use_iam_profile: 'Utiliza un perfil de instancia de AWS EC2 para otorgar acceso al S3 bucket. NOTA: habilitar esto requiere que Discourse se ejecute en una instancia EC2 configurada apropiadamente, y sobrescribe las configuraciones de «clave de acceso s3» y «clave secreta s3».' s3_upload_bucket: "El nombre del bucket Amazon S3 donde se subirán los archivos. AVISO: debe ser en minúsculas, sin puntos ni guiones bajos." - s3_access_key_id: "La clave secreta de acceso de Amazon S3 que se usará para subir imágenes, archivos adjuntos y copias de respaldo." + s3_access_key_id: "La clave secreta de acceso de Amazon S3 que se usará para subir imágenes, archivos adjuntos y copias de seguridad." s3_secret_access_key: "La clave secreta de acceso de Amazon S3 que se usará para subir imágenes, adjuntos y copias de seguridad." s3_region: "El nombre de región de Amazon S3 que se utilizará para subir imágenes y backups." s3_cdn_url: "URL de un CDN que se utilizará para todos los activos s3 (por ejemplo: https://cdn.somewhere.com). AVISO: después de cambiar esta opción debes hacer un rebake de todos las publicaciones antiguas." @@ -1666,7 +1694,12 @@ es: image_preview_jpg_quality: "Calidad de las imágenes redimensionadas (1 es la peor calidad, 99 la mejor, 100 mantiene la calidad original)." allow_staff_to_upload_any_file_in_pm: "Permitir a los miembros del staff subir cualquier archivo en MP." strip_image_metadata: "Eliminar metadatos de imágenes." - min_ratio_to_crop: "Proporción utilizada para recortar imágenes altas. Ingresa el resultado de anchura / altura." + composer_media_optimization_image_enabled: "Permite la optimización multimedia en el lado del cliente de archivos de imágenes cargados." + composer_media_optimization_image_kilobytes_optimization_threshold: "Tamaño mínimo del archivo de imagen para activar la optimización del lado del cliente" + composer_media_optimization_image_resize_dimensions_threshold: "Ancho mínimo de la imagen para activar el redimensionamiento del lado del cliente" + composer_media_optimization_image_resize_width_target: "Las imágenes con una anchura mayor que `composer_media_optimization_image_dimensions_resize_threshold` serán redimensionadas a esta anchura. Debe ser >= que `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "Calidad de codificación JPEG utilizada en el proceso de recodificación." + min_ratio_to_crop: "Proporción utilizada para recortar imágenes altas. Introduce el resultado de anchura / altura." simultaneous_uploads: "Número máximo de archivos que se pueden arrastrar y soltar en el editor" default_invitee_trust_level: "Nivel de confianza por defecto (0-4) para usuarios invitados." default_trust_level: "Nivel de confianza por defecto (0-4) para todos los usuarios nuevos. ¡AVISO! Cambiar esto puede resultar en alto riesgo de spam." @@ -1689,20 +1722,20 @@ es: tl3_requires_posts_read_cap: "El número máximo requerido de publicaciones leídas en los últimos (período nc3) días." tl3_requires_topics_viewed_all_time: "El número total mínimo de temas que un usuario debió de haber visto para calificar a promoción de nivel de confianza 3." tl3_requires_posts_read_all_time: "El número mínimo total de publicaciones que un usuario debe haber leído para calificar a nivel de confianza 3." - tl3_requires_max_flagged: "El usuario no debe haber tenido más de x publicaciones reportadas por x diferentes usuarios en los últimos (período nc3) días para cumplir los requisitos de promoción al nivel de confianza 3, donde x es el valor de esta opción. (0 o más)" + tl3_requires_max_flagged: "El usuario no debe haber tenido más de x publicaciones denunciadas por x diferentes usuarios en los últimos (período nc3) días para cumplir los requisitos de promoción al nivel de confianza 3, donde x es el valor de esta opción. (0 o más)" tl3_promotion_min_duration: "El número mínimo de días de duración que una promoción al nivel de confianza 3 debe tener antes de que el usuario pueda ser degradado de vuelta a nivel de confianza 2." tl3_requires_likes_given: "El número mínimo de me gusta que un usuario debe dar en los últimos (período nc3) días para calificar al nivel de confianza 3." tl3_requires_likes_received: "El número mínimo de me gusta que un usuario debe recibir en los últimos (período nc3) días para calificar al nivel de confianza 3." tl3_links_no_follow: "No quitar rel=nofollow de los enlaces publicados por usuarios con nivel de confianza 3." trusted_users_can_edit_others: "Permitir a los usuarios con alto nivel de confianza editar el contenido de otros usuarios" min_trust_to_create_topic: "El nivel mínimo de confianza requerido para crear un nuevo tema." - allow_flagging_staff: "Si está activado, los usuarios pueden reportar publicaciones hechas desde cuentas del staff." + allow_flagging_staff: "Si está activado, los usuarios pueden denunciar publicaciones hechas desde cuentas del personal." min_trust_to_edit_wiki_post: "El nivel mínimo de confianza requerido para editar una publicación marcada como wiki." min_trust_to_edit_post: "El nivel mínimo de confianza requerido para editar publicaciones." min_trust_to_allow_self_wiki: "El nivel mínimo de confianza requerido para que un usuario convierta sus propias publicaciones a wiki." - min_trust_to_send_messages: "El nivel mínimo de confianza requerido para crear nuevos mensajes personales." - min_trust_to_send_email_messages: "Nivel de confianza mínimo para mandar mensajes personales a través de correo electrónico." - min_trust_to_flag_posts: "El nivel mínimo de confianza requerido para reportar publicaciones" + min_trust_to_send_messages: "El nivel mínimo de confianza requerido para crear nuevos mensajes privados." + min_trust_to_send_email_messages: "Nivel de confianza mínimo para mandar mensajes privados a través de correo electrónico." + min_trust_to_flag_posts: "El nivel mínimo de confianza requerido para denunciar publicaciones" min_trust_to_post_links: "El nivel mínimo de confianza requerido para incluir enlaces en una publicación" min_trust_to_post_embedded_media: "Nivel confianza mínimo para incluir imágenes en una publicación" min_trust_level_to_allow_profile_background: "Nivel confianza mínimo para subir un fondo de perfil" @@ -1726,8 +1759,8 @@ es: title_max_word_length: "La longitud máxima permitida de una palabra, en caracteres, en el título del tema." title_min_entropy: "La mínima entropía permitida (caracteres únicos) requerida para el título de un tema." body_min_entropy: "La mínima entropía permitida (caracteres únicos) requerida para el cuerpo de un publicación." - allow_uppercase_posts: "Permitir todas mayúsculas en el título de un tema o en el cuerpo del mensaje" - max_consecutive_replies: "Número de mensajes seguidos que un usuario debe publicar en un tema antes de que se le prohíba añadir otra respuesta." + allow_uppercase_posts: "Permitir todas mayúsculas en el título de un tema o en el cuerpo de la publicación." + max_consecutive_replies: "Número de publicaciones seguidas que un usuario debe hacer en un tema antes de que se le prohíba añadir otra respuesta." enable_filtered_replies_view: 'Hacer que el botón «(n) respuestas» colapse todas las demás publicaciones y solo muestre la publicación actual y sus respuestas.' title_fancy_entities: "Convertir caracteres ASCII comunes en entidades HTML de adorno en el título de los temas, como SmartyPants https://daringfireball.net/projects/smartypants/" min_title_similar_length: "La extensión mínima que un título debe tener antes de que se revise la existencia de temas similares." @@ -1758,9 +1791,9 @@ es: topic_post_like_heat_low: "Después de que la proporción me gusta:publicaciones exceda esta relación, el campo contador de publicaciones se resaltará ligeramente." topic_post_like_heat_medium: "Después de la proporción «me gusta»:publicaciones exceda esta relación, el campo contador de publicaciones se resaltará moderadamente." topic_post_like_heat_high: "Después de la proporción «me gusta»:publicaciones exceda esta relación, el campo contador de publicaciones se resaltará fuertemente." - faq_url: "Si tienes un documento de preguntas frecuentes alojado en algún otro sitio y que quieras utilizar, ingresa la URL completa aquí." - tos_url: "Si tienes un documento de condiciones de servicio alojado en algún otro sitio y que quieras utilizar, ingresa la URL completa aquí." - privacy_policy_url: "Si tienes un documento de política de privacidad alojado en algún otro sitio y que quieras utilizar, ingresa la URL completa aquí." + faq_url: "Si tienes un documento de preguntas frecuentes alojado en algún otro sitio y que quieras utilizar, introduce la URL completa aquí." + tos_url: "Si tienes un documento de condiciones de servicio alojado en algún otro sitio y que quieras utilizar, introduce la URL completa aquí." + privacy_policy_url: "Si tienes un documento de política de privacidad alojado en algún otro sitio y que quieras utilizar, introduce la URL completa aquí." log_anonymizer_details: "Mantener o no los detalles de un usuario en el registro después de ser anonimizados. Para cumplir con la RGPD, deberás apagarlo." newuser_spam_host_threshold: "Cantidad de veces que un usuario nuevo puede publicar un enlace al mismo host dentro del `newuser_spam_host_threshold` de sus publicaciones antes de ser considerado spam." allowed_spam_host_domains: "Una lista de dominios que se excluyen de las pruebas de spam. A los usuarios nuevos no se les restringirá nunca la posibilidad de crear publicaciones con enlaces a estos dominios." @@ -1774,15 +1807,18 @@ es: max_age_unmatched_ips: "Eliminar entradas de IP prohibidos que no coincidan después de (N) días." num_flaggers_to_close_topic: "Número mínimo de denunciantes únicos requerido para pausar un tema automáticamente para su intervención" num_hours_to_close_topic: "Número de horas que se pausa un tema para su invervención." - auto_respond_to_flag_actions: "Activar la respuesta automática al deshacer un reporte." + auto_respond_to_flag_actions: "Activar la respuesta automática al deshacer una denuncia." min_first_post_typing_time: "Período mínimo en milisegundos durante el que un usuario debe escribir en su primera publicación, si no se llega a este umbral, la publicación entrará automáticamente a la cola de moderación. Establece esta opción a 0 para deshabilitar (no recomendado)." auto_silence_fast_typers_on_first_post: "Silenciar automáticamente a usuarios que no cumplan el umbral establecido en min_first_post_typing_time" auto_silence_fast_typers_max_trust_level: "Nivel máximo de confianza hasta el que se podrán silenciar usuarios que escriban demasiado rápido en su primera publicación" + auto_silence_first_post_regex: "Expresión regular (que ignora mayúsculas/minúsculas) que, si coincide con la primera publicación de un usuario, lo silenciará y mandará el mensaje a la cola de aprobación. Por ejemplo: enfadado|a[bc]a hará que todas las primeras publicaciones que contengan enfadado, aba o aca se manden a la cola y se silencie al autor. Solo se ejecutará en el primer mensaje de cada usuario. DEPRECIADO: Utiliza en su lugar Palabras Vigiladas para Silencio." reviewable_claiming: "¿Es necesario reclamar el contenido revisable antes de poder actuar sobre el mismo?" reviewable_default_topics: "Mostrar contenido revisable agrupado por tema por defecto" reviewable_default_visibility: "No muestre elementos revisables a menos que cumplan con esta prioridad" + reviewable_low_priority_threshold: "El filtro de prioridades oculta elementos a revisar que no lleguen a esta puntuación, salvo que se use el filtro «(cualquiera)»." high_trust_flaggers_auto_hide_posts: "Las publicaciones de los usuarios nuevos se esconden automáticamente tras ser marcadas como spam por un usuario con NC3 o superior" - cooldown_hours_until_reflag: "Cantidad de tiempo que los usuarios deberán esperar antes de poder reportar de nuevo una publicación." + cooldown_hours_until_reflag: "Cantidad de tiempo que los usuarios deberán esperar antes de poder denunciar de nuevo una publicación." + slow_mode_prevents_editing: "¿El «Modo lento» evita la edición después de edit_grace_period?" reply_by_email_enabled: "Habilitar la respuesta a temas por correo electrónico." reply_by_email_address: "Plantilla para la dirección de correo electrónico que aparecerá al recibir correos con la función de respuesta por correo electrónico: %%{reply_key}@respuesta.ejemplo.com o respuestas+%%{reply_key}@ejemplo.com" alternative_reply_by_email_addresses: "Lista de plantillas alternativa para las direcciones de respuesta por correo electrónico. Ejemplo: %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" @@ -1831,7 +1867,7 @@ es: log_mail_processing_failures: "Registra todos los fallos de procesamiento de /registros" email_in: 'Permitir a los usuarios crear nuevos temas por correo electrónico (requiere el polling manual o pop3). Configura las direcciones en la pestaña «ajustes» de cada categoría.' email_in_min_trust: "El nivel de confianza mínimo requerido para poder publicar temas nuevos por correo electrónico." - email_in_authserv_id: "El identificador del servicio realizando revisiones de autenticación en correos electrónicos entrantes. Ver: https://meta.discourse.org/t/134358 para obtener instrucciones sobre cómo configurar esto." + email_in_authserv_id: "El identificador del servicio realizando revisiones de autenticación en correos electrónicos entrantes. Consulta: https://meta.discourse.org/t/134358 para obtener instrucciones sobre cómo configurar esto." email_in_spam_header: "El encabezado del correo electrónico para detectar spam." enable_imap: "Habilitar IMAP para sincronizar mensajes de grupo." enable_imap_write: "Habilitar sincronización IMAP bidireccional. Si está deshabilitado, todas las operaciones de escritura en cuentas IMAP están deshabilitadas." @@ -1863,9 +1899,9 @@ es: digest_suppress_categories: "Suprimir estas categorías de los correos electrónicos de resumen." disable_digest_emails: "Desactivar los correos electrónicos de resumen para todos los usuarios." apply_custom_styles_to_digest: "Las plantillas de correo electrónico y css personalizadas se aplican a los correos electrónicos de resumen." - email_accent_bg_color: "El color de realce utilizado como fondo en algunos elementos en los correos electrónicos HTML. Ingresa un nombre de color («rojo») o un valor hexadecimal («#FF0000»)." - email_accent_fg_color: "El color de texto renderizado que irá con el color de fondo en los correos electrónicos HTML. Ingresa un nombre de color («blanco») o un valor hexadecimal («#FFFFFF»)." - email_link_color: "El color de los enlaces en los correos electrónicos HTML. Ingresa un nombre de color («azul») o un valor hexadecimal («#0000FF»)." + email_accent_bg_color: "El color de realce utilizado como fondo en algunos elementos en los correos electrónicos HTML. Introduce un nombre de color («rojo») o un valor hexadecimal («#FF0000»)." + email_accent_fg_color: "El color de texto renderizado que irá con el color de fondo en los correos electrónicos HTML. Introduce un nombre de color («blanco») o un valor hexadecimal («#FFFFFF»)." + email_link_color: "El color de los enlaces en los correos electrónicos HTML. Introduce un nombre de color («azul») o un valor hexadecimal («#0000FF»)." detect_custom_avatars: "Verificar o no que los usuarios han subido una imagen de perfil." max_daily_gravatar_crawls: "Número máximo de veces que Discourse comprobará Gravatar en busca de avatares personalizados en un día" public_user_custom_fields: "Una lista con campos personalizados para el usuario que se pueden recuperar a través de la API." @@ -1873,7 +1909,7 @@ es: enable_user_directory: "Proporcionar un directorio de usuarios que se pueda navegar" enable_group_directory: "Proporcionar un directorio de grupos que se pueda navegar" enable_category_group_moderation: "Permitir que los grupos moderen contenido en categorías específicas" - group_in_subject: "Configura %%{optional_pm} en el título del correo electrónico para nombrar el primer grupo en MP, ver: Personalizar el formato del asunto para correos electrónicos estandar" + group_in_subject: "Configura %%{optional_pm} en el título del correo electrónico para nombrar el primer grupo en MP, consulta: Personalizar el formato del asunto para correos electrónicos estándar" allow_anonymous_posting: "Permitir a los usuarios cambiar a modo anónimo" anonymous_posting_min_trust_level: "Nivel de confianza mínimo requerido para permitir la publicación anónima" anonymous_account_duration_minutes: "Para proteger el anonimato, crear una nueva cuenta anónima cada N minutos para cada usuario. Ejemplo: si se establece en 600, tan pronto como pasen 600 minutos desde la última publicación Y el usuario cambie a anónimo, se creará una nueva cuenta anónima." @@ -1906,13 +1942,13 @@ es: automatically_unpin_topics: "Quitar destacado automáticamente cuando el usuario llega al final del tema." read_time_word_count: "Número de palabras por minuto para calcular el tiempo de lectura estimado." topic_page_title_includes_category: "La etiqueta del título de la página del tema incluye el nombre de la categoría." - native_app_install_banner_ios: "Muestra el anuncio de la aplicación DiscourseHub en dispositivos iOS a usuarios regulares (nivel de confianza 1 y superior)." - native_app_install_banner_android: "Muestra el anuncio de la aplicación DiscourseHub en dispositivos Android a usuarios regulares (nivel de confianza 1 y superior)." + native_app_install_banner_ios: "Muestra el anuncio de la aplicación DiscourseHub en dispositivos iOS a usuarios habituales (nivel de confianza 1 y superior)." + native_app_install_banner_android: "Muestra el anuncio de la aplicación DiscourseHub en dispositivos Android a usuarios habituales (nivel de confianza 1 y superior)." app_association_android: "Los contenidos del extremo .well-known/assetlinks.json, utilizado por la API de «Digital Asset Links» de Google." app_association_ios: "Los contenidos del extremo apple-app-site-association, utilizado para crear enlaces universales entre este sitio y las aplicacios iOS." share_anonymized_statistics: "Compartir estadísticas de uso anonimizadas." - auto_handle_queued_age: "Maneja automáticamente los registros que están esperando para ser revisados después de esta cantidad de días. Los reportes serán ignorados. Los mensajes y usuarios en cola serán rechazados. Establece el valor en 0 para desactivar esta función." - svg_icon_subset: "Agregar iconos adicionales de FontAwesome 5 que te gustaría incluir en tus recursos. Usa el prefijo 'fa-' para iconos sólidos, 'far-' para los iconos regulares y 'fab-' para los iconos de marca." + auto_handle_queued_age: "Maneja automáticamente los registros que están esperando para ser revisados después de esta cantidad de días. Las denuncias serán ignoradas. Los mensajes y usuarios en cola serán rechazados. Establece el valor en 0 para desactivar esta función." + svg_icon_subset: "Agregar iconos adicionales de FontAwesome 5 que te gustaría incluir en tus recursos. Usa el prefijo «fa-» para iconos sólidos, «far-» para los iconos normales y «fab-» para los iconos de marca." max_prints_per_hour_per_user: "Número máximo de vistas de página /print (establece el valor en 0 para desactivar esta opción)" full_name_required: "El nombre completo es un campo obligatorio del perfil de usuario." enable_names: "Mostrar el nombre completo del usuario en su perfil, tarjeta de usuario y correos electrónicos. Desactiva esta opción para ocultar el nombre completo en todas partes." @@ -1922,7 +1958,7 @@ es: default_code_lang: "Lenguaje de programación que se usará para colorear los bloques de código de GitHub («auto» para automático, «nohighlight» para no colorear, ruby, python, etc.)" warn_reviving_old_topic_age: "Cuando alguien publica en un tema cuya última respuesta fue hace más tiempo que este número de días, se le mostrará un aviso para desalentar el hecho de revivir una antigua discusión. Establece el valor en 0 para deshabilitar." autohighlight_all_code: "Forzar el resaltado de código a los bloques de código preformateado cuando no se especifique el lenguaje del código." - highlighted_languages: "Reglas de resaltado de sintaxis incluidas. (Aviso: incluir demasiados lenguajes puede afectar al rendimiento) Ver: https://highlightjs.org/static/demo para un demo." + highlighted_languages: "Reglas de resaltado de sintaxis incluidas. (Aviso: incluir demasiados lenguajes puede afectar al rendimiento) Consulta: https://highlightjs.org/static/demo para un demo." show_copy_button_on_codeblocks: "Agrega un botón a los bloques de código para copiar el contenido del bloque al portapapeles del usuario. Esta característica no es compatible con Internet Explorer." embed_any_origin: "Permitir la inserción de contenido sin importar su origen. Esto se requiere para aplicaciones móviles con HTML estático." embed_topics_list: "Soportar la inserción HTML de listas de temas" @@ -1934,13 +1970,15 @@ es: allowed_href_schemes: "Esquemas permitidos en enlaces además de http y https." embed_post_limit: "Número máximo de publicaciones que se pueden insertar." embed_username_required: "Se requiere el nombre de usuario para la creación de temas." - notify_about_flags_after: "Si hay reportes que no han sido revisados después de este número de horas, enviar un mensaje personal a los moderadores. Establece el valor en 0 para deshabilitar." + notify_about_flags_after: "Si hay denuncias que no han sido revisadas después de este número de horas, enviar un mensaje personal a los moderadores. Establece el valor en 0 para deshabilitar." show_create_topics_notice: "Si el sitio tiene menos de 5 temas abiertos al público, mostrar un aviso pidiendo a los administradores crear más temas." delete_drafts_older_than_n_days: "Eliminar borradores de más de (n) días de antigüedad." + delete_merged_stub_topics_after_days: "Número de días que hay que esperar antes de eliminar automáticamente los temas pendientes totalmente fusionados. Poner a 0 para no eliminar nunca los temas pendientes." bootstrap_mode_min_users: "Número mínimo de usuarios requerido para desactivar el modo bootstrap (establece en 0 para desactivar esta opción)" prevent_anons_from_downloading_files: "Impedir que usuarios anónimos descarguen archivos adjuntos." secure_media: 'Limita el acceso a los medios subidos (imágenes, video, audio). Si está habilitado «inicio de sesión requerido», solo los usuarios que hayan iniciado sesión pueden acceder a los medios subidos. En caso contrario, se limitará el acceso únicamente a los medios subidos en mensajes privados. ADVERTENCIA: se deben habilitar las subidas S3 antes de poder habilitar esta configuración. Ver the secure media topic on Meta para más detalles.' secure_media_allow_embed_images_in_emails: "Permite incrustar imágenes seguras que normalmente serían quitadas de los correos electrónicos si su tamaño es menor que la configuración de 'secure media max email embed image size kb'." + secure_media_max_email_embed_image_size_kb: "El tamaño límite para las imágenes seguras que se incrustarán en los correos electrónicos si está activada la configuración «los medios seguros permiten su incrustación en los correos electrónicos». Sin esa configuración activada, esta configuración no tiene efecto." slug_generation_method: "Elegir un método de generación de slug. «encoded» generará cadenas con código porcentual. «none» deshabilitará completamente el slug." enable_emoji: "Habilitar emoji" enable_emoji_shortcuts: "Texto común de emoticones como :) :p :( se convertirán a emojis" @@ -1950,13 +1988,13 @@ es: approve_post_count: "La cantidad de publicaciones de usuarios nuevos o de nivel básico que deben ser aprobadas" approve_unless_trust_level: "Las publicaciones de usuarios con un nivel de confianza inferior a este deberán ser aprobadas" approve_new_topics_unless_trust_level: "Los temas nuevos de usuarios por debajo de este nivel de confianza deben ser aprobados" - approve_unless_staged: "Temas y mensajes nuevos de usuarios temporales deben ser aprobados" + approve_unless_staged: "Los temas y las publicaciones nuevas de usuarios temporales deben ser aprobadas" notify_about_queued_posts_after: "Si hay publicaciones que han estado esperando para ser revisadas por más de este número de horas, enviar una notificación a todos los moderadores. Establece el valor en 0 para desactivar estas notificaciones." auto_close_messages_post_count: "Número máximo de publicaciones permitidas en un mensaje antes de que se cierre automáticamente (0 para desactivar)" auto_close_topics_post_count: "Número máximo de publicaciones permitidas en un tema antes de que se cierre automáticamente (0 para desactivar)" auto_close_topics_create_linked_topic: "Crear un nuevo tema enlazado de continuación cuando un tema es cerrado automáticamente basado en la configuración de 'auto close topics post count'" code_formatting_style: "El botón de código en el editor establecerá por defecto este estilo de formato de código" - max_allowed_message_recipients: "Máxima cantidad de destinatarios permitidos en un mensaje." + max_allowed_message_recipients: "Cantidad máxima de destinatarios permitidos en un mensaje." watched_words_regular_expressions: "Las palabras vigiladas son expresiones regulares." enable_diffhtml_preview: "Funcionalidad experimental, usar diffHTML para sincronizar la previsualización en lugar de volver a renderizar toda de nuevo." old_post_notice_days: "Tiempo a partir del cual una publicación se considera antigua" @@ -1964,13 +2002,13 @@ es: returning_user_notice_tl: "Nivel de confianza mínimo requerido para ver avisos de usuarios que vuelven." returning_users_days: "Cuántos días deben transcurrir antes de que se considere que un usuario está regresando." review_media_unless_trust_level: "El staff revisará las publicaciones de los usuarios con niveles de confianza bajos si tiene contenidos multimedia integrados." - blur_tl0_flagged_posts_media: "Difuminar imágenes en publicaciones reportadas para ocultar contenido potencialmente delicado." + blur_tl0_flagged_posts_media: "Difuminar imágenes en publicaciones denunciadas para ocultar contenido potencialmente delicado." enable_page_publishing: "Permitir que los miembros del personal publiquen temas en nuevas URL con su propio estilo." show_published_pages_login_required: "Los usuarios anónimos pueden ver páginas publicadas, incluso cuando sea necesario iniciar sesión." - skip_auto_delete_reply_likes: "Al eliminar automáticamente respuestas antiguas, no eliminar mensajes con este número de \"me gusta\" o más." + skip_auto_delete_reply_likes: "Al eliminar automáticamente las respuestas antiguas, no eliminar publicaciones con este número de «me gusta» o más." default_email_digest_frequency: "Cuán a menudo recibirán los usuarios correos electrónicos de resumen por defecto." default_include_tl0_in_digests: "Incluir publicaciones de usuarios nuevos en los correos electrónicos de resumen por defecto. Los usuarios pueden cambiar esto en sus preferencias." - default_email_level: "Establecer nivel por defecto de las notificaciones por correo electrónico para los temas regulares." + default_email_level: "Establecer nivel por defecto de las notificaciones por correo electrónico para los temas normales." default_email_messages_level: "Establecer el nivel por defecto de notificaciones por correo electrónico cuando alguien envía un mensaje a un usuario." default_email_mailing_list_mode: "Enviar por defecto un correo electrónico por cada publicación nueva." default_email_mailing_list_mode_frequency: "Los usuarios que activen el modo lista de correo recibirán correos con esta frecuencia por defecto." @@ -1984,13 +2022,13 @@ es: default_other_enable_quoting: "Activar respuesta citando texto seleccionado por defecto." default_other_enable_defer: "Habilitar la funcionalidad de temas diferidos por defecto." default_other_dynamic_favicon: "Mostrar la cantidad de temas nuevos/actualizados en el icono del navegador por defecto." - default_other_skip_new_user_tips: "Omitir consejos y medallas de bienvenida." + default_other_skip_new_user_tips: "Omitir consejos y insignias de bienvenida." default_other_like_notification_frequency: "Notificar a los usuarios sobre los me gusta por defecto" default_topics_automatic_unpin: "Quitar destacado automáticamente cuando el usuario llega al final del tema." default_categories_watching: "Lista de categorías que están vigiladas por defecto." default_categories_tracking: "Lista de categorías que están seguidas por defecto" default_categories_muted: "Lista de categorías que están silenciadas por defecto." - default_categories_watching_first_post: "Lista de categorías en las que el primer mensaje de cada tema nuevo se vigilará por defecto." + default_categories_watching_first_post: "Lista de categorías en las que la primera publicación de cada tema nuevo se vigilará por defecto." default_categories_regular: "Lista de categorías que no están silenciadas por defecto. Útil cuando el ajuste `mute_all_categories_by_default` está habilitado." mute_all_categories_by_default: "Establezca el nivel de notificación predeterminado de todas las categorías en silenciado. Solicite a los usuarios que opten por las categorías para que aparezcan en las páginas de 'latest' y 'categories'. Si desea modificar los valores predeterminados para usuarios anónimos, establezca la configuración en 'default_categories_'." default_tags_watching: "Lista de etiquetas vigiladas por defecto." @@ -2017,7 +2055,7 @@ es: tags_sort_alphabetically: "Mostrar etiquetas en orden alfabético. Por defecto se mostrarán por popularidad." tags_listed_by_group: "Listar etiquetas por grupo de etiquetas en la página de etiquetas." tag_style: "Estilo visual de las etiquetas." - allow_staff_to_tag_pms: "Permitir a los miembros del staff etiquetar cualquier mensaje personal" + allow_staff_to_tag_pms: "Permitir a los miembros del personal etiquetar cualquier mensaje personal" min_trust_level_to_tag_topics: "Nivel mínimo requerido para etiquetar temas" suppress_overlapping_tags_in_list: "Si alguna etiqueta coincide con palabras en el título de los temas, ocultarla." remove_muted_tags_from_latest: "No muestre temas etiquetados unicamente con etiquetas silenciadas en la lista de temas más reciente." @@ -2028,19 +2066,19 @@ es: shared_drafts_category: "Habilite la característica de borradores compartidos designando una categoría para borradores de temas. Los temas de esta categoría se eliminarán de las listas de temas para los miembros del staff." shared_drafts_min_trust_level: "Permitir a los usuarios ver y editar borradores compartidos." push_notifications_prompt: "Mostrar aviso del consentimiento del usuario." - push_notifications_icon: "El icono de la medalla que aparece en el menú de notificaciones. Se recomienda un PNG monocromático de 96 × 96 con transparencia." + push_notifications_icon: "El icono de la insignia que aparece en el menú de notificaciones. Se recomienda un PNG monocromático de 96 × 96 con transparencia." base_font: "Fuente base para la mayoría de texto en el sitio. Los temas lo pueden sobreescribir a través de la propiedad personalizada de CSS `--font-family`." heading_font: "Fuente a usar para los encabezados del sitio. Los temas lo pueden sobreescribir a través de la propiedad personalizada de CSS `--heading-font-family`." short_title: "El título corto se utilizará en la pantalla de inicio del usuario, el iniciador u otros lugares donde el espacio puede ser limitado. Debe limitarse a 12 caracteres." dashboard_hidden_reports: "Permitir ocultar los informes especificados del panel de control." dashboard_visible_tabs: "Elige qué pestañas del panel de control se mostrarán." - dashboard_general_tab_activity_metrics: "Selecciona los reportes que se muestran como medidas de actividad en la pestaña general." + dashboard_general_tab_activity_metrics: "Selecciona las denuncias que se muestran como medidas de actividad en la pestaña general." gravatar_name: "Nombre del proveedor de Gravatar" gravatar_base_url: "URL de la API base del proveedor de Gravatar" gravatar_login_url: "URL relativa a gravatar_base_url, la cual provee al usuario el acceso al servicio de Gravatar" share_quote_buttons: "Determinar los elementos que aparecen en el cuadro de compartir y su orden." share_quote_visibility: "Cuándo mostrar los botones de compartir citas: nunca, a los usuarios anónimos sólo o a todos los usuarios. " - create_revision_on_bulk_topic_moves: "Crear edición para los primeros mensajes cuando los temas se mueven a una nueva categoría en masa." + create_revision_on_bulk_topic_moves: "Crear edición para las primeras publicaciones cuando los temas se mueven a una nueva categoría en masa." errors: invalid_email: "Dirección de correo electrónico inválida. " invalid_username: "No existe ningún usuario con ese nombre de usuario. " @@ -2049,9 +2087,9 @@ es: invalid_integer_min: "El valor debe ser igual o mayor que %{min}. " invalid_integer_max: "El valor no puede ser mayor que %{max}." invalid_integer: "El valor debe ser un numero entero. " - regex_mismatch: "El valor ingresado no tiene el formato requerido. " + regex_mismatch: "El valor introducido no tiene el formato requerido." must_include_latest: "El menú superior debe incluir la pestaña «recientes»." - invalid_string: "Valor inválido. " + invalid_string: "Valor no válido." invalid_string_min_max: "Debe tener entre %{min} y %{max} caracteres." invalid_string_min: "Debe tener un mínimo de %{min} caracteres. " invalid_string_max: "No debe exceder los %{max} caracteres. " @@ -2110,7 +2148,7 @@ es: blank_id_error: "El campo `external_id` es obligatorio, pero estaba en blanco" email_error: "No se ha podido registrar una cuenta con la dirección de correo electrónico %{email}. Por favor, ponte en contacto con la administración del sitio." missing_secret: "La autenticación ha fallado porque no se ha incluido el secreto. Ponte en contacto con la administración del sitio web para arreglar este problema." - invite_redeem_failed: "No se ha podido canjear la invitación. Por favor, contacta con la administración del sitio." + invite_redeem_failed: "No se ha podido canjear la invitación. Contacta con la administración del sitio." original_poster: "Autor original" most_posts: "Con más publicaciones" most_recent_poster: "Autor más reciente" @@ -2121,35 +2159,35 @@ es: not_seen_in_a_month: "¡Hola! Hacía tiempo que no te veíamos. Estos han sido los temas más destacados desde que nos visitaste por última vez." merge_posts: edit_reason: - one: "Un tema fue juntado por %{username}" - other: "%{count} mensajes fueron fusionados por %{username}" + one: "Una publicación fue fusionada por %{username}" + other: "%{count} publicaciones fueron fusionadas por %{username}" errors: different_topics: "No se pueden fusionar publicaciones que pertenecen a temas diferentes." - different_users: "No se pueden fusionar mensajes que pertenezcan a diferentes usuarios." - max_post_length: "No se pueden juntar las publicaciones porque la longitud del conjunto supera la máxima permitida por mensaje." + different_users: "No se pueden fusionar publicaciones que pertenezcan a diferentes usuarios." + max_post_length: "No se pueden fusionar las publicaciones porque la longitud del conjunto supera la máxima permitida por publicación." move_posts: new_topic_moderator_post: - one: "Un mensaje ha sido separado a un nuevo tema: %{topic_link}" + one: "Una publicación ha sido separada a un nuevo tema: %{topic_link}" other: "%{count} publicaciones han sido separadas a un nuevo tema: %{topic_link}" new_message_moderator_post: - one: "Un mensaje ha sido separado a un nuevo mensaje: %{topic_link}" - other: "%{count} mensajes han sido separados a un mensaje nuevo: %{topic_link}" + one: "Una publicación ha sido separada a un nuevo mensaje: %{topic_link}" + other: "%{count} publicaciones han sido separadas a un nuevo mensaje: %{topic_link}" existing_topic_moderator_post: - one: "Un mensaje ha sido fusionado con un tema existente: %{topic_link}" - other: "%{count} mensajes han sido unidos a un tema existente: %{topic_link}" + one: "Una publicación ha sido fusionada con un tema existente: %{topic_link}" + other: "%{count} publicaciones han sido fusionadas a un tema existente: %{topic_link}" existing_message_moderator_post: - one: "Un post ha sido unido a un mensaje existente: %{topic_link}" - other: "%{count} publicaciones han sido unidas a un mensaje existente: %{topic_link}" + one: "Una publicación ha sido fusionada a un mensaje existente: %{topic_link}" + other: "%{count} publicaciones han sido fusionadas a un mensaje existente: %{topic_link}" change_owner: post_revision_text: "Se ha transferido la propiedad" publish_page: slug_errors: blank: "no puede dejarse en blanco" unavailable: "no está disponible" - invalid: "contiene caracteres inválidos" + invalid: "contiene caracteres no válidos" topic_statuses: autoclosed_message_max_posts: - one: "Este mensaje fue cerrado automáticamente al alcanzar el límite máximo de %{count} respuesta." + one: "Este mensaje se cerró automáticamente al alcanzar el límite máximo de %{count} respuesta." other: "Este mensaje se cerró automáticamente al alcanzar el límite máximo de %{count} respuestas." autoclosed_topic_max_posts: one: "Este tema fue cerrado automáticamente al alcanzar el límite máximo de %{count} respuesta." @@ -2230,17 +2268,17 @@ es: new_registrations_disabled: "El registro de nuevas cuentas no está permitido en este momento." password_too_long: "Las contraseñas están limitadas a 200 caracteres." email_too_long: "El correo electrónico que has proporcionado es demasiado largo. Las direcciones de correo no deben tener más de 254 caracteres y los nombres de dominio no más de 253." - wrong_invite_code: "El código de invitación que ingresaste es incorrecto." + wrong_invite_code: "El código de invitación que has introducido es incorrecto." reserved_username: "Ese nombre de usuario no está permitido." missing_user_field: "No has completado todos los campos de usuario" auth_complete: "Autenticación completa." click_to_continue: "Haz clic aquí para continuar." already_logged_in: "¡Vaya! Esta invitación es solo para nuevos usuarios que no tengan ya una cuenta." second_factor_title: "Autenticación de dos factores" - second_factor_description: "Por favor, ingresa el código de autenticación desde tu aplicación:" - second_factor_backup_description: "Por favor, ingresa uno de los códigos de respaldo:" + second_factor_description: "Introduce el código de autenticación desde tu aplicación:" + second_factor_backup_description: "Introduce uno de los códigos de respaldo:" second_factor_backup_title: "Códigos de respaldo de la autenticación de dos factores" - invalid_second_factor_code: "Código de autentificación inválido. Cada código se puede usar solo una vez." + invalid_second_factor_code: "Código de autentificación no válido. Cada código se puede usar solo una vez." invalid_security_key: "Clave de seguridad inválida." missing_second_factor_name: "Por favor, introduce un nombre." missing_second_factor_code: "Por favor, introduce un código." @@ -2288,7 +2326,7 @@ es: not_allowed: "este proveedor de correo electrónico no está permitido. Por favor, utiliza otra dirección de correo electrónico." blocked: "no está permitido." revoked: "No se enviarán más correos electrónicos a «%{email}» hasta %{date}." - does_not_exist: "-" + does_not_exist: "N/D" ip_address: blocked: "No se permiten nuevos registros desde tu dirección IP." max_new_accounts_per_registration_ip: "No se permiten nuevos registros desde tu dirección IP (se alcanzó el límite máximo). Contacta un miembro del staff." @@ -2300,10 +2338,14 @@ es: fixed_primary_email: "Correo electrónico fijo principal para el usuario temporal" same_ip_address: "Misma dirección de IP (%{ip_address}) de otros usuarios" inactive_user: "Usuario inactivo" + reviewable_reject_auto: "Manejar automáticamente los revisables en cola" reviewable_reject: "Usuario revisable rechazado" email_in_spam_header: "El primer correo electrónico del usuario se marcó como spam." already_silenced: "El usuario ya fue silenciado por %{staff} %{time_ago}." already_suspended: "El usuario ya fue suspendido por %{staff} %{time_ago}." + cannot_delete_has_posts: + one: "El usuario %{username} tiene %{count} publicación en un tema público o en un mensaje personal, por lo que no se pueden eliminar." + other: "El usuario %{username} tiene %{count} publicaciones en un tema público o en un mensaje personal, por lo que no se pueden eliminar." reviewables_reminder: submitted: one: "Hay elementos enviados desde hace más de %{count} hora. [Por favor, revísalos](%{base_path}/review)" @@ -2316,11 +2358,11 @@ es: subject_template: "Confirma que no quieres recibir más correos electrónicos de %{site_title}" text_body_template: | Alguien (¿tal vez tú?) solicitó no recibir más actualizaciones por correo electrónico desde %{site_domain_name} a esta dirección de correo electrónico. - Si deseas confirmar esta acción, por favor, haz clic en el siguiente enlace: + Si deseas confirmar esta acción, haz clic en el siguiente enlace: %{confirm_unsubscribe_link} - Si quieres continuar recibiendo actualizaciones por correo electrónico, por favor ignora este correo. + Si quieres continuar recibiendo actualizaciones por correo electrónico, ignora este correo. invite_mailer: title: "Correo de invitación" subject_template: "%{inviter_name} te invitó a «%{topic_title}» en %{site_domain_name}" @@ -2420,32 +2462,73 @@ es: test_mailer: title: "Correo electrónico de prueba" subject_template: "[%{email_prefix}] Prueba de envío de correo electrónico" + text_body_template: | + Este es un correo electrónico de prueba de + + [**%{base_url}**][0] + + La entregabilidad del correo electrónico es complicada. Aquí hay algunas cosas importantes que deberías comprobar primero: + + - Asegúrate de establecer correctamente la dirección «correo electrónico de notificación» de: en la configuración de tu sitio. **El dominio especificado en la dirección «de» de los correos electrónicos que envíes es el dominio con el que se validará tu correo electrónico**. + + - Saber ver el origen bruto del correo electrónico en tu cliente de correo, para poder examinar los encabezados del correo en busca de pistas importantes. En Gmail, es la opción «mostrar original» en el menú desplegable de la parte superior derecha de cada correo. + + - **IMPORTANTE:** ¿Tiene tu ISP un registro DNS inverso introducido para asociar los nombres de dominio y las direcciones IP desde las que envías el correo? Prueba tu registro PTR inverso][2] aquí. Si tu ISP no introduce el registro de puntero DNS inverso adecuado, es muy poco probable que se entregue tu correo electrónico. + + - ¿Es correcto el [registro SPF][8] de tu dominio? Prueba tu registro SPF][1] aquí. Ten en cuenta que TXT es el tipo de registro oficial correcto para SPF. + + - ¿Es correcto el [registro DKIM][3] de tu dominio? Esto mejorará significativamente la capacidad de entrega del correo electrónico. Prueba tu registro DKIM][7] aquí. + + - Si diriges tu propio servidor de correo, comprueba que las IP de tu servidor de correo no están [en ninguna lista de bloqueo de correo electrónico][4]. Comprueba también que definitivamente está enviando un nombre de host totalmente calificado que se resuelve en DNS en su mensaje HELO. Si no es así, tu correo será rechazado por muchos servicios de correo. + + - Te recomendamos encarecidamente que **envíes un correo electrónico de prueba a [mail-tester.com][mt]** para verificar que todo lo anterior funciona correctamente. + + (La forma *fácil* es crear una cuenta en [SendGrid][sg], [SparkPost][sp], [Mailgun][mg] o [Mailjet][mj], que tienen planes de correo de bajo coste y estarán bien para comunidades pequeñas. No obstante, tendrás que configurar los registros SPF y DKIM en tus DNS) + + Esperamos que hayas recibido bien esta prueba de entregabilidad del correo electrónico. + + Buena suerte, + + Tus amigos de [Discourse](https://www.discourse.org) + + [0]: %{base_url} + [1]: https://www.kitterman.com/spf/validate.html + [2]: https://mxtoolbox.com/ReverseLookup.aspx + [3]: http://www.dkim.org/ + [4]: https://whatismyipaddress.com/blacklist-check + [7]: https://www.mail-tester.com/spf-dkim-check + [8]: http://www.openspf.org/SPF_Record_Syntax + [sg]: https://goo.gl/r1WMF6 + [sp]: https://www.sparkpost.com/ + [mg]: https://www.mailgun.com/ + [mj]: https://www.mailjet.com/pricing/ + [mt]: https://www.mail-tester.com/ new_version_mailer: title: "Correo electrónico de nueva versión" subject_template: "[%{email_prefix}] Nueva versión de Discourse, actualización disponible" text_body_template: | - ¡Hurra! Una version nueva de [Discourse] (https://www.discourse.org) está disponible. + ¡Hurra! Hay una nueva versión de [Discourse] (https://www.discourse.org) disponible. Tu versión: %{installed_version} Versión nueva: **%{new_version}** Actualiza la versión fácilmente mediante nuestra **[actualización de navegador de un clic](%{base_url}/admin/upgrade)** - - Descubre que hay de nuevo en las [notas de la versión](https://meta.discourse.org/tag/release-notes) o revisa [raw GitHub changelog](https://github.com/discourse/discourse/commits/master) + - Descubre qué novedades hay en las [notas de la versión](https://meta.discourse.org/tag/release-notes) o revisa [raw GitHub changelog](https://github.com/discourse/discourse/commits/master) - Visita [meta.discourse.org](https://meta.discourse.org) para noticias, discusiones y soporte de Discourse new_version_mailer_with_notes: title: "Correo electrónico de nueva versión con notas" subject_template: "[%{email_prefix}] actualización disponible" text_body_template: | - Hurra! Una version nueva de [Discourse] (https://www.discourse.org) está disponible. + Hurra! Hay una nueva versión de [Discourse] (https://www.discourse.org) disponible. Tu versión: %{installed_version} Versión nueva: **%{new_version}** - Actualiza la versión fácilmente mediante nuestra **[actualización de navegador de un clic](%{base_url}/admin/upgrade)** + Actualiza la versión fácilmente mediante nuestra **[actualización de navegador en un clic](%{base_url}/admin/upgrade)** - - Descubre que hay de nuevo en las [notas de la versión](https://meta.discourse.org/tag/release-notes) o revisa [raw GitHub changelog](https://github.com/discourse/discourse/commits/master) + - Descubre qué novedades hay en las [notas de la versión](https://meta.discourse.org/tag/release-notes) o revisa [raw GitHub changelog](https://github.com/discourse/discourse/commits/master) - Visita [meta.discourse.org](https://meta.discourse.org) para noticias, discusiones y soporte de Discourse @@ -2453,19 +2536,19 @@ es: %{notes} flag_reasons: - off_topic: "Tu publicación fue reportada como **sin relación con el tema**: la comunidad piensa que no se ajusta debidamente al tema, definido por el título o la primera publicación." - inappropriate: "Tu publicación fue reportada como **inapropiada**: la comunidad piensa que es ofensivo, abusivo o que vulnera alguna de [directrices de la comunidad](%{base_path}/directrices)." - spam: "Tu publicación fue reportada como **spam**: la comunidad piensa que se trata de un anuncio, algo de naturaleza promocional, en vez de algo útil o relevante para lo que se espera del tema." - notify_moderators: "Tu publicación fue reportada para la **atención de un moderador**: la comunidad piensa que algo de esta publicación requiere la intervención manual de un miembro del staff." + off_topic: "Tu publicación fue denunciada como **sin relación con el tema**: la comunidad piensa que no se ajusta debidamente al tema, definido por el título o la primera publicación." + inappropriate: "Tu publicación fue denunciada como **inapropiada**: la comunidad piensa que es ofensiva, abusiva o que vulnera alguna de las [directrices de la comunidad](%{base_path}/directrices)." + spam: "Tu publicación fue denunciada como **spam**: la comunidad piensa que se trata de un anuncio, algo de naturaleza promocional, en vez de algo útil o relevante para lo que se espera del tema." + notify_moderators: "Tu publicación fue denunciada para la **atención de un moderador**: la comunidad piensa que algo de esta publicación requiere la intervención manual de un miembro del personal." flags_dispositions: - agreed: "Gracias por avisarnos. Coincidimos con tu reporte en que hay un problema y lo estamos revisando." - agreed_and_deleted: "Gracias por avisarnos. Coincidimos con tu reporte en que hay un problema y hemos quitado la publicación." + agreed: "Gracias por avisarnos. Coincidimos con tu denuncia en que hay un problema y lo estamos revisando." + agreed_and_deleted: "Gracias por avisarnos. Coincidimos con tu denuncia en que hay un problema y hemos quitado la publicación." disagreed: "Gracias por hacérnoslo saber. Estamos revisándolo." ignored: "Gracias por hacérnoslo saber. Estamos revisándolo." ignored_and_deleted: "Gracias por hacérnoslo saber. Hemos eliminado la publicación." temporarily_closed_due_to_flags: - one: "Este tema está cerrado temporalmente durante al menos %{count} hora debido a un número elevado de reportes de la comunidad." - other: "Este tema está cerrado temporalmente durante al menos %{count} horas debido a un número elevado de reportes de la comunidad." + one: "Este tema está cerrado temporalmente durante al menos %{count} hora debido a un número elevado de denuncias de la comunidad." + other: "Este tema está cerrado temporalmente durante al menos %{count} horas debido a un número elevado de denuncias de la comunidad." system_messages: private_topic_title: "Tema #%{id}" contents_hidden: "Por favor, visita la publicación para ver su contenido." @@ -2488,7 +2571,7 @@ es: Para más consejos, consulta nuestras [directrices de la comunidad](%{base_url}/guidelines). post_hidden_again: title: "Mensaje ocultado de nuevo" - subject_template: "Mensaje ocultado debido a reportes de la comunidad, staff notificado" + subject_template: "Mensaje ocultado debido a denuncias de la comunidad, se ha informado al personal" text_body_template: | Hola: @@ -2498,9 +2581,9 @@ es: %{flag_reason} - La comunidad reportó esta publicación y ahora se encuentra oculta. **Dado que esta publicación se ha ocultado más de una vez, tu publicación se mantendrá oculta hasta que la gestione un miembro del staff.** + La comunidad reportó esta publicación y ahora se encuentra oculta. **Dado que esta publicación se ha ocultado más de una vez, tu publicación se mantendrá oculta hasta que la gestione un miembro del personal.** - Para más consejos, por favor, consulta nuestras [directrices de la comunidad](%{base_url}/guidelines). + Para más consejos, consulta nuestras [directrices de la comunidad](%{base_url}/guidelines). queued_by_staff: title: "Una publicación está esperando aprobación" subject_template: "Publicación oculta por el staff, esperando aprobación" @@ -2515,23 +2598,23 @@ es: Para más información, consulta nuestras [directrices de la comunidad](%{base_url}/guidelines). flags_disagreed: - title: "Publicación reportada recuperada por el staff" - subject_template: "Publicación reportada recuperada por el staff" + title: "Publicación denunciada recuperada por el personal" + subject_template: "Publicación denunciada recuperada por el personal" text_body_template: | Hola: Este es un mensaje automático de %{site_name} para informarte que [tu publicación](%{base_url}%{url}) fue recuperada. - Esta publicación fue reportada por la comunidad y un miembro del staff optó por recuperarla. + Esta publicación fue denunciada por la comunidad y un miembro del equipo optó por recuperarla. [detalles=«Clic aquí para expandir la publicación recuperada»] - ``` reducción + ``` markdown %{flagged_post_raw_content} ``` [/details] flags_agreed_and_post_deleted: - title: "Tema reportado y eliminado por el staff" - subject_template: "Tema reportado y eliminado por el staff" + title: "Tema denunciado y eliminado por el personal" + subject_template: "Tema denunciado y eliminado por el personal" text_body_template: | Hola, @@ -2539,7 +2622,7 @@ es: %{flag_reason} - La publicación ha sido reportada por la comunidad, y un miembro del equipo ha decidido eliminarla. + La publicación ha sido denunciada por la comunidad, y un miembro del equipo ha decidido eliminarla. ``` markdown %{flagged_post_raw_content} @@ -2550,7 +2633,7 @@ es: text_body_template: | Si quieres unos consejos para empezar, [echa un vistazo a esta entrada de blog](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). - Conforme vayas participando, te iremos conociendo más y tus limitaciones temporales como usuario nuevo serán levantadas. Con el tiempo ganarás [niveles de confianza](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) que incluyen capacidades especiales para ayudarnos a gestionar la comunidad juntos. + Conforme vayas participando, te iremos conociendo más y se irán levantando tus limitaciones temporales como usuario nuevo. Con el tiempo ganarás [niveles de confianza](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) que incluyen capacidades especiales para ayudarnos a gestionar la comunidad juntos. welcome_user: title: "Bienvenida al usuario" subject_template: "¡Bienvenido a %{site_name}!" @@ -2577,14 +2660,14 @@ es: Como %{role}, ahora tienes acceso a la interfaz de administración. - Con un gran poder viene una gran responsabilidad. Si no tienes experiencia moderando, por favor, revisa la [Guía de moderación](https://meta.discourse.org/t/discourse-moderation-guide/63116). + Un gran poder conlleva una gran responsabilidad. Si no tienes experiencia moderando, consulta la [Guía de moderación](https://meta.discourse.org/t/discourse-moderation-guide/63116). welcome_invite: title: "Bienvenida al invitado" subject_template: "¡Bienvenido a %{site_name}!" text_body_template: | Gracias por aceptar tu invitación a %{site_name} -- ¡Te damos la bienvenida! - Hemos creado automáticamente una nueva cuenta para ti: **%{username}**. Puedes cambiar tu nombre de usuario en cualquier momento visitando [tu perfil][preferencias]. + Hemos creado automáticamente una nueva cuenta para ti: **%{username}**. Puedes cambiar tu nombre de usuario en cualquier momento visitando [tu perfil][prefs]. Para iniciar sesión en adelante: @@ -2606,12 +2689,12 @@ es: Te animamos a que sigas participando, nos gusta que estés por aquí. backup_succeeded: - title: "Copia de respaldo realizada con éxito" - subject_template: "La copia de respaldo se completó exitosamente" + title: "Copia de seguridad realizada con éxito" + subject_template: "La copia de seguridad se completó con éxito" text_body_template: | - Copia de respaldo realizada con éxito. + Copia de seguridad realizada con éxito. - Visita la sección [administrador > copias de respaldo](%{base_url}/admin/backups) para descargar tu nuevo respaldo. + Visita la sección [administrador > copias de seguridad](%{base_url}/admin/backups) para descargar tu nueva copia de seguridad. Aquí está el registro: @@ -2619,10 +2702,10 @@ es: %{logs} ``` backup_failed: - title: "Error en la copia de respaldo" - subject_template: "La copia de respaldo falló" + title: "Error en la copia de seguridad" + subject_template: "La copia de seguridad falló" text_body_template: | - La copia de respaldo ha fallado. + La copia de seguridad ha fallado. Aquí está el registro: @@ -2680,13 +2763,13 @@ es: title: "Exportación de datos en CSV realizada con éxito" subject_template: "[%{export_title}] exportación de datos completa" text_body_template: | - ¡Tus datos se exportaron exitosamente! :dvd: + ¡Tus datos se exportaron con éxito! :dvd: %{download_link} Este enlace de descarga será válido durante 48 horas. - La información está comprimida en forma de archivo zip. Si el archivo no se extrae automáticamente cuando lo abras, utiliza la herrmaienta que se recomienda aquí: https://www.7-zip.org/ + La información está comprimida en forma de archivo zip. Si el archivo no se extrae automáticamente cuando lo abras, utiliza la herramienta que se recomienda aquí: https://www.7-zip.org/ csv_export_failed: title: "Error en la exportación de datos en CSV" subject_template: "La exportación de datos falló" @@ -2697,35 +2780,35 @@ es: text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (titulado %{former_title}) no funcionó. - Tu cuenta no tiene el nivel de confianza suficiente para publicar nuevos temas con esta dirección de correo. Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + Tu cuenta no tiene el nivel de confianza suficiente para publicar nuevos temas con esta dirección de correo. Si crees que esto es un error, [contacta a un miembro del personal](%{base_url}/about). email_reject_user_not_found: title: "Correo electrónico rechazado. Cuenta de usuario no encontrada" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Usuario no encontrado" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título %{former_title}) no funcionó. - Tu respuesta se envió desde un correo electrónico desconocido. Intenta usar otro correo o [contacta a un miembro del staff](%{base_url}/about). + Tu respuesta se envió desde un correo electrónico desconocido. Intenta usar otro correo o [contacta a un miembro del personal](%{base_url}/about). email_reject_screened_email: title: "Correo electrónico rechazado. Correo bloqueado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Correo bloqueado" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - Tu respuesta se envió desde un correo bloqueado. Intenta usar otro correo o [contacta a un miembro del staff](%{base_url}/about). + Tu respuesta se envió desde un correo bloqueado. Intenta usar otro correo o [contacta a un miembro del personal](%{base_url}/about). email_reject_not_allowed_email: title: "Correo electrónico rechazado. Correo electrónico no permitido" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Correo bloqueado" text_body_template: | Lo sentimos, pero tu mensaje a %{destination} (con el título%{former_title}) no funcionó. - Tu respuesta se envió desde una dirección de correo electrónico bloqueada. Trata de enviar la respuesta desde otra dirección de correo electrónico o [contactar a un miembro del staff](%{base_url}/about). + Tu respuesta se envió desde una dirección de correo electrónico bloqueada. Trata de enviar la respuesta desde otra dirección de correo electrónico o [contacta con un miembro del personal](%{base_url}/about). email_reject_inactive_user: title: "Correo electrónico rechazado. Usuario inactivo" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Usuario inactivo" text_body_template: | Lo sentimos pero tu correo electrónico para %{destination} (con el título %{former_title}) no funcionó. - La cuenta asociada a esta dirección de correo electrónico no ha sido activada. Por favor, activa tu cuenta antes de enviar correos electrónicos. + La cuenta asociada a esta dirección de correo electrónico no ha sido activada. Activa tu cuenta antes de enviar correos electrónicos. email_reject_silenced_user: title: "Correo electrónico rechazado. Usuario silenciado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Usuario silenciado" @@ -2739,7 +2822,7 @@ es: text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - Tu respuesta se envió desde un correo diferente al que esperábamos, así que no estamos seguros de que seas la misma persona. Intenta usar otro correo, o [contacta a un miembro del staff](%{base_url}/about). + Tu respuesta se envió desde un correo diferente al que esperábamos, así que no estamos seguros de que seas la misma persona. Intenta usar otro correo, o [contacta con un miembro del personal](%{base_url}/about). email_reject_empty: title: "Correo electrónico rechazado. Vacío" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Sin contenido" @@ -2757,26 +2840,26 @@ es: No hemos encontrado tu respuesta en el correo electrónico. **Asegúrate de escribir tu entera respuesta al principio del correo** - no podemos analizar respuestas entre líneas. email_reject_invalid_access: - title: "Correo electrónico rechazado. Acceso inválido" - subject_template: "[%{email_prefix}] Problema de correo electrónico -- Acceso inválido" + title: "Correo electrónico rechazado. Acceso no válido" + subject_template: "[%{email_prefix}] Problema de correo electrónico -- Acceso no válido" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - Tu cuenta no tiene los permisos para crear nuevos temas en esta categoría. Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + Tu cuenta no tiene los permisos para crear nuevos temas en esta categoría. Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_strangers_not_allowed: title: "Rechazar correos electrónicos desconocidos No permitido" - subject_template: "[%{email_prefix}] Problema de correo electrónico -- Acceso inválido" + subject_template: "[%{email_prefix}] Problema de correo electrónico -- Acceso no válido" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - La categoría a la que envió este correo electrónico solo permite respuestas de usuarios con cuentas válidas y direcciones de correo electrónico conocidas. Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + La categoría a la que envió este correo electrónico solo permite respuestas de usuarios con cuentas válidas y direcciones de correo electrónico conocidas. Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_invalid_post: title: "Correo electrónico de rechazo. Publicación inválida" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Error de publicación" text_body_template: | Lo sentimos, tu correo electrónico para %{destination} (con el título %{former_title}) no funcionó. - Las posibles causas son: formato complejo, mensaje demasiado extenso o demasiado breve. Por favor, inténtalo de nuevo o publica a través del sitio web si el problema continúa. + Las posibles causas son: formato complejo, mensaje demasiado extenso o demasiado breve. Inténtalo de nuevo o publica a través del sitio web si el problema continúa. email_reject_invalid_post_specified: title: "Correo electrónico rechazado. Publicación especificada inválida" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Error de publicación" @@ -2787,7 +2870,7 @@ es: %{post_error} - Si puedes corregir el problema sugerido, por favor, inténtalo de nuevo. + Si puedes corregir el problema sugerido, inténtalo de nuevo. date_invalid: "No hay fecha de creación de la publicación. ¿Le falta al correo electrónico un encabezado de Date:?" email_reject_post_too_short: title: "Correo electrónico de rechazo. Publicación muy corta" @@ -2795,21 +2878,21 @@ es: text_body_template: | Lo sentimos, tu correo para %{destination} (con el título %{former_title}) no funcionó. - Para asegurar la calidad de las conversaciones, las respuestas muy cortas no están permitidas. ¿Podrías responder con al menos %{count} caracteres? Alternativamente, puedes darle me gusta a un mensaje respondiendo por correo electrónico con "+1". + Para asegurar la calidad de las conversaciones, no están permitidas las respuestas muy cortas. ¿Podrías responder con al menos %{count} caracteres? Alternativamente, puedes darle me gusta a un mensaje respondiendo por correo electrónico con «+1». email_reject_invalid_post_action: title: "Correo electrónico de rechazo. Acción de publicación inválida" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Acción de publicación inválida" text_body_template: | Lo sentimos, tu mensaje de correo electrónico para %{destination} (con el título%{former_title}) no funcionó. - La acción de publicación no se reconoce. Por favor, intenta más tarde o envía el mensaje desde el sitio web de forma tradicional si el error continua. + La acción de publicación no se reconoce. Inténtalo más tarde o envía el mensaje desde el sitio web de forma tradicional si el error continua. email_reject_reply_key: title: "Correo electrónico de rechazo. Clave de respuesta" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Clave de respuesta desconocida" text_body_template: | Lo sentimos, tu correo a %{destination} (con el título%{former_title}) no funcionó. - La clave de respuesta es inválida o desconocida, así que no podemos determinar a qué estás respondiendo. [Contacta a un miembro del staff](%{base_url}/about). + La clave de respuesta no es válida o es desconocida, así que no podemos determinar a qué estás respondiendo. [Contacta con un miembro del personal](%{base_url}/about). email_reject_bad_destination_address: title: "Correo electrónico de rechazo. Dirección del destinatario incorrecta" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Dirección desconocida" @@ -2831,35 +2914,35 @@ es: text_body_template: | Lo sentimos, tu correo electrónico para %{destination} (con el título %{former_title}) no funcionó. - Solo aceptamos respuestas a las notificaciones originales durante %{number_of_days} días. Por favor, [visita el tema](%{short_url}) para seguir con la conversación. + Solo aceptamos respuestas a las notificaciones originales durante %{number_of_days} días. [Visita el tema](%{short_url}) para seguir con la conversación. email_reject_topic_not_found: title: "Correo electrónico de rechazo. Tema no encontrado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Tema no encontrado" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - El tema al que estás respondiendo ya no existe -- ¿quizás ha sido eliminado? Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + El tema al que estás respondiendo ya no existe -- ¿quizás ha sido eliminado? Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_topic_closed: title: "Correo electrónico de rechazo. Tema cerrado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Tema cerrado" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título %{former_title}) no ha funcionado. - El tema al que estás respondiendo está cerrado y no permite más respuestas. Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + El tema al que estás respondiendo está cerrado y no permite más respuestas. Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_auto_generated: title: "Correo electrónico de rechazo. Autogenerado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Respuesta generada automaticamente" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título%{former_title}) no funcionó. - Tu correo electrónico ha sido marcado como «autogenerado», lo que significa que ha sido creado automáticamente por una computadora en lugar de haber sido escrito por un ser humano; no podemos aceptar este tipo de correos. Si crees que esto es un error, [contacta un miembro del staff](%{base_url}/about). + Tu correo electrónico ha sido marcado como «autogenerado», lo que significa que ha sido creado automáticamente por un ordenador en lugar de haber sido escrito por un ser humano; no podemos aceptar este tipo de correos. Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_unrecognized_error: title: "Correo electrónico de rechazo. Error no identificado" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Error no reconocido" text_body_template: | Lo sentimos, tu mensaje a %{destination} (con el título %{former_title}) no funcionó. - Hay un error no reconocido mientras se procesó tu correo electrónico y no se pudo publicar. Intenta nuevamente o [contacta a un miembro del staff](%{base_url}/about). + Se produjo un error desconocido mientras se procesaba tu correo electrónico y no se pudo publicar. Inténtalo de nuevo o [contacta con un miembro del personal](%{base_url}/about). email_reject_attachment: title: "Correo electrónico de rechazo. Archivos adjuntos" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Archivos adjuntos rechazados" @@ -2869,30 +2952,30 @@ es: Detalles: %{rejected_errors} - Si crees que se trata de un error, [contacta a un miembro del staff](%{base_url}/about). + Si crees que se trata de un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_reply_not_allowed: title: "Correo electrónico de rechazo. No permitido" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Respuesta no permitida" text_body_template: | Lo sentimos, tu mensaje de correo a %{destination} (con el título %{former_title}) no funcionó. - Tu cuenta no tiene los permisos para responder al tema. Si crees que esto es un error, [contacta a un miembro del staff](%{base_url}/about). + Tu cuenta no tiene los permisos para responder al tema. Si crees que esto es un error, [contacta con un miembro del personal](%{base_url}/about). email_reject_reply_to_digest: title: "Correo de rechazo por respuesta al resumen" subject_template: "[%{email_prefix}] Problema de correo electrónico — Responder al resumen" text_body_template: | Lo sentimos, pero tu mensaje de correo electrónico a %{destination} (titulado %{former_title}) no ha funcionado. - Has respondido a un correo electrónico de resumen, lo cual no es aceptado. + Has respondido a un correo electrónico de resumen, lo cual no es aceptable. - Si cree que se trata de un error, [ponte en contacto con un miembro del staff] (%{base_url}/acerca de). + Si crees que se trata de un error, [ponte en contacto con un miembro del personal] (%{base_url}/acerca de). email_error_notification: title: "Error de notificación de correo electrónico" subject_template: "[%{email_prefix}] Problema de correo electrónico -- Error de autenticación POP" text_body_template: | - Hubo un error de autenticación mientras se ejecutaba el polling de los correos electrónicos desde el servidor POP. + Lamentablemente, se ha producido un error de autenticación al sondear los correos electrónicos del servidor POP. - Por favor, asegúrate de que se han configurado las credenciales POP correctamente en los [ajustes del sitio](%{base_url}/admin/site_settings/category/email). + Asegúrate de que se han configurado las credenciales POP correctamente en los [ajustes del sitio](%{base_url}/admin/site_settings/category/email). Si dispones de una interfaz de usuario web para la cuenta de correo POP, es posible que debas iniciar sesión allí y comprobar tu configuración. email_revoked: @@ -2916,33 +2999,33 @@ es: text_body_template: | Hola, - Este es un mensaje automatizado de %{site_name} para informarte que %{ignores_threshold} usuarios han ignorado @%{username}. Esto podría indicar que un problema se está desarrollando en tu comunidad. + Este es un mensaje automatizado de %{site_name} para informarte de que %{ignores_threshold} usuarios han ignorado a @%{username}. Esto podría indicar que un problema se está desarrollando en tu comunidad. Es posible que desees [revisar las publicaciones recientes](%{base_url}/u/%{username}/summary) de este usuario y potencialmente otros usuarios en el [informe de usuario ignorado y silenciado](%{base_url}/admin/reports/top_ignored_users). Para obtener orientación adicional, consulta nuestras [directrices de la comunidad](%{base_url}/directrices). too_many_spam_flags: - title: "Demasiados reportes de spam" + title: "Demasiadas denuncias de spam" subject_template: "Cuenta nueva suspendida" text_body_template: | Hola, - Esto es un mensaje automatizado de %{site_name} para hacerte saber que tus publicaciones han sido ocultadas temporalmente porquer la comunidad las ha reportado. + Esto es un mensaje automatizado de %{site_name} para hacerte saber que tus publicaciones han sido ocultadas temporalmente porque la comunidad las ha denunciado. - Como medida preventiva, tu nueva cuenta ha sido silenciada y no podrás crear respuestas o temas hasta que un miembro del staff la revise. Lamentamos las molestias. + Como medida preventiva, tu nueva cuenta ha sido silenciada y no podrás crear respuestas o temas hasta que un miembro del personal la revise. Lamentamos las molestias. Para más información, echa un vistazo a nuestras [directrices de la comunidad](%{base_url}/directrices). too_many_tl3_flags: - title: "Demasiadas reportes NC3" + title: "Demasiadas denuncias NC3" subject_template: "Nueva cuenta suspendida" text_body_template: | Hola, - Esto es un mensaje automátizado de %{site_name} para hacerte saber que tu cuenta se encuentra suspendida debido a numerosos reportes de la comunidad. + Esto es un mensaje automátizado de %{site_name} para hacerte saber que tu cuenta se encuentra suspendida debido a numerosas denuncias de la comunidad. - Como medida preventiva, tu cuenta nueva ha sido silenciada y no podrás crear nuevas respuestas o temas hasta que un miembro del staff la revise. Lamentamos las molestias ocasionadas. + Como medida preventiva, tu cuenta nueva ha sido silenciada y no podrás crear nuevas respuestas o temas hasta que un miembro del personal la revise. Lamentamos las molestias ocasionadas. - Para más información, por favor, lee las [directrices de la comunidad](%{base_url}/directrices). + Para más información, lee las [directrices de la comunidad](%{base_url}/directrices). silenced_by_staff: title: "Silenciado por el staff" subject_template: "Cuenta temporalmente suspendida" @@ -2951,18 +3034,18 @@ es: Esto es un mensaje automatizado de %{site_name} para hacerte saber que tu cuenta ha sido temporalmente retenida como medida preventiva. - Por favor, sigue navegando, pero no podrás responder o crear temas hasta que un [miembro del staff](%{base_url}/about) revise tus últimos mensajes. Lamentamos las molestias ocasionadas. + Puedes seguir navegando, pero no podrás responder o crear temas hasta que un [miembro del personal](%{base_url}/about) revise tus últimos mensajes. Lamentamos las molestias ocasionadas. Para más información sobre lo que consideramos adecuado, lee las [directrices de la comunidad](%{base_url}/directrices). user_automatically_silenced: title: "Usuario silenciado automáticamente" - subject_template: "Usuario nuevo %{username} silenciado por reportes de la comunidad" + subject_template: "Usuario nuevo %{username} silenciado por denuncias de la comunidad" text_body_template: | Este es un mensaje automatizado. - El usuario nuevo [%{username}](%{user_url}) se silenció automáticamente porque múltiples usuarios han reportado mensaje(s) de %{username}. + El usuario nuevo [%{username}](%{user_url}) se silenció automáticamente porque múltiples usuarios han denunciado mensaje(s) de %{username}. - Por favor, [mira los reportes](%{base_url}/admin/flags). Si %{username} se silenció incorrectamente, haz clic en el botón de quitar silencio en la [página de administración del usuario](%{user_url}). + Por favor, [comprueba las denuncias](%{base_url}/admin/flags). Si %{username} se silenció incorrectamente, haz clic en el botón de quitar silencio en la [página de administración del usuario](%{user_url}). Esto puede ser cambiado con el ajuste `silence_new_user` en la configuración del sitio. spam_post_blocked: @@ -2982,7 +3065,7 @@ es: text_body_template: | Hola, - Esto es un mensaje automático desde %{site_name} para informarte de que, tras la revisión por parte de miembros del staff, tu cuenta ya no se encuentra suspendida. + Esto es un mensaje automático desde %{site_name} para informarte de que, tras la revisión por parte de miembros del personal, tu cuenta ya no se encuentra suspendida. Ahora puedes crear temas y responder a otros usuarios. Gracias por tu paciencia. pending_users_reminder: @@ -3004,16 +3087,16 @@ es: text_body_template: | Tenemos nuevos consejos y recomendaciones para ti basados en tus ajustes del sitio actuales. - [Visita el dashboard de tu sitio](%{base_url}/admin) para verlos. + [Visita el panel de control de tu sitio](%{base_url}/admin) para verlos. - Si no hay nada visible en tu dashboard, otro miembro del staff podría haber actuado ya sobre este consejo. Puedes encontrar una lista de acciones del staff en tus [registros de acciones del staff](%{base_url}/admin/logs/staff_action_logs). + Si no hay nada visible en tu panel de control, otro miembro del personal podría haber actuado ya sobre este consejo. Puedes encontrar una lista de acciones del personal en tus [registros de acciones del personal](%{base_url}/admin/logs/staff_action_logs). new_user_of_the_month: title: "¡Eres el usuario nuevo del mes!" subject_template: "¡Eres el usuario nuevo del mes!" text_body_template: | ¡Enhorabuena! Has ganado el **premio de usuario nuevo del mes de%{month_year}**. :trophy: - Este premio solo lo ganan dos de los nuevos miembros de cada mes y será visible permanentemente en [la página de medallas](%{url}). + Este premio solo lo ganan dos de los nuevos miembros de cada mes y será visible permanentemente en [la página de insignias](%{url}). Te has convertido rápidamente en un miembro de gran valor para nuestra comunidad. Gracias por unirte ¡y continúa con el gran trabajo! queued_posts_reminder: @@ -3051,6 +3134,7 @@ es: only_reply_by_email_pm: "Responde a este correo electrónico para contestar a %{participants}." visit_link_to_respond: "[Visita el tema](%{base_url}%{url}) para responder." visit_link_to_respond_pm: "[Visita el mensaje](%{base_url}%{url}) para responder a %{participants}." + reply_above_line: "## Escribe tu respuesta sobre esta línea. ##" posted_by: "Publicado por %{username} el %{post_date}" pm_participants: "Participantes: %{participants}" invited_group_to_private_message_body: | @@ -3265,13 +3349,13 @@ es: title: "Cuenta ya existente" subject_template: "[%{email_prefix}] La cuenta ya existe" text_body_template: | - Intentaste crear una cuenta en %{site_name} o intentaste cambiar el correo electrónico de una cuenta a %{email}. Sin embargo, una cuenta ya existe con la dirección de correo %{email}. + Intentaste crear una cuenta en %{site_name} o intentaste cambiar el correo electrónico de una cuenta a %{email}. Sin embargo, ya existe una cuenta con la dirección de correo %{email}. Si olvidaste la contraseña, [restablécela ahora](%{base_url}/password-reset). Si no quisiste crear una cuenta con el correo %{email} o cambiar la dirección de correo electrónico, no te preocupes – puedes ignorar este mensaje. - Si tienes alguna pregunta, [contacta a nuestro amigable staff](%{base_url}/about). + Si tienes alguna pregunta, [contacta a nuestro amable personal](%{base_url}/about). account_second_factor_disabled: title: "Autenticación de dos factores desactivada" subject_template: "[%{email_prefix}] Autenticación de dos factores desactivada" @@ -3314,11 +3398,11 @@ es: title: "Iniciar sesión con un enlace" subject_template: "[%{email_prefix}] Iniciar sesión con un enlace" text_body_template: | - Aquí tienes tu enlace para ingresar a [%{site_name}](%{base_url}). + Aquí tienes tu enlace para iniciar sesión en [%{site_name}](%{base_url}). Si no has solicitado este enlace, puedes simplemente ignorar este correo electrónico. - Haz clic en el siguiente enlace para ingresar: + Haz clic en el siguiente enlace para iniciar sesión: %{base_url}/session/email-login/%{email_token} set_password: title: "Establecer contraseña" @@ -3326,12 +3410,12 @@ es: text_body_template: | Alguien ha solicitado añadir una contraseña a tu cuenta en [%{site_name}](%{base_url}). Alternativamente, puedes iniciar sesión usando cualquier servicio online soportado (como Google, Facebook, etc) que esté asociado con esta dirección de correo electrónico validada. - Si no realizaste esta petición, por favor, ignora este correo. + Si no realizaste esta petición, ignora este correo. Haz clic en el siguiente enlace para escoger una contraseña: %{base_url}/u/password-reset/%{email_token} admin_login: - title: "Incicio de sesión de administrador" + title: "Inicio de sesión de administrador" subject_template: "[%{email_prefix}] Inicio de sesión" text_body_template: | Alguien ha solicitado iniciar sesión en tu cuenta de [%{site_name}](%{base_url}). @@ -3371,7 +3455,7 @@ es: subject_template: "[%{email_prefix}] Confirma tu dirección actual de correo electrónico" text_body_template: | Antes de que podamos cambiar tu dirección de correo electrónico, es necesario que confirmes - que la cuenta de correo electrónica actual está bajo tu control. Tras completar este paso, deberemos confirmar también la nueva dirección. + que la cuenta de correo electrónico actual está bajo tu control. Tras completar este paso, deberemos confirmar también la nueva dirección. Confirma tu dirección de correo electrónico actual para %{site_name} haciendo clic en el siguiente enlace: @@ -3390,19 +3474,13 @@ es: notify_old_email: title: "Antiguo correo electrónico de notificaciones" subject_template: "[%{email_prefix}] Tu dirección de correo electrónico ha sido cambiada" - text_body_template: | - Este es un mensaje automatizado para comunicarte que tu correo electrónico para el sitio %{site_name} ha sido modificado. Si esto fue un error, por favor, contacta - a un administrador del sitio. - - Tu correo electrónico fue modificado por: - - %{new_email} + text_body_template: "Este es un mensaje automatizado para comunicarte que tu correo electrónico para \nel sitio %{site_name} ha sido modificado. Si esto fue un error, ponte en contacto con un \nadministrador del sitio.\n\nTu correo electrónico fue modificado por:\n\n%{new_email}\n" notify_old_email_add: title: "Notificar correo electrónico antiguo (Añadir)" subject_template: "[%{email_prefix}] Dirección de correo electrónico añadida" text_body_template: | Este es un mensaje automatizado para informarte de que se ha añadido una dirección de correo electrónico para - %{site_name}. Si esto se hizo por error, por favor, ponte en en contacto con la administración del sitio. + %{site_name}. Si esto se hizo por error, ponte en en contacto con la administración del sitio. La dirección de correo electrónico añadida es: @@ -3413,9 +3491,9 @@ es: text_body_template: | ¡Bienvenido a %{site_name}! - Un miembro del staff aprobó tu cuenta en %{site_name}. + Un miembro del personal aprobó tu cuenta en %{site_name}. - Puedes ahora acceder a tu nueva cuenta ingresando en: + Puedes ahora acceder a tu nueva cuenta iniciando sesión en: %{base_url} Si no puedes hacer clic en el enlace, intenta copiarlo y pegarlo en la barra de direcciones de tu navegador. @@ -3424,7 +3502,7 @@ es: Creemos en una comunidad con un [comportamiento civilizado en comunidad](%{base_url}/directrices) en todo momento. - ¡Disfruta tu estadía! + ¡Disfruta de tu estancia! signup_after_reject: title: "Registro: después de rechazo" subject_template: "Te han rechazado la cuenta en %{site_name}" @@ -3436,12 +3514,12 @@ es: title: "Regístrate" subject_template: "[%{email_prefix}] Confirma tu cuenta nueva" text_body_template: | - ¡Bienvenido a %{site_name}! + ¡Te damos la bienvenida a %{site_name}! Haz clic en el siguiente enlace para confirmar y activar tu cuenta nueva: %{base_url}/u/activate-account/%{email_token} - Si no puedes hacer clic en el enlace, intentar copiarlo y pegarlo en la barra de direcciones de tu navegador. + Si no puedes hacer clic en el enlace, intenta copiarlo y pegarlo en la barra de direcciones de tu navegador. activation_reminder: title: "Recordatorio de activación" subject_template: "[%{email_prefix}] Recordatorio: confirma tu cuenta" @@ -3468,7 +3546,7 @@ es: Si has sido tú, ¡genial! No tienes que hacer más nada. - Si no has sido tú, por favor, [revisa tus sesiones activas](%{base_url}/my/preferences/account) y considera cambiar tu contraseña. + Si no has sido tú, [revisa tus sesiones activas](%{base_url}/my/preferences/account) y considera cambiar tu contraseña. post_approved: title: "Tu publicación ha sido aprobada" subject_template: "[%{site_name}] Tu publicación ha sido aprobada" @@ -3576,6 +3654,104 @@ es: Edita la primera publicación de este tema para cambiar el contenido de la página %{page_name}. guidelines_topic: title: "Preguntas frecuentes/Directrices" + body: | + + + ## [Este es un lugar civilizado para la discusión pública](#civilizado) + + Trata este foro de discusión con el mismo respeto con el que tratarías un parque público. Nosotros también somos un recurso comunitario compartido — un lugar para compartir habilidades, conocimientos e intereses a través de una conversación continua. + + Estas no son reglas estrictas. Son directrices para ayudar al juicio humano de nuestra comunidad y mantener este lugar amable y amistoso para el discurso público civilizado. + + + + ## [Mejorar el debate](#mejorar) + + Ayúdanos a hacer de éste un gran lugar de debate añadiendo siempre algo positivo a la discusión, por pequeño que sea. Si no estás seguro de que tu mensaje aporte algo a la conversación, piensa en lo que quieres decir y vuelve a intentarlo más tarde. + + Una forma de mejorar el debate es descubrir los que ya se están produciendo. Dedica un tiempo a navegar por los temas de aquí antes de responder o iniciar el tuyo propio, y tendrás más posibilidades de conocer a otras personas que compartan tus intereses. + + Los temas que se discuten aquí nos importan, y queremos que actúes como si también te importaran. Sé respetuoso con los temas y las personas que los discuten, incluso si no estás de acuerdo con algo de lo que se dice. + + + + ## [Sé agradable, incluso cuando no estés de acuerdo](#agreeable) + + Es posible que quieras responder discrepando. Eso está bien. Pero recuerda _criticar las ideas, no las personas_. Por favor, evita: + + * Los insultos + * Ataques ad hominem + * Responder al tono de un mensaje en lugar de a su contenido real + * Contradecir de forma precipitada + + En lugar de ello, aporta ideas reflexivas que mejoren la conversación. + + + + ## [Tu participación cuenta](#participa) + + Las conversaciones que mantenemos aquí marcan el tono de cada nueva llegada. Ayúdanos a influir en el futuro de esta comunidad eligiendo participar en las discusiones que hacen de este foro un lugar interesante — y evitando las que no lo hacen. + + Discourse proporciona herramientas que permiten a la comunidad identificar colectivamente las mejores (y peores) contribuciones: marcadores, me gusta, denuncias, respuestas, ediciones, vigilancia, silenciamiento, etc. Utiliza estas herramientas para mejorar tu propia experiencia, y también la de los demás. + + Dejemos nuestra comunidad mejor de lo que la encontramos. + + + + ## [Si ves un problema, denúncialo](#denunciar-problemas) + + Los moderadores tienen una autoridad especial; son los responsables de este foro. Pero tú también lo eres. Con tu ayuda, los moderadores pueden ser facilitadores de la comunidad, no solo conserjes o policías. + + Cuando veas un mal comportamiento, no respondas. Responder fomenta el mal comportamiento al reconocerlo, consume tu energía y hace perder el tiempo a todos. Sólo márcalo. Si se acumulan suficientes banderas, se tomarán medidas, ya sea automáticamente o mediante la intervención de un moderador. + + Para mantener nuestra comunidad, los moderadores se reservan el derecho de eliminar cualquier contenido y cualquier cuenta de usuario por cualquier motivo y en cualquier momento. Los moderadores no revisan los nuevos mensajes; los moderadores y los operadores del sitio no se hacen responsables de ningún contenido publicado por la comunidad. + + + + ## [Sé siempre civilizado](#sé-civilizado) + + Nada sabotea una conversación sana como la grosería: + + ## Sé civilizado. No publiques nada que una persona razonable pueda considerar ofensivo, abusivo o que incite al odio. + * No ensucies el foro. No publiques nada obsceno o sexualmente explícito. + * Respeta a los demás. No acoses ni agobies a nadie, ni te hagas pasar por otra persona, ni expongas su información privada. + * Respeta nuestro foro. No publiques spam ni vandalices el foro de otro modo. + + No se trata de términos concretos con definiciones precisas — evita incluso la _apariencia_ de cualquiera de estas cosas. Si no estás seguro, pregúntate cómo te sentirías si tu mensaje apareciera en la portada de un sitio de noticias importante. + + Este es un foro público, y los motores de búsqueda indexan estas discusiones. Mantén el lenguaje, los enlaces y las imágenes seguros para la familia y los amigos. + + + + ## [Mantén el orden](#mantén-el-orden) + + Haz el esfuerzo de poner las cosas en el lugar correcto, para que podamos pasar más tiempo discutiendo y menos limpiando. Por eso: + + * No empieces un tema en la categoría equivocada; lee las definiciones de las categorías. + * No publiques lo mismo en varios temas. + * No publiques respuestas sin contenido. + * No desvíes un tema cambiándolo a mitad de camino. + * No firmes tus publicaciones; cada publicación tiene tu información de perfil. + + En lugar de publicar «+1» o «De acuerdo», utiliza el botón «Me gusta». En lugar de llevar un tema existente en una dirección radicalmente diferente, utiliza la Respuesta como Tema vinculado. + + + + ## [Publica solo tus cosas](#robo) + + No puedes publicar nada digital que pertenezca a otra persona sin permiso. No puedes publicar descripciones, enlaces o métodos para robar la propiedad intelectual de alguien (software, vídeo, audio, imágenes), o para infringir cualquier otra ley. + + + + ## [Dirigido por ti](#poder) + + Este sitio está gestionado por tu [amable personal local](%{base_path}/about) y *tú*, la comunidad. Si tienes más preguntas sobre cómo deberían funcionar las cosas aquí, abre un nuevo tema en la [categoría de comentarios sobre el sitio](%{base_path}/c/site-feedback) y discutamos. Si hay una cuestión crítica o urgente que no puede ser tratada por un meta tema o una denuncia, ponte en contacto con nosotros a través de la [página del personal](%{base_path}/about). + + + + ## [Condiciones de servicio](#condiciones-de-servicio) + + Sí, la jerga legal es aburrida, pero debemos protegernos a nosotros mismos – y por extensión, a ti y a tus datos – contra gente poco amistosa. Tenemos unas [Condiciones de servicio](%{base_path}/tos) que describen tu (y nuestro) comportamiento y derechos relacionados con el contenido, la privacidad y las leyes. Para utilizar este servicio, debes aceptar respetar nuestras [TOS](%{base_path}/tos). tos_topic: title: "Términos de servicio" body: | @@ -3844,247 +4020,248 @@ es: name: Editor description: Primera edición de una publicación long_description: | - Esta medalla se concede la primera vez que editas una de tus publicaciones. Aunque no siempre podrás editar tus publicaciones, editar es una buena idea — te permite mejorar tus publicaciones, corregir pequeños errores o añadir algo que se te había pasado. ¡Edita para hacer mejores a tus publicaciones! + Esta insignia se concede la primera vez que editas una de tus publicaciones. Aunque no siempre podrás editar tus publicaciones, editar es una buena idea ya que te permite mejorar tus publicaciones, corregir pequeños errores o añadir algo que se te había pasado. ¡Edita para hacer mejores a tus publicaciones! wiki_editor: name: Editor de wiki description: Primera edición de un tema wiki long_description: | - Esta medalla se concede la primera vez que editas un tema de tipo wiki. + Esta insignia se concede la primera vez que editas un tema de tipo wiki. basic_user: name: Básico description: Acceso a todas las funciones esenciales de la comunidad long_description: | - Esta medalla se concede cuando alcanzas el nivel de confianza 1. Gracias por quedarte y leer unos cuantos temas para aprender acerca de qué es esta comunidad. Las restricciones de usuario nuevo se han eliminado; podrás usar las funcionalidades esenciales como enviar mensajes privados a otras personas, reportar publicaciones, editar publicaciones wiki y publicar con múltiples imágenes y enlaces. + Esta insignia se concede cuando alcanzas el nivel de confianza 1. Gracias por quedarte y leer unos cuantos temas para aprender acerca de qué es esta comunidad. Las restricciones de usuario nuevo se han eliminado; podrás usar las funcionalidades esenciales como enviar mensajes privados a otras personas, reportar publicaciones, editar publicaciones wiki y publicar con múltiples imágenes y enlaces. member: name: Miembro description: Acceso a invitaciones, mensajes grupales y más me gusta long_description: | - Esta medalla se concede al llegar a nivel de confianza 2. Gracias por participar durante estas semanas y unirte de verdad a nuestra comunidad. Ahora puedes enviar invitaciones desde tu página de usuario o desde los temas, crear mensajes personales grupales y dar más me gusta por día. + Esta insignia se concede al llegar a nivel de confianza 2. Gracias por participar durante estas semanas y unirte de verdad a nuestra comunidad. Ahora puedes enviar invitaciones desde tu página de usuario o desde los temas, crear mensajes personales grupales y dar más me gusta por día. regular: name: Habitual description: 'Acceso a las funciones: recategorizar, renombrar, publicar enlaces sin el tag no-follow y dar más me gusta' long_description: | - Esta medalla se concede al llegar a nivel de confianza 3. Gracias por ser una parte constante de nuestra comunidad durante estos meses. Ahora eres uno de nuestros lectores más activos y una fuente de confianza que hace nuestra comunidad mejor. Ahora puedes recategorizar y renombrar temas, tus reportes tienen más peso y tienes acceso a una categoría especial privada, además de poder dar muchos más me gusta al día. + Esta insignia se concede al llegar a nivel de confianza 3. Gracias por ser una parte constante de nuestra comunidad durante estos meses. Ahora eres uno de nuestros lectores más activos y una fuente de confianza que hace nuestra comunidad mejor. Ahora puedes recategorizar y renombrar temas, tus denuncias tienen más peso y tienes acceso a una categoría especial privada, además de poder dar muchos más me gusta al día. leader: name: Líder description: 'Acceso a las funciones: editar, destacar, cerrar, archivar, dividir y combinar temas globalmente' long_description: | - Esta medalla se concede cuando alcanzas el nivel de confianza 4. Eres un líder de la comunidad elegido por los administradores, constituyes un ejemplo positivo para los demás mediante tus hechos y palabras. Ahora puedes editar todas las publicaciones y realizar acciones de moderador como destacar temas, cerrarlos, archivarlos, hacerlos invisibles, dividirlos o fusionarlos. + Esta insignia se concede cuando alcanzas el nivel de confianza 4. Eres un líder de la comunidad elegido por los administradores, constituyes un ejemplo positivo para los demás mediante tus hechos y palabras. Ahora puedes editar todas las publicaciones y realizar acciones de moderador como destacar temas, cerrarlos, archivarlos, hacerlos invisibles, dividirlos o fusionarlos. welcome: name: '¡Bienvenido/a!' description: Recibió un me gusta long_description: | - Esta medalla se concede al recibir tu primer me gusta en una publicación. ¡Enhorabuena! Publicaste algo que los demás miembros de la comunidad han considerado útil, interesante o entretenido. + Esta insignia se concede al recibir tu primer me gusta en una publicación. ¡Enhorabuena! Publicaste algo que los demás miembros de la comunidad han considerado útil, interesante o entretenido. autobiographer: name: Autobiógrafo description: Detalló información en su perfil de usuario long_description: | - Esta medalla se concede por rellenar tu perfil de usuario y seleccionar una foto de perfil. Al contar más sobre ti y lo que te interesa, la comunidad es más cercana y está más conectada. ¡Anímate! + Esta insignia se concede por rellenar tu perfil de usuario y seleccionar una foto de perfil. Al contar más sobre ti y lo que te interesa, la comunidad es más cercana y está más conectada. ¡Anímate! anniversary: name: Aniversario description: Miembro activo desde hace un año y que ha publicado al menos una vez long_description: | - Esta medalla se concede cuando has sido miembro durante un año y has publicado al menos una vez durante este tiempo. Gracias por seguir con nosotros y contribuir con nuestra comunidad. ¡No sería lo mismo sin ti! + Esta insignia se concede cuando has sido miembro durante un año y has publicado al menos una vez durante este tiempo. Gracias por seguir con nosotros y contribuir con nuestra comunidad. ¡No sería lo mismo sin ti! nice_post: name: Buena respuesta description: Recibió 10 me gusta en una respuesta - long_description: "Esta medalla se concede cuando una de tus respuestas consigue 10 me gusta. Tus respuestas han gustado a la comunidad y han ayudado a mantener viva la conversación. \n" + long_description: | + Esta insignia se concede cuando una de tus respuestas consigue 10 me gusta. Tus respuestas han gustado a la comunidad y han ayudado a mantener viva la conversación. good_post: name: Muy buena respuesta description: Recibió 25 me gusta en una respuesta long_description: | - Esta medalla se concede cuando una de tus respuestas consigue 25 me gusta. Tu respuesta ha sido excepcional y ha conseguido hacer la conversación mucho más interesante. + Esta insignia se concede cuando una de tus respuestas consigue 25 me gusta. Tu respuesta ha sido excepcional y ha conseguido hacer la conversación mucho más interesante. great_post: name: Excelente respuesta description: Recibió 50 me gusta en una respuesta long_description: | - Esta medalla se concede cuando una de tus respuestas consigue 50 me gusta. ¡Wow! ¡Tu respuesta ha sido fascinante, inspiradora muy graciosa o muy bien pensada y le ha encantado a la comunidad! + Esta insignia se concede cuando una de tus respuestas consigue 50 me gusta. ¡Wow! ¡Tu respuesta ha sido fascinante, inspiradora muy graciosa o muy bien pensada y le ha encantado a la comunidad! nice_topic: name: Buen tema description: Recibió 10 me gusta en un tema long_description: | - Esta medalla se concede cuando un tema tuyo recibe 10 me gusta. Iniciaste una conversación interesante que le ha gustado a la comunidad. + Esta insignia se concede cuando un tema tuyo recibe 10 me gusta. Iniciaste una conversación interesante que le ha gustado a la comunidad. good_topic: name: Muy buen tema description: Recibió 25 me gusta en un tema long_description: | - Esta medalla se concede cuando un tema tuyo consigue 25 me gusta. Iniciaste una conversación que la comunidad ha disfrutado mucho. + Esta insignia se concede cuando un tema tuyo consigue 25 me gusta. Iniciaste una conversación que la comunidad ha disfrutado mucho. great_topic: name: Excelente tema description: Recibió 50 me gusta en un tema long_description: | - Esta medalla se concede cuando un tema tuyo consigue 50 me gusta. ¡Comenzaste un tema fascinante cuya discusión le ha encantado a la comunidad! + Esta insignia se concede cuando un tema tuyo consigue 50 me gusta. ¡Comenzaste un tema fascinante cuya discusión le ha encantado a la comunidad! nice_share: name: Buena contribución description: Compartió una publicación con 25 visitantes únicos long_description: | - Esta medalla se concede por compartir un enlace que fue visitado por 25 visitantes externos. Gracias por difundir las palabras sobre nuestras discusiones y esta comunidad. + Esta insignia se concede por compartir un enlace que fue visitado por 25 visitantes externos. Gracias por difundir las palabras sobre nuestras discusiones y esta comunidad. good_share: name: Muy buena contribución description: Compartió una publicación con 300 visitantes únicos long_description: | - Esta medalla se concede cuando compartes un enlace y 300 visitantes externos hacen clic. ¡Buen trabajo! Has enseñado una conversación excelente a unas cuantas personas nuevas y has ayudado a crecer la comunidad. + Esta insignia se concede cuando compartes un enlace y 300 visitantes externos hacen clic. ¡Buen trabajo! Has enseñado una conversación excelente a unas cuantas personas nuevas y has ayudado a crecer la comunidad. great_share: name: Excelente contribución description: Compartió una publicación con 1000 visitantes únicos long_description: | - Esta medalla se concede cuando compartes un enlace y 1000 visitantes externos hacen clic en él. ¡Increíble! Has promocionado una conversación muy interesante a una gran audiencia y has ayudado a crecer la comunidad. + Esta insignia se concede cuando compartes un enlace y 1000 visitantes externos hacen clic en él. ¡Increíble! Has promocionado una conversación muy interesante a una gran audiencia y has ayudado a crecer la comunidad. first_like: name: Primer me gusta description: Le dio a me gusta a una publicación long_description: | - Esta medalla se otorga la primera vez que le das me gusta a una publicación usando el botón :heart:. Darle me gusta a publicaciones es una manera estupenda de hacer saber al resto de los miembros de la comunidad que lo que han publicado te ha parecido interesante, útil, entretenido o divertido. ¡Comparte el amor! + Esta insignia se otorga la primera vez que le das me gusta a una publicación usando el botón :heart:. Darle me gusta a publicaciones es una manera estupenda de hacer saber al resto de los miembros de la comunidad que lo que han publicado te ha parecido interesante, útil, entretenido o divertido. ¡Comparte el amor! first_flag: - name: Primer reporte + name: Primera denuncia description: Reportó una publicación long_description: | - Esta medalla se concede la primera vez que reportas algo. Reportar es la manera en la que mantenemos el foro en buen estado. Si ves algo que deba ser revisado por un moderador por cualquier motivo, no dudes en reportarlo. Si ves un problema... ¡:flag_black: repórtalo! + Esta insignia se concede la primera vez que denuncias algo. Denunciar es la manera en la que mantenemos el foro en buen estado. Si ves algo que deba ser revisado por un moderador por cualquier motivo, no dudes en denunciarlo. Si ves un problema... ¡:flag_black: denúncialo! promoter: name: Promotor description: Invitó a un usuario long_description: | - Esta medalla se concede cuando invitas a alguien a unirse a la comunidad a través del botón de invitación en tu página del perfil o al final de un tema. Invitar a amigos que puedan estar interesados en debates específicos es una excelente manera para integrar personas nuevas a la comunidad, ¡gracias! + Esta insignia se concede cuando invitas a alguien a unirse a la comunidad a través del botón de invitación en tu página del perfil o al final de un tema. Invitar a amigos que puedan estar interesados en debates específicos es una excelente manera para integrar personas nuevas a la comunidad, ¡gracias! campaigner: name: Activista description: Invitó a 3 usuarios long_description: | - Esta medalla se concede cuando has invitado a 3 personas que han pasado tiempo en el sitio y se han convertido en usuarios básicos. Una comunidad vibrante necesita de un flujo constante de nuevas voces que participen en las conversaciones. + Esta insignia se concede cuando has invitado a 3 personas que han pasado tiempo en el sitio y se han convertido en usuarios básicos. Una comunidad vibrante necesita de un flujo constante de nuevas voces que participen en las conversaciones. champion: name: Campeón description: Invitó a 5 miembros long_description: | - Esta medalla se concede cuando has invitado a 5 personas que han pasado el suficiente tiempo en el sitio como para convertirse en miembros de pleno derecho. ¡Bravo! ¡Gracias por enriquecer la diversidad de la comunidad con nuevos miembros! + Esta insignia se concede cuando has invitado a 5 personas que han pasado el suficiente tiempo en el sitio como para convertirse en miembros de pleno derecho. ¡Bravo! ¡Gracias por enriquecer la diversidad de la comunidad con nuevos miembros! first_share: name: Primera vez que comparte description: Compartió una publicación long_description: | - Esta medalla se otorga la primera vez que compartes un enlace a una respuesta o tema usando el botón de compartir. Compartir enlaces es una excelente manera de mostrar discusiones interesantes con el resto del mundo y hacer crecer la comunidad. + Esta insignia se otorga la primera vez que compartes un enlace a una respuesta o tema usando el botón de compartir. Compartir enlaces es una excelente manera de mostrar discusiones interesantes con el resto del mundo y hacer crecer la comunidad. first_link: name: Primer enlace description: Añadió un enlace a otro tema long_description: | - Esta medalla se otorga la primera vez que incluyes un enlace a otro tema. Enlazar temas ayuda al resto de lectores a encontrar conversaciones interesantes. ¡Enlaza libremente! + Esta insignia se otorga la primera vez que incluyes un enlace a otro tema. Enlazar temas ayuda al resto de lectores a encontrar conversaciones interesantes. ¡Enlaza libremente! first_quote: name: Primera cita description: Citó una publicación long_description: | - Esta medalla se concede la primera vez que citas algo en una respuesta. Citar las partes relevantes de mensajes anteriores ayuda a mantener la conversación conectada y sin salirse del tema. La manera más fácil de citar es seleccionando una parte de un mensaje y luego pulsar el botón de responder. ¡Cita abundantemente! + Esta insignia se concede la primera vez que citas algo en una respuesta. Citar las partes relevantes de mensajes anteriores ayuda a mantener la conversación conectada y sin salirse del tema. La manera más fácil de citar es seleccionando una parte de un mensaje y luego pulsar el botón de responder. ¡No dejes de citar! read_guidelines: name: Preguntas frecuentes leídas description: Leyó las directrices de la comunidad long_description: | - Esta medalla se concede por leer las directrices de la comunidad. Con seguir y compartir estas simples pautas, nos ayudarás a construir un foro más seguro, divertido y sostenible. Acuérdate de que detrás de cada usuario hay un ser humano muy parecido a ti. ¡Sé amable! + Esta insignia se concede por leer las directrices de la comunidad. Con seguir y compartir estas simples pautas, nos ayudarás a construir un foro más seguro, divertido y sostenible. Acuérdate de que detrás de cada usuario hay un ser humano muy parecido a ti. ¡Sé amable! reader: name: Lector description: Leyó todas las respuestas en un tema con más de 100 respuestas long_description: | - Esta medalla se concede la primera vez que lees un tema largo, uno con más de 100 respuestas. Leer una conversación con atención te ayuda a seguir el debate, entender los diferentes puntos de vista y llevar a conversaciones más interesantes. Cuanto más lees, mejor es la conversación. Como nos gusta decir: ¡la lectura es fundamental! :slight_smile: + Esta insignia se concede la primera vez que lees un tema largo, uno con más de 100 respuestas. Leer una conversación con atención te ayuda a seguir el debate, entender los diferentes puntos de vista y llevar a conversaciones más interesantes. Cuanto más lees, mejor es la conversación. Como nos gusta decir: ¡la lectura es fundamental! :slight_smile: popular_link: name: Enlace popular description: Publicó un enlace externo con al menos 50 clics long_description: | - Esta medalla se concede cuando el enlace que has compartido alcanza los 50 clics. ¡Gracias por publicar un enlace útil que añade un contexto interesante a la conversación! + Esta insignia se concede cuando el enlace que has compartido alcanza los 50 clics. ¡Gracias por publicar un enlace útil que añade un contexto interesante a la conversación! hot_link: name: Enlace muy popular description: Publicó un enlace externo con al menos 300 clics long_description: | - Esta medalla se concede cuando el enlace que has compartido alcanza los 300 clics. ¡Gracias por publicar un enlace que impulsó la conversación e ilustró el debate! + Esta insignia se concede cuando el enlace que has compartido alcanza los 300 clics. ¡Gracias por publicar un enlace que impulsó la conversación e ilustró el debate! famous_link: name: Enlace famoso description: Publicó un enlace externo con al menos 1000 clics long_description: | - Esta medalla se concede cuando un enlace que compartiste llega a 1000 clics. ¡Vaya! Publicaste un enlace que mejoró significativamente la conversación al añadir un detalle esencial, contextual e informativo. ¡Gran trabajo! + Esta insignia se concede cuando un enlace que compartiste llega a 1000 clics. ¡Vaya! Publicaste un enlace que mejoró significativamente la conversación al añadir un detalle esencial, contextual e informativo. ¡Gran trabajo! appreciated: name: Apreciado description: Recibió 1 me gusta en 20 publicaciones long_description: | - Esta medalla se concede cuando recibes al menos un me gusta en 20 publicaciones distintas. ¡La comunidad está disfrutando tus contribuciones a las conversaciones! + Esta insignia se concede cuando recibes al menos un me gusta en 20 publicaciones distintas. ¡La comunidad está disfrutando tus contribuciones a las conversaciones! respected: name: Respetado description: Recibió 2 me gusta en 100 publicaciones long_description: | - Esta medalla se concede cuando recibes al menos 2 me gusta en 100 publicaciones distintas. ¡La comunidad está apreciando tus numerosas aportaciones a las conversación! + Esta insignia se concede cuando recibes al menos 2 me gusta en 100 publicaciones distintas. ¡La comunidad está apreciando tus numerosas aportaciones a las conversación! admired: name: Admirado description: Recibió 5 me gusta en 300 publicaciones long_description: | - Esta medalla se concede cuando recibes al menos 5 me gusta en 300 publicaciones distintas. ¡Bravo! ¡La comunidad admira tus aportes frecuentes y de gran calidad! + Esta insignia se concede cuando recibes al menos 5 me gusta en 300 publicaciones distintas. ¡Bravo! ¡La comunidad admira tus aportes frecuentes y de gran calidad! out_of_love: name: Dadivoso description: Por usar %{max_likes_per_day} me gusta en un día. long_description: | - Esta medalla se concede cuando usas todos los %{max_likes_per_day}de tus me gusta diarios. Recuerda tomar un momento y dar me gusta a las publicaciones que disfrutas, así incentivas a los demás miembros a crear aún mejores debates en el futuro. + Esta insignia se concede cuando usas todos los %{max_likes_per_day}de tus me gusta diarios. Recuerda tomar un momento y dar me gusta a las publicaciones que disfrutas, así incentivas a los demás miembros a crear aún mejores debates en el futuro. higher_love: name: Generoso description: Utilizó %{max_likes_per_day} me gusta en un día 5 veces long_description: | - Esta medalla se concede cuando usas todos los %{max_likes_per_day} me gusta diarios por 5 días. ¡Gracias por tomarte tiempo en incentivar y premiar las mejores conversaciones cada día! + Esta insignia se concede cuando usas todos los %{max_likes_per_day} me gusta diarios por 5 días. ¡Gracias por tomarte tiempo en incentivar y premiar las mejores conversaciones cada día! crazy_in_love: name: Magnánimo description: Por usar %{max_likes_per_day} me gusta en un día 20 veces long_description: | - Esta medalla se concede cuando usas todos los %{max_likes_per_day}de tus me gusta diarios por 20 días. ¡Wowl! ¡Eres un modelo a seguir por animar a los demás miembros de la comunidad! + Esta insignia se concede cuando usas todos los %{max_likes_per_day}de tus me gusta diarios por 20 días. ¡Guau! ¡Eres un modelo a seguir por animar a los demás miembros de la comunidad! thank_you: name: Gracias description: Ha recibido me gusta en 20 publicaciones y dado 10 me gusta long_description: | - Esta medalla se concede cuando has recibido 20 me gusta en tus publicaciones y has dado 10 o más de vuelta. Cuando a alguien le gustan tus publicaciones, también encuentras el tiempo para agradecer las publicaciones de otras personas. + Esta insignia se concede cuando has recibido 20 me gusta en tus publicaciones y has dado 10 o más de vuelta. Cuando a alguien le gustan tus publicaciones, también encuentras el tiempo para agradecer las publicaciones de otras personas. gives_back: name: Cómplice description: Ha recibido me gusta en 100 publicaciones y dado 100 me gusta long_description: | - Esta medalla se concede cuando has recibido 100 me gusta en tus publicaciones y has dado 100 o más de vuelta. ¡Gracias por dar tanto y agradecer de vuelta! + Esta insignia se concede cuando has recibido 100 me gusta en tus publicaciones y has dado 100 o más de vuelta. ¡Gracias por dar tanto y agradecer de vuelta! empathetic: name: Empático description: Ha recibido me gusta en 500 publicaciones y dado 1000 me gusta long_description: | - Esta medalla se concede cuando has recibido 500 me gusta y le has dado me gusta a otros 1000 mensajes. Eres un modelo de generosidad y apreciación mutua :two_hearts:. + Esta insignia se concede cuando has recibido 500 me gusta y le has dado me gusta a otros 1000 mensajes. Eres un modelo de generosidad y apreciación mutua :two_hearts:. first_emoji: name: Primer emoji description: Utilizó un emoji en una publicación long_description: | - Esta medalla se concede la primera vez que añades un emoji a tu publicación :thumbsup:. Los emoji te permiten añadir emociones a tu publicación, desde felicidad :smiley: a tristeza :anguished: pasando por enfado :angry: o cualquier otra expresión :sunglasses:. Comienza escribiendo : (dos puntos) o haz clic en el botón emoji de la barra del editor para seleccionar entre cientos de opciones. :ok_hand: + Esta insignia se concede la primera vez que añades un emoji a tu publicación :thumbsup:. Los emoji te permiten añadir emociones a tu publicación, desde felicidad :smiley: a tristeza :anguished: pasando por enfado :angry: o cualquier otra expresión :sunglasses:. Comienza escribiendo : (dos puntos) o haz clic en el botón emoji de la barra del editor para seleccionar entre cientos de opciones. :ok_hand: first_mention: name: Primera mención description: Mencionó a un usuario en una publicación long_description: | - Esta medalla se concede la primera vez que mencionas a un @usuario en una publicación. Las menciones generan notificaciones a la persona mencionada para que sepan que les estás mencionando. Simplemente empieza escribiendo @ (arroba) para mencionar a alguien o, si está permitido, a un grupo – es una forma conveniente de captar la atención de alguien. + Esta insignia se concede la primera vez que mencionas a un @usuario en una publicación. Las menciones generan notificaciones a la persona mencionada para que sepan que les estás mencionando. Simplemente empieza escribiendo @ (arroba) para mencionar a alguien o, si está permitido, a un grupo – es una forma conveniente de captar la atención de alguien. first_onebox: name: Primer onebox description: Publicó un enlace expandido vía onebox long_description: | - Esta medalla se concede la primera vez que publicas un enlace solo en una línea, que se expande automáticamente en un onebox con un resumen, título y (cuando esté disponible) imagen. + Esta insignia se concede la primera vez que publicas un enlace solo en una línea, que se expande automáticamente en un onebox con un resumen, título y (cuando esté disponible) imagen. first_reply_by_email: name: Primera respuesta por correo description: Respondió a una publicación por correo electrónico long_description: | - Esta medalla se concede la primera vez que respondes a una publicación por correo electrónico :e-mail:. + Esta insignia se concede la primera vez que respondes a una publicación por correo electrónico :e-mail:. new_user_of_the_month: name: "Usuario nuevo del mes" description: Contribuciones excepcionales en su primer mes long_description: | - Esta medalla se otorga para felicitar a dos nuevos usuarios cada mes por sus excelentes colaboraciones de acuerdo a la frecuencia con las que sus publicaciones reciben me gusta y quiénes se lo dan. + Esta insignia se otorga para felicitar a dos nuevos usuarios cada mes por sus excelentes colaboraciones de acuerdo a la frecuencia con las que sus publicaciones reciben me gusta y quiénes se lo dan. enthusiast: name: Entusiasta description: Visitó el sitio 10 días consecutivos long_description: | - Esta medalla se concede por visitarnos durante 10 días consecutivos. ¡Gracias por quedarte con nosotros más de una semana! + Esta insignia se concede por visitarnos durante 10 días consecutivos. ¡Gracias por quedarte con nosotros más de una semana! aficionado: name: Aficionado description: Visitó 100 días consecutivos long_description: | - Esta medalla se concede por visitarnos durante 100 días consecutivos. ¡Eso es más de tres meses! + Esta insignia se concede por visitarnos durante 100 días consecutivos. ¡Eso es más de tres meses! devotee: name: Devoto description: Visitó 365 días consecutivos long_description: | - Esta medalla se concede por visitarnos durante 365 días consecutivos. ¡Wow, un año entero! - badge_title_metadata: "%{display_name} medalla en %{site_title}" + Esta insignia se concede por visitarnos durante 365 días consecutivos. ¡Guau, un año entero! + badge_title_metadata: "%{display_name} insignia en %{site_title}" admin_login: success: "Correo electrónico enviado" errors: unknown_email_address: "Dirección de correo electrónico desconocida." - invalid_token: "Token inválido." + invalid_token: "Token no válido." email_input: "Correo electrónico de administrador" submit_button: "Enviar correo electrónico" performance_report: @@ -4120,7 +4297,7 @@ es: button: "Registrarse" title: "Registrar cuenta de administrador" help: "registra una cuenta para empezar" - no_emails: "Por desgracia, no se definió ningún correo electrónico de administrador durante la configuración inicial, por lo que terminar de instalar el foro será complicado. Por favor, añade un correo de desarrollador en el archivo de configuración o crea una cuenta de administrador desde la consola." + no_emails: "Por desgracia, no se definió ningún correo electrónico de administrador durante la configuración inicial, por lo que terminar de instalar el foro será complicado. Añade un correo de desarrollador en el archivo de configuración o crea una cuenta de administrador desde la consola." confirm_email: title: "Confirmar tu correo electrónico" message: "

    Hemos enviado un correo de activación a %{email}. Por favor, sigue las instrucciones en el correo para activar tu cuenta.

    Si no llega, revisa tu carpeta de spam y asegúrate de configurar el correo electrónico correctamente.

    " @@ -4162,7 +4339,7 @@ es: fields: welcome: label: "Tema de bienvenida" - description: "

    ¿Cómo le describirías tu comunidad a un desconocido en alrededor de 1 minuto?

    • ¿A quién está enfocado el contenido?
    • ¿Qué puedo encontrar aquí?
    • ¿Por qué debería visitar esta página?
    • Tu tema de bienvenida es lo primero que los nuevos visitantes verán en el sitio. Piensa que es el párrafo que define la esencia de tu comunidad.

      " + description: "

      ¿Cómo le describirías tu comunidad a un desconocido en alrededor de 1 minuto?

      • ¿A quién va dirigido el contenido?
      • ¿Qué puedo encontrar aquí?
      • ¿Por qué debería visitar esta página?

      Tu tema de bienvenida es lo primero que los nuevos visitantes verán en el sitio. Piensa que es el párrafo que define la esencia de tu comunidad.

      " one_paragraph: "El mensaje de bienvenida no debería ser más de un párrafo." extra_description: "Si no te decides, puedes saltarte este paso y editar el tema de bienvenida más adelante." privacy: @@ -4192,17 +4369,17 @@ es: contact_email: label: "Correo" placeholder: "nombre@ejemplo.com" - description: "Dirección de correo electrónico de la persona o grupo responsable de esta comunidad. Usado para notificaciones críticas como reportes sin atender y actualizaciones de seguridad. También estará en la página acerca de para cosas urgentes." + description: "Dirección de correo electrónico de la persona o grupo responsable de esta comunidad. Se usa para notificaciones críticas como denuncias sin atender y actualizaciones de seguridad. También estará en la página acerca de para cosas urgentes." contact_url: label: "Página web" placeholder: "https://ejemplo.com/contacto" description: "Tu página de contacto general o la de tu organización. Se mostrará en la página de acerca de." site_contact: label: "Mensajes automatizados" - description: "Todos los mensajes privados automatizados de Discourse serán enviados desde este usuario, tales como reportes de advertencia y notificaciones de respaldos exitosos." + description: "Todos los mensajes privados automatizados de Discourse serán enviados desde este usuario, tales como denuncias de advertencia y notificaciones de copias de seguridad exitosas." corporate: title: "Organización" - description: "Esta información será ingresada en tus Términos de servicio, las cuales podrás editar en todo momento en la categoría Staff. Si no tienes una empresa, puedes saltarte este paso por ahora." + description: "Esta información se introducirá en tus Términos de servicio, las cuales podrás editar en todo momento en la categoría Personal. Si no tienes una empresa, puedes saltarte este paso por ahora." fields: company_name: label: "Nombre de la compañía" @@ -4219,7 +4396,7 @@ es: title: "Tipos de letra" fields: body_font: - label: "Tipo de letra para contenido de mensajes" + label: "Fuente del cuerpo" heading_font: label: "Tipo de letra para encabezados" font_preview: @@ -4301,7 +4478,7 @@ es: revoked: Revocada restored: Restaurada reviewables: - already_handled: "Gracias, pero ya revisamos esta publicación y determinamos que no necesita reportarse de nuevo." + already_handled: "Gracias, pero ya revisamos esta publicación y determinamos que no necesita denunciarse de nuevo." already_handled_and_user_not_exist: "Gracias, pero otra persona ya lo ha revisado, y ese usuario ha dejado de existir." priorities: low: "Bajo" @@ -4320,11 +4497,11 @@ es: post_count: "Las primeras publicaciones de cada usuario deben ser aprobadas por el staff. Consulta `approve_post_count`." trust_level: "Los usuarios con niveles de confianza bajos deben tener respuestas aprobadas por el staff. Consulta `approve_unless_trust_level`." new_topics_unless_trust_level: "Los usuarios con niveles de confianza bajos deben tener temas aprobados por el staff. Consulta `approve_new_topics_unless_trust_level`." - fast_typer: "El usuario nuevo escribió su primer mensaje de forma sospechosamente rápida. Podría ser un bot o comportamiento de spammer. Consulta `min_first_post_typing_time`." + fast_typer: "El usuario nuevo escribió su primer mensaje de forma sospechosamente rápida. Podría ser un bot o un spammer. Consulta `min_first_post_typing_time`." auto_silence_regexp: "Usuario nuevo cuya primera publicación coincide con el ajuste `auto_silence_first_post_regex`." watched_word: "Esta publicación contiene una palabra vigilada. Mira tu lista de palabras vigiladas." staged: "Los temas y publicaciones nuevas para usuarios provisionales deben ser aprobados por el staff. Consulta `approve_unless_staged`." - category: "Los mensajes en esta categoría requieren la aprobación manual del staff. Consulta la configuración de la categoría." + category: "Los mensajes en esta categoría requieren la aprobación manual del personal. Consulta la configuración de la categoría." must_approve_users: "Todos los usuarios nuevos deben ser aprobados por el staff. Consulta `must_approve_users`." invite_only: "Todos los usuarios nuevos deben ser invitados. Consulta `invite_only`." email_auth_res_enqueue: "Este correo falló la comprobación DMARC, por lo que seguramente no es de quien parece ser. Comprueba los encabezados del correo para más información." @@ -4334,13 +4511,13 @@ es: queued_by_staff: "Un miembro del staff cree que esta publicación debe que ser revisada. Se mantendrá oculta hasta entonces." actions: agree: - title: "Concordar..." + title: "Estar de acuerdo..." agree_and_keep: title: "Mantener publicación" description: "Estar de acuerdo con la denuncia y mantener la publicación intacta." agree_and_keep_hidden: title: "Mantener la publicación oculta" - description: "Concordar con el reporte y mantener la publicación oculta." + description: "Aceptar la denuncia y mantener la publicación oculta." agree_and_suspend: title: "Suspender usuario" description: "Estar de acuerdo con la denuncia y suspender el usuario." @@ -4363,14 +4540,14 @@ es: delete_and_ignore_replies: title: "Eliminar publicación + respuestas e ignorar" description: "Eliminar publicación y todas sus respuestas; si es la primera publicación de un tema, eliminarlo también" - confirm: "¿Estás seguro de que quieres eliminar las respuestas de la publicación también?" + confirm: "¿Seguro que quieres eliminar las respuestas de la publicación también?" delete_and_agree: title: "Eliminar publicación y coincidir" description: "Eliminar publicación; si es la primera del tema, eliminar el tema también." delete_and_agree_replies: title: "Eliminar publicación + respuestas y coincidir" description: "Eliminar publicación y todas sus respuestas; si es la primera publicación de un tema, eliminarlo también" - confirm: "¿Estás seguro de que quieres eliminar las respuestas de la publicación también?" + confirm: "¿Seguro que quieres eliminar las respuestas de la publicación también?" disagree_and_restore: title: "Discrepar y restaurar publicación" description: "Restaurar la publicación para que los usuarios la puedan ver." diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index 113c6efb75..ea0e80a592 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -927,7 +927,6 @@ fa_IR: post_undo_action_window_mins: "تعداد دقایقی که کاربران اجازه دارند اقدامی را که در نوشته انجام داده اند باز گردانند. (پسند، پرچم گذاری،‌ چیزهای دیگر)." must_approve_users: "مدیران باید کاربران جدید را قبل از اجازه دسترسی به سایت تایید کنند." review_every_post: "همه نوشته‌ها باید بررسی شوند. هشدار! برای وب‌سایت‌های فعال و مشغول توصیه نمی‌شود." - pending_users_reminder_delay: "اگر کاربر‌ها بیشتر از این مقدار ساعت منتظر تایید بودند به مدیران اعلام کن. مقدار -1 برای غیرفعال‌سازی." maximum_session_age: "کاربر برای n ساعت از آخرین بازدید در حالت وارد شده می‌ماند." cors_origins: "ریشه های مجاز برای cross-origin requests درخواست متقابل منشاء (CORS). هر منشاء باید دارای http:// or https://. The DISCOURSE_ENABLE_CORS env برای تنظیم به کارگیری CORS باید متغیر باشد." use_admin_ip_allowlist: "مدیر‌ها فقط در صورتی می‌توانند وارد شوند که در لیست آیپی‌های نمایش داده شده باشند. (مدیریت > گزارش‌ها > آیپی‌های نمایش داده شده)" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 75a13bd0da..a426b5ffc3 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -6,15 +6,15 @@ fi: dates: - short_date_no_year: "D. MMM" + short_date_no_year: "D. MMMM[ta]" short_date: "D. MMMM[ta] YYYY" - long_date: "D. MMMM[ta] YYYY, H:mm" + long_date: "D. MMMM[ta] YYYY, H.mm" datetime_formats: &datetime_formats formats: - short: "%-d.%-m.%Y" + short: "%d.%m.%Y" short_no_year: "%-d. %B[ta]" date_only: "%d. %B[ta] %Y" - long: "%d. %B[ta] %Y, [klo] %H.%M" + long: "%B %-d, %Y, %l.%M%P" no_day: "%B %Y" date: month_names: @@ -40,43 +40,43 @@ fi: topics: "Ketjut" posts: "viestit" views: "katselua" - loading: "Lataa" - powered_by_html: 'Voimanlähteenä Discourse, toimii parhaiten, kun JavaScript on käytössä' - sign_up: "Luo tili" + loading: "Ladataan" + powered_by_html: 'Palvelun tarjoaa Discourse, toimii parhaiten, kun JavaScript on käytössä' + sign_up: "Rekisteröidy" log_in: "Kirjaudu" submit: "Lähetä" purge_reason: "Hylätty, aktivoimaton tili poistettiin automaattisesti" disable_remote_images_download_reason: "Linkattujen kuvien lataaminen poistettiin käytöstä vähäisen tallennustilan vuoksi." - anonymous: "Anonyymejä" + anonymous: "Anonyymi" remove_posts_deleted_by_author: "Kirjoittajan poistama" - redirect_warning: "Emme pystyneet varmistamaan että klikkaamasi linkki on oikeasti lähetetty palstalle. Jos haluat silti jatkaa, klikkaa alla olevaa linkkiä." + redirect_warning: "Emme pystyneet varmistamaan, että klikkaamasi linkki on oikeasti lähetetty foorumille. Jos haluat silti jatkaa, klikkaa alla olevaa linkkiä." on_another_topic: "Toisessa ketjussa" inline_oneboxer: topic_page_title_post_number: "%{post_number}" topic_page_title_post_number_by_user: "#%{post_number} käyttäjältä %{username}" themes: - bad_color_scheme: "Ei voi päivittää teemaa, väripaletti ei kelpaa" - other_error: "Jotakin meni vikaan, kun teemaa päivitettiin" - ember_selector_error: "Valitettavasti #ember- tai .ember-näkymän CSS-valitsimien käyttö ei ole sallittua, koska nämä nimet luodaan dynaamisesti ajon aikana ja ne muuttuvat ajan myötä, mikä johtaa lopulta rikkoutuneeseen CSS: ään. Kokeile toista valitsinta." + bad_color_scheme: "Teemaa ei voi päivittää, väripaletti ei kelpaa" + other_error: "Jokin meni vikaan päivitettäessä teemaa" + ember_selector_error: "Valitettavasti #ember- tai .ember-näkymän CSS-valitsimien käyttö ei ole sallittua, koska nämä nimet luodaan dynaamisesti ajon aikana ja ne muuttuvat ajan myötä, mikä johtaa lopulta rikkoutuneeseen CSS:ään. Kokeile toista valitsinta." compile_error: unrecognized_extension: "Tuntematon tiedostomuoto: %{extension}" import_error: generic: Teeman tuomisessa tapahtui virhe about_json: "Virhe tuotaessa: about.json ei ole olemassa tai ei kelpaa. Oletko varma, että tämä on Discourse-teema?" - about_json_values: "about.json sisältää arvoja, jotka eivät kelpaa: %{errors}" - modifier_values: "about.json sisältää virheellisiä arvoja: %{errors}" - git: "Virhe git-tietovarastoa kloonattaessa: ei ole pääsyoikeutta tai tietovarastoa ei löytynyt" + about_json_values: "about.json sisältää virheellisiä arvoja: %{errors}" + modifier_values: "about.json-tiedoston määreet sisältävät virheellisiä arvoja: %{errors}" + git: "Virhe git-tietovarastoa kloonattaessa: ei käyttöoikeutta tai tietovarastoa ei löytynyt" git_ref_not_found: "git checkout epäonnistui kohteelle: %{ref}" unpack_failed: "Tiedoston purku epäonnistui" file_too_big: "Purettu tiedosto on liian suuri." - unknown_file_type: "Lähettämäsi tiedosto ei vaikuta olevan käypä Discourse-teema." + unknown_file_type: "Lataamasi tiedosto ei näytä olevan kelvollinen Discourse-teema." not_allowed_theme: "`%{repo}` ei ole sallittujen teemojen luettelossa (tarkista `allowed_theme_repos` -asetus)." errors: component_no_user_selectable: "Teemakomponentti ei voi olla käyttäjän valittavissa" component_no_default: "Teemakomponentti ei voi olla oletusteema" component_no_color_scheme: "Teemakomponentilla ei voi olla väripalettia" - no_multilevels_components: "Teemat joilla on tytärteemoja eivät voi olla tytärteemoja itse" - optimized_link: Optimoidut kuvalinkit ovat lyhytikäisiä eikä niitä tulisi sisällyttää teeman lähdekoodiin. + no_multilevels_components: "Teemat, joilla on tytärteemoja, eivät voi olla tytärteemoja" + optimized_link: Optimoidut kuvalinkit ovat lyhytikäisiä, eikä niitä tulisi sisällyttää teeman lähdekoodiin. settings_errors: invalid_yaml: "Annettu YAML ei kelpaa" data_type_not_a_number: "Tietotyypiksi ei ole mahdollista asettaa `%{name}`. Tuettuja tietotyyppejä ovat `integer`, `bool`, `list`, `enum` ja `upload`" @@ -86,39 +86,39 @@ fi: default_out_range: "Asetuksen \"%{name}\" oletusarvo ei ole sallituissa rajoissa." enum_value_not_valid: "Valittu arvo ei ole enum-arvojen listalla." number_value_not_valid: "Uusi arvo ei ole sallituissa rajoissa." - number_value_not_valid_min_max: "Sen täytyy olla vähintään %{min}ja enintään %{max}." + number_value_not_valid_min_max: "Sen täytyy olla vähintään %{min} ja enintään %{max}." number_value_not_valid_min: "Sen täytyy olla suurempi tai yhtä suuri kuin %{min}." number_value_not_valid_max: "Sen täytyy olla pienempi tai yhtä suuri kuin %{max}." string_value_not_valid: "Uuden arvon pituus ei ole sallituissa rajoissa." - string_value_not_valid_min_max: "Sen tulee olla %{min}-%{max} merkin pituinen." - string_value_not_valid_min: "Sen täytyy olla vähintään %{min}merkkiä pitkä." - string_value_not_valid_max: "Sen täytyy olla enintään %{max}merkkiä pitkä" + string_value_not_valid_min_max: "Sen täytyy olla %{min}–%{max} merkin pituinen." + string_value_not_valid_min: "Sen täytyy olla vähintään %{min} merkkiä pitkä." + string_value_not_valid_max: "Sen täytyy olla enintään %{max} merkkiä pitkä" locale_errors: top_level_locale: "Lokalisaatiotiedoston ylimmän tason avaimen täytyy olla sama kuin lokalisaation nimi" invalid_yaml: "Käännös-YAML ei kelpaa" emails: incoming: default_subject: "Tämä ketju tarvitsee otsikon" - show_trimmed_content: "Näytä piilotettu sisältö" - maximum_staged_user_per_email_reached: "Saavutti maksimimäärän automaattisesti luotuja esikäyttäjiä per sähköpostiosoite." + show_trimmed_content: "Näytä rajattu sisältö" + maximum_staged_user_per_email_reached: "Maksimimäärä luotuja esikäyttäjiä per sähköpostiosoite saavutettu." no_subject: "(ei aihetta)" no_body: "(ei leipätekstiä)" errors: empty_email_error: "Näin käy, kun saapuneessa sähköpostissa ei lue mitään." - no_message_id_error: "Näin käy, kun viestin otsikkotiedoista puuttuu ID-tunniste (engl. message-ID)." + no_message_id_error: "Näin käy, kun viestillä ei ole Message-Id-otsikkotietoa." auto_generated_email_error: "Näin käy, kun viestin kiireellisyysluokitus (engl. precedence header) on joku seuraavista: list, junk, bulk tai auto_reply, tai kun joku muu otsikkotiedoista sisältää jonkun seuraavista: auto-submitted, auto-replied tai auto-generated." no_body_detected_error: "Näin käy, kun leipätekstin poiminta epäonnistuu eikä liitteitä ole." no_sender_detected_error: "Näin käy, kun emme tunnista käypää sähköpostiosoitetta viestin From-otsikkotiedosta." - from_reply_by_address_error: "Näin käy, kun lähettäjän osoite (From) on sama kuin vastausten sähköpostiosoite." + from_reply_by_address_error: "Näin käy, kun lähettäjän osoite (From) on sama kuin vastaussähköpostiosoite." inactive_user_error: "Näin käy, kun lähettäjä ei ole aktiivinen." silenced_user_error: "Näin käy, kun lähettäjä on hiljennetty." - bad_destination_address: "Näin käy, kun yksikään viestin vastaanottaja/kopio-kenttien osoitteista ei täsmää asetettuihin saapuvan sähköpostin osoitteisiin." - strangers_not_allowed_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka jäsen ei ole." - insufficient_trust_level_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka vähimmäisluottamustasovaatimusta ei täytä." + bad_destination_address: "Näin käy, kun yksikään viestin vastaanottaja- tai kopiokenttien osoitteista ei täsmää asetettuihin saapuvan sähköpostin osoitteisiin." + strangers_not_allowed_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka jäsen hän ei ole." + insufficient_trust_level_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka vähimmäisluottamustasovaatimusta hän ei täytä." reply_user_not_matching_error: "Näin käy, kun vastaus saapuu eri sähköpostiosoitteesta kuin mihin ilmoitus lähetettiin." topic_not_found_error: "Näin käy, kun vastauksen saapuessa ketju, johon viesti oli tarkoitettu, on poistettu." topic_closed_error: "Näin käy, kun vastauksen saapuessa ketju, johon viesti oli tarkoitettu, on suljettu." - bounced_email_error: "Sähköposti on palautetun sähköpostin raportti" + bounced_email_error: "Sähköposti on toimittamattoman sähköpostin raportti" screened_email_error: "Näin käy, kun lähettäjän sähköpostiosoite on jo seulottu." unsubscribe_not_allowed: "Näin käy, kun tämä käyttäjä ei voi perua tilausta sähköpostitse." email_not_allowed: "Näin käy, kun sähköpostiosoite ei ole sallittujen listalla tai on kiellettyjen listalla." @@ -135,9 +135,9 @@ fi: accepted: täytyy hyväksyä blank: ei voi olla tyhjä present: täytyy olla tyhjä - confirmation: ! "ei vastaa %{attribute}" + confirmation: ! "ei vastaa määritettä %{attribute}" empty: ei voi olla tyhjä - equal_to: täytyy olla sama kuin %{count} + equal_to: täytyy olla yhtä suuri kuin %{count} even: täytyy olla parillinen exclusion: on varattu greater_than: täytyy olla suurempi kuin %{count} @@ -149,7 +149,7 @@ fi: is_invalid_meaningful: "vaikuttaa epäselvältä, suurin osa sanoista sisältää samat kirjaimet uudestaan ja uudestaan?" is_invalid_unpretentious: "vaikuttaa epäselvältä, yksi tai useampi sana on hyvin pitkä?" is_invalid_quiet: "vaikuttaa epäselvältä, kirjoititko tarkoituksella KAIKKI ISOILLA KIRJAIMILLA?" - invalid_timezone: "'%{tz}' ei ole käypä aikavyöhyke" + invalid_timezone: "'%{tz}' ei ole kelvollinen aikavyöhyke" contains_censored_words: "sisältää nämä sensuroidut sanat: %{censored_words}" less_than: täytyy olla vähemmän kuin %{count} less_than_or_equal_to: täytyy olla yhtä suuri tai pienempi kuin %{count} @@ -157,12 +157,12 @@ fi: not_an_integer: täytyy olla kokonaisluku odd: täytyy olla pariton record_invalid: ! "Validointi epäonnistui: %{errors}" - max_emojis: "ei voi olla yli %{max_emojis_count}emojia" - emojis_disabled: "ei voi olla emojeita" + max_emojis: "ei voi sisältää yli %{max_emojis_count} emojia" + emojis_disabled: "ei voi sisältää emojeita" ip_address_already_screened: "sisältyy jo olemassa olevaan sääntöön" restrict_dependent_destroy: one: "Tietuetta ei voi poistaa, koska siitä riippuva tietue %{record} on olemassa" - many: "Tietueita ei voi poistaa, koska siitä riippuva tietue %{record} on olemassa." + many: "Tietuetta ei voi poistaa, koska siitä riippuva tietue %{record} on olemassa" too_long: one: on liian pitkä (enintään %{count} merkki) other: on liian pitkä (enintään %{count} merkkiä) @@ -170,15 +170,15 @@ fi: one: on liian lyhyt (vähintään %{count} merkki) other: on liian lyhyt (vähintään %{count} merkkiä) wrong_length: - one: on väärän mittainen (pitäisi olla %{count} merkki) - other: on väärän mittainen (pitäisi olla %{count} merkkiä) - other_than: "pitää olla muu kuin %{count}" - auth_overrides_username: "Käyttäjänimeä täytyy muokata SSO-tarjoajan päässä, sillä `sso_overrides_username` -asetus on kytketty." + one: on väärän pituinen (pitäisi olla %{count} merkki) + other: on väärän pituinen (pitäisi olla %{count} merkkiä) + other_than: "täytyy olla muu kuin %{count}" + auth_overrides_username: "Käyttäjätunnus täytyy päivittää todennuksen tarjoajan puolella, koska `auth_overrides_username`-asetus on käytössä." template: body: ! "Seuraavien kenttien kanssa oli ongelmia:" header: - one: "%{count} virhe esti tallentamasta tätä %{model}" - other: ! "%{count} virhettä esti tallentamasta tätä %{model}" + one: "%{count} virhe esti tallentamasta tätä kohdetta %{model}" + other: ! "%{count} virhettä esti tallentamasta tätä kohdetta %{model}" embed: load_from_remote: "Viestin lataamisessa tapahtui virhe." site_settings: @@ -186,48 +186,48 @@ fi: invalid_choice: one: "Määrittämäsi valinta ei kelpaa %{name}" other: "Määrittämäsi valinnat eivät kelpaa %{name}" - default_categories_already_selected: "Et voi valita aluetta, joka on käytössä toisella listalla" - default_tags_already_selected: "Et voi valita aluetta, joka on käytössä toisella listalla" - s3_upload_bucket_is_required: "Et voi ottaa s3 latausta käyttöön, jos et ole määrittänyt 's3_upload_bucket'." - enable_s3_uploads_is_required: "Et voi ottaa S3-inventorya käyttöön, jollei S3-lataukset ole käytössä." + default_categories_already_selected: "Et voi valita aluetta, joka on käytössä toisella listalla." + default_tags_already_selected: "Et voi valita tunnistetta, joka on käytössä toisella listalla." + s3_upload_bucket_is_required: "Et voi ottaa latauksia S3:een käyttöön, ellet ole määrittänyt 's3_upload_bucket'-määritettä." + enable_s3_uploads_is_required: "Et voi ottaa inventaariota S3:een käyttöön, ellet ole ottanut käyttöön latauksia S3:een." page_publishing_requirements: "Sivun julkaisua ei voida ottaa käyttöön, jos suojattu media on käytössä." - s3_backup_requires_s3_settings: "Et voi käyttää S3:a varmuuskopiosijaintina, jos %{setting_name} ei ole määritetty." + s3_backup_requires_s3_settings: "Et voi käyttää S3:a varmuuskopiosijaintina, ellei %{setting_name} ei ole määritetty." s3_bucket_reused: "Sama säiliö ei voi olla sekä 's3_upload_bucket' että 's3_backup_bucket'. Valitse eri säiliö tai määritä eri polku jokaiselle säiliölle." - secure_media_requirements: "S3-lataukset täytyy ottaa käyttöön, jotta voi ottaa käyttöön suojatut medialataukset." - share_quote_facebook_requirements: "Facebook-sovellustunnus on määritettävä jotta lainausten jakaminen Facebookiin on mahdollista." - second_factor_cannot_enforce_with_socials: "Et voi pakottaa 2FA-asetusta käyttöön kun yhteisökirjautuminen on aktiivisena. Poista yhteisökirjautuminen käytöstä täällä: %{auth_provider_names}" - second_factor_cannot_be_enforced_with_disabled_local_login: "Et voi pakottaa kaksivaiheista tunnistautumista, jos paikallinen kirjautuminen on pois käytöstä." + secure_media_requirements: "S3-lataukset täytyy ottaa käyttöön ennen suojatun median käyttöönottoa." + share_quote_facebook_requirements: "Facebook-sovellustunnus on määritettävä, jotta lainausten jakaminen Facebookiin on mahdollista." + second_factor_cannot_enforce_with_socials: "Et voi pakottaa 2FA-asetusta käyttöön, kun yhteisökirjautuminen on aktiivisena. Poista yhteisökirjautuminen käytöstä täällä: %{auth_provider_names}" + second_factor_cannot_be_enforced_with_disabled_local_login: "Et voi pakottaa kaksivaiheista tunnistusta, jos paikallinen kirjautuminen on pois käytöstä." second_factor_cannot_be_enforced_with_discourse_connect_enabled: "Et voi pakottaa 2FA-asetusta käyttöön kun DiscourseConnect on aktiivisena." - local_login_cannot_be_disabled_if_second_factor_enforced: "Et voi ottaa paikallista kirjautumista käytöstä, jos kaksivaiheinen tunnistautuminen on pakollinen. Kun otat kaksivaiheisen tunnistautumisen pakollisuuden käytöstä, voit ottaa käytöstä paikallisen kirjautumisen." - cannot_enable_s3_uploads_when_s3_enabled_globally: "Et voi ottaa S3-latauksia käyttöön, koska S3-lataukset ovat käytössä globaalisti. Päälle kytkeminen sivustonlaajuisesti voisi aiheuttaa kriittisiä latauksiin liittyviä ongelmia" + local_login_cannot_be_disabled_if_second_factor_enforced: "Et voi poistaa paikallista kirjautumista käytöstä, jos kaksivaiheinen tunnistus on pakollinen. Kun poistat kaksivaiheisen tunnistuksen pakollisuuden, voit ottaa käyttöön paikallisen kirjautumisen." + cannot_enable_s3_uploads_when_s3_enabled_globally: "Et voi ottaa S3-latauksia käyttöön, koska S3-lataukset ovat käytössä globaalisti, ja käyttöönotto sivustonlaajuisesti voi aiheuttaa kriittisiä latauksiin liittyviä ongelmia" cors_origins_should_not_have_trailing_slash: "CORS-poluissa ei tulisi olla kauttaviivaa (/) lopussa." - conflicting_google_user_id: 'Tämän käyttäjätilin Google Account ID on muuttunut. Henkilökunnan toimenpiteet ovat tarpeen tietoturvasyistä. Ota yhteyttä henkilökuntaan ja ohjaa heidät osoitteeseen
      https://meta.discourse.org/t/76575' + conflicting_google_user_id: 'Tämän tilin Google-tilitunnus on muuttunut. Henkilökunnan toimenpiteet ovat tarpeen tietoturvasyistä. Ota yhteyttä henkilökuntaan ja ohjaa heidät osoitteeseen
      https://meta.discourse.org/t/76575' onebox: invalid_address: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska palvelinta '%{hostname}' ei löydy. Esikatselun sijaan viestissä näkyy vain linkki. :cry:" error_response: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska palvelin palautti virhekoodin %{status_code}. Esikatselun sijaan viestissä näkyy vain linkki. :cry:" missing_data: - one: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska seuraavaa oEmbed / OpenGraph-tunnistetta ei löytynyt: %{missing_attributes}" - other: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska seuraavia oEmbed / OpenGraph-tunnisteita ei löytynyt: %{missing_attributes}" + one: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska seuraavaa oEmbed-/OpenGraph-tunnistetta ei löytynyt: %{missing_attributes}" + other: "Valitettavasti emme voineet luoda esikatselua tälle verkkosivulle, koska seuraavia oEmbed-/OpenGraph-tunnisteita ei löytynyt: %{missing_attributes}" word_connector: comma: ", " invite: - expired: "Kutsun tunniste on vanhentunut. Ota yhteys henkilökuntaan." - not_found: "Kutsusi tunnistusväline ei kelpaa. Ota yhteyttä henkilökuntaan." - not_found_json: "Kutsusi tunnistusväline ei kelpaa. Ota yhteyttä henkilökuntaan." + expired: "Kutsun tunniste on vanhentunut. Ota yhteyttä henkilökuntaan." + not_found: "Kutsun tunniste ei kelpaa. Ota yhteyttä henkilökuntaan." + not_found_json: "Kutsun tunniste ei kelpaa. Ota yhteyttä henkilökuntaan." not_matching_email: "Sähköpostiosoitteesi ja kutsun tunnisteeseen liitetty sähköpostiosoite eivät täsmää. Ota yhteyttä henkilökuntaan." not_found_template: |

      Kutsusi sivustolle%{site_name} on jo käytetty.

      -

      Jos muistat salasanasi voit kirjautua sisään.

      +

      Jos muistat salasanasi, voit kirjautua sisään.

      Muussa tapauksessa vaihda salasanasi.

      not_found_template_link: | -

      Kutsua sivustolle %{site_name} ei voida enää lunastaa. Pyydä henkilöä, joka kutsui sinut lähettämään sinulle uusi kutsu.

      - user_exists: "Ei ole tarpeen lähettää kutsua osoitteeseen %{email}, sillä kutsuttavalla on jo tunnus!" +

      Kutsua sivustolle %{site_name} ei voida enää käyttää. Pyydä henkilöä, joka kutsui sinut, lähettämään sinulle uusi kutsu.

      + user_exists: "Osoitteeseen %{email} ei tarvitse lähettää kutsua, sillä kutsuttavalla on jo tili!" invite_exists: "Olet jo lähettänyt kutsun osoitteeseen %{email}." invalid_email: "%{email} ei ole kelvollinen sähköpostiosoite." confirm_email: "

      Melkein valmista! Lähetimme aktivointiviestin sähköpostiosoitteeseesi. Aktivoi tilisi noudattamalla sähköpostissa kerrottuja ohjeita.

      Jos viesti ei saavu, tarkistathan roskapostikansiosi.

      " - cant_invite_to_group: "Sinulla ei ole oikeuksia kutsua käyttäjiä määriteltyihin ryhmiin. Varmista, että olet sen ryhmän/ryhmien omistaja, joihin yrität kutsua." + cant_invite_to_group: "Sinulla ei ole oikeuksia kutsua käyttäjiä määriteltyihin ryhmiin. Varmista, että olet niiden ryhmien omistaja, joihin yrität kutsua." disabled_errors: discourse_connect_enabled: "Kutsut on poistettu käytöstä, koska DiscourseConnect on aktiivinen." invalid_access: "Sinulla ei ole oikeutta nähdä pyydettyä resurssia." @@ -236,63 +236,63 @@ fi: max_rows: "Ensimmäiset %{max_bulk_invites} kutsua lähetettiin. Jaa tiedosto pienempiin osiin." error: "Tiedoston lataus epäonnistui. Yritä myöhemmin uudelleen." invite_link: - email_taken: "Tämä sähköpostiosoite on jo käytössä. Jos sinulla on jo tili, kirjaudu sisään tai vaihda salasana." - max_redemptions_limit: "tulisi olla välillä 2 ja %{max_limit}." + email_taken: "Tämä sähköpostiosoite on jo käytössä. Jos sinulla on jo tili, kirjaudu sisään tai palauta salasana." + max_redemptions_limit: "tulisi olla välillä 2–%{max_limit}." topic_invite: failed_to_invite: "Käyttäjää ei voi kutsua ketjuun, jollei hän ole jonkun näistä ryhmistä jäsen: %{group_names}." user_exists: "Pahoittelut, tämä käyttäjä on jo kutsuttu. Voit kutsua toisen käyttäjän ketjuun vain yhden kerran." - muted_invitee: "Valitettavasti tämä käyttäjä on hiljentänyt sinut." - muted_topic: "Valitettavasti käyttäjä on hiljentänyt tämän ketjun." - receiver_does_not_allow_pm: "Valitettavasti tämä käyttäjä ei salli sinun lähettää hänelle yksityisiä viestejä." - sender_does_not_allow_pm: "Valitettavasti et salli käyttäjän lähettää sinulle yksityisiä viestejä." + muted_invitee: "Valitettavasti tämä käyttäjä on vaimentanut sinut." + muted_topic: "Valitettavasti käyttäjä on vaimentanut tämän ketjun." + receiver_does_not_allow_pm: "Tämä käyttäjä ei salli sinun lähettää hänelle yksityisviestejä." + sender_does_not_allow_pm: "Et salli tämän käyttäjän lähettää sinulle yksityisviestejä." user_cannot_see_topic: "%{username} ei näe tätä ketjua." backup: operation_already_running: "Tehtävä on parhaillaan käynnissä. Uutta tehtävää ei voi aloittaa juuri nyt." backup_file_should_be_tar_gz: "Varmuuskopion tulisi olla .tar.gz-pakattu tiedosto." - not_enough_space_on_disk: "Ei tarpeeksi levytilaa." + not_enough_space_on_disk: "Levyllä ei ole tarpeeksi tilaa tämän varmuuskopion lataamiseen." invalid_filename: "Varmuuskopion nimi sisältää ei-sallittuja merkkejä. Sallittuja ovat a-z 0-9 . - _." - file_exists: "Tiedosto jota yrität lähettää on jo olemassa." - invalid_params: "Vastasit pyyntöön parametreilla, jotka eivät kelpaa: %{message}" + file_exists: "Tiedosto, jota yrität ladata, on jo olemassa." + invalid_params: "Annoit virheelliset parametrit pyyntöön: %{message}" not_logged_in: "Sinun täytyy kirjautua sisään ensin." - not_found: "Pyydettyä osoitetta tai resurssia ei löytynyt." + not_found: "Pyydettyä URL-osoitetta tai resurssia ei löytynyt." invalid_access: "Sinulla ei ole oikeutta nähdä pyydettyä resurssia." - authenticator_not_found: "Autentikointimenetelmää ei ole olemassa tai se on poistettu käytöstä." - invalid_api_credentials: "Et voi katsella pyytämääsi resurssia. Rajapinnan käyttäjänimi tai avain ei kelpaa." - provider_not_enabled: "Et saa katsella pyydettyä resurssia. Autentikoinnintarjoajaa ei ole otettu käyttöön." - provider_not_found: "Et saa katsella pyydettyä resurssia. Autentikoinnintarjoajaa ei ole olemassa." - read_only_mode_enabled: "Sivusto on vain luku-tilassa. Vuorovaikutteiset toiminnot ovat poissa käytöstä." - invalid_grant_badge_reason_link: "Ulkoiset linkit ja epäkelvot Discourse-linkit eivät kelpaa ansiomerkin syyksi" - email_template_cant_be_modified: "Tätä sähköpostipohjaa ei voi muokata" - invalid_whisper_access: "Joko kuiskaukset eivät ole käytössä tai sinulla ei ole oikeutta kuiskata." + authenticator_not_found: "Todennusmenetelmää ei ole olemassa tai se on poistettu käytöstä." + invalid_api_credentials: "Et voi katsella pyytämääsi resurssia. API-käyttäjätunnus tai avain ei kelpaa." + provider_not_enabled: "Et voi katsella pyydettyä resurssia. Todennuksen tarjoajaa ei ole otettu käyttöön." + provider_not_found: "Et voi katsella pyydettyä resurssia. Todennuksen tarjoajaa ei ole olemassa." + read_only_mode_enabled: "Sivusto on vain luku -tilassa. Vuorovaikutteiset toiminnot ovat poissa käytöstä." + invalid_grant_badge_reason_link: "Ulkoiset tai virheelliset Discourse-linkit eivät kelpaa kunniamerkin syyssä" + email_template_cant_be_modified: "Tätä sähköpostimallia ei voi muokata" + invalid_whisper_access: "Kuiskaukset eivät ole käytössä tai sinulla ei ole oikeutta luoda kuiskausviestejä." not_in_group: title_topic: "Ketjun lukeminen vaatii jäsenyyden ryhmässä '%{group}'." request_membership: "Hae jäsenyyttä" join_group: "Liity ryhmään" - deleted_topic: "Ups! Ketju on poistettu eikä ole enää saatavilla." + deleted_topic: "Ketju on poistettu eikä ole enää saatavilla." delete_topic_failed: "Ketjun poistaminen epäonnistui. Ota yhteyttä sivuston ylläpitoon." reading_time: "Lukuaika" likes: "Tykkäykset" too_many_replies: - one: "Pahoittelut, uudet käyttäjät voivat kirjoittaa yhden vastauksen samaan ketjuun." - other: "Pahoittelut, uudet käyttäjät voivat kirjoittaa %{count} vastausta samaan ketjuun." + one: "Uudet käyttäjät voivat tilapäisesti kirjoittaa vain %{count} vastauksen samaan ketjuun." + other: "Uudet käyttäjät voivat tilapäisesti kirjoittaa vain %{count} vastausta samaan ketjuun." max_consecutive_replies: - one: "Perättäiset vastaukset on kielletty. Muokkaa edellistä vastaustasi tai odota että joku vastaa sinulle." - other: "Voi vastata enintään %{count} kertaa perättäin. Muokkaa edellistä vastaustasi tai odota että joku vastaa sinulle." + one: "Perättäiset vastaukset on kielletty. Muokkaa edellistä vastaustasi tai odota, että joku vastaa sinulle." + other: "Voit vastata enintään %{count} kertaa perättäin. Muokkaa edellistä vastaustasi tai odota, että joku vastaa sinulle." embed: - start_discussion: "Lisää kommentti" - continue: "Siirry keskusteluun" + start_discussion: "Aloita keskustelu" + continue: "Jatka keskustelua" error: "Virhe upotettaessa" - referer: "Referer-viite:" - error_topics: "`Embed topics list` -sivustoasetus ei ole käytössä" - mismatch: "Referer-viitettä ei lähetetty tai se ei täsmää mihinkään näistä isännistä:" + referer: "Viittaaja:" + error_topics: "Upota kerjuluettelo -sivustoasetus ei ole käytössä" + mismatch: "Viittaajaa ei lähetetty tai se ei täsmää mihinkään näistä isännistä:" no_hosts: "Isäntiä ei ole määritetty upotusta varten." configure: "Määritä upottaminen" more_replies: one: "%{count} muu kommentti" other: "%{count} muuta kommenttia" - loading: "Haetaan kommentteja..." - permalink: "Ikilinkki" - imported_from: "Tämä on kommenttiketju artikkelille %{link}" + loading: "Ladataan keskustelua ..." + permalink: "Pysyvä linkki" + imported_from: "Tämä on oheiskeskusteluketju alkuperäiselle viestille osoitteessa %{link}" in_reply_to: "▶ %{username}" replies: one: "%{count} vastaus" @@ -303,40 +303,40 @@ fi: last_reply: "Viimeisin vastaus" created: "Luotu" new_topic: "Aloita uusi ketju" - no_mentions_allowed: "Pahoittelut, et voi mainita muita käyttäjiä." + no_mentions_allowed: "Et voi mainita muita käyttäjiä." too_many_mentions: - one: "Pahoittelut, voit mainita viestissä vain yhden käyttäjän." - other: "Pahoittelut, voit mainita viestissä vain %{count} käyttäjää." - no_mentions_allowed_newuser: "Pahoittelut, uusi käyttäjä ei voi mainita muita käyttäjiä." + one: "Voit mainita viestissä vain yhden muun käyttäjän." + other: "Voit mainita viestissä vain %{count} käyttäjää." + no_mentions_allowed_newuser: "Uudet käyttäjät eivät voi mainita muita käyttäjiä." too_many_mentions_newuser: - one: "Pahoittelut, uusi käyttäjä voi mainita viestissä vain yhden käyttäjän." - other: "Pahoittelut, uusi käyttäjä voi mainita viestissä vain %{count} käyttäjää." - no_embedded_media_allowed_trust: "Valitettavasti et saa upottaa mediaobjekteja viesteihin." - no_embedded_media_allowed: "Valitettavasti uudet käyttäjät eivät saa upottaa mediaobjekteja viesteihin." + one: "Uudet käyttäjät voivat mainita viestissä vain yhden muun käyttäjän." + other: "Uudet käyttäjät voivat mainita viestissä vain %{count} käyttäjää." + no_embedded_media_allowed_trust: "Et voi upottaa mediaobjekteja viesteihin." + no_embedded_media_allowed: "Uudet käyttäjät eivät voi upottaa mediaobjekteja viesteihin." too_many_embedded_media: - one: "Valitettavasti uudet käyttäjät voivat upottaa vain yhden mediaobjektin viesteihin." - other: "Valitettavasti uudet käyttäjät voivat upottaa vain %{count} mediaobjektia viesteihin." - no_attachments_allowed: "Pahoittelut, uusi käyttäjä ei voi liittää liitteitä viesteihin." + one: "Uudet käyttäjät voivat upottaa vain yhden mediaobjektin viesteihin." + other: "Uudet käyttäjät voivat upottaa vain %{count} mediaobjektia viesteihin." + no_attachments_allowed: "Uudet käyttäjät eivät voi liittää liitteitä viesteihin." too_many_attachments: - one: "Pahoittelut, uusi käyttäjä voi liittää vain yhden liitteen viestiin." - other: "Pahoittelut, uusi käyttäjä voi liittää vain %{count} liittettä viestiin." - no_links_allowed: "Pahoittelut, uusi käyttäjä ei voi laittaa linkkiä viestiin." - links_require_trust: "Pahoittelut, et voi laittaa linkkejä viesteihin" + one: "Uudet käyttäjät voivat liittää vain yhden liitteen viestiin." + other: "Uudet käyttäjätvat liittää vain %{count} liitettä viestiin." + no_links_allowed: "Uudet käyttäjät eivät voi lisätä linkkejä viesteihin." + links_require_trust: "Et voi voi lisätä linkkejä viesteihin." too_many_links: - one: "Pahoittelut, uudet käyttäjät voivat laittaa vain yhden linkin viestiin." - other: "Pahoittelut, uusi käyttäjä voi laittaa vain %{count} linkkiä viestiin." + one: "Uudet käyttäjät voivat lisätä vain yhden linkin viestiin." + other: "Uudet käyttäjät voivat lisätä vain %{count} linkkiä viestiin." contains_blocked_word: "Viestissäsi on kielletty sana: %{word}" contains_blocked_words: "Viestissäsi on kiellettyjä sanoja: %{words}" - spamming_host: "Pahoittelut, linkit tuolle sivulle eivät ole sallittuja." + spamming_host: "Et voi lisätä linkkiä tähän isäntään." user_is_suspended: "Hyllytetyt käyttäjät eivät saa luoda viestejä." - topic_not_found: "Jotain on mennyt pieleen. Ehkä tämä ketju on suljettu tai poistettu sillä välin, kun katselit sitä?" - not_accepting_pms: "Pahoittelut, %{username} ei ota vastaan yksityisviestejä tällä hetkellä." - max_pm_recipients: "Pahoittelut, voit lähettää viestin enintään %{recipients_limit} vastaanottajalle." - pm_reached_recipients_limit: "Pahoittelut, yksityisviestillä ei voi olla yli %{recipients_limit} vastaanottajaa." - removed_direct_reply_full_quotes: "Jos edellinen viesti lainataan kokonaan, poista lainaus automaattisesti." + topic_not_found: "Jokin on mennyt pieleen. Ehkä tämä ketju on suljettu tai poistettu sillä välin, kun katselit sitä?" + not_accepting_pms: "%{username} ei ota vastaan yksityisviestejä tällä hetkellä." + max_pm_recipients: "Voit lähettää viestin enintään %{recipients_limit} vastaanottajalle." + pm_reached_recipients_limit: "Yksityisviestillä ei voi olla yli %{recipients_limit} vastaanottajaa." + removed_direct_reply_full_quotes: "Automaattisesti poistettu koko edellisen viestin lainaus." watched_words_auto_tag: "Automaattisesti merkitty ketju" - secure_upload_not_allowed_in_public_topic: "Pahoittelut, näitä suojattuja latauksia ei voi käyttää julkisessa ketjussa: %{upload_filenames}" - create_pm_on_existing_topic: "Valitettavasti et voi luoda yksityisviestiä olemassa olevasta ketjusta." + secure_upload_not_allowed_in_public_topic: "Näitä suojattuja latauksia ei voi käyttää julkisessa ketjussa: %{upload_filenames}" + create_pm_on_existing_topic: "Et voi luoda yksityisviestiä olemassa olevasta ketjusta." slow_mode_enabled: "Tämä ketju on hidastetussa tilassa." just_posted_that: "on liian samanlainen kuin aiempi viestisi" invalid_characters: "sisältää epäkelpoja merkkejä" @@ -344,10 +344,10 @@ fi: next_page: "seuraava sivu →" prev_page: "← edellinen sivu" page_num: "Sivu %{num}" - home_title: "Koti" + home_title: "Aloitus" topics_in_category: "Ketjut alueella '%{category}'" - rss_posts_in_topic: "ketjun '%{topic}' RSS syöte" - rss_topics_in_category: "RSS syötteet alueen '%{category}' ketjuista" + rss_posts_in_topic: "ketjun '%{topic}' RSS-syöte" + rss_topics_in_category: "RSS-syötteet alueen '%{category}' ketjuista" rss_num_posts: one: "%{count} viesti" other: "%{count} viestiä" @@ -357,7 +357,7 @@ fi: read_full_topic: "Lue koko ketju" private_message_abbrev: "YV" rss_description: - latest: "Tuoreimmat viestiketjut" + latest: "Tuoreimmat ketjut" top: "Suositut ketjut" top_all: "Kaikkien aikojen parhaat ketjut" top_yearly: "Vuoden parhaat ketjut" @@ -366,27 +366,27 @@ fi: top_weekly: "Viikon parhaat ketjut" top_daily: "Päivän parhaat ketjut" posts: "Uusimmat viestit" - private_posts: "Tuoreimmat yksityisviestit" + private_posts: "Uusimmat yksityisviestit" group_posts: "Uusimmat viestit ryhmässä %{group_name}" group_mentions: "Uusimmat maininnat ryhmässä %{group_name}" user_posts: "Viimeisimmät viestit käyttäjältä @%{username}" user_topics: "Viimeisimmät ketjut käyttäjältä @%{username}" tag: "Tunnisteelliset ketjut" - badge: "%{display_name} -ansiomerkki sivustolla %{site_title}" + badge: "Kunniamerkki %{display_name} sivustolla %{site_title}" too_late_to_edit: "Tämä viesti luotiin liian kauan sitten. Sitä ei voi enää muokata tai poistaa." - edit_conflict: "Toinen käyttäjä muokkasi viestiä, minkä vuoksi sinun tekemääsi muutosta ei voi enää tallentaa." - revert_version_same: "Nykyinen revisio on sama, kuin jonka yrität palauttaa." + edit_conflict: "Toinen käyttäjä muokkasi viestiä, minkä vuoksi sinun tekemiäsi muutoksia ei voi enää tallentaa." + revert_version_same: "Nykyinen versio on sama kuin versio, jonka yrität palauttaa." cannot_edit_on_slow_mode: "Tämä aihe on hidastetussa tilassa. Huolellisen, harkitun keskustelun kannustamiseksi vanhojen viestien muokkaaminen tässä aiheessa ei ole tällä hetkellä sallittua." excerpt_image: "kuva" bookmarks: errors: already_bookmarked_post: "Samaa viestiä ei voi kirjanmerkitä kahdesti." - too_many: "Valitettavasti et voi lisätä enempää kuin %{limit} kirjanmerkkiä. Voit poistaa kirjanmerkkejä %{user_bookmarks_url} kautta." + too_many: "Et voi lisätä enempää kuin %{limit} kirjanmerkkiä. Voit poistaa kirjanmerkkejä kohdassa %{user_bookmarks_url}." cannot_set_past_reminder: "Kirjanmerkkimuistutusta ei voi asettaa menneisyyteen." cannot_set_reminder_in_distant_future: "Kirjanmerkkimuistutusta ei voi asettaa yli 10 vuoden päähän tulevaisuuteen." - time_must_be_provided: "Kaikkiin muistutuksiin on asetettava kellonaika." + time_must_be_provided: "kaikkiin muistutuksiin on asetettava kellonaika" reminders: - at_desktop: "Ensi kerralla kun olen työpöytäympäristössäni" + at_desktop: "Ensi kerralla kun olen työpöytälaitteellani" later_today: "Myöhemmin tänään" next_business_day: "Seuraavana arkipäivänä" tomorrow: "Huomenna" @@ -404,8 +404,8 @@ fi: member_already_exist: one: "'%{username}' on jo ryhmän jäsen." other: "Nämä käyttäjät ovat jo ryhmän jäseniä: %{username}" - invalid_domain: "'%{domain}' ei ole käypä verkkotunnus." - invalid_incoming_email: "'%{email}' ei ole käypä sähköpostiosoite." + invalid_domain: "'%{domain}' ei ole kelvollinen verkkotunnus." + invalid_incoming_email: "'%{email}' ei ole kelvollinen sähköpostiosoite." email_already_used_in_group: "'%{email}' on jo käytössä ryhmällä '%{group_name}'." email_already_used_in_category: "'%{email}' on jo käytössä alueella '%{category_name}'." cant_allow_membership_requests: "Et voi sallia ryhmäjäsenyyden hakemista, jos ryhmällä ei ole isäntää." @@ -417,7 +417,7 @@ fi: no_invites_without_local_logins: "Voit kutsua vain rekisteröityneitä käyttäjiä, kun paikalliset kirjautumistiedot on poistettu käytöstä" default_names: everyone: "kaikki" - admins: "yllapitajat" + admins: "ylläpitäjät" moderators: "valvojat" staff: "henkilokunta" trust_level_0: "luottamustaso_0" @@ -430,29 +430,29 @@ fi: request_accepted_pm: title: "Sinut hyväksyttiin ryhmään @%{group_name}" body: | - Pyyntösi ryhmään @%{group_name} liittymiseksi on hyväksytty ja olet nyt jäsen. + Pyyntösi ryhmään @%{group_name} liittymiseksi on hyväksytty, ja olet nyt jäsen. education: until_posts: one: "ensimmäisen %{count} viestin" other: "ensimmäisen %{count} viestin" "new-topic": | - Tervetuloa, tämä on %{site_name} — **kiitos uuden viestiketjun aloittamisesta!** + Tervetuloa, tämä on %{site_name} — **kiitos uuden keskustelun aloittamisesta!** - - Kuulostaako otsikko mielenkiintoiselta jos luet sen ääneen? Kuvaako se ketjua hyvin? + – Kuulostaako otsikko mielenkiintoiselta, jos luet sen ääneen? Kuvaako se ketjua hyvin? - - Ketä tämä ketju voisi kiinnostaa? Mikä on sen tarkoitus? Minkälaisia vastauksia haluaisit? + – Ketä tämä ketju voisi kiinnostaa? Mikä on sen tarkoitus? Minkälaisia vastauksia haluaisit? - - Sisällytä tekstiin yleisesti käytettyjä sanoja, jotta muut voivat *löytää* sen. Voit ryhmitellä ketjun vastaavien viestiketjujen kanssa valitsemalla sille sopivan keskustelualueen (tai tunnisteen) + – Sisällytä tekstiin yleisesti käytettyjä sanoja, jotta muut voivat *löytää* sen. Voit ryhmitellä ketjun vastaavien viestiketjujen kanssa valitsemalla sille sopivan keskustelualueen (tai tunnisteen) Lue lisää vinkkejä [ohjeista](%{base_path}/guidelines). Tämä ohjepaneeli näytetään vain %{education_posts_text} ajan. "new-reply": | Tervetuloa, tämä on %{site_name} — **kiitos keskusteluun osallistumisesta!** - - Ole ystävällinen muille käyttäjille. + – Ole ystävällinen muille käyttäjille. - - Parantaako vastauksesi keskustelua jollain tapaa? + – Parantaako vastauksesi keskustelua jollain tapaa? - - Rakentava palaute on tervetullutta, mutta kritisoi *ideoita*, älä ihmisiä. + – Rakentava palaute on tervetullutta, mutta kritisoi *ideoita*, älä ihmisiä. Lue lisää vinkkejä [ohjeista](%{base_path}/guidelines). Tämä ohjepaneeli näytetään vain %{education_posts_text} ajan. avatar: | @@ -474,7 +474,7 @@ fi: dominating_topic: | ### Anna muidenkin osallistua keskusteluun - Tämä ketju on selvästikin tärkeä sinulle – olet kirjoittanut yli %{percent}% vastauksista. + Tämä ketju on selvästikin tärkeä sinulle – olet kirjoittanut yli %{percent} % vastauksista. Ketju voisi olla vieläkin parempi, jos useammilla olisi tilaa jakaa omia näkökulmiaan. Voisit kutsua heitä tänne? get_a_room: | @@ -508,7 +508,7 @@ fi: post: raw: "Leipäteksti" user_profile: - bio_raw: "Minusta" + bio_raw: "Tietoa minusta" errors: models: topic: @@ -516,16 +516,16 @@ fi: base: warning_requires_pm: "Voit liittää varoituksia vain yksityisviesteihin." too_many_users: "Voit lähettää varoituksia vain yhdelle käyttäjälle kerrallaan." - cant_send_pm: "Pahoittelut, et voi lähettää yksityisviestiä tällä käyttäjälle." - no_user_selected: "Sinun täytyy valita kelpaava käyttäjä." + cant_send_pm: "Et voi lähettää yksityisviestiä tälle käyttäjälle." + no_user_selected: "Sinun täytyy valita kelvollinen käyttäjä." reply_by_email_disabled: "Sähköpostilla vastaaminen ei ole käytössä." - send_to_email_disabled: "Valitettavasti et voi lähettää yksityisviestejä sähköpostiin." - target_user_not_found: "Ei löydetty yhtä niistä käyttäjistä, joille lähetät viestiä." + send_to_email_disabled: "Et voi lähettää yksityisviestejä sähköpostiin." + target_user_not_found: "Yhtä käyttäjää, jolle lähetät tämän viestin, ei löydy." unable_to_update: "Ketjun päivittäminen epäonnistui." unable_to_tag: "Tunnisteen asettaminen ketjuun epäonnistui." featured_link: - invalid: "ei ole käypä. URL:in tulisi sisältää http:// tai https://." - invalid_category: "ei voi muokata tällä alueella." + invalid: "ei ole kelvollinen. URL-osoitteen tulisi sisältää http:// tai https://." + invalid_category: "ei ole muokattavissa tällä alueella." user: attributes: password: @@ -534,17 +534,17 @@ fi: same_as_email: "on sama kuin sähköpostiosoitteesi. Valitse turvallisempi salasana." same_as_current: "on sama kuin nykyinen salasanasi." same_as_name: "on sama kuin nimesi." - unique_characters: "on liikaa toistuvia merkkejä. Valitse turvallisempi salasana." + unique_characters: "sisältää liikaa toistuvia merkkejä. Valitse turvallisempi salasana." username: same_as_password: "on sama kuin salasanasi." name: same_as_password: "on sama kuin salasanasi." ip_address: - signup_not_allowed: "Liittyminen ei ole sallittu tälle tilille." + signup_not_allowed: "Rekisteröityminen ei ole sallittu tälle tilille." user_profile: attributes: featured_topic_id: - invalid: "Tätä ketjua et voi valikoida profiiliisi." + invalid: "Tätä ketjua ei voi valikoida profiiliisi." user_email: attributes: user_id: @@ -552,14 +552,14 @@ fi: color_scheme_color: attributes: hex: - invalid: "ei ole sallittu väri" + invalid: "ei ole kelvollinen väri" post_reply: base: - different_topic: "Viestin ja siihen liittyvän vastauksen tulee olla samassa ketjussa." + different_topic: "Viestin ja siihen liittyvän vastauksen täytyy olla samassa ketjussa." web_hook: attributes: payload_url: - invalid: "URL ei kelpaa- URL:in tulee sisältää http:// tai https://. Välilyöntejä ei saa olla." + invalid: "URL ei kelpaa. URL-osoitteen täytyy sisältää http:// tai https://. Välilyöntejä ei sallita." custom_emoji: attributes: name: @@ -569,7 +569,7 @@ fi: execute_at: in_the_past: "täytyy olla tulevaisuudessa." duration_minutes: - cannot_be_zero: "on oltava suurempi kuin 0." + cannot_be_zero: "täytyy olla suurempi kuin 0." exceeds_maximum: "voi olla enintään 20 vuotta." translation_overrides: attributes: @@ -580,10 +580,10 @@ fi: word: too_many: "Toiminnolle on määritelty liian monta sanaa" <<: *errors - uncategorized_category_name: "Kategorisoimattomat" + uncategorized_category_name: "Luokittelematon" vip_category_name: "Lounge" vip_category_description: "Alue luottamustason 3 ja ylemmille käyttäjille." - meta_category_name: "Palaute" + meta_category_name: "Sivuston palaute" meta_category_description: "Keskustelua tästä sivustosta, sen järjestämisestä, siitä miten se toimii ja miten sitä voisi parantaa." staff_category_name: "Henkilökunta" staff_category_description: "Yksityinen alue henkilökunnan keskusteluille. Ketjut näkyvät ainoastaan ylläpitäjille ja valvojille." @@ -595,14 +595,14 @@ fi: **Muokkaa tästä** tiivis kuvaus yhteisöstäsi: - - Kenelle se on suunnattu? - - Mistä täällä keskustellaan? - - Miksi heidän kannattaa käydä täällä? - - Mistä he löytävät lisätietoa (linkkejä, ohjeita jne.)? + – Kenelle se on suunnattu? + – Mistä täällä keskustellaan? + – Miksi heidän kannattaa käydä täällä? + – Mistä he löytävät lisätietoa (linkkejä, ohjeita jne.)? - Saattaa olla hyvä sulkea tämä ketju ylläpitäjän :wrench: -valikon kautta (oikealla ylhäällä sekä sivun pohjalla), jottei vastauksia kasaannu ilmoituksen alle. + Saattaa olla hyvä sulkea tämä ketju ylläpitäjän :wrench: -valikon kautta (oikealla ylhäällä sekä sivun alaosassa), jottei vastauksia kasaannu ilmoituksen alle. lounge_welcome: title: "Tervetuloa Loungeen" body: |2 @@ -628,33 +628,33 @@ fi: [trust]: https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/ admin_quick_start_title: "LUE ENSIN: Ylläpitäjän pika-aloitusopas" category: - topic_prefix: "Alueesta %{category}" + topic_prefix: "Tietoa alueesta %{category}" replace_paragraph: "(Korvaa tämä kappale lyhyellä kuvauksella uudesta alueesta. Tämä kuvaus näytetään alueen valinnan yhteydessä, joten yritä pitää se alle 200 merkin pituisena.)" - post_template: "%{replace_paragraph}\n\nKäytä seuraavat kappaleet pidempään kuvaukseen tai selvittääksesi alueen säännöt ja ohjeet:\n\n- Miksi käyttäjät valitsisivat tämän alueen? Mitä varten se on?\n\n- Kuinka se tarkkaan ottaen eroaa muista olemassa olevista alueista?\n\n- Minkälaista sisältöä alueen ketjuissa tulisi yleensä olla?\n\n- Tarvitaanko tätä aluetta? Voisiko sen yhdistää toiseen alueeseen tai siirtää toisen alueen alle?\n" + post_template: "%{replace_paragraph}\n\nKäytä seuraavat kappaleet pidempään kuvaukseen tai selvittääksesi alueen säännöt ja ohjeet:\n\n– Miksi käyttäjät valitsisivat tämän alueen? Mitä varten se on?\n\n–Kuinka se tarkkaan ottaen eroaa muista olemassa olevista alueista?\n\n– Minkälaista sisältöä alueen ketjuissa tulisi yleensä olla?\n\n– Tarvitaanko tätä aluetta? Voisiko sen yhdistää toiseen alueeseen tai siirtää toisen alueen alle?\n" errors: not_found: "Aluetta ei löytynyt!" - uncategorized_parent: "Alueettomilla ei voi olla emoaluetta" - self_parent: "Alue ei voi olla itsensä emoalue" - depth: "Alue ei voi olla tytäralueen tytäralue" - invalid_email_in: "'%{email}' ei ole käypä sähköpostiosoite" + uncategorized_parent: "Alueettomilla ei voi olla ylätason aluetta" + self_parent: "Alue ei voi olla itsensä ylätason talue" + depth: "Ala-aluetta voi asettaa toisen ala-alueen alle" + invalid_email_in: "'%{email}' ei ole kelvollinen sähköpostiosoite" email_already_used_in_group: "'%{email}' on jo käytössä ryhmällä '%{group_name}'." email_already_used_in_category: "'%{email}' on jo käytössä alueella '%{category_name}'." description_incomplete: "Alueen kuvauksessa on oltava ainakin yksi kappale." - permission_conflict: "Ryhmällä jolle myönnetään pääsy tytäralueelle, on oltava pääsy emoalueelle. Näillä ryhmillä on pääsy jollekin tytäralueelle, muttei emoalueelle: %{group_names}." + permission_conflict: "Ryhmällä, jolle myönnetään pääsy ala-alueelle, on oltava pääsy ylätason alueelle. Näillä ryhmillä on pääsy jollekin ala-alueelle, muttei ylätason alueelle: %{group_names}." disallowed_topic_tags: "Ketjulla on tunnisteita, jotka eivät ole sallittuja tällä alueella: '%{tags}'" disallowed_tags_generic: "Tällä ketjulla on kiellettyjä tunnisteita." cannot_delete: uncategorized: "Tämä alue on erityinen. Se on tarkoitettu säilytyspaikaksi ketjuille, joilla ei ole aluetta; sitä ei voi poistaa." - has_subcategories: "Aluetta ei voi poistaa, koska sillä on tytäralueita." + has_subcategories: "Aluetta ei voi poistaa, koska sillä on ala-alueita." topic_exists: one: "Aluetta ei voi poistaa, koska siellä on %{count} ketju. Vanhin ketju on %{topic_link}." other: "Aluetta ei voi poistaa, koska siellä on %{count} ketjua. Vanhin ketju on %{topic_link}." - topic_exists_no_oldest: "Aluetta ei voi poistaa, koska ketjujen lukumäärä on #{count}." + topic_exists_no_oldest: "Aluetta ei voi poistaa, koska ketjujen lukumäärä on %{count}." uncategorized_description: "Ketjut, jotka eivät tarvitse aluetta tai eivät sovi muihin alueisiin." trust_levels: admin: "Ylläpito" staff: "Henkilökunta" - change_failed_explanation: "Yritit alentaa käyttäjän %{user_name} luottamustasolle '%{new_trust_level}'. Käyttäjän luottamustaso on jo valmiiksi '%{current_trust_level}'. %{user_name} jatkaa edelleen luottamustasolla '%{current_trust_level}' - jos haluat alentaa luottamustasoa, lukitse se ensin" + change_failed_explanation: "Yritit alentaa käyttäjän %{user_name} luottamustasolle '%{new_trust_level}'. Käyttäjän luottamustaso on jo valmiiksi '%{current_trust_level}'. %{user_name} jatkaa edelleen luottamustasolla '%{current_trust_level}' – jos haluat alentaa luottamustasoa, lukitse se ensin" post: image_placeholder: broken: "Tämä kuva ei toimi" @@ -670,15 +670,15 @@ fi: create_topic: "Olet luomassa aiheita liian nopeasti. Odota %{time_left} ja yritä sitten uudelleen." create_post: "Vastaat hieman liian nopeasti. Odota %{time_left} ja yritä sitten uudelleen." delete_post: "Poistat viestejä hieman liian nopeasti. Odota %{time_left} ja yritä sitten uudelleen." - public_group_membership: "Liityt / poistut ryhmistä hieman liian usein. Odota %{time_left} ja yritä sitten uudelleen." - topics_per_day: "Olet saavuttanut päivittäin sallittujen uusien aiheiden enimmäismäärän. Odota %{time_left} ja yritä sitten uudelleen." + public_group_membership: "Liityt tai poistut ryhmistä hieman liian usein. Odota %{time_left} ja yritä sitten uudelleen." + topics_per_day: "Olet saavuttanut päivittäin sallittujen uusien ketjujen enimmäismäärän. Odota %{time_left} ja yritä sitten uudelleen." pms_per_day: "Olet saavuttanut päivässä sallitun viestien enimmäismäärän. Odota %{time_left} ja yritä sitten uudelleen." - create_like: "Vau! Olet jakanut paljon rakkautta! Olet saavuttanut päivittäisten tykkäysten ylärajan tälle päivälle, mutta kun saavutat luottamustason, ansaitset enemmän päivittäisiä tykkäyksiä. Odota %{time_left} ja yritä sitten uudelleen." + create_like: "Vau! Olet jakanut paljon rakkautta! Olet saavuttanut päivittäisten tykkäysten ylärajan tälle päivälle, mutta kun saavutat korkeamman luottamustason, ansaitset enemmän päivittäisiä tykkäyksiä. Odota %{time_left} ja yritä sitten uudelleen." create_bookmark: "Olet saavuttanut päivittäisten kirjanmerkkien enimmäismäärän. Odota %{time_left} ja yritä sitten uudelleen." edit_post: "Olet saavuttanut päivittäisten muokkausten enimmäismäärän. Odota %{time_left} ja yritä sitten uudelleen." live_post_counts: "Kysyt viestimääriä liian nopealla tahdilla. Odota %{time_left} ja yritä sitten uudelleen." - unsubscribe_via_email: "Olet perunut enimmäismäärän sähköpostitse. Odota %{time_left} ja yritä sitten uudelleen." - topic_invitations_per_day: "Olet lähettänyt enimmäismäärän kutsuja ketjuihin. Odota %{time_left} niin voit lähettää lisää kutsuja." + unsubscribe_via_email: "Olet tehnyt enimmäismäärän peruutuksia sähköpostitse. Odota %{time_left} ja yritä sitten uudelleen." + topic_invitations_per_day: "Olet lähettänyt enimmäismäärän kutsuja ketjuihin. Odota %{time_left}, niin voit lähettää lisää kutsuja." hours: one: "%{count} tunti" other: "%{count} tuntia" @@ -759,16 +759,16 @@ fi: one: "lähes vuosi sitten" other: "lähes %{count} vuotta sitten" password_reset: - no_token: "Pahoittelut, tämä salasanan uusimisen linkki on liian vanha. Paina 'Kirjaudu sisään' nappia ja valitse 'Unohdin salasanani' saadaksesi uuden linkin." + no_token: "Tämä salasanan vaihtolinkki on liian vanha. Paina 'Kirjaudu sisään' -painiketta ja valitse 'Unohdin salasanani' saadaksesi uuden linkin." choose_new: "Valitse uusi salasana" choose: "Valitse salasana" update: "Päivitä salasana" save: "Aseta salasana" - title: "Uusi salasana" - success: "Salasanan vaihto onnistui ja olet nyt kirjautuneena sisään." + title: "Palauta salasana" + success: "Salasanan vaihto onnistui, ja olet nyt kirjautunut sisään." success_unapproved: "Salasana vaihdettu onnistuneesti." email_login: - invalid_token: "Pahoittelut, tämä sisäänkirjautumislinkki on liian vanha. Paina 'Kirjaudu sisään' nappia ja valitse 'Unohdin salasanani' saadaksesi uuden linkin." + invalid_token: "Tämä sähköpostikirjautumislinkki on liian vanha. Paina 'Kirjaudu sisään' -painiketta ja valitse 'Unohdin salasanani' saadaksesi uuden linkin." title: "Sähköpostikirjautuminen" user_auth_tokens: browser: @@ -786,7 +786,7 @@ fi: ipad: "iPad" iphone: "iPhone" ipod: "iPod" - linux: "GNU/Linux -tietokone" + linux: "GNU/Linux-tietokone" mac: "Mac" mobile: "mobiililaite" windows: "Windows-tietokone" @@ -800,7 +800,7 @@ fi: windows: "Microsoft Windows" unknown: "tuntematon käyttöjärjestelmä" change_email: - wrong_account_error: "Olet kirjautuneena väärällä käyttäjätilillä. Kirjaudu ulos ja yritä uudelleen." + wrong_account_error: "Olet kirjautuneena väärällä tilillä. Kirjaudu ulos ja yritä uudelleen." confirmed: "Sähköpostiosoite päivitetty." please_continue: "Jatka sivustolle %{site_name}" error: "Sähköpostiosoitteen vaihdossa tapahtui virhe. Ehkäpä tämä sähköpostiosoite on jo käytössä?" @@ -814,49 +814,49 @@ fi: description: "Vahvista, että haluat sähköpostiosoitteesi muutettavan seuraavasti:" description_add: "Vahvista, että haluat lisätä vaihtoehtoisen sähköpostiosoitteen:" authorizing_old: - title: "Vaihda sähköpostiosoitettasi" - description: "Vahvista sähköpostiosoitteesi vaihdos" + title: "Vaihda sähköpostiosoitteesi" + description: "Vahvista sähköpostiosoitteesi muutos" description_add: "Vahvista, että haluat lisätä vaihtoehtoisen sähköpostiosoitteen:" old_email: "Vanha sähköposti: %{email}" new_email: "Uusi sähköposti: %{email}" almost_done_title: "Vahvista uusi sähköpostiosoite" - almost_done_description: "Lähetimme sinulle sähköpostin uuteen osoitteeseesi, jotta voit vahvistaa vaihdoksen!" + almost_done_description: "Lähetimme sinulle sähköpostin uuteen osoitteeseesi, jotta voit vahvistaa muutoksen!" associated_accounts: - revoke_failed: "Tunnustasi palveluntarjoajalla %{provider_name} ei onnistuttu perumaan." + revoke_failed: "Tunnustasi palveluntarjoajalla %{provider_name} ei voitu perua." connected: "(yhdistetty)" activation: action: "Aktivoi tilisi klikkaamalla tästä" - already_done: "Pahoittelut, tämän tilin varmennuslinkki ei ole enää voimassa. Ehkäpä tili on jo varmennettu?" - please_continue: "Tilisi on nyt varmennettu; sivu ohjautuu palstan etusivulle." + already_done: "Tämä tilin varmennuslinkki ei ole enää voimassa. Ehkäpä tili on jo varmennettu?" + please_continue: "Tilisi on nyt varmennettu; sinut ohjataan aloitussivulle." continue_button: "Jatka sivustolle %{site_name}" welcome_to: "Tervetuloa sivustolle %{site_name}!" approval_required: "Valvojan täytyy hyväksyä uusi tilisi, jotta pääset palstalle. Saat sähköpostin, kun tilisi on hyväksytty." missing_session: "Emme voineet havaita, onnistuiko tilisi luonti. Varmista, että selaimesi sallii evästeiden käytön." - activated: "Pahoittelut, tämä tili on jo aktivoitu." + activated: "Tämä tili on jo aktivoitu." admin_confirm: title: "Vahvista ylläpitäjätili" description: "Oletko varma, että haluat käyttäjästä %{target_username} (%{target_email}) ylläpitäjän?" grant: "Myönnä ylläpitäjäoikeudet" complete: "%{target_username} on nyt ylläpitäjä." - back_to: "Palaa %{title}" + back_to: "Palaa kohteeseen %{title}" reviewable_score_types: needs_approval: title: "Odottaa hyväksyntää" post_action_types: off_topic: - title: "Se eksyy aiheesta" + title: "Eksyy aiheesta" description: "Viesti ei ole liity meneillään olevaan keskusteluun, jonka aiheen määrittelee ketjun otsikko ja aloitusviesti. Viesti pitäisi luultavasti siirtää toiseen paikkaan." short_description: "Ei liity aiheeseen" spam: title: "Roskaposti" - description: "Viesti on mainos tai ilkivaltaa. Se on hyödytön ja epäolennainen tähän ketjuun." + description: "Viesti on mainos tai ilkivaltaa. Se on hyödytön tai epäolennainen tähän ketjuun." short_description: "On mainos tai häiriköintiä" - email_title: '"%{title}" liputettiin roskapostiksi' + email_title: '"%{title}" merkittiin roskapostiksi' email_body: "%{link}\n\n%{message}" inappropriate: title: "Sopimaton" - description: 'Viestissä on sisältöä, jota tolkun ihminen pitää loukkaavana, herjaavana tai palstan sääntöjen vastaisena.' - short_description: 'Palstan sääntöjen vastainen' + description: 'Viestissä on sisältöä, jota kohtuullinen henkilö pitäisi loukkaavana, herjaavana tai yhteisön sääntöjen vastaisena.' + short_description: 'Yhteisön sääntöjen vastainen' notify_user: title: "Lähetä käyttäjälle @%{username} viesti." description: "Haluan keskustella viestistä kirjoittajan kanssa kahden kesken." @@ -867,7 +867,7 @@ fi: title: "Jotain muuta" description: "Henkilökunnan tulisi huomioida viesti muusta kuin yllä listatusta syystä." short_description: "Henkilökunnan tulisi huomioida muusta syystä" - email_title: 'Henkilökunnan tulisi huomioida viesti ketjussa "%{title}" ' + email_title: 'Henkilökunnan tulisi huomioida viesti ketjussa "%{title}"' email_body: "%{link}\n\n%{message}" bookmark: title: "Kirjanmerkki" @@ -882,12 +882,12 @@ fi: title: "luonnosvirhe" description: "Luonnosta muokataan toisessa ikkunassa. Lataa tämä sivu uudelleen." draft_backup: - pm_title: "Varmuuskopiot ketjuihin suunnatuista luonnoksista" - pm_body: "Ketju johon varmuuskopiot luonnoksista sijoitetaan" + pm_title: "Varmuuskopiot käynnissä olevien ketjujen luonnoksista" + pm_body: "Ketju, joka sisältää varmuuskopioluonnoksia" user_activity: no_default: - self: "Et ole vielä tehnyt mitään mainittavaa." - others: "Ei ole tehnyt mitään mainittavaa." + self: "Sinulla ei ole vielä toimintaa." + others: "Ei toimintaa." no_bookmarks: self: "Kirjanmerkeissäsi ei ole mitään. Kirjanmerkitsemällä viestejä löydät ne myöhemmin helposti." search: "Kirjanmerkkejä ei löytynyt näillä hakusanoilla." @@ -899,55 +899,55 @@ fi: self: "Et ole vastannut yhteenkään viestiin." others: "Ei vastauksia." no_drafts: - self: "Sinulla ei ole luonnoksia. Aloita kirjoittamaan viestiä mihin tahansa ketjuun niin se tallentuu automaattiseksi uudeksi luonnokseksi." + self: "Sinulla ei ole luonnoksia. Aloita kirjoittamaan vastausta mihin tahansa ketjuun, niin se tallentuu automaattiseksi uudeksi luonnokseksi." webauthn: validation: invalid_type_error: "Annettu webauthn-tyyppi ei kelpaa. Kelvollisia ovat webauthn.get ja webauthn.create." - challenge_mismatch_error: "Palautettu haaste ei vastaa todennuspalvelimen luomaa haastetta." - invalid_origin_error: "Todentautumisen alkuperä (origin) ei vastaa palvelimen alkuperää." - malformed_attestation_error: "Todentautumisdatan avaaminen epäonnistui." - invalid_relying_party_id_error: "Todennuspyynnön Relying Party ID ei vastaa palvelimen Relying Party ID:tä." + challenge_mismatch_error: "Annettu haaste ei vastaa todennuspalvelimen luomaa haastetta." + invalid_origin_error: "Todennuspyynnön alkuperä ei vastaa palvelimen alkuperää." + malformed_attestation_error: "Todennustietojen dekoodauksessa tapahtui virhe." + invalid_relying_party_id_error: "Todennuspyynnön välittävän osapuolen tunnus ei vastaa palvelimen välittävän osapuolen tunnusta." user_verification_error: "Käyttäjävahvistus vaaditaan." - unsupported_public_key_algorithm_error: "Palvelin ei tue annettua julkisen salauksen algoritmia." - unsupported_attestation_format_error: "Palvelin ei tue tätä todentautumistapaa." - credential_id_in_use_error: "Annettu pääsytieto-ID on jo käytössä." - public_key_error: "Pääsytiedon julkisen salauksen vahvistus epäonnistui." + unsupported_public_key_algorithm_error: "Palvelin ei tue annettua julkisen avaimen algoritmia." + unsupported_attestation_format_error: "Palvelin ei tue tätä todennustapaa." + credential_id_in_use_error: "Annettu tunnistetieto on jo käytössä." + public_key_error: "Tunnistetiedon julkisen avaimen vahvistus epäonnistui." ownership_error: "Käyttäjä ei omista tunnistautumislaitetta." - not_found_error: "Tunnistautumislaitetta annetulla pääsytieto-ID:llä ei löytynyt." - unknown_cose_algorithm_error: "Tunnistautumislaitteen algoritmia ei tunnistettu." + not_found_error: "Tunnistautumislaitetta annetulla tunnistetiedolla ei löytynyt." + unknown_cose_algorithm_error: "Tunnistautumislaitteelle käytettyä algoritmia ei tunnistettu." topic_flag_types: spam: title: "Roskaposti" - description: "Tämä ketju on mainos. Se ei ole hyödyllinen tai relevantti tällä sivustolla, vaan on luonteeltaan mainostamista." - long_form: "liputti tämän roskapostiksi" + description: "Tämä ketju on mainos. Se ei ole hyödyllinen tai olennainen tällä sivustolla, vaan on luonteeltaan mainostamista." + long_form: "merkitsi tämän roskapostiksi" short_description: "Tämä on mainos" inappropriate: title: "Sopimaton" - description: 'Ketjussa on sisältöä, jota tolkun ihminen pitää loukkaavana, herjaavana tai palstan sääntöjen vastaisena.' - long_form: "liputti tämän sopimattomaksi" - short_description: 'Palstan sääntöjen vastainen' + description: 'Ketjussa on sisältöä, jota kohtuullinen henkilö pitäisi loukkaavana, herjaavana tai yhteisön sääntöjen vastaisena.' + long_form: "merkitsi tämän sopimattomaksi" + short_description: 'Yhteisön sääntöjen rikkomus' notify_moderators: title: "Jotain muuta" - description: 'Henkilökunnan tulisi huomioida tämä ketju palstan sääntöjen, palveluehtojen tai jonkun muun syyn vuoksi.' - long_form: "liputit tämän valvojille tiedoksi" + description: 'Henkilökunnan tulisi huomioida tämä ketju sääntöjen, palveluehtojen tai jonkun muun edellä listaamattoman syyn vuoksi.' + long_form: "merkitsit tämän valvojille tiedoksi" short_description: "Henkilökunnan tulisi huomioida muusta syystä" email_title: 'Ketju"%{title}" kaipaa valvojan huomiota' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

      Muu yhteisö liputti viestisi. Käy lukemassa saapuneet viestisi.

      ' - user_must_edit: "

      Tämä viesti on liputettu ja on siksi piilotettu väliaikaisesti.

      " + you_must_edit: '

      Muu yhteisö merkitsi viestisi. Käy lukemassa saapuneet viestisi.

      ' + user_must_edit: "

      Tämä viesti on merkitty ja on siksi piilotettu väliaikaisesti.

      " ignored: - hidden_content: "

      Estettyä sisältöä

      " + hidden_content: "

      Ohitettu sisältö

      " archetypes: regular: title: "Tavallinen ketju" banner: title: "Banneriketju" message: - make: "Tämä ketju on nyt banneri. Se näytetään jokaisen sivun ylälaidassa, kunnes käyttäjä kuittaa sen nähdyksi." - remove: "Tämä ketju ei ole enää banneri. Sitä ei näytetä enää jokaisen sivun ylälaidassa." + make: "Tämä ketju on nyt banneri. Se näytetään jokaisen sivun yläosassa, kunnes käyttäjä kuittaa sen nähdyksi." + remove: "Tämä ketju ei ole enää banneri. Sitä ei näytetä enää jokaisen sivun yläosassa." unsubscribed: - title: "Sähköpostiasetukset päivitetty!" + title: "Sähköpostiasetukset päivitetty" description: "sähköpostiasetukset osoitteelle %{email} päivitettiin. Voit muuttaa sähköpostiasetuksia käyttäjäasetuksissasi." topic_description: "Tilaa %{link} uudelleen ketjun alla tai oikealla puolen sijaitsevan ilmoitusvalikon kautta." private_topic_description: "Voit tilata ketjun uudelleen sen alla tai oikealla puolella sijaitsevan ilmoitusvalikon kautta." @@ -956,54 +956,54 @@ fi: unsubscribe: title: "Peru tilaus" stop_watching_topic: "Lopeta ketjun tarkkailu, %{link}" - mute_topic: "Vaimenna ilmoitukset tästä ketjusta, %{link}" + mute_topic: "Vaimenna kaikki ilmoitukset tästä ketjusta, %{link}" unwatch_category: "Lopeta kaikkien alueen %{category} ketjujen tarkkailu" mailing_list_mode: "Poistu postituslistatilasta" all: "Älä lähetä minulle sähköpostia sivustolta %{sitename}" - different_user_description: "Olet kirjautunut sisään eri käyttäjänä kuin jolle sähköposti lähetettiin. Kirjaudu ulos tai siirry anonyymitilaan ja yritä sitten uudelleen." - not_found_description: "Valitettavasti emme löytäneet tilauksen peruutusta. Kenties sinulle lähetetty linkki on vanhentunut?" + different_user_description: "Olet kirjautunut sisään eri käyttäjänä kuin käyttäjä, jolle sähköposti lähetettiin. Kirjaudu ulos tai siirry anonyymitilaan ja yritä sitten uudelleen." + not_found_description: "Tilauksen peruutusta ei löytynyt. Kenties sinulle lähetetty linkki on vanhentunut?" log_out: "Kirjaudu ulos" submit: "Tallenna asetukset" digest_frequency: - title: "Saat sähköpostikoosteita %{frequency}" - never_title: "Sinulle ei lähetetä yhteenvetoja sähköpostitse" - select_title: "Sähköpostikoosteiden taajuus:" + title: "Saat yhteenvetosähköposteja %{frequency}" + never_title: "Sinulle ei lähetetä yhteenvetosähköposteja" + select_title: "Aseta yhteenvetosähköpostien tiheydeksi:" never: "ei koskaan" - every_30_minutes: "puolen tunnin välein" + every_30_minutes: "30 minuutin välein" every_hour: "tunneittain" daily: "päivittäin" weekly: "viikottain" every_month: "kuukausittain" - every_six_months: "puolen vuoden välein" + every_six_months: "kuuden kuukauden välein" user_api_key: title: "Anna sovellukselle käyttöoikeus" authorize: "Anna käyttöoikeus" read: "luku" read_write: "luku/kirjoitus" - description: '"%{application_name}" pyytää seuraavaa käyttöoikeutta tunnukseesi:' - instructions: 'Loimme juuri uuden käyttäjärajapinta-avaimen, jolla voit käyttää palveluamme "%{application_name}". Liitä tämä avainkoodi sovellukseesi:' + description: '"%{application_name}" pyytää seuraavaa käyttöoikeutta tiliisi:' + instructions: 'Loimme juuri uuden käyttäjä-API-avaimen, jota voit käyttää sovelluksessa "%{application_name}". Liitä tämä avainkoodi sovellukseesi:' otp_description: 'Haluatko sallia sovellukselle "%{application_name}" pääsyn sivustolle?' otp_confirmation: confirm_title: Jatka sivustolle %{site_name} logging_in_as: Kirjaudutaan käyttäjänä %{username} confirm_button: Kirjaudu - no_trust_level: "Pahoittelut, luottamustasosi ei ole riittävä käyttäjärajapinnan käyttämiseen" - generic_error: "Pahoittelut, API-salausavainta ei muodostettu; sivuston ylläpitäjä on saattanut ottaa ominaisuuden pois käytöstä" + no_trust_level: "Luottamustasosi ei ole riittävä käyttäjä-APIn käyttämiseen" + generic_error: "Käyttäjä-API-avaimien luominen ei onnistunut, sivuston ylläpitäjä on saattanut poistaa ominaisuuden käytöstä" scopes: message_bus: "Reaaliaikaiset päivitykset" notifications: "Ilmoitusten lukeminen ja kuittaaminen" push: "Push-ilmoitukset ulkoisiin palveluihin" - session_info: "Tiedot käyttäjän istunnosta" + session_info: "Käyttäjän istunnon tietojen lukeminen" read: "Kaikkien luku" - write: "Kaikkiin kirjaittaminen" - one_time_password: "Luo kertakäyttöinen kirjautumiskoodi" - bookmarks_calendar: "Lue kirjanmerkkimuistutuksia" - invalid_public_key: "Pahoittelut, julkinen avain ei kelpaa." - invalid_auth_redirect: "Pahoittelut, tämä auth_redirect host ei ole sallittu." - invalid_token: "Tunnistusväline puuttuu, ei kelpaa tai on vanhentunut." + write: "Kaikkiin kirjoittaminen" + one_time_password: "Kertakäyttöisen kirjautumiskoodin luominen" + bookmarks_calendar: "Kirjanmerkkimuistutusten lukeminen" + invalid_public_key: "Julkinen avain ei kelpaa." + invalid_auth_redirect: "Tämä auth_redirect host ei ole sallittu." + invalid_token: "Tunniste puuttuu, ei kelpaa tai on vanhentunut." flags: errors: - already_handled: "Lippu ehdittiin jo käsitellä" + already_handled: "Merkintä on jo käsitelty" reports: default: labels: @@ -1020,27 +1020,27 @@ fi: edit_reason: Syy description: "Kuinka monesti viestejä muokattiin." user_flagging_ratio: - title: "Käyttäjän liputussuhde" + title: "Käyttäjän merkintäsuhde" labels: user: Käyttäjä - agreed_flags: Hyväksyttyjä lippuja - disagreed_flags: Hylättyjä lippuja - ignored_flags: Sivuutettuja lippuja - score: Luku - description: "Luettelo käyttäjistä järjestettynä sen mukaan, miten henkilökunta suhtautuu heidän liputuksiinsa (hylätyt jaettuna hyväksytyillä)." + agreed_flags: Hyväksyttyjä merkintöjä + disagreed_flags: Hylättyjä merkintöjä + ignored_flags: Sivuutettuja merkintöjä + score: Arvo + description: "Luettelo käyttäjistä järjestettynä sen mukaan, miten henkilökunta on reagoinut heidän merkintöihinsä (hylätyt jaettuna hyväksytyillä)." moderators_activity: title: "Valvoja-aktiivisuus" labels: moderator: Valvoja - flag_count: Arvioituja lippuja + flag_count: Arvioituja merkintöjä time_read: Lukuaika topic_count: Aloitetut ketjut post_count: Kirjoitetut viestit pm_count: Kirjoitetut yksityisviestit revision_count: Revisiot - description: Luettelo valvojien aktiivisuudesta. Sisältää käsitellyt liput, lukuajan, luodut ketjut, lähetetyt viestit, lähetetyt yksityisviestit ja tarkastukset. + description: Luettelo valvojien aktiivisuudesta. Sisältää käsitellyt merkinnät, lukuajan, luodut ketjut, lähetetyt viestit, lähetetyt yksityisviestit ja revisiot. flags_status: - title: "Lippujen status" + title: "Merkintöjen tila" values: agreed: Samaa mieltä disagreed: Eri mieltä @@ -1052,16 +1052,16 @@ fi: poster: Kirjoittaja flagger: Liputtaja time_to_resolution: Selvitysaika - description: "Luettelo lippujen statuksista sisältäen lippujen tyypit, kirjoittajat, liputtajat ja selvitysajat." + description: "Luettelo merkintöjen tiloista, sisältäen merkintöjen tyypit, kirjoittajat, merkitsijät ja selvitysajat." visits: - title: "Vierailut" + title: "Käyttäjien vierailut" xaxis: "Päivä" yaxis: "Vierailujen määrä" description: "Vierailleiden käyttäjien määrä." signups: - title: "Liittymiset" + title: "Rekisteröitymiset" xaxis: "Päivä" - yaxis: "Liittymisten lukumäärä" + yaxis: "Rekisteröitymisten määrä" description: "Uudet rekisteröidyt käyttäjät tällä ajanjaksolla." new_contributors: title: "Uusia kirjoittajia" @@ -1078,13 +1078,13 @@ fi: yaxis: "Päivä" description: "Kuinka monen käyttäjän luottamustaso nousi tällä ajanjaksolla." consolidated_page_views: - title: "Yhdistetyt sivukatselut" + title: "Kootut sivukatselut" xaxis: page_view_crawler: "Hakurobotit" page_view_anon: "Anonyymit käyttäjät" page_view_logged_in: "Kirjautuneet käyttäjät" yaxis: "Päivä" - description: "Kuinka monta sivukatselua kirjautuneilta, anonyymeiltä ja hakuroboteilta yhteensä." + description: "Kuinka monta sivukatselua kirjautuneilta ja anonyymeiltä käyttäjiltä sekä hakuroboteilta yhteensä." labels: post: Viesti editor: Muokkaaja @@ -1094,7 +1094,7 @@ fi: title: "PAK/KAK" xaxis: "Päivä" yaxis: "PAK/KAK" - description: "Käyttäjät jotka kirjautuivat viimeisen päivän aikana jaettuna käyttäjillä, jotka kirjautuivat kuukauden aikana - tuottaa %-luvun joka kuvastaa yhteisön \"tarttuvuutta\". Tähtää yli 30 %:iin." + description: "Käyttäjät, jotka kirjautuivat sisään viimeisen päivän aikana, jaettuna käyttäjillä, jotka kirjautuivat sisään viimeisen kuukauden aikana – tuottaa prosenttiarvon, joka ilmaisee yhteisön sitoutuneisuutta. Tähtää yli 30 prosenttiin." daily_engaged_users: title: "Sitoutuneet päivittäiskäyttäjät" xaxis: "Päivä" @@ -1119,12 +1119,12 @@ fi: title: "Tykkäyksiä" xaxis: "Päivä" yaxis: "Uusien tykkäysten lukumäärä" - description: "Uudet tykkäykset." + description: "Uusien tykkäysten lukumäärä." flags: - title: "Liputukset" + title: "Merkinnät" xaxis: "Päivä" - yaxis: "Liputusten lukumäärä" - description: "Uudet liputukset." + yaxis: "Merkintöjen lukumäärä" + description: "Uusien merkintöjen lukumäärä." bookmarks: title: "Kirjanmerkit" xaxis: "Päivä" @@ -1176,9 +1176,9 @@ fi: title: "Järjestelmä" xaxis: "Päivä" yaxis: "Viestien lukumäärä" - description: "Kuinka monta yksityisviestiä systeemi on lähettänyt automaattisesti." + description: "Kuinka monta yksityisviestiä järjestelmä on lähettänyt automaattisesti." moderator_warning_private_messages: - title: "Varoitus valvojalle" + title: "Valvojan varoitus" xaxis: "Päivä" yaxis: "Viestien lukumäärä" description: "Kuinka monta yksityistä varoitusta valvojat ovat lähettäneet." @@ -1186,16 +1186,16 @@ fi: title: "Ilmoita valvojille" xaxis: "Päivä" yaxis: "Viestien lukumäärä" - description: "Kuinka monta kertaa valvojia on yksityisesti huomautettu lipuista." + description: "Kuinka monta kertaa valvojia on yksityisesti huomautettu merkinnästä." notify_user_private_messages: title: "Ilmoita käyttäjälle" xaxis: "Päivä" yaxis: "Viestien lukumäärä" - description: "Kuinka monesti käyttäjiä huomautettiin yksityisesti lipuista." + description: "Kuinka monesti käyttäjiä huomautettiin yksityisesti merkinnästä." top_referrers: title: "Parhaat viittaajat" xaxis: "Käyttäjä" - num_clicks: "Klikkausta" + num_clicks: "Klikkauksia" num_topics: "Ketjut" labels: user: "Käyttäjä" @@ -1205,24 +1205,24 @@ fi: top_traffic_sources: title: "Parhaat liikenteen lähteet" xaxis: "Verkkotunnus" - num_clicks: "Klikkausta" + num_clicks: "Klikkauksia" num_topics: "Ketjut" num_users: "Käyttäjät" labels: domain: Verkkotunnus - num_clicks: Klikkaukset + num_clicks: Klikkauksia num_topics: Ketjut - description: "Ulkoiset lähteet jotka ovat linkittäneet tälle sivustolle eniten." + description: "Ulkoiset lähteet, jotka ovat linkittäneet tälle sivustolle eniten." top_referred_topics: title: "Parhaat viitatut ketjut" labels: - num_clicks: "Klikkaukset" + num_clicks: "Klikkauksia" topic: "Ketju" - description: "Ketjut jotka ovat saaneet eniten klikkauksia ulkoisilta sivustoilta." + description: "Ketjut, jotka ovat saaneet eniten klikkauksia ulkoisilta sivustoilta." page_view_anon_reqs: title: "Anonyymit" xaxis: "Päivä" - yaxis: "Anonyymien sivukatselut" + yaxis: "Anonyymit sivukatselut" description: "Kuinka monta sivunkatselua sisäänkirjautumattomilta vierailta." page_view_logged_in_reqs: title: "Kirjautuneita" @@ -1233,7 +1233,7 @@ fi: title: "Hakurobottien sivukatselut" xaxis: "Päivä" yaxis: "Hakurobottien sivukatselut" - description: "Hakurobotit järjestettynä sivukatseluiden mukaan." + description: "Sivukatseluita hakuroboteilta ajan myötä." page_view_total_reqs: title: "Sivukatselua" xaxis: "Päivä" @@ -1245,30 +1245,30 @@ fi: yaxis: "Kirjautuneiden mobiilikäyttäjien sivukatselut" description: "Kuinka monta sivukatselua kirjautuneilta mobiilikäyttäjiltä." page_view_anon_mobile_reqs: - title: "Anonyymien sivukatselut" + title: "Anonyymit sivukatselut" xaxis: "Päivä" yaxis: "Anonyymien mobiilikäyttäjien sivukatselut" description: "Kuinka monta sivukatselua kirjautumattomilta mobiilikäyttäjiltä." http_background_reqs: - title: "Taustajärjestelmä" + title: "Tausta" xaxis: "Päivä" yaxis: "Pyyntöjä sivun päivittämiseen ja seuraamiseen" http_2xx_reqs: - title: "Status 2xx (OK)" + title: "Tila 2xx (OK)" xaxis: "Päivä" - yaxis: "Onnistuneita pyyntöjä (Status 2xx)" + yaxis: "Onnistuneita pyyntöjä (tila 2xx)" http_3xx_reqs: - title: "HTTP 3xx (Uudelleenohjaus)" + title: "HTTP 3xx (uudelleenohjaus)" xaxis: "Päivä" - yaxis: "Uudelleenohjauksia (Status 3xx)" + yaxis: "Uudelleenohjauspyyntöjä (tila 3xx)" http_4xx_reqs: - title: "HTTP 4xx (Asiakasvirhe)" + title: "HTTP 4xx (asiakasvirhe)" xaxis: "Päivä" - yaxis: "Asiakasvirheitä (Status 4xx)" + yaxis: "Asiakasvirheitä (tila 4xx)" http_5xx_reqs: - title: "HTTP 5xx (Palvelinvirhe)" + title: "HTTP 5xx (palvelinvirhe)" xaxis: "Päivä" - yaxis: "Palvelinvirheitä (Status 5xx)" + yaxis: "Palvelinvirheitä (tila 5xx)" http_total_reqs: title: "Yhteensä" xaxis: "Päivä" @@ -1304,7 +1304,7 @@ fi: device: Laite os: Käyttöjärjestelmä login_time: Kirjautumisaika - description: "Yksityiskohdat uusista kirjautumisista, jotka epäilyttävällä tavalla poikkesivat aiemmista kirjautumisista." + description: "Tiedot uusista kirjautumisista, jotka poikkesivat epäilyttävällä tavalla aiemmista kirjautumisista." staff_logins: title: "Ylläpitäjien kirjautumiset" labels: @@ -1313,7 +1313,7 @@ fi: login_at: Kirjautumisaika description: "Luettelo ylläpitäjien kirjautumisista ja niiden paikoista." top_uploads: - title: "Parhaat lataukset" + title: "Suosituimmat lataukset" labels: filename: Tiedostonimi extension: Tiedostopääte @@ -1321,302 +1321,301 @@ fi: filesize: Tiedoston koko description: "Luettelo kaikista latauksista järjestettynä tiedostopäätteen, tiedoston koon ja käyttäjän mukaan." top_ignored_users: - title: "Estetyimmät / Vaimennetuimmat käyttäjät" + title: "Estetyimmät/vaimennetuimmat käyttäjät" labels: ignored_user: Estetty käyttäjä ignores_count: Estojen määrä mutes_count: Vaimennusten määrä - description: "Käyttäjät jotka monet muista on vaimentaneet ja/tai estäneet." + description: "Käyttäjät, jotka monet muista on vaimentaneet tai estäneet." dashboard: - rails_env_warning: "Palvelintasi ajetaan %{env} moodissa." - host_names_warning: "Sivuston config/database.yml tiedosto käyttää oletusisäntänimeä. Päivitä se käyttämään sivuston isäntänimeä." - sidekiq_warning: 'Sidekiq ei ole käynnissä. Monet tehtävät, kuten sähköpostien lähettäminen, suoritetaan asynkronisesti sidekiqin avulla. Varmista, että vähintään yksi sidekiq prosessi on käynnissä. Opiskele lisää Sidekiqista täältä.' - queue_size_warning: "Jonossa olevien tehtävien määrä on %{queue_size}, joka on korkea. Tämä voi olla merkki ongelmista Sidekiq prosess(e)issa tai sinun voi täytyä lisätä Sidekiq workerien määrää." - memory_warning: "Palvelimella on alle 1GB muistia. Vähintään 1 GB muistia on suositeltavaa." + rails_env_warning: "Palvelintasi ajetaan tilassa %{env}." + host_names_warning: "Sivuston config/database.yml tiedosto käyttää oletusarvoista localhost-isäntänimeä. Päivitä se käyttämään sivuston isäntänimeä." + sidekiq_warning: 'Sidekiq ei ole käynnissä. Monet tehtävät, kuten sähköpostien lähettäminen, suoritetaan asynkronisesti sidekiqin avulla. Varmista, että vähintään yksi sidekiq-prosessi on käynnissä. Lue lisää Sidekiqista täällä.' + queue_size_warning: "Jonossa olevien tehtävien määrä on %{queue_size}, joka on korkea. Tämä voi olla merkki ongelmista Sidekiq prosesseissa, tai sinun voi täytyä lisätä Sidekiqin workerien määrää." + memory_warning: "Palvelimella on alle 1 Gt muistia. Vähintään 1 Gt muistia on suositeltavaa." google_oauth2_config_warning: 'Palvelin on konfiguroitu hyväksymään liittyminen ja kirjautuminen Google OAuth2:n kautta (enable_google_oauth2_logins), mutta client id- ja client secret -arvoja ei ole asetettu. Aseta arvot sivuston asetuksissa. Lue lisää tästä oppaasta.' facebook_config_warning: 'Palvelin on konfiguroitu hyväksymään liittyminen ja kirjautuminen Facebookin kautta (enable_facebook_logins), mutta app id- ja app secret -arvoja ei ole asetettu. Aseta arvot sivuston asetuksissa. Lue lisää tästä oppaasta.' twitter_config_warning: 'Palvelin on konfiguroitu hyväksymään liittyminen ja kirjautuminen Twitterin kautta (enable_twitter_logins), mutta key- ja secret-arvoja ei ole asetettu. Aseta arvot sivuston asetuksissa. Lue lisää tästä oppaasta.' github_config_warning: 'Palvelin on konfiguroitu hyväksymään liittyminen ja kirjautuminen GitHubin kautta (enable_github_logins), mutta client id- ja app secret -arvoja ei ole asetettu. Aseta arvot sivuston asetuksissa. Lue lisää tästä oppaasta.' - s3_config_warning: 'Palvelin on konfiguroitu tallentamaan sivustolle ladatut tiedostot S3:een, mutta vähintään yksi tiedoista on asettamatta: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile tai s3_upload_bucket. Päivitä ne sivuston asetuksissa. Lisätietoa englanninkielisestä ketjusta "How to set up image uploads to S3?".' - s3_backup_config_warning: 'Palvelin on konfiguroitu tallentamaan varmuuskopiot S3:een, mutta vähintään yksi tiedoista on asettamatta: s3_access_key_id, s3_secret_access_key tai s3_upload_bucket . Päivitä ne sivuston asetuksissa. Lisätietoa englanninkielisestä ketjusta "How to set up image uploads to S3?".' - s3_cdn_warning: 'Palvelin on määritetty käyttämään S3-latausta, mutta S3 CDN-asetuksia ei ole määritelty. Tämä voi johtaa kalliisiin S3-kustannuksiin ja hitaampaan sivuston suorituskykyyn. Lue lisää aiheesta täältä.' - image_magick_warning: 'Palvelin on konfiguroitu luomaan esikatselukuvia suurista kuvista, mutta ImageMagickia ei ole asennettu. Asenna ImageMagick paketinhallinnasta tai lataa uusin versio.' - failing_emails_warning: '%{num_failed_jobs} sähköpostitehtävää on epäonnistunut. Tarkista app.yml ja varmista, että sähköpostipalvelimen asetukset ovat kunnossa. Katsele epäonnistuneita tehtäviä Sidekiqissa.' + s3_config_warning: 'Palvelin on konfiguroitu tallentamaan sivustolle ladatut tiedostot S3:een, mutta vähintään yksi tiedoista on asettamatta: s3_access_key_id, s3_secret_access_key, s3_use_iam_profile tai s3_upload_bucket. Päivitä ne /admin/site_settings">sivuston asetuksissa. Lisätietoa saat englanninkielisestä ketjusta "How to set up image uploads to S3?".' + s3_backup_config_warning: 'Palvelin on konfiguroitu tallentamaan varmuuskopiot S3:een, mutta vähintään yksi tiedoista on asettamatta: s3_access_key_id, s3_secret_access_key tai s3_upload_bucket . Päivitä ne /admin/site_settings">sivuston asetuksissa. Lisätietoa saat englanninkielisestä ketjusta "How to set up image uploads to S3?".' + s3_cdn_warning: 'Palvelin on määritetty käyttämään S3-latausta, mutta S3:n CDN-asetuksia ei ole määritelty. Tämä voi johtaa kalliisiin S3-kustannuksiin ja hitaampaan sivuston suorituskykyyn. Lue lisää aiheesta täältä.' + image_magick_warning: 'Palvelin on konfiguroitu luomaan esikatselukuvia suurista kuvista, mutta ImageMagickia ei ole asennettu. Asenna ImageMagick paketinhallinnasta tai lataa uusin versio.' + failing_emails_warning: '%{num_failed_jobs} sähköpostitehtävää on epäonnistunut. Tarkista app.yml ja varmista, että sähköpostipalvelimen asetukset ovat kunnossa. Katsele epäonnistuneita tehtäviä Sidekiqissa.' subfolder_ends_in_slash: "Alihakemiston asetuksesi ei kelpaa; DISCOURSE_RELATIVE_URL_ROOT päättyy vinoviivaan." email_polling_errored_recently: - one: "Sähköpostin pollaus on aiheuttanut virheen edellisen 24 tunnin aikana. Tarkastele lokeja saadaksesi lisätietoja." - other: "Sähköpostin pollaus aiheutti %{count} virhettä edellisen 24 tunnin aikana. Tarkastele lokeja saadaksesi lisätietoja." - missing_mailgun_api_key: "Palvelin on määritelty lähettämään sähköpostit Mailgunin avulla, muttet ole määritellyt rajapinta-avainta, jolla varmistetaan webhook-viestien aitous." + one: "Sähköpostin pollaus on aiheuttanut virheen edellisten 24 tunnin aikana. Tarkastele lokeja saadaksesi lisätietoja." + other: "Sähköpostin pollaus aiheutti %{count} virhettä edellisten 24 tunnin aikana. Tarkastele lokeja saadaksesi lisätietoja." + missing_mailgun_api_key: "Palvelin on määritelty lähettämään sähköpostit Mailgunin avulla, muttet ole määrittänyt API-avainta, jolla varmistetaan webhook-viestien aitous." bad_favicon_url: "Favicon ei lataudu. Tarkista favicon-asetus sivuston asetuksissa." - poll_pop3_timeout: "Yhteyttä POP3-palvelimelle aikakatkaistaan ja saapuvaa sähköpostia ei voitu hakea. Tarkista POP3-asetukset ja palveluntarjoaja." - poll_pop3_auth_error: "Yhteys POP3-palvelimelle epäonnistuu autentikaatiovirheen vuoksi. Tarkista POP3-asetukset." - force_https_warning: "Sivusto käyttää SSL-salausta, mutta `force_https` ei ole valittuna asetuksissa." + poll_pop3_timeout: "Yhteys POP3-palvelimeen aikakatkaistaan. Saapuvia sähköposteja ei voitu hakea. Tarkista POP3-asetukset ja palveluntarjoaja." + poll_pop3_auth_error: "Yhteys POP3-palvelimelle epäonnistuu todennusvirheen vuoksi. Tarkista POP3-asetukset." + force_https_warning: "Sivusto käyttää SSL-salausta, mutta `force_https` ei ole valittuna sivuston asetuksissa." out_of_date_themes: "Päivityksiä on saatavilla näihin teemoihin:" - unreachable_themes: "Näille teemoille ei löytynyt päivityksiä:" + unreachable_themes: "Näille teemoille ei voitu tarkistaa päivityksiä:" site_settings: display_local_time_in_user_card: "Näytä käyttäjän aikavyöhykkeeseen perustuva paikallinen aika hänen käyttäjäkortissaan." censored_words: "Sanat, jotka korvataan automaattisesti merkeillä ■■■■" delete_old_hidden_posts: "Poista automaattisesti kaikki yli 30 päivää piilotettuna olleet viestit." - default_locale: "Tämän Discourse-ympäristön oletuskieli. Voit korvata systeemin luomien alueiden ja ketjujen tekstejä kohteessa Mukauta / Tekstit." + default_locale: "Tämän Discourse-ympäristön oletuskieli. Voit korvata järjestelmän luomien alueiden ja ketjujen tekstejä kohteessa Mukauta / Tekstit." allow_user_locale: "Salli käyttäjien vaihtaa käyttöliittymän kieli omista asetuksista" support_mixed_text_direction: "Salli vasemmalta-oikealle- ja oikealta-vasemmalle-kirjoitusta käytettävän sekaisin." min_post_length: "Viestin merkkien minimimäärä" min_first_post_length: "Ketjun aloitusviestin (leipätekstin) merkkien minimimäärä" - min_personal_message_post_length: "Yksityisviestin vähimmäismerkkimäärä" - max_post_length: "Viestin merkkien minimimäärä" + min_personal_message_post_length: "Yksityisviestin minimimerkkimäärä" + max_post_length: "Viestin merkkien maksimimäärä" topic_featured_link_enabled: "Ota käyttöön ketjulinkit." show_topic_featured_link_in_digest: "Näytä ketjulinkki tiivistelmäsähköpostissa." - min_topic_views_for_delete_confirm: "Vähimmäismäärä katselukertoja, joita ketjussa on oltava jotta vahvistusikkuna näytetään poiston yhteydessä" + min_topic_views_for_delete_confirm: "Vähimmäismäärä katselukertoja, joita ketjussa on oltava, jotta vahvistusikkuna näytetään poiston yhteydessä" min_topic_title_length: "Viestin otsikon merkkien minimimäärä" max_topic_title_length: "Viestin otsikon merkkien maksimimäärä" min_personal_message_title_length: "Yksityisviestin otsikon vähimmäismerkkimäärä" max_emojis_in_title: "Kuinka monta emojia enintään otsikossa" min_search_term_length: "Haun merkkien minimimäärä" search_tokenize_chinese_japanese_korean: "Pakota haku käsittelemään kiinaa/japania/koreaa myös muunkielisillä sivustoilla" - search_prefer_recent_posts: "Jos hakeminen suurelta palstaltasi on hidasta, tämä asetus kokeilee hakemistorakennetta, jossa tuoreimmat viestit ovat ensin" - search_recent_posts_size: "Kuinka monta tuoretta viestiä pidetään hakemistossa" + search_prefer_recent_posts: "Jos hakeminen suurelta foorumilta on hidasta, tämä asetus kokeilee indeksiä, jossa tuoreimmat viestit ovat ensin" + search_recent_posts_size: "Kuinka monta tuoretta viestiä pidetään indeksissä" log_search_queries: "Pidä lokia käyttäjien tekemistä hauista" search_query_log_max_size: "Kuinka monta hakukyselyä säilötään enintään" - search_query_log_max_retention_days: "Kuinka pitkään hakukyselyä enintään säilötään päivissä." - search_ignore_accents: "Älä välitä aksenteista, kun haetaan tekstiä" + search_query_log_max_retention_days: "Kuinka monta päivää hakukyselyä enintään säilötään." + search_ignore_accents: "Älä välitä aksenteista, kun haetaan tekstiä." category_search_priority_low_weight: "Matalan hakuprioriteetin painoarvo." category_search_priority_high_weight: "Korkean hakuprioriteetin painoarvo." - allow_uncategorized_topics: "Salli ketjujen aloittaminen valitsematta aluetta. VAROITUS: Alueettomat ketjut täytyy siirtää jollekin alueelle ennen kuin poistat asetuksen käytöstä." + allow_uncategorized_topics: "Salli ketjujen aloittaminen valitsematta aluetta. VAROITUS: alueettomat ketjut täytyy siirtää jollekin alueelle ennen kuin poistat asetuksen käytöstä." allow_duplicate_topic_titles: "Salli ketjun aloittaminen identtisellä otsikolla." - allow_duplicate_topic_titles_category: "Salli ketjut joilla on identtiset otsikot, jos ketjut on eri alueella. allow_duplicate_topic_titles on oltava pois päältä." + allow_duplicate_topic_titles_category: "Salli ketjut, joilla on identtiset otsikot, jos ketjut on eri alueella. Asetuksen allow_duplicate_topic_titles on oltava pois päältä." unique_posts_mins: "Kuinka monen minuutin kuluttua käyttäjä voi lähettää uudestaan samansisältöisen viestin" educate_until_posts: "Näytä uuden käyttäjän ohje, kun käyttäjä alkaa kirjoittamaan ensimmäistä (n) viestiään viestikenttään." - title: "Palstan nimi, käytetään title tagissa." - site_description: "Kuvaile sivustoa yhdellä lauseella, jota käytetään meta description tagissa." - short_site_description: "Lyhyt kuvaus, jota käytetään etusivulla otsikkotagissa (title tag)." - contact_url: "URL-osoite sivuston yhteydenottoja varten. Näytetään Tietoja -sivulla kiireellisiä yhteydenottoja varten." - crawl_images: "Lataa linkatut kuvat kuvan dimensioiden määrittamiseksi." + title: "Tämän sivuston nimi, käytetään otsikkotunnisteessa." + site_description: "Kuvaile sivustoa yhdellä lauseella, jota käytetään metakuvaustunnisteessa." + short_site_description: "Lyhyt kuvaus, jota käytetään aloitussivun otsikkotunnisteessa." + contact_url: "URL-osoite sivuston yhteydenottoja varten. Näytetään Tietoja-sivulla kiireellisiä yhteydenottoja varten." + crawl_images: "Lataa linkatut kuvat kuvan mittojen määrittamiseksi." download_remote_images_to_local: "Muunna linkatut kuvat liitetiedostoiksi lataamalla ne; tämä estää kuvien rikkoontumisen vanhentuneiden linkkien vuoksi." - download_remote_images_threshold: "Vähin vapaa tila, jotta linkatut kuvat ladataan (prosenteissa)" - disabled_image_download_domains: "Linkattuja kuvia ei koskaan ladata näistä verkkotunnuksista. Pystyviivalla eroteltu lista." - editing_grace_period: "Viestin muokkaaminen (n) sekunnin sisällä sen lähettämisestä ei luo viestistä uutta versiota viestin lokiin." - editing_grace_period_max_diff: "Kuinka monen merkin muutos sallitaan katumusaikana. Jos muutos on isompi tallennetaan uusi viestirevisio (luottamustasolla 0 ja 1)." - editing_grace_period_max_diff_high_trust: "Kuinka monen merkin muutos sallitaan katumusaikana. Jos muutos on isompi tallennetaan uusi viestirevisio (luottamustasosta 2 ylöspäin)." + download_remote_images_threshold: "Vähin vapaa tila, jotta linkatut kuvat ladataan paikallisesti (prosenteissa)" + disabled_image_download_domains: "Linkattuja kuvia ei koskaan ladata näistä verkkotunnuksista. Pystyviivalla eroteltu luettelo." + editing_grace_period: "Viestin muokkaaminen (n) sekunnin sisällä sen lähettämisestä ei luo viestistä uutta versiota viestin historiaan." + editing_grace_period_max_diff: "Kuinka monen merkin muutos sallitaan muokkauksen katumusaikana. Jos muutos on isompi, tallennetaan uusi viestirevisio (luottamustasolla 0 ja 1)." + editing_grace_period_max_diff_high_trust: "Kuinka monen merkin muutos sallitaan muokkauksen katumusaikana. Jos muutos on isompi, tallennetaan uusi viestirevisio (luottamustasosta 2 ylöspäin)." staff_edit_locks_post: "Viesti lukitaan muokkauksilta, jos henkilökunnan jäsen muokkaa sitä" - post_edit_time_limit: "Lt0- tai lt1-kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0 niin voi muokata aina." - tl2_post_edit_time_limit: "Lt2+ -kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0 niin voi muokata aina." - edit_history_visible_to_public: "Salli kaikkien nähdä muokatun viestin edelliset versiot. Jos asetus otetaan pois käytöstä, vain henkilökunta näkee versiot." - delete_removed_posts_after: "Kirjoittajalta poistetut viestit poistetaan automaattisesti (n) tunnin kuluttua. Jos asetetaan 0, viestit poistetaan välittömästi." + post_edit_time_limit: "Lt0- tai lt1-kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0, niin viestiä voi muokata aina." + tl2_post_edit_time_limit: "Vähintään Lt2-kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0, niin viestiä voi muokata aina." + edit_history_visible_to_public: "Salli kaikkien nähdä muokatun viestin edelliset versiot. Jos asetus poistetaan käytöstä, vain henkilökunta näkee versiot." + delete_removed_posts_after: "Kirjoittajan poistamat viestit poistetaan automaattisesti (n) tunnin kuluttua. Jos arvoksi asetetaan 0, viestit poistetaan välittömästi." max_image_width: "Esikatselukuvan suurin sallittu leveys viestissä" max_image_height: "Esikatselukuvan suurin sallittu korkeus viestissä" fixed_category_positions: "Jos tämä on valittuna, voit muokata alueiden järjestystä. Jos tätä ei valita, alueet järjestetään aktiivisuuden mukaan." - fixed_category_positions_on_create: "Jos tämä on valittuna, alueiden järjestys pysyy samana uuden ketjun aloittamisen dialogissa (edellyttää fixed_category_positions)" + fixed_category_positions_on_create: "Jos tämä on valittuna, alueiden järjestys pysyy samana uuden ketjun aloittamisen dialogissa (edellyttää fixed_category_positions-asetusta)." add_rel_nofollow_to_user_content: 'Lisää rel nofollow kaikkeen käyttäjien lähettämään sisältöön, paitsi sivuston sisäisiin linkkeihin (sisältäen ylemmät verkkotunnukset). Jos muutat asetusta, sinun täytyy rakentaa viestit uudelleen komennolla: "rake posts:rebake"' - exclude_rel_nofollow_domains: "Lista verkkotunnuksista, joihin osoittaviin linkkeihin nofollow:ta ei lisätä. esimerkki.fi sallii automaattisesti myös sub.esimerkki.fin. Sinun tulisi vähintäänkin lisätä tämän sivuston verkkotunnus, jotta hakurobotit löytävät kaiken sisällön. Jos sivustosi muita osia on toisen verkkotunnuksen alaisuudessa, lisää nekin." - post_excerpt_maxlength: "Viestin katkelman merkkien maksimimäärä." - topic_excerpt_maxlength: "Ketjun katkelman / tiivistelmän enimmäispituus, luodaan ketjun ensimmäisestä viestistä." + exclude_rel_nofollow_domains: "Luettelo verkkotunnuksista, joihin osoittaviin linkkeihin nofollow'ta ei lisätä. Esimerkki.fi sallii automaattisesti myös verkkotunnuksen sub.esimerkki.fi. Sinun tulisi vähintäänkin lisätä tämän sivuston verkkotunnus, jotta hakurobotit löytävät kaiken sisällön. Jos sivustosi muita osia on toisen verkkotunnuksen alaisuudessa, lisää nekin." + post_excerpt_maxlength: "Viestin katkelman tai yhteenvedon merkkien maksimimäärä." + topic_excerpt_maxlength: "Ketjun katkelman tai yhteenvedon enimmäispituus, luodaan ketjun ensimmäisestä viestistä." show_pinned_excerpt_mobile: "Näytä katkelma kiinnitetyistä ketjuista mobiilinäkymässä." show_pinned_excerpt_desktop: "Näytä katkelma kiinnitetyistä ketjuista työpöytänäkymässä." post_onebox_maxlength: "Discourse-viestin Onebox-esikatselun merkkien maksimimäärä." blocked_onebox_domains: "Verkko-osoitteet, joista ei luoda Onebox-esikatselua." allowed_inline_onebox_domains: "Verkko-osoitteet, joista luodaan minimoitu Onebox-esikatselu, jos niihin linkitetään määrittämättä otsikkoa." - enable_inline_onebox_on_all_domains: "Ohita inline_onebox_domain_whitelist -asetus ja salli Onebox-esikatselut kaikista verkko-osoitteista." + enable_inline_onebox_on_all_domains: "Ohita inline_onebox_domain_whitelist-asetus ja salli Onebox-esikatselut kaikista verkko-osoitteista." max_oneboxes_per_post: "Oneboxien enimmäismäärä yhdessä viestissä" logo: "Kuva, joka toimii sivuston logona sivuston vasemmassa yläkulmassa. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätetty tyhjäksi, tilalla näytetään sivuston nimi." - logo_small: "Kuva, joka toimii sivuston pienenä logona sivuston yläkulmassa, kun vieritetään alaspäin. Valitse neliönmuotoinen kuva. Valitse neliönmallinen, 120×120-kokoinen kuva. Jos jätetty tyhjäksi, tilalla näytetään koti-merkki." - digest_logo: "Vaihtoehtoinen logo, jota käytetään sivustosi sähköpostitiivistelmien yläosassa. Valitse leveä suorakulmainen kuva. Älä käytä SVG-kuvaa. Jos jätetty tyhjäksi, `logo`-kuvaketta käytetään." - mobile_logo: "Logo, jota sivuston mobiiliversio käyttää. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätetty tyhjäksi, `logo`-kuvaketta käytetään." + logo_small: "Kuva, joka toimii sivuston pienenä logona sivuston yläkulmassa, kun vieritetään alaspäin. Valitse neliönmuotoinen kuva, jonka koko on 120×120. Jos jätetty tyhjäksi, tilalla näytetään aloitussivun symboli." + digest_logo: "Vaihtoehtoinen logo, jota käytetään sivustosi sähköpostiyhteenvetojen yläosassa. Valitse leveä suorakulmainen kuva. Älä käytä SVG-kuvaa. Jos jätetty tyhjäksi, `logo`-asetuksen kuvaa käytetään." + mobile_logo: "Logo, jota sivuston mobiiliversio käyttää. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätetty tyhjäksi, `logo`-asetuksen kuvaa käytetään." logo_dark: "Vaihtoehtoinen 'logo' -asetus sivuston tummaa värimallia varten." logo_small_dark: "Vaihtoehtoinen 'logo small' -asetus sivuston tummaa värimallia varten." mobile_logo_dark: "Vaihtoehtoinen 'mobile logo' -asetus sivuston tummaa värimallia varten." - large_icon: "Kuva, josta rakennetaan muut metadata-ikonit. Tulisi ideaalitapauksessa olla suurempi kuin 512×512. Jos jätetty tyhjäksi, logo_small -kuvaketta käytetään." - manifest_icon: "Kuva, jota käytetään logo/splash -kuvana Androidissa. Skaalataan automaattisesti kokoon 512×512. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään." - favicon: "Palstan favicon, ks. https://en.wikipedia.org/wiki/Favicon. Täytyy olla pgn, jotta toimii CDN:n kanssa. Skaalataan kokoon 32x32. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään." - apple_touch_icon: "Apple touch laitteilla käytetty kuvake. Muunnetaan automaattisesti kokoon 180x180. Jos jätetään tyhjäksi, käytetään large_icon. " - opengraph_image: "Oletuksena käytettävä opengraph-kuva, käytetään kun sivulla ei ole muuta sopivaa kuvaa. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään" - twitter_summary_large_image: "Twitter-tiivistelmäkortin \"summary large image\" (leveyttä tulisi olla ainakin 280 ja korkeutta ainakin 150). Jos jätetty tyhjäksi, tavallisen kortin metadata luodaan opengraph_image -kuvakkeen avulla." - notification_email: "Sähköpostiosoite, josta kaikki tärkeät järjestelmän lähettämät sähköpostiviestit lähetetään. Verkkotunnuksen SPF, DKIM ja reverse PTR tietueiden täytyy olla kunnossa, jotta sähköpostit menevät perille." - email_custom_headers: "Pystyviivalla eroteltu lista mukautetuista sähköpostin tunnisteista" - email_subject: "Mukauta sähköpostiviestien otsikon muoto. Katso englanninkielinen ohje: https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + large_icon: "Kuva, josta rakennetaan muut metadatakuvakkeet. Tulisi ideaalitapauksessa olla suurempi kuin 512×512. Jos jätetty tyhjäksi, logo_small-kuvaa käytetään." + manifest_icon: "Kuva, jota käytetään logo-/splash-kuvana Androidissa. Skaalataan automaattisesti kokoon 512×512. Jos jätetty tyhjäksi, large_icon-kuvaa käytetään." + favicon: "Sivuston favicon, ks. https://en.wikipedia.org/wiki/Favicon. Täytyy olla png, jotta toimii CDN:n kanssa. Skaalataan kokoon 32x32. Jos jätetty tyhjäksi, large_icon-kuvaketta käytetään." + apple_touch_icon: "Applen kosketuslaitteissa käytettävä kuvake. Skaalataan automaattisesti kokoon 180x180. Jos jätetään tyhjäksi, large_icon-kuvaketta käytetään." + opengraph_image: "Oletuksena käytettävä opengraph-kuva, käytetään, kun sivulla ei ole muita sopivia kuvia. Jos jätetty tyhjäksi, large_icon-kuvaa käytetään" + twitter_summary_large_image: "Twitter-kortin \"summary large image\" (leveys vähintään 180 ja korkeus vähintään 150). Jos jätetty tyhjäksi, tavallisen kortin metatiedot luodaan käyttämällä opengraph_image-kuvaa." + notification_email: "Sähköpostiosoite, josta kaikki tärkeät järjestelmän lähettämät sähköpostiviestit lähetetään. Tässä määritetyn verkkotunnuksen SPF, DKIM ja käänteiset PTR-tietueet täytyy olla asetettu oikein, jotta sähköpostit menevät perille." + email_custom_headers: "Pystyviivalla eroteltu luettelo mukautetuista sähköpostin otsikkotiedoista" + email_subject: "Mukautettava tavallisten sähköpostiviestien aiheen muoto. Katso englanninkielinen ohje: https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "Kertoo käyttäjälle tarkemmin, miksi hän ei pääse tiettyyn ketjuun. Huomioi: tämä vaarantaa tietoturvaa, koska käyttäjä saa tietää, että URL osoittaa olemassa olevaan ketjuun." - force_https: "Pakota sivusto käyttämään vain HTTPS:ää. VAROITUS: älä ota tätä käyttöön ennen kuin HTTPS on täysin käytössä ja toimii täysin kaikkialla! Tarkastitko käyttämäsi CDN, kaikki sosiaaliset kirjautumiset ja kaikki ulkoiset logot / muut riippuvuudet ovat myös HTTPS-yhteensopivia?" + force_https: "Pakota sivusto käyttämään vain HTTPS:ää. VAROITUS: älä ota tätä käyttöön ennen kuin HTTPS on täysin käytössä ja toimii täysin kaikkialla! Tarkastitko käyttämäsi CDN:n, kaikki sosiaaliset kirjautumiset ja kaikki ulkoiset logot ja muut riippuvuudet varmistaaksesi, että ne ovat myös HTTPS-yhteensopivia?" summary_score_threshold: "Viestin minimipistemäärä, jotta se näytetään ketjun tiivistelmässä." summary_percent_filter: "Kun käyttäjä klikkaa 'Näytä ketjun tiivistelmä', näytä paras % viesteistä" summary_max_results: "Maksimimäärä viestejä, jotka näytetään ketjun tiivistelmässä" - enable_personal_messages: "Salli luottamustason 1 saavuttaneiden käyttäjien (\"min trust level to send messages\" on säädettävissä) lähettää yksityisviestejä ja vastata niihin. Huomioi, että henkilökunta voi aina lähettää yksityisviestejä." - enable_system_message_replies: "Sallii käyttäjien vastata systeemin yksityisviesteihin, vaikka yksityisviestit eivät olisikaan käytössä" + enable_personal_messages: "Salli luottamustason 1 saavuttaneiden käyttäjien (määritettävissä viestien lähettämiseen vaadittavassa vähimmäisluottamustasossa) lähettää yksityisviestejä ja vastata niihin. Huomioi, että henkilökunta voi aina lähettää yksityisviestejä." + enable_system_message_replies: "Sallii käyttäjien vastata järjestelmän viesteihin, vaikka yksityisviestit eivät olisikaan käytössä" enable_long_polling: "Ilmoitusten käyttämä viestiväylä voi käyttää long pollingia" - long_polling_base_url: "Base URL, jota käytetään long pollingissa (kun CDN on käytössä, varmista että tähän on asetettu origin pull) esim: http://origin.site.com" - long_polling_interval: "Kuinka kauan palvelimen pitäisi odottaa ennen vastaamista asiakkaalle, kun ei ole mitään dataa jota lähettää (vain kirjautuneille käyttäjille)" - polling_interval: "Kun long polling ei ole käytössä, kuinka usein kirjautuneet käyttäjät pollaavat, millisekunneissa." + long_polling_base_url: "Perus-URL, jota käytetään long pollingissa (kun CDN tarjoaa dynaamista sisältöä, varmista että tämä on asetettu noudoksi lähteestä) esim: http://lähde.sivusto.com" + long_polling_interval: "Kuinka kauan palvelimen pitäisi odottaa ennen vastaamista asiakkaalle, kun lähetettävää dataa ei ole (vain kirjautuneille käyttäjille)" + polling_interval: "Kun long polling ei ole käytössä, kuinka usein kirjautuneet käyttäjät pollaavat millisekunneissa." anon_polling_interval: "Kuinka usein anonyymit käyttäjät pollaavat millisekunneissa" background_polling_interval: "Kuinka usein asiakkaat pollaavat, millisekunneissa (kun ikkuna ei ole aktiivisena)" - hide_post_sensitivity: "Todennäköisyys sille, että liputettu viesti piilotetaan" - silence_new_user_sensitivity: "Todennäköisyys sille, että roskapostiliputukset hiljentävät uuden käyttäjän" - auto_close_topic_sensitivity: "Todennäköisyys sille, että liputettu ketju automaattisesti suljetaan" - cooldown_minutes_after_hiding_posts: "Kuinka monta minuuttia käyttäjän tulee odottaa ennen kuin voi muokata viestiään, jonka yhteisö on liputtanut piiloon." + hide_post_sensitivity: "Todennäköisyys sille, että merkitty viesti piilotetaan" + silence_new_user_sensitivity: "Todennäköisyys sille, että roskapostimerkinnät hiljentävät uuden käyttäjän" + auto_close_topic_sensitivity: "Todennäköisyys sille, että merkitty ketju suljetaan automaattisesti" + cooldown_minutes_after_hiding_posts: "Kuinka monta minuuttia käyttäjän tulee odottaa ennen kuin voi hän muokata viestiään, joka on piilotettu yhteisön merkinnän ansiosta" max_topics_in_first_day: "Kuinka monta ketjua käyttäjä voi aloittaa ensimmäistä viestiään seuraavien 24 tunnin aikana" max_replies_in_first_day: "Kuinka monta vastausta käyttäjä voi kirjoittaa ensimmäistä viestiään seuraavien 24 tunnin aikana" tl2_additional_likes_per_day_multiplier: "Nosta tykkäysten päivittäistä rajaa tasolla lt2 (konkari) kertomalla tällä luvulla" tl3_additional_likes_per_day_multiplier: "Nosta tykkäysten päivittäistä rajaa tasolla lt3 (mestari) kertomalla tällä luvulla" tl4_additional_likes_per_day_multiplier: "Nosta tykkäysten päivittäistä rajaa tasolla lt4 (johtaja) kertomalla tällä luvulla" - num_users_to_silence_new_user: "Jos uuden käyttäjän viestit saavat num_spam_flags_to_silence_new_user roskapostiliputusta näin monelta eri käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." - num_tl3_flags_to_silence_new_user: "Jos uuden käyttäjän viestit saavat näin monta liputusta num_tl3_users_to_silence_new_user eri luottamustason 3 käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." - num_tl3_users_to_silence_new_user: "Jos uuden käyttäjän viestit saavat num_tl3_flags_to_silence_new_user liputusta näin monelta eri luottamustason 3 käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." - notify_mods_when_user_silenced: "Jos käyttäjä hiljennetään automaattisesti, lähetä viesti kaikile valvojille." - flag_sockpuppets: "Jos ketjuun vastaa uusi käyttäjä, jonka IP on sama kuin ketjun aloittajalla, liputa molemmat viestit mahdolliseksi roskapostiksi." + num_users_to_silence_new_user: "Jos uuden käyttäjän viestit saavat num_spam_flags_to_silence_new_user roskapostilmerkintää näin monelta eri käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." + num_tl3_flags_to_silence_new_user: "Jos uuden käyttäjän viestit saavat näin monta merkintää num_tl3_users_to_silence_new_user eri luottamustason 3 käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." + num_tl3_users_to_silence_new_user: "Jos uuden käyttäjän viestit saavat num_tl3_flags_to_silence_new_user merkintää näin monelta eri luottamustason 3 käyttäjältä, piilota kaikki hänen viestinsä ja estä uusien viestien lähettäminen. 0 poistaa toiminnon käytöstä." + notify_mods_when_user_silenced: "Jos käyttäjä hiljennetään automaattisesti, lähetä viesti kaikille valvojille." + flag_sockpuppets: "Jos ketjuun vastaa uusi käyttäjä, jonka IP on sama kuin ketjun aloittajalla, merkitse molemmat viestit mahdolliseksi roskapostiksi." traditional_markdown_linebreaks: "Käytä perinteisiä rivinvaihtoja Markdownissa, joka vaatii kaksi perättäistä välilyöntiä rivin vaihtoon." - enable_markdown_typographer: "Paranna tekstin luettavuutta typografisten sääntöjen avulla: suorat lainausmerkit korvataan 'kaarevilla lainausmerkeillä’, (c) (tm) korvataan symboleilla, -- korvataan m-ajatusviivalla – jne." + enable_markdown_typographer: "Paranna tekstin luettavuutta typografisten sääntöjen avulla: suorat lainausmerkit korvataan 'kaarevilla lainausmerkeillä’, (c) ja (tm) korvataan symboleilla, -- korvataan em-viivalla – jne." enable_markdown_linkify: "Tee linkin näköisestä tekstistä automaattisesti linkki: www.esimerkki.fi ja https://esimerkki,fi muutetaan automaattisesti linkeiksi" markdown_linkify_tlds: "Luettelo ylätason verkkotunnuksista, jotka muutetaan esiintyessään linkeiksi automaattisesti" - markdown_typographer_quotation_marks: "Luettelo (puoli)lainausmerkkipareista, joilla korvataan" - post_undo_action_window_mins: "Kuinka monta minuuttia käyttäjällä on aikaa perua viestiin kohdistuva toimi (tykkäys, liputus, etc)." - must_approve_users: "Henkilökunnan täytyy hyväksyä kaikki tilit, ennen uusien käyttäjien päästämistä sivustolle." - invite_code: "Käyttäjän on kirjoitettava tämä koodi, jotta tilin rekisteröinti sallitaan. Ohitetaan, kun se on tyhjä (kirjainkoko ei eroa)" + markdown_typographer_quotation_marks: "Luettelo korvattavista lainausmerkki- ja puolilainausmerkkipareista" + post_undo_action_window_mins: "Kuinka monta minuuttia käyttäjällä on aikaa perua viestiin kohdistuva toimi (tykkäys, merkintä jne.)." + must_approve_users: "Henkilökunnan täytyy hyväksyä kaikki tilit ennen uusien käyttäjien päästämistä sivustolle." + invite_code: "Käyttäjän on kirjoitettava tämä koodi, jotta tilin rekisteröinti sallitaan, ohitetaan, kun se on tyhjä (ei huomioi kirjainkokoa)" approve_suspect_users: "Lisää epäilyttävät käyttäjät tarkastusjonoon. Epäilyttävät käyttäjät ovat täydentäneet kuvauksen tai sivuston käyttäjäprofiiliin, mutta heillä ei ole lukuaktiivisuutta." review_every_post: "Kaikki viestit on tarkastettava. VAROITUS! EI SUOSITELLA RUUHKAISILLE SIVUSTOILLE." - pending_users_reminder_delay: "Ilmoita valvojille, jos uusi käyttäjä on odottanut hyväksyntää kauemmin kuin näin monta tuntia. Aseta -1, jos haluat kytkeä ilmoitukset pois päältä." persistent_sessions: "Käyttäjät pysyvät sisäänkirjautuneena, vaikka selain on suljettuna" maximum_session_age: "Käyttäjä pysyy sisäänkirjautuneena n tuntia vierailunsa jälkeen" ga_version: "Käytettävä Google Universal Analytics -versio: v3 (analytics.js), v4 (gtag)" - ga_universal_tracking_code: "Google Universal Analytics -seurantakoodin tunnus, esim .: UA-12345678-9; katso https://google.com/analytics" + ga_universal_tracking_code: "Google Universal Analytics -seurantakoodin tunnus, esim.: UA-12345678-9; katso https://google.com/analytics" ga_universal_domain_name: "Google Universal Analytics -verkkotunnus, esimerkiksi: mysite.com; katso https://google.com/analytics" - ga_universal_auto_link_domains: "Ota käyttöön Google Universal Analytics -verkkotunnusten seuranta. Asiakastunniste liitetään näihin verkkotunnuksiin ulospäin suuntautuvissa linkeissä. Tutustu Googlen Verkkotunnusten välisen seurannan määrittäminen-ohjeeseen." - enable_escaped_fragments: "Käytä Googlen Ajax-sivustoille tarkoitettua API:a, jos webcrawleria ei tunnisteta. Katso https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" + ga_universal_auto_link_domains: "Ota käyttöön Google Universal Analyticsin verkkotunnusten välinen seuranta. Asiakastunniste liitetään näihin verkkotunnuksiin ulospäin suuntautuvissa linkeissä. Tutustu Googlen Verkkotunnusten välisen seurannan määrittäminen ohjeeseen." + enable_escaped_fragments: "Käytä Googlen Ajax-sivustoille tarkoitettua APIa, jos webcrawleria ei tunnisteta. Katso https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" moderators_manage_categories_and_groups: "Salli valvojien hallita alueita ja ryhmiä" - cors_origins: "Salli lähteet CORS-pyynnöille (cross-origin request). Jokaisen lähteen pitää sisältää http:// tai https://. DISCOURSE_ENABLE_CORS asetus pitää olla valittuna ottaaksesi CORSin käyttöön." + cors_origins: "Sallitut alkuperät eri alkuperät sisältäville pyynnöille (CORS). Jokaisen alkuperän täytyy sisältää http:// tai https://. DISCOURSE_ENABLE_CORS-ympäristömuuttuja täytyy olla asetettu todeksi CORSin käyttöönottamiseksi." use_admin_ip_allowlist: "Ylläpitäjät voivat kirjautua vain IP-osoitteista, jotka on määritetty Seulottavien IP:iden listassa (Ylläpito > Lokit > Seulottavat IP:t)" blocked_ip_blocks: "Luettelo yksityisistä IP-lohkoista, joita Discourse ei tulisi koskaan indeksoida" - allowed_internal_hosts: "Luettelo sisäisistä isännistä joita Discourse voi turvallisesti indeksoida onebox-esikatselua ja muita tarkoituksia varten" - allowed_onebox_iframes: "Luettelo iframe src -verkkotunnuksista, jotka sallitaan Onebox-upotusten kautta. `*` sallii kaikki Onebox-oletusmoottorit." - allowed_iframes: "Iframe src -verkkotunnukset, jotka Discourse voi turvallisesti sallia viesteissä" + allowed_internal_hosts: "Luettelo sisäisistä isännistä, joita Discourse voi turvallisesti indeksoida onebox-esikatselua ja muita tarkoituksia varten" + allowed_onebox_iframes: "Luettelo iframe src -verkkotunnuksista, jotka sallitaan Onebox-upotusten kautta. `*` sallii kaikki Onebox-oletusmoduulit." + allowed_iframes: "Iframe src -verkkotunnusten etuliitteet, jotka Discourse voi turvallisesti sallia viesteissä" allowed_crawler_user_agents: "Hakurobottien käyttäjäagentit, jotka saavat tulla sivustolle. VAROITUS! TÄMÄ ASETUS ESTÄÄ KAIKKI HAKUROBOTIT, JOITA EI LISTATA TÄSSÄ!" - blocked_crawler_user_agents: "Käyttäjäagentin nimessä oleva kirjainkoosta riippumaton yksilöllinen merkkijono, jolla tunnistetaan estettävät hakurobotit. Ei sovelleta, jos sallittujen hakurobottien lista on määriteltynä." + blocked_crawler_user_agents: "Käyttäjäagentin nimessä oleva kirjainkoosta riippumaton yksilöllinen merkkijono, jolla tunnistetaan estettävät hakurobotit. Ei sovelleta, jos sallittujen hakurobottien luettelo on määriteltynä." slow_down_crawler_user_agents: "Hakurobottien käyttäjäagentit, joiden toiminnan nopeutta rajoitetaan hakuviivemääräyksen (Crawl-delay directive) mukaisesti" - slow_down_crawler_rate: "Jos slow_down_crawler_user_agents on määritelty tämä nopeusrajoitus koskee kaikkia hakurobotteja (kuinka monta sekuntia pitää kulua pyyntöjen välillä)" + slow_down_crawler_rate: "Jos slow_down_crawler_user_agents on määritelty, tämä nopeusrajoitus koskee kaikkia hakurobotteja (kuinka monta sekuntia pitää kulua pyyntöjen välillä)" content_security_policy: "Ota käyttöön epäilyttävän sisällön seulonta (Content-Security-Policy)" content_security_policy_report_only: "Ota käyttöön epäilyttävästä sisällöstä raportointi (Content-Security-Policy-Report-Only)" - content_security_policy_collect_reports: "Ota käyttöön CSP-seulontojen kerääminen lokiin /csp_reports" - content_security_policy_frame_ancestors: "Rajoita kuka voi upottaa iframe-kehyksiin CSP:n kautta tällä sivustolla. Hallitse sallittuja isäntiä Upottaminen -sivulla" - invalidate_inactive_admin_email_after_days: "Ylläpitäjätunnukset jotka eivät ole vierailleet sivustolla näin moneen päivään joutuvat vahvistamaan sähköpostiosoitteensa uudelleen ennen sisäänkirjautumista. Aseta 0 poistaaksesi käytöstä." - top_menu: "Mitkä painikkeet näytetään kotisivun navigointipalkissa, ja missä järjestyksessä. Esimerkiksi latest|new|unread|categories|top|read|posted|bookmarks" - post_menu: "Mitkä painikkeet näytetään viestin valikossa, ja missä järjestyksessä. Esimerkiksi like|edit|flag|delete|share|bookmark|reply" - post_menu_hidden_items: "Piilotettavat painikkeet viestin valikosta, kunnes '...' klikataan." - share_links: "Mitkä painikkeet näytetään Jaa-valikossa ja missä järjestyksessä." - site_contact_username: "Henkilökuntaan kuuluvan käyttäjä, jonka nimissä kaikki automaattiset viestit lähetetään. Jos jätetty tyhjäksi, oletuksena on System-käyttäjä." - site_contact_group_name: "Käypä ryhmän nimi, joka kutsutaan kaikkiin automaattisesti luotuihin yksityiskeskusteluihin." - send_welcome_message: "Lähetä kaikille uusille käyttäjille tervetuliaisviesti, jossa on pikakäyttöopas" + content_security_policy_collect_reports: "Ota käyttöön CSP-seulontojen kerääminen lokiin kohteessa /csp_reports" + content_security_policy_frame_ancestors: "Rajoita sitä, ketkä voivat upottaa tämän sivuston iframe-kehyksiin CSP:n kautta. Hallitse sallittuja isäntiä Upottaminen-sivulla" + invalidate_inactive_admin_email_after_days: "Ylläpitäjätilit, jotka eivät ole vierailleet sivustolla näin moneen päivään, joutuvat vahvistamaan sähköpostiosoitteensa uudelleen ennen sisäänkirjautumista. Aseta 0 poistaaksesi käytöstä." + top_menu: "Mitkä painikkeet näytetään aloitussivun navigointipalkissa ja missä järjestyksessä. Esimerkiksi latest|new|unread|categories|top|read|posted|bookmarks" + post_menu: "Mitkä painikkeet näytetään viestin valikossa ja missä järjestyksessä. Esimerkiksi like|edit|flag|delete|share|bookmark|reply" + post_menu_hidden_items: "Viestivalikossa piilotettavat valikon vaihtoehdot, ellei laajentavaa kolmea pistettä klikata." + share_links: "Mitkä kohteet näytetään jakamisvalintaikkunassa ja missä järjestyksessä." + site_contact_username: "Henkilökuntaan kuuluvan käyttäjä, jonka nimissä kaikki automaattiset viestit lähetetään. Jos jätetty tyhjäksi, oletuksena on oletusjärjestelmätili." + site_contact_group_name: "Kelvollinen ryhmän nimi, joka kutsutaan kaikkiin automaattisesti luotuihin viesteihin." + send_welcome_message: "Lähetä kaikille uusille käyttäjille tervetuloviesti, jossa on pika-aloitusopas." send_tl1_welcome_message: "Lähetä luottamustason 1 saavuttaville käyttäjille yksityinen tervetuloviesti." send_tl2_promotion_message: "Lähetä uusille luottamustason 2 käyttäjille viesti ylennyksestä." - suppress_reply_directly_below: "Älä näytä vastausten lukumäärää viestissä, jos ainoa vastaus on seuraavassa viestissä." - suppress_reply_directly_above: "Älä näytä vastauksena-painiketta viestin yläreunassa, jos viestissä on vastattu vain edelliseen viestiin." + suppress_reply_directly_below: "Älä näytä vastausten lukumäärää viestissä,, jos viestin alapuolella on vain yksi vastaus." + suppress_reply_directly_above: "Älä näytä vastauksena-merkintää viestissä, jos viestin yläpuolella on vain yksi vastaus." remove_full_quote: "Poista koko viestin lainaukset suorista vastauksista automaattisesti." - suppress_reply_when_quoting: "Älä näytä vastauksena-painiketta viestin yläreunassa, kun viestissä on lainaus." - max_reply_history: "Maksimimäärä vastauksia, jotka avataan klikattaessa 'vastauksena' painiketta" + suppress_reply_when_quoting: "Älä näytä vastauksena-painiketta viestissä, kun viestissä on lainaus." + max_reply_history: "Maksimimäärä laajennettavia vastauksia, kun \"vastauksena\" laajennetaan" topics_per_period_in_top_summary: "Ketjujen lukumäärä, joka näytetään oletuksena Suositut-listauksissa." topics_per_period_in_top_page: "Ketjujen lukumäärä, joka näytetään laajennetussa Suositut-listauksessa." - redirect_users_to_top_page: "Ohjaa uudet ja kauan poissa olleet käyttäjät automaattisesti suositut-sivulle." + redirect_users_to_top_page: "Ohjaa uudet ja kauan poissa olleet käyttäjät automaattisesti Suositut-sivulle." top_page_default_timeframe: "Suositut-sivun oletusaikajakso." moderators_view_emails: "Salli valvojien tarkastella käyttäjien sähköpostiosoitteita" - prioritize_username_in_ux: "Näytä käyttäjänimi ensimmäisenä käyttäjäsivulla, -kortissa ja viesteissä (jos poistetaan käytöstä, nimi näytetään ensin)" - enable_rich_text_paste: "Ota käyttöön automaattinen muunnos HTML:stä Markdowniin, kun tekstiä liitetään kirjoitusalueelle (kokeellinen)" - send_old_credential_reminder_days: "Muistuta vanhoista tunnistetiedoista" - email_token_valid_hours: "Unohtuneen salasanan / tilin vahvistamisen tokenit ovat voimassa (n) tuntia." - enable_badges: "Ota käyttöön ansiomerkkijärjestelmä" + prioritize_username_in_ux: "Näytä käyttäjätunnus ensimmäisenä käyttäjäsivulla, -kortissa ja viesteissä (jos poistetaan käytöstä, nimi näytetään ensin)" + enable_rich_text_paste: "Ota käyttöön automaattinen muunnos HTML:stä Markdowniin, kun tekstiä liitetään kirjoitusalueelle (kokeellinen)." + send_old_credential_reminder_days: "Muistuta vanhoista tunnistetiedoista (päivän jälkeen)" + email_token_valid_hours: "Unohtuneen salasanan / tilin aktivoinnin tunnukset ovat voimassa (n) tuntia." + enable_badges: "Ota kunniamerkkijärjestelmä käyttöön" enable_whispers: "Salli henkilökunnan yksityiskeskustelu ketjujen sisällä." - allow_index_in_robots_txt: "Määritä robots.txt -tiedostossa, että hakurobotit saavat indeksoida tätä sivustoa. Poikkeuksellisissa tapauksissa voit kokonaan kustomoida robots.txt:n." - blocked_email_domains: "Pystyviivalla eroteltu lista sähköposti-verkkotunnuksista, joista käyttäjät eivät voi luoda tiliä. Esimerkiksi mailinator.com|trashmail.net" - allowed_email_domains: "Pystyviivalla eroteltu lista sähköposti-verkkotunnuksista, joista käyttäjien pitää luoda tilinsä. VAROITUS: Käyttäjiä, joiden sähköpostiosoite on muusta verkkotunnuksesta ei sallita!" - hide_email_address_taken: "Älä kerro käyttäjälle, että käyttäjätili annetulla sähköpostiosoitteella on jo olemassa, kun hän liittyy palstalle tai kun hän pyytää salasanan palauttamista." - log_out_strict: "Kun kirjaudutaan ulos, kirjaa käyttäjä ulos kaikilta laitteilta" - version_checks: "Pingaa Discourse Hubia päivityksistä ja näytä ilmoitus /admin-hallintapaneelissa, kun uusi versio on saatavilla" - new_version_emails: "Lähetä sähköposti contact_email osoitteeseen kun uusi versio Discoursesta on saatavilla." + allow_index_in_robots_txt: "Määritä robots.txt-tiedostossa, että hakurobotit saavat indeksoida tätä sivustoa. Poikkeuksellisissa tapauksissa voit ohittaa robots.txt:n pysyvästi." + blocked_email_domains: "Pystyviivalla eroteltu luettelo sähköpostiverkkotunnuksista, joilla käyttäjät eivät voi luoda tiliä. Esimerkiksi mailinator.com|trashmail.net" + allowed_email_domains: "Pystyviivalla eroteltu luettelo sähköpostiverkkotunnuksista, joilla käyttäjien TÄYTYY luoda tilinsä. VAROITUS: käyttäjiä, joiden sähköpostiverkkotunnus ei ole luettelossa, ei sallita!" + hide_email_address_taken: "Älä kerro käyttäjälle, että käyttäjätili annetulla sähköpostiosoitteella on jo olemassa, kun hän liittyy palstalle tai pyytää salasanan palauttamista." + log_out_strict: "Kun kirjaudutaan ulos, kirjaa käyttäjä ulos KAIKILTA laitteilta" + version_checks: "Pingaa Discourse Hubia päivityksistä ja näytä ilmoitus /ylläpitäjän hallintapaneelissa, kun uusi versio on saatavilla" + new_version_emails: "Lähetä sähköposti contact_email-osoitteeseen, kun uusi versio Discoursesta on saatavilla." invite_expiry_days: "Kuinka monta päivää käyttäjäkutsujen avaimet ovat voimassa" invite_only: "Uuden käyttäjän täytyy saada kutsu luotetulta käyttäjältä tai henkilökunnalta. Julkinen rekisteröityminen on pois käytöstä." login_required: "Vaadi kirjautumista sivuston lukemiseen, estä kirjautumattomilta pääsy." - min_username_length: "Käyttäjänimen vähimmäispituus merkeissä. VAROITUS: Jos olemassa olevalla käyttäjällä tai ryhmällä on tätä lyhyempi nimi, sivustosi hajoaa!" - max_username_length: "Käyttäjänimen vähimmäispituus merkeissä. VAROITUS: Jos olemassa olevalla käyttäjällä tai ryhmällä on tätä pidempi nimi, sivustosi hajoaa!" - unicode_usernames: "Salli käyttäjänimien ja ryhmänimien sisältää Unicode-kirjaimia ja -numeroita." - reserved_usernames: "Käyttäjänimet, joita ei voi rekisteröidä. Jokerimerkkiä * voi käyttää korvaamaan merkin nolla kertaa tai useammin." + min_username_length: "Käyttäjätunnuksen vähimmäispituus merkeissä. VAROITUS: jos olemassa olevalla käyttäjällä tai ryhmällä on tätä lyhyempi nimi, sivustosi hajoaa!" + max_username_length: "Käyttäjätunnuksen enimmäispituus merkeissä. VAROITUS: jos olemassa olevalla käyttäjällä tai ryhmällä on tätä pidempi nimi, sivustosi hajoaa!" + unicode_usernames: "Salli käyttäjätunnusten ja ryhmien nimien sisältää Unicode-kirjaimia ja -numeroita." + reserved_usernames: "Käyttäjätunnukset, joita ei voi rekisteröidä. Jokerimerkkiä * voi käyttää korvaamaan merkin nolla kertaa tai useammin." min_password_length: "Salasanan vähimmäispituus." min_admin_password_length: "Ylläpitäjän salasanan vähimmäispituus." - password_unique_characters: "Vähimmäismäärä eri merkkejä salasanassa." + password_unique_characters: "Vähimmäismäärä yksilöllisiä merkkejä salasanassa." block_common_passwords: "Älä salli salasanoja, jotka ovat 10 000 yleisimmän salasanan joukossa." discourse_connect_overrides_bio: "Syrjäyttää käyttäjän kuvauksen itsestään käyttäjäprofiilissa ja estää sen muokkaamisen" enable_local_logins_via_email: "Salli käyttäjän pyytää klikattava kirjautumislinkki, joka lähetetään hänen sähköpostiinsa." - allow_new_registrations: "Salli uusien käyttäjien rekisteröityminen. Ota tämä asetus pois käytöstä estääksesi uusien käyttäjätilien luomisen." - enable_signup_cta: "Näytä palaaville kirjautumattomille käyttäjille ilmoitus, jossa kehotetaan heitä luomaan tili." - enable_google_oauth2_logins: "Ota käyttöön Google Oauth2 -tunnistautuminen. Tämä on tunnistautumistapa, jota Google nykyisellään tulee. Key ja secret vaaditaan. Katso Configuring Google login for Discourse." - google_oauth2_client_id: "Google-applikaatiosi Client ID." - google_oauth2_client_secret: "Google-applikaatiosi Client secret." - google_oauth2_prompt: "Ei-pakollinen välilyönneillä eroteltu lista string-arvoja, jotka määräävät kysyykö todennuspalvelin käyttäjää uudelleentunnistautumista ja suostumusta. Ks. käyvät arvot: https://developers.google.com/identity/protocols/OpenIDConnect#prompt" + allow_new_registrations: "Salli uusien käyttäjien rekisteröityminen. Poista tämä asetus käytöstä estääksesi uusien tilien luomisen." + enable_signup_cta: "Näytä palaaville kirjautumattomille käyttäjille ilmoitus, jossa heitä kehotetaan luomaan tili." + enable_google_oauth2_logins: "Ota käyttöön Google Oauth2 -todennus. Tämä on todennustapa, jota Google tukee tällä hetkellä. Avain ja salainen koodi vaaditaan. Katso Configuring Google login for Discourse." + google_oauth2_client_id: "Google-sovelluksesi asiakastunnus." + google_oauth2_client_secret: "Google-sovelluksesi asiakkaan salatunnus." + google_oauth2_prompt: "Valinnainen välilyönneillä eroteltu lista merkkijonoarvoja, jotka määräävät pyytääkö todennuspalvelin käyttäjältä uudelleentunnistautumista ja suostumusta. Ks. käyvät arvot: https://developers.google.com/identity/protocols/OpenIDConnect#prompt" enable_twitter_logins: "Ota käyttöön Twitter-tunnistautuminen, vaaditaan twitter_consumer_key ja twitter_consumer_secret. Katso Configuring Twitter login (and rich embeds) for Discourse." - twitter_consumer_key: "Twitter-tunnistautumisen consumer key, joka rekisteröidään palvelussa https://developer.twitter.com/apps" - twitter_consumer_secret: "Twitter-tunnistautumisen consumer secret, joka rekisteröidään palvelussa https://developer.twitter.com/apps" - enable_facebook_logins: "Ota käyttöön Facebook-tunnistautuminen, vaaditaan facebook_app_id ja facebook_app_secret. Katso Configuring Facebook login for Discourse." - facebook_app_secret: "Facebook-tunnistautumisen app secret, joka rekisteröidään palvelussa https://developers.facebook.com/apps" - enable_discord_logins: "Salli käyttäjien kirjautua Discordin avulla?" - discord_client_id: 'Discordin Client ID (Tarvitsetko? Käy Discordin kehittäjäportaalissa)' - discord_secret: "Discordin Secret Key" - enable_backups: "Salli ylläpitäjien tehdä varmuuskopioita palstasta" + twitter_consumer_key: "Twitter-todennuksen consumer key, joka rekisteröidään osoitteessa https://developer.twitter.com/apps" + twitter_consumer_secret: "Twitter-todennuksen consumer secret, joka rekisteröidään osoitteessa https://developer.twitter.com/apps" + enable_facebook_logins: "Ota käyttöön Facebook-todennus, facebook_app_id ja facebook_app_secret vaaditaan. Katso Configuring Facebook login for Discourse." + facebook_app_secret: "Facebook-todennuksen app secret, joka rekisteröidään osoitteessa https://developers.facebook.com/apps" + enable_discord_logins: "Salli käyttäjien tehdä todennus Discordin avulla?" + discord_client_id: 'Discordin asiakastunnus (Tarvitsetko sellaisen? Käy Discordin kehittäjäportaalissa)' + discord_secret: "Discordin salainen avain" + enable_backups: "Salli ylläpitäjien tehdä varmuuskopioita foorumista" allow_restore: "Salli palautus, joka korvaa KAIKEN sivuston datan! Jätä valitsematta, jos et aio palauttaa sivuston varmuuskopiota" - maximum_backups: "Tallennettuna pidettävien varmuuskopioiden maksimimäärä. Vanhemmat varmuuskopiot poistetaan automaattisesti" - automatic_backups_enabled: "Tee automaattinen varmuuskopiointi, kuten tiheysasetus on määritelty" + maximum_backups: "Tallennettuna pidettävien varmuuskopioiden enimmäismäärä. Vanhemmat varmuuskopiot poistetaan automaattisesti" + automatic_backups_enabled: "Tee automaattinen varmuuskopiointi varmuuskopiotiheydessä määritetyn mukaisesti" backup_frequency: "Kuinka monen päivän välein otetaan varmuuskopio." - s3_backup_bucket: "Amazon S3 bucket johon varmuuskopiot ladataan. VAROITUS: Varmista, että se on yksityinen." - s3_endpoint: "Kohdeasemaksi voidaan vaihtaa muu S3-yhteensopiva palvelu kuten DigitalOcean Spaces tai Minio. VAROITUS: Jätä tyhjäksi, jos käytät AWS S3:a." - s3_configure_tombstone_policy: "Ota käyttöön tombstone-hakemiston automaattinen tyhjennys. TÄRKEÄÄ: Jos ei käytössä, tilaa ei vapaudu kun ladattuja tiedostoja poistetaan." + s3_backup_bucket: "Etäsäilö, johon varmuuskopiot ladataan. VAROITUS: varmista, että se on yksityinen." + s3_endpoint: "Päätepisteeksi voidaan vaihtaa muu S3-yhteensopiva palvelu kuten DigitalOcean Spaces tai Minio. VAROITUS: Jätä tyhjäksi, jos käytät AWS S3:a." + s3_configure_tombstone_policy: "Ota käyttöön tombstone-hakemiston automaattinen tyhjennys. TÄRKEÄÄ: Jos ei käytössä, tilaa ei vapaudu, kun ladattuja tiedostoja poistetaan." backup_time_of_day: "UTC-kellonaika, jolloin varmuuskopio tehdään." - backup_with_uploads: "Sisällytä lataukset ajastettuihin varmuuskopioihin. Jos tämä on pois käytöstä, vain tietokanta varmuuskopioidaan." - backup_location: "SIjainti, jonne varmuuskopiot säilötään. TÄRKEÄÄ: S3 vaatii toimiakseen, että käyvät S3-käyttöoikeustiedot on syötetty Tiedostot-asetuksiin." - backup_gzip_compression_level_for_uploads: "Gzip-pakkausaste, jota käytetään kun pakataan ladattuja tiedostoja." - include_thumbnails_in_backups: "Sisällytä luodut esikatselukuvat varmuuskopioihin. Ottaminen pois käytöstä pienentää varmuuskopioita, mutta varmuuskopiopalautuksen yhteydessä kaikki viestit on rakennettava uudelleen." + backup_with_uploads: "Sisällytä lataukset ajastettuihin varmuuskopioihin. Jos tämä ei ole käytössä, vain tietokanta varmuuskopioidaan." + backup_location: "SIjainti, jonne varmuuskopiot säilötään. TÄRKEÄÄ: S3 vaatii toimiakseen, että käyvät S3-tunnistetiedot on syötetty Tiedostot-asetuksiin." + backup_gzip_compression_level_for_uploads: "Gzip-pakkausaste, jota käytetään palvelimeen ladattujen tiedostojen pakkaamiseen." + include_thumbnails_in_backups: "Sisällytä luodut esikatselukuvat varmuuskopioihin. Käytöstä poistaminen pienentää varmuuskopioita, mutta varmuuskopiopalautuksen yhteydessä kaikki viestit on rakennettava uudelleen." active_user_rate_limit_secs: "Kuinka usein 'last_seen_at' kenttä päivitetään, sekunneissa" verbose_localization: "Näytä laajennetut lokalisointitiedot käyttöliittymässä" previous_visit_timeout_hours: "Kuinka kauan vierailun on täytynyt kestää, jotta se lasketaan 'edelliseksi' vierailuksi, tunneissa" top_topics_formula_log_views_multiplier: "katselukertojen logaritmin kerroin (n) Suositut-listauksen kaavassa: `log(katselut) * (n) + avausviestin tykkäykset * 0.5 + PIENEMPI(tykkäysten määrä / viestien määrä, 3) + 10 + log(viestien määrä)`" top_topics_formula_first_post_likes_multiplier: "avausviestin tykkäysmäärän kerroin (n) Suositut-listauksen kaavassa: `log(katselut) * 2 + avausviestin tykkäykset * (n) + PIENEMPI(tykkäysten määrä / viestien määrä, 3) + 10 + log(viestien määrä)`" top_topics_formula_least_likes_per_post_multiplier: "tykkäykset/viestit -suhteen enimmäisarvo (n) Suositut-listauksen kaavassa: `log(katselut) * 2 + avausviestin tykkäykset * 0.5 + PIENEMPI(tykkäysten määrä / viestien määrä, (n)) + 10 + log(viestien määrä)`" - enable_safe_mode: "Salli käyttäjän mennä vikasietotilaan, jotta hän voi rajata pois ongelmanaiheuttajista lisäosat." - rate_limit_create_topic: "Ketjun aloittamisen jälkeen käyttäjän täytyy odottaa (n) sekuntia voidakseen aloittaa toisen ketjun." - rate_limit_create_post: "Viestin luomisen jälkeen käyttäjän täytyy odottaa (n) sekuntia voidakseen luoda uuden viestin." - rate_limit_new_user_create_topic: "Ketjun luomisen jälkeen uuden käyttäjän täytyy odottaa (n) sekuntia voidakseen luoda uuden ketjun." - rate_limit_new_user_create_post: "Viestin luomisen jälkeen uuden käyttäjän täytyy odottaa (n) sekuntia voidakseen luoda uuden viestin." + enable_safe_mode: "Salli käyttäjän siirtyä vikasietotilaan lisäosien vianmääritystä varten." + rate_limit_create_topic: "Ketjun aloittamisen jälkeen käyttäjän täytyy odottaa (n) sekuntia ennen toisen ketjun aloittamista." + rate_limit_create_post: "Viestin luomisen jälkeen käyttäjän täytyy odottaa (n) sekuntia ennen uuden viestin luomista." + rate_limit_new_user_create_topic: "Ketjun luomisen jälkeen uuden käyttäjän täytyy odottaa (n) sekuntia ennen uuden ketjun luomista." + rate_limit_new_user_create_post: "Viestin luomisen jälkeen uuden käyttäjän täytyy odottaa (n) sekuntia ennen uuden viestin luomista." max_likes_per_day: "Tykkäysten päivittäinen maksimimäärä per käyttäjä." - max_flags_per_day: "Liputusten päivittäinen maksimimäärä per käyttäjä." + max_flags_per_day: "Merkintöjen päivittäinen maksimimäärä per käyttäjä." max_bookmarks_per_day: "Kirjanmerkkien päivittäinen maksimimäärä per käyttäjä." max_edits_per_day: "Muokkausten päivittäinen maksimimäärä per käyttäjä." max_topics_per_day: "Kuinka monta ketjua käyttäjä voi aloittaa päivässä." max_invites_per_day: "Maksimimäärä kutsuja, jonka käyttäjä voi lähettää päivässä." - max_topic_invitations_per_day: "Maksimimäärä ketjukutsuja, jonka yksittäinen käyttäjä voi lähettää päivässä" + max_topic_invitations_per_day: "Maksimimäärä ketjukutsuja, jonka käyttäjä voi lähettää päivässä." max_logins_per_ip_per_hour: "Enimmäismäärä kirjautumisia IP-osoitetta kohden tunnissa" max_logins_per_ip_per_minute: "Enimmäismäärä kirjautumisia IP-osoitetta kohden minuutissa" - alert_admins_if_errors_per_minute: "Virheiden määrä minuutissa, jonka seurauksena hälytetään ylläpitäjä. 0 ottaa toiminnon pois käytöstä. HUOM: vaatii uudelleenkäynnistyksen." - alert_admins_if_errors_per_hour: "Virheiden määrä tunnissa, jonka seurauksena hälytetään ylläpitäjä. 0 ottaa toiminnon pois käytöstä. HUOM: vaatii uudelleenkäynnistyksen." + alert_admins_if_errors_per_minute: "Virheiden määrä minuutissa, jonka seurauksena hälytetään ylläpitäjä. 0 poistaa toiminnon käytöstä. HUOM: vaatii uudelleenkäynnistyksen." + alert_admins_if_errors_per_hour: "Virheiden määrä tunnissa, jonka seurauksena hälytetään ylläpitäjä. 0 poistaa toiminnon käytöstä. HUOM: vaatii uudelleenkäynnistyksen." categories_topics: "Kuinka monta ketjua näytetään Alueet-sivulla (/categories). Jos 0, etsitään automaattisesti arvoa, jolla kaksi saraketta pysyvät symmetrisinä (alueet ja ketjut)." suggested_topics: "Ehdotettujen ketjujen määrä ketjun alaosassa." limit_suggested_to_category: "Ehdota ketjuja vain nykyiseltä alueelta." suggested_topics_max_days_old: "Ehdotettujen ketjujen ei tulisi olla yli n päivää vanhoja." - suggested_topics_unread_max_days_old: "Ehdotusten lukemattomista ketjuista ei tule olla yli n päivää vanhoja." - clean_up_uploads: "Poista orpoutuneet liitetiedostot, joita ei käytetä viesteissä, laittoman hostauksen estämiseksi. VAROITUS: kannattaa varmuuskopioida /uploads kansio ennen tämän asetuksen ottamista käyttöön." - clean_orphan_uploads_grace_period_hours: "Varoaika (tunteina) kunnes orpoutuneet liitetiedostot poistetaan" - purge_deleted_uploads_grace_period_days: "Varoaika (päivinä) kunnes poistettu liitetiedosto tuhotaan." - purge_unactivated_users_grace_period_days: "Varoaika (päivissä) ennen kuin aktivoimaton käyttäjätili poistetaan. Aseta 0 niin aktivoimattomia käyttäjiä ei poisteta ollenkaan." - enable_s3_uploads: "Lataa liitetiedostot Amazon S3:een. Tärkeää: edellyttää toimivat S3 kirjautumistiedot (access key id ja secret access key)." - s3_upload_bucket: "Amazon S3 bucket, jonne lataukset sijoitetaan. VAROITUS: täytyy olla pienillä kirjaimilla, ei pisteitä, ei alaviivoja." - s3_access_key_id: "Amazon S3 access key id, jota käytetään kun ladataan kuvia, liitteitä ja varmuuskopioita." - s3_secret_access_key: "Amazon S3 secret access key, jota käytetään kun ladataan kuvia, liitteitä ja varmuuskopioita." - s3_region: "Amazon S3 region name -alue, jota käytetään kun ladataan kuvia ja varmuuskopioita." - s3_cdn_url: "CDN URL, jota käytetään S3:ssa sijaitseville tiedostoille (esimerkiksi https://cdn.jossain.com). VAROITUS: tämän asetuksen muuttamisen jälkeen sinun täytyy rakentaa uudelleen kaikki vanhat viestit." - avatar_sizes: "Profiilikuvista automaattisesti luotavat koot." + suggested_topics_unread_max_days_old: "Ehdotettujen lukemattomien ketjujen ei tule olla yli n päivää vanhoja." + clean_up_uploads: "Poista orpoutuneet liitetiedostot, joita ei käytetä viesteissä, laittoman hostauksen estämiseksi. VAROITUS: kannattaa varmuuskopioida /uploads-kansio ennen tämän asetuksen ottamista käyttöön." + clean_orphan_uploads_grace_period_hours: "Varoaika (tunteina) ennen kuin orpoutuneet liitetiedostot poistetaan" + purge_deleted_uploads_grace_period_days: "Varoaika (päivinä) ennen kuin poistettu liitetiedosto tuhotaan." + purge_unactivated_users_grace_period_days: "Varoaika (päivissä) ennen kuin aktivoimaton käyttäjätili poistetaan. Aseta arvoksi 0, niin aktivoimattomia käyttäjiä ei poisteta ollenkaan." + enable_s3_uploads: "Lataa liitetiedostot Amazon S3:een. Tärkeää: edellyttää toimivat S3-tunnistetiedot (access key id ja secret access key)." + s3_upload_bucket: "Amazon S3 -säilö, jonne lataukset sijoitetaan. VAROITUS: täytyy olla pienillä kirjaimilla, ei pisteitä, ei alaviivoja." + s3_access_key_id: "Amazon S3:n access key id, jota käytetään ladattaessa kuvia, liitteitä ja varmuuskopioita." + s3_secret_access_key: "Amazon S3:n secret access key, jota käytetään ladattaessa kuvia, liitteitä ja varmuuskopioita." + s3_region: "Amazon S3:n alueen nimi, jota käytetään ladattaessa kuvia ja varmuuskopioita." + s3_cdn_url: "CDN:n URL, jota käytetään S3:ssa sijaitseville tiedostoille (esimerkiksi https://cdn.jossain.com). VAROITUS: tämän asetuksen muuttamisen jälkeen sinun täytyy rakentaa uudelleen kaikki vanhat viestit." + avatar_sizes: "Avatareista automaattisesti luotavat koot." external_system_avatars_enabled: "Käytä ulkopuolista avatarpalvelua." external_system_avatars_url: "Ulkoisen avatarpalvelun URL. Sallitut vaihdokset ovat {username} {first_letter} {color} {size}" restrict_letter_avatar_colors: "Luettelo kuusinumeroisista heksadesimaalisista väriarvoista, joita käytetään kirjainavatareja luotaessa." selectable_avatars_enabled: "Pakota käyttäjä valitsemaan avatarinsa listalta." selectable_avatars: "Avatarit, joista käyttäjä voi valita." allow_all_attachments_for_group_messages: "Salli kaikki sähköpostiliitteet ryhmäviesteissä." - png_to_jpg_quality: "Muunnetun JPG-tiedoston laatu (1 on huonoin laatu, 99 on paras laatu, 100 ottaa pois käytöstä)." + png_to_jpg_quality: "Muunnetun JPG-tiedoston laatu (1 on huonoin laatu, 99 on paras laatu, 100 poistaa käytöstä)." allow_staff_to_upload_any_file_in_pm: "Salli henkilökunnan ladata minkätyyppisiä liitteitä tahansa yksityisviesteihin." - strip_image_metadata: "Poista metadata kuvista." - min_ratio_to_crop: "Suhde, jolla korkeat kuvat cropataan. Syötä tulos suhteena leveys / korkeus." - simultaneous_uploads: "Kuinka monta tiedostoa voi enintään raahata viestieditoriin" - default_invitee_trust_level: "Oletus luottamustaso (0-4) kutsutuille käyttäjille." - default_trust_level: "Uusien käyttäjien oletusarvoinen luottamustaso (0-4). VAROITUS! Tämän muuttaminen altistaa roskapostille." + strip_image_metadata: "Poista metatiedot kuvista." + min_ratio_to_crop: "Suhde, jolla korkeat kuvat rajataan. Syötä tulos leveys/korkeus-suhteena." + simultaneous_uploads: "Kuinka monta tiedostoa voi enintään vetää ja pudottaa viestieditoriin" + default_invitee_trust_level: "Oletusluottamustaso (0–4) kutsutuille käyttäjille." + default_trust_level: "Uusien käyttäjien oletusarvoinen luottamustaso (0–4). VAROITUS! Tämän muuttaminen altistaa roskapostille." tl1_requires_topics_entered: "Kuinka monessa ketjussa uuden käyttäjän täytyy käydä ennen ylentämistä luottamustasolle 1." tl1_requires_read_posts: "Kuinka monta viestiä uuden käyttäjän täytyy lukea ennen ylentämistä luottamustasolle 1." tl1_requires_time_spent_mins: "Kuinka monta minuuttia uuden käyttäjän täytyy lukea keskusteluita ennen ylentämistä luottamustasolle 1." @@ -1628,90 +1627,90 @@ fi: tl2_requires_likes_given: "Kuinka monta tykkäystä käyttäjän täytyy antaa ennen ylentämistä luottamustasolle 2." tl2_requires_topic_reply_count: "Kuinka moneen ketjuun käyttäjän täytyy vastata ennen ylentämistä luottamustasolle 2." tl3_time_period: "Luottamustason 3 vaatimuksiin liittyvän ajanjakson pituus (päivissä)" - tl3_requires_days_visited: "Monenako päivänä vähintään käyttäjän täytyy olla vieraillut sivustolla viimeisen (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. Ota ylennykset käytöstä asettamalla arvo korkeammaksi kuin lt3 aikaraja. (0 tai korkeampi)" - tl3_requires_topics_replied_to: "Moneenko ketjuun vähintään käyttäjän täytyy olla vastannut viimeisen (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. (0 tai korkeampi)" - tl3_requires_topics_viewed: "Prosentteina, kuinka suuressa osassa viimeisimpien (tl3 time period) päivän aikana aloitetuista ketjuista käyttäjän täytyy olla käynyt voidakseen saavuttaa luottamustason 3. (asteikko 0-100)" - tl3_requires_topics_viewed_cap: "Maksimimäärä katseltuja ketjuja, joka vaaditaan edellisen (tl3 time period) aikana." - tl3_requires_posts_read: "Montako prosenttia viimeisen (tl3 time period) päivän aikana luoduista viesteistä käyttäjän täytyy olla katsellut voidakseen saavuttaa luottamustason 3. (asteikko 0-100)" - tl3_requires_posts_read_cap: "Maksimimäärä luettuja viestejä, joka vaaditaan edellisen (tl3 time period) aikana." + tl3_requires_days_visited: "Monenako päivänä vähintään käyttäjän täytyy olla vieraillut sivustolla viimeisten (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. Poista käytöstä ylennykset lt3:lle asettamalla arvo korkeammaksi kuin lt3:n aikaraja. (0 tai korkeampi)" + tl3_requires_topics_replied_to: "Moneenko ketjuun vähintään käyttäjän täytyy olla vastannut viimeisten (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. (0 tai korkeampi)" + tl3_requires_topics_viewed: "Prosentteina, kuinka suuressa osassa viimeisten (tl3 time period) päivän aikana aloitetuista ketjuista käyttäjän täytyy olla käynyt voidakseen saavuttaa luottamustason 3. (0–100)" + tl3_requires_topics_viewed_cap: "Maksimimäärä katseltuja ketjuja, joka vaaditaan edellisten (tl3 time period) aikana." + tl3_requires_posts_read: "Montako prosenttia viimeisten (tl3 time period) päivän aikana luoduista viesteistä käyttäjän täytyy olla katsellut voidakseen saavuttaa luottamustason 3. (0–100)" + tl3_requires_posts_read_cap: "Maksimimäärä luettuja viestejä, joka vaaditaan edellisten (tl3 time period) päivän aikana." tl3_requires_topics_viewed_all_time: "Monessako ketjussa käyttäjän täytyy olla käynyt voidakseen saavuttaa luottamustason 3." - tl3_requires_posts_read_all_time: "Montako viestiä käyttäjän täytyy olla katsonut voidakseen saavuttaa luottamustason 3." - tl3_requires_max_flagged: "Käyttäjällä ei saa olla enempää kuin x viestiä liputettuna x eri käyttäjältä viimeisen (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. (0 tai korkeampi)" + tl3_requires_posts_read_all_time: "Montako viestiä käyttäjän täytyy olla lukenut voidakseen saavuttaa luottamustason 3." + tl3_requires_max_flagged: "Käyttäjällä ei saa olla enempää kuin x viestiä merkittynä x eri käyttäjältä viimeisten (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3. (0 tai korkeampi)" tl3_promotion_min_duration: "Kuinka montaa päivää luottamustasolle 3 ylennyksen jälkeen käyttäjä voidaa jälleen alentaa luottamustasolle 2." - tl3_requires_likes_given: "Montako tykkäystä käyttäjän täytyy olla vähintään antanut viimeisen (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3." - tl3_requires_likes_received: "Montako tykkäystä käyttäjän täytyy olla vähintään saanut viimeisen (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3." - tl3_links_no_follow: "Älä poista rel=nofollow -attribuuttia linkeistä luottamustason 3 käyttäjiltä." + tl3_requires_likes_given: "Montako tykkäystä käyttäjän täytyy olla vähintään antanut viimeisten (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3." + tl3_requires_likes_received: "Montako tykkäystä käyttäjän täytyy olla vähintään saanut viimeisten (tl3 time period) päivän aikana voidakseen saavuttaa luottamustason 3." + tl3_links_no_follow: "Älä poista rel=nofollow-määritettä linkeistä luottamustason 3 käyttäjiltä." trusted_users_can_edit_others: "Salli korkean luottamustason käyttäjän muokata toisten käyttäjien viestien sisältöjä" min_trust_to_create_topic: "Ketjun aloittamiseen vaadittava luottamustaso." - allow_flagging_staff: "Jos käytössä, käyttäjät voivat liputtaa henkilökunnan viestejä." - min_trust_to_edit_wiki_post: "Wikiviestin muokkaamiseen vaadittava luottamustaso." + allow_flagging_staff: "Jos käytössä, käyttäjät voivat merkitä henkilökunnan viestejä." + min_trust_to_edit_wiki_post: "Wiki-viestin muokkaamiseen vaadittava luottamustaso." min_trust_to_edit_post: "Viestin muokkaamiseen vaadittava luottamustaso." min_trust_to_allow_self_wiki: "Minimiluottamustaso, jolla käyttäjä voi tehdä omasta viestistään wiki-viestin." min_trust_to_send_messages: "Vähimmäisluottamustaso, jolla voi aloittaa yksityisviestiketjuja." - min_trust_to_flag_posts: "Vähimmäisluottamustaso, jolla voi liputtaa viestejä" + min_trust_to_flag_posts: "Vähimmäisluottamustaso, jolla voi merkitä viestejä" min_trust_to_post_links: "Vähimmäisluottamustaso, jolla voi lisätä linkkejä viesteihin" - allowed_link_domains: "Verkkotunnukset, joihin voi linkittää vaikkei olisikaan riittävällä luottamustasolla, jotta muuten voisi lisätä linkkejä viesteihin" + allowed_link_domains: "Verkkotunnukset, joihin käyttäjät voivat linkittää, vaikkei heillä olisikaan riittävää luottamustasoa lisätä linkkejä" newuser_max_links: "Kuinka monta linkkiä uusi käyttäjä voi lisätä viestiin." newuser_max_attachments: "Kuinka monta liitettä uusi käyttäjä voi lisätä viestiin." - newuser_max_mentions_per_post: "Kuinka monta @nimi mainintaa uusi käyttäjä voi lisätä viestiin." - newuser_max_replies_per_topic: "Uuden käyttäjän viestien maksimimäärä samassa ketjussa, kunnes joku vastaa heille." - max_mentions_per_post: "Kuinka monta @nimi mainintaa kukaan voi lisätä viestiin." + newuser_max_mentions_per_post: "Kuinka monta @nimi-ilmoitusta uusi käyttäjä voi lisätä viestiin." + newuser_max_replies_per_topic: "Uuden käyttäjän viestien maksimimäärä samassa ketjussa, kunnes joku vastaa hänelle." + max_mentions_per_post: "Kuinka monta @nimi-ilmoitusta kukaan voi lisätä viestiin." max_users_notified_per_group_mention: "Kuinka moni voi saada ilmoituksen, kun ryhmä mainitaan (jos raja ylittyy, kukaan ei saa ilmoitusta)" enable_mentions: "Salli käyttäjän mainita toinen käyttäjä." create_thumbnails: "Luo esikatselu- ja lightbox-kuvia, jotka ovat liian suuria mahtuakseen viestiin." email_time_window_mins: "Odota (n) minuuttia ennen ilmoitussähköpostien lähettämistä, jotta käyttäjällä on aikaa muokata ja viimeistellä viestinsä." - personal_email_time_window_seconds: "Odota (n) sekuntia ennen kuin lähetetään mitään yksityisiä sähköposti-ilmoituksia, jotta käyttäjillä on mahdollisuus muokata ja viimeistellä yksityisviestinsä." + personal_email_time_window_seconds: "Odota (n) sekuntia ennen kuin yksityisviestien sähköposti-ilmoitusten lähettämistä, jotta käyttäjillä on mahdollisuus muokata ja viimeistellä viestinsä." email_posts_context: "Kuinka monta edellistä vastausta liitetään kontekstiksi sähköposti-ilmoituksessa." - flush_timings_secs: "Kuinka usein timing data päivitetään palvelimelle, sekunneissa." - title_max_word_length: "Sanan enimmäispituus merkkeinä ketjun otsikossa" - title_min_entropy: "Ketjun otsikon minimientropia (uniikkeja merkkejä)." - body_min_entropy: "Viestin minimientropia (uniikkeja merkkejä)." + flush_timings_secs: "Kuinka usein ajoitustiedot päivitetään palvelimelle, sekunneissa." + title_max_word_length: "Suurin sallittu sanan pituus merkkeinä ketjun otsikossa." + title_min_entropy: "Ketjun otsikossa vaadittava minimientropia (uniikkeja merkkejä, muilla kuin englannin kielen merkeillä on suurempi painotus)." + body_min_entropy: "Ketjun leipätekstissä vaadittava minimientropia (uniikkeja merkkejä, muilla kuin englannin kielen merkeillä on suurempi painotus)." allow_uppercase_posts: "Salli pelkillä isoilla kirjaimilla kirjoittaminen otsikossa tai viestin leipätekstissä." max_consecutive_replies: "Kuinka monta perättäistä viestiä käyttäjä voi kirjoittaa ketjuun ennen kuin häntä estetään lähettämästä uutta vastausta" title_fancy_entities: "Muunna tavalliset ASCII-merkit hienommiksi HTML-merkinnöiksi ketjujen otsikoissa SmartyPantsin avulla https://daringfireball.net/projects/smartypants/" min_title_similar_length: "Ketjun otsikon minimipituus, kunnes sitä verrataan muihin samankaltaisiin ketjuihin." - desktop_category_page_style: "/Keskustelualueet-sivun visuaalinen tyyli." - category_colors: "Lista alueiden sallituista väriarvoista, heksadesimaaleina." - category_style: "Aluemerkin tyyli." + desktop_category_page_style: "/categories-sivun visuaalinen tyyli." + category_colors: "Luettelo alueiden sallituista väriarvoista heksadesimaaleina." + category_style: "Aluemerkkien visuaalinen tyyli." default_dark_mode_color_scheme_id: "Värimalli, jota käytetään tummassa tilassa." dark_mode_none: "Ei valittu" - max_attachment_size_kb: "Liitetyn tiedoston suurin sallittu koko kilotavuissa. Tämä pitää asettaa myös nginxin (client_max_body_size) / apachen tai proxyn asetuksista." + max_attachment_size_kb: "Liitetyn tiedoston suurin sallittu koko kilotavuissa. Tämä täytyy asettaa myös nginxin (client_max_body_size) / apachen tai välityspalvelimen asetuksissa." authorized_extensions: "Liitetiedostojen sallitut tiedostopäätteet (käytä '*' salliaksesi kaikki tiedostotyypit)" - authorized_extensions_for_staff: "Luettelo tiedostopäätteistä, jotka ovat sallittuja henkilökunnan jäsenille niiden lisäksi, jotka on määritelty sivustoasetuksella \"authorized_extensions\". (salli asettamalla * kaikki tiedostotyypit)" + authorized_extensions_for_staff: "Luettelo tiedostopäätteistä, jotka ovat sallittuja henkilökunnan jäsenille niiden lisäksi, jotka on määritelty sivustoasetuksella \"authorized_extensions\". (käytä '*' salliaksesi kaikki tiedostotyypit)" theme_authorized_extensions: "Teemalatausten liitetiedostojen sallitut tiedostopäätteet (käytä '*' salliaksesi kaikki tiedostotyypit)" max_similar_results: "Kuinka monta samankaltaista ketjua näytetään viestikentän päällä uutta ketjua aloitettaessa. Vertailu perustuu sekä otsikkoon että leipätekstiin." title_prettify: "Estä yleiset kirjoitusvirheet otsikossa, kuten pelkät isot kirjaimet, pieni ensimmäinen kirjain, useat !- ja ?-merkit ym." title_remove_extraneous_space: "Poista lopettavia välimerkkejä edeltävät tyhjät merkit." automatic_topic_heat_values: 'Päivitä "topic views heat" ja "topic post like heat" -asetuksia sivuston aktiivisuuden perusteella automaattisesti.' - topic_views_heat_low: "Näin monen katselun jälkeen katselut-saraketta korostetaan hieman." - topic_views_heat_medium: "Näin monen katselun jälkeen katselut-saraketta korostetaan kohtalaisesti." - topic_views_heat_high: "Näin monen katselun jälkeen katselut-saraketta korostetaan voimakkaasti." - cold_age_days_low: "Kun keskustelua on käyty näin monta päivää, viimeisimmän viestin päivämäärää himmennetään hieman.." + topic_views_heat_low: "Näin monen katselun jälkeen katselut-kenttää korostetaan hieman." + topic_views_heat_medium: "Näin monen katselun jälkeen katselut-kenttää korostetaan kohtalaisesti." + topic_views_heat_high: "Näin monen katselun jälkeen katselut-kenttää korostetaan voimakkaasti." + cold_age_days_low: "Kun keskustelua on käyty näin monta päivää, viimeisimmän viestin päivämäärää himmennetään hieman." cold_age_days_medium: "Kun keskustelua on käyty näin monta päivää, viimeisimmän viestin aikaa himmennetään kohtalaisesti." cold_age_days_high: "Kun keskustelua on käyty näin monta päivää, viimeisimmän viestin aikaa himmennetään paljon." - history_hours_low: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa ikonia hieman." - history_hours_medium: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa ikonia kohtalaisesti." - history_hours_high: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa ikonia voimakkaasti." - topic_post_like_heat_low: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän saraketta korostetaan hieman." - topic_post_like_heat_medium: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän saraketta korostetaan kohtalaisesti." - topic_post_like_heat_high: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän saraketta korostetaan voimakkaasti." + history_hours_low: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa kuvaketta hieman." + history_hours_medium: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa kuvaketta kohtalaisesti." + history_hours_high: "Kun viestiä on muokattu näin monen tunnin sisällä, korostetaan muokkauksesta kertovaa kuvaketta voimakkaasti." + topic_post_like_heat_low: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän kenttää korostetaan hieman." + topic_post_like_heat_medium: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän kenttää korostetaan kohtalaisesti." + topic_post_like_heat_high: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän kenttää korostetaan voimakkaasti." faq_url: "Jos haluat käyttää sivuston ulkopuolella ylläpidettyä UKK-listaa, syötä URL tähän." tos_url: "Jos haluat ylläpitää käyttöehtoja sivuston ulkopuolella, syötä URL tähän." privacy_policy_url: "Jos haluat ylläpitää tietosuojaselostetta sivuston ulkopuolella, syötä URL tähän." - log_anonymizer_details: "Pidetäänkö käyttäjästä tietoa lokeissa anonymisoinnin jälkeen. Jos noudatat GDPR:ää sinun on kytkettävä tämä pois käytöstä." - newuser_spam_host_threshold: "Kuinka monta kertaa uusi käyttäjä voi linkittää samalle sivustolle `newuser_spam_host_posts` viesteissään, ennen kuin se tulkitaan roskapostin lähettämiseksi." + log_anonymizer_details: "Pidetäänkö käyttäjästä tietoa lokeissa anonymisoinnin jälkeen. Jos noudatat GDPR:ää, sinun on poistettava tämä käytöstä." + newuser_spam_host_threshold: "Kuinka monta kertaa uusi käyttäjä voi linkittää samalle sivustolle `newuser_spam_host_posts` viestissään, ennen kuin se tulkitaan roskapostin lähettämiseksi." allowed_spam_host_domains: "Lista verkkotunnuksista, joita ei oteta huomioon roskapostin tunnistamisessa. Uusilla käyttäjillä ei ole rajoituksia linkkaamisessa näihin tunnuksiin." topic_view_duration_hours: "Laske uusi ketjun katselu kerran per IP/käyttäjä joka N:s tunti" user_profile_view_duration_hours: "Laske uusi profiilin katselu kerran per IP/käyttäjä joka N:s tunti" - levenshtein_distance_spammer_emails: "Verrattaessa sähköpostiosoitteita tunnettuihin roskapostittajiin, näin monen merkin ero saa vielä aikaan löydöksen." + levenshtein_distance_spammer_emails: "Verrattaessa sähköpostiosoitteita tunnettuihin roskapostittajiin, näin monen merkin ero saa vielä aikaan sumean osuman." max_new_accounts_per_registration_ip: "Jos samasta IP-osoitteesta on jo (n) luottamustason 0 käyttäjätiliä (eikä yhtään henkilökunnan tai vähintään LT2), lakkaa hyväksymästä uusia rekisteröitymisiä tästä IP:stä." - min_ban_entries_for_roll_up: "Kun Kääri-painiketta painetaan, luodaan IP-porttikielloista aliverkon kattavia kieltoja jos kieltoja on asettu vähintään (N) määrä." + min_ban_entries_for_roll_up: "Kun Kokoa-painiketta painetaan, luodaan IP-porttikielloista aliverkon kattavia, kieltoja jos kieltoja on asettu vähintään (N) määrä." max_age_unmatched_emails: "Poista osumattomat seulotut sähköpostiosoitteet (N) päivän jälkeen." max_age_unmatched_ips: "Poista osumattomat seulotut IP-osoitteet (N) päivän jälkeen." - num_flaggers_to_close_topic: "Kuinka monta eri liputtajaa tarvitaan, jotta ketju voi mennä tauolle puuttumistoimia odottamaan" + num_flaggers_to_close_topic: "Kuinka monta eri merkitsijää tarvitaan, jotta ketju voi mennä tauolle puuttumistoimia odottamaan" num_hours_to_close_topic: "Kuinka moneksi tunniksi ketju menee tauolle puuttumistoimia odottamaan." - auto_respond_to_flag_actions: "Ota käyttöön automaattinen vastaus lippua poistettaessa." - min_first_post_typing_time: "Minimimäärä aikaa millisekunneissa, joka käyttäjän täytyy kirjoittaa ensimmäistä viestiään. Jos rajaa ei saavuteta, viesti lisätään automaattisesti hyväksyttävien jonoon. Aseta 0 ottaaksesi pois käytöstä (ei suositella)." + auto_respond_to_flag_actions: "Ota käyttöön automaattinen vastaus merkintää poistettaessa." + min_first_post_typing_time: "Minimimäärä aikaa millisekunneissa, joka käyttäjän täytyy kirjoittaa ensimmäistä viestiään. Jos rajaa ei saavuteta, viesti lisätään automaattisesti hyväksyttävien jonoon. Aseta 0 poistaaksesi käytöstä (ei suositella)." auto_silence_fast_typers_on_first_post: "Hiljennä automaattisesti käyttäjät, joiden ensimmäisen viestin kirjoittamiseen ei kulu min_first_post_typing_time" auto_silence_fast_typers_max_trust_level: "Enimmäisluottamustaso, jolla nopea kirjoittaja voidaan hiljentää automaattisesti" reviewable_claiming: "Tarvitseeko arvioitava sisältö omia ennen kuin sen voi käsitellä?" @@ -1721,30 +1720,30 @@ fi: reply_by_email_address: "Saapuvien sähköpostivastausten sähköpostiosoitekaava, esimerkiksi: %%{reply_key}@reply.esimerkki.fi or replies+%%{reply_key}@esimerkki.fi" alternative_reply_by_email_addresses: "Lista vaihtoehtoisista saapuvien sähköpostivastausten sähköpostiosoitekaavoista, esimerkiksi: %%{reply_key}@reply.esimerkki.fi tai replies+%%{reply_key}@esimerkki.fi" incoming_email_prefer_html: "Käytä HTML:ää tekstin sijaan saapuvissa sähköposteissa." - strip_incoming_email_lines: "Poista saapuvien sähköpostien jokaisen rivin alusta ja lopusta tyhjämerkit." + strip_incoming_email_lines: "Poista saapuvien sähköpostien jokaisen rivin alusta ja lopusta tyhjät merkit." disable_emails: "Estä Discoursea lähettämästä minkäänlaisia sähköposteja. Ota pois sähköpostit kaikilta käyttäjiltä valitsemalla \"yes\" . \"Non-staff\" poistaa sähköpostit vain muilta kuin henkilökunnalta." - strip_images_from_short_emails: "Poista kuvat sähköposteista, joiden koko on alle 2800 tavua" + strip_images_from_short_emails: "Poista kuvat sähköposteista, joiden koko on alle 2 800 tavua" short_email_length: "Lyhyen sähköpostin pituus tavuissa" display_name_on_email_from: "Näytä sähköpostien lähettäjinä käyttäjien koko nimet" unsubscribe_via_email: "Salli käyttäjän lakkauttaa sähköposti-ilmoitukset lähettämällä sähköpostiviesti, jonka otsikossa tai leipätekstissä esiintyy sana \"unsubscribe\"" - unsubscribe_via_email_footer: "Liitä sähköpostiviestien alaosaan mailto: linkki, jonka avulla saaja voi lakkauttaa sähköposti-ilmoitukset" + unsubscribe_via_email_footer: "Liitä sähköpostiviestien alaosaan mailto:-linkki, jonka avulla saaja voi lakkauttaa sähköposti-ilmoitukset" delete_email_logs_after_days: "Poista sähköpostilokit (N) päivän jälkeen. Aseta 0 säilyttääksesi ikuisesti." disallow_reply_by_email_after_days: "Estä vastaaminen sähköpostitse (N) päivän jälkeen. 0 sallii vastaamisen aina." max_emails_per_day_per_user: "Käyttäjälle päivässä lähetettävien sähköpostien enimmäismäärä. Aseta 0, jos et halua rajoittaa." enable_staged_users: "Luo automaattisesti esikäyttäjiä, kun saapuvia sähköposteja käsitellään." maximum_staged_users_per_email: "Enimmäismäärä automaattisesti luotuja esikäyttäjiä, kun käsitellään saapuvaa sähköpostia." - auto_generated_allowlist: "Lista sähköpostiosoitteista, joiden viestejä ei tarkasteta automaattisesti luodun sisällön osalta. Esimerkki: foo@bar.com|discourse@bar.com" + auto_generated_allowlist: "Luettelo sähköpostiosoitteista, joiden viestejä ei tarkasteta automaattisesti luodun sisällön osalta. Esimerkki: foo@bar.com|discourse@bar.com" block_auto_generated_emails: "Estä saapuvat sähköpostit, jotka tunnistetaan automaattisesti luoduiksi." ignore_by_title: "Jätä sähköpostit huomiotta niiden otsikon perusteella." - mailgun_api_key: "Mailgunin Secret API key, jolla verifioidaan webhook-viestit." + mailgun_api_key: "Mailgunin salainen API-avain, jolla vahvistetaan webhook-viestit." soft_bounce_score: "Lisättävät palautuspisteet, kun hetkellinen palautus tapahtuu." hard_bounce_score: "Lisättävät palautuspisteet, kun pysyvä palautus tapahtuu." bounce_score_threshold: "Palautuspistemäärä, jonka ylityttyä käyttäjälle ei lähetetä sähköpostia." reset_bounce_score_after_days: "Nollaa palautuspisteet X päivä kuluttua." forwarded_emails_behaviour: "Kuinka Discourseen välitettyjä sähköposteja käsitellään" always_show_trimmed_content: "Näytä kaikkialla saapuvien sähköpostien karsitut osat. VAROITUS: voi paljastaa sähköpostiosoitteita." - private_email: "Älä sisällytä sähköpostien otsikoihin äläkä leipäteksteihin viestien tai ketjujen sisältöä. HUOM: tämä ottaa käytöstä myös tiivistelmäsähköpostit." - post_excerpts_in_emails: "Sähköposti-ilmoituksiin laita aina katkelmia kokonaisten viestien sijaan." + private_email: "Älä sisällytä sähköpostien otsikoihin äläkä leipäteksteihin viestien tai ketjujen sisältöä. HUOM: tämä poistaa käytöstä myös tiivistelmäsähköpostit." + post_excerpts_in_emails: "Sähköposti-ilmoituksissa, lisää aina katkelmia kokonaisten viestien sijaan." raw_email_max_length: "Enintään kuinka monta merkkiä säilytetään saapuvasta sähköpostista." raw_rejected_email_max_length: "Enintään kuinka monta merkkiä säilytetään hylätystä saapuvasta sähköpostista." delete_rejected_email_after_days: "Poista yli (n) päivää vanhat hylätyt sähköpostit." @@ -1752,141 +1751,141 @@ fi: pop3_polling_enabled: "Pollaa sähköpostivastaukset POP3:lla." pop3_polling_ssl: "Käytä SSL-salausta yhdistettäessä POP3-palvelimeen. (Suositellaan)" pop3_polling_openssl_verify: "Varmenna TLS-palvelinsertifikaatti (Oletus: päällä)" - pop3_polling_period_mins: "Tiheys minuuteissa kuinka usein POP3 tililtä tarkastetaan uudet sähköpostit. HUOM: vaatii uudelleenkäynnistyksen." - pop3_polling_port: "POP3 pollauksen portti." - pop3_polling_host: "POP3 pollauksen host." - pop3_polling_username: "POP3 käyttäjänimi" - pop3_polling_password: "POP3 käyttäjätilin salasana." - pop3_polling_delete_from_server: "Poista sähköpostit palvelimelta. HUOM: Jos tämä on pois päältä sinun täytyy siivota saapuvien sähköpostien kansio manuaalisesti" - log_mail_processing_failures: "Kirjaa kaikki sähköpostin prosessoinnin virheet lokiin /logs" + pop3_polling_period_mins: "Tiheys minuuteissa kuinka usein POP3-tililtä tarkastetaan uudet sähköpostit. HUOM: vaatii uudelleenkäynnistyksen." + pop3_polling_port: "POP3-tilin pollauksen portti." + pop3_polling_host: "Isäntä, jolta pollataan sähköposteja POP3:n kautta." + pop3_polling_username: "Sen POP3-tilin käyttäjätunnus, jolta pollataan sähköposteja." + pop3_polling_password: "Sen POP3-tilin salasana, jolta pollataan sähköposteja." + pop3_polling_delete_from_server: "Poista sähköpostit palvelimelta. HUOM: Jos poistat tämän käytöstä, sinun täytyy siivota saapuvien sähköpostien kansio manuaalisesti" + log_mail_processing_failures: "Kirjaa kaikki sähköpostin käsittelyvirheet lokiin: /logs" email_in: 'Salli käyttäjien aloittaa uusia ketjuja sähköpostitse (vaatii manuaalisen tai pop3-pollauksen). Määritä osoitteet jokaiselle alueelle erikseen niiden Asetukset-välilehdiltä. ' email_in_min_trust: "Vähimmäisluottamustaso, jonka käyttäjä tarvitsee uuden ketjun aloittamiseen sähköpostitse." - email_in_spam_header: "Sähköpostiheader joka tunnistaa roskapostin." - email_prefix: "Sähköpostin otsikossa käytettävä [tunniste]. Jos et aseta arvoa, oletusarvona käytetään 'otsikkoa'." - email_site_title: "Sähköpostin lähettäjänä käytettävä nimi. Jos arvoa ei ole asetettu, oletuksena käytetään 'title'. Jos 'title' sisältää merkkejä joita ei sallita sähköpostin lähettäjän nimessä, käytä tätä asetusta." - find_related_post_with_key: "Käytä vain vastausavainta tunnistettaessa, mihin viestiin viesti on vastaus. VAROITUS: pois päältä kytkeminen mahdollistaa sähköpostiosoitteeseen pohjautuvan toisena esiintymisen." + email_in_spam_header: "Sähköpostin otsikkotiedot roskapostin tunnistamiseksi." + email_prefix: "Sähköpostin aiheessa käytettävä [tunniste]. Jos et aseta arvoa, oletusarvona käytetään 'otsikkoa'." + email_site_title: "Sähköpostin lähettäjänä käytettävä nimi. Jos arvoa ei ole asetettu, oletuksena käytetään 'otsikkoa'. Jos 'otsikko' sisältää merkkejä, joita ei sallita sähköpostin lähettäjän nimessä, käytä tätä asetusta." + find_related_post_with_key: "Käytä vain vastausavainta vastatun viestin löytämiseen. VAROITUS: tämän poistaminen käytöstä mahdollistaa sähköpostiosoitteeseen pohjautuvan toisena esiintymisen." minimum_topics_similar: "Kuinka monta ketjua täytyy olla olemassa, jotta samankaltaisia ketjuja näytetään uutta ketjua aloitettaessa." - relative_date_duration: "Monenako päivänä viestin lähettämisen jälkeen päivämäärät näytetään suhteellisina (7p) eikä absoluuttisina (20 Huhti)." + relative_date_duration: "Kuinka montaa päivää viestin lähettämisen jälkeen päivämäärät näytetään suhteellisina (7 pv) eikä absoluuttisina (20. huhtikuuta)." delete_user_max_post_age: "Älä salli käyttäjien poistamista, joiden ensimmäinen viesti on vanhempi kuin (x) päivää." - delete_all_posts_max: "Kerralla poistettavien viestien maksimimäärä Poista kaikki viestit-painikkeella. Jos käyttäjällä on enemmän viestejä, niitä ei voi poistaa kerralla eikä käyttäjää voi poistaa." - username_change_period: "Kuinka monen päivän ajan voi vaihtaa käyttäjänimeään sen jälkeen, kun rekisteröityy (0 estää käyttäjänimen muuttamisen)." + delete_all_posts_max: "Kerralla poistettavien viestien maksimimäärä Poista kaikki viestit -painikkeella. Jos käyttäjällä on enemmän viestejä, niitä ei voi poistaa kerralla eikä käyttäjää voi poistaa." + username_change_period: "Kuinka monen päivän ajan käyttäjätunnuksen voi vaihtaa rekisteröitymisen jälkeen (0 estää käyttäjätunnuksen vaihtamisen)." email_editable: "Salli käyttäjien vaihtaa sähköpostiosoitteensa tilin luomisen jälkeen." logout_redirect: "Minne selain ohjataan uloskirjautumisen jälkeen (esim: https://esimerkki.fi/logout)" allow_uploaded_avatars: "Salli käyttäjien ladata oma profiilikuva." - default_avatars: "Verkko-osoitteet profiilikuviin, joita käytetään oletuksena uusille käyttäjille." + default_avatars: "URL-osoitteet avatareihin, joita käytetään oletuksena uusille käyttäjille, kunnes he vaihtavagt sen." automatically_download_gravatars: "Lataa käyttäjille Gravatarit automaattisesti tilin luonnin ja sähköpostin vaihdon yhteydessä." - digest_topics: "Yläraja sähköpostikoosteessa näytettävien suosittujen ketjujen määrälle." - digest_posts: "Yläraja sähköpostikoosteessa näytettävien suosittujen viestien määrälle." - digest_other_topics: "Yläraja sähköpostikoosteen 'Uutta seuraamissasi ketjuissa ja alueissa' -osiossa näytettävien ketjujen määrälle." - digest_min_excerpt_length: "Vähimmäispituus merkeissä viestin katkelmalle, joka näytetään sähköpostikoosteissa." - suppress_digest_email_after_days: "Älä lähetä sähköpostikoostetta käyttäjille, jotka eivät ole vierailleet sivustolla yli (n) päivään." - digest_suppress_categories: "Älä liitä näiden alueiden viestejä sähköpostikoosteisiin." - disable_digest_emails: "Poista sähköpostikoosteet kaikilta käyttäjiltä." - apply_custom_styles_to_digest: "Mukautettua sähköpostipohjaa ja CSS sovelletaan tiivistelmäsähköposteihin." - email_accent_bg_color: "Kakkosväri, jota käytetään joidenkin elementtien taustavärinä HTML-sähköpostiviesteissä. Anna värin nimi englanniksi ('red') tai sen Hex-arvo ('#FFF000')." - email_accent_fg_color: "Sähköpostiviestin taustavärin päällä olevan tekstin väri HTML-sähköpostiviesteissä. Anna värin nimi englanniksi ('white') tai sen Hex-arvo ('#FFFFFF')." - email_link_color: "Linkkien väri HTML-sähköpostiviesteissä. Anna värin nimi ('blue') tai sen Hex-arvo ('#0000FF')." + digest_topics: "Yläraja yhteenvetosähköpostissa näytettävien suosittujen ketjujen määrälle." + digest_posts: "Yläraja yhteenvetosähköpostissa näytettävien suosittujen viestien määrälle." + digest_other_topics: "Yläraja yhteenvetosähköpostissa 'Uutta seuraamissasi ketjuissa ja alueissa' -osiossa näytettävien ketjujen määrälle." + digest_min_excerpt_length: "Vähimmäispituus merkeissä viestin katkelmalle, joka näytetään yhteenvetosähköpostissa." + suppress_digest_email_after_days: "Älä lähetä yhteenvetosähköpostia käyttäjille, jotka eivät ole vierailleet sivustolla yli (n) päivään." + digest_suppress_categories: "Älä liitä näiden alueiden viestejä yhteenvetosähköposteihin." + disable_digest_emails: "Poista yhteenvetosähköpostit käytöstä kaikilta käyttäjiltä." + apply_custom_styles_to_digest: "Mukautettua sähköpostipohjaa ja CSS:ää käytetään yhteenvetosähköposteihin." + email_accent_bg_color: "Kakkosväri, jota käytetään joidenkin elementtien taustavärinä HTML-sähköpostiviesteissä. Anna värin nimi englanniksi ('red') tai sen hex-arvo ('#FFF000')." + email_accent_fg_color: "Sähköpostiviestin taustavärin päällä olevan tekstin väri HTML-sähköpostiviesteissä. Anna värin nimi englanniksi ('white') tai sen hex-arvo ('#FFFFFF')." + email_link_color: "Linkkien väri HTML-sähköpostiviesteissä. Anna värin nimi englanniksi ('blue') tai sen hex-arvo ('#0000FF')." detect_custom_avatars: "Tarkistetaanko, ovatko käyttäjät ladanneet oman profiilikuvan." max_daily_gravatar_crawls: "Korkeintaan kuinka monta kertaa Discourse tarkistaa avatarit Gravatarista päivässä" - enable_user_directory: "Näytä hakemisto käyttäjistä" - enable_group_directory: "Tarjoa luettelo ryhmistä selailtavaksi" + enable_user_directory: "Näytä hakemisto käyttäjistä selattavaksi" + enable_group_directory: "Näytä hakemisto ryhmistä selattavaksi" allow_anonymous_posting: "Salli käyttäjien vaihtaa anonyymiin tilaan" - anonymous_posting_min_trust_level: "Anonyymin tilan käyttämiseen vaadittava luottamustila" + anonymous_posting_min_trust_level: "Anonyymien viestien lähettämiseen vaadittava luottamustila" anonymous_account_duration_minutes: "Suojellaksesi anonymiteettiä, luo käyttäjälle uusi anonyymi tili N minuutin välein. Esimerkki: jos arvoksi asetetaan 600, kun 600 minuuttia tulee kuluneeksi edellisestä viestistä JA käyttäjä vaihtaa anonyymiin tilaan, luodaan uusi anonyymi tili." - hide_user_profiles_from_public: "Älä näytä käyttäjäkortteja, käyttäjäprofiileita tai käyttäjähakemistoa kirjautumattomille käyttäjille." + hide_user_profiles_from_public: "Älä näytä käyttäjäkortteja, käyttäjäprofiileita tai käyttäjähakemistoa anonyymeille käyttäjille." allow_featured_topic_on_user_profiles: "Salli käyttäjän esitellä linkkiä ketjuun käyttäjäkortissaan ja profiilissaan." - show_inactive_accounts: "Salli kirjautuneiden käyttähien selailla aktivoimattomia käyttäjätilejä." + show_inactive_accounts: "Salli kirjautuneiden käyttäjien selata aktivoimattomia käyttäjätilejä." hide_suspension_reasons: "Älä näytä hyllytysten syitä julkisesti käyttäjäprofiileissa." log_personal_messages_views: "Pidä lokia siitä, kun ylläpitäjät lukevat toisten käyttäjien/ryhmien yksityiskeskusteluja." - ignored_users_count_message_threshold: "Huomauta valvojia jos monet käyttäjät estävät tietyn käyttäjän." - ignored_users_message_gap_days: "Millaisin väliajoin henkilökuntaa huomautetaan käyttäjästä, jonka monet muut ovat estäneet." - clean_up_inactive_users_after_days: "Kuinka monen päivän kuluttua epäaktiivinen käyttäjä (luottamustaso 0 eikä yhtään viestiä) poistetaan. Arvo 0 poistaa siivouksen käytöstä." + ignored_users_count_message_threshold: "Huomauta valvojille, jos näin moni käyttäjä sivuuttaa tietyn käyttäjän." + ignored_users_message_gap_days: "Millaisin väliajoin valvojia huomautetaan uudelleen käyttäjästä, jonka monet muut ovat sivuuttaneet." + clean_up_inactive_users_after_days: "Kuinka monen päivän kuluttua ei-aktiivinen käyttäjä (luottamustaso 0 eikä yhtään viestiä) poistetaan. Arvo 0 poistaa siivouksen käytöstä." user_selected_primary_groups: "Salli käyttäjän asettaa ensisijainen ryhmänsä itse" - allowed_user_website_domains: "Käyttäjän kotisivu voi olla näiden verkkotunnusten alainen. Pystyviivoin erotettu lista." + allowed_user_website_domains: "Käyttäjän verkkosivusto voi olla näiden verkkotunnusten alainen. Pystyviivoin erotettu lista." allow_profile_backgrounds: "Salli käyttäjien ladata profiilin taustakuva." - sequential_replies_threshold: "Kuinka monen peräkkäisen viestin jälkeen yhdessä ketjussa käyttäjää muistutetaan peräkkäisistä vastauksista." + sequential_replies_threshold: "Kuinka monen peräkkäisen viestin jälkeen yhdessä ketjussa käyttäjää muistutetaan liian monesta peräkkäisestä vastauksesta." get_a_room_threshold: "Kuinka monta viestiä tulee kohdistaa samalle käyttäjälle samassa ketjussa, jotta näytetään varoitus." - enable_mobile_theme: "Mobiililaitteet käyttävät erillistä teemaa ja kahden välillä on mahdollista vaihtaa. Poista asetus käytöstä, jos haluat käyttää omaa tyylitiedostoa, joka on mukautuva eri laitteille." - dominating_topic_minimum_percent: "Kuinka monta prosenttia ketjun viesteistä käyttäjän täytyy kirjoittaa, ennen kuin tulee muistutetuksi ketjun dominoinnista." + enable_mobile_theme: "Mobiililaitteet käyttävät mobiiliystävällistä teemaa, jonka voi vaihtaa koko sivustoon. Poista asetus käytöstä, jos haluat käyttää omaa tyylitiedostoa, joka on mukautuva eri laitteille." + dominating_topic_minimum_percent: "Kuinka monta prosenttia ketjun viesteistä käyttäjän täytyy kirjoittaa, ennen kuin häntä muistutetaan ketjun dominoinnista." disable_avatar_education_message: "Poista opastusviesti käytöstä, joka näytetään kun profiilikuvaa vaihdetaan." suppress_uncategorized_badge: "Älä näytä alueettomille ketjuille tunnusta ketjujen listauksissa." header_dropdown_category_count: "Kuinka monta aluetta näytetään yläpalkin pudotusvalikossa." - permalink_normalizations: "Sovella tätä säännöllistä lauseketta ennen ikilinkkien sovittamista, esim. /(topic.*)\\?.*/\\1 riisuu hakulausekkeet ketjujen reiteistä. Muoto on regex+string, \\1 jne. avulla pääset käsiksi captureihin." + permalink_normalizations: "Sovella tätä säännöllistä lauseketta ennen pysyvien linkkien sovittamista, esim. /(topic.*)\\?.*/\\1 poistaa hakulausekkeet ketjujen reiteistä. Muoto on regex+string, \\1 jne. avulla pääset käsiksi captureihin." global_notice: "Näytä kaikilla sivuilla kaikille käyttäjille KIIREELLISESTÄ HÄTÄTAPAUKSESTA kertova banneri, jota ei voi piilottaa. Vaihda tyhjäksi piilottaaksesi sen (HTML sallittu)." - disable_system_edit_notifications: "Poista muokkausilmoitukset system-käyttäjältä, kun 'download_remote_images_to_local' on asetettu." + disable_system_edit_notifications: "Poistaa järjestelmäkäyttäjän muokkausilmoitukset käytöstä, kun 'download_remote_images_to_local' on asetettu." notification_consolidation_threshold: "Kuinka monta tykkäys- tai jäsenhakemusilmoitusta tulee saada, jotta ne yhdistetään yhdeksi. Arvo 0 poistaa käytöstä." - likes_notification_consolidation_window_mins: "Kesto minuutteina jonka aikana tykkäysilmoitukset yhdistetään yhdeksi ilmoitukseksi, kun raja on saavutettu. Rajan voi määrittää asetuksella `SiteSetting.notification_consolidation_threshold`." + likes_notification_consolidation_window_mins: "Kesto minuutteina, jonka aikana tykkäysilmoitukset yhdistetään yhdeksi ilmoitukseksi, kun raja on saavutettu. Rajan voi määrittää asetuksella `SiteSetting.notification_consolidation_threshold`." automatically_unpin_topics: "Poista ketjun kiinnitys automaattisesti, kun käyttäjä on sen lopussa." read_time_word_count: "Sanamäärä minuutissa, jota käytetään lukuajan arviointiin." - topic_page_title_includes_category: "Ketjusivun title tag -otsikkotunniste sisältää alueen nimen." - native_app_install_banner_ios: "Näyttää DiscourseHub-sovelluksen bännerin iOS-käyttäjille peruskäyttäjille (luottamustasosta 1 ylöspäin)." - native_app_install_banner_android: "Näyttää DiscourseHub-sovelluksen bännerin Android-käyttäjille peruskäyttäjille (luottamustasosta 1 ylöspäin)." + topic_page_title_includes_category: "Ketjusivun otsikkotunniste sisältää alueen nimen." + native_app_install_banner_ios: "Näyttää DiscourseHub-sovelluksen bannerin iOS-laitteilla tavallisille käyttäjille (luottamustasosta 1 ylöspäin)." + native_app_install_banner_android: "Näyttää DiscourseHub-sovelluksen bannerin Android-laitteilla tavallisille käyttäjille (luottamustasosta 1 ylöspäin)." share_anonymized_statistics: "Julkaise yksilöimättömät käyttötilastot." - auto_handle_queued_age: "Käsittele automaattisesti asiat, jotka ovat odottaneet käsittelyä näin monta päivää. Lut sivuutetaan. Jonossa olevat viestit ja käyttäjät hylätään. Jos asetat 0:ksi, ominaisuus ei ole käytössä." - svg_icon_subset: "Lisää ylimääräisiä FontAwesome 5 -ikoneita, jotka haluat sisällyttää käytettäviisi. Käytä etuliitettä \"fa-\" kun haluat täytetyn ikonin, \"far-\" kun haluat tavallisen ja \"fab-\" kun haluat brändi-ikonin." + auto_handle_queued_age: "Käsittele automaattisesti asiat, jotka ovat odottaneet käsittelyä näin monta päivää. Merkinnät ohitetaan. Jonossa olevat viestit ja käyttäjät ohitetaan. Jos asetat 0:ksi, ominaisuus ei ole käytössä." + svg_icon_subset: "Lisää ylimääräisiä FontAwesome 5 -kuvakkeita, jotka haluat sisällyttää käytettäviisi. Käytä etuliitettä \"fa-\", kun haluat täytetyn kuvakkeen, \"far-\", kun haluat tavallisen ja \"fab-\", kun haluat brändikuvakkeen." max_prints_per_hour_per_user: "Tulostuspyyntöjen (/print) enimmäismäärä (aseta 0 poistaaksesi käytöstä)" - full_name_required: "Koko nimi on käyttäjäprofiilin vaadittu kohta" + full_name_required: "Koko nimi on käyttäjäprofiilin pakollinen kenttä." enable_names: "Näytä käyttäjän koko nimi profiilissa, käyttäjäkortissa ja sähköposteissa. Poista käytöstä piilottaaksesi koko nimen kaikkialla." - display_name_on_posts: "Näytä käyttäjän pitkä nimi viesteissä @nimen lisäksi." + display_name_on_posts: "Näytä käyttäjän koko nimi viesteissä @käyttäjätunnuksen lisäksi." show_time_gap_days: "Jos kahden viestin välissä on kulunut näin monta päivää, näytä aikaväli ketjussa." short_progress_text_threshold: "Kuinka monen viestin jälkeen ketjun edistyspalkissa näytetään vain nykyisen viestin numero. Jos muutat palkin leveyttä, voit joutua muuttamaan tätä arvoa." warn_reviving_old_topic_age: "Kun käyttäjä alkaa kirjoittamaan vastausta ketjuun, jonka uusin viesti on tätä vanhempi päivissä, näytetään varoitus. Poista käytöstä asettamalla arvoksi 0." - autohighlight_all_code: "Pakota koodin korostus kaikkiin esimuotoiltuihin tekstiblokkeihin, vaikka käyttäjä ei määrittelisi kieltä." - highlighted_languages: "Mitä syntaksikorostussääntöjä on käytössä. (Varoitus: liian monen kielen käyttöönotto voi vaikuttaa palstan suorituskykyyn.) Ks. demo: https://highlightjs.org/static/demo" - embed_topics_list: "Tue ketjulistausten HTML-upottamista" + autohighlight_all_code: "Pakota koodin korostus kaikkiin esimuotoiltuihin koodilohkoihin, vaikka käyttäjä ei määrittelisi kieltä." + highlighted_languages: "Mitä syntaksikorostussääntöjä on käytössä. (Varoitus: liian monen kielen käyttöönotto voi vaikuttaa suorituskykyyn.) Ks. demo: https://highlightjs.org/static/demo" + embed_topics_list: "Tue ketjuluetteloiden HTML-upottamista" embed_truncate: "Typistä upotetut viestit." embed_support_markdown: "Tue Markdown-muotoilua upotetuissa viesteissä." allowed_embed_selectors: "Pilkuin eroteltu luettelo CSS-elementeistä, jotka on sallittu upotuksissa." allowed_href_schemes: "Linkeissä sallitut skeemat http:n ja https:n lisäksi." embed_post_limit: "Upotettavien viestien maksimimäärä." - embed_username_required: "Käyttäjänimi vaaditaan ketjun luomiseksi." - notify_about_flags_after: "Jos lippuja on ollut käsittelemättä näin monen tunnin ajan, lähetä yksityisviesti valvojille. Poista käytöstä asettamalla 0." - show_create_topics_notice: "Jos palstalla on vähemmän kuin 5 julkista ketjua, kehota ylläpitäjiä aloittamaan ketjuja." + embed_username_required: "Käyttäjätunnus vaaditaan ketjun luomiseksi." + notify_about_flags_after: "Jos merkintöjä on ollut käsittelemättä näin monen tunnin ajan, lähetä yksityisviesti valvojille. Poista käytöstä asettamalla 0." + show_create_topics_notice: "Jos sivustolla on vähemmän kuin 5 julkista ketjua, kehota ylläpitäjiä aloittamaan ketjuja." delete_drafts_older_than_n_days: "Poista yli (n) päivää vanhat luonnokset." bootstrap_mode_min_users: "Vähimmäismäärä käyttäjiä, joka vaaditaan aloitustilan poistamiseen (aseta 0 poistaaksesi käytöstä)" - slug_generation_method: "Valitse polkulyhenteen luomisen metodi. 'encoded' käyttää prosenttikoodausta, 'none' poistaa polkulyhenteet käytöstä." + slug_generation_method: "Valitse polkulyhenteen luomisen metodi, 'encoded' käyttää prosenttikoodausta, 'none' poistaa polkulyhenteet käytöstä." enable_emoji: "Ota emoji käyttöön" - enable_emoji_shortcuts: "Yleiset tekstiemojit kuten :) :p :( muunnetaan emojeiksi" + enable_emoji_shortcuts: "Yleiset tekstiemojit, kuten :), :p ja :(, muunnetaan emojeiksi" emoji_set: "Millaiset emojit haluat?" emoji_autocomplete_min_chars: "Kuinka monta merkkiä vaaditaan, jotta sanantäydentävä emojivalitsin aukeaa" enable_inline_emoji_translation: "Ota käyttöön emojien kääntäminen merkkijonojen keskeltä (ei väliä tai välimerkkejä ennen emojia)" - approve_post_count: "Viestien lukumäärä, joka tarkastetaan uusilta käyttäjiltä ja haastajilta." - approve_unless_trust_level: "Tätä luottamustasoa alhaisempien käyttäjien viestit tarkastetaan" - approve_new_topics_unless_trust_level: "Tätä luottamustasoa alhaisempien käyttäjien aloittamat ketjut tarkastetaan" + approve_post_count: "Viestien lukumäärä, joka uusilta käyttäjiltä ja haastajilta täytyy hyväksyä" + approve_unless_trust_level: "Tätä luottamustasoa alhaisempien käyttäjien viestit täytyy hyväksyä" + approve_new_topics_unless_trust_level: "Tätä luottamustasoa alhaisempien käyttäjien aloittamat ketjut täytyy hyväksyä" approve_unless_staged: "Esikäyttäjien uudet ketjut ja viestit täytyy hyväksyä" notify_about_queued_posts_after: "Jos viestejä on ollut hyväksymättä näin monen tunnin ajan, lähetä ilmoitus valvojille. Poista ilmoitukset käytöstä asettamalla 0." - auto_close_messages_post_count: "Maksimimäärä viestejä yksityisketjussa, kunnes se suljetaan automaattisesti (0 poistaaksesi käytöstä)" - auto_close_topics_post_count: "Maksimimäärä viestejä ketjussa, kunnes se suljetaan automaattisesti (0 poistaaksesi käytöstä)" + auto_close_messages_post_count: "Maksimimäärä viestejä viestissä, kunnes se suljetaan automaattisesti (aseta 0 poistaaksesi käytöstä)" + auto_close_topics_post_count: "Maksimimäärä viestejä ketjussa, kunnes se suljetaan automaattisesti (aseta 0 poistaaksesi käytöstä)" code_formatting_style: "Viestikentän koodipainike käyttää oletuksena tätä koodimuotoilutyyliä" max_allowed_message_recipients: "Kuinka monta vastaanottajaa yksityisviestillä voi olla." watched_words_regular_expressions: "Tarkkaillut sanat ovat säännöllisiä lausekkeita." - old_post_notice_days: "Kuinka monen päivän kuluttua viestihuomio vanhenee" - new_user_notice_tl: "Vähimmäisluottamustaso, jolla näkee uusiin käyttäjiin liittyvät viestihuomiot." - returning_user_notice_tl: "Vähimmäisluottamustaso, jolla näkee palaaviin käyttäjiin liittyvät viestihuomiot." + old_post_notice_days: "Kuinka monen päivän kuluttua viesti-ilmoitus vanhenee" + new_user_notice_tl: "Vähimmäisluottamustaso, jolla näkee uusiin käyttäjiin liittyvät viesti-ilmoitukset." + returning_user_notice_tl: "Vähimmäisluottamustaso, jolla näkee palaaviin käyttäjiin liittyvät viesti-ilmoitukset." returning_users_days: "Kuinka monen päivän kuluttua käyttäjä tulkitaan palaavaksi." - default_email_digest_frequency: "Kuinka usein käyttäjille lähetetään sähköpostikooste oletuksena." - default_include_tl0_in_digests: "Sisällytä uusien käyttäjien viestit sähköpostikoosteisiin oletuksena. Tätä voi muuttaa käyttäjäasetuksissa." + default_email_digest_frequency: "Kuinka usein käyttäjille lähetetään yhteenvetosähköposti oletuksena." + default_include_tl0_in_digests: "Sisällytä uusien käyttäjien viestejä yhteenvetosähköposteihin oletuksena. Tätä voi muuttaa käyttäjäasetuksissa." default_email_level: "Aseta oletusarvo tavallisten ketjujen sähköposti-ilmoitustasolle." - default_email_messages_level: "Aseta oletusarvo sähköposti-ilmoitustasolle, kun joku lähettää käyttäjälle yksityisviestin." + default_email_messages_level: "Aseta oletusarvo sähköposti-ilmoitustasolle, kun joku lähettää käyttäjälle viestin." default_email_mailing_list_mode: "Lähetä oletuksena sähköposti jokaisesta uudesta viestistä." default_email_mailing_list_mode_frequency: "Postituslistatilassa käyttäjä saa sähköpostia oletuksena näin usein." default_email_previous_replies: "Sisällytä aiemmat vastaukset sähköposteihin oletuksena." - default_email_in_reply_to: "Sisällytä lyhennelmä vastattavasta viestistä sähköpostiin oletuksena." - default_other_new_topic_duration_minutes: "Yleinen oletusarvo sille, koska ketju tulkitaan uudeksi." + default_email_in_reply_to: "Sisällytä katkelma vastattavasta viestistä sähköpostiin oletuksena." + default_other_new_topic_duration_minutes: "Yleinen oletusarvo sille, milloin ketju tulkitaan uudeksi." default_other_auto_track_topics_after_msecs: "Yleinen oletusarvo sille, missä ajassa ketjua aletaan seurata." default_other_notification_level_when_replying: "Yleinen oletusasetus ilmoitusasetukselle, kun käyttäjä vastaa ketjuun." default_other_external_links_in_new_tab: "Avaa oletuksena ulkopuoliset linkit uudessa välilehdessä." default_other_enable_quoting: "Ota oletuksena käyttöön lainaaminen valitsemalla tekstiä." default_other_enable_defer: "Ota ketjun lykkäystoiminto käyttöön oletuksena." - default_other_dynamic_favicon: "Näytä oletuksena uusien/päivittyneiden ketjujen määrä selaimen ikonissa." - default_other_skip_new_user_tips: "Ohita uuden käyttäjän aloittamisvinkit ja -ansiomerkit." - default_other_like_notification_frequency: "Ilmoita käyttäjiä tykkäyksistä oletuksena" - default_topics_automatic_unpin: "Aseta oletukseksi, että ketjun kiinnitys poistuu automaattisesti, jos käyttäjä selaa keskustelun loppuun" - default_categories_watching: "Lista oletuksena tarkkailtavista alueista." - default_categories_tracking: "Lista oletuksena seurattavista alueista." - default_categories_muted: "Lista oletuksena vaimennetuista alueista." - default_categories_watching_first_post: "Lista alueista, joiden ketjujen ensimmäisiä viestejä tarkkaillaan oletuksena." - mute_all_categories_by_default: "Aseta kaikkien alueiden oletusilmoitustasoksi vaimennettu. Aja käyttäjä itse valitsemaan alueet, jotka näkyvät Tuoreimmat- ja Alueet-sivuilla. Jos haluat muuttaa oletusarvoja kirjautumattomille käyttäjille, säädä 'default_categories_' -asetuksia." + default_other_dynamic_favicon: "Näytä oletuksena uusien/päivittyneiden ketjujen määrä selaimen kuvakkeessa." + default_other_skip_new_user_tips: "Ohita uuden käyttäjän aloittamisvinkit ja -kunniamerkit." + default_other_like_notification_frequency: "Ilmoita käyttäjille tykkäyksistä oletuksena" + default_topics_automatic_unpin: "Aseta oletukseksi, että ketjun kiinnitys poistuu automaattisesti, jos käyttäjä selaa keskustelun loppuun." + default_categories_watching: "Luettelo oletuksena tarkkailtavista alueista." + default_categories_tracking: "Luettelo oletuksena seurattavista alueista." + default_categories_muted: "Luettelo oletuksena vaimennetuista alueista." + default_categories_watching_first_post: "Luettelo alueista, joiden ketjujen ensimmäisiä viestejä tarkkaillaan oletuksena." + mute_all_categories_by_default: "Aseta kaikkien alueiden oletusilmoitustasoksi vaimennettu. Vaadi käyttäjää valitsemaan itse alueet, jotka näkyvät Tuoreimmat- ja Alueet-sivuilla. Jos haluat muuttaa oletusarvoja kirjautumattomille käyttäjille, säädä 'default_categories_' -asetuksia." default_tags_watching: "Luettelo tunnisteista, joita tarkkaillaan oletuksena." default_tags_tracking: "Luettelo tunnisteista, joita seurataan oletuksena." default_tags_muted: "Luettelo tunnisteista, jotka vaimennettu oletuksena." @@ -1895,70 +1894,70 @@ fi: default_title_count_mode: "Sivun otsikon laskurin oletusasetus" retain_web_hook_events_period_days: "Kuinka monta päivää tietoa webhook-tapahtumista säilötään." retry_web_hook_events: "Yritä uudelleen epäonnistuneita webhook-tapahtumia neljästi. Aikavälit yritysten välillä ovat 1, 5, 25 ja 125 minuuttia." - revoke_api_keys_days: "Kuinka monen päivän kuluttua käyttämätön rajapinta-avain mitätöidään automaattisesti (0 niin ei koskaan)" - allow_user_api_keys: "Salli rajapinnan käyttäjäavainten muodostaminen" - allow_user_api_key_scopes: "Lista rajapinnan käyttäjäavaimiin liittyvistä oikeuksista" - min_trust_level_for_user_api_key: "Vähimmäisluottamustaso, joka vaaditaan rajapinnan käyttäjäavainten muodostamiseen" - allowed_user_api_push_urls: "Sallitut URL-osoitteet " - expire_user_api_keys_days: "Kuinka monessa päivässä rajapinnan käyttäjäavain vanhenee automaattisesti (0 niin ei koskaan)" - tagging_enabled: "Ota käyttöön ketjujen tunnisteet?" + revoke_api_keys_days: "Kuinka monen päivän kuluttua käyttämätön API-avain mitätöidään automaattisesti (0 tarkoittaa ei koskaan)" + allow_user_api_keys: "Salli käyttäjä-API-avainten muodostaminen" + allow_user_api_key_scopes: "Luettelo käyttäjä-API-avaimiin liittyvistä oikeuksista" + min_trust_level_for_user_api_key: "Vähimmäisluottamustaso, joka vaaditaan käyttäjä-API-avainten muodostamiseen" + allowed_user_api_push_urls: "Sallitut URL-osoitteet palvelimen työnnöille käyttäjä-APIin" + expire_user_api_keys_days: "Kuinka monessa päivässä käyttäjä-API-avain vanhenee automaattisesti (0 tarkoittaa ei koskaan)" + tagging_enabled: "Otetaanko ketjujen tunnisteet käyttöön?" min_trust_to_create_tag: "Vähimmäisluottamustaso, jolla voi luoda tunnisteita." max_tags_per_topic: "Suurin tunnisteiden määrä, joka voi liittyä ketjuun." max_tag_length: "Enimmäismerkkimäärä tunnisteen nimelle." max_tag_search_results: "Kun haetaan tunnisteita, enintään näin monta hakutulosta näytetään." max_tags_in_filter_list: "Enimmäismäärä tunnisteita, joka näytetään suodatuspudotusvalikossa. Käytetyimmät tunnisteet näytetään." tags_sort_alphabetically: "Näytä tunnisteet aakkosjärjestyksessä. Oletusasetus on näyttäminen suosion mukaan." - tags_listed_by_group: "Ryhmittele tunnisteet tunnisteryhmittäin Tunnisteet-sivulla." - tag_style: "Tunnisteiden visuaalinen tyyli." - allow_staff_to_tag_pms: "Salli henkilökunnan asettaa tunnisteitä mihin tahansa yksityiskeskusteluun" + tags_listed_by_group: "Listaa tunnisteet tunnisteryhmittäin Tunnisteet-sivulla." + tag_style: "Tunnistemerkkien visuaalinen tyyli." + allow_staff_to_tag_pms: "Salli henkilökunnan asettaa tunnisteitä mihin tahansa yksityisviestiin" min_trust_level_to_tag_topics: "Vähimmäisluottamustaso, jolla voi lisätä tunnisteita ketjuihin" suppress_overlapping_tags_in_list: "Jos tunniste esiintyy sanana täsmälleen ketjun otsikossa, älä näytä tunnistetta" - remove_muted_tags_from_latest: "Jos ketjulla on vain vaimennettuja tunnisteita, älä näytä ketjua Tuoreimmat-listauksessa." + remove_muted_tags_from_latest: "Jos ketjulla on vain vaimennettuja tunnisteita, älä näytä ketjua Tuoreimmat-ketjuluettelossa." force_lowercase_tags: "Salli vain pienet kirjaimet uusissa tunnisteissa." company_name: "Yrityksen nimi" - governing_law: "Minkä lakeja noudatetaan" + governing_law: "Sovellettava lainsäädäntö" city_for_disputes: "Kaupunki jonka oikeudessa riidat ratkotaan" - shared_drafts_category: "Ota käyttöön jaetut luonnokset -toiminto määrittämällä alue, joka on ketjuluonnoksille varattu. Alueen ketjut eivät näy ketjulistauksissa henkilökunnankaan jäsenille." + shared_drafts_category: "Ota käyttöön jaetut luonnokset -toiminto määrittämällä alue, joka on ketjuluonnoksille varattu. Alueen ketjut eivät näy ketjuluetteloissa henkilökunnan jäsenille." push_notifications_prompt: "Näytä käyttäjäsuostumuspyyntö." - short_title: "Lyhyttä nimeä käytetään käyttäjän kotiruudussa (home screen), käynnistyssovelluksissa (launcher) ja muissa tilanteissa, joissa tilaa on rajallisesti. Sen tulisi olla enintään 12 merkin pituinen." + short_title: "Lyhyttä nimeä käytetään käyttäjän aloitusnäytöllä, käynnistysruudussa ja muissa tilanteissa, joissa tilaa on rajallisesti. Sen tulisi olla enintään 12 merkin pituinen." dashboard_general_tab_activity_metrics: "Valitse raportit, jotka näytetään aktiivisuusmittareina Yleistä-välilehdellä." errors: invalid_email: "Sähköpostiosoite ei kelpaa." - invalid_username: "Tällä nimellä ei löydy käyttäjää." + invalid_username: "Tällä käyttäjätunnuksella ei löydy käyttäjää." invalid_group: "Tällä nimellä ei löydy ryhmää." - invalid_integer_min_max: "Arvon pitää olla välillä %{min} - %{max}." - invalid_integer_min: "Arvon pitää olla vähintään %{min} tai suurempi." + invalid_integer_min_max: "Arvon pitää olla välillä %{min}–%{max}." + invalid_integer_min: "Arvon pitää olla vähintään %{min}." invalid_integer_max: "Arvo ei voi olla suurempi kuin %{max}." invalid_integer: "Arvon pitää olla kokonaisluku." - regex_mismatch: "Arvo ei vastaa vaadittua formaattia." - must_include_latest: "Ylävalikon täytyy sisältää 'tuoreimmat' välilehti." + regex_mismatch: "Arvo ei vastaa vaadittua muotoa." + must_include_latest: "Ylävalikon täytyy sisältää 'tuoreimmat'-välilehti." invalid_string: "Arvo ei kelpaa." - invalid_string_min_max: "Merkkien lukumäärän täytyy olla välillä %{min} - %{max}." + invalid_string_min_max: "Merkkien lukumäärän täytyy olla %{min}–%{max}." invalid_string_min: "Täytyy olla vähintään %{min} merkkiä." invalid_string_max: "Ei saa olla yli %{max} merkkiä." - invalid_reply_by_email_address: "Arvon täytyy sisältää '%{reply_key}' ja olla eri, kuin ilmoitusten sähköpostiosoite." + invalid_reply_by_email_address: "Arvon täytyy sisältää '%{reply_key}' ja erota ilmoitusten sähköpostiosoitteesta." invalid_alternative_reply_by_email_addresses: "Kaikkien arvojen täytyy sisältää '%{reply_key}' ja erota ilmoitusten sähköpostiosoitteesta." - pop3_polling_host_is_empty: "Sinun täytyy asettaa 'pop3 polling host' ennen POP3-pollauksen ottamista käyttöön." - pop3_polling_username_is_empty: "Sinun täytyy asettaa 'pop3 polling username' ennen POP3-pollauksen ottamista käyttöön." - pop3_polling_password_is_empty: "Sinun täytyy asettaa 'pop3 polling password' ennen POP3-pollauksen ottamista käyttöön." - pop3_polling_authentication_failed: "POP3 autentikaatio epäonnistui. Tarkasta pop3 kirjautumistiedot." - reply_by_email_address_is_empty: "'reply by email address' täytyy olla asetettuna ennen sähköpostivastausten ottamista käyttöön." - email_polling_disabled: "Sinun täytyy ottaa käyttöön joko manuaalinen tai POP3 pollaus ennen sähköpostivastauksia." - user_locale_not_enabled: "'allow user locale' täytyy olla asetettuna ennen tämän asetuksen ottamista käyttöön." + pop3_polling_host_is_empty: "Sinun täytyy asettaa 'pop3 polling host' ennen POP3-pollauksen käyttöönottoa." + pop3_polling_username_is_empty: "Sinun täytyy asettaa 'pop3 polling username' ennen POP3-pollauksen käyttöönottoa." + pop3_polling_password_is_empty: "Sinun täytyy asettaa 'pop3 polling password' ennen POP3-pollauksen käyttöönottoa." + pop3_polling_authentication_failed: "POP3-todennus epäonnistui. Tarkista pop3-tunnistetiedot." + reply_by_email_address_is_empty: "'Reply by email address' täytyy olla asetettuna ennen sähköpostivastausten ottamista käyttöön." + email_polling_disabled: "Sinun täytyy ottaa käyttöön joko manuaalinen tai POP3-pollaus ennen sähköpostivastausten käyttöönottoa." + user_locale_not_enabled: "'Allow user locale' täytyy olla asetettuna ennen tämän asetuksen ottamista käyttöön." invalid_regex: "Säännöllinen lauseke ei kelpaa tai ei ole sallittu." - email_editable_enabled: "Asetus 'email editable' on otettava pois käytöstä ennen tämän asetuksen käyttöönottoa." - staged_users_disabled: "\"Esikäyttäjät\" on otettava käyttöön ennen tämän asetuksen käyttöönottoa." - reply_by_email_disabled: "Asetus \"reply by email\" täytyy ottaa käyttöön ennen tämän asetuksen käyttöönottoa." - enable_local_logins_disabled: "Asetus \"enable local logins\" täytyy kytkeä päälle ennen tämän asetuksen päällekytkemistä." - min_username_length_exists: "Käyttäjänimen vähimmäispituus ei voi olla suurempi kuin lyhin käyttäjänimi (%{username})." + email_editable_enabled: "Sähköpostin muokkausmahdollisuus on poistettava käytöstä ennen tämän asetuksen käyttöönottoa." + staged_users_disabled: "Esikäyttäjät on otettava käyttöön ennen tämän asetuksen käyttöönottoa." + reply_by_email_disabled: "Vastaus sähköpostilla täytyy ottaa käyttöön ennen tämän asetuksen käyttöönottoa." + enable_local_logins_disabled: "Paikalliset kirjautumiset täytyy ottaa käyttöön ennen tämän asetuksen käyttöönottoa." + min_username_length_exists: "Käyttäjätunnuksen vähimmäispituus ei voi olla suurempi kuin lyhin käyttäjätunnus (%{username})." min_username_length_range: "Vähimmäisarvo ei voi olla suurempi kuin enimmäisarvo." - max_username_length_exists: "Käyttäjänimen enimmäispituus ei voi olla suurempi kuin pisin käyttäjänimi (%{username})." + max_username_length_exists: "Käyttäjätunnuksen enimmäispituus ei voi olla suurempi kuin pisin käyttäjätunnus (%{username})." max_username_length_range: "Enimmäisarvo ei voi olla pienempi kuin vähimmäisarvo." invalid_hex_value: "Väriarvon täytyy olla kuusinumeroinen heksadesimaalikoodi." allowed_unicode_usernames: regex_invalid: "Säännöllinen lauseke ei kelpaa: %{error}" leading_trailing_slash: "Säännöllinen lauseke ei voi alkaa eikä loppua kauttaviivalla." - unicode_usernames_avatars: "Sisäinen avatarjärjestelmä ei tue Unicode-käyttäjänimiä." + unicode_usernames_avatars: "Sisäinen avatarjärjestelmä ei tue Unicode-käyttäjätunnuksia." list_value_count: "Luettelon täytyy sisältää täsmälleen %{count} arvoa." placeholder: discourse_connect_provider_secrets: @@ -1980,8 +1979,8 @@ fi: unknown_error: "Tiliisi liittyen on tapahtunut virhe. Ota yhteyttä sivuston ylläpitäjään." timeout_expired: "Kirjautuminen on vanhentunut, yritä kirjautua sisään uudestaan." no_email: "Sähköpostiosoitetta ei annettu. Ota yhteyttä sivuston ylläpitäjään." - blank_id_error: " Vaaditaan `external_id` mutta se oli tyhjä" - email_error: "Tunnusta ei voitu luoda osoitteelle %{email}. Ota yhteyttä sivuston ylläpitäjään." + blank_id_error: "`External_id` on pakollinen, mutta se on tyhjä" + email_error: "Tiliä ei voitu luoda sähköpostiosoitteella %{email}. Ota yhteyttä sivuston ylläpitäjään." original_poster: "Alkuperäinen kirjoittaja" most_posts: "Eniten viestejä" most_recent_poster: "Uusin kirjoittaja" @@ -2002,14 +2001,14 @@ fi: one: "Viesti erotettiin uuteen ketjuun: %{topic_link}" other: "%{count} viestiä erotettiin uuteen ketjuun: %{topic_link}" new_message_moderator_post: - one: "Viesti erotettiin uuteen yksityisviestiketjuun: %{topic_link}" - other: "%{count} viestiä erotettiin uuteen yksityisviestiketjuun: %{topic_link}" + one: "Viesti erotettiin uuteen viestiin: %{topic_link}" + other: "%{count} viestiä erotettiin uuteen viestiin: %{topic_link}" existing_topic_moderator_post: - one: "Viesti siirrettiin ketjuun: %{topic_link}" - other: "%{count} viestiä siirrettiin ketjuun: %{topic_link}" + one: "Viesti yhdistettiin ketjuun: %{topic_link}" + other: "%{count} viestiä yhdistettiin ketjuun: %{topic_link}" existing_message_moderator_post: - one: "Viesti siirrettiin yksityisviestiketjuun: %{topic_link}" - other: "%{count} viestiä siirrettiin yksityisviestiketjuun: %{topic_link}" + one: "Viesti yhdistettiin viestiin: %{topic_link}" + other: "%{count} viestiä yhdistettiin viestiin: %{topic_link}" change_owner: post_revision_text: "Vaihtoi omistajaa" publish_page: @@ -2018,38 +2017,38 @@ fi: invalid: "sisältää epäkelpoja merkkejä" topic_statuses: autoclosed_message_max_posts: - one: "Tämä yksityisketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." - other: "Tämä yksityisketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." + one: "Tämä viesti suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." + other: "Tämä viesti suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." autoclosed_topic_max_posts: - one: "Tämä viestiketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." - other: "Tämä viestiketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." + one: "Tämä ketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." + other: "Tämä ketju suljettiin automaattisesti sen saavutettua vastausten maksimimäärän %{count}." autoclosed_enabled_days: one: "Tämä ketju suljettiin automaattisesti %{count} päivän kuluttua. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin ajastetusti %{count} päivän kuluttua. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} päivän kuluttua. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_enabled_hours: one: "Tämä ketju suljettiin automaattisesti %{count} tunnin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin ajastetusti %{count} tunnin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} tunnin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_enabled_minutes: one: "Tämä ketju suljettiin automaattisesti %{count} minuutin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin ajastetusti %{count} minuutin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} minuutin kuluttua. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_enabled_lastpost_days: one: "Tämä ketju suljettiin automaattisesti %{count} päivän kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin automaattisesti %{count} päivän kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} päivän kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_enabled_lastpost_hours: one: "Tämä ketju suljettiin automaattisesti %{count} tunnin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin automaattisesti %{count} tunnin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} tunnin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_enabled_lastpost_minutes: one: "Tämä ketju suljettiin automaattisesti %{count} minuutin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." - other: "Ketju suljettiin automaattisesti %{count} minuutin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." + other: "Tämä ketju suljettiin automaattisesti %{count} minuutin kuluttua viimeisestä viestistä. Uusia vastauksia ei voi enää kirjoittaa." autoclosed_disabled_days: - one: "Ketju avattiin ajastetusti %{count} päivän kuluttua." - other: "Ketju avattiin ajastetusti %{count} päivän kuluttua." + one: "Ketju avattiin automaattisesti %{count} päivän kuluttua." + other: "Ketju avattiin automaattisesti %{count} päivän kuluttua." autoclosed_disabled_hours: - one: "Ketju avattiin ajastetusti %{count} tunnin kuluttua." - other: "Ketju avattiin ajastetusti %{count} tunnin kuluttua." + one: "Ketju avattiin automaattisesti %{count} tunnin kuluttua." + other: "Ketju avattiin automaattisesti %{count} tunnin kuluttua." autoclosed_disabled_minutes: - one: "Ketju avattiin ajastetusti %{count} minuutin kuluttua." - other: "Ketju avattiin ajastetusti %{count} minuutin kuluttua." + one: "Ketju avattiin automaattisesti %{count} minuutin kuluttua." + other: "Ketju avattiin automaattisesti %{count} minuutin kuluttua." autoclosed_disabled_lastpost_days: one: "Ketju avattiin automaattisesti %{count} päivän kuluttua viimeisimmästä viestistä." other: "Ketju avattiin automaattisesti %{count} päivän kuluttua viimeisimmästä viestistä." @@ -2065,48 +2064,48 @@ fi: login: security_key_description: "Kun fyysinen tunnistautumislaite on kätesi ulottuvilla, klikkaa alla olevaa \"Tunnistaudu tunnistautumislaitteella\" -painiketta." security_key_alternative: "Kokeile muuta tapaa" - security_key_authenticate: "Tunnistaudu tunnistautumislaitteen avulla" - security_key_not_allowed_error: "Tunnistaumislaitteella tunnistautumisprosessi joko vanheni tai peruutettiin." - security_key_no_matching_credential_error: "Tunnistautumislaitteelta ei löytynyt kelpaavia pääsytietoja." - security_key_support_missing_error: "Tämä laitteesi tai selaimesi ei tue tunnistaumislaitteita. Käytä muuta tapaa." + security_key_authenticate: "Tee todennus tunnistautumislaitteella" + security_key_not_allowed_error: "Tunnistaumislaitteella tunnistautumisprosessi joko aikakatkaistiin tai peruutettiin." + security_key_no_matching_credential_error: "Tunnistautumislaitteelta ei löytynyt kelpaavia tunnistetietoja." + security_key_support_missing_error: "Laitteesi tai selaimesi ei tue tunnistaumislaitteita. Käytä muuta tapaa." security_key_invalid: "Tunnistautumislaitteen vahvistaminen epäonnistui." not_approved: "Tiliäsi ei ole vielä hyväksytty. Saat ilmoituksen sähköpostilla, kun voit kirjautua sisään." - incorrect_username_email_or_password: "Väärä käyttäjänimi, sähköposti tai salasana" + incorrect_username_email_or_password: "Väärä käyttäjätunnus, sähköpostiosoite tai salasana" incorrect_password: "Väärä salasana" wait_approval: "Kiitos kirjautumisesta. Ilmoitamme, kun tilisi on hyväksytty." - active: "Tilisi on aktivoitu ja valmiina käyttöön." - activate_email: "

      Melkein valmista! Lähetimme sähköpostin osoitteeseen %{email}. Aktivoi käyttäjätilisi seuraamalla viestin ohjeita.

      Jos viesti ei tullut perille, tarkista roskapostikansiosi.

      " - not_activated: "Et voi vielä kirjautua sisään. Lähetimme sinulle vahvistusviestin. Seuraa sähköpostiviestin ohjeita ottaaksesi tunnuksen käyttöön." + active: "Tilisi on aktivoitu ja valmiina käytettäväksi." + activate_email: "

      Melkein valmista! Lähetimme sähköpostin osoitteeseen %{email}. Aktivoi tilisi seuraamalla viestin ohjeita.

      Jos viesti ei tullut perille, tarkista roskapostikansiosi.

      " + not_activated: "Et voi vielä kirjautua sisään. Lähetimme sinulle vahvistusviestin. Seuraa sähköpostiviestin ohjeita tilisi aktivoimiseksi." not_allowed_from_ip_address: "Et voi kirjautua käyttäjätunnuksella %{username} tästä IP-osoitteesta." admin_not_allowed_from_ip_address: "Et voi kirjautua ylläpitäjänä tästä IP-osoitteesta." suspended: "Et voi kirjautu sisään ennen %{date}." - suspended_with_reason: "Tämä käyttäjätili on hyllytetty %{date} asti: %{reason}" + suspended_with_reason: "Tili on hyllytetty %{date} asti: %{reason}" errors: "%{errors}" not_available: "Ei saatavissa. Kokeile %{suggestion}?" - something_already_taken: "Jotain meni pieleen. Ehkäpä tämä käyttäjänimi tai sähköpostiosoite on jo rekisteröity. Kokeile salasana unohtui -linkkiä." + something_already_taken: "Jokin meni vikaan. Tämä käyttäjätunnus tai sähköpostiosoite on ehkä jo rekisteröity, kokeile salasanan unohduslinkkiä." omniauth_error: generic: "Pahoittelut, tilisi valtuuttaminen epäonnistui. Yritä uudelleen." - csrf_detected: "Valtuuttaminen katkesi määräajan ylityttyä tai vaihdoit selainta. Yritä uudelleen." + csrf_detected: "Valtuuttaminen aikakatkaistiin tai vaihdoit selainta. Yritä uudelleen." request_error: "Valtuuttamista aloitettaessa tapahtui virhe. Yritä uudelleen." - invalid_iat: "Valtuutusvälinettä ei voida vahvistaa palvelinkellojen eron vuoksi. Yritä uudelleen." - omniauth_confirm_title: "Kirjaudu käyttäen %{provider}" + invalid_iat: "Valtuutustunnistetta ei voida vahvistaa palvelinkellojen eron vuoksi. Yritä uudelleen." + omniauth_confirm_title: "Kirjaudu tavalla %{provider}" omniauth_confirm_button: "Jatka" - authenticator_error_no_valid_email: "Mikään tiliin %{account} liittyvistä sähköpostiosoitteista ei ole sallittu. Voit joutua määrittämään käyttäjätilisi eri sähköpostiosoitteella." + authenticator_error_no_valid_email: "Mikään tiliin %{account} liittyvistä sähköpostiosoitteista ei ole sallittu. Voit joutua määrittämään tilisi eri sähköpostiosoitteella." new_registrations_disabled: "Uusien tilien luonti ei ole tällä hetkellä sallittu." password_too_long: "Salasanan enimmäispituus on 200 merkkiä." - email_too_long: "Antamasi sähköpostiosoite on liian pitkä. Mailbox nimi ei saa olla yli 254 merkkiä pitkä, eikä domain nimi yli 253 merkkiä pitkä." - reserved_username: "Käyttäjänimi ei ole sallittu." - missing_user_field: "Et ole täyttänyt kaikkia kenttiä" - auth_complete: "Todentaminen suoritettu." + email_too_long: "Antamasi sähköpostiosoite on liian pitkä. Postilaatikon nimi ei saa olla yli 254 merkkiä pitkä, eikä verkkotunnuksen nimi yli 253 merkkiä pitkä." + reserved_username: "Käyttäjätunnus ei ole sallittu." + missing_user_field: "Et ole täyttänyt kaikkia käyttäjäkenttiä" + auth_complete: "Todennus suoritettu." click_to_continue: "Jatka klikkaamalla tästä." - second_factor_title: "Kaksivaiheinen tunnistautuminen" + second_factor_title: "Kaksivaiheinen tunnistus" second_factor_description: "Syötä vaadittava todennuskoodi sovelluksestasi:" - second_factor_backup_description: "Syötä yksi varmuuskoodeistasi" - invalid_second_factor_code: "Todennuskoodi ei kelpaa. Yhtä koodia voi käyttää vain kerran." + second_factor_backup_description: "Syötä yksi varakoodeistasi:" + invalid_second_factor_code: "Todennuskoodi ei kelpaa. Kunkin koodin voi käyttää vain kerran." invalid_security_key: "Tunnistautumislaite ei kelpaa." second_factor_toggle: totp: "Käytä todennussovellusta tai tunnistautumislaitetta tämän sijaan" - backup_code: "Käytä varmuuskoodia tämän sijaan" + backup_code: "Käytä varakoodia tämän sijaan" admin: email: sent_test: "lähetettiin!" @@ -2116,9 +2115,9 @@ fi: deactivated_by_inactivity: one: "Deaktivoitiin automaattisesti %{count} päivän epäaktiivisuuden vuoksi" other: "Deaktivoitiin automaattisesti %{count} päivän epäaktiivisuuden vuoksi" - activated_by_staff: "Henkilökunta vahvisti" + activated_by_staff: "Henkilökunta aktivoi" new_user_typed_too_fast: "Uusi käyttäjä näppäili liian nopeasti" - content_matches_auto_block_regex: "Sisältö täyttää automaattisesti estettävän säännöllisen lausekkeen" + content_matches_auto_block_regex: "Sisältö vastaa automaattisesti estettävää säännöllistä lauseketta" username: short: "täytyy olla vähintään %{min} merkkiä" long: "ei saa olla yli %{max} merkkiä" @@ -2127,30 +2126,30 @@ fi: unique: "täytyy olla uniikki" blank: "pakollinen kenttä" must_begin_with_alphanumeric_or_underscore: "täytyy alkaa kirjaimella, numerolla tai alaviivalla" - must_end_with_alphanumeric: "täytyy loppua kirjaimeen tai numeroon" + must_end_with_alphanumeric: "täytyy päättyä kirjaimeen tai numeroon" must_not_contain_two_special_chars_in_seq: "ei saa sisältää peräkkäin kahta tai useampaa erikoismerkkiä (.-_)" - must_not_end_with_confusing_suffix: "ei voi päättyä harhaanjohtavaan päätteeseen kuten .json tai .png jne." + must_not_end_with_confusing_suffix: "ei voi päättyä harhaanjohtavaan päätteeseen, kuten .json tai .png jne." email: - not_allowed: "ei sallita tältä sähköpostin palvelunatarjoajalta. Ole hyvä, ja käytä toista sähköpostiosoitetta." + not_allowed: "ei sallita tältä sähköpostipalvelunatarjoajalta. Käytä toista sähköpostiosoitetta." blocked: "ei ole sallittu." revoked: "Sähköpostia ei lähetetä osoitteeseen '%{email}' ennen %{date}." ip_address: blocked: "Uusien tilien luonti tästä IP-osoitteesta ei ole sallittu." max_new_accounts_per_registration_ip: "Rekisteröitymisiä ei oteta vastaan IP-osoitteestasi (maksimimäärä saavutettu). Ota yhteyttä henkilökuntaan." website: - domain_not_allowed: "Verkkosivu ei kelpaa. Sallitus verkkotunnukset ovat: %{domains}" - auto_rejected: "Hylättiin automaattisesti iän perusteella. Katso auto_handle_queued_age -sivustoasetus." + domain_not_allowed: "Verkkosivusto ei kelpaa. Sallitus verkkotunnukset ovat: %{domains}" + auto_rejected: "Hylättiin automaattisesti iän perusteella. Katso auto_handle_queued_age-sivustoasetus." destroy_reasons: unused_staged_user: "Käyttämätön esikäyttäjä" same_ip_address: "Sama IP-osoite (%{ip_address}) kuin muilla käyttäjillä" - inactive_user: "Epäaktiivinen käyttäjä" + inactive_user: "Ei-aktiivinen käyttäjä" reviewables_reminder: submitted: - one: "Tarkastettavaa on ollut odottamassa %{count} tunnin ajan. [Tarkastele niitä](%{base_path}/review)." - other: "Tarkastettavaa on ollut odottamassa %{count} tunnin ajan. [Tarkastele niitä](%{base_path}/review)." + one: "Kohteet lähetettiin yli %{count} tunti sitten. [Tarkasta ne](%{base_path}/review)." + other: "Kohteet lähetettiin yli %{count} tuntia sitten. [Tarkasta ne](%{base_path}/review)." subject_template: - one: "%{count} asia odottaa tarkastusta" - other: "%{count} asiaa odottaa tarkastusta" + one: "%{count} kohde odottaa tarkastusta" + other: "%{count} kodetta odottaa tarkastusta" unsubscribe_mailer: title: "Peru sähköpostimuistukset" subject_template: "Vahvista, ettet enää halua, että %{site_title} lähettää sinulle sähköpostimuistutuksia." @@ -2200,7 +2199,7 @@ fi: %{invite_link} invite_forum_mailer: - title: "Kutsu palstalle" + title: "Kutsu foorumille" subject_template: "%{inviter_name} kutsui sinut sivustolle %{site_domain_name}" text_body_template: | %{inviter_name} kutsui sinut liittymään sivustolle @@ -2213,7 +2212,7 @@ fi: %{invite_link} custom_invite_forum_mailer: - title: "Kutsu palstalle saatesanoilla" + title: "Kutsu foorumille saatesanoilla" subject_template: "%{inviter_name} kutsui sinut sivustolle %{site_domain_name}" text_body_template: | %{inviter_name} kutsui sinut liittymään sivustolle @@ -2231,7 +2230,7 @@ fi: %{invite_link} invite_password_instructions: title: "Salasanaohjeistus kutsutulle" - subject_template: "Aseta salasana %{site_name} -tunnuksellesi" + subject_template: "Aseta salasana sivuston %{site_name} tilillesi" text_body_template: | Kiitos, kun hyväksyit kutsun sivustolle %{site_name} -- tervetuloa! @@ -2243,7 +2242,7 @@ fi: title: "Lataa varmuuskopio" subject_template: "[%{email_prefix}] Varmuuskopion lataus sivustosta" text_body_template: | - Tässä [varmuuskopio sivustosta](%{backup_file_path}), jota pyysit. + Tässä pyytämäsi [varmuuskopio sivustosta](%{backup_file_path}). Lähetimme tämän latauslinkin varmennettuun sähköpostiosoitteeseesi turvallisuussyistä. @@ -2254,7 +2253,7 @@ fi: title: "Ylläpitäjän vahvistaminen" subject_template: "[%{email_prefix}] Vahvista uusi ylläpitäjätili" text_body_template: | - Vahvista että haluat käyttäjästä **%{target_username} (%{target_email})** ylläpitäjän palstallesi. + Vahvista, että haluat käyttäjästä **%{target_username} (%{target_email})** ylläpitäjän foorumillesi. [Vahvista ylläpitäjätili](%{admin_confirm_url}) test_mailer: @@ -2269,11 +2268,11 @@ fi: Sinun versiosi: %{installed_version} Uusi versio: **%{new_version}** - - Päivitä käyttäen helppoa **[yhden klikkauksen päivitystä selaimessa](%{base_url}/admin/upgrade)** + – Päivitä käyttäen helppoa **[yhden klikkauksen päivitystä selaimessa](%{base_url}/admin/upgrade)** - - Katso mikä on uutta [julkaisutiedoista](https://meta.discourse.org/tag/release-notes) tai tutki [raakaa GitHubin muutoslokia](https://github.com/discourse/discourse/commits/master) + – Katso mikä on uutta [julkaisutiedoista](https://meta.discourse.org/tag/release-notes) tai tutki [raakaa GitHubin muutoslokia](https://github.com/discourse/discourse/commits/master) - - Käy osoitteessa [meta.discourse.org](https://meta.discourse.org) lukemassa uutisia ja keskustelua, tai hae apua Discourseen liittyen + – Käy osoitteessa [meta.discourse.org](https://meta.discourse.org) lukemassa uutisia ja keskustelua, tai hae apua Discourseen liittyen new_version_mailer_with_notes: title: "Uusi versio julkaisumuistiolla" subject_template: "[%{email_prefix}] päivitys saatavilla" @@ -2283,20 +2282,20 @@ fi: Sinun versiosi: %{installed_version} Uusi versio: **%{new_version}** - - Päivitä käyttäen helppoa **[yhden klikkauksen päivitystä selaimessa](%{base_url}/admin/upgrade)** + – Päivitä käyttäen helppoa **[yhden klikkauksen päivitystä selaimessa](%{base_url}/admin/upgrade)** - - Katso mikä on uutta [julkaisutiedoista](https://meta.discourse.org/tag/release-notes) tai tutki [raakaa GitHubin muutoslokia](https://github.com/discourse/discourse/commits/master) + – Katso mikä on uutta [julkaisutiedoista](https://meta.discourse.org/tag/release-notes) tai tutki [raakaa GitHubin muutoslokia](https://github.com/discourse/discourse/commits/master) - - Käy osoitteessa [meta.discourse.org](https://meta.discourse.org) lukemassa uutisia ja keskustelua, tai hae apua Discourseen liittyen + – Käy osoitteessa [meta.discourse.org](https://meta.discourse.org) lukemassa uutisia ja keskustelua, tai hae apua Discourseen liittyen ### Julkaisutiedot %{notes} flag_reasons: - off_topic: "Viestisi liputettiin **eksyvän aiheesta**: koetaan, ettei se ei kuulu ketjun aiheeseen, jonka määrittelevät sen aloitusviesti ja otsikko." - inappropriate: "Viestisi liputettiin **sopimattomaksi**: se koetaan loukkaavaksi, herjaavaksi tai [palstan sääntöjen](%{base_path}/guidelines) vastaiseksi." - spam: "Viestisi liputettiin **roskapostiksi**: sen koetaan olevan luonteeltaan mainostamista eikä hyödyllinen tai asiallinen lisä keskusteluun." - notify_moderators: "Viestisi liputettiin **valvojien tiedoksi**: siinä koetaan olevan jotain, mihin henkilökunnan pitäisi puuttua." + off_topic: "Viestisi merkittiin **eksyvän aiheesta**: koetaan, ettei se ei kuulu ketjun aiheeseen, jonka määrittelevät sen aloitusviesti ja otsikko." + inappropriate: "Viestisi merkittiin **sopimattomaksi**: se koetaan loukkaavaksi, herjaavaksi tai [yhteisön sääntöjen](%{base_path}/guidelines) vastaiseksi." + spam: "Viestisi merkittiin **roskapostiksi**: sen koetaan olevan luonteeltaan mainostamista eikä hyödyllinen tai asiallinen lisä keskusteluun." + notify_moderators: "Viestisi merkittiin **valvojien tiedoksi**: siinä koetaan olevan jotain, mihin henkilökunnan pitäisi puuttua." flags_dispositions: agreed: "Kiitos kun toit asian tietoomme. Olemme samaa mieltä ongelmasta ja selvitämme sitä." agreed_and_deleted: "Kiitos kun toit asian tietoomme. Olemme samaa mieltä ongelmasta ja poistimme kyseisen viestin." @@ -2304,14 +2303,14 @@ fi: ignored: "Kiitos kun toit asian tietoomme. Selvitämme asiaa." ignored_and_deleted: "Kiitos kun toit asian tietoomme. Poistimme kyseisen viestin." temporarily_closed_due_to_flags: - one: "Ketju on väliaikaisesti suljettu ainakin yhden tunnin ajaksi johtuen suuresta määrästä yhteisön liputuksia." - other: "Ketju on väliaikaisesti suljettu ainakin %{count} tunnin ajaksi johtuen suuresta määrästä yhteisön liputuksia." + one: "Ketju on väliaikaisesti suljettu ainakin %{count} tunnin ajaksi johtuen suuresta määrästä yhteisön merkintöjä." + other: "Ketju on väliaikaisesti suljettu ainakin %{count} tunnin ajaksi johtuen suuresta määrästä yhteisön merkintöjä." system_messages: private_topic_title: "Ketju #%{id}" contents_hidden: "Käy lukemassa viesti, jotta näet sen sisällön." post_hidden: title: "Viesti piilotettu" - subject_template: "Yhteisön piiloon liputtama viesti" + subject_template: "Yhteisön piiloon merkitsemä viesti" text_body_template: | Hello, @@ -2321,14 +2320,14 @@ fi: %{flag_reason} - Viesti piilotettiin yhteisön liputuksien vuoksi, joten harkitse miten voisit muokata viestiä palautteen pohjalta. **Voit muokata viestiä kun %{edit_delay} minuuttia on kulunut, mikä palauttaa automaattisesti sen näkyville.** + Viesti piilotettiin yhteisön merkintöjen vuoksi, joten harkitse miten voisit muokata viestiä palautteen pohjalta. **Voit muokata viestiä %{edit_delay} minuutin kuluttua, mikä palauttaa automaattisesti sen näkyville.** - Jos viesti kuitenkin piilotetaan toisen kerran, se pysyy piilotettuna kunnes henkilökunta selvittää tilanteen. + Jos viesti kuitenkin piilotetaan toisen kerran, se pysyy piilotettuna, kunnes henkilökunta selvittää tilanteen. Saat lisätietoa [yhteisön säännöistä](%{base_url}/guidelines). post_hidden_again: title: "Viesti piilotettiin jälleen" - subject_template: "Yhteisöliputukset piilottivat viestin, henkilökunnalle ilmoitettiin" + subject_template: "Yhteisön merkinnät piilottivat viestin, henkilökunnalle ilmoitettiin" text_body_template: | Hei, @@ -2338,20 +2337,20 @@ fi: %{flag_reason} - Yhteisö liputti viestin ja se on nyt piilotettu. **Koska viestisi on piilotettu useammin kuin kerran, se pysyy piilotettuna kunnes henkilökunnan jäsen selvittää tilanteen.** + Yhteisö merkitsi viestin, ja se on nyt piilotettu. **Koska viestisi on piilotettu useammin kuin kerran, se pysyy piilotettuna, kunnes henkilökunnan jäsen selvittää tilanteen.** Saat lisätietoa [yhteisön säännöistä](%{base_url}/guidelines). queued_by_staff: title: "Viesti odottaa hyväksyntää" flags_disagreed: - title: "Henkilökunta palautti viestin" - subject_template: "Henkilökunta palautti viestin" + title: "Henkilökunta palautti merkityn viestin" + subject_template: "Henkilökunta palautti merkityn viestin" text_body_template: | Hello, Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että [viestisi](%{base_url}%{url}) palautettiin. - Yhteisö oli liputtanut viestin ja henkilökunnan jäsen päätti palauttaa sen. + Yhteisö oli merkinnyt viestin, ja henkilökunnan jäsen päätti palauttaa sen. [details="Klikkaa nähdäksesi palautetun viestin"] ``` markdown @@ -2359,18 +2358,18 @@ fi: ``` [/details] flags_agreed_and_post_deleted: - title: "Henkilökunta poisti liputetun viestin" - subject_template: "Henkilökunta poisti liputetun viestin" + title: "Henkilökunta poisti merkityn viestin" + subject_template: "Henkilökunta poisti merkityn viestin" usage_tips: text_body_template: | [Tässä blogikirjoituksessa](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/) on joitakin käteviä vinkkejä uudelle käyttäjälle. - Sitä mukaa kun toimit täällä, opimme tuntemaan sinut ja väliaikaisia uuden käyttäjän rajoitteita poistetaan automaattisesti. Ajan myötä nouset ylemmille [luottamustasoille](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) ja saat valtuuksia, joiden avulla voit osallistua yhteisömme ylläpitoon. + Sitä mukaa kun toimit täällä, opimme tuntemaan sinut, ja väliaikaisia uuden käyttäjän rajoitteita poistetaan automaattisesti. Ajan myötä nouset ylemmille [luottamustasoille](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) ja saat valtuuksia, joiden avulla voit osallistua yhteisömme ylläpitoon. welcome_user: title: "Tervetuloa käyttäjä" subject_template: "Tervetuloa sivustolle %{site_name}!" text_body_template: | - Kiitos kun liityit %{site_name} ja tervetuloa! + Kiitos kun liityit sivustolle %{site_name}, ja tervetuloa! %{new_user_tips} @@ -2381,27 +2380,27 @@ fi: title: "Tervetuloa LT1-käyttäjä" subject_template: "Kiitos että olet viettänyt aikaa kanssamme" text_body_template: | - Hei! Olemme huomanneet että olet lukenut ahkerasti. Se on mahtavaa ja siksi nostimme sinut ylemmälle [luottamustasolle!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) + Hei! Olemme huomanneet että olet lukenut ahkerasti. Se on mahtavaa, ja siksi nostimme sinut ylemmälle [luottamustasolle!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) - Olemme iloisia siitä että vietät aikaasi täällä ja haluaisimme tietää sinusta enemmänkin. Käytä hetki aikaa ja [täytä käyttäjäprofiilisi](%{base_url}/my/preferences/profile) tai [aloita uusi ketju](%{base_url}/categories) jos siltä tuntuu. + Olemme iloisia siitä, että vietät aikaasi täällä ja haluaisimme tietää sinusta enemmänkin. Käytä hetki aikaa ja [täytä profiilisi](%{base_url}/my/preferences/profile) tai [aloita uusi ketju](%{base_url}/categories) jos siltä tuntuu. welcome_staff: title: "Tervetuloa henkilökuntaan" subject_template: "Onnittelut, sinulle on myönnetty %{role}-status! " text_body_template: | - Toinen henkilökunnan jäsen myönsi sinulle %{role}-statuksen + Toinen henkilökunnan jäsen myönsi sinulle statuksen %{role} - %{role}-status tuo pääsyn ylläpitokäyttöliittymään. + Status %{role} tuo pääsyn ylläpitokäyttöliittymään. - Suuri voima tuo mukanaan suuren vastuun. Jos valvonta on sinulle uutta, tutustu [Valvontaoppaaseen](https://meta.discourse.org/t/discourse-moderation-guide/63116). + Suuri voima tuo mukanaan suuren vastuun. Jos valvonta on sinulle uutta, tutustu [valvontaoppaaseen](https://meta.discourse.org/t/discourse-moderation-guide/63116). welcome_invite: title: "Tervetuloa kutsuttu" subject_template: "Tervetuloa sivustolle %{site_name}!" text_body_template: | - Kiitos kun liityit %{site_name} ja tervetuloa! + Kiitos kun liityit sivustolle %{site_name}, ja tervetuloa! - - Loimme sinulle uuden tunnuksen **%{username}**. Muuta nimeäsi tai käyttäjätunnustasi [käyttäjäasetuksissasi][prefs]. + – Loimme sinulle uuden tunnuksen **%{username}**. Muuta nimeäsi tai käyttäjätunnustasi [käyttäjäasetuksissasi][prefs]. - - Kun kirjaudut sisään, **käytä samaa sähköpostiosoitetta kuin kutsuttaessa** — muuten emme tunnistua sinua sinuksi! + – Kun kirjaudut sisään, **käytä samaa sähköpostiosoitetta kuin kutsuttaessa** — muuten emme tunnista sinua! %{new_user_tips} @@ -2411,14 +2410,14 @@ fi: [prefs]: %{user_preferences_url} tl2_promotion_message: - subject_template: "Onnittelut luottamustasonoususta!" + subject_template: "Onnittelut luottamustason noususta!" backup_succeeded: title: "Varmuuskopiointi onnistui" subject_template: "Varmuuskopiointi suoritettu onnistuneesti" text_body_template: | Varmuuskopiointi onnistui. - Lataa varmuuskopio siirtymällä [ylläpito > varmuuskopiot](%{base_url}/admin/backups). + Lataa varmuuskopio siirtymällä kohtaan [ylläpito > varmuuskopiot](%{base_url}/admin/backups). Tässä loki: @@ -2438,7 +2437,7 @@ fi: ``` restore_succeeded: title: "Palautus onnistui" - subject_template: "Palauttaminen suoritettu onnistuneesti" + subject_template: "Palautus suoritettu onnistuneesti" text_body_template: | Palautus onnistui. @@ -2449,7 +2448,7 @@ fi: ``` restore_failed: title: "Palautus epäonnistui" - subject_template: "Palauttaminen epäonnistui" + subject_template: "Palautus epäonnistui" text_body_template: | Palautus epäonnistui. @@ -2460,7 +2459,7 @@ fi: ``` bulk_invite_succeeded: title: "Massakutsun lähetys onnistui" - subject_template: "Massakutsun käsittely onnistui." + subject_template: "Massakutsun käsittely onnistui" text_body_template: "Massakutsutiedostosi on käsitelty, %{sent} kutsua lähetetty." bulk_invite_failed: title: "Massakutsun lähetys epäonnistui" @@ -2483,201 +2482,201 @@ fi: Yllä oleva latauslinkki toimii 48 tunnin ajan. - Tiedot on pakattu zip-arkistoksi. Jos arkisto ei purkaudu kun yrität avata sitä, käytä täällä suositeltua työkalua: https://www.7-zip.org/ + Tiedot on pakattu zip-arkistoksi. Jos arkisto ei purkaudu, kun yrität avata sitä, käytä täällä suositeltua työkalua: https://www.7-zip.org/ csv_export_failed: title: "CSV:n vienti epäonnistui" - subject_template: "Datan vienti epäonnistui" - text_body_template: "Pahoittelemme, mutta datan vienti epäonnistui. Tarkasta lokit tai [ota yhteyttä henkilökuntaan](%{base_url}/about)." + subject_template: "Tietojen vienti epäonnistui" + text_body_template: "Tietojen vienti epäonnistui. Tarkasta lokit tai [ota yhteyttä henkilökuntaan](%{base_url}/about)." email_reject_insufficient_trust_level: - title: "Sähköposti hylätty - riittämätön luottamustaso" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Riittämätön luottamustaso" + title: "Sähköposti hylätty – riittämätön luottamustaso" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- riittämätön luottamustaso" text_body_template: | - Pahoittelut, sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Et voi lähettää uusia ketjunaloituksia tähän sähköpostiosoitteeseen, koska luottamustasosi ei riitä. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_user_not_found: - title: "Sähköposti hylätty - käyttäjää ei löytynyt" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Käyttäjää ei löytynyt" + title: "Sähköposti hylätty – käyttäjää ei löytynyt" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- käyttäjää ei löytynyt" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Vastauksesi lähetettiin tuntemattomasta sähköpostiosoitteesta. Yritä lähettää viesti toisesta osoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). + Vastauksesi lähetettiin tuntemattomasta sähköpostiosoitteesta. Kokeile lähettää viesti toisesta osoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_screened_email: - title: "Sähköposti hylätty - estetty sähköpostiosoite" - subject_template: "[%{email_prefix}] Sähköpostiongelma-- Estetty sähköpostiosoite" + title: "Sähköposti hylätty – estetty sähköpostiosoite" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- estetty sähköpostiosoite" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Vastauksesi lähetettiin estetystä sähköpostiosoitteesta. Yritä lähettää viesti toisesta sähköpostiosoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). + Vastauksesi lähetettiin estetystä sähköpostiosoitteesta. Kokeile lähettää viesti toisesta sähköpostiosoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_not_allowed_email: - title: "Sähköposti hylätty - osoite ei sallittu" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Estetty osoite" + title: "Sähköposti hylätty – osoite ei sallittu" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- estetty sähköpostiosoite" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Vastauksesi on peräisin estetystä sähköpostiosoitteesta. Kokeile lähettää toisesta sähköpostiosoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_inactive_user: - title: "Sähköposti hylätty - aktivoimaton käyttäjä" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Aktivoimaton käyttäjä" + title: "Sähköposti hylätty – aktivoimaton käyttäjä" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- aktivoimaton käyttäjä" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Käyttäjätiliä tällä sähköpostiosoitteella ei ole aktivoitu. Aktivoi käyttäjätili ennen sähköpostien lähettämistä. email_reject_silenced_user: title: "Sähköposti hylätty - hiljennetty käyttäjä" - subject_template: "%{email_prefix} Sähköpostiongelma - Hiljennetty käyttäjä" + subject_template: "%{email_prefix} Sähköpostiongelma – hiljennetty käyttäjä" text_body_template: | - Pahoittelut, sähköpostin lähettäminen kohteeseen %{destination} (titled %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Tähän sähköpostiosoitteeseen liittyvä tilisi on hiljennetty. email_reject_reply_user_not_matching: - title: "Sähköposti hylätty - käyttäjä ei täsmää" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Vastaus odottamattomasta osoitteesta" + title: "Sähköposti hylätty – käyttäjä ei täsmää" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- vastaus odottamattomasta osoitteesta" text_body_template: | - Pahoittelut, sähköpostin lähettäminen kohteeseen %{destination} (titled %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Vastauksesi ei saapunut siitä sähköpostiosoitteesta kuin odotimme, joten emme voineet olla varmoja lähettäjästä. Kokeile lähettää viestisi toisesta sähköpostiosoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). + Vastauksesi ei saapunut odottamastamme sähköpostiosoitteesta, joten emme voineet olla varmoja lähettäjästä. Kokeile lähettää viestisi toisesta sähköpostiosoitteesta tai [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_empty: - title: "Sähköposti hylätty - tyhjä" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Ei sisältöä" + title: "Sähköposti hylätty – tyhjä" + subject_template: "[%{email_prefix}] Sähköpostiongelma -- ei sisältöä" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Emme löytäneet sähköpostiviestistäsi sisältöä. Jos saat tämän viestin ja viestisi _sisälsi_ sisältöä, yritä uudestaan yksinkertaisemmalla muotoilulla. email_reject_parsing: - title: "Sähköposti hylätty - jäsennys" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Sisältöä ei tunnistettu" + title: "Sähköposti hylätty – jäsennys" + subject_template: "[%{email_prefix}] Sähköpostiongelma – sisältöä ei tunnistettu" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Emme tunnistaneet sähköpostiviestistäsi sisältöä. **Varmista, että kirjoitit viestisi sähköpostiviestin alkuun** - emme pysty käsittelemään lainausten sekaan kirjoitettuja vastauksia. + Emme tunnistaneet sähköpostiviestistäsi sisältöä. **Varmista, että kirjoitit viestisi sähköpostiviestin alkuun** – emme pysty käsittelemään lainausten sekaan kirjoitettuja vastauksia. email_reject_invalid_access: - title: "Sähköposti hylätty - pääsy estetty" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Ei pääsyoikeutta" + title: "Sähköposti hylätty – pääsy estetty" + subject_template: "[%{email_prefix}] Sähköpostiongelma – ei käyttöoikeutta" text_body_template: | - Pahoittelut, sähköpostiviestiäsi tänne: %{destination} (otsikolla %{former_title}) ei voitu toimittaa. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Käyttäjätililläsi ei ole oikeutta aloittaa ketjua sillä alueella. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). + Tililläsi ei ole oikeutta aloittaa ketjua sillä alueella. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_strangers_not_allowed: - title: "Sähköposti hylätty - vierailla ei pääsyä" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Ei pääsyoikeutta" + title: "Sähköposti hylätty – vierailla ei pääsyä" + subject_template: "[%{email_prefix}] Sähköpostiongelma – ei käyttöoikeutta" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Alueelle jolle lähetit viestin voivat kirjoittaa ne, joilla on käypä käyttäjätunnus ja sähköpostiosoite. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). + Alueelle, jolle lähetit viestin, voivat kirjoittaa ne, joilla on kelvollinen tili ja tunnettu sähköpostiosoite. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_invalid_post: - title: "Sähköposti hylätty - viesti ei kelpaa" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Lähetysvirhe" + title: "Sähköposti hylätty – viesti ei kelpaa" + subject_template: "[%{email_prefix}] Sähköpostiongelma – lähetysvirhe" text_body_template: | - Pahoittelemme: sähköpostiviestisi kohteeseen %{destination} (titled %{former_title}) ei toiminut odotetusti. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Mahdollisia syitä ovat ainakin: ei-tuettu muotoilu, liian suuri viesti, liian pieni viesti. Ole hyvä ja yritä uudelleen; voit myös lähettää viestisi sivuston kautta, jos tämä ongelma ei vaikuta poistuvan. + Mahdollisia syitä ovat ainakin: ei-tuettu muotoilu, liian suuri viesti, liian pieni viesti. Yritä uudelleen tai lähetä viestisi sivuston kautta, jos ongelma toistuu. email_reject_invalid_post_specified: - title: "Sähköposti hylätty - viesti ei kelpaa, tarkennettu" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Lähetysvirhe" + title: "Sähköposti hylätty – viesti ei kelpaa, tarkennettu" + subject_template: "[%{email_prefix}] Sähköpostiongelma – lähetysvirhe" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Syy: %{post_error} Jos voit korjata ongelman, yritä uudelleen. - date_invalid: "Viestin luomispäivämäärää ei löytynyt. Puuttuuko sähköpostista Date: header?" + date_invalid: "Viestin luomispäivämäärää ei löytynyt. Puuttuuko sähköpostista Date:-otsikkotieto?" email_reject_post_too_short: - title: "Sähköposti hylättiin, koska lyhyt" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Viesti liian lyhyt" + title: "Sähköposti hylättiin liian lyhyenä" + subject_template: "[%{email_prefix}] Sähköpostiongelma – viesti liian lyhyt" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Koska haluamme vaalia syvällisempiä keskusteluja, erittäin lyhyitä vastauksia ei sallita. Voitko laittaa vastauksiisi vähintään %{count} merkkiä? Vaihtoehtoisesti voit tykätä viestistä sähköpostitse vastaamalla "+1". email_reject_invalid_post_action: - title: "Sähköposti hylätty - kielletty viestitoiminto" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Kielletty viestitoiminto" + title: "Sähköposti hylätty – kielletty viestitoiminto" + subject_template: "[%{email_prefix}] Sähköpostiongelma – kielletty viestitoiminto" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Toimintoa ei tunnistettu. Yritä uudelleen tai lähetä viesti nettisivun kautta, jos ongelma jatkuu. + Toimintoa ei tunnistettu. Yritä uudelleen tai lähetä viesti verkkosivuston kautta, jos ongelma toistuu. email_reject_reply_key: - title: "Sähköposti hylätty - vastausavain" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Tuntematon vastausavain" + title: "Sähköposti hylätty – vastausavain" + subject_template: "[%{email_prefix}] Sähköpostiongelma – tuntematon vastausavain" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (titled %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Sähköpostiviestin vastaustunniste, engl. 'reply key', ei ole kelvollinen, minkä vuoksi ei tiedetä, mihin asiaan viestisi oli tarkoitus vastata. [Ota yhteyttä henkilökuntaan](%{base_url}/about). + Sähköpostiviestin vastaustunniste ('reply key') on virheellinen tai tuntematon, minkä vuoksi emme tiedä, mihin viestiin sähköpostiviestisi oli tarkoitus vastata. [Ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_bad_destination_address: - title: "Sähköposti hylätty - tuntematon vastaanottajaosoite" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Tuntematon Vastaanottaja: -osoite" + title: "Sähköposti hylätty – tuntematon vastaanottajaosoite" + subject_template: "[%{email_prefix}] Sähköpostiongelma – tuntematon vastaanottajan osoite" email_reject_old_destination: - title: "Sähköposti hylätty - vanha kohde" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Yrität vastata vanhaan ilmoitukseen" + title: "Sähköposti hylätty – vanha kohde" + subject_template: "[%{email_prefix}] Sähköpostiongelma – yrität vastata vanhaan ilmoitukseen" text_body_template: | - Pahoittelemme, mutta sähköpostin lähettäminen tänne %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Alkuperäisiin ilmoituksiin voi meillä vastata vain %{number_of_days} päivän ajan. Jatka keskustelua [vierailemalla ketjussa](%{short_url}). email_reject_topic_not_found: - title: "Sähköposti hylätty - ketjua ei löytynyt" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Ketjua ei löytynyt" + title: "Sähköposti hylätty – ketjua ei löytynyt" + subject_template: "[%{email_prefix}] Sähköpostiongelma – ketjua ei löytynyt" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Ketjua johon yritit kirjoittaa ei ole enää olemassa -- ehkä se poistettiin? Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). + Ketjua, johon yritit kirjoittaa, ei ole enää olemassa – ehkä se poistettiin? Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_topic_closed: - title: "Sähköposti hylätty - ketju suljettu" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Suljettu ketju" + title: "Sähköposti hylätty – ketju suljettu" + subject_template: "[%{email_prefix}] Sähköpostiongelma – ketju suljettu" text_body_template: | - Pahoittelut, sähköpostiviestiäsi tänne: %{destination} (otsikolla %{former_title}) ei voitu toimittaa. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Ketju, johon yritit vastata on tällä hetkellä suljettu, eikä siihen voi enää vastata. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). + Ketju, johon yritit vastata, on tällä hetkellä suljettu, eikä siihen voi enää vastata. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_auto_generated: - title: "Sähköposti hylätty - automaattivastaus" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Automaattivastaus" + title: "Sähköposti hylätty – automaattinen vastaus" + subject_template: "[%{email_prefix}] Sähköpostiongelma – automaattinen vastaus" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Järjestelmä havaitsi viestisi olevan tietokoneen automaattisesti luoma eikä ihmisen kirjoittama, eikä viestiä voitu siksi hyväksyä. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_unrecognized_error: - title: "Sähköposti hylätty - Tunnistamaton virhe" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Tunnistamaton virhe" + title: "Sähköposti hylätty – tunnistamaton virhe" + subject_template: "[%{email_prefix}] Sähköpostiongelma – tunnistamaton virhe" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. - Viestiäsi käsiteltäessä tapahtui tunnistamaton virhe eikä sitä siksi julkaistu. Kokeile uudelleen tai [ota yhteyttä henkilökuntaan](%{base_url}/about). + Viestiäsi käsiteltäessä tapahtui tunnistamaton virhe, eikä sitä siksi julkaistu. Kokeile uudelleen tai [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_attachment: title: "Sähköpostiliite hylätty" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- Liite hylätty" + subject_template: "[%{email_prefix}] Sähköpostiongelma – liite hylätty" text_body_template: | - Valitettavasti jotkut liitteistäsi sähköpostiviestistäsi kohteeseen %{destination} (otsikolla%{former_title}) hylättiin. + Jotkin liitteet sähköpostiviestissäsi kohteeseen %{destination} (otsikolla%{former_title}) hylättiin. Tietoja: %{rejected_errors} Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_reject_reply_not_allowed: - title: "Sähköposti hylätty - vastaaminen ei sallittu" - subject_template: "%{email_prefix} Sähköpostiongelma -- Vastaaminen ei sallittu" + title: "Sähköposti hylätty – vastaaminen ei sallittu" + subject_template: "%{email_prefix} Sähköpostiongelma – vastaaminen ei sallittu" text_body_template: | - Pahoittelut, sähköpostiviestisi lähetys kohteeseen %{destination} (titled %{former_title}) ei onnistunut.  + Sähköpostin lähettäminen kohteeseen %{destination} (otsikolla %{former_title}) ei onnistunut. Sinulla ei ole oikeuksia vastata ketjuun. Jos uskot, että tämä johtuu virheestä, [ota yhteyttä henkilökuntaan](%{base_url}/about). email_error_notification: title: "Ilmoitus sähköpostivirheestä" - subject_template: "[%{email_prefix}] Sähköpostiongelma -- virhe POP-autentikoinnissa" + subject_template: "[%{email_prefix}] Sähköpostiongelma – virhe POP-todennuksessa" text_body_template: | - Sähköpostin pollauksessa POP-serveriltä tapahtui autentikointivirhe. + Sähköpostin pollauksessa POP-serveriltä tapahtui todennusvirhe. - Varmista, että olet konfiguroinut POP-tunnukset oikein [sivuston asetuksissa](%{base_url}/admin/site_settings/category/email). + Varmista, että olet määrittänyt POP-tunnukset oikein [sivuston asetuksissa](%{base_url}/admin/site_settings/category/email). - Jos POP-sähköpostitilille on nettikäyttöliittymä, voit joutua kirjautumaan sinne ja tarkistamaan asetukset. + Jos POP-sähköpostitilillä on verkkokäyttöliittymä, voit joutua kirjautumaan sinne ja tarkistamaan asetukset. email_revoked: title: "Sähköposti peruutettiin" subject_template: "Onko sähköpostiosoitteesi oikea?" text_body_template: | - Pahoittelut, mutta meidän on vaikeaa tavoittaa sinua sähköpostitse. Muutamasta viimeisimmästä sähköpostista kaikki ovat palanneet takaisin. koska eivät ole päässeet perille. + Meidän on vaikeaa tavoittaa sinua sähköpostitse. Muutamasta viimeisimmästä sähköpostista kaikki ovat palanneet takaisin, koska niiden toimitus ei ole onnistunut. - Voisitko varmistaa että [sähköpostiosoitteesi](%{base_url}/my/preferences/email) on käypä ja toimiva? Voit myös lisätä sähköpostiosoitteemme osoitekirjaasi / yhteystietoihisi, mikä parantaa viestien perille saapumista. + Voisitko varmistaa, että [sähköpostiosoitteesi](%{base_url}/my/preferences/email) on oikea ja toimiva? Voit myös lisätä sähköpostiosoitteemme osoitekirjaasi tai yhteystietoihisi, mikä parantaa viestien perille saapumista. email_bounced: | Viesti sähköpostiin %{email} palautui. @@ -2692,53 +2691,53 @@ fi: text_body_template: | Hei, - Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että %{ignores_threshold} käyttäjää on estäneet käyttäjän %{username}. Tämä voi viitata siihen, että yhteisössäsi kytee ongelma. + Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että %{ignores_threshold} käyttäjää on estänyt käyttäjän %{username}. Tämä voi viitata siihen, että yhteisössäsi kytee ongelma. - Voit [tarkastella tämän käyttäjän viimeisimpiä viestejä](%{base_url}/u/%{username}/summary), ja mahdollisesti muidenkin lukemalla [estetyt ja hiljennetyt käyttäjät -raportin](%{base_url}/admin/reports/top_ignored_users). + Voit [tarkastella tämän käyttäjän viimeisimpiä viestejä](%{base_url}/u/%{username}/summary), ja mahdollisesti muidenkin lukemalla [sivuutetut ja hiljennetyt käyttäjät -raportin](%{base_url}/admin/reports/top_ignored_users). Saat lisätietoa [yhteisön säännöistä](%{base_url}/guidelines). too_many_spam_flags: - title: "Liian monta roskapostiliputusta" - subject_template: "Uusi tili on estetty" + title: "Liian monta roskapostimerkintää" + subject_template: "Uusi tili on pidossa" text_body_template: | Hei, - Lähetämme tämän automaattisen viestin sivustolta %{site_name} kertoaksemme, että viestejäsi on väliakaisesti piilotettu yhteisön liputusten perusteella. + Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että viestisi on piilotettu tilapäisesti, koska yhteisö on merkinnyt niitä. - Varotoimena uusi käyttäjätilisi on hiljennetty etkä voi vastata tai aloittaa uusia ketjuja ennen kuin henkilökunnan jäsen arvioi tilanteen. Pahoittelemme tästä aiheutuvaa vaivaa. + Varotoimena uusi tilisi on hiljennetty, etkä voi vastata tai aloittaa uusia ketjuja ennen kuin henkilökunnan jäsen tarkastaa tilisi. Pahoittelemme tästä aiheutuvaa vaivaa. Saat lisätietoa [yhteisön ohjeista](%{base_url}/guidelines). too_many_tl3_flags: - title: "Liian monta lt3-lippua" - subject_template: "Uusi tili on estetty" + title: "Liian monta LT3-merkintää" + subject_template: "Uusi tili on pidossa" text_body_template: | Hei, - Lähetämme tämän automaattisen viestin sivustolta %{site_name} kertoaksemme, että käyttäjätilisi on jäädytetty yhteisön liputusten perusteella. + Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että tilisi on pidossa johtuen suuresta määrästä yhteisön merkintöjä. - Varotoimena uusi käyttäjätilisi on hiljennetty etkä voi vastata etkä aloittaa uusia ketjuja ennen kuin henkilökunnan jäsen arvioi tilanteen. Pahoittelemme tästä aiheutuvaa vaivaa. + Varotoimena uusi tilisi on hiljennetty, etkä voi vastata etkä aloittaa uusia ketjuja ennen kuin henkilökunnan jäsen tarkastaa tilisi. Pahoittelemme tästä aiheutuvaa vaivaa. Saat lisätietoa [yhteisön ohjeista](%{base_url}/guidelines). silenced_by_staff: title: "Henkilökunta hiljensi" - subject_template: "Tili väliaikaisesti jäädytetty" + subject_template: "Tili väliaikaisesti pidossa" text_body_template: | Hei, - Lähetämme tämän automaattisen viestin sivustolta %{site_name} kertoaksemme, että käyttäjätilisi on jäädytetty yhteisön liputusten perusteella. + Tämä automaattinen viesti lähetettiin sivustolta %{site_name} kertoaksemme, että tilisi on asetettu tilapäisesti pitoon varotoimena. - Voit jatkaa selailua, muttet voi vastata etkä aloittaa uusia ketjuja ennen kuin henkilökunnan jäsen arvioi tilanteen. Pahoittelemme tästä aiheutuvaa vaivaa. + Voit jatkaa selailua, muttet voi vastata etkä aloittaa uusia ketjuja ennen kuin [henkilökunnan jäsen](%{base_url}/about) tarkastaa viimeisimmät viestisi. Pahoittelemme tästä aiheutuvaa vaivaa. Saat lisätietoa [yhteisön ohjeista](%{base_url}/guidelines). user_automatically_silenced: title: "Käyttäjä hiljennettiin automaattisesti" - subject_template: "Yhteisön liputukset hiljensivät uuden käyttäjän %{username}" + subject_template: "Yhteisön merkinnät hiljensivät uuden käyttäjän %{username}" text_body_template: | Tämä on automaattinen viesti. - Uusi käyttäjä [%{username}](%{user_url}) hiljennettiin automaattisesti, koska useampi käyttäjä liputti hänen viestinsä tai viestejänsä. + Uusi käyttäjä [%{username}](%{user_url}) hiljennettiin automaattisesti, koska useampi käyttäjä on merkinnyt käyttäjän %{username} viestejä. - [Arvioi liputukset](%{base_url}/admin/flags). Jos %{username} hiljennettiin eli viestien kirjoittaminen estettiin ilman syytä. kumoa hiljennys klikkaamalla painiketta [käyttäjän ylläpitosivulla](%{user_url}). + [Tarkasta merkinnät](%{base_url}/admin/flags). Jos %{username} hiljennettiin ilman syytä, kumoa hiljennys klikkaamalla painiketta [käyttäjän ylläpitosivulla](%{user_url}). Rajaa voi muuttaa sivustoasetuksella `silence_new_user. spam_post_blocked: @@ -2746,11 +2745,11 @@ fi: subject_template: "Uuden käyttäjän %{username} viestit estettiin toistuvien linkkien vuoksi" unsilenced: title: "Hiljennys kumottu" - subject_template: "Käyttäjätili ei ole enää jäädytetty" + subject_template: "Käyttäjätili ei ole enää pidossa" text_body_template: | Hei, - Tämä on automaattinen viesti sivustolta %{site_name} kertoaksemme, ettei käyttäjätilisi ole enää jäädytetty henkilökunnan arvioinnin jälkeen. + Tämä on automaattinen viesti sivustolta %{site_name} kertoaksemme, ettei käyttäjätilisi ole enää pidossa henkilökunnan arvioinnin jälkeen. Voit nyt jälleen kirjoittaa vastauksia ja aloittaa ketjuja. Kiitos kärsivällisyydestäsi. pending_users_reminder: @@ -2759,29 +2758,29 @@ fi: one: "%{count} käyttäjä odottaa hyväksymistä" other: "%{count} käyttäjää odottaa hyväksymistä" text_body_template: | - Uusia käyttäjiä odottaa hyväksyntää (tai hylkäystä), mitä ennen he eivät voi käyttää palstaa. + Uusia käyttäjiä odottaa hyväksyntää (tai hylkäystä), mitä ennen he eivät voi käyttää foorumia. - [Tarkastele niitä](%{base_url}/review). + [Tarkasta käyttäjät](%{base_url}/review). download_remote_images_disabled: - title: "Linkattuja kuvia ei ladata" - subject_template: "Linkattujen kuvien lataaminen on otettu pois käytöstä" - text_body_template: "Asetus `download_remote_images_to_local` on otettu pois käytöstä, koska vapaan tilan rajoitus `download_remote_images_threshold` saavutettiin." + title: "Linkattujen kuvien lataaminen on poistettu käytöstä" + subject_template: "Linkattujen kuvien lataaminen on poistettu käytöstä" + text_body_template: "Asetus `download_remote_images_to_local` on poistettu käytöstä, koska vapaan tilan rajoitus `download_remote_images_threshold` saavutettiin." dashboard_problems: title: "Hallintapaneelissa ongelmia" subject_template: "Uusia neuvoja sivustosi hallintapaneelissa" text_body_template: | Meillä on joitakin neuvoja ja suosituksiasi nykyisiin sivustoasetuksiisi pohjautuen. - Katsele [sivustosi hallintapaneelissa](%{base_url}/admin). + Katsele niitä [sivustosi hallintapaneelissa](%{base_url}/admin). Jos hallintapaneelissa ei näy mitään, toinen henkilökuntalainen on voinut jo ottaa vinkistä vaarin. Luettelo henkilökunnan toimista löytyy [henkilökuntalokista](%{base_url}/admin/logs/staff_action_logs). new_user_of_the_month: title: "Olet kuukauden tulokas!" subject_template: "Olet kuukauden tulokas!" text_body_template: | - Onneksi olkoon, olet ansainnut **Kuukauden tulokas -palkinnon %{month_year}** :trophy: + Onneksi olkoon, olet ansainnut **Kuukauden tulokas -palkinnon kuussa %{month_year}** :trophy: - Palkinto myönnetään vain kahdelle käyttäjälle joka kuukausi, ja se näkyy pysyvästi [käyttäjäsivullasi](%{url}). + Palkinto myönnetään vain kahdelle käyttäjälle joka kuukausi, ja se näkyy pysyvästi [kunniamerkkisivullasi](%{url}). Sinusta on äkkiä tullut tärkeä osa yhteisöä. Kiitos kun liityit, ja jatka samaa rataa! queued_posts_reminder: @@ -2818,7 +2817,7 @@ fi: only_reply_by_email: "Vastaa vastaamalla tähän sähköpostiin." only_reply_by_email_pm: "Vastaa käyttäjille %{participants} vastaamalla tähän sähköpostiin." visit_link_to_respond: "[Vieraile ketjussa](%{base_url}%{url}) vastataksesi." - visit_link_to_respond_pm: "Vastaa käyttäjille %{participants} [vierailemalla keskustelussa](%{base_url}%{url})." + visit_link_to_respond_pm: "Vastaa käyttäjille %{participants} [vierailemalla viestissä](%{base_url}%{url})." posted_by: "Käyttäjältä %{username} %{post_date}" pm_participants: "Osanottajat: %{participants}" invited_group_to_private_message_body: | @@ -2900,7 +2899,7 @@ fi: %{respond_instructions} user_replied: - title: "Käyttäjälle vastattiin" + title: "Käyttäjä vastasi" subject_template: "[%{email_prefix}] %{topic_title}" text_body_template: | %{header_instructions} @@ -2911,7 +2910,7 @@ fi: %{respond_instructions} user_replied_pm: - title: "Käyttäjän yksityisviestiin vastattiin" + title: "Käyttäjä vastasi yksityisviestiin" subject_template: "[%{email_prefix}] [YV] %{topic_title}" text_body_template: | %{header_instructions} @@ -2977,7 +2976,7 @@ fi: %{respond_instructions} user_posted: - title: "Käyttäjälle kirjoittiin" + title: "Käyttäjä kirjoitti" subject_template: "[%{email_prefix}] %{topic_title}" text_body_template: | %{header_instructions} @@ -3019,7 +3018,7 @@ fi: title: "Tili hyllytetty" subject_template: "[%{email_prefix}] Tilisi on hyllytetty" account_silenced: - title: "Käyttäjätili hiljennetty" + title: "Tili hiljennetty" subject_template: "[%{email_prefix}] Tilisi on hiljennetty" account_exists: title: "Tili on jo olemassa" @@ -3027,9 +3026,9 @@ fi: text_body_template: | Yritit luoda tilin sivustolle %{site_name} tai yritit muuttaa tilin sähköpostiosoitteeksi %{email}. Sähköpostiosoitteella %{email} on kuitenkin jo tili olemassa. - Jos unohdit salasanasi, [voit uusia sen nyt](%{base_url}/password-reset). + Jos unohdit salasanasi, [voit palauttaa sen nyt](%{base_url}/password-reset). - Jos et yrittänyt luoda tunnusta sähköpostiosoitteella %{email} tai vaihtaa sähköpostiosoitettasi, älä huoli - voit huoletta jättää tämän viestin huomiotta. + Jos et yrittänyt luoda tunnusta sähköpostiosoitteella %{email} tai vaihtaa sähköpostiosoitettasi, älä huoli – voit huoletta jättää tämän viestin huomiotta. Jos sinulla on kysyttävää, [ota yhteyttä avuliaaseen henkilökuntaamme](%{base_url}/about). digest: @@ -3044,15 +3043,15 @@ fi: join_the_discussion: "Lue lisää" popular_posts: "Suosittuja viestejä" more_new: "Uutta sinulle" - subject_template: "[%{email_prefix}] Kooste" - unsubscribe: "Tämä kooste lähetettiin sivustolta %{site_link}, koska sinua ei ole näkynyt vähään aikaan. Voit perua tilauksen muuttamalla %{email_preferences_link} tai %{unsubscribe_link} peruaksesi tilauksen." + subject_template: "[%{email_prefix}] Yhteenveto" + unsubscribe: "Tämä yhteenveto lähetettiin sivustolta %{site_link}, koska sinua ei ole näkynyt vähään aikaan. Voit perua tilauksen muuttamalla %{email_preferences_link} tai %{unsubscribe_link} peruaksesi tilauksen." your_email_settings: "sähköpostiasetuksiasi" click_here: "klikkaa tätä" from: "%{site_name}" preheader: "Lyhyt yhteenveto tapahtumista viime vierailusi (%{last_seen_at}) jälkeen" forgot_password: title: "Unohtunut salasana" - subject_template: "[%{email_prefix}] Salasanan uusiminen" + subject_template: "[%{email_prefix}] Salasanan palauttaminen" text_body_template: | Salasanasi uusimista pyydettiin sivustolla [%{site_name}](%{base_url}). @@ -3064,19 +3063,19 @@ fi: title: "Kirjaudu linkin avulla" subject_template: "%{email_prefix} Kirjautuminen linkin avulla" text_body_template: | - Tässä linkki jolla voit kirjautua sivustolle [%{site_name}](%{base_url}). + Tässä linkki, jolla voit kirjautua sivustolle [%{site_name}](%{base_url}). - Jos et pyytänyt linkkiä voit huoletta sivuuttaa tämän sähköpostiviestin. + Jos et pyytänyt linkkiä, voit huoletta sivuuttaa tämän sähköpostiviestin. Kirjaudu klikkaamalla linkkiä: %{base_url}/session/email-login/%{email_token} set_password: - title: "Syötä salasana" + title: "Aseta salasana" subject_template: "[%{email_prefix}] Aseta salasana" text_body_template: | - Joku haluaa lisätä salasanan tilillesi sivustolla [%{site_name}](%{base_url}). Vaihtoehtoisesti, voit kirjautua sisään muiden tuettujen palveluiden (Google, Facebook, etc) tileillä, jotka ovat tämän saman sähköpostiosoitteen alaisia. + Joku haluaa lisätä salasanan tilillesi sivustolla [%{site_name}](%{base_url}). Vaihtoehtoisesti, voit kirjautua sisään muiden tuettujen palveluiden (Google, Facebook jne.) tileillä, jotka ovat tämän saman sähköpostiosoitteen alaisia. - Jos et ole pyynnön taustalla, voit huoletta jättää tämän viestin huomiotta. + Jos et tehnyt tätä pyyntöä, voit huoletta jättää tämän viestin huomiotta. Valitse uusi salasana klikkaamalla linkkiä: %{base_url}/u/password-reset/%{email_token} @@ -3085,9 +3084,9 @@ fi: subject_template: "[%{email_prefix}] Kirjautuminen" account_created: title: "Tili luotu" - subject_template: "[%{email_prefix}] Uusi käyttäjätilisi" + subject_template: "[%{email_prefix}] Uusi tilisi" text_body_template: | - Sinulle luotiin käyttäjätili sivustolle %{site_name} + Sinulle luotiin tili sivustolle %{site_name} Valitse salasana uudelle tilillesi klikkaamalla linkkiä: %{base_url}/u/password-reset/%{email_token} @@ -3118,12 +3117,12 @@ fi: %{new_email} signup_after_approval: - title: "Liity hyväksynnän jälkeen" + title: "Rekisteröityminen hyväksynnän jälkeen" subject_template: "Sinut on hyväksytty sivustolle %{site_name}!" text_body_template: | Tervetuloa sivustolle %{site_name}! - Henkilökunnan jäsen hyväksyi käyttäjätilisi sivustolle %{site_name}. + Henkilökunnan jäsen hyväksyi tilisi sivustolle %{site_name}. Voit kirjautua sisään osoitteessa: %{base_url} @@ -3136,15 +3135,15 @@ fi: Nauti palstastamme! signup_after_reject: - title: "Rekisteröidy hylkäyksen jälkeen" + title: "Rekisteröityminen hylkäyksen jälkeen" subject_template: "Sinut on hylätty palveluta %{site_name}" text_body_template: | Palvelun %{site_name} ylläpitäjät ovat hylänneet tilisi. %{reject_reason} signup: - title: "Liity" - subject_template: "[%{email_prefix}] Vahvista uusi käyttäjätilisi" + title: "Rekisteröityminen" + subject_template: "[%{email_prefix}] Vahvista uusi tilisi" text_body_template: | Tervetuloa sivustolle %{site_name}! @@ -3170,11 +3169,11 @@ fi: text_body_template: | Hei, - Huomasimme että kirjauduit sinulle epätavalliselta laitteelta tai paikasta. Olitko se sinä? + Huomasimme, että kirjauduit sinulle epätavalliselta laitteelta tai paikasta. Olitko se sinä? - - Paikka: %{location} (%{client_ip}) - - Selain: %{browser} - - Laite: %{device} – %{os} + – Paikka: %{location} (%{client_ip}) + – Selain: %{browser} + – Laite: %{device} – %{os} Jos se olit sinä, hyvä! Sinun ei tarvitse reagoida mitenkään. @@ -3183,7 +3182,7 @@ fi: title: "Viestisi hyväksyttiin" page_forbidden: title: "Hups! Tämä sivu on yksityinen." - site_setting_missing: "\"%{name}\" -asetus on pakollinen." + site_setting_missing: "Asetus \"%{name}\" on pakollinen." page_not_found: title: "Hups! Sivua ei ole tai se on yksityinen." popular_topics: "Suosittuja" @@ -3204,39 +3203,39 @@ fi: deleted: "poistettu" image: "kuva" upload: - edit_reason: "lataa kuvista kopiot" - unauthorized: "Pahoittelut, tiedostomuoto ei ole sallittu (sallitut tiedostopäätteet: %{authorized_extensions})." + edit_reason: "lataa kuvista paikalliset kopiot" + unauthorized: "Tiedosto, jota yrität ladata, ei ole sallittu (sallitut tiedostopäätteet: %{authorized_extensions})." pasted_image_filename: "Liitetty kuva" store_failure: "Latauksen #%{upload_id} käyttäjälle #%{user_id} tallentaminen epäonnistui." - file_missing: "Pahoittelut, sinun täytyy valita tiedosto joka ladataan." - empty: "Pahoittelut, mutta tiedostosi on tyhjä." + file_missing: "Sinun täytyy valita ladattava tiedosto." + empty: "Tiedostosi on tyhjä." png_to_jpg_conversion_failure_message: "PNG:n muuttamisessa JPG:ksi tapahtui virhe." - optimize_failure_message: "Lähetetyn kuvan optimoinniss tapahtui virhe." + optimize_failure_message: "Ladatun kuvan optimoinnissa tapahtui virhe." attachments: - too_large: "Pahoittelut, tiedosto jonka latausta yritit on liian suuri ( suurin tiedostokoko on %{max_size_kb}KB)." + too_large: "Tiedosto, jonka yritit ladata, on liian suuri (suurin tiedostokoko on %{max_size_kb} kt)." images: - too_large: "Pahoittelut, kuva jonka yritit ladata on liian suuri (suurin sallittu kuvakoko on %{max_size_kb}KB), pienennä kuvaa ja yritä uudestaan." - larger_than_x_megapixels: "Pahoittelut, kuva jota yrität lähettää on liian suuri (maksimikoko on %{max_image_megapixels} megapikseliä). Muuta kuvan kokoa ja yritä uudelleen." - size_not_found: "Pahoittelut, mutta emme pystyneet selvittämään kuvan kokoa. Ehkä kuvatiedosto on vahingoittunut?" + too_large: "Kuva, jonka yritit ladata, on liian suuri (suurin sallittu koko on %{max_size_kb} kt), pienennä kuvaa ja yritä uudestaan." + larger_than_x_megapixels: "Kuva, jonka yritit ladata, on liian suuri (maksimikoko on %{max_image_megapixels} megapikseliä). Muuta kuvan kokoa ja yritä uudelleen." + size_not_found: "Emme pystyneet selvittämään kuvan kokoa. Ehkä kuvatiedosto on vahingoittunut?" placeholders: - too_large: "(kuva on suurempi kuin %{max_size_kb}KB)" + too_large: "(kuva on suurempi kuin %{max_size_kb} kt)" avatar: - missing: "Pahoittelut, emme löydä profiilikuvaa, joka olisi yhdistetty tähän sähköpostiosoitteeseen. Voitko yrittää ladata sen uudestaan?" + missing: "Tähän sähköpostiosoitteeseen liitettyä avataria ei löydy. Voitko yrittää ladata sen uudestaan?" flag_reason: sockpuppet: "Uusi käyttäjä aloitti ketjun, johon toinen uusi käyttäjä samasta IP-osoitteesta (%{ip_address}) vastasi. Katso asetus `flag_sockpuppets`." - spam_hosts: "Tämä uusi käyttäjä yritti luoda useita viestejä, joissa oli linkkejä samaan verkkotunnukseen. Kaikki tämän käyttäjän linkkejä sisältävät viestit tulisi tarkastaa. Ks. `newuser_spam_host_threshold` -sivustoasetus." + spam_hosts: "Tämä uusi käyttäjä yritti luoda useita viestejä, joissa oli linkkejä samaan verkkotunnukseen. Kaikki tämän käyttäjän linkkejä sisältävät viestit tulisi tarkastaa. Katso asetus `newuser_spam_host_threshold`." skipped_email_log: exceeded_emails_limit: "max_emails_per_day_per_user ylitettiin" exceeded_bounces_limit: "bounce_score_threshold ylitettiin" mailing_list_no_echo_mode: "Postituslistan ilmoitukset on poistettu käytöstä käyttäjän omilta viesteiltä" - user_email_no_user: "Käyttäjää jonka id on %{user_id} ei löytynyt" - user_email_post_not_found: "Viestiä jonka id on %{post_id} ei löytynyt" + user_email_no_user: "Käyttäjää tunnuksella %{user_id} ei löytynyt" + user_email_post_not_found: "Viestiä tunnuksella %{post_id} ei löytynyt" user_email_anonymous_user: "Käyttäjä on anonyymi" user_email_user_suspended_not_pm: "Käyttäjä on hyllytetty, ei yksityisviesti" user_email_seen_recently: "Käyttäjä nähtiin äskettäin" user_email_notification_already_read: "Ilmoitus luettiin jo ennen kuin tämä sähköposti lähetettiin" - user_email_notification_topic_nil: "post.topic on nil" - user_email_post_user_deleted: "Viestin lähettänyt käyttäjä on poistettu" + user_email_notification_topic_nil: "post.topic on tyhjä arvo" + user_email_post_user_deleted: "Viestin lähettänyt käyttäjä on poistettu." user_email_post_deleted: "kirjoittaja poisti viestin" user_email_user_suspended: "käyttäjä hyllytettiin" user_email_already_read: "käyttäjä luki jo viestin" @@ -3244,8 +3243,8 @@ fi: user_email_no_email: "Käyttäjätunnukseen %{user_id} ei ole liitetty sähköpostiosoitetta" sender_message_blank: "viesti on tyhjä" sender_message_to_blank: "message.to on tyhjä" - sender_text_part_body_blank: "text_part.body on tyhj" - sender_body_blank: "body on tyhjä" + sender_text_part_body_blank: "text_part.body on tyhjä" + sender_body_blank: "leipäteksti on tyhjä" sender_post_deleted: "viesti on poistettu" sender_message_to_invalid: "vastaanottajan sähköpostiosoite ei kelpaa" sender_topic_deleted: "ketju poistettiin" @@ -3288,22 +3287,22 @@ fi: ## [Mitä tietoja keräämme?](#collect) - Tallennamme tietoa sinusta, kun rekisteröidyt sivustolle, ja keräämme dataa toimistasi palstalla: lukemisestasi, kirjoittamisestasi ja siitä miten reagoit täällä jaettuun sisältöön. + Tallennamme tietoa sinusta, kun rekisteröidyt sivustolle, ja keräämme tietoa toimistasi palstalla: lukemisestasi, kirjoittamisestasi ja siitä, miten reagoit täällä jaettuun sisältöön. - Kun rekisteröidyt sivustolle, sinua voidaan pyytää antamaan nimesi ja sähköpostiosoitteesi. Voit kuitenkin halutessasi vierailla sivustolla rekisteröitymättä. Sähköpostiosoitteesi varmistetaan aidoksi ainutkertaisen linkin sisältävän sähköpostiviestin avulla. Kun linkkiä klikataan, tiedämme sinun hallinnoivan antamaasi sähköpostiosoitetta. + Kun rekisteröidyt sivustolle, sinua voidaan pyytää antamaan nimesi ja sähköpostiosoitteesi. Voit kuitenkin halutessasi vierailla sivustolla rekisteröitymättä. Sähköpostiosoitteesi varmistetaan ainutkertaisen linkin sisältävän sähköpostiviestin avulla. Kun linkkiä klikataan, tiedämme sinun hallinnoivan antamaasi sähköpostiosoitetta. - Kun olet rekisteröitynyt ja kirjoitat, tallennamme IP-osoitteen josta viesti lähtee. Voimme myös ylläpitää palvelinlokia, joka sisältää jokaista palvelimellemme lähetettyä pyyntöä vastaavan IP-osoitteen. + Kun olet rekisteröitynyt ja kirjoitat, tallennamme IP-osoitteen, josta viesti lähtee. Voimme myös ylläpitää palvelinlokia, joka sisältää jokaista palvelimellemme lähetettyä pyyntöä vastaavan IP-osoitteen. ## [Mihin käytämme sinusta kerättyä tietoa?](#use) - Mitä tahansa sinusta kerättyä tietoa voidaan käyttää jollain näistä tavoista: + Mitä tahansa sinusta kerättyä tietoa voidaan käyttää jollakin näistä tavoista: * Tarjotaksemme yksilöllisen kokemuksen — tietojen avulla vastaamme sinun henkilökohtaisiin tarpeisiisi. * Parantaaksemme sivustoa — pyrimme jatkuvasti parantamaan sivuston toimintaa kerätyn tiedon ja käyttäjäpalautteen perusteella. * Parantaaksemme asiakaspalvelua — tiedot auttavat tarjoamaan tehokkaampaa asiakaspalvelua ja teknistä tukea. - * Lähettääksemme ajoittain sähköpostiviestejä — antamaasi sähköpostiosoitteeseen voidaan lähettää tietoa, valitsemiasi ilmoituksia tapahtumista eri ketjuissa tai käyttäjänimeesi liittyen ja/tai vastineita tiedusteluihisi, pyyntöihisi tai kysymyksiisi. + * Lähettääksemme ajoittain sähköpostiviestejä — antamaasi sähköpostiosoitteeseen voidaan lähettää tietoa, valitsemiasi ilmoituksia tapahtumista eri ketjuissa tai käyttäjänimeesi liittyen tai vastineita tiedusteluihisi, pyyntöihisi tai kysymyksiisi. @@ -3313,7 +3312,7 @@ fi: - ## [Miten dataa säilötään?](#data-retention) + ## [Miten tietoja säilötään?](#data-retention) Pyrimme parhaamme mukaan: @@ -3324,9 +3323,9 @@ fi: ## [Käytämmekö evästeitä?](#cookies) - Kyllä. Evästeet ovat pieniä tiedostoja, jotka sivusto tai sen palveluntarjoaja siirtää tietokoneesi kiintolevylle verkkoselaimesi kautta (jos sallit sen). Nämä evästeet mahdollistavat selaimesi tunnistamisen ja jos olet rekisteröitynyt käyttäjä, sen yhdistämisen käyttäjätunnukseesi. + Kyllä. Evästeet ovat pieniä tiedostoja, jotka sivusto tai sen palveluntarjoaja siirtää tietokoneesi kiintolevylle verkkoselaimesi kautta (jos sallit sen). Nämä evästeet mahdollistavat selaimesi tunnistamisen, ja jos olet rekisteröitynyt käyttäjä, sen yhdistämisen käyttäjätunnukseesi. - Evästeiden avulla tunnistamme ja tallennamme asetuksia tulevia vierailuja varten ja keräämme yleisluontoista dataa sivuston liikennemääristä ja sivustolla liikkumisesta, jotta voimme tarjota tulevaisuudessa paremman käyttökokemuksen ja hyödyllisiä toimintoja. Voimme tehdä yhteistyötä kolmansien osapuolien kanssa ymmärtääksemme paremmin sivustolla vierailevia. Näillä palveluntarjoajilla ei ole lupaa käyttää keräämäämme tietoa muuhun kuin palvelumme kehittämiseen. + Evästeiden avulla tunnistamme ja tallennamme asetuksia tulevia vierailuja varten ja keräämme yleisluontoista tietoa sivuston liikennemääristä ja sivustolla liikkumisesta, jotta voimme tarjota tulevaisuudessa paremman käyttökokemuksen ja hyödyllisiä toimintoja. Voimme tehdä yhteistyötä kolmansien osapuolien kanssa ymmärtääksemme paremmin sivustolla vierailevia. Näillä palveluntarjoajilla ei ole lupaa käyttää keräämäämme tietoa muuhun kuin palvelumme kehittämiseen. @@ -3338,13 +3337,13 @@ fi: ## [Linkit kolmansien tahojen sivustoille](#third-party) - Joissain tapauksissa, harkintamme mukaan, voimme sallia tai tarjota kolmannen osapuolen tuotteita tai palveluita sivustollamme. Näillä kolmannen osapuolen sivustoilla on erilliset itsenäiset tietosuojaselosteensa. Emme ole edesvastuussa sisällöstä tai toiminnasta näillä linkitetyillä sivustoilla. Joka tapauksessa, pyrimme vaalimaan sivustomme kunniallisuutta ja otamme ilolla vastaan kaikenlaisen palautteen näitä sivustoja koskien. + Joissain tapauksissa, harkintamme mukaan, voimme sallia tai tarjota kolmannen osapuolen tuotteita tai palveluita sivustollamme. Näillä kolmannen osapuolen sivustoilla on erilliset itsenäiset tietosuojaselosteensa. Emme ole vastuussa sisällöstä tai toiminnasta näillä linkitetyillä sivustoilla. Joka tapauksessa pyrimme vaalimaan sivustomme kunniallisuutta ja otamme ilolla vastaan kaikenlaisen palautteen näitä sivustoja koskien. ## [Lapsen yksilönsuoja internetissä -lain noudattaminen](#coppa) - Sivustomme, tuottteemme ja palvelumme on suunnattu 13-vuotiaille ja sitä vanhemmille. Jos tämä palvelin on Yhdysvalloissa ja olet alle 13-vuotias, COPPA-lain ([Children's Online Privacy Protection Act](https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act)) nojalla älä käytä tätä sivustoa. + Sivustomme, tuotteemme ja palvelumme on suunnattu 13-vuotiaille ja sitä vanhemmille. Jos tämä palvelin on Yhdysvalloissa ja olet alle 13-vuotias, COPPA-lain ([Children's Online Privacy Protection Act](https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act)) nojalla älä käytä tätä sivustoa. @@ -3368,7 +3367,7 @@ fi: badges: mass_award: errors: - invalid_csv: Rivillä %{line_number} törmättiin virheeseen. Varmistu, että CSV:ssä on yksi sähköpostiosoite riviä kohden. + invalid_csv: Rivillä %{line_number} havaittiin virhe. Varmista, että CSV:ssä on yksi sähköpostiosoite riviä kohden. editor: name: Muokkaaja description: Ensimmäinen viestin muokkaus @@ -3378,242 +3377,242 @@ fi: name: Wiki-muokkaaja description: Ensimmäinen wiki-muokkaus long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa muokkaat jotakin wiki-viestiä. + Tämä kunniamerkki myönnetään, kun ensi kertaa muokkaat jotakin wiki-viestiä. basic_user: name: Haastaja description: Myönnetty kaikki palstan välttämättömimmät toiminnot long_description: | - Tämä ansiomerkki myönnetään, kun nouset luottamustasolle 1. Kiitos siitä, että olet päättänyt antaa palstalle mahdollisuuden ja selvittänyt mistä yhteisössä on kyse lukemalla muutamaa ketjua. Uuden käyttäjän rajoituksia on nyt poistettu; sait käyttöösi välttämättömimmät palstan toiminnot kuten yksityisviestit, liputtamisen, wiki-viestien muokkaamisen ja oikeuden laittaa viestiin useampia kuvia ja linkkejä. + Tämä kunniamerkki myönnetään, kun nouset luottamustasolle 1. Kiitos siitä, että olet päättänyt antaa palstalle mahdollisuuden ja selvittänyt mistä yhteisössä on kyse lukemalla muutamaa ketjua. Uuden käyttäjän rajoituksia on nyt poistettu; sait käyttöösi välttämättömimmät palstan toiminnot, kuten yksityisviestit, merkinnät, wiki-viestien muokkaamisen ja oikeuden lisätä viestiin useampia kuvia ja linkkejä. member: name: Konkari description: Myönnetty kutsut, ryhmäviestit ja lisää tykkäyksiä long_description: | - Tämä ansiomerkki myönnetään, kun nouset luottamustasolle 2. Kiitos siitä, että olet ollut keskuudessamme viikkojen ajan ja tullut osaksi yhteisöä. Voit nyt lähettää kutsuja käyttäjäsivultasi ja ketjuista, aloittaa yksityisiä ryhmäkeskusteluja ja käytössäsi on enemmän tykkäyksiä päivää kohden. + Tämä kunniamerkki myönnetään, kun nouset luottamustasolle 2. Kiitos siitä, että olet ollut keskuudessamme viikkojen ajan ja tullut osaksi yhteisöä. Voit nyt lähettää kutsuja käyttäjäsivultasi ja ketjuista, aloittaa yksityisiä ryhmäkeskusteluja ja käytössäsi on enemmän tykkäyksiä päivää kohden. regular: name: Mestari - description: Myönnetty ketjujen siirto toiselle alueelle ja uudelleen nimeäminen, hakukoneiden seuraamat linkit, wiki-viestit ja lisää tykkäyksiä + description: Myönnetty ketjujen siirto toiselle alueelle ja uudelleennimeäminen, hakukoneiden seuraamat linkit, wiki-viestit ja lisää tykkäyksiä long_description: | - Tämä ansiomerkki myönnetään, kun nouset luottamustasolle 3. Kiitos kun olet ollut tärkeä osa yhteisöä kuukausien ajan. Olet yksi innokkaimmista lukijoista ja luotettava sisällön tuottaja, ja olet tekemässä yhteisöstä niin hienoa kuin se on. Voit nyt siirtää alueelta toiselle ja uudelleennimetä ketjuja, roskapostiliputuksesi ovat tehokkaampia, pääset lounge-alueelle ja käytössäsi on selvästi enemmän tykkäyksiä päivää kohden. + Tämä kunniamerkki myönnetään, kun nouset luottamustasolle 3. Kiitos kun olet ollut tärkeä osa yhteisöä kuukausien ajan. Olet yksi innokkaimmista lukijoista ja luotettava sisällön tuottaja, ja olet tekemässä yhteisöstä niin hienoa kuin se on. Voit nyt siirtää ketjuja alueelta toiselle ja uudelleennimetä ketjuja, roskapostimerkintäsi ovat tehokkaampia, pääset lounge-alueelle ja käytössäsi on selvästi enemmän tykkäyksiä päivää kohden. leader: name: Johtaja description: Myönnetty minkä tahansa viestin muokkaaminen, kiinnittäminen, sulkeminen, arkistoiminen, pilkkominen ja yhdistäminen ja lisää tykkäyksiä long_description: | - Tämä ansiomerkki myönnetään, kun nouset luottamustasolle 4. Palstan henkilökunta on valinnut sinut johtajaksi. Näytät muille hyvää esimerkkiä sanoin ja teoin. Voit nyt muokata mitä tahansa viestiä ja käytössäsi on yleiset valvojatyökalut kuten kiinnittäminen, sulkeminen, listauksista poistaminen, arkistoiminen, pilkkominen ja yhdistäminen. + Tämä kunniamerkki myönnetään, kun nouset luottamustasolle 4. Palstan henkilökunta on valinnut sinut johtajaksi. Näytät muille hyvää esimerkkiä sanoin ja teoin. Voit nyt muokata mitä tahansa viestiä, ja käytössäsi on yleiset valvojatyökalut kuten kiinnittäminen, sulkeminen, listauksista poistaminen, arkistoiminen, pilkkominen ja yhdistäminen. welcome: name: Tervetuloa description: Sai tykkäyksen long_description: | - Tämä ansiomerkki myönnetään, kun viestistäsi tykätään ensi kertaa. Onnittelut, olet tuottanut jotakin, mitä toinen käyttäjä on pitänyt mielenkiintoisena, hauskana tai hyödyllisenä! + Tämä kunniamerkki myönnetään, kun viestistäsi tykätään ensi kertaa. Onnittelut, olet tuottanut jotakin, mitä toinen käyttäjä on pitänyt mielenkiintoisena, hauskana tai hyödyllisenä! autobiographer: name: Omaelämäkerta description: Täytti käyttäjätiedot long_description: | - Tämä ansiomerkki myönnetään, kun täytät käyttäjätiedot ja valitset profiilikuvan. Kertomalla hieman itsestäsi ja kiinnostuksen kohteistasi edistät yhteisöllisyyttä. Liity meihin! + Tämä kunniamerkki myönnetään, kun täytät käyttäjäprofiilin ja valitset profiilikuvan. Kertomalla hieman itsestäsi ja kiinnostuksen kohteistasi edistät yhteisöllisyyttä. Liity meihin! anniversary: name: Vuosipäivä description: Aktiivinen jäsen vuoden ajan, kirjoittanut ainakin yhden viestin long_description: | - Tämä ansiomerkki myönnetään vuosittain jäsenyytesi vuosipäivän kunniaksi sillä ehdolla, että olet kirjoittanut vuoden aikana ainakin yhden viestin. Kiitos, kun olet yhä keskuudessamme ja antanut panoksesi yhteisön hyväksi. Emme pärjäisi ilman sinua. + Tämä kunniamerkki myönnetään vuosittain jäsenyytesi vuosipäivän kunniaksi sillä ehdolla, että olet kirjoittanut vuoden aikana ainakin yhden viestin. Kiitos, kun olet yhä keskuudessamme ja antanut panoksesi yhteisön hyväksi. Emme pärjäisi ilman sinua. nice_post: name: Hyvä vastaus description: Vastaus sai 10 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun vastauksesi saa 10 tykkäystä. Teit sillä vaikutuksen yhteisöön ja edistit keskustelua! + Tämä kunniamerkki myönnetään, kun vastauksesi saa 10 tykkäystä. Teit sillä vaikutuksen yhteisöön ja edistit keskustelua! good_post: name: Erinomainen vastaus description: Vastaus sai 25 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun vastauksesi saa 25 tykkäystä. Vastauksesi oli poikkeuksellinen ja teki keskustelusta todella paljon mielenkiintoisemman. + Tämä kunniamerkki myönnetään, kun vastauksesi saa 25 tykkäystä. Vastauksesi oli poikkeuksellinen ja teki keskustelusta paljon mielenkiintoisemman. great_post: name: Loistava vastaus description: Vastaus sai 50 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun vastauksesi saa 50 tykkäystä. Vau! Vastauksesi oli innoittava, hulvaton tai oivaltava, ja yhteisö rakasti sitä! + Tämä kunniamerkki myönnetään, kun vastauksesi saa 50 tykkäystä. Vau! Vastauksesi oli innoittava, hulvaton tai oivaltava, ja yhteisö rakasti sitä! nice_topic: name: Hyvä ketju - description: Ketjunavaus sai 10 tykkäystä + description: Ketju sai 10 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun ketjunaloituksesi saa 10 tykkäystä. Aloitit kiintoisan keskustelun, josta yhteisö nautti. + Tämä kunniamerkki myönnetään, kun ketjusi saa 10 tykkäystä. Aloitit kiintoisan keskustelun, josta yhteisö nautti. good_topic: name: Erinomainen ketju - description: Ketjunavaus sai 25 tykkäystä + description: Ketju sai 25 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun ketjunaloituksesi saa 25 tykkäystä. Aloitit vilkkaan keskustelun, jonka ympärillä oli pöhinää. + Tämä kunniamerkki myönnetään, kun ketjusi saa 25 tykkäystä. Aloitit vilkkaan keskustelun, jonka ympärillä oli pöhinää. great_topic: name: Loistava ketju - description: Ketjunavaus sai 50 tykkäystä + description: Ketju sai 50 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun ketjunaloituksesi saa 50 tykkäystä. Aloitit kiehtovan keskustelun, jota yhteisö rakasti! + Tämä kunniamerkki myönnetään, kun ketjusi saa 50 tykkäystä. Aloitit kiehtovan keskustelun, jota yhteisö rakasti! nice_share: name: Hyvä jako description: Jakoi viestin, ja linkin kautta saapui 25 vierailijaa long_description: | - Tämä ansiomerkki myönnetään, kun jaat linkin, jota klikkaa 25 ulkopuolista vierailijaa. Kiitos kun levität sanaa palstan keskusteluista ja yhteisöstä. + Tämä kunniamerkki myönnetään, kun jaat linkin, jota klikkaa 25 ulkopuolista vierailijaa. Kiitos kun levität sanaa palstan keskusteluista ja yhteisöstä. good_share: name: Erinomainen jako description: Jakoi viestin, ja linkin kautta saapui 300 vierailijaa long_description: | - Tämä ansiomerkki myönnetään, kun jaat linkin jota klikkaa 300 ulkopuolista vierailijaa. Hyvää työtä! Olet esitellyt upean keskustelun joukolle uusia ihmisiä ja siten auttanut yhteisöä kasvamaan. + Tämä kunniamerkki myönnetään, kun jaat linkin, jota klikkaa 300 ulkopuolista vierailijaa. Hyvää työtä! Olet esitellyt upean keskustelun joukolle uusia ihmisiä ja siten auttanut yhteisöä kasvamaan. great_share: name: Loistava jako - description: Jakoi viestin, ja linkin kautta saapui 1000 vierailijaa + description: Jakoi viestin, ja linkin kautta saapui 1 000 vierailijaa long_description: | - Tämä ansiomerkki myönnetään, kun jaat linkin jota klikkaa 1000 ulkopuolista vierailijaa. Vau! Olet mainostanut kiinnostavaa keskustelua valtavalle joukolle uusia ihmisiä ja siten auttanut yhteisöä kasvamaan merkittävästi. + Tämä kunniamerkki myönnetään, kun jaat linkin, jota klikkaa 1 000 ulkopuolista vierailijaa. Vau! Olet mainostanut kiinnostavaa keskustelua valtavalle joukolle uusia ihmisiä ja siten auttanut yhteisöä kasvamaan merkittävästi. first_like: name: Ensimmäinen tykkäys description: Tykkäsi viestistä long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa tykkäät viestistä käyttäen :heart: -nappia. Viestistä tykkääminen on hyvä tapa viestiä yhteisön toiselle jäsenelle, että hänen viestinsä oli kiintoisa, hyödyllinen, osuva tai hauska. Jaa rakkautta! + Tämä kunniamerkki myönnetään, kun ensi kertaa tykkäät viestistä käyttäen :heart: -painiketta. Viestistä tykkääminen on hyvä tapa viestiä yhteisön toiselle jäsenelle, että hänen viestinsä oli kiintoisa, hyödyllinen, osuva tai hauska. Jaa rakkautta! first_flag: - name: Ensimmäinen liputus - description: Liputti viestin + name: Ensimmäinen merkintä + description: Merkitsi viestin long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa liputat viestin. Liputtaminen on keino, jolla yhdessä pidämme mukavana paikkana. Jos huomaat viestin joka valvojien tulisi huomioida syystä tai toisesta, älä epäröi liputtaa. Kun näet ongelman, :flag_black: liputa! + Tämä kunniamerkki myönnetään, kun ensi kertaa merkitset viestin. Merkitseminen on keino, jolla yhdessä pidämme mukavana paikkana. Jos huomaat viestin joka valvojien tulisi huomioida syystä tai toisesta, älä epäröi merkitä viestejä. Kun näet ongelman, :flag_black: merkitse se! promoter: name: Markkinoija description: Kutsui käyttäjän long_description: | - Tämä ansiomerkki myönnetään, kun kutsut jonkun liittymään yhteisöön käyttäen kutsupainiketta joko käyttäjäsivullasi tai ketjun alapuolella. Joistain keskusteluista mahdollisesti kiinnostuneen ystävän kutsuminen on erinomainen tapa tuoda uusia ihmisiä yhteisöön, joten kiitos! + Tämä kunniamerkki myönnetään, kun kutsut jonkun liittymään yhteisöön käyttäen kutsupainiketta joko käyttäjäsivullasi tai ketjun alaosassa. Joistain keskusteluista mahdollisesti kiinnostuneen ystävän kutsuminen on erinomainen tapa tuoda uusia ihmisiä yhteisöön, joten kiitos! campaigner: name: Kampanjoija description: Kutsui kolme haastajaa (luottamustaso 1) long_description: | - Tämä ansiomerkki myönnetään, kun 3 kutsumaasi ihmistä viettää sen verran aikaa palstalla, että heidän luottamustasonsa nousee ja heistä tulee "haastajia". Elinvoimainen yhteisö tarvitsee säännöllisesti uusia jäseniä tuomaan tuoreita näkökulmia keskusteluihin. + Tämä kunniamerkki myönnetään, kun 3 kutsumaasi ihmistä viettää sen verran aikaa palstalla, että heidän luottamustasonsa nousee ja heistä tulee "haastajia". Elinvoimainen yhteisö tarvitsee säännöllisesti uusia jäseniä tuomaan tuoreita näkökulmia keskusteluihin. champion: name: Kampanjapäällikkö description: Kutsui viisi konkaria (luottamustaso 2) long_description: | - Tämä ansiomerkki myönnetään, kun 5 kutsumaasi ihmistä viettää sen verran aikaa palstalla, että heistä tulee yhteisön täysivaltaisia jäseniä. Vau! Kiitos, kun edistät yhteisömme monimuotoisuutta tuomalla uusia jäseniä! + Tämä kunniamerkki myönnetään, kun 5 kutsumaasi ihmistä viettää sen verran aikaa palstalla, että heistä tulee yhteisön täysivaltaisia jäseniä. Vau! Kiitos, kun edistät yhteisömme monimuotoisuutta tuomalla uusia jäseniä! first_share: name: Ensimmäinen jako description: Jakoi viestin long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa jaat linkin vastaukseen tai ketjuun käyttämällä Jaa-nappia. Jakaminen on hyvä tapa esitellä kiintoisia keskusteluita muulle maailmalle ja siten kasvattaa yhteisöä. + Tämä kunniamerkki myönnetään, kun ensi kertaa jaat linkin vastaukseen tai ketjuun käyttämällä Jaa-nappia. Jakaminen on hyvä tapa esitellä kiintoisia keskusteluita muulle maailmalle ja siten kasvattaa yhteisöä. first_link: name: Ensimmäinen linkki description: Linkitti toiseen ketjuun long_description: | - Tämä ansiomerkki myönnetään, kun ensi kerran linkität toiseen ketjuun. Linkittäminen auttaa kanssalukijoita löytämään kiintoisia asiaa sivuavia keskusteluita ja osoittaa yhteyksiä ketjujen välillä. Linkitä ahkerasti! + Tämä kunniamerkki myönnetään, kun ensi kerran linkität toiseen ketjuun. Linkittäminen auttaa kanssalukijoita löytämään kiintoisia asiaa sivuavia keskusteluita ja osoittaa yhteyksiä ketjujen välillä. Linkitä ahkerasti! first_quote: name: Ensimmäinen lainaus description: Lainasi viestiä long_description: | - Tämä ansiomerkki myönnetään, kun ensi kerran lainaat viestiä vastauksessasi. Edellisten viestien olennaisten osien lainaaminen auttaa pitämään keskustelun yhtenäisenä ja aiheen mukaisena. Helpoin tapa on maalata viestin osa ja sitten painaa vastaa-painiketta. Muista lainata! + Tämä kunniamerkki myönnetään, kun ensi kerran lainaat viestiä vastauksessasi. Edellisten viestien olennaisten osien lainaaminen auttaa pitämään keskustelun yhtenäisenä ja aiheen mukaisena. Helpoin tapa on maalata viestin osa ja sitten painaa vastauspainiketta. Muista lainata! read_guidelines: name: Luki ohjeet description: Luki yhteisön säännöt long_description: | - Tämä ansiomerkki myönnetään, kun luet yhteisön säännöt. Noudattamalla ja jakamalla näitä yksinkertaisia ohjeita edistät yhteisön turvallisuutta, viihtyisyyttä ja jatkuvuutta. Muista aina, että toisessa päässä on toinen ihminen, hyvin itsesi kaltainen. Ole mukava toisille! + Tämä kunniamerkki myönnetään, kun luet yhteisön säännöt. Noudattamalla ja jakamalla näitä yksinkertaisia ohjeita edistät yhteisön turvallisuutta, viihtyisyyttä ja jatkuvuutta. Muista aina, että toisessa päässä on toinen ihminen, hyvin itsesi kaltainen. Ole mukava toisille! reader: name: Lukutoukka description: Luki jokaisen viestin ketjusta, jossa on enemmän kuin 100 vastausta long_description: | - Tämä ansiomerkki myönnetään, kun ensi kerran luet pitkän, vähintään 100 vastauksen ketjun. Keskustelua huolellisesti lukemalla tiedät missä mennään ja ymmärrät eri näkökulmia, mikä johtaa mielenkiintoisempiin keskusteluihin. Mitä enemmän luet, sitä parempaa on vuoropuhelu. Kuten tapaamme sanoa, lukeminen on kaiken A ja O! :slight_smile: + Tämä kunniamerkki myönnetään, kun ensi kerran luet pitkän, vähintään 100 vastauksen ketjun. Keskustelua huolellisesti lukemalla tiedät missä mennään ja ymmärrät eri näkökulmia, mikä johtaa mielenkiintoisempiin keskusteluihin. Mitä enemmän luet, sitä parempaa on vuoropuhelu. Kuten tapaamme sanoa, lukeminen on kaiken A ja O! :slight_smile: popular_link: name: Suosittu linkki description: Linkitti ulkoiselle sivustolle, ja linkkiä klikattiin 50 kertaa long_description: | - Tämä ansiomerkki myönnetään, kun linkkiäsi klikataan 50 kertaa. Kiitos kun laitoit hyödyllisen linkin, joka toi mielenkiintoista sisältöä keskusteluun! + Tämä kunniamerkki myönnetään, kun jakamaasi linkkiäsi klikataan 50 kertaa. Kiitos kun lisäsit hyödyllisen linkin, joka toi mielenkiintoista sisältöä keskusteluun! hot_link: name: Kuuma linkki description: Linkitti ulkoiselle sivustolle, ja linkkiä klikattiin 300 kertaa long_description: | - Tämä ansiomerkki myönnetään, kun linkkiäsi klikataan 300 kertaa. Kiitos kun laitoit kiehtovan linkin, joka vei keskustelua eteenpäin ja todella edisti keskustelua! + Tämä kunniamerkki myönnetään, kun jakamaasi linkkiäsi klikataan 300 kertaa. Kiitos kun lisäsit kiehtovan linkin, joka vei keskustelua eteenpäin ja todella edisti keskustelua! famous_link: name: Kuuluisa linkki - description: Linkitti ulkoiselle sivustolle, ja linkkiä klikattiin 1000 kertaa + description: Linkitti ulkoiselle sivustolle, ja linkkiä klikattiin 1 000 kertaa long_description: | - Tämä ansiomerkki myönnetään, kun linkkisi saa 1000 klikkausta. Vau! Sen sisältö paransi keskustelua merkittävästi. Hyvin tehty! + Tämä ansiomerkki myönnetään, kun jakamasi linkkisi saa 1 000 klikkausta. Vau! Sen sisältö paransi keskustelua merkittävästi. Hyvin tehty! appreciated: name: Arvostettu description: 20 viestiä sai tykkäyksen long_description: | - Tämä ansiomerkki myönnetään, kun olet saanut tykkäyksen vähintään 20 viestistä. Yhteisö nauttii panoksestasi keskusteluun täällä! + Tämä kunniamerkki myönnetään, kun olet saanut tykkäyksen vähintään 20 viestistä. Yhteisö nauttii panoksestasi keskusteluun täällä! respected: name: Kunnioitettu description: 100 viestiä sai ainakin 2 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun olet saanut vähintään 2 tykkäystä 100 viestistä. Yhteisö alkaa arvostaa panostasi keskusteluihin täällä. + Tämä kunniamerkki myönnetään, kun olet saanut vähintään 2 tykkäystä 100 viestistä. Yhteisö alkaa arvostaa panostasi keskusteluihin täällä. admired: name: Ihailtu description: 300 viestiä sai ainakin 5 tykkäystä long_description: | - Tämä ansiomerkki myönnetään, kun olet saanut vähintään 5 tykkäystä 300 viestistä. Wow! Yhteisö ihailee toistuvaa korkealaatuista panostasi keskusteluun täällä! + Tämä kunniamerkki myönnetään, kun olet saanut vähintään 5 tykkäystä 300 viestistä. Wow! Yhteisö ihailee toistuvaa korkealaatuista panostasi keskusteluun täällä! out_of_love: name: Rakkaudesta description: Käytti %{max_likes_per_day} tykkäystä päivässä long_description: | - Tämä ansiomerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi. Se, että muistaa pysähtyä hetkeksi tykkäämään arvostamastaan viestistä, kannustaa muita palstan kirjoittajia luomaan hienoja keskusteluja jatkossakin. + Tämä kunniamerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi. Se, että muistaa pysähtyä hetkeksi tykkäämään arvostamastaan viestistä, kannustaa muita palstan kirjoittajia luomaan hienoja keskusteluja jatkossakin. higher_love: name: Lukkarinrakkautta description: Käytti %{max_likes_per_day} tykkäystä päivässä viidesti long_description: | - Tämä ansiomerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi viitenä päivänä. Kiitos kun vaivaudut aktiivisesti kannustamaan muita hyviin keskusteluihin joka päivä! + Tämä kunniamerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi viitenä päivänä. Kiitos kun vaivaudut aktiivisesti kannustamaan muita hyviin keskusteluihin joka päivä! crazy_in_love: name: Korviaan myöten rakastunut long_description: | - Tämä ansiomerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi 20 päivänä. Vau! Esimerkillistä muiden palstan käyttäjien kannustamista! + Tämä kunniamerkki myönnetään, kun olet käyttänyt kaikki %{max_likes_per_day} päivittäistä tykkäystäsi 20 päivänä. Vau! Esimerkillistä muiden palstan käyttäjien kannustamista! thank_you: name: Kiitos description: 20 viestistä on tykätty ja on tykännyt 10 viestistä long_description: | - Tämä ansiomerkki myönnetään, kun olet sinulla on 20 tykättyä viestiä ja olet antanut 10 tykkäystä takaisin. Kun joku tykkää viestistäsi, löydät aikaa tykätä myös muiden viesteistä. + Tämä kunniamerkki myönnetään, kun olet sinulla on 20 tykättyä viestiä ja olet antanut 10 tykkäystä takaisin. Kun joku tykkää viestistäsi, löydät aikaa tykätä myös muiden viesteistä. gives_back: name: Vastavuoroinen description: 100 viestistä on tykätty ja on tykännyt 100 viestistä long_description: | - Tämä ansiomerkki myönnetään, kun sinulla on 100 tykättyä viestiä ja olet antanut vähintään 100 tykkäystä takaisin. Kiitos kun laitat hyvän kiertämään! + Tämä kunniamerkki myönnetään, kun sinulla on 100 tykättyä viestiä ja olet antanut vähintään 100 tykkäystä takaisin. Kiitos kun laitat hyvän kiertämään! empathetic: name: Empaattinen - description: 500 viestistä on tykätty ja on tykännyt 1000 viestistä + description: 500 viestistä on tykätty ja on tykännyt 1 000 viestistä long_description: | - Tämä ansiomerkki myönnetään, kun sinulla on 500 tykättyä viestiä ja olet antanut vähintään 1000 tykkäystä takaisin. Wow! Olet esimerkki avokätisyydestä ja keskinäisestä arvostuksesta :two_hearts:. + Tämä kunniamerkki myönnetään, kun sinulla on 500 tykättyä viestiä ja olet antanut vähintään 1 000 tykkäystä takaisin. Wow! Olet esimerkki avokätisyydestä ja keskinäisestä arvostuksesta :two_hearts:. first_emoji: - name: Ensimmäinen Emoji - description: Käytti Emojia viestissä + name: Ensimmäinen emoji + description: Käytti emojia viestissä long_description: | - Tämä ansiomerkki myönnetään, kun ensi kerran lisäät emojin viestiin :thumbsup:. Emoji auttaa viestimään tunteista, ilosta :smiley: to suruun :anguished: ja vihaan :angry: ja kaikkea siltä väliltä :sunglasses:. Näppäile : (kaksoispiste) tai paina viestikentän työkalupalkin työkalupainiketta niin pääset valitsemaan sadoista vaihtoehdoista :ok_hand: + Tämä kunniamerkki myönnetään, kun ensi kerran lisäät emojin viestiin :thumbsup:. Emoji auttaa viestimään tunteista, ilosta :smiley: suruun :anguished: ja vihaan :angry: ja kaikkea siltä väliltä :sunglasses:. Näppäile : (kaksoispiste) tai paina viestikentän työkalupalkin emojipainiketta, niin pääset valitsemaan sadoista vaihtoehdoista :ok_hand: first_mention: name: Ensimmäinen maininta description: Mainitsi käyttäjän viestissä long_description: | - Tämä ansiomerkki myönnetään, kun ensi kerran mainitset jonkun @käyttäjänimen viestissäsi. Jokaisesta maininnasta sen kohde saa ilmoituksen, jotta tietää viestistäsi. Mainitse käyttäjä kirjoittamalla @ (at-merkki) tai, jos sallittua voit mainita myös ryhmän. Se on helppo tapa kiinnittää heidän huomionsa. + Tämä kunniamerkki myönnetään, kun ensi kerran mainitset jonkun @käyttäjätunnuksen viestissäsi. Jokaisesta maininnasta sen kohde saa ilmoituksen, jotta tietää viestistäsi. Mainitse käyttäjä kirjoittamalla @ (at-merkki), tai voit mainita myös ryhmän, jos se on sallittua. Se on helppo tapa kiinnittää heidän huomionsa. first_onebox: name: Ensimmäinen Onebox description: Lähetti linkin, josta tehtiin onebox long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa lisäät linkin omalle rivilleen, josta sitten automaattisesti tehdään onebox-esikatselu otsikolla, lyhyellä kuvauksella ja (jos saatavilla) kuvalla. + Tämä kunniamerkki myönnetään, kun ensi kertaa lisäät linkin omalle rivilleen, josta sitten automaattisesti tehdään onebox-esikatselu otsikolla, lyhyellä kuvauksella ja (jos saatavilla) kuvalla. first_reply_by_email: name: Ensimmäinen vastaus sähköpostilla description: Vastasi sähköpostilla long_description: | - Tämä ansiomerkki myönnetään, kun ensi kertaa vastaat sähköpostilla :e-mail:. + Tämä kunniamerkki myönnetään, kun ensi kertaa vastaat viestiin sähköpostilla :e-mail:. new_user_of_the_month: name: "Kuukauden tulokas" description: Erinomaista osallistumista ensimmäisen kuukauden aikana long_description: | - Tämä ansiomerkki myönnetään kahdelle uudelle käyttäjälle joka kuukausi kiitoksena erinomaisesta osallistumisestaan. Mittari on tykkäykset: kuinka usein viesteistä tykätään ja kuka tykkää. + Tämä kunniamerkki myönnetään kahdelle uudelle käyttäjälle joka kuukausi kiitoksena erinomaisesta osallistumisestaan. Mittari on tykkäykset: kuinka usein viesteistä tykätään ja kuka tykkää. enthusiast: name: Intoilija description: Vieraili 10 perättäisenä päivänä long_description: | - Tämä ansiomerkki myönnetään, kun vierailet 10 peräkkäisenä päivänä. Kiitos kun olet ollut kanssamme yli viikon ajan! + Tämä kunniamerkki myönnetään, kun vierailet 10 peräkkäisenä päivänä. Kiitos kun olet ollut kanssamme yli viikon ajan! aficionado: name: Hullaantunut description: Vieraili 100 perättäisenä päivänä long_description: | - Tämä ansiomerkki myönnetään, kun vierailet 100 peräkkäisenä päivänä. Sehän on yli kolme kuukautta! + Tämä kunniamerkki myönnetään, kun vierailet 100 peräkkäisenä päivänä. Sehän on yli kolme kuukautta! devotee: name: Omistautunut description: Vieraili 365 perättäisenä päivänä long_description: | - Tämä ansiomerkki myönnetään, kun vierailet 365 peräkkäisenä päivänä. Vau, kokonainen vuosi! - badge_title_metadata: "%{display_name} -ansiomerkki sivustolla %{site_title}" + Tämä kunniamerkki myönnetään, kun vierailet 365 peräkkäisenä päivänä. Vau, kokonainen vuosi! + badge_title_metadata: "Kunniamerkki %{display_name} sivustolla %{site_title}" admin_login: success: "Sähköposti lähetetty" errors: unknown_email_address: "Tuntematon sähköpostiosoite." - invalid_token: "Tunnistusväline ei kelpaa." + invalid_token: "Tunniste ei kelpaa." email_input: "Ylläpitäjän sähköpostiosoite" submit_button: "Lähetä sähköposti" performance_report: @@ -3622,19 +3621,19 @@ fi: tags: title: "Tunnisteet" minimum_required_tags: - one: "Täytyy valita ainakin %{count} tunniste." - other: "Täytyy valita ainakin %{count} tunnistetta." + one: "Sinun täytyy valita ainakin %{count} tunniste." + other: "Sinun täytyy valita ainakin %{count} tunnistetta." upload_row_too_long: "CSV:ssä tulee olla yksi tunniste per rivi. Tunnistetta voi seurata pilkku ja sitä tunnisteryhmän nimi." forbidden: invalid: one: "Valitsemaasi tunnistetta ei voi käyttää" other: "Mitään valitsemistasi tunnisteista ei voi käyttää" - in_this_category: '"%{tag_name}" ei voi käyttää tällä alueella' + in_this_category: 'Tunnistetta "%{tag_name}" ei voi käyttää tällä alueella' restricted_to: one: 'Tunnistetta "%{tag_name}" voi käyttää ainoastaan alueella %{category_names}' - other: 'Tunnistetta "%{tag_name}" voi käyttää ainoastaan alueilla: %{category_names}' - synonym: 'Synonyymit kielletty. Käytä sijasta tunnistetta "%{tag_name}".' - has_synonyms: 'Tunnistetta "%{tag_name}" ei voi käyttää koska sillä on synonyymejä.' + other: 'Tunnistetta "%{tag_name}" voi käyttää ainoastaan seuraavilla alueilla: %{category_names}' + synonym: 'Synonyymit kielletty. Käytä sen sijaan tunnistetta "%{tag_name}".' + has_synonyms: 'Tunnistetta "%{tag_name}" ei voi käyttää, koska sillä on synonyymejä.' invalid_target_tag: "ei voi olla synonyymin synonyymi" synonyms_exist: "ei ole sallittua, kun synonyymejä on olemassa" rss_by_tag: "Ketjut tunnisteella %{tag}" @@ -3642,28 +3641,28 @@ fi: congratulations: "Onneksi olkoon, asensit Discoursen!" register: button: "Rekisteröidy" - title: "Rekisteröi ylläpitäjätunnus" - help: "aloita rekisteröimällä uusi tunnus" - no_emails: "Valitettavasti ylläpitäjän sähköpostiosoitetta ei määritetty asennuksen aikana, joten asennuksen viimeistely voi olla vaikeaa. Lisää kehittäjäsähköposti konfiguraatiotiedostoon tai luo ylläpitäjätili konsolissa." + title: "Rekisteröi ylläpitäjän tili" + help: "aloita rekisteröimällä uusi tili" + no_emails: "Ylläpitäjän sähköpostiosoitetta ei määritetty asennuksen aikana, joten asennuksen viimeistely voi olla vaikeaa. Lisää kehittäjän sähköpostiosoite määritystiedostoon tai luo ylläpitäjän tili konsolissa." confirm_email: title: "Vahvista sähköpostisi" - message: "

      Lähetimme aktivointiviestin sähköpostiosoitteeseen %{email}. Ota tili käyttöön seuraamalla sähköpostiviestin ohjeita.

      Jos viesti ei saavu, tarkista roskapostikansio ja varmistu että asensit sähköpostipalvelun oikein.

      " + message: "

      Lähetimme aktivointiviestin sähköpostiosoitteeseen %{email}. Aktivoi tili seuraamalla sähköpostiviestin ohjeita.

      Jos viesti ei saavu, tarkista roskapostikansio ja varmista, että määritit sähköpostin oikein.

      " resend_email: - title: "Lähetä vahvistusviesti uudelleen" - message: "Lähetimme uuden vahvistusviestin osoitteeseen %{email}" + title: "Lähetä aktivointiviesti uudelleen" + message: "

      Lähetimme uuden aktivointiviestin osoitteeseen %{email}" safe_mode: title: "Siirry vikasietotilaan" description: "Vikasietotilassa voit testata sivustoasi ilman lisäosia ja sivuston mukautuksia." no_customizations: "Poista nykyinen teema käytöstä" - only_official: "Poista käytöstä epäviralliset lisäosat" - no_plugins: "Poista käytöstä kaikki lisäosat" + only_official: "Poista epäviralliset lisäosat käytöstä" + no_plugins: "Poista kaikki lisäosat käytöstä" enter: "Siirry vikasietotilaan" must_select: "Ainakin yksi on valittava, jotta voi siirtyä vikasietotilaan." wizard: title: "Discoursen asennus" step: locale: - title: "Tervetuloa Discourse-asennukseen!" + title: "Tervetuloa Discourseen!" fields: default_locale: description: "Mikä on yhteisön oletuskieli?" @@ -3682,13 +3681,13 @@ fi: placeholder: "Paras yhteisö ikinä missään" introduction: title: "Johdanto" - disabled: "

      Ketjua otsikolla “%{topic_title}” ei löytynyt.

      • If Jos olet muuttanut otsikkoa, muuta sivuston esittelytekstiä muokkaamalla sitä ketjua.
      • Jos olet poistanut ketjun, luo uusi ketju otsikolla “%{topic_title}”. Ketjun ensimmäisen viestin sisältö on sivustosi esittelyteksti.
      " + disabled: "

      Ketjua otsikolla “%{topic_title}” ei löytynyt.

      • Jos olet muuttanut otsikkoa, muuta sivuston esittelytekstiä muokkaamalla sitä ketjua.
      • Jos olet poistanut ketjun, luo uusi ketju otsikolla “%{topic_title}”. Ketjun ensimmäisen viestin sisältö on sivustosi esittelyteksti.
      " fields: welcome: label: "Tervetuloa-ketju" description: "

      Kuinka kuvailisit yhteisöä hississä ventovieraalle, jos sinulla olisi minuutti aikaa?

      • Keille nämä keskustelut on suunnattu?
      • Mistä täällä puhutaan?
      • Miksi täällä kannattaa käydä?

      Tervetuloa-ketju on ensimmäinen asia, jonka palstalle saapunut näkee. Ajattele sitä yhden kappaleen mittaisena \"hissipuheena\" tai \"perimmäisenä tavoitteena\".

      " one_paragraph: "Pidä tervetuloviesti yhden kappaleen mittaisena." - extra_description: "Jos olet epävarma, voit ohittaa tämän vaiheen ja muokata tervetuloa-ketjua myöhemmin." + extra_description: "Jos olet epävarma, voit ohittaa tämän vaiheen ja muokata tervetuloketjua myöhemmin." privacy: title: "Käyttöoikeudet" description: "

      Onko yhteisö avoin kaikille vai onko sitä rajattu jäsenyyden, kutsujen tai hyväksynnän avulla? Jos haluat, voit määritellä asioita aluksi yksityisesti ja muuttaa sivuston julkiseksi myöhemmin.

      " @@ -3716,17 +3715,17 @@ fi: contact_email: label: "Sähköposti" placeholder: "nimi@esimerkki.fi" - description: "Tätä yhteisöä ylläpitävän henkilön tai ryhmän sähköpostiosoite. Käytetään vastaanottamaan kriittisiä huomautuksia käsittelemättömistä liputuksista ja tietoturvapäivityksistä. Se näkyy myös Tietoja -sivulla kiireellisiä yhteydenottoja varten." + description: "Tätä yhteisöä ylläpitävän henkilön tai ryhmän sähköpostiosoite. Käytetään vastaanottamaan kriittisiä huomautuksia käsittelemättömistä merkinnöistä ja tietoturvapäivityksistä. Se näkyy myös Tietoja-sivulla kiireellisiä yhteydenottoja varten." contact_url: label: "Verkkosivu" placeholder: "https://www.esimerkki.fi/ota-yhteytta" - description: "Sinun tai organisaatiosi yleinen yhteydenottosivu. Näkyy Tietoja -sivulla." + description: "Sinun tai organisaatiosi yleinen yhteydenottosivu. Näkyy Tietoja-sivulla." site_contact: label: "Automaattiset viestit" - description: "Kaikki Discoursen automaattiset yksityisviestit lähetetään tältä käyttäjältä, kuten varoitukset liputuksista ja ilmoitukset valmistuneista varmuuskopioista." + description: "Kaikki Discoursen automaattiset yksityisviestit lähetetään tältä käyttäjältä, kuten varoitukset merkinnöistä ja ilmoitukset valmistuneista varmuuskopioista." corporate: title: "Organisaatio" - description: "Tämä tieto syötetään Palveluehdot -sivulle, joka on henkilökunta-alueella sijaitseva ketju jota voit muokata tarpeen mukaan. Jos sinulla ei ole yritystä, voit hypätä tämän vaiheen yli." + description: "Tämä tieto lisätään käyttöehtoihin, joka on henkilökunta-alueella sijaitseva ketju, jota voit muokata tarpeen mukaan. Jos sinulla ei ole yritystä, voit ohittaa tämän vaiheen." fields: company_name: label: "Yrityksen nimi" @@ -3735,10 +3734,10 @@ fi: label: "Sovellettava lainsäädäntö" placeholder: "Kalifornian laki" city_for_disputes: - label: "Kaupunki jonka oikeudessa riidat ratkotaan" + label: "Kaupunki, jonka oikeudessa riidat ratkotaan" placeholder: "San Francisco, Kalifornia" colors: - title: "Värimallit" + title: "Värit" fonts: title: "Fontit" fields: @@ -3755,20 +3754,20 @@ fi: label: "Ensisijainen logo" description: "Sivuston vasemmassa yläkulmassa oleva logokuva. Käytä leveää suorakulmaista kuvaa, jonka korkeus on 120 ja kuvasuhde suurempi kuin 3:1" logo_small: - label: "Neliömäinen logo" - description: "Neliön muotoinen versio sivuston logosta. Näkyy sivuston vasemmassa yläkulmassa kun vierität selaimessa sivua alaspäin sekä sosiaalisen median julkaisuissa. Mielellään suurempi kuin 512x512." + label: "Neliölogo" + description: "Neliön muotoinen versio sivuston logosta. Näkyy sivuston vasemmassa yläkulmassa, kun vierität selaimessa sivua alaspäin, sekä sosiaalisen median julkaisuissa. Mielellään suurempi kuin 512x512." icons: title: "Kuvakkeet" fields: favicon: label: "Selaimen kuvake" - description: "Favicon-kuvake, jota käytetään kuvaamaan sivustoa selaimissa ja joka näyttää hyvältä pienissä kokoluokissa. Suositellut tiedostomuodot ovat PNG tai JPG. Käytämme oletuksena neliömäistä logoa." + description: "Kuvake, jota käytetään kuvaamaan sivustoa selaimissa, ja joka näyttää hyvältä pienissä kokoluokissa. Suositellut tiedostomuodot ovat PNG tai JPG. Käytämme oletuksena neliön muotoista logoa." large_icon: label: "Suuri kuvake" - description: "Kuvake jota käytetään kuvaamaan sivustoa moderneilla laitteilla ja joka näyttää hyvältä suuremmissa kokoluokissa. Mielellään suurempi kuin 512 × 512. Käytämme oletuksena neliömäistä logoa." + description: "Kuvake, jota käytetään kuvaamaan sivustoa moderneilla laitteilla, ja joka näyttää hyvältä suuremmissa kokoluokissa. Mielellään suurempi kuin 512 × 512. Käytämme oletuksena neliön muotoista logoa." homepage: - description: "Suosittelemme tuoreimmat ketjut -näkymää etusivuksi, mutta voit halutessasi näyttää myös keskustelualueet (ketjut ryhmitelty aihealueen mukaan) etusivulla." - title: "Etusivu" + description: "Suosittelemme näyttämään tuoreimmat ketjut aloitussivullasi, mutta voit halutessasi näyttää myös keskustelualueet (ketjut ryhmiteltynä aihealueen mukaan)." + title: "Aloitussivu" fields: homepage_style: choices: @@ -3789,27 +3788,27 @@ fi: invites: title: "Kutsu henkilökuntaa" description: "Melkein valmista! Kutsu vielä joitakin ihmisiä pohjustamaan keskusteluita mielenkiintoisilla aiheilla ja vastauksilla, jotta yhteisösi pääsee alkuun." - disabled: "Koska paikalliset kirjautumiset ovat pois käytöstä, ei ole mahdollista kutsua ketään. Ole hyvä jatka seuraavaan vaiheeseen." + disabled: "Koska paikalliset kirjautumiset ovat pois käytöstä, ei ole mahdollista kutsua ketään. Jatka seuraavaan vaiheeseen." finished: - title: "Discourse -sivustosi on valmis!" + title: "Discourse-sivustosi on valmis!" description: | -

      Jos haluat muuttaa näitä asetuksia myöhemmin, suorita tämä asetustoiminto uudelleen tai vieraile ylläpitopaneelissa; löydät sen jakoavainkuvakkeen vierestä sivuston valikosta.

      -

      Discoursea on helppo mukauttaa entisestään sen tehokkaalla teemajärjestelmällä. meta.discourse.org -sivustosta löydät esimerkkejä suosituista teemoista ja komponenteista.

      -

      Onnittelut ja pidä hauskaa rakentaessasi uutta yhteisöäsi!

      +

      Jos haluat muuttaa näitä asetuksia myöhemmin, suorita tämä ohjattu toiminto uudelleen tai vieraile ylläpitopaneelissa; löydät sen jakoavainkuvakkeen vierestä sivuston valikosta.

      +

      Discoursea on helppo mukauttaa entisestään sen tehokkaalla teemajärjestelmällä. meta.discourse.org-sivustosta löydät esimerkkejä suosituista teemoista ja komponenteista.

      +

      Onnittelut, ja pidä hauskaa rakentaessasi uutta yhteisöäsi!

      search_logs: graph_title: "Hakujen määrä" joined: "Liittyi" discourse_push_notifications: popup: - mentioned: '%{username} mainitsi sinut ketjussa "%{topic}" - %{site_title}' - group_mentioned: '%{username} mainitsi ryhmäsi ketjussa "%{topic}" - %{site_title}' - quoted: '%{username} lainasi sinua ketjussa "%{topic}" - %{site_title}' - replied: '%{username} vastasi sinulle ketjussa "%{topic}" - %{site_title}' - posted: '%{username} lähetti viestin ketjuun "%{topic}" - %{site_title}' - private_message: '%{username} lähetti sivulle yksityisviestin keskustelussa "%{topic}" - %{site_title}' - linked: '%{username} linkitti viestiisi ketjussa "%{topic}" - %{site_title}' - watching_first_post: '%{username} aloitti ketjun "%{topic}" - %{site_title}' - confirm_title: "Ilmoitukset käytössä - %{site_title}" + mentioned: '%{username} mainitsi sinut ketjussa "%{topic}" – %{site_title}' + group_mentioned: '%{username} mainitsi sinut ketjussa "%{topic}"–- %{site_title}' + quoted: '%{username} lainasi sinua ketjussa "%{topic}" – %{site_title}' + replied: '%{username} vastasi sinulle ketjussa "%{topic}" – %{site_title}' + posted: '%{username} lähetti viestin ketjuun "%{topic}" – %{site_title}' + private_message: '%{username} lähetti sivulle yksityisviestin keskustelussa "%{topic}" – %{site_title}' + linked: '%{username} linkitti viestiisi ketjussa "%{topic}" – %{site_title}' + watching_first_post: '%{username} aloitti ketjun "%{topic}" – %{site_title}' + confirm_title: "Ilmoitukset käytössä – %{site_title}" confirm_body: "Onnistui! Ilmoitukset ovat käytössä." custom: "Ilmoitus käyttäjältä %{username} sivustolla %{site_title}" staff_action_logs: diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 0fa41f1dc5..dfdc958f0f 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -79,7 +79,7 @@ fr: optimized_link: Les liens optimisés d'images sont éphémères et ne devraient pas être inclus dans le code source d'un thème. settings_errors: invalid_yaml: "Le YAML est invalide" - data_type_not_a_number: "Le type `%{name}` n'est pas supporté. Les types supportés sont `integer`, `bool`, `list`, `enum` et `upload`" + data_type_not_a_number: "Le type `%{name}` n'est pas supporté. Les types supportés sont « integer », « bool », « list », « enum » et « upload »" name_too_long: "Il y a un paramètre avec un nom trop long. Longueur maximum 255" default_value_missing: "Le paramètre `%{name}` n'a pas de valeur par défaut" default_not_match_type: "Le type de la valeur par défaut du paramètre `%{name}` ne correspond pas au type du paramètre." @@ -188,11 +188,11 @@ fr: other: "Vous avez spécifié les choix invalides %{name}" default_categories_already_selected: "Vous ne pouvez pas sélectionner une catégorie qui est utilisée dans une autre liste." default_tags_already_selected: "Vous ne pouvez pas sélectionner une étiquette utilisée dans une autre liste." - s3_upload_bucket_is_required: "Vous ne pouvez pas activer l'envoi de fichiers sur S3 avant d'avoir renseigné le paramètre 's3_upload_bucket'." + s3_upload_bucket_is_required: "Vous ne pouvez pas activer l'envoi de fichiers sur S3 avant d'avoir renseigné le paramètre « s3_upload_bucket »." enable_s3_uploads_is_required: "Vous ne pouvez pas activer l'inventaire sur S3 avant d'avoir activer les envois S3." page_publishing_requirements: "La publication de pages ne peut pas être activée si la sécurisation des médias est activée." s3_backup_requires_s3_settings: "Utiliser S3 comme emplacement de sauvegarde nécessite de renseigner le paramètre '%{setting_name}'." - s3_bucket_reused: "Il n'est pas possible d'utiliser le même bucket pour 's3_upload_bucket' and 's3_backup_bucket'. Choisir un autre bucket ou utiliser un chemin d'accès différent pour chaque bucket." + s3_bucket_reused: "Il n'est pas possible d'utiliser le même bucket pour « s3_upload_bucket » et « s3_backup_bucket ». Choisissez un autre bucket ou utilisez un chemin d'accès différent pour chaque bucket." secure_media_requirements: "L'envoi de fichiers sur S3 doit être activé avant d'activer la sécurisation des médias." share_quote_facebook_requirements: "Vous devez définir un identifiant d'application Facebook pour activer le partage de citation vers Facebook." second_factor_cannot_enforce_with_socials: "Vous ne pouvez pas rendre obligatoire l'authentification à deux facteurs lorsque des connexions sociales sont activées. Vous devez d'abord désactiver la connexion via : %{auth_provider_names}" @@ -237,7 +237,7 @@ fr: bulk_invite: file_should_be_csv: "Le fichier envoyé doit être au format CSV." max_rows: "Les premières %{max_bulk_invites} invitations ont été envoyées. Essayez de diviser le fichier en parties plus petites." - error: "Il y a eu une erreur en envoyant ce fichier. Merci de réessayer plus tard." + error: "Une erreur est survenue lors de l'envoi de ce fichier. Veuillez réessayer plus tard." invite_link: email_taken: "Cette adresse courriel est déjà utilisée. Si vous possédez déjà un compte, veuillez vous connecter ou réinitialiser votre mot de passe." max_redemptions_limit: "doit être entre 2 et %{max_limit}." @@ -254,7 +254,7 @@ fr: backup_file_should_be_tar_gz: "Le fichier de sauvegarde doit être une archive au format .tar.gz." not_enough_space_on_disk: "Il n'y a pas assez d'espace sur le disque pour charger cette sauvegarde." invalid_filename: "Le nom du fichier de sauvegarde contient des caractères invalides. Sont autorisés : a-z 0-9 . - _." - file_exists: "Le fichier que vous essayer d'envoyer existe déjà." + file_exists: "Le fichier que vous essayez d'envoyer existe déjà." invalid_params: "Vous avez fourni des paramètres invalides pour la requête : %{message}" not_logged_in: "Vous devez être connecté pour faire cela." not_found: "L'URL ou la ressource demandée n'a pas été retrouvée." @@ -301,8 +301,8 @@ fr: one: "%{count} réponse" other: "%{count} réponses" likes: - one: "%{count} J'aime" - other: "%{count} J'aime" + one: "%{count} « J'aime »" + other: "%{count} « J'aime »" last_reply: "Dernière réponse" created: "Créé" new_topic: "Créer un nouveau sujet" @@ -338,7 +338,7 @@ fr: pm_reached_recipients_limit: "Désolé, vous ne pouvez pas avoir plus de %{recipients_limit} destinataires dans un message direct." removed_direct_reply_full_quotes: "La citation complète du message précédent a été supprimée automatiquement." watched_words_auto_tag: "Sujet automatiquement étiqueté" - secure_upload_not_allowed_in_public_topic: "Désolé, les envois sécurisés suivants ne peuvent pas être utilisés dans un sujet public : %{upload_filenames}." + secure_upload_not_allowed_in_public_topic: "Nous sommes désolés, les envois sécurisés suivants ne peuvent pas être utilisés dans un sujet public : %{upload_filenames}." create_pm_on_existing_topic: "Désolé, vous ne pouvez pas créer un message direct sur un sujet existant." slow_mode_enabled: "Ce sujet est en mode ralenti." just_posted_that: "est trop similaire à ce que vous avez récemment posté" @@ -462,11 +462,11 @@ fr: avatar: | ### Avez-vous pensé à une photo de profil ? - Vous avez posté quelques sujets et réponses, mais votre photo de profil n'est pas unique comme vous -- c'est juste une lettre. + Vous avez publié quelques sujets et réponses, mais votre photo de profil n'est pas unique comme vous, ce n'est qu'une lettre. Avez-vous pensé à **[visiter votre profil utilisateur](%{profile_path})** et ajouter une photo qui vous représente ? - C'est plus facile de suivre les discussions et de rencontrer des personnes intéressantes dans les conversations lorsque tout le monde a une photo de profil unique ! + Il est plus facile de suivre les discussions et de rencontrer des personnes intéressantes dans les conversations lorsque tout le monde a une photo de profil unique ! sequential_replies: | ### Pensez à répondre à plusieurs messages en même temps @@ -665,8 +665,8 @@ fr: image_placeholder: broken: "Cette image ne fonctionne pas" has_likes: - one: "%{count} J'aime" - other: "%{count} J'aime" + one: "%{count} « J'aime »" + other: "%{count} « J'aime »" rate_limiter: slow_down: "Vous avez réalisé cette action un trop grand nombre de fois, veuillez réessayer plus tard." too_many_requests: "Vous avez effectué cette action trop de fois. Veuillez attendre %{time_left}avant de réessayer." @@ -679,7 +679,7 @@ fr: public_group_membership: "Vous rejoignez ou quittez des groupes à rythme un peu trop élevé. Nous vous invitons à patienter %{time_left} avant de réessayer." topics_per_day: "Vous avez atteint la limite quotidienne de nouveaux sujets qu'il vous est possible de créer. Vous pourrez créer de nouveaux sujets d'ici %{time_left}." pms_per_day: "Vous avez atteint la limite quotidienne de messages qu'il vous est possible de publier. Vous pourrez publier de nouveaux messages d'ici %{time_left}." - create_like: "Woah, vous avez beaucoup d'amour à partager ! Vous avez atteint la limite quotidienne du nombre de « J'aime » qu'ils vous est possible de donner. Cependant, vous aurez le droit d'en donner davantage à mesure que votre niveau de confiance augmentera. Vous pourrez à nouveau laisser des « J'aime » d'ici %{time_left}." + create_like: "Ouah ! Vous avez beaucoup d'amour à partager ! Vous avez atteint la limite quotidienne du nombre de « J'aime » qu'ils vous est possible d'attribuer. Cependant, vous aurez le droit d'en attribuer davantage à mesure que votre niveau de confiance augmentera. Vous pourrez à nouveau laisser des « J'aime » d'ici %{time_left}." create_bookmark: "Vous avez atteint la limite quotidienne du nombre de signets qu'il vous est possible de créer. Vous pourrez créer de nouveaux signets d'ici %{time_left}." edit_post: "Vous avez atteint la limite quotidienne du nombre de modifications qu'il vous est possible d'apporter à des messages. Vous pourrez faire de nouvelles modifications d'ici %{time_left}." live_post_counts: "Vous demandez le nombre de messages en activité trop rapidement. Veuillez patienter %{time_left} avant de réessayer." @@ -865,8 +865,8 @@ fr: short_description: 'Une transgression de notre charte communautaire' notify_user: title: "Envoyer un message à @%{username}" - description: "J'aimerais parler à cette personne directement et personnellement au sujet de son message." - short_description: "J'aimerais parler à cette personne directement et personnellement au sujet de son message." + description: "J'aimerais m'adresser à cette personne directement et personnellement au sujet de son message." + short_description: "J'aimerais m'adresser à cette personne directement et personnellement au sujet de son message." email_title: 'Votre message sur « %{title} »' email_body: "%{link}\n\n%{message}" notify_moderators: @@ -1136,8 +1136,8 @@ fr: likes: title: "J'aime" xaxis: "Jour" - yaxis: "Nombre de nouveau J'aime" - description: "Nombre de nouveau J'aime." + yaxis: "Nombre de nouveaux « J'aime »" + description: "Nombre de nouveaux « J'aime »." flags: title: "Signalements" xaxis: "Jour" @@ -1336,7 +1336,7 @@ fr: filename: Nom du fichier extension: Extension author: Auteur - filesize: Taille de fichier + filesize: Taille du fichier description: "Liste de tous les fichiers envoyés, par extension, avec la taille des fichiers et leur auteur." top_ignored_users: title: "Principaux utilisateurs ignorés / mis sous silence" @@ -1355,9 +1355,9 @@ fr: facebook_config_warning: 'Le serveur est configuré pour permettre l''authentification par Facebook (enable_facebook_logins), mais les paramètres facebook_app_id et facebook_app_secret ne sont pas renseignés. Allez dans les Paramètres et mettez les à jour. Voir le guide pour en savoir plus.' twitter_config_warning: 'Le serveur est configuré pour permettre l''authentification par Twitter (enable_twitter_logins), mais les paramètres key et secret ne sont pas renseignés. Allez dans les Paramètres et mettez les à jour. Voir le guide pour en savoir plus.' github_config_warning: 'Le serveur est configuré pour permettre l''authentification par GitHub (enable_github_logins), mais les paramètres github_client_id et github_client_secret ne sont pas renseignés. Allez dans les Paramètres et mettez les à jour. Voir le guide pour en savoir plus.' - s3_config_warning: 'Le serveur est configuré pour envoyer les sauvegardes sur S3, mais l''un des paramètres suivants n''est pas renseigné : s3_access_key_id, s3_secret_access_key ou s3_backup_bucket. Allez dans les Paramètres et mettez les à jour. Voir « Comment mettre en place une sauvegarde sur S3 ? » pour en savoir plus.' - s3_backup_config_warning: 'Le serveur est configuré pour envoyer les sauvegardes sur S3, mais l''un des paramètres suivants n''est pas renseigné : s3_access_key_id, s3_secret_access_key, s3_use_iam_profile, or s3_backup_bucket. Allez dans les Paramètres et mettez les à jour. Voir « Comment mettre en place une sauvegarde sur S3 ? » pour en savoir plus.' - s3_cdn_warning: 'Le serveur est configuré pour télécharger des fichiers vers S3, mais aucun CDN S3 n''est configuré. Cela peut entraîner des coûts S3 élevés et des performances du site moindres. Voir « Using Object Storage for Uploads » pour en savoir plus.' + s3_config_warning: 'Le serveur est configuré pour envoyer les sauvegardes sur S3, mais l''un des paramètres suivants n''est pas renseigné : s3_access_key_id, s3_secret_access_key ou s3_backup_bucket. Allez dans les Paramètres et mettez-les à jour. Consultez « Comment mettre en place une sauvegarde sur S3 ? » pour en savoir plus.' + s3_backup_config_warning: 'Le serveur est configuré pour envoyer les sauvegardes sur S3, mais l''un des paramètres suivants n''est pas renseigné : s3_access_key_id, s3_secret_access_key, s3_use_iam_profile ou s3_backup_bucket. Allez dans les Paramètres et mettez-les à jour. Consultez « Comment mettre en place une sauvegarde sur S3 ? » pour en savoir plus.' + s3_cdn_warning: 'Le serveur est configuré pour envoyer les fichiers sur S3, mais aucun CDN S3 n''est configuré. Cela peut entraîner des coûts S3 élevés et réduire les performances du site. Consultez « Utiliser un stockage d''objet pour les envois » pour en savoir plus.' image_magick_warning: 'Le serveur est configuré pour créer des aperçus des grandes images, mais ImageMagick n''est pas installé. Installez ImageMagick en utilisant votre gestionnaire de paquets favori ou téléchargez directement la dernière version.' failing_emails_warning: 'Il y a %{num_failed_jobs} tâches d''envois de courriel en erreur. Vérifiez votre fichier app.yml et assurez-vous de la conformité des paramètres du serveur de courriel. Voir aussi les processus en échec dans Sidekiq.' subfolder_ends_in_slash: "Votre configuration de sous-répertoire est erronée ; DISCOURSE_RELATIVE_URL_ROOT se termine avec une barre oblique ." @@ -1449,7 +1449,7 @@ fr: mobile_logo_dark: "Alternative au paramètre 'mobile logo' pour le mode sombre." large_icon: "Image utilisée comme base pour d'autres icônes de métadonnées. Elle devrait idéalement être plus grande que 512 x 512. Si vous laissez ce champ vide, logo_small sera utilisé." manifest_icon: "Image utilisée comme logo / image de démarrage sur Android. Elle sera automatiquement redimensionnée en 512 × 512. Si vous laissez ce champ vide, large_icon sera utilisée." - manifest_screenshots: "Captures d'écrans qui mettent en valeur les atouts et les fonctionnalités de votre instance du logiciel. Ces images doivent toutes être enregistrées localement sur le serveur et posséder des dimensions identiques." + manifest_screenshots: "Captures d'écran qui mettent en valeur les atouts et les fonctionnalités de votre instance du logiciel. Ces images doivent toutes être enregistrées localement sur le serveur et posséder des dimensions identiques." favicon: "Un favicon pour votre site, voir https://fr.wikipedia.org/wiki/Favicon . Pour fonctionner correctement sur un CDN, il doit être au format PNG. Il sera redimensionné en 32x32. Si vous laissez ce champ vide, large_icon sera utilisée." apple_touch_icon: "Icône utilisée pour les appareils Apple touch. Elle sera automatiquement redimensionnée en 180x180. Si vous laissez ce champ vide, large_icon sera utilisée." opengraph_image: "Image opengraph par défaut, utilisée lorsque la page n'a pas d'autre image appropriée. Si vous laissez ce champ vide, large_icon sera utilisée" @@ -1463,7 +1463,7 @@ fr: same_site_cookies: "Délivrer des cookies avec l'attribut SameSite. Cela permet d'éliminer tous les vecteurs de requêtes illégitimes par rebond (attaques de type « CSRF ») dans les navigateurs qui le prennent en charge (en mode \"Lax\" ou \"Strict\"). Attention : le mode \"Strict\" ne fonctionnera que sue des sites qui obligent à s'authentifier et qui utilisent un système d'authentification externe." summary_score_threshold: "Le score minimum pour qu'un message soit inclus dans le résultat de « Résumer ce sujet »" summary_posts_required: "Nombre minimum de messages dans un sujet avant que la fonctionnalité « Résumer ce sujet » soit activée. Les modifications de ce paramètre sont appliquées rétroactivement sur une semaine." - summary_likes_required: "Nombre minimum de J'aime dans un sujet avant que la fonctionnalité « Résumer ce sujet » soit activée. Les modifications de ce paramètre sont appliquées rétroactivement sur une semaine." + summary_likes_required: "Nombre minimal de « J'aime » dans un sujet avant que la fonctionnalité « Résumer ce sujet » soit activée. Les modifications de ce paramètre sont appliquées rétroactivement sur une semaine." summary_percent_filter: "Quand un utilisateur clique sur « Résumer ce sujet », montrer le top % des messages" summary_max_results: "Nombre maximum de messages inclus dans le résultat de « Résumer ce sujet »" enable_personal_messages: "Autoriser les utilisateurs de niveau de confiance 1 à créer des messages directs et à y répondre (configurable via le niveau de confiance minimum pour envoyer des messages directs). Notez que les responsables peuvent toujours envoyer des messages directs." @@ -1481,9 +1481,9 @@ fr: cooldown_minutes_after_hiding_posts: "Nombre de minutes qu'un utilisateur doit attendre avant de pouvoir modifier un message masqué suite à un signalement de la communauté" max_topics_in_first_day: "Le nombre maximum de sujets qu'un utilisateur est autorisé à créer dans la période de 24h après avoir créé son premier message" max_replies_in_first_day: "Le nombre maximum de réponses qu'un utilisateur est autorisé à créer dans la période de 24h après avoir créé son premier message" - tl2_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de J'aime pour les utilisateurs de niveau 2 (membres) en la multipliant par ce nombre" - tl3_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de J'aime pour les utilisateurs de niveau 3 (habitués) en la multipliant par ce nombre" - tl4_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de J'aime pour les utilisateurs de niveau 4 (meneurs) en la multipliant par ce nombre" + tl2_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de « J'aime » pour les utilisateurs de niveau 2 (membres) en la multipliant par ce nombre" + tl3_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de « J'aime » pour les utilisateurs de niveau 3 (habitués) en la multipliant par ce nombre" + tl4_additional_likes_per_day_multiplier: "Augmenter la limite quotidienne de « J'aime » pour les utilisateurs de niveau 4 (meneurs) en la multipliant par ce nombre" tl2_additional_edits_per_day_multiplier: "Augmenter la limite quotidienne de modifications de messages pour les utilisateurs de niveau 2 (membres) en la multipliant par ce nombre" tl3_additional_edits_per_day_multiplier: "Augmenter la limite quotidienne de modifications de messages pour les utilisateurs de niveau 3 (habitués) en la multipliant par ce nombre" tl4_additional_edits_per_day_multiplier: "Augmenter la limite quotidienne de modifications de messages pour les utilisateurs de niveau 4 (meneurs) en la multipliant par ce nombre" @@ -1497,12 +1497,11 @@ fr: enable_markdown_linkify: "Traiter automatiquement le texte qui ressemble à un lien comme un lien : www.site.com et https://site.com seront automatiquement liés." markdown_linkify_tlds: "Liste des domaines de premier niveau qui sont automatiquement traités comme des liens." markdown_typographer_quotation_marks: "Liste des paires de remplacement des guillemets doubles et simples" - post_undo_action_window_mins: "Nombre de minutes pendant lesquelles un utilisateur peut annuler une action sur un message (J'aime, signaler, etc.)" + post_undo_action_window_mins: "Nombre de minutes pendant lesquelles un utilisateur peut annuler une action sur un message (« J'aime », signalement, etc.)" must_approve_users: "Un responsable doit approuver tous les nouveaux utilisateurs avant qu'ils aient accès au site." invite_code: "Les utilisateurs doivent entrer ce code pour pouvoir créer un compte (cela est ignoré si le champ est laissé vide, insensible à la casse)" approve_suspect_users: "Ajouter les utilisateurs suspects à la file des éléments en attente d'examen. Les utilisateurs qui ont renseigné une biographie ou un site web sur leur profil mais n'ont pas parcouru le forum sont définis comme suspects." review_every_post: "Chaque message doit être examiné par un responsable avant sa publication. AVERTISSEMENT ! DÉCONSEILLÉ POUR UN SITE À FORTE FRÉQUENTATION." - pending_users_reminder_delay: "Avertir les modérateurs si des nouveaux utilisateurs sont en attente d'approbation depuis ce nombre d'heures. Mettre -1 pour désactiver les notifications." persistent_sessions: "Les utilisateurs resteront connectés lorsque le navigateur web sera fermé" maximum_session_age: "L'utilisateur restera connecté pour n heures après la dernière visite" ga_version: "Version de Google Universal Analytics à utiliser : v3 (analytics.js), v4 (gtag)" @@ -1586,7 +1585,7 @@ fr: auth_overrides_email: "Écrase l'adresse de courriel définie localement en la remplaçant par l'adresse de courriel définie sur le site externe à chaque connexion, et interdit de la modifier localement. S'applique à tous les fournisseurs de service d'authentification. (ATTENTION : des divergences peuvent se produire en raison d'un algorithme de normalisation des adresses de courriel locales)" auth_overrides_username: "Écrase le nom d'utilisateur défini localement en le remplaçant par le nom d'utilisateur défini sur le site externe à chaque connexion, et interdit de le modifier localement. S'applique à tous les fournisseurs de service d'authentification. (ATTENTION : des divergences peuvent se produire en raison de contraintes de longueur ou de complexité des noms d'utilisateur)" auth_overrides_name: "Écrase le nom complet défini localement en le remplaçant par le nom complet défini sur le site externe à chaque connexion, et interdit de le modifier localement. S'applique à tous les fournisseurs de service d'authentification." - discourse_connect_overrides_avatar: "Écrase l'image de profil en la remplaçant par une image de profil définie dans les données transmises par le biais de DiscourseConnect. Si ce paramètre est activité, les utilisateurs ne seront pas autorisés à charger des images de profils sur le serveur Discourse." + discourse_connect_overrides_avatar: "Écrase l'image de profil en la remplaçant par une image de profil définie dans les données transmises par le biais de DiscourseConnect. Si ce paramètre est activé, les utilisateurs ne seront pas autorisés à charger les images de profils sur le serveur Discourse." discourse_connect_overrides_location: "Écrase le champ 'Localisation' défini localement en le remplaçant par une valeur définie par un site externe par le biais de données transmises avec le protocole DiscourseConnect, et interdit sa modification locale." discourse_connect_overrides_website: "Écrase le champ 'Site internet' défini localement en le remplaçant par une valeur définie par un site externe par le biais de données transmises avec le protocole DiscourseConnect, et interdit sa modification locale." discourse_connect_overrides_profile_background: "Écrase l'arrière-plan du profil défini localement en le remplaçant par une image définie par un site externe par le biais de données transmises avec le protocole DiscourseConnect." @@ -1624,9 +1623,9 @@ fr: s3_endpoint: "La destination peut être modifiée pour envoyer les sauvegardes vers un service compatible avec S3, par exemple DigitalOcean Spaces ou Minio. ATTENTION : laisser vide si vous utilisez AWS S3." s3_configure_tombstone_policy: "Activer la suppression automatique des fichiers non utilisés. IMPORTANT : en cas de désactivation, aucun espace disque ne sera récupéré après la suppression des fichiers." s3_disable_cleanup: "Empêcher la suppression des anciennes sauvegardes sur S3 lorsqu'il y a plus de sauvegardes que le maximum autorisé." - enable_s3_inventory: "Générer des rapports et vérifier les envois avec l'inventaire Amazon S3. IMPORTANT : nécessite un accès valide à S3 (l'identifiant et la clé secrète)." + enable_s3_inventory: "Générer des rapports et vérifier les envois avec l'inventaire Amazon S3. IMPORTANT : cela nécessite un accès valide à S3 (l'identifiant et la clé secrète)." backup_time_of_day: "Heure (UTC) de planification de la sauvegarde." - backup_with_uploads: "Inclure les fichiers envoyés dans les sauvegardes. Si désactivé, seule la base de données sera sauvegardée." + backup_with_uploads: "Inclure les fichiers envoyés dans les sauvegardes. Si cette option est désactivée, seule la base de données sera sauvegardée." backup_location: "Emplacement de stockage des sauvegardes. IMPORTANT : S3 nécessite la saisie d'identifiants valides dans la section `Fichiers` des paramètres du site." backup_gzip_compression_level_for_uploads: "Niveau de compression gzip utilisé pour compresser les envois." include_thumbnails_in_backups: "Inclure les vignettes générées dans les sauvegardes. Si vous désactivez cette option, les sauvegardes seront plus petites mais leur restauration nécessitera de régénérer tous les messages." @@ -1634,14 +1633,14 @@ fr: verbose_localization: "Afficher des informations supplémentaires sur l'interface des textes à traduire." previous_visit_timeout_hours: "Combien d'heures pour qu'une visite soit considérée comme la visite « précédente »." top_topics_formula_log_views_multiplier: "formule pour la valeur de multiplicateur de vues de journal (n) dans les sujets tendance : `log(views_count) * (n) + op_likes_count * 0.5 + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_first_post_likes_multiplier: "formule pour la valeur de multiplicateur de premiers J'aime (n) dans les sujets tendance : `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" - top_topics_formula_least_likes_per_post_multiplier: "formule pour la valeur de multiplicateur de moins de J'aime par message (n) dans les sujets tendance: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" + top_topics_formula_first_post_likes_multiplier: "formule permettant d'obtenir la valeur du multiplicateur des premiers « J'aime » (n) dans les sujets tendance : 'log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)'" + top_topics_formula_least_likes_per_post_multiplier: "formule permettant d'obtenir la valeur du multiplicateur du plus petit nombre de « J'aime » par message (n) dans les sujets tendance : 'log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)'" enable_safe_mode: "Permettre aux utilisateurs d'utiliser le mode sans échec pour déboguer les extensions." rate_limit_create_topic: "Après la création d'un sujet, les utilisateurs doivent attendre (n) secondes avant de pouvoir en créer un nouveau." rate_limit_create_post: "Après avoir publié un message, les utilisateurs doivent attendre (n) secondes avant de pouvoir en publier un autre." rate_limit_new_user_create_topic: "Après la création d'un sujet, les nouveaux utilisateurs doivent attendre (n) secondes avant de pouvoir en créer un nouveau." rate_limit_new_user_create_post: "Après avoir publié un message, les nouveaux utilisateurs doivent attendre (n) secondes avant de pouvoir en publier un autre." - max_likes_per_day: "Nombre maximum de J'aime par utilisateur chaque jour." + max_likes_per_day: "Nombre maximal de « J'aime » par utilisateur chaque jour." max_flags_per_day: "Nombre maximum de signalement par utilisateur chaque jour." max_bookmarks_per_day: "Nombre maximum de signets par utilisateur et par jour." max_edits_per_day: "Nombre maximum de modifications par utilisateur chaque jour." @@ -1662,16 +1661,16 @@ fr: limit_suggested_to_category: "Afficher uniquement les sujets de la catégorie actuelle dans les sujets similaires." suggested_topics_max_days_old: "Les sujets suggérés ne devraient pas être plus anciens que n jours." suggested_topics_unread_max_days_old: "Les sujets non lus suggérés ne doivent pas être âgés de plus de n jours." - clean_up_uploads: "Supprimer les fichiers envoyés orphelins pour prévenir les hébergements illégaux. ATTENTION : vous devriez faire une sauvegarde de votre répertoire /uploads avant d'activer ce paramètre." - clean_orphan_uploads_grace_period_hours: "La période de grâce (en heures) avant qu'un fichier envoyé orphelin soit retiré." + clean_up_uploads: "Supprimez les fichiers envoyés orphelins pour prévenir les hébergements illégaux. ATTENTION : vous devriez faire une sauvegarde de votre répertoire « /uploads » avant d'activer ce paramètre." + clean_orphan_uploads_grace_period_hours: "La période de grâce (en heures) avant qu'un fichier envoyé orphelin soit supprimé." purge_deleted_uploads_grace_period_days: "La période de grâce (en jours) avant qu'un fichier envoyé et supprimé soit effacé." purge_unactivated_users_grace_period_days: "Période de grâce (en jours) avant qu'un utilisateur qui n'a pas activé son compte soit supprimé. Régler à 0 pour ne jamais purger les utilisateurs non activés." - enable_s3_uploads: "Placez les fichiers envoyés sur un stockage Amazon S3. IMPORTANT : nécessite un accès valide à S3 (l'identifiant et la clé secrète)." + enable_s3_uploads: "Placez les fichiers envoyés sur un stockage Amazon S3. IMPORTANT : cela nécessite un accès valide à S3 (l'identifiant et la clé secrète)." s3_use_iam_profile: 'Utiliser un profil d''instance AWS EC2 pour accorder l''accès au bucket S3. NOTE : cela nécessite que Discourse soit exécuté sur une instance EC2 correctement configurée et remplace les paramètres « s3 access key id » et « s3 secret access key ».' - s3_upload_bucket: "Le nom du bucket Amazon S3 qui contiendra les fichiers téléchargés. ATTENTION : doit être en minuscule, sans points et sans tirets bas." - s3_access_key_id: "La clé d'accès Amazon S3 qui sera utilisée pour envoyer les images, les pièces joints et les sauvegardes." - s3_secret_access_key: "La clé d'accès secrète Amazon S3 qui sera utilisée pour envoyer les images, les pièces joints et les sauvegardes." - s3_region: "Le nom de la région Amazon S3 qui va être utilisée pour envoyer des images et sauvegardes." + s3_upload_bucket: "Le nom du bucket Amazon S3 qui contiendra les fichiers envoyés. ATTENTION : ce nom doit être en minuscule, sans point ni tiret bas." + s3_access_key_id: "La clé d'accès Amazon S3 qui sera utilisée pour envoyer les images, les pièces jointes et les sauvegardes." + s3_secret_access_key: "La clé d'accès secrète Amazon S3 qui sera utilisée pour envoyer les images, les pièces jointes et les sauvegardes." + s3_region: "Le nom de la région Amazon S3 qui va être utilisée pour envoyer les images et sauvegardes." s3_cdn_url: "L'adresse du CDN à utiliser pour toutes les ressources s3 (par exemple : https://cdn.monsite.com). ATTENTION : après avoir changé ce paramètre, vous devez régénérer la totalité des messages existants." avatar_sizes: "Liste des tailles des avatars automatiquement générés" external_system_avatars_enabled: "Utilisez un service d'avatars externe." @@ -1683,12 +1682,12 @@ fr: selectable_avatars: "Liste d'avatars que les utilisateurs peuvent sélectionner." allow_all_attachments_for_group_messages: "Autoriser toutes les pièces-jointes pour les messages de groupes." png_to_jpg_quality: "Qualité du fichier JPEG converti (1 est la plus faible, 99 est la meilleure, 100 pour désactiver)." - recompress_original_jpg_quality: "Qualité des fichiers image téléchargés (1 est la plus faible, 99 est la meilleure, 100 pour désactiver)." + recompress_original_jpg_quality: "Qualité des fichiers image envoyés (1 représente la qualité la plus faible, et 99 la meilleure qualité, 100 sert à désactiver)." image_preview_jpg_quality: "Qualité des fichiers image redimensionnés (1 est la plus faible, 99 est la meilleure, 100 pour désactiver)." allow_staff_to_upload_any_file_in_pm: "Autoriser les responsables à envoyer n'importe quel fichier dans les messages directs." strip_image_metadata: "Enlever les métadonnées de l'image." min_ratio_to_crop: "Ratio utilisé pour recadrer les images en hauteur. Entrez le résultat de la largeur / hauteur." - simultaneous_uploads: "Nombre maximum de fichiers qui peuvent être glissés et déposés dans l'éditeur" + simultaneous_uploads: "Nombre maximal de fichiers qui peuvent être glissés et déposés dans l'éditeur" default_invitee_trust_level: "Niveau de confiance par défaut (0-4) pour les invités." default_trust_level: "Niveau de confiance par défaut (entre 0 et 4) pour tous les nouveaux utilisateurs. ATTENTION ! Changer ce paramètre peut vous exposer à des spams." tl1_requires_topics_entered: "À combien de sujets un nouvel utilisateur doit avoir participé pour être promu au niveau de confiance 1." @@ -1698,8 +1697,8 @@ fr: tl2_requires_read_posts: "Combien de message un utilisateur doit avoir lu pour être promu au niveau de confiance 2." tl2_requires_time_spent_mins: "Combien de minutes un utilisateur doit avoir passées à lire des messages pour être promu au niveau de confiance 2." tl2_requires_days_visited: "Combien de jours un utilisateur doit visiter le site pour être promu au niveau de confiance 2." - tl2_requires_likes_received: "Combien de J'aime un utilisateur doit recevoir pour être promu au niveau de confiance 2." - tl2_requires_likes_given: "Combien de J'aime un utilisateur doit donner pour être promu au niveau de confiance 2." + tl2_requires_likes_received: "Combien de « J'aime » un utilisateur doit recevoir pour être promu au niveau de confiance 2." + tl2_requires_likes_given: "Combien de « J'aime » un utilisateur doit attribuer pour être promu au niveau de confiance 2." tl2_requires_topic_reply_count: "À combien de sujets un utilisateur doit avoir participé pour être promu au niveau de confiance 2." tl3_time_period: "Période de temps requise pour accéder au niveau de confiance 3 (en jours)" tl3_requires_days_visited: "Nombre minimum de jours qu'un utilisateur doit avoir passé sur le site dans les (tl3 time period) derniers jours pour être éligible au niveau de confiance 3. Fixer à plus que la période pour désactiver les promotions. (0 ou plus)" @@ -1712,8 +1711,8 @@ fr: tl3_requires_posts_read_all_time: "Nombre minimum de messages qu'un utilisateur doit avoir vu pour être promu au niveau de confiance 3." tl3_requires_max_flagged: "L'utilisateur ne doit pas avoir plus de x messages signalés par x utilisateurs différents dans les (tl3 time period) derniers jours pour être éligible au niveau de confiance 3, x étant la valeur de ce paramètre. (0 ou plus)" tl3_promotion_min_duration: "Nombre minimum de jours qu'un utilisateur restera promu au niveau de confiance 3 avant de pouvoir être rétrogradé au niveau de confiance 2." - tl3_requires_likes_given: "Le nombre minimum de J'aime à donner dans les (tl3 time period) derniers jours pour être éligible au niveau de confiance 3." - tl3_requires_likes_received: "Le nombre minimum de J'aime à recevoir dans les (tl3 time period) derniers jours pour être éligible au niveau de confiance 3." + tl3_requires_likes_given: "Le nombre minimal de « J'aime » à attribuer au cours des (tl3 time period) derniers jours pour être éligible au niveau de confiance 3." + tl3_requires_likes_received: "Le nombre minimal de « J'aime » à recevoir au cours des (tl3 time period) derniers jours pour être éligible au niveau de confiance 3." tl3_links_no_follow: "Ne pas retirer rel=nofollow sur les liens des messages par les utilisateurs de niveau de confiance 3." trusted_users_can_edit_others: "Permettre aux utilisateurs ayant un niveau de confiance élevé de modifier le contenu d'autres utilisateurs" min_trust_to_create_topic: "Le niveau de confiance minimum pour créer un nouveau sujet." @@ -1726,8 +1725,8 @@ fr: min_trust_to_flag_posts: "Le niveau de confiance minimum requis pour signaler des messages" min_trust_to_post_links: "Le niveau de confiance minimum requis pour inclure des liens dans les messages" min_trust_to_post_embedded_media: "Le niveau de confiance minimum requis pour intégrer des éléments multimédia dans un message" - min_trust_level_to_allow_profile_background: "Le niveau de confiance minimum requis pour télécharger un arrière-plan de profil" - min_trust_level_to_allow_user_card_background: "Le niveau de confiance minimum requis pour télécharger un arrière-plan de carte utilisateur" + min_trust_level_to_allow_profile_background: "Le niveau de confiance minimal requis pour envoyer un arrière-plan de profil" + min_trust_level_to_allow_user_card_background: "Le niveau de confiance minimal requis pour envoyer un arrière-plan de carte utilisateur" min_trust_level_to_allow_invite: "Le niveau de confiance minimum requis pour inviter des utilisateurs" min_trust_level_to_allow_ignore: "Le niveau de confiance minimum requis pour ignorer les utilisateurs" allowed_link_domains: "Domaines vers lesquels les utilisateurs peuvent créer des liens même s'ils n'ont pas le niveau de confiance suffisant pour publier des liens." @@ -1757,10 +1756,10 @@ fr: category_style: "Style visuel pour les badges de catégorie." default_dark_mode_color_scheme_id: "Le jeu de couleurs utilisé en mode sombre." dark_mode_none: "Aucune" - max_image_size_kb: "La taille maximale des images envoyées en Ko. La valeur doit aussi être configurée dans Nginx (client_max_body_size), Apache ou le proxy. Les images plus grandes que cette taille et plus petites que client_max_body_size seront redimensionnées à l'envoi." + max_image_size_kb: "La taille maximale des images envoyées en Ko. La valeur doit aussi être configurée dans Nginx (client_max_body_size), Apache ou le proxy. Les images dont la taille est supérieure à celle indiquée et inférieure à client_max_body_size seront redimensionnées à l'envoi." max_attachment_size_kb: "La taille maximale des fichiers envoyés en Ko. La valeur doit aussi être configurée dans Nginx (client_max_body_size), Apache ou le proxy." authorized_extensions: "Une liste d'extensions de fichier autorisées pour les envois sur le serveur (mettre « * » pour autoriser tous les types)" - authorized_extensions_for_staff: "Une liste des extensions de fichiers autorisées pour le téléchargement pour les responsables en plus de la liste définie dans le paramètre `authorized_extensions'. (utilisez « * » pour activer tous les types de fichiers)" + authorized_extensions_for_staff: "Une liste des extensions de fichiers autorisées pour les envois effectués par les responsables en plus de la liste définie dans le paramètre « authorized_extensions ». (utilisez « * » pour activer tous les types de fichiers)" theme_authorized_extensions: "Une liste d'extensions de fichier autorisées pour les envois de thème (mettre « * » pour autoriser tous les types de fichier)" max_similar_results: "Combien de sujets similaires sont affichés lors de la création d'un nouveau sujet. La comparaison se base sur le titre et le contenu." max_image_megapixels: "Nombre maximum autorisé de mégapixels pour une image. Les images avec un nombre plus élevé seront rejetées." @@ -1776,16 +1775,16 @@ fr: history_hours_low: "Un message modifié durant ce nombre d'heures aura l'indicateur de modification légèrement mis en évidence" history_hours_medium: "Un message modifié durant ce nombre d'heures aura l'indicateur de modification modérément mis en évidence." history_hours_high: "Un message modifié durant ce nombre d'heures aura l'indicateur de modification fortement mis en évidence." - topic_post_like_heat_low: "Après le dépassement de ce ratio J'aime/message, le compteur de messages est légèrement mis en évidence." - topic_post_like_heat_medium: "Après le dépassement de ce ratio J'aime/message, le compteur de messages est modérément mis en évidence." - topic_post_like_heat_high: "Après le dépassement de ce ratio J'aime/message, le compteur de messages est fortement mis en évidence." + topic_post_like_heat_low: "Après le dépassement de ce ratio « J'aime »/message, le compteur de messages est légèrement mis en évidence." + topic_post_like_heat_medium: "Après le dépassement de ce ratio « J'aime »/message, le compteur de messages est modérément mis en évidence." + topic_post_like_heat_high: "Après le dépassement de ce ratio « J'aime »/message, le compteur de messages est fortement mis en évidence." faq_url: "Si vous disposez déjà d'une FAQ/Règles de la communauté, hébergée ailleurs, que vous souhaitez utiliser, vous pouvez renseigner l'URL complète ici." tos_url: "Si vous disposez déjà de conditions générales d'utilisation hébergées ailleurs que vous souhaitez utiliser, vous pouvez renseigner leur URL complète ici." privacy_policy_url: "Si vous disposez déjà d'une politique de confidentialité hébergée ailleurs que vous voulez utiliser, vous pouvez renseigner son URL complète ici." log_anonymizer_details: "Conserver les détails relatifs aux utilisateurs dans les fichiers de journalisation après qu'ils aient été rendus anonymes. Vous devrez désactiver cette fonction pour être en conformité avec le RGPD." newuser_spam_host_threshold: "Combien de fois un nouvel utilisateur peut publier un lien vers le même domaine dans la limite de leur `newuser_spam_host_threshold` messages avant d'être considéré comme du spam." allowed_spam_host_domains: "Une liste des domaines exclus des hôtes testés pour spam. Les nouveaux utilisateurs ne seront jamais empêchés de créer des messages contenant des liens vers ces domaines." - staff_like_weight: "Le poids à accorder aux J'aime provenant d'un responsable (les autres J'aime ont un poids de 1)." + staff_like_weight: "La pondération à accorder aux « J'aime » provenant d'un responsable (les autres « J'aime » ont une pondération de 1)." topic_view_duration_hours: "Compter la vue d'un sujet une seule fois par IP ou par utilisateur toutes les N heures" user_profile_view_duration_hours: "Compter la vue d'un profil d'utilisateur une seule fois par IP ou par utilisateur qui visite toutes les N heures" levenshtein_distance_spammer_emails: "Une adresse courriel sera attribuée à un spammeur connu même si elle diffère par ce nombre de caractères." @@ -1912,7 +1911,7 @@ fr: user_selected_primary_groups: "Autoriser les utilisateurs à définir leur groupe principal" max_notifications_per_user: "Volume maximum de notifications par utilisateur. Si ce seuil est dépassé alors les notifications les plus anciennes seront supprimées. Appliqué chaque semaine. 0 pour le désactiver." allowed_user_website_domains: "Le site web de l'utilisateur sera vérifié par rapport à ces domaines. Liste délimitée par des barres verticales." - allow_profile_backgrounds: "Autoriser les utilisateurs à envoyer des arrières-plans de profil." + allow_profile_backgrounds: "Autoriser les utilisateurs à envoyer des arrière-plans de profil." sequential_replies_threshold: "Nombre de messages consécutifs qu'un utilisateur peut publier dans un sujet avant d'être averti d'avoir publié trop de réponses à la suite." get_a_room_threshold: "Nombre de messages qu'un utilisateur doit faire à la même personne dans un même sujet avant d'être averti." enable_mobile_theme: "Les appareils mobiles utilisent un thème adapté aux mobiles, avec la possibilité de passer à la totalité du site. Désactivez cette option si vous voulez utiliser une feuille de style personnalisée qui répond à tous les types de client." @@ -1923,8 +1922,8 @@ fr: permalink_normalizations: "Appliquer l'expression régulière suivante avant de détecter les permaliens, par exemple /(\\/topic.*)\\?.*/\\1 supprimera les chaînes de requête des chemins de sujet. Le format est regex+string, utilisez \\1 etc. pour capturer des séquences" global_notice: "Afficher une bannière de notification globale et qui ne peut être ignorée à tous les visiteurs pour un message URGENT (HTML autorisé). Laissez le champ vide pour masquer la bannière." disable_system_edit_notifications: "Désactiver les notifications de modifications par l'utilisateur système lorsque l'option 'download_remote_images_to_local' est activée." - notification_consolidation_threshold: "Nombre de notifications de J'aime ou de demandes d'adhésion à partir duquel les notifications sont regroupées en une seule. Mettre 0 pour désactiver." - likes_notification_consolidation_window_mins: "Durée en minutes après que le seuil soit atteint pour regrouper les notifications de J'aime en une seule notification. Le seuil peut être configuré via `SiteSetting.notification_consolidation_threshold`." + notification_consolidation_threshold: "Nombre de notifications de « J'aime » ou de demandes d'adhésion à partir duquel les notifications sont regroupées en une seule. Mettre 0 pour désactiver." + likes_notification_consolidation_window_mins: "Durée en minutes après laquelle les notifications de « J'aime » sont consolidées en une seule notification une fois que le seuil est atteint. Le seuil peut être configuré via 'SiteSetting.Notification_Consolidation_Threshold'." automatically_unpin_topics: "Désépingler automatiquement les sujets lorsque l'utilisateur atteint la fin de la liste." read_time_word_count: "Nombre de mots par minute servant de base de calcul à l'estimation du temps de lecture." topic_page_title_includes_category: "La balise title de la page du sujet comprend le nom de la catégorie." @@ -1962,7 +1961,7 @@ fr: delete_merged_stub_topics_after_days: "Délai (en jours) de suppression automatique des sujets-troncs, c'est-à-dire les sujets sans réponses résultant de la fusion de deux sujets par déplacement de messages. Si la valeur est de 0, les sujets-troncs ne seront pas supprimés." bootstrap_mode_min_users: "Nombre minimum d'utilisateurs nécessaire pour désactiver le mode spécial de lancement (mettre à 0 pour désactiver)" prevent_anons_from_downloading_files: "Empêcher les utilisateurs anonymes de télécharger les pièces jointes." - secure_media: 'Limite l''accès à TOUS les fichiers envoyés (image, vidéo, audio, texte, PDF, ZIP, etc.). Si le paramètre « login required » est activé, seuls les utilisateurs connectés peuvent accéder aux fichiers envoyés. Sinon, l''accès sera limité uniquement pour les médias des messages et catégories privés. ATTENTION : ce paramètre est complexe et nécessite des connaissances d''administration approfondies. Voir ce sujet sur les médias sécurisés pour plus d''informations.' + secure_media: 'Limite l''accès à TOUS les fichiers envoyés (image, vidéo, audio, texte, PDF, ZIP, etc.). Si le paramètre « connexion requise » est activé, seuls les utilisateurs connectés peuvent accéder aux fichiers envoyés. Sinon, l''accès sera limité uniquement pour les médias des messages et catégories privés. ATTENTION : ce paramètre est complexe et nécessite des connaissances d''administration approfondies. Consultez ce sujet sur les médias sécurisés pour en savoir plus.' secure_media_allow_embed_images_in_emails: "Autorise l'intégration d'images sécurisées (qui seraient autrement expurgées) dans les courriels, si leur taille est inférieure à la valeur du paramètre 'secure media max email embed image size kb'." secure_media_max_email_embed_image_size_kb: "La taille maximale d'images sécurisées qui seront intégrées dans des courriels si le paramètre 'secure media allow embed in emails' est activé. Si celui-ci n'est pas activé, ce paramètre est sans effet." slug_generation_method: "Choisissez une méthode de génération d'identifiant. « encoded » générera des chaînes de caractères encodées avec des pourcentages. « none » désactivera complètement les identifiants." @@ -1991,7 +1990,7 @@ fr: blur_tl0_flagged_posts_media: "Flouter les images des messages signalés pour masquer du contenu potentiellement NSFW." enable_page_publishing: "Autorisez les responsables de publier des sujets vers de nouvelles URL utilisant leur propre style." show_published_pages_login_required: "Les utilisateurs anonymes peuvent voir les pages publiées même quand la connexion est nécessaire." - skip_auto_delete_reply_likes: "Lorsque des réponses anciennes sont automatiquement supprimées, ignorer la suppression des messages avec ce nombre de J'aime ou plus." + skip_auto_delete_reply_likes: "Lors de la suppression automatique des anciennes réponses, cette option permet d'ignorer la suppression des messages ayant ce nombre ou un nombre supérieur de « J'aime »." default_email_digest_frequency: "Par défaut, à quelle fréquence les utilisateurs reçoivent les résumés par courriel." default_include_tl0_in_digests: "Par défaut, inclure les messages des nouveaux utilisateurs dans les résumés par courriel. Les utilisateurs peuvent changer cela dans leurs préférences." default_email_level: "Définissez le niveau de notification courriel par défaut pour les sujets standards." @@ -2009,7 +2008,7 @@ fr: default_other_enable_defer: "Par défaut, activer le bouton pour reporter un sujet à plus tard." default_other_dynamic_favicon: "Par défaut, faire apparaître le nombre de sujets récemment créés ou mis à jour sur l'icône navigateur." default_other_skip_new_user_tips: "Ignorer les conseils et badges d'intégration des nouveaux utilisateurs." - default_other_like_notification_frequency: "Par défaut, notifier les utilisateurs d'un J'aime" + default_other_like_notification_frequency: "Par défaut, notifier les utilisateurs lors de l'attribution d'un « J'aime »" default_topics_automatic_unpin: "Par défaut, désépingler automatiquement les sujets lorsque l'utilisateur atteint la fin de la liste." default_categories_watching: "Liste de catégories surveillées par défaut." default_categories_tracking: "Liste de catégories suivies par défaut." @@ -2278,7 +2277,7 @@ fr: merge_user: updating_username: "Mise à jour du nom d'utilisateur…" changing_post_ownership: "Modification de la propriété du message…" - merging_given_daily_likes: "Fusion des J'aime quotidiens…" + merging_given_daily_likes: "Fusion des « J'aime » quotidiens…" merging_post_timings: "Fusion des horaires des messages…" merging_user_visits: "Fusion des visites utilisateur…" updating_site_settings: "Mise à jour des paramètres du site…" @@ -3354,7 +3353,7 @@ fr: new_topics: "Nouveaux sujets" unread_notifications: "Notifications non lues" unread_high_priority: "Notifications prioritaires non lues" - liked_received: "J'aime reçus" + liked_received: "« J'aime » reçus" new_users: "Nouveaux utilisateurs" popular_topics: "Sujets populaires" follow_topic: "Suivre ce sujet" @@ -3569,24 +3568,24 @@ fr: deleted: "supprimé" image: "image" upload: - edit_reason: "téléchargement d'une copie locale des images distantes" - unauthorized: "Désolé, le fichier que vous essayer d'envoyer n'est pas autorisé (extensions autorisées : %{authorized_extensions})." + edit_reason: "téléchargement de copies locales des images distantes" + unauthorized: "Nous sommes désolés, le fichier que vous essayer d'envoyer n'est pas autorisé (extensions autorisées : %{authorized_extensions})." pasted_image_filename: "Image collée" - store_failure: "Erreur lors du stockage du fichier #%{upload_id} pour l'utilisateur #%{user_id}." - file_missing: "Désolé, il faut fournir un fichier à envoyer." - empty: "Désolé, mais le fichier que vous avez fourni est vide." - png_to_jpg_conversion_failure_message: "Une erreur est survenue lors de la conversion de PNG en JPG." + store_failure: "Erreur lors du stockage du fichier numéro %{upload_id} pour l'utilisateur %{user_id}." + file_missing: "Nous sommes désolés, vous devez fournir un fichier à envoyer." + empty: "Nous sommes désolés, mais le fichier que vous avez fourni est vide." + png_to_jpg_conversion_failure_message: "Une erreur est survenue lors de la conversion du format PNG en JPG." optimize_failure_message: "Une erreur est survenue lors de l'optimisation de l'image envoyée." attachments: - too_large: "Désolé, le fichier que vous essayez d'envoyer est trop volumineux (la taille maximale autorisée est de %{max_size_kb} Ko)." + too_large: "Nous sommes désolés, le fichier que vous essayez d'envoyer est trop volumineux (la taille maximale autorisée est de %{max_size_kb} Ko)." images: - too_large: "Désolé, l'image que vous essayez d'envoyer est trop grande (taille maximale de %{max_size_kb} Ko) ; veuillez la redimensionner puis réessayez." - larger_than_x_megapixels: "Désolé, l'image que vous essayez d'envoyer est trop grande (la dimension maximale est de %{max_image_megapixels} mégapixels) ; veuillez la redimensionner puis réessayez." - size_not_found: "Désolé, mais nous n'avons pas pu déterminer la taille de votre image. Peut-être est-elle corrompue ?" + too_large: "Nous sommes désolés, l'image que vous essayez d'envoyer est trop grande (la taille maximale autorisée est de %{max_size_kb} Ko). Veuillez la redimensionner puis réessayez." + larger_than_x_megapixels: "Nous sommes désolés, l'image que vous essayez d'envoyer est trop grande (la dimension maximale est de %{max_image_megapixels} mégapixels). Veuillez la redimensionner puis réessayez." + size_not_found: "Nous sommes désolés, mais nous n'avons pas pu déterminer la taille de votre image. Peut-être est-elle corrompue ?" placeholders: - too_large: "(image plus large que %{max_size_kb} Ko)" + too_large: "(image plus grande que %{max_size_kb} Ko)" avatar: - missing: "Désolé, nous ne parvenons pas à trouver un avatar associé à cette adresse courriel. Pouvez-vous essayer de l'envoyer à nouveau ?" + missing: "Nous sommes désolés, nous ne parvenons pas à trouver un avatar associé à cette adresse courriel. Pouvez-vous essayer de l'envoyer à nouveau ?" flag_reason: sockpuppet: "Un nouvel utilisateur a créé un sujet et un autre nouvel utilisateur avec la même adresse IP (%{ip_address}) a répondu. Voir le paramètre `flag_sockpuppets`." spam_hosts: "Ce nouvel utilisateur a tenté de créer plusieurs messages avec des liens vers le même domaine. Tous les messages de cet utilisateur qui contiennent des liens doivent être examinés. Cette règle peut être ajustée avec le paramètre `newuser_spam_host_threshold`." @@ -3651,40 +3650,40 @@ fr: ## [Ce lieu est consacré à la discussion courtoise et publique](#civilized) - Nous vous invitons à faire preuve du même degré de respect envers ce forum qu'envers un jardin public. Nous aussi sommes une ressource collective : un endroit où partager des savoir-faire, des connaissances ou des goûts par le biais de conversations écrites. + Nous vous invitons à faire preuve du même degré de respect envers ce forum qu'envers un jardin public. Nous constituons également une ressource collective, un endroit où partager des savoir-faire, des connaissances ou des goûts par le biais de conversations écrites. Ces règles n'appellent pas à une interprétation stricte. Ce sont des lignes directrices, conçues pour fournir un cadre au discernement des membres de notre communauté afin que ce lieu demeure amical, bienveillant et propice aux échanges courtois en public. - ## [Ajoutez à la discussion](#improve) + ## [Contribuez à la discussion](#improve) - Participez à faire de ce forum un lieu de discussion agréable en y apportant quelque chose de bon à chaque message, si modestement que ce soit. Si vous n'êtes pas certain que votre message alimentera la conversation, repensez à ce que vous souhaitez dire avant de reprendre sa rédaction. + Contribuez à faire de ce forum un lieu de discussion agréable en y apportant quelque chose de bon à chaque message, si modestement que ce soit. Si vous doutez que votre message puisse alimenter la conversation, repensez à ce que vous souhaitez communiquer avant de poursuivre sa rédaction. - Une façon d'ajouter aux discussions consiste à découvrir celles qui ont lieu. Passez un peu de temps à consulter celles qui se tiennent ici avant d'y répondre ou avant de lancer la vôtre, ainsi vous augmenterez vos chances de rencontrer des personnes qui partagent vos centres d'intérêt. + L'une des façons de contribuer aux discussions consiste à rechercher celles qui ont déjà lieu. Passez un peu de temps à consulter celles qui se tiennent ici avant d'y répondre ou avant de créer la vôtre. Vous augmenterez ainsi vos chances de rencontrer des personnes qui partagent vos centres d'intérêt. - Les sujets dont nous discutons ici nous tiennent à cœur, et nous souhaitons que vous vous comportiez comme s'ils vous importaient aussi. Soyez respectueux des thèmes choisis et des personnes qui en discutent, y compris si vous n'êtes pas d'accord avec tout ce qui peut être dit. + Les sujets dont nous discutons ici nous tiennent à cœur, et nous souhaitons que vous vous comportiez comme s'ils vous importaient aussi. Faites preuve de respect envers les thèmes choisis et les personnes qui en discutent, y compris si vous n'êtes pas d'accord avec tout ce qui est dit. - ## [Soyez plaisant, même si vous êtes en désaccord](#agreeable) + ## [Faites preuve de courtoisie, même si vous êtes en désaccord](#agreeable) - Vous pouvez tout à fait exprimer un désaccord. Mais veillez à _critiquer les idées, pas les personnes_. Nous vous prions d'éviter : + Vous pouvez tout à fait exprimer un désaccord. Mais veillez à critiquer les idées, pas les personnes. Nous vous prions d'éviter : * les insultes * les attaques ad hominem * de réagir au ton d'un message plutôt qu'au fond de ce qu'il exprime * l'esprit de contradiction systématique - Tâchez plutôt d'apporter un point de vue réfléchi qui puisse améliorer la conversation. + Tâchez plutôt d'apporter un point de vue réfléchi susceptible d'améliorer la conversation. ## [Votre participation importe](#participate) - Les conversations qui se tiennent ici donnent le ton à chaque nouveau-venu. Aidez-nous à façonner l'avenir de cette communauté en favorisant le fait de participer à des discussions qui rendent ce forum intéressant — et évitez celles dont ça n'est pas le cas. + Les conversations qui se tiennent ici donnent voix à chaque nouvel utilisateur ou nouvelle utilisatrice. Aidez-nous à façonner l'avenir de cette communauté en participant à des discussions qui rendent ce forum intéressant et évitez celles dont ce n'est pas le cas. - Discourse comprend des outils qui permettent à la communauté d'identifier collectivement les meilleures (et les pires) contributions : signets, J'aime, signalements, réponses, modifications, abonnements, mise sous silence, et ainsi de suite. Utilisez ces fonctionnalités pour améliorer votre propre expérience, ainsi que celle des autres membres. + Discourse comprend des outils qui permettent à la communauté d'identifier collectivement les meilleures (et les pires) contributions : signets, « J'aime », signalements, réponses, modifications, abonnements, mise en sourdine, et ainsi de suite. Utilisez ces fonctionnalités pour améliorer votre propre expérience, ainsi que celle des autres membres. Faites en sorte que la communauté se porte mieux au moment de votre départ qu'à celui de votre arrivée. @@ -3694,55 +3693,55 @@ fr: Les modérateurs disposent d'une autorité particulière : ils sont responsables de ce forum. Mais vous l'êtes aussi. Moyennant votre aide, les modérateurs pourront faciliter la vie de la communauté, plutôt que de seulement servir de gendarme ou de concierge. - Face à un comportement nuisible, ne répondez pas. Répondre dans de telles circonstances encourage les comportements délétères en leur accordant une forme de reconnaissance ; vous y gâcherez de votre énergie et tout le monde y perdra son temps. _Signalez-le, rien de plus_. Si plusieurs signalements s'accumulent, une suite leur sera donnée, soit par l'intervention d'un modérateur, soit de façon automatisée. + Face à un comportement nuisible, ne répondez pas. Répondre dans de telles circonstances encourage les comportements délétères en leur accordant une forme de reconnaissance ; vous y perdrez de l'énergie et tout le monde y perdra son temps. Signalez-le, rien de plus. Si plusieurs signalements s'accumulent, une suite sera donnée, soit par l'intervention d'un modérateur, soit de façon automatisée. - Pour le bien de notre communauté, les modérateurs sont autorisés à supprimer tout contenu ou tout compte utilisateur, à tout moment et quel qu'en soit le motif. Veuillez noter que les modérateurs n'ont pas connaissance des messages avant leur publication ; les modérateurs et les administrateurs du site se dégagent de toute responsabilité quant au contenu publié au sein de la communauté. + Pour le bien de notre communauté, les modérateurs sont autorisés à supprimer tout contenu ou tout compte d'utilisateur, à tout moment et quel qu'en soit le motif. Veuillez noter que les modérateurs n'ont pas connaissance des messages avant leur publication ; les modérateurs et les administrateurs du site déclinent toute responsabilité quant au contenu publié au sein de la communauté. - ## [Soyez toujours courtois](#be-civil) + ## [Faites toujours preuve de politesse](#be-civil) Rien de tel que l'impolitesse pour saboter une bonne conversation. - * Faites preuve de civilité. Ne publiez rien qui ne soit considéré comme offensant, injurieux, violent ou haineux par une personne raisonnable. - * Ne soyez pas grossier. Ne publiez rien d'obscène ni de pornographique. - * Respectez autrui. Ne harcelez personne, n'accablez personne, ne vous faites pas passer pour quelqu'un d'autre, et ne révélez pas d'informations privées qui ne soient pas les vôtres. - * Respectez notre forum. N'y publiez pas de spam, ne le vandalisez pas. + * Faites preuve de civilité. Ne publiez rien qui puisse être considéré comme offensant, injurieux, violent ou haineux par une personne raisonnable. + * Évitez la grossièreté. Ne publiez rien d'obscène ni de pornographique. + * Respectez les autres. Ne harcelez personne, n'accablez personne, ne vous faites pas passer pour quelqu'un d'autre, et ne révélez pas d'informations privées qui ne soient pas les vôtres. + * Respectez notre forum. N'y publiez pas de spam et ne le vandalisez pas. - Ces termes sont volontairement abstraits et dénués de définitions précises ; abstenez-vous de tout ce qui puisse avoir la simple _apparence_ de telles choses. En cas de doute, demandez-vous : que feriez-vous si votre message finissait en page d'accueil d'un site d'actualités nationales ? + Ces conditions sont volontairement abstraites et dénuées de définitions précises ; abstenez-vous de tout ce qui peut avoir la simple apparence de telles choses. En cas de doute, demandez-vous : que feriez-vous si votre message finissait en page d'accueil d'un site d'actualités nationales ? - Ce forum est public, et ces discussions sont indexées par les moteurs de recherche. Faites en sorte que le langage, les liens et les images y figurant conviennent à la famille et aux amis de chacun. + Ce forum est public, et ces discussions sont indexées par les moteurs de recherche. Faites en sorte que le langage, les liens et les images y figurant conviennent aux membres de la famille et aux amis de chacun. ## [Mettez-y un peu d'ordre](#keep-tidy) - Faites en sorte que les choses soient à leur place, afin que nous passions plus de temps à échanger et moins de temps à faire le ménage. Donc : + Faites en sorte que les choses soient à leur place, afin que nous passions plus de temps à échanger et moins de temps à faire le ménage. À ce titre : - * Ne lancez pas un nouveau sujet dans la mauvaise catégorie ; nous vous invitons à lire les descriptions des catégories. + * Ne créez pas un nouveau sujet dans une catégorie erronée ; nous vous invitons à lire les descriptions des catégories. * Ne publiez pas plusieurs fois le même message dans différents sujets. * Ne publiez pas de réponses dénuées de toute substance. - * Ne détournez pas un fil de discussion en changeant de sujet en plein milieu. - * N'inscrivez pas de signature dans vos messages ; chacun d'eux contient déjà des informations tirées de votre profil. + * Ne détournez pas un fil de discussion en changeant de sujet. + * N'inscrivez pas de signature dans vos messages. Ces derniers contiennent déjà des informations extraites de votre profil. Plutôt que d'écrire « +1 » ou « c'est clair », utilisez le bouton J'aime. Plutôt que de faire dériver un sujet vers une direction radicalement autre, utilisez la fonctionnalité Répondre via un sujet lié. ## [Ne publiez que ce qui vous appartient](#stealing) - Il vous est interdit de publier quoi que ce soit qui ne vous appartienne pas en format numérique sans l'autorisation de son auteur. Vous ne devez pas publier de procédé qui décrive une façon de voler la propriété intellectuelle d'autrui (images, son, vidéo, logiciels) ou qui décrive une façon de violer toute autre loi, ni de lien renvoyant vers de tels procédés. + Il vous est interdit de publier quoi que ce soit qui ne vous appartienne pas au format numérique sans l'autorisation de son auteur. Vous ne pouvez pas publier de procédés visant à voler la propriété intellectuelle d'autrui (images, son, vidéo, logiciels) ou à violer toute autre loi, ni de liens renvoyant vers de tels procédés. - ## [Propulsé par vous](#power) + ## [Soutenu par vous](#power) - Ce site est tenu par vos [gentils administrateurs](%{base_path}/about) et par *vous*, sa communauté. Si vous avez une question au sujet de la façon dont les choses devraient fonctionner chez nous, lancez un nouveau sujet dans la [catégorie consacrée](%{base_path}/c/site-feedback) et discutons-en ! En cas de problème critique ou urgent ne pouvant pas être géré par un méta-sujet ou par un signalement, vous pouvez nous joindre via la [page des responsables](%{base_path}/about). + Ce site est géré par vos [gentils administrateurs](%{base_path}/about) et par *vous*, la communauté. Si vous avez une question au sujet de la façon dont les choses devraient fonctionner chez nous, créez un nouveau sujet dans la [catégorie consacrée](%{base_path}/c/site-feedback) et discutons-en ! En cas de problème critique ou urgent ne pouvant pas être géré par un méta-sujet ou par un signalement, vous pouvez nous joindre via la [page des responsables](%{base_path}/about). ## [Conditions d'utilisation](#tos) - Le jargon juridique est barbant, certes, mais nous devons veiller à nous protéger — et par extension, veiller à vous protéger, ainsi que vos données — contre des gens aux intentions peu amicales. Nous avons des [conditions générales d'utilisation](%{base_path}/tos) qui décrivent vos droits et devoirs (ainsi que les nôtres) ayant trait au contenu, à la protection de la vie privée et à la législation. Pour utiliser ce service, vous devez obéir à nos [CGU](%{base_path}/tos). + Le jargon juridique est ennuyeux, certes, mais nous devons veiller à nous protéger (et par extension, à vous protéger, ainsi que vos données) contre les individus mal intentionnés. Nous avons des [conditions générales d'utilisation](%{base_path}/tos) qui décrivent vos droits et devoirs (ainsi que les nôtres) ayant trait au contenu, à la protection de la vie privée et à la législation. Pour utiliser ce service, vous devez respecter nos [CGU](%{base_path}/tos). tos_topic: title: "Conditions générales d'utilisation" body: | @@ -4024,24 +4023,24 @@ fr: Ce badge est accordé lorsque vous atteignez le niveau de confiance 1. Merci d'être resté sur le forum et d'avoir lu quelques sujets pour en apprendre plus sur notre communauté. Les restrictions liées au nouveaux utilisateurs ont été levées et vous avez accès aux fonctionnalités essentielles telles que la messagerie personnelle, le signalement, l'édition des wikis et la possibilité de publier plusieurs images et liens. member: name: Membre - description: Accès accordé aux invitations, aux messages de groupe et à plus de J'aime + description: Accès accordé aux invitations, aux messages de groupe et à plus de « J'aime » long_description: | - Ce badge est accordé lorsque vous atteignez le niveau de confiance 2. Merci d'avoir participé durant plusieurs semaines à notre communauté. Vous pouvez désormais envoyer des invitations personnelles depuis votre page utilisateur ou un sujet, envoyer des messages groupés et avez des J'aime supplémentaires à donner chaque jour. + Ce badge est accordé lorsque vous atteignez le niveau de confiance 2. Merci d'avoir participé durant plusieurs semaines à notre communauté. Vous pouvez désormais envoyer des invitations personnelles depuis votre page utilisateur ou un sujet, envoyer des messages groupés et vous bénéficiez de « J'aime » supplémentaires à attribuer chaque jour. regular: name: Habitué - description: Accès accordé à la re-catégorisation, au renommage, au suivi de liens, au wikis et à plus de J'aime + description: Accès accordé à la recatégorisation, au renommage, au suivi de liens, au wikis et à plus de « J'aime » long_description: | - Ce badge est accordé lorsque vous atteignez le niveau de confiance 3. Merci d'avoir été un participant régulier à notre communauté pendant ces quelques mois. Vous êtes l'un de nos lecteurs les plus actifs et un contributeur sérieux à ce qui rend notre communauté si belle. Vous pouvez désormais recatégoriser et renommer des sujets, accéder au salon privé, signaler des spams et vous avez plein de J'aime en plus à donner chaque jour. + Ce badge est accordé lorsque vous atteignez le niveau de confiance 3. Merci d'avoir contribué régulièrement à notre communauté pendant ces quelques mois. Vous faites partie de nos lecteurs et lectrices les plus actifs et votre contribution à ce qui rend notre communauté si belle est significative. Vous pouvez désormais recatégoriser et renommer des sujets, accéder au salon privé, signaler des spams et vous bénéficiez de nombreux « J'aime » supplémentaires à attribuer chaque jour. leader: name: Meneur - description: Accès accordé à l'édition globale, l'épinglage, la fermeture, l'archivage, la séparation et la fusion de sujets, et toujours plus de J'aime + description: Accès accordé à l'édition globale, l'épinglage, la fermeture, l'archivage, la séparation et la fusion de sujets, et toujours plus de « J'aime » long_description: | Ce badge est accordé lorsque vous atteignez le niveau de confiance 4. Vous êtes un meneur choisi par l'équipe dans cette communauté, et vous montrez l'exemple dans vos actions et vos paroles. Vous avez la possibilité de modifier tous les messages, utiliser les actions de modérations telles qu'épingler, fermer, masquer, archiver, scinder et fusionner les sujets. welcome: name: Bienvenue - description: A reçu un J'aime + description: A reçu un « J'aime » long_description: | - Ce badge est accordé lorsque vous recevez votre premier J'aime sur un de vos messages. Félicitations, vous avez écrit quelque chose que les membres de votre communauté ont trouvé intéressant, sympa ou utile ! + Ce badge est accordé lorsque vous recevez votre premier « J'aime » sur un de vos messages. Félicitations, vous avez écrit quelque chose que les membres de votre communauté ont trouvé intéressant, sympa ou utile ! autobiographer: name: Autobiographe description: A complété les informations de son profil @@ -4054,34 +4053,34 @@ fr: Ce badge est accordé après avoir été membre du site pendant une année, avec au moins un message créé durant cette année. Merci d'être resté avec nous et de contribuer ainsi à notre communauté ! Nous n'aurions pas pu le faire sans vous. nice_post: name: Jolie réponse - description: A reçu 10 J'aime sur une réponse + description: A reçu 10 « J'aime » sur une réponse long_description: | - Ce badge est accordé quand votre réponse obtient 10 J'aime. Votre réponse a fait bonne impression sur la communauté et a aidé la conversation à progresser. + Ce badge est accordé quand votre réponse obtient 10 « J'aime ». Votre réponse a fait bonne impression auprès de la communauté et a aidé la conversation à progresser. good_post: name: Bonne réponse - description: A reçu 25 J'aime sur une réponse + description: A reçu 25 « J'aime » sur une réponse long_description: | - Ce badge est accordé quand votre réponse obtient 25 J'aime. Votre réponse était exceptionnelle et a rendu la conversation beaucoup plus intéressante ! + Ce badge est accordé quand votre réponse obtient 25 « J'aime ». Votre réponse était exceptionnelle et a rendu la conversation beaucoup plus intéressante ! great_post: name: Super réponse - description: A reçu 50 J'aime sur une réponse + description: A reçu 50 « J'aime » sur une réponse long_description: | - Ce badge est accordé quand votre réponse obtient 50 J'aime. Votre réponse était inspirante, fascinante, hilarante ou pertinente et la communauté l'a adorée ! + Ce badge est accordé quand votre réponse obtient 50 « J'aime ». Votre réponse était inspirante, fascinante, hilarante ou pertinente et la communauté l'a adorée ! nice_topic: name: Sujet intéressant - description: A reçu 10 J'aime sur un sujet + description: A reçu 10 « J'aime » sur un sujet long_description: | - Ce badge est accordé quand votre sujet obtient 10 J'aime. Vous avez commencé une conversation intéressante que la communauté a apprécié ! + Ce badge est accordé quand votre sujet obtient 10 « J'aime ». Vous avez commencé une conversation intéressante que la communauté a apprécié ! good_topic: name: Bon sujet - description: A reçu 25 J'aime sur un sujet + description: A reçu 25 « J'aime » sur un sujet long_description: | - Ce badge est accordé quand votre sujet obtient 25 J'aime. Vous avez lancé une conversation vibrante autour de laquelle la communauté s'est ralliée ! + Ce badge est accordé quand votre sujet obtient 25 « J'aime ». Vous avez lancé une conversation vibrante autour de laquelle la communauté s'est ralliée ! great_topic: name: Super sujet - description: A reçu 50 J'aime sur un sujet + description: A reçu 50 « J'aime » sur un sujet long_description: | - Ce badge est accordé quand votre sujet obtient 50 J'aime. Vous avez initié une conversation fascinante et la communauté a apprécié la discussion dynamique résultante ! + Ce badge est accordé quand votre sujet obtient 50 « J'aime ». Vous avez initié une conversation fascinante et la communauté a apprécié la discussion dynamique à laquelle elle a donné lieu ! nice_share: name: Partage sympa description: Message partagé avec 25 visiteurs uniques @@ -4098,7 +4097,7 @@ fr: long_description: | Ce badge est accordé après le partage d'un lien qui a été cliqué par 1000 visiteurs extérieurs. Whaou ! Vous avez fait la promotion d'une discussion intéressante auprès d'une nouvelle audience d'envergure et avez aidé notre communauté à grandir de manière significative ! first_like: - name: Premier J'aime + name: Premier « J'aime » description: A aimé un message long_description: | Ce badge est accordé la première fois que vous aimez un message en utilisant le bouton :heart:. Aimer des messages est le moyen parfait pour dire aux autres membres de la communauté que ce qu'ils ont posté était intéressant, utile, sympa ou amusant ! @@ -4164,49 +4163,49 @@ fr: Ce badge est accordé quand un lien que vous avez partagé obtient 1000 clics. Whaou ! Vous avez posté un lien qui a notablement amélioré la discussion en ajoutant des détails et des informations essentielles. Bon travail ! appreciated: name: Apprécié - description: A reçu 1 J'aime sur 20 messages + description: A reçu 1 « J'aime » sur 20 messages long_description: | - Ce badge est accordé lorsque vous recevez au moins un J'aime sur 20 messages différents. La communauté apprécie vos contributions aux conversations ! + Ce badge est accordé lorsque vous recevez au moins un « J'aime » sur 20 messages différents. La communauté apprécie vos contributions aux conversations ! respected: name: Respecté - description: A reçu 2 J'aime sur 100 messages + description: A reçu 2 « J'aime » sur 100 messages long_description: | - Ce badge est accordé lorsque vous recevez au moins 2 J'aime sur 100 messages différents. La communauté a de plus en plus de respect pour vos nombreuses contributions aux conversations. + Ce badge est accordé lorsque vous recevez au moins 2 « J'aime » sur 100 messages différents. La communauté a de plus en plus de respect à l'égard de vos nombreuses contributions aux conversations. admired: name: Admiré - description: A reçu 5 J'aime sur 300 messages + description: A reçu 5 « J'aime » sur 300 messages long_description: | - Ce badge est accordé lorsque vous recevez au moins 5 J'aime sur 300 messages différents. Whaou ! La communauté admire vos fréquentes contributions de haute qualité aux conversations. + Ce badge est accordé lorsque vous recevez au moins 5 « J'aime » sur 300 messages différents. Ouah ! La communauté admire vos fréquentes contributions de grande qualité. out_of_love: name: Trop d'amour - description: A utilisé %{max_likes_per_day} J'aime en une journée + description: A utilisé %{max_likes_per_day} « J'aime » en une journée long_description: | - Ce badge est accordé lorsque vous utilisez la totalité de vos %{max_likes_per_day} J'aime quotidiens. Rappelez-vous de prendre un moment pour aimer les messages qui vous plaisent et que vous appréciez. Cela encourage les autres membres de la communauté à créer encore plus de discussions de qualité à l'avenir. + Ce badge est accordé lorsque vous utilisez la totalité de vos %{max_likes_per_day} « J'aime » quotidiens. Rappelez-vous de prendre un moment pour aimer les messages qui vous plaisent et que vous appréciez. Cela encouragera les autres membres de la communauté à créer encore plus de discussions de qualité à l'avenir. higher_love: name: Amour plus fort - description: A utilisé %{max_likes_per_day} J'aime en un jour 5 fois + description: A utilisé %{max_likes_per_day} « J'aime » en un jour à 5 reprises long_description: | - Ce badge est accordé lorsque vous utilisez la totalité de vos %{max_likes_per_day} J'aime par jour pendant 5 jours. Merci de prendre le temps d'encourager activement les meilleures conversations chaque jour ! + Ce badge est accordé lorsque vous utilisez la totalité de vos %{max_likes_per_day} « J'aime » quotidiens pendant 5 jours. Merci de prendre le temps d'encourager activement les meilleures conversations au quotidien ! crazy_in_love: name: Fou amoureux - description: A utilisé %{max_likes_per_day} J'aime en un jour 20 fois + description: A utilisé %{max_likes_per_day} « J'aime » en un jour à 20 reprises long_description: | - Ce badge est accordé lorsque vous utilisez la totalité vos %{max_likes_per_day} J'aime par jour pendant 20 jours. Whaou ! Vous êtes un modèle d'encouragement pour les membres de la communauté ! + Ce badge est accordé lorsque vous utilisez la totalité vos %{max_likes_per_day} « J'aime » quotidiens pendant 20 jours. Ouah ! Vous êtes un modèle d'encouragement pour les membres de la communauté ! thank_you: name: Merci - description: A 20 messages ayant reçu un J'aime et a donné 10 J'aime + description: A 20 messages ayant reçu un « J'aime » et a attribué 10 « J'aime » long_description: | - Ce badge est accordé quand vous avez reçu 20 J'aime sur vos messages et en avez donné 10 ou plus en retour. Quand quelqu'un aime vos messages, vous trouvez le temps d'aimer ce que les autres postent à leur tour. + Ce badge est accordé quand vous avez reçu 20 « J'aime » sur vos messages et en avez attribué 10 ou plus en retour. Quand quelqu'un aime vos messages, vous trouvez le temps d'aimer ce que les autres publient à leur tour. gives_back: name: Redonne - description: A 100 messages ayant reçu un J'aime et a donné 100 J'aime + description: A 100 messages ayant reçu un « J'aime » et a attribué 100 « J'aime » long_description: | - Ce badge est accordé quand vous avez reçu 100 J'aime et en avez donné 100 ou plus en retour. Whaou ! Merci de rendre ce lieu meilleur ! + Ce badge est accordé quand vous avez reçu 100 « J'aime » et en avez attribué 100 ou plus en retour. Ouah ! Merci de rendre ce lieu meilleur ! empathetic: name: Empathique - description: A 500 messages ayant reçu un J'aime et a donné 1000 J'aime + description: A 500 messages ayant reçu un « J'aime » et a donné 1000 « J'aime » long_description: | - Ce badge est accordé quand vous avez reçu 500 J'aime et en avez donné 1000 ou plus en retour. Whaou ! Vous êtes un modèle de générosité et d'amour mutuel :two_hearts:. + Ce badge est accordé quand vous avez reçu 500 « J'aime » et en avez donné 1000 ou plus en retour. Ouah ! Vous êtes un modèle de générosité et d'amour mutuel :two_hearts:. first_emoji: name: Premier émoji description: A utilisé un émoji dans un message @@ -4231,7 +4230,7 @@ fr: name: "Nouvel utilisateur du mois" description: Contributions remarquables durant leur premier mois long_description: | - Ce badge est accordé pour féliciter deux nouveaux utilisateurs chaque mois pour leur excellente contribution mesurée par la fréquence à laquelle leurs messages reçoivent des J'aime et par qui. + Ce badge est accordé pour féliciter deux nouveaux utilisateurs chaque mois pour leur excellente contribution mesurée par la fréquence à laquelle leurs messages reçoivent des « J'aime » et par qui. enthusiast: name: Passionné description: A visité 10 jours consécutifs diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml index 26ad3117e7..466f4e730a 100644 --- a/config/locales/server.gl.yml +++ b/config/locales/server.gl.yml @@ -1457,7 +1457,6 @@ gl: must_approve_users: "O equipo debe aprobar todas as novas contas antes de que teñan permiso para acceder ao sitio." invite_code: "O usuario debe inserir este código para que se lle permita rexistrar a conta, ignórase cando está baleiro (non distingue entre maiúsculas e minúsculas)" approve_suspect_users: "Engadir usuarios sospeitosos á cola de revisión. Os usuarios sospeitosos inseriron unha biografía ou sitio web, pero non teñen actividade de lectura." - pending_users_reminder_delay: "Notificarlles aos moderadores se hai usuarios novos que leven á espera da súa aprobación un tempo superior a este número de horas. Estabelecer en -1 para desactivar notificacións." persistent_sessions: "Os usuarios conservarán a sesión iniciada cando o navegador estea pechado" maximum_session_age: "O usuario permanecerá coa sesión iniciada durante n horas desde a última visita" ga_version: "Versión de Google Universal Analytics que usar: v3 (analytics.js), v4 (gtag)" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index f8c7cd63a5..1ed16983d0 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -1585,7 +1585,7 @@ he: invite_code: "על המשתמש להקליד את הקוד הזה כדי שיתאפשר לו להירשם לחשבון, כשריק אין לזה משמעות (אין משמעות לאותיות גדולות/קטנות)" approve_suspect_users: "הוספת משתמשים חשודים לתור הסקירה. משתמשים חשודים מילאו ביוגרפיה/אתר אבל לא קראו שום דבר." review_every_post: "כל הפוסטים חייבים בסקירה. אזהרה! לא מומלץ לאתרים עמוסים." - pending_users_reminder_delay: "יש להודיע למפקחים אם משתמשים חדשים ממתינים לעדכון מעבר לכמות כזו של שעות. יש להגדיר ל־‎-1 כדי לנטרל את ההתראות." + pending_users_reminder_delay_minutes: "יש להודיע למפקחים אם משתמשים חדשים ממתינים לעדכון מעבר לכמות כזו של דקות. יש להגדיר ל־‎-1 כדי להשבית את ההתראות." persistent_sessions: "המשתמשים יישארו במערכת כאשר הדפדפן סגור" maximum_session_age: "משתמשים ישארו מחוברים ל n שעות מאז ביקורם האחרון" ga_version: "גרסה של Google Universal Analytics לשימוש: v3‏ (analytics.js),‏ v4‏ (gtag)" @@ -1635,6 +1635,7 @@ he: send_old_credential_reminder_days: "להזכיר על פרטי גישה ישנים לאחר ימים" email_token_valid_hours: "סיסמאות שכחת סיסמה / הפעלת חשבון תקפים במשך (n) שעות." enable_badges: "הפעלת מערכת העיטורים" + max_favorite_badges: "מספר העיטורים המרבי שמשתמש יכול לבחור" enable_whispers: "אפשרו לצוות לקיים תקשורת פרטית בתוך נושאים." allow_index_in_robots_txt: "יש לציין ב־robots.txt שמותר לאתר הזה להצטרף למאגר של מנועי החיפוש. במקרים יוצאי דופן, ניתן לדרוס את robots.txt באופן קבוע." blocked_email_domains: "רשימה מופרדת בקווים אנכיים (|) של שמות תחום דוא״ל שאסור להירשם דרכם. למשל: mailinator.com|trashmail.net" @@ -1770,6 +1771,11 @@ he: image_preview_jpg_quality: "איכות קובצי התמונות שגודלם משתנה (1 היא האיכות הנמוכה ביותר, 99 הטובה ביותר, 100 משבית)." allow_staff_to_upload_any_file_in_pm: "אפשרו לחברי צוות להעלות כל קובץ בהודעה פרטית." strip_image_metadata: "הסרת מטה-דאטה מהתמונה" + composer_media_optimization_image_enabled: "מאפשר מיטוב של מדיה בצד הלקוח של קובצי תמונה שהועלו." + composer_media_optimization_image_kilobytes_optimization_threshold: "גודל קובץ תמונה מזערי להפעלת מיטוב בצד הלקוח" + composer_media_optimization_image_resize_dimensions_threshold: "רוחב תמונה מזערי להפעלת שינוי גודל בצד הלקוח" + composer_media_optimization_image_resize_width_target: "תמונות שרוחבן עולה על `composer_media_optimization_image_dimensions_resize_threshold`, גודלן ישתנה לרוחב זה. הערך חייב להיות >= לעומת `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "איכות קידוד JPEG המשמשת בתהליך הקידוד מחדש." min_ratio_to_crop: "יחס לחיתוך תמונות גבוהות. יש להקליד את התוצאה ברוחב / גובה." simultaneous_uploads: "מספר הקבצים המרבי שניתן לגרור ולהשליך אל מחבר ההודעות" default_invitee_trust_level: "ברירת מחדל של רמת אמון (0-4) של משתמשים מוזמנים." @@ -1887,6 +1893,7 @@ he: reviewable_default_visibility: "לא להציג פריטים שמיועדים לסקירה אלמלא הם תואמים את העדיפות הזו" high_trust_flaggers_auto_hide_posts: "פוסטים של משתמש חדש מוסתרים אוטומטים לאחר שסומנו כספאם על ידי משתמש בדרגת אמון 3 ומעלה" cooldown_hours_until_reflag: "כמה זמן יהיה על משתמשים לחכות לפני שיוכלו לסמן פוסט כספאם פעם נוספת" + slow_mode_prevents_editing: "האם ‚מצב אטי’ מונע עריכה לאחר editing_grace_period (תקופת חסד לעריכה)?" reply_by_email_enabled: "הפעלת תגובה לנושאים דרך דוא״ל." reply_by_email_address: "תבנית עבור כתובות מייל של מענה באמצעות אימייל, למשל: {reply_key}@reply.example.com או replies+%%{reply_key}@example.com" alternative_reply_by_email_addresses: "רשימה של כמה תבניות לתגובות במייל באמצעות כתובות מייל נכנסות. למשל: %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" @@ -3242,6 +3249,7 @@ he: only_reply_by_email_pm: "יש להשיב להודעה הזאת כדי להגיב אל %{participants}." visit_link_to_respond: "[בקרו בנושא](%{base_url}%{url}) כדי לענות." visit_link_to_respond_pm: "יש [לבקר בהודעה](%{base_url}%{url}) כדי להגיב אל %{participants}." + reply_above_line: "## נא להקליד את התגובה שלך מעל לשורה הזאת. ##" posted_by: "פורסם על ידי %{username} ב %{post_date}" pm_participants: "משתתפים: %{participants}" invited_group_to_private_message_body: "הקבוצה @%{group_name} הוזמנה להתכתבות על ידי %{username} \n\n> **[%{topic_title}](%{topic_url})**\n>\n> %{topic_excerpt}\n\nבאתר\n\n> %{site_title} -- %{site_description}\n\nכדי להצטרף להודעה יש ללחוץ על הקישור שלהלן:\n\n%{topic_url}\n" diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml index 82dd07a186..2fe961c685 100644 --- a/config/locales/server.hy.yml +++ b/config/locales/server.hy.yml @@ -1148,7 +1148,6 @@ hy: enable_markdown_linkify: "Ավտոմատ կերպով հղում դարձնել տեքստը, որը նման է հղման. www.example.com -ը և https://example.com -ը ավտոմատ կերպով կդառնան հղում" markdown_linkify_tlds: "Թոփ մակարդակի դոմենների ցանկ, որոնք ավտոմատ կերպով հղում են դառնում:" post_undo_action_window_mins: "Րոպեների քանակը, որոնց ընթացքում օգտատերերին թույլատրվում է ետարկել գրառման վրա կատարված վերջին գործողությունները (հավանում, դրոշակավորում և այլն):" - pending_users_reminder_delay: "Ծանուցել մոդերատորներին, եթե նոր օգտատերերը սպասում են հաստատման ավելի երկար, քան այսքան ժամ: Սահմանեք -1՝ ծանուցւոմները անջատելու համար:" maximum_session_age: "Օգտատերը կմնա մուտքագրված վերջին այցելությունից n ժամ անց" enable_escaped_fragments: "Վերադարձ դեպի Google's Ajax-Crawling API , եթե ոչ մի ցանցային տվայլների հավաքագրիչ (webcrawler) հայտնաբերված չէ: Այցելեք https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" cors_origins: "Թույլատրելի origin-ներ խաչաձև origin հարցումների համար (CORS): Յուրաքանչյուր origin պետք է ներառի http:// կամ https://: DISCOURSE_ENABLE_CORS env փոփոխականը պետք է սահմանված լինի true ՝ CORS -ի միացման համար:" diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index cc6377c582..7ab24e7b4f 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -1477,7 +1477,6 @@ it: invite_code: "L'utente deve digitare questo codice per consentire la registrazione dell'account, ignorato quando vuoto (senza distinzione tra maiuscole e minuscole)" approve_suspect_users: "Aggiungi utenti sospetti alla coda di revisione. Gli utenti sospetti hanno inserito una biografia o un sito web ma non hanno attività di lettura." review_every_post: "Tutti i messaggi devono essere revisionati. ATTENZIONE! NON CONSIGLIATO PER SITI MOLTO ATTIVI." - pending_users_reminder_delay: "Notifica i moderatori se nuovi utenti sono in attesa di approvazione per più di queste ore. Imposta a -1 per disabilitare le notifiche." persistent_sessions: "Gli utenti rimarranno connessi alla chiusura del browser" maximum_session_age: "L'utente resterà connesso per n ore dall'ultima visita" ga_version: "Versione di Google Universal Analytics da usare: v3 (analytics.js), v4 (gtag)" diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index 46f636e54f..896fc992a6 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -6,31 +6,31 @@ ja: dates: - short_date_no_year: "MMM D" - short_date: "YYYY MMM D" - long_date: "YYYY MMMM D h:mma" + short_date_no_year: "MMM D 日" + short_date: "YYYY 年 MMM D 日" + long_date: "YYYY 年 MMMM D 日 a h:mm" datetime_formats: &datetime_formats formats: - short: "%Y-%d-%m" - short_no_year: "%B %-d" - date_only: "%B%-d、%Y" - long: "%B%-d、%Y、%I:%M%P" - no_day: "%Y %m" + short: "%Y-%m-%d" + short_no_year: "%B %-d 日" + date_only: "%Y 年 %B %-d 日" + long: "%Y 年 %B %-d 日%P %l:%M" + no_day: "%Y 年 %B" date: month_names: - null - - 1月 - - 2月 - - 3月 - - 4月 - - 5月 - - 6月 - - 7月 - - 8月 - - 9月 - - 10月 - - 11月 - - 12月 + - 1 月 + - 2 月 + - 3 月 + - 4 月 + - 5 月 + - 6 月 + - 7 月 + - 8 月 + - 9 月 + - 10 月 + - 11 月 + - 12 月 <<: *datetime_formats time: am: "午前" @@ -41,186 +41,186 @@ ja: posts: "投稿" views: " 閲覧" loading: "読み込み中" - powered_by_html: 'Discourseにより提供されています。最適な表示のために、Javascriptを有効にしてください。' - sign_up: "アカウントを作成" + powered_by_html: 'Discourse により提供されています。最適な表示のために、Javascript を有効にしてください。' + sign_up: "サインアップ" log_in: "ログイン" submit: "送信" - purge_reason: "放棄されていたため自動的に削除、アカウントを停止しました。" - disable_remote_images_download_reason: "ディスク容量が不足しているため、リモートでの画像ダウンロードは無効になっています。" + purge_reason: "放置・無効アカウントとして自動的に削除されました。" + disable_remote_images_download_reason: "ディスク容量が不足しているため、リモート画像のダウンロードは無効化されました。" anonymous: "匿名" - remove_posts_deleted_by_author: "削除者" - redirect_warning: "選択されたリンクがこのフォーラムに投稿されたものと確認できませんでした。このまま進むには、下のリンクを選択してください。" - on_another_topic: "別のトピックで" + remove_posts_deleted_by_author: "作者によって削除" + redirect_warning: "選択されたリンクがこのフォーラムに実際に投稿されていることを確認できませんでした。このまま進むには、下のリンクを選択してください。" + on_another_topic: "別のトピック" inline_oneboxer: topic_page_title_post_number: "#%{post_number}" - topic_page_title_post_number_by_user: "#%{post_number} %{username}から" + topic_page_title_post_number_by_user: "%{username} の #%{post_number}" themes: - bad_color_scheme: "テーマを更新できません、無効なカラーパレットです" - other_error: "サーバーに不具合があるので、テーマを更新してください。" + bad_color_scheme: "テーマを更新できません。無効なカラーパレットです" + other_error: "テーマの更新中に問題が発生しました" compile_error: - unrecognized_extension: "ファイルの拡張子が認識できません。: %{extension}" + unrecognized_extension: "ファイル拡張子を認識できません: %{extension}" import_error: - generic: テーマをインポートするときにエラーが起こりました - about_json: "インポート エラー: about.json が存在しないか、無効です。これはDiscourseのテーマですか?" - about_json_values: "about.jsonは無効な値があります: %{errors}" - modifier_values: "about.json修飾子に無効な値が含まれています: %{errors}" - git: "gitレポジトリのクローニングに失敗しました。アクセスが許可されていないか、レポジトリが見つかりません。" + generic: テーマをインポート中にエラーが発生しました + about_json: "インポートエラー: about.json が存在しないか無効です。これは Discourseテーマですか?" + about_json_values: "about.json に無効な値が含まれます: %{errors}" + modifier_values: "about.json 修飾子に無効な値が含まれます: %{errors}" + git: "git リポジトリのクローニング中にエラーが発生しました。アクセスが拒否されたか、リポジトリが見つかりません" unpack_failed: "ファイルの解凍に失敗しました" - file_too_big: "非圧縮ファイルが大きすぎます。" - unknown_file_type: "アップロードしたファイルは、有効なDiscourseテーマではないようです。" + file_too_big: "解凍されたファイルが大きすぎます。" + unknown_file_type: "アップロードしたファイルは、有効な Discourse テーマではないようです。" errors: - component_no_user_selectable: "テーマコンポーネントはユーザー選択可能にできません" - component_no_default: "テーマコンポーネントはデフォルトテーマにできません" - component_no_color_scheme: "テーマコンポーネントにカラーパターン設定はありません" - no_multilevels_components: "子テーマ自身を子テーマのテーマにはできません" - optimized_link: 最適化された画像リンクは一時的なものであり、テーマのソースコードに含めるべきではありません。 + component_no_user_selectable: "テーマコンポーネントをユーザー選択可能にできません" + component_no_default: "テーマコンポーネントをデフォルトテーマにできません" + component_no_color_scheme: "テーマコンポーネントにカラーパレットを含めることはできません" + no_multilevels_components: "子テーマを持つテーマをその子テーマにできません。" + optimized_link: 最適化された画像のリンクは一時的なリンクであるため、テーマのソースコードに含めるべきではありません。 settings_errors: - invalid_yaml: "入力されたYAMLは不正です" - data_type_not_a_number: "`%{name}` 型はサポートされていません。サポートしている型は `integer`、`bool`、`list`、`enum`、`upload`です。" - name_too_long: "長すぎる名前の設定があります。長さの最大値は255です。" - default_value_missing: "設定%{name}はデフォルト値を持っていません" - default_not_match_type: "設定%{name}のデフォルト値の型が、設定の型と合致しません" - default_out_range: "`%{name}`のデフォルト値は、指定された範囲ではありません。" - enum_value_not_valid: "選択された値は列挙された選択しの一つではありません。" + invalid_yaml: "入力された YAML は無効です。" + data_type_not_a_number: "設定 `%{name}` の型はサポートされていません。サポートされている型は `integer`、`bool`、`list`、`enum`、`upload` です" + name_too_long: "名前が長すぎる設定があります。最大長は 255 です" + default_value_missing: "設定 `%{name}` にデフォルト値がありません" + default_not_match_type: "設定 `%{name}` のデフォルト値の型が設定の型に一致していません。" + default_out_range: "設定 `%{name}` のデフォルト値は指定された範囲内にありません。" + enum_value_not_valid: "選択された値は列挙型の選択肢に含まれていません。" number_value_not_valid: "新しい値は許可された範囲を超えています。" - number_value_not_valid_min_max: "%{min}と%{max}の間になければなりません。" - number_value_not_valid_min: "%{min}以上でなければなりません。" - number_value_not_valid_max: "%{max}以下でなければなりません。" - string_value_not_valid: "新しい値の長さが許可された範囲を超えています。" - string_value_not_valid_min_max: "%{min}文字以上%{max}文字以下でなければいけません。" - string_value_not_valid_min: "少なくとも%{min}文字は必要です。" - string_value_not_valid_max: "%{max}文字以下でなければいけません。" + number_value_not_valid_min_max: "%{min} と %{max} の間である必要があります。" + number_value_not_valid_min: "%{min} 以上である必要があります。" + number_value_not_valid_max: "%{max} 以下である必要があります。" + string_value_not_valid: "新しい値の長さは許可された範囲を超えています。" + string_value_not_valid_min_max: "%{min} 文字以上 %{max} 文字以下である必要があります。" + string_value_not_valid_min: "%{min} 文字以上である必要があります。" + string_value_not_valid_max: "%{max} 文字以下である必要があります。" locale_errors: - invalid_yaml: "翻訳YAMLが無効です" + invalid_yaml: "翻訳 YAML が無効です" emails: incoming: - default_subject: "このトピックはタイトルが必要です。" - show_trimmed_content: "続きを読む" + default_subject: "このトピックはタイトルが必要です" + show_trimmed_content: "続きを表示" no_subject: "(件名なし)" - no_body: "(内容なし)" + no_body: "(本文なし)" errors: - empty_email_error: "受け取ったメールが空白だったときに発生します。" + empty_email_error: "受け取ったメールが空白である場合に発生します。" no_message_id_error: "メールに 'Message-Id' ヘッダーがない場合に発生します。" - auto_generated_email_error: "'precedence'ヘッダーにlist、junk、bulk、auto_replyが設定されている場合、または他のヘッダーに auto-submitted、auto-replied、auto-generatedが含まれている場合に発生します。" - no_body_detected_error: "本文を展開できず、添付ファイルがない場合に発生します。" - no_sender_detected_error: "Fromヘッダーに有効なメールアドレスが見つからなかった場合に発生します。" - from_reply_by_address_error: "Fromヘッダーが返信のメールアドレスと一致する場合に発生します。" + auto_generated_email_error: "'precedence' ヘッダーに list、junk、bulk、auto_reply が設定されている場合、または他のヘッダーに auto-submitted、auto-replied、auto-generated が含まれている場合に発生します。" + no_body_detected_error: "本文を抽出できず、添付ファイルがない場合に発生します。" + no_sender_detected_error: "From ヘッダーに有効なメールアドレスが見つからなかった場合に発生します。" + from_reply_by_address_error: "From ヘッダーが reply by のメールアドレスと一致する場合に発生します。" inactive_user_error: "送信者がアクティブでない場合に発生します。" - silenced_user_error: "送信者が沈黙したときに発生します。" - bad_destination_address: "To/Ccフィールドのいずれのメールアドレスも、設定された受信メールアドレスと一致しない場合に発生します。" + silenced_user_error: "送信者が投稿禁止になっている場合に発生します。" + bad_destination_address: "宛先と CC フィールドのいずれのメールアドレスも、設定された受信メールアドレスと一致しない場合に発生します。" strangers_not_allowed_error: "ユーザーがメンバーではないカテゴリに新しいトピックを作成しようとしたときに発生します。" - insufficient_trust_level_error: "必要なトラストレベルを持たないユーザーが新しいトピックを作成しようとしたときに発生します。" - bounced_email_error: "Eメールは返送されたEメールレポートです。" - screened_email_error: "送信者の電子メールアドレスがすでにブロックされている場合に発生します。" - unrecognized_error: "認識されていないエラー" + insufficient_trust_level_error: "必要な信頼レベルを持たないユーザーが新しいトピックを作成しようとしたときに発生します。" + bounced_email_error: "メールはバウンスメールのレポートです。" + screened_email_error: "送信者のメールアドレスがすでにスクリーン対象である場合に発生します。" + unrecognized_error: "不明なエラー" view_redacted_media: "メディアを表示" errors: &errors format: ! "%{attribute} %{message}" - format_with_full_message: "%{attribute}%{message}" + format_with_full_message: "%{attribute}: %{message}" messages: - too_long_validation: "は、最大文字数(%{max}文字)を超えています。(入力したのは%{length}文字 ) " - invalid_boolean: "無効な選択肢" - taken: "は既に使用されています" + too_long_validation: "は、最大 %{max} 文字に制限されていますが、%{length} 文字が入力されました。" + invalid_boolean: "無効なブール値。" + taken: "はすでに使用されています" accepted: に同意する必要があります blank: を入力してください present: は入力しないでください - confirmation: ! "と%{attribute}の入力が一致しません" - empty: 本文が入力されていません - equal_to: は%{count}にしてください - even: は偶数にしてください - exclusion: はすでに使われています - greater_than: は%{count}より大きい値にしてください - greater_than_or_equal_to: は%{count}以上の値にしてください - has_already_been_used: "は既に使用されています" - inclusion: は一覧にありません - invalid: は正しくありません - is_invalid: "文章に不適切な文字、または文字列があるようです。\n文章をもう一度確認してやり直してください。" + confirmation: ! "は %{attribute} に一致していません" + empty: 空にできません + equal_to: は %{count} である必要があります + even: は偶数である必要があります + exclusion: は予約されています + greater_than: は %{count} より大きい値である必要があります + greater_than_or_equal_to: は %{count} 以上である必要があります + has_already_been_used: "はすでに使用されています" + inclusion: はリストに含まれていません + invalid: は無効です + is_invalid: "は曖昧です。完全な文ですか?" invalid_timezone: "'%{tz}' は有効なタイムゾーンではありません" - contains_censored_words: "次の検閲された単語が含まれています: %{censored_words}" - less_than: は%{count}より小さい値にしてください - less_than_or_equal_to: は%{count}以下の値にしてください - not_a_number: は数値で入力してください - not_an_integer: は整数で入力してください - odd: は奇数にしてください - record_invalid: ! "検証に失敗しました: %{errors}" - emojis_disabled: "絵文字を持っていません" - ip_address_already_screened: "すでに存在するルールを含んでいます" + contains_censored_words: "次の検閲された単語が含まれています: %{censored_words}" + less_than: は %{count} より小さい値である必要があります + less_than_or_equal_to: は %{count} 以下の値である必要があります + not_a_number: は数値ではありません + not_an_integer: は整数である必要があります + odd: は奇数である必要があります + record_invalid: ! "検証に失敗しました: %{errors}" + emojis_disabled: "は絵文字を使用できません" + ip_address_already_screened: "はすでに既存のルールに含まれています" restrict_dependent_destroy: - one: "%{record}が存在しているので削除できません" - many: "%{record}が存在しているので削除できません。" + one: "依存する %{record} が存在するため、レコードを削除できません" + many: "依存する %{record} が存在するため、レコードを削除できません" too_long: - other: は%{count}文字以内で入力してください + other: は長すぎます (最大 %{count} 文字です) too_short: - other: は%{count}文字以上で入力してください + other: は短すぎます (最低 %{count}文字です) wrong_length: - other: は%{count}文字で入力してください - other_than: "は%{count}以外の値にしてください" + other: は %{count} 文字である必要があります + other_than: "は %{count} 以外の値である必要があります" template: body: ! "次のフィールドに問題があります:" header: - other: ! "%{model} の保存中に%{count} 個のエラーが発生しました" + other: ! "%{count} 個のエラーによりこの %{model} を保存できませんでした" embed: - load_from_remote: "投稿の読み込みに失敗しました。" + load_from_remote: "投稿を読み込み中にエラーが発生しました。" site_settings: - default_categories_already_selected: "他のリストで使われているカテゴリーは選択できません。" - s3_upload_bucket_is_required: "'s3_upload_bucket'がない場合、S3にアップロードを有効にすることはできません。" + default_categories_already_selected: "他のリストで使われているカテゴリは選択できません。" + s3_upload_bucket_is_required: "'s3_upload_bucket' を入力しない限り、S3 へのアップロードを有効にすることはできません。" invite: - user_exists: "%{email}を招待する必要はありません。 すでにアカウントを持っています!" + user_exists: "%{email} を招待する必要はありません。 のアカウントはすでに存在します!" disabled_errors: - invalid_access: "リクエストしたリソースの閲覧が許可されていません" + invalid_access: "リクエストしたリソースの閲覧が許可されていません。" bulk_invite: - file_should_be_csv: "アップロードファイルはcsv形式である必要があります。" - error: "アップロードファイルにエラーが発生しました。時間が経ってから再度お試しください。" + file_should_be_csv: "アップロードファイルは csv 形式である必要があります。" + error: "ファイルをアップロード中にエラーが発生しました。後でもう一度お試しください。" topic_invite: - user_exists: "ごめんなさい、そのユーザーはすでに招待されています。一つのトピックにつき一度しかユーザーを招待できません。" - sender_does_not_allow_pm: "申し訳ありませんが、そのユーザーがプライベートメッセージを送信することを許可していません。" + user_exists: "そのユーザーはすでに招待されています。ユーザーを一度しかトピックに招待できません。" + sender_does_not_allow_pm: "そのユーザーがプライベートメッセージを送信することを許可していません。" backup: - operation_already_running: "操作を実行しています。他の操作はできません。" - backup_file_should_be_tar_gz: "バックアップファイルは .tar.gz形式である必要があります。" - not_enough_space_on_disk: "このバックアップファイルをアップロードするディスクの空き容量が足りません。" - invalid_filename: "バックアップファイル名に無効な文字が含まれています。有効な文字はa-z 0-9 . - _ です。" - file_exists: "アップロードしようとしているファイルはすでに存在しています。" - not_logged_in: "ログインしてください。" - not_found: "リクエストされたURL、リソースは見つかりませんでした" - invalid_access: "リクエストしたリソースの閲覧が許可されていません" - read_only_mode_enabled: "このサイトは閲覧専用状態です。変更などの操作は無効になっています。" + operation_already_running: "作業を実行中です。新しいジョブを開始できません。" + backup_file_should_be_tar_gz: "バックアップファイルは .tar.gz 形式のアーカイブである必要があります。" + not_enough_space_on_disk: "このバックアップをアップロードするディスクの空き容量が足りません。" + invalid_filename: "バックアップファイル名に無効な文字が含まれています。有効な文字は a-z 0-9 . - _ です。" + file_exists: "アップロードしようとしているファイルはすでに存在します。" + not_logged_in: "この操作を行うにはログインしてください。" + not_found: "リクエストされた URL またはリソースは見つかりませんでした。" + invalid_access: "リクエストしたリソースの閲覧が許可されていません。" + read_only_mode_enabled: "このサイトは閲覧専用状態です。操作は無効になっています。" not_in_group: join_group: "グループに参加" reading_time: "閲覧時間" likes: "いいね!" too_many_replies: - other: "申し訳ありません、新しいユーザーの同じトピックへの返信は、一時的に %{count} 回に制限されています。" + other: "新しいユーザーの同じトピックへの返信は、一時的に %{count} 回に制限されています。" max_consecutive_replies: - other: " %{count}回を超える連続の返信は禁止されています。以前の返信を編集するか、他の人の返信をお待ちください。" + other: "%{count} 回を超える連続の返信は禁止されています。前の返信を編集するか、他の人の返信をお待ちください。" embed: - start_discussion: "会話をする" - continue: "会話を続ける" + start_discussion: "ディスカッションを開始" + continue: "ディスカッションを続行" referer: "リファラー:" more_replies: - other: "%{count} 以上の返信" - loading: "会話を読み込んでいます…" + other: "他 %{count} 件の返信" + loading: "ディスカッションを読み込み中…" permalink: "パーマリンク" - imported_from: "これは、オリジナルエントリ%{link}に対するディスカッショントピックです" + imported_from: "これは、元のエントリ (%{link}) に関連するディスカッショントピックです" in_reply_to: "▶ %{username}" replies: - other: "%{count}件のリプライ" - created: "作成者" - no_mentions_allowed: "あなたは他のユーザーへメンションを送ることができません。" - no_mentions_allowed_newuser: "申し訳ありませんが、新規ユーザはこのポストにおいて他のユーザをタグ付けできません。" - no_attachments_allowed: "申し訳ありませんが、新規ユーザはこのポストにファイルを添付できません。" + other: "返信: %{count} 件" + created: "作成" + no_mentions_allowed: "他のユーザーをメンションできません。" + no_mentions_allowed_newuser: "新規ユーザーは他のユーザーをメンションできません。" + no_attachments_allowed: "新規ユーザーは投稿に添付ファイルを追加できません。" too_many_attachments: - other: "ごめんなさい、新しいユーザーは一つのポストにつき%{count}個のファイルしか貼り付けられません。" - no_links_allowed: "申し訳ありませんが、新規ユーザはこのポストにリンクを貼れません。" - links_require_trust: "ごめんなさい、ポストにリンクを含めることはできません。" + other: "新規ユーザーは投稿につき %{count} 個のファイルしか添付できません。" + no_links_allowed: "新規ユーザーは投稿にリンクを追加できません。" + links_require_trust: "投稿にリンクを含めることはできません。" too_many_links: - other: "ごめんなさい、新しいユーザーは一つのポストにつき%{count}個のリンクしか貼り付けられません。" - contains_blocked_word: "ポストに許可されていない言葉が含まれています: %{word}" - spamming_host: "申し訳ありませんが、このホストへのリンクを貼ることはできません。" + other: "新規ユーザーは投稿につき %{count} 件のリンクしか追加できません。" + contains_blocked_word: "投稿に許可されていない言葉が含まれています: %{word}" + spamming_host: "このホストへのリンクを投稿できません。" user_is_suspended: "凍結中のユーザーは投稿できません。" topic_not_found: "問題が発生しました。トピックがクローズしたか、閲覧中に削除された可能性があります。" - just_posted_that: "は最近の投稿と内容がほぼ一緒です" - invalid_characters: "は不正な文字を含んでいます" - is_invalid: "文章に不適切な文字、または文字列があるようです。\n文章をもう一度確認してやり直してください。" + just_posted_that: "は最近の投稿に非常に似ています" + invalid_characters: "に無効な文字が含まれています" + is_invalid: "は曖昧です。完全な文ですか?" next_page: "次のページ →" prev_page: "← 前のページ" page_num: "%{num} ページ" @@ -229,30 +229,30 @@ ja: rss_posts_in_topic: "'%{topic}' の RSS フィード" rss_topics_in_category: "'%{category}' カテゴリ内のトピックの RSS フィード" rss_num_posts: - other: "%{count} 件" - read_full_topic: "完全なトピックを読む" + other: "投稿 %{count} 件" + read_full_topic: "全トピックを読む" private_message_abbrev: "メッセージ" rss_description: latest: "最新のトピック" - top: "トップトピック" - top_all: "すべての期間のトップトピック" - top_yearly: "年間のトップトピック" - top_quarterly: "四半期のトップトピック" - top_monthly: "月間のトップトピック" - top_weekly: "今週のトップトピック" - top_daily: "今日のトップトピック" - posts: "最近の投稿" + top: "人気のトピック" + top_all: "全期間の人気のトピック" + top_yearly: "年間の人気のトピック" + top_quarterly: "四半期の人気のトピック" + top_monthly: "月間の人気のトピック" + top_weekly: "今週の人気のトピック" + top_daily: "今日の人気のトピック" + posts: "最新の投稿" private_posts: "最新の個人メッセージ" - group_posts: "%{group_name}からの最新投稿" - group_mentions: "%{group_name}からの最新メンション" - user_posts: "@%{username}からの最新投稿" - user_topics: "@%{username}からの最新トピック" - tag: "タグされたトピック" - too_late_to_edit: "この投稿の編集・削除期間が過ぎました。編集・削除が出来ません。" + group_posts: "%{group_name} の最新の投稿" + group_mentions: "%{group_name} の最新のメンション" + user_posts: "@%{username} の最新の投稿" + user_topics: "@%{username} の最新のトピック" + tag: "タグ付きトピック" + too_late_to_edit: "この投稿はかなり前に作成されました。編集または削除はできなくなりました。" excerpt_image: "画像" bookmarks: reminders: - later_today: "今日中" + later_today: "今日の後程" next_business_day: "翌営業日" tomorrow: "明日" next_week: "来週" @@ -260,30 +260,28 @@ ja: custom: "カスタムの日付と時刻" groups: errors: - invalid_incoming_email: "%{email}のメール認証に失敗しました\U0001F62D" - email_already_used_in_group: "'%{email}'はすでに'%{group_name}'グループに使われています。" - email_already_used_in_category: "'%{email}'はすでに'%{category_name}'カテゴリーに使われています。" + invalid_incoming_email: "%{email} は有効なメールアドレスではありません。" + email_already_used_in_group: "'%{email}' はすでに '%{group_name}' グループに使われています。" + email_already_used_in_category: "'%{email}' はすでに '%{category_name}' カテゴリに使われています。" default_names: - everyone: "みなさん" + everyone: "全員" admins: "管理者" - moderators: "モデレータ" + moderators: "モデレーター" staff: "スタッフ" - trust_level_0: "トラストレベル0" - trust_level_1: "トラストレベル1" - trust_level_2: "トラストレベル2" - trust_level_3: "トラストレベル3" - trust_level_4: "トラストレベル4" + trust_level_0: "trust_level_0" + trust_level_1: "trust_level_1" + trust_level_2: "trust_level_2" + trust_level_3: "trust_level_3" + trust_level_4: "trust_level_4" education: until_posts: - other: "%{count} 投稿" + other: "投稿 %{count} 件" too_many_replies: | ### このトピックへの返信数の制限に達しました - 新しいユーザーは、同じトピックへの返信数が%{newuser_max_replies_per_topic}件に一時的に制限されています。 + 新しいユーザーの同じトピックへの返信は、一時的に %{newuser_max_replies_per_topic} 件に制限されています。 - 返信を追加する代わりに、次に手段を検討してください。 - ・これまでの返信を編集する。 - ・他のトピックを訪れる。 + 返信を追加する代わりに、前の返信を編集するかほかのトピックにアクセスしてください。 activerecord: attributes: category: @@ -300,60 +298,60 @@ ja: topic: attributes: base: - too_many_users: "一度に1ユーザーにしか警告を送る事はできません" - no_user_selected: "有効なユーザーを選択してください" + too_many_users: "一度に 1 人のユーザーにのみ警告を送ることができます。" + no_user_selected: "有効なユーザーを選択してください。" user: attributes: password: - common: "は10000最も一般的なパスワードのいずれかである。より安全なパスワードを使用してください。" - same_as_username: "はあなたのユーザー名と同じです。より安全なパスワードを使用してください" - same_as_email: "はあなたのメールアドレスと同じです。より安全なパスワードを使用してください" + common: "は 10000 個の最も一般的なパスワードの 1 つです。より安全なパスワードを使用してください。" + same_as_username: "はあなたのユーザー名と同じです。より安全なパスワードを使用してください。" + same_as_email: "はあなたのメールアドレスと同じです。より安全なパスワードを使用してください。" same_as_current: "はあなたの現在のパスワードと同じです。" - unique_characters: "パスワードに繰り返しが多すぎます。より安全なものを入力してください。" + unique_characters: "パスワードに繰り返しが多すぎます。より安全なものを使用してください。" ip_address: - signup_not_allowed: "このアカウントからのサインアップできません。" + signup_not_allowed: "このアカウントからサインアップできません。" color_scheme_color: attributes: hex: - invalid: "は有効なカラーではありません" + invalid: "は有効な色ではありません" <<: *errors - uncategorized_category_name: "その他" + uncategorized_category_name: "未分類" vip_category_name: "ラウンジ" - vip_category_description: "トラストレベル3以上のメンバーのみが参加できるカテゴリ" - meta_category_name: "サイトフィードバック" - meta_category_description: "このサイトに関するディスカッション、この組織、それがどのように機能し、 そして、我々はそれをどのように改善できるでしょうか。" + vip_category_description: "信頼レベル 3 以上のメンバーのみが参加できるカテゴリです。" + meta_category_name: "サイトに関する意見" + meta_category_description: "このサイト、組織、仕組み、および改善に関するディスカッションです。" staff_category_name: "スタッフ" - staff_category_description: "スタッフのためのプライベートカテゴリです。トピックは管理者とモデレータのみが閲覧できます" + staff_category_description: "スタッフディスカッション用の非公開カテゴリです。トピックは管理者とモデレーターのみが閲覧できます。" discourse_welcome_topic: - title: "Discourseへようこそ" + title: "Discourse へようこそ" lounge_welcome: title: "ラウンジへようこそ" category: - topic_prefix: "%{category}カテゴリについて" + topic_prefix: "%{category} カテゴリについて" errors: - uncategorized_parent: "未分類カテゴリは親カテゴリに設定出来ません。" - self_parent: "自分自身がサブカテゴリの親になることはできません" - depth: "他のサブカテゴリの下にサブカテゴリを作成することはできません" - invalid_email_in: "%{email}のメール認証に失敗しました\U0001F62D" - email_already_used_in_group: "'%{email}'はすでに'%{group_name}'グループに使われています。" - email_already_used_in_category: "'%{email}'はすでに'%{category_name}'カテゴリーに使われています。" - description_incomplete: "カテゴリーの説明文は最低でも1段落必要です。" + uncategorized_parent: "未分類カテゴリに親カテゴリを設定できません" + self_parent: "サブカテゴリの親をそのサブカテゴリに設定できません" + depth: "サブカテゴリの下に別のサブカテゴリを作成することはできません" + invalid_email_in: "%{email} は有効なメールアドレスではありません。" + email_already_used_in_group: "'%{email}' はすでに '%{group_name}' グループに使われています。" + email_already_used_in_category: "'%{email}' はすでに '%{category_name}' カテゴリに使われています。" + description_incomplete: "カテゴリの説明の投稿には少なくとも 1 段落必要です。" cannot_delete: - has_subcategories: "サブカテゴリを持っているため、このカテゴリを削除できません。" - topic_exists_no_oldest: "%{count}個のトピックを持っているため、このカテゴリを削除できません。" - uncategorized_description: "カテゴリーを必要としない、または既存のカテゴリーにマッチしないトピックです。" + has_subcategories: "サブカテゴリがあるためこのカテゴリを削除できません。" + topic_exists_no_oldest: "%{count} 件のトピックがあるためこのカテゴリを削除できません。" + uncategorized_description: "カテゴリが不要なトピック、または既存のカテゴリに当てはまらないトピックです。" trust_levels: - admin: "管理設定" + admin: "管理者" staff: "スタッフ" - change_failed_explanation: "%{user_name} を '%{new_trust_level}' に下げようとしましたが、既にトラストレベルが '%{current_trust_level}' です。%{user_name} は '%{current_trust_level}' のままになります - もしユーザーを降格させたい場合は、トラストレベルをロックしてください" + change_failed_explanation: "%{user_name} を '%{new_trust_level}' に降格しようとしましたが、既に信頼レベルは '%{current_trust_level}' になっています。%{user_name} は '%{current_trust_level}' のままになります。ユーザーを降格する場合は、先に信頼レベルをロックしてください" post: image_placeholder: - broken: "この画像は表示されません" + broken: "この画像は破損しています" rate_limiter: - slow_down: "アクションの制限により一定時間の制限を受けています。後ほどお試しください。" - too_many_requests: "アクション制限によっていって時間の制限を受けています。%{time_left}お待ちください" + slow_down: "この操作の実行回数が多すぎます。後でもう一度お試しください。" + too_many_requests: "この操作の実行回数が多すぎます。%{time_left}経ってからもう一度お試しください。" by_type: - unsubscribe_via_email: "メールから不登録の上限に達しました。どうか%{time_left}後に再度お試しください!" + unsubscribe_via_email: "メールでの購読解除数の上限に達しました。%{time_left}経ってからもう一度お試しください。" hours: other: "%{count} 時間" minutes: @@ -363,107 +361,107 @@ ja: short_time: "数分" datetime: distance_in_words: - half_a_minute: "1分前" + half_a_minute: "1 分未満" less_than_x_seconds: - other: "< %{count} 秒" + other: "%{count} 秒未満" x_seconds: other: "%{count} 秒" less_than_x_minutes: - other: "%{count}分前" + other: "%{count} 分未満" x_minutes: - other: "%{count}分" + other: "%{count} 分" about_x_hours: other: "%{count} 時間" x_days: other: "%{count} 日" about_x_months: - other: "%{count}ヶ月" + other: "%{count} か月" x_months: - other: "%{count}ヶ月" + other: "%{count} か月" about_x_years: other: "%{count} 年" over_x_years: - other: "> %{count} 年" + other: "%{count} 年以上" almost_x_years: other: "%{count} 年" distance_in_words_verbose: half_a_minute: "たった今" - less_than_x_seconds: "現在" + less_than_x_seconds: "たった今" x_seconds: other: "%{count} 秒前" less_than_x_minutes: - other: "%{count}分未満" + other: "%{count} 分ほど前" x_minutes: - other: "%{count}分前" + other: "%{count} 分前" about_x_hours: other: "%{count} 時間前" x_days: other: "%{count} 日前" about_x_months: - other: "約 %{count}ヶ月前" + other: "約 %{count} か月前" x_months: - other: "%{count}ヶ月前" + other: "%{count} か月前" about_x_years: other: "約 %{count} 年前" over_x_years: other: "%{count} 年以上前" almost_x_years: - other: "だいたい %{count} 年前" + other: "ほぼ %{count} 年前" password_reset: - no_token: "申し訳ありません。パスワード変更のためのリンクは古いようです。ログインボタンを選択し、再度パスワード変更を行ってください" + no_token: "パスワード変更用のリンクは古すぎます。ログインボタンを選択し、「パスワードを忘れました」を使って新しいリンクを取得してください。" choose_new: "新しいパスワードを選択する" choose: "パスワードを選択する" - update: "パスワード更新" - save: "パスワードを設定する" - title: "パスワードリセット" - success: "パスワードを更新し、ログインしました。" - success_unapproved: "パスワードを更新しました。" + update: "パスワードを更新" + save: "パスワードを設定" + title: "パスワードをリセット" + success: "パスワードを変更し、ログインしました。" + success_unapproved: "パスワードを変更しました。" email_login: - invalid_token: "ごめんなさい、このメールに送られたログインリンクは古すぎます。ログインボタンを選択し、再度パスワード変更を行なってください。" + invalid_token: "このメールに送られたログインリンクは古すぎます。ログインボタンを選択し、「パスワードを忘れました」を使って新しいリンクを取得してください。" title: "メールログイン" change_email: confirmed: "メールアドレスが更新されました。" - please_continue: "%{site_name}へ" + please_continue: "%{site_name} に進む" error: "メールアドレスの変更中にエラーが発生しました。このアドレスはすでに使われている可能性があります。" - error_staged: "メールアドレスの変更中にエラーが発生しました。このアドレスはすでに使われている可能性があります。" - already_done: "申し訳ありませんが、この確認リンクは有効ではありません。既にあなたのメールは変更されていませんか?" + error_staged: "メールアドレスの変更中にエラーが発生しました。このアドレスはすでにステージングユーザーに使われている可能性があります。" + already_done: "この確認リンクは有効ではありません。すでにメールアドレスが変更されている可能性があります。" activation: - action: "クリックしてアカウントを認証する" - already_done: "申し訳ありませんが、このアカウント認証リンクは無効です。既にアカウントがアクティブになっていませんか?" - please_continue: "あなたのアカウントは確認されました。ホームにリダイレクトされます" - continue_button: "%{site_name} へ" - welcome_to: "%{site_name} へようこそ!" - approval_required: "このフォーラムにアクセスするにはモデレータによる承認が必要です。承認されるとメールにて通知されます!" + action: "クリックしてアカウントを確認する" + already_done: "このアカウントの確認リンクは無効です。すでにアカウントがアクティブになっている可能性があります。" + please_continue: "あなたのアカウントは確認されました。ホームページにリダイレクトされます。" + continue_button: "%{site_name} に進む" + welcome_to: "%{site_name} へようこそ!" + approval_required: "このフォーラムにアクセスするにはモデレーターによる承認が必要です。承認されるとメールで通知されます!" missing_session: "アカウントが作成されたかどうか検出できません。Cookieを有効にしてください。" - activated: "このアカウントはすでにアクティベートされています。" + activated: "このアカウントはすでに確認済みです。" admin_confirm: - title: "管理アカウントの確認" + title: "管理者アカウントの確認" reviewable_score_types: needs_approval: title: "承認待ち" post_action_types: off_topic: - title: "関係ない話題" - description: "この投稿はタイトルと投稿で定義される現在の内容に関連しておらず、どこかに移動させる必要があります" + title: "関係のないトピック" + description: "この投稿はタイトルと最初の投稿で定義される現在のディスカッションに関連していないため、別の場所に移動する必要があります。" short_description: "ディスカッションに関連がない" spam: - title: "スパム" + title: "迷惑" description: "この投稿は広告、または悪質行為です。 現在のトピックとは関連性がありません。" - short_description: "営利目的か破壊行為の可能性" - email_title: '"%{title}"はスパムとして通報されています' + short_description: "これは広告または悪質行為です" + email_title: '"%{title}" は迷惑として通報されています' email_body: "%{link}\n\n%{message}" inappropriate: title: "不適切" notify_user: - title: "@%{username}へメッセージを送る" - description: "この投稿について直接話がしたいです。" - short_description: "彼らの投稿に対して直接話したいです。" - email_title: '「%{title}」にの投稿' + title: "@%{username} にメッセージを送る" + description: "この投稿についてこのユーザーと直接話すことを希望します。" + short_description: "この投稿についてこのユーザーと直接話すことを希望します。" + email_title: '「%{title}」の投稿について' email_body: "%{link}\n\n%{message}" notify_moderators: title: "その他" description: "この投稿は上記以外の理由でスタッフの注意が必要です。" - short_description: "その他の理由で管理人による確認が必要" + short_description: "その他の理由でスタッフの注意が必要" email_body: "%{link}\n\n%{message}" bookmark: title: "ブックマーク" @@ -475,87 +473,87 @@ ja: short_description: "この投稿を「いいね!」する" user_activity: no_default: - self: "まだアクティビティーがありません" - others: "アクティビティーなし" + self: "まだアクティビティがありません。" + others: "アクティビティがありません。" no_bookmarks: self: "ブックマークした投稿はありません。ブックマークを使用すると、特定の投稿を素早く参照できます。" - others: "ブックマークはありません" + others: "ブックマークはありません。" no_likes_given: - self: "いいね!はしていません。" - others: "いいね!した投稿はありません。" + self: "「いいね!」した投稿はありません。" + others: "「いいね!」した投稿はありません。" no_replies: - self: "まだ投稿に対して返信していません。" + self: "まだ投稿に返信していません。" others: "返信はありません。" topic_flag_types: spam: - title: "スパム" - description: "このトピックは広告です。これは、このサイトに有益ではないまたは関連していない、事実上の広告です。" - long_form: "スパムとして通報" - short_description: "広告です" + title: "迷惑" + description: "このトピックは広告です。これは、このサイトに有益ではないまたは関連していない事実上の広告です。" + long_form: "迷惑として通報" + short_description: "これは広告です" inappropriate: title: "不適切" description: 'このトピックには一般的な人が攻撃的、虐待的、またはコミュニティガイドラインに違反すると見なすコンテンツが含まれています。' - long_form: "不適切として通報する" + long_form: "不適切として通報" notify_moderators: title: "その他" description: 'このトピックはガイドライン、利用規約または上記に記載されていない別の理由で、一般のスタッフによる注意が必要です。' - long_form: "モデレーターへ知らせるために通報する" - short_description: "その他の理由で管理人による確認が必要" - email_title: 'トピック"%{title}" は不適切な可能性があるため、管理人による確認を必要とする。' + long_form: "モデレーターの注意要として通報" + short_description: "その他の理由でスタッフによる注意が必要" + email_title: 'トピック "%{title}" はモデレーターの注意が必要です' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

      投稿が他のユーザーから通報されました。投稿したメッセージを確認してください。

      ' - user_must_edit: "

      この投稿は他のユーザーから通報されたため、非表示にされています。

      " + you_must_edit: '

      あなたの投稿はコミュニティから通報されました。投稿したメッセージを確認してください。

      ' + user_must_edit: "

      この投稿はコミュニティから通報されたため、一時的に非表示にされています。

      " archetypes: regular: title: "通常のトピック" banner: title: "バナートピック" message: - make: "このトピックはバナートピックに設定されています。ユーザーが解除しない限り、ページ毎の一番上に表示されます" - remove: "このトピックはバナーではなくなりました。トップページには表示されなくなります" + make: "このトピックはバナーに設定されました。ユーザーが閉じるまで各ページの上部に表示されます。" + remove: "このトピックはバナーではなくなりました。各ページの上部に表示されなくなります。" unsubscribed: - title: "メール設定を更新しました!" - description: " %{email}のメール設定は更新されました。変更するには あなたのユーザー設定にアクセスしてください。" - topic_description: "%{link}を再配信するために、トピックの右か下の通知コントロールを使用してください。" - private_topic_description: "再配信するために、トピックの右か下の通知コントロールを使用してください。" + title: "メールの設定を更新しました!" + description: "%{email} のメールの設定が更新されました。変更するにはあなたのユーザー設定にアクセスしてください。" + topic_description: "%{link} を購読し直すには、トピックの下または右にある通知の管理を使用してください。" + private_topic_description: "購読し直すには、トピックの下か右にある通知の管理を使用してください。" unsubscribe: - title: "配信停止" - stop_watching_topic: "%{link}内のトピックのウォッチを停止" - mute_topic: "%{link}通知をミュートにする" - unwatch_category: "%{category}内の全トピックのウォッチを解除" - mailing_list_mode: "メーリンリストモードを無効にする" - different_user_description: "電子メールで送信したものとは別のユーザーとしてログインしています。 ログアウトするか、匿名モードになり、もう一度やり直してください。" + title: "購読解除" + stop_watching_topic: "%{link} のトピックのウォッチを停止する" + mute_topic: "%{link} のトピックのすべての通知をミュートにする" + unwatch_category: "%{category} 内のすべてのトピックのウォッチを停止する" + mailing_list_mode: "メーリンリストモードをオフにする" + different_user_description: "メールで送信したものとは異なるユーザーとしてログインしています。 ログアウトするか、匿名モードを開始して、もう一度お試しください。" log_out: "ログアウト" digest_frequency: - never: "追跡しない" - every_30_minutes: "30分毎" - every_hour: "1時間毎" + never: "送信しない" + every_30_minutes: "30 分毎" + every_hour: "1 時間毎" daily: "毎日" weekly: "毎週" every_month: "毎月" - every_six_months: "6ヶ月毎" + every_six_months: "6 か月毎" user_api_key: - title: "アプリケーションアクセスの認証" - authorize: "認証" - read: "既読" - read_write: "既読/書く" - description: '"%{application_name}" があなたのアカウントへのアクセスを要求しています: ' + title: "アプリケーションアクセスの承認" + authorize: "承認" + read: "読み取り" + read_write: "読み取り/書き込み" + description: '"%{application_name}" があなたのアカウントへの次のアクセス権を要求しています:' otp_confirmation: - confirm_title: '%{site_name}へ' + confirm_title: '%{site_name} に進む' logging_in_as: '%{username} としてログイン' confirm_button: ログイン完了 - no_trust_level: "申し訳ありませんが、ユーザーAPIへのアクセスはできません。" - generic_error: "申し訳ありませんが、この機能は管理者が使用できなくしています。" + no_trust_level: "ユーザー API にアクセスするために必要な信頼レベルがありません。" + generic_error: "ユーザー API キーを発行できませんでした。この機能はサイト管理者によって無効化されている可能性があります。" scopes: message_bus: "ライプアップデート" - notifications: "全ての未読通知を既読にします" - session_info: "ユーザー情報を読み取ります" - read: "全て既読にする" + notifications: "通知を読み取ってクリアする" + session_info: "ユーザーセッション情報を読み取る" + read: "すべて読み取る" reports: default: labels: - count: カウント + count: 件数 day: 日 post_edits: labels: @@ -569,12 +567,12 @@ ja: score: スコア moderators_activity: labels: - moderator: モデレータ + moderator: モデレーター flags_status: labels: flag: タイプ visits: - title: "アクセスユーザー" + title: "ユーザーアクセス" xaxis: "日" yaxis: "アクセス数" signups: @@ -605,12 +603,12 @@ ja: posts: title: "投稿" xaxis: "日" - yaxis: "新規投稿数" + yaxis: "新しい投稿の数" likes: - title: "いいね" + title: "いいね!" xaxis: "日" - yaxis: "新規「いいね!」数" - description: "新規「いいね!」数." + yaxis: "新しい「いいね!」の数" + description: "新しい「いいね!」の数です。" flags: title: "通報" xaxis: "日" @@ -618,10 +616,10 @@ ja: bookmarks: title: "ブックマーク" xaxis: "日" - yaxis: "新規ブックマーク数" + yaxis: "新しいブックマークの数" users_by_trust_level: - title: "ユーザーごとのトラストレベル" - xaxis: "トラストレベル" + title: "信頼レベル別ユーザー" + xaxis: "信頼レベル" yaxis: "ユーザー数" users_by_type: xaxis: "タイプ" @@ -630,36 +628,36 @@ ja: type: タイプ xaxis_labels: admin: 管理者 - moderator: モデレータ + moderator: モデレーター suspended: 凍結中 emails: - title: "送信メール" + title: "送信されたメール" xaxis: "日" - yaxis: "メールの数" + yaxis: "メール件数" user_to_user_private_messages: xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" user_to_user_private_messages_with_replies: xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" system_private_messages: title: "システム" xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" moderator_warning_private_messages: - title: "モデレータ警告" + title: "モデレーター警告" xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" notify_moderators_private_messages: title: "管理人に通知" xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" notify_user_private_messages: - title: "ユーザーの通知" + title: "ユーザーに通知" xaxis: "日" - yaxis: "メッセージ数" + yaxis: "メッセージ件数" top_referrers: - title: "トップリファラ" + title: "上位リファラー" xaxis: "ユーザー" num_clicks: "クリック" num_topics: "トピック" @@ -668,7 +666,7 @@ ja: num_clicks: "クリック" num_topics: "トピック" top_traffic_sources: - title: "トップトラフィックソース" + title: "上位トラフィックソース" xaxis: "ドメイン" num_clicks: "クリック" num_topics: "トピック" @@ -678,46 +676,46 @@ ja: num_clicks: クリック num_topics: トピック top_referred_topics: - title: "トップ被参照トピック" + title: "人気の参照先トピック" labels: num_clicks: "クリック" topic: "トピック" page_view_anon_reqs: title: "匿名ユーザー" xaxis: "日" - yaxis: "匿名ユーザーからの閲覧数" + yaxis: "匿名のページビュー" page_view_logged_in_reqs: title: "ログイン" xaxis: "日" - yaxis: "ログインユーザーからの閲覧数" + yaxis: "ログインユーザーのページビュー" page_view_crawler_reqs: - title: "ウェブクローラからの閲覧数" + title: "ウェブクローラーのページビュー" xaxis: "日" - yaxis: "ウェブクローラからの閲覧数" + yaxis: "ウェブクローラーのページビュー" page_view_total_reqs: - title: "PV" + title: "ページビュー" xaxis: "日" - yaxis: "合計閲覧数" + yaxis: "合計ページビュー" page_view_logged_in_mobile_reqs: - title: "ログインユーザーからの閲覧数" + title: "ログインユーザーのページビュー" xaxis: "日" - yaxis: "モバイルでログインしているユーザーからの閲覧数" + yaxis: "モバイルログインユーザーのページビュー" page_view_anon_mobile_reqs: - title: "匿名ユーザーからの閲覧数" + title: "匿名ページビュー" xaxis: "日" - yaxis: "モバイルで閲覧している匿名ユーザーの閲覧数" + yaxis: "モバイル匿名ページビュー" http_background_reqs: title: "背景" xaxis: "日" - yaxis: "Requests used for live update and tracking" + yaxis: "ライブアップデートと追跡に使用されるリクエスト" http_2xx_reqs: - title: "Status 2xx (OK)" + title: "ステータス 2xx (OK)" xaxis: "日" - yaxis: "成功 (Status 2xx)" + yaxis: "成功したリクエスト (Status 2xx)" http_3xx_reqs: title: "HTTP 3xx (リダイレクト)" xaxis: "日" - yaxis: "リダイレクト (Status 3xx)" + yaxis: "リダイレクトリクエスト (Status 3xx)" http_4xx_reqs: title: "HTTP 4xx (クライアントエラー)" xaxis: "日" @@ -727,15 +725,15 @@ ja: xaxis: "日" yaxis: "サーバーエラー (Status 5xx)" http_total_reqs: - title: "全体" + title: "総合" xaxis: "日" - yaxis: "全リクエスト" + yaxis: "合計リクエスト" time_to_first_response: - title: "最初の返答までの時間" + title: "最初の応答までの時間" xaxis: "日" - yaxis: "平均時間(時間)" + yaxis: "平均時間 (時間)" topics_with_no_response: - title: "返答のないトピック" + title: "応答のないトピック" xaxis: "日" yaxis: "合計" mobile_visits: @@ -743,7 +741,7 @@ ja: yaxis: "アクセス数" web_crawlers: labels: - page_views: "PV" + page_views: "ページビュー" suspicious_logins: labels: user: ユーザー @@ -756,639 +754,639 @@ ja: labels: filename: ファイル名 dashboard: - rails_env_warning: "サーバは %{env} モードで起動中です。" + rails_env_warning: "サーバーは %{env} モードで実行中です。" host_names_warning: "現在 config/database.yml ファイルは、デフォルトの localhost をホスト名として使用しています。あなたのサイトのホスト名に更新してください。" - sidekiq_warning: 'Sidekiq が起動していません。メール送信等、多くのタスクが sidekiq により非同期に実行されます。少なくとも sidekiq のプロセスを1つは起動してください。Sidekiq についてはこちらを参考にしてください。' - queue_size_warning: "キューイングされたジョブの数は %{queue_size} で、これは多いです。SIdekiqのプロセスに問題があるか、Sidekiqのworkerを増やす必要があります" - memory_warning: "現在サーバが 1GB 未満の総メモリで起動しています。推奨メモリサイズは 1GB 以上です。" + sidekiq_warning: 'Sidekiq が実行していません。メール送信などの多くのタスクは sidekiq により非同期に実行されます。少なくとも 1 つの sidekiq プロセスが実行していることを確認してください。Sidekiq についてはこちらをご覧ください。' + queue_size_warning: "キューに入れられたジョブの数は %{queue_size} で、これは高い数字です。Sidekiq プロセスに問題があるか、Sidekiq ワーカーを増やす必要があります。" + memory_warning: "現在サーバーは 1 GB 未満の総メモリで実行しています。推奨メモリサイズは 1 GB 以上です。" site_settings: - censored_words: "自動的に ■■■■ で置換されます" - delete_old_hidden_posts: "30日以上非表示になっている投稿を自動で削除します" - allow_user_locale: "ユーザーが言語を選択できるようにする" - min_post_length: "投稿を許可する最少の文字数" - min_first_post_length: "最初の投稿(投稿本文)を許可する最少の文字数" - min_personal_message_post_length: "メッセージに投稿可能な最少の文字数" - max_post_length: "投稿を許可する最大の文字数" - min_topic_title_length: "トピックタイトルとして許可する最小の文字数" - max_topic_title_length: "トピックタイトルとして許可する最大の文字数" - min_personal_message_title_length: "プライベートメッセージのタイトルとして許容する最小の文字数" - min_search_term_length: "検索ワードとして有効にする最小の文字数" - allow_uncategorized_topics: "カテゴリなしのトピック作成を許可するか。警告:未分類のトピックがある場合は、これをオフにする前に、再分類する必要があります。" - allow_duplicate_topic_titles: "トピックタイトルの重複を許可" - unique_posts_mins: "同じ内容の投稿を再投稿可能にする時間 (分)" - educate_until_posts: "最初(または複数)の投稿でタイピングを開始したら、ポップアップでガイダンスを表示させるか" - title: "サイトの名前です。titleタグで使用されます" - site_description: "このサイトについて簡単に説明してください。 descriptionタグで使用されます。" - short_site_description: "ホームページのtitleタグで使用される簡単な説明です。" - crawl_images: "正しい幅と高さを取得するためにURLから画像を取得する" - download_remote_images_to_local: "リモート画像をダウンロードしてローカル画像に変換する。破損した画像を防ぎます" - download_remote_images_threshold: "リモート画像をダウンロードするために必要なディスクスペースの最低残容量 (パーセント)" - disabled_image_download_domains: "これらのドメインからは、リモート画像のダウンロードを行いません。パイプ区切りのリスト" - editing_grace_period: "投稿から(N)秒間は、ポストの新しいバージョンを作成しない" - edit_history_visible_to_public: "すべてのユーザに対して投稿の編集履歴を許可する。無効の場合はスタッフメンバーのみが確認できます" - delete_removed_posts_after: "投稿者は投稿を削除してから(N)時間後に削除されます。0を設定すると、すぐに削除されます" - max_image_width: "投稿内での画像サムネイルの最大の幅" - max_image_height: "投稿内での画像サムネイルの最大の高さ" - fixed_category_positions: "チェックすると、カテゴリの表示順をコントロールできます。チェックしない場合、アクティビティ順に表示されます" - fixed_category_positions_on_create: "チェックすると、トピック作成ダイアログ上でカテゴリの順序が維持されます(fixed category positions が必要)。" - add_rel_nofollow_to_user_content: ' 内部リンク(親ドメインを含む)を除き、投稿されたすべてのユーザコンテンツに rel nofollow を追加する。この設定を反映するには "rake posts:rebake" を実行して baked markdown をすべて更新する必要があります' - exclude_rel_nofollow_domains: "nofollowをリンクに追加しないドメインのリストです。 example.comは自動的にsub.example.comも許可します。少なくとも、Webクローラーがすべてのコンテンツを見つけやすくするために、このサイトのドメインを追加する必要があります。 もし、Webサイトの他の部分に別のドメインがある場合は、それらも追加してください。" - post_excerpt_maxlength: "投稿の引用/サマリの最大文字数" - post_onebox_maxlength: "Discourse OneBox投稿の最大文字数" - logo: "サイトの左上にあるロゴ画像です。高さ120、縦横比3:1を超える広い長方形の画像を使用してください。空白のままにすると、サイトのタイトルがテキストで表示されます。" - logo_small: "あなたのサイトの左上にある小さなロゴ画像です。下にスクロールしたときに見えます。 120×120の正方形の画像を使用してください。空白のままにすると、ホームグリフが表示されます。" - digest_logo: "サイトのメールサマリーの上部で使用されるロゴの代替画像です。広い長方形の画像を使用してください。 SVG画像を使わないでください。空白のままにすると、 `logo`で設定した画像が使用されます。" - mobile_logo: "あなたのサイトのモバイル版で使用されるロゴです。高さ120、縦横比3:1を超える広い長方形の画像を使用します。空白のままにすると、 'logo'で設定した画像が使用されます。" - notification_email: "システムからの重要なメール送信に使用する from: メールアドレス。メールが届くように、ここに指定されたドメインはSPF、DKIM、逆引きPTRレコードが正しく設定されている必要があります。" - email_custom_headers: "カスタムメールヘッダのリスト (パイプ(バーティカルバー) 区切り)" - summary_score_threshold: "'トピックサマリー'に投稿が含まれるために必要な最低スコア" - summary_percent_filter: "ユーザが'トピックサマリー'をクリックしたとき, 上位何パーセントのポストを表示するか" + censored_words: "自動的に ■■■■ に置換される語" + delete_old_hidden_posts: "30日以上非表示になっている投稿を自動的に削除します。" + allow_user_locale: "ユーザーが独自の言語インターフェース設定を選択することを許可する" + min_post_length: "投稿の最低文字数" + min_first_post_length: "最初の投稿 (トピック本文) の最低文字数" + min_personal_message_post_length: "メッセージ投稿の最低文字数" + max_post_length: "投稿の最大文字数" + min_topic_title_length: "トピックタイトルの最低文字数" + max_topic_title_length: "トピックタイトルの最大文字数" + min_personal_message_title_length: "メッセージのタイトルの最小文字数" + min_search_term_length: "有効な検索語の最小文字数" + allow_uncategorized_topics: "カテゴリなしでトピックを作成することを許可する。警告: 未分類のトピックがある場合は、これをオフにする前に、カテゴリを設定する必要があります。" + allow_duplicate_topic_titles: "トピックに同一の重複するタイトルを指定することを許可する。" + unique_posts_mins: "同じコンテンツを投稿できるまでの時間 (分)" + educate_until_posts: "最初の (n) 件の新規投稿を入力し始めたときに、新規ユーザーチュートリアルのパネルをポップアップ表示します。" + title: "title タグで使用される、このサイトの名前です。" + site_description: "description タグで使用される、このサイトについて簡単な説明です。" + short_site_description: "ホームページの title タグで使用される簡単な説明です。" + crawl_images: "リモート URL から画像を取得して、正しい幅と高さの寸法を挿入する。" + download_remote_images_to_local: "リモート画像をダウンロードしてローカル画像に変換する。画像の破損を防止できます。" + download_remote_images_threshold: "リモート画像をダウンロードするために必要なディスクの最低空き容量 (パーセント表示)" + disabled_image_download_domains: "これらのドメインのリモート画像をダウンロードしない (パイプ区切りのリスト)。" + editing_grace_period: "投稿から (n) 秒間は、編集しても投稿履歴に新しいバージョンを作成しない。" + edit_history_visible_to_public: "編集した投稿の前のバージョンをすべてのユーザーが閲覧することを許可する。無効である場合、スタッフメンバーのみが閲覧できます。" + delete_removed_posts_after: "作者が削除した投稿は (n) 時間後に自動的に削除される。0 に設置すると、投稿は直ちに削除されます。" + max_image_width: "投稿内のサムネイル画像の最大幅" + max_image_height: "投稿内のサムネイル画像の最大高" + fixed_category_positions: "オンにすると、カテゴリを固定された順に並べ替えられるようになります。オフにすると、アクティビティ順に並べ替えられます。" + fixed_category_positions_on_create: "オンにすると、カテゴリの順序はトピック作成ダイアログで管理されるようになります (fixed_category_positions が必要)。" + add_rel_nofollow_to_user_content: '内部リンク (親ドメインを含む) を除き、投稿されたすべてのユーザーコンテンツに rel nofollow を追加します。この設定を反映するには "rake posts:rebake" を実行してすべての投稿を作成し直す必要があります' + exclude_rel_nofollow_domains: "nofollow をリンクに追加してはいけないドメインのリストです。 example.com は自動的に sub.example.com も許可します。少なくとも、ウェブクローラーがすべてのコンテンツを見つけやすくするために、このサイトのドメインを追加する必要があります。 ウェブサイトの一部がほかのドメイン上にある場合は、それらも追加してください。" + post_excerpt_maxlength: "投稿の抜粋/要約の最大長。" + post_onebox_maxlength: "Discourse OneBox 投稿の最大文字数。" + logo: "サイトの左上にあるロゴ画像です。高さ 120、縦横比 3:1を超える広幅の長方形の画像を使用してください。空白のままにすると、サイトタイトルのテキストが表示されます。" + logo_small: "あなたのサイトの左上にある小さなロゴ画像です。下にスクロールしたときに見えます。 120×120 の正方形の画像を使用してください。空白のままにすると、ホームグリフが表示されます。" + digest_logo: "サイトのメール要約の上部で使用されるロゴの代替画像です。広い長方形の画像を使用してください。SVG 画像を使わないでください。空白のままにすると、`logo` で設定した画像が使用されます。" + mobile_logo: "あなたのサイトのモバイル版で使用されるロゴです。高さ120、縦横比 3:1 を超える広い長方形の画像を使用します。空白のままにすると、'logo' で設定した画像が使用されます。" + notification_email: "システムからの重要なメール送信に使用する from: メールアドレス。メールが届くように、ここに指定されたドメインは SPF、DKIM、逆引き PTR レコードが正しく設定されている必要があります。" + email_custom_headers: "カスタムメールヘッダーのパイプ区切りのリスト" + summary_score_threshold: "「このトピックを要約」に含まれるために必要な投稿の最低スコア" + summary_percent_filter: "「このトピックを要約」をクリックしたとき表示される上位投稿の割合%" enable_long_polling: "通知用のメッセージバスによるロングポーリングの利用を許可する" - long_polling_base_url: "ロングポーリングのベースURL(CDNが動的コンテンツを配信している場合、これをoriginに指定してください) eg: http://origin.site.com" - long_polling_interval: "ユーザに送信するデータが存在しないとき、サーバが待機する時間(ログインユーザーのみ)" - polling_interval: "ロングポーリングではないときの、ログイン済みクライアントのポーリング間隔(ミリ秒)" - anon_polling_interval: "匿名ユーザのクライアントのポーリング間隔 (ミリ秒)" + long_polling_base_url: "ロングポーリングのベース URL (CDN が動的コンテンツを配信している場合、これを origin pull に指定してください) 例: http://origin.site.com" + long_polling_interval: "送信するデータが存在しない場合に、サーバーがクライアントに応答するまで待機する時間 (ログインユーザーのみ)" + polling_interval: "ロングポーリングではないときの、ログイン済みクライアントのポーリング間隔 (ミリ秒)" + anon_polling_interval: "匿名クライアントのポーリング間隔 (ミリ秒)" background_polling_interval: "ウィンドウがバックグラウンド時のクライアントのポーリング間隔(ミリ秒)" - cooldown_minutes_after_hiding_posts: "通報により非表示状態になったポストをユーザが編集可能になるまでの時間 (分)" - tl2_additional_likes_per_day_multiplier: "この数字を掛けると TL2 (メンバー) の1日あたりの「いいね!」の上限を増やします" - tl3_additional_likes_per_day_multiplier: "この数字を掛けると TL3 (レギュラー) の1日あたりの「いいね!」の上限を増やします" - tl4_additional_likes_per_day_multiplier: "この数字を掛けると TL4 (リーダー) の1日あたりの「いいね!」の上限を増やします" + cooldown_minutes_after_hiding_posts: "通報により非表示状態になった投稿をユーザーが編集可能になるまでの時間 (分)" + tl2_additional_likes_per_day_multiplier: "この数字を掛けると TL2 (メンバー) の 1 日あたりの「いいね!」の上限を増やします" + tl3_additional_likes_per_day_multiplier: "この数字を掛けると TL3 (レギュラー) の 1 日あたりの「いいね!」の上限を増やします" + tl4_additional_likes_per_day_multiplier: "この数字を掛けると TL4 (リーダー) の 1 日あたりの「いいね!」の上限を増やします" traditional_markdown_linebreaks: "Markdown の従来形式のラインブレーク (行の終わりにダブルスペース) を使う" - post_undo_action_window_mins: "投稿に対するアクション (「いいね!」、通報等) 取り消しを許可する時間 (秒)" - must_approve_users: "ユーザがサイトにアクセスするには、スタッフの承認が必要" - cors_origins: "CORSを許可。オリジンはhttp://かhttps://を含む必要があります。CORSを有効にするには、環境変数 DISCOURSE_ENABLE_CORSにtrueをセットする必要があります" + post_undo_action_window_mins: "投稿に対する最近の操作 (「いいね!」、通報等) の取り消しを許可する時間 (分)" + must_approve_users: "すべての新規ユーザーアカウントがアクセスを許可される前に、スタッフがアカウントを承認する必要がある" + cors_origins: "CORS を許可。オリジンは http:// か https:// を含む必要があります。CORS を有効にするには、環境変数 DISCOURSE_ENABLE_CORS に true を設定する必要があります。" top_menu: "ホームナビゲーションに表示する項目・表示順を指定。例: latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "投稿メニューに表示する項目を指定。例 like|edit|flag|delete|share|bookmark|reply" - post_menu_hidden_items: "開くボタンをクリックするまで投稿のメニュー項目を隠します" - share_links: "シェアダイアログに表示する項目、表示順を指定" - site_contact_username: "自動送信メールのfromに使用される有効なスタッフのユーザー名。空欄の場合デフォルトのシステムアカウントが使用される" - send_welcome_message: "全ての新規ユーザにクイックスタートガイド付きのウェルカムメッセージを送信する。" - send_tl1_welcome_message: "新しい信頼レベル1のユーザーにウェルカムメッセージを送信します。" - suppress_reply_directly_below: "ポストに回答が1つしかない場合、ポストの回答数を表示しない" - suppress_reply_directly_above: "ポストに回答が1つしかない場合、ポストのin-reply-toを表示しない" - suppress_reply_when_quoting: "ポストが引用返信だった場合、ポストのin-reply-toを表示しない" - max_reply_history: "返信のin-reply-toを展開する最大数" - topics_per_period_in_top_summary: "デフォルトのトピックサマリに表示されるトップトピックの数" - topics_per_period_in_top_page: "'もっと見る'を展開したときに表示するトップトピックの数" - redirect_users_to_top_page: "新規ユーザーと長く不在のユーザーをトップページに自動的にリダイレクトさせる" - email_token_valid_hours: "パスワードリマインダ、アカウントアクティベート時のトークンを何時間有効にするか" + post_menu_hidden_items: "展開ボタンがクリックされるまで、デフォルトで非表示にする投稿のメニュー項目。" + share_links: "共有ダイアログに表示する項目、表示順を指定。" + site_contact_username: "自動送信メールの from に使用される有効なスタッフのユーザー名。空欄の場合デフォルトのシステムアカウントが使用されます。" + send_welcome_message: "すべての新規ユーザーにクイックスタートガイド付きのウェルカムメッセージを送信する。" + send_tl1_welcome_message: "新しい信頼レベル 1 のユーザーにウェルカムメッセージを送信します。" + suppress_reply_directly_below: "投稿に返信が 1 つしかない場合、投稿の展開可能な返信数を表示しない。" + suppress_reply_directly_above: "投稿に返信が 1 つしかない場合、投稿の展開可能な in-reply-to を表示しない。" + suppress_reply_when_quoting: "投稿が引用返信だった場合、投稿の展開可能な in-reply-to を表示しない。" + max_reply_history: "in-reply-toを展開するときに展開する返信の最大数" + topics_per_period_in_top_summary: "デフォルトの人気トピックの要約に表示される人気トピックの数" + topics_per_period_in_top_page: "人気トピックの 'もっと表示' を展開したときに、人気トピックに表示する人気トピックの数" + redirect_users_to_top_page: "新規ユーザーと長く不在のユーザーをトップページに自動的にリダイレクトする" + email_token_valid_hours: "「パスワードを忘れました」またはアカウントアクティベーションのトークンを有効にする時間 (n)" enable_badges: "バッジ機能を有効にする" - blocked_email_domains: "ユーザーがアカウント登録をすることができない、パイプ区切りのドメイン名のリスト。 例 : mailinator.com|trashmail.net" - allowed_email_domains: "ユーザー登録に必要なパイプ区切りのメールドメインのリスト。警告: 指定したメールドメイン以外のユーザは許可されません!" - log_out_strict: "ログアウトした際に、そのユーザーの全デバイスのセッションをログアウトさせる" - new_version_emails: "Discourseの新しいバージョンが利用可能になった際に contact_email アドレスにメールで通知する" - invite_expiry_days: "招待キーの有効期間 (日)" + blocked_email_domains: "ユーザーがアカウント登録をすることができないメールドメインの、パイプ区切りのリスト。例: mailinator.com|trashmail.net" + allowed_email_domains: "ユーザー登録に必要なメールドメインのパイプ区切りのリスト。警告: 指定したメールドメイン以外のユーザーの登録は許可されません!" + log_out_strict: "ログアウトした際に、そのユーザーの全デバイスのセッションをログアウトする" + new_version_emails: "Discourse の新しいバージョンが利用可能になった際に contact_email アドレスにメールで通知する" + invite_expiry_days: "ユーザー招待キーの有効期間 (日)" login_required: "サイトのコンテンツを閲覧するには認証を必須にして、匿名アクセスを拒否する" min_password_length: "パスワードの最小の長さ" - block_common_passwords: "最もよく使われている10,000個のパスワードを許可しない" - allow_new_registrations: "新しいユーザの登録を許可。ユーザを誰でも作れないようにするためには、チェックを外してください。" - google_oauth2_client_id: "あなたのGoogleアプリケーションのクライアントID" - google_oauth2_client_secret: "あなたの Google アプリケーションのクライアント Secret。" - allow_restore: "すべてのサイトデータを置き換える復元を許可します\n。バックアップからの復元を行うとき以外はfalseのままにしてください。" + block_common_passwords: "最もよく使われている 10,000 個のパスワードを許可しない" + allow_new_registrations: "新しいユーザーの登録を許可。新規アカウントを作成できないようにするには、これをオフにします。" + google_oauth2_client_id: "あなたの Google アプリケーションのクライアント ID" + google_oauth2_client_secret: "あなたの Google アプリケーションのクライアントシークレット" + allow_restore: "復元を許可。サイトの全データが置き換えられます!バックアップを復元しない場合は、false のままにしてください" maximum_backups: "ディスクに保存するバックアップの最大数。古いバックアップは自動的に削除されます" - s3_backup_bucket: "バックアップを保持するバケット。 警告: 必ずプライベートバケットになっていることを確認してください" + s3_backup_bucket: "バックアップを保持するリモートバケット。警告: 必ずプライベートバケットになっていることを確認してください" active_user_rate_limit_secs: "'last_seen_at' フィールドを更新する頻度 (秒)" - verbose_localization: "翻訳者向けの機能を表示をします" + verbose_localization: "UI に詳細なローカリゼーションのヒントを表示する" previous_visit_timeout_hours: "'previous' visit とみなす時間 (時間)" - rate_limit_create_topic: "トピック作成後、次のトピックを作成するまでにユーザが待たなければならない時間 (秒)" - rate_limit_create_post: "ポスト投稿後、次のポストを投稿するまでにユーザが待たなければならない時間 (秒)" - rate_limit_new_user_create_topic: "新しいユーザがトピックを作った後、次のトピックが作成できるまでの時間(秒)" - rate_limit_new_user_create_post: "投稿後、次のポストを投稿するまでに新しいユーザが待たなければならない時間 (秒)" - max_likes_per_day: "ユーザが一日に「いいね!」できる最大数" - max_flags_per_day: "ユーザが一日に行える通報の回数" - max_bookmarks_per_day: "ユーザが一日にブックマークできる最大数" - max_edits_per_day: "ユーザが一日に編集できる最大数" - max_topics_per_day: "ユーザが一日に作成できるトピックの最大数" - max_invites_per_day: "ユーザが一日に招待できる最大数" - max_topic_invitations_per_day: "ユーザが一日にトピックに招待できる最大数" - suggested_topics: "トピック下部に表示されるおすすめトピックの数" - limit_suggested_to_category: "現在参照しているトピックのカテゴリからトピックをサジェストする" - suggested_topics_max_days_old: "関連トピックはn日より前のものであってはいけません。" - clean_up_uploads: "不正アップロードを防ぐためにどこからも参照されていないアップロードファイルを削除する。警告: この設定を有効にする前に /uploads ディレクトリのバックアップを取ることをおすすめします" - clean_orphan_uploads_grace_period_hours: "どこからもリンクされていないアップロードファイルを削除するまでの猶予期間 (単位:時間)。" - purge_deleted_uploads_grace_period_days: "削除されたアップロードファイルが完全削除されるまでの猶予期間 (単位:日)。" - enable_s3_uploads: "Amazon S3にアップロードします。重要: 有効なS3 credentials(access key id & secret access key)が必要です" - s3_upload_bucket: "ファイルをアップロードする Amazon S3のバケット名。 警告: 小文字である必要があり、ピリオドとアンダースコアを含むことはできません" - s3_cdn_url: "s3アセットに利用するCDNのURL(例: https://cdn.somewhere.com) 警告: 変更後は過去の投稿をrebakeする必要があります" - avatar_sizes: "自動生成するアバターサイズのリスト" + rate_limit_create_topic: "トピック作成後、ユーザーは (n) 秒待ってから次のトピックを作成する必要があります。" + rate_limit_create_post: "投稿後、ユーザーは (n) 秒待ってから次の投稿を作成する必要があります。" + rate_limit_new_user_create_topic: "トピック作成後、新規ユーザーは (n) 秒待ってから次のトピックを作成する必要があります。" + rate_limit_new_user_create_post: "投稿後、新規ユーザーは (n) 秒待ってから次の投稿を作成する必要があります。" + max_likes_per_day: "ユーザーが 1 日に「いいね!」できる最大回数" + max_flags_per_day: "ユーザーが 1 日に行える通報の回数" + max_bookmarks_per_day: "ユーザーが 1 日にブックマークできる最大数" + max_edits_per_day: "ユーザーが 1 日に編集できる最大数" + max_topics_per_day: "ユーザーが 1 日に作成できるトピックの最大数" + max_invites_per_day: "ユーザーが 1 日に招待できる最大数" + max_topic_invitations_per_day: "ユーザーが 1 日にトピックに招待できる最大数" + suggested_topics: "トピックの下に表示される推奨トピックの数" + limit_suggested_to_category: "推奨トピックには現在参照しているカテゴリのトピックのみを表示する。" + suggested_topics_max_days_old: "投稿されて n 日経過したものを推奨トピックにしない。" + clean_up_uploads: "不正なホスティングを防ぐために、どこからも参照されていないアップロードを削除する。警告: この設定を有効にする前に /uploads ディレクトリをバックアップすることをお勧めします。" + clean_orphan_uploads_grace_period_hours: "どこからもリンクされていないアップロードを削除するまでの猶予期間 (時間)。" + purge_deleted_uploads_grace_period_days: "削除されたアップロードが消去されるまでの猶予期間 (日)。" + enable_s3_uploads: "Amazon S3 ストレージにアップロードを配置する。重要: 有効な S3 資格情報 (アクセスキー ID とシークレットアクセスキーの両方) が必要です。" + s3_upload_bucket: "ファイルをアップロードする Amazon S3 のバケット名。警告: 小文字である必要があり、ピリオドとアンダースコアは使用できません。" + s3_cdn_url: "すべての s3 アセットに使用する CDN の URL (例: https://cdn.somewhere.com)。警告: この設定を変更した後は過去の全投稿を rebake する必要があります。" + avatar_sizes: "自動生成されたアバターのサイズのリスト" external_system_avatars_enabled: "外部のアバターシステムサービスを使用します。" - default_invitee_trust_level: "招待したユーザのデフォルトトラストレベル(0-4)" - default_trust_level: "新規ユーザのデフォルトトラストレベル(0-4) 警告: 変更すると深刻なスパムのリスクがあります" - tl1_requires_topics_entered: "新規ユーザーがトラストレベル1に昇格するために閲覧しなければならないトピックの数" - tl1_requires_read_posts: "新規ユーザがトラストレベル1に昇格するために閲覧しなければらないポストの数" - tl1_requires_time_spent_mins: "新規ユーザがトラストレベル1に昇格するためにポストを読まなければならない時間 (分)" - tl2_requires_topics_entered: "ユーザーがトラストレベル2に昇格するために閲覧しなければならないトピックの数" - tl2_requires_read_posts: "トラストレベル2に昇格するために閲覧しなければならないポストの数" - tl2_requires_time_spent_mins: "トラストレベル2に昇格するためにポストを読まなければならない時間(分)" - tl2_requires_days_visited: "トラストレベル2に昇格するためにサイトを訪問しなければない日数" - tl2_requires_likes_received: "トラストレベル2に昇格するためにもらわなければならない「いいね!」の数" - tl2_requires_likes_given: "トラストレベル2に昇格するためにしなければならない「いいね!」の数" - tl2_requires_topic_reply_count: "トラストレベル2に昇格するために回答しなければいけないトピックの数" - tl3_time_period: "トラストレベル3に必要な期間(日単位)" - tl3_requires_topics_viewed_all_time: "トラストレベル3に昇格するために必要な、トピックを閲覧した合計" - tl3_requires_posts_read_all_time: "トラストレベル3に昇格するために必要なユーザが閲覧したポストの合計" - tl3_promotion_min_duration: "トラストレベル3に昇格したユーザが、トラストレベル2に降格しない日数" - tl3_links_no_follow: "トラストレベル3のユーザのポスト内のrel=nofolowを削除しない" - min_trust_to_create_topic: "新規にトピックを作成するために必要な最低トラストレベル。" - min_trust_to_edit_wiki_post: "ポストをwikiにするために必要な最低トラストレベル" - newuser_max_links: "新しいユーザが投稿内に貼れるリンクの数" - newuser_max_attachments: "新しいユーザが投稿内に添付できるファイルの数" - newuser_max_mentions_per_post: "新しいユーザが投稿内で @name で通知できる最大の数" - newuser_max_replies_per_topic: "新しいユーザが1つのトピックで誰かがそれに回答するまでに回答できる最大の数" - max_mentions_per_post: "ユーザがポスト内で@name で通知できる最大数" - create_thumbnails: "大きすぎる画像はポストにフィットするように、サムネイルを作成しlightbox 画像を作成する" - email_time_window_mins: "ユーザによるポストの最終編集チャンスを与えるために、通知用メール送信までに待つ時間 (分)" - email_posts_context: "通知メールのコンテンツに含める回答の数" - flush_timings_secs: "サーバにタイミングデータを送信する頻度 (秒)" + default_invitee_trust_level: "招待したユーザーのデフォルトの信頼レベル (0~4)。" + default_trust_level: "新規ユーザーのデフォルトの信頼レベル (0~4) です。警告: 変更すると深刻な迷惑行為のリスクがあります。" + tl1_requires_topics_entered: "新規ユーザーが信頼レベル 1 に昇格するために閲覧しなければならないトピックの数。" + tl1_requires_read_posts: "新規ユーザーが信頼レベル 1 に昇格するために閲覧しなければらない投稿の数。" + tl1_requires_time_spent_mins: "新規ユーザーが信頼レベル 1 に昇格するために投稿を読まなければならない時間 (分)。" + tl2_requires_topics_entered: "ユーザーが信頼レベル 2 に昇格するために閲覧しなければならないトピックの数。" + tl2_requires_read_posts: "ユーザーが信頼レベル 2 に昇格するために閲覧しなければらない投稿の数。" + tl2_requires_time_spent_mins: "ユーザーが信頼レベル 2 に昇格するために投稿を読まなければならない時間 (分)。" + tl2_requires_days_visited: "ユーザーが信頼レベル 2 に昇格するためにサイトにアクセスしなければならない日数。" + tl2_requires_likes_received: "ユーザーが信頼レベル 2 に昇格するためにもらわなければならない「いいね!」の数。" + tl2_requires_likes_given: "ユーザーが信頼レベル 2 に昇格するために付けなければならない「いいね!」の数。" + tl2_requires_topic_reply_count: "ユーザーが信頼レベル 2 に昇格するために返信しなければいけないトピックの数。" + tl3_time_period: "信頼レベル 3 に必要な期間 (日数)" + tl3_requires_topics_viewed_all_time: "ユーザーが信頼レベル 3 に昇格するために閲覧しなければならないトピックの合計数。" + tl3_requires_posts_read_all_time: "ユーザーが信頼レベル 3 に昇格するために読まなければならない投稿の合計数。" + tl3_promotion_min_duration: "信頼レベル 3 に昇格したユーザーが、信頼レベル 2 に降格しない日数。" + tl3_links_no_follow: "信頼レベル 3 のユーザーが投稿したリンクから rel=nofolow を削除しない。" + min_trust_to_create_topic: "新規トピックを作成するために必要な最低信頼レベル。" + min_trust_to_edit_wiki_post: "ウィキとしてマークされた投稿を編集するために必要な最低信頼レベル。" + newuser_max_links: "新規ユーザーが投稿に追加できるリンクの数" + newuser_max_attachments: "新規ユーザーが投稿に追加できる添付ファイルの数" + newuser_max_mentions_per_post: "新規ユーザーが投稿で @name 通知を行える最大回数" + newuser_max_replies_per_topic: "他のユーザーが返信するまで、新規ユーザーが 1 つのトピックで行える返信の最大数。" + max_mentions_per_post: "すべてのユーザーが投稿内で @name 通知を行える最大数" + create_thumbnails: "大きすぎて投稿に収まらないが画像のサムネイルを作成し、ライトボックにします。" + email_time_window_mins: "ユーザーに最終編集の機会を与えられるよう、通知メールを送信するまで (n) 分待機する。" + email_posts_context: "通知メールにコンテキストとして含める以前の返信の件数。" + flush_timings_secs: "サーバーに消去タイミングデータを送信する頻度 (秒)。" title_max_word_length: "トピックタイトルの最大文字数" - title_min_entropy: "トピックタイトルの最小許容エントロピー (ユニークキャラクターや英語以外の単語を含むとより大きな値になります)" - body_min_entropy: "ポスト本文の最小許容エントロピー (ユニークキャラクターや英語以外の単語を含むとより大きな値になります)" - allow_uppercase_posts: "タイトル/本文内で大文字のみの文章を許可する。" + title_min_entropy: "トピックタイトルの最小許容エントロピー (特殊文字や英語以外の単語を含むとより大きな値になります)" + body_min_entropy: "投稿本文の最小許容エントロピー (特殊文字や英語以外の単語を含むとより大きな値になります)" + allow_uppercase_posts: "トピックタイトルまたは投稿本文での大文字のみ使用を許可する。" min_title_similar_length: "類似トピックのチェックに必要な最小タイトル長" - category_colors: "カテゴリに利用可能な色 (16進数指定) のリスト" - category_style: "カテゴリバッジのスタイル" + category_colors: "カテゴリに利用可能な 16 進数の色値のリスト" + category_style: "カテゴリバッジの視覚的スタイル" dark_mode_none: "なし" - max_attachment_size_kb: "添付可能なファイルの最大サイズ (kB) nginx 側での設定 (client_max_body_size) / apache または proxy における設定も同時に行う必要があります" - authorized_extensions: "アップロード可能なファイルの拡張子のリスト('*'で全ての拡張子が有効になります)" - max_similar_results: "新規トピック編集中に類似トピックをいくつ表示するか。比較はタイトルと本文に基づきます" - title_prettify: "よくあるタイトルのミススペルや文法エラー (例: 最初の文字が小文字、文の最後で ! や ? や . などが重複) を防止する" - topic_views_heat_low: "多くの閲覧があると、このフィールドはやや強調されます" - topic_views_heat_medium: "多くの閲覧があると、このフィールドは適度に強調されます" - topic_views_heat_high: "多くの閲覧があると、このフィールドは強く強調されます" - cold_age_days_low: "議論から日が経つと、更新日がすこし薄く表示されます" - cold_age_days_medium: "議論から日が経つと、更新日が適度に薄く表示されます" - cold_age_days_high: "議論から日が経つと、更新日が強く淡色表示されます" - history_hours_low: "数時間以内に編集したポストは、編集インジケーターがやや強調されます" - history_hours_medium: "数時間以内に編集したポストは、編集インジケーターが適度に強調されます" - history_hours_high: "数時間以内に編集したポストは、編集インジケーターが強く強調されます" - topic_post_like_heat_low: "いいね数/ ポスト数の比率がこの比率を超えると、ポスト数のフィールドがやや強調されます" - topic_post_like_heat_medium: "いいね数/ ポスト数の比率がこの比率を超えると、ポスト数のフィールドが適度に強調されます" - topic_post_like_heat_high: "いいね数/ ポスト数の比率がこの比率を超えると、ポスト数のフィールドが強く強調されます" - faq_url: "FAQが他のサイトにある場合、URLをここに指定します。" - tos_url: "他のサイトに利用規約を掲載している場合は、URLをここに指定してください。" - privacy_policy_url: "他のサイトにプライバシーポリシーを掲載している場合は、URLをここに指定してください。" - allowed_spam_host_domains: "スパムホスト検査から除外するドメインのリスト。新規ユーザはこれらのドメインでポスト内にリンクを作成することを制限されません" - levenshtein_distance_spammer_emails: "スパマーのメールアドレスとマッチさせるとき、数文字違いの曖昧な一致でも許可する" - max_new_accounts_per_registration_ip: "もし既にトラストレベル0のユーザーがそのIPから(N)個作成されていたら、そのIPから登録できないようにする(スタッフメンバー、トラストレベル2以上は除く)" - min_ban_entries_for_roll_up: "When clicking the Roll up button, will create a new subnet ban entry if there are at least (N) entries." - max_age_unmatched_emails: "(N)日間一致しなかったスクリーンメールアドレスを削除" - max_age_unmatched_ips: "(N)日間一致しなかったスクリーンIPアドレスを削除" - num_flaggers_to_close_topic: "介入するため、自動的にトピックを停止するために必要なフラグを付けたユニークユーザの数" - auto_respond_to_flag_actions: "フラグを解除時の自動返信を有効にする" - min_first_post_typing_time: "最初の投稿に必要なタイピング時間。閾値以下の場合、投稿は自動的に承認キューに追加されます。0を設定すると無効化されます(推奨されません)" - reply_by_email_enabled: "メールでのトピックへの返信を有効化する" - strip_images_from_short_emails: "メールのサイズが2800 Bytesを超えないように画像を削除する" + max_attachment_size_kb: "添付可能なファイルの最大サイズ (kB)。nginx (client_max_body_size) / apache または proxy でも設定する必要があります。" + authorized_extensions: "アップロードできるファイルの拡張子のリスト (すべての拡張子を有効にするには '*' を使用してください)" + max_similar_results: "新規トピックを作成中に、エディターの上に表示する類似トピックの数。タイトルと本文に基づいて比較されます。" + title_prettify: "一般的なタイトルのスペルミスやエラー (すべて大文字、先頭の小文字、複数の!や?の使用、文末の重複するピリオドなど) を防止します。" + topic_views_heat_low: "指定された数の閲覧があると、閲覧フィールドはやや強調表示されます。" + topic_views_heat_medium: "指定された数の閲覧があると、閲覧フィールドは中程度に強調表示されます。" + topic_views_heat_high: "指定された数の閲覧があると、閲覧フィールドは濃く強調表示されます。" + cold_age_days_low: "会話から指定された時間が経つと、最終アクティビティ日がわずかに薄く表示されます。" + cold_age_days_medium: "会話から指定された時間が経つと、最終アクティビティ日が中程度に薄く表示されます。" + cold_age_days_high: "会話から指定された時間が経つと、最終アクティビティ日が非常に薄く表示されます。" + history_hours_low: "指定された時間内に編集された投稿の編集インジケーターが、やや強調表示されます。" + history_hours_medium: "指定された時間内に編集された投稿の編集インジケーターが、中程度に強調表示されます。" + history_hours_high: "指定された時間内に編集された投稿の編集インジケーターが、濃く強調表示されます。" + topic_post_like_heat_low: "「いいね!」数と投稿数の比率が指定された比率を超えると、投稿数のフィールドがやや強調表示されます。" + topic_post_like_heat_medium: "「いいね!」数と投稿数の比率が指定された比率を超えると、投稿数のフィールドが中程度に強調表示されます。" + topic_post_like_heat_high: "「いいね!」数と投稿数の比率が指定された比率を超えると、投稿数のフィールドが濃く強調表示されます。" + faq_url: "使用する FAQ が他の場所でホスティングされている場合、その完全な URL をここに指定します。" + tos_url: "使用する利用規約が他の場所でホスティングされている場合、その完全な URL をここに指定します。" + privacy_policy_url: "使用するプライバシーポリシーが他の場所でホスティングされている場合、その完全な URL をここに指定します。" + allowed_spam_host_domains: "スパムホスト検査から除外するドメインのリスト。新規ユーザーはこれらのドメインへのリンクを使って投稿を作成することができます。" + levenshtein_distance_spammer_emails: "迷惑メールのアドレスを照合する場合、あいまい一致を許可する文字数の差。" + max_new_accounts_per_registration_ip: "この IP でアクセスする信頼レベル 0 のアカウントが (n) 個存在する場合 (さらにこれらがスタッフメンバーや TL2 以上のメンバーでない場合)、この IP から新たに登録できないようにします。" + min_ban_entries_for_roll_up: "ロールアップボタンをクリックする際に少なくとも (N) 個のエントリーがある場合、新しいサブネット禁止エントリーを作成します。" + max_age_unmatched_emails: "(N) 日間一致しなかったスクリーン対象メールアドレスを削除します。" + max_age_unmatched_ips: "(N) 日間一致しなかったスクリーン対象 IP アドレスを削除します。" + num_flaggers_to_close_topic: "トピックを自動的に停止して介入するために必要なユニーク通報ユーザーの最小数" + auto_respond_to_flag_actions: "通報を解除したときの自動返信を有効にします。" + min_first_post_typing_time: "最初の投稿時にユーザーが入力する必要のある最低時間 (ミリ秒)。しきい値を満たさない場合、投稿は自動的に要承認キューに追加されます。無効にするには 0 を設定します (非推奨)" + reply_by_email_enabled: "メールでのトピックへの返信を有効化します。" + strip_images_from_short_emails: "2800 バイト未満のメールから画像を削除します。" short_email_length: "ショートメールのバイト数" - pop3_polling_enabled: "メール回答のために POP3 をポーリングする" - pop3_polling_ssl: "POP3サーバへSSL接続する(推奨)" - pop3_polling_period_mins: "メールのためのPOP3アカウントを確認する期間(分) 注意: 再起動が必要" - pop3_polling_port: "ポーリングを行うPOP3アカウントのポート" - pop3_polling_host: "ポーリングを行うPOP3のホスト" - pop3_polling_username: "ポーリングを行うPOP3アカウントのユーザー名" - pop3_polling_password: "ポーリングを行うPOP3アカウントのパスワード" - email_in_min_trust: "メールでのトピック作成を許可する最低トラストレベル" - email_prefix: "メールのタイトルに使用される[ラベル]。空白の場合、タイトルがデフォルトで使用されます" - email_site_title: "サイトからのメールの送信者として使用されるタイトル。 空白の場合、タイトルがデフォルトで使用されます。タイトルにメールの送信者として使用できない文字列が含まれている場合、この設定を使用します" - minimum_topics_similar: "類似トピック表示のために存在しなければならないトピックの数" - relative_date_duration: "ポスト投稿後、ポスト投稿日が相対表示 (例: 7d)から絶対表示(例: Feb 20)に変わるまでの日数" - delete_user_max_post_age: "最初のポストが(x)日よりも古いユーザは削除しない" - delete_all_posts_max: "すべてのポストを削除ボタンで一度に削除可能な最大ポスト数。ユーザがここで指定した以上のポストを投稿していた場合は、一度に全ポストを削除することができません。またユーザも削除されません。" - email_editable: "登録後、ユーザによるメールアドレスの変更を許可する。" - allow_uploaded_avatars: "プロフィール画像のアップロードを許可" - default_avatars: "新規ユーザが変更するまで使用されるデフォルトのプロフィール画像URL" - automatically_download_gravatars: "アカウントの生成時、メールアドレスの変更時にGravatarをダウンロード" - detect_custom_avatars: "ユーザがプロフィール画像をアップロードしたか確認する" - max_daily_gravatar_crawls: "Discourseがプロフィール画像の確認をgravastarに行う回数の上限" - enable_user_directory: "ユーザーディレクトリの閲覧を提供" - allow_anonymous_posting: "全てのユーザーに匿名モードを許可" - anonymous_posting_min_trust_level: "匿名の投稿を行うための最小のトラストレベル" - anonymous_account_duration_minutes: "匿名性を守るため、N 分毎に匿名ユーザーを作成しなおします。 例 : 600を設定すると匿名ユーザへの変更と最後の投稿から600分経過していた場合、匿名アカウントが作成されます" - allow_profile_backgrounds: "プロフィール背景のアップロードを許可" - enable_mobile_theme: "モバイル端末にモバイル向けテーマ (通常サイト用テーマにスイッチ可能) を利用する。レスポンシブなスタイルシートを使用する場合はこの設定を無効にしてください。" - dominating_topic_minimum_percent: "ユーザがトピックを占拠しているとリマインドを行う、ポスト投稿支配率 " - suppress_uncategorized_badge: "トピック一覧でカテゴライズされていないトピックのバッジは表示しない" - disable_system_edit_notifications: "システムユーザが'download_remote_images_to_local' を有効にした場合、編集時の通知を無効にする" - full_name_required: "ユーザープロフィールのフルネームを必須にする" - enable_names: "ユーザーのプロフィール、ユーザーカード、メールアドレスでのフルネームを表示します。無効にすると、フルネームは非表示になります" - display_name_on_posts: "@usernameに加えて、ポストにユーザのフルネームを表示する" - show_time_gap_days: "2つの投稿の作成日が離れていた場合、time gapをトピックに表示する。" - short_progress_text_threshold: "プログレスバーが現在のポスト番号のみを表示するようになるポスト数のしきい値。プログレスバーの幅を変更した際には、この値を変更することをおすすめします。" - warn_reviving_old_topic_age: "最後の返信がこの設定よりも古いトピックに返信すると、警告を表示します。0を設定すると無効になります" - autohighlight_all_code: "明示的に言語を指定しなくても、全てのコードブロックにコードハイライトを強制的に適用する" - embed_truncate: "embedされた投稿をtruncateする" - embed_post_limit: "表示可能な投稿の最大数を埋め込む" - show_create_topics_notice: "サイトにpublicなトピックが5よりも少ない場合、トピックを作成するための通知が管理者に表示される" - delete_drafts_older_than_n_days: "(n) 日間経過したドラフトを削除" - slug_generation_method: "slugを生成するメソッドを選択。 'encode'はパーセントエンコードされた文字列を生成します。 'none'は、slugを無効にします" + pop3_polling_enabled: "メール返信のために POP3 をポーリングします。" + pop3_polling_ssl: "POP3 サーバーへ SSL 接続します (推奨)。" + pop3_polling_period_mins: "POP3 アカウントのメールを確認する期間 (分)。注意: 再起動が必要です。" + pop3_polling_port: "ポーリングを行う POP3 アカウントのポートです。" + pop3_polling_host: "ポーリングを行う POP3 のホストです。" + pop3_polling_username: "ポーリングを行う POP3 アカウントのユーザー名です。" + pop3_polling_password: "ポーリングを行う POP3 アカウントのパスワードです。" + email_in_min_trust: "メールでのトピック作成を許可する最低信頼レベル" + email_prefix: "メールの件名に使用される [label]。空白の場合、'title' がデフォルトで使用されます。" + email_site_title: "サイトからのメールの送信者として使用されるサイトのタイトル。 空白の場合、'title' がデフォルトで使用されます。'title' にメールの送信者として使用できない文字列が含まれている場合、この設定を使用します。" + minimum_topics_similar: "新規トピックを作成する際に類似トピックを表示するため必要な既存のトピックの数です。" + relative_date_duration: "投稿後、投稿日が相対表示 (例: 7 日) から絶対表示 (2 月 20 日) に変わるまでの日数。" + delete_user_max_post_age: "最初の投稿が (x) 日間以上前であるユーザーは削除しない" + delete_all_posts_max: "[すべての投稿を削除] ボタンで一度に削除可能な最大投稿数。ユーザーがここで指定した以上の投稿を作成していた場合は、全投稿をまとめて削除することができません。またユーザーも削除されません。" + email_editable: "登録後、ユーザーにメールアドレスの変更を許可します。" + allow_uploaded_avatars: "ユーザーにカスタムプロフィール画像のアップロードを許可します。" + default_avatars: "新規ユーザーが変更するまで使用されるデフォルトのアバターの URL です。" + automatically_download_gravatars: "アカウントの作成時またはメールアドレスの変更時にユーザーの Gravatar をダウンロードします。" + detect_custom_avatars: "ユーザーがカスタムプロフィール画像をアップロードしたかを確認するかどうか。" + max_daily_gravatar_crawls: "Discourse が 1 日に Gravatar でカスタムアバターの有無をチェックする回数の上限" + enable_user_directory: "閲覧用のユーザーディレクトリを提供" + allow_anonymous_posting: "ユーザーに匿名モードへの切り替えを許可" + anonymous_posting_min_trust_level: "匿名で投稿するために必要な最低信頼レベル" + anonymous_account_duration_minutes: "匿名性を守るため、N 分毎に各ユーザーの匿名アカウントを作成しなおします。例 : 600 に設定すると、最終投稿から 600 分が経過し、さらにユーザーが匿名に切り替えると、新しい匿名アカウントが作成されます。" + allow_profile_backgrounds: "ユーザーにプロフィール背景のアップロードを許可します。" + enable_mobile_theme: "モバイルデバイスにモバイル対応のテーマを使用します (デスクトップサイトに切り替え可能)。完全にレスポンシブなカスタムスタイルシートを使用する場合はこの設定を無効にしてください。" + dominating_topic_minimum_percent: "ユーザーがトピックを過度に占領していることを通知する、トピック内のユーザーの投稿率。" + suppress_uncategorized_badge: "トピックリストに未分類のトピックのバッジを表示しない" + disable_system_edit_notifications: "download_remote_images_to_local' が有効である場合、システムユーザーによる編集の通知を無効にします。" + full_name_required: "ユーザープロフィールのフルネームを必須にします。" + enable_names: "ユーザーのプロフィール、ユーザーカード、メールアドレスでのフルネームを表示します。無効にすると、フルネームはすべての場所で非表示になります。" + display_name_on_posts: "投稿に、@username に加えてユーザーのフルネームを表示します。" + show_time_gap_days: "2つの投稿の作成日の間が指定された日数以上の場合、トピックに時間の差を表示します。" + short_progress_text_threshold: "トピック内の投稿数が指定された数字を超えると、プログレスバーに現在の投稿番号のみを表示します。プログレスバーの幅を変更すると、この値を変更する必要がある場合があります。" + warn_reviving_old_topic_age: "最後の返信が指定された日数より古いトピックに返信し始めると、警告を表示します。0 に設定すると無効になります。" + autohighlight_all_code: "明示的に言語を指定しなくても、すべての整形済みのコードブロックにコードハイライトを強制的に適用します。" + embed_truncate: "埋め込まれた投稿切り捨てます。" + embed_post_limit: "埋め込める投稿の最大数。" + show_create_topics_notice: "サイトの公開トピック数が 5 件未満である場合、管理者にトピックを作成するかどうかを尋ねる通知を表示します。" + delete_drafts_older_than_n_days: "(n) 日間経過した下書きを削除します。" + slug_generation_method: "スラッグ生成方法を選択します。'encode' はパーセントエンコード文字列を生成します。'none'は、スラッグを無効にします。" enable_emoji: "絵文字を有効にする" - emoji_set: "How would you like your emoji?" - approve_unless_trust_level: "トラストレベル以下のユーザーの投稿には承認が必要" - skip_auto_delete_reply_likes: "古い返信を自動的に削除する場合は、この数以上のいいねの投稿の削除をスキップしてください。" - default_email_previous_replies: "デフォルトでメールの文章に以前の返信を含める" - default_other_skip_new_user_tips: "新規ユーザーのヒントとバッジをスキップする." + emoji_set: "絵文字はどうですか?" + approve_unless_trust_level: "この信頼レベル以下のユーザーの投稿には承認が必要" + skip_auto_delete_reply_likes: "古い返信を自動的に削除する場合は、この数以上の「いいね!」がある投稿の削除をスキップします。" + default_email_previous_replies: "デフォルトでメールに以前の返信を含めます。" + default_other_skip_new_user_tips: "新規ユーザーオンボーディングのヒントとバッジをスキップします。" tagging_enabled: "トピックでタグを有効にしますか?" - tag_style: "カテゴリバッジのスタイル" + tag_style: "タグバッジの視覚的スタイル" errors: - invalid_email: "不正なメールアドレスです" - invalid_username: "そのユーザ名のユーザは存在しません" - invalid_integer_min_max: "値は%{min}と%{max}の間である必要があります" - invalid_integer_min: "値は%{min}以上必要です" - invalid_integer_max: "値は%{max}以上にすることはできません" - invalid_integer: "値は整数で入力してください" - regex_mismatch: "値は、指定されたフォーマットと一致しませんでした" - must_include_latest: "トップメニューは'latest'タブを含む必要があります" - invalid_string: "不正な値です" - invalid_string_min_max: "文字数は%{min}と%{max}の間である必要があります" - invalid_string_min: "少なくとも%{min}文字は必要です" - invalid_string_max: "%{max}文字以下である必要があります" - invalid_reply_by_email_address: "値は'%{reply_key}'を含む必要があり、通知メールとは異なる必要があります" + invalid_email: "無効なメールアドレスです。" + invalid_username: "そのユーザー名のユーザは存在しません。" + invalid_integer_min_max: "値は %{min} から %{max} の間である必要があります。" + invalid_integer_min: "値は %{min} 以上である必要があります。" + invalid_integer_max: "値は %{max} を超えることはできません。" + invalid_integer: "値は整数である必要があります。" + regex_mismatch: "値は必要な形式に一致していません。" + must_include_latest: "トップメニューに [最新] タブを含む必要があります。" + invalid_string: "無効な値です。" + invalid_string_min_max: "文字数は %{min} から %{max} の間である必要があります。" + invalid_string_min: "少なくとも %{min} 文字である必要があります。" + invalid_string_max: "%{max} 文字以下である必要があります。" + invalid_reply_by_email_address: "値に '%{reply_key}' を含める必要があり、通知メールとは異なる必要があります。" min_username_length_range: "最小値を最大値より上にすることはできません。" search: - within_post: "#%{post_number} by %{username}" + within_post: "%{username} の #%{post_number}" types: category: "カテゴリ" topic: "結果" - user: "ユーザ" + user: "ユーザー" discourse_connect: - timeout_expired: "アカウントへのログインがタイムアウトになりました。もう一度トップページからログインしてください。" - original_poster: "Original Poster" - most_posts: "Most Posts" - most_recent_poster: "Most Recent Poster" - frequent_poster: "Frequent Poster" + timeout_expired: "アカウントへのログインがタイムアウトになりました。もう一度ログインしてください。" + original_poster: "元の投稿者" + most_posts: "最も多い投稿" + most_recent_poster: "最も最近の投稿者" + frequent_poster: "頻繁な投稿者" redirected_to_top_reasons: - new_user: "コミュニティへようこそ! これらが最も人気な最近のトピックです" - not_seen_in_a_month: "お帰りなさい! 最近見かけませんでしたね。 これらがあなたが離れてから最も人気のトピックです" + new_user: "コミュニティへようこそ!最も人気のある最近のトピックはこちらです。" + not_seen_in_a_month: "お帰りなさい!最近見かけませんでしたね。あなたがいない間に最も人気の高かったトピックはこちらです。" move_posts: existing_topic_moderator_post: - other: "%{count}つのポストが存在するトピックに統合されました:%{topic_link}" + other: "%{count} 件の投稿は次の既存のトピックにマージされました: %{topic_link}" publish_page: slug_errors: - blank: "を入力してください" - invalid: "は不正な文字を含んでいます" + blank: "は空にできません" + invalid: "に無効な文字が含まれています" topic_statuses: autoclosed_message_max_posts: - other: "このメッセージは%{count}つの返信に達した時に自動的に閉じます。" + other: "このメッセージは返信が %{count} 件に達した時に自動的にクローズされました。" autoclosed_topic_max_posts: - other: "このトピックは%{count}つの返信に達した時に自動的に閉じます。" + other: "このトピックは返信が %{count} 件に達した時に自動的にクローズされました。" autoclosed_enabled_days: - other: "このトピックは%{count}日が経過したので自動的にクローズされました。新たに返信することはできません。" + other: "このトピックは %{count} 日が経過したので自動的にクローズされました。新たに返信することはできません。" autoclosed_enabled_hours: - other: "このトピックは%{count}時間が経過したので自動的にクローズされました。新たに返信することはできません。" + other: "このトピックは %{count} 時間が経過したので自動的にクローズされました。新たに返信することはできません。" autoclosed_enabled_minutes: - other: "このトピックは%{count}分が経過したので自動的にクローズされました。新たに返信することはできません。" + other: "このトピックは %{count} 分が経過したので自動的にクローズされました。新たに返信することはできません。" autoclosed_enabled_lastpost_days: - other: "このトピックは最後の返信から%{count}日が経過したので自動的にクローズされました。新たに返信することはできません。" + other: "このトピックは最後の返信から %{count} 日が経過したので自動的にクローズされました。新たに返信することはできません。" autoclosed_enabled_lastpost_hours: - other: "このトピックは最後の返信から%{count}時間が経過したので自動的にクローズされました。新たに返信することはできません。" + other: "このトピックは最後の返信から %{count} 時間が経過したので自動的にクローズされました。新たに返信することはできません。" autoclosed_enabled_lastpost_minutes: - other: "このトピックは最後の返信から%{count}分が経過したので自動的にクローズされました。新たに返信することはできません。" - autoclosed_disabled: "このトピックは再オープンされました。新しい回答が投稿できるようになりました。。" - autoclosed_disabled_lastpost: "このトピックは再オープンされました。新しい回答が投稿できます" + other: "このトピックは最後の返信から %{count} 分が経過したので自動的にクローズされました。新たに返信することはできません。" + autoclosed_disabled: "このトピックはオープンになりました。新しい返信を投稿できます。" + autoclosed_disabled_lastpost: "このトピックはオープンになりました。新しい返信を投稿できます。" login: - security_key_description: "物理的なセキュリティキーを用意している場合は、以下のセキュリティキーで認証ボタンを押します。" + security_key_description: "物理的なセキュリティキーの準備ができたら、[セキュリティキーで認証] ボタンを押します。" security_key_alternative: "別の方法を試してください" security_key_authenticate: "セキュリティキーで認証" - security_key_not_allowed_error: "セキュリティキー認証プロセスがタイムアウトしたか、キャンセルされました。" + security_key_not_allowed_error: "セキュリティキーの認証プロセスがタイムアウトしたかキャンセルされました。" security_key_no_matching_credential_error: "提供されたセキュリティキーに一致する資格情報が見つかりませんでした。" - security_key_support_missing_error: "現在のデバイスまたはブラウザは、セキュリティキーの使用をサポートしていません。別の方法を使用してください。" - not_approved: "あなたのアカウントはまだ承認されていません。ログイン可能になった際にメールで通知いたします。" - incorrect_username_email_or_password: "ユーザ名、メールアドレス、またはパスワードが違います" - incorrect_password: "パスワードが違います" - wait_approval: "サインアップありがとうござました。アカウントが承認され次第メールにて通知いたします。" - active: "アカウントが利用可能になりました。" - not_activated: "まだログインできません。メールを送信済ですので、メールの指示に従ってあなたのアカウントを有効にしてください。" - not_allowed_from_ip_address: "そのIPアドレスからは%{username} としてログインできません" - admin_not_allowed_from_ip_address: "そのIPアドレスからは管理者としてログインできません" + security_key_support_missing_error: "現在のデバイスまたはブラウザではセキュリティキーの使用がサポートされていません。別の方法を使用してください。" + not_approved: "あなたのアカウントはまだ承認されていません。ログインできるようになったら、メールで通知します。" + incorrect_username_email_or_password: "ユーザー名、メールアドレス、またはパスワードが誤っています" + incorrect_password: "パスワードが誤っています" + wait_approval: "ご登録いただきありがとうございます。アカウントが承認され次第メールで通知いたします。" + active: "アカウントの確認が完了し、利用可能になりました。" + not_activated: "まだログインできません。確認メールを送信しましたので、メールに記載の指示に従ってあなたのアカウントを有効にしてください。" + not_allowed_from_ip_address: "その IP アドレスからは %{username} としてログインできません。" + admin_not_allowed_from_ip_address: "その IP アドレスからは管理者としてログインできません。" suspended: "%{date} までログインできません。" suspended_with_reason: "%{date} までアカウントが凍結されました: %{reason}" errors: "%{errors}" not_available: "それは利用できません。\"%{suggestion}\" はどうですか?" - something_already_taken: "エラーが発生しました。ユーザ名またはメールアドレスが既に使用中の可能性があります。パスワードリセットを行ってください。" - omniauth_confirm_button: "続く" - new_registrations_disabled: "新規登録は現在行えません。" - password_too_long: "パスワードは200文字までです" - email_too_long: "メールアドレスが長過ぎます。アドレス部は254文字以内に、ドメイン部は253文字以内にする必要があります" + something_already_taken: "エラーが発生しました。ユーザー名またはメールアドレスがすでに登録されている可能性があります。パスワードリセットを行ってください。" + omniauth_confirm_button: "続行" + new_registrations_disabled: "現在、新しいアカウントの登録を行えません。" + password_too_long: "パスワードは 200 文字までです。" + email_too_long: "メールアドレスが長すぎます。メールボックス名は 254 文字以内、ドメイン名は 253 文字以内である必要があります。" reserved_username: "そのユーザー名は許可されていません" - missing_user_field: "全てのユーザーフィールドの入力が終わっていません" + missing_user_field: "すべてのユーザーフィールドに入力されていません。" auth_complete: "認証が完了しました。" - click_to_continue: "クリックして続けてください。" - second_factor_title: "2段階認証" - second_factor_backup_description: "バックアップコードを入力: " + click_to_continue: "ここをクリックして続行します。" + second_factor_title: "二要素認証" + second_factor_backup_description: "バックアップコードの 1 つを入力してください。" second_factor_toggle: backup_code: "代わりにバックアップコードを使用する" admin: email: - sent_test: "送信完了!" + sent_test: "送信完了!" user: username: - short: "少なくとも%{min}文字は必要です" - long: "%{max}文字以下である必要があります" - unique: "はユニークである必要があります" - blank: "は空であってはなりません" - must_begin_with_alphanumeric_or_underscore: "最初は英数字で始まる必要があります" - must_end_with_alphanumeric: "最後は英数字で終わる必要があります" + short: "は少なくとも %{min} 文字である必要があります" + long: "は %{max} 文字以下である必要があります" + unique: "は一意である必要があります" + blank: "は空にできません" + must_begin_with_alphanumeric_or_underscore: "は英数字またはアンダースコアで始まる必要があります" + must_end_with_alphanumeric: "は英数字で終わる必要があります" email: - not_allowed: "はこのメールプロバイダーを許可していません。他のメールアドレスを使用してください。" + not_allowed: "はこのメールプロバイダーを許可していません。別のメールアドレスを使用してください。" blocked: "は許可されていません。" does_not_exist: "該当なし" ip_address: - blocked: "あなたのIPアドレスからの新規登録は許可されていません" - max_new_accounts_per_registration_ip: "あなたのIPアドレスからの新規登録は行えません(登録可能な数を超えています)。スタッフへお問い合わせください。" + blocked: "あなたの IP アドレスからの新規登録は許可されていません。" + max_new_accounts_per_registration_ip: "あなたの IP アドレスからの新規登録は許可されていません (登録可能な数を超えています)。スタッフメンバーにお問い合わせください。" invite_mailer: - subject_template: "%{inviter_name}が%{site_domain_name}の%{topic_title}に招待しました。" + subject_template: "%{inviter_name} があなたを %{site_domain_name} の「%{topic_title}」に招待しました" text_body_template: | - %{inviter_name}があなたを招待しました - - %{topic_title} - - %{topic_excerpt} + %{inviter_name} があなたをディスカッションに招待しました > %{site_title} -- %{site_description} - もし興味があれば、下のリンクをクリックしてください: + にある + + > **%{topic_title}** + > + > %{topic_excerpt} + + 興味があれば、下のリンクをクリックしてください %{invite_link} custom_invite_mailer: - subject_template: "%{inviter_name}が%{site_domain_name}の%{topic_title}にあなたを招待しました" + subject_template: "%{inviter_name} があなたを %{site_domain_name} の「%{topic_title}」に招待しました" text_body_template: | - %{inviter_name}が招待しました - - >**%{topic_title}** - > - >%{topic_excerpt} + %{inviter_name} があなたをディスカッションに招待しました > %{site_title} -- %{site_description} - メッセージ + にある - >%{user_custom_message} + > **%{topic_title}** + > + > %{topic_excerpt} - もしあなたが興味あれば、下のリンクをチェックしてください: + 以下のメッセージがあります + + > %{user_custom_message} + + 興味があれば、下のリンクをクリックしてください %{invite_link} invite_forum_mailer: - subject_template: "%{inviter_name}があなたを%{site_domain_name}に招待しました。" + subject_template: "%{inviter_name} があなたを %{site_domain_name} に招待しました" text_body_template: | - %{inviter_name}があなたを + %{inviter_name} があなたを招待しました >**%{site_title}** > >%{site_description} - もし興味があれば、下のリンクをチェックしてください!: + 興味があれば、下のリンクをクリックしてください %{invite_link} custom_invite_forum_mailer: - subject_template: "%{inviter_name}があなたを%{site_domain_name}に招待しました。" + subject_template: "%{inviter_name} があなたを %{site_domain_name} に招待しました" text_body_template: | - %{inviter_name}があなたを + %{inviter_name} があなたを招待しました - >**%{site_title}** + > **%{site_title}** > - >%{site_description} + > %{site_description} - メッセージ + 以下のメッセージがあります - >%{user_custom_message} + > %{user_custom_message} - もし興味があれば、下のリンクをチェックしてください: + 興味があれば、下のリンクをクリックしてください %{invite_link} invite_password_instructions: - subject_template: "%{site_name} アカウントのパスワードを設定" - text_body_template: | - %{site_name}への招待を許可してくださってありがとうございます。 -- ようこそ! - - リンクをクリックしてパスワードを設定してください: - %{base_url}/u/password-reset/%{email_token} - - (もし上のリンクが失効していたら、メールアドレスでログインする時に「パスワードを忘れた」を選択してください。) + subject_template: "%{site_name} アカウントのパスワード設定" + text_body_template: "%{site_name} への招待を承諾いただきありがとうございます。ようこそ!\n\nリンクをクリックしてパスワードを設定してください: \n%{base_url}/u/password-reset/%{email_token}\n\n(リンクの有効期限が切れている場合は、メールアドレスでログインする時に「パスワードを忘れました」を選択してください。)\n" flag_reasons: - off_topic: "あなたの投稿/トピックは「話題に関係ない」として通報されました" - spam: "あなたの投稿は「スパム」として通報されました。営利目的、宣伝目的の投稿を行った可能性があります。" - notify_moderators: "あなたの投稿は「モデレータへ通報」として通報されました。モデレータによる判断が必要な内容を含んでいる可能性があります。" + off_topic: "あなたの投稿は「話題に関係ない」として通報されました。コミュニティはあなたの投稿がタイトルと最初の投稿で定義されているトピックにふさわしくないと判断しました。" + spam: "あなたの投稿は「迷惑」として通報されました。コミュニティはあなたの投稿に営利目的、宣伝目的の性質があり、トピックに意図される有用性または関連性がないと判断しました。" + notify_moderators: "あなたの投稿は「モデレーターの注意要」として通報されました。コミュニティはあなたの投稿にスタッフメンバーによる手動介入が必要であると判断しました。" flags_dispositions: - agreed: "伝えてくれてありがとうございます。問題に同意し、調査しています" - agreed_and_deleted: "伝えてくれてありがとうございます。問題に同意し、投稿を削除します" - disagreed: "伝えてくれてありがとうございます。調査しています" - ignored: "伝えてくれてありがとうございます。調査しています" - ignored_and_deleted: "伝えてくれてありがとうございます。投稿を削除しました" + agreed: "ご連絡いただきありがとうございます。問題があることを認め、現在調査しています。" + agreed_and_deleted: "ご連絡いただきありがとうございます。問題があることを認め、投稿を削除しました。" + disagreed: "ご連絡いただきありがとうございます。現在調査しています。" + ignored: "ご連絡いただきありがとうございます。現在調査しています。" + ignored_and_deleted: "ご連絡いただきありがとうございます。投稿を削除しました。" system_messages: private_topic_title: "トピック #%{id}" - contents_hidden: "内容を確認するためにその投稿にアクセスして下さい。" + contents_hidden: "コンテンツを見るには投稿にアクセスしてください。" post_hidden: title: "非表示の投稿" - subject_template: "コミュニティーフラッグによって非表示にされた投稿" + subject_template: "コミュニティ通報によって非表示にされた投稿" post_hidden_again: title: "再度非表示" - subject_template: "コミュニティーフラッグにより非表示になりました。" + subject_template: "コミュニティ通報によって非表示にされた投稿、スタッフ通知済み" queued_by_staff: - title: "この投稿は承認が必要です" + title: "承認待ちの投稿" welcome_user: title: "ようこそ:ユーザー" - subject_template: "%{site_name} へようこそ!" + subject_template: "%{site_name} へようこそ!" text_body_template: | - %{site_name}に参加ありがとうございます! + %{site_name} に参加していただきありがとうございます! %{new_user_tips} - 私たちは [civilized community behavior](%{base_url}/guidelines) に基づいています。 + コミュニティでは常に礼節ある行動 (%{base_url}/guidelines) を取ってもらえると信じています。 - 楽しんで! + ではお楽しみください! welcome_invite: - subject_template: "%{site_name}へようこそ!" + subject_template: "%{site_name} へようこそ!" backup_succeeded: subject_template: "バックアップは正常に完了しました" backup_failed: - subject_template: "バックアップ失敗" + subject_template: "バックアップに失敗しました" restore_succeeded: - subject_template: "復元が正常に完了しました" + subject_template: "復元は正常に完了しました" restore_failed: subject_template: "復元に失敗しました" bulk_invite_succeeded: - subject_template: "ユーザの一括招待に成功しました。" - text_body_template: "ユーザの一括招待が処理され、%{sent}通の招待メールを送信しました" + subject_template: "ユーザーの一括招待に成功しました" + text_body_template: "ユーザーの一括招待ファイルが処理され、%{sent} 件の招待状が送信されました。" bulk_invite_failed: - subject_template: "ユーザの一括招待中にエラーが発生しました" + subject_template: "ユーザーの一括招待中にエラーが発生しました" csv_export_failed: subject_template: "データのエクスポートに失敗しました" email_reject_parsing: text_body_template: | - 申し訳ありません,あなたの %{destination} へのメール(titled %{former_title}) は動きませんでした - メールから回答を見つける事ができませんでした。返信はメールのトップにあることを確認してください。インライン回答を見つける事はできません。 + %{destination} へのメールメッセージ (件名: %{former_title}) は処理されませんでした。 + + メールに返信が見つかりませんでした。**メールのトップに返信が入力されていることを確認してください** インライン返信は処理できません。 email_reject_invalid_post: text_body_template: | - 申し訳ありません,あなたの %{destination} へのメール(titled %{former_title}) は動きませんでした + %{destination} へのメールメッセージ (件名: %{former_title}) は処理されませんでした。 - 複雑な書式、大きすぎるメッセージ、小さすぎるメッセージなど、複数の原因が考えられます。もう一度試してください。もし続くようであれば、ウェブサイトから投稿してください + 複雑な書式、メッセージのサイズなどの原因が考えられます。もう一度お試しください。このエラーが続くようであれば、ウェブサイトから投稿してください。 email_reject_invalid_post_specified: text_body_template: | - 申し訳ありません,あなたの %{destination} へのメール(titled %{former_title}) は動きませんでした + %{destination} へのメールメッセージ (件名: %{former_title}) は処理されませんでした。 理由: %{post_error} - 問題が修正できたら、もう一度試してください + 問題を修正できるようであれば、もう一度お試しください。 spam_post_blocked: - subject_template: "同一リンクの連続投稿による新規ユーザ %{username} のブロック" + subject_template: "同一リンクの連続投稿により、新規ユーザー %{username} の投稿をブロック" pending_users_reminder: subject_template: - other: "%{count} 人のユーザか承認を待っています。" + other: "承認待ちのユーザーが %{count} 人います" download_remote_images_disabled: - subject_template: "リモート画像のダウンロードを無効化" - text_body_template: "'download_remote_images_threshold'の制限に達したため、`download_remote_images_to_local`の設定は無効になりました" + subject_template: "リモート画像のダウンロードは無効化されました" + text_body_template: "`download_remote_images_threshold` のディスク空き容量制限に達したため、`download_remote_images_to_local` 設定は無効になりました。" unsubscribe_link: | - 配信停止をご希望の場合は、[こちらをクリック](%{unsubscribe_url})してください。 + メールの購読を解除するには、[こちらをクリック](%{unsubscribe_url})してください。 unsubscribe_link_and_mail: | - 配信停止をご希望の場合は、[こちらをクリック](%{unsubscribe_url})してください。 + メールの購読を解除するには、[こちらをクリック](%{unsubscribe_url})してください。 unsubscribe_mailing_list: | - 本メッセージは、メーリングリストモードの設定が有効になっているため配信されました。 - 配信停止をご希望の場合は、[こちらをクリック](%{unsubscribe_url})してください。 - subject_re: "Re:" - subject_pm: "[プライベートメッセージ]" + 本メッセージは、メーリングリストモードが有効になっているため配信されました。 + + メールの購読を解除するには、[こちらをクリック](%{unsubscribe_url})してください。 + subject_re: "Re: " + subject_pm: "[PM] " user_notifications: - previous_discussion: "以前の回答" + previous_discussion: "以前の返信" unsubscribe: - title: "解除" - description: "メールに興味がありませんか? 下のリンクをクリックすると、即座にメール解除ができます:" - reply_by_email: "回答するにはこのメールに返信するか、 (%{base_url}%{url}) に[ブラウザでアクセスしてください]。" - visit_link_to_respond: "回答するには(%{base_url}%{url})に[ブラウザでアクセスしてください]。" + title: "購読解除" + description: "メールの購読に興味がありませんか?下のリンクをクリックすると、すぐに購読を解除できます:" + reply_by_email: "返信するには、[トピックにアクセス](%{base_url}%{url})するか、このメールに返信してください。" + visit_link_to_respond: "返信するには[トピックにアクセス](%{base_url}%{url})してください。" posted_by: "%{post_date} に %{username} が投稿" account_second_factor_disabled: text_body_template: | - アカウントの2要素認証が %{site_name}で無効になっています。これで、パスワードのみでログインできます。追加の認証コードは不要になりました。 + %{site_name} のアカウントの二要素認証が無効になったため、パスワードのみでログインできるようになりました。今後、追加の認証コードは不要です。 - 2要素認証を無効にすることを選択しなかった場合、誰かがあなたのアカウントを侵害した可能性があります。 + 二要素認証を無効にしなかった場合、誰かがあなたのアカウントを侵害した可能性があります。 - 不明な点がございましたら、[スタッフにお問い合わせください](%{base_url}/ about)。 + 不明な点がございましたら、[スタッフにお問い合わせください](%{base_url}/about)。 digest: - why: "あなたが最後にアクセスした %{last_seen_at} 以降の %{site_link} のまとめです" - since_last_visit: "最後の訪問" - new_topics: "新しいトピック" - unread_notifications: "未読通知" - liked_received: "いいねを受領" - new_users: "新しいユーザー" - popular_topics: "人気トピック" - follow_topic: "トピックをフォロー" - join_the_discussion: "続きを読む" - popular_posts: "人気投稿" - subject_template: "[%{email_prefix}]まとめ" - unsubscribe: "このまとめは、しばらくサイトを訪れていないため、%{site_link}から送られました。%{email_preferences_link}を変更するか、%{unsubscribe_link}購読を中止できます。" + why: "最後にアクセスした %{last_seen_at} 以降の %{site_link} のまとめ" + since_last_visit: "最終アクセス以降" + new_topics: "新規トピック" + unread_notifications: "未読の通知" + liked_received: "受け取った「いいね!」" + new_users: "新規ユーザー" + popular_topics: "人気のトピック" + follow_topic: "このトピックをフォロー" + join_the_discussion: "もっと読む" + popular_posts: "人気の投稿" + subject_template: "[%{email_prefix}] 要約" + unsubscribe: "この要約は、しばらくアクセスがなかった場合に %{site_link} から送信されます。%{email_preferences_link} を変更するか、%{unsubscribe_link} で購読を解除できます。" your_email_settings: "メール設定" click_here: "こちらをクリック" - preheader: "%{last_seen_at}の最後の訪問以降の簡単な要約" + preheader: "%{last_seen_at} の最終アクセス以降の要約" forgot_password: - title: "パスワードを忘れた" - subject_template: "[%{email_prefix}]パスワードのリセット" + title: "パスワードを忘れました" + subject_template: "[%{email_prefix}] パスワードのリセット" text_body_template: | - 誰かが[%{site_name}](%{base_url})のパスワードをリセットしようとしてます。 + 誰かが [%{site_name}](%{base_url}) のパスワードをリセットしようとしてます。 - もしあなたでなければ、メールを無視していただいて構いません。 + あなたがリクエストしなかった場合は、メールを無視していただいても構いません。 - 以下をのリンクをクリックして新しいパスワードを設定してください: + 新しいパスワードを設定するには、次のリンクをクリックしてください: %{base_url}/u/password-reset/%{email_token} email_login: title: "リンク経由のログイン" - subject_template: "[%{email_prefix}]リンク経由のログイン" + subject_template: "[%{email_prefix}] リンク経由のログイン" text_body_template: | - [%{site_name}](%{base_url})にログインするためのリンクです。 + これは、[%{site_name}](%{base_url}) にログインするためのリンクです。 - もし、このリンクをリクエストしていなければ、メールを無視して構いません。 + このリンクをリクエストしなかった場合は、メールを無視していただいても構いません。 - 以下のリンクをチェックしてください: + ログインするには次のリンクをクリックしてください: %{base_url}/session/email-login/%{email_token} set_password: title: "パスワードを設定" admin_login: text_body_template: | - [%{site_name}](%{base_url})にログインするためのリンクです。 + 誰かが [%{site_name}](%{base_url}) のアカウントにログインしようとしています。 - もし、このリンクをリクエストしていなければ、メールを無視しても問題ありません。 + これをリクエストしなかった場合は、メールを無視していただいても構いません。 - ログインするには以下のリンクをクリックしてください: + ログインするには次のリンクをクリックしてください: %{base_url}/session/email-login/%{email_token} account_created: - subject_template: "[%{email_prefix}]新しいアカウント" + subject_template: "[%{email_prefix}] 新しいアカウント" confirm_new_email: - title: "メールを確認してください" - subject_template: "[%{email_prefix}]新しいメールアドレスを確認してください" + title: "新しいメールを確認してください" + subject_template: "[%{email_prefix}] 新しいメールアドレスを確認してください" confirm_new_email_via_admin: - title: "メールを確認してください" - subject_template: "[%{email_prefix}]新しいメールアドレスを確認してください" + title: "新しいメールを確認してください" + subject_template: "[%{email_prefix}] 新しいメールアドレスを確認してください" confirm_old_email: - title: "古いメールの確認" - subject_template: "[%{email_prefix}]現在のメールアドレスの確認" + title: "古いメールを確認してください" + subject_template: "[%{email_prefix}] 現在のメールアドレスを確認してください" confirm_old_email_add: - subject_template: "[%{email_prefix}]現在のメールアドレスの確認" + subject_template: "[%{email_prefix}] 現在のメールアドレスを確認してください" signup_after_approval: - subject_template: "%{site_name} への参加承認完了!" + subject_template: "%{site_name} での承認が完了しました!" signup: - title: "サインアップ" - subject_template: "[%{email_prefix}]新しいアカウントの確認" + title: "登録" + subject_template: "[%{email_prefix}] 新しいアカウントを確認してください" text_body_template: | - ようこそ%{site_name}へ! + %{site_name} へようこそ! - アカウントを確認して有効にするには、次のリンクをクリックしてください: + 新しいアカウントを確認して有効にするには、次のリンクをクリックしてください: %{base_url}/u/activate-account/%{email_token} - もし上のリンクがクリックできなければ、webサイトのアドレスバーに直接コピーして貼り付けてください。 + このリンクがクリックできない場合は、リンクをコピーしてウェブブラウザのアドレスバーに貼り付けてください。 post_approved: title: "あなたの投稿は承認されました" page_not_found: - title: "ページが見つからないか、アクセス出来ない場所にあります。" + title: "おっとっと!ページが存在しないか非公開です。" popular_topics: "人気" - recent_topics: "最新" - see_more: "もっと見る" - search_title: "サイトを検索" + recent_topics: "最近" + see_more: "もっと" + search_title: "このサイトを検索" search_button: "検索" login_required: welcome_message: | - ## [%{title}へようこそ](#welcome) - アカウントが必要です。続けるにはアカウントを作るかログインしてください。 + ## [%{title} へようこそ](#welcome) + アカウントが必要です。続行するには、アカウントを作成するかログインしてください。 welcome_message_invite_only: | - ## [%{title}へようこそ](#welcome) - アカウントが必要です。続けるには既存のメンバーに招待を依頼するかログインしてください。 + ## [%{title} へようこそ](#welcome) + アカウントが必要です。続行するには、既存のメンバーに招待をリクエストするかログインしてください。 deleted: "削除" image: "画像" upload: edit_reason: "ダウンロードされた画像のコピー" - unauthorized: "申し訳ありません、ファイルのアップロードが拒否されました (サポートするファイルの拡張子: %{authorized_extensions})." + unauthorized: "アップロードしようとしているファイルは許可されていません (許可されている拡張子: %{authorized_extensions})。" pasted_image_filename: "貼り付けた画像" - store_failure: "ユーザ#%{user_id}のアップロード#%{upload_id} の保存に失敗しました" + store_failure: "ユーザー #%{user_id} のアップロード #%{upload_id} の保存に失敗しました。" attachments: - too_large: "申し訳ありませんが、ファイルが大きすぎてアップロードできません (最大サイズは %{max_size_kb}KB です)" + too_large: "アップロードしようとしているファイルが大きすぎます (最大サイズは %{max_size_kb} KB です)。" images: - too_large: "申し訳ありませんが、画像が大きすぎてアップロードできません (最大サイズは %{max_size_kb}KB です)。リサイズして再アップロードしてください" - size_not_found: "申し訳ありませんが、画像のサイズを判定できませんでした。画像が壊れているかもしれません。" + too_large: "アップロードしようとしている画像が大きすぎます (最大サイズは %{max_size_kb} KB です)。サイズを変更してもう一度お試しください。" + size_not_found: "画像のサイズを判定できませんでした。画像が破損している可能性があります。" skipped_email_log: - user_email_no_user: "id %{user_id}のユーザーは見つかりませんでした" - user_email_post_not_found: "id %{post_id}のポストは見つかりません" + user_email_no_user: "ID %{user_id} のユーザーが見つかりません" + user_email_post_not_found: "ID %{post_id} の投稿が見つかりません" user_email_anonymous_user: "ユーザーは匿名です" - user_email_user_suspended_not_pm: "ユーザーは凍結状態です、プライベートメッセージはありません" - user_email_seen_recently: "最近訪れたユーザ" - user_email_notification_already_read: "メールが既読になったという通知です" - user_email_notification_topic_nil: "post.topic is nil" - user_email_post_deleted: "投稿は投稿者によって削除されました" - user_email_user_suspended: "ユーザは凍結状態です" - user_email_already_read: "ユーザーはポストを既に読んだ" - sender_message_blank: "メッセージが空" - sender_message_to_blank: "メッセージの宛先(to)が空" - sender_text_part_body_blank: "テキストの本文が空です" + user_email_user_suspended_not_pm: "ユーザーは凍結状態ですが、メッセージは凍結されていません" + user_email_seen_recently: "最近アクセスしたユーザー" + user_email_notification_already_read: "このメールに関する通知は既読です" + user_email_notification_topic_nil: "post.topic は NIL です" + user_email_post_deleted: "投稿は作者によって削除されました" + user_email_user_suspended: "ユーザーは凍結状態です" + user_email_already_read: "ユーザーはこの投稿を閲覧済みです" + sender_message_blank: "メッセージが空です" + sender_message_to_blank: "message.to が空です" + sender_text_part_body_blank: "text_part.body が空です" sender_body_blank: "本文が空です" - sender_post_deleted: "投稿が削除されました" + sender_post_deleted: "投稿は削除されています" sender_message_to_invalid: "受信者のメールアドレスが無効です" - sender_topic_deleted: "トピックは削除されました" + sender_topic_deleted: "トピックは削除されています" color_schemes: base_theme_name: "ベース" default_theme_name: "デフォルト" - edit_this_page: "ページを編集" + edit_this_page: "このページを編集" csv_export: boolean_yes: "はい" boolean_no: "いいえ" - rate_limit_error: "投稿者は1日1回ダウンロードすることが可能です、明日再度お試しください。" + rate_limit_error: "投稿のダウンロードは 1 日に 1 回のみ可能です。明日再度お試しください。" static_topic_first_reply: | - %{page_name}ページのコンテンツを編集するには、このトピックの最初の投稿を編集してください + %{page_name} ページのコンテンツを変更するには、このトピックの最初の投稿を編集してください。 guidelines_topic: title: "FAQ/ガイドライン" tos_topic: @@ -1690,16 +1688,16 @@ ja: homepage_style: choices: latest: - label: "最新トピック" + label: "最新のトピック" categories_only: - label: "カテゴリー" + label: "カテゴリのみ" categories_with_featured_topics: - label: "カテゴリーと特集されたトピック" + label: "注目のトピックのカテゴリ" categories_and_latest_topics: - label: "カテゴリーと最新トピック" + label: "カテゴリと最新トピック" categories_and_top_topics: - label: "カテゴリーと人気トピック" - joined: "参加日" + label: "カテゴリと人気トピック" + joined: "参加" discourse_push_notifications: popup: mentioned: '"%{topic}"で%{username} にメンションされました - %{site_title}' diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index cdeabf9b6e..ea000875ec 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -103,6 +103,7 @@ ko: maximum_staged_user_per_email_reached: "이메일 당 생성할 수 있는 격리 사용자의 최대치에 도달했습니다." no_subject: "(제목 없음)" no_body: "(내용 없음)" + missing_attachment: "(첨부 파일 %{filename}이 없음)" errors: empty_email_error: "수신한 raw mail이 공백일 때 발생합니다." no_message_id_error: "메일에 'Message-Id'헤더가 없을 때 발생합니다." @@ -262,6 +263,7 @@ ko: invalid_whisper_access: "속삭임이 활성화되어 있지 않거나 속삭임 게시물을 작성할 수있는 권한이 없습니다" not_in_group: title_topic: "이 항목을 보려면 '%{group}' 그룹의 가입을 요청해야 합니다." + title_category: "이 항목을 보려면 '%{group}' 그룹의 가입을 요청해야 합니다." request_membership: "회원 신청" join_group: "그룹 참여하기" deleted_topic: "죄송합니다! 이 주제는 삭제되었으며 더 이상 사용할 수 없습니다." @@ -1459,7 +1461,7 @@ ko: invite_code: "계정 등록이 가능하도록하려면이 코드를 입력해야합니다 (비어있을 경우 무시)." approve_suspect_users: "검토 대기열에 의심스러운 사용자를 추가하십시오. 의심스러운 사용자가 바이오 / 웹 사이트에 입장했지만 읽기 활동이 없습니다." review_every_post: "모든 게시물을 검토해야합니다. 경고! 사용량이 많은 사이트에는 권장되지 않습니다." - pending_users_reminder_delay: "새로운 사용자가 승인을 기다리는 시간이 여기에 지정된 시간횟수보다 더 길어길경우 운영자에게 알려줍니다. 알림을 해제하려면 -1로 설정하세요." + pending_users_reminder_delay_minutes: "새 사용자가 다음 시간보다 오랫동안 승인 대기 중일 경우 관리자에게 알립니다. 알림을 비활성화하려면 -1로 설정합니다." persistent_sessions: "사용자는 웹 브라우저가 닫혀도 로그인 상태를 유지합니다." maximum_session_age: "마지막 방문으로 부터 n시간 동안 사용자의 로그인이 유지됩니다" ga_version: "사용할 Google 유니버설 애널리틱스 버전: v3 (analytics.js), v4 (gtag)" @@ -1509,6 +1511,7 @@ ko: send_old_credential_reminder_days: "며칠 후 이전 자격 증명에 대해 알림" email_token_valid_hours: "비밀번호 찾기, 계정 활성화에 사용되는 토큰의 유효 기간(시간)" enable_badges: "배지 기능 사용" + max_favorite_badges: "사용자가 선택할 수있는 최대 배지 수" enable_whispers: "토픽 내 운영진 비공개 커뮤니케이션 허용" allow_index_in_robots_txt: "robots.txt에 웹 검색 엔진이이 사이트를 색인 할 수 있도록 지정하십시오. 예외적 인 경우 robots.txt를 영구적으로 무시할 수 있습니다." blocked_email_domains: "사용자가 계정을 등록 할 수없는 파이프- 로 구분 된 이메일 도메인 목록입니다. 예: mailinator.com | trashmail.net" @@ -1537,8 +1540,10 @@ ko: enable_discourse_connect_provider: "/session/sso_provider 끝점에서 DiscourseConnect (이전의 'Discourse SSO') 공급자 프로토콜을 구현하려면 discourse_connect_provider_secrets를 설정해야합니다." discourse_connect_url: "DiscourseConnect 엔드포인트의 URL (반드시 http:// 또는 https:// 를 포함해야 함)" discourse_connect_secret: "DiscourseConnect 정보를 암호화 방식으로 인증하는데 사용되는 비밀 문자열입니다. 10자 이상이어야 합니다." + discourse_connect_provider_secrets: "DiscourseConnect를 사용하는 도메인 시크릿 목록입니다. DiscourseConnect 암호가 10자 이상인지 확인하십시오. 와일드 카드 기호*는 모든 도메인 또는 그 일부만 일치시키는데 사용할 수 있습니다. (예. * .example.com)" discourse_connect_overrides_bio: "사용자 프로필의 사용자 소개 기능을 무시하고 사용자가 변경할 수 없도록 합니다." discourse_connect_overrides_groups: "모든 수동 그룹 구성원을 그룹 속성에 지정된 그룹과 동기화합니다. (경고: 그룹을 지정하지 않으면 모든 수동 그룹 구성원이 지워짐)" + auth_overrides_username: "로그인 할 때마다 외부 사이트의 사용자 이름으로 로컬 사용자 이름을 재정의하고 로컬 변경을 방지합니다. 모든 인증 공급자에 적용됩니다. (경고: 사용자 이름 길이/요구 사항의 차이로 인해 불일치가 발생할 수 있음)" auth_overrides_name: "로그인 할 때마다 사용자의 로컬 전체 이름을 무시하고 외부 사이트 전체 이름을 사용해 로컬 변경을 방지합니다. 모든 인증 공급자에 적용됩니다." discourse_connect_overrides_avatar: "외부 DiscourseConnect 페이로드의 아바타로 사용자 아바타를 재정의합니다. 활성화되면 사용자는 Discourse에서 아바타를 업로드 할 수 없습니다." discourse_connect_overrides_location: "DiscourseConnect 페이로드의 외부 위치로 사용자 위치를 재정의하고 로컬 변경을 방지합니다." @@ -1641,6 +1646,11 @@ ko: image_preview_jpg_quality: "크기가 조정된 이미지 파일의 품질 (1은 가장 낮은 품질, 99는 최고 품질, 100은 사용하지 않도록 설정)" allow_staff_to_upload_any_file_in_pm: "관리자는 개인메시지에 파일 첨부를 허용합니다." strip_image_metadata: "스트립 이미지 메타 데이터." + composer_media_optimization_image_enabled: "업로드된 이미지 파일의 클라이언트측 미디어 최적화를 활성화합니다." + composer_media_optimization_image_kilobytes_optimization_threshold: "클라이언트 측 최적화를 트리거하기 위한 최소 이미지 파일 크기" + composer_media_optimization_image_resize_dimensions_threshold: "클라이언트측 크기 조정을 트리거하는 최소 이미지 너비" + composer_media_optimization_image_resize_width_target: "너비가`composer_media_optimization_image_dimensions_resize_threshold`보다 큰 이미지는 이 너비로 크기가 조정됩니다. `composer_media_optimization_image_dimensions_resize_threshold`보다 크거나 같아야합니다." + composer_media_optimization_image_encode_quality: "재 인코딩 프로세스에 사용되는 JPEG 인코딩 품질입니다." min_ratio_to_crop: "큰 이미지를 자르는 데 사용되는 비율. 너비 / 높이의 결과를 입력하십시오." simultaneous_uploads: "글 작성기에서 끌어서 놓을 수 있는 최대 파일 수" default_invitee_trust_level: "초대 된 사용자의 기본 회원 등급 (0-4) 입니다." @@ -2787,6 +2797,18 @@ ko: email_reject_bad_destination_address: title: "이메일 거부 잘못된 대상 주소" subject_template: "[%{email_prefix}] 이메일 문제-알 수 없음 : 주소" + text_body_template: | + %{destination} (제목 : %{former_title})에 대한 이메일 메시지가 작동하지 않았습니다. + + 확인해야 할 사항은 다음과 같습니다. + + - 하나 이상의 이메일 주소를 사용하십니까? 원래 사용한 이메일 주소와 다른 이메일 주소로 답장 했습니까? 이메일 답장을 보내려면 답장 할 때 동일한 이메일 주소를 사용해야합니다. + + - 이메일 소프트웨어가 답장 할 때 Reply-To: 이메일 주소를 올바르게 사용 했습니까? 일부 이메일 소프트웨어는 From: 주소로 잘못 답장을 보냅니다. + + - 이메일의 Message-ID 헤더가 수정 되었습니까? Message-ID는 일관성이 있고 변경되지 않아야합니다. + + 도움이 더 필요하세요? %{base_url}/about 의 연락처 세부 정보를 통해 저희에게 연락하십시오. email_reject_old_destination: title: "이메일 거부 이전 대상" subject_template: "[%{email_prefix}] 이메일 문제-이전 알림에 답장하려고합니다" @@ -3010,6 +3032,7 @@ ko: only_reply_by_email_pm: "%{participants}에 응답하려면이 이메일에 회신하십시오." visit_link_to_respond: "[글](%{base_url}%{url})을 살펴보고 댓글을 작성해보세요." visit_link_to_respond_pm: "%{participants}에 응답하려면 [글 보기](%{base_url}%{url})을 살펴보세요." + reply_above_line: "##이 줄 위에 댓글을 입력하십시오. ##" posted_by: "%{username} 사용자가 %{post_date}에 게시하였습니다." pm_participants: "참가자 : %{participants}" invited_group_to_private_message_body: | diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index 3248cd1eac..aec7e833bf 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -1398,7 +1398,6 @@ nl: must_approve_users: "Stafleden moeten alle nieuwe gebruikersaccounts goedkeuren voordat ze de website mogen bezoeken." invite_code: "Gebruiker moet deze code intypen voor accountregistratie, genegeerd wanneer leeg (niet hoofdlettergevoelig)" approve_suspect_users: "Verdachte gebruikers toevoegen aan de wachtrij voor beoordeling. Verdachte gebruikers hebben een bio/website ingevoerd, maar hebben geen leesactiviteit." - pending_users_reminder_delay: "Moderators inlichten als nieuwe gebruikers langer dan dit aantal uren op goedkeuring wachten. Stel dit in op -1 om meldingen uit te schakelen." maximum_session_age: "Gebruikers blijven (n) uur na hun laatste bezoek aangemeld" gtm_container_id: "Google Tag Manager-container-ID, bv. GTM-ABCDEF.
      Opmerking: scripts van derden die door GTM worden geladen, dienen mogelijk op de acceptatielijst te worden geplaatst in 'content security policy script src'." enable_escaped_fragments: "Terugvallen naar Google's Ajax-Crawling-API als geen webcrawler wordt gedetecteerd. Zie https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index 57f38af831..b294c74b13 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -1580,7 +1580,6 @@ pl_PL: invite_code: "Użytkownik musi wpisać ten kod, aby zezwolić na rejestrację konta, ignorowany, gdy jest pusty (bez rozróżniania wielkości liter)" approve_suspect_users: "Dodaj podejrzanych użytkowników do kolejki recenzji. Podejrzani użytkownicy weszli do biografii / witryny, ale nie czytali." review_every_post: "Wszystkie posty muszą zostać sprawdzone. OSTRZEŻENIE! NIE ZALECA SIĘ DLA RUCHLIWYCH WITRYN." - pending_users_reminder_delay: "Powiadomić moderatorów jeżeli nowi użytkownicy czekali na zatwierdzenie dłużej niż his mamy godzin. Ustaw -1 aby wyłączyć powiadomienia. " persistent_sessions: "Użytkownicy pozostaną zalogowani po zamknięciu przeglądarki internetowej" maximum_session_age: "Użytkownik zostanie zalogowany przez n godzin od czasu ostatniej wizyty." ga_version: "Wersja Google Universal Analytics do użycia: v3 (analytics.js), v4 (gtag)" @@ -1762,6 +1761,10 @@ pl_PL: image_preview_jpg_quality: "Jakość plików graficznych o zmienionym rozmiarze (1 to najniższa jakość, 99 to najlepsza jakość, 100 bez zmian)." allow_staff_to_upload_any_file_in_pm: "Pozwól personelowi przesyłać pliki w wiadomościach." strip_image_metadata: "Pasek metadanych obrazu." + composer_media_optimization_image_enabled: "Umożliwia optymalizację przesyłanych plików graficznych po stronie klienta." + composer_media_optimization_image_kilobytes_optimization_threshold: "Minimalny rozmiar pliku obrazu do uruchomienia optymalizacji po stronie klienta" + composer_media_optimization_image_resize_dimensions_threshold: "Minimalna szerokość obrazu, aby wywołać zmianę rozmiaru po stronie klienta" + composer_media_optimization_image_encode_quality: "Jakość kodowania JPEG używana w procesie ponownego kodowania." min_ratio_to_crop: "Współczynnik stosowany do przycinania wysokich zdjęć. Wpisz wynik szerokości / wysokości." simultaneous_uploads: "Maksymalna liczba plików, które można przeciągnąć i upuścić w kompozytorze" default_invitee_trust_level: "Domyślny poziom zaufania (0-4) dla zaproszonych użytkowników." @@ -3239,6 +3242,7 @@ pl_PL: only_reply_by_email_pm: "Odpowiedz na tego e‐maila, by odpowiedzieć %{participants}." visit_link_to_respond: "[Zobacz Temat](%{base_url}%{url}) aby odpowiedzieć." visit_link_to_respond_pm: "[Wyświetl wiadomość] (%{base_url}%{url}), aby odpowiedzieć na %{participants}." + reply_above_line: "## Wpisz swoją odpowiedź powyżej tej linii. ##" posted_by: "Dodany przez %{username} w dniu %{post_date}" pm_participants: "Uczestnicy: %{participants}" invited_group_to_private_message_body: | diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index e685d7d648..c44d9e35f3 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -907,7 +907,6 @@ pt: traditional_markdown_linebreaks: "Utilize tradicionais quebras de linha no Markdown, que requer dois espaços no final para uma quebra de linha." post_undo_action_window_mins: "Número de minutos durante o qual os utilizadores têm permissão para desfazer ações numa mensagem (gostos, sinalizações, etc)." must_approve_users: "O pessoal deverá aprovar todas as contas dos novos utilizadores antes destes terem permissão para aceder ao sítio." - pending_users_reminder_delay: "Notificar moderadores se novos utilizadores estiverem à espera de aprovação por mais que esta quantidade de horas. Configurar com -1 para desativar notificações." maximum_session_age: "Utilizador permanecerá ligado durante n horas desde a última visita" cors_origins: "Permitidos compartilhamentos de recursos de origem-cruzada (CORS). Cada origem deve incluir http:// ou https://. A variável de ambiente DISCOURSE_ENABLE_CORS tem que estar configurada a verdadeiro para ativar o CORS." use_admin_ip_allowlist: "Os Administradores só podem iniciar sessão se estiverem num endereço IP definido na lista de IPs Rastreados (Admin > Logs > IPs Rastreados)." diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index adb39e0820..fb5ab6d5c7 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -1320,7 +1320,6 @@ pt_BR: markdown_linkify_tlds: "Lista de domínios de nível superior que são tratados automaticamente como links" post_undo_action_window_mins: "Número de minutos permitidos aos usuários para desfazerem uma ação recente em uma publicação (curtir, sinalizar, etc)" must_approve_users: "Gerenciadores deverão aprovar novas contas de usuários antes das mesmas serem permitidas o acesso ao site." - pending_users_reminder_delay: "Notifique os moderadores se novos usuários estiverem aguardando aprovação por mais tempo que muitas horas. Defina como -1 para desativar as notificações." maximum_session_age: "Usuário continuará logado por n horas desde a última visita" enable_escaped_fragments: "Voltar para a API do Google Ajax-Crawling se não for encontrado um webcrawler. Consulte https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" cors_origins: "Origens permitidas para pedidos de origem-cruzada (CORS). Cada origem deve incluir http:// ou https://. A variável de ambiente DISCOURSE_ENABLE_CORS deve ser definida como true para habilitar CORS." diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index 09d341dc64..8ab2496a67 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -853,7 +853,6 @@ ro: traditional_markdown_linebreaks: "Folosește întreruperi de rând tradiționale în Markdown, ceea ce necesită două spații pentru un capăt de rând. " post_undo_action_window_mins: "Numărul de minute în care utilizatorii pot anula acțiunile recente asupra unei postări (aprecieri, marcări cu marcaje de avertizare, etc)." must_approve_users: "Personalul trebuie să aprobe toate conturile utilizatorilor noi înainte ca ei să aibă aces în site." - pending_users_reminder_delay: "Notifică moderatorii dacă noii utilizatori sunt în așteptarea aprobării de mai mult de atâtea ore. Setează la -1 pentru a dezactiva notificările." maximum_session_age: "Utilizatorul va rămâne autentificat pentru n ore de la ultima vizită" cors_origins: "Origini permise pentru interogările inter-origini (CORS). Fiecare origine trebuie să includă http:// sau https://. Variabila env DISCOURSE_ENABLE_CORS trebuie setată pe true pentru a activa CORS." use_admin_ip_allowlist: "Adminii se pot autentifica numai dacă au o adresă IP definită în lista de IP-uri verificate (Admin > Rapoarte > IP-uri verificate) " diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index dfb693c318..b2ba13fef1 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -103,6 +103,7 @@ ru: maximum_staged_user_per_email_reached: "Достигнуто максимальное количество сымитированных пользователей, созданных по электронной почте." no_subject: "(нет темы)" no_body: "(нет содержимого)" + missing_attachment: "(Вложение %{filename} отсутствует)" continuing_old_discussion: one: "Продолжение обсуждение темы [%{title}](%{url}), поскольку указанная тема была создана более %{count} дня назад." few: "Продолжение обсуждение темы [%{title}](%{url}), поскольку указанная тема была создана более %{count} дней назад." @@ -288,6 +289,7 @@ ru: invalid_whisper_access: "Либо скрытые сообщения не разрешены, либо у вас нет прав на создание скрытых сообщений" not_in_group: title_topic: "Вам нужно запросить членство в группе '%{group}', чтобы увидеть эту тему." + title_category: "Вам нужно запросить членство в группе '%{group}', чтобы увидеть этот раздел." request_membership: "Запрос на включение в группу" join_group: "Присоединиться к группе" deleted_topic: "Ой, тема была удалена и более недоступна." @@ -1609,7 +1611,7 @@ ru: invite_code: "Пользователь должен ввести этот код для регистрации учётной записи (без учёта регистра)" approve_suspect_users: "Добавлять подозрительных пользователей в очередь премодерации. Пользователи с подозрительной активностью имеют доступ к своему профилю, но не могут читать сообщения." review_every_post: "Проверять каждое сообщение. ПРЕДУПРЕЖДЕНИЕ! НЕ РЕКОМЕНДУЕТСЯ ВКЛЮЧАТЬ ЭТОТ ПАРАМЕТР НА ЗАГРУЖЕННЫХ САЙТАХ." - pending_users_reminder_delay: "Уведомлять модераторов, если новые пользователи ждут одобрения больше, чем указанное здесь количество часов. Установите значение в -1 для отключения уведомлений." + pending_users_reminder_delay_minutes: "Уведомлять модераторов, если новые пользователи ждут одобрения больше, чем указанное здесь количество минут. Установите значение в '-1' для отключения уведомлений." persistent_sessions: "Пользователи остаются авторизованными после закрытия веб-браузера" maximum_session_age: "Пользователь остаётся в системе в течение указанного здесь количества часов с момента последнего посещения" ga_version: "Используемая версия Google Universal Analytics: v3 (analytics.js), v4 (gtag)" @@ -1659,6 +1661,7 @@ ru: send_old_credential_reminder_days: "Напомнить о старых учётных данных через указанное количество дней" email_token_valid_hours: "Ссылка на восстановление пароля / активацию аккаунта будет действовать в течение указанного здесь количества часов." enable_badges: "Включить систему наград" + max_favorite_badges: "Максимальное количество наград, которое может выбрать пользователь" enable_whispers: "Разрешать персоналу создавать скрытые сообщения в темах." allow_index_in_robots_txt: "Укажите в файле robots.txt, что этот сайт может быть проиндексирован поисковыми системами. В исключительных случаях вы можете навсегда переопределить файл robots.txt." blocked_email_domains: "Перечень почтовых доменов, разделённых вертикальной чертой, с которых запрещена регистрация учётных записей. Пример: mailinator.com|trashmail.net" @@ -1794,6 +1797,11 @@ ru: image_preview_jpg_quality: "Качество файлов JPG при изменении их размера (1 - низкое качество, 99 - лучшее качество, 100 - отключить параметр)." allow_staff_to_upload_any_file_in_pm: "Разрешать сотрудникам загружать любые файлы в личных сообщениях." strip_image_metadata: "Удалять метаданные из изображения." + composer_media_optimization_image_enabled: "Включить оптимизацию загружаемых файлов изображений на стороне клиента." + composer_media_optimization_image_kilobytes_optimization_threshold: "Минимальный размер файла изображения для запуска оптимизации на стороне клиента" + composer_media_optimization_image_resize_dimensions_threshold: "Минимальная ширина изображения для изменения размера изображения на стороне клиента" + composer_media_optimization_image_resize_width_target: "Для изображений с шириной больше, чем указано в параметре `composer_media_optimization_image_dimensions_resize_threshold`, ширина будет изменена до указанного здесь значения. Значение должно быть больше или равно параметру `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "Качество JPEG-кодирования, используемое в процессе оптимизации." min_ratio_to_crop: "Отношение, используемое для обрезки высоких изображений. Введите необходимое отношение ширины к высоте." simultaneous_uploads: "Максимальное количество файлов, которые можно вставить в редактор сообщения." default_invitee_trust_level: "Уровень доверия для приглашённых пользователей (от 0 до 4)." @@ -1910,8 +1918,10 @@ ru: reviewable_claiming: "Нужно ли резервировать проверяемый контент за конкретным сотрудником, прежде чем отправить контент на премодерацию?" reviewable_default_topics: "Показывать модерируемый контент, сгруппированный по темам" reviewable_default_visibility: "Не показывать модерируемый контент, если он не соответствует этому приоритету" + reviewable_low_priority_threshold: "Фильтр приоритета скрывает проверяемые объекты, которые не соответствуют выбранному фильтру, только если не используется фильтр 'любой'." high_trust_flaggers_auto_hide_posts: "Сообщения нового пользователя автоматически скрываются, если они помечаются как спам пользователем с уровнем доверия 3 и выше" cooldown_hours_until_reflag: "Количество часов, в течение которых пользователи не смогут повторно пожаловаться на сообщение" + slow_mode_prevents_editing: "Запрещать редактирование сообщений в замедленном режиме по прошествии льготного периода редактирования?" reply_by_email_enabled: "Разрешать отвечать в темах с помощью электронных писем." reply_by_email_address: "Шаблон для ответа по email в формате: %%{reply_key}@reply.example.com или replies+%%{reply_key}@example.com" alternative_reply_by_email_addresses: "Список альтернативных шаблонов для ответа, применяемый на основе входящих адресов электронной почты. Пример: %%{reply_key}@reply.example.com|replies+1%%{reply_key}@example.com" @@ -1999,14 +2009,14 @@ ru: max_daily_gravatar_crawls: "Максимальное количество запросов в течение дня, когда Discourse будет опрашивать Gravatar на наличие пользовательских аватаров" public_user_custom_fields: "Список пользовательских полей, которые можно получить с помощью API." staff_user_custom_fields: "Список пользовательских полей, которые можно получить для сотрудников с помощью API." - enable_user_directory: "Указать каталог пользователей для просмотра" + enable_user_directory: "Отображать список участников форума" enable_group_directory: "Указать каталог групп для просмотра" enable_category_group_moderation: "Разрешать группам модерировать контент в определённых разделах" group_in_subject: "Установить переменную %%{optional_pm} в теме электронного письма в соответствии с именем первой группы в личном кабинете, см. тему Настройка формата темы для стандартных электронных писем" allow_anonymous_posting: "Разрешать пользователям переключаться в анонимный режим." anonymous_posting_min_trust_level: "Минимальный уровень доверия для возможности создавать темы от имени анонимного пользователя." anonymous_account_duration_minutes: "Защита от слишком частого создания новых учётных записей.\nПример: Если значение установлено в 600, это означает, что должно пройти не менее 600 минут с момента последнего сообщения пользователя И его выхода из системы, после чего он сможет создать новую учётную запись." - hide_user_profiles_from_public: "Отключить карточки пользователей, профили пользователей и каталог пользователей для анонимных пользователей." + hide_user_profiles_from_public: "Отключить отображение карточек пользователей, профилей и списка участников форума для анонимных пользователей." allow_users_to_hide_profile: "Разрешить пользователям скрывать свой профиль и присутствие на форуме" allow_featured_topic_on_user_profiles: "Разрешать пользователям размещать ссылку на избранную тему в карточке пользователя и в профиле." show_inactive_accounts: "Разрешать авторизованным пользователям просматривать профили неактивных учётных записей." @@ -2476,6 +2486,11 @@ ru: email_in_spam_header: "Первое электронное письмо пользователя было помечено как спам" already_silenced: "Пользователь уже был заблокирован сотрудником %{staff} %{time_ago}." already_suspended: "Пользователь уже был заморожен сотрудником %{staff} %{time_ago}." + cannot_delete_has_posts: + one: "У пользователя %{username} есть %{count} сообщение, (публичное или личное), поэтому его нельзя удалить." + few: "У пользователя %{username} есть %{count} сообщения, (публичных или личных), поэтому его нельзя удалить." + many: "У пользователя %{username} есть %{count} сообщений, (публичных или личных), поэтому его нельзя удалить." + other: "У пользователя %{username} есть %{count} сообщений, (публичных или личных), поэтому его нельзя удалить." reviewables_reminder: submitted: one: "Сообщения были отправлены более %{count} часа назад. [Пожалуйста, просмотрите их](%{base_path}/review)." @@ -2785,13 +2800,13 @@ ru: Мы очень рады, что вы проводите время с нами, и мы хотели бы узнать о вас больше. Найдите минутку, чтобы [заполнить свой профиль](%{base_url}/my/preferences/profile) и не стесняйтесь [начать новую тему](%{base_url} /categories). welcome_staff: title: "Приветствуем нового сотрудника форума" - subject_template: "Поздравляем, вы получили статус %{role}!" + subject_template: "Поздравляем, вы получили статус '%{role}'!" text_body_template: | - Сотрудник присвоил вам статус %{role}. + Сотрудник присвоил вам статус '%{role}'. - У роли %{role} есть доступ к интерфейсу администратора. + У роли '%{role}' есть доступ к интерфейсу администратора. - С большой властью приходит большая ответственность. Если вы новичок в модерации, обратитесь к [Руководству модератора] (https://meta.discourse.org/t/discourse-moderation-guide/63116). + С большой властью приходит большая ответственность. Если роль модератора для вас в новинку - обратитесь к [Руководству модератора](https://meta.discourse.org/t/discourse-moderation-guide/63116). welcome_invite: title: "Приветствуем приглашённого пользователя форума" subject_template: "Добро пожаловать на сайт %{site_name}!" @@ -3267,6 +3282,7 @@ ru: only_reply_by_email_pm: "Ответьте на это личное сообщение от %{participants} по электронной почте." visit_link_to_respond: "Для ответа [посетите эту тему](%{base_url}%{url})." visit_link_to_respond_pm: "[Откройте это личное сообщение](%{base_url}%{url}) для ответа на это письмо от %{participants}." + reply_above_line: "## Пожалуйста, напечатайте ответ над этой линией. ##" posted_by: "Отправлено пользователем %{username} %{post_date}" pm_participants: "Участники: %{participants}" invited_group_to_private_message_body: | diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml index 0cb33b8407..84533dd9d1 100644 --- a/config/locales/server.sk.yml +++ b/config/locales/server.sk.yml @@ -813,7 +813,6 @@ sk: tl4_additional_likes_per_day_multiplier: "Zvýšiť počet páči sa mi na deň pre úroveň dôvery 4 (vedúci) vynásobením týmto číslom" traditional_markdown_linebreaks: "V Markdown použiť tradičné oddeľovače riadkov, čo vyžaduje dve koncové medzery ako oddeľovač riadku." post_undo_action_window_mins: "Počet minút počas ktorých môžu používatelia zrušiť poslednú akciu na príspevku (\"Páči sa\", označenie, atď..)." - pending_users_reminder_delay: "Upozorni moderátora ak nový používateľ čaká na schválenie dlhšie ako tento počet hodín. Nastavte -1 pre vypnutie upozornenia." cors_origins: "Povoľ prameňom/origins použitie cross-origin dotazov (CORS). Každý prameň/origin musí obsahovať http:// alebo https://. Aby ste umožnili CORS, systémové premenná DISCOURSE_ENABLE_CORS musí byť nastavená na true." use_admin_ip_allowlist: "Správcovia sa môťu prihlásiť iba pokiaľ pristupujú z adresy uvedenej v zozname kontrolovaných IP adries (Admin > Logs > Screened Ips)" top_menu: "Určuje, ktoré položky sa zobrazia na navigácií na domovskej stránke a v akom poradí. Napríklad posledné|neprečítané|kategórie|naj|prečítané|záložky" diff --git a/config/locales/server.sr.yml b/config/locales/server.sr.yml index b7b6eb4b53..d3108e40e0 100644 --- a/config/locales/server.sr.yml +++ b/config/locales/server.sr.yml @@ -333,7 +333,6 @@ sr: min_personal_message_title_length: "Minimalna dozvoljena dužina naslova poruke u znakovima" min_search_term_length: "Minimalna dozvoljena dužina termina za pretragu u znakovima" max_replies_in_first_day: "Maksimalan broj odgovora koje korisnik može da kreira u prvih 24 sata nakon kreiranja svoje prve poruke." - pending_users_reminder_delay: "Obavesti moderatore ako novi korisnik čeka na odobrenje duže nego ovoliko časova. Podesiti na -1 da bi se onemogućile notifikacije." maximum_session_age: "Korisnik će ostati ulogovan n časova od prethodnog logovanja" send_welcome_message: "Pošalji svim novim korisnicima poruku dobrodošlice sa kratkim uputstvom za početnike." dark_mode_none: "Ništa" diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index f729257414..d3b7683798 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -1488,7 +1488,7 @@ sv: invite_code: "Användare måste ange denna kod för att ha tillåtelse att registrera ett konto, ignoreras om det lämnas tomt (ej skiftlägeskänslig)" approve_suspect_users: "Lägg till misstänkta användare i kön för granskning. Misstänkta användare har angett en bio/webbsida men har ingen läsaktivitet." review_every_post: "Alla inlägg måste granskas. VARNING! REKOMMENDERAS INTE FÖR MYCKET AKTIVA WEBBPLATSER." - pending_users_reminder_delay: "Avisera moderatorer om nya användare har väntat på godkännande längre än så här många timmar. Ange -1 för att inaktivera aviseringar." + pending_users_reminder_delay_minutes: "Avisera moderatorer om nya användare har väntat på godkännande längre än så här många minuter. Ange -1 för att inaktivera aviseringar." persistent_sessions: "Användarna förblir inloggade när webbläsaren stängs" maximum_session_age: "Användaren kommer fortsättningsvis att vara inloggad i n timmar sedan senaste besöket" ga_version: "Version av Google Universal Analytics att använda: v3 (analytics.js), v4 (gtag)" @@ -1538,6 +1538,7 @@ sv: send_old_credential_reminder_days: "Påminn om gamla behörigheter efter dag" email_token_valid_hours: "Länkarna Glömt lösenord/aktivera konto är giltiga i (n) timmar." enable_badges: "Aktivera utmärkelsesystemet" + max_favorite_badges: "Maximalt antal utmärkelser som användaren kan välja" enable_whispers: "Tiliåt privat kommunikation inom ämnena." allow_index_in_robots_txt: "Ange i robots.txt att den här webbplatsen får indexeras av webbsökmotorer. I undantagsfall kan du permanent åsidosätta robots.txt." blocked_email_domains: "En textfil avgränsad med vertikalstreck med alla e-postdomäner som användare inte tillåts registrera konton med. Exempel: mailinator.com|trashmail.net" @@ -1673,6 +1674,11 @@ sv: image_preview_jpg_quality: "Kvaliteten på storleksändrade bildfiler (1 är lägsta kvalitet, 99 är bästa kvalitet, 100 för att inaktivera)." allow_staff_to_upload_any_file_in_pm: "Låt personalen ladda upp alla filer i PM." strip_image_metadata: "Radera metadata för bild." + composer_media_optimization_image_enabled: "Möjliggör medieoptimering på klientsidan av uppladdade bildfiler." + composer_media_optimization_image_kilobytes_optimization_threshold: "Minsta bildfilstorlek för att utlösa optimering på klientsidan" + composer_media_optimization_image_resize_dimensions_threshold: "Minsta bildbredd för att utlösa storleksändring på klientsidan" + composer_media_optimization_image_resize_width_target: "Bilder med större bredd än `composer_media_optimization_image_dimensions_resize_threshold` kommer att ändras till denna bredd. Måste vara >= än `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "JPEG-kodningskvalitet som används i omkodningsprocessen." min_ratio_to_crop: "Förhållandet används för att beskära höga bilder. Ange resultatet av bredd/höjd." simultaneous_uploads: "Maximalt antal filer som kan dras och släppas in i redigeraren" default_invitee_trust_level: "Förvald förtroendenivå (0-4) för inbjudna användare." @@ -1792,6 +1798,7 @@ sv: reviewable_low_priority_threshold: "Prioritetsfiltret döljer granskningsbara objekt som inte uppfyller den här poängen om inte filtret '(valfritt)' används." high_trust_flaggers_auto_hide_posts: "Ny användares inlägg döljs automatiskt efter att de har flaggats som skräppost av en användare med minst FN3" cooldown_hours_until_reflag: "Hur lång tid användare måste avvakta tills de kan flagga ett inlägg igen" + slow_mode_prevents_editing: "Förhindrar 'Långsamt läge' redigering, efter editing_grace_period?" reply_by_email_enabled: "Aktivera möjlighet att svara på ämnen via e-post." reply_by_email_address: "Mall för inkommande e-postadress för svar via e-post, till exempel: %%{reply_key}@svar.exempel.se eller svar+%%{reply_key}@exempel.se" alternative_reply_by_email_addresses: "Lista på alternativa mallar för svar genom inkommen e-post. Exempelvis: %%{reply_key}@svar.exempel.se|replies+%% {reply_key}@exempel.se" @@ -3006,6 +3013,7 @@ sv: only_reply_by_email_pm: "Besvara det här e-postmeddelandet för att svara %{participants}." visit_link_to_respond: "[Gå till ämnet](%{base_url}%{url}) för att ge ditt svar." visit_link_to_respond_pm: "[Gå till meddelandet](%{base_url}%{url}) för att svara till %{participants}." + reply_above_line: "## Vi ber dig skriva ditt svar ovanför denna rad. ##" posted_by: "Publicerat av %{username} den %{post_date}" pm_participants: "Deltagare: %{participants}" invited_group_to_private_message_body: "%{username} bjöd in @%{group_name} till ett meddelande\n\n> **[%{topic_title}](%{topic_url})**\n>\n> %{topic_excerpt}\n\nvid \n\n> %{site_title} -- %{site_description}\n\nOm du är intresserad, klicka på länken nedan:\n\n%{topic_url}\n" diff --git a/config/locales/server.sw.yml b/config/locales/server.sw.yml index 914d7c1432..2d50613ea9 100644 --- a/config/locales/server.sw.yml +++ b/config/locales/server.sw.yml @@ -803,7 +803,6 @@ sw: notify_mods_when_user_silenced: "Kama mtumiaji akinyamazishwa, tuma ujumbe kwa wasimamizi wote." markdown_linkify_tlds: "Orodha ya vikoa vya hali ya juu ambavyo otomatikali ni viungo." post_undo_action_window_mins: "Dakika watumiaji wanaruhusiwa kutendua vitendo vya hivi karibuni kwenye chapisho (kupenda, bendera, etc)." - pending_users_reminder_delay: "Wajulishe wasimamizi kama watumiaji wapya wamekuwa wanasubiria kibali kwa mda zaidi ya masaa haya. Seti -1 kusitisha taarifa." maximum_session_age: "mtumiaji ataendelea kuwa ndani kwa masaa n toka mara ya mwisho alipotembelea" site_contact_username: "Jina la msaidizi lililo sahihi litakalotuma ujumbe otomatikali. Kama ikiachwa wazi chaguo-msingi la akaunti ya mfumo litatumika." send_welcome_message: "Watumie watumiaji wapya ujumbe wa kuwakaribisha na muongozo mfupi wa kuanzia." diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index de275b3cab..ad5e5ab44f 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -1315,7 +1315,7 @@ tr_TR: post_undo_action_window_mins: "Bir gönderide yapılan yeni eylemlerin (beğenme, bildirme vb) geri alınabileceği zaman, dakika olarak" must_approve_users: "Siteye erişimlerine izin verilmeden önce tüm yeni kullanıcı hesaplarının görevliler tarafından onaylanması gerekir." review_every_post: "Tüm gönderiler gözden geçirilmelidir. UYARI! YOĞUN SİTELER İÇİN ÖNERİLMEZ." - pending_users_reminder_delay: "Belirtilen saatten daha uzun bir süredir onay bekleyen yeni kullanıcılar mevcutsa moderatörleri bilgilendir. Bilgilendirmeyi devre dışı bırakmak için -1 girin." + pending_users_reminder_delay_minutes: "Yeni kullanıcılar bu kadar dakikadan daha uzun süredir onay bekliyorsa moderatörleri bilgilendir. Bildirimleri devre dışı bırakmak için -1 olarak ayarlayın." maximum_session_age: "Kullanıcı son ziyaretinden bu yana n saat boyunca giriş yapmış olarak kalacak" enable_escaped_fragments: "Eğer bir ağ gezgini algılanmazsa Google'ın Ajax Crawling API'ına geri dönün. Bkz. Https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" cors_origins: "Cross-origin isteklerin (CORS) izin verilen originler. Her origin http:// veya https:// içermeli. CORS'u etkinleştirebilmek için DISCOURSE_ENABLE_CORS env değişkeni doğru olarak ayarlanmalı." @@ -1353,6 +1353,7 @@ tr_TR: enable_rich_text_paste: "Besteciye metin yapıştırırken otomatik HTML'den Markdown'a dönüştürme özelliğini etkinleştirin. (Deneysel)" email_token_valid_hours: "Parolamı unuttum / hesap etkinleştirme jetonları (n) saat geçerlidir." enable_badges: "Rozet sistemini etkinleştir" + max_favorite_badges: "Kullanıcının seçebileceği maksimum rozet sayısı" enable_whispers: "Konular dahilinde yetkililerin özel iletişimine izin ver." allow_index_in_robots_txt: "Robots.txt dosyasında bu sitenin web arama motorları tarafından dizine eklenmesine izin verildiğini belirtin. İstisnai durumlarda kalıcı olarak robots.txt dosyasını geçersiz kılabilirsiniz ." blocked_email_domains: "Kullanıcıların kayıt olurken kullanamayacağı e-posta alan adlarının, dikey çizgilerle ayrıştırılmış listesi. Örneğin: mailinator.com|trashmail.net" @@ -2645,6 +2646,7 @@ tr_TR: only_reply_by_email_pm: "%{participants}'e cevap vermek için bu maili yanıtla." visit_link_to_respond: "Cevaplamak için [konuyu ziyaret edin](%{base_url}%{url})." visit_link_to_respond_pm: "%{participants}'a cevap vermek için [Mesaja Git](%{base_url}%{url})" + reply_above_line: "## Lütfen cevabınızı bu satırın üzerine yazın. ##" posted_by: "%{post_date} tarihinde %{username} tarafından gönderildi" pm_participants: "Katılımcılar: %{participants}" invited_group_to_private_message_body: | diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index 8211460d00..30ed1eebee 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -1553,7 +1553,6 @@ uk: must_approve_users: "Персонал повинен схвалити всі нові облікові записи користувачів, перш ніж їм буде надано доступ до сайту" invite_code: "Користувач повинен ввести цей код для дозволу реєстрації облікового запису, ігнорується, коли пусто (нечутлива до регістру)" approve_suspect_users: "Додавати підозрілих користувачів до черги огляду. Підозрілі користувачі можуть входити до профілю, але не можуть читати повідомлення." - pending_users_reminder_delay: "Повідомляти модераторів, якщо нові користувачі чекають схвалення довше ніж стільки годин. Встановіть -1 для відключення цих повідомлень." persistent_sessions: "Користувачі залишатимуться авторизовані при закритті веб-браузера" maximum_session_age: "Користувач залишиться в системі протягом n годин з моменту останнього відвідування" ga_version: "Версія Google Universal Analytics для використання: v3 (analytics.js), v4 (gtag)" diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index 7819f9c2d3..bf07c020cd 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -1246,7 +1246,6 @@ ur: markdown_typographer_quotation_marks: "ڈبل اور سِنگل قَوٹس کے متبادل جوڑوں کی فہرست" post_undo_action_window_mins: "منٹوں کی تعداد جب تک صارفین کو ایک پوسٹ پر حالیہ کارروائیوں (لائیک، فلَیگ، وغیرہ) کو واپس کرلینے کی اجازت ہے۔" must_approve_users: "سائٹ تک رسائی حاصل کرنے سے پہلے سٹاف کا تمام نئے صارف اکاؤنٹس کو منظور کرنا ضروری ہے۔" - pending_users_reminder_delay: "ماڈریٹرز کو مطلع کریں اگر نئے صارفین اِس سے زیادہ گھنٹوں سے منظوری کے منتظر ہوں۔ اطلاعات کو غیر فعال کرنے کیلئے -1 سَیٹ کریں۔" maximum_session_age: "آخری وزٹ سے ن گھنٹوں بعد تک صارف کو لاگڈ اِن رکھا جائے گا" enable_escaped_fragments: "اگر کسی وَیب کرالر کا پتہ نہ لگے تو گُوگل اَیجَیکس-کرالِنگ API کا استعمال کریں۔ دیکھیے https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" cors_origins: "وہ اَوریجِن جن کیلئے کراس-اَوریجِن درخواستوں (CORS) کی اجازت ہے۔ ہراَوریجِن میں http:// یا https:// شامل ہونا ضروری ہے۔ CORS کو فعال کرنے کیلئے DISCOURSE_ENABLE_CORS کی وَیلِیو ٹرُو پر مقرر ہونا لاذمی ہے۔" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index f4084423cd..d7f61d0e79 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -927,7 +927,6 @@ vi: traditional_markdown_linebreaks: "Sử dụng ngắt dòng truyền thống trong Markdown, đòi hỏi hai khoảng trống kế tiếp cho một ngắt dòng." markdown_typographer_quotation_marks: "Danh sách các cặp thay thế dấu ngoặc kép và dấu ngoặc đơn" post_undo_action_window_mins: "Số phút thành viên được phép làm lại các hành động gần đây với bài viết (like, đánh dấu...)." - pending_users_reminder_delay: "Thông báo cho quản trị viên nếu thành viên mới đã chờ duyệt lâu hơn số giờ được thiết lập ở đây, đặt là -1 để tắt thông báo." cors_origins: "Cho phép CORS (Cross-Origin Requests). Mỗi nguyên gốc phải kèm theo http:// hoặc https://. Biến env DISCOURSE_ENABLE_CORS phải được đặt là 'true' để bật CORS." use_admin_ip_allowlist: "Admin chỉ có thể đăng nhập nếu có địa chỉ IP được định nghĩa trước trong danh sách Screened IPs (Admin > Logs > Screened Ips)." invalidate_inactive_admin_email_after_days: "Các tài khoản quản trị viên chưa truy cập trang web trong số ngày này sẽ cần xác thực lại địa chỉ email của họ trước khi đăng nhập. Đặt thành 0 để tắt." diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index 065a1b350b..e5ce20dd40 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -49,7 +49,7 @@ zh_CN: disable_remote_images_download_reason: "远程图像下载因为磁盘空间不足已经被停用。" anonymous: "匿名" remove_posts_deleted_by_author: "被作者删除" - redirect_warning: "我们无法验证你选择的链接是否已实际发布到论坛。如果你仍想继续,请选择以下链接。" + redirect_warning: "我们无法验证您选择的链接是否已实际发布到论坛。如果您仍想继续,请选择以下链接。" on_another_topic: "在另外的话题中" inline_oneboxer: topic_page_title_post_number: "#%{post_number}" @@ -62,14 +62,14 @@ zh_CN: unrecognized_extension: "无法识别的文件扩展名:%{extension}" import_error: generic: 导入该主题时发生了错误 - about_json: "导入错误:about.json不存在或无效。你确定这是一个Discourse主题码?" + about_json: "导入错误:about.json不存在或无效。您确定这是一个Discourse主题码?" about_json_values: "about.json 包含无效值:%{errors}" modifier_values: "about.json 的修改包含无效值:%{errors}" git: "克隆git仓库时出错,访问被拒绝或找不到仓库" git_ref_not_found: "无法切换到git引用:%{ref}" unpack_failed: "无法解压缩文件" file_too_big: "未压缩的文件太大。" - unknown_file_type: "你上传的文件似乎不是一个有效的 Discourse 主题。" + unknown_file_type: "您上传的文件似乎不是一个有效的 Discourse 主题。" not_allowed_theme: "`%{repo}` 不在允许的主题列表中(请检查全局设置中的 `allowed_theme_repos`)。" errors: component_no_user_selectable: "主题组件不能由用户选择" @@ -129,7 +129,7 @@ zh_CN: format: ! "%{attribute}%{message}" format_with_full_message: "%{attribute}:%{message}" messages: - too_long_validation: "最多只能有 %{max} 个字;你已经输入了 %{length} 个字。" + too_long_validation: "最多只能有 %{max} 个字;您已经输入了 %{length} 个字。" invalid_boolean: "无效布尔值。" taken: "已经被采用" accepted: 必须被接受 @@ -148,7 +148,7 @@ zh_CN: is_invalid: "似乎不清楚,这是一个完整的句子?" is_invalid_meaningful: "似乎不清楚,大多数字眼都包含重复的字母?" is_invalid_unpretentious: "似乎不清楚,一个或多个单词很长?" - is_invalid_quiet: "似乎不清楚,你是要用全部大写字母输入吗?" + is_invalid_quiet: "似乎不清楚,您是要用全部大写字母输入吗?" invalid_timezone: "“%{tz}”不是一个有效的时区" contains_censored_words: "包含了以下敏感词:%{censored_words}" less_than: 必须小于 %{count} @@ -178,52 +178,52 @@ zh_CN: embed: load_from_remote: "载入帖子时出错了。" site_settings: - invalid_category_id: "你所指定的一个分类是不存在的" + invalid_category_id: "您所指定的一个分类是不存在的" invalid_choice: - other: "你指定了无效的选择 %{name}" - default_categories_already_selected: "你不能选择一个已经被用在其它列表中的分类。" - default_tags_already_selected: "你不能选择一个已经被使用在其它列表中的标签。" - s3_upload_bucket_is_required: "你没有填写“s3_upload_bucket”,不能开启上传至 S3。" - enable_s3_uploads_is_required: "除非已启用上传到 S3,否则你无法存储到S3。" + other: "您指定了无效的选择 %{name}" + default_categories_already_selected: "您不能选择一个已经被用在其它列表中的分类。" + default_tags_already_selected: "您不能选择一个已经被使用在其它列表中的标签。" + s3_upload_bucket_is_required: "您没有填写“s3_upload_bucket”,不能开启上传至 S3。" + enable_s3_uploads_is_required: "除非已启用上传到 S3,否则您无法存储到S3。" page_publishing_requirements: "如果启用了安全媒体,则无法启用页面发布。" - s3_backup_requires_s3_settings: "除非你提供了'%{setting_name}',否则无法将S3用作备份位置。" - s3_bucket_reused: "你不可以将同一个 bucket 同时用作 ‘s3_upload_bucket’ 和 ‘s3_backup_bucket’。请选择一个不同的 bucket 或为每个 bucket 使用不同的路径。" + s3_backup_requires_s3_settings: "除非您提供了'%{setting_name}',否则无法将S3用作备份位置。" + s3_bucket_reused: "您不可以将同一个 bucket 同时用作 ‘s3_upload_bucket’ 和 ‘s3_backup_bucket’。请选择一个不同的 bucket 或为每个 bucket 使用不同的路径。" secure_media_requirements: "在启用安全媒体之前,必须先启用S3上传。" - share_quote_facebook_requirements: "你必须设置一个Facebook app id 才能启用Facebook的引用分享功能。" - second_factor_cannot_enforce_with_socials: "你不能在启用社交登录时强制启用双重认证。你必须先禁用以下登录方式:%{auth_provider_names}" + share_quote_facebook_requirements: "您必须设置一个Facebook app id 才能启用Facebook的引用分享功能。" + second_factor_cannot_enforce_with_socials: "您不能在启用社交登录时强制启用双重认证。您必须先禁用以下登录方式:%{auth_provider_names}" second_factor_cannot_be_enforced_with_disabled_local_login: "如果禁用本地登录则无法强制启用2FA。" second_factor_cannot_be_enforced_with_discourse_connect_enabled: "如果启用了 DiscourseConnect,则无法强制执行 2FA。" local_login_cannot_be_disabled_if_second_factor_enforced: "如果强制启用2FA则不能禁用本地登录。在禁用本地登录之前,请禁用强制启用2FA。" - cannot_enable_s3_uploads_when_s3_enabled_globally: "你无法启用S3上传,因为S3上传已经全局启用了,另外这样站点级的启用可能会导致上传的文件出现严重问题" - cors_origins_should_not_have_trailing_slash: "你不应该将尾随斜线 (/) 添加到 Cross Origins。" + cannot_enable_s3_uploads_when_s3_enabled_globally: "您无法启用S3上传,因为S3上传已经全局启用了,另外这样站点级的启用可能会导致上传的文件出现严重问题" + cors_origins_should_not_have_trailing_slash: "您不应该将尾随斜线 (/) 添加到 Cross Origins。" conflicting_google_user_id: '此账户的Google账户ID已更改; 出于安全原因,需要管理人员干预。请联系工作人员并指引他们前往https://meta.discourse.org/t/76575' onebox: - invalid_address: "抱歉,我们无法为此网页生成预览,因为找不到服务器 “%{hostname}”。你的帖子中只会显示链接,而不是预览。 :cry:" - error_response: "抱歉,我们无法为此网页生成预览,因为该服务器返回错误代码 %{status_code}。你的帖子中只会显示链接,而不是预览。 :cry:" + invalid_address: "抱歉,我们无法为此网页生成预览,因为找不到服务器 “%{hostname}”。您的帖子中只会显示链接,而不是预览。 :cry:" + error_response: "抱歉,我们无法为此网页生成预览,因为该服务器返回错误代码 %{status_code}。您的帖子中只会显示链接,而不是预览。 :cry:" missing_data: other: "抱歉,我们无法为该网页生成预览,因为找不到 oEmbed / OpenGraph 标签: %{missing_attributes}" word_connector: comma: ", " invite: - expired: "你的邀请码不正确。请联系工作人员。" - not_found: "你的邀请码不正确。请联系工作人员。" - not_found_json: "你的邀请码不正确。请联系工作人员。" - not_matching_email: "你的电子邮件地址和与邀请关联的电子邮件地址不符。请联系管理员。" + expired: "您的邀请码不正确。请联系工作人员。" + not_found: "您的邀请码不正确。请联系工作人员。" + not_found_json: "您的邀请码不正确。请联系工作人员。" + not_matching_email: "您的电子邮件地址和与邀请关联的电子邮件地址不符。请联系管理员。" not_found_template: | -

      邀请你加入%{site_name}的邀请函已被激活。

      +

      邀请您加入%{site_name}的邀请函已被激活。

      -

      如果你记得密码,你可以选择登录。

      +

      如果您记得密码,您可以选择登录。

      否则,请重置密码。

      not_found_template_link: |

      %{site_name} 的邀请已失效。请让邀请人重新发送新邀请。

      user_exists: "不需要邀请%{email},他们已经拥有账户了!" - invite_exists: "你已经邀请了 %{email}。" + invite_exists: "您已经邀请了 %{email}。" invalid_email: "'%{email}' 不是一个有效邮箱地址。" rate_limit: other: "您已经在之前发出过 %{count} 份邀请,请等待 %{time_left} 然后再试。" - confirm_email: "

      快完成了!我们发送了一封激活邮件到你的邮件地址。请按照邮件中的步骤来激活你的账户。

      如果你没有收到邮件,请检查你的垃圾邮件收件箱。

      " - cant_invite_to_group: "你未被允许邀请用户到指定的群组。请确保你是群组的所有者。" + confirm_email: "

      快完成了!我们发送了一封激活邮件到您的邮件地址。请按照邮件中的步骤来激活您的账户。

      如果您没有收到邮件,请检查您的垃圾邮件收件箱。

      " + cant_invite_to_group: "您未被允许邀请用户到指定的群组。请确保您是群组的所有者。" disabled_errors: discourse_connect_enabled: "邀请已禁用,因为已启用 DiscourseConnect。" invalid_access: "您没有权限查看所请求的资源。" @@ -232,36 +232,36 @@ zh_CN: max_rows: "前%{max_bulk_invites}个邀请已经被发出。尝试将文件分割成更小的部分。" error: "上传文件的时候出错了。请稍后重试。" invite_link: - email_taken: "这个电子邮件地址已被使用。如果你已经拥有一个账户,请登录或重置密码。" + email_taken: "这个电子邮件地址已被使用。如果您已经拥有一个账户,请登录或重置密码。" max_redemptions_limit: "应该在 2 和 %{max_limit} 之间。" topic_invite: failed_to_invite: "如果用户不是以下任一群组的成员,则无法被邀请加入到此话题:%{group_names}。" - user_exists: "抱歉,用户已经被邀请过了。你只可以邀请一个用户到一个话题中一次。" - muted_invitee: "抱歉,该用户忽略了你。" + user_exists: "抱歉,用户已经被邀请过了。您只可以邀请一个用户到一个话题中一次。" + muted_invitee: "抱歉,该用户忽略了您。" muted_topic: "抱歉,该用户忽略了该话题。" - receiver_does_not_allow_pm: "抱歉,该用户不允许你向他们发送私人消息。" - sender_does_not_allow_pm: "对不起,你不允许该用户给你发送私人信息。" + receiver_does_not_allow_pm: "抱歉,该用户不允许您向他们发送私人消息。" + sender_does_not_allow_pm: "对不起,您不允许该用户给您发送私人信息。" user_cannot_see_topic: "%{username} 无法看到该话题。" backup: operation_already_running: "有操作正在运行。目前无法开始新的操作。" backup_file_should_be_tar_gz: "备份文件应该是一个 .tar.gz 压缩文件。" not_enough_space_on_disk: "没有足够的磁盘空间可供备份文件上传。" invalid_filename: "备份文件名包含无效字符。有效字符为 a-z 0-9 . - _." - file_exists: "你尝试上传的文件已存在。" - invalid_params: "你为请求提供了无效参数:%{message}" - not_logged_in: "你需要登录后才能这么做。" + file_exists: "您尝试上传的文件已存在。" + invalid_params: "您为请求提供了无效参数:%{message}" + not_logged_in: "您需要登录后才能这么做。" not_found: "找不到请求的地址或资源。" - invalid_access: "你没有权限查看所请求的资源。" + invalid_access: "您没有权限查看所请求的资源。" authenticator_not_found: "身份验证方法不存在或已被禁用。" - invalid_api_credentials: "你没有权限查看所请求的资源。API 用户名或者秘钥无效。" - provider_not_enabled: "你没有权限查看所请求的资源。身份验证提供程序未启用。" - provider_not_found: "你没有权限查看所请求的资源。身份验证提供程序不存在。" + invalid_api_credentials: "您没有权限查看所请求的资源。API 用户名或者秘钥无效。" + provider_not_enabled: "您没有权限查看所请求的资源。身份验证提供程序未启用。" + provider_not_found: "您没有权限查看所请求的资源。身份验证提供程序不存在。" read_only_mode_enabled: "这个站点正处于只读模式。交互功能已禁用。" invalid_grant_badge_reason_link: "徽章原因中不允许外部或无效的discourse链接" email_template_cant_be_modified: "该邮件模板不能被修改" - invalid_whisper_access: "没有启用密语或者是你无权创建密语帖子" + invalid_whisper_access: "没有启用密语或者是您无权创建密语帖子" not_in_group: - title_topic: "你需要请求加入 `%{group}` 群组才能查看此话题。" + title_topic: "您需要请求加入 `%{group}` 群组才能查看此话题。" request_membership: "申请会员" join_group: "加入群组" deleted_topic: "哎呀!该话题已被删除了并且不再可用。" @@ -271,7 +271,7 @@ zh_CN: too_many_replies: other: "我们非常抱歉,新用户被暂时限制同一个话题内回复不超过%{count}次。" max_consecutive_replies: - other: "不得连续回复超过%{count}次。请编辑你之前的回复,或等待某个人回复你。" + other: "不得连续回复超过%{count}次。请编辑您之前的回复,或等待某个人回复您。" embed: start_discussion: "开始讨论" continue: "继续讨论" @@ -294,13 +294,13 @@ zh_CN: last_reply: "最新回复" created: "创建时间" new_topic: "创建新话题" - no_mentions_allowed: "抱歉,你无法提到其他用户。" + no_mentions_allowed: "抱歉,您无法提到其他用户。" too_many_mentions: - other: "抱歉,你一次仅能提到 %{count} 个用户。" + other: "抱歉,您一次仅能提到 %{count} 个用户。" no_mentions_allowed_newuser: "抱歉,新用户不能@其它用户。" too_many_mentions_newuser: other: "抱歉,新用户在一个帖子中最多能@ %{count} 位用户。" - no_embedded_media_allowed_trust: "抱歉,你不能在帖子中嵌入媒体项目。" + no_embedded_media_allowed_trust: "抱歉,您不能在帖子中嵌入媒体项目。" no_embedded_media_allowed: "抱歉,新用户不能在帖子中嵌入媒体项目。" too_many_embedded_media: other: "抱歉,新用户只能在帖子中嵌入%{count}个媒体项目。" @@ -308,23 +308,23 @@ zh_CN: too_many_attachments: other: "抱歉,新用户在一个帖子中仅能上传 %{count} 个附件。" no_links_allowed: "抱歉,新用户不能在帖子中发表链接。" - links_require_trust: "抱歉,你的帖子中不能包含链接。" + links_require_trust: "抱歉,您的帖子中不能包含链接。" too_many_links: other: "抱歉,新用户在一个帖子中仅能发表 %{count} 条链接。" - contains_blocked_word: "你的帖子中包含不允许的词:%{word}" - contains_blocked_words: "你的帖子中含有多个被禁止的词:%{words}" - spamming_host: "抱歉,你不能发表该主机的链接。" + contains_blocked_word: "您的帖子中包含不允许的词:%{word}" + contains_blocked_words: "您的帖子中含有多个被禁止的词:%{words}" + spamming_host: "抱歉,您不能发表该主机的链接。" user_is_suspended: "被封禁的用户不允许发贴。" - topic_not_found: "出现问题。或许这个话题在你看的时候已经被关闭或删除了。" + topic_not_found: "出现问题。或许这个话题在您看的时候已经被关闭或删除了。" not_accepting_pms: "对不起,%{username} 目前不接收讯息。" - max_pm_recipients: "抱歉,你至多可以向%{recipients_limit}个收件人发送私信。" + max_pm_recipients: "抱歉,您至多可以向%{recipients_limit}个收件人发送私信。" pm_reached_recipients_limit: "抱歉,私信中的收信人不能超过%{recipients_limit}。" removed_direct_reply_full_quotes: "自动删除对上一个帖子的完整引用。" watched_words_auto_tag: "自动标记话题" secure_upload_not_allowed_in_public_topic: "抱歉,无法在公开的话题中使用以下安全上传:%{upload_filenames}。" - create_pm_on_existing_topic: "抱歉,你无法在现有话题上创建PM。" + create_pm_on_existing_topic: "抱歉,您无法在现有话题上创建PM。" slow_mode_enabled: "本话题处于慢速模式。" - just_posted_that: "太类似于你最近发表的内容" + just_posted_that: "太类似于您最近发表的内容" invalid_characters: "包含无效字符" is_invalid: "似乎不清楚,这是一个完整的句子? " next_page: "下一页 →" @@ -358,16 +358,16 @@ zh_CN: tag: "加标签的话题" badge: "%{site_title}的%{display_name}徽章" too_late_to_edit: "这个话题在很早之前创建。不能被编辑或者被删除。" - edit_conflict: "该帖子由其他用户编辑,你的更改无法再保存。" - revert_version_same: "目前的版本和你想要回退至的版本一样。" + edit_conflict: "该帖子由其他用户编辑,您的更改无法再保存。" + revert_version_same: "目前的版本和您想要回退至的版本一样。" cannot_edit_on_slow_mode: "本话题处于慢速模式。为了鼓励优质的讨论,目前不允许编辑本话题的旧帖。" excerpt_image: "图片" bookmarks: errors: - already_bookmarked_post: "你不能将同一帖子收藏两次。" - too_many: "抱歉,你不能添加超过 %{limit} 个收藏内容,请访问 %{user_bookmarks_url} 删除一些收藏。" - cannot_set_past_reminder: "你不能在此话题设置收藏提醒。" - cannot_set_reminder_in_distant_future: "你不能够设置一个大于十年的收藏提醒。" + already_bookmarked_post: "您不能将同一帖子收藏两次。" + too_many: "抱歉,您不能添加超过 %{limit} 个收藏内容,请访问 %{user_bookmarks_url} 删除一些收藏。" + cannot_set_past_reminder: "您不能在此话题设置收藏提醒。" + cannot_set_reminder_in_distant_future: "您不能够设置一个大于十年的收藏提醒。" time_must_be_provided: "必须为所有提醒设置时间" reminders: at_desktop: "下次我使用桌面设备时" @@ -383,20 +383,20 @@ zh_CN: other: "%{count}个用户已被添加到这个群组。" errors: grant_trust_level_not_valid: "'%{trust_level}'不是有效的信任等级。" - can_not_modify_automatic: "你不能修改自动组" + can_not_modify_automatic: "您不能修改自动组" member_already_exist: other: "以下用户已经是该群组的成员:%{username}" invalid_domain: "“%{domain}”不是有效域名。" invalid_incoming_email: "“%{email}”不是有效邮箱地址。" email_already_used_in_group: "“%{email}”已经被群组“%{group_name}”使用了。" email_already_used_in_category: "“%{email}”已经被分类“%{category_name}”使用了。" - cant_allow_membership_requests: "对没有拥有者的群组,你无法批准成员请求。" - already_requested_membership: "你已请求成为该群组的成员。" + cant_allow_membership_requests: "对没有拥有者的群组,您无法批准成员请求。" + already_requested_membership: "您已请求成为该群组的成员。" adding_too_many_users: other: "单次最多可添加%{count}个用户" usernames_or_emails_required: "用户名和电子邮件是必须的" no_invites_with_discourse_connect: "启用 DiscourseConnect 时,您只能邀请注册用户" - no_invites_without_local_logins: "本地登录禁用时,你只能邀请已经注册的用户" + no_invites_without_local_logins: "本地登录禁用时,您只能邀请已经注册的用户" default_names: everyone: "任何人" admins: "管理员" @@ -410,38 +410,38 @@ zh_CN: request_membership_pm: title: "%{group_name}的成员请求" request_accepted_pm: - title: "你已接受加入@%{group_name}" + title: "您已接受加入@%{group_name}" body: | - 你要加入@%{group_name}的请求已被允许,现在你是其中一员了。 + 您要加入@%{group_name}的请求已被允许,现在您是其中一员了。 education: until_posts: other: "%{count} 个帖子" "new-topic": | - 欢迎使用 %{site_name} — **感谢你开始新的对话!** + 欢迎使用 %{site_name} — **感谢您开始新的对话!** - - 如果你大声朗读出标题,听起来有趣吗?能否很好地概括内容? + - 如果您大声朗读出标题,听起来有趣吗?能否很好地概括内容? - - 谁会对此感兴趣?为什么会感兴趣?你想得到怎样的回应? + - 谁会对此感兴趣?为什么会感兴趣?您想得到怎样的回应? - - 在你的话题中包含常用词,以便其他人能够“找到”它。要将你的话题与相关的话题关联,请选择一个分类(或标签)。 + - 在您的话题中包含常用词,以便其他人能够“找到”它。要将您的话题与相关的话题关联,请选择一个分类(或标签)。 - 更多内容,请[请参阅我们的社区准则](%{base_path}/guidelines)。该面板仅在你的前 %{education_posts_text}出现。 + 更多内容,请[请参阅我们的社区准则](%{base_path}/guidelines)。该面板仅在您的前 %{education_posts_text}出现。 "new-reply": | - 欢迎来到%{site_name} — **感谢你的贡献!** + 欢迎来到%{site_name} — **感谢您的贡献!** - 请对其他社区成员保持友善。 - - 你的回复是否有益于交流? + - 您的回复是否有益于交流? - 欢迎有建设性的批评,但是请对事不对人。 - 更多内容,[请查看我们的社区准则](%{base_path}/guidelines)。此信息面板只会在你发表前 %{education_posts_text} 时显示。 + 更多内容,[请查看我们的社区准则](%{base_path}/guidelines)。此信息面板只会在您发表前 %{education_posts_text} 时显示。 avatar: | - ### 想更改你的头像吗? + ### 想更改您的头像吗? - 你已经发表和回复了几个话题,但你的头像并不像你自己一样独一无二——它仅仅是一个字母。 + 您已经发表和回复了几个话题,但您的头像并不像您自己一样独一无二——它仅仅是一个字母。 - 你想过更改你的头像 **[查看你的个人资料](%{profile_path})** 并上传一张代表你自己的照片吗? + 您想过更改您的头像 **[查看您的个人资料](%{profile_path})** 并上传一张代表您自己的照片吗? 如果每个人都有自己独特的头像会让人更加容易参与讨论和发现有趣的人! sequential_replies: | @@ -449,35 +449,35 @@ zh_CN: 与其回复话题多次,不如只回复一次,包括引用前面的帖子或者@用户名。 - 你可以在你的回复中添加引用。只要选择你想引用的文字,然后点击随后出现的引用回复按钮。 + 您可以在您的回复中添加引用。只要选择您想引用的文字,然后点击随后出现的引用回复按钮。 这样其它人会更容易地看一个长的回复,而不是多个分开的回复。 dominating_topic: | ### 让其他人也加入到讨论中 - 显然这个话题对你很重要 &ndash;但所有回复中你的发言占比超过%{percent}% + 显然这个话题对您很重要 &ndash;但所有回复中您的发言占比超过%{percent}% - 留一点空间给其他人分享他们自己的见解会更好。你能邀请他们吗? + 留一点空间给其他人分享他们自己的见解会更好。您能邀请他们吗? get_a_room: | ### 鼓励每一个人都参与到讨论当中 - 你已经在这个话题中给@%{reply_username}回复了%{count}次了! + 您已经在这个话题中给@%{reply_username}回复了%{count}次了! - 良好讨论应该包含许多不同的声音和观点。你能让其他人也参与进来吗? + 良好讨论应该包含许多不同的声音和观点。您能让其他人也参与进来吗? - 不要忘记了,如果你想私下继续与特定用户进行对话,可以[给他们发送私信](%{base_path}/u/%{reply_username})。 + 不要忘记了,如果您想私下继续与特定用户进行对话,可以[给他们发送私信](%{base_path}/u/%{reply_username})。 too_many_replies: | - ### 你发表的回复数量已经达到了上限。 + ### 您发表的回复数量已经达到了上限。 非常抱歉,但新用户被限制只能在同一话题中回复 %{newuser_max_replies_per_topic} 次。 - 与其添加另一个回复不如考虑编辑你以前的回复,或访问其他话题。 + 与其添加另一个回复不如考虑编辑您以前的回复,或访问其他话题。 reviving_old_topic: | ### 重新激活这个话题? - 该话题最后的回复是**%{time_ago}**。你的回复将让话题重新出现在列表的顶端并通知所有原先参与过讨论的人。 + 该话题最后的回复是**%{time_ago}**。您的回复将让话题重新出现在列表的顶端并通知所有原先参与过讨论的人。 - 你确定要延续这个久远的话题吗? + 您确定要延续这个久远的话题吗? activerecord: attributes: category: @@ -495,13 +495,13 @@ zh_CN: topic: attributes: base: - warning_requires_pm: "你只能在个人消息中附加警告。" + warning_requires_pm: "您只能在个人消息中附加警告。" too_many_users: "一次只能发送警告给一个用户。" - cant_send_pm: "抱歉,你不能向该用户发送消息。" - no_user_selected: "你必须选择一个有效的用户。" + cant_send_pm: "抱歉,您不能向该用户发送消息。" + no_user_selected: "您必须选择一个有效的用户。" reply_by_email_disabled: "用邮件回复已被禁止" - send_to_email_disabled: "抱歉,你不能向该邮件发送消息。" - target_user_not_found: "未找到你要向其发送此消息的其中一个用户" + send_to_email_disabled: "抱歉,您不能向该邮件发送消息。" + target_user_not_found: "未找到您要向其发送此消息的其中一个用户" unable_to_update: "更新该话题时发生错误。" unable_to_tag: "给话题设置标签时发生错误。" featured_link: @@ -511,21 +511,21 @@ zh_CN: attributes: password: common: "是 10000 个最常用的密码之一。请用一个更安全的密码。" - same_as_username: "与你的用户名相同。请使用一个更安全的密码。" - same_as_email: "与你的邮件地址相同。请使用一个更安全的密码。" - same_as_current: "和你当前的密码相同" - same_as_name: "和你的名字一样。" + same_as_username: "与您的用户名相同。请使用一个更安全的密码。" + same_as_email: "与您的邮件地址相同。请使用一个更安全的密码。" + same_as_current: "和您当前的密码相同" + same_as_name: "和您的名字一样。" unique_characters: "包含太多重复的字符。请使用一个更安全的密码。" username: - same_as_password: "和你的密码相同。" + same_as_password: "和您的密码相同。" name: - same_as_password: "和你的密码相同。" + same_as_password: "和您的密码相同。" ip_address: signup_not_allowed: "不能使用这个账户登录。" user_profile: attributes: featured_topic_id: - invalid: "该话题不能显示在你的个人资料中。" + invalid: "该话题不能显示在您的个人资料中。" user_email: attributes: user_id: @@ -585,20 +585,20 @@ zh_CN: - 你可能想要在管理 :wrench: (右上角)中关闭这个话题,这样回复就不会积压在这个通告下。 + 您可能想要在管理 :wrench: (右上角)中关闭这个话题,这样回复就不会积压在这个通告下。 lounge_welcome: title: "欢迎来到贵宾室" body: |2 恭喜!:confetti_ball: - 如果你看到了这个话题,说明你已经被提升至**常规**(信任等级3)了。 + 如果您看到了这个话题,说明您已经被提升至**常规**(信任等级3)了。 - 你现在可以… + 您现在可以… * 编辑任何话题的标题 * 改变任何话题的分类 - * 让你的链接设置为 follow 属性([自动不跟踪](https://en.wikipedia.org/wiki/Nofollow)限制已经移除) + * 让您的链接设置为 follow 属性([自动不跟踪](https://en.wikipedia.org/wiki/Nofollow)限制已经移除) * 访问一个只有信任等级3及更高才能见到的贵宾室分类 * 一次标记即可隐藏垃圾信息 @@ -618,7 +618,7 @@ zh_CN: not_found: "未找到分类!" uncategorized_parent: "未分类不能有一个父分类" self_parent: "一个子分类不能属于它自己。" - depth: "你不能在一个子分类下再包含一个子分类。" + depth: "您不能在一个子分类下再包含一个子分类。" invalid_email_in: "'%{email}'不是一个有效邮箱地址。" email_already_used_in_group: "'%{email}' 已经被群组'%{group_name}'使用了。" email_already_used_in_category: "'%{email}' 已经被分类'%{category_name}'使用了。" @@ -636,30 +636,30 @@ zh_CN: trust_levels: admin: "管理员" staff: "工作人员" - change_failed_explanation: "你尝试将%{user_name}降至%{new_trust_level}。然而他们的信任等级已经是%{current_trust_level}。%{user_name}将仍处于%{current_trust_level}——如果你想要降级用户,先锁定信任等级。" + change_failed_explanation: "您尝试将%{user_name}降至%{new_trust_level}。然而他们的信任等级已经是%{current_trust_level}。%{user_name}将仍处于%{current_trust_level}——如果您想要降级用户,先锁定信任等级。" post: image_placeholder: broken: "此图片已损坏" has_likes: other: "%{count}赞" rate_limiter: - slow_down: "你执行这个操作太多次了,请稍后再试。" - too_many_requests: "你执行这个操作太多次了,请等待%{time_left}后再试。" + slow_down: "您执行这个操作太多次了,请稍后再试。" + too_many_requests: "您执行这个操作太多次了,请等待%{time_left}后再试。" by_type: - first_day_replies_per_day: "我们赞赏你的热情,保持这样! 话虽如此,为了社区的安全,你已经达到了新用户第一天可以回复的最多数量。 请等待 %{time_left} ,你将可以继续回复。" - first_day_topics_per_day: "我们赞赏你的热情! 话虽如此,为了社区的安全,你已经达到了新用户第一天可以创建的话题的最大数量。 请等待 %{time_left},你讲可以继续创建新的话题。" - create_topic: "你创建新话题的速度太快了,请等待 %{time_left} 后再试。" - create_post: "你回帖太快了,请等待 %{time_left} 后再试。" - delete_post: "你删帖的速度有点太快了。请等待 %{time_left} 再试。" - public_group_membership: "你加入/离开小组的频率有点高,请等待 %{time_left} 后再试。" - topics_per_day: "你已经达到了每天允许的最大新话题数。你可以在 %{time_left} 后创建更多新话题。" - pms_per_day: "你已经达到了每天允许的最大私信数。你可以在 %{time_left} 后发送新私信。" - create_like: "哇!你已经分享了很多爱!今天你已经达到了每日点赞上限,但是随着你的信任等级提升,你将获得更高的每日点赞上限。你将在 %{time_left} 后可再次为帖子点赞。" - create_bookmark: "你已达到每日收藏的最大使用数量。你可以在 %{time_left} 后添加更多收藏。" - edit_post: "你已经达到了每日编辑的最大数量。你可以在 %{time_left} 后编辑更多内容。" - live_post_counts: "你请求帖子数量的速度太快了,请等待%{time_left}后再试。" - unsubscribe_via_email: "你已经达到了用邮件解除订阅的最大数量限制。请等待%{time_left}后再试。" - topic_invitations_per_day: "你已经达到了话题邀请的最大数量。你可以在 %{time_left} 后发送更多邀请。" + first_day_replies_per_day: "我们赞赏您的热情,保持这样! 话虽如此,为了社区的安全,您已经达到了新用户第一天可以回复的最多数量。 请等待 %{time_left} ,您将可以继续回复。" + first_day_topics_per_day: "我们赞赏您的热情! 话虽如此,为了社区的安全,您已经达到了新用户第一天可以创建的话题的最大数量。 请等待 %{time_left},您讲可以继续创建新的话题。" + create_topic: "您创建新话题的速度太快了,请等待 %{time_left} 后再试。" + create_post: "您回帖太快了,请等待 %{time_left} 后再试。" + delete_post: "您删帖的速度有点太快了。请等待 %{time_left} 再试。" + public_group_membership: "您加入/离开小组的频率有点高,请等待 %{time_left} 后再试。" + topics_per_day: "您已经达到了每天允许的最大新话题数。您可以在 %{time_left} 后创建更多新话题。" + pms_per_day: "您已经达到了每天允许的最大私信数。您可以在 %{time_left} 后发送新私信。" + create_like: "哇!您已经分享了很多爱!今天您已经达到了每日点赞上限,但是随着您的信任等级提升,您将获得更高的每日点赞上限。您将在 %{time_left} 后可再次为帖子点赞。" + create_bookmark: "您已达到每日收藏的最大使用数量。您可以在 %{time_left} 后添加更多收藏。" + edit_post: "您已经达到了每日编辑的最大数量。您可以在 %{time_left} 后编辑更多内容。" + live_post_counts: "您请求帖子数量的速度太快了,请等待%{time_left}后再试。" + unsubscribe_via_email: "您已经达到了用邮件解除订阅的最大数量限制。请等待%{time_left}后再试。" + topic_invitations_per_day: "您已经达到了话题邀请的最大数量。您可以在 %{time_left} 后发送更多邀请。" hours: other: "%{count}小时" minutes: @@ -722,8 +722,8 @@ zh_CN: update: "更新密码" save: "设置密码" title: "重置密码" - success: "你的密码已经修改成功,你现在已经登录。" - success_unapproved: "你的密码已经修改成功。" + success: "您的密码已经修改成功,您现在已经登录。" + success_unapproved: "您的密码已经修改成功。" email_login: invalid_token: "抱歉,邮件登录链接已过期。选择登录按钮再使用“我忘记了密码”获得一个新链接。" title: "邮件登录" @@ -757,42 +757,42 @@ zh_CN: windows: "Microsoft Windows" unknown: "未知操作系统" change_email: - wrong_account_error: "你登录了错误的账户,请退出后重试。" - confirmed: "你的电子邮箱已被更新。" + wrong_account_error: "您登录了错误的账户,请退出后重试。" + confirmed: "您的电子邮箱已被更新。" please_continue: "转入到%{site_name}" - error: "在修改你的电子邮箱地址时出现了错误,可能此邮箱已经在论坛中使用了?" - doesnt_exist: "这个电子邮件地址与你的账户没有关联。" - error_staged: "在修改你的电子邮箱地址时出现了错误。这个邮箱已经被一个暂存用户占用了。" - already_done: "抱歉,此激活链接已经失效。可能你已经修改了邮箱?" + error: "在修改您的电子邮箱地址时出现了错误,可能此邮箱已经在论坛中使用了?" + doesnt_exist: "这个电子邮件地址与您的账户没有关联。" + error_staged: "在修改您的电子邮箱地址时出现了错误。这个邮箱已经被一个暂存用户占用了。" + already_done: "抱歉,此激活链接已经失效。可能您已经修改了邮箱?" confirm: "确认" - max_secondary_emails_error: "你已达到所允许的辅助电子邮件数量最大限制。" + max_secondary_emails_error: "您已达到所允许的辅助电子邮件数量最大限制。" authorizing_new: title: "确认您的新邮箱" - description: "请确认你想要修改的电子邮件地址:" - description_add: "请确认你要添加的次要电子邮件地址:" + description: "请确认您想要修改的电子邮件地址:" + description_add: "请确认您要添加的次要电子邮件地址:" authorizing_old: - title: "修改你的电子邮件地址" - description: "请确认你的电子邮件地址更改" - description_add: "请确认你要添加的次要电子邮件地址:" + title: "修改您的电子邮件地址" + description: "请确认您的电子邮件地址更改" + description_add: "请确认您要添加的次要电子邮件地址:" old_email: "旧邮件:%{email}" new_email: "新邮件:%{email}" almost_done_title: "确认新的电子邮件地址" almost_done_description: "我们需要发送一封电子邮件到您的新电子邮件地址以确认更改!" associated_accounts: - revoke_failed: "无法使用%{provider_name}吊销你的账户。" + revoke_failed: "无法使用%{provider_name}吊销您的账户。" connected: "(已连接)" activation: - action: "点击这儿激活你的账户" - already_done: "抱歉,此账户激活链接已经失效。可能你的账户已经被激活了?" - please_continue: "你的新账户已激活;即将转到主页。" + action: "点击这儿激活您的账户" + already_done: "抱歉,此账户激活链接已经失效。可能您的账户已经被激活了?" + please_continue: "您的新账户已激活;即将转到主页。" continue_button: "转入到%{site_name}" welcome_to: "欢迎来到%{site_name}!" - approval_required: "你的新账户需要由一位论坛版主手动批准方可使用。一旦你的账户获得批准,你将收到一封电子邮件通知!" - missing_session: "无法确认你是否注册了账户,请确认你已经启用了 cookie。" + approval_required: "您的新账户需要由一位论坛版主手动批准方可使用。一旦您的账户获得批准,您将收到一封电子邮件通知!" + missing_session: "无法确认您是否注册了账户,请确认您已经启用了 cookie。" activated: "抱歉,这个账户已经被激活了。" admin_confirm: title: "确认管理员账户" - description: "你确定要将%{target_username}(%{target_email})设为管理员吗?" + description: "您确定要将%{target_username}(%{target_email})设为管理员吗?" grant: "授予管理员权限" complete: "%{target_username}现在是管理员了。" back_to: "返回%{title}" @@ -818,7 +818,7 @@ zh_CN: title: "给@%{username}发送一条私信" description: "我想亲自与此人私下交流关于该帖子的事情。" short_description: "我想亲自与此人私下交流关于该帖子的事情。" - email_title: '你在“%{title}”中的帖子' + email_title: '您在“%{title}”中的帖子' email_body: "%{link}\n\n%{message}" notify_moderators: title: "其他事项" @@ -843,20 +843,20 @@ zh_CN: pm_body: "话题包含备份的草稿" user_activity: no_default: - self: "你还没有活动。" + self: "您还没有活动。" others: "无活动。" no_bookmarks: - self: "你没有收藏帖子;收藏可让你快速查阅特定帖子。" + self: "您没有收藏帖子;收藏可让您快速查阅特定帖子。" search: "在所提供的搜索查询中没有找到收藏内容。" others: "没有收藏。" no_likes_given: - self: "你还没有点赞任何帖子。" + self: "您还没有点赞任何帖子。" others: "没有被赞的帖子。" no_replies: - self: "你还未回复任何话题" + self: "您还未回复任何话题" others: "无回复" no_drafts: - self: "你没有草稿;任何话题中撰写回复,它将自动保存为新草稿。" + self: "您没有草稿;任何话题中撰写回复,它将自动保存为新草稿。" email_settings: pop3_authentication_error: "提供的 POP3 凭据存在问题,请检查用户名和密码后重试。" imap_authentication_error: "提供的 IMAP 凭据存在问题,请检查用户名和密码后重试。" @@ -903,8 +903,8 @@ zh_CN: email_title: '话题“%{title}”需要版主注意' email_body: "%{link}\n\n%{message}" flagging: - you_must_edit: '

      你的帖子被社区标记了。请查看你的消息。

      ' - user_must_edit: "

      你的帖子已经被社区标记并被临时隐藏。

      " + you_must_edit: '

      您的帖子被社区标记了。请查看您的消息。

      ' + user_must_edit: "

      您的帖子已经被社区标记并被临时隐藏。

      " ignored: hidden_content: "

      忽视的内容

      " archetypes: @@ -917,7 +917,7 @@ zh_CN: remove: "本话题已经不再是横幅话题。它将不在每个页面的顶部显示。" unsubscribed: title: "已更新电子邮件偏好设置!" - description: "%{email}的电子邮件设置已更新。要更改你的电子邮件设置,访问你的用户设置页面。" + description: "%{email}的电子邮件设置已更新。要更改您的电子邮件设置,访问您的用户设置页面。" topic_description: "点击链接 %{link} 重新订阅,或是使用话题底部或右侧的通知控制。" private_topic_description: "使用话题底部或右侧的通知控制重新订阅。" uploads: @@ -929,13 +929,13 @@ zh_CN: unwatch_category: "不再关注%{category}分类中的所有话题" mailing_list_mode: "停用邮件列表模式" all: "不要从%{sitename}给我发送任何邮件" - different_user_description: "你当前登录的账户与我们所发送邮件的用户不符。请登出,或进入隐私模式,然后重试。" - not_found_description: "抱歉,我们未能找到此退订。或许是因为你的邮件中的链接是很久以前的并且过期了?" + different_user_description: "您当前登录的账户与我们所发送邮件的用户不符。请登出,或进入隐私模式,然后重试。" + not_found_description: "抱歉,我们未能找到此退订。或许是因为您的邮件中的链接是很久以前的并且过期了?" log_out: "退出登录" submit: "保存设置" digest_frequency: - title: "你将每%{frequency}接收摘要邮件" - never_title: "你将不再收到摘要邮件" + title: "您将每%{frequency}接收摘要邮件" + never_title: "您将不再收到摘要邮件" select_title: "将摘要邮件的频率设为:" never: "从不" every_30_minutes: "每半小时" @@ -949,14 +949,14 @@ zh_CN: authorize: "授权" read: "阅读" read_write: "读/写" - description: '“%{application_name}”正在请求访问你的账户中的以下权限:' + description: '“%{application_name}”正在请求访问您的账户中的以下权限:' instructions: '我们刚刚生成了一个新的用户API密钥供您使用“%{application_name}”,请将以下密钥粘贴到您的应用程序中:' - otp_description: '你要允许“%{application_name}”访问此网站吗?' + otp_description: '您要允许“%{application_name}”访问此网站吗?' otp_confirmation: confirm_title: 转入到%{site_name} logging_in_as: 用%{username}登录 confirm_button: 登录完成 - no_trust_level: "抱歉,你没有使用用户 API 所需要的信任等级" + no_trust_level: "抱歉,您没有使用用户 API 所需要的信任等级" generic_error: "抱歉,我们不能分发用户 API 密钥,该特性已经被站点管理员禁用" scopes: message_bus: "实时更新" @@ -1297,11 +1297,11 @@ zh_CN: mutes_count: 静音计数 description: "被许多其他用户忽略和/或忽视的用户。" dashboard: - rails_env_warning: "你的服务器运行在 %{env} 模式。" - host_names_warning: "你的 config/database.yml 文件使用的是默认的 localhost 主机名。请更新为你的站点主机名。" + rails_env_warning: "您的服务器运行在 %{env} 模式。" + host_names_warning: "您的 config/database.yml 文件使用的是默认的 localhost 主机名。请更新为您的站点主机名。" sidekiq_warning: 'Sidekiq未运行。很多任务,例如发送电子邮件,是被Sidekiq异步执行的。请确保至少运行一个 Sidekiq进程。了解Sidekiq。' queue_size_warning: "队列中有较多任务,为 %{queue_size} 个。这可能是因为 Sidekiq 进程的问题导致,或者需要更多的 Sidekiq 进程。" - memory_warning: "你的服务器环境内存少于 1GB,我们建议至少要有 1GB 内存。" + memory_warning: "您的服务器环境内存少于 1GB,我们建议至少要有 1GB 内存。" google_oauth2_config_warning: '设置中允许使用Google OAuth2(enable_google_oauth2_logins)登录,但是没有设置client id和secret的值。前往站点设置更新设置。查看文档。' facebook_config_warning: '设置中允许使用Facebook(enable_facebook_login)登录,但是没有设置app id和secret的值。前往站点设置更新设置。查看文档。' twitter_config_warning: '设置允许使用Twitter(enable_twitter_logins)登录,但没有设置 key 和 secret的值。 前往站点设置更新设置。参考设定指南。' @@ -1309,24 +1309,24 @@ zh_CN: s3_config_warning: '服务器被配置为上传文件到s3,但以下的值中有未被设定的项目:s3_access_key_id、s3_secret_access_key、s3_use_iam_profile 或 s3_upload_bucket。到站点设置中更新设置。看看“参考如何设置图片上传至 S3?”以了解更多。' s3_backup_config_warning: '服务器被配置为上传备份到S3,但是以下的值中有未被设定的项目:s3_access_key_id、s3_secret_access_key、s3_use_iam_profile 或 s3_backup_bucket。到站点设置中更新设置。看看“参考如何设置图片上传至 S3?”以了解更多。' s3_cdn_warning: '服务器配置为将文件上传到 S3,但没有配置 S3 CDN。这可能会导致昂贵的 S3 成本和站点性能降低。 请参阅 “使用对象存储进行上传” 了解更多信息.' - image_magick_warning: '服务器被设置为给大图片创建缩略图,但是ImageMagick没有被安装。用你最喜爱的包管理器安装ImageMagick或下载最新版。' + image_magick_warning: '服务器被设置为给大图片创建缩略图,但是ImageMagick没有被安装。用您最喜爱的包管理器安装ImageMagick或下载最新版。' failing_emails_warning: '有%{num_failed_jobs}个邮件任务失败。请检查app.yml文件是否正确配置了邮件服务器。查看Sidekiq中失败的任务。' - subfolder_ends_in_slash: "你的子目录设置不正确;DISCOURSE_RELATIVE_URL_ROOT以斜杠结尾。" + subfolder_ends_in_slash: "您的子目录设置不正确;DISCOURSE_RELATIVE_URL_ROOT以斜杠结尾。" email_polling_errored_recently: other: "邮件轮询在过去的 24 小时内出现了 %{count} 个错误。看一看日志寻找详情。" missing_mailgun_api_key: "服务器设置使用Mailgun发送邮件,但并未设置验证webhook私信的API密钥。" bad_favicon_url: "网站图标无法载入。检查站点设置中的图标设置。" - poll_pop3_timeout: "连接 POP3 服务器超时。无法获取进站邮件。请检查你的POP3 设置和服务商。" - poll_pop3_auth_error: "连接至 POP3 服务器因身份验证错误而失败。请检查你的POP3 设置。" - force_https_warning: "你的网站使用了SSL。但是,你的网站设置中尚未启用`force_https`。" + poll_pop3_timeout: "连接 POP3 服务器超时。无法获取进站邮件。请检查您的POP3 设置和服务商。" + poll_pop3_auth_error: "连接至 POP3 服务器因身份验证错误而失败。请检查您的POP3 设置。" + force_https_warning: "您的网站使用了SSL。但是,您的网站设置中尚未启用`force_https`。" out_of_date_themes: "以下主题有更新:" unreachable_themes: "我们无法检查以下主题的更新:" - watched_word_regexp_error: "捕获敏感词 %{action} 的正则表达式无效。请检查你的 敏感词设置,或禁用 “敏感词正则表达式” 设置。" + watched_word_regexp_error: "捕获敏感词 %{action} 的正则表达式无效。请检查您的 敏感词设置,或禁用 “敏感词正则表达式” 设置。" site_settings: display_local_time_in_user_card: "打开用户卡片时,根据用户的时区显示本地时间。" censored_words: "将被自动替换为 ■■■■" delete_old_hidden_posts: "自动删除被隐藏超过 30 天的帖子。" - default_locale: "此Discourse实例的默认语言。你可以在自定义/文本内容替换系统生成的分类或话题的文本。" + default_locale: "此Discourse实例的默认语言。您可以在自定义/文本内容替换系统生成的分类或话题的文本。" allow_user_locale: "允许用户选择他们自己的语言界面" set_locale_from_accept_language_header: "使用浏览器的语言标头为匿名用户设置界面语言" support_mixed_text_direction: "支持混合的从左到右和从右到左的文本方向。" @@ -1351,7 +1351,7 @@ zh_CN: search_ignore_accents: "搜索文本时忽略重音。" category_search_priority_low_weight: "权重给予分类低搜索优先级。" category_search_priority_high_weight: "权重给予分类高搜索优先级。" - allow_uncategorized_topics: "允许发表没有分类的帖子。警告:如果存在任何未分类的帖子,你必须给他们重新分类后才能关闭该选项。" + allow_uncategorized_topics: "允许发表没有分类的帖子。警告:如果存在任何未分类的帖子,您必须给他们重新分类后才能关闭该选项。" allow_duplicate_topic_titles: "允许有相同或重复标题的话题。" allow_duplicate_topic_titles_category: "在不同的分类中允许帖子话题可以重复。\nallow_duplicate_topic_titles 必须设置为否(false)。" unique_posts_mins: "多少分钟之后才允许一个用户再次发表包含相同内容的帖子" @@ -1375,10 +1375,10 @@ zh_CN: max_image_width: "帖子中图片允许的最大缩略图宽度" max_image_height: "帖子中图片允许的最大缩略图宽度" responsive_post_image_sizes: "调整预览图像的大小以允许以下像素比率的高DPI屏幕。 删除所有值以禁用响应图像。" - fixed_category_positions: "如果选中,你将能够固定分类的排序。反之,分类将按照活跃顺序显示。" + fixed_category_positions: "如果选中,您将能够固定分类的排序。反之,分类将按照活跃顺序显示。" fixed_category_positions_on_create: "如果选中,话题创建对话框中会维持分类的排序(需要 fixed_category_positions)。" - add_rel_nofollow_to_user_content: '添加 rel nofollow 属性到所有的用户内容,除了内部链接(包括父域名)。如果你更改了这个,你必须使用“rake posts:rebake"重制所有帖子。' - exclude_rel_nofollow_domains: "不应添加nofollow的域名列表。example.com 会自动包含 sub.example.com。至少你应该填写本站域名来帮助爬虫获得所有内容。如果你网站的其余部分在另外的域名,也请添加那些域名。" + add_rel_nofollow_to_user_content: '添加 rel nofollow 属性到所有的用户内容,除了内部链接(包括父域名)。如果您更改了这个,您必须使用“rake posts:rebake"重制所有帖子。' + exclude_rel_nofollow_domains: "不应添加nofollow的域名列表。example.com 会自动包含 sub.example.com。至少您应该填写本站域名来帮助爬虫获得所有内容。如果您网站的其余部分在另外的域名,也请添加那些域名。" post_excerpt_maxlength: "帖子摘要的最大字符长度" topic_excerpt_maxlength: "根据话题的第一帖所生成,话题的节选或摘要的最大长度。" show_pinned_excerpt_mobile: "在移动版中显示置顶话题的摘要。" @@ -1389,9 +1389,9 @@ zh_CN: enable_inline_onebox_on_all_domains: "忽略inline_onebox_domain_allowlist设置,并允许所有域名的行内onebox。" force_custom_user_agent_hosts: "在所有请求上使用自定义 onebox 用户代理的主机。(对于限制用户代理访问的主机尤其有用)。" max_oneboxes_per_post: "帖子中最多的 Onebox 数量。" - facebook_app_access_token: "从你的 Facebook 应用程序 ID 和密钥生成的令牌。用于生成 Instagram oneboxes。" - logo: "你网站左上角的商标图像。使用高120且宽高比大于3:1的宽矩形图像。如果留空,将显示网站的标题文本。" - logo_small: "出现在你站点左上角的小号的商标图像,滚动后可见。使用方形120×120的图像。如果留空,将显示一个主页图标。" + facebook_app_access_token: "从您的 Facebook 应用程序 ID 和密钥生成的令牌。用于生成 Instagram oneboxes。" + logo: "您网站左上角的商标图像。使用高120且宽高比大于3:1的宽矩形图像。如果留空,将显示网站的标题文本。" + logo_small: "出现在您站点左上角的小号的商标图像,滚动后可见。使用方形120×120的图像。如果留空,将显示一个主页图标。" digest_logo: "邮件摘要顶部所使用的备用商标图像。使用宽矩形的图像。不要使用SVG图像。 如果留空,将使用`logo`设置中的图像。" mobile_logo: "网站的移动版本上使用的商标。使用高120且宽高比大于3:1的宽矩形图像。 如果留空,将使用`logo`设置中的图像。" logo_dark: "用于深色模式的“logo\"站点设置。" @@ -1400,7 +1400,7 @@ zh_CN: large_icon: "用作其他元数据图标的基础的图像。 理想情况下应大于512 x 512。如果留空,将使用logo_small。" manifest_icon: "用作Android图标的图像。会被自动调整为512×512。如果留空,将使用large_icon。" manifest_screenshots: "用于在安装提示页面上展示社区的特色和功能的屏幕截图。所有图像均应为本地上传且尺寸相同。" - favicon: "你的站点图标(favicon),参考https://zh.wikipedia.org/wiki/Favicon。如果要正确地通过 CDN 分发,它必须是png图片。会被调整为32x32。如果留空,将使用large_icon。" + favicon: "您的站点图标(favicon),参考https://zh.wikipedia.org/wiki/Favicon。如果要正确地通过 CDN 分发,它必须是png图片。会被调整为32x32。如果留空,将使用large_icon。" apple_touch_icon: "用于Apple触控设备的图标。会被自动调整为180x180。如果留空,将使用large_icon。" opengraph_image: "默认的opengraph图像,当页面没有其它合适的图像时使用。如果留空,将使用large_icon。" twitter_summary_large_image: "Twitter卡片的“摘要大图”(宽度至少应为280,高度至少为150)。如果留空,则使用opengraph_image生成常规卡片元数据。" @@ -1409,7 +1409,7 @@ zh_CN: email_subject: "自定义邮件标题的格式。参见https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "为用户提供关于他们为什么不能访问特定话题的更多细节。注意:这是不安全的,因为用户将知道一个 URL 是否链接到一个有效的话题。" enforce_second_factor: "强制用户启用双重验证。选择“all”以强制所有用户启用。选择“staff”仅强制管理人员。" - force_https: "强制使用 https。警告:开启前必须确认 HTTPS 已经配置并能够正常使用!你检查了 CDN、第三方登录和站外的 logo / 依赖是否支持 HTTPS 了吗?" + force_https: "强制使用 https。警告:开启前必须确认 HTTPS 已经配置并能够正常使用!您检查了 CDN、第三方登录和站外的 logo / 依赖是否支持 HTTPS 了吗?" same_site_cookies: "使用 Same-site cookies 以消除所有跨站请求伪造的攻击面 (Lax 或 Strict)。警告:Strict 仅在强制登录并使用外部身份验证方法的网站上有效。" summary_score_threshold: "一个帖子被加入到“话题摘要”中所需的最小分值" summary_posts_required: "一个话题启用“话题摘要”前最少需要有多少个回帖。修改此设置会影响过去一个星期。" @@ -1452,7 +1452,6 @@ zh_CN: invite_code: "用户必须输入此代码才能够进行账户注册,留空则无需输入(大小写敏感)" approve_suspect_users: "添加可疑用户到审核队列。可疑用户填写了简介或网站但是没有浏览行为。" review_every_post: "所有帖子都必须经过审查。警告!不建议用于繁忙的站点。" - pending_users_reminder_delay: "如果新用户等待批准时间超过此小时设置则通知版主。设置 -1 关闭通知。" persistent_sessions: "用户将在浏览器被关闭后保持登录状态" maximum_session_age: "用户自访问后可维持登录 n 小时" ga_version: "要使用的 Google Universal Analytics 版本:v3(analytics.js),v4(gtag)" @@ -1503,7 +1502,7 @@ zh_CN: email_token_valid_hours: "“忘记密码”/“激活账户”令牌有效的小时数。" enable_badges: "启用徽章系统" enable_whispers: "允许管理人员在话题中私密交流。" - allow_index_in_robots_txt: "在robots.txt中指定允许网络搜索引擎建立该网站的索引。在特殊情况下,你可以永久覆盖robots.txt 。" + allow_index_in_robots_txt: "在robots.txt中指定允许网络搜索引擎建立该网站的索引。在特殊情况下,您可以永久覆盖robots.txt 。" blocked_email_domains: "不允许注册账户的邮件域名列表(以管道符号分割)。例如:mailinator.com|trashmail.net" allowed_email_domains: "用户必须使用列表中的域名邮箱注册(使用管道符号分割)。警告:用户将无法使用列表之外的域名邮箱注册!" auto_approve_email_domains: "使用来自此域名列表中所提供的邮箱的用户会被自动批准。" @@ -1532,7 +1531,7 @@ zh_CN: discourse_connect_secret: "用于加密验证 DiscourseConnect 信息的密钥字符串,请确保是 10 个字符或者更长" discourse_connect_provider_secrets: "使用 DiscourseConnect 的域名与密钥对列表。确保 SSO 密钥为 10 个字符或更长。通配符 * 可用于匹配任何域名或仅匹配其中的一部分(例如* .example.com)。" discourse_connect_overrides_bio: "覆盖用户个人资料中的用户个人资料,并阻止用户对其进行更改" - discourse_connect_overrides_groups: "同步制定群组中手动创建的群组成员的群组属性。\n(警告:如果你不指定群组,所有的手动群组成员资格将被用户清除)" + discourse_connect_overrides_groups: "同步制定群组中手动创建的群组成员的群组属性。\n(警告:如果您不指定群组,所有的手动群组成员资格将被用户清除)" auth_overrides_email: "在每次登录时用外部网站的电子邮件覆盖本地电子邮件,并防止本地更改。适用于所有身份验证提供程序。(警告:由于本地电子邮件的规范化,可能会出现差异)" auth_overrides_username: "在每次登录时,用外部网站的用户名覆盖本地用户名,并防止本地更改。 适用于所有身份验证提供程序。(警告: 由于用户名长度/要求的不同,可能会出现差异)" auth_overrides_name: "每次登录都使用外部站点的全名覆盖本地全名,并且禁止本地修改。应用于所有的身份认证提供商。" @@ -1566,7 +1565,7 @@ zh_CN: discord_secret: "Discord Secret Key" discord_trusted_guilds: '只允许这些 Discord 公会的成员通过 Discord 登录。使用公会的数字 ID。更多有关信息,请查看这里的说明。留空则允许任意公会。' enable_backups: "允许管理员创建论坛备份" - allow_restore: "允许导入数据,这将能替换所有全站数据!除非你计划导入数据,否则请保持设置为 false" + allow_restore: "允许导入数据,这将能替换所有全站数据!除非您计划导入数据,否则请保持设置为 false" maximum_backups: "磁盘保存的最大备份数量。老的备份将自动删除" automatic_backups_enabled: "按照定义的备份频率运行自动备份计划" backup_frequency: "备份之间的天数。" @@ -1612,7 +1611,7 @@ zh_CN: limit_suggested_to_category: "只在当前分类的推荐帖子列表中显示帖子。" suggested_topics_max_days_old: "推荐话题的创建时间不能超过 n 天。" suggested_topics_unread_max_days_old: "推荐的未读话题创建时间不能大于 n 天。" - clean_up_uploads: "移除孤立的已上传资料。警告:你可能想要在启用这个设定前备份一下 /uploads 目录。" + clean_up_uploads: "移除孤立的已上传资料。警告:您可能想要在启用这个设定前备份一下 /uploads 目录。" clean_orphan_uploads_grace_period_hours: "删除孤立上传资料的宽限期(单位:小时)" purge_deleted_uploads_grace_period_days: "彻底删除孤立的上传内容的宽限期(单位:天)" purge_unactivated_users_grace_period_days: "删除未激活用户账户的宽限期(单位:天)。设置为 0 将永不清除未激活用户。" @@ -1622,7 +1621,7 @@ zh_CN: s3_access_key_id: "将用于上传图片,附件和备份的Amazon S3 access key id。" s3_secret_access_key: "将用于上传图片,附件和备份的Amazon S3 secret access key。" s3_region: "将用于上传图片和备份的 Amazon S3 区域名。" - s3_cdn_url: "用户 S3 资料的 CDN URL(例如:https://cdn.somewhere.com)。警告:在改变该设置后你必须重制所有老帖子。" + s3_cdn_url: "用户 S3 资料的 CDN URL(例如:https://cdn.somewhere.com)。警告:在改变该设置后您必须重制所有老帖子。" avatar_sizes: "自动生成的头像大小列表。" external_system_avatars_enabled: "使用外部系统头像服务。" external_system_avatars_url: "外部系统头像服务的 URL 地址。可选参数是 {username} {first_letter} {color} {size}" @@ -1640,7 +1639,7 @@ zh_CN: min_ratio_to_crop: "用于裁剪高图像的比率。输入宽/高值。" simultaneous_uploads: "可以在编辑器中拖放的最大文件数" default_invitee_trust_level: "受邀用户的默认信任等级(0-4)。" - default_trust_level: "所有新用户的默认信任等级(0-4)。警告!改变此项将使你面临广告泛滥的风险。" + default_trust_level: "所有新用户的默认信任等级(0-4)。警告!改变此项将使您面临广告泛滥的风险。" tl1_requires_topics_entered: "新用户升级到信任等级1所需要进入的话题数量。" tl1_requires_read_posts: "新用户升级到信任等级1所需要阅读的帖子数量。" tl1_requires_time_spent_mins: "新用户升级到信任等级1需要看帖多少分钟。" @@ -1686,7 +1685,7 @@ zh_CN: newuser_max_attachments: "一个新用户可以添加到一个帖子里的附件数量。" newuser_max_mentions_per_post: "一个访问者可以在一个帖子里使用 @name 提及的最大数量。" newuser_max_replies_per_topic: "直至有人回复他们前,新用户在一个帖子里的最大回复数量。" - max_mentions_per_post: "你可以在一个帖子里使用 @name 提及的最大数量。" + max_mentions_per_post: "您可以在一个帖子里使用 @name 提及的最大数量。" max_users_notified_per_group_mention: "当群组被提及时,接受提醒的最大用户数 ( 超过阈值后将不发送提醒 )" enable_mentions: "允许用户提及其他用户。" create_thumbnails: "为太大而无法恰当地显示在帖子里的图片创建 lightbox 缩略图。" @@ -1729,10 +1728,10 @@ zh_CN: topic_post_like_heat_low: "在赞/帖子的比值超过这个值后,帖子计数栏将稍微高亮。" topic_post_like_heat_medium: "在赞/帖子的比值超过这个值后,帖子数量一栏将明显高亮。" topic_post_like_heat_high: "在赞与帖子的比例超过此比例后,帖子数量一栏将强烈高亮。" - faq_url: "如果你的 FAQ 文档在外部,那么请在此填写其完整 URL 地址。" - tos_url: "如果你的服务条款文档在外部,那么请在此填写其完整 URL 地址。" - privacy_policy_url: "如果你的隐私政策文档在外部,那么请在此填写其完整 URL 地址。" - log_anonymizer_details: "匿名后是否将用户的详细信息保留在日志中。在遵守GDPR时,你需要关闭它。" + faq_url: "如果您的 FAQ 文档在外部,那么请在此填写其完整 URL 地址。" + tos_url: "如果您的服务条款文档在外部,那么请在此填写其完整 URL 地址。" + privacy_policy_url: "如果您的隐私政策文档在外部,那么请在此填写其完整 URL 地址。" + log_anonymizer_details: "匿名后是否将用户的详细信息保留在日志中。在遵守GDPR时,您需要关闭它。" newuser_spam_host_threshold: "被判定为垃圾前,新用户能发表指向同一主机的链接的次数。" allowed_spam_host_domains: "不需要进行垃圾内容测试的域名列表。新用户永远不会因发表带有这些域名链接的内容而被限制。" staff_like_weight: "管理人员的赞的权重(非管理人员的赞的权重为1)。" @@ -1828,7 +1827,7 @@ zh_CN: automatically_download_gravatars: "为注册或更改邮箱的用户下载 Gravatar 头像。" digest_topics: "邮件摘要中显示热门话题的最大数量。" digest_posts: "邮件摘要中所显示的热门帖子的数量。" - digest_other_topics: "邮件摘要中“你所关注的话题和分类中的新内容”这一部分的最大话题数量。" + digest_other_topics: "邮件摘要中“您所关注的话题和分类中的新内容”这一部分的最大话题数量。" digest_min_excerpt_length: "邮件摘要中显示的帖子摘要字符数下限。" suppress_digest_email_after_days: "停止给(n)天未登录网站的用户发送摘要邮件。" digest_suppress_categories: "从摘要邮件中排除这些分类的内容。" @@ -1864,7 +1863,7 @@ zh_CN: allow_profile_backgrounds: "允许用户上传个人资料背景图片。" sequential_replies_threshold: "在被提醒过多连续的回复前,用户在话题中可以连续回复的帖子数量。" get_a_room_threshold: "用户被警告前,可在同一个话题下向同一个人回帖的数量。" - enable_mobile_theme: "为移动设备启用移动友好的主题,但也能切换回完整站点。如果你想要使用自定义的响应式主题请禁用它。" + enable_mobile_theme: "为移动设备启用移动友好的主题,但也能切换回完整站点。如果您想要使用自定义的响应式主题请禁用它。" dominating_topic_minimum_percent: "被提醒过度干预一个话题前,单个用户发帖可占整个话题的百分比。" disable_avatar_education_message: "禁用更改头像操作的教育私信。" suppress_uncategorized_badge: "话题列表中的未分类话题不要显示徽章。" @@ -1883,13 +1882,13 @@ zh_CN: app_association_ios: "端点apple-app-site-association 的内容,用于在创建该站点和 iOS 应用程序之间通用链接。" share_anonymized_statistics: "分享匿名化的使用数据。" auto_handle_queued_age: "此设定天数后自动处理待审核的记录。标记将被忽略。队列中的帖子及用户将被拒绝。设为 0 将禁用此功能。" - svg_icon_subset: "添加你希望包含在资产中的其它FontAwesome 5图标。使用前缀'fa-'表示实心图标,'far-'表示常规图标,'fab-'表示品牌图标。" + svg_icon_subset: "添加您希望包含在资产中的其它FontAwesome 5图标。使用前缀'fa-'表示实心图标,'far-'表示常规图标,'fab-'表示品牌图标。" max_prints_per_hour_per_user: "/print 页面的每小时最大展示量(设置为 0 禁用)" full_name_required: "全名是用户个人信息的必填项。" enable_names: "在用户的个人信息、用户卡片和邮件中显示全名。禁用以在所有地方隐藏全名。" display_name_on_posts: "在用户的帖子中显示他们的全名以及他们的 @username。" show_time_gap_days: "两个帖子的发表时间相隔多少天内时,在话题中以时间间隔显示。" - short_progress_text_threshold: "在话题中的帖子数超过这个数量后,进度条将只显示当前的帖子数量。如果你更改了进度条的宽度,你需要修改这个值。" + short_progress_text_threshold: "在话题中的帖子数超过这个数量后,进度条将只显示当前的帖子数量。如果您更改了进度条的宽度,您需要修改这个值。" default_code_lang: "使用Github code block(auto, nohighlight, ruby, python etc)作为默认的编程语言语法高亮。" warn_reviving_old_topic_age: "当有人开始回复一个最后回复于设定天数之前的话题时,将显示一个警告。将其设置为 0 以禁用。" autohighlight_all_code: "即使未显式声明语言,仍为所有预格式化代码块应用语法高亮。" @@ -1916,7 +1915,7 @@ zh_CN: slug_generation_method: "选择一个链接生成方式。“encoded”将生成以百分号编码的链接。“none”将禁用自定义链接,只生成默认链接。" enable_emoji: "启用绘文字(emoji)" enable_emoji_shortcuts: "常见的笑脸文字如 :) :p :( 将被转换为绘文字" - emoji_set: "你喜欢哪一种 emoji?" + emoji_set: "您喜欢哪一种 emoji?" emoji_autocomplete_min_chars: "触发自动填充表情符号弹出窗口所需的最少字符数" enable_inline_emoji_translation: "启用行内表情符号解析(之前没有任何空格或标点)" approve_post_count: "新用户或基础用户需要被审核的帖子数量" @@ -2030,29 +2029,29 @@ zh_CN: invalid_json: "无效的 JSON" invalid_reply_by_email_address: "值必须包含 '%{reply_key' 并且要与通知邮件不同。" invalid_alternative_reply_by_email_addresses: "必须包括 “%{reply_key}” 并与通知邮件地址不同。" - pop3_polling_host_is_empty: "在启用 POP3 轮询前,你必须设置 'pop3 polling host'。" - pop3_polling_username_is_empty: "在启用 POP3 轮询前,你必须设置 'pop3 polling username'。" - pop3_polling_password_is_empty: "在启用 POP3 轮询前,你必须设置 'pop3 polling password'。" - pop3_polling_authentication_failed: "POP3 验证失败。请验证你的 pop3 账户信息。" - reply_by_email_address_is_empty: "在启用邮件回复之前,你必须设置“reply by email address”。" - email_polling_disabled: "在启用邮件回复功能前,你必须启用手动或者 POP3 轮询。" - user_locale_not_enabled: "你必须先设置 'allow user locale' 再启用该设置。" + pop3_polling_host_is_empty: "在启用 POP3 轮询前,您必须设置 'pop3 polling host'。" + pop3_polling_username_is_empty: "在启用 POP3 轮询前,您必须设置 'pop3 polling username'。" + pop3_polling_password_is_empty: "在启用 POP3 轮询前,您必须设置 'pop3 polling password'。" + pop3_polling_authentication_failed: "POP3 验证失败。请验证您的 pop3 账户信息。" + reply_by_email_address_is_empty: "在启用邮件回复之前,您必须设置“reply by email address”。" + email_polling_disabled: "在启用邮件回复功能前,您必须启用手动或者 POP3 轮询。" + user_locale_not_enabled: "您必须先设置 'allow user locale' 再启用该设置。" invalid_regex: "正则表达式非法或者不允许。" - email_editable_enabled: "你必须先禁用 'email editable' 再启用该设置。" - staged_users_disabled: "你必须先启用“暂存用户”再启用该设置。" - reply_by_email_disabled: "你必须先启用 '用 email 回复' 再启用该设置。" + email_editable_enabled: "您必须先禁用 'email editable' 再启用该设置。" + staged_users_disabled: "您必须先启用“暂存用户”再启用该设置。" + reply_by_email_disabled: "您必须先启用 '用 email 回复' 再启用该设置。" discourse_connect_url_is_empty: "在启用该设置前必须设置“discourse connect url”。" - discourse_connect_invite_only: "你不能够同时启用DiscourseConnect和仅邀请模式。" + discourse_connect_invite_only: "您不能够同时启用DiscourseConnect和仅邀请模式。" enable_local_logins_disabled: "必须先启用“启用本地登录”,才能启用此设置。" min_username_length_exists: "不能设置最小用户名长度大于最短用户名(%{username})。" min_username_length_range: "不能将最小值设置为高于最大值。" max_username_length_exists: "不能设置最大用户名长度小于最长用户名(%{username})。" max_username_length_range: "不能将最大值设置为低于最小值。" invalid_hex_value: "颜色值需为6位16进制代码。" - empty_selectable_avatars: "启用该设置前,你必须先上传至少两个可选的头像。" + empty_selectable_avatars: "启用该设置前,您必须先上传至少两个可选的头像。" category_search_priority: - low_weight_invalid: "你不能将权重设置为大于或等于1。" - high_weight_invalid: "你不能将权重设置为小于或等于1。" + low_weight_invalid: "您不能将权重设置为大于或等于1。" + high_weight_invalid: "您不能将权重设置为小于或等于1。" allowed_unicode_usernames: regex_invalid: "正在表达式无效:%{error}" leading_trailing_slash: "正则表达式不能以斜杠作为开头或结尾。" @@ -2074,9 +2073,9 @@ zh_CN: video: "[视频]" discourse_connect: login_error: "登录错误" - not_found: "无法找到你的账户。请联系站点管理人员。" - account_not_approved: "你的帐户正在等待批准。获得批准时你会收到一封通知邮件。" - unknown_error: "你的账户出了一点问题。请联系站点管理人员。" + not_found: "无法找到您的账户。请联系站点管理人员。" + account_not_approved: "您的帐户正在等待批准。获得批准时您会收到一封通知邮件。" + unknown_error: "您的账户出了一点问题。请联系站点管理人员。" timeout_expired: "帐户登录超时,请尝试重新登录。" no_email: "没有找到邮件地址。请联系站点管理员。" blank_id_error: "当前空缺的`external_id`是必需的" @@ -2090,7 +2089,7 @@ zh_CN: poster_description_joiner: ", " redirected_to_top_reasons: new_user: "欢迎来到我们的社区!这些都是最近热门话题。" - not_seen_in_a_month: "欢迎回来!我们已经好久没见到你了。这些是你不在时的最热门话题。" + not_seen_in_a_month: "欢迎回来!我们已经好久没见到您了。这些是您不在时的最热门话题。" merge_posts: edit_reason: other: "%{username}合并了 %{count} 个帖子" @@ -2148,58 +2147,58 @@ zh_CN: auto_deleted_by_timer: "由计时器自动删除。" login: invalid_second_factor_method: "所选的双重认证方法无效。" - not_enabled_second_factor_method: "你的帐户未启用所选的双重验证方式。" - security_key_description: "当你准备好物理安全密钥后,请按下面的“使用安全密钥进行身份验证”按钮。" + not_enabled_second_factor_method: "您的帐户未启用所选的双重验证方式。" + security_key_description: "当您准备好物理安全密钥后,请按下面的“使用安全密钥进行身份验证”按钮。" security_key_alternative: "尝试另一种方式" security_key_authenticate: "使用安全密钥进行身份验证" security_key_not_allowed_error: "安全密钥验证超时或被取消。" security_key_no_matching_credential_error: "在提供的安全密钥中找不到匹配的凭据。" security_key_support_missing_error: "您当前的设备或浏览器不支持使用安全密钥。请使用其他方法。" security_key_invalid: "验证安全密钥时出错。" - not_approved: "你的账户尚未获得批准。一旦你的账户获得批准,你会收到一封电子邮件。" + not_approved: "您的账户尚未获得批准。一旦您的账户获得批准,您会收到一封电子邮件。" incorrect_username_email_or_password: "用户名、电子邮箱或密码不正确" incorrect_password: "密码错误" - wait_approval: "谢谢注册账户。我们会在你的账户获得批准之后通知你。" - active: "你的账户已经被激活,可以使用了。" - activate_email: "

      快完成了!我们发送了一封激活邮件到%{email}。请按照邮件中的步骤来激活你的账户。

      如果你没有收到邮件,请检查你的垃圾邮件收件箱。

      " - not_activated: "你还不能登录。我们发送了一封激活邮件给你,请按照邮件中的步骤来激活你的账户。" - not_allowed_from_ip_address: "你不能以 %{username} 身份从该 IP 地址登录。" - admin_not_allowed_from_ip_address: "你不能从该 IP 地址以管理员身份登录。" - reset_not_allowed_from_ip_address: "你不能够从当前的IP地址请求重置密码。" - suspended: "你要等到 %{date} 之后才能登录。" + wait_approval: "谢谢注册账户。我们会在您的账户获得批准之后通知您。" + active: "您的账户已经被激活,可以使用了。" + activate_email: "

      快完成了!我们发送了一封激活邮件到%{email}。请按照邮件中的步骤来激活您的账户。

      如果您没有收到邮件,请检查您的垃圾邮件收件箱。

      " + not_activated: "您还不能登录。我们发送了一封激活邮件给您,请按照邮件中的步骤来激活您的账户。" + not_allowed_from_ip_address: "您不能以 %{username} 身份从该 IP 地址登录。" + admin_not_allowed_from_ip_address: "您不能从该 IP 地址以管理员身份登录。" + reset_not_allowed_from_ip_address: "您不能够从当前的IP地址请求重置密码。" + suspended: "您要等到 %{date} 之后才能登录。" suspended_with_reason: "账户已封禁至 %{date}:%{reason}" errors: "%{errors}" not_available: "不可用,试试%{suggestion}?" something_already_taken: "出了一些问题,可能此用户名或电子邮箱已经被注册。试试 忘记密码链接吧。" omniauth_error: - generic: "抱歉,验证你的账户时出错。请重试。" + generic: "抱歉,验证您的账户时出错。请重试。" csrf_detected: "授权超时,或者您已切换浏览器。请重试。" request_error: "授权时发生错误。请重试。" invalid_iat: "由于服务器时钟差异,无法验证授权令牌。请重试。" - omniauth_error_unknown: "在处理你的登录过程中发生了错误,请重试。" + omniauth_error_unknown: "在处理您的登录过程中发生了错误,请重试。" omniauth_confirm_title: "使用%{provider}登录" omniauth_confirm_button: "继续" - authenticator_error_no_valid_email: "关联%{account}的账户没有优秀的邮件地址。你可能需要用不同的邮件地址配置你的账户。" + authenticator_error_no_valid_email: "关联%{account}的账户没有优秀的邮件地址。您可能需要用不同的邮件地址配置您的账户。" new_registrations_disabled: "现在不允许注册新账户。" password_too_long: "密码不能超过 200 个字符。" - email_too_long: "你输入的邮件太长了。邮箱名不得超过 254 字符,域名必须不超过 253 字符。" - wrong_invite_code: "你输入的邀请码不正确。" + email_too_long: "您输入的邮件太长了。邮箱名不得超过 254 字符,域名必须不超过 253 字符。" + wrong_invite_code: "您输入的邀请码不正确。" reserved_username: "该用户名不可被使用。" - missing_user_field: "你还没有填写完所有用户字段" + missing_user_field: "您还没有填写完所有用户字段" auth_complete: "验证已经完成。" click_to_continue: "点击这里继续。" already_logged_in: "抱歉!这个邀请只用于尚未拥有账户的新用户。" second_factor_title: "双重认证" - second_factor_description: "请输入你的APP验证码:" + second_factor_description: "请输入您的APP验证码:" second_factor_backup_description: "请输入备用码:" - second_factor_backup_title: "双重认证备份码" + second_factor_backup_title: "双重认证备份代码" invalid_second_factor_code: "验证码无效。每个代码只能使用一次。" invalid_security_key: "无效的安全密钥。" missing_second_factor_name: "请提供一个名称。" missing_second_factor_code: "请提供一个验证码" second_factor_toggle: totp: "请改用身份验证器应用或安全密钥" - backup_code: "使用备份码" + backup_code: "使用备份代码" admin: email: sent_test: "已发送!" @@ -2242,8 +2241,8 @@ zh_CN: revoked: "在%{date}之前不发送邮件至“%{email}”" does_not_exist: "N/A" ip_address: - blocked: "不允许从你的 IP 地址注册新用户。" - max_new_accounts_per_registration_ip: "不允许从你的 IP 地址注册新用户(达到上限)。联系一个管理人员。" + blocked: "不允许从您的 IP 地址注册新用户。" + max_new_accounts_per_registration_ip: "不允许从您的 IP 地址注册新用户(达到上限)。联系一个管理人员。" website: domain_not_allowed: "网站无效。允许的域名有:%{domains}" auto_rejected: "因时限被自动拒绝。查看站点自动处理队列时限设置。" @@ -2264,20 +2263,20 @@ zh_CN: other: "%{count}个项目需要被审核" unsubscribe_mailer: title: "取消订阅发件人" - subject_template: "确认你不想要收到%{site_title}的电子邮件更新" + subject_template: "确认您不想要收到%{site_title}的电子邮件更新" text_body_template: | - 我们收到了取消订阅来自%{site_domain_name}的邮件更新的请求(是你的操作吗?)。 + 我们收到了取消订阅来自%{site_domain_name}的邮件更新的请求(是您的操作吗?)。 - 如果你确认该请求,点击链接: + 如果您确认该请求,点击链接: %{confirm_unsubscribe_link} - 如果你想要继续收到邮件更新,你可以忽略这封邮件。 + 如果您想要继续收到邮件更新,您可以忽略这封邮件。 invite_mailer: title: "要求发件人" - subject_template: "%{inviter_name} 邀请你参与 '%{topic_title}' 于 %{site_domain_name}" + subject_template: "%{inviter_name} 邀请您参与 '%{topic_title}' 于 %{site_domain_name}" text_body_template: | - %{inviter_name} 邀请你参与讨论 + %{inviter_name} 邀请您参与讨论 > **%{topic_title}** > @@ -2287,14 +2286,14 @@ zh_CN: > %{site_title} -- %{site_description} - 如果你感兴趣,点击以下链接: + 如果您感兴趣,点击以下链接: %{invite_link} custom_invite_mailer: title: "自定义邀请发件人" - subject_template: "%{inviter_name}邀请你加入'%{topic_title}'于%{site_domain_name}" + subject_template: "%{inviter_name}邀请您加入'%{topic_title}'于%{site_domain_name}" text_body_template: | - %{inviter_name} 邀请你参与讨论 + %{inviter_name} 邀请您参与讨论 > **%{topic_title}** > @@ -2308,27 +2307,27 @@ zh_CN: > %{user_custom_message} - 如果你感兴趣,点击以下链接: + 如果您感兴趣,点击以下链接: %{invite_link} invite_forum_mailer: title: "邀请论坛发件人" - subject_template: "%{inviter_name}邀请你加入%{site_domain_name}" + subject_template: "%{inviter_name}邀请您加入%{site_domain_name}" text_body_template: | - %{inviter_name} 邀请你加入 + %{inviter_name} 邀请您加入 > **%{site_title}** > > %{site_description} - 如果你感兴趣,点击以下链接: + 如果您感兴趣,点击以下链接: %{invite_link} custom_invite_forum_mailer: title: "自定义论坛邀请发件人" - subject_template: "%{inviter_name}邀请你加入%{site_domain_name}" + subject_template: "%{inviter_name}邀请您加入%{site_domain_name}" text_body_template: | - %{inviter_name} 邀请你加入 + %{inviter_name} 邀请您加入 > **%{site_title}** > @@ -2338,38 +2337,38 @@ zh_CN: > %{user_custom_message} - 如果你感兴趣,点击以下链接: + 如果您感兴趣,点击以下链接: %{invite_link} invite_password_instructions: title: "邀请密码指示" subject_template: "为 %{site_name} 账户设置密码" text_body_template: | - 感谢你接受来自%{site_name}的邀请——欢迎! + 感谢您接受来自%{site_name}的邀请——欢迎! 点击下面的链接立即选择一个密码: %{base_url}/u/password-reset/%{email_token} - (如果链接已经过期,在登录时点击“我忘记了密码”,再次输入你的邮箱即可。) + (如果链接已经过期,在登录时点击“我忘记了密码”,再次输入您的邮箱即可。) download_backup_mailer: title: "下载备份邮件发件人" subject_template: "[%{email_prefix}] 站点备份下载" text_body_template: | - 这是你的[站点备份文件](%{backup_file_path})。 + 这是您的[站点备份文件](%{backup_file_path})。 - 出于安全原因,我们把下载链接发送至你验证过的邮箱。 + 出于安全原因,我们把下载链接发送至您验证过的邮箱。 - (如果你**没有**请求下载,你需要思考一下了——某管理员请求了备份文件) + (如果您**没有**请求下载,您需要思考一下了——某管理员请求了备份文件) no_token: | 抱歉,这个备份下载链接已经被使用或已经过期。 admin_confirmation_mailer: title: "管理员确认" subject_template: "[%{site_name}] 确认新管理员账户" - text_body_template: "请确认你要添加**%{target_username}(%{target_email})**作为你的论坛的管理员。 \n\n[确认管理员账户](%{admin_confirm_url})\n" + text_body_template: "请确认您要添加**%{target_username}(%{target_email})**作为您的论坛的管理员。 \n\n[确认管理员账户](%{admin_confirm_url})\n" test_mailer: title: "测试发件人" subject_template: "[%{email_prefix}] 邮件可达性测试" - text_body_template: "这是一封测试邮件,它来自 \n\n[**%{base_url}**][0]\n\n邮件传输是复杂的。下面是几个你需要第一时间留意的重点:\n\n- 请*确保*正确地在站点设置中设置了`notification email`。**你所发出的邮件的来源字段的域名需要被验证**。\n\n- 清楚如何使用你的邮件客户端查看邮件原始内容,这样你能够检查邮件标头以获取重要线索。在Gmail中,每封邮件的右上角下拉菜单中都有“显示原始内容”的选项。\n\n- **重要:**你的ISP是否设置了反向DNS记录以关联你用于发送邮件的域名和IP地址?[测试你的反向PTR记录][2]。如果你的ISP未正确设置反向DNS记录,你的邮件不太可能被正确送达。\n\n- 你的域名的[SPF记录][8]正确吗?[测试你的SPF记录][1]。注意SPF的正确记录类型是TXT.\n\n- 你的域名的[DKIM记录][3]正确吗?这会显著地提升邮件送达。[测试你的DKIM记录][7]。\n\n- 如果你使用自建的邮件服务器,确保你的邮件服务器的IP地址[不在邮件屏蔽列表中][4]。还要验证其在HELO信息中正确地发送出一个DNS解析正常的域名。否则,你的邮件会被许多邮件服务拒收。\n\n- 我们强烈建议你**发送一封测试邮件到[mail-tester.com][mt]**以验证上述所有设置正常。\n\n(较*方便*的方法就是创建一个[SendGrid][sg]、[SparkPost][sp]、[Mailgun][mg]或[Mailjet][mj]的账号,这些平台提供适用于小型社区的低成本邮寄服务。不过,你仍然需要在DNS中设置SPF和DKIM记录!)\n\n希望你能成功收到这封测试邮件!\n\n祝君好运,\n\n你在[Discourse](https://www.discourse.org)的伙伴\n\n[0]: %{base_url}\n[1]: https://www.kitterman.com/spf/validate.html\n[2]: https://mxtoolbox.com/ReverseLookup.aspx\n[3]: http://www.dkim.org/\n[4]: https://whatismyipaddress.com/blacklist-check\n[7]: https://www.mail-tester.com/spf-dkim-check\n[8]: http://www.openspf.org/SPF_Record_Syntax\n[sg]: https://goo.gl/r1WMF6\n[sp]: https://www.sparkpost.com/\n[mg]: https://www.mailgun.com/\n[mj]: https://www.mailjet.com/pricing/\n[mt]: https://www.mail-tester.com/\n" + text_body_template: "这是一封测试邮件,它来自 \n\n[**%{base_url}**][0]\n\n邮件传输是复杂的。下面是几个您需要第一时间留意的重点:\n\n- 请*确保*正确地在站点设置中设置了`notification email`。**您所发出的邮件的来源字段的域名需要被验证**。\n\n- 清楚如何使用您的邮件客户端查看邮件原始内容,这样您能够检查邮件标头以获取重要线索。在Gmail中,每封邮件的右上角下拉菜单中都有“显示原始内容”的选项。\n\n- **重要:**您的ISP是否设置了反向DNS记录以关联您用于发送邮件的域名和IP地址?[测试您的反向PTR记录][2]。如果您的ISP未正确设置反向DNS记录,您的邮件不太可能被正确送达。\n\n- 您的域名的[SPF记录][8]正确吗?[测试您的SPF记录][1]。注意SPF的正确记录类型是TXT.\n\n- 您的域名的[DKIM记录][3]正确吗?这会显著地提升邮件送达。[测试您的DKIM记录][7]。\n\n- 如果您使用自建的邮件服务器,确保您的邮件服务器的IP地址[不在邮件屏蔽列表中][4]。还要验证其在HELO信息中正确地发送出一个DNS解析正常的域名。否则,您的邮件会被许多邮件服务拒收。\n\n- 我们强烈建议您**发送一封测试邮件到[mail-tester.com][mt]**以验证上述所有设置正常。\n\n(较*方便*的方法就是创建一个[SendGrid][sg]、[SparkPost][sp]、[Mailgun][mg]或[Mailjet][mj]的账号,这些平台提供适用于小型社区的低成本邮寄服务。不过,您仍然需要在DNS中设置SPF和DKIM记录!)\n\n希望您能成功收到这封测试邮件!\n\n祝君好运,\n\n您在[Discourse](https://www.discourse.org)的伙伴\n\n[0]: %{base_url}\n[1]: https://www.kitterman.com/spf/validate.html\n[2]: https://mxtoolbox.com/ReverseLookup.aspx\n[3]: http://www.dkim.org/\n[4]: https://whatismyipaddress.com/blacklist-check\n[7]: https://www.mail-tester.com/spf-dkim-check\n[8]: http://www.openspf.org/SPF_Record_Syntax\n[sg]: https://goo.gl/r1WMF6\n[sp]: https://www.sparkpost.com/\n[mg]: https://www.mailgun.com/\n[mj]: https://www.mailjet.com/pricing/\n[mt]: https://www.mail-tester.com/\n" new_version_mailer: title: "新版本发件人" subject_template: "[%{email_prefix}] 新 Discourse 版本可供升级" @@ -2403,14 +2402,14 @@ zh_CN: %{notes} flag_reasons: - off_topic: "你的帖子被标记为 **偏离话题**:鉴于当前的话题标题和第一个帖子,社区成员们感觉它不适合处于这个话题中。" - inappropriate: "你的帖子被标记为 **不恰当**:社区成员感觉它有冒犯或者侮辱的意味,亦或是它违反了[社区准则](%{base_path}/guidelines)。" - spam: "你的帖子被标记为 **广告**:社区成员觉得它是广告,像是在过度地推广着什么,而不是预期中与话题有关的内容。" - notify_moderators: "你的帖子被标记为 **需要版主关注**:社区成员认为帖子需要管理人员介入。" + off_topic: "您的帖子被标记为 **偏离话题**:鉴于当前的话题标题和第一个帖子,社区成员们感觉它不适合处于这个话题中。" + inappropriate: "您的帖子被标记为 **不恰当**:社区成员感觉它有冒犯或者侮辱的意味,亦或是它违反了[社区准则](%{base_path}/guidelines)。" + spam: "您的帖子被标记为 **广告**:社区成员觉得它是广告,像是在过度地推广着什么,而不是预期中与话题有关的内容。" + notify_moderators: "您的帖子被标记为 **需要版主关注**:社区成员认为帖子需要管理人员介入。" flags_dispositions: - agreed: "谢谢你的私信。我们认为这是个问题。我们正在进行处理。" + agreed: "谢谢您的私信。我们认为这是个问题。我们正在进行处理。" agreed_and_deleted: "感谢通知我们。我们认为这是一个问题,并且我们已经删除了帖子。" - disagreed: "谢谢你的私信。我们正在进行处理。" + disagreed: "谢谢您的私信。我们正在进行处理。" ignored: "感谢通知我们。我们正在调查情况。" ignored_and_deleted: "感谢通知我们。我们已经删除了帖子。" temporarily_closed_due_to_flags: @@ -2422,15 +2421,15 @@ zh_CN: title: "帖子隐藏" subject_template: "帖子因社区标记隐藏" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知你的帖子已被隐藏的自动消息。 + 这是%{site_name}通知您的帖子已被隐藏的自动消息。 <%{base_url}%{url}> %{flag_reason} - 这个帖子被隐藏是因为被社区成员所标记,所以请考虑根据他们的反馈修改你的帖子。**你可以在 %{edit_delay} 分钟后开始编辑你的帖子,然后它将重新被显示。** + 这个帖子被隐藏是因为被社区成员所标记,所以请考虑根据他们的反馈修改您的帖子。**您可以在 %{edit_delay} 分钟后开始编辑您的帖子,然后它将重新被显示。** 然而,如果帖子再次被社区成员标记并隐藏,它将被隐藏至版主处理后。 @@ -2439,15 +2438,15 @@ zh_CN: title: "再次发布隐藏" subject_template: "被社区标记所隐藏,已通知管理人员" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知你的帖子再次被隐藏了的自动消息。 + 这是%{site_name}通知您的帖子再次被隐藏了的自动消息。 <%{base_url}}%{url}> %{flag_reason} - 社区成员标记了该帖子,现已被隐藏。**由于此帖子已被隐藏超过一次,你的帖子现在将持续隐藏,直到由工作人员作出处理。** + 社区成员标记了该帖子,现已被隐藏。**由于此帖子已被隐藏超过一次,您的帖子现在将持续隐藏,直到由工作人员作出处理。** 有关其他指导,请参阅我们的[社区指引](%{base_url}/guidelines)。 queued_by_staff: @@ -2456,20 +2455,20 @@ zh_CN: text_body_template: | Hello, - 这是一条从 %{site_name} 发来的自动消息,,你的帖子已隐藏。 + 这是一条从 %{site_name} 发来的自动消息,,您的帖子已隐藏。 <%{base_url}%{url}> - 你的帖子将一直隐藏,直到管理员对其进行审核。 + 您的帖子将一直隐藏,直到管理员对其进行审核。 有关其他指导,请参阅我们的[社区准则](%{base_url}/guidelines)。 flags_disagreed: title: "管理人员恢复了被标记的帖子" subject_template: "管理人员恢复了被标记的帖子" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知[你的贴子](%{base_url}%{url})已被恢复的自动消息。 + 这是%{site_name}通知[您的贴子](%{base_url}%{url})已被恢复的自动消息。 此贴曾被社区标记过,管理人员选择将其恢复。 @@ -2482,9 +2481,9 @@ zh_CN: title: "管理人员删除了已标记的帖子" subject_template: "管理人员删除了已标记的帖子" text_body_template: | - 你好, + 您好, - 这是一则由%{site_name}自动发送的消息,告知[你的帖子](%{base_url}%{url})被移除了。 + 这是一则由%{site_name}自动发送的消息,告知[您的帖子](%{base_url}%{url})被移除了。 %{flag_reason} @@ -2499,65 +2498,65 @@ zh_CN: text_body_template: | 要查看给新用户的简要技巧,[(英文)看看这篇博客文章](http://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/)。 - 只要你不断参与,我们将更了解你,并且新用户的临时限制将被移除。一段时间后你将获得[信任等级](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/),这将提供一些特殊功能来帮助我们更好地管理社区。 + 只要您不断参与,我们将更了解您,并且新用户的临时限制将被移除。一段时间后您将获得[信任等级](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/),这将提供一些特殊功能来帮助我们更好地管理社区。 welcome_user: title: "欢迎用户" subject_template: "欢迎来到 %{site_name}!" text_body_template: | - 感谢你加入%{site_name},欢迎! + 感谢您加入%{site_name},欢迎! %{new_user_tips} 我们始终相信[讨论应该文明](%{base_url}/guidelines)。 - 好好享受你在社区的时光吧! + 好好享受您在社区的时光吧! welcome_tl1_user: title: "欢迎信任等级1用户" subject_template: "感谢与我们共度时光" text_body_template: | - 嘿。我们看到你一直在忙着阅读,这太棒了,所以我们已经提升你了[信任等级!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) + 嘿。我们看到您一直在忙着阅读,这太棒了,所以我们已经提升您了[信任等级!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) - 我们很高兴你和我们共度时光,我们很想知道更多关于你的事情。花一点时间[填写你的个人资料](%{base_url}/my/preferences/profile),或随时[开始一个新话题](%{base_url}/categories)。 + 我们很高兴您和我们共度时光,我们很想知道更多关于您的事情。花一点时间[填写您的个人资料](%{base_url}/my/preferences/profile),或随时[开始一个新话题](%{base_url}/categories)。 welcome_staff: title: "欢迎管理人员" - subject_template: "恭喜,你已获得%{role}身份!" + subject_template: "恭喜,您已获得%{role}身份!" text_body_template: | - 一位管理员已授予你%{role}身份。 + 一位管理员已授予您%{role}身份。 - 作为一名%{role},现在你可以访问管理面板。 + 作为一名%{role},现在您可以访问管理面板。 - 拥有权利的同时也被赋予了重大的责任。如果你不熟悉管理,请参考[管理指南](https://meta.discourse.org/t/discourse-moderation-guide/63116)。 + 拥有权利的同时也被赋予了重大的责任。如果您不熟悉管理,请参考[管理指南](https://meta.discourse.org/t/discourse-moderation-guide/63116)。 welcome_invite: title: "欢迎邀请" subject_template: "欢迎来到 %{site_name}!" text_body_template: | - 感谢你接受邀请加入%{site_name} —— 欢迎! + 感谢您接受邀请加入%{site_name} —— 欢迎! - - 我们为你创建了账户**%{username}**。访问[你的用户设置][prefs]修改名字或密码。 + - 我们为您创建了账户**%{username}**。访问[您的用户设置][prefs]修改名字或密码。 - - 你登录的时候,请**使用收到邀请的邮箱地址**登录——否则我们就无法分辨是不是你本人! + - 您登录的时候,请**使用收到邀请的邮箱地址**登录——否则我们就无法分辨是不是您本人! - %{new_user_tips} 我们始终相信[讨论应该文明](%{base_url}/guidelines)。 - 好好享受你在论坛的时光吧! + 好好享受您在论坛的时光吧! [prefs]: %{user_preferences_url} tl2_promotion_message: - subject_template: "恭喜!你的信任等级提升了!" + subject_template: "恭喜!您的信任等级提升了!" text_body_template: | - 我们已经为你提升到另一个[信任等级](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)! + 我们已经为您提升到另一个[信任等级](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)! 作为经验丰富的用户,您可能会喜欢[这个方便实用的技巧清单](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/)。 - 希望你能够继续活跃 —— 感谢你的参与。 + 希望您能够继续活跃 —— 感谢您的参与。 backup_succeeded: title: "备份成功" subject_template: "备份成功完成" text_body_template: | 备份成功。 - 访问[管理 > 备份](%{base_url}/admin/backups)下载你的新备份。 + 访问[管理 > 备份](%{base_url}/admin/backups)下载您的新备份。 以下为日志: @@ -2601,7 +2600,7 @@ zh_CN: bulk_invite_succeeded: title: "批量邀请成功" subject_template: "批量用户邀请成功完成" - text_body_template: "你的批量邀请用户已经完成,发送了 %{sent} 个邀请。" + text_body_template: "您的批量邀请用户已经完成,发送了 %{sent} 个邀请。" bulk_invite_failed: title: "批量邀请失败" subject_template: "批量邀请完成,并有一些错误" @@ -2615,146 +2614,146 @@ zh_CN: ``` user_added_to_group_as_owner: title: "添加为群组的所有者" - subject_template: "你已被添加为 %{group_name} 群组的所有者" + subject_template: "您已被添加为 %{group_name} 群组的所有者" text_body_template: | - 你已被添加为 [%{group_name}] (%{base_url}%{group_path}) 群组的所有者。 + 您已被添加为 [%{group_name}] (%{base_url}%{group_path}) 群组的所有者。 user_added_to_group_as_member: title: "添加为群组的成员" - subject_template: "你已被添加为 %{group_name} 群组的成员" + subject_template: "您已被添加为 %{group_name} 群组的成员" text_body_template: | - 你已被添加为 [%{group_name}] (%{base_url}%{group_path}) 群组的成员。 + 您已被添加为 [%{group_name}] (%{base_url}%{group_path}) 群组的成员。 csv_export_succeeded: title: "CSV导出成功" subject_template: "[%{export_title}]数据导出完成" text_body_template: | - 你的数据导出成功!:dvd: + 您的数据导出成功!:dvd: %{download_link} 上方的下载地址将在 48 小时后失效。 - 用户数据以 Zip 格式归档。若归档文件无法在你打开时自动解压,请使用此处推荐的工具:https://www.7-zip.org/ + 用户数据以 Zip 格式归档。若归档文件无法在您打开时自动解压,请使用此处推荐的工具:https://www.7-zip.org/ csv_export_failed: title: "导致CSV失败" subject_template: "数据导出失败" - text_body_template: "我们很抱歉,但是你的数据导出请求失败了。请检查日志或[联系管理人员](%{base_url}/about)。" + text_body_template: "我们很抱歉,但是您的数据导出请求失败了。请检查日志或[联系管理人员](%{base_url}/about)。" email_reject_insufficient_trust_level: title: "拒收邮件 信任等级不足" subject_template: "[%{email_prefix}] 邮件问题 -- 信任等级不足" text_body_template: | - 我们很抱歉,但是你发送至%{destination}(名为 %{former_title})的邮件出问题了。 + 我们很抱歉,但是您发送至%{destination}(名为 %{former_title})的邮件出问题了。 - 你的账户没有足够的信任等级以该邮件地址发布新话题。如果你有异议,[联系管理人员](%{base_url}/about)。 + 您的账户没有足够的信任等级以该邮件地址发布新话题。如果您有异议,[联系管理人员](%{base_url}/about)。 email_reject_user_not_found: title: "Email被拒绝:用户未找到" subject_template: "[%{email_prefix}] 邮件问题 -- 未找到用户" text_body_template: | - 我们非常抱歉,但是你发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 + 我们非常抱歉,但是您发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 - 你发送回复的邮件地址是未知的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 + 您发送回复的邮件地址是未知的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 email_reject_screened_email: title: "Email 被拒绝:已屏蔽的 Email 地址" subject_template: "[%{email_prefix}] 邮件问题 -- 被封禁的邮件地址" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 - 你发送回复的邮件地址是已被封禁的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 + 您发送回复的邮件地址是已被封禁的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 email_reject_not_allowed_email: title: "邮件被拒绝:不被允许的邮件地址" subject_template: "[%{email_prefix}] 邮件问题 -- 被封禁的邮件地址" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 - 你发送回复的邮件地址是已被封禁的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 + 您发送回复的邮件地址是已被封禁的邮件地址。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 email_reject_inactive_user: title: "Email被拒绝:未激活的用户" subject_template: "[%{email_prefix}] 邮件问题 -- 未激活用户" text_body_template: | - 我们非常抱歉,但是你发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 + 我们非常抱歉,但是您发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 - 与你账户关联的邮件地址没有激活,请先激活你的账户再发送邮件。 + 与您账户关联的邮件地址没有激活,请先激活您的账户再发送邮件。 email_reject_silenced_user: title: "Email 被拒绝:已禁言的用户" subject_template: "[%{email_prefix}] 邮件问题——已禁言的用户" text_body_template: | - 我们非常抱歉,但是你发送至 (%{destination}题为 %{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至 (%{destination}题为 %{former_title})的邮件出问题了。 - 与你邮件地址关联的账户已被禁言。 + 与您邮件地址关联的账户已被禁言。 email_reject_reply_user_not_matching: title: "Email 被拒绝:用户不匹配" subject_template: "[%{email_prefix}] 邮件问题 -- 未预期的回复地址" text_body_template: | - 我们非常抱歉,但是你发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 + 我们非常抱歉,但是您发送至 %{destination}(名为%{former_title}) 的邮件出问题了。 - 你发送回复的邮件地址与我们预期的地址不同,所以我们不确定你是不是同一个人。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 + 您发送回复的邮件地址与我们预期的地址不同,所以我们不确定您是不是同一个人。试试从另外一个邮件地址发送,或者[联系管理人员](%{base_url}/about)。 email_reject_empty: title: "Email被拒绝:空" subject_template: "[%{email_prefix}] 邮件问题 -- 无内容" text_body_template: | - 我们很抱歉,但是你发送至 %{destination}(名为 %{former_title})的邮件没有成功。 + 我们很抱歉,但是您发送至 %{destination}(名为 %{former_title})的邮件没有成功。 - 我们不能找到你邮件中的任何回复内容。 + 我们不能找到您邮件中的任何回复内容。 - 如果你**确定**你的邮件中有回复内容,试试用更简单的格式。 + 如果您**确定**您的邮件中有回复内容,试试用更简单的格式。 email_reject_parsing: title: "邮件拒绝解析" subject_template: "[%{email_prefix}] 邮件问题 -- 无法识别内容" text_body_template: | - 我们很抱歉,但是你发送至 %{destination}(名为 %{former_title})的邮件无法发送。 + 我们很抱歉,但是您发送至 %{destination}(名为 %{former_title})的邮件无法发送。 - 我们不能找到你邮件中的回复。**请确认将你的回复置于邮件顶端**——我们不能处理行内回复。 + 我们不能找到您邮件中的回复。**请确认将您的回复置于邮件顶端**——我们不能处理行内回复。 email_reject_invalid_access: title: "Email被拒绝:无效访问" subject_template: "[%{email_prefix}] 邮件问题 -- 无效访问" text_body_template: | - 我们很抱歉,但是你发送至%{destination}(名为 %{former_title})的邮件出问题了。 + 我们很抱歉,但是您发送至%{destination}(名为 %{former_title})的邮件出问题了。 - 你的账户没有达到足够的信任等级在该分类发布新话题。如果你有异议,[联系管理人员](%{base_url}/about)。 + 您的账户没有达到足够的信任等级在该分类发布新话题。如果您有异议,[联系管理人员](%{base_url}/about)。 email_reject_strangers_not_allowed: title: "Email被拒绝:不允许陌生人" subject_template: "[%{email_prefix}] 邮件问题 -- 无效访问" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(标题为%{former_title})无效。 + 我们非常抱歉,但是您发送至%{destination}(标题为%{former_title})无效。 - 邮件所发送的分类只接受来自拥有邮箱地址的有效账户的回复。如果你认为这是一个错误,[联系管理人员](%{base_url}/about)。 + 邮件所发送的分类只接受来自拥有邮箱地址的有效账户的回复。如果您认为这是一个错误,[联系管理人员](%{base_url}/about)。 email_reject_invalid_post: title: "邮件拒绝无效话题" subject_template: "[%{email_prefix}] 邮件问题 -- 发表错误" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 可能的原因是:复杂的格式、私信超长、私信太短。请再试一次,如果还不行,使用网站发表。 email_reject_invalid_post_specified: title: "邮件拒绝指定的帖子无效" subject_template: "[%{email_prefix}] 邮件问题 -- 发表错误" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 原因: %{post_error} - 如果你能解决错误,请再试一次。 + 如果您能解决错误,请再试一次。 date_invalid: "没有找到帖子的创建时间。电子邮件是否缺少日期header?" email_reject_post_too_short: title: "邮件拒绝帖子过短" subject_template: "[%{email_prefix}] 邮件问题 - 帖子过短" text_body_template: | - 很抱歉,你发送到%{destination}的邮件(标题%{former_title})无效。 + 很抱歉,您发送到%{destination}的邮件(标题%{former_title})无效。 - 为了促进更深入的对话,不允许进行非常简短的回复。你能回复至少%{count}个字符吗?或者回复“+1”来发送邮件。 + 为了促进更深入的对话,不允许进行非常简短的回复。您能回复至少%{count}个字符吗?或者回复“+1”来发送邮件。 email_reject_invalid_post_action: title: "邮件拒绝无效的帖子动作" subject_template: "[%{email_prefix}] 邮件问题 -- 无效帖子动作" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 发送的命令无法被识别。请再试一次,如果还有问题,使用网站发表。 email_reject_reply_key: title: "邮件拒绝回复键" subject_template: "[%{email_prefix}] 邮件问题 -- 未知回复键" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 邮件中的回复信令是无效或者未知的,所以我们不知道这封邮件回复给谁了。请[联系管理人员](%{base_url}/about)。 email_reject_bad_destination_address: @@ -2765,90 +2764,90 @@ zh_CN: 以下是一些需要检查的事项: - - 你使用多个电子邮件地址吗?你回复的电子邮件地址不同于原来使用的电子邮件地址吗?电子邮件回复要求您在回复时使用相同的电子邮件地址。 + - 您使用多个电子邮件地址吗?您回复的电子邮件地址不同于原来使用的电子邮件地址吗?电子邮件回复要求您在回复时使用相同的电子邮件地址。 - - 回复时,你的电子邮件软件是否正确使用 Reply-To: email address?不幸的是,某些电子邮件软件错误地使用 From: address,这不起作用。 + - 回复时,您的电子邮件软件是否正确使用 Reply-To: email address?不幸的是,某些电子邮件软件错误地使用 From: address,这不起作用。 - 电子邮件中的 Message-ID 标题是否已修改?Message-ID 必须保持一致且不变。 需要更多帮助?联系我们: %{base_url}/about email_reject_old_destination: title: "邮件拒绝老地址" - subject_template: "[%{email_prefix}] 邮件问题 - 你正尝试回复旧通知" + subject_template: "[%{email_prefix}] 邮件问题 - 您正尝试回复旧通知" text_body_template: | - 很抱歉,你发送到%{destination}的邮件(标题%{former_title})无效。 + 很抱歉,您发送到%{destination}的邮件(标题%{former_title})无效。 我们只接受%{number_of_days}天的原始通知回复。请[访问话题](%{short_url})继续对话。 email_reject_topic_not_found: title: "Email被拒绝:话题不存" subject_template: "[%{email_prefix}] 邮件问题 -- 话题未找到" text_body_template: | - 我们很抱歉,但是你发送至%{destination}(名为 %{former_title})的邮件出问题了。 + 我们很抱歉,但是您发送至%{destination}(名为 %{former_title})的邮件出问题了。 - 你回复的话题不存在——可能已经被删除了?如果你有异议,[联系管理人员](%{base_url}/about)。 + 您回复的话题不存在——可能已经被删除了?如果您有异议,[联系管理人员](%{base_url}/about)。 email_reject_topic_closed: title: "Email被拒绝:话题已关闭" subject_template: "[%{email_prefix}] 邮件问题 -- 话题已关闭" text_body_template: | - 我们很抱歉,但是你发送至 %{destination} ( 名为 %{former_title} )的邮件出问题了。 + 我们很抱歉,但是您发送至 %{destination} ( 名为 %{former_title} )的邮件出问题了。 - 你回复的话题已经被关闭,不允许回复。如果你有异议,[联系管理人员](%{base_url}/about)。 + 您回复的话题已经被关闭,不允许回复。如果您有异议,[联系管理人员](%{base_url}/about)。 email_reject_auto_generated: title: "邮件拒绝自动生成" subject_template: "[%{email_prefix}] 邮件问题 -- 自动生成的回复" text_body_template: | - 我们很抱歉,但是你发送至%{destination}(名为 %{former_title})的邮件出问题了。 + 我们很抱歉,但是您发送至%{destination}(名为 %{former_title})的邮件出问题了。 - 系统觉得你的邮件是“自动生成”的,即电脑生成了该邮件而不是由你输入的;我们不能接受这样的邮件。如果你有异议,[联系管理人员](%{base_url}/about)。 + 系统觉得您的邮件是“自动生成”的,即电脑生成了该邮件而不是由您输入的;我们不能接受这样的邮件。如果您有异议,[联系管理人员](%{base_url}/about)。 email_reject_unrecognized_error: title: "Email被拒绝:无法识别的错误" subject_template: "[%{email_prefix}] 邮件问题 -- 无法识别的错误" text_body_template: | - 我们非常抱歉,但是你发送至%{destination}(名为%{former_title})的邮件出问题了。 + 我们非常抱歉,但是您发送至%{destination}(名为%{former_title})的邮件出问题了。 - 在处理你的电子邮件时出现无法识别的错误,它并未发布。你可以再试一次,或者[联系管理人员](%{base_url}/about)。 + 在处理您的电子邮件时出现无法识别的错误,它并未发布。您可以再试一次,或者[联系管理人员](%{base_url}/about)。 email_reject_attachment: title: "邮件附件被拒" subject_template: "[%{email_prefix}] 邮件问题 -- 附件被拒" text_body_template: | - 很遗憾,你的邮件中的一些附件(%{destination}(标题%{former_title})已被拒绝。 + 很遗憾,您的邮件中的一些附件(%{destination}(标题%{former_title})已被拒绝。 细节: %{rejected_errors} - 如果你认为这是一个错误,[联系管理人员](%{base_url}/about)。 + 如果您认为这是一个错误,[联系管理人员](%{base_url}/about)。 email_reject_reply_not_allowed: title: "邮件被拒绝:不允许回复" subject_template: "[%{email_prefix}]邮件问题 -- 不允许回复" text_body_template: | - 我们很抱歉,但是你发送至%{destination}(标题 %{former_title})的邮件出问题了。 + 我们很抱歉,但是您发送至%{destination}(标题 %{former_title})的邮件出问题了。 - 你的账户没有权限回复这个话题。如果你认为这是一个错误,[联系管理人员](%{base_url}/about)。 + 您的账户没有权限回复这个话题。如果您认为这是一个错误,[联系管理人员](%{base_url}/about)。 email_reject_reply_to_digest: title: "回复摘要邮件被拒" subject_template: "[%{email_prefix}] 邮件问题 -- 回复摘要邮件" text_body_template: | - 抱歉,你发送到%{destination}(标题为%{former_title})的邮件出现了问题。 + 抱歉,您发送到%{destination}(标题为%{former_title})的邮件出现了问题。 我们不接受直接回复摘要邮件。 - 如果你认为这是一个错误,[联系我们的管理人员](%{base_url}/about). + 如果您认为这是一个错误,[联系我们的管理人员](%{base_url}/about). email_error_notification: title: "邮件错误提醒" subject_template: "[%{email_prefix}] 电子邮件错误 -- POP 验证错误" text_body_template: | 不幸的是,从 POP 服务器查询邮件时遇到了验证错误。 - 请确认你在[站点设置](%{base_url}/admin/site_settings/category/email)中已经正确配置了 POP 验证信息。 + 请确认您在[站点设置](%{base_url}/admin/site_settings/category/email)中已经正确配置了 POP 验证信息。 - 如果 POP 邮件账户有图形界面,你可以登录后查看邮箱设置。 + 如果 POP 邮件账户有图形界面,您可以登录后查看邮箱设置。 email_revoked: title: "邮件撤销" - subject_template: "你的邮箱地址是否正确?" + subject_template: "您的邮箱地址是否正确?" text_body_template: | - 很抱歉,我们无法通过邮件与你联系。我们最后几封给你的邮件都因无法投递而全部退回。 + 很抱歉,我们无法通过邮件与您联系。我们最后几封给您的邮件都因无法投递而全部退回。 - 你能否确保[你的邮件地址](%{base_url}/我的/设置/邮件)有效且有效?你可能还希望将我们的邮件地址添加到您的地址簿/联系人列表中,以提高可投递性。 + 您能否确保[您的邮件地址](%{base_url}/我的/设置/邮件)有效且有效?您可能还希望将我们的邮件地址添加到您的地址簿/联系人列表中,以提高可投递性。 email_bounced: | %{email}的邮件已退回。 @@ -2863,42 +2862,42 @@ zh_CN: text_body_template: | 您好, - 这是 %{site_name} 告知你 @%{username} 已被 %{ignores_threshold} 名用户忽略的自动消息。这可能表示你的社区已开始产生问题。 + 这是 %{site_name} 告知您 @%{username} 已被 %{ignores_threshold} 名用户忽略的自动消息。这可能表示您的社区已开始产生问题。 - 你可能想查阅[此用户的近期发帖](%{base_url}/u/%{username}/summary)和[忽略及禁言汇报](%{base_url}/admin/reports/top_ignored_users)中的其他用户。 + 您可能想查阅[此用户的近期发帖](%{base_url}/u/%{username}/summary)和[忽略及禁言汇报](%{base_url}/admin/reports/top_ignored_users)中的其他用户。 - 若你需要额外帮助,请参见我们的[社区准则](%{base_url}/guidelines)。 + 若您需要额外帮助,请参见我们的[社区准则](%{base_url}/guidelines)。 too_many_spam_flags: title: "太多垃圾标记" subject_template: "新账户被搁置" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知你的帖子因被社区成员标记已被暂时隐藏的自动消息 + 这是%{site_name}通知您的帖子因被社区成员标记已被暂时隐藏的自动消息 - 作为预防措施,你的新账户已被停用,并且在管理人员审核你的账户前你无法创建回复或话题。我们对此深表抱歉。 + 作为预防措施,您的新账户已被停用,并且在管理人员审核您的账户前您无法创建回复或话题。我们对此深表抱歉。 有关其他指导,请参阅我们的[社区指引](%{base_url}/guidelines)。 too_many_tl3_flags: title: "太多信任等级3用户标记" subject_template: "新账户被暂时封禁" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知你的帖子因社区标记而被临时禁用的自动消息。 + 这是%{site_name}通知您的帖子因社区标记而被临时禁用的自动消息。 - 出于谨慎的考虑,在管理人员审核通过前,你的新账户不能再发表新的回复或者话题。我们对此带来的不便表示歉意。 + 出于谨慎的考虑,在管理人员审核通过前,您的新账户不能再发表新的回复或者话题。我们对此带来的不便表示歉意。 要查看更多指引,请参考我们的[社区指引](%{base_url}/guidelines)。 silenced_by_staff: title: "被管理人员禁言" subject_template: "账户临时被禁用" text_body_template: | - 你好, + 您好, - %{site_name}提醒你,出于谨慎起见你的账户已被暂时停用。 + %{site_name}提醒您,出于谨慎起见您的账户已被暂时停用。 - 你可以继续浏览,但在[管理人员](%{base_url}/about)审核你最近的发帖前,你的账户将无法发表新的回复或者话题。我们对此带来的不便表示歉意。 + 您可以继续浏览,但在[管理人员](%{base_url}/about)审核您最近的发帖前,您的账户将无法发表新的回复或者话题。我们对此带来的不便表示歉意。 要查看更多指引,请参阅我们的[社区指引](%{base_url}/guidelines)。 user_automatically_silenced: @@ -2927,11 +2926,11 @@ zh_CN: title: "解除禁言" subject_template: "账户不再被挂起" text_body_template: | - 你好, + 您好, - 这是%{site_name}通知你的账户在管理人员审核后不再受到限制的自动消息。 + 这是%{site_name}通知您的账户在管理人员审核后不再受到限制的自动消息。 - 你现在又可以创建回复和话题了。感谢你的耐心等待。 + 您现在又可以创建回复和话题了。感谢您的耐心等待。 pending_users_reminder: title: "待审核用户提醒" subject_template: @@ -2950,14 +2949,14 @@ zh_CN: text_body_template: | 基于您目前的网站设置,我们有一些新的建议和推荐。 - [访问你的仪表板](%{base_url}/admin)来查看他们。 + [访问您的仪表板](%{base_url}/admin)来查看他们。 - 如果你的仪表板上没有任何可见的内容,那么另一个工作人员可能已经根据这些建议采取了行动。 + 如果您的仪表板上没有任何可见的内容,那么另一个工作人员可能已经根据这些建议采取了行动。 可以查看[管理人员操作日志](%{base_url}/admin/logs/staff_action_logs)。 new_user_of_the_month: - title: "你是这个月的最佳新用户!" - subject_template: "你是这个月的最佳新用户!" + title: "您是这个月的最佳新用户!" + subject_template: "您是这个月的最佳新用户!" text_body_template: | 恭喜,你是**%{month_year}月最佳新用户**。 :trophy: @@ -2969,7 +2968,7 @@ zh_CN: subject_template: other: "%{count} 个帖子等待审核" text_body_template: | - 你好, + 您好, 为了保持适度新用户的帖子,正在等待审核。[批准还是拒绝](%{base_url}/review?type=ReviewableQueuedPost)。 unsubscribe_link: | @@ -2977,7 +2976,7 @@ zh_CN: unsubscribe_link_and_mail: | 要退订这些邮件,[点击这里](%{unsubscribe_url})。 unsubscribe_mailing_list: | - 你启用了邮件列表模式,所以收到了这些邮件。 + 您启用了邮件列表模式,所以收到了这些邮件。 要退订这些邮件,[点击这里](%{unsubscribe_url})。 subject_re: "回:" @@ -2986,7 +2985,7 @@ zh_CN: user_notifications: previous_discussion: "之前的回复" reached_limit: - other: "注意:我们每天最多发送 %{count} 邮件。访问站点看看没被发送的消息。还有你很火!" + other: "注意:我们每天最多发送 %{count} 邮件。访问站点看看没被发送的消息。还有您很火!" in_reply_to: "回复给" unsubscribe: title: "取消订阅" @@ -3014,7 +3013,7 @@ zh_CN: %{topic_url} invited_to_private_message_body: | - %{username}邀请你参与私信 + %{username}邀请您参与私信 > **[%{topic_title}](%{topic_url})** > @@ -3028,7 +3027,7 @@ zh_CN: %{topic_url} invited_to_topic_body: | - %{username}邀请你参与讨论 + %{username}邀请您参与讨论 > **[%{topic_title}](%{topic_url})** > @@ -3052,7 +3051,7 @@ zh_CN: %{respond_instructions} user_invited_to_private_message_pm: title: "邀请用户至私信" - subject_template: "[%{email_prefix}] %{username}邀请你至私信“%{topic_title}”" + subject_template: "[%{email_prefix}] %{username}邀请您至私信“%{topic_title}”" text_body_template: | %{header_instructions} @@ -3061,7 +3060,7 @@ zh_CN: %{respond_instructions} user_invited_to_private_message_pm_staged: title: "邀请用户至暂存私信" - subject_template: "[%{email_prefix}] %{username}邀请你至私信“%{topic_title}”" + subject_template: "[%{email_prefix}] %{username}邀请您至私信“%{topic_title}”" text_body_template: | %{header_instructions} @@ -3070,7 +3069,7 @@ zh_CN: %{respond_instructions} user_invited_to_topic: title: "邀请用户至话题" - subject_template: "[%{email_prefix}] %{username}邀请你至“%{topic_title}”" + subject_template: "[%{email_prefix}] %{username}邀请您至“%{topic_title}”" text_body_template: | %{header_instructions} @@ -3195,41 +3194,41 @@ zh_CN: %{message} account_suspended: title: "账户已封禁" - subject_template: "[%{email_prefix}] 你的账户已经被禁用" + subject_template: "[%{email_prefix}] 您的账户已经被禁用" text_body_template: | - 你的账号已被论坛禁用,直到 %{suspended_till} 为止。 + 您的账号已被论坛禁用,直到 %{suspended_till} 为止。 原因 - %{reason} account_silenced: title: "账户已禁言" - subject_template: "[%{email_prefix}] 你的账户已被禁言" + subject_template: "[%{email_prefix}] 您的账户已被禁言" text_body_template: | - 你的账号已经被禁言至 %{silenced_till}。 + 您的账号已经被禁言至 %{silenced_till}。 原因 - %{reason} account_exists: title: "账户已经存在" subject_template: "[%{email_prefix}] 账户已经存在" text_body_template: | - 你刚试着在%{site_name}创建账户或者试着将账户的邮箱改为%{email}。然而,%{email}已经绑定于另一账户了。 + 您刚试着在%{site_name}创建账户或者试着将账户的邮箱改为%{email}。然而,%{email}已经绑定于另一账户了。 - 如果你忘记了密码,[立即重置](%{base_url}/password-reset)。 + 如果您忘记了密码,[立即重置](%{base_url}/password-reset)。 - 如果你没有试着用%{email}创建账户或者更改邮件,不要担心——你可以安全地忽略这封邮件。 + 如果您没有试着用%{email}创建账户或者更改邮件,不要担心——您可以安全地忽略这封邮件。 - 如果你有任何问题,[联系我们友善的工作人员](%{base_url}/about)。 + 如果您有任何问题,[联系我们友善的工作人员](%{base_url}/about)。 account_second_factor_disabled: title: "双重认证已停用" subject_template: "[%{email_prefix}] 双重认证已停用" text_body_template: | - 你在 %{site_name} 的账户已停用了双重认证。你现在只需使用密码便可以登录;不再需要额外的验证代码。 + 您在 %{site_name} 的账户已停用了双重认证。您现在只需使用密码便可以登录;不再需要额外的验证代码。 - 如果你未选择禁用双重验证,则有可能有人入侵了你的账户。 + 如果您未选择禁用双重验证,则有可能有人入侵了您的账户。 如果有任何疑问,[联系我们友善的管理人员](%{base_url}/about)。 digest: - why: "在你上次于%{last_seen_at}访问后,%{site_link}的新内容摘要。" - since_last_visit: "自从你上次访问" + why: "在您上次于%{last_seen_at}访问后,%{site_link}的新内容摘要。" + since_last_visit: "自从您上次访问" new_topics: "新话题" unread_notifications: "未读通知" unread_high_priority: "未读的高优先级通知" @@ -3239,20 +3238,20 @@ zh_CN: follow_topic: "关注话题" join_the_discussion: "阅读更多" popular_posts: "流行帖子" - more_new: "你关注的新帖" + more_new: "您关注的新帖" subject_template: "[%{email_prefix}] 摘要" - unsubscribe: "这是来自%{site_link}的摘要邮件,因为你长时间没有访问站点而发送。修改你的邮件设置%{email_preferences_link},或%{unsubscribe_link}取消订阅。" - your_email_settings: "你的邮件设置" + unsubscribe: "这是来自%{site_link}的摘要邮件,因为您长时间没有访问站点而发送。修改您的邮件设置%{email_preferences_link},或%{unsubscribe_link}取消订阅。" + your_email_settings: "您的邮件设置" click_here: "点击此处" from: "%{site_name}" - preheader: "在你上次于%{last_seen_at}访问后的新内容摘要" + preheader: "在您上次于%{last_seen_at}访问后的新内容摘要" forgot_password: title: "忘记密码" subject_template: "[%{email_prefix}] 密码重置" text_body_template: | - 在[%{site_name}](%{base_url})上有人要求重置你的密码。 + 在[%{site_name}](%{base_url})上有人要求重置您的密码。 - 如果不是你,你可以安全地忽略此邮件。 + 如果不是您,您可以安全地忽略此邮件。 点击以下链接设置新密码: %{base_url}/u/password-reset/%{email_token} @@ -3260,9 +3259,9 @@ zh_CN: title: "用链接登录" subject_template: "[%{email_prefix}]链接登录" text_body_template: | - 这是你用于登录[%{site_name}](%{base_url})的链接 + 这是您用于登录[%{site_name}](%{base_url})的链接 - 如果你未请求过此链接,可以放心地忽略此电子邮件。 + 如果您未请求过此链接,可以放心地忽略此电子邮件。 点击下方链接登录: %{base_url}/session/email-login/%{email_token} @@ -3270,9 +3269,9 @@ zh_CN: title: "设置密码" subject_template: "[%{email_prefix}] 设置密码" text_body_template: | - 有人尝试设置你在 [%{site_name}](%{base_url}) 的密码。除此之外,你可以从已验证过你邮件地址的在线服务商登录。 + 有人尝试设置您在 [%{site_name}](%{base_url}) 的密码。除此之外,您可以从已验证过您邮件地址的在线服务商登录。 - 如果不是你在请求添加密码,你可以直接忽略本邮件。 + 如果不是您在请求添加密码,您可以直接忽略本邮件。 点击下面的链接来选择设置密码: %{base_url}/u/password-reset/%{email_token} @@ -3280,63 +3279,63 @@ zh_CN: title: "管理员登录" subject_template: "[%{email_prefix}] 登录" text_body_template: | - 有人要登录你在[%{site_name}](%{base_url})上的账户。 + 有人要登录您在[%{site_name}](%{base_url})上的账户。 - 如果你没有发出此请求,则可以放心地忽略此电子邮件。 + 如果您没有发出此请求,则可以放心地忽略此电子邮件。 单击以下链接登录: %{base_url}/session/email-login/%{email_token} account_created: title: "账户已创建" - subject_template: "[%{email_prefix}] 你的新账户" + subject_template: "[%{email_prefix}] 您的新账户" text_body_template: | - %{site_name}已经为你创建了新账户 + %{site_name}已经为您创建了新账户 点击下面的链接设置新账户密码: %{base_url}/u/password-reset/%{email_token} confirm_new_email: title: "确认新邮箱" - subject_template: "[%{email_prefix}] 确认你的新电子邮箱地址" + subject_template: "[%{email_prefix}] 确认您的新电子邮箱地址" text_body_template: | - 点击以下链接确认你在 %{site_name} 的新电子邮件地址: + 点击以下链接确认您在 %{site_name} 的新电子邮件地址: %{base_url}/u/confirm-new-email/%{email_token} - 如果你没有要求此更改,请联系管理员 (%{base_url}/about) 。 + 如果您没有要求此更改,请联系管理员 (%{base_url}/about) 。 confirm_new_email_via_admin: title: "确认新邮箱" - subject_template: "[%{email_prefix}] 确认你的新电子邮箱地址" + subject_template: "[%{email_prefix}] 确认您的新电子邮箱地址" text_body_template: | - 点击以下链接确认你在%{site_name}的新电子邮件地址: + 点击以下链接确认您在%{site_name}的新电子邮件地址: %{base_url}/u/confirm-new-email/%{email_token} - 此电子邮件的更改由网站管理员发送。如果你没有要求此更改,请联系管理员(%{base_url}/about)。 + 此电子邮件的更改由网站管理员发送。如果您没有要求此更改,请联系管理员(%{base_url}/about)。 confirm_old_email: title: "确认旧邮箱" - subject_template: "[%{email_prefix}] 确认你现在的电子邮箱地址" + subject_template: "[%{email_prefix}] 确认您现在的电子邮箱地址" text_body_template: | - 在我们更改你的邮件地址前,我们需要确认你掌控当前邮件账户。在你完成这一步后,我们会让你确认新的邮件地址。 + 在我们更改您的邮件地址前,我们需要确认您掌控当前邮件账户。在您完成这一步后,我们会让您确认新的邮件地址。 - 点击下面链接以确认你当前在%{site_name}的邮件地址: + 点击下面链接以确认您当前在%{site_name}的邮件地址: %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email_add: title: "确认旧邮箱(添加)" - subject_template: "[%{email_prefix}] 确认你现在的电子邮箱地址" + subject_template: "[%{email_prefix}] 确认您现在的电子邮箱地址" text_body_template: | - 在我们添加新的电子邮件地址之前,我们需要确认你掌控当前的电子邮件账户。在你完成这一步后,我们会让你确认新的电子邮件地址。 + 在我们添加新的电子邮件地址之前,我们需要确认您掌控当前的电子邮件账户。在您完成这一步后,我们会让您确认新的电子邮件地址。 - 点击以下链接,确认你当前在%{site_name}电子邮件地址: + 点击以下链接,确认您当前在%{site_name}电子邮件地址: %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: title: "通知旧邮箱" - subject_template: "[%{email_prefix}] 你的邮箱已经修改成功" + subject_template: "[%{email_prefix}] 您的邮箱已经修改成功" text_body_template: | - 这是%{site_name}自动发出的邮件,以告知你的邮箱地址已经被修改了。如果这是一个错误,请联系站点管理人员。 + 这是%{site_name}自动发出的邮件,以告知您的邮箱地址已经被修改了。如果这是一个错误,请联系站点管理人员。 - 你的邮箱地址被修改为: + 您的邮箱地址被修改为: %{new_email} notify_old_email_add: @@ -3347,18 +3346,18 @@ zh_CN: 已添加至%{site_name}。如果有误,请联系 网站管理员。 - 你添加的电子邮件地址是: + 您添加的电子邮件地址是: %{new_email} signup_after_approval: title: "在审批之后注册" - subject_template: "你通过了%{site_name}的审核!" + subject_template: "您通过了%{site_name}的审核!" text_body_template: | 欢迎来到%{site_name}! - 管理人员在%{site_name}上批准了你的账户。 + 管理人员在%{site_name}上批准了您的账户。 - 你现在可以通过以下登录访问你的新账户: + 您现在可以通过以下登录访问您的新账户: %{base_url} 如果上述链接无法点击,请尝试将其复制并粘贴到浏览器的地址栏中。 @@ -3370,30 +3369,30 @@ zh_CN: 入住愉快! signup_after_reject: title: "拒绝后的注册" - subject_template: "你在 %{site_name}上被拒绝" + subject_template: "您在 %{site_name}上被拒绝" text_body_template: | - 一名工作人员拒绝了你在 %{site_name} 上的帐户。 + 一名工作人员拒绝了您在 %{site_name} 上的帐户。 %{reject_reason} signup: title: "注册" - subject_template: "[%{email_prefix}] 确认你的新账户" + subject_template: "[%{email_prefix}] 确认您的新账户" text_body_template: | 欢迎来到 %{site_name}! - 点击下面链接确认并激活你的新账户: + 点击下面链接确认并激活您的新账户: %{base_url}/u/activate-account/%{email_token} - 如果以上链接无法点击,请将它复制并粘贴到你的浏览器的地址栏。 + 如果以上链接无法点击,请将它复制并粘贴到您的浏览器的地址栏。 activation_reminder: title: "激活提醒" - subject_template: "[%{email_prefix}]记得确认你的账户" + subject_template: "[%{email_prefix}]记得确认您的账户" text_body_template: | 欢迎来到 %{site_name}! - 这是一个提醒你激活账户的友情提醒。 + 这是一个提醒您激活账户的友情提醒。 - 请点击下面的链接来确认并激活你的新账户: + 请点击下面的链接来确认并激活您的新账户: %{base_url}/u/activate-account/%{email_token} 如果上方的链接无法点击,请尝试将其复制并粘贴到浏览器的地址栏中。 @@ -3401,24 +3400,24 @@ zh_CN: title: "新登录警告" subject_template: "[%{site_name}]新登录于%{location}" text_body_template: | - 你好, + 您好, - 我们注意到您从通常不使用的设备或位置登录。 这是你吗? + 我们注意到您从通常不使用的设备或位置登录。 这是您吗? - 位置:%{location}(%{client_ip})  - 浏览器:%{browser}  - 设备:%{device} - %{os} - 如果这是你,太棒了!你不需要做任何其他事情。 + 如果这是您,太棒了!您不需要做任何其他事情。 - 如果不是你,请[查看你现有的会话](%{base_url}/my/preferences/account)并考虑更改密码。 + 如果不是您,请[查看您现有的会话](%{base_url}/my/preferences/account)并考虑更改密码。 post_approved: - title: "你的帖子已通过审核" - subject_template: "[%{site_name}] 你的帖子已通过审核" + title: "您的帖子已通过审核" + subject_template: "[%{site_name}] 您的帖子已通过审核" text_body_template: | - 你好: + 您好: - 这是来自 %{site_name} 的自动消息,[你的帖子] (%{base_url}%{post_url}) 已通过审核。 + 这是来自 %{site_name} 的自动消息,[您的帖子] (%{base_url}%{post_url}) 已通过审核。 page_forbidden: title: "啊噢!该页面是私密的。" site_setting_missing: "站点设定 `%{name}` 必须被设置。" @@ -3431,35 +3430,35 @@ zh_CN: search_button: "搜索" offline: title: "无法载入应用" - offline_page_message: "你似乎正处于离线状态!请检查网络连接并重试。" + offline_page_message: "您似乎正处于离线状态!请检查网络连接并重试。" login_required: welcome_message: | ## [欢迎来到 %{title}](#welcome) - 你需要一个账户。请创建一个账户或者登录以继续。 + 您需要一个账户。请创建一个账户或者登录以继续。 welcome_message_invite_only: | ## [欢迎来到%{title}](#welcome) - 你需要一个账号。请从现有用户处获取一个邀请或者登录。 + 您需要一个账号。请从现有用户处获取一个邀请或者登录。 deleted: "已删除" image: "图片" upload: edit_reason: "下载外部图片留作副本" - unauthorized: "抱歉,你没有上传文件的权限(验证扩展:%{authorized_extensions})。" + unauthorized: "抱歉,您没有上传文件的权限(验证扩展:%{authorized_extensions})。" pasted_image_filename: "粘贴的图像" store_failure: "用户#%{user_id} 上传的 #%{upload_id} 保存失败。" - file_missing: "抱歉,你必须选择一个文件上传。" - empty: "抱歉,但是你提交的文件是空的。" + file_missing: "抱歉,您必须选择一个文件上传。" + empty: "抱歉,但是您提交的文件是空的。" png_to_jpg_conversion_failure_message: "从PNG转换为JPG时发生错误。" optimize_failure_message: "优化上传的图像时出现了错误。" attachments: - too_large: "抱歉,你试图上传的文件太大了(最大限制为%{max_size_kb}%KB)。" + too_large: "抱歉,您试图上传的文件太大了(最大限制为%{max_size_kb}%KB)。" images: - too_large: "抱歉,你试图上传的图片太大了(最大限制为%{max_size_kb}%KB),请裁剪它并重试。" - larger_than_x_megapixels: "对不起,你上传的图片太大(最大支持%{max_image_megapixels}像素),请缩小图片然后再试一次。" - size_not_found: "抱歉,我们无法获取图片大小。也许是你的图片损坏了?" + too_large: "抱歉,您试图上传的图片太大了(最大限制为%{max_size_kb}%KB),请裁剪它并重试。" + larger_than_x_megapixels: "对不起,您上传的图片太大(最大支持%{max_image_megapixels}像素),请缩小图片然后再试一次。" + size_not_found: "抱歉,我们无法获取图片大小。也许是您的图片损坏了?" placeholders: too_large: "(图片大小超过%{max_size_kb}KB)" avatar: - missing: "抱歉,我们没法找到与你邮件地址关联的头像。你能再上传一次试试吗?" + missing: "抱歉,我们没法找到与您邮件地址关联的头像。您能再上传一次试试吗?" flag_reason: sockpuppet: "新用户创建了话题,而另一个新用户以同一个 IP 在该话题回复。查看站点设置的 `flag_sockpuppets`。" spam_hosts: "新用户尝试创建多个链接到相同域名的帖子。此用户所有包含链接的帖子需要审核。详见站点设置`newuser_spam_host_threshold`。" @@ -3522,21 +3521,21 @@ zh_CN: tos_topic: title: "服务条款" body: | - 这些条款用于管理<%{base_url}>上互联网论坛的使用。要使用论坛,你必须与运行论坛的公司%{company_name}达成这些条款。 + 这些条款用于管理<%{base_url}>上互联网论坛的使用。要使用论坛,您必须与运行论坛的公司%{company_name}达成这些条款。 公司可能会根据不同条款提供其他产品和服务。这些条款仅适用于论坛的使用。 跳到: - [重要条款](#heading--重要条款) - - [你使用论坛的许可](#heading--permission) + - [您使用论坛的许可](#heading--permission) - [论坛的使用条件](#heading--conditions) - [可接受的用途](#heading--可接受使用) - [内容标准](#heading--content-standards) - [执法](#heading--强制执行) - - [你的账户](#heading--你的账户) - - [你的内容](#heading--你的内容) - - [你的责任](#heading--责任) + - [您的账户](#heading--您的账户) + - [您的内容](#heading--您的内容) + - [您的责任](#heading--责任) - [免责声明](#heading--免责声明) - [责任限额](#heading--liability) - [反馈](#heading--feedback) @@ -3548,151 +3547,151 @@ zh_CN:

      重要条款

      - ***这些条款包括一些影响你的权利和责任的重要条款,例如[免责声明](#heading-disclaimers)中的免责声明,对[责任限制](#heading--liability)中公司对你的责任的限制,你同意在[责任使用](#heading-responsibility)中滥用论坛所造成的损害赔偿公司,以及在[争议](#heading--disputes)中仲裁争议的协议*** + ***这些条款包括一些影响您的权利和责任的重要条款,例如[免责声明](#heading-disclaimers)中的免责声明,对[责任限制](#heading--liability)中公司对您的责任的限制,您同意在[责任使用](#heading-responsibility)中滥用论坛所造成的损害赔偿公司,以及在[争议](#heading--disputes)中仲裁争议的协议*** -

      你使用论坛的权限

      +

      您使用论坛的权限

      - 在遵守这些条款的前提下,公司允许你使用论坛。每个人都需要同意这些条款才能使用论坛。 + 在遵守这些条款的前提下,公司允许您使用论坛。每个人都需要同意这些条款才能使用论坛。

      论坛使用条件

      - 你使用论坛的许可受以下条件限制: + 您使用论坛的许可受以下条件限制: - 1.你必须至少十三岁。 + 1.您必须至少十三岁。 - 2.如果公司直接与你表示不能,你不能继续使用该论坛。 + 2.如果公司直接与您表示不能,您不能继续使用该论坛。 - 3.你必须按照[可接受使用](#heading--accepted-use)和[内容标准](#heading--content-standards)使用论坛。 + 3.您必须按照[可接受使用](#heading--accepted-use)和[内容标准](#heading--content-standards)使用论坛。

      可接受的使用

      - 1.你不能使用论坛违法。 + 1.您不能使用论坛违法。 - 2.未经特别许可,你不得在论坛上使用或尝试使用他人的账户。 + 2.未经特别许可,您不得在论坛上使用或尝试使用他人的账户。 - 3.你不得在论坛上购买,出售或以其他方式交易用户名或其他唯一标识符。 + 3.您不得在论坛上购买,出售或以其他方式交易用户名或其他唯一标识符。 - 4.你不得通过论坛发送广告,连锁信或其他请求,或使用论坛收集商业邮件列表或数据库的地址或其他个人数据。 + 4.您不得通过论坛发送广告,连锁信或其他请求,或使用论坛收集商业邮件列表或数据库的地址或其他个人数据。 - 5.你不能使用程序自动访问论坛或监控论坛,例如使用网络爬虫,浏览器插件或插件,或其他非Web浏览器的计算机程序。如果你运行一个搜索引擎,你可以抓取论坛为其公开搜索引擎编制索引。 + 5.您不能使用程序自动访问论坛或监控论坛,例如使用网络爬虫,浏览器插件或插件,或其他非Web浏览器的计算机程序。如果您运行一个搜索引擎,您可以抓取论坛为其公开搜索引擎编制索引。 - 6.你不能使用论坛将邮件发送到通讯组列表,新闻组或组邮件别名。 + 6.您不能使用论坛将邮件发送到通讯组列表,新闻组或组邮件别名。 - 7.你不得虚假暗示你与本公司有关联或得到许可。 + 7.您不得虚假暗示您与本公司有关联或得到许可。 - 8.你不得在其他论坛上超链接到本论坛的图片或其他非超文本内容。 + 8.您不得在其他论坛上超链接到本论坛的图片或其他非超文本内容。 - 9.你不得从论坛下载的资料中删除任何显示所有权的标记。 + 9.您不得从论坛下载的资料中删除任何显示所有权的标记。 - 10.你可能无法使用` +
    + + + + + + + +
    +
    +
    + + + +
    +

    Block

    +
    + +
    +
    +
    + +
    + +
    + + +
    +
    +
    +
    + + + + + + +
    +
    +
    + + +
    +
      +
    • +

      Tweet with a location

      +

      + You can add location information to your Tweets, such as your city or precise location, from the web and via third-party applications. You always have the option to delete your Tweet location history. + Learn more +

      +
      + + +
      +
    • +
    +
    + +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
      +
      +
      + +
      + + + +
      +
      +
      + + +
      +

      Your lists

      +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      + + +
      +

      Create a new list

      +
      +
      +
      +
      + + +
      +
      + +
      + + + Under 100 characters, optional +
      +
      + +
      + Privacy +
      + + +
      +
      +
      + +
      + +
      +
      + +
      +
      +
      +
      + +
      +
      +
      + + + +
      +

      +
      + +
      +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      +
      +
      + + + + +
      +
      +
      + + +
      +

      Copy link to Tweet

      +
      +
      +
      + +
      +
      +
      +
      +
      + + +
      +
      +
      + + +
      +

      Embed this Tweet

      +

      Embed this Video

      +
      +
      +
      +

      Add this Tweet to your website by copying the code below. Learn more

      +

      Add this video to your website by copying the code below. Learn more

      + + +
      +
      +
      +

      Hmm, there was a problem reaching the server.

      +
      + +
      +
      + +
      +
      + +
      +
      +
      + +

      By embedding Twitter content in your website or app, you are agreeing to the Twitter Developer Agreement and Developer Policy.

      +

      Preview

      +
      +
      +
      + +
      +
      +
      +
      + + +
      +
      +
      + + +
      +

      Why you're seeing this ad

      +
      +
      +
      +
      +
      + +
      +
      +
      +
      + + + +
      +
      +
      + + +
      +

      Log in to Twitter

      +
      +
      +
      + +
      +
      +
      +
      + +
      + +
      + + +
      + +
      + + · + Forgot password? +
      + + + + + + + + + + +
      +
      +
      +
      + Don't have an account? Sign up » +
      +
      +
      +
      + +
      +
      +
      + + +
      +

      Sign up for Twitter

      +
      +
      +
      + +
      +

      Not on Twitter? Sign up, tune into the things you care about, and get updates as they happen.

      +
      +
      + Sign up +
      +
      +
      +
      + Have an account? Log in » +
      +
      +
      +
      + +
      +
      +
      + + +
      +

      Two-way (sending and receiving) short codes:

      +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      CountryCodeFor customers of
      United States40404(any)
      Canada21212(any)
      United Kingdom86444Vodafone, Orange, 3, O2
      Brazil40404Nextel, TIM
      Haiti40404Digicel, Voila
      Ireland51210Vodafone, O2
      India53000Bharti Airtel, Videocon, Reliance
      Indonesia89887AXIS, 3, Telkomsel, Indosat, XL Axiata
      Italy4880804Wind
      3424486444Vodafone
      + » See SMS short codes for other countries +
      +
      +
      +
      +
      + +
      +
      +
      + + +
      +

      Confirmation

      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      + + +
      +

       

      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + + + +
      +
      + + +
      +
      +
      + + +
      +
      +
      +
      +
      +
      +
      + + +
      + + + + +
      +

      + + Welcome home! +

      +

      This timeline is where you’ll spend most of your time, getting instant updates about what matters to you.

      +
      + + + +
      +

      + + Tweets not working for you? +

      +

      + Hover over the profile pic and click the Following button to unfollow any account. +

      +
      + +
      + +

      + + Say a lot with a little +

      +

      + When you see a Tweet you love, tap the heart — it lets the person who wrote it know you shared the love. +

      +
      + +
      +

      + + Spread the word +

      +

      + The fastest way to share someone else’s Tweet with your followers is with a Retweet. Tap the icon to send it instantly. +

      +
      + +
      +

      + + Join the conversation +

      +

      + Add your thoughts about any Tweet with a Reply. Find a topic you’re passionate about, and jump right in. +

      +
      + + + +
      +

      + + Learn the latest +

      +

      + Get instant insight into what people are talking about now. +

      +
      + +
      +

      + + Get more of what you love +

      +

      + Follow more accounts to get instant updates about topics you care about. +

      +
      + +
      +

      + + Find what's happening +

      +

      + See the latest conversations about any topic instantly. +

      +
      + +
      +

      + + Never miss a Moment +

      +

      + Catch up instantly on the best stories happening as they unfold. +

      +
      +
      + +
      + + +
      +
      +
      +
      + + + + +
      +
      +
      +
      + + +
      +
      + +
      + +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
        + +
        +
      1. + + + +
        + +
        + + +
        + +
        + + + + + +
        + + + + Jeff Atwood‏Verified account @codinghorror + + + + Jun 27 + + +
        +
        + +
        +
        +
        +
        +
        +
          + +
        • + +
        • +
        • + +
        • +
        +
        + +
        + +
        + +
        + + + + + + + +
        +

        Anyone else doing cellular Apple Watches rather than full blown cell phones for their almost-teens? The more I research this the more I like it.

        +
        + + + + + + + + + + + + +
        + +
        + + + + + 24 replies + + + + + 5 retweets + + + + + 96 likes + + +
        + +
        +
        + +
        + +
        + +
        + + +
        + +
        + + + + + + +
        + +
        + + + + + + + + +
        + Show this thread +
        + + + + +
        + +
        + + + +
      2. +
        + + + + + + + + + + + + + + + + + + +
      +
        +
        +
        +
        +
        +
        +
        + + +
        + +
        + + + +
        +
        + + + + Jeff Atwood‏Verified account @codinghorror + + + + Jun 27 + + + + + +
        +
        + + + + + + + + + + +
        + +
        + +
        +
        + +
        +
        +
        +
        +
        +
          + +
        • + +
        • +
        • + +
        • +
        +
        + +
        + +
        + +
        + +
        + + + +
        +

        My first text message from my child! A moment that shall live on in infamy!pic.twitter.com/TQMRJsFwnO

        +
        + + + + + +
        +
        +
        +
        +
        + +
        + + +
        +
        +
        +
        + + + +
        +
        + + 8:20 PM - 27 Jun 2021 + + + +
        + + +
        +
        + +
          + +
        • + + + 90 Likes + +
        • + +
        • + + Blair "The Architect" + + + Ferdi (Ferdinand Fanötöna Zebua) + + + Greg Stoll 💉💉🎉 + + + Miles McNerney + + + Jon C + + + Javad:~$ + + + Philip is staying at home + + + Welcome to the NFT 📉 | BLM 🏳️‍🌈 + + + liagason + +
        • +
        + + +
        +
        + + + +
        + + + + +
        + + + + + 4 replies + + + + + 0 retweets + + + + + 90 likes + + +
        + +
        +
        + +
        + +
        + +
        + + +
        + +
        + + + + + + +
        + +
        + +
        + +
        +
        + +
        + + + +
        +
        +
        +
        +
        +
          + + + + + + + + + + + + +
        1. +
            +
          1. + + + +
            + +
            + + +
            + +
            + + + + + +
            + + + + Ferdi (Ferdinand Fanötöna Zebua)‏ @f_fz + + + + Jun 27 + + +
            +
            + +
            +
            +
            +
            +
            +
              + +
            • + +
            • +
            • + +
            • +
            +
            + +
            + +
            + +
            + + + + + +
            + Replying to @codinghorror + + + +
            + + + +
            +

            Mind-blown!

            +
            + + + + + + + + + + + + +
            + +
            + + + + + 0 replies + + + + + 0 retweets + + + + + 1 like + + +
            + +
            +
            + +
            + +
            + +
            + + +
            + +
            + + + + + + +
            + +
            + + + + + + + + + + + +
            + +
            + + + + +
            +
            +
            +
            +
            Thanks. Twitter will use this to make your timeline better. + Undo +
            +
            +
            +
            +
            + + Undo +
            +
            +
            +
            + +
          2. +
          +
        2. + + +
        3. +
            +
          1. + + + +
            + +
            + + +
            + +
            + + + + + +
            + + + + Chet Faliszek‏Verified account @chetfaliszek + + + + Jun 27 + + +
            +
            + +
            +
            +
            +
            +
            +
              + +
            • + +
            • +
            • + +
            • +
            +
            + +
            + +
            + +
            + + + + + +
            + Replying to @codinghorror + + + +
            + + + +
            +

            Make it their first NFT!

            +
            + + + + + + + + + + + + +
            + +
            + + + + + 0 replies + + + + + 0 retweets + + + + + 2 likes + + +
            + +
            +
            + +
            + +
            + +
            + + +
            + +
            + + + + + + +
            + +
            + + + + + + + + + + + +
            + +
            + + + + +
            +
            +
            +
            +
            Thanks. Twitter will use this to make your timeline better. + Undo +
            +
            +
            +
            +
            + + Undo +
            +
            +
            +
            + +
          2. +
          +
        4. + + +
        5. +
            +
          1. + + + +
            + +
            + + +
            + +
            + + + + + +
            + + + + Shawn Holmes‏ @Hanzo55 + + + + Jun 27 + + +
            +
            + +
            +
            +
            +
            +
            +
              + +
            • + +
            • +
            • + +
            • +
            +
            + +
            + +
            + +
            + + + + + +
            + Replying to @codinghorror + + + +
            + + + +
            +

            This will be the next milestonepic.twitter.com/ARtq5AkZqG

            +
            + + + + + +
            +
            +
            +
            +
            + +
            + + +
            +
            +
            +
            + + + + + + + + +
            + +
            + + + + + 0 replies + + + + + 0 retweets + + + + + 2 likes + + +
            + +
            +
            + +
            + +
            + +
            + + +
            + +
            + + + + + + +
            + +
            + + + + + + + + + + + +
            + +
            + + + + +
            +
            +
            +
            +
            Thanks. Twitter will use this to make your timeline better. + Undo +
            +
            +
            +
            +
            + + Undo +
            +
            +
            +
            + +
          2. +
          +
        6. + + +
        7. +
            +
          1. + + + +
            + +
            + + +
            + +
            + + + + + +
            + + + + Andrei Taranchenko‏ @andrenaleen + + + + Jun 27 + + +
            +
            + +
            +
            +
            +
            +
            +
              + +
            • + +
            • +
            • + +
            • +
            +
            + +
            + +
            + +
            + + + + + +
            + Replying to @codinghorror + + + +
            + + + +
            +

            I mean, a text from your kid is less dramatic than Pearl Harbor, but I don't have all the info.

            +
            + + + + + + + + + + + + +
            + +
            + + + + + 0 replies + + + + + 0 retweets + + + + + 1 like + + +
            + +
            +
            + +
            + +
            + +
            + + +
            + +
            + + + + + + +
            + +
            + + + + + + + + + + + +
            + +
            + + + + +
            +
            +
            +
            +
            Thanks. Twitter will use this to make your timeline better. + Undo +
            +
            +
            +
            +
            + + Undo +
            +
            +
            +
            + +
          2. +
          +
        8. + + + + + + + +
        +
        +
        +
        +
        + + +

        + + +

        + +

        +
        +
        + + +
        +
        + +
        +
        + +
        +
        +
        +
        +
        +

        Loading seems to be taking a while.

        +

        + Twitter may be over capacity or experiencing a momentary hiccup. Try again or visit Twitter Status for more information. +

        +
        +
        +
        + +
          +
          +
          +
          +
          +
          + + + +
          + + +
          +
          +

          Promoted Tweet

          +
            + +
          +
          +
          + +
          +
          +
          +
          + +

          false

          +
          +
          +
            +
          +
          +
          + +
          +
          + + +
          +
          +
          +
          +
            +
          • © 2021 Twitter
          • +
          • About
          • +
          • Help Center
          • +
          • Terms
          • +
          • Privacy policy
          • +
          • Cookies
          • +
          • Ads info
          • +
          +
          +
          + +
          + +
          +
          + + +
          +
          +
          +
          + +
          + + + +
          + + + + + + + + + + + + + + + diff --git a/spec/lib/onebox/engine/twitter_status_onebox_spec.rb b/spec/lib/onebox/engine/twitter_status_onebox_spec.rb index daa6f36cf9..e74c6ee27f 100644 --- a/spec/lib/onebox/engine/twitter_status_onebox_spec.rb +++ b/spec/lib/onebox/engine/twitter_status_onebox_spec.rb @@ -74,6 +74,23 @@ describe Onebox::Engine::TwitterStatusOnebox do let(:retweets_count) { "201" } end + shared_context "featured image info" do + before do + @link = "https://twitter.com/codinghorror/status/1409351083177046020" + @onebox_fixture = "twitterstatus_featured_image" + + stub_request(:get, @link.downcase).to_return(status: 200, body: onebox_response(@onebox_fixture)) + end + + let(:full_name) { "Jeff Atwood" } + let(:screen_name) { "codinghorror" } + let(:avatar) { "" } + let(:timestamp) { "3:02 PM - 27 Jun 2021" } + let(:link) { @link } + let(:favorite_count) { "90" } + let(:retweets_count) { "0" } + end + shared_examples "includes quoted tweet data" do it 'includes quoted tweet' do expect(html).to include("If you bought a ticket for tonight’s @Metallica show at Stade de France, you have helped") @@ -115,6 +132,18 @@ describe Onebox::Engine::TwitterStatusOnebox do it_behaves_like '#to_html' it_behaves_like "includes quoted tweet data" end + + context "with a featured image tweet" do + let(:tweet_content) do + "My first text message from my child! A moment that shall live on in infamy!" + end + + include_context "featured image info" + include_context "engines" + + it_behaves_like "an engine" + it_behaves_like '#to_html' + end end context "with twitter client" do From 15320d432b3045ea4e094a21db6a081f98b8b879 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Tue, 13 Jul 2021 21:58:51 +0200 Subject: [PATCH 376/403] DEV: Make badges grid a `grid` (#13719) Even grid gaps, more space for text, removed on-hover shadow Co-authored-by: awesomerobot --- .../app/templates/components/badge-card.hbs | 6 +- .../stylesheets/common/base/user-badges.scss | 72 +++++++------------ 2 files changed, 26 insertions(+), 52 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs index fc05530843..613b99b0ef 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/badge-card.hbs @@ -23,10 +23,8 @@ {{/if}} {{/if}} -
          -
          - {{icon-or-image badge}} -
          +
          + {{icon-or-image badge}}

          {{badge.name}}

          diff --git a/app/assets/stylesheets/common/base/user-badges.scss b/app/assets/stylesheets/common/base/user-badges.scss index aaa9aff8e8..3b57376149 100644 --- a/app/assets/stylesheets/common/base/user-badges.scss +++ b/app/assets/stylesheets/common/base/user-badges.scss @@ -121,12 +121,9 @@ } .badge-card { - position: relative; - display: inline-block; background-color: var(--primary-very-low); border: 1px solid var(--primary-low); - margin-bottom: 2vh; - transition: box-shadow 0.25s; + position: relative; .check-display { position: absolute; @@ -151,9 +148,8 @@ .badge-contents { display: flex; - min-height: 128px; - height: 100%; - padding: 0 10%; + min-height: 8em; + padding: 0 1.5em; .badge-link { color: var(--primary); @@ -161,19 +157,20 @@ .badge-icon { display: flex; - flex: 0 0 auto; - width: 1.23em; - margin-right: 5%; + flex-shrink: 0; + margin-right: 1.5em; align-items: center; justify-content: center; - font-size: 3.5em; - a { - width: 100%; + width: 64px; + + svg { + font-size: 3.5em; } + img { width: 100%; - max-width: 65px; - max-height: 80px; + max-width: 64px; + max-height: 64px; } &.badge-type-gold .fa { @@ -193,11 +190,8 @@ display: flex; flex: 1 1 auto; align-items: center; - padding: 1em 1.5em 1em 0; + padding: 1em 0; color: var(--primary); - @media screen and (max-width: 600px) { - padding-right: 0; - } h3 { margin-bottom: 0.25em; @@ -209,38 +203,15 @@ } } - &.medium { - flex: 0 0 auto; - width: 32%; - margin-right: 1.63%; - @media screen and (min-width: 851px) { - &:nth-of-type(3n) { - margin-right: 0; - } - } - @include breakpoint(medium) { - width: 48.5%; - &:nth-of-type(2n) { - margin-right: 0; - } - } - @include breakpoint(mobile-extra-large) { - flex: 0 0 100%; - } - &:hover { - box-shadow: shadow("card"); - } - &:active { - box-shadow: none; - } - } &.large { width: 100%; align-self: flex-start; + @media screen and (min-width: 767px) { max-width: calc(#{$large-width} / 2); margin-right: 1.5em; } + .badge-contents { padding: 0 5%; h3 { @@ -273,15 +244,20 @@ } .badge-group-list { + display: grid; + grid: auto-flow / repeat(3, 1fr); + grid-gap: 1em; margin-bottom: 1.5em; - display: flex; - flex-wrap: wrap; + @include breakpoint(medium) { - justify-content: space-between; + grid: auto-flow / repeat(2, 1fr); + } + + @include breakpoint(mobile-extra-large) { + grid: auto-flow / 1fr; } .title { - width: 100%; font-size: $font-up-1; } } From d11fe6fde50e25bab9abf6f588d44734511dee2a Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 14 Jul 2021 04:15:58 +0300 Subject: [PATCH 377/403] FIX: Use rem for font sizes in post headings (#13720) Size of headings increased proportionally with their nesting because their size was relative to the parent element (used em). This commit makes headings from posts use rem instead which are relative to the root HTML element.

          test

          looks the same as

          test

          now. --- app/assets/stylesheets/common/base/topic-post.scss | 12 ++++++------ app/assets/stylesheets/common/font-variables.scss | 8 ++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index d96570a2bf..3238f3eb9a 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -124,27 +124,27 @@ $quote-share-maxwidth: 150px; } h1 { - font-size: $font-up-3; + font-size: var(--font-up-3-rem); } h2 { - font-size: $font-up-2; + font-size: var(--font-up-2-rem); } h3 { - font-size: $font-up-1; + font-size: var(--font-up-1-rem); } h4 { - font-size: $font-0; + font-size: var(--font-0-rem); } h5 { - font-size: $font-down-1; + font-size: var(--font-down-1-rem); } h6 { - font-size: $font-down-2; + font-size: var(--font-down-2-rem); } a { diff --git a/app/assets/stylesheets/common/font-variables.scss b/app/assets/stylesheets/common/font-variables.scss index c64ccfd220..4d6972e54b 100644 --- a/app/assets/stylesheets/common/font-variables.scss +++ b/app/assets/stylesheets/common/font-variables.scss @@ -20,6 +20,14 @@ --font-down-5: 0.5em; --font-down-6: 0.4355em; + // Font-size definitions in rem used in cooked headings + --font-up-3-rem: 1.5157rem; + --font-up-2-rem: 1.3195rem; + --font-up-1-rem: 1.1487rem; + --font-0-rem: 1rem; + --font-down-1-rem: 0.8706rem; + --font-down-2-rem: 0.7579rem; + // inputs/textareas in iOS need to be at least 16px to avoid triggering zoom on focus // with base at 15px, the below gives 16.05px --font-size-ios-input: 1.07em; From 068889cb5f54fd4b402eeeb77572c33ad261f86b Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 14 Jul 2021 14:23:14 +1000 Subject: [PATCH 378/403] FIX: Email threads sometimes not grouping for group SMTP (#13727) This PR fixes a couple of issues related to group SMTP: 1. When running the group SMTP job, we were exiting early if the email was for the OP because of an IMAP race condition. However this causes issues when replying as a new topic for an existing SMTP topic, as the recipient does not get the OP email which can cause threading problems. 2. When sending emails for a new topic spun out like the issue in 1., we are not maintaining the original subject/topic title because that is based on the incoming email record, which we were not doing because the group SMTP email was never sent because of issue 1. --- app/jobs/regular/group_smtp_email.rb | 6 +++++- spec/jobs/regular/group_smtp_email_spec.rb | 24 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/jobs/regular/group_smtp_email.rb b/app/jobs/regular/group_smtp_email.rb index ce113e5a77..3970ecaa1f 100644 --- a/app/jobs/regular/group_smtp_email.rb +++ b/app/jobs/regular/group_smtp_email.rb @@ -52,7 +52,11 @@ module Jobs # # Basically, we should never be sending this notification for the first # post in a topic. - if post.is_first_post? + # + # If the group does not have IMAP enabled then this could be legitimate, + # for example in cases where we are creating a new topic to reply to another + # group PM and we need to send the participants the group OP email. + if post.is_first_post? && group.imap_enabled ImapSyncLog.warn("Aborting SMTP email for post #{post.id} in topic #{post.topic_id} to #{email}, the post is the OP and should not send an email.", group) return end diff --git a/spec/jobs/regular/group_smtp_email_spec.rb b/spec/jobs/regular/group_smtp_email_spec.rb index 9b792a8458..bc8991f097 100644 --- a/spec/jobs/regular/group_smtp_email_spec.rb +++ b/spec/jobs/regular/group_smtp_email_spec.rb @@ -171,9 +171,27 @@ RSpec.describe Jobs::GroupSmtpEmail do context "when the post in the argument is the OP" do let(:post_id) { post.topic.posts.first.id } - it "aborts and does not send a group SMTP email; the OP is the one that sent the email in the first place" do - expect { subject.execute(args) }.not_to(change { EmailLog.count }) - expect(ActionMailer::Base.deliveries.count).to eq(0) + + context "when the group has imap enabled" do + before do + group.update!(imap_enabled: true) + end + + it "aborts and does not send a group SMTP email; the OP is the one that sent the email in the first place" do + expect { subject.execute(args) }.not_to(change { EmailLog.count }) + expect(ActionMailer::Base.deliveries.count).to eq(0) + end + end + + context "when the group does not have imap enabled" do + before do + group.update!(imap_enabled: false) + end + + it "sends the email as expected" do + subject.execute(args) + expect(ActionMailer::Base.deliveries.count).to eq(1) + end end end From c3045e6828fb05a41d079f1d268359368fdd863d Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Wed, 14 Jul 2021 06:42:31 +0200 Subject: [PATCH 379/403] FIX: Don't try to load badges if there none left (#13695) Converted `actions` hash to `@action` and added: ``` if (!this.canLoadMore) { return; } ``` --- .../discourse/app/controllers/badges/show.js | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/badges/show.js b/app/assets/javascripts/discourse/app/controllers/badges/show.js index 4cab9625da..3c3d9ec71d 100644 --- a/app/assets/javascripts/discourse/app/controllers/badges/show.js +++ b/app/assets/javascripts/discourse/app/controllers/badges/show.js @@ -1,7 +1,7 @@ +import EmberObject, { action } from "@ember/object"; import Controller, { inject as controller } from "@ember/controller"; import discourseComputed, { observes } from "discourse-common/utils/decorators"; import Badge from "discourse/models/badge"; -import EmberObject from "@ember/object"; import I18n from "I18n"; import UserBadge from "discourse/models/user-badge"; @@ -50,35 +50,6 @@ export default Controller.extend({ return this.siteSettings.enable_badges && hasTitleBadges && hasBadge; }, - actions: { - loadMore() { - if (this.loadingMore) { - return; - } - this.set("loadingMore", true); - - const userBadges = this.userBadges; - - UserBadge.findByBadgeId(this.get("model.id"), { - offset: userBadges.length, - username: this.username, - }) - .then((result) => { - userBadges.pushObjects(result); - if (userBadges.length === 0) { - this.set("noMoreBadges", true); - } - }) - .finally(() => { - this.set("loadingMore", false); - }); - }, - - toggleSetUserTitle() { - return this.toggleProperty("hiddenSetTitle"); - }, - }, - @discourseComputed("noMoreBadges", "grantCount", "userBadges.length") canLoadMore(noMoreBadges, grantCount, userBadgeLength) { if (noMoreBadges) { @@ -96,4 +67,37 @@ export default Controller.extend({ _showFooter() { this.set("application.showFooter", !this.canLoadMore); }, + + @action + loadMore() { + if (!this.canLoadMore) { + return; + } + + if (this.loadingMore) { + return; + } + this.set("loadingMore", true); + + const userBadges = this.userBadges; + + UserBadge.findByBadgeId(this.get("model.id"), { + offset: userBadges.length, + username: this.username, + }) + .then((result) => { + userBadges.pushObjects(result); + if (userBadges.length === 0) { + this.set("noMoreBadges", true); + } + }) + .finally(() => { + this.set("loadingMore", false); + }); + }, + + @action + toggleSetUserTitle() { + return this.toggleProperty("hiddenSetTitle"); + }, }); From a2425487b266771f8b9a7c17fe487b02c5b2f557 Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Wed, 14 Jul 2021 07:45:26 +0200 Subject: [PATCH 380/403] UX: Make sure there's always a margin on badges page (#13693) --- .../javascripts/discourse/app/templates/badges/show.hbs | 2 +- app/assets/stylesheets/common/base/user.scss | 4 ++++ app/assets/stylesheets/mobile/user.scss | 4 ---- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/badges/show.hbs b/app/assets/javascripts/discourse/app/templates/badges/show.hbs index cc33f81d62..c624121d6d 100644 --- a/app/assets/javascripts/discourse/app/templates/badges/show.hbs +++ b/app/assets/javascripts/discourse/app/templates/badges/show.hbs @@ -51,7 +51,7 @@ {{#unless canLoadMore}} {{#if canShowOthers}} -
          +
          {{i18n "badges.others_count" count=othersCount}}
          {{/if}} diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index c1a2022d6f..4bdf5f39c4 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -420,6 +420,10 @@ } } +.user-badges { + margin-bottom: 2em; +} + .stats-title { text-transform: uppercase; margin-bottom: 10px; diff --git a/app/assets/stylesheets/mobile/user.scss b/app/assets/stylesheets/mobile/user.scss index 53dfc3bfaa..a955b9aeba 100644 --- a/app/assets/stylesheets/mobile/user.scss +++ b/app/assets/stylesheets/mobile/user.scss @@ -261,10 +261,6 @@ width: 100%; } -.user-badges { - margin-bottom: 2em; -} - .user-preferences { padding-bottom: 2em; From 1fb48fc9a6f6dbf194bc946040e75ab21364f167 Mon Sep 17 00:00:00 2001 From: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> Date: Wed, 14 Jul 2021 01:19:21 -0500 Subject: [PATCH 381/403] A11Y: Add labels where needed (#13686) --- .../discourse/app/templates/components/bookmark.hbs | 2 +- app/assets/javascripts/discourse/app/templates/topic.hbs | 4 ++-- app/assets/stylesheets/common/components/bookmark-modal.scss | 2 +- config/locales/client.en.yml | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/bookmark.hbs b/app/assets/javascripts/discourse/app/templates/components/bookmark.hbs index f974890068..90b3ae7e54 100644 --- a/app/assets/javascripts/discourse/app/templates/components/bookmark.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/bookmark.hbs @@ -9,7 +9,7 @@
          {{input id="bookmark-name" value=model.name name="bookmark-name" class="bookmark-name" enter=(action "saveAndClose") placeholder=(i18n "post.bookmarks.name_placeholder") maxlength="100"}} - {{d-button icon="cog" action=(action "toggleOptionsPanel") class="bookmark-options-button"}} + {{d-button icon="cog" action=(action "toggleOptionsPanel") class="bookmark-options-button" ariaLabel="post.bookmarks.options"}}
          diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs index 6798b6177a..5fb8e40fe9 100644 --- a/app/assets/javascripts/discourse/app/templates/topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/topic.hbs @@ -45,8 +45,8 @@ {{plugin-outlet name="edit-topic" args=(hash model=model buffered=buffered)}}
          - {{d-button action=(action "finishedEditingTopic") class="btn-primary submit-edit" icon="check"}} - {{d-button action=(action "cancelEditingTopic") class="btn-default cancel-edit" icon="times"}} + {{d-button action=(action "finishedEditingTopic") class="btn-primary submit-edit" icon="check" ariaLabel="composer.save_edit"}} + {{d-button action=(action "cancelEditingTopic") class="btn-default cancel-edit" icon="times" ariaLabel="composer.cancel"}} {{#if canRemoveTopicFeaturedLink}} diff --git a/app/assets/stylesheets/common/components/bookmark-modal.scss b/app/assets/stylesheets/common/components/bookmark-modal.scss index f97c37a6c2..5d53ac81d7 100644 --- a/app/assets/stylesheets/common/components/bookmark-modal.scss +++ b/app/assets/stylesheets/common/components/bookmark-modal.scss @@ -42,7 +42,7 @@ margin-left: 0.5em; margin-bottom: 0.5em; background: transparent; - padding-right: 6px; + padding: 6px; } .bookmark-options-panel { diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ff09213a1c..071d079388 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3113,6 +3113,7 @@ en: name: "Name" name_placeholder: "What is this bookmark for?" set_reminder: "Remind me" + options: "Options" actions: delete_bookmark: name: "Delete bookmark" From 7e52eada200627b2f224c7980c8ed51d77f42028 Mon Sep 17 00:00:00 2001 From: Kim Lindberger Date: Wed, 14 Jul 2021 08:20:57 +0200 Subject: [PATCH 382/403] FIX: Use Terser for minification even if uglify-js is not available (#13683) --- lib/tasks/assets.rake | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake index 41ab8ab28c..69f8b3f75c 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -18,7 +18,7 @@ task 'assets:precompile:before' do # is recompiled Emoji.clear_cache - if !`which uglifyjs`.empty? && !ENV['SKIP_NODE_UGLIFY'] + if !`which terser`.empty? && !ENV['SKIP_NODE_UGLIFY'] $node_uglify = true end @@ -102,11 +102,8 @@ def compress_node(from, to) source_map_url = cdn_path "/assets/#{to}.map" base_source_map = assets_path + assets_additional_path - # TODO: Remove uglifyjs when base image only includes terser - js_compressor = `which terser`.empty? ? 'uglifyjs' : 'terser' - cmd = <<~EOS - #{js_compressor} '#{assets_path}/#{from}' -m -c -o '#{to_path}' --source-map "base='#{base_source_map}',root='#{source_map_root}',url='#{source_map_url}'" + terser '#{assets_path}/#{from}' -m -c -o '#{to_path}' --source-map "base='#{base_source_map}',root='#{source_map_root}',url='#{source_map_url}'" EOS STDERR.puts cmd From 2318bd66a7debfa4e7fc7fa6ddd9a71d69a087c9 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Wed, 14 Jul 2021 12:51:55 +0300 Subject: [PATCH 383/403] FIX: Use array to keep best link for each onebox (#13717) Use a Map to hold the best link element for each Onebox HTML element. Using an Object did not work as intended because Object can use only Strings or Symbols as keys. Using HTML elements (representing oneboxes) as keys most probably converted them to some generic string and sometimes different Oneboxes were associated same key. It seems to be browser and content dependent, without any clear indication of what is happening internally. This bug caused link counts to show only for the last Onebox because the best link from the last Onebox was considered for all the other Oneboxes. --- .../javascripts/discourse/app/widgets/post-cooked.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/widgets/post-cooked.js b/app/assets/javascripts/discourse/app/widgets/post-cooked.js index 35c65f9122..f115de17ee 100644 --- a/app/assets/javascripts/discourse/app/widgets/post-cooked.js +++ b/app/assets/javascripts/discourse/app/widgets/post-cooked.js @@ -98,13 +98,13 @@ export default class PostCooked { // find the best element in each onebox and display link counts only // for that one (the best element is the most significant one to the // viewer) - const bestElements = []; + const bestElements = new Map(); $html[0].querySelectorAll("aside.onebox").forEach((onebox) => { // look in headings first for (let i = 1; i <= 6; ++i) { const hLinks = onebox.querySelectorAll(`h${i} a[href]`); if (hLinks.length > 0) { - bestElements[onebox] = hLinks[0]; + bestElements.set(onebox, hLinks[0]); return; } } @@ -112,7 +112,7 @@ export default class PostCooked { // use the header otherwise const hLinks = onebox.querySelectorAll("header a[href]"); if (hLinks.length > 0) { - bestElements[onebox] = hLinks[0]; + bestElements.set(onebox, hLinks[0]); } }); @@ -142,8 +142,8 @@ export default class PostCooked { const $onebox = $link.closest(".onebox"); if ( $onebox.length === 0 || - !bestElements[$onebox[0]] || - bestElements[$onebox[0]] === $link[0] + !bestElements.has($onebox[0]) || + bestElements.get($onebox[0]) === $link[0] ) { const title = I18n.t("topic_map.clicks", { count: lc.clicks }); $link.append( From 2a95f892afbe4030c0c98a740124c212d5eb7b60 Mon Sep 17 00:00:00 2001 From: Gerhard Schlager Date: Wed, 14 Jul 2021 13:26:12 +0200 Subject: [PATCH 384/403] DEV: Update `i18n:check` rake task to detect invalid Markdown links (#13728) In addition to that it fixes a problem where the check failed on empty locale files and allows calling the rake task with multiple locales. --- lib/i18n/locale_file_checker.rb | 14 ++++++++++++++ lib/tasks/i18n.rake | 22 ++++++++++++++-------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/i18n/locale_file_checker.rb b/lib/i18n/locale_file_checker.rb index 1f30f1ab66..e818823fd8 100644 --- a/lib/i18n/locale_file_checker.rb +++ b/lib/i18n/locale_file_checker.rb @@ -8,6 +8,7 @@ class LocaleFileChecker TYPE_UNSUPPORTED_INTERPOLATION_KEYS = 2 TYPE_MISSING_PLURAL_KEYS = 3 TYPE_INVALID_MESSAGE_FORMAT = 4 + TYPE_INVALID_MARKDOWN_LINK = 5 def check(locale) @errors = {} @@ -20,9 +21,12 @@ class LocaleFileChecker @locale_yaml = YAML.load_file(locale_path) @reference_yaml = YAML.load_file(reference_path) + next if @locale_yaml.blank? || @locale_yaml.first[1].blank? + check_interpolation_keys check_plural_keys check_message_format + check_markdown_links end @errors @@ -93,6 +97,16 @@ class LocaleFileChecker end end + def check_markdown_links + traverse_hash(@locale_yaml, []) do |keys, value| + next if value.is_a?(Array) + + if /\[.*?\]\s+\(.*?\)/.match?(value) + add_error(keys, TYPE_INVALID_MARKDOWN_LINK, nil, pluralized: false) + end + end + end + def check_plural_keys known_parent_keys = Set.new diff --git a/lib/tasks/i18n.rake b/lib/tasks/i18n.rake index 64fce0e148..b35cce8fdc 100644 --- a/lib/tasks/i18n.rake +++ b/lib/tasks/i18n.rake @@ -6,15 +6,19 @@ require 'seed_data/topics' require 'colored2' desc "Checks locale files for errors" -task "i18n:check", [:locale] => [:environment] do |_, args| +task "i18n:check" => [:environment] do |_, args| failed_locales = [] - if args[:locale].present? - if LocaleSiteSetting.valid_value?(args[:locale]) - locales = [args[:locale]] - else - puts "ERROR: #{locale} is not a valid locale" - exit 1 + if args.extras.present? + locales = [] + + args.extras.each do |locale| + if LocaleSiteSetting.valid_value?(locale) + locales << locale + else + puts "ERROR: #{locale} is not a valid locale" + exit 1 + end end else locales = LocaleSiteSetting.supported_locales @@ -44,8 +48,10 @@ task "i18n:check", [:locale] => [:environment] do |_, args| "Missing plural keys".magenta when LocaleFileChecker::TYPE_INVALID_MESSAGE_FORMAT "Invalid message format".yellow + when LocaleFileChecker::TYPE_INVALID_MARKDOWN_LINK + "Invalid markdown links".yellow end - details = error[:details] ? ": #{error[:details]}" : "" + details = error[:details].present? ? ": #{error[:details]}" : "" puts error[:key] << " -- " << message << details end From c750bfb4af26235dbcd7ec28cb67d58c9254ab8e Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Wed, 14 Jul 2021 13:51:33 +0200 Subject: [PATCH 385/403] FIX: A memoization bug in UserLookup and refactor (#13692) `@group_lookup` memoization bug was introduced in #13587. --- lib/user_lookup.rb | 57 ++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/lib/user_lookup.rb b/lib/user_lookup.rb index 8ea880b4dd..590c6e2754 100644 --- a/lib/user_lookup.rb +++ b/lib/user_lookup.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class UserLookup + def self.lookup_columns + @user_lookup_columns ||= %i{id username name uploaded_avatar_id primary_group_id flair_group_id admin moderator trust_level} + end + + def self.group_lookup_columns + @group_lookup_columns ||= %i{id name flair_icon flair_upload_id flair_bg_color flair_color} + end def initialize(user_ids = []) @user_ids = user_ids.tap(&:compact!).tap(&:uniq!).tap(&:flatten!) @@ -12,66 +19,42 @@ class UserLookup end def primary_groups - @primary_groups ||= begin - hash = {} - users.values.each do |u| - if u.primary_group_id - hash[u.id] = groups[u.primary_group_id] - end + @primary_groups ||= users.values.each_with_object({}) do |user, hash| + if user.primary_group_id + hash[user.id] = groups[user.primary_group_id] end - hash end end def flair_groups - @flair_groups ||= begin - hash = {} - users.values.each do |u| - if u.flair_group_id - hash[u.id] = groups[u.flair_group_id] - end + @flair_groups ||= users.values.each_with_object({}) do |user, hash| + if user.flair_group_id + hash[user.id] = groups[user.flair_group_id] end - hash end end private - def self.lookup_columns - @user_lookup_columns ||= %i{id username name uploaded_avatar_id primary_group_id flair_group_id admin moderator trust_level} - end - - def self.group_lookup_columns - @group_lookup_columns ||= %i{id name flair_icon flair_upload_id flair_bg_color flair_color} - end - def users - @users ||= user_lookup_hash - end - - def user_lookup_hash - hash = {} - User.where(id: @user_ids) + @users ||= User + .where(id: @user_ids) .select(self.class.lookup_columns) - .each { |user| hash[user.id] = user } - hash + .index_by(&:id) end def groups - @group_lookup = begin + @group_lookup ||= begin group_ids = users.values.map { |u| [u.primary_group_id, u.flair_group_id] } group_ids.flatten! group_ids.uniq! group_ids.compact! - hash = {} - - Group.includes(:flair_upload) + Group + .includes(:flair_upload) .where(id: group_ids) .select(self.class.group_lookup_columns) - .each { |g| hash[g.id] = g } - - hash + .index_by(&:id) end end end From f89b135a219f32fcb6f751864568f152b3c8e9fd Mon Sep 17 00:00:00 2001 From: Jarek Radosz Date: Wed, 14 Jul 2021 16:43:24 +0200 Subject: [PATCH 386/403] FIX: `user/badges` grid fix (#13729) No more special CSS just for this path. --- .../discourse/app/templates/user/badges.hbs | 28 +++++++++++-------- .../tests/acceptance/user-anonymous-test.js | 2 +- app/assets/stylesheets/desktop/user.scss | 9 ------ 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/user/badges.hbs b/app/assets/javascripts/discourse/app/templates/user/badges.hbs index 68dd60ea50..8c20796569 100644 --- a/app/assets/javascripts/discourse/app/templates/user/badges.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/badges.hbs @@ -1,16 +1,20 @@ -{{#d-section pageClass="user-badges" class="user-content user-badges-list"}} +{{#d-section pageClass="user-badges" class="user-content"}}

          {{i18n "badges.favorite_count" count=this.favoriteBadges.length max=siteSettings.max_favorite_badges}}

          - {{#each sortedBadges as |ub|}} - {{badge-card - badge=ub.badge - count=ub.count - canFavorite=ub.can_favorite - isFavorite=ub.is_favorite - username=username - canFavoriteMoreBadges=canFavoriteMoreBadges - onFavoriteClick=(action "favorite" ub) - filterUser="true"}} - {{/each}} + +
          + {{#each sortedBadges as |ub|}} + {{badge-card + badge=ub.badge + count=ub.count + canFavorite=ub.can_favorite + isFavorite=ub.is_favorite + username=username + canFavoriteMoreBadges=canFavoriteMoreBadges + onFavoriteClick=(action "favorite" ub) + filterUser="true" + }} + {{/each}} +
          {{/d-section}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js index 6274bf797e..e3d014df89 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-anonymous-test.js @@ -29,7 +29,7 @@ acceptance("User Anonymous", function () { test("Badges", async function (assert) { await visit("/u/eviltrout/badges"); assert.ok($("body.user-badges-page").length, "has the body class"); - assert.ok(exists(".user-badges-list .badge-card"), "shows a badge"); + assert.ok(exists(".badge-group-list .badge-card"), "shows a badge"); }); test("Restricted Routes", async function (assert) { diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 9c9d216944..de3568b37f 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -55,15 +55,6 @@ background-color: var(--secondary); box-sizing: border-box; - &.user-badges-list { - display: flex; - flex-wrap: wrap; - } - - &.user-badges-list .favorite-count { - flex: 100%; - } - .btn.right { float: right; } From 7605cbab273033d15ddfe2820ebad4949a215992 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Wed, 14 Jul 2021 20:21:34 +0530 Subject: [PATCH 387/403] DEV: Bump `discourse_dev_assets` to 0.0.3. (#13730) Using `literate_randomizer` to generate random paragraph text. --- Gemfile.lock | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 443c226b65..f0e5d1d4c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,8 +115,9 @@ GEM railties (>= 3.1) discourse-ember-source (3.12.2.3) discourse-fonts (0.0.8) - discourse_dev_assets (0.0.2) + discourse_dev_assets (0.0.3) faker (~> 2.16) + literate_randomizer docile (1.4.0) ecma-re-validator (0.3.0) regexp_parser (~> 2.0) @@ -202,6 +203,7 @@ GEM listen (3.5.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + literate_randomizer (0.4.0) lograge (0.11.2) actionpack (>= 4) activesupport (>= 4) From 7d43e5182109482fec062b501e20272c21acf538 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 14 Jul 2021 15:17:32 -0400 Subject: [PATCH 388/403] FIX: Remove button to dismiss theme error messages (#13734) --- .../admin/addon/templates/customize-themes-show.hbs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs index 078af97bb6..272df66adc 100644 --- a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs @@ -16,10 +16,7 @@
          {{#each model.errors as |error|}} -
          - - {{error}} -
          +
          {{error}}
          {{/each}} {{#unless model.supported}} From f7ab852e123afe22b9a594bd43af712b7cebd98b Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 14 Jul 2021 15:18:29 -0400 Subject: [PATCH 389/403] FIX: Issues with custom icons in themes (#13732) Fixes two issues: - ignores invalid XML in custom icon sprite SVG file (and outputs an error if sprite was uploaded via admin UI) - clears SVG sprite cache when deleting an `icons-sprite` upload in a theme --- app/models/theme_field.rb | 31 ++++++++++++++++++- lib/svg_sprite/svg_sprite.rb | 10 ++++-- spec/components/svg_sprite/svg_sprite_spec.rb | 13 ++++++++ spec/fixtures/images/bad-xml-icon-sprite.svg | 6 ++++ spec/models/theme_field_spec.rb | 11 +++++++ 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 spec/fixtures/images/bad-xml-icon-sprite.svg diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index a181248f92..847745642c 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -170,6 +170,29 @@ class ThemeField < ActiveRecord::Base [js_compiler.content, errors&.join("\n")] end + def validate_svg_sprite_xml + upload = Upload.find(self.upload_id) rescue nil + + if Discourse.store.external? + external_copy = Discourse.store.download(upload) rescue nil + path = external_copy.try(:path) + else + path = Discourse.store.path_for(upload) + end + + content = File.read(path) + error = nil + + begin + svg_file = Nokogiri::XML(content) do |config| + config.options = Nokogiri::XML::ParseOptions::NOBLANKS + end + rescue => e + error = "Error with #{self.name}: #{e.inspect}" + end + error + end + def raw_translation_data(internal: false) # Might raise ThemeTranslationParser::InvalidYaml ThemeTranslationParser.new(self, internal: internal).load @@ -358,7 +381,7 @@ class ThemeField < ActiveRecord::Base self.compiler_version = Theme.compiler_version elsif svg_sprite_field? DB.after_commit { SvgSprite.expire_cache } - self.error = nil + self.error = validate_svg_sprite_xml self.value_baked = "baked" self.compiler_version = Theme.compiler_version end @@ -554,6 +577,12 @@ class ThemeField < ActiveRecord::Base MessageBus.publish "/footer-change/#{theme.id}", self.value if theme && self.name == "footer" end + after_destroy do + if svg_sprite_field? + DB.after_commit { SvgSprite.expire_cache } + end + end + private JAVASCRIPT_TYPES = %w( diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 8f0ae39a03..e65fa4f2e7 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -351,10 +351,16 @@ License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL end custom_svg_sprites(theme_id).each do |item| - svg_file = Nokogiri::XML(item[:sprite]) do |config| - config.options = Nokogiri::XML::ParseOptions::NOBLANKS + begin + svg_file = Nokogiri::XML(item[:sprite]) do |config| + config.options = Nokogiri::XML::ParseOptions::NOBLANKS + end + rescue => e + Rails.logger.warn("Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}") end + next if !svg_file + svg_file.css("symbol").each do |sym| icon_id = prepare_symbol(sym, item[:filename]) diff --git a/spec/components/svg_sprite/svg_sprite_spec.rb b/spec/components/svg_sprite/svg_sprite_spec.rb index 0ade6bc390..15bccf6b1b 100644 --- a/spec/components/svg_sprite/svg_sprite_spec.rb +++ b/spec/components/svg_sprite/svg_sprite_spec.rb @@ -264,6 +264,19 @@ describe SvgSprite do expect(SvgSprite.bundle(theme.id)).to match(/my-custom-theme-icon/) end + it 'does not fail on bad XML in custom icon sprite' do + theme = Fabricate(:theme) + fname = "bad-xml-icon-sprite.svg" + + upload = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) + + theme.set_field(target: :common, name: SvgSprite.theme_sprite_variable_name, upload_id: upload.id, type: :theme_upload_var) + theme.save! + + expect(Upload.where(id: upload.id)).to be_exist + expect(SvgSprite.bundle(theme.id)).to match(/arrow-down/) + end + it 'includes custom icons in a child theme' do theme = Fabricate(:theme) fname = "custom-theme-icon-sprite.svg" diff --git a/spec/fixtures/images/bad-xml-icon-sprite.svg b/spec/fixtures/images/bad-xml-icon-sprite.svg new file mode 100644 index 0000000000..0ae94c6d7a --- /dev/null +++ b/spec/fixtures/images/bad-xml-icon-sprite.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index 47462f3892..9854ce3084 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -419,6 +419,17 @@ HTML theme_field.update(upload: Fabricate(:upload)) expect(theme_field.value_baked).to eq(nil) end + + it "clears SVG sprite cache when upload is deleted" do + fname = "custom-theme-icon-sprite.svg" + sprite = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) + + theme_field.update(upload: sprite) + expect(SvgSprite.custom_svg_sprites(theme.id).size).to eq(1) + + theme_field.destroy! + expect(SvgSprite.custom_svg_sprites(theme.id).size).to eq(0) + end end end From 2484abddb6d75e7ace2d99bd63fa2a83ed716cce Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 14 Jul 2021 22:52:35 +0300 Subject: [PATCH 390/403] FIX: Assets for the theme tests page are not compressed (#13736) A couple of weeks we made a change that skipped compressing assets used by the theme qunit page: https://github.com/discourse/discourse/pull/13619. This is a follow-up PR to stop the application helper from generating the assets for the theme qunit page with `.br` or `.gzip` extensions when a site uses S3 as a CDN. --- app/helpers/application_helper.rb | 15 +++++++++++---- spec/helpers/application_helper_spec.rb | 6 ++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 480b242969..9dbc7aef9b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -75,10 +75,17 @@ module ApplicationHelper path = "#{GlobalSetting.s3_cdn_url}#{path}" end - if is_brotli_req? - path = path.gsub(/\.([^.]+)$/, '.br.\1') - elsif is_gzip_req? - path = path.gsub(/\.([^.]+)$/, '.gz.\1') + # assets needed for theme testing are not compressed because they take a fair + # amount of time to compress (+30 seconds) during rebuilds/deploys when the + # vast majority of sites will never need them, so it makes more sense to serve + # them uncompressed instead of making everyone's rebuild/deploy take +30 more + # seconds. + if !script.start_with?("discourse/tests/") + if is_brotli_req? + path = path.gsub(/\.([^.]+)$/, '.br.\1') + elsif is_gzip_req? + path = path.gsub(/\.([^.]+)$/, '.gz.\1') + end end elsif GlobalSetting.cdn_url&.start_with?("https") && is_brotli_req? && Rails.env != "development" diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 4a4c4002a6..ccc7842ef9 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -69,6 +69,12 @@ describe ApplicationHelper do expect(link).to eq(preload_link("https://s3cdn.com/assets/application.js")) end + + it "gives s3 cdn but without brotli/gzip extensions for theme tests assets" do + helper.request.env["HTTP_ACCEPT_ENCODING"] = 'gzip, br' + link = helper.preload_script('discourse/tests/theme_qunit_ember_jquery') + expect(link).to eq(preload_link("https://s3cdn.com/assets/discourse/tests/theme_qunit_ember_jquery.js")) + end end end From ae33104702a25242aef1c1892581691e09e509bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jul 2021 00:19:49 +0200 Subject: [PATCH 391/403] Build(deps): Bump rubocop-ast from 1.7.0 to 1.8.0 (#13737) Bumps [rubocop-ast](https://github.com/rubocop-hq/rubocop-ast) from 1.7.0 to 1.8.0. - [Release notes](https://github.com/rubocop-hq/rubocop-ast/releases) - [Changelog](https://github.com/rubocop/rubocop-ast/blob/master/CHANGELOG.md) - [Commits](https://github.com/rubocop-hq/rubocop-ast/compare/v1.7.0...v1.8.0) --- updated-dependencies: - dependency-name: rubocop-ast dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f0e5d1d4c9..a26a45e88a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -394,7 +394,7 @@ GEM rubocop-ast (>= 1.7.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.7.0) + rubocop-ast (1.8.0) parser (>= 3.0.1.1) rubocop-discourse (2.4.2) rubocop (>= 1.1.0) From 0109edb8475ff07bf0bf0c764c718f1983b646f8 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 14 Jul 2021 21:14:05 -0400 Subject: [PATCH 392/403] UX: stop imgur/google photo mobile onebox overflow (#13738) --- app/assets/stylesheets/common/base/onebox.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index f04dd16ada..72e2939798 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -672,7 +672,7 @@ aside.onebox.twitterstatus .onebox-body { color: #fff; background-color: rgba(0, 0, 0, 0.6); @include ellipsis; - max-width: 690px; + max-width: 100%; padding: 5px 0; .inner-box { From 31aa701518788fd3e08f5ec9f155e9a3fbe6b9a5 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Thu, 15 Jul 2021 05:53:26 +0300 Subject: [PATCH 393/403] FEATURE: Add option to grant badge multiple times to users using Bulk Award (#13571) Currently when bulk-awarding a badge that can be granted multiple times, users in the CSV file are granted the badge once no matter how many times they're listed in the file and only if they don't have the badge already. This PR adds a new option to the Badge Bulk Award feature so that it's possible to grant users a badge even if they already have the badge and as many times as they appear in the CSV file. --- .../addon/controllers/admin-badges-award.js | 108 +++++++++++---- .../admin/addon/routes/admin-badges-award.js | 5 + .../admin/addon/templates/badges-award.hbs | 43 +++++- .../acceptance/admin-badges-award-test.js | 32 +++++ .../tests/fixtures/badges-fixture.js | 1 + .../stylesheets/common/admin/badges.scss | 12 ++ app/controllers/admin/badges_controller.rb | 56 +++++--- app/jobs/regular/mass_award_badge.rb | 18 +-- app/services/badge_granter.rb | 90 +++++++++++-- config/locales/client.en.yml | 5 +- config/locales/server.en.yml | 2 + spec/jobs/mass_award_badge_spec.rb | 32 +++-- spec/requests/admin/badges_controller_spec.rb | 124 +++++++++++++++++- spec/services/badge_granter_spec.rb | 108 +++++++++++++++ 14 files changed, 544 insertions(+), 92 deletions(-) create mode 100644 app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js diff --git a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js index 218b3cb0e0..4318575a34 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-badges-award.js @@ -2,38 +2,98 @@ import Controller from "@ember/controller"; import I18n from "I18n"; import { ajax } from "discourse/lib/ajax"; import bootbox from "bootbox"; -import { popupAjaxError } from "discourse/lib/ajax-error"; +import { extractError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ saving: false, replaceBadgeOwners: false, + grantExistingHolders: false, + fileSelected: false, + unmatchedEntries: null, + resultsMessage: null, + success: false, + unmatchedEntriesCount: 0, - actions: { - massAward() { - const file = document.querySelector("#massAwardCSVUpload").files[0]; + resetState() { + this.setProperties({ + saving: false, + unmatchedEntries: null, + resultsMessage: null, + success: false, + unmatchedEntriesCount: 0, + }); + this.send("updateFileSelected"); + }, - if (this.model && file) { - const options = { - type: "POST", - processData: false, - contentType: false, - data: new FormData(), - }; + @discourseComputed("fileSelected", "saving") + massAwardButtonDisabled(fileSelected, saving) { + return !fileSelected || saving; + }, - options.data.append("file", file); - options.data.append("replace_badge_owners", this.replaceBadgeOwners); + @discourseComputed("unmatchedEntriesCount", "unmatchedEntries.length") + unmatchedEntriesTruncated(unmatchedEntriesCount, length) { + return unmatchedEntriesCount && length && unmatchedEntriesCount > length; + }, - this.set("saving", true); + @action + updateFileSelected() { + this.set( + "fileSelected", + !!document.querySelector("#massAwardCSVUpload")?.files?.length + ); + }, - ajax(`/admin/badges/award/${this.model.id}`, options) - .then(() => { - bootbox.alert(I18n.t("admin.badges.mass_award.success")); - }) - .catch(popupAjaxError) - .finally(() => this.set("saving", false)); - } else { - bootbox.alert(I18n.t("admin.badges.mass_award.aborted")); - } - }, + @action + massAward() { + const file = document.querySelector("#massAwardCSVUpload").files[0]; + + if (this.model && file) { + const options = { + type: "POST", + processData: false, + contentType: false, + data: new FormData(), + }; + + options.data.append("file", file); + options.data.append("replace_badge_owners", this.replaceBadgeOwners); + options.data.append("grant_existing_holders", this.grantExistingHolders); + + this.resetState(); + this.set("saving", true); + + ajax(`/admin/badges/award/${this.model.id}`, options) + .then( + ({ + matched_users_count: matchedCount, + unmatched_entries: unmatchedEntries, + unmatched_entries_count: unmatchedEntriesCount, + }) => { + this.setProperties({ + resultsMessage: I18n.t("admin.badges.mass_award.success", { + count: matchedCount, + }), + success: true, + }); + if (unmatchedEntries.length) { + this.setProperties({ + unmatchedEntries, + unmatchedEntriesCount, + }); + } + } + ) + .catch((error) => { + this.setProperties({ + resultsMessage: extractError(error), + success: false, + }); + }) + .finally(() => this.set("saving", false)); + } else { + bootbox.alert(I18n.t("admin.badges.mass_award.aborted")); + } }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js index ee6cf4b825..6fe72a6bf1 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-badges-award.js +++ b/app/assets/javascripts/admin/addon/routes/admin-badges-award.js @@ -9,4 +9,9 @@ export default Route.extend({ ); } }, + + setupController(controller) { + this._super(...arguments); + controller.resetState(); + }, }); diff --git a/app/assets/javascripts/admin/addon/templates/badges-award.hbs b/app/assets/javascripts/admin/addon/templates/badges-award.hbs index bb6c4bf22e..52bed37623 100644 --- a/app/assets/javascripts/admin/addon/templates/badges-award.hbs +++ b/app/assets/javascripts/admin/addon/templates/badges-award.hbs @@ -14,25 +14,62 @@

          {{i18n "admin.badges.mass_award.upload_csv"}}

          - +
          + {{#if model.multiple_grant}} + + {{/if}}
          {{d-button class="btn-primary" action=(action "massAward") type="submit" - disabled=saving + disabled=massAwardButtonDisabled + icon="certificate" label="admin.badges.mass_award.perform"}} - {{#link-to "adminBadges.index" class="btn btn-danger"}} + {{#link-to "adminBadges.index" class="btn btn-normal"}} {{d-icon "times"}} {{i18n "cancel"}} {{/link-to}} + {{#if saving}} + {{i18n "uploading"}} + {{/if}} + {{#if resultsMessage}} +

          + {{#if success}} + {{d-icon "check" class="bulk-award-status-icon success"}} + {{else}} + {{d-icon "times" class="bulk-award-status-icon failure"}} + {{/if}} + {{resultsMessage}} +

          + {{#if unmatchedEntries.length}} +

          + {{d-icon "exclamation-triangle" class="bulk-award-status-icon failure"}} + + {{#if unmatchedEntriesTruncated}} + {{i18n "admin.badges.mass_award.csv_has_unmatched_users_truncated_list" count=unmatchedEntriesCount}} + {{else}} + {{i18n "admin.badges.mass_award.csv_has_unmatched_users"}} + {{/if}} + +

          +
            + {{#each unmatchedEntries as |entry|}} +
          • {{entry}}
          • + {{/each}} +
          + {{/if}} + {{/if}} {{else}} {{i18n "admin.badges.mass_award.no_badge_selected"}} {{/if}} diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js new file mode 100644 index 0000000000..c2c6e66119 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/admin-badges-award-test.js @@ -0,0 +1,32 @@ +import { + acceptance, + exists, + query, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import I18n from "I18n"; + +acceptance("Admin - Badges - Mass Award", function (needs) { + needs.user(); + test("when the badge can be granted multiple times", async function (assert) { + await visit("/admin/badges/award/new"); + await click( + '.admin-badge-list-item span[data-badge-name="Both image and icon"]' + ); + assert.equal( + query("label.grant-existing-holders").textContent.trim(), + I18n.t("admin.badges.mass_award.grant_existing_holders"), + "checkbox for granting existing holders is displayed" + ); + }); + + test("when the badge can not be granted multiple times", async function (assert) { + await visit("/admin/badges/award/new"); + await click('.admin-badge-list-item span[data-badge-name="Only icon"]'); + assert.ok( + !exists(".grant-existing-holders"), + "checkbox for granting existing holders is not displayed" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/fixtures/badges-fixture.js b/app/assets/javascripts/discourse/tests/fixtures/badges-fixture.js index ee4ce84b42..bc6530686e 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/badges-fixture.js +++ b/app/assets/javascripts/discourse/tests/fixtures/badges-fixture.js @@ -1726,6 +1726,7 @@ export default { name: "Both image and icon", icon: "fa-rocket", image_url: "/assets/some-image.png", + multiple_grant: true, }, ] } diff --git a/app/assets/stylesheets/common/admin/badges.scss b/app/assets/stylesheets/common/admin/badges.scss index d573295cfd..71cf7b3260 100644 --- a/app/assets/stylesheets/common/admin/badges.scss +++ b/app/assets/stylesheets/common/admin/badges.scss @@ -134,6 +134,18 @@ .award-badge { margin: 15px 0 0 15px; float: left; + max-width: 70%; + + .bulk-award-status-icon { + margin-right: 3px; + + &.success { + color: var(--success); + } + &.failure { + color: var(--danger); + } + } .badge-preview { min-height: 110px; diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb index d020699989..6d278679e5 100644 --- a/app/controllers/admin/badges_controller.rb +++ b/app/controllers/admin/badges_controller.rb @@ -3,6 +3,8 @@ require 'csv' class Admin::BadgesController < Admin::AdminController + MAX_CSV_LINES = 50_000 + BATCH_SIZE = 200 def index data = { @@ -52,37 +54,50 @@ class Admin::BadgesController < Admin::AdminController end replace_badge_owners = params[:replace_badge_owners] == 'true' - BadgeGranter.revoke_all(badge) if replace_badge_owners + ensure_users_have_badge_once = params[:grant_existing_holders] != 'true' + if !ensure_users_have_badge_once && !badge.multiple_grant? + render_json_error( + I18n.t('badges.mass_award.errors.cant_grant_multiple_times', badge_name: badge.display_name), + status: 422 + ) + return + end - batch_number = 1 line_number = 1 - batch = [] - + usernames = [] + emails = [] File.open(csv_file) do |csv| - mode = Email.is_valid?(CSV.parse_line(csv.first).first) ? 'email' : 'username' - csv.rewind - - csv.each_line do |email_line| - line = CSV.parse_line(email_line).first + csv.each_line do |line| + line = CSV.parse_line(line).first&.strip + line_number += 1 if line.present? - batch << line - line_number += 1 + if line.include?('@') + emails << line + else + usernames << line + end end - # Split the emails in batches of 200 elements. - full_batch = csv.lineno % (BadgeGranter::MAX_ITEMS_FOR_DELTA * batch_number) == 0 - last_batch_item = full_batch || csv.eof? - - if last_batch_item - Jobs.enqueue(:mass_award_badge, users_batch: batch, badge_id: badge.id, mode: mode) - batch = [] - batch_number += 1 + if emails.size + usernames.size > MAX_CSV_LINES + return render_json_error I18n.t('badges.mass_award.errors.too_many_csv_entries', count: MAX_CSV_LINES), status: 400 end end end + BadgeGranter.revoke_all(badge) if replace_badge_owners - head :ok + results = BadgeGranter.enqueue_mass_grant_for_users( + badge, + emails: emails, + usernames: usernames, + ensure_users_have_badge_once: ensure_users_have_badge_once + ) + + render json: { + unmatched_entries: results[:unmatched_entries].first(100), + matched_users_count: results[:matched_users_count], + unmatched_entries_count: results[:unmatched_entries_count] + }, status: :ok rescue CSV::MalformedCSVError render_json_error I18n.t('badges.mass_award.errors.invalid_csv', line_number: line_number), status: 400 end @@ -147,6 +162,7 @@ class Admin::BadgesController < Admin::AdminController end private + def find_badge params.require(:id) Badge.find(params[:id]) diff --git a/app/jobs/regular/mass_award_badge.rb b/app/jobs/regular/mass_award_badge.rb index ef51289917..e813e999df 100644 --- a/app/jobs/regular/mass_award_badge.rb +++ b/app/jobs/regular/mass_award_badge.rb @@ -3,20 +3,12 @@ module Jobs class MassAwardBadge < ::Jobs::Base def execute(args) - return unless mode = args[:mode] - badge = Badge.find_by(id: args[:badge_id]) + user = User.find_by(id: args[:user]) + return if user.blank? + badge = Badge.find_by(enabled: true, id: args[:badge]) + return if badge.blank? - users = User.select(:id, :username, :locale) - - if mode == 'email' - users = users.with_email(args[:users_batch]) - else - users = users.where(username_lower: args[:users_batch].map!(&:downcase)) - end - - return if users.empty? || badge.nil? - - BadgeGranter.mass_grant(badge, users) + BadgeGranter.mass_grant(badge, user, count: args[:count]) end end end diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 96dec02e98..01dddbaa8a 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -21,23 +21,87 @@ class BadgeGranter BadgeGranter.new(badge, user, opts).grant end - def self.mass_grant(badge, users) - return unless badge.enabled? + def self.enqueue_mass_grant_for_users(badge, emails: [], usernames: [], ensure_users_have_badge_once: true) + emails = emails.map(&:downcase) + usernames = usernames.map(&:downcase) + usernames_map_to_ids = {} + emails_map_to_ids = {} + if usernames.size > 0 + usernames_map_to_ids = User.where(username_lower: usernames).pluck(:username_lower, :id).to_h + end + if emails.size > 0 + emails_map_to_ids = User.with_email(emails).pluck('LOWER(user_emails.email)', :id).to_h + end - system_user_id = Discourse.system_user.id - now = Time.zone.now - user_badges = users.map { |u| { badge_id: badge.id, user_id: u.id, granted_by_id: system_user_id, granted_at: now, created_at: now } } - granted_badges = UserBadge.insert_all(user_badges, returning: %i[user_id]) + count_per_user = {} + unmatched = Set.new + (usernames + emails).each do |entry| + id = usernames_map_to_ids[entry] || emails_map_to_ids[entry] + if id.blank? + unmatched << entry + next + end - users.each do |user| + if ensure_users_have_badge_once + count_per_user[id] = 1 + else + count_per_user[id] ||= 0 + count_per_user[id] += 1 + end + end + + existing_owners_ids = [] + if ensure_users_have_badge_once + existing_owners_ids = UserBadge.where(badge: badge).distinct.pluck(:user_id) + end + count_per_user.each do |user_id, count| + next if ensure_users_have_badge_once && existing_owners_ids.include?(user_id) + + Jobs.enqueue( + :mass_award_badge, + user: user_id, + badge: badge.id, + count: count + ) + end + + { + unmatched_entries: unmatched.to_a, + matched_users_count: count_per_user.size, + unmatched_entries_count: unmatched.size + } + end + + def self.mass_grant(badge, user, count:) + return if !badge.enabled? + + raise ArgumentError.new("count can't be less than 1") if count < 1 + + UserBadge.transaction do + DB.exec(<<~SQL * count, now: Time.zone.now, system: Discourse.system_user.id, user_id: user.id, badge_id: badge.id) + INSERT INTO user_badges + (granted_at, created_at, granted_by_id, user_id, badge_id, seq) + VALUES + ( + :now, + :now, + :system, + :user_id, + :badge_id, + COALESCE(( + SELECT MAX(seq) + 1 + FROM user_badges + WHERE badge_id = :badge_id AND user_id = :user_id + ), 0) + ); + SQL notification = send_notification(user.id, user.username, user.locale, badge) - DB.exec( - "UPDATE user_badges SET notification_id = :notification_id WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id", - notification_id: notification.id, - user_id: user.id, - badge_id: badge.id - ) + DB.exec(<<~SQL, notification_id: notification.id, user_id: user.id, badge_id: badge.id) + UPDATE user_badges + SET notification_id = :notification_id + WHERE notification_id IS NULL AND user_id = :user_id AND badge_id = :badge_id + SQL UserBadge.update_featured_ranks!(user.id) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 071d079388..ad549a9313 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5249,8 +5249,11 @@ en: perform: "Award Badge to Users" upload_csv: Upload a CSV with either user emails or usernames aborted: Please upload a CSV containing either user emails or usernames - success: Your CSV was received and users will receive their badge shortly. + success: Your CSV was received and %{count} users will receive their badge shortly. + csv_has_unmatched_users: "The following entries are in the CSV file but they couldn't be matched to existing users, and therefore won't receive the badge:" + csv_has_unmatched_users_truncated_list: "There were %{count} entries in the CSV file that couldn't be matched to existing users, and therefore won't receive the badge. Due to the large number of unmatched entries, only the first 100 are shown:" replace_owners: Remove the badge from previous owners + grant_existing_holders: Grant additional badges to existing badge holders emoji: title: "Emoji" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1633d234f4..3ac856726c 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -4449,7 +4449,9 @@ en: mass_award: errors: invalid_csv: We encountered an error on line %{line_number}. Please confirm the CSV has one email per line. + too_many_csv_entries: Too many entries in the CSV file. Please provide a CSV file with no more than %{count} entries. badge_disabled: Please enable the %{badge_name} badge first. + cant_grant_multiple_times: Can't grant the %{badge_name} badge multiple times to a single user. editor: name: Editor description: First post edit diff --git a/spec/jobs/mass_award_badge_spec.rb b/spec/jobs/mass_award_badge_spec.rb index 3fcff6faa9..15945e0866 100644 --- a/spec/jobs/mass_award_badge_spec.rb +++ b/spec/jobs/mass_award_badge_spec.rb @@ -9,22 +9,13 @@ describe Jobs::MassAwardBadge do let(:email_mode) { 'email' } it 'creates the badge for an existing user' do - execute_job([user.email]) + execute_job(user) expect(UserBadge.where(user: user, badge: badge).exists?).to eq(true) end - it 'works with multiple users' do - user_2 = Fabricate(:user) - - execute_job([user.email, user_2.email]) - - expect(UserBadge.exists?(user: user, badge: badge)).to eq(true) - expect(UserBadge.exists?(user: user_2, badge: badge)).to eq(true) - end - it 'also creates a notification for the user' do - execute_job([user.email]) + execute_job(user) expect(Notification.exists?(user: user)).to eq(true) expect(UserBadge.where.not(notification_id: nil).exists?(user: user, badge: badge)).to eq(true) @@ -35,14 +26,27 @@ describe Jobs::MassAwardBadge do UserBadge.create!(badge_id: Badge::Member, user: user, granted_by: Discourse.system_user, granted_at: Time.now) - execute_job([user.email, user_2.email]) + execute_job(user) + execute_job(user_2) expect(UserBadge.find_by(user: user, badge: badge).featured_rank).to eq(2) expect(UserBadge.find_by(user: user_2, badge: badge).featured_rank).to eq(1) end - def execute_job(emails) - subject.execute(users_batch: emails, badge_id: badge.id, mode: 'email') + it 'grants a badge multiple times to a user' do + badge.update!(multiple_grant: true) + Notification.destroy_all + execute_job(user, count: 4, grant_existing_holders: true) + instances = UserBadge.where(user: user, badge: badge) + expect(instances.count).to eq(4) + expect(instances.pluck(:seq).sort).to eq((0...4).to_a) + notifications = Notification.where(user: user) + expect(notifications.count).to eq(1) + expect(instances.map(&:notification_id).uniq).to contain_exactly(notifications.first.id) + end + + def execute_job(user, count: 1, grant_existing_holders: false) + subject.execute(user: user.id, badge: badge.id, count: count, grant_existing_holders: grant_existing_holders) end end end diff --git a/spec/requests/admin/badges_controller_spec.rb b/spec/requests/admin/badges_controller_spec.rb index b6d26dc3b2..9d9de65297 100644 --- a/spec/requests/admin/badges_controller_spec.rb +++ b/spec/requests/admin/badges_controller_spec.rb @@ -183,7 +183,7 @@ describe Admin::BadgesController do end describe '#mass_award' do - before { @user = Fabricate(:user, email: 'user1@test.com', username: 'username1') } + fab!(:user) { Fabricate(:user, email: 'user1@test.com', username: 'username1') } it 'does nothing when there is no file' do post "/admin/badges/award/#{badge.id}.json", params: { file: '' } @@ -210,9 +210,12 @@ describe Admin::BadgesController do file = file_from_fixtures('user_emails.csv', 'csv') + UserBadge.destroy_all post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) } - expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true) + expect(response.status).to eq(200) + expect(UserBadge.where(user: user, badge: badge).count).to eq(1) + expect(UserBadge.where(user: user, badge: badge).first.seq).to eq(0) end it 'awards the badge using a list of usernames' do @@ -222,7 +225,8 @@ describe Admin::BadgesController do post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) } - expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true) + expect(response.status).to eq(200) + expect(UserBadge.where(user: user, badge: badge).count).to eq(1) end it 'works with a CSV containing nil values' do @@ -232,7 +236,22 @@ describe Admin::BadgesController do post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) } - expect(UserBadge.exists?(user: @user, badge: badge)).to eq(true) + expect(response.status).to eq(200) + expect(UserBadge.where(user: user, badge: badge).count).to eq(1) + end + + it 'does not grant the badge again to a user if they already have the badge' do + Jobs.run_immediately! + badge.update!(multiple_grant: true) + BadgeGranter.grant(badge, user) + user.reload + + file = file_from_fixtures('usernames_with_nil_values.csv', 'csv') + + post "/admin/badges/award/#{badge.id}.json", params: { file: fixture_file_upload(file) } + + expect(response.status).to eq(200) + expect(UserBadge.where(user: user, badge: badge).count).to eq(1) end it 'fails when the badge is disabled' do @@ -244,6 +263,103 @@ describe Admin::BadgesController do expect(response.status).to eq(422) end + + context "when grant_existing_holders is true" do + it "fails when the badge cannot be granted multiple times" do + file = file_from_fixtures('user_emails.csv', 'csv') + badge.update!(multiple_grant: false) + post "/admin/badges/award/#{badge.id}.json", params: { + file: fixture_file_upload(file), + grant_existing_holders: true + } + + expect(response.status).to eq(422) + expect(response.parsed_body['errors']).to eq([ + I18n.t("badges.mass_award.errors.cant_grant_multiple_times", badge_name: badge.name) + ]) + end + + it "fails when CSV file contains more entries that it's allowed" do + badge.update!(multiple_grant: true) + csv = Tempfile.new + csv.write("#{user.username}\n" * 11) + csv.rewind + stub_const(Admin::BadgesController, "MAX_CSV_LINES", 10) do + post "/admin/badges/award/#{badge.id}.json", params: { + file: fixture_file_upload(csv), + grant_existing_holders: true + } + end + expect(response.status).to eq(400) + expect(response.parsed_body["errors"]).to include(I18n.t("badges.mass_award.errors.too_many_csv_entries", count: 10)) + ensure + csv&.close + csv&.unlink + end + + it "includes unmatched entries and the number of users who will receive the badge in the response" do + Jobs.run_immediately! + badge.update!(multiple_grant: true) + csv = Tempfile.new + content = [ + "nonexistentuser", + "nonexistentuser", + "nonexistentemail@discourse.fake" + ] + content << user.username + content << user.username + csv.write(content.join("\n")) + csv.rewind + post "/admin/badges/award/#{badge.id}.json", params: { + file: fixture_file_upload(csv), + grant_existing_holders: true + } + expect(response.status).to eq(200) + expect(response.parsed_body['unmatched_entries']).to contain_exactly( + "nonexistentuser", + "nonexistentemail@discourse.fake" + ) + expect(response.parsed_body['matched_users_count']).to eq(1) + expect(response.parsed_body['unmatched_entries_count']).to eq(2) + expect(UserBadge.where(user: user, badge: badge).count).to eq(2) + ensure + csv&.close + csv&.unlink + end + + it "grants the badge to the users in the CSV as many times as they appear in it" do + Jobs.run_immediately! + badge.update!(multiple_grant: true) + user_without_badge = Fabricate(:user) + user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) } + + csv_content = [ + user_with_badge.email.titlecase, + user_with_badge.username.titlecase, + user_without_badge.email.titlecase, + user_without_badge.username.titlecase + ] * 20 + + csv = Tempfile.new + csv.write(csv_content.join("\n")) + csv.rewind + post "/admin/badges/award/#{badge.id}.json", params: { + file: fixture_file_upload(csv), + grant_existing_holders: true + } + expect(response.status).to eq(200) + expect(response.parsed_body['unmatched_entries']).to eq([]) + expect(response.parsed_body['matched_users_count']).to eq(2) + expect(response.parsed_body['unmatched_entries_count']).to eq(0) + sequence = UserBadge.where(user: user_with_badge, badge: badge).pluck(:seq) + expect(sequence.sort).to eq((0...(40 + 1)).to_a) + sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq) + expect(sequence.sort).to eq((0...40).to_a) + ensure + csv&.close + csv&.unlink + end + end end end end diff --git a/spec/services/badge_granter_spec.rb b/spec/services/badge_granter_spec.rb index fea28b4b19..2b3fe90a8b 100644 --- a/spec/services/badge_granter_spec.rb +++ b/spec/services/badge_granter_spec.rb @@ -486,4 +486,112 @@ describe BadgeGranter do expect(BadgeGranter.notification_locale('pl_PL')).to eq('pl_PL') end end + + describe '.mass_grant' do + it 'raises an error if the count argument is less than 1' do + expect do + BadgeGranter.mass_grant(badge, user, count: 0) + end.to raise_error(ArgumentError, "count can't be less than 1") + end + + it 'grants the badge to the user as many times as the count argument' do + BadgeGranter.mass_grant(badge, user, count: 10) + sequence = UserBadge.where(badge: badge, user: user).pluck(:seq).sort + expect(sequence).to eq((0...10).to_a) + + BadgeGranter.mass_grant(badge, user, count: 10) + sequence = UserBadge.where(badge: badge, user: user).pluck(:seq).sort + expect(sequence).to eq((0...20).to_a) + end + end + + describe '.enqueue_mass_grant_for_users' do + before { Jobs.run_immediately! } + + it 'returns a list of the entries that could not be matched to any users' do + results = BadgeGranter.enqueue_mass_grant_for_users( + badge, + emails: ['fakeemail@discourse.invalid', user.email], + usernames: [user.username, 'fakeusername'], + ) + expect(results[:unmatched_entries]).to contain_exactly( + 'fakeemail@discourse.invalid', + 'fakeusername' + ) + expect(results[:matched_users_count]).to eq(1) + expect(results[:unmatched_entries_count]).to eq(2) + end + + context 'when ensure_users_have_badge_once is true' do + it 'ensures each user has the badge at least once and does not grant the badge multiple times to one user' do + BadgeGranter.grant(badge, user) + user_without_badge = Fabricate(:user) + + Notification.destroy_all + results = BadgeGranter.enqueue_mass_grant_for_users( + badge, + usernames: [ + user.username, + user.username, + user_without_badge.username, + user_without_badge.username + ], + ensure_users_have_badge_once: true + ) + expect(results[:unmatched_entries]).to eq([]) + expect(results[:matched_users_count]).to eq(2) + expect(results[:unmatched_entries_count]).to eq(0) + + sequence = UserBadge.where(user: user, badge: badge).pluck(:seq) + expect(sequence).to contain_exactly(0) + # no new badge/notification because user already had the badge + # before enqueue_mass_grant_for_users was called + expect(user.reload.notifications.size).to eq(0) + + sequence = UserBadge.where(user: user_without_badge, badge: badge) + expect(sequence.pluck(:seq)).to contain_exactly(0) + notifications = user_without_badge.reload.notifications + expect(notifications.size).to eq(1) + expect(sequence.first.notification_id).to eq(notifications.first.id) + expect(notifications.first.notification_type).to eq(Notification.types[:granted_badge]) + end + end + + context 'when ensure_users_have_badge_once is false' do + it 'grants the badge to the users as many times as they appear in the emails and usernames arguments' do + badge.update!(multiple_grant: true) + user_without_badge = Fabricate(:user) + user_with_badge = Fabricate(:user).tap { |u| BadgeGranter.grant(badge, u) } + + Notification.destroy_all + emails = [user_with_badge.email.titlecase, user_without_badge.email.titlecase] * 20 + usernames = [user_with_badge.username.titlecase, user_without_badge.username.titlecase] * 20 + + results = BadgeGranter.enqueue_mass_grant_for_users( + badge, + emails: emails, + usernames: usernames, + ensure_users_have_badge_once: false + ) + expect(results[:unmatched_entries]).to eq([]) + expect(results[:matched_users_count]).to eq(2) + expect(results[:unmatched_entries_count]).to eq(0) + + sequence = UserBadge.where(user: user_with_badge, badge: badge).pluck(:seq) + expect(sequence.size).to eq(40 + 1) + expect(sequence.sort).to eq((0...(40 + 1)).to_a) + sequence = UserBadge.where(user: user_without_badge, badge: badge).pluck(:seq) + expect(sequence.size).to eq(40) + expect(sequence.sort).to eq((0...40).to_a) + + # each user gets 1 notification no matter how many times + # they're repeated in the file. + [user_without_badge, user_with_badge].each do |u| + notifications = u.reload.notifications + expect(notifications.size).to eq(1) + expect(notifications.map(&:notification_type).uniq).to contain_exactly(Notification.types[:granted_badge]) + end + end + end + end end From 76fe3a16a9f347ad1535bdfcbc5a39d27a88f178 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 15 Jul 2021 11:11:41 +0530 Subject: [PATCH 394/403] DEV: trigger new discourse events `after_create_dev_record` & `after_populate_dev_records`. (#13733) After every new random record created using the `dev:populate` rake task a new Discourse event will be triggered. So the plugins can modify the records if needed. --- lib/discourse_dev/record.rb | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/discourse_dev/record.rb b/lib/discourse_dev/record.rb index b6196aebcf..5713178f64 100644 --- a/lib/discourse_dev/record.rb +++ b/lib/discourse_dev/record.rb @@ -18,18 +18,20 @@ module DiscourseDev end @model = model - @type = model.to_s + @type = model.to_s.downcase.to_sym @count = count end def create! record = model.create!(data) yield(record) if block_given? + DiscourseEvent.trigger(:after_create_dev_record, record, type) + record end def populate! if current_count >= @count - puts "Already have #{current_count} #{type.downcase} records" + puts "Already have #{current_count} #{type} records" Rake.application.top_level_tasks.each do |task_name| Rake::Task[task_name].reenable @@ -39,17 +41,20 @@ module DiscourseDev return elsif current_count > 0 @count -= current_count - puts "There are #{current_count} #{type.downcase} records. Creating #{@count} more." + puts "There are #{current_count} #{type} records. Creating #{@count} more." else - puts "Creating #{@count} sample #{type.downcase} records" + puts "Creating #{@count} sample #{type} records" end + records = [] @count.times do - create! + records << create! putc "." end + DiscourseEvent.trigger(:after_populate_dev_records, records, type) puts + records end def current_count From 4ce58fbf0b3eb87387e63b29f8f0355f55da4087 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 15 Jul 2021 07:15:57 +0100 Subject: [PATCH 395/403] DEV: Improve rake `release_note:generate` date handling (#13726) * DEV: Improve rake `release_note:generate` date handling A commit-ish value like HEAD@{2021-01-01} is based on the **local state** of HEAD on that date. It does not use dates attached to commits. Instead, the rake task now detects date-like strings and supplies them to `git log` via the `--after` and `--before` flags * Skip printing plugin when there are no changes found A list of skipped plugins is printed on a single line at the end of the output --- lib/tasks/release_note.rake | 108 ++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 41 deletions(-) diff --git a/lib/tasks/release_note.rake b/lib/tasks/release_note.rake index cf19078f6e..12d68c1e7f 100644 --- a/lib/tasks/release_note.rake +++ b/lib/tasks/release_note.rake @@ -1,48 +1,26 @@ # frozen_string_literal: true +DATE_REGEX = /\A\d{4}-\d{2}-\d{2}/ + +CHANGE_TYPES = [ + { pattern: /^FEATURE:/, heading: "New Features" }, + { pattern: /^FIX:/, heading: "Bug Fixes" }, + { pattern: /^UX:/, heading: "UX Changes" }, + { pattern: /^SECURITY:/, heading: "Security Changes" }, + { pattern: /^PERF:/, heading: "Performance" }, + { pattern: /^A11Y:/, heading: "Accessibility" }, +] + desc "generate a release note from the important commits" task "release_note:generate", :from, :to, :repo do |t, args| repo = args[:repo] || "." - from = args[:from] || `git -C #{repo} describe --tags --abbrev=0`.strip - to = args[:to] || "HEAD" + changes = find_changes(repo, args[:from], args[:to]) - bug_fixes = Set.new - new_features = Set.new - ux_changes = Set.new - sec_changes = Set.new - perf_changes = Set.new - a11y_changes = Set.new - - out = `git -C #{repo} log --pretty="tformat:%s" '#{from}..#{to}'` - next "Status #{$?.exitstatus} running git log\n#{out}" if !$?.success? - - out.each_line do |comment| - next if comment =~ /^\s*Revert/ - split_comments(comment).each do |line| - if line =~ /^FIX:/ - bug_fixes << better(line) - elsif line =~ /^FEATURE:/ - new_features << better(line) - elsif line =~ /^UX:/ - ux_changes << better(line) - elsif line =~ /^SECURITY:/ - sec_changes << better(line) - elsif line =~ /^PERF:/ - perf_changes << better(line) - elsif line =~ /^A11Y:/ - a11y_changes << better(line) - end - end + CHANGE_TYPES.each do |ct| + print_changes(ct[:heading], changes[ct]) end - print_changes("New Features", new_features) - print_changes("Bug Fixes", bug_fixes) - print_changes("UX Changes", ux_changes) - print_changes("Security Changes", sec_changes) - print_changes("Performance", perf_changes) - print_changes("Accessibility", a11y_changes) - - if [bug_fixes, new_features, ux_changes, sec_changes, perf_changes, a11y_changes].all?(&:empty?) + if changes.values.all?(&:empty?) puts "(no changes)", "" end end @@ -51,7 +29,7 @@ end # 1. Make sure you have a local, up-to-date clone of https://github.com/discourse/all-the-plugins # 2. In all-the-plugins, `git submodule update --init --recursive --remote` # 3. Change back to your discourse directory -# 4. rake "release_note:plugins:generate[ HEAD@{2021-06-01} , HEAD@{now} , /path/to/all-the-plugins/plugins/* , discourse ]" +# 4. rake "release_note:plugins:generate[ 2021-06-01 , 2021-07-01 , /path/to/all-the-plugins/plugins/* , discourse ]" desc "generate release notes for all official plugins in a directory" task "release_note:plugins:generate", :from, :to, :plugin_glob, :org do |t, args| from = args[:from] @@ -65,12 +43,60 @@ task "release_note:plugins:generate", :from, :to, :plugin_glob, :org do |t, args all_repos = all_repos.filter { |dir| `git -C #{dir} remote get-url origin`.match?(/github.com[\/:]#{git_org}\//) } end + no_changes_repos = [] + all_repos.each do |dir| - puts "## #{File.basename(dir)}\n\n" - Rake::Task["release_note:generate"].invoke(from, to, dir) - Rake::Task["release_note:generate"].reenable + name = File.basename(dir) + changes = find_changes(dir, from, to) + + if changes.values.all?(&:empty?) + no_changes_repos << name + next + end + + puts "## #{name}\n\n" + CHANGE_TYPES.each do |ct| + print_changes(ct[:heading], changes[ct]) + end puts "---", "" end + + puts "(No changes found in #{no_changes_repos.join(", ")})" +end + +def find_changes(repo, from, to) + dates = from&.match?(DATE_REGEX) || to&.match?(DATE_REGEX) + + if !dates + from ||= `git -C #{repo} describe --tags --abbrev=0`.strip + to ||= "HEAD" + end + + cmd = "git -C #{repo} log --pretty='tformat:%s' " + if dates + cmd += "--after '#{from}' " if from + cmd += "--before '#{to}' " if to + else + cmd += "#{from}..#{to}" + end + + out = `#{cmd}` + raise "Status #{$?.exitstatus} running git log\n#{out}" if !$?.success? + + changes = {} + CHANGE_TYPES.each do |ct| + changes[ct] = Set.new + end + + out.each_line do |comment| + next if comment =~ /^\s*Revert/ + split_comments(comment).each do |line| + ct = CHANGE_TYPES.find { |t| line =~ t[:pattern] } + changes[ct] << better(line) if ct + end + end + + changes end def print_changes(heading, changes) From 5cd447695e014d68ce3da58d45f42c8059b5438b Mon Sep 17 00:00:00 2001 From: Andrei Prigorshnev Date: Thu, 15 Jul 2021 14:51:44 +0400 Subject: [PATCH 396/403] FIX: problems with choosing favorite badges (#13731) --- app/controllers/user_badges_controller.rb | 5 ++++- spec/requests/user_badges_controller_spec.rb | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb index ae67a4cb42..60b1275461 100644 --- a/app/controllers/user_badges_controller.rb +++ b/app/controllers/user_badges_controller.rb @@ -109,10 +109,13 @@ class UserBadgesController < ApplicationController return render json: failed_json, status: 400 end + new_is_favorite_value = !user_badge.is_favorite UserBadge .where(user_id: user_badge.user_id, badge_id: user_badge.badge_id) - .update(is_favorite: !user_badge.is_favorite) + .update_all(is_favorite: new_is_favorite_value) UserBadge.update_featured_ranks!(user_badge.user_id) + + user_badge.is_favorite = new_is_favorite_value render_serialized(user_badge, DetailedUserBadgeSerializer, root: :user_badge) end diff --git a/spec/requests/user_badges_controller_spec.rb b/spec/requests/user_badges_controller_spec.rb index 42ea307fd2..4c17ac2763 100644 --- a/spec/requests/user_badges_controller_spec.rb +++ b/spec/requests/user_badges_controller_spec.rb @@ -294,20 +294,26 @@ describe UserBadgesController do it "favorites a badge" do sign_in(user) put "/user_badges/#{user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) + parsed = response.parsed_body + expect(parsed["user_badge"]["is_favorite"]).to eq(true) user_badge = UserBadge.find_by(user: user, badge: badge) - expect(user_badge.is_favorite).to be true + expect(user_badge.is_favorite).to eq(true) end it "unfavorites a badge" do sign_in(user) user_badge.toggle!(:is_favorite) put "/user_badges/#{user_badge.id}/toggle_favorite.json" + expect(response.status).to eq(200) + parsed = response.parsed_body + expect(parsed["user_badge"]["is_favorite"]).to eq(false) user_badge = UserBadge.find_by(user: user, badge: badge) - expect(user_badge.is_favorite).to be false + expect(user_badge.is_favorite).to eq(false) end it "works with multiple grants" do @@ -323,16 +329,22 @@ describe UserBadgesController do put "/user_badges/#{user_badge.id}/toggle_favorite.json" expect(response.status).to eq(200) + parsed = response.parsed_body + expect(parsed["user_badge"]["is_favorite"]).to eq(false) expect(user_badge.reload.is_favorite).to eq(false) expect(user_badge2.reload.is_favorite).to eq(false) put "/user_badges/#{user_badge.id}/toggle_favorite.json" expect(response.status).to eq(200) + parsed = response.parsed_body + expect(parsed["user_badge"]["is_favorite"]).to eq(true) expect(user_badge.reload.is_favorite).to eq(true) expect(user_badge2.reload.is_favorite).to eq(true) put "/user_badges/#{other_user_badge.id}/toggle_favorite.json" expect(response.status).to eq(200) + parsed = response.parsed_body + expect(parsed["user_badge"]["is_favorite"]).to eq(true) expect(other_user_badge.reload.is_favorite).to eq(true) end end From 74b37301438b3d31fa3a3215d9083c13fbd0d308 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 15 Jul 2021 17:45:32 +0530 Subject: [PATCH 397/403] DEV: return populated data at the end of the method. (#13739) And some minor refactoring. --- lib/discourse_dev/post.rb | 9 ++++++--- lib/discourse_dev/record.rb | 32 +++++++++++++++++--------------- lib/discourse_dev/topic.rb | 13 ++++++++----- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/lib/discourse_dev/post.rb b/lib/discourse_dev/post.rb index 74d9bb85fe..76c895406b 100644 --- a/lib/discourse_dev/post.rb +++ b/lib/discourse_dev/post.rb @@ -38,6 +38,7 @@ module DiscourseDev post = PostCreator.new(user, data).create! topic.reload generate_likes(post) + post end def generate_likes(post) @@ -63,9 +64,11 @@ module DiscourseDev def populate! generate_likes(topic.first_post) - @count.times do - create! - end + super(ignore_current_count: true) + end + + def current_count + topic.posts_count - 1 end def self.add_replies!(args) diff --git a/lib/discourse_dev/record.rb b/lib/discourse_dev/record.rb index 5713178f64..b8b07d25ff 100644 --- a/lib/discourse_dev/record.rb +++ b/lib/discourse_dev/record.rb @@ -29,31 +29,33 @@ module DiscourseDev record end - def populate! - if current_count >= @count - puts "Already have #{current_count} #{type} records" + def populate!(ignore_current_count: false) + unless ignore_current_count + if current_count >= @count + puts "Already have #{current_count} #{type} records" - Rake.application.top_level_tasks.each do |task_name| - Rake::Task[task_name].reenable + Rake.application.top_level_tasks.each do |task_name| + Rake::Task[task_name].reenable + end + + Rake::Task['dev:repopulate'].invoke + return + elsif current_count > 0 + @count -= current_count + puts "There are #{current_count} #{type} records. Creating #{@count} more." + else + puts "Creating #{@count} sample #{type} records" end - - Rake::Task['dev:repopulate'].invoke - return - elsif current_count > 0 - @count -= current_count - puts "There are #{current_count} #{type} records. Creating #{@count} more." - else - puts "Creating #{@count} sample #{type} records" end records = [] @count.times do records << create! - putc "." + putc "." unless type == :post end + puts unless type == :post DiscourseEvent.trigger(:after_populate_dev_records, records, type) - puts records end diff --git a/lib/discourse_dev/topic.rb b/lib/discourse_dev/topic.rb index 64bc86fd26..b15d7fc85f 100644 --- a/lib/discourse_dev/topic.rb +++ b/lib/discourse_dev/topic.rb @@ -67,21 +67,24 @@ module DiscourseDev def create! @category = Category.random user = self.user - topic = Faker::DiscourseMarkdown.with_user(user.id) { data } - post = PostCreator.new(user, topic).create! + topic_data = Faker::DiscourseMarkdown.with_user(user.id) { data } + post = PostCreator.new(user, topic_data).create! - if override = @settings.dig(:replies, :overrides).find { |o| o[:title] == topic[:title] } + if override = @settings.dig(:replies, :overrides).find { |o| o[:title] == topic_data[:title] } reply_count = override[:count] else reply_count = Faker::Number.between(from: @settings.dig(:replies, :min), to: @settings.dig(:replies, :max)) end - Post.new(post.topic, reply_count).populate! + topic = post.topic + Post.new(topic, reply_count).populate! + topic end def populate! - super + topics = super delete_unwanted_sidekiq_jobs + topics end def user From ebce983a263def15f0a3f8abbfeafb3d7d765498 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 15 Jul 2021 21:11:59 +0800 Subject: [PATCH 398/403] DEV: pass more arguments to before-create-topic-button (#13740) This commit passes a few more arguments to the `before-create-topic-button` outlet. We need these arguments to avoid template overrides in themes. --- .../discourse/app/templates/components/d-navigation.hbs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs b/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs index 7d3abc4e98..3578c63a80 100644 --- a/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/d-navigation.hbs @@ -39,7 +39,10 @@ args=(hash canCreateTopic=canCreateTopic createTopicDisabled=createTopicDisabled - createTopicLabel=createTopicLabel) + createTopicLabel=createTopicLabel + additionalTags=additionalTags + category=category + tag=tag) }} {{create-topic-button From d6fc39c8860f86410b2a20702fa0bac4f37b8978 Mon Sep 17 00:00:00 2001 From: Vinoth Kannan Date: Thu, 15 Jul 2021 19:53:57 +0530 Subject: [PATCH 399/403] FEATURE: update existing users when group default notifications changed. (#13434) Currently, the changes will only affect the users added after. --- .../modals/site-setting-default-categories.js | 19 +- .../components/group-manage-save-button.js | 29 ++- .../group-default-notifications.js | 5 + .../app/mixins/modal-update-existing-users.js | 19 ++ .../javascripts/discourse/app/models/group.js | 4 +- .../modal/group-default-notifications.hbs | 8 + app/controllers/groups_controller.rb | 165 ++++++++++++++++++ config/locales/client.en.yml | 5 + spec/requests/groups_controller_spec.rb | 78 ++++++++- 9 files changed, 308 insertions(+), 24 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/controllers/group-default-notifications.js create mode 100644 app/assets/javascripts/discourse/app/mixins/modal-update-existing-users.js create mode 100644 app/assets/javascripts/discourse/app/templates/modal/group-default-notifications.hbs diff --git a/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js b/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js index c46fe08b7b..8c3a264898 100644 --- a/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js +++ b/app/assets/javascripts/admin/addon/controllers/modals/site-setting-default-categories.js @@ -1,20 +1,5 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ModalUpdateExistingUsers from "discourse/mixins/modal-update-existing-users"; -export default Controller.extend(ModalFunctionality, { - onShow() { - this.set("updateExistingUsers", null); - }, - - actions: { - updateExistingUsers() { - this.set("updateExistingUsers", true); - this.send("closeModal"); - }, - - cancel() { - this.set("updateExistingUsers", false); - this.send("closeModal"); - }, - }, -}); +export default Controller.extend(ModalFunctionality, ModalUpdateExistingUsers); diff --git a/app/assets/javascripts/discourse/app/components/group-manage-save-button.js b/app/assets/javascripts/discourse/app/components/group-manage-save-button.js index 82cc182b7d..3c7e17d634 100644 --- a/app/assets/javascripts/discourse/app/components/group-manage-save-button.js +++ b/app/assets/javascripts/discourse/app/components/group-manage-save-button.js @@ -4,10 +4,12 @@ import I18n from "I18n"; import discourseComputed from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAutomaticMembershipAlert } from "discourse/controllers/groups-new"; +import showModal from "discourse/lib/show-modal"; export default Component.extend({ saving: null, disabled: false, + updateExistingUsers: null, @discourseComputed("saving") savingText(saving) { @@ -28,14 +30,37 @@ export default Component.extend({ group.automatic_membership_email_domains ); + const opts = {}; + if (this.updateExistingUsers !== null) { + opts.update_existing_users = this.updateExistingUsers; + } + return group - .save() + .save(opts) .then((data) => { + if (data.user_count) { + const controller = showModal("group-default-notifications", { + model: { + count: data.user_count, + }, + }); + + controller.set("onClose", () => { + this.updateExistingUsers = controller.updateExistingUsers; + this.send("save"); + }); + + return; + } + if (data.route_to) { DiscourseURL.routeTo(data.route_to); } - this.set("saved", true); + this.setProperties({ + saved: true, + updateExistingUsers: null, + }); if (this.afterSave) { this.afterSave(); diff --git a/app/assets/javascripts/discourse/app/controllers/group-default-notifications.js b/app/assets/javascripts/discourse/app/controllers/group-default-notifications.js new file mode 100644 index 0000000000..8c3a264898 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/group-default-notifications.js @@ -0,0 +1,5 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import ModalUpdateExistingUsers from "discourse/mixins/modal-update-existing-users"; + +export default Controller.extend(ModalFunctionality, ModalUpdateExistingUsers); diff --git a/app/assets/javascripts/discourse/app/mixins/modal-update-existing-users.js b/app/assets/javascripts/discourse/app/mixins/modal-update-existing-users.js new file mode 100644 index 0000000000..699061545f --- /dev/null +++ b/app/assets/javascripts/discourse/app/mixins/modal-update-existing-users.js @@ -0,0 +1,19 @@ +import Mixin from "@ember/object/mixin"; + +export default Mixin.create({ + onShow() { + this.set("updateExistingUsers", null); + }, + + actions: { + updateExistingUsers() { + this.set("updateExistingUsers", true); + this.send("closeModal"); + }, + + cancel() { + this.set("updateExistingUsers", false); + this.send("closeModal"); + }, + }, +}); diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index 23a8e54f26..5fcbf4618b 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -292,10 +292,10 @@ const Group = RestModel.extend({ }); }, - save() { + save(opts = {}) { return ajax(`/groups/${this.id}`, { type: "PUT", - data: { group: this.asJSON() }, + data: Object.assign({ group: this.asJSON() }, opts), }); }, diff --git a/app/assets/javascripts/discourse/app/templates/modal/group-default-notifications.hbs b/app/assets/javascripts/discourse/app/templates/modal/group-default-notifications.hbs new file mode 100644 index 0000000000..1bb030c842 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/group-default-notifications.hbs @@ -0,0 +1,8 @@ +{{#d-modal-body title="groups.default_notifications.modal_title"}} + {{i18n "groups.default_notifications.modal_description" count=model.count}} +{{/d-modal-body}} + +
          + {{d-button action=(action "updateExistingUsers") class="btn-primary" label="groups.default_notifications.modal_yes"}} + {{d-button action=(action "cancel") label="groups.default_notifications.modal_no"}} +
          diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 14687f789a..ec07ac68e0 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -155,10 +155,25 @@ class GroupsController < ApplicationController params_with_permitted = group_params(automatic: group.automatic) clear_disabled_email_settings(group, params_with_permitted) + categories, tags = [] + if !group.automatic || current_user.admin + categories, tags = user_default_notifications(group, params_with_permitted) + + if params[:update_existing_users].blank? + user_count = count_existing_users(group.group_users, categories, tags) + + if user_count > 0 + render json: { user_count: user_count } + return + end + end + end + if group.update(params_with_permitted) GroupActionLogger.new(current_user, group, skip_guardian: true).log_change_group_settings group.record_email_setting_changes!(current_user) group.expire_imap_mailbox_cache + update_existing_users(group.group_users, categories, tags) if categories.present? || tags.present? if guardian.can_see?(group) render json: success_json @@ -757,4 +772,154 @@ class GroupsController < ApplicationController params_with_permitted[:email_password] = nil end end + + def user_default_notifications(group, params) + category_notifications = group.group_category_notification_defaults.pluck(:category_id, :notification_level).to_h + tag_notifications = group.group_tag_notification_defaults.pluck(:tag_id, :notification_level).to_h + categories = {} + tags = {} + + NotificationLevels.all.each do |key, value| + category_ids = (params["#{key}_category_ids".to_sym] || []) - ["-1"] + + category_ids.each do |category_id| + category_id = category_id.to_i + old_value = category_notifications[category_id] + + metadata = { + old_value: old_value, + new_value: value + } + + if old_value.blank? + metadata[:action] = :create + elsif old_value == value + category_notifications.delete(category_id) + next + else + metadata[:action] = :update + end + + categories[category_id] = metadata + end + + tag_names = (params["#{key}_tags".to_sym] || []) - ["-1"] + tag_ids = Tag.where(name: tag_names).pluck(:id) + + tag_ids.each do |tag_id| + old_value = tag_notifications[tag_id] + + metadata = { + old_value: old_value, + new_value: value + } + + if old_value.blank? + metadata[:action] = :create + elsif old_value == value + tag_notifications.delete(tag_id) + next + else + metadata[:action] = :update + end + + tags[tag_id] = metadata + end + end + + (category_notifications.keys - categories.keys).each do |category_id| + categories[category_id] = { action: :delete, old_value: category_notifications[category_id] } + end + + (tag_notifications.keys - tags.keys).each do |tag_id| + tags[tag_id] = { action: :delete, old_value: tag_notifications[tag_id] } + end + + [categories, tags] + end + + %i{ + count + update + }.each do |action| + define_method("#{action}_existing_users") do |group_users, categories, tags| + return 0 if categories.blank? && tags.blank? + + ids = [] + + categories.each do |category_id, data| + if data[:action] == :update || data[:action] == :delete + category_users = CategoryUser.where(category_id: category_id, notification_level: data[:old_value], user_id: group_users.select(:user_id)) + + if action == :update + category_users.delete_all + else + ids += category_users.pluck(:user_id) + end + + categories.delete(category_id) if data[:action] == :delete && action == :update + end + end + + tags.each do |tag_id, data| + if data[:action] == :update || data[:action] == :delete + tag_users = TagUser.where(tag_id: tag_id, notification_level: data[:old_value], user_id: group_users.select(:user_id)) + + if action == :update + tag_users.delete_all + else + ids += tag_users.pluck(:user_id) + end + + tags.delete(tag_id) if data[:action] == :delete && action == :update + end + end + + if categories.present? || tags.present? + group_users.select(:id, :user_id).find_in_batches do |batch| + user_ids = batch.pluck(:user_id) + + categories.each do |category_id, data| + category_users = [] + existing_users = CategoryUser.where(category_id: category_id, user_id: user_ids).where("notification_level IS NOT NULL") + skip_user_ids = existing_users.pluck(:user_id) + + batch.each do |group_user| + next if skip_user_ids.include?(group_user.user_id) + category_users << { category_id: category_id, user_id: group_user.user_id, notification_level: data[:new_value] } + end + + next if category_users.blank? + + if action == :update + CategoryUser.insert_all!(category_users) + else + ids += category_users.pluck(:user_id) + end + end + + tags.each do |tag_id, data| + tag_users = [] + existing_users = TagUser.where(tag_id: tag_id, user_id: user_ids).where("notification_level IS NOT NULL") + skip_user_ids = existing_users.pluck(:user_id) + + batch.each do |group_user| + next if skip_user_ids.include?(group_user.user_id) + tag_users << { tag_id: tag_id, user_id: group_user.user_id, notification_level: data[:new_value], created_at: Time.now, updated_at: Time.now } + end + + next if tag_users.blank? + + if action == :update + TagUser.insert_all!(tag_users) + else + ids += tag_users.pluck(:user_id) + end + end + end + end + + ids.uniq.count + end + end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ad549a9313..6ffc48acc9 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -892,6 +892,11 @@ en: flair_type: icon: "Select an icon" image: "Upload an image" + default_notifications: + modal_title: "User default notifications" + modal_description: "Would you like to apply this change historically? This will change preferences for %{count} existing users." + modal_yes: "Yes" + modal_no: "No, only apply change going forward" user_action_groups: "1": "Likes" diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index 4b0e22a268..b0e31de1d8 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -714,7 +714,8 @@ describe GroupsController do name: 'testing', tracking_category_ids: [category.id], tracking_tags: [tag.name] - } + }, + update_existing_users: false } end.to change { GroupHistory.count }.by(13) @@ -783,7 +784,8 @@ describe GroupsController do members_visibility_level: 3, tracking_category_ids: [category.id], tracking_tags: [tag.name] - } + }, + update_existing_users: false } expect(response.status).to eq(200) @@ -852,6 +854,75 @@ describe GroupsController do expect(event[:event_name]).to eq(:group_updated) expect(event[:params].first).to eq(group) end + + context "user default notifications" do + it "should update default notification preference for existing users" do + user1 = Fabricate(:user) + user2 = Fabricate(:user) + CategoryUser.create!(user: user1, category: category, notification_level: 4) + TagUser.create!(user: user1, tag: tag, notification_level: 4) + TagUser.create!(user: user2, tag: tag, notification_level: 4) + group.add(user1) + group.add(user2) + + put "/groups/#{group.id}.json", params: { + group: { + flair_color: 'BBB', + name: 'testing', + incoming_email: 'test@mail.org', + primary_group: true, + automatic_membership_email_domains: 'test.org', + grant_trust_level: 2, + visibility_level: 1, + members_visibility_level: 3, + tracking_category_ids: [category.id], + tracking_tags: [tag.name] + } + } + + expect(response.status).to eq(200) + expect(response.parsed_body["user_count"]).to eq(group.group_users.count - 1) + + put "/groups/#{group.id}.json", params: { + group: { + flair_color: 'BBB', + name: 'testing', + incoming_email: 'test@mail.org', + primary_group: true, + automatic_membership_email_domains: 'test.org', + grant_trust_level: 2, + visibility_level: 1, + members_visibility_level: 3, + tracking_category_ids: [category.id], + tracking_tags: [tag.name] + }, + update_existing_users: true + } + + expect(response.status).to eq(200) + expect(response.parsed_body["success"]).to eq("OK") + + put "/groups/#{group.id}.json", params: { + group: { + flair_color: 'BBB', + name: 'testing', + incoming_email: 'test@mail.org', + primary_group: true, + automatic_membership_email_domains: 'test.org', + grant_trust_level: 2, + visibility_level: 1, + members_visibility_level: 3, + watching_category_ids: [category.id], + tracking_tags: [tag.name] + }, + update_existing_users: true + } + + expect(response.status).to eq(200) + expect(response.parsed_body["success"]).to eq("OK") + expect(CategoryUser.exists?(user: user2, category: category, notification_level: 3)).to be_truthy + end + end end context "when user is a site moderator" do @@ -889,7 +960,8 @@ describe GroupsController do members_visibility_level: 3, tracking_category_ids: [category.id], tracking_tags: [tag.name] - } + }, + update_existing_users: false } expect(response.status).to eq(200) From a23153fdca00e315dd328fafa32697b2abaa27e0 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 15 Jul 2021 12:51:46 -0400 Subject: [PATCH 400/403] FIX: Add order to outputted stylesheet link tags (#13735) See PR for details. (Disabled by default in this commit.) --- config/site_settings.yml | 3 + lib/stylesheet/manager.rb | 54 ++++++---------- spec/components/stylesheet/manager_spec.rb | 71 +++++++++++++++++++++- 3 files changed, 92 insertions(+), 36 deletions(-) diff --git a/config/site_settings.yml b/config/site_settings.yml index 45f5d2a0f5..c9b83f777c 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -353,6 +353,9 @@ basic: default: "arial" enum: "BaseFontSetting" refresh: true + order_stylesheets: + default: false + hidden: true login: invite_only: diff --git a/lib/stylesheet/manager.rb b/lib/stylesheet/manager.rb index ea7fe6be2c..20e0577191 100644 --- a/lib/stylesheet/manager.rb +++ b/lib/stylesheet/manager.rb @@ -188,12 +188,13 @@ class Stylesheet::Manager def stylesheet_link_tag(target = :desktop, media = 'all') stylesheets = stylesheet_details(target, media) - stylesheets.map do |stylesheet| href = stylesheet[:new_href] theme_id = stylesheet[:theme_id] data_theme_id = theme_id ? "data-theme-id=\"#{theme_id}\"" : "" - %[] + theme_name = stylesheet[:theme_name] + data_theme_name = theme_name ? "data-theme-name=\"#{theme_name}\"" : "" + %[] end.join("\n").html_safe end @@ -211,54 +212,37 @@ class Stylesheet::Manager @@lock.synchronize do stylesheets = [] - stale_theme_ids = [] - theme_ids = is_theme_target ? @theme_ids : [nil] - - theme_ids.each do |theme_id| - cache_key = "path_#{target}_#{theme_id}_#{current_hostname}" - - if href = cache[cache_key] - stylesheets << { - target: target, - theme_id: theme_id, - new_href: href - } - else - stale_theme_ids << theme_id - end - end - - scss_checker = ScssChecker.new(target, stale_theme_ids) if is_theme_target - themes = load_themes(stale_theme_ids) - + scss_checker = ScssChecker.new(target, @theme_ids) + themes = load_themes(@theme_ids) themes.each do |theme| theme_id = theme&.id - data = { target: target, theme_id: theme_id } + data = { target: target, theme_id: theme_id, theme_name: theme&.name.downcase, remote: theme.remote_theme_id? } builder = Builder.new(target: target, theme: theme, manager: self) - is_theme = builder.is_theme? - has_theme = builder.theme.present? - if is_theme && !has_theme - next - else - next if is_theme && builder.theme&.component && !scss_checker.has_scss(theme_id) - builder.compile unless File.exists?(builder.stylesheet_fullpath) - href = builder.stylesheet_path(current_hostname) - cache.defer_set("path_#{target}_#{theme_id}_#{current_hostname}", href) - end + next if builder.theme&.component && !scss_checker.has_scss(theme_id) + builder.compile unless File.exists?(builder.stylesheet_fullpath) + href = builder.stylesheet_path(current_hostname) data[:new_href] = href stylesheets << data end + + if SiteSetting.order_stylesheets && stylesheets.size > 1 + stylesheets = stylesheets.sort_by do |s| + [ + s[:remote] ? 0 : 1, + s[:theme_id] == @theme_id ? 1 : 0, + s[:theme_name] + ] + end + end else builder = Builder.new(target: target, manager: self) builder.compile unless File.exists?(builder.stylesheet_fullpath) href = builder.stylesheet_path(current_hostname) - cache.defer_set("path_#{target}__#{current_hostname}", href) - data = { target: target, new_href: href } stylesheets << data end diff --git a/spec/components/stylesheet/manager_spec.rb b/spec/components/stylesheet/manager_spec.rb index 7cc2639650..8100893a37 100644 --- a/spec/components/stylesheet/manager_spec.rb +++ b/spec/components/stylesheet/manager_spec.rb @@ -20,7 +20,7 @@ describe Stylesheet::Manager do end context "themes with components" do - let(:child_theme) { Fabricate(:theme, component: true).tap { |c| + let(:child_theme) { Fabricate(:theme, component: true, name: "a component").tap { |c| c.set_field(target: :common, name: "scss", value: ".child_common{.scss{color: red;}}") c.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}") c.set_field(target: :mobile, name: "scss", value: ".child_mobile{.scss{color: red;}}") @@ -135,6 +135,75 @@ describe Stylesheet::Manager do ) end + context "stylesheet order" do + let(:z_child_theme) do + Fabricate(:theme, component: true, name: "ze component").tap do |z| + z.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}") + z.save! + end + end + + let(:remote) { RemoteTheme.create!(remote_url: "https://github.com/org/remote-theme1") } + + let(:child_remote) do + Fabricate(:theme, remote_theme: remote, component: true).tap do |t| + t.set_field(target: :desktop, name: "scss", value: ".child_desktop{.scss{color: red;}}") + t.save! + end + end + + before do + SiteSetting.order_stylesheets = true + end + + it 'output remote child, then sort children alphabetically, then local parent' do + theme.add_relative_theme!(:child, z_child_theme) + theme.add_relative_theme!(:child, child_remote) + + manager = manager(theme.id) + hrefs = manager.stylesheet_details(:desktop_theme, 'all') + + parent = hrefs.select { |href| href[:theme_id] == theme.id }.first + child_a = hrefs.select { |href| href[:theme_id] == child_theme.id }.first + child_z = hrefs.select { |href| href[:theme_id] == z_child_theme.id }.first + child_r = hrefs.select { |href| href[:theme_id] == child_remote.id }.first + + child_local_A = "" + child_local_Z = "" + child_remote_R = "" + parent_local = "" + + link_hrefs = manager.stylesheet_link_tag(:desktop_theme).gsub('media="all" rel="stylesheet" data-target="desktop_theme" ', '') + + expect(link_hrefs).to eq([child_remote_R, child_local_A, child_local_Z, parent_local].join("\n").html_safe) + end + + it "output remote child, remote parent, local child" do + remote2 = RemoteTheme.create!(remote_url: "https://github.com/org/remote-theme2") + remote_main_theme = Fabricate(:theme, remote_theme: remote2, name: "remote main").tap do |t| + t.set_field(target: :desktop, name: "scss", value: ".el{color: red;}") + t.save! + end + + remote_main_theme.add_relative_theme!(:child, z_child_theme) + remote_main_theme.add_relative_theme!(:child, child_remote) + + manager = manager(remote_main_theme.id) + hrefs = manager.stylesheet_details(:desktop_theme, 'all') + + parent_r = hrefs.select { |href| href[:theme_id] == remote_main_theme.id }.first + child_z = hrefs.select { |href| href[:theme_id] == z_child_theme.id }.first + child_r = hrefs.select { |href| href[:theme_id] == child_remote.id }.first + + parent_remote = "" + child_local = "" + child_remote = "" + + link_hrefs = manager.stylesheet_link_tag(:desktop_theme).gsub('media="all" rel="stylesheet" data-target="desktop_theme" ', '') + expect(link_hrefs).to eq([child_remote, parent_remote, child_local].join("\n").html_safe) + end + end + it 'outputs tags for non-theme targets for theme component' do child_theme = Fabricate(:theme, component: true) From 55bed489177c28c839a173980252572f75e49be7 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Thu, 15 Jul 2021 12:52:40 -0400 Subject: [PATCH 401/403] DEV: Remove stylesheet controller non-prod code (#13745) --- app/controllers/stylesheets_controller.rb | 25 +---------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/app/controllers/stylesheets_controller.rb b/app/controllers/stylesheets_controller.rb index df53cf5803..fbcef46462 100644 --- a/app/controllers/stylesheets_controller.rb +++ b/app/controllers/stylesheets_controller.rb @@ -23,40 +23,17 @@ class StylesheetsController < ApplicationController stylesheet = manager.color_scheme_stylesheet_details(params[:id], 'all') render json: stylesheet end + protected def show_resource(source_map: false) extension = source_map ? ".css.map" : ".css" - params[:name] - no_cookies target, digest = params[:name].split(/_([a-f0-9]{40})/) - if !Rails.env.production? - # TODO add theme - # calling this method ensures we have a cache for said target - # we hold off re-compilation till someone asks for asset - if target.include?("color_definitions") - split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - - Stylesheet::Manager.new.color_scheme_stylesheet_link_tag(color_scheme_id) - else - theme_id = - if target.include?("theme") - split_target, theme_id = target.split(/_(-?[0-9]+)/) - theme_id if theme_id.present? && Theme.exists?(id: theme_id) - else - split_target, color_scheme_id = target.split(/_(-?[0-9]+)/) - Theme.where(color_scheme_id: color_scheme_id).pluck_first(:id) - end - - Stylesheet::Manager.new(theme_id: theme_id).stylesheet_link_tag(split_target, nil) - end - end - cache_time = request.env["HTTP_IF_MODIFIED_SINCE"] if cache_time From 8b89787426169af9c89e73caeb0434a27c36f32a Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 15 Jul 2021 19:31:50 +0100 Subject: [PATCH 402/403] SECURITY: Sanitize YouTube Onebox data (#13748) CVE-2021-32764 --- lib/onebox/engine/youtube_onebox.rb | 16 +++++++++++----- .../initializers/{lazyYT.js.es6 => lazyYT.js} | 2 ++ .../assets/javascripts/{ => lib}/lazyYT.js | 16 +++++++++------- plugins/lazy-yt/plugin.rb | 4 +--- spec/lib/onebox/engine/youtube_onebox_spec.rb | 11 +++++++++++ 5 files changed, 34 insertions(+), 15 deletions(-) rename plugins/lazy-yt/assets/javascripts/initializers/{lazyYT.js.es6 => lazyYT.js} (92%) rename plugins/lazy-yt/assets/javascripts/{ => lib}/lazyYT.js (96%) diff --git a/lib/onebox/engine/youtube_onebox.rb b/lib/onebox/engine/youtube_onebox.rb index 81f822ce0b..237a29e81b 100644 --- a/lib/onebox/engine/youtube_onebox.rb +++ b/lib/onebox/engine/youtube_onebox.rb @@ -89,25 +89,31 @@ module Onebox def video_id @video_id ||= begin + id = nil + # http://youtu.be/afyK1HSFfgw if uri.host["youtu.be"] id = uri.path[/\/([\w\-]+)/, 1] - return id if id end # https://www.youtube.com/embed/vsF0K3Ou1v0 if uri.path["/embed/"] - id = uri.path[/\/embed\/([\w\-]+)/, 1] - return id if id + id ||= uri.path[/\/embed\/([\w\-]+)/, 1] end # https://www.youtube.com/watch?v=Z0UISCEe52Y - params['v'] + id ||= params['v'] + + sanitize_yt_id(id) end end def list_id - @list_id ||= params['list'] + @list_id ||= sanitize_yt_id(params['list']) + end + + def sanitize_yt_id(raw) + raw&.match?(/\A[\w-]+\z/) ? raw : nil end def embed_params diff --git a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 b/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js similarity index 92% rename from plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 rename to plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js index 16c63d01d2..cbc4df3c68 100644 --- a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 +++ b/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js @@ -1,9 +1,11 @@ import { withPluginApi } from "discourse/lib/plugin-api"; +import initLazyYt from "../lib/lazyYT"; export default { name: "apply-lazyYT", initialize() { withPluginApi("0.1", (api) => { + initLazyYt($); api.decorateCooked( ($elem) => { const iframes = $(".lazyYT", $elem); diff --git a/plugins/lazy-yt/assets/javascripts/lazyYT.js b/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js similarity index 96% rename from plugins/lazy-yt/assets/javascripts/lazyYT.js rename to plugins/lazy-yt/assets/javascripts/lib/lazyYT.js index 8078671868..b6f3ddf669 100644 --- a/plugins/lazy-yt/assets/javascripts/lazyYT.js +++ b/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js @@ -11,7 +11,9 @@ * */ -(function ($) { +import escape from "discourse-common/lib/escape"; + +export default function initLazyYt($) { "use strict"; function setUp($el, settings) { @@ -75,13 +77,13 @@ innerHtml.push('
          '); innerHtml.push( '' ); if (title === undefined || title === null || title === "") { - innerHtml.push("youtube.com/watch?v=" + id); + innerHtml.push("youtube.com/watch?v=" + escape(id)); } else { - innerHtml.push(title); + innerHtml.push(escape(title)); } innerHtml.push(""); innerHtml.push("
          "); // .html5-title @@ -121,7 +123,7 @@ $( [ '', @@ -143,7 +145,7 @@ $el .html( '' @@ -170,4 +172,4 @@ setUp($el, settings); }); }; -})(jQuery); +} diff --git a/plugins/lazy-yt/plugin.rb b/plugins/lazy-yt/plugin.rb index 04d14d413e..bdc3ea4a03 100644 --- a/plugins/lazy-yt/plugin.rb +++ b/plugins/lazy-yt/plugin.rb @@ -5,14 +5,12 @@ # version: 1.0.1 # authors: Arpit Jalan # url: https://github.com/discourse/discourse/tree/master/plugins/lazy-yt +# transpile_js: true hide_plugin if self.respond_to?(:hide_plugin) require "onebox" -# javascript -register_asset "javascripts/lazyYT.js" - # stylesheet register_asset "stylesheets/lazyYT.css" register_asset "stylesheets/lazyYT_mobile.scss", :mobile diff --git a/spec/lib/onebox/engine/youtube_onebox_spec.rb b/spec/lib/onebox/engine/youtube_onebox_spec.rb index 94171e01c5..d99e1a33c4 100644 --- a/spec/lib/onebox/engine/youtube_onebox_spec.rb +++ b/spec/lib/onebox/engine/youtube_onebox_spec.rb @@ -65,6 +65,17 @@ describe Onebox::Engine::YoutubeOnebox do expect(Onebox.preview('https://www.youtube.com/watch?v=21Lk4YiASMo&potential[]=exploit&potential[]=fun').to_s).not_to match(/potential|exploit|fun/) end + it "ignores video_id with unacceptable characters" do + # (falls back to generic onebox) + Onebox::Engine::AllowlistedGenericOnebox.any_instance.stubs(:to_html).returns(+"allowlisted_html") + expect(Onebox.preview('https://www.youtube.com/watch?v=%3C%3E21Lk4YiASMo').to_s).to eq("allowlisted_html") + end + + it "ignores list_id with unacceptable characters" do + # (falls back to video-only onebox) + expect(Onebox.preview('https://www.youtube.com/watch?v=21Lk4YiASMo&list=%3C%3EUUQau-O2C0kGJpR3_CHBTGbw').to_s).not_to include("UUQau-O2C0kGJpR3_CHBTGbw") + end + it "converts time strings into a &start= parameter" do expect(Onebox.preview('https://www.youtube.com/watch?v=21Lk4YiASMo&start=3782').to_s).to match(/start=3782/) expect(Onebox.preview('https://www.youtube.com/watch?start=1h3m2s&v=21Lk4YiASMo').to_s).to match(/start=3782/) From 5f8fa976d45c9e00a2a289cc18593e1af110783e Mon Sep 17 00:00:00 2001 From: Neil Lalonde Date: Thu, 15 Jul 2021 14:54:02 -0400 Subject: [PATCH 403/403] Version bump to v2.8.0.beta3 (#13703) --- lib/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/version.rb b/lib/version.rb index 359f0a4bcd..88a32aa503 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -10,7 +10,7 @@ module Discourse MAJOR = 2 MINOR = 8 TINY = 0 - PRE = 'beta2' + PRE = 'beta3' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end