From d2e9ea6193da8339166dd179a2ffcceea5a56f05 Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Wed, 11 Jan 2023 16:43:18 +0800 Subject: [PATCH 001/169] FEATURE: Allow group owners promote more owners (#19768) This change allows group owners (in addition to admins) to promote other members to owners. --- .../app/components/group-member-dropdown.js | 9 + .../discourse/app/controllers/group-index.js | 6 +- .../javascripts/discourse/app/models/group.js | 4 +- .../discourse/app/templates/group-index.hbs | 2 + .../app/templates/mobile/group-index.hbs | 1 + .../tests/acceptance/group-index-test.js | 2 +- app/controllers/admin/groups_controller.rb | 29 ---- app/controllers/groups_controller.rb | 32 ++++ app/serializers/basic_group_serializer.rb | 9 + config/routes.rb | 2 +- spec/requests/admin/groups_controller_spec.rb | 151 ----------------- .../schemas/json/group_create_response.json | 3 + .../api/schemas/json/group_response.json | 3 + .../schemas/json/groups_list_response.json | 3 + spec/requests/groups_controller_spec.rb | 158 ++++++++++++++++++ 15 files changed, 227 insertions(+), 187 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/group-member-dropdown.js b/app/assets/javascripts/discourse/app/components/group-member-dropdown.js index bd6fa1006a..f8bff73391 100644 --- a/app/assets/javascripts/discourse/app/components/group-member-dropdown.js +++ b/app/assets/javascripts/discourse/app/components/group-member-dropdown.js @@ -43,6 +43,15 @@ export default DropdownSelectBoxComponent.extend({ icon: "shield-alt", }); } + } else if (this.canEditGroup && !this.member.owner) { + items.push({ + id: "makeOwner", + name: I18n.t("groups.members.make_owner"), + description: I18n.t("groups.members.make_owner_description", { + username: this.get("member.username"), + }), + icon: "shield-alt", + }); } if (this.currentUser.staff) { diff --git a/app/assets/javascripts/discourse/app/controllers/group-index.js b/app/assets/javascripts/discourse/app/controllers/group-index.js index fd6826cd95..a1fa863299 100644 --- a/app/assets/javascripts/discourse/app/controllers/group-index.js +++ b/app/assets/javascripts/discourse/app/controllers/group-index.js @@ -134,17 +134,17 @@ export default Controller.extend({ case "removeMembers": return ajax(`/groups/${this.model.id}/members.json`, { type: "DELETE", - data: { user_ids: selection.map((u) => u.id).join(",") }, + data: { user_ids: selection.mapBy("id").join(",") }, }).then(() => { this.model.reloadMembers(this.memberParams, true); this.set("isBulk", false); }); case "makeOwners": - return ajax(`/admin/groups/${this.model.id}/owners.json`, { + return ajax(`/groups/${this.model.id}/owners.json`, { type: "PUT", data: { - group: { usernames: selection.map((u) => u.username).join(",") }, + usernames: selection.mapBy("username").join(","), }, }).then(() => { selection.forEach((s) => s.set("owner", true)); diff --git a/app/assets/javascripts/discourse/app/models/group.js b/app/assets/javascripts/discourse/app/models/group.js index fc1b4725fb..f43812a8d3 100644 --- a/app/assets/javascripts/discourse/app/models/group.js +++ b/app/assets/javascripts/discourse/app/models/group.js @@ -148,9 +148,9 @@ const Group = RestModel.extend({ }, async addOwners(usernames, filter, notifyUsers) { - const response = await ajax(`/admin/groups/${this.id}/owners.json`, { + const response = await ajax(`/groups/${this.id}/owners.json`, { type: "PUT", - data: { group: { usernames, notify_users: notifyUsers } }, + data: { usernames, notify_users: notifyUsers }, }); if (filter) { diff --git a/app/assets/javascripts/discourse/app/templates/group-index.hbs b/app/assets/javascripts/discourse/app/templates/group-index.hbs index 926d895daa..dc0dfa4809 100644 --- a/app/assets/javascripts/discourse/app/templates/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/group-index.hbs @@ -54,6 +54,7 @@ {{/if}} @@ -148,6 +149,7 @@ {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs b/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs index 1986e24374..831ec8e81f 100644 --- a/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs +++ b/app/assets/javascripts/discourse/app/templates/mobile/group-index.hbs @@ -44,6 +44,7 @@ {{/if}} 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 020a5a4463..226775a680 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js @@ -39,7 +39,7 @@ acceptance("Group Members", function (needs) { needs.user(); needs.pretender((server, helper) => { - server.put("/admin/groups/47/owners.json", () => { + server.put("/groups/47/owners.json", () => { return helper.response({ success: true }); }); }); diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 4d9e581fd3..1af73960fe 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -44,35 +44,6 @@ class Admin::GroupsController < Admin::StaffController end end - def add_owners - group = Group.find_by(id: params.require(:id)) - raise Discourse::NotFound unless group - - return can_not_modify_automatic if group.automatic - guardian.ensure_can_edit_group!(group) - - users = User.where(username: group_params[:usernames].split(",")) - - users.each do |user| - group_action_logger = GroupActionLogger.new(current_user, group) - - if !group.users.include?(user) - group.add(user) - group_action_logger.log_add_user_to_group(user) - end - group.group_users.where(user_id: user.id).update_all(owner: true) - group_action_logger.log_make_user_group_owner(user) - - if group_params[:notify_users] == "true" || group_params[:notify_users] == true - group.notify_added_to_group(user, owner: true) - end - end - - group.restore_user_count! - - render json: success_json.merge!(usernames: users.pluck(:username)) - end - def remove_owner group = Group.find_by(id: params.require(:id)) raise Discourse::NotFound unless group diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 99f3634ee6..4a772039f6 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -385,6 +385,32 @@ class GroupsController < ApplicationController end end + def add_owners + group = Group.find_by(id: params.require(:id)) + raise Discourse::NotFound unless group + + return can_not_modify_automatic if group.automatic + guardian.ensure_can_edit_group!(group) + + users = users_from_params + group_action_logger = GroupActionLogger.new(current_user, group) + + users.each do |user| + if !group.users.include?(user) + group.add(user) + group_action_logger.log_add_user_to_group(user) + end + group.group_users.where(user_id: user.id).update_all(owner: true) + group_action_logger.log_make_user_group_owner(user) + + group.notify_added_to_group(user, owner: true) if params[:notify_users].to_s == "true" + end + + group.restore_user_count! + + render json: success_json.merge!(usernames: users.pluck(:username)) + end + def join ensure_logged_in unless current_user.staff? @@ -667,6 +693,12 @@ class GroupsController < ApplicationController end end + protected + + def can_not_modify_automatic + render_json_error(I18n.t("groups.errors.can_not_modify_automatic")) + end + private def add_user_to_group(group, user, notify = false) diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index ab64df800a..f5d4c80d36 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -31,6 +31,7 @@ class BasicGroupSerializer < ApplicationSerializer :members_visibility_level, :can_see_members, :can_admin_group, + :can_edit_group, :publish_read_state def include_display_name? @@ -73,6 +74,14 @@ class BasicGroupSerializer < ApplicationSerializer owner_group_ids.present? end + def can_edit_group + scope.can_edit_group?(object) + end + + def include_can_edit_group? + scope.can_edit_group?(object) + end + def can_admin_group scope.can_admin_group?(object) end diff --git a/config/routes.rb b/config/routes.rb index abdf514ef8..8531638a99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -112,7 +112,6 @@ Discourse::Application.routes.draw do resources :groups, only: [:create] do member do - put "owners" => "groups#add_owners" delete "owners" => "groups#remove_owner" put "primary" => "groups#set_primary" end @@ -1052,6 +1051,7 @@ Discourse::Application.routes.draw do get "permissions" => "groups#permissions" put "members" => "groups#add_members" + put "owners" => "groups#add_owners" put "join" => "groups#join" delete "members" => "groups#remove_member" delete "leave" => "groups#leave" diff --git a/spec/requests/admin/groups_controller_spec.rb b/spec/requests/admin/groups_controller_spec.rb index c09142449e..5f101bf10b 100644 --- a/spec/requests/admin/groups_controller_spec.rb +++ b/spec/requests/admin/groups_controller_spec.rb @@ -146,157 +146,6 @@ RSpec.describe Admin::GroupsController do end end - describe "#add_owners" do - context "when logged in as an admin" do - before { sign_in(admin) } - - it "should work" do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: [user.username, admin.username].join(","), - }, - } - - expect(response.status).to eq(200) - - response_body = response.parsed_body - - expect(response_body["usernames"]).to contain_exactly(user.username, admin.username) - - expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly(user, admin) - end - - it "returns not-found error when there is no group" do - group.destroy! - - put "/admin/groups/#{group.id}/owners.json", params: { group: { usernames: user.username } } - - expect(response.status).to eq(404) - end - - it "does not allow adding owners to an automatic group" do - group.update!(automatic: true) - - expect do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: user.username, - }, - } - end.to_not change { group.group_users.count } - - expect(response.status).to eq(422) - expect(response.parsed_body["errors"]).to eq(["You cannot modify an automatic group"]) - end - - it "does not notify users when the param is not present" do - put "/admin/groups/#{group.id}/owners.json", params: { group: { usernames: user.username } } - expect(response.status).to eq(200) - - topic = - Topic.find_by( - title: - I18n.t( - "system_messages.user_added_to_group_as_owner.subject_template", - group_name: group.name, - ), - archetype: "private_message", - ) - expect(topic.nil?).to eq(true) - end - - it "notifies users when the param is present" do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: user.username, - notify_users: true, - }, - } - expect(response.status).to eq(200) - - topic = - Topic.find_by( - title: - I18n.t( - "system_messages.user_added_to_group_as_owner.subject_template", - group_name: group.name, - ), - archetype: "private_message", - ) - expect(topic.nil?).to eq(false) - expect(topic.topic_users.map(&:user_id)).to include(-1, user.id) - end - end - - context "when logged in as a moderator" do - before { sign_in(moderator) } - - context "with moderators_manage_categories_and_groups enabled" do - before { SiteSetting.moderators_manage_categories_and_groups = true } - - it "adds owners" do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: [user.username, admin.username, moderator.username].join(","), - }, - } - - response_body = response.parsed_body - - expect(response.status).to eq(200) - expect(response_body["usernames"]).to contain_exactly( - user.username, - admin.username, - moderator.username, - ) - expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly( - user, - admin, - moderator, - ) - end - end - - context "with moderators_manage_categories_and_groups disabled" do - before { SiteSetting.moderators_manage_categories_and_groups = false } - - it "prevents adding of owners with a 403 response" do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: [user.username, admin.username, moderator.username].join(","), - }, - } - - expect(response.status).to eq(403) - expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) - expect(group.group_users.where(owner: true).map(&:user)).to be_empty - end - end - end - - context "when logged in as a non-staff user" do - before { sign_in(user) } - - it "prevents adding of owners with a 404 response" do - put "/admin/groups/#{group.id}/owners.json", - params: { - group: { - usernames: [user.username, admin.username].join(","), - }, - } - - expect(response.status).to eq(404) - expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) - expect(group.group_users.where(owner: true).map(&:user)).to be_empty - end - end - end - describe "#remove_owner" do let(:user2) { Fabricate(:user) } let(:user3) { Fabricate(:user) } diff --git a/spec/requests/api/schemas/json/group_create_response.json b/spec/requests/api/schemas/json/group_create_response.json index 2d77910bbe..6c50cb851b 100644 --- a/spec/requests/api/schemas/json/group_create_response.json +++ b/spec/requests/api/schemas/json/group_create_response.json @@ -119,6 +119,9 @@ "can_admin_group": { "type": "boolean" }, + "can_edit_group": { + "type": "boolean" + }, "publish_read_state": { "type": "boolean" } diff --git a/spec/requests/api/schemas/json/group_response.json b/spec/requests/api/schemas/json/group_response.json index a663ba0e1b..9163e33f9d 100644 --- a/spec/requests/api/schemas/json/group_response.json +++ b/spec/requests/api/schemas/json/group_response.json @@ -122,6 +122,9 @@ "can_admin_group": { "type": "boolean" }, + "can_edit_group": { + "type": "boolean" + }, "publish_read_state": { "type": "boolean" }, diff --git a/spec/requests/api/schemas/json/groups_list_response.json b/spec/requests/api/schemas/json/groups_list_response.json index cea82af0f2..25fc1ac007 100644 --- a/spec/requests/api/schemas/json/groups_list_response.json +++ b/spec/requests/api/schemas/json/groups_list_response.json @@ -131,6 +131,9 @@ "can_admin_group": { "type": "boolean" }, + "can_edit_group": { + "type": "boolean" + }, "publish_read_state": { "type": "boolean" } diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index 0e108963ae..6b432c1b2a 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -1638,6 +1638,164 @@ RSpec.describe GroupsController do end end + describe "#add_owners" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "should work" do + put "/groups/#{group.id}/owners.json", + params: { + usernames: [user.username, admin.username].join(","), + } + + expect(response.status).to eq(200) + + response_body = response.parsed_body + + expect(response_body["usernames"]).to contain_exactly(user.username, admin.username) + + expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly(user, admin) + end + + it "returns not-found error when there is no group" do + group.destroy! + + put "/groups/#{group.id}/owners.json", params: { usernames: user.username } + + expect(response.status).to eq(404) + end + + it "does not allow adding owners to an automatic group" do + group.update!(automatic: true) + + expect do + put "/groups/#{group.id}/owners.json", params: { usernames: user.username } + end.to_not change { group.group_users.count } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to eq( + [I18n.t("groups.errors.can_not_modify_automatic")], + ) + end + + it "does not notify users when the param is not present" do + put "/groups/#{group.id}/owners.json", params: { usernames: user.username } + expect(response.status).to eq(200) + + topic = + Topic.find_by( + title: + I18n.t( + "system_messages.user_added_to_group_as_owner.subject_template", + group_name: group.name, + ), + archetype: "private_message", + ) + expect(topic.nil?).to eq(true) + end + + it "notifies users when the param is present" do + put "/groups/#{group.id}/owners.json", + params: { + usernames: user.username, + notify_users: true, + } + expect(response.status).to eq(200) + + topic = + Topic.find_by( + title: + I18n.t( + "system_messages.user_added_to_group_as_owner.subject_template", + group_name: group.name, + ), + archetype: "private_message", + ) + expect(topic.nil?).to eq(false) + expect(topic.topic_users.map(&:user_id)).to include(-1, user.id) + end + end + + context "when logged in as a moderator" do + before { sign_in(moderator) } + + context "with moderators_manage_categories_and_groups enabled" do + before { SiteSetting.moderators_manage_categories_and_groups = true } + + it "adds owners" do + put "/groups/#{group.id}/owners.json", + params: { + usernames: [user.username, admin.username, moderator.username].join(","), + } + + response_body = response.parsed_body + + expect(response.status).to eq(200) + expect(response_body["usernames"]).to contain_exactly( + user.username, + admin.username, + moderator.username, + ) + expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly( + user, + admin, + moderator, + ) + end + end + + context "with moderators_manage_categories_and_groups disabled" do + before { SiteSetting.moderators_manage_categories_and_groups = false } + + it "prevents adding of owners with a 403 response" do + put "/groups/#{group.id}/owners.json", + params: { + usernames: [user.username, admin.username, moderator.username].join(","), + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(group.group_users.where(owner: true).map(&:user)).to be_empty + end + end + end + + context "when logged in as a non-owner" do + before { sign_in(user) } + + it "prevents adding of owners with a 403 response" do + put "/groups/#{group.id}/owners.json", + params: { + usernames: [user.username, admin.username].join(","), + } + + expect(response.status).to eq(403) + expect(response.parsed_body["errors"]).to include(I18n.t("invalid_access")) + expect(group.group_users.where(owner: true).map(&:user)).to be_empty + end + end + + context "when logged in as an owner" do + before { sign_in(user) } + + it "allows adding new owners" do + group.add_owner(user) + + put "/groups/#{group.id}/owners.json", + params: { + usernames: [user.username, admin.username].join(","), + } + + expect(response.status).to eq(200) + expect(response.parsed_body["usernames"]).to contain_exactly( + user.username, + admin.username, + ) + expect(group.group_users.where(owner: true).map(&:user)).to contain_exactly(user, admin) + end + end + end + describe "#join" do let(:public_group) { Fabricate(:public_group) } From 14d97f9cf1742d30f82fff050821beb3a92fc957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Guitaut?= Date: Thu, 24 Nov 2022 16:28:21 +0100 Subject: [PATCH 002/169] FEATURE: Show more context in Discourse topic oneboxes Currently when generating a onebox for Discourse topics, some important context is missing such as categories and tags. This patch addresses this issue by introducing a new onebox engine dedicated to display this information when available. Indeed to get this new information, categories and tags are exposed in the topic metadata as opengraph tags. --- .../stylesheets/common/base/onebox.scss | 22 ++++++- .../stylesheets/common/base/tagging.scss | 3 +- app/helpers/application_helper.rb | 9 +++ app/views/topics/show.html.erb | 2 +- lib/cooked_processor_mixin.rb | 2 +- lib/onebox/engine/discourse_topic_onebox.rb | 59 +++++++++++++++++++ lib/onebox/normalizer.rb | 24 ++++---- lib/onebox/open_graph.rb | 11 +++- lib/onebox/sanitize_config.rb | 8 ++- lib/onebox/templates/discoursetopic.mustache | 42 +++++++++++++ lib/topic_view.rb | 14 ++--- spec/fixtures/onebox/discourse_topic.response | 4 ++ spec/helpers/application_helper_spec.rb | 32 ++++++++++ .../engine/discourse_topic_onebox_spec.rb | 51 ++++++++++++++++ spec/lib/onebox/open_graph_spec.rb | 28 +++++++++ spec/lib/topic_view_spec.rb | 13 ++++ 16 files changed, 298 insertions(+), 26 deletions(-) create mode 100644 lib/onebox/engine/discourse_topic_onebox.rb create mode 100644 lib/onebox/templates/discoursetopic.mustache create mode 100644 spec/lib/onebox/engine/discourse_topic_onebox_spec.rb diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index b35ae31a21..e2efc25cc1 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -738,7 +738,8 @@ aside.onebox.xkcd .onebox-body img { // allowlistedgeneric twitter labels .onebox.allowlistedgeneric, -.onebox.whitelistedgeneric { +.onebox.whitelistedgeneric, +.onebox.discoursetopic { .label1, .label2 { color: var(--primary-med-or-secondary-med); @@ -754,6 +755,7 @@ aside.onebox.xkcd .onebox-body img { .onebox { &.allowlistedgeneric, &.whitelistedgeneric, + &.discoursetopic, &.gfycat, &.githubfolder { .site-icon { @@ -769,6 +771,24 @@ aside.onebox.xkcd .onebox-body img { } } +.onebox.discoursetopic { + h3 { + width: 100%; + margin-bottom: 0.2rem !important; + } + + .d-icon-tag { + width: 0.75rem; + padding-top: 0.3rem; + position: absolute; + color: var(--primary-medium); + } + + .discourse-tags .discourse-tag:first-of-type { + padding-left: 1rem; + } +} + .onebox.gfycat p { span.label1 a { white-space: nowrap; diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 8050ece7ca..1014899d57 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -131,7 +131,8 @@ .discourse-tags { display: inline-flex; flex-wrap: wrap; - a { + a, + span { margin-right: 0.25em; } } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2520cd69b5..537e45b1e7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -311,6 +311,15 @@ module ApplicationHelper result << tag(:meta, { name: "twitter:#{property}", content: content }, nil, true) end end + Array + .wrap(opts[:breadcrumbs]) + .each do |breadcrumb| + result << tag(:meta, property: "og:article:section", content: breadcrumb[:name]) + result << tag(:meta, property: "og:article:section:color", content: breadcrumb[:color]) + end + Array + .wrap(opts[:tags]) + .each { |tag_name| result << tag(:meta, property: "og:article:tag", content: tag_name) } if opts[:read_time] && opts[:read_time] > 0 && opts[:like_count] && opts[:like_count] > 0 result << tag(:meta, name: "twitter:label1", value: I18n.t("reading_time")) diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index d949ebb2b0..65c12933c9 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -132,7 +132,7 @@ <% content_for :head do %> <%= auto_discovery_link_tag(@topic_view, {action: :feed, slug: @topic_view.topic.slug, topic_id: @topic_view.topic.id}, rel: 'alternate nofollow', title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %> - <%= raw crawlable_meta_data(title: @topic_view.title, description: @topic_view.summary(strip_images: true), image: @topic_view.image_url, read_time: @topic_view.read_time, like_count: @topic_view.like_count, ignore_canonical: true, published_time: @topic_view.published_time) %> + <%= raw crawlable_meta_data(title: @topic_view.title, description: @topic_view.summary(strip_images: true), image: @topic_view.image_url, read_time: @topic_view.read_time, like_count: @topic_view.like_count, ignore_canonical: true, published_time: @topic_view.published_time, breadcrumbs: @breadcrumbs, tags: @topic_view.tags) %> <% if @topic_view.prev_page || @topic_view.next_page %> <% if @topic_view.prev_page %> diff --git a/lib/cooked_processor_mixin.rb b/lib/cooked_processor_mixin.rb index 4c2fba4c17..bc872b4501 100644 --- a/lib/cooked_processor_mixin.rb +++ b/lib/cooked_processor_mixin.rb @@ -70,7 +70,7 @@ module CookedProcessorMixin found = false parent = img while parent = parent.parent - if parent["class"] && parent["class"].include?("allowlistedgeneric") + if parent["class"] && parent["class"].match?(/\b(allowlistedgeneric|discoursetopic)\b/) found = true break end diff --git a/lib/onebox/engine/discourse_topic_onebox.rb b/lib/onebox/engine/discourse_topic_onebox.rb new file mode 100644 index 0000000000..0247299d13 --- /dev/null +++ b/lib/onebox/engine/discourse_topic_onebox.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Onebox + module Engine + class DiscourseTopicOnebox + include Engine + include StandardEmbed + include LayoutSupport + + matches_regexp(%r{/t/.*(/\d+)?}) + + def data + @data ||= { + categories: categories, + link: link, + article_published_time: published_time.strftime("%-d %b %y"), + article_published_time_title: published_time.strftime("%I:%M%p - %d %B %Y"), + domain: html_entities.decode(raw[:site_name].truncate(80, separator: " ")), + description: html_entities.decode(raw[:description].truncate(250, separator: " ")), + title: html_entities.decode(raw[:title].truncate(80, separator: " ")), + image: image, + render_tags?: render_tags?, + render_category_block?: render_category_block?, + }.reverse_merge(raw) + end + alias verified_data data + + private + + def categories + Array + .wrap(raw[:article_sections]) + .map + .with_index { |name, index| { name: name, color: raw[:article_section_colors][index] } } + end + + def published_time + @published_time ||= Time.parse(raw[:published_time]) + end + + def html_entities + @html_entities ||= HTMLEntities.new + end + + def image + image = Onebox::Helpers.get_absolute_image_url(raw[:image], @url) + Onebox::Helpers.normalize_url_for_output(html_entities.decode(image)) + end + + def render_tags? + raw[:article_tags].present? + end + + def render_category_block? + render_tags? || categories.present? + end + end + end +end diff --git a/lib/onebox/normalizer.rb b/lib/onebox/normalizer.rb index ac4c26b541..ecd0b509f1 100644 --- a/lib/onebox/normalizer.rb +++ b/lib/onebox/normalizer.rb @@ -4,19 +4,11 @@ module Onebox class Normalizer attr_reader :data - def get(attr, length = nil, sanitize = true) - return nil if Onebox::Helpers.blank?(data) - + def get(attr, *args) value = data[attr] - - return nil if Onebox::Helpers.blank?(value) - - value = html_entities.decode(value) - value = Sanitize.fragment(value) if sanitize - value.strip! - value = Onebox::Helpers.truncate(value, length) unless length.nil? - - value + return if value.blank? + return value.map { |v| sanitize_value(v, *args) } if value.is_a?(Array) + sanitize_value(value, *args) end def method_missing(attr, *args, &block) @@ -48,5 +40,13 @@ module Onebox def html_entities @html_entities ||= HTMLEntities.new end + + def sanitize_value(value, length = nil, sanitize = true) + value = html_entities.decode(value) + value = Sanitize.fragment(value) if sanitize + value.strip! + value = Onebox::Helpers.truncate(value, length) if length + value + end end end diff --git a/lib/onebox/open_graph.rb b/lib/onebox/open_graph.rb index 4fb2579347..8ff754fc52 100644 --- a/lib/onebox/open_graph.rb +++ b/lib/onebox/open_graph.rb @@ -22,6 +22,8 @@ module Onebox private + COLLECTIONS = %i[article_section article_section_color article_tag] + def extract(doc) return {} if Onebox::Helpers.blank?(doc) @@ -33,7 +35,14 @@ module Onebox if (m["property"] && m["property"][/^(?:og|article|product):(.+)$/i]) || (m["name"] && m["name"][/^(?:og|article|product):(.+)$/i]) value = (m["content"] || m["value"]).to_s - data[$1.tr("-:", "_").to_sym] ||= value unless Onebox::Helpers.blank?(value) + next if Onebox::Helpers.blank?(value) + key = $1.tr("-:", "_").to_sym + data[key] ||= value + if key.in?(COLLECTIONS) + collection_name = "#{key}s".to_sym + data[collection_name] ||= [] + data[collection_name] << value + end end end diff --git a/lib/onebox/sanitize_config.rb b/lib/onebox/sanitize_config.rb index 49c552f05b..d0ea4ed417 100644 --- a/lib/onebox/sanitize_config.rb +++ b/lib/onebox/sanitize_config.rb @@ -10,7 +10,7 @@ module Onebox Sanitize::Config::RELAXED, elements: Sanitize::Config::RELAXED[:elements] + - %w[audio details embed iframe source video svg path], + %w[audio details embed iframe source video svg path use], attributes: { "a" => Sanitize::Config::RELAXED[:attributes]["a"] + %w[target], "audio" => %w[controls controlslist], @@ -40,7 +40,8 @@ module Onebox "path" => %w[d fill-rule], "svg" => %w[aria-hidden width height viewbox], "div" => [:data], # any data-* attributes, - "span" => [:data], # any data-* attributes + "span" => [:data], # any data-* attributes, + "use" => %w[href], }, add_attributes: { "iframe" => { @@ -89,6 +90,9 @@ module Onebox "source" => { "src" => HTTP_PROTOCOLS, }, + "use" => { + "href" => [:relative], + }, }, css: { properties: Sanitize::Config::RELAXED[:css][:properties] + %w[--aspect-ratio], diff --git a/lib/onebox/templates/discoursetopic.mustache b/lib/onebox/templates/discoursetopic.mustache new file mode 100644 index 0000000000..706b91b9fb --- /dev/null +++ b/lib/onebox/templates/discoursetopic.mustache @@ -0,0 +1,42 @@ +{{#image}}{{/image}} + +
+

{{title}}

+ {{#render_category_block?}} +
+ {{#categories}} + + + + {{name}} + + + {{/categories}} + {{#render_tags?}} +
+
+
+ + {{#article_tags}} + {{.}} + {{/article_tags}} +
+
+
+ {{/render_tags?}} +
+ {{/render_category_block?}} +
+ +{{#description}} +

{{description}}

+{{/description}} + +{{#data1}} +

+ {{label1}}: {{data1}} + {{#data2}} + {{label2}}: {{data2}} + {{/data2}} +

+{{/data1}} diff --git a/lib/topic_view.rb b/lib/topic_view.rb index a6431e392e..ebfc6d04c7 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -713,6 +713,10 @@ class TopicView @mentioned_users = mentioned_users.to_h { |u| [u.username, u] } end + def tags + @topic.tags.map(&:name) + end + protected def read_posts_set @@ -814,13 +818,9 @@ class TopicView end def find_topic(topic_or_topic_id) - if topic_or_topic_id.is_a?(Topic) - topic_or_topic_id - else - # with_deleted covered in #check_and_raise_exceptions - finder = Topic.with_deleted.where(id: topic_or_topic_id).includes(:category) - finder.first - end + return topic_or_topic_id if topic_or_topic_id.is_a?(Topic) + # with_deleted covered in #check_and_raise_exceptions + Topic.with_deleted.includes(:category, :tags).find_by(id: topic_or_topic_id) end def unfiltered_posts diff --git a/spec/fixtures/onebox/discourse_topic.response b/spec/fixtures/onebox/discourse_topic.response index 8c078f3164..2d79976ba8 100644 --- a/spec/fixtures/onebox/discourse_topic.response +++ b/spec/fixtures/onebox/discourse_topic.response @@ -194,6 +194,10 @@ And that too in just over an year, way to go! [boom]"> + + + + diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 0b70af8174..c25cd5efbb 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -640,6 +640,38 @@ RSpec.describe ApplicationHelper do expect(helper.crawlable_meta_data).not_to include("twitter:image") end end + + context "with breadcrumbs" do + subject(:metadata) { helper.crawlable_meta_data(breadcrumbs: breadcrumbs) } + + let(:breadcrumbs) do + [{ name: "section1", color: "ff0000" }, { name: "section2", color: "0000ff" }] + end + let(:tags) { <<~HTML.strip } + + + + + HTML + + it "generates section and color tags" do + expect(metadata).to include tags + end + end + + context "with tags" do + subject(:metadata) { helper.crawlable_meta_data(tags: tags) } + + let(:tags) { %w[tag1 tag2] } + let(:output_tags) { <<~HTML.strip } + + + HTML + + it "generates tag tags" do + expect(metadata).to include output_tags + end + end end describe "discourse_color_scheme_stylesheets" do diff --git a/spec/lib/onebox/engine/discourse_topic_onebox_spec.rb b/spec/lib/onebox/engine/discourse_topic_onebox_spec.rb new file mode 100644 index 0000000000..7b8450c7e7 --- /dev/null +++ b/spec/lib/onebox/engine/discourse_topic_onebox_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe Onebox::Engine::DiscourseTopicOnebox do + subject(:onebox) { described_class.new(url) } + + describe "#data" do + subject(:data) { onebox.data } + + let(:url) do + "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483" + end + let(:expected_data) do + { + article_published_time: "6 Feb 14", + article_published_time_title: "04:55AM - 06 February 2014", + article_tags: %w[how-to sso], + card: "summary", + categories: [{ name: "praise", color: "9EB83B" }], + data1: "1 mins 🕑", + data2: "9 ❤", + description: + "Congratulations Discourse for qualifying Repositories with the most stars on GitHub Octoverse. And that too in just over an year, way to go! 💥", + domain: "Discourse Meta", + favicon: + "https://d11a6trkgmumsb.cloudfront.net/optimized/3X/b/3/b33be9538df3547fcf9d1a51a4637d77392ac6f9_2_32x32.png", + ignore_canonical: "true", + image: + "https://d11a6trkgmumsb.cloudfront.net/optimized/2X/d/d063b3b0807377d98695ee08042a9ba0a8c593bd_2_690x362.png", + label1: "Reading time", + label2: "Likes", + link: + "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483", + published_time: "2014-02-06T04:55:19+00:00", + render_category_block?: true, + render_tags?: true, + site_name: "Discourse Meta", + title: "Congratulations, most stars in 2013 GitHub Octoverse!", + url: + "https://meta.discourse.org/t/congratulations-most-stars-in-2013-github-octoverse/12483", + } + end + + before do + stub_request(:get, url).to_return(status: 200, body: onebox_response("discourse_topic")) + end + + it "returns the expected data" do + expect(data).to include expected_data + end + end +end diff --git a/spec/lib/onebox/open_graph_spec.rb b/spec/lib/onebox/open_graph_spec.rb index efbbb90bb4..71de9468e8 100644 --- a/spec/lib/onebox/open_graph_spec.rb +++ b/spec/lib/onebox/open_graph_spec.rb @@ -24,4 +24,32 @@ RSpec.describe Onebox::OpenGraph do og = described_class.new(doc) expect(og.image).to eq("http://test.com/test'ing.mp3") end + + describe "Collections" do + subject(:graph) { described_class.new(doc) } + + let(:doc) { Nokogiri.HTML(<<-HTML) } + + test + + + + + + + + HTML + + it "handles multiple article:tag tags" do + expect(graph.article_tags).to eq %w[tag1 tag2] + end + + it "handles multiple article:section tags" do + expect(graph.article_sections).to eq %w[category1 category2] + end + + it "handles multiple article:section:color tags" do + expect(graph.article_section_colors).to eq %w[ff0000 0000ff] + end + end end diff --git a/spec/lib/topic_view_spec.rb b/spec/lib/topic_view_spec.rb index 560993003a..7dc2ff115f 100644 --- a/spec/lib/topic_view_spec.rb +++ b/spec/lib/topic_view_spec.rb @@ -1072,4 +1072,17 @@ RSpec.describe TopicView do end end end + + describe "#tags" do + subject(:topic_view_tags) { topic_view.tags } + + let(:topic_view) { described_class.new(topic, user) } + let(:topic) { Fabricate.build(:topic, tags: tags) } + let(:tags) { Fabricate.build_times(2, :tag) } + let(:user) { Fabricate(:user) } + + it "returns the tags names" do + expect(topic_view_tags).to match tags.map(&:name) + end + end end From c7767686ccdc14dd6093c5d0daef7f79113d0182 Mon Sep 17 00:00:00 2001 From: chapoi <101828855+chapoi@users.noreply.github.com> Date: Wed, 11 Jan 2023 16:17:20 +0100 Subject: [PATCH 003/169] UX: streamline avatar in topic list (#19829) --- app/assets/stylesheets/desktop/topic-list.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index 30d21f8ad1..ba540f199d 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -89,9 +89,8 @@ } .posters a:first-child .avatar.latest:not(.single) { box-shadow: 0 0 3px 1px rgba(var(--tertiary-rgb), 0.35); - border: 2px solid rgba(var(--tertiary-rgb), 0.5); + border: 1px solid rgba(var(--tertiary-rgb), 0.5); position: relative; - top: -2px; left: -2px; } From 92bb728fe52b71dbfbc181c03cad8fb0d84bed94 Mon Sep 17 00:00:00 2001 From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Date: Wed, 11 Jan 2023 13:02:22 -0600 Subject: [PATCH 004/169] DEV: Add search suggestions for tag-intersections (#19777) Added `tagIntersection` search context for handling search suggestions on tag intersection and tag+category routes. # Tag & Category Route Search Suggestions eg. /tags/c/general/5/updates ### Before Screenshot 2023-01-06 at 2 58 50 PM ### After Screenshot 2023-01-06 at 3 00 35 PM # Tag Intersection Route Search Suggestions eg. /tags/intersection/updates/foo ### Before Screenshot 2023-01-06 at 3 02 23 PM ### After Screenshot 2023-01-09 at 2 02 09 PM I defaulted to using `+` as a separator for tag intersections. The reasoning behind this is that we don't make the tag intersection routes easily accessible, so if you are going out of your way to view multiple tags, you are most likely going to be searching by **both** of those tags as well. # General Search Introducing flex wrap removes whitespace causing a [test](https://github.com/discourse/discourse/pull/19777/files#diff-5d3d13fabc1a511635eb7471ffe74f4d455d77f6984543c2be6ad136dfaa6d3aR813) to fail, but to remedy this I added spacing to the `.search-item-prefix` and `.search-item-slug` which achieves the same thing. ### After Screenshot 2023-01-09 at 2 04 54 PM --- .../discourse/app/routes/tag-show.js | 16 ++- .../app/widgets/search-menu-results.js | 84 +++++++++++- .../discourse/tests/acceptance/search-test.js | 122 +++++++++++++++++- .../stylesheets/common/base/search-menu.scss | 16 ++- 4 files changed, 226 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse/app/routes/tag-show.js b/app/assets/javascripts/discourse/app/routes/tag-show.js index 9c73f3b1d8..2482f9159d 100644 --- a/app/assets/javascripts/discourse/app/routes/tag-show.js +++ b/app/assets/javascripts/discourse/app/routes/tag-show.js @@ -136,7 +136,21 @@ export default DiscourseRoute.extend(FilterModeMixin, { noSubcategories, loading: false, }); - this.searchService.set("searchContext", model.tag.searchContext); + + if (model.category || model.additionalTags) { + const tagIntersectionSearchContext = { + type: "tagIntersection", + tagId: model.tag.id, + tag: model.tag, + additionalTags: model.additionalTags || null, + categoryId: model.category?.id || null, + category: model.category || null, + }; + + this.searchService.set("searchContext", tagIntersectionSearchContext); + } else { + this.searchService.set("searchContext", model.tag.searchContext); + } }, titleToken() { diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index bc88e254e5..6b4464313d 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -415,13 +415,42 @@ createWidget("search-menu-assistant", { const content = []; const { suggestionKeyword, term } = attrs; - let prefix = term?.split(suggestionKeyword)[0].trim() || ""; - if (prefix.length) { - prefix = `${prefix} `; + let prefix; + if (suggestionKeyword !== "+") { + prefix = term?.split(suggestionKeyword)[0].trim() || ""; + + if (prefix.length) { + prefix = `${prefix} `; + } } switch (suggestionKeyword) { + case "+": + attrs.results.forEach((item) => { + if (item.additionalTags) { + prefix = term?.split(" ").slice(0, -1).join(" ").trim() || ""; + } else { + prefix = term?.split("#")[0].trim() || ""; + } + + if (prefix.length) { + prefix = `${prefix} `; + } + + content.push( + this.attach("search-menu-assistant-item", { + prefix, + tag: item.tagName, + additionalTags: item.additionalTags, + category: item.category, + slug: term, + withInLabel: attrs.withInLabel, + isIntersection: true, + }) + ); + }); + break; case "#": attrs.results.forEach((item) => { if (item.model) { @@ -572,6 +601,36 @@ createWidget("search-menu-initial-options", { }) ); break; + case "tagIntersection": + let tagTerm; + if (ctx.additionalTags) { + const tags = [ctx.tagId, ...ctx.additionalTags]; + tagTerm = `${term} tags:${tags.join("+")}`; + } else { + tagTerm = `${term} #${ctx.tagId}`; + } + let suggestionOptions = { + tagName: ctx.tagId, + additionalTags: ctx.additionalTags, + }; + if (ctx.category) { + const categorySlug = ctx.category.parentCategory + ? `#${ctx.category.parentCategory.slug}:${ctx.category.slug}` + : `#${ctx.category.slug}`; + suggestionOptions.categoryName = categorySlug; + suggestionOptions.category = ctx.category; + tagTerm = tagTerm + ` ${categorySlug}`; + } + + content.push( + this.attach("search-menu-assistant", { + term: tagTerm, + suggestionKeyword: "+", + results: [suggestionOptions], + withInLabel: true, + }) + ); + break; case "user": content.push( this.attach("search-menu-assistant-item", { @@ -677,11 +736,22 @@ createWidget("search-menu-assistant-item", { link: false, }) ); - } else if (attrs.tag) { - attributes.href = getURL(`/tag/${attrs.tag}`); - content.push(iconNode("tag")); - content.push(h("span.search-item-tag", attrs.tag)); + // category and tag combination + if (attrs.tag && attrs.isIntersection) { + attributes.href = getURL(`/tag/${attrs.tag}`); + content.push(iconNode("tag")); + content.push(h("span.search-item-tag", attrs.tag)); + } + } else if (attrs.tag) { + if (attrs.isIntersection && attrs.additionalTags?.length) { + const tags = [attrs.tag, ...attrs.additionalTags]; + content.push(h("span.search-item-tag", `tags:${tags.join("+")}`)); + } else { + attributes.href = getURL(`/tag/${attrs.tag}`); + content.push(iconNode("tag")); + content.push(h("span.search-item-tag", attrs.tag)); + } } else if (attrs.user) { const userResult = [ avatarImg("small", { diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js index f0b21db01e..49a7cff76f 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js @@ -757,6 +757,47 @@ acceptance("Search - assistant", function (needs) { return helper.response(searchFixtures["search/query"]); }); + server.get("/tag/dev/notifications", () => { + return helper.response({ + tag_notification: { id: "dev", notification_level: 2 }, + }); + }); + + server.get("/tags/c/bug/1/dev/l/latest.json", () => { + return helper.response({ + users: [], + primary_groups: [], + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 1, + per_page: 30, + tags: [ + { + id: 1, + name: "dev", + topic_count: 1, + }, + ], + topics: [], + }, + }); + }); + + server.get("/tags/intersection/dev/foo.json", () => { + return helper.response({ + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 1, + per_page: 30, + topics: [], + }, + }); + }); + server.get("/u/search/users", () => { return helper.response({ users: [ @@ -810,13 +851,92 @@ acceptance("Search - assistant", function (needs) { query( ".search-menu .results ul.search-menu-assistant .search-item-prefix" ).innerText, - "sam " + "sam" ); await click(firstCategory); assert.strictEqual(query("#search-term").value, `sam #${firstResultSlug}`); }); + test("Shows category / tag combination shortcut when both are present", async function (assert) { + await visit("/tags/c/bug/dev"); + await click("#search-button"); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .category-name") + .innerText, + "bug", + "Category is displayed" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "dev", + "Tag is displayed" + ); + }); + + test("Updates tag / category combination search suggestion when typing", async function (assert) { + await visit("/tags/c/bug/dev"); + await click("#search-button"); + await fillIn("#search-term", "foo bar"); + + assert.strictEqual( + query( + ".search-menu .results ul.search-menu-assistant .search-item-prefix" + ).innerText, + "foo bar", + "Input is applied to search query" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .category-name") + .innerText, + "bug" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "dev", + "Tag is displayed" + ); + }); + + test("Shows tag combination shortcut when visiting tag intersection", async function (assert) { + await visit("/tags/intersection/dev/foo"); + await click("#search-button"); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "tags:dev+foo", + "Tags are displayed" + ); + }); + + test("Updates tag intersection search suggestion when typing", async function (assert) { + await visit("/tags/intersection/dev/foo"); + await click("#search-button"); + await fillIn("#search-term", "foo bar"); + + assert.strictEqual( + query( + ".search-menu .results ul.search-menu-assistant .search-item-prefix" + ).innerText, + "foo bar", + "Input is applied to search query" + ); + + assert.strictEqual( + query(".search-menu .results ul.search-menu-assistant .search-item-tag") + .innerText, + "tags:dev+foo", + "Tags are displayed" + ); + }); + test("shows in: shortcuts", async function (assert) { await visit("/"); await click("#search-button"); diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 189cee2a63..95a2353e03 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -208,9 +208,13 @@ $search-pad-horizontal: 0.5em; margin-top: 2px; } - .search-item-slug .badge-wrapper { - font-size: var(--font-0); - margin-left: 2px; + .search-item-slug { + margin-right: 5px; + + .badge-wrapper { + font-size: var(--font-0); + margin-left: 2px; + } } .search-menu-initial-options { @@ -225,7 +229,13 @@ $search-pad-horizontal: 0.5em; .search-menu-initial-options, .search-result-tag, .search-menu-assistant { + .search-item-prefix { + padding-right: 5px; + } .search-link { + display: flex; + flex-wrap: wrap; + align-items: center; @include ellipsis; .d-icon { margin-right: 5px; From 21a95b000eac9c1d239e6db9ff9d682b5dfd61da Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 09:41:39 +1000 Subject: [PATCH 005/169] DEV: Remove defunct TODOs (#19825) * Firefox now finally returns PerformanceMeasure from performance.measure * Some TODOs were really more NOTE or FIXME material or no longer relevant * retain_hours is not needed in ExternalUploadsManager, it doesn't seem like anywhere in the UI sends this as a param for uploads * https://github.com/discourse/discourse/pull/18413 was merged so we can remove JS test workaround for settings --- .../discourse/app/components/composer-editor.js | 2 -- .../discourse/app/mixins/extendable-uploader.js | 2 +- .../javascripts/discourse/app/mixins/upload-debugging.js | 1 - app/models/external_upload_stub.rb | 3 --- app/services/external_upload_manager.rb | 2 -- lib/s3_helper.rb | 9 +++------ lib/upload_creator.rb | 7 ++----- .../unit/lib/chat-emoji-reaction-store-test.js | 5 +---- 8 files changed, 7 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/composer-editor.js b/app/assets/javascripts/discourse/app/components/composer-editor.js index 2c8cd34338..7388a6fc3d 100644 --- a/app/assets/javascripts/discourse/app/components/composer-editor.js +++ b/app/assets/javascripts/discourse/app/components/composer-editor.js @@ -106,8 +106,6 @@ export default Component.extend(ComposerUploadUppy, { fileUploadElementId: "file-uploader", mobileFileUploaderId: "mobile-file-upload", - // TODO (martin) Remove this once the chat plugin is using the new composerEventPrefix - eventPrefix: "composer", composerEventPrefix: "composer", uploadType: "composer", uppyId: "composer-editor-uppy", diff --git a/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js b/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js index 8a20a5858b..d5dab6cce9 100644 --- a/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js +++ b/app/assets/javascripts/discourse/app/mixins/extendable-uploader.js @@ -74,7 +74,7 @@ export default Mixin.create(UploadDebugging, { } }, - // TODO (martin) This and _onPreProcessComplete will need to be tweaked + // NOTE: This and _onPreProcessComplete will need to be tweaked // if we ever add support for "determinate" preprocessors for uppy, which // means the progress will have a value rather than a started/complete // state ("indeterminate"). diff --git a/app/assets/javascripts/discourse/app/mixins/upload-debugging.js b/app/assets/javascripts/discourse/app/mixins/upload-debugging.js index 0c7a935f3e..07c6d59854 100644 --- a/app/assets/javascripts/discourse/app/mixins/upload-debugging.js +++ b/app/assets/javascripts/discourse/app/mixins/upload-debugging.js @@ -31,7 +31,6 @@ export default Mixin.create({ _instrumentUploadTimings() { if (!this._performanceApiSupport()) { - // TODO (martin) (2021-01-23) Check if FireFox fixed this yet. warn( "Some browsers do not return a PerformanceMeasure when calling performance.mark, disabling instrumentation. See https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#return_value and https://bugzilla.mozilla.org/show_bug.cgi?id=1724645", { id: "discourse.upload-debugging" } diff --git a/app/models/external_upload_stub.rb b/app/models/external_upload_stub.rb index 99ed046b97..10004f2906 100644 --- a/app/models/external_upload_stub.rb +++ b/app/models/external_upload_stub.rb @@ -43,9 +43,6 @@ class ExternalUploadStub < ActiveRecord::Base @statuses ||= Enum.new(created: 1, uploaded: 2, failed: 3) end - # TODO (martin): Lifecycle rule would be best to clean stuff up in the external - # systems, I don't think we really want to be calling out to the external systems - # here right? def self.cleanup! expired_created.delete_all expired_uploaded.delete_all diff --git a/app/services/external_upload_manager.rb b/app/services/external_upload_manager.rb index 23523ab4bc..58823158ff 100644 --- a/app/services/external_upload_manager.rb +++ b/app/services/external_upload_manager.rb @@ -149,8 +149,6 @@ class ExternalUploadManager raise ChecksumMismatchError if external_sha1 && external_sha1 != actual_sha1 end - # TODO (martin): See if these additional opts will be needed - # - check if retain_hours is needed opts = { type: external_upload_stub.upload_type, existing_external_upload_key: external_upload_stub.key, diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index f8082f04ca..b8dc762c84 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -175,12 +175,9 @@ class S3Helper cors_rules: final_rules, }, ) - rescue Aws::S3::Errors::AccessDenied => err - # TODO (martin) Remove this warning log level once we are sure this new - # ensure_cors! rule is functioning correctly. - Discourse.warn_exception( - err, - message: "Could not PutBucketCors rules for #{@s3_bucket_name}, rules: #{final_rules}", + rescue Aws::S3::Errors::AccessDenied + Rails.logger.info( + "Could not PutBucketCors rules for #{@s3_bucket_name}, rules: #{final_rules}", ) return false end diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index d83af7461c..32922cc468 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -57,9 +57,6 @@ class UploadCreator true end ) - - # TODO (martin) Validate @opts[:type] to make sure only blessed types are passed - # in, since the clientside can pass any type it wants. end def create_for(user_id) @@ -78,13 +75,13 @@ class UploadCreator is_image ||= @image_info && FileHelper.is_supported_image?("test.#{@image_info.type}") is_image = false if @opts[:for_theme] - # if this is present then it means we are creating an upload record from + # If this is present then it means we are creating an upload record from # an external_upload_stub and the file is > ExternalUploadManager::DOWNLOAD_LIMIT, # so we have not downloaded it to a tempfile. no modifications can be made to the # file in this case because it does not exist; we simply move it to its new location # in S3 # - # TODO (martin) I've added a bunch of external_upload_too_big checks littered + # FIXME: I've added a bunch of external_upload_too_big checks littered # throughout the UploadCreator code. It would be better to have two seperate # classes with shared methods, rather than doing all these checks all over the # place. Needs a refactor. diff --git a/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js index b53a2084dc..2f34312a6d 100644 --- a/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js +++ b/plugins/chat/test/javascripts/unit/lib/chat-emoji-reaction-store-test.js @@ -16,13 +16,10 @@ module("Discourse Chat | Unit | chat-emoji-reaction-store", function (hooks) { this.chatEmojiReactionStore.reset(); }); - // TODO (martin) Remove site setting workarounds after core PR#1290 test("defaults", function (assert) { assert.deepEqual( this.chatEmojiReactionStore.favorites, - (this.siteSettings.default_emoji_reactions || "") - .split("|") - .filter((val) => val) + this.siteSettings.default_emoji_reactions.split("|").filter((val) => val) ); }); From 387693e889fd4f335439c3badc80e767b93056b2 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 10:04:46 +1000 Subject: [PATCH 006/169] FIX: Improve error reporting and failure modes for channel archiving (#19791) There was an issue with channel archiving, where at times the topic creation could fail which left the archive in a bad state, as read-only instead of archived. This commit does several things: * Changes the ChatChannelArchiveService to validate the topic being created first and if it is not valid report the topic creation errors in the PM we send to the user * Changes the UI message in the channel with the archive status to reflect that topic creation failed * Validate the new topic when starting the archive process from the UI, and show the validation errors to the user straight away instead of creating the archive record and starting the process This also fixes another issue in the discourse_dev config which was failing because YAML parsing does not enable all classes by default now, which was making the seeding rake task for chat fail. --- lib/discourse_dev/config.rb | 5 +- .../api/chat_channels_archives_controller.rb | 55 ++++---- .../chat/app/models/chat_channel_archive.rb | 4 + .../components/chat-channel-archive-status.js | 8 +- plugins/chat/config/locales/client.en.yml | 3 +- plugins/chat/config/locales/server.en.yml | 9 ++ .../chat/lib/chat_channel_archive_service.rb | 118 +++++++++++++----- .../lib/chat_channel_archive_service_spec.rb | 33 +++-- .../chat_channels_archives_controller_spec.rb | 16 +++ .../chat/spec/system/archive_channel_spec.rb | 12 ++ 10 files changed, 199 insertions(+), 64 deletions(-) diff --git a/lib/discourse_dev/config.rb b/lib/discourse_dev/config.rb index f83eebe880..778aeae962 100644 --- a/lib/discourse_dev/config.rb +++ b/lib/discourse_dev/config.rb @@ -10,10 +10,11 @@ module DiscourseDev 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) + # https://stackoverflow.com/questions/71332602/upgrading-to-ruby-3-1-causes-psychdisallowedclass-exception-when-using-yaml-lo + default_config = YAML.load_file(default_file_path, permitted_classes: [Date]) if File.exist?(file_path) - user_config = YAML.load_file(file_path) + user_config = YAML.load_file(file_path, permitted_classes: [Date]) 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) diff --git a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb b/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb index 759348ef4e..ca5640e992 100644 --- a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb +++ b/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb @@ -8,35 +8,48 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl guardian.ensure_can_change_channel_status!(channel_from_params, :archived) raise Discourse::InvalidAccess if !existing_archive.failed? Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) - else - archive_params = - params - .require(:archive) - .tap do |ca| - ca.require(:type) - ca.permit(:title, :topic_id, :category_id, tags: []) - end + return render json: success_json + end - new_topic = archive_params[:type] == "new_topic" - raise Discourse::InvalidParameters if new_topic && archive_params[:title].blank? - raise Discourse::InvalidParameters if !new_topic && archive_params[:topic_id].blank? + new_topic = archive_params[:type] == "new_topic" + raise Discourse::InvalidParameters if new_topic && archive_params[:title].blank? + raise Discourse::InvalidParameters if !new_topic && archive_params[:topic_id].blank? - if !guardian.can_change_channel_status?(channel_from_params, :read_only) - raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived")) - end + if !guardian.can_change_channel_status?(channel_from_params, :read_only) + raise Discourse::InvalidAccess.new(I18n.t("chat.errors.channel_cannot_be_archived")) + end - Chat::ChatChannelArchiveService.begin_archive_process( + begin + Chat::ChatChannelArchiveService.create_archive_process( chat_channel: channel_from_params, acting_user: current_user, - topic_params: { - topic_id: archive_params[:topic_id], - topic_title: archive_params[:title], - category_id: archive_params[:category_id], - tags: archive_params[:tags], - }, + topic_params: topic_params, ) + rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err + return render json: failed_json.merge(errors: err.errors), status: 400 end render json: success_json end + + private + + def archive_params + @archive_params ||= + params + .require(:archive) + .tap do |ca| + ca.require(:type) + ca.permit(:title, :topic_id, :category_id, tags: []) + end + end + + def topic_params + @topic_params ||= { + topic_id: archive_params[:topic_id], + topic_title: archive_params[:title], + category_id: archive_params[:category_id], + tags: archive_params[:tags], + } + end end diff --git a/plugins/chat/app/models/chat_channel_archive.rb b/plugins/chat/app/models/chat_channel_archive.rb index e84cdb35e3..057af4e5bf 100644 --- a/plugins/chat/app/models/chat_channel_archive.rb +++ b/plugins/chat/app/models/chat_channel_archive.rb @@ -13,6 +13,10 @@ class ChatChannelArchive < ActiveRecord::Base def failed? !complete? && self.archive_error.present? end + + def new_topic? + self.destination_topic_title.present? + end end # == Schema Information diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js index 94d27ed5a6..0f23a725e3 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-archive-status.js @@ -19,8 +19,11 @@ export default Component.extend({ "channel.archive_failed" ) channelArchiveFailedMessage() { + const translationKey = !this.channel.archive_topic_id + ? "chat.channel_status.archive_failed_no_topic" + : "chat.channel_status.archive_failed"; return htmlSafe( - I18n.t("chat.channel_status.archive_failed", { + I18n.t(translationKey, { completed: this.channel.archived_messages, total: this.channel.total_messages, topic_url: this._getTopicUrl(), @@ -50,6 +53,9 @@ export default Component.extend({ }, _getTopicUrl() { + if (!this.channel.archive_topic_id) { + return ""; + } return getURL(`/t/-/${this.channel.archive_topic_id}`); }, }); diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 9f912699a8..e7761687bc 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -205,7 +205,8 @@ en: read_only: "Read Only" archived_header: "Channel is archived" archived: "Archived" - archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived in the destination topic. Press retry to attempt to complete the archive." + archive_failed: "Archive channel failed. %{completed}/%{total} messages have been archived. the destination topic. Press retry to attempt to complete the archive." + archive_failed_no_topic: "Archive channel failed. %{completed}/%{total} messages have been archived, the destination topic was not created. Press retry to attempt to complete the archive." archive_completed: "See the archive topic" closed_header: "Channel is closed" closed: "Closed" diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml index 62c2229208..9cdaf1c2d6 100644 --- a/plugins/chat/config/locales/server.en.yml +++ b/plugins/chat/config/locales/server.en.yml @@ -35,6 +35,15 @@ en: subject_template: "Chat channel archive failed" text_body_template: | Archiving the chat channel **\#%{channel_name}** has failed. %{messages_archived} messages have been archived. Partially archived messages were copied into the topic [%{topic_title}](%{topic_url}). Visit the channel at %{channel_url} to retry. + chat_channel_archive_failed_no_topic: + title: "Chat Channel Archive Failed" + subject_template: "Chat channel archive failed" + text_body_template: | + Archiving the chat channel **\#%{channel_name}** has failed. No messages have been archived. The topic was not created successfully for the following reasons: + + %{topic_validation_errors} + + Visit the channel at %{channel_url} to retry. chat: deleted_chat_username: deleted diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb index 45196c1f93..0cd7a9c1d2 100644 --- a/plugins/chat/lib/chat_channel_archive_service.rb +++ b/plugins/chat/lib/chat_channel_archive_service.rb @@ -2,9 +2,7 @@ ## # From time to time, site admins may choose to sunset a chat channel and archive -# the messages within. The main use case for this is a topic-based channel, but -# it can be used for category channels just fine. It cannot be used for DM channels -# in its current iteration. +# the messages within. It cannot be used for DM channels in its current iteration. # # To archive a channel, we mark it read_only first to prevent any further message # additions or changes, and create a record to track whether the archive topic @@ -17,9 +15,29 @@ class Chat::ChatChannelArchiveService ARCHIVED_MESSAGES_PER_POST = 100 - def self.begin_archive_process(chat_channel:, acting_user:, topic_params:) + class ArchiveValidationError < StandardError + attr_reader :errors + + def initialize(errors: []) + super + @errors = errors + end + end + + def self.create_archive_process(chat_channel:, acting_user:, topic_params:) return if ChatChannelArchive.exists?(chat_channel: chat_channel) + # Only need to validate topic params for a new topic, not an existing one. + if topic_params[:topic_id].blank? + valid, errors = + Chat::ChatChannelArchiveService.validate_topic_params( + Guardian.new(acting_user), + topic_params, + ) + + raise ArchiveValidationError.new(errors: errors) if !valid + end + ChatChannelArchive.transaction do chat_channel.read_only!(acting_user) @@ -48,6 +66,21 @@ class Chat::ChatChannelArchiveService chat_channel.chat_channel_archive end + def self.validate_topic_params(guardian, topic_params) + topic_creator = + TopicCreator.new( + Discourse.system_user, + guardian, + { + title: topic_params[:topic_title], + category: topic_params[:category_id], + tags: topic_params[:tags], + import_mode: true, + }, + ) + [topic_creator.valid?, topic_creator.errors.full_messages] + end + attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title def initialize(chat_channel_archive) @@ -60,22 +93,22 @@ class Chat::ChatChannelArchiveService chat_channel_archive.update(archive_error: nil) begin - ensure_destination_topic_exists! + return if !ensure_destination_topic_exists! Rails.logger.info( "Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).", ) - # a batch should be idempotent, either the post is created and the + # A batch should be idempotent, either the post is created and the # messages are deleted or we roll back the whole thing. # - # at some point we may want to reconsider disabling post validations, + # At some point we may want to reconsider disabling post validations, # and add in things like dynamic resizing of the number of messages per - # post based on post length, but that can be done later + # post based on post length, but that can be done later. # - # another future improvement is to send a MessageBus message for each + # Another future improvement is to send a MessageBus message for each # completed batch, so the UI can receive updates and show a progress - # bar or something similar + # bar or something similar. chat_channel .chat_messages .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| @@ -95,7 +128,7 @@ class Chat::ChatChannelArchiveService kick_all_users complete_archive rescue => err - notify_archiver(:failed, error: err) + notify_archiver(:failed, error_message: err.message) raise err end end @@ -144,29 +177,44 @@ class Chat::ChatChannelArchiveService }, ) - chat_channel_archive.update!(destination_topic: topic_creator.create) + if topic_creator.valid? + chat_channel_archive.update!(destination_topic: topic_creator.create) + else + Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") + notify_archiver( + :failed_no_topic, + error_message: topic_creator.errors.full_messages.join("\n"), + ) + end end - Rails.logger.info("Creating first post for #{chat_channel_title} archive.") - create_post( - I18n.t( - "chat.channel.archive.first_post_raw", - channel_name: chat_channel_title, - channel_url: chat_channel.url, - ), - ) + if chat_channel_archive.destination_topic.present? + Rails.logger.info("Creating first post for #{chat_channel_title} archive.") + create_post( + I18n.t( + "chat.channel.archive.first_post_raw", + channel_name: chat_channel_title, + channel_url: chat_channel.url, + ), + ) + end else Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") end - update_destination_topic_status + if chat_channel_archive.destination_topic.present? + update_destination_topic_status + return true + end + + false end def update_destination_topic_status - # we only want to do this when the destination topic is new, not an + # We only want to do this when the destination topic is new, not an # existing topic, because we don't want to update the status unexpectedly # on an existing topic - if chat_channel_archive.destination_topic_title.present? + if chat_channel_archive.new_topic? if SiteSetting.chat_archive_destination_topic_status == "archived" chat_channel_archive.destination_topic.update!(archived: true) elsif SiteSetting.chat_archive_destination_topic_status == "closed" @@ -198,16 +246,17 @@ class Chat::ChatChannelArchiveService notify_archiver(:success) end - def notify_archiver(result, error: nil) + def notify_archiver(result, error_message: nil) base_translation_params = { channel_name: chat_channel_title, - topic_title: chat_channel_archive.destination_topic.title, - topic_url: chat_channel_archive.destination_topic.url, + topic_title: chat_channel_archive.destination_topic&.title, + topic_url: chat_channel_archive.destination_topic&.url, + topic_validation_errors: result == :failed_no_topic ? error_message : nil, } - if result == :failed + if result == :failed || result == :failed_no_topic Discourse.warn_exception( - error, + error_message, message: "Error when archiving chat channel #{chat_channel_title}.", env: { chat_channel_id: chat_channel.id, @@ -219,10 +268,17 @@ class Chat::ChatChannelArchiveService channel_url: chat_channel.url, messages_archived: chat_channel_archive.archived_messages, ) - chat_channel_archive.update(archive_error: error.message) + chat_channel_archive.update(archive_error: error_message) + message_translation_key = + case result + when :failed + :chat_channel_archive_failed + when :failed_no_topic + :chat_channel_archive_failed_no_topic + end SystemMessage.create_from_system_user( chat_channel_archive.archived_by, - :chat_channel_archive_failed, + message_translation_key, error_translation_params, ) else @@ -235,7 +291,7 @@ class Chat::ChatChannelArchiveService ChatPublisher.publish_archive_status( chat_channel, - archive_status: result, + archive_status: result != :success ? :failed : :success, archived_messages: chat_channel_archive.archived_messages, archive_topic_id: chat_channel_archive.destination_topic_id, total_messages: chat_channel_archive.total_messages, diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb index 5c4c97c392..2d79e85690 100644 --- a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb @@ -12,11 +12,11 @@ describe Chat::ChatChannelArchiveService do let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } subject { Chat::ChatChannelArchiveService } - describe "#begin_archive_process" do + describe "#create_archive_process" do before { 3.times { Fabricate(:chat_message, chat_channel: channel) } } it "marks the channel as read_only" do - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -25,7 +25,7 @@ describe Chat::ChatChannelArchiveService do end it "creates the chat channel archive record to save progress and topic params" do - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -40,7 +40,7 @@ describe Chat::ChatChannelArchiveService do it "enqueues the archive job" do channel_archive = - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -56,13 +56,13 @@ describe Chat::ChatChannelArchiveService do end it "does nothing if there is already an archive record for the channel" do - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, ) expect { - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -74,7 +74,7 @@ describe Chat::ChatChannelArchiveService do new_message = Fabricate(:chat_message, chat_channel: channel) new_message.trash! channel_archive = - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -90,7 +90,7 @@ describe Chat::ChatChannelArchiveService do def start_archive @channel_archive = - subject.begin_archive_process( + subject.create_archive_process( chat_channel: channel, acting_user: user, topic_params: topic_params, @@ -167,6 +167,23 @@ describe Chat::ChatChannelArchiveService do ) end + it "does not continue archiving if the destination topic fails to be created" do + SiteSetting.max_emojis_in_title = 1 + + create_messages(3) && start_archive + @channel_archive.update!(destination_topic_title: "Wow this is the new title :tada: :joy:") + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(false) + expect(@channel_archive.reload.failed?).to eq(true) + expect(@channel_archive.archive_error).to eq("Title can't have more than 1 emoji") + + pm_topic = Topic.private_messages.last + expect(pm_topic.title).to eq( + I18n.t("system_messages.chat_channel_archive_failed.subject_template"), + ) + expect(pm_topic.first_post.raw).to include("Title can't have more than 1 emoji") + end + describe "channel members" do before do create_messages(3) diff --git a/plugins/chat/spec/requests/api/chat_channels_archives_controller_spec.rb b/plugins/chat/spec/requests/api/chat_channels_archives_controller_spec.rb index c209f5f498..2acd568e9e 100644 --- a/plugins/chat/spec/requests/api/chat_channels_archives_controller_spec.rb +++ b/plugins/chat/spec/requests/api/chat_channels_archives_controller_spec.rb @@ -98,6 +98,22 @@ RSpec.describe Chat::Api::ChatChannelsArchivesController do }.not_to change { ChatChannelArchive.count } end + context "when archiving to a new topic" do + it "returns validation errors if the topic is not valid" do + SiteSetting.max_emojis_in_title = 1 + new_topic_params_invalid = new_topic_params.dup + new_topic_params_invalid[:archive][ + :title + ] = "Some new topic with too many emoji :joy: :sob: :tada:" + sign_in(admin) + expect { + post "/chat/api/channels/#{channel.id}/archives", params: new_topic_params_invalid + }.not_to change { ChatChannelArchive.count } + expect(response.status).to eq(400) + expect(response.parsed_body["errors"]).to eq(["Title can't have more than 1 emoji"]) + end + end + describe "when retrying the archive process" do fab!(:channel) { Fabricate(:category_channel, chatable: category, status: :read_only) } fab!(:archive) do diff --git a/plugins/chat/spec/system/archive_channel_spec.rb b/plugins/chat/spec/system/archive_channel_spec.rb index 998045e1b5..bfbb33f3e9 100644 --- a/plugins/chat/spec/system/archive_channel_spec.rb +++ b/plugins/chat/spec/system/archive_channel_spec.rb @@ -65,6 +65,18 @@ RSpec.describe "Archive channel", type: :system, js: true do expect(page).to have_css(".chat-channel-archive-status") end + it "shows an error when the topic is invalid" do + chat.visit_channel_settings(channel_1) + click_button(I18n.t("js.chat.channel_settings.archive_channel")) + find("#split-topic-name").fill_in( + with: "An interesting topic for cats :cat: :cat2: :smile_cat:", + ) + click_button(I18n.t("js.chat.channel_archive.title")) + + expect(page).not_to have_content(I18n.t("js.chat.channel_archive.process_started")) + expect(page).to have_content("Title can't have more than 1 emoji") + end + context "when archived channels had unreads" do before { channel_1.add(current_user) } From 9fcd8336e41c8f11ce0df7b4f8e34f171637f8bb Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 12 Jan 2023 08:22:28 +0800 Subject: [PATCH 007/169] FIX: Regression in TopicTrackingState MessageBus message scope. (#19835) 0403cda1d1b34c8e27701b165a13bc2969b5e24b introduced a regression where topics in non read-restricted categories have its TopicTrackingState MessageBus messages published with the `group_ids: [nil]` option. This essentially means that no one would be able to view the message. --- app/models/topic_tracking_state.rb | 20 ++- spec/models/topic_tracking_state_spec.rb | 156 ++++++++++++----------- 2 files changed, 98 insertions(+), 78 deletions(-) diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index 10fa727450..490bf9a514 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -95,6 +95,8 @@ class TopicTrackingState end def self.publish_muted(topic) + return unless topic.regular? + user_ids = topic .topic_users @@ -110,6 +112,8 @@ class TopicTrackingState end def self.publish_unmuted(topic) + return unless topic.regular? + user_ids = User .watching_topic(topic) @@ -172,6 +176,8 @@ class TopicTrackingState end def self.publish_recover(topic) + return unless topic.regular? + group_ids = secure_category_group_ids(topic) message = { topic_id: topic.id, message_type: RECOVER_MESSAGE_TYPE } @@ -180,6 +186,8 @@ class TopicTrackingState end def self.publish_delete(topic) + return unless topic.regular? + group_ids = secure_category_group_ids(topic) message = { topic_id: topic.id, message_type: DELETE_MESSAGE_TYPE } @@ -188,6 +196,8 @@ class TopicTrackingState end def self.publish_destroy(topic) + return unless topic.regular? + group_ids = secure_category_group_ids(topic) message = { topic_id: topic.id, message_type: DESTROY_MESSAGE_TYPE } @@ -549,12 +559,14 @@ class TopicTrackingState end def self.secure_category_group_ids(topic) - ids = topic&.category&.secure_group_ids + category = topic.category - if ids.blank? - [Group::AUTO_GROUPS[:admin]] + if category.read_restricted + ids = [Group::AUTO_GROUPS[:admins]] + ids.push(*category.secure_group_ids) + ids.uniq else - ids + nil end end private_class_method :secure_category_group_ids diff --git a/spec/models/topic_tracking_state_spec.rb b/spec/models/topic_tracking_state_spec.rb index b63421d4d4..154ff98c93 100644 --- a/spec/models/topic_tracking_state_spec.rb +++ b/spec/models/topic_tracking_state_spec.rb @@ -3,33 +3,91 @@ RSpec.describe TopicTrackingState do fab!(:user) { Fabricate(:user) } fab!(:whisperers_group) { Fabricate(:group) } - - let(:post) { create_post } - - let(:topic) { post.topic } fab!(:private_message_post) { Fabricate(:private_message_post) } let(:private_message_topic) { private_message_post.topic } + let(:post) { create_post } + let(:topic) { post.topic } - fab!(:read_restricted_category) { Fabricate(:category, read_restricted: true) } - fab!(:read_restricted_topic) { Fabricate(:topic, category: read_restricted_category) } + shared_examples "does not publish message for private topics" do |method| + it "should not publish any message for a private topic" do + messages = + MessageBus.track_publish { described_class.public_send(method, private_message_topic) } - describe ".publish_new" do - it "should publish message only to admin group when category is read restricted but no groups have been granted access" do + expect(messages).to eq([]) + end + end + + shared_examples "publishes message to right groups and users" do |message_bus_channel, method| + fab!(:public_category) { Fabricate(:category, read_restricted: false) } + fab!(:topic_in_public_category) { Fabricate(:topic, category: public_category) } + fab!(:group) { Fabricate(:group) } + fab!(:read_restricted_category_with_groups) { Fabricate(:private_category, group: group) } + + fab!(:topic_in_read_restricted_category_with_groups) do + Fabricate(:topic, category: read_restricted_category_with_groups) + end + + fab!(:read_restricted_category_with_no_groups) { Fabricate(:category, read_restricted: true) } + + fab!(:topic_in_read_restricted_category_with_no_groups) do + Fabricate(:topic, category: read_restricted_category_with_no_groups) + end + + it "should publish message to everyone for a topic in a category that is not read restricted" do message = MessageBus - .track_publish("/new") { described_class.publish_new(read_restricted_topic) } + .track_publish(message_bus_channel) do + described_class.public_send(method, topic_in_public_category) + end .first data = message.data - expect(data["topic_id"]).to eq(read_restricted_topic.id) - expect(data["message_type"]).to eq(described_class::NEW_TOPIC_MESSAGE_TYPE) - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admin]) + expect(data["topic_id"]).to eq(topic_in_public_category.id) + expect(message.group_ids).to eq(nil) + expect(message.user_ids).to eq(nil) + end + + it "should publish message only to admin group and groups that have permission to read a category when topic is in category that is restricted to certain groups" do + message = + MessageBus + .track_publish(message_bus_channel) do + described_class.public_send(method, topic_in_read_restricted_category_with_groups) + end + .first + + data = message.data + + expect(data["topic_id"]).to eq(topic_in_read_restricted_category_with_groups.id) + expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admins], group.id) + expect(message.user_ids).to eq(nil) + end + + it "should publish message only to admin group when topic is in category that is read restricted but no groups have been granted access" do + message = + MessageBus + .track_publish(message_bus_channel) do + described_class.public_send(method, topic_in_read_restricted_category_with_no_groups) + end + .first + + data = message.data + + expect(data["topic_id"]).to eq(topic_in_read_restricted_category_with_no_groups.id) + expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admins]) expect(message.user_ids).to eq(nil) end end + describe ".publish_new" do + include_examples("publishes message to right groups and users", "/new", :publish_new) + include_examples("does not publish message for private topics", :publish_new) + end + describe ".publish_latest" do + include_examples("publishes message to right groups and users", "/latest", :publish_latest) + include_examples("does not publish message for private topics", :publish_latest) + it "can correctly publish latest" do message = MessageBus.track_publish("/latest") { described_class.publish_latest(topic) }.first @@ -38,6 +96,8 @@ RSpec.describe TopicTrackingState do expect(data["topic_id"]).to eq(topic.id) expect(data["message_type"]).to eq(described_class::LATEST_MESSAGE_TYPE) expect(data["payload"]["archetype"]).to eq(Archetype.default) + expect(message.group_ids).to eq(nil) + expect(message.user_ids).to eq(nil) end it "publishes whisper post to staff users and members of whisperers group" do @@ -54,29 +114,6 @@ RSpec.describe TopicTrackingState do expect(message.group_ids).to contain_exactly(whisperers_group.id, Group::AUTO_GROUPS[:staff]) end - - it "should publish message only to admin group when category is read restricted but no groups have been granted access" do - message = - MessageBus - .track_publish("/latest") { described_class.publish_latest(read_restricted_topic) } - .first - - data = message.data - - expect(data["topic_id"]).to eq(read_restricted_topic.id) - expect(data["message_type"]).to eq(described_class::LATEST_MESSAGE_TYPE) - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admin]) - expect(message.user_ids).to eq(nil) - end - - describe "private message" do - it "should not publish any message" do - messages = - MessageBus.track_publish { described_class.publish_latest(private_message_topic) } - - expect(messages).to eq([]) - end - end end describe ".publish_read" do @@ -241,6 +278,8 @@ RSpec.describe TopicTrackingState do let(:user) { Fabricate(:user, last_seen_at: Date.today) } let(:post) { create_post(user: user) } + include_examples("does not publish message for private topics", :publish_muted) + it "can correctly publish muted" do TopicUser.find_by(topic: topic, user: post.user).update(notification_level: 0) messages = MessageBus.track_publish("/latest") { TopicTrackingState.publish_muted(topic) } @@ -273,6 +312,8 @@ RSpec.describe TopicTrackingState do let(:third_user) { Fabricate(:user, last_seen_at: Date.today) } let(:post) { create_post(user: user) } + include_examples("does not publish message for private topics", :publish_unmuted) + it "can correctly publish unmuted" do Fabricate(:topic_tag, topic: topic) SiteSetting.mute_all_categories_by_default = true @@ -734,50 +775,17 @@ RSpec.describe TopicTrackingState do end describe ".publish_recover" do - it "should publish message only to admin group when category is read restricted but no groups have been granted access" do - message = - MessageBus - .track_publish("/recover") { described_class.publish_recover(read_restricted_topic) } - .first - - data = message.data - - expect(data["topic_id"]).to eq(read_restricted_topic.id) - expect(data["message_type"]).to eq(described_class::RECOVER_MESSAGE_TYPE) - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admin]) - expect(message.user_ids).to eq(nil) - end + include_examples("publishes message to right groups and users", "/recover", :publish_recover) + include_examples("does not publish message for private topics", :publish_recover) end describe ".publish_delete" do - it "should publish message only to admin group when category is read restricted but no groups have been granted access" do - message = - MessageBus - .track_publish("/delete") { described_class.publish_delete(read_restricted_topic) } - .first - - data = message.data - - expect(data["topic_id"]).to eq(read_restricted_topic.id) - expect(data["message_type"]).to eq(described_class::DELETE_MESSAGE_TYPE) - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admin]) - expect(message.user_ids).to eq(nil) - end + include_examples("publishes message to right groups and users", "/delete", :publish_delete) + include_examples("does not publish message for private topics", :publish_delete) end describe ".publish_destroy" do - it "should publish message only to admin group when category is read restricted but no groups have been granted access" do - message = - MessageBus - .track_publish("/destroy") { described_class.publish_destroy(read_restricted_topic) } - .first - - data = message.data - - expect(data["topic_id"]).to eq(read_restricted_topic.id) - expect(data["message_type"]).to eq(described_class::DESTROY_MESSAGE_TYPE) - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:admin]) - expect(message.user_ids).to eq(nil) - end + include_examples("publishes message to right groups and users", "/destroy", :publish_destroy) + include_examples("does not publish message for private topics", :publish_destroy) end end From 7b63c42304abbe42bbea50472e55fa7d6af916e5 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 12 Jan 2023 12:29:50 +1100 Subject: [PATCH 008/169] FEATURE: add basic instrumentation to defer queue (#19824) This will give us some aggregate stats on the defer queue performance. It is limited to 100 entries (for safety) which is stored in an LRU cache. Scheduler::Defer.stats can then be used to get an array that denotes: - number of runs and completions (queued, finished) - error count (errors) - total duration (duration) We can look later at exposing these metrics to gain visibility on the reason the defer queue is clogged. --- lib/scheduler/defer.rb | 24 ++++++++++++++++++++++++ spec/lib/scheduler/defer_spec.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/lib/scheduler/defer.rb b/lib/scheduler/defer.rb index 0afdcfbaa0..6b05928006 100644 --- a/lib/scheduler/defer.rb +++ b/lib/scheduler/defer.rb @@ -4,15 +4,18 @@ require "weakref" module Scheduler module Deferrable DEFAULT_TIMEOUT ||= 90 + STATS_CACHE_SIZE ||= 100 def initialize @async = !Rails.env.test? @queue = Queue.new @mutex = Mutex.new + @stats_mutex = Mutex.new @paused = false @thread = nil @reactor = nil @timeout = DEFAULT_TIMEOUT + @stats = LruRedux::ThreadSafeCache.new(STATS_CACHE_SIZE) end def timeout=(t) @@ -23,6 +26,10 @@ module Scheduler @queue.length end + def stats + @stats_mutex.synchronize { @stats.to_a } + end + def pause stop! @paused = true @@ -38,6 +45,11 @@ module Scheduler end def later(desc = nil, db = RailsMultisite::ConnectionManagement.current_db, &blk) + @stats_mutex.synchronize do + stats = (@stats[desc] ||= { queued: 0, finished: 0, duration: 0, errors: 0 }) + stats[:queued] += 1 + end + if @async start_thread if !@thread&.alive? && !@paused @queue << [db, blk, desc] @@ -74,6 +86,7 @@ module Scheduler # using non_block to match Ruby #deq def do_work(non_block = false) db, job, desc = @queue.deq(non_block) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) db ||= RailsMultisite::ConnectionManagement::DEFAULT RailsMultisite::ConnectionManagement.with_connection(db) do @@ -84,6 +97,10 @@ module Scheduler end if !non_block job.call rescue => ex + @stats_mutex.synchronize do + stats = @stats[desc] + stats[:errors] += 1 if stats + end Discourse.handle_job_exception(ex, message: "Running deferred code '#{desc}'") ensure warning_job&.cancel @@ -93,6 +110,13 @@ module Scheduler Discourse.handle_job_exception(ex, message: "Processing deferred code queue") ensure ActiveRecord::Base.connection_handler.clear_active_connections! + @stats_mutex.synchronize do + stats = @stats[desc] + if stats + stats[:finished] += 1 + stats[:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + end + end end end diff --git a/spec/lib/scheduler/defer_spec.rb b/spec/lib/scheduler/defer_spec.rb index 67882c2a7e..266af82bc3 100644 --- a/spec/lib/scheduler/defer_spec.rb +++ b/spec/lib/scheduler/defer_spec.rb @@ -18,6 +18,32 @@ RSpec.describe Scheduler::Defer do after { @defer.stop! } + it "supports basic instrumentation" do + @defer.later("first") {} + @defer.later("first") {} + @defer.later("second") {} + @defer.later("bad") { raise "boom" } + + wait_for(200) { @defer.length == 0 } + + stats = Hash[@defer.stats] + + expect(stats["first"][:queued]).to eq(2) + expect(stats["first"][:finished]).to eq(2) + expect(stats["first"][:errors]).to eq(0) + expect(stats["first"][:duration]).to be > 0 + + expect(stats["second"][:queued]).to eq(1) + expect(stats["second"][:finished]).to eq(1) + expect(stats["second"][:errors]).to eq(0) + expect(stats["second"][:duration]).to be > 0 + + expect(stats["bad"][:queued]).to eq(1) + expect(stats["bad"][:finished]).to eq(1) + expect(stats["bad"][:duration]).to be > 0 + expect(stats["bad"][:errors]).to eq(1) + end + it "supports timeout reporting" do @defer.timeout = 0.05 From 29ef2cb550a0d463e4c359f1b8ff634c52e17b5d Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 12 Jan 2023 12:40:42 +1100 Subject: [PATCH 009/169] FEATURE: raise redirect avatar cache to 1 day (#19840) In "GlobalSetting.redirect_avatar_requests" mode, when the application gets an avatar request it returns a "redirect" to the S3 CDN. This shields the application from caching avatars and downloading from S3. However clients will make 2 requests per avatar. (one to get redirect, second to get avatar) A one hour cache on a redirect means there may be an increase in CDN traffic, given more clients will ask for the redirect every hour. This may also lead to an increase in origin requests to the application. To mitigate lets cache the CDN URL for 1 day. The downside is that any changes to S3 CDN need extra care to allow for the extra 1 day delay. (leave data around for 1 extra day) --- app/controllers/user_avatars_controller.rb | 2 +- spec/requests/user_avatars_controller_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/user_avatars_controller.rb b/app/controllers/user_avatars_controller.rb index abf2408313..c943d32615 100644 --- a/app/controllers/user_avatars_controller.rb +++ b/app/controllers/user_avatars_controller.rb @@ -192,7 +192,7 @@ class UserAvatarsController < ApplicationController end def redirect_s3_avatar(url) - immutable_for 1.hour + immutable_for 1.day redirect_to url, allow_other_host: true end diff --git a/spec/requests/user_avatars_controller_spec.rb b/spec/requests/user_avatars_controller_spec.rb index 45e7851e40..db4146208a 100644 --- a/spec/requests/user_avatars_controller_spec.rb +++ b/spec/requests/user_avatars_controller_spec.rb @@ -139,7 +139,7 @@ RSpec.describe UserAvatarsController do expect(response.status).to eq(302) expect(response.location).to eq("https://s3-cdn.example.com/optimized/path") - expect(response.headers["Cache-Control"]).to eq("max-age=3600, public, immutable") + expect(response.headers["Cache-Control"]).to eq("max-age=86400, public, immutable") end it "serves new version for old urls" do From 779b9add2416516dd5b8f10ddd067efac72c2374 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 11:45:37 +1000 Subject: [PATCH 010/169] DEV: Remove bookmark column ignores (#19838) These columns were deleted in https://github.com/discourse/discourse/commit/f8f55cef678726ecc8c9470150e6478d1c351cd5 --- app/models/bookmark.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb index 3e1a6dab38..e69e67fd02 100644 --- a/app/models/bookmark.rb +++ b/app/models/bookmark.rb @@ -1,11 +1,6 @@ # frozen_string_literal: true class Bookmark < ActiveRecord::Base - self.ignored_columns = [ - "post_id", # TODO (martin) (2022-08-01) remove - "for_topic", # TODO (martin) (2022-08-01) remove - ] - cattr_accessor :registered_bookmarkables self.registered_bookmarkables = [] From 98d5a0e63c5f8dfa38b379d9b5301f56c34c7f10 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 11:45:53 +1000 Subject: [PATCH 011/169] DEV: Remove old TODO for chat webhooks (#19839) I inspected the JSON payload from OpsGenie and added a note to show what it can look like. --- .../incoming_chat_webhooks_controller.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb index f528e59513..58d730cbc6 100644 --- a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb +++ b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb @@ -85,12 +85,15 @@ class Chat::IncomingChatWebhooksController < ApplicationController ) end + # The webhook POST body can be in 3 different formats: + # + # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads + # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments + # * { payload: "", attachments: null, text: null }, where JSON STRING can look + # like the `attachments` example above (along with other attributes), which is fired by OpsGenie def validate_payload - params.require([:key]) + params.require(:key) - # TODO (martin) It is not clear whether the :payload key is actually - # present in the webhooks sent from OpsGenie, so once it is confirmed - # in production what we are actually getting then we can remove this. if !params[:text] && !params[:payload] && !params[:attachments] raise Discourse::InvalidParameters end @@ -99,7 +102,7 @@ class Chat::IncomingChatWebhooksController < ApplicationController def debug_payload return if !SiteSetting.chat_debug_webhook_payloads Rails.logger.warn( - "Debugging chat webhook payload: " + + "Debugging chat webhook payload for endpoint #{params[:key]}: " + JSON.dump( { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, ), From 421fbfd1c7e8916889a4d5dc6ea39287dadf764c Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Thu, 12 Jan 2023 09:46:50 +0800 Subject: [PATCH 012/169] FIX: Fix flaky test resulting from PostAlerter keyword arguments (#19826) We've been doing some work to support new keyword argument semantics in Ruby 3. As part of that we made some changes to `DiscourseEvent::TestHelper`. The backwards compatibility fix doesn't work if the method is called with an empty hash as the final argument. This fix adds a valid option to the final hash in the particular test. --- spec/services/post_alerter_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index 43d63b46d6..d35ea6a831 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -1053,10 +1053,12 @@ RSpec.describe PostAlerter do it "triggers :before_create_notification" do type = Notification.types[:private_message] events = - DiscourseEvent.track_events { PostAlerter.new.create_notification(user, type, post, {}) } + DiscourseEvent.track_events do + PostAlerter.new.create_notification(user, type, post, { revision_number: 1 }) + end expect(events).to include( event_name: :before_create_notification, - params: [user, type, post, {}], + params: [user, type, post, { revision_number: 1 }], ) end end From 1f59a8299d582d6a099280af5a7ab544873699ed Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 13:54:15 +1000 Subject: [PATCH 013/169] DEV: Address TODOs for email Message-ID generation (#19842) Remove some old deprecated methods and update docs. Will leave the backwards-compatible Message-ID formats a little while longer just to be sure. --- lib/email/message_id_service.rb | 59 +++++++-------------------- spec/lib/message_id_service_spec.rb | 63 ----------------------------- 2 files changed, 15 insertions(+), 107 deletions(-) diff --git a/lib/email/message_id_service.rb b/lib/email/message_id_service.rb index 4ba6b0d737..c2cdcbbf2b 100644 --- a/lib/email/message_id_service.rb +++ b/lib/email/message_id_service.rb @@ -5,14 +5,21 @@ module Email # Email Message-IDs are used in both our outbound and inbound email # flow. For the outbound flow via Email::Sender, we assign a unique # Message-ID for any emails sent out from the application. - # If we are sending an email related to a topic, such as through the + # If we are sending an email related to a post, such as through the # PostAlerter class, then the Message-ID will contain references to - # the topic ID, and if it is for a specific post, the post ID, - # along with a random suffix to make the Message-ID truly unique. - # The host must also be included on the Message-IDs. + # the post ID. The host must also be included on the Message-IDs. + # The format looks like this: + # + # discourse/post/POST_ID@HOST + # + # We previously had the following formats, but support for these + # will be removed in 2023: + # + # topic/TOPIC_ID/POST_ID@HOST + # topic/TOPIC_ID@HOST # # For the inbound email flow via Email::Receiver, we use Message-IDs - # to discern which topic or post the inbound email reply should be + # to discern which topic and post the inbound email reply should be # in response to. In this case, the Message-ID is extracted from the # References and/or In-Reply-To headers, and compared with either # the IncomingEmail table, the Post table, or the IncomingEmail to @@ -29,38 +36,6 @@ module Email "<#{SecureRandom.uuid}@#{host}>" end - # TODO (martin) 2023-01-01 Deprecated, remove this once the new threading - # systems have been in place for a while. - def generate_for_post(post, use_incoming_email_if_present: false) - if use_incoming_email_if_present && post.incoming_email&.message_id.present? - return "<#{post.incoming_email.message_id}>" - end - - "" - end - - # TODO (martin) 2023-01-01 Deprecated, remove this once the new threading - # systems have been in place for a while. - def generate_for_topic(topic, use_incoming_email_if_present: false, canonical: false) - first_post = topic.ordered_posts.first - incoming_email = first_post.incoming_email - - # If the incoming email was created by handle_mail, then it was an - # inbound email sent to Discourse and handled by Email::Receiver, - # this is the only case where we want to use the original Message-ID - # because we want to maintain threading in the original mail client. - if use_incoming_email_if_present && incoming_email&.message_id.present? && - incoming_email&.created_via == IncomingEmail.created_via_types[:handle_mail] - return "<#{first_post.incoming_email.message_id}>" - end - - if canonical - "" - else - "" - end - end - ## # The outbound_message_id may be present because either: # @@ -96,7 +71,7 @@ module Email def find_post_from_message_ids(message_ids) message_ids = message_ids.map { |message_id| message_id_clean(message_id) } - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. topic_ids = @@ -131,11 +106,7 @@ module Email Post.where(id: post_ids).order(:created_at).last end - def random_suffix - SecureRandom.hex(12) - end - - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. def discourse_generated_message_id?(message_id) @@ -144,7 +115,7 @@ module Email !!(message_id =~ message_id_discourse_regexp) end - # TODO (martin) 2023-01-01 We should remove these backwards-compatible + # TODO (martin) 2023-04-01 We should remove these backwards-compatible # formats for the Message-ID and solely use the discourse/post/999@host # format. def message_id_post_id_regexp diff --git a/spec/lib/message_id_service_spec.rb b/spec/lib/message_id_service_spec.rb index dc8fa436fc..271bed7706 100644 --- a/spec/lib/message_id_service_spec.rb +++ b/spec/lib/message_id_service_spec.rb @@ -7,69 +7,6 @@ RSpec.describe Email::MessageIdService do subject { described_class } - describe "#generate_for_post" do - it "generates for the post using the message_id on the post's incoming_email" do - Fabricate(:incoming_email, message_id: "test@test.localhost", post: post) - post.reload - expect(subject.generate_for_post(post, use_incoming_email_if_present: true)).to eq( - "", - ) - end - - it "generates for the post without an incoming_email record" do - expect(subject.generate_for_post(post)).to match(subject.message_id_post_id_regexp) - expect(subject.generate_for_post(post, use_incoming_email_if_present: true)).to match( - subject.message_id_post_id_regexp, - ) - end - end - - describe "#generate_for_topic" do - it "generates for the topic using the message_id on the first post's incoming_email" do - Fabricate( - :incoming_email, - message_id: "test213428@somemailservice.com", - post: post, - created_via: IncomingEmail.created_via_types[:handle_mail], - ) - post.reload - expect(subject.generate_for_topic(topic, use_incoming_email_if_present: true)).to eq( - "", - ) - end - - it "does not use the first post's incoming email if it was created via group_smtp, only handle_mail" do - incoming = - Fabricate( - :incoming_email, - message_id: "test213428@somemailservice.com", - post: post, - created_via: IncomingEmail.created_via_types[:group_smtp], - ) - post.reload - expect(subject.generate_for_topic(topic, use_incoming_email_if_present: true)).to match( - subject.message_id_topic_id_regexp, - ) - incoming.update(created_via: IncomingEmail.created_via_types[:handle_mail]) - expect(subject.generate_for_topic(topic, use_incoming_email_if_present: true)).to eq( - "", - ) - end - - it "generates for the topic without an incoming_email record" do - expect(subject.generate_for_topic(topic)).to match(subject.message_id_topic_id_regexp) - expect(subject.generate_for_topic(topic, use_incoming_email_if_present: true)).to match( - subject.message_id_topic_id_regexp, - ) - end - - it "generates canonical for the topic" do - canonical_topic_id = subject.generate_for_topic(topic, canonical: true) - expect(canonical_topic_id).to match(subject.message_id_topic_id_regexp) - expect(canonical_topic_id).to eq("") - end - end - describe "#generate_or_use_existing" do it "does not override a post's existing outbound_message_id" do post.update!(outbound_message_id: "blah@host.test") From 2ed75dbaf6617a69eefaacca1b194e30b063a068 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 13:54:26 +1000 Subject: [PATCH 014/169] DEV: DRY up PageObject::Topic and PageObject::Components::Composer (#19841) The latter can be called directly from the Topic page object, so we can remove some duplication between the two. There are levels of page objects (e.g. entire page, component, complete flow) and its perfectly valid to call one from another. --- .../page_objects/components/composer.rb | 25 ++++++++++---- spec/system/page_objects/pages/base.rb | 4 --- spec/system/page_objects/pages/topic.rb | 33 ++++++++++--------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/spec/system/page_objects/components/composer.rb b/spec/system/page_objects/components/composer.rb index 28dd6b88a2..1ac5ebfb6a 100644 --- a/spec/system/page_objects/components/composer.rb +++ b/spec/system/page_objects/components/composer.rb @@ -3,12 +3,6 @@ module PageObjects module Components class Composer < PageObjects::Components::Base - def open_new_topic - visit("/latest") - find("button#create-topic").click - self - end - def open_composer_actions find(".composer-action-title .btn").click self @@ -20,10 +14,23 @@ module PageObjects end def fill_content(content) - find("#reply-control .d-editor-input").fill_in(with: content) + composer_input.fill_in(with: content) self end + def type_content(content) + composer_input.send_keys(content) + self + end + + def clear_content + fill_content("") + end + + def has_content?(content) + composer_input.value == content + end + def select_action(action) find(action(action)).click self @@ -40,6 +47,10 @@ module PageObjects def button_label find("#reply-control .btn-primary .d-button-label") end + + def composer_input + find("#reply-control .d-editor .d-editor-input") + end end end end diff --git a/spec/system/page_objects/pages/base.rb b/spec/system/page_objects/pages/base.rb index 60c2ffb24f..48b005fc1c 100644 --- a/spec/system/page_objects/pages/base.rb +++ b/spec/system/page_objects/pages/base.rb @@ -4,10 +4,6 @@ module PageObjects module Pages class Base include Capybara::DSL - - def setup_component_classes!(component_classes) - @component_classes = component_classes - end end end end diff --git a/spec/system/page_objects/pages/topic.rb b/spec/system/page_objects/pages/topic.rb index efe278d05c..f863315468 100644 --- a/spec/system/page_objects/pages/topic.rb +++ b/spec/system/page_objects/pages/topic.rb @@ -4,13 +4,7 @@ module PageObjects module Pages class Topic < PageObjects::Pages::Base def initialize - setup_component_classes!( - post_show_more_actions: ".show-more-actions", - post_action_button_bookmark: ".bookmark.with-reminder", - reply_button: ".topic-footer-main-buttons > .create", - composer: "#reply-control", - composer_textarea: "#reply-control .d-editor .d-editor-input", - ) + @composer_component = PageObjects::Components::Composer.new end def visit_topic(topic) @@ -18,6 +12,17 @@ module PageObjects self end + def open_new_topic + page.visit "/" + find("button#create-topic").click + self + end + + def open_new_message + page.visit "/new-message" + self + end + def visit_topic_and_open_composer(topic) visit_topic(topic) click_reply_button @@ -85,24 +90,20 @@ module PageObjects has_css?("#reply-control.open") end - def find_composer - find("#reply-control .d-editor .d-editor-input") - end - def type_in_composer(input) - find_composer.send_keys(input) + @composer_component.type_content(input) end def fill_in_composer(input) - find_composer.fill_in(with: input) + @composer_component.fill_content(input) end def clear_composer - fill_in_composer("") + @composer_component.clear_content end def has_composer_content?(content) - find_composer.value == content + @composer_component.has_content?(content) end def send_reply @@ -110,7 +111,7 @@ module PageObjects end def fill_in_composer_title(title) - find("#reply-title").fill_in(with: title) + @composer_component.fill_title(title) end private From 9a6eefaafc707b624c4ff6adbf6a788b9d9aaf18 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 12 Jan 2023 14:12:49 +1000 Subject: [PATCH 015/169] DEV: Fix chat sidebar system spec flaky (#19844) The spec was flaky because it was dependent on order, when usernames got high enough sequence numbers in them we would get this error: > expected to find text "bruce99, bruce100" in "bruce100, bruce99" Also move selectors into page object and use them in the spec instead. --- .../system/page_objects/sidebar/sidebar.rb | 8 +++ .../system/sidebar_navigation_menu_spec.rb | 52 +++++++++---------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb index e416e67c89..ededff5373 100644 --- a/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb +++ b/plugins/chat/spec/system/page_objects/sidebar/sidebar.rb @@ -3,6 +3,14 @@ module PageObjects module Pages class Sidebar < PageObjects::Pages::Base + def channels_section + find(".sidebar-section-chat-channels") + end + + def dms_section + find(".sidebar-section-chat-dms") + end + def open_draft_channel find(".sidebar-section-chat-dms .sidebar-section-header-button", visible: false).click end diff --git a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb index 33f7a9b78d..bdbde12575 100644 --- a/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb +++ b/plugins/chat/spec/system/sidebar_navigation_menu_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.describe "Sidebar navigation menu", type: :system, js: true do + let(:sidebar_page) { PageObjects::Pages::Sidebar.new } + fab!(:current_user) { Fabricate(:user) } before do @@ -18,8 +20,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "displays correct channels section title" do visit("/") - expect(page).to have_css( - ".sidebar-section-chat-channels .sidebar-section-header-text", + expect(sidebar_page.channels_section).to have_css( + ".sidebar-section-header-text", text: I18n.t("js.chat.chat_channels"), ) end @@ -27,8 +29,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "displays the correct hash icon prefix" do visit("/") - expect(page).to have_css( - ".sidebar-section-chat-channels .sidebar-section-link-#{channel_1.slug} .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag", + expect(sidebar_page.channels_section).to have_css( + ".sidebar-section-link-#{channel_1.slug} .sidebar-section-link-prefix svg.prefix-icon.d-icon-hashtag", ) end @@ -53,8 +55,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "has a lock badge" do visit("/") - expect(page).to have_css( - ".sidebar-section-chat-channels .sidebar-section-link-#{private_channel_1.slug} .sidebar-section-link-prefix svg.prefix-badge.d-icon-lock", + expect(sidebar_page.channels_section).to have_css( + ".sidebar-section-link-#{private_channel_1.slug} .sidebar-section-link-prefix svg.prefix-badge.d-icon-lock", ) end end @@ -67,8 +69,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "unescapes the emoji" do visit("/") - expect(page).to have_css( - ".sidebar-section-chat-channels .sidebar-section-link-#{channel_1.slug} .emoji", + expect(sidebar_page.channels_section).to have_css( + ".sidebar-section-link-#{channel_1.slug} .emoji", ) end end @@ -88,8 +90,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "has a muted class" do visit("/") - expect(page).to have_css( - ".sidebar-section-chat-channels .sidebar-section-link-#{channel_2.slug}.sidebar-section-link--muted", + expect(sidebar_page.channels_section).to have_css( + ".sidebar-section-link-#{channel_2.slug}.sidebar-section-link--muted", ) end end @@ -101,9 +103,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do visit("/") expect( - page.find(".sidebar-section-chat-channels .sidebar-section-link-#{channel_1.slug}")[ - "title" - ], + sidebar_page.channels_section.find(".sidebar-section-link-#{channel_1.slug}")["title"], ).to eq("<script>alert('hello')</script>") end end @@ -118,8 +118,8 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do visit("/") expect( - page.find( - ".sidebar-section-chat-dms a.sidebar-section-link:nth-child(1) .sidebar-section-link-prefix img", + sidebar_page.dms_section.find( + "a.sidebar-section-link:nth-child(1) .sidebar-section-link-prefix img", )[ "src" ], @@ -130,7 +130,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do visit("/") expect( - page.find(".sidebar-section-chat-dms a.sidebar-section-link:nth-child(1)"), + sidebar_page.dms_section.find("a.sidebar-section-link:nth-child(1)"), ).to have_content(other_user.username) end @@ -143,27 +143,27 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "displays the status" do visit("/") - expect( - page.find(".sidebar-section-chat-dms a.sidebar-section-link:nth-child(1)"), - ).to have_css(".user-status") + expect(sidebar_page.dms_section.find("a.sidebar-section-link:nth-child(1)")).to have_css( + ".user-status", + ) end end end context "when channel has more than 2 participants" do - fab!(:user_1) { Fabricate(:user) } - fab!(:user_2) { Fabricate(:user) } + fab!(:user_1) { Fabricate(:user, username: "zoesmith") } + fab!(:user_2) { Fabricate(:user, username: "alansmith") } fab!(:dm_channel_1) do Fabricate(:direct_message_channel, users: [current_user, user_1, user_2]) end - it "displays all participants names" do + it "displays all participants names in alphabetical order" do visit("/") expect( - page.find( - ".sidebar-section-chat-dms a.sidebar-section-link:nth-child(1) .sidebar-section-link-content-text", + sidebar_page.dms_section.find( + "a.sidebar-section-link:nth-child(1) .sidebar-section-link-content-text", ), - ).to have_content("#{user_1.username}, #{user_2.username}") + ).to have_content("alansmith, zoesmith") end end @@ -179,7 +179,7 @@ RSpec.describe "Sidebar navigation menu", type: :system, js: true do it "escapes the title attribute using it" do visit("/") - expect(page.find(".sidebar-section-chat-dms .channel-#{dm_channel_1.id}")["title"]).to eq( + expect(sidebar_page.dms_section.find(".channel-#{dm_channel_1.id}")["title"]).to eq( "Chat with @<script>alert('hello')</script>", ) end From 9e55a1ca88ad21bf0d043de10333f006fdde1ca7 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Thu, 12 Jan 2023 12:46:48 +0800 Subject: [PATCH 016/169] DEV: Fix typo in chat spec (#19836) --- plugins/chat/spec/requests/chat_controller_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/chat/spec/requests/chat_controller_spec.rb b/plugins/chat/spec/requests/chat_controller_spec.rb index 8ff5912df5..0b99361235 100644 --- a/plugins/chat/spec/requests/chat_controller_spec.rb +++ b/plugins/chat/spec/requests/chat_controller_spec.rb @@ -997,7 +997,8 @@ RSpec.describe Chat::ChatController do end it "doesn't invite users who cannot chat" do - SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:admin] + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:admins] + expect { put "/chat/#{chat_channel.id}/invite.json", params: { user_ids: [user.id] } }.not_to change { From 1a759fd75f895fad02193caab715a3abc84e0c01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jan 2023 10:47:23 +0100 Subject: [PATCH 017/169] Build(deps): Bump @ember/render-modifiers in /app/assets/javascripts (#19832) Bumps [@ember/render-modifiers](https://github.com/emberjs/ember-render-modifiers) from 2.0.4 to 2.0.5. - [Release notes](https://github.com/emberjs/ember-render-modifiers/releases) - [Changelog](https://github.com/emberjs/ember-render-modifiers/blob/master/CHANGELOG.md) - [Commits](https://github.com/emberjs/ember-render-modifiers/compare/v2.0.4...v2.0.5) --- updated-dependencies: - dependency-name: "@ember/render-modifiers" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/assets/javascripts/discourse/package.json | 2 +- app/assets/javascripts/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index c4aa0e7d36..860fcbea48 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -23,7 +23,7 @@ "@ember-compat/tracked-built-ins": "^0.9.1", "@ember/jquery": "^2.0.0", "@ember/optional-features": "^2.0.0", - "@ember/render-modifiers": "^2.0.4", + "@ember/render-modifiers": "^2.0.5", "@ember/test-helpers": "^2.9.3", "@glimmer/component": "^1.1.2", "@glimmer/syntax": "^0.84.2", diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index 2b8a33d8d7..cd2bd73c9e 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -1054,10 +1054,10 @@ mkdirp "^1.0.4" silent-error "^1.1.1" -"@ember/render-modifiers@^2.0.4": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@ember/render-modifiers/-/render-modifiers-2.0.4.tgz#0ac7af647cb736076dbfcd54ca71e090cd329d71" - integrity sha512-Zh/fo5VUmVzYHkHVvzWVjJ1RjFUxA2jH0zCp2+DQa80Bf3DUXauiEByxU22UkN4LFT55DBFttC0xCQSJG3WTsg== +"@ember/render-modifiers@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@ember/render-modifiers/-/render-modifiers-2.0.5.tgz#4b1d9496a82ca471aeaa3ecddd94ef089450f415" + integrity sha512-5cJ1niIdOJC6k6KtIn9HGbr1DATJQp4ZqMv1vbi6LKQWbVCQ3byvKONtUEi3H0wcewlrcaWCqXOgm0nACzCOQA== dependencies: "@embroider/macros" "^1.0.0" ember-cli-babel "^7.26.11" From 66e8fe9cc63b86ce83b380a2c9563723affefffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Guitaut?= Date: Thu, 19 May 2022 16:58:31 +0200 Subject: [PATCH 018/169] DEV: Migrate existing cookies to Rails 7 format This patch introduces a cookies rotator as indicated in the Rails upgrade guide. This allows to migrate from the old SHA1 digest to the new SHA256 digest. --- config/application.rb | 3 ++ .../new_framework_defaults_7_0.rb | 2 +- lib/middleware/cookies_rotator.rb | 34 +++++++++++++++++++ spec/integration/multisite_cookies_spec.rb | 29 ++++++++++++++-- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 lib/middleware/cookies_rotator.rb diff --git a/config/application.rb b/config/application.rb index 0c31d3a327..d59e525da7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -171,6 +171,9 @@ module Discourse require "middleware/discourse_public_exceptions" config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path) + require "middleware/cookies_rotator" + config.middleware.insert_before ActionDispatch::Cookies, Middleware::CookiesRotator + require "discourse_js_processor" require "discourse_sourcemapping_url_processor" diff --git a/config/initializers/new_framework_defaults_7_0.rb b/config/initializers/new_framework_defaults_7_0.rb index fdbf91e62f..a52539798f 100644 --- a/config/initializers/new_framework_defaults_7_0.rb +++ b/config/initializers/new_framework_defaults_7_0.rb @@ -25,7 +25,7 @@ Rails.application.config.action_view.apply_stylesheet_media_default = false # # See upgrading guide for more information on how to build a rotator. # https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html -# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256 +Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256 # Change the digest class for ActiveSupport::Digest. # Changing this default means that for example Etags change and diff --git a/lib/middleware/cookies_rotator.rb b/lib/middleware/cookies_rotator.rb new file mode 100644 index 0000000000..03cc327215 --- /dev/null +++ b/lib/middleware/cookies_rotator.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Implementing cookies rotator for Rails 7+ as a middleware because this will +# work in single site mode AND in multisite mode without leaking anything in +# `Rails.application.config.action_dispatch.cookies_rotations`. +module Middleware + class CookiesRotator + def initialize(app) + @app = app + end + + def call(env) + request = ActionDispatch::Request.new(env) + env[ + ActionDispatch::Cookies::COOKIES_ROTATIONS + ] = ActiveSupport::Messages::RotationConfiguration.new.tap do |cookies| + key_generator = + ActiveSupport::KeyGenerator.new( + request.secret_key_base, + iterations: 1000, + hash_digest_class: OpenSSL::Digest::SHA1, + ) + key_len = ActiveSupport::MessageEncryptor.key_len + + cookies.rotate( + :encrypted, + key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len), + ) + cookies.rotate(:signed, key_generator.generate_key(request.signed_cookie_salt)) + end + @app.call(env) + end + end +end diff --git a/spec/integration/multisite_cookies_spec.rb b/spec/integration/multisite_cookies_spec.rb index 9a24b4d0e2..09d043d3d2 100644 --- a/spec/integration/multisite_cookies_spec.rb +++ b/spec/integration/multisite_cookies_spec.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true RSpec.describe "multisite", type: %i[multisite request] do + let!(:first_host) { get "http://test.localhost/session/csrf.json" } + it "works" do get "http://test.localhost/session/csrf.json" - expect(response.status).to eq(200) + expect(response).to have_http_status :ok cookie = CGI.escape(response.cookies["_forum_session"]) id1 = session["session_id"] @@ -11,7 +13,7 @@ RSpec.describe "multisite", type: %i[multisite request] do headers: { "Cookie" => "_forum_session=#{cookie};", } - expect(response.status).to eq(200) + expect(response).to have_http_status :ok id2 = session["session_id"] expect(id1).to eq(id2) @@ -20,10 +22,31 @@ RSpec.describe "multisite", type: %i[multisite request] do headers: { "Cookie" => "_forum_session=#{cookie};", } - expect(response.status).to eq(200) + expect(response).to have_http_status :ok id3 = session["session_id"] # Session cookie was rejected and rotated expect(id2).not_to eq(id3) end + + describe "Cookies rotator" do + let!(:rotations) { request.cookies_rotations } + let(:second_host) { get "http://test2.localhost/session/csrf.json" } + let(:global_rotations) { Rails.application.config.action_dispatch.cookies_rotations } + + it "adds different rotations for different hosts" do + first_host + expect(request.cookies_rotations).to have_attributes signed: rotations.signed, + encrypted: rotations.encrypted + + second_host + expect(request.cookies_rotations).not_to have_attributes signed: rotations.signed, + encrypted: rotations.encrypted + end + + it "doesn't change global rotations" do + second_host + expect(global_rotations).to have_attributes signed: [], encrypted: [] + end + end end From 4093fc6074b5586ed5ea4479492171277e5dba86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Guitaut?= Date: Thu, 12 Jan 2023 12:01:20 +0100 Subject: [PATCH 019/169] Revert "DEV: Migrate existing cookies to Rails 7 format" This reverts commit 66e8fe9cc63b86ce83b380a2c9563723affefffa as it unexpectedly caused some users to be logged out. We are investigating the problem. --- config/application.rb | 3 -- .../new_framework_defaults_7_0.rb | 2 +- lib/middleware/cookies_rotator.rb | 34 ------------------- spec/integration/multisite_cookies_spec.rb | 29 ++-------------- 4 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 lib/middleware/cookies_rotator.rb diff --git a/config/application.rb b/config/application.rb index d59e525da7..0c31d3a327 100644 --- a/config/application.rb +++ b/config/application.rb @@ -171,9 +171,6 @@ module Discourse require "middleware/discourse_public_exceptions" config.exceptions_app = Middleware::DiscoursePublicExceptions.new(Rails.public_path) - require "middleware/cookies_rotator" - config.middleware.insert_before ActionDispatch::Cookies, Middleware::CookiesRotator - require "discourse_js_processor" require "discourse_sourcemapping_url_processor" diff --git a/config/initializers/new_framework_defaults_7_0.rb b/config/initializers/new_framework_defaults_7_0.rb index a52539798f..fdbf91e62f 100644 --- a/config/initializers/new_framework_defaults_7_0.rb +++ b/config/initializers/new_framework_defaults_7_0.rb @@ -25,7 +25,7 @@ Rails.application.config.action_view.apply_stylesheet_media_default = false # # See upgrading guide for more information on how to build a rotator. # https://guides.rubyonrails.org/v7.0/upgrading_ruby_on_rails.html -Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256 +# Rails.application.config.active_support.key_generator_hash_digest_class = OpenSSL::Digest::SHA256 # Change the digest class for ActiveSupport::Digest. # Changing this default means that for example Etags change and diff --git a/lib/middleware/cookies_rotator.rb b/lib/middleware/cookies_rotator.rb deleted file mode 100644 index 03cc327215..0000000000 --- a/lib/middleware/cookies_rotator.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -# Implementing cookies rotator for Rails 7+ as a middleware because this will -# work in single site mode AND in multisite mode without leaking anything in -# `Rails.application.config.action_dispatch.cookies_rotations`. -module Middleware - class CookiesRotator - def initialize(app) - @app = app - end - - def call(env) - request = ActionDispatch::Request.new(env) - env[ - ActionDispatch::Cookies::COOKIES_ROTATIONS - ] = ActiveSupport::Messages::RotationConfiguration.new.tap do |cookies| - key_generator = - ActiveSupport::KeyGenerator.new( - request.secret_key_base, - iterations: 1000, - hash_digest_class: OpenSSL::Digest::SHA1, - ) - key_len = ActiveSupport::MessageEncryptor.key_len - - cookies.rotate( - :encrypted, - key_generator.generate_key(request.authenticated_encrypted_cookie_salt, key_len), - ) - cookies.rotate(:signed, key_generator.generate_key(request.signed_cookie_salt)) - end - @app.call(env) - end - end -end diff --git a/spec/integration/multisite_cookies_spec.rb b/spec/integration/multisite_cookies_spec.rb index 09d043d3d2..9a24b4d0e2 100644 --- a/spec/integration/multisite_cookies_spec.rb +++ b/spec/integration/multisite_cookies_spec.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true RSpec.describe "multisite", type: %i[multisite request] do - let!(:first_host) { get "http://test.localhost/session/csrf.json" } - it "works" do get "http://test.localhost/session/csrf.json" - expect(response).to have_http_status :ok + expect(response.status).to eq(200) cookie = CGI.escape(response.cookies["_forum_session"]) id1 = session["session_id"] @@ -13,7 +11,7 @@ RSpec.describe "multisite", type: %i[multisite request] do headers: { "Cookie" => "_forum_session=#{cookie};", } - expect(response).to have_http_status :ok + expect(response.status).to eq(200) id2 = session["session_id"] expect(id1).to eq(id2) @@ -22,31 +20,10 @@ RSpec.describe "multisite", type: %i[multisite request] do headers: { "Cookie" => "_forum_session=#{cookie};", } - expect(response).to have_http_status :ok + expect(response.status).to eq(200) id3 = session["session_id"] # Session cookie was rejected and rotated expect(id2).not_to eq(id3) end - - describe "Cookies rotator" do - let!(:rotations) { request.cookies_rotations } - let(:second_host) { get "http://test2.localhost/session/csrf.json" } - let(:global_rotations) { Rails.application.config.action_dispatch.cookies_rotations } - - it "adds different rotations for different hosts" do - first_host - expect(request.cookies_rotations).to have_attributes signed: rotations.signed, - encrypted: rotations.encrypted - - second_host - expect(request.cookies_rotations).not_to have_attributes signed: rotations.signed, - encrypted: rotations.encrypted - end - - it "doesn't change global rotations" do - second_host - expect(global_rotations).to have_attributes signed: [], encrypted: [] - end - end end From 5dcb245eacfa66f7fba794e61a45ebd21ea7fae9 Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Thu, 12 Jan 2023 19:12:20 +0800 Subject: [PATCH 020/169] FIX: Ruby 2 backward compatible plugin logout redirect (#19845) This is a very subtle one. Setting the redirect URL is done by passing a hash through a Discourse event. This is broken on Ruby 2 since the support for keyword arguments in events was added. In Ruby 2 the last argument is cast to keyword arguments if it is a hash. The key point here is that creates a new copy of the hash, so what the plugin is modifying is not the hash that was passed. --- app/controllers/session_controller.rb | 2 +- lib/discourse.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index 9c8216522c..a11b1bc7a8 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -594,7 +594,7 @@ class SessionController < ApplicationController client_ip: request&.ip, user_agent: request&.user_agent, } - DiscourseEvent.trigger(:before_session_destroy, event_data) + DiscourseEvent.trigger(:before_session_destroy, event_data, **Discourse::Utils::EMPTY_KEYWORDS) redirect_url = event_data[:redirect_url] reset_session diff --git a/lib/discourse.rb b/lib/discourse.rb index acb8d2060d..577ae78100 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -12,6 +12,9 @@ module Discourse class Utils URI_REGEXP ||= URI.regexp(%w[http https]) + # TODO: Remove this once we drop support for Ruby 2. + EMPTY_KEYWORDS ||= {} + # Usage: # Discourse::Utils.execute_command("pwd", chdir: 'mydirectory') # or with a block From ab9ea509170c07569c007bd8bd6a24bd03a33434 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 12 Jan 2023 13:52:50 +0000 Subject: [PATCH 021/169] Bump minimum Ruby version to 3.1 (#19848) --- config/application.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/application.rb b/config/application.rb index 0c31d3a327..466fe3fcc9 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("2.7.0") - STDERR.puts "Discourse requires Ruby 2.7 or above" +if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1.0") + STDERR.puts "Discourse requires Ruby 3.1 or above" exit 1 end From 192d8c25e6f1f89002400d60fde2f85542d4fd87 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Thu, 12 Jan 2023 12:12:19 -0300 Subject: [PATCH 022/169] DEV: Move back to web-push gem (#19849) Our fork was needed for OpenSSL 3 and Ruby 2.X compatibility. The OpenSSL 3 part was merged into the gem for version 3. Discourse dropped support for Ruby 2.X. That means we don't need our fork anymore. --- Gemfile | 7 +------ Gemfile.lock | 15 +++++---------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Gemfile b/Gemfile index ef77228f74..ec96ff7007 100644 --- a/Gemfile +++ b/Gemfile @@ -261,12 +261,7 @@ if ENV["IMPORT"] == "1" gem "parallel", require: false end -# workaround for openssl 3.0, see -# https://github.com/pushpad/web-push/pull/2 -gem "web-push", - require: false, - git: "https://github.com/xfalcox/web-push", - branch: "openssl-3-compat" +gem "web-push" gem "colored2", require: false gem "maxminddb" diff --git a/Gemfile.lock b/Gemfile.lock index 06f20a01c2..e62460537d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,15 +5,6 @@ GIT mail (2.8.0.edge) mini_mime (>= 0.1.1) -GIT - remote: https://github.com/xfalcox/web-push - revision: 369df8f475a4cd4832a7679bec16576665f24d24 - branch: openssl-3-compat - specs: - web-push (2.1.0) - hkdf (~> 1.0) - jwt (~> 2.0) - openssl (~> 3.0) GEM remote: https://rubygems.org/ @@ -483,6 +474,10 @@ GEM uri (0.12.0) uri_template (0.7.0) version_gem (1.1.1) + web-push (3.0.0) + hkdf (~> 1.0) + jwt (~> 2.0) + openssl (~> 3.0) webdrivers (5.2.0) nokogiri (~> 1.6) rubyzip (>= 1.3.0) @@ -638,7 +633,7 @@ DEPENDENCIES uglifier unf unicorn - web-push! + web-push webdrivers webmock webrick From 8fd9a93a1a82f9119536e0382603b4b06ba7d22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= <1108771+megothss@users.noreply.github.com> Date: Thu, 12 Jan 2023 14:07:42 -0300 Subject: [PATCH 023/169] DEV: Added notification type for 'discourse-circles' (#19834) Reserved an ID to be used by notifications generated on the `discourse-circles` plugin. --- app/models/notification.rb | 1 + spec/requests/api/schemas/json/site_response.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/app/models/notification.rb b/app/models/notification.rb index f8aeab2b73..86ec8057ac 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -155,6 +155,7 @@ class Notification < ActiveRecord::Base following: 800, # Used by https://github.com/discourse/discourse-follow following_created_topic: 801, # Used by https://github.com/discourse/discourse-follow following_replied: 802, # Used by https://github.com/discourse/discourse-follow + circles_activity: 900, # Used by https://github.com/discourse/discourse-circles ) end diff --git a/spec/requests/api/schemas/json/site_response.json b/spec/requests/api/schemas/json/site_response.json index 09d239f432..78f03880ef 100644 --- a/spec/requests/api/schemas/json/site_response.json +++ b/spec/requests/api/schemas/json/site_response.json @@ -127,6 +127,9 @@ }, "following_replied": { "type": "integer" + }, + "circles_activity": { + "type": "integer" } }, "required": [ From 28078d78e2b5a5753bb084483c7afb4ab2daa756 Mon Sep 17 00:00:00 2001 From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Date: Thu, 12 Jan 2023 12:22:11 -0600 Subject: [PATCH 024/169] DEV: Make 'username' optional for bookmark notifications (#19851) Data Explorer queries have a `user_id` assigned to each query created. DE Reports can be bookmarked for later reference. When creating the bookmark notification there was the possibility of a notification error being thrown (that made the notification menu inaccessible) due to a DE Query not having a owner (associated user_id). This can happen in a couple ways: - having a query created by a user that was then later deleted leaving the query without ownership - having a TA create a query for a customer using a temporary account, that would then later be deleted leaving the query without ownership Since there is a case that `bookmark.user` is not valid the PR makes the `bookmark.user.username` optional for a bookmark notification. As [tested](https://github.com/discourse/discourse/pull/19851/files#diff-5b5154de37f96988d551feff6f1dfe5ba804fbcbc1c33b5478dde02a447a634f) in the case a username is not present, we will still render the `content` of the notification minus the username. This creates a safe fallback when looking up non-valid users. --- .../app/widgets/quick-access-bookmarks.js | 2 +- .../components/widgets/quick-access-item-test.js | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/discourse/app/widgets/quick-access-bookmarks.js b/app/assets/javascripts/discourse/app/widgets/quick-access-bookmarks.js index c7ec173d1e..e58b40f2f7 100644 --- a/app/assets/javascripts/discourse/app/widgets/quick-access-bookmarks.js +++ b/app/assets/javascripts/discourse/app/widgets/quick-access-bookmarks.js @@ -70,7 +70,7 @@ createWidgetFrom(QuickAccessPanel, "quick-access-bookmarks", { href, title: bookmark.name, content: bookmark.title, - username: bookmark.user.username, + username: bookmark.user?.username, }); }, diff --git a/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js b/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js index 9e3dbb2616..4b17281f38 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/widgets/quick-access-item-test.js @@ -32,5 +32,21 @@ module( const contentDiv = query(CONTENT_DIV_SELECTOR); assert.strictEqual(contentDiv.innerText, '"quote"'); }); + + test("Renders the notification content with no username when username is not present", async function (assert) { + this.set("args", { + content: "content", + username: undefined, + }); + + await render( + hbs`` + ); + + const contentDiv = query(CONTENT_DIV_SELECTOR); + const usernameSpan = query("li a div span"); + assert.strictEqual(contentDiv.innerText, "content"); + assert.strictEqual(usernameSpan.innerText, ""); + }); } ); From 3030a538192252c9d29e4fb763d31810327f370a Mon Sep 17 00:00:00 2001 From: Daniel Waterworth Date: Thu, 12 Jan 2023 14:03:26 -0600 Subject: [PATCH 025/169] FIX: Prevent concurrent updates to top_topics (#19854) to prevent lock timeouts --- app/models/top_topic.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/models/top_topic.rb b/app/models/top_topic.rb index 975adc8ec7..31fbf4efd5 100644 --- a/app/models/top_topic.rb +++ b/app/models/top_topic.rb @@ -5,21 +5,25 @@ class TopTopic < ActiveRecord::Base # The top topics we want to refresh often def self.refresh_daily! - transaction do - remove_invisible_topics - add_new_visible_topics + DistributedMutex.synchronize("update_top_topics", validity: 5.minutes) do + transaction do + remove_invisible_topics + add_new_visible_topics - update_counts_and_compute_scores_for(:daily) + update_counts_and_compute_scores_for(:daily) + end end end # We don't have to refresh these as often def self.refresh_older! - older_periods = periods - %i[daily all] + DistributedMutex.synchronize("update_top_topics", validity: 5.minutes) do + older_periods = periods - %i[daily all] - transaction { older_periods.each { |period| update_counts_and_compute_scores_for(period) } } + transaction { older_periods.each { |period| update_counts_and_compute_scores_for(period) } } - compute_top_score_for(:all) + compute_top_score_for(:all) + end end def self.refresh! From 5db72f8dafd0513647264739d4278540a15cbcab Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 13 Jan 2023 06:47:58 +0800 Subject: [PATCH 026/169] FIX: Preload user sidebar attrs when `?enable_sidebar=1` (#19843) This allows users to preview the sidebar even when `SiteSetting.naviation_menu` is set to `false`. --- .../javascripts/bootstrap-json/index.js | 5 +++++ app/controllers/application_controller.rb | 12 +++++++++- .../concerns/user_sidebar_mixin.rb | 2 +- .../system/page_objects/components/sidebar.rb | 15 +++++++++++++ spec/system/viewing_sidebar_spec.rb | 22 +++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 spec/system/page_objects/components/sidebar.rb create mode 100644 spec/system/viewing_sidebar_spec.rb diff --git a/app/assets/javascripts/bootstrap-json/index.js b/app/assets/javascripts/bootstrap-json/index.js index a2087fdc16..87826a54f7 100644 --- a/app/assets/javascripts/bootstrap-json/index.js +++ b/app/assets/javascripts/bootstrap-json/index.js @@ -252,6 +252,11 @@ async function buildFromBootstrap(proxy, baseURL, req, response, preload) { url.searchParams.append("safe_mode", reqUrlSafeMode); } + const enableSidebar = forUrlSearchParams.get("enable_sidebar"); + if (enableSidebar) { + url.searchParams.append("enable_sidebar", enableSidebar); + } + const reqUrlPreviewThemeId = forUrlSearchParams.get("preview_theme_id"); if (reqUrlPreviewThemeId) { url.searchParams.append("preview_theme_id", reqUrlPreviewThemeId); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 41bd4889bc..7302867e75 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -640,15 +640,25 @@ class ApplicationController < ActionController::Base def preload_current_user_data store_preloaded( "currentUser", - MultiJson.dump(CurrentUserSerializer.new(current_user, scope: guardian, root: false)), + MultiJson.dump( + CurrentUserSerializer.new( + current_user, + scope: guardian, + root: false, + enable_sidebar_param: params[:enable_sidebar], + ), + ), ) + report = TopicTrackingState.report(current_user) + serializer = ActiveModel::ArraySerializer.new( report, each_serializer: TopicTrackingStateSerializer, scope: guardian, ) + store_preloaded("topicTrackingStates", MultiJson.dump(serializer)) end diff --git a/app/serializers/concerns/user_sidebar_mixin.rb b/app/serializers/concerns/user_sidebar_mixin.rb index cd87219585..f70b97bec2 100644 --- a/app/serializers/concerns/user_sidebar_mixin.rb +++ b/app/serializers/concerns/user_sidebar_mixin.rb @@ -45,6 +45,6 @@ module UserSidebarMixin private def sidebar_navigation_menu? - !SiteSetting.legacy_navigation_menu? + !SiteSetting.legacy_navigation_menu? || options[:enable_sidebar_param] == "1" end end diff --git a/spec/system/page_objects/components/sidebar.rb b/spec/system/page_objects/components/sidebar.rb new file mode 100644 index 0000000000..06fde5dc8c --- /dev/null +++ b/spec/system/page_objects/components/sidebar.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class Sidebar < PageObjects::Components::Base + def visible? + page.has_css?("#d-sidebar") + end + + def has_category_section_link?(category) + page.has_link?(category.name, class: "sidebar-section-link") + end + end + end +end diff --git a/spec/system/viewing_sidebar_spec.rb b/spec/system/viewing_sidebar_spec.rb new file mode 100644 index 0000000000..4b9da86e9c --- /dev/null +++ b/spec/system/viewing_sidebar_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe "Viewing sidebar", type: :system, js: true do + fab!(:admin) { Fabricate(:admin) } + fab!(:user) { Fabricate(:user) } + fab!(:category_sidebar_section_link) { Fabricate(:category_sidebar_section_link, user: user) } + + describe "when using the legacy navigation menu" do + before { SiteSetting.navigation_menu = "legacy" } + + it "should display the sidebar when `enable_sidebar` query param is '1'" do + sign_in(user) + + visit("/latest?enable_sidebar=1") + + sidebar = PageObjects::Components::Sidebar.new + + expect(sidebar).to be_visible + expect(sidebar).to have_category_section_link(category_sidebar_section_link.linkable) + end + end +end From 8a1b50f62d23b37e1563b4b35567f9e548fede7e Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 13 Jan 2023 07:03:48 +0800 Subject: [PATCH 027/169] DEV: Update README to reflect that at least Ruby 3.1 is required (#19855) Minimum Ruby version required was bumped in ab9ea509170c07569c007bd8bd6a24bd03a33434 --- README.md | 2 +- docs/INSTALL.md | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4673f84cc3..42f771bfa2 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ To get your environment setup, follow the community setup guide for your operati If you're familiar with how Rails works and are comfortable setting up your own environment, you can also try out the [**Discourse Advanced Developer Guide**](docs/DEVELOPER-ADVANCED.md), which is aimed primarily at Ubuntu and macOS environments. -Before you get started, ensure you have the following minimum versions: [Ruby 2.7+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13+](https://www.postgresql.org/download/), [Redis 6.2+](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! +Before you get started, ensure you have the following minimum versions: [Ruby 3.1+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13+](https://www.postgresql.org/download/), [Redis 6.2+](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! ## Setting up Discourse diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 052ea90b31..7dd1a37075 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -2,10 +2,10 @@ > :bell: The only officially supported installs of Discourse are [Docker](https://www.docker.io/) based. You must have SSH access to a 64-bit Linux server **with Docker support**. We regret that we cannot support any other methods of installation including cpanel, plesk, webmin, etc. -Simple 30 minute basic install: +Simple 30 minute basic install: [**Beginner Docker install guide**][basic] -Powerful, flexible, large / multiple server install: +Powerful, flexible, large / multiple server install: [**Advanced Docker install guide**][advanced] ### Why do you only officially support Docker? @@ -23,8 +23,7 @@ Hosting Rails applications is complicated. Even if you already have Postgres, Re - [Postgres 13+](https://www.postgresql.org/download/) - [Redis 6+](https://redis.io/download) -- [Ruby 2.7](https://www.ruby-lang.org/en/downloads/) (we recommend 2.7.2) - +- [Ruby 3.1+](https://www.ruby-lang.org/en/downloads/) ## Security We take security very seriously at Discourse, and all our code is 100% open source and peer reviewed. Please read [our security guide](https://github.com/discourse/discourse/blob/main/docs/SECURITY.md) for an overview of security measures in Discourse. From 73ec80893d750e82d0cdaf3f57383751566031d7 Mon Sep 17 00:00:00 2001 From: Selase Krakani <849886+s3lase@users.noreply.github.com> Date: Fri, 13 Jan 2023 01:21:04 +0000 Subject: [PATCH 028/169] FEATURE: Extend topic update API scope to allow status updates (#19654) Allow an API key created with topic:update API scope to make updates to topic status. This change also introduces an optional category_id scope param. --- app/controllers/topics_controller.rb | 7 +- app/models/api_key_scope.rb | 4 +- config/locales/client.en.yml | 2 +- lib/route_matcher.rb | 2 +- spec/requests/topics_controller_spec.rb | 93 +++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 4ad8b87911..c2a54dc9ff 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -479,7 +479,12 @@ class TopicsController < ApplicationController enabled = params[:enabled] == "true" check_for_status_presence(:status, status) - @topic = Topic.find_by(id: topic_id) + @topic = + if params[:category_id] + Topic.find_by(id: topic_id, category_id: params[:category_id].to_i) + else + Topic.find_by(id: topic_id) + end case status when "closed" diff --git a/app/models/api_key_scope.rb b/app/models/api_key_scope.rb index c0d072d0bc..55b4d0dda4 100644 --- a/app/models/api_key_scope.rb +++ b/app/models/api_key_scope.rb @@ -28,8 +28,8 @@ class ApiKeyScope < ActiveRecord::Base params: %i[topic_id], }, update: { - actions: %w[topics#update], - params: %i[topic_id], + actions: %w[topics#update topics#status], + params: %i[topic_id category_id], }, read: { actions: %w[topics#show topics#feed topics#posts], diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a807ded6f4..0bd807d42d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -4670,7 +4670,7 @@ en: topics: read: Read a topic or a specific post in it. RSS is also supported. write: Create a new topic or post to an existing one. - update: Update a topic. Change the title, category, tags, etc. + update: Update a topic. Change the title, category, tags, status, archetype, featured_link etc. read_lists: Read topic lists like top, new, latest, etc. RSS is also supported. posts: edit: Edit any post or a specific one. diff --git a/lib/route_matcher.rb b/lib/route_matcher.rb index 6512f7942f..4e4fd028d1 100644 --- a/lib/route_matcher.rb +++ b/lib/route_matcher.rb @@ -59,7 +59,7 @@ class RouteMatcher params.all? do |param| param_alias = aliases&.[](param) - allowed_values = [allowed_param_values[param.to_s]].flatten + allowed_values = [allowed_param_values.fetch(param.to_s, [])].flatten value = requested_params[param.to_s] alias_value = requested_params[param_alias.to_s] diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index ea369bef34..3c00a04b90 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1037,6 +1037,99 @@ RSpec.describe TopicsController do expect(topic.posts.last.action_code).to eq("visible.enabled") end end + + context "with API key" do + let(:api_key) { Fabricate(:api_key, user: moderator, created_by: moderator) } + + context "when key scope has restricted params" do + before do + ApiKeyScope.create( + resource: "topics", + action: "update", + api_key_id: api_key.id, + allowed_parameters: { + "category_id" => ["#{topic.category_id}"], + }, + ) + end + + it "fails to update topic status in an unpermitted category" do + put "/t/#{topic.id}/status.json", + params: { + status: "closed", + enabled: "true", + category_id: tracked_category.id, + }, + headers: { + "HTTP_API_KEY" => api_key.key, + "HTTP_API_USERNAME" => api_key.user.username, + } + + expect(response.status).to eq(403) + expect(response.body).to include(I18n.t("invalid_access")) + expect(topic.reload.closed).to eq(false) + end + + it "fails without a category_id" do + put "/t/#{topic.id}/status.json", + params: { + status: "closed", + enabled: "true", + }, + headers: { + "HTTP_API_KEY" => api_key.key, + "HTTP_API_USERNAME" => api_key.user.username, + } + + expect(response.status).to eq(403) + expect(response.body).to include(I18n.t("invalid_access")) + expect(topic.reload.closed).to eq(false) + end + + it "updates topic status in a permitted category" do + put "/t/#{topic.id}/status.json", + params: { + status: "closed", + enabled: "true", + category_id: topic.category_id, + }, + headers: { + "HTTP_API_KEY" => api_key.key, + "HTTP_API_USERNAME" => api_key.user.username, + } + + expect(response.status).to eq(200) + expect(topic.reload.closed).to eq(true) + end + end + + context "when key scope has no param restrictions" do + before do + ApiKeyScope.create( + resource: "topics", + action: "update", + api_key_id: api_key.id, + allowed_parameters: { + }, + ) + end + + it "updates topic status" do + put "/t/#{topic.id}/status.json", + params: { + status: "closed", + enabled: "true", + }, + headers: { + "HTTP_API_KEY" => api_key.key, + "HTTP_API_USERNAME" => api_key.user.username, + } + + expect(response.status).to eq(200) + expect(topic.reload.closed).to eq(true) + end + end + end end describe "#destroy_timings" do From 55bdab2b3b325e8c49721fc2f2431fe7bcb7366b Mon Sep 17 00:00:00 2001 From: Selase Krakani <849886+s3lase@users.noreply.github.com> Date: Fri, 13 Jan 2023 01:47:44 +0000 Subject: [PATCH 029/169] FIX: Ensure poll extraction is not attempted if post body is absent (#19718) Since the poll post handler runs very early in the post creation process, it's possible to run the handler on an obiviously invalid post. This change ensures the post's `raw` value is present before proceeding. --- plugins/poll/lib/poll.rb | 4 +++ .../spec/requests/posts_controller_spec.rb | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 plugins/poll/spec/requests/posts_controller_spec.rb diff --git a/plugins/poll/lib/poll.rb b/plugins/poll/lib/poll.rb index fa34b0bf22..e29564a7eb 100644 --- a/plugins/poll/lib/poll.rb +++ b/plugins/poll/lib/poll.rb @@ -306,6 +306,10 @@ class DiscoursePoll::Poll end def self.extract(raw, topic_id, user_id = nil) + # Poll Post handlers get called very early in the post + # creation process. `raw` could be nil here. + return [] if raw.blank? + # TODO: we should fix the callback mess so that the cooked version is available # in the validators instead of cooking twice raw = raw.sub(%r{\[quote.+/quote\]}m, "") diff --git a/plugins/poll/spec/requests/posts_controller_spec.rb b/plugins/poll/spec/requests/posts_controller_spec.rb new file mode 100644 index 0000000000..8ec707779e --- /dev/null +++ b/plugins/poll/spec/requests/posts_controller_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PostsController do + let(:admin) { Fabricate(:admin) } + + describe "#create" do + it "fails gracefully without a post body" do + key = Fabricate(:api_key).key + + expect do + post "/posts.json", + params: { + title: "this is test body", + }, + headers: { + HTTP_API_USERNAME: admin.username, + HTTP_API_KEY: key, + } + end.not_to change { Topic.count } + + expect(response.status).to eq(422) + end + end +end From 690e2f15ab9549486aaa6750e1093c1336bf17f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jan 2023 23:25:14 -0300 Subject: [PATCH 030/169] Build(deps): Bump nokogiri from 1.13.10 to 1.14.0 (#19856) Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.10 to 1.14.0. - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.10...v1.14.0) --- updated-dependencies: - dependency-name: nokogiri dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e62460537d..0ff6d62a8a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,7 +5,6 @@ GIT mail (2.8.0.edge) mini_mime (>= 0.1.1) - GEM remote: https://rubygems.org/ specs: @@ -236,16 +235,16 @@ GEM net-smtp (0.3.3) net-protocol nio4r (2.5.8) - nokogiri (1.13.10) + nokogiri (1.14.0) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.10-aarch64-linux) + nokogiri (1.14.0-aarch64-linux) racc (~> 1.4) - nokogiri (1.13.10-arm64-darwin) + nokogiri (1.14.0-arm64-darwin) racc (~> 1.4) - nokogiri (1.13.10-x86_64-darwin) + nokogiri (1.14.0-x86_64-darwin) racc (~> 1.4) - nokogiri (1.13.10-x86_64-linux) + nokogiri (1.14.0-x86_64-linux) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) From 8ee71d439be094f777f19a8fca44c9c2a914d479 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 13 Jan 2023 11:04:26 +0800 Subject: [PATCH 031/169] FIX: Add migration to reindex invalid indexes (#19858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Discourse, there are many migration files where we CREATE INDEX CONCURRENTLY which requires us to set disable_ddl_transaction!. Setting disable_ddl_transaction! in a migration file runs the SQL statements outside of a transaction. The implication of this is that there is no ROLLBACK should any of the SQL statements fail. We have seen lock timeouts occuring when running CREATE INDEX CONCURRENTLY. When that happens, the index would still have been created but marked as invalid by Postgres. Per the postgres documentation: > If a problem arises while scanning the table, such as a deadlock or a uniqueness violation in a unique index, the CREATE INDEX command will fail but leave behind an “invalid” index. This index will be ignored for querying purposes because it might be incomplete; however it will still consume update overhead. > The recommended recovery method in such cases is to drop the index and try again to perform CREATE INDEX CONCURRENTLY . (Another possibility is to rebuild the index with REINDEX INDEX CONCURRENTLY ). When such scenarios happen, we are supposed to either drop and create the index again or run a REINDEX operation. However, I noticed today that we have not been doing so in Discourse. Instead, we’ve been incorrectly working around the problem by checking for the index existence before creating the index in order to make the migration idempotent. What this potentially mean is that we might have invalid indexes which are lying around in the database which PG will ignore for querying purposes. This commits adds a migration which queries for all the invalid indexes in the `public` namespace and reindexes them. --- .../20230113002617_reindex_invalid_indexes.rb | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 db/post_migrate/20230113002617_reindex_invalid_indexes.rb diff --git a/db/post_migrate/20230113002617_reindex_invalid_indexes.rb b/db/post_migrate/20230113002617_reindex_invalid_indexes.rb new file mode 100644 index 0000000000..290e9e0f53 --- /dev/null +++ b/db/post_migrate/20230113002617_reindex_invalid_indexes.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ReindexInvalidIndexes < ActiveRecord::Migration[7.0] + disable_ddl_transaction! + + def up + invalid_index_names = DB.query_single(<<~SQL) + SELECT + pg_class.relname + FROM pg_class, pg_index, pg_namespace + WHERE pg_index.indisvalid = false + AND pg_index.indexrelid = pg_class.oid + AND pg_namespace.nspname = 'public' + AND relnamespace = pg_namespace.oid; + SQL + + invalid_index_names.each { |index_name| execute "REINDEX INDEX CONCURRENTLY #{index_name}" } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From b4b8b0346151422df398aed18052a2e7156a872b Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 13 Jan 2023 13:31:28 +1000 Subject: [PATCH 032/169] DEV: Add option to disable rspec diff truncation ENV var (#19861) Sometimes you may have a large string or object that you are comparing with some expectation, and you want to see the full diff between actual and expected without rspec truncating 90% of the diff. Setting the max_formatted_output_length to nil disables this truncation completely. c.f. https://www.rubydoc.info/gems/rspec-expectations/RSpec/Expectations/Configuration#max_formatted_output_length=-instance_method Use `RSPEC_DISABLE_DIFF_TRUNCATION=1` to disable this. --- spec/rails_helper.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 1644f6af06..02aa2e80cd 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -190,6 +190,18 @@ RSpec.configure do |config| # instead of true. config.use_transactional_fixtures = true + # Sometimes you may have a large string or object that you are comparing + # with some expectation, and you want to see the full diff between actual + # and expected without rspec truncating 90% of the diff. Setting the + # max_formatted_output_length to nil disables this truncation completely. + # + # c.f. https://www.rubydoc.info/gems/rspec-expectations/RSpec/Expectations/Configuration#max_formatted_output_length=-instance_method + if ENV["RSPEC_DISABLE_DIFF_TRUNCATION"] + config.expect_with :rspec do |expectation| + expectation.max_formatted_output_length = nil + end + end + # If true, the base class of anonymous controllers will be inferred # automatically. This will be the default behavior in future versions of # rspec-rails. From ce6335693a4a110848a1bde87f245549d6f626ad Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 13 Jan 2023 10:52:05 +0000 Subject: [PATCH 033/169] DEV: Bump sprockets to include ERB kwargs fix (#19850) This should resolve these warnings under Ruby 3.1 ``` warning: Passing safe_level with the 2nd argument of ERB.new is deprecated ``` Unfortunately Sprockets 3.x has not seen a rubygems release since 2018, so we need to fetch these improvements via git. --- Gemfile | 4 ++-- Gemfile.lock | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index ec96ff7007..fae4e9e9bf 100644 --- a/Gemfile +++ b/Gemfile @@ -32,8 +32,8 @@ end gem "json" # TODO: At the moment Discourse does not work with Sprockets 4, we would need to correct internals -# This is a desired upgrade we should get to. -gem "sprockets", "3.7.2" +# We intend to drop sprockets rather than upgrade to 4.x +gem "sprockets", git: "https://github.com/rails/sprockets", branch: "3.x" # this will eventually be added to rails, # allows us to precompile all our templates in the unicorn master diff --git a/Gemfile.lock b/Gemfile.lock index 0ff6d62a8a..92da4e7dff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,15 @@ GIT mail (2.8.0.edge) mini_mime (>= 0.1.1) +GIT + remote: https://github.com/rails/sprockets + revision: f4d3dae71ef29c44b75a49cfbf8032cce07b423a + branch: 3.x + specs: + sprockets (3.7.2) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + GEM remote: https://rubygems.org/ specs: @@ -442,9 +451,6 @@ GEM snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - sprockets (3.7.2) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) @@ -621,7 +627,7 @@ DEPENDENCIES shoulda-matchers sidekiq simplecov - sprockets (= 3.7.2) + sprockets! sprockets-rails sshkey stackprof From 5cd136510aedf540a344febdf1400bbd1c8f8c5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Jan 2023 12:22:00 +0000 Subject: [PATCH 034/169] Build(deps): Bump message-bus-client in /app/assets/javascripts (#19864) Bumps [message-bus-client](https://github.com/discourse/message_bus) from 4.3.1 to 4.3.2. - [Release notes](https://github.com/discourse/message_bus/releases) - [Changelog](https://github.com/discourse/message_bus/blob/main/CHANGELOG) - [Commits](https://github.com/discourse/message_bus/compare/v4.3.1...v4.3.2) --- updated-dependencies: - dependency-name: message-bus-client dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/assets/javascripts/discourse/package.json | 2 +- app/assets/javascripts/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 860fcbea48..520c709a7e 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -81,7 +81,7 @@ "jsdom": "^21.0.0", "loader.js": "^4.7.0", "markdown-it": "^13.0.1", - "message-bus-client": "^4.3.0", + "message-bus-client": "^4.3.2", "messageformat": "0.1.5", "pretender": "^3.4.7", "pretty-text": "1.0.0", diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index cd2bd73c9e..34c1cb25dd 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -6880,10 +6880,10 @@ merge2@^1.2.3, merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -message-bus-client@^4.3.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/message-bus-client/-/message-bus-client-4.3.1.tgz#2107b569131b03d7277801cd3409059e48e9f25e" - integrity sha512-gPG8POalZrM6t9xZPIzER3uDCiAfdwMEjx6ulbYICqzJx0CpLSnZRXKuWvhds4dM3iZQZXpH37UCfYYNICKu5g== +message-bus-client@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/message-bus-client/-/message-bus-client-4.3.2.tgz#6c0db2f011e5e419d2bd47e668063f009cd65971" + integrity sha512-q0OardiBDTePbDqPciRDzwpzuleq1zGK4/jlBRjB9aVuqs5XrikUmrl7gRNJEiB0EyFNdl1ZYQzQR7V5L3hdhw== messageformat@0.1.5: version "0.1.5" From e21c79ae2345f7274333165a4ae5931f2c4ca80e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Jan 2023 12:22:21 +0000 Subject: [PATCH 035/169] Build(deps): Bump message_bus from 4.3.1 to 4.3.2 (#19865) Bumps [message_bus](https://github.com/discourse/message_bus) from 4.3.1 to 4.3.2. - [Release notes](https://github.com/discourse/message_bus/releases) - [Changelog](https://github.com/discourse/message_bus/blob/main/CHANGELOG) - [Commits](https://github.com/discourse/message_bus/compare/v4.3.1...v4.3.2) --- updated-dependencies: - dependency-name: message_bus dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 92da4e7dff..00ab95ac6c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -214,7 +214,7 @@ GEM matrix (0.4.2) maxminddb (0.1.22) memory_profiler (1.0.1) - message_bus (4.3.1) + message_bus (4.3.2) rack (>= 1.1.3) method_source (1.0.0) mini_mime (1.1.2) From 8e7e6e14c7354ee968414f43ac8f07fea12338c7 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Fri, 13 Jan 2023 09:22:33 -0300 Subject: [PATCH 036/169] DEV: Add Ruby 3.2 to test matrix (#19862) * DEV: Add Ruby 3.2 to test matrix * DEV: Update test name --- .github/workflows/tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 17393c8517..27cf46c90b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,9 +18,9 @@ permissions: jobs: build: if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" - name: ${{ matrix.target }} ${{ matrix.build_type }} + name: ${{ matrix.target }} ${{ matrix.build_type }} ${{ matrix.ruby }} runs-on: ${{ (matrix.build_type == 'annotations') && 'ubuntu-latest' || 'ubuntu-20.04-8core' }} - container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }} + container: discourse/discourse_test:slim${{ (matrix.build_type == 'frontend' || matrix.build_type == 'system') && '-browsers' || '' }}${{ (matrix.ruby == '3.2') && '-ruby-3.2.0' || '' }} timeout-minutes: 20 env: @@ -38,11 +38,16 @@ jobs: matrix: build_type: [backend, frontend, system, annotations] target: [core, plugins] + ruby: ['3.1', '3.2'] exclude: - build_type: annotations target: plugins - build_type: frontend target: core # Handled by core_frontend_tests job (below) + - build_type: frontend + ruby: '3.2' + - build_type: annotations + ruby: '3.2' steps: - uses: actions/checkout@v3 From 076b3a651434e88f285d7bc743d38fbf5fd321bc Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Fri, 13 Jan 2023 11:39:49 -0300 Subject: [PATCH 037/169] DEV: Key bundler CI cache on Ruby version (#19868) --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 27cf46c90b..706057c01b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,9 +73,9 @@ jobs: uses: actions/cache@v3 with: path: vendor/bundle - key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} + key: ${{ runner.os }}-${{ matrix.ruby }}-gem-${{ hashFiles('**/Gemfile.lock') }} restore-keys: | - ${{ runner.os }}-gem- + ${{ runner.os }}-${{ matrix.ruby }}-gem- - name: Setup gems run: | From f525f722ea51fad88e24fa593751eda52742062c Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 13 Jan 2023 16:13:13 +0100 Subject: [PATCH 038/169] DEV: adds expandedOnInsert option to sk (#19870) Allows to display a select-kit component expanded by default. Usage: ``` ``` --- .../components/select-kit/single-select-test.js | 13 +++++++++++++ .../select-kit/addon/components/select-kit.js | 9 +++++++++ 2 files changed, 22 insertions(+) diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js index cc8a13006d..14874b0b93 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js @@ -411,4 +411,17 @@ module("Integration | Component | select-kit/single-select", function (hooks) { assert.ok(header.bottom > body.top, "it correctly offsets the body"); }); + + test("options.expandedOnInsert", async function (assert) { + setDefaultState(this); + await render(hbs` + + `); + + assert.dom(".single-select.is-expanded").exists(); + }); }); diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit.js b/app/assets/javascripts/select-kit/addon/components/select-kit.js index 304b6c3f5b..95f8d8cd55 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit.js @@ -188,6 +188,14 @@ export default Component.extend( this.handleDeprecations(); }, + didInsertElement() { + this._super(...arguments); + + if (this.selectKit.options.expandedOnInsert) { + this._open(); + } + }, + click(event) { event.preventDefault(); event.stopPropagation(); @@ -296,6 +304,7 @@ export default Component.extend( desktopPlacementStrategy: null, hiddenValues: null, disabled: false, + expandedOnInsert: false, }, autoFilterable: computed("content.[]", "selectKit.filter", function () { From a444023113117046d21cf996589a629bd5e5a97f Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 13 Jan 2023 16:39:21 +0100 Subject: [PATCH 039/169] DEV: adds row index support (#19871) This commits adds a data-index attribute on each `select-kit-row` DOM node and also makes available `this.index` in each `select-kit-row` template. --- .../select-kit/single-select-test.js | 18 ++++++++++++++++++ .../components/select-kit/select-kit-row.js | 2 ++ .../select-kit/select-kit-collection.hbs | 5 +++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js index 14874b0b93..c06eeadf84 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/single-select-test.js @@ -393,6 +393,24 @@ module("Integration | Component | select-kit/single-select", function (hooks) { ); }); + test("row index", async function (assert) { + this.setProperties({ + content: [ + { id: 1, name: "john" }, + { id: 2, name: "jane" }, + ], + value: null, + }); + + await render( + hbs`` + ); + await this.subject.expand(); + + assert.dom('.select-kit-row[data-index="0"][data-value="1"]').exists(); + assert.dom('.select-kit-row[data-index="1"][data-value="2"]').exists(); + }); + test("options.verticalOffset", async function (assert) { setDefaultState(this, { verticalOffset: -50 }); await render(hbs` diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js index 26330d1128..126a9d80f5 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-row.js @@ -18,6 +18,7 @@ export default Component.extend(UtilsMixin, { "title", "rowValue:data-value", "rowName:data-name", + "index:data-index", "role", "ariaChecked:aria-checked", "guid:data-guid", @@ -30,6 +31,7 @@ export default Component.extend(UtilsMixin, { "isNone:none", "item.classNames", ], + index: 0, role: "menuitemradio", diff --git a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs b/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs index f17c087bc3..cb9ac5ee1a 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs +++ b/app/assets/javascripts/select-kit/addon/templates/components/select-kit/select-kit-collection.hbs @@ -1,12 +1,13 @@ {{#if this.collection.content.length}} -{{/if}} \ No newline at end of file +{{/if}} From b8bbdcf0129fcc25ca346422bb66d8b66600b14f Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Fri, 13 Jan 2023 13:45:07 -0300 Subject: [PATCH 040/169] DEV: Update PostgreSQL and Redis versions (#19869) * DEV: Update PostgreSQL and Redis versions * DEV: Update versions in main readme --- README.md | 2 +- docs/INSTALL.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 42f771bfa2..0cbfcd4f97 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ To get your environment setup, follow the community setup guide for your operati If you're familiar with how Rails works and are comfortable setting up your own environment, you can also try out the [**Discourse Advanced Developer Guide**](docs/DEVELOPER-ADVANCED.md), which is aimed primarily at Ubuntu and macOS environments. -Before you get started, ensure you have the following minimum versions: [Ruby 3.1+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13+](https://www.postgresql.org/download/), [Redis 6.2+](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! +Before you get started, ensure you have the following minimum versions: [Ruby 3.1+](https://www.ruby-lang.org/en/downloads/), [PostgreSQL 13](https://www.postgresql.org/download/), [Redis 7](https://redis.io/download). If you're having trouble, please see our [**TROUBLESHOOTING GUIDE**](docs/TROUBLESHOOTING.md) first! ## Setting up Discourse diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7dd1a37075..ffadf383e3 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -21,8 +21,8 @@ Hosting Rails applications is complicated. Even if you already have Postgres, Re ### Software Requirements -- [Postgres 13+](https://www.postgresql.org/download/) -- [Redis 6+](https://redis.io/download) +- [Postgres 13](https://www.postgresql.org/download/) +- [Redis 7](https://redis.io/download) - [Ruby 3.1+](https://www.ruby-lang.org/en/downloads/) ## Security From 0dc938ef015863ac3344b597d62d2f1e3c5835a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Jan 2023 19:18:52 +0100 Subject: [PATCH 041/169] Build(deps): Bump rubocop-rspec from 2.16.0 to 2.17.0 (#19866) Bumps [rubocop-rspec](https://github.com/rubocop/rubocop-rspec) from 2.16.0 to 2.17.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.16.0...v2.17.0) --- updated-dependencies: - dependency-name: rubocop-rspec dependency-type: indirect update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] 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 00ab95ac6c..15e97c1b3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -411,7 +411,7 @@ GEM rubocop-discourse (3.0.3) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.16.0) + rubocop-rspec (2.17.0) rubocop (~> 1.33) ruby-prof (1.4.5) ruby-progressbar (1.11.0) From b27415d8a85c174eacf3734e7ddade4964d1905a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Jan 2023 22:31:06 +0100 Subject: [PATCH 042/169] Build(deps-dev): Bump parallel_tests from 4.0.0 to 4.1.0 (#19872) Bumps [parallel_tests](https://github.com/grosser/parallel_tests) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/grosser/parallel_tests/releases) - [Changelog](https://github.com/grosser/parallel_tests/blob/master/CHANGELOG.md) - [Commits](https://github.com/grosser/parallel_tests/compare/v4.0.0...v4.1.0) --- updated-dependencies: - dependency-name: parallel_tests dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] 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 15e97c1b3a..8ce3b7b774 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -295,7 +295,7 @@ GEM openssl (> 2.0, < 3.1) optimist (3.0.1) parallel (1.22.1) - parallel_tests (4.0.0) + parallel_tests (4.1.0) parallel parser (3.2.0.0) ast (~> 2.4.1) From 9ed4550b861ea58b10bb3102458f316de48f2deb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Jan 2023 22:32:00 +0100 Subject: [PATCH 043/169] Build(deps): Bump eslint in /app/assets/javascripts (#19873) Bumps [eslint](https://github.com/eslint/eslint) from 8.31.0 to 8.32.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v8.31.0...v8.32.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/assets/javascripts/discourse/package.json | 2 +- app/assets/javascripts/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 520c709a7e..6812f18a40 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -72,7 +72,7 @@ "ember-rfc176-data": "^0.3.17", "ember-source": "~3.28.11", "ember-test-selectors": "^6.0.0", - "eslint": "^8.31.0", + "eslint": "^8.32.0", "eslint-plugin-qunit": "^7.3.4", "handlebars": "^4.7.7", "html-entities": "^2.3.3", diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index 34c1cb25dd..9850edd276 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -4516,10 +4516,10 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@^8.31.0: - version "8.31.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.31.0.tgz#75028e77cbcff102a9feae1d718135931532d524" - integrity sha512-0tQQEVdmPZ1UtUKXjX7EMm9BlgJ08G90IhWh0PKDCb3ZLsgAOHI8fYSIzYVZej92zsgq+ft0FGsxhJ3xo2tbuA== +eslint@^8.32.0: + version "8.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.32.0.tgz#d9690056bb6f1a302bd991e7090f5b68fbaea861" + integrity sha512-nETVXpnthqKPFyuY2FNjz/bEd6nbosRgKbkgS/y1C7LJop96gYHWpiguLecMHQ2XCPxn77DS0P+68WzG6vkZSQ== dependencies: "@eslint/eslintrc" "^1.4.1" "@humanwhocodes/config-array" "^0.11.8" From f72875c729ae64a9c2f2dd89a06f33a2d836f975 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Mon, 16 Jan 2023 06:04:53 +0800 Subject: [PATCH 044/169] DEV: Introduce `enable_new_notifications_menu` site setting (#19860) The `enable_new_notifications_menu` site setting allows sites that have `navigation_menu` set to `legacy` to use the redesigned notifications menu before switching to the new sidebar navigation menu. --- app/models/user.rb | 2 +- config/locales/server.en.yml | 2 ++ config/site_settings.yml | 3 ++ ...enable_new_notifications_menu_validator.rb | 15 ++++++++++ ...e_new_notifications_menu_validator_spec.rb | 18 ++++++++++++ spec/models/user_spec.rb | 28 +++++++++++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 lib/validators/enable_new_notifications_menu_validator.rb create mode 100644 spec/lib/validators/enable_new_notifications_menu_validator_spec.rb diff --git a/app/models/user.rb b/app/models/user.rb index d0d9846a43..e4e8faad86 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1805,7 +1805,7 @@ class User < ActiveRecord::Base end def redesigned_user_menu_enabled? - !SiteSetting.legacy_navigation_menu? + !SiteSetting.legacy_navigation_menu? || SiteSetting.enable_new_notifications_menu end protected diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6244bd62c7..8a3eb8cbb2 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2388,6 +2388,7 @@ en: navigation_menu: "Determine which navigation menu to use. Sidebar and header navigation are customizable by users. Legacy option is available for backward compatibility." default_sidebar_categories: "Selected categories will be displayed under Sidebar's Categories section by default." default_sidebar_tags: "Selected tags will be displayed under Sidebar's Tags section by default." + enable_new_notifications_menu: "Enables the new notifications menu for the legacy navigation menu." enable_new_user_profile_nav_groups: "EXPERIMENTAL: Users of the selected groups will be shown the new user profile navigation menu" enable_experimental_topic_timeline_groups: "EXPERIMENTAL: Users of the selected groups will be shown the refactored topic timeline" enable_experimental_hashtag_autocomplete: "EXPERIMENTAL: Use the new #hashtag autocompletion system for categories and tags that renders the selected item differently and has improved search" @@ -2449,6 +2450,7 @@ en: discourse_connect_cannot_be_enabled_if_second_factor_enforced: "You cannot enable DiscourseConnect if 2FA is enforced." delete_rejected_email_after_days: "This setting cannot be set lower than the delete_email_logs_after_days setting or greater than %{max}" invalid_uncategorized_category_setting: "The Uncategorized category cannot be selected if allow uncategorized topics is not allowed" + enable_new_notifications_menu_not_legacy_navigation_menu: "You must set `navigation_menu` to `legacy` before enabling this setting." placeholder: discourse_connect_provider_secrets: diff --git a/config/site_settings.yml b/config/site_settings.yml index 599e0de852..e842264f55 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2084,6 +2084,9 @@ navigation: choices: - "default" - "unread_new" + enable_new_notifications_menu: + default: false + validator: "EnableNewNotificationsMenuValidator" embedding: embed_by_username: diff --git a/lib/validators/enable_new_notifications_menu_validator.rb b/lib/validators/enable_new_notifications_menu_validator.rb new file mode 100644 index 0000000000..30de97d1ba --- /dev/null +++ b/lib/validators/enable_new_notifications_menu_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class EnableNewNotificationsMenuValidator + def initialize(opts = {}) + end + + def valid_value?(value) + return true if value == "f" + SiteSetting.navigation_menu == "legacy" + end + + def error_message + I18n.t("site_settings.errors.enable_new_notifications_menu_not_legacy_navigation_menu") + end +end diff --git a/spec/lib/validators/enable_new_notifications_menu_validator_spec.rb b/spec/lib/validators/enable_new_notifications_menu_validator_spec.rb new file mode 100644 index 0000000000..309db022d8 --- /dev/null +++ b/spec/lib/validators/enable_new_notifications_menu_validator_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +RSpec.describe EnableNewNotificationsMenuValidator do + it "does not allow `enable_new_notifications_menu` site settings to be enabled when `navigation_menu` site settings is not set to `legacy`" do + SiteSetting.navigation_menu = "sidebar" + + expect { SiteSetting.enable_new_notifications_menu = true }.to raise_error( + Discourse::InvalidParameters, + /#{I18n.t("site_settings.errors.enable_new_notifications_menu_not_legacy_navigation_menu")}/, + ) + end + + it "allows `enable_new_notifications_menu` site settings to be enabled when `navigation_menu` site settings is set to `legacy`" do + SiteSetting.navigation_menu = "legacy" + + expect { SiteSetting.enable_new_notifications_menu = true }.to_not raise_error + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3b40512912..ea8f7b708b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -3399,4 +3399,32 @@ RSpec.describe User do expect(user.new_personal_messages_notifications_count).to eq(1) end end + + describe "#redesigned_user_menu_enabled?" do + it "returns true when `navigation_menu` site settings is `legacy` and `enable_new_notifications_menu` site settings is enabled" do + SiteSetting.navigation_menu = "legacy" + SiteSetting.enable_new_notifications_menu = true + + expect(user.redesigned_user_menu_enabled?).to eq(true) + end + + it "returns false when `navigation_menu` site settings is `legacy` and `enable_new_notifications_menu` site settings is not enabled" do + SiteSetting.navigation_menu = "legacy" + SiteSetting.enable_new_notifications_menu = false + + expect(user.redesigned_user_menu_enabled?).to eq(false) + end + + it "returns true when `navigation_menu` site settings is `sidebar`" do + SiteSetting.navigation_menu = "sidebar" + + expect(user.redesigned_user_menu_enabled?).to eq(true) + end + + it "returns true when `navigation_menu` site settings is `header_dropdown`" do + SiteSetting.navigation_menu = "header dropdown" + + expect(user.redesigned_user_menu_enabled?).to eq(true) + end + end end From 29f7ec709068dd87375afec1b61fe46cd37c48b9 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Sun, 15 Jan 2023 22:08:44 +0000 Subject: [PATCH 045/169] DEV: Prevent defer stats exception when thread aborted (#19863) When the thread is aborted, an exception is raised before the `start` of a job is set, and therefore raises an exception in the `ensure` block. This commit checks that `start` exists, and also adds `abort_on_exception=true` so that this issue would have caused test failures. --- lib/discourse.rb | 14 +++++++++++++- lib/scheduler/defer.rb | 18 ++++++++++++------ spec/lib/scheduler/defer_spec.rb | 6 +++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/discourse.rb b/lib/discourse.rb index 577ae78100..0fa1598d3c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -191,6 +191,18 @@ module Discourse reset_job_exception_stats! + if Rails.env.test? + def self.catch_job_exceptions! + raise "tests only" if !Rails.env.test? + @catch_job_exceptions = true + end + + def self.reset_catch_job_exceptions! + raise "tests only" if !Rails.env.test? + remove_instance_variable(:@catch_job_exceptions) + end + end + # Log an exception. # # If your code is in a scheduled job, it is recommended to use the @@ -220,7 +232,7 @@ module Discourse { current_db: cm.current_db, current_hostname: cm.current_hostname }.merge(context), ) - raise ex if Rails.env.test? + raise ex if Rails.env.test? && !@catch_job_exceptions end # Expected less matches than what we got in a find diff --git a/lib/scheduler/defer.rb b/lib/scheduler/defer.rb index 6b05928006..176ae1a62a 100644 --- a/lib/scheduler/defer.rb +++ b/lib/scheduler/defer.rb @@ -79,7 +79,11 @@ module Scheduler def start_thread @mutex.synchronize do @reactor = MessageBus::TimerThread.new if !@reactor - @thread = Thread.new { do_work while true } if !@thread&.alive? + @thread = + Thread.new do + @thread.abort_on_exception = true if Rails.env.test? + do_work while true + end if !@thread&.alive? end end @@ -110,11 +114,13 @@ module Scheduler Discourse.handle_job_exception(ex, message: "Processing deferred code queue") ensure ActiveRecord::Base.connection_handler.clear_active_connections! - @stats_mutex.synchronize do - stats = @stats[desc] - if stats - stats[:finished] += 1 - stats[:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + if start + @stats_mutex.synchronize do + stats = @stats[desc] + if stats + stats[:finished] += 1 + stats[:duration] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + end end end end diff --git a/spec/lib/scheduler/defer_spec.rb b/spec/lib/scheduler/defer_spec.rb index 266af82bc3..f886335fd2 100644 --- a/spec/lib/scheduler/defer_spec.rb +++ b/spec/lib/scheduler/defer_spec.rb @@ -12,11 +12,15 @@ RSpec.describe Scheduler::Defer do end before do + Discourse.catch_job_exceptions! @defer = DeferInstance.new @defer.async = true end - after { @defer.stop! } + after do + @defer.stop! + Discourse.reset_catch_job_exceptions! + end it "supports basic instrumentation" do @defer.later("first") {} From d59ed1cbfe9a8623a63a074b67b73cdf8f758455 Mon Sep 17 00:00:00 2001 From: chapoi <101828855+chapoi@users.noreply.github.com> Date: Sun, 15 Jan 2023 23:09:23 +0100 Subject: [PATCH 046/169] UX: fix alignment issues with autocomplete (#19828) --- .../components/email-group-user-chooser-row.hbs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs b/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs index 3a293eaadc..edfe7ced3b 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs +++ b/app/assets/javascripts/select-kit/addon/templates/components/email-group-user-chooser-row.hbs @@ -1,16 +1,20 @@ {{#if this.item.isUser}} {{avatar this.item imageSize="tiny"}} - {{format-username this.item.id}} - {{this.item.name}} +
+ {{format-username this.item.id}} + {{this.item.name}} +
{{#if (and this.item.showUserStatus this.item.status)}} {{/if}} {{decorate-username-selector this.item.id}} {{else if this.item.isGroup}} {{d-icon "users"}} - {{this.item.id}} - {{this.item.full_name}} +
+ {{this.item.id}} + {{this.item.full_name}} +
{{else}} {{d-icon "envelope"}} {{this.item.id}} -{{/if}} \ No newline at end of file +{{/if}} From 2eb0a300b683dc6c33ef399482934c3d9e32d03a Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 16 Jan 2023 10:20:37 +1000 Subject: [PATCH 047/169] FIX: Use hashtags in channel archive PMs if available (#19859) If the enable_experimental_hashtag_autocomplete setting is enabled, then we should autolink hashtag references to the archived channels (e.g. #blah::channel) for a nicer UX, and just show the channel name if not (since doing #channelName can lead to weird inconsistent results). --- plugins/chat/config/locales/server.en.yml | 6 +++--- plugins/chat/lib/chat_channel_archive_service.rb | 9 ++++++++- .../lib/chat_channel_archive_service_spec.rb | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml index 9cdaf1c2d6..f0c8abefb9 100644 --- a/plugins/chat/config/locales/server.en.yml +++ b/plugins/chat/config/locales/server.en.yml @@ -29,17 +29,17 @@ en: title: "Chat Channel Archive Complete" subject_template: "Chat channel archive completed successfully" text_body_template: | - Archiving the chat channel **\#%{channel_name}** has been completed successfully. The messages were copied into the topic [%{topic_title}](%{topic_url}). + Archiving the chat channel %{channel_hashtag_or_name} has been completed successfully. The messages were copied into the topic [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Chat Channel Archive Failed" subject_template: "Chat channel archive failed" text_body_template: | - Archiving the chat channel **\#%{channel_name}** has failed. %{messages_archived} messages have been archived. Partially archived messages were copied into the topic [%{topic_title}](%{topic_url}). Visit the channel at %{channel_url} to retry. + Archiving the chat channel %{channel_hashtag_or_name} has failed. %{messages_archived} messages have been archived. Partially archived messages were copied into the topic [%{topic_title}](%{topic_url}). Visit the channel at %{channel_url} to retry. chat_channel_archive_failed_no_topic: title: "Chat Channel Archive Failed" subject_template: "Chat channel archive failed" text_body_template: | - Archiving the chat channel **\#%{channel_name}** has failed. No messages have been archived. The topic was not created successfully for the following reasons: + Archiving the chat channel %{channel_hashtag_or_name} has failed. No messages have been archived. The topic was not created successfully for the following reasons: %{topic_validation_errors} diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb index 0cd7a9c1d2..cf656ef4ca 100644 --- a/plugins/chat/lib/chat_channel_archive_service.rb +++ b/plugins/chat/lib/chat_channel_archive_service.rb @@ -248,7 +248,7 @@ class Chat::ChatChannelArchiveService def notify_archiver(result, error_message: nil) base_translation_params = { - channel_name: chat_channel_title, + channel_hashtag_or_name: channel_hashtag_or_name, topic_title: chat_channel_archive.destination_topic&.title, topic_url: chat_channel_archive.destination_topic&.url, topic_validation_errors: result == :failed_no_topic ? error_message : nil, @@ -301,4 +301,11 @@ class Chat::ChatChannelArchiveService def kick_all_users Chat::ChatChannelMembershipManager.new(chat_channel).unfollow_all_users end + + def channel_hashtag_or_name + if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete + return "##{chat_channel.slug}::channel" + end + chat_channel_title + end end diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb index 2d79e85690..d42c0f3d1a 100644 --- a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb @@ -12,6 +12,8 @@ describe Chat::ChatChannelArchiveService do let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } subject { Chat::ChatChannelArchiveService } + before { SiteSetting.chat_enabled = true } + describe "#create_archive_process" do before { 3.times { Fabricate(:chat_message, chat_channel: channel) } } @@ -184,6 +186,20 @@ describe Chat::ChatChannelArchiveService do expect(pm_topic.first_post.raw).to include("Title can't have more than 1 emoji") end + context "when enable_experimental_hashtag_autocomplete" do + before { SiteSetting.enable_experimental_hashtag_autocomplete = true } + + it "uses the channel slug to autolink a hashtag for the channel in the PM" do + create_messages(3) && start_archive + subject.new(@channel_archive).execute + expect(@channel_archive.reload.complete?).to eq(true) + pm_topic = Topic.private_messages.last + expect(pm_topic.first_post.cooked).to include( + "#{channel.title(user)}", + ) + end + end + describe "channel members" do before do create_messages(3) From 7c975481597c46ba1064b37f8f4627da82c5b689 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 16 Jan 2023 10:53:00 +1000 Subject: [PATCH 048/169] FIX: Fix incorrect hashtag setting migration (#19857) Added in c2013865d78d353280ae68135b4c218e21ffb566, this migration was supposed to only turn off the hashtag setting for existing sites (since that was the old default) but its doing it for new ones too because we run all migrations on new sites. Instead, we should only run this if the first migration was only just created, meaning its a new site. --- ...rimental_hashtag_feature_default_for_new_sites.rb | 12 +++++++++++- plugins/chat/spec/models/chat_message_spec.rb | 4 +++- .../advanced_user_narrative_spec.rb | 3 +++ spec/integrity/common_mark_spec.rb | 3 +++ spec/lib/pretty_text_spec.rb | 9 +++++++++ spec/requests/hashtags_controller_spec.rb | 4 ++++ 6 files changed, 33 insertions(+), 2 deletions(-) diff --git a/db/migrate/20230103004613_make_experimental_hashtag_feature_default_for_new_sites.rb b/db/migrate/20230103004613_make_experimental_hashtag_feature_default_for_new_sites.rb index e0047fdb3c..55f6d34036 100644 --- a/db/migrate/20230103004613_make_experimental_hashtag_feature_default_for_new_sites.rb +++ b/db/migrate/20230103004613_make_experimental_hashtag_feature_default_for_new_sites.rb @@ -2,11 +2,21 @@ class MakeExperimentalHashtagFeatureDefaultForNewSites < ActiveRecord::Migration[7.0] def up - execute(<<~SQL) + result = execute <<~SQL + SELECT created_at + FROM schema_migration_details + ORDER BY created_at + LIMIT 1 + SQL + + settings_insert_query = <<~SQL INSERT INTO site_settings (name, data_type, value, created_at, updated_at) VALUES ('enable_experimental_hashtag_autocomplete', 5, 'f', now(), now()) ON CONFLICT DO NOTHING SQL + + # keep enable_experimental_hashtag_autocomplete disabled for for existing sites + execute settings_insert_query if result.first["created_at"].to_datetime < 1.hour.ago end def down diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat_message_spec.rb index 1ce6a39ed3..9e305f98f2 100644 --- a/plugins/chat/spec/models/chat_message_spec.rb +++ b/plugins/chat/spec/models/chat_message_spec.rb @@ -234,8 +234,10 @@ describe ChatMessage do expect(cooked).to eq("

@mention

") end - # TODO (martin) Remove this when enable_experimental_hashtag_autocomplete is default it "supports category-hashtag plugin" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + category = Fabricate(:category) cooked = ChatMessage.cook("##{category.slug}") diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb index 82e5024e99..14561dceed 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb @@ -494,6 +494,9 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do end it "should create the right reply" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + category = Fabricate(:category) post.update!(raw: "Check out this ##{category.slug}") diff --git a/spec/integrity/common_mark_spec.rb b/spec/integrity/common_mark_spec.rb index c89f9ff0e8..85c8c82122 100644 --- a/spec/integrity/common_mark_spec.rb +++ b/spec/integrity/common_mark_spec.rb @@ -5,6 +5,9 @@ RSpec.describe "CommonMark" do SiteSetting.enable_markdown_typographer = false SiteSetting.highlighted_languages = "ruby|aa" + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + html, state, md = nil failed = 0 diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb index 25a8d80749..7b3ef98320 100644 --- a/spec/lib/pretty_text_spec.rb +++ b/spec/lib/pretty_text_spec.rb @@ -1619,6 +1619,9 @@ RSpec.describe PrettyText do end it "produces hashtag links" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + category = Fabricate(:category, name: "testing") category2 = Fabricate(:category, name: "known") Fabricate(:topic, tags: [Fabricate(:tag, name: "known")]) @@ -1908,6 +1911,9 @@ HTML end it "does not replace hashtags and mentions" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + Fabricate(:user, username: "test") category = Fabricate(:category, slug: "test") Fabricate( @@ -1927,6 +1933,9 @@ HTML end it "does not replace hashtags and mentions when watched words are regular expressions" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + SiteSetting.watched_words_regular_expressions = true Fabricate(:user, username: "test") diff --git a/spec/requests/hashtags_controller_spec.rb b/spec/requests/hashtags_controller_spec.rb index 9cffbf5b96..2d2b0f3ca6 100644 --- a/spec/requests/hashtags_controller_spec.rb +++ b/spec/requests/hashtags_controller_spec.rb @@ -14,6 +14,10 @@ RSpec.describe HashtagsController do before do SiteSetting.tagging_enabled = true + + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + tag_group end From 553b4888ba811bc802169c68b43206dda81117c9 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Mon, 16 Jan 2023 13:17:23 +1000 Subject: [PATCH 049/169] DEV: Revert syntax-tree line change in unicorn.conf.rb listen (#19874) Some internal tooling expects this to be one line, see /t/90198/13 --- config/unicorn.conf.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/unicorn.conf.rb b/config/unicorn.conf.rb index 2e0b22f48f..68bb4e2efa 100644 --- a/config/unicorn.conf.rb +++ b/config/unicorn.conf.rb @@ -16,8 +16,9 @@ worker_processes (ENV["UNICORN_WORKERS"] || 3).to_i working_directory discourse_path # listen "#{discourse_path}/tmp/sockets/unicorn.sock" -listen ENV["UNICORN_LISTENER"] || - "#{(ENV["UNICORN_BIND_ALL"] ? "" : "127.0.0.1:")}#{(ENV["UNICORN_PORT"] || 3000).to_i}" + +# stree-ignore +listen ENV["UNICORN_LISTENER"] || "#{(ENV["UNICORN_BIND_ALL"] ? "" : "127.0.0.1:")}#{(ENV["UNICORN_PORT"] || 3000).to_i}" FileUtils.mkdir_p("#{discourse_path}/tmp/pids") if !File.exist?("#{discourse_path}/tmp/pids") From b704e338eff61e6fc18e0e4207d14583eea96702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Mon, 16 Jan 2023 11:55:00 +0100 Subject: [PATCH 050/169] DEV: extract anniversary badge query (#19716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So it can easily be overwritten in a plugin for example. ### Added more tests to provide better coverage We previously only had `u.silenced_till IS NULL` but I made it consistent with pretty much every other places where we check for "active" users. These two new lines do change the query a tiny bit though. **Before** - You could not get the badge if you were currently silenced (no matter what period is being checked) - You could get the badge if you were suspended 😬 **After** - You can't get the badge if you were silenced during the past year - You can't get the badge if you were suspended during the past year ### Improved the performance of the query by using `NOT EXISTS` instead of `LEFT JOIN / COUNT() = 0` There is no difference in behaviour between ```sql LEFT JOIN user_badges AS ub ON ub.user_id = u.id AND ... [...] HAVING COUNT(ub.*) = 0 ``` and ```sql NOT EXISTS (SELECT 1 FROM user_badges AS ub WHERE ub.user_id = u.id AND ...) ``` The only difference is performance-wise. The `NOT EXISTS` is 10-30% faster on very large databases (aka. posts and users in X millions). I checked on 3 of the largest datasets I could find. --- .../scheduled/grant_anniversary_badges.rb | 27 ++------------ lib/badge_queries.rb | 28 +++++++++++++++ spec/jobs/grant_anniversary_badges_spec.rb | 36 +++++++++++++++++++ 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/app/jobs/scheduled/grant_anniversary_badges.rb b/app/jobs/scheduled/grant_anniversary_badges.rb index c244a2e92e..06e35cb368 100644 --- a/app/jobs/scheduled/grant_anniversary_badges.rb +++ b/app/jobs/scheduled/grant_anniversary_badges.rb @@ -6,34 +6,13 @@ module Jobs def execute(args) return unless SiteSetting.enable_badges? - badge = Badge.find_by(id: Badge::Anniversary, enabled: true) - return unless badge + return unless badge = Badge.find_by(id: Badge::Anniversary, enabled: true) start_date = args[:start_date] || 1.year.ago end_date = start_date + 1.year - fmt_end_date = end_date.iso8601(6) - fmt_start_date = start_date.iso8601(6) - - user_ids = DB.query_single <<~SQL - SELECT u.id AS user_id - FROM users AS u - INNER JOIN posts AS p ON p.user_id = u.id - INNER JOIN topics AS t ON p.topic_id = t.id - LEFT OUTER JOIN user_badges AS ub ON ub.user_id = u.id AND - ub.badge_id = #{Badge::Anniversary} AND - ub.granted_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}' - WHERE u.active AND - u.silenced_till IS NULL AND - NOT p.hidden AND - p.deleted_at IS NULL AND - t.visible AND - t.archetype <> 'private_message' AND - p.created_at BETWEEN '#{fmt_start_date}' AND '#{fmt_end_date}' AND - u.created_at <= '#{fmt_start_date}' - GROUP BY u.id - HAVING COUNT(p.id) > 0 AND COUNT(ub.id) = 0 - SQL + sql = BadgeQueries.anniversaries(start_date, end_date) + user_ids = DB.query_single(sql) User .where(id: user_ids) diff --git a/lib/badge_queries.rb b/lib/badge_queries.rb index 4eda809873..c73c579d5c 100644 --- a/lib/badge_queries.rb +++ b/lib/badge_queries.rb @@ -271,4 +271,32 @@ module BadgeQueries WHERE "rank" = 1 SQL end + + def self.anniversaries(start_date, end_date) + start_date = start_date.iso8601(6) + end_date = end_date.iso8601(6) + + <<~SQL + SELECT u.id + FROM users AS u + JOIN posts AS p ON p.user_id = u.id + JOIN topics AS t ON p.topic_id = t.id + WHERE u.id > 0 + AND u.active + AND NOT u.staged + AND (u.silenced_till IS NULL OR u.silenced_till < '#{start_date}') + AND (u.suspended_till IS NULL OR u.suspended_till < '#{start_date}') + AND u.created_at <= '#{start_date}' + AND NOT p.hidden + AND p.deleted_at IS NULL + AND p.created_at BETWEEN '#{start_date}' AND '#{end_date}' + AND t.visible + AND t.archetype <> 'private_message' + AND t.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM user_badges AS ub WHERE ub.user_id = u.id AND ub.badge_id = #{Badge::Anniversary} AND ub.granted_at BETWEEN '#{start_date}' AND '#{end_date}') + AND NOT EXISTS (SELECT 1 FROM anonymous_users AS au WHERE au.user_id = u.id) + GROUP BY u.id + HAVING COUNT(p.id) > 0 + SQL + end end diff --git a/spec/jobs/grant_anniversary_badges_spec.rb b/spec/jobs/grant_anniversary_badges_spec.rb index 5cee313087..57b54708c9 100644 --- a/spec/jobs/grant_anniversary_badges_spec.rb +++ b/spec/jobs/grant_anniversary_badges_spec.rb @@ -12,6 +12,15 @@ RSpec.describe Jobs::GrantAnniversaryBadges do expect(badge).to be_blank end + it "doesn't award to a bot" do + user = Fabricate(:bot, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + it "doesn't award to an inactive user" do user = Fabricate(:user, created_at: 400.days.ago, active: false) Fabricate(:post, user: user, created_at: 1.week.ago) @@ -30,6 +39,33 @@ RSpec.describe Jobs::GrantAnniversaryBadges do expect(badge).to be_blank end + it "doesn't award to a suspended user" do + user = Fabricate(:user, created_at: 400.days.ago, suspended_till: 1.year.from_now) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to a staged user" do + user = Fabricate(:user, created_at: 400.days.ago, staged: true) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + + it "doesn't award to an anonymous user" do + user = Fabricate(:anonymous, created_at: 400.days.ago) + Fabricate(:post, user: user, created_at: 1.week.ago) + granter.execute({}) + + badge = user.user_badges.where(badge_id: Badge::Anniversary) + expect(badge).to be_blank + end + it "doesn't award when a post is deleted" do user = Fabricate(:user, created_at: 400.days.ago) Fabricate(:post, user: user, created_at: 1.week.ago, deleted_at: 1.day.ago) From 1ce9582a6c3d85201d8a3eec26a2a9c3be887311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Guitaut?= Date: Mon, 16 Jan 2023 14:40:32 +0100 Subject: [PATCH 051/169] FIX: Display Discourse onebox tag icon properly in chat --- app/assets/stylesheets/common/base/tagging.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 1014899d57..cad9c18d98 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -131,6 +131,7 @@ .discourse-tags { display: inline-flex; flex-wrap: wrap; + position: relative; a, span { margin-right: 0.25em; From c3070288ea05019b5f5f14451abdee9ab963e8de Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Mon, 16 Jan 2023 19:16:17 +0200 Subject: [PATCH 052/169] FEATURE: Verify email webhook signatures (#19690) * FEATURE: Verify Sendgrid webhook signature * FEATURE: Verify more webhook signatures * DEV: Add test for AWS webhook * FEATURE: Implement algorithm for Mandrill * FEATURE: Add warning if webhooks are unsafe --- app/controllers/webhooks_controller.rb | 125 ++++++++-- config/locales/server.en.yml | 5 + config/site_settings.yml | 15 ++ spec/requests/webhooks_controller_spec.rb | 281 ++++++++++++++++++++++ 4 files changed, 410 insertions(+), 16 deletions(-) diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index b84d59c269..6044ef83bb 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,12 +6,20 @@ class WebhooksController < ActionController::Base skip_before_action :verify_authenticity_token def mailgun - return mailgun_failure if SiteSetting.mailgun_api_key.blank? + return signature_failure if SiteSetting.mailgun_api_key.blank? params["event-data"] ? handle_mailgun_new(params) : handle_mailgun_legacy(params) end def sendgrid + if SiteSetting.sendgrid_verification_key.present? + return signature_failure if !valid_sendgrid_signature? + else + Rails.logger.warn( + "Received a Sendgrid webhook, but no verification key has been configured. This is unsafe behaviour and will be disallowed in the future.", + ) + end + events = params["_json"] || [params] events.each do |event| message_id = Email::MessageIdService.message_id_clean((event["smtp-id"] || "")) @@ -32,6 +40,14 @@ class WebhooksController < ActionController::Base end def mailjet + if SiteSetting.mailjet_webhook_token.present? + return signature_failure if !valid_mailjet_token? + else + Rails.logger.warn( + "Received a Mailjet webhook, but no token has been configured. This is unsafe behaviour and will be disallowed in the future.", + ) + end + events = params["_json"] || [params] events.each do |event| message_id = event["CustomID"] @@ -49,20 +65,29 @@ class WebhooksController < ActionController::Base end def mandrill - events = JSON.parse(params["mandrill_events"]) - events.each do |event| - message_id = event.dig("msg", "metadata", "message_id") - to_address = event.dig("msg", "email") - error_code = event.dig("msg", "diag") - - case event["event"] - when "hard_bounce" - process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code) - when "soft_bounce" - process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code) - end + if SiteSetting.mandrill_authentication_key.present? + return signature_failure if !valid_mandrill_signature? + else + Rails.logger.warn( + "Received a Mandrill webhook, but no authentication key has been configured. This is unsafe behaviour and will be disallowed in the future.", + ) end + JSON + .parse(params["mandrill_events"]) + .each do |event| + message_id = event.dig("msg", "metadata", "message_id") + to_address = event.dig("msg", "email") + error_code = event.dig("msg", "diag") + + case event["event"] + when "hard_bounce" + process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code) + when "soft_bounce" + process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code) + end + end + success end @@ -73,6 +98,14 @@ class WebhooksController < ActionController::Base end def postmark + if SiteSetting.postmark_webhook_token.present? + return signature_failure if !valid_postmark_token? + else + Rails.logger.warn( + "Received a Postmark webhook, but no token has been configured. This is unsafe behaviour and will be disallowed in the future.", + ) + end + # see https://postmarkapp.com/developer/webhooks/bounce-webhook#bounce-webhook-data # and https://postmarkapp.com/developer/api/bounce-api#bounce-types @@ -90,6 +123,14 @@ class WebhooksController < ActionController::Base end def sparkpost + if SiteSetting.sparkpost_webhook_token.present? + return signature_failure if !valid_sparkpost_token? + else + Rails.logger.warn( + "Received a Sparkpost webhook, but no token has been configured. This is unsafe behaviour and will be disallowed in the future.", + ) + end + events = params["_json"] || [params] events.each do |event| message_event = event.dig("msys", "message_event") @@ -131,7 +172,7 @@ class WebhooksController < ActionController::Base private - def mailgun_failure + def signature_failure render body: nil, status: 406 end @@ -158,7 +199,7 @@ class WebhooksController < ActionController::Base def handle_mailgun_legacy(params) unless valid_mailgun_signature?(params["token"], params["timestamp"], params["signature"]) - return mailgun_failure + return signature_failure end event = params["event"] @@ -185,7 +226,7 @@ class WebhooksController < ActionController::Base signature["timestamp"], signature["signature"], ) - return mailgun_failure + return signature_failure end data = params["event-data"] @@ -205,6 +246,58 @@ class WebhooksController < ActionController::Base success end + def valid_sendgrid_signature? + signature = request.headers["X-Twilio-Email-Event-Webhook-Signature"] + timestamp = request.headers["X-Twilio-Email-Event-Webhook-Timestamp"] + request.body.rewind + payload = request.body.read + + hashed_payload = Digest::SHA256.digest("#{timestamp}#{payload}") + decoded_signature = Base64.decode64(signature) + + begin + public_key = OpenSSL::PKey::EC.new(Base64.decode64(SiteSetting.sendgrid_verification_key)) + rescue StandardError => err + Rails.logger.error("Invalid Sendgrid verification key") + return false + end + + public_key.dsa_verify_asn1(hashed_payload, decoded_signature) + end + + def valid_mailjet_token? + ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.mailjet_webhook_token) + end + + def valid_mandrill_signature? + signature = request.headers["X-Mandrill-Signature"] + + payload = "#{Discourse.base_url}/webhooks/mandrill" + params + .permit(:mandrill_events) + .to_h + .sort_by(&:first) + .each do |key, value| + payload += key.to_s + payload += value + end + + payload_signature = + OpenSSL::HMAC.digest("sha1", SiteSetting.mandrill_authentication_key, payload) + ActiveSupport::SecurityUtils.secure_compare( + signature, + Base64.strict_encode64(payload_signature), + ) + end + + def valid_postmark_token? + ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.postmark_webhook_token) + end + + def valid_sparkpost_token? + ActiveSupport::SecurityUtils.secure_compare(params[:t], SiteSetting.sparkpost_webhook_token) + end + def process_bounce(message_id, to_address, bounce_score, bounce_error_code = nil) return if message_id.blank? || to_address.blank? diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 8a3eb8cbb2..2b656f5ec0 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -2040,6 +2040,11 @@ en: block_auto_generated_emails: "Block incoming emails identified as being auto generated." ignore_by_title: "Ignore incoming emails based on their title." mailgun_api_key: "Mailgun Secret API key used to verify webhook messages." + sendgrid_verification_key: "Sendgrid verification key used to verify webhook messages." + mailjet_webhook_token: "Token used to verify webhook payload. It must be passed as the 't' query parameter of the webhook, for example: https://example.com/webhook/mailjet?t=supersecret" + mandrill_authentication_key: "Mandrill authentication key used to verify webhook messages." + postmark_webhook_token: "Token used to verify webhook payload. It must be passed as the 't' query parameter of the webhook, for example: https://example.com/webhook/postmark?t=supersecret" + sparkpost_webhook_token: "Token used to verify webhook payload. It must be passed as the 't' query parameter of the webhook, for example: https://example.com/webhook/sparkpost?t=supersecret" soft_bounce_score: "Bounce score added to the user when a temporary bounce happens." hard_bounce_score: "Bounce score added to the user when a permanent bounce happens." diff --git a/config/site_settings.yml b/config/site_settings.yml index e842264f55..5e917c9d9b 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1246,6 +1246,21 @@ email: default: "" regex: '^((key-)?\h{32}|\h{32}-\h{8}-\h{8})$' secret: true + sendgrid_verification_key: + default: "" + secret: true + mailjet_webhook_token: + default: "" + secret: true + mandrill_authentication_key: + default: "" + secret: true + postmark_webhook_token: + default: "" + secret: true + sparkpost_webhook_token: + default: "" + secret: true bounce_score_threshold: client: true default: 4 diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb index 8d6050096c..452774d773 100644 --- a/spec/requests/webhooks_controller_spec.rb +++ b/spec/requests/webhooks_controller_spec.rb @@ -105,6 +105,53 @@ RSpec.describe WebhooksController do expect(email_log.bounce_error_code).to eq("5.0.0") expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) end + + it "verifies signatures" do + SiteSetting.sendgrid_verification_key = + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g==" + + post "/webhooks/sendgrid.json", + headers: { + "X-Twilio-Email-Event-Webhook-Signature" => + "MEUCIGHQVtGj+Y3LkG9fLcxf3qfI10QysgDWmMOVmxG0u6ZUAiEAyBiXDWzM+uOe5W0JuG+luQAbPIqHh89M15TluLtEZtM=", + "X-Twilio-Email-Event-Webhook-Timestamp" => "1600112502", + }, + params: + "[{\"email\":\"hello@world.com\",\"event\":\"dropped\",\"reason\":\"Bounced Address\",\"sg_event_id\":\"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA\",\"sg_message_id\":\"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0\",\"smtp-id\":\"\",\"timestamp\":1600112492}]\r\n" + + expect(response.status).to eq(200) + end + + it "returns error if signature verification fails" do + SiteSetting.sendgrid_verification_key = + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE83T4O/n84iotIvIW4mdBgQ/7dAfSmpqIM8kF9mN1flpVKS3GRqe62gw+2fNNRaINXvVpiglSI8eNEc6wEA3F+g==" + + post "/webhooks/sendgrid.json", + headers: { + "X-Twilio-Email-Event-Webhook-Signature" => + "MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH3j/0=", + "X-Twilio-Email-Event-Webhook-Timestamp" => "1600112502", + }, + params: + "[{\"email\":\"hello@world.com\",\"event\":\"dropped\",\"reason\":\"Bounced Address\",\"sg_event_id\":\"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA\",\"sg_message_id\":\"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0\",\"smtp-id\":\"\",\"timestamp\":1600112492}]\r\n" + + expect(response.status).to eq(406) + end + + it "returns error if signature is invalid" do + SiteSetting.sendgrid_verification_key = "foo" + + post "/webhooks/sendgrid.json", + headers: { + "X-Twilio-Email-Event-Webhook-Signature" => + "MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH3j/0=", + "X-Twilio-Email-Event-Webhook-Timestamp" => "1600112502", + }, + params: + "[{\"email\":\"hello@world.com\",\"event\":\"dropped\",\"reason\":\"Bounced Address\",\"sg_event_id\":\"ZHJvcC0xMDk5NDkxOS1MUnpYbF9OSFN0T0doUTRrb2ZTbV9BLTA\",\"sg_message_id\":\"LRzXl_NHStOGhQ4kofSm_A.filterdrecv-p3mdw1-756b745b58-kmzbl-18-5F5FC76C-9.0\",\"smtp-id\":\"\",\"timestamp\":1600112492}]\r\n" + + expect(response.status).to eq(406) + end end describe "#mailjet" do @@ -127,9 +174,47 @@ RSpec.describe WebhooksController do expect(email_log.bounce_error_code).to eq(nil) # mailjet doesn't give us this expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) end + + it "verifies signatures" do + SiteSetting.mailjet_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/mailjet.json?t=foo", + params: { + "event" => "bounce", + "email" => email, + "hard_bounce" => true, + "CustomID" => message_id, + } + + expect(response.status).to eq(200) + expect(email_log.reload.bounced).to eq(true) + end + + it "returns error if signature verification fails" do + SiteSetting.mailjet_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/mailjet.json?t=bar", + params: { + "event" => "bounce", + "email" => email, + "hard_bounce" => true, + "CustomID" => message_id, + } + + expect(response.status).to eq(406) + expect(email_log.reload.bounced).to eq(false) + end end describe "#mandrill" do + let(:payload) do + "mandrill_events=%5B%7B%22event%22%3A%22hard_bounce%22%2C%22msg%22%3A%7B%22email%22%3A%22em%40il.com%22%2C%22diag%22%3A%225.1.1%22%2C%22bounce_description%22%3A%22smtp%3B+550-5.1.1+The+email+account+that+you+tried+to+reach+does+not+exist.%22%2C%22metadata%22%3A%7B%22message_id%22%3A%2212345%40il.com%22%7D%7D%7D%5D" + end + it "works" do user = Fabricate(:user, email: email) email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) @@ -159,6 +244,38 @@ RSpec.describe WebhooksController do expect(email_log.bounce_error_code).to eq("5.1.1") expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) end + + it "verifies signatures" do + SiteSetting.mandrill_authentication_key = "wr_JeJNO9OI65RFDrvk3Zw" + + post "/webhooks/mandrill.json", + headers: { + "X-Mandrill-Signature" => "Q5pCb903EjEqRZ99gZrlYKOfvIU=", + }, + params: payload + + expect(response.status).to eq(200) + end + + it "returns error if signature verification fails" do + SiteSetting.mandrill_authentication_key = "wr_JeJNO9OI65RFDrvk3Zw" + + post "/webhooks/mandrill.json", headers: { "X-Mandrill-Signature" => "foo" }, params: payload + + expect(response.status).to eq(406) + end + + it "returns error if signature is invalid" do + SiteSetting.mandrill_authentication_key = "foo" + + post "/webhooks/mandrill.json", + headers: { + "X-Mandrill-Signature" => "Q5pCb903EjEqRZ99gZrlYKOfvIU=", + }, + params: payload + + expect(response.status).to eq(406) + end end describe "#mandrill_head" do @@ -187,6 +304,7 @@ RSpec.describe WebhooksController do expect(email_log.bounce_error_code).to eq(nil) # postmark doesn't give us this expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) end + it "soft bounces" do user = Fabricate(:user, email: email) email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) @@ -204,6 +322,38 @@ RSpec.describe WebhooksController do expect(email_log.bounce_error_code).to eq(nil) # postmark doesn't give us this expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score) end + + it "verifies signatures" do + SiteSetting.postmark_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/postmark.json?t=foo", + params: { + "Type" => "HardBounce", + "MessageID" => message_id, + "Email" => email, + } + + expect(response.status).to eq(200) + expect(email_log.reload.bounced).to eq(true) + end + + it "returns error if signature verification fails" do + SiteSetting.postmark_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/postmark.json?t=bar", + params: { + "Type" => "HardBounce", + "MessageID" => message_id, + "Email" => email, + } + + expect(response.status).to eq(406) + expect(email_log.reload.bounced).to eq(false) + end end describe "#sparkpost" do @@ -235,5 +385,136 @@ RSpec.describe WebhooksController do expect(email_log.bounced).to eq(true) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) end + + it "verifies signatures" do + SiteSetting.sparkpost_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/sparkpost.json?t=foo", + params: { + "_json" => [ + { + "msys" => { + "message_event" => { + "bounce_class" => 10, + "error_code" => "554", + "rcpt_to" => email, + "rcpt_meta" => { + "message_id" => message_id, + }, + }, + }, + }, + ], + } + + expect(response.status).to eq(200) + expect(email_log.reload.bounced).to eq(true) + end + + it "returns error if signature verification fails" do + SiteSetting.sparkpost_webhook_token = "foo" + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + post "/webhooks/sparkpost.json?t=bar", + params: { + "_json" => [ + { + "msys" => { + "message_event" => { + "bounce_class" => 10, + "error_code" => "554", + "rcpt_to" => email, + "rcpt_meta" => { + "message_id" => message_id, + }, + }, + }, + }, + ], + } + + expect(response.status).to eq(406) + expect(email_log.reload.bounced).to eq(false) + end + end + + describe "#aws" do + let(:payload) do + { + "Type" => "Notification", + "Message" => { + "notificationType" => "Bounce", + :"bounce" => { + "bounceType" => "Permanent", + "reportingMTA" => "dns; email.example.com", + :"bouncedRecipients" => [ + { + "emailAddress" => email, + "status" => "5.1.1", + "action" => "failed", + "diagnosticCode" => "smtp; 550 5.1.1 <#{email}>... User", + }, + ], + "bounceSubType" => "General", + "timestamp" => "2016-01-27T14:59:38.237Z", + "feedbackId" => "00000138111222aa-33322211-cccc-cccc-cccc-ddddaaaa068a-000000", + "remoteMtaIp" => "127.0.2.0", + }, + :"mail" => { + "timestamp" => "2016-01-27T14:59:38.237Z", + "source" => "john@example.com", + "sourceArn" => "arn:aws:ses:us-east-1:888888888888:identity/example.com", + "sourceIp" => "127.0.3.0", + "sendingAccountId" => "123456789012", + "callerIdentity" => "IAM_user_or_role_name", + "messageId" => message_id, + "destination" => [email, "jane@example.com", "mary@example.com", "richard@example.com"], + "headersTruncated" => false, + "headers" => [ + { "name" => "From", "value" => "\"John Doe\" " }, + { + "name" => "To", + "value" => + "\"Test\" <#{email}>, \"Jane Doe\" , \"Mary Doe\" , \"Richard Doe\" ", + }, + { "name" => "Message-ID", "value" => message_id }, + { "name" => "Subject", "value" => "Hello" }, + { "name" => "Content-Type", "value" => "text/plain; charset=\"UTF-8\"" }, + { "name" => "Content-Transfer-Encoding", "value" => "base64" }, + { "name" => "Date", "value" => "Wed, 27 Jan 2016 14:05:45 +0000" }, + ], + "commonHeaders" => { + "from" => ["John Doe "], + "date" => "Wed, 27 Jan 2016 14:05:45 +0000", + "to" => [ + "\"Test\" <#{email}>, Jane Doe , Mary Doe , Richard Doe ", + ], + "messageId" => message_id, + "subject" => "Hello", + }, + }, + }.to_json, + }.to_json + end + + before { Jobs.run_immediately! } + + it "works" do + user = Fabricate(:user, email: email) + email_log = Fabricate(:email_log, user: user, message_id: message_id, to_address: email) + + require "aws-sdk-sns" + Aws::SNS::MessageVerifier.any_instance.stubs(:authentic?).with(payload).returns(true) + + post "/webhooks/aws.json", headers: { "RAW_POST_DATA" => payload } + expect(response.status).to eq(200) + + email_log.reload + expect(email_log.bounced).to eq(true) + expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) + end end end From 0fea826f424f18109e94a8c4feaab9b033bcdab7 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Mon, 16 Jan 2023 19:20:19 +0200 Subject: [PATCH 053/169] FIX: Validate tags parameter of TopicQuery (#19830) Recently, we have seen some errors related to invalid tags value being passed to TopicQuery. --- lib/topic_query.rb | 9 +++++++-- spec/requests/list_controller_spec.rb | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 5b33a15fbe..cd3ce27508 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -15,10 +15,15 @@ class TopicQuery @validators ||= begin int = lambda { |x| Integer === x || (String === x && x.match?(/^-?[0-9]+$/)) } - zero_up_to_max_int = lambda { |x| int.call(x) && x.to_i.between?(0, PG_MAX_INT) } + array_or_string = lambda { |x| Array === x || String === x } - { max_posts: zero_up_to_max_int, min_posts: zero_up_to_max_int, page: zero_up_to_max_int } + { + max_posts: zero_up_to_max_int, + min_posts: zero_up_to_max_int, + page: zero_up_to_max_int, + tags: array_or_string, + } end end diff --git a/spec/requests/list_controller_spec.rb b/spec/requests/list_controller_spec.rb index 9307d13353..547e057313 100644 --- a/spec/requests/list_controller_spec.rb +++ b/spec/requests/list_controller_spec.rb @@ -30,6 +30,9 @@ RSpec.describe ListController do get "/latest?page=1111111111111111111111111111111111111111" expect(response.status).to eq(400) + + get "/latest?tags[1]=hello" + expect(response.status).to eq(400) end it "returns 200 for legit requests" do @@ -59,6 +62,9 @@ RSpec.describe ListController do get "/latest.json?topic_ids=14583%2C14584" expect(response.status).to eq(200) + + get "/latest?tags[]=hello" + expect(response.status).to eq(200) end (Discourse.anonymous_filters - [:categories]).each do |filter| From 624f4a7de92d8a464c91a334bf9fad510af7e4ef Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 16 Jan 2023 17:28:59 +0000 Subject: [PATCH 054/169] Drop support for iOS < 15.7 (#19847) https://meta.discourse.org/t/224747 --- README.md | 2 +- .../app/initializers/safari-13-deprecation.js | 30 -- .../javascripts/discourse/config/targets.js | 3 +- .../discourse/scripts/browser-detect.js | 9 +- app/assets/javascripts/polyfills.js | 322 +----------------- app/models/theme.rb | 2 +- config/locales/client.en.yml | 2 - lib/discourse_js_processor.rb | 14 +- lib/mobile_detection.rb | 4 +- spec/lib/discourse_js_processor_spec.rb | 39 +++ spec/lib/mobile_detection_spec.rb | 14 +- 11 files changed, 65 insertions(+), 376 deletions(-) delete mode 100644 app/assets/javascripts/discourse/app/initializers/safari-13-deprecation.js diff --git a/README.md b/README.md index 0cbfcd4f97..2b914eab91 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Discourse supports the **latest, stable releases** of all major browsers and pla | Microsoft Edge | | | | Mozilla Firefox | | | -Additionally, we aim to support Safari on iOS 12.5+ until January 2023 (Discourse 3.0). +Additionally, we aim to support Safari on iOS 15.7+. ## Built With diff --git a/app/assets/javascripts/discourse/app/initializers/safari-13-deprecation.js b/app/assets/javascripts/discourse/app/initializers/safari-13-deprecation.js deleted file mode 100644 index 521cd6c0a3..0000000000 --- a/app/assets/javascripts/discourse/app/initializers/safari-13-deprecation.js +++ /dev/null @@ -1,30 +0,0 @@ -import I18n from "I18n"; -import { withPluginApi } from "discourse/lib/plugin-api"; - -function setupMessage(api) { - const isSafari = navigator.vendor === "Apple Computer, Inc."; - if (!isSafari) { - return; - } - - let safariMajorVersion = navigator.userAgent.match(/Version\/(\d+)\./)?.[1]; - safariMajorVersion = safariMajorVersion - ? parseInt(safariMajorVersion, 10) - : null; - - if (safariMajorVersion && safariMajorVersion <= 13) { - api.addGlobalNotice( - I18n.t("safari_13_warning"), - "browser-deprecation-warning", - { dismissable: true, dismissDuration: moment.duration(1, "week") } - ); - } -} - -export default { - name: "safari-13-deprecation", - - initialize() { - withPluginApi("0.8.37", setupMessage); - }, -}; diff --git a/app/assets/javascripts/discourse/config/targets.js b/app/assets/javascripts/discourse/config/targets.js index 13213df378..09210acbf6 100644 --- a/app/assets/javascripts/discourse/config/targets.js +++ b/app/assets/javascripts/discourse/config/targets.js @@ -10,8 +10,7 @@ const browsers = [ ]; if (isCI || isProduction) { - // https://meta.discourse.org/t/224747 - browsers.push("Safari 12"); + browsers.push("Safari 15"); } module.exports = { diff --git a/app/assets/javascripts/discourse/scripts/browser-detect.js b/app/assets/javascripts/discourse/scripts/browser-detect.js index 86cce506a8..b205e684eb 100644 --- a/app/assets/javascripts/discourse/scripts/browser-detect.js +++ b/app/assets/javascripts/discourse/scripts/browser-detect.js @@ -1,7 +1,14 @@ /* eslint-disable no-var */ // `let` is not supported in very old browsers (function () { - if (!window.WeakMap || !window.Promise || typeof globalThis === "undefined") { + if ( + !window.WeakMap || + !window.Promise || + typeof globalThis === "undefined" || + !String.prototype.replaceAll || + !CSS.supports || + !CSS.supports("aspect-ratio: 1") + ) { window.unsupportedBrowser = true; } else { // Some implementations of `WeakMap.prototype.has` do not accept false diff --git a/app/assets/javascripts/polyfills.js b/app/assets/javascripts/polyfills.js index 381b769e39..c5c0caaa89 100644 --- a/app/assets/javascripts/polyfills.js +++ b/app/assets/javascripts/polyfills.js @@ -1,325 +1,5 @@ /* eslint-disable */ -// Needed for iOS <= 13.3 -if (!String.prototype.replaceAll) { - String.prototype.replaceAll = function ( - pattern, - replacementStringOrFunction - ) { - let regex; - - if ( - Object.prototype.toString.call(pattern).toLowerCase() === - "[object regexp]" - ) { - regex = pattern; - } else { - const escapedStr = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - regex = new RegExp(escapedStr, "g"); - } - - return this.replace(regex, replacementStringOrFunction); - }; -} - -// Needed for Safari 15.2 and below -// from: https://github.com/iamdustan/smoothscroll -(function () { - "use strict"; - - function e() { - var e = window; - var t = document; - if ( - "scrollBehavior" in t.documentElement.style && - e.__forceSmoothScrollPolyfill__ !== true - ) { - return; - } - var o = e.HTMLElement || e.Element; - var r = 1.8; - var l = { - scroll: e.scroll || e.scrollTo, - scrollBy: e.scrollBy, - elementScroll: o.prototype.scroll || s, - scrollIntoView: o.prototype.scrollIntoView, - }; - var n = - e.performance && e.performance.now - ? e.performance.now.bind(e.performance) - : Date.now; - - function i(e) { - var t = ["MSIE ", "Trident/", "Edge/"]; - return new RegExp(t.join("|")).test(e); - } - var f = i(e.navigator.userAgent) ? 1 : 0; - - function s(e, t) { - this.scrollLeft = e; - this.scrollTop = t; - } - - function c(e) { - return 0.5 * (1 - Math.cos(Math.PI * e)); - } - - function a(e) { - if ( - e === null || - typeof e !== "object" || - e.behavior === undefined || - e.behavior === "auto" || - e.behavior === "instant" - ) { - return true; - } - if (typeof e === "object" && e.behavior === "smooth") { - return false; - } - throw new TypeError( - "behavior member of ScrollOptions " + - e.behavior + - " is not a valid value for enumeration ScrollBehavior." - ); - } - - function u(e, t) { - if (t === "Y") { - return e.clientHeight + f < e.scrollHeight; - } - if (t === "X") { - return e.clientWidth + f < e.scrollWidth; - } - } - - function d(t, o) { - var r = e.getComputedStyle(t, null)["overflow" + o]; - return r === "auto" || r === "scroll"; - } - - function p(e) { - var t = u(e, "Y") && d(e, "Y"); - var o = u(e, "X") && d(e, "X"); - return t || o; - } - - function h(e) { - while (e !== t.body && p(e) === false) { - e = e.parentNode || e.host; - } - return e; - } - - function v(e, t) { - var o = r / t; - var l = Math.pow(1.16, Math.max(e / 80, 1)); - return o * e * (1 / l); - } - - function y(t) { - var o = n(); - var r = e.devicePixelRatio; - var l; - var i; - var f; - var s; - var a = v(Math.abs(t.x - t.startX), r); - var u = v(Math.abs(t.y - t.startY), r); - var d = (o - t.startTime) / a; - var p = (o - t.startTime) / u; - d = d > 1 ? 1 : d; - p = p > 1 ? 1 : p; - l = c(d); - i = c(p); - f = t.startX + (t.x - t.startX) * l; - s = t.startY + (t.y - t.startY) * i; - t.method.call(t.scrollable, f, s); - if (f !== t.x || s !== t.y) { - e.requestAnimationFrame(y.bind(e, t)); - } - } - - function m(o, r, i) { - var f; - var c; - var a; - var u; - var d = n(); - if (o === t.body) { - f = e; - c = e.scrollX || e.pageXOffset; - a = e.scrollY || e.pageYOffset; - u = l.scroll; - } else { - f = o; - c = o.scrollLeft; - a = o.scrollTop; - u = s; - } - y({ - scrollable: f, - method: u, - startTime: d, - startX: c, - startY: a, - x: r, - y: i, - }); - } - e.scroll = e.scrollTo = function () { - if (arguments[0] === undefined) { - return; - } - if (a(arguments[0]) === true) { - l.scroll.call( - e, - arguments[0].left !== undefined - ? arguments[0].left - : typeof arguments[0] !== "object" - ? arguments[0] - : e.scrollX || e.pageXOffset, - arguments[0].top !== undefined - ? arguments[0].top - : arguments[1] !== undefined - ? arguments[1] - : e.scrollY || e.pageYOffset - ); - return; - } - m.call( - e, - t.body, - arguments[0].left !== undefined - ? ~~arguments[0].left - : e.scrollX || e.pageXOffset, - arguments[0].top !== undefined - ? ~~arguments[0].top - : e.scrollY || e.pageYOffset - ); - }; - e.scrollBy = function () { - if (arguments[0] === undefined) { - return; - } - if (a(arguments[0])) { - l.scrollBy.call( - e, - arguments[0].left !== undefined - ? arguments[0].left - : typeof arguments[0] !== "object" - ? arguments[0] - : 0, - arguments[0].top !== undefined - ? arguments[0].top - : arguments[1] !== undefined - ? arguments[1] - : 0 - ); - return; - } - m.call( - e, - t.body, - ~~arguments[0].left + (e.scrollX || e.pageXOffset), - ~~arguments[0].top + (e.scrollY || e.pageYOffset) - ); - }; - o.prototype.scroll = o.prototype.scrollTo = function () { - if (arguments[0] === undefined) { - return; - } - if (a(arguments[0]) === true) { - if (typeof arguments[0] === "number" && arguments[1] === undefined) { - throw new SyntaxError("Value could not be converted"); - } - l.elementScroll.call( - this, - arguments[0].left !== undefined - ? ~~arguments[0].left - : typeof arguments[0] !== "object" - ? ~~arguments[0] - : this.scrollLeft, - arguments[0].top !== undefined - ? ~~arguments[0].top - : arguments[1] !== undefined - ? ~~arguments[1] - : this.scrollTop - ); - return; - } - var e = arguments[0].left; - var t = arguments[0].top; - m.call( - this, - this, - typeof e === "undefined" ? this.scrollLeft : ~~e, - typeof t === "undefined" ? this.scrollTop : ~~t - ); - }; - o.prototype.scrollBy = function () { - if (arguments[0] === undefined) { - return; - } - if (a(arguments[0]) === true) { - l.elementScroll.call( - this, - arguments[0].left !== undefined - ? ~~arguments[0].left + this.scrollLeft - : ~~arguments[0] + this.scrollLeft, - arguments[0].top !== undefined - ? ~~arguments[0].top + this.scrollTop - : ~~arguments[1] + this.scrollTop - ); - return; - } - this.scroll({ - left: ~~arguments[0].left + this.scrollLeft, - top: ~~arguments[0].top + this.scrollTop, - behavior: arguments[0].behavior, - }); - }; - o.prototype.scrollIntoView = function () { - if (a(arguments[0]) === true) { - l.scrollIntoView.call( - this, - arguments[0] === undefined ? true : arguments[0] - ); - return; - } - var o = h(this); - var r = o.getBoundingClientRect(); - var n = this.getBoundingClientRect(); - if (o !== t.body) { - m.call( - this, - o, - o.scrollLeft + n.left - r.left, - o.scrollTop + n.top - r.top - ); - if (e.getComputedStyle(o).position !== "fixed") { - e.scrollBy({ - left: r.left, - top: r.top, - behavior: "smooth", - }); - } - } else { - e.scrollBy({ - left: n.left, - top: n.top, - behavior: "smooth", - }); - } - }; - } - if (typeof exports === "object" && typeof module !== "undefined") { - module.exports = { - polyfill: e, - }; - } else { - e(); - } -})(); +// Polyfills for old browsers can be added here /* eslint-enable */ diff --git a/app/models/theme.rb b/app/models/theme.rb index 23701642e5..6b3b2ee750 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -6,7 +6,7 @@ require "json_schemer" class Theme < ActiveRecord::Base include GlobalPath - BASE_COMPILER_VERSION = 69 + BASE_COMPILER_VERSION = 70 attr_accessor :child_components diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0bd807d42d..308b01f608 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -3987,8 +3987,6 @@ en: browser_update: 'Unfortunately, your browser is unsupported. Please switch to a supported browser to view rich content, log in and reply.' - safari_13_warning: This site will soon remove support for iOS and Safari versions 13 and below. A simplified read-only version will remain available. (more information) - permission_types: full: "Create / Reply / See" create_post: "Reply / See" diff --git a/lib/discourse_js_processor.rb b/lib/discourse_js_processor.rb index ae167ffe57..74167a7ec4 100644 --- a/lib/discourse_js_processor.rb +++ b/lib/discourse_js_processor.rb @@ -6,22 +6,12 @@ class DiscourseJsProcessor class TranspileError < StandardError end + # To generate a list of babel plugins used by ember-cli, set + # babel: { debug: true } in ember-cli-build.js, then run `yarn ember build -prod` DISCOURSE_COMMON_BABEL_PLUGINS = [ - "proposal-optional-chaining", ["proposal-decorators", { legacy: true }], - "transform-template-literals", - "proposal-class-properties", "proposal-class-static-block", - "proposal-private-property-in-object", - "proposal-private-methods", - "proposal-numeric-separator", - "proposal-logical-assignment-operators", - "proposal-nullish-coalescing-operator", - "proposal-json-strings", - "proposal-optional-catch-binding", "transform-parameters", - "proposal-async-generator-functions", - "proposal-object-rest-spread", "proposal-export-namespace-from", ] diff --git a/lib/mobile_detection.rb b/lib/mobile_detection.rb index 1ff3361a15..b92523059e 100644 --- a/lib/mobile_detection.rb +++ b/lib/mobile_detection.rb @@ -26,8 +26,8 @@ module MobileDetection MODERN_MOBILE_REGEX = %r{ - \(.*iPhone\ OS\ 1[3-9].*\)| - \(.*iPad.*OS\ 1[3-9].*\)| + \(.*iPhone\ OS\ 1[5-9].*\)| + \(.*iPad.*OS\ 1[5-9].*\)| Chrome\/8[89]| Chrome\/9[0-9]| Chrome\/1[0-9][0-9]| diff --git a/spec/lib/discourse_js_processor_spec.rb b/spec/lib/discourse_js_processor_spec.rb index 3c2eeac803..b23dc86536 100644 --- a/spec/lib/discourse_js_processor_spec.rb +++ b/spec/lib/discourse_js_processor_spec.rb @@ -37,6 +37,45 @@ RSpec.describe DiscourseJsProcessor do end end + it "passes through modern JS syntaxes which are supported in our target browsers" do + script = <<~JS.chomp + optional?.chaining; + const template = func`test`; + class MyClass { + classProperty = 1; + #privateProperty = 1; + #privateMethod() { + console.log("hello world"); + } + } + let numericSeparator = 100_000_000; + logicalAssignment ||= 2; + nullishCoalescing ?? 'works'; + try { + "optional catch binding"; + } catch { + "works"; + } + async function* asyncGeneratorFunction() { + yield await Promise.resolve('a'); + } + let a = { + x, + y, + ...spreadRest + }; + JS + + result = DiscourseJsProcessor.transpile(script, "blah", "blah/mymodule") + expect(result).to eq <<~JS.strip + define("blah/mymodule", [], function () { + "use strict"; + + #{script.indent(2)} + }); + JS + end + it "correctly transpiles widget hbs" do result = DiscourseJsProcessor.transpile(<<~JS, "blah", "blah/mymodule") import hbs from "discourse/widgets/hbs-compiler"; diff --git a/spec/lib/mobile_detection_spec.rb b/spec/lib/mobile_detection_spec.rb index 853fe24b1b..584e20ebda 100644 --- a/spec/lib/mobile_detection_spec.rb +++ b/spec/lib/mobile_detection_spec.rb @@ -10,6 +10,8 @@ RSpec.describe MobileDetection do Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Mobile Safari/537.36 (comp Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.3626.121 Mobile Safari/537.36 (comp + Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1 + Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1 STR end @@ -17,7 +19,6 @@ RSpec.describe MobileDetection do (<<~STR).split("\n") Mozilla/5.0 (iPhone; CPU iPhone OS 15_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1 Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Mobile Safari/537.36 (compatible; - Mozilla/5.0 (iPhone; CPU iPhone OS 14_8_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Mobile/15E148 Safari/604.1 Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Mobile Safari/537.36 (compatible Mozilla/5.0 (Android 12; Mobile; rv:98.0) Gecko/98.0 Firefox/98.0 Mozilla/5.0 (iPad; CPU OS 15_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1 @@ -26,7 +27,6 @@ RSpec.describe MobileDetection do Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 DiscourseHub Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.88 Mobile Safari/537.36 Mozilla/5.0 (Android 12; Mobile; rv:99.0) Gecko/99.0 Firefox/99.0 -Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/99.0.4844.59 Mobile/15E148 Safari/604.1 Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.88 Mobile Safari/537.36 Mozilla/5.0 (Linux; Android 12; Pixel 4a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.88 Mobile Safari/537.36 Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.60 Mobile Safari/537.36 @@ -36,11 +36,17 @@ Mozilla/5.0 (iPhone; CPU iPhone OS 14_8 like Mac OS X) AppleWebKit/605.1.15 (KHT it "detects modern browsers correctly" do modern_user_agents.each do |agent| - expect(MobileDetection.modern_mobile_device?(agent)).to eq(true) + expect(MobileDetection.modern_mobile_device?(agent)).to( + eq(true), + "Failed User Agent: '#{agent}'", + ) end old_user_agents.each do |agent| - expect(MobileDetection.modern_mobile_device?(agent)).to eq(false) + expect(MobileDetection.modern_mobile_device?(agent)).to( + eq(false), + "Failed User Agent: '#{agent}'", + ) end end end From 41f3bb8b5092eef6f6d8c6a97bb74623ec97310d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 16 Jan 2023 18:06:46 +0000 Subject: [PATCH 055/169] FIX: Restore class-property babel transform for themes (#19883) This seems to be required for decorators to work on class properties. Followup to 624f4a7de92d8a464c91a334bf9fad510af7e4ef --- app/models/theme.rb | 2 +- lib/discourse_js_processor.rb | 2 ++ spec/lib/discourse_js_processor_spec.rb | 25 ++++++++++++++++++------- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/models/theme.rb b/app/models/theme.rb index 6b3b2ee750..808a01a1aa 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -6,7 +6,7 @@ require "json_schemer" class Theme < ActiveRecord::Base include GlobalPath - BASE_COMPILER_VERSION = 70 + BASE_COMPILER_VERSION = 71 attr_accessor :child_components diff --git a/lib/discourse_js_processor.rb b/lib/discourse_js_processor.rb index 74167a7ec4..f0452f0f1b 100644 --- a/lib/discourse_js_processor.rb +++ b/lib/discourse_js_processor.rb @@ -10,6 +10,8 @@ class DiscourseJsProcessor # babel: { debug: true } in ember-cli-build.js, then run `yarn ember build -prod` DISCOURSE_COMMON_BABEL_PLUGINS = [ ["proposal-decorators", { legacy: true }], + "proposal-class-properties", + "proposal-private-methods", "proposal-class-static-block", "transform-parameters", "proposal-export-namespace-from", diff --git a/spec/lib/discourse_js_processor_spec.rb b/spec/lib/discourse_js_processor_spec.rb index b23dc86536..90748d3aae 100644 --- a/spec/lib/discourse_js_processor_spec.rb +++ b/spec/lib/discourse_js_processor_spec.rb @@ -41,13 +41,6 @@ RSpec.describe DiscourseJsProcessor do script = <<~JS.chomp optional?.chaining; const template = func`test`; - class MyClass { - classProperty = 1; - #privateProperty = 1; - #privateMethod() { - console.log("hello world"); - } - } let numericSeparator = 100_000_000; logicalAssignment ||= 2; nullishCoalescing ?? 'works'; @@ -76,6 +69,24 @@ RSpec.describe DiscourseJsProcessor do JS end + it "supports decorators and class properties without error" do + script = <<~JS.chomp + class MyClass { + classProperty = 1; + #privateProperty = 1; + #privateMethod() { + console.log("hello world"); + } + @decorated + myMethod(){ + } + } + JS + + result = DiscourseJsProcessor.transpile(script, "blah", "blah/mymodule") + expect(result).to include("_applyDecoratedDescriptor") + end + it "correctly transpiles widget hbs" do result = DiscourseJsProcessor.transpile(<<~JS, "blah", "blah/mymodule") import hbs from "discourse/widgets/hbs-compiler"; From 55d344a934736529d49cdaaac183ea59009afc90 Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Mon, 16 Jan 2023 15:12:12 -0300 Subject: [PATCH 056/169] DEV: Update yaml-lint to 0.1.2 for Ruby 3.2 compat (#19882) --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8ce3b7b774..e1fb37d28e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -496,7 +496,7 @@ GEM xorcist (1.1.3) xpath (3.2.0) nokogiri (~> 1.8) - yaml-lint (0.0.10) + yaml-lint (0.1.2) zeitwerk (2.6.6) PLATFORMS From 0feb9ad341b90e79dc05ada15d6b655c79151ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Saquetim?= <1108771+megothss@users.noreply.github.com> Date: Mon, 16 Jan 2023 15:48:00 -0300 Subject: [PATCH 057/169] DEV: Added callback to change the query used to filter groups in search (#19884) Added plugin registry that will allow adding callbacks that can change the query that is used to filter groups while running a search. --- lib/discourse_plugin_registry.rb | 2 ++ lib/plugin/instance.rb | 4 +++ lib/search.rb | 27 ++++++++++++----- spec/lib/search_spec.rb | 52 ++++++++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb index 9f0fd9e608..daaa144b36 100644 --- a/lib/discourse_plugin_registry.rb +++ b/lib/discourse_plugin_registry.rb @@ -106,6 +106,8 @@ class DiscoursePluginRegistry define_filtered_register :hashtag_autocomplete_data_sources define_filtered_register :hashtag_autocomplete_contextual_type_priorities + define_filtered_register :search_groups_set_query_callbacks + def self.register_auth_provider(auth_provider) self.auth_providers << auth_provider end diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index fc69c10476..743f35283e 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -1292,6 +1292,10 @@ class Plugin::Instance DiscoursePluginRegistry.register_topic_preloader_association(fields, self) end + def register_search_group_query_callback(callback) + DiscoursePluginRegistry.register_search_groups_set_query_callback(callback, self) + end + private def validate_directory_column_name(column_name) diff --git a/lib/search.rb b/lib/search.rb index e81b7ab0d9..7774810057 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -615,12 +615,17 @@ class Search end advanced_filter(/^group:(.+)$/i) do |posts, match| - group_id = + group_query = Group .visible_groups(@guardian.user) .members_visible_groups(@guardian.user) - .where("name ilike ? OR (id = ? AND id > 0)", match, match.to_i) - .pluck_first(:id) + .where("groups.name ILIKE ? OR (id = ? AND id > 0)", match, match.to_i) + + DiscoursePluginRegistry.search_groups_set_query_callbacks.each do |cb| + group_query = cb.call(group_query, @term, @guardian) + end + + group_id = group_query.pluck_first(:id) if group_id posts.where( @@ -944,11 +949,17 @@ class Search end def groups_search - groups = - Group - .visible_groups(@guardian.user, "name ASC", include_everyone: false) - .where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%") - .limit(limit) + group_query = + Group.visible_groups(@guardian.user, "groups.name ASC", include_everyone: false).where( + "groups.name ILIKE :term OR groups.full_name ILIKE :term", + term: "%#{@term}%", + ) + + DiscoursePluginRegistry.search_groups_set_query_callbacks.each do |cb| + group_query = cb.call(group_query, @term, @guardian) + end + + groups = group_query.limit(limit) groups.each { |group| @results.add(group) } end diff --git a/spec/lib/search_spec.rb b/spec/lib/search_spec.rb index 01640a5f99..4388a13ebd 100644 --- a/spec/lib/search_spec.rb +++ b/spec/lib/search_spec.rb @@ -897,7 +897,7 @@ RSpec.describe Search do result = Search.execute("search term") expect(result.posts.first.topic_title_headline).to eq(<<~HTML.chomp) - Very very very very very very very long topic title with our search term in the middle of the title + Very very very very very very very long topic title with our search term in the middle of the title HTML end @@ -1316,7 +1316,28 @@ RSpec.describe Search do context "with non staff logged in" do it "shows doesn’t show group" do - expect(search.groups.map(&:name)).to be_empty + end + end + end + + context "with registered plugin callbacks" do + let!(:group) { Fabricate(:group, name: "plugin-special") } + + context "when :search_groups_set_query_callback is registered" do + it "changes the search results" do + # initial result (without applying the plugin callback ) + expect(search.groups.map(&:name).include?("plugin-special")).to eq(true) + + DiscoursePluginRegistry.register_search_groups_set_query_callback( + Proc.new { |query, term, guardian| query.where.not(name: "plugin-special") }, + Plugin::Instance.new, + ) + + # after using the callback we expect the search result to be changed because the + # query was altered + expect(search.groups.map(&:name).include?("plugin-special")).to eq(false) + + DiscoursePluginRegistry.reset_register!(:search_groups_set_query_callbacks) end end end @@ -1780,6 +1801,31 @@ RSpec.describe Search do Search.execute("group:#{group.id}", guardian: Guardian.new(user)).posts, ).to contain_exactly(post) end + + context "with registered plugin callbacks" do + context "when :search_groups_set_query_callback is registered" do + it "changes the search results" do + group.update!( + visibility_level: Group.visibility_levels[:public], + members_visibility_level: Group.visibility_levels[:public], + ) + + # initial result (without applying the plugin callback ) + expect(Search.execute("group:like_a_boss").posts).to contain_exactly(post) + + DiscoursePluginRegistry.register_search_groups_set_query_callback( + Proc.new { |query, term, guardian| query.where.not(name: "Like_a_Boss") }, + Plugin::Instance.new, + ) + + # after using the callback we expect the search result to be changed because the + # query was altered + expect(Search.execute("group:like_a_boss").posts).to be_blank + + DiscoursePluginRegistry.reset_register!(:search_groups_set_query_callbacks) + end + end + end end it "supports badge" do @@ -2356,7 +2402,7 @@ RSpec.describe Search do expect(results.posts.length).to eq(1) # TODO: this is a test we need to fix! - #expect(results.blurb(results.posts.first)).to include('Rágis') + # expect(results.blurb(results.posts.first)).to include('Rágis') results = Search.execute("สวัสดี", type_filter: "topic") expect(results.posts.length).to eq(1) From 3479264012854c3154b6a1d4e26cc795981cca07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jan 2023 23:40:33 +0100 Subject: [PATCH 058/169] Build(deps): Bump rubocop-rspec from 2.17.0 to 2.18.0 (#19887) --- Gemfile.lock | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e1fb37d28e..0440466148 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -408,11 +408,14 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.24.1) parser (>= 3.1.1.0) + rubocop-capybara (2.17.0) + rubocop (~> 1.41) rubocop-discourse (3.0.3) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.17.0) + rubocop-rspec (2.18.0) rubocop (~> 1.33) + rubocop-capybara ruby-prof (1.4.5) ruby-progressbar (1.11.0) ruby-readability (0.7.0) From 88b009963945bbf1a80e8fd06357e747fef9b3c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jan 2023 23:41:15 +0100 Subject: [PATCH 059/169] Build(deps): Bump faraday from 2.7.2 to 2.7.3 (#19886) --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0440466148..1b3dab8d70 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -144,7 +144,7 @@ GEM faker (2.23.0) i18n (>= 1.8.11, < 2) fakeweb (1.3.0) - faraday (2.7.2) + faraday (2.7.3) faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) From d4e262d5dc5cf1585c313b547aba751b604040b1 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 17 Jan 2023 10:16:07 +1000 Subject: [PATCH 060/169] DEV: Add syntax_tree check to lefthook (#19877) Adds the pre-commit hook for syntax_tree to lefthook. Didn't add the normal linter because it seems to have a max file limit, which we exceed with a combination of *.rb and *.rake. If the format doesn't match on commit we get this: ``` EXECUTE > syntax_tree [warn] lib/post_jobs_enqueuer.rb [warn] lib/tasks/s3.rake The listed files did not match the expected format. ``` --- lefthook.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lefthook.yml b/lefthook.yml index be90ea676c..d7ba164855 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -11,6 +11,9 @@ pre-commit: rubocop: glob: "*.rb" run: bundle exec rubocop --parallel --force-exclusion {staged_files} + syntax_tree: + glob: "*.{rb,rake}" + run: bundle exec stree check Gemfile {staged_files} prettier: glob: "*.js" include: "app/assets/javascripts|plugins/.+?/assets/javascripts" From 14983c5b8ed160ac6d0887f397982d0cf6597510 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Tue, 17 Jan 2023 10:16:28 +1000 Subject: [PATCH 061/169] FIX: New hashtag support for narrative bot advanced narrative (#19875) The discobot advanced tutorial was failing when the new hashtags were enabled with enable_experimental_hashtag_autocomplete set to true, since the CSS selector is different. This commit fixes the issue and also changes the instructions if this is enabled since we no longer require the hashtag to not be at the start of the line. c.f. https://meta.discourse.org/t/it-is-impossible-to-complete-the-hashtag-section-of-the-discobot-advanced-tutorial/251494 --- .../config/locales/server.en.yml | 10 ++++++ .../advanced_user_narrative.rb | 31 +++++++++++++---- .../advanced_user_narrative_spec.rb | 34 +++++++++++++++++-- 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/plugins/discourse-narrative-bot/config/locales/server.en.yml b/plugins/discourse-narrative-bot/config/locales/server.en.yml index cf69370c2a..d1114d78f4 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.en.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.en.yml @@ -401,9 +401,19 @@ en: Did you know that you can refer to categories and tags in your post? For example, have you seen the %{category} category? Type `#` in the middle of a sentence and select any category or tag. + instructions_experimental: |- + Did you know that you can refer to categories and tags in your post? For example, have you seen the %{category} category? + + Type `#` anywhere in a sentence and select any category or tag. not_found: |- Hmm, I don’t see a category in there anywhere. Note that `#` can't be the first character. Can you copy this in your next reply? + ```text + I can create a category link via # + ``` + not_found_experimental: |- + Hmm, I don’t see a category in there anywhere. Can you copy this in your next reply? + ```text I can create a category link via # ``` diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb index 1fea4f13d5..8b93776603 100644 --- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/advanced_user_narrative.rb @@ -46,10 +46,20 @@ module DiscourseNarrativeBot slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}" end - I18n.t( - "#{I18N_KEY}.category_hashtag.instructions", - i18n_post_args(category: "##{slug}"), - ) + # TODO (martin) When enable_experimental_hashtag_autocomplete is the only option + # update the instructions and remove instructions_experimental, as well as the + # not_found translation + if SiteSetting.enable_experimental_hashtag_autocomplete + I18n.t( + "#{I18N_KEY}.category_hashtag.instructions_experimental", + i18n_post_args(category: "##{slug}"), + ) + else + I18n.t( + "#{I18N_KEY}.category_hashtag.instructions", + i18n_post_args(category: "##{slug}"), + ) + end end, recover: { action: :reply_to_recover, @@ -288,7 +298,9 @@ module DiscourseNarrativeBot topic_id = @post.topic_id return unless valid_topic?(topic_id) - if Nokogiri::HTML5.fragment(@post.cooked).css(".hashtag").size > 0 + hashtag_css_class = + SiteSetting.enable_experimental_hashtag_autocomplete ? ".hashtag-cooked" : ".hashtag" + if Nokogiri::HTML5.fragment(@post.cooked).css(hashtag_css_class).size > 0 raw = <<~MD #{I18n.t("#{I18N_KEY}.category_hashtag.reply", i18n_post_args)} @@ -300,7 +312,14 @@ module DiscourseNarrativeBot else fake_delay unless @data[:attempted] - reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found", i18n_post_args)) + if SiteSetting.enable_experimental_hashtag_autocomplete + reply_to( + @post, + I18n.t("#{I18N_KEY}.category_hashtag.not_found_experimental", i18n_post_args), + ) + else + reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found", i18n_post_args)) + end end enqueue_timeout_job(@user) false diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb index 14561dceed..529ad5c67a 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb @@ -377,6 +377,9 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do context "when reply contains the skip trigger" do it "should create the right reply" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + parent_category = Fabricate(:category, name: "a") _category = Fabricate(:category, parent_category: parent_category, name: "b") @@ -414,6 +417,9 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do context "when user recovers a post in the right topic" do it "should create the right reply" do + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false + parent_category = Fabricate(:category, name: "a") _category = Fabricate(:category, parent_category: parent_category, name: "b") post @@ -442,6 +448,9 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do topic_id: topic.id, track: described_class.to_s, ) + + # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites + SiteSetting.enable_experimental_hashtag_autocomplete = false end context "when post is not in the right topic" do @@ -494,9 +503,6 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do end it "should create the right reply" do - # TODO (martin) Remove when enable_experimental_hashtag_autocomplete is default for all sites - SiteSetting.enable_experimental_hashtag_autocomplete = false - category = Fabricate(:category) post.update!(raw: "Check out this ##{category.slug}") @@ -513,6 +519,28 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do :tutorial_change_topic_notification_level, ) end + + context "when enable_experimental_hashtag_autocomplete is true" do + before { SiteSetting.enable_experimental_hashtag_autocomplete = true } + + it "should create the right reply" do + category = Fabricate(:category) + + post.update!(raw: "Check out this ##{category.slug}") + narrative.input(:reply, user, post: post) + + expected_raw = <<~RAW + #{I18n.t("discourse_narrative_bot.advanced_user_narrative.category_hashtag.reply", base_uri: "")} + + #{I18n.t("discourse_narrative_bot.advanced_user_narrative.change_topic_notification_level.instructions", base_uri: "")} + RAW + + expect(Post.last.raw).to eq(expected_raw.chomp) + expect(narrative.get_data(user)[:state].to_sym).to eq( + :tutorial_change_topic_notification_level, + ) + end + end end context "with topic notification level tutorial" do From 1e8a1a0d24b3407f26302337de4386d044ad8282 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Tue, 17 Jan 2023 15:50:21 +0800 Subject: [PATCH 062/169] PERF: N+1 queries when viewing tags (#19891) When the `tags_listed_by_group` site setting is enabled, we were seeing the N+1 queries problem when multiple `TagGroup` records are listed. This commit fixes that by ensuring that we are not filtering through the `tags` association after the association has been eager loaded. --- app/controllers/tags_controller.rb | 4 +-- app/models/tag_group.rb | 4 +++ spec/requests/tags_controller_spec.rb | 46 +++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 9b27b69e6a..5a8a8137b7 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -46,14 +46,14 @@ class TagsController < ::ApplicationController TagGroup .visible(guardian) .order("name ASC") - .includes(:tags) + .includes(:none_synonym_tags) .map do |tag_group| { id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json( - tag_group.tags.where(target_tag_id: nil), + tag_group.none_synonym_tags, show_pm_tags: guardian.can_tag_pms?, ), } diff --git a/app/models/tag_group.rb b/app/models/tag_group.rb index 54ee33bc1c..802ab651ac 100644 --- a/app/models/tag_group.rb +++ b/app/models/tag_group.rb @@ -5,6 +5,10 @@ class TagGroup < ActiveRecord::Base has_many :tag_group_memberships, dependent: :destroy has_many :tags, through: :tag_group_memberships + has_many :none_synonym_tags, + -> { where(target_tag_id: nil) }, + through: :tag_group_memberships, + source: "tag" has_many :category_tag_groups, dependent: :destroy has_many :category_required_tag_groups, dependent: :destroy has_many :categories, through: :category_tag_groups diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index ba04422120..bd4e0908f5 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -80,9 +80,10 @@ RSpec.describe TagsController do it "works for tags in groups" do tag_group = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym]) - get "/tags.json" - expect(response.status).to eq(200) + get "/tags.json" + + expect(response.status).to eq(200) tags = response.parsed_body["tags"] expect(tags.length).to eq(0) group = response.parsed_body.dig("extras", "tag_groups")&.first @@ -90,6 +91,47 @@ RSpec.describe TagsController do expect(group["tags"].length).to eq(2) expect(group["tags"].map { |t| t["id"] }).to contain_exactly(test_tag.name, topic_tag.name) end + + it "does not result in N+1 queries with multiple tag_groups" do + tag_group = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym]) + + # warm up + get "/tags.json" + expect(response.status).to eq(200) + + initial_sql_queries_count = + track_sql_queries do + get "/tags.json" + + expect(response.status).to eq(200) + + tag_groups = response.parsed_body.dig("extras", "tag_groups") + + expect(tag_groups.length).to eq(1) + expect(tag_groups.map { |tag_group| tag_group["name"] }).to contain_exactly( + tag_group.name, + ) + end.length + + tag_group2 = Fabricate(:tag_group, tags: [topic_tag]) + + new_sql_queries_count = + track_sql_queries do + get "/tags.json" + + expect(response.status).to eq(200) + + tag_groups = response.parsed_body.dig("extras", "tag_groups") + + expect(tag_groups.length).to eq(2) + expect(tag_groups.map { |tag_group| tag_group["name"] }).to contain_exactly( + tag_group.name, + tag_group2.name, + ) + end.length + + expect(new_sql_queries_count).to be <= initial_sql_queries_count + end end context "with tags_listed_by_group disabled" do From 341f93e0ba5bd7d13abe4f00e94a460e216621f8 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Tue, 17 Jan 2023 16:28:32 +0800 Subject: [PATCH 063/169] DEV: Fix linting (#19892) Follow-up to 1e8a1a0d24b3407f26302337de4386d044ad8282 --- spec/requests/tags_controller_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index bd4e0908f5..8664605a4c 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -93,7 +93,7 @@ RSpec.describe TagsController do end it "does not result in N+1 queries with multiple tag_groups" do - tag_group = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym]) + tag_group1 = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym]) # warm up get "/tags.json" @@ -109,7 +109,7 @@ RSpec.describe TagsController do expect(tag_groups.length).to eq(1) expect(tag_groups.map { |tag_group| tag_group["name"] }).to contain_exactly( - tag_group.name, + tag_group1.name, ) end.length @@ -125,7 +125,7 @@ RSpec.describe TagsController do expect(tag_groups.length).to eq(2) expect(tag_groups.map { |tag_group| tag_group["name"] }).to contain_exactly( - tag_group.name, + tag_group1.name, tag_group2.name, ) end.length From 9cdeb93375ab4c019aaf8ed4a1805b700d661812 Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Tue, 17 Jan 2023 16:50:15 +0800 Subject: [PATCH 064/169] FEATURE: Allow TL4 users to see unlisted topics (#19890) TL4 users can already list and unlist topics, but they can't see the unlisted topics. This change brings this to par by allowing TL4 users to also see unlisted topics. --- lib/guardian/topic_guardian.rb | 4 ++++ lib/topic_query.rb | 10 +++++++--- spec/fabricators/user_fabricator.rb | 2 ++ spec/lib/guardian/topic_guardian_spec.rb | 20 ++++++++++++++++---- spec/lib/topic_query_spec.rb | 6 +++++- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 05fb6ad041..0846a9feb4 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -277,6 +277,10 @@ module TopicGuardian ) end + def can_see_unlisted_topics? + is_staff? || @user.has_trust_level?(TrustLevel[4]) + end + def can_get_access_to_topic?(topic) topic&.access_topic_via_group.present? && authenticated? end diff --git a/lib/topic_query.rb b/lib/topic_query.rb index cd3ce27508..37c3120f49 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -642,9 +642,13 @@ class TopicQuery options.reverse_merge!(@options) 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? - options[:visible] = false if @user && @user.id == options[:filtered_to_user] + # Whether to include unlisted (visible = false) topics + viewing_own_topics = @user && @user.id == options[:filtered_to_user] + + if options[:visible].nil? + options[:visible] = true if @user.nil? || @user.regular? + options[:visible] = false if @guardian.can_see_unlisted_topics? || viewing_own_topics + end # Start with a list of all topics result = Topic.unscoped.includes(:category) diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index 1a0fcca19a..ffbc5a9f4d 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -100,6 +100,8 @@ Fabricator(:trust_level_0, from: :user) { trust_level TrustLevel[0] } Fabricator(:trust_level_1, from: :user) { trust_level TrustLevel[1] } +Fabricator(:trust_level_3, from: :user) { trust_level TrustLevel[3] } + Fabricator(:trust_level_4, from: :user) do name "Leader McElderson" username { sequence(:username) { |i| "tl4#{i}" } } diff --git a/spec/lib/guardian/topic_guardian_spec.rb b/spec/lib/guardian/topic_guardian_spec.rb index 94ff370848..fc7678efe5 100644 --- a/spec/lib/guardian/topic_guardian_spec.rb +++ b/spec/lib/guardian/topic_guardian_spec.rb @@ -3,7 +3,8 @@ RSpec.describe TopicGuardian do fab!(:user) { Fabricate(:user) } fab!(:admin) { Fabricate(:admin) } - fab!(:tl3_user) { Fabricate(:leader) } + fab!(:tl3_user) { Fabricate(:trust_level_3) } + fab!(:tl4_user) { Fabricate(:trust_level_4) } fab!(:moderator) { Fabricate(:moderator) } fab!(:category) { Fabricate(:category) } fab!(:group) { Fabricate(:group) } @@ -121,7 +122,6 @@ RSpec.describe TopicGuardian do describe "#can_review_topic?" do it "returns false for TL4 users" do - tl4_user = Fabricate(:user, trust_level: TrustLevel[4]) topic = Fabricate(:topic) expect(Guardian.new(tl4_user).can_review_topic?(topic)).to eq(false) @@ -134,8 +134,6 @@ RSpec.describe TopicGuardian do end it "returns true for TL4 users" do - tl4_user = Fabricate(:user, trust_level: TrustLevel[4]) - expect(Guardian.new(tl4_user).can_create_unlisted_topic?(topic)).to eq(true) end @@ -144,6 +142,20 @@ RSpec.describe TopicGuardian do end end + describe "#can_see_unlisted_topics?" do + it "is allowed for staff users" do + expect(Guardian.new(moderator).can_see_unlisted_topics?).to eq(true) + end + + it "is allowed for TL4 users" do + expect(Guardian.new(tl4_user).can_see_unlisted_topics?).to eq(true) + end + + it "is not allowed for lower level users" do + expect(Guardian.new(tl3_user).can_see_unlisted_topics?).to eq(false) + end + end + # The test cases here are intentionally kept brief because majority of the cases are already handled by # `TopicGuardianCanSeeConsistencyCheck` which we run to ensure that the implementation between `TopicGuardian#can_see_topic_ids` # and `TopicGuardian#can_see_topic?` is consistent. diff --git a/spec/lib/topic_query_spec.rb b/spec/lib/topic_query_spec.rb index ffd62eaa1d..8ba558a353 100644 --- a/spec/lib/topic_query_spec.rb +++ b/spec/lib/topic_query_spec.rb @@ -16,6 +16,7 @@ RSpec.describe TopicQuery do fab!(:creator) { Fabricate(:user) } let(:topic_query) { TopicQuery.new(user) } + fab!(:tl4_user) { Fabricate(:trust_level_4) } fab!(:moderator) { Fabricate(:moderator) } fab!(:admin) { Fabricate(:admin) } @@ -795,8 +796,11 @@ RSpec.describe TopicQuery do # includes the invisible topic if you're a moderator expect(TopicQuery.new(moderator).list_latest.topics.include?(invisible_topic)).to eq(true) - # includes the invisible topic if you're an admin" do + # includes the invisible topic if you're an admin expect(TopicQuery.new(admin).list_latest.topics.include?(invisible_topic)).to eq(true) + + # includes the invisible topic if you're a TL4 user + expect(TopicQuery.new(tl4_user).list_latest.topics.include?(invisible_topic)).to eq(true) end context "with sort_order" do From 011c9b997331a0c5a88a5d498bfcc0d8b06cf22d Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 17 Jan 2023 09:54:33 +0000 Subject: [PATCH 065/169] DEV: Use message-bus chunked encoding in development (#19878) This was previously disabled because of incompatibility with the ember-cli proxy. This commit fixes that incompatibility, and restores the development behaviour to match production. There were three issues at play: 1. Our bootstrap-js addon handles the forwarding of most requests in the ember-cli proxy. This is not built to handle streaming responses. Solution: skip our custom request processing for `/message-bus/*` and use ember-cli's default `http-proxy`. 2. The request/response size-limiting middleware (`rawMiddleware`) would apply even to unhandled paths, causing request and response bodies to be buffered. Solution: skip it for any paths which are not handled by our custom addon. 3. Expressjs servers will buffer/compress responses. Solution: add `Cache-Control: no-transform` to message-bus responses. For now I've done this in development only, but it may be useful to add it to message-bus's default headers in future --- app/assets/javascripts/bootstrap-json/index.js | 17 ++++++++++++++--- .../discourse/app/initializers/message-bus.js | 3 +-- config/initializers/004-message_bus.rb | 7 +++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/bootstrap-json/index.js b/app/assets/javascripts/bootstrap-json/index.js index 87826a54f7..b795588ce7 100644 --- a/app/assets/javascripts/bootstrap-json/index.js +++ b/app/assets/javascripts/bootstrap-json/index.js @@ -463,6 +463,13 @@ to serve API requests. For example: baseURL = rootURL === "" ? "/" : cleanBaseURL(rootURL || baseURL); const rawMiddleware = express.raw({ type: () => true, limit: "100mb" }); + const pathRestrictedRawMiddleware = (req, res, next) => { + if (this.shouldHandleRequest(req, baseURL)) { + return rawMiddleware(req, res, next); + } else { + return next(); + } + }; app.use( "/favicon.ico", @@ -474,9 +481,9 @@ to serve API requests. For example: ) ); - app.use(rawMiddleware, async (req, res, next) => { + app.use(pathRestrictedRawMiddleware, async (req, res, next) => { try { - if (this.shouldForwardRequest(req, baseURL)) { + if (this.shouldHandleRequest(req, baseURL)) { await handleRequest(proxy, baseURL, req, res); } else { // Fixes issues when using e.g. "localhost" instead of loopback IP address @@ -497,7 +504,7 @@ to serve API requests. For example: }); }, - shouldForwardRequest(request, baseURL) { + shouldHandleRequest(request, baseURL) { if ( [ `${baseURL}tests/index.html`, @@ -513,6 +520,10 @@ to serve API requests. For example: return false; } + if (request.path.startsWith(`${baseURL}message-bus/`)) { + return false; + } + return true; }, }; diff --git a/app/assets/javascripts/discourse/app/initializers/message-bus.js b/app/assets/javascripts/discourse/app/initializers/message-bus.js index 7b0593a863..8eb790749d 100644 --- a/app/assets/javascripts/discourse/app/initializers/message-bus.js +++ b/app/assets/javascripts/discourse/app/initializers/message-bus.js @@ -86,8 +86,7 @@ export default { messageBus.baseUrl = siteSettings.long_polling_base_url.replace(/\/$/, "") + "/"; - messageBus.enableChunkedEncoding = - isProduction() && siteSettings.enable_chunked_encoding; + messageBus.enableChunkedEncoding = siteSettings.enable_chunked_encoding; if (messageBus.baseUrl !== "/") { messageBus.ajax = function (opts) { diff --git a/config/initializers/004-message_bus.rb b/config/initializers/004-message_bus.rb index 2fa74be763..e6f1b19c6b 100644 --- a/config/initializers/004-message_bus.rb +++ b/config/initializers/004-message_bus.rb @@ -62,6 +62,11 @@ def setup_message_bus_env(env) extra_headers["Discourse-Logged-Out"] = "1" if env[Auth::DefaultCurrentUserProvider::BAD_TOKEN] + if Rails.env.development? + # Adding no-transform prevents the expressjs ember-cli proxy buffering/compressing the response + extra_headers["Cache-Control"] = "no-transform, must-revalidate, private, max-age=0" + end + hash = { extra_headers: extra_headers, user_id: user_id, @@ -100,6 +105,8 @@ MessageBus.on_middleware_error do |env, e| [403, {}, ["Invalid Access"]] elsif RateLimiter::LimitExceeded === e [429, { "Retry-After" => e.available_in.to_s }, [e.description]] + elsif Errno::EPIPE === e + [422, {}, ["Closed by Client"]] end end From ef437a1e419abd5925cb3f569e8c2ed9714c358b Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 17 Jan 2023 09:54:50 +0000 Subject: [PATCH 066/169] DEV: Bump Rails to 7.0.4 (#19881) --- Gemfile | 2 +- Gemfile.lock | 56 ++++++++++++++++++++++++++-------------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Gemfile b/Gemfile index fae4e9e9bf..162670dd70 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ else # this allows us to include the bits of rails we use without pieces we do not. # # To issue a rails update bump the version number here - rails_version = "7.0.3.1" + rails_version = "7.0.4" gem "actionmailer", rails_version gem "actionpack", rails_version gem "actionview", rails_version diff --git a/Gemfile.lock b/Gemfile.lock index 1b3dab8d70..e6bdb318d1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,25 +17,25 @@ GIT GEM remote: https://rubygems.org/ specs: - actionmailer (7.0.3.1) - actionpack (= 7.0.3.1) - actionview (= 7.0.3.1) - activejob (= 7.0.3.1) - activesupport (= 7.0.3.1) + actionmailer (7.0.4) + actionpack (= 7.0.4) + actionview (= 7.0.4) + activejob (= 7.0.4) + activesupport (= 7.0.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.0) - actionpack (7.0.3.1) - actionview (= 7.0.3.1) - activesupport (= 7.0.3.1) + actionpack (7.0.4) + actionview (= 7.0.4) + activesupport (= 7.0.4) rack (~> 2.0, >= 2.2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.3.1) - activesupport (= 7.0.3.1) + actionview (7.0.4) + activesupport (= 7.0.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -44,15 +44,15 @@ GEM actionview (>= 6.0.a) active_model_serializers (0.8.4) activemodel (>= 3.0) - activejob (7.0.3.1) - activesupport (= 7.0.3.1) + activejob (7.0.4) + activesupport (= 7.0.4) globalid (>= 0.3.6) - activemodel (7.0.3.1) - activesupport (= 7.0.3.1) - activerecord (7.0.3.1) - activemodel (= 7.0.3.1) - activesupport (= 7.0.3.1) - activesupport (7.0.3.1) + activemodel (7.0.4) + activesupport (= 7.0.4) + activerecord (7.0.4) + activemodel (= 7.0.4) + activesupport (= 7.0.4) + activesupport (7.0.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -334,9 +334,9 @@ GEM rails_multisite (4.0.1) activerecord (> 5.0, < 7.1) railties (> 5.0, < 7.1) - railties (7.0.3.1) - actionpack (= 7.0.3.1) - activesupport (= 7.0.3.1) + railties (7.0.4) + actionpack (= 7.0.4) + activesupport (= 7.0.4) method_source rake (>= 12.2) thor (~> 1.0) @@ -512,14 +512,14 @@ PLATFORMS x86_64-linux DEPENDENCIES - actionmailer (= 7.0.3.1) - actionpack (= 7.0.3.1) - actionview (= 7.0.3.1) + actionmailer (= 7.0.4) + actionpack (= 7.0.4) + actionview (= 7.0.4) actionview_precompiler active_model_serializers (~> 0.8.3) - activemodel (= 7.0.3.1) - activerecord (= 7.0.3.1) - activesupport (= 7.0.3.1) + activemodel (= 7.0.4) + activerecord (= 7.0.4) + activesupport (= 7.0.4) addressable annotate aws-sdk-s3 @@ -604,7 +604,7 @@ DEPENDENCIES rack-protection rails_failover rails_multisite - railties (= 7.0.3.1) + railties (= 7.0.4) rake rb-fsevent rbtrace From 4204b984eea18b62e909e6d456ca34004ad7c476 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Tue, 17 Jan 2023 12:49:42 +0000 Subject: [PATCH 067/169] DEV: Allow accessing sourcemaps on `/brotli_asset` path (#19894) Our JS files reference sourcemaps relative to their current path. On sites with non-S3 CDN setups, we use a special path for brotli assets (39a524aa). This caused the sourcemap requests to 404. This commit fixes the issue by allowing the `.map` files to be accessed under `/brotli_asset/*`. --- app/controllers/static_controller.rb | 6 +++++- spec/requests/static_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb index fde841a46e..1eb75bd810 100644 --- a/app/controllers/static_controller.rb +++ b/app/controllers/static_controller.rb @@ -193,7 +193,11 @@ class StaticController < ApplicationController def brotli_asset is_asset_path - serve_asset(".br") { response.headers["Content-Encoding"] = "br" } + if params[:path].end_with?(".map") + serve_asset + else + serve_asset(".br") { response.headers["Content-Encoding"] = "br" } + end end def cdn_asset diff --git a/spec/requests/static_controller_spec.rb b/spec/requests/static_controller_spec.rb index 0fc1f68f4a..884f7ae55f 100644 --- a/spec/requests/static_controller_spec.rb +++ b/spec/requests/static_controller_spec.rb @@ -122,6 +122,22 @@ RSpec.describe StaticController do File.delete(file_path) end end + + it "can serve sourcemaps on adjacent paths" do + assets_path = Rails.root.join("public/assets") + + FileUtils.mkdir_p(assets_path) + + file_path = assets_path.join("test.map") + File.write(file_path, "fake source map") + GlobalSetting.stubs(:cdn_url).returns("https://www.example.com/") + + get "/brotli_asset/test.map" + + expect(response.status).to eq(200) + ensure + File.delete(file_path) + end end describe "#cdn_asset" do From 86b4f4d664d35ba8b9a5be2e11fab014c88f4e6a Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Tue, 17 Jan 2023 11:13:55 -0500 Subject: [PATCH 068/169] UX: Refactor alignment of tag icon in Discourse onebox (#19880) Followup to 1ce9582a6c3d85201d8a3eec26a2a9c3be887311 --- app/assets/stylesheets/common/base/onebox.scss | 18 +++++++++--------- .../stylesheets/common/base/tagging.scss | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index e2efc25cc1..94205c7419 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -777,15 +777,15 @@ aside.onebox.xkcd .onebox-body img { margin-bottom: 0.2rem !important; } - .d-icon-tag { - width: 0.75rem; - padding-top: 0.3rem; - position: absolute; - color: var(--primary-medium); - } - - .discourse-tags .discourse-tag:first-of-type { - padding-left: 1rem; + .discourse-tags { + vertical-align: bottom; + .d-icon-tag { + font-size: var(--font-down-1); + margin-right: 0.35em; + margin-top: 0.15em; + color: var(--primary-medium); + align-self: center; + } } } diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index cad9c18d98..1014899d57 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -131,7 +131,6 @@ .discourse-tags { display: inline-flex; flex-wrap: wrap; - position: relative; a, span { margin-right: 0.25em; From 145d2baa140584213918e01b6137b553e700ba95 Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 17 Jan 2023 12:18:16 -0500 Subject: [PATCH 069/169] A11Y: add aria tags to the new user nav (#19774) --- .../app/components/d-navigation-item.js | 30 ++- .../discourse/app/components/user-nav.hbs | 98 +++++---- .../app/components/user-nav/activity-nav.hbs | 49 ++++- .../components/user-nav/notifications-nav.hbs | 70 ++++--- .../components/user-nav/preferences-nav.hbs | 126 +++++++----- .../components/horizontal-overflow-nav.hbs | 7 +- .../discourse/app/templates/preferences.hbs | 3 +- .../discourse/app/templates/user-invited.hbs | 2 +- .../templates/user-private-messages-group.hbs | 72 +++---- .../templates/user-private-messages-user.hbs | 85 ++++---- .../discourse/app/templates/user/activity.hbs | 3 +- .../discourse/app/templates/user/messages.hbs | 1 + .../app/templates/user/notifications.hbs | 3 +- .../acceptance/user-private-messages-test.js | 194 +++++++++++++----- .../page_objects/pages/user_preferences.rb | 4 +- 15 files changed, 473 insertions(+), 274 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/d-navigation-item.js b/app/assets/javascripts/discourse/app/components/d-navigation-item.js index bb32a38490..ec9e17dd22 100644 --- a/app/assets/javascripts/discourse/app/components/d-navigation-item.js +++ b/app/assets/javascripts/discourse/app/components/d-navigation-item.js @@ -11,7 +11,31 @@ export default Component.extend({ attributeBindings: ["ariaCurrent:aria-current", "title"], - ariaCurrent: computed("router.currentRouteName", "route", function () { - return this.router.currentRouteName === this.route ? "page" : null; - }), + ariaCurrent: computed( + "router.currentRouteName", + "router.currentRoute.parent.name", + "route", + "ariaCurrentContext", + function () { + let ariaCurrentValue = "page"; + + // when there are multiple levels of navigation + // we want the active parent to get `aria-current="page"` + // and the active child to get `aria-current="location"` + if (this.ariaCurrentContext === "subNav") { + ariaCurrentValue = "location"; + } else if (this.ariaCurrentContext === "parentNav") { + if ( + this.router.currentRouteName !== this.route && // not the current route + this.router.currentRoute.parent.name.includes(this.route) // but is the current parent route + ) { + return "page"; + } + } + + return this.router.currentRouteName === this.route + ? ariaCurrentValue + : null; + } + ), }); diff --git a/app/assets/javascripts/discourse/app/components/user-nav.hbs b/app/assets/javascripts/discourse/app/components/user-nav.hbs index 96b8f220c9..4221692a4c 100644 --- a/app/assets/javascripts/discourse/app/components/user-nav.hbs +++ b/app/assets/javascripts/discourse/app/components/user-nav.hbs @@ -1,55 +1,63 @@
- + {{#unless @user.profile_hidden}} -
  • - - {{d-icon "user"}} - {{i18n "user.summary.title"}} - -
  • + + {{d-icon "user"}} + {{i18n "user.summary.title"}} + + + + {{d-icon "stream"}} + {{i18n "user.activity_stream"}} + -
  • - - {{d-icon "stream"}} - {{i18n "user.activity_stream"}} - -
  • {{/unless}} {{#if @showNotificationsTab}} -
  • - - {{d-icon "bell" class="glyph"}} - {{i18n "user.notifications"}} - -
  • + + {{d-icon "bell" class="glyph"}} + {{i18n "user.notifications"}} + {{/if}} {{#if @showPrivateMessages}} -
  • - - {{d-icon "envelope"}} - {{i18n "user.private_messages"}} - -
  • + + {{d-icon "envelope"}} + {{i18n "user.private_messages"}} + {{/if}} {{#if @canInviteToForum}} -
  • - - {{d-icon "user-plus"}} - {{i18n "user.invited.title"}} - -
  • + + {{d-icon "user-plus"}} + {{i18n "user.invited.title"}} + {{/if}} {{#if @showBadges}} -
  • - - {{d-icon "certificate"}} - {{i18n "badges.title"}} - -
  • + + {{d-icon "certificate"}} + {{i18n "badges.title"}} + {{/if}} {{#if @user.can_edit}} -
  • - - {{d-icon "cog"}} - {{i18n "user.preferences"}} - -
  • + + {{d-icon "cog"}} + {{i18n "user.preferences"}} + {{/if}} {{#if (and this.site.mobileView this.currentUser.staff)}} -
  • +
  • {{d-icon "wrench"}} {{i18n "admin.user.manage_user"}} diff --git a/app/assets/javascripts/discourse/app/components/user-nav/activity-nav.hbs b/app/assets/javascripts/discourse/app/components/user-nav/activity-nav.hbs index b0f5ed0c48..85107fa1e9 100644 --- a/app/assets/javascripts/discourse/app/components/user-nav/activity-nav.hbs +++ b/app/assets/javascripts/discourse/app/components/user-nav/activity-nav.hbs @@ -1,44 +1,77 @@ - + {{d-icon "stream"}} {{i18n "user.filters.all"}} - + {{d-icon "list-ul"}} {{i18n "user_action_groups.4"}} - + {{d-icon "reply"}} {{i18n "user_action_groups.5"}} {{#if @user.showRead}} - + {{d-icon "history"}} {{i18n "user.read"}} {{/if}} {{#if @user.showDrafts}} - + {{d-icon "pencil-alt"}} {{@draftLabel}} {{/if}} {{#if (gt @model.pending_posts_count 0)}} - + {{d-icon "clock"}} {{@pendingLabel}} {{/if}} - + {{d-icon "heart"}} {{i18n "user_action_groups.1"}} {{#if @user.showBookmarks}} - + {{d-icon "bookmark"}} {{i18n "user_action_groups.3"}} diff --git a/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs b/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs index 75cd13dc92..bfda5d1e23 100644 --- a/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs +++ b/app/assets/javascripts/discourse/app/components/user-nav/notifications-nav.hbs @@ -1,39 +1,49 @@ -
  • - - {{d-icon "bell"}} - {{i18n "user.filters.all"}} - -
  • + + {{d-icon "bell"}} + {{i18n "user.filters.all"}} + -
  • - - {{d-icon "reply"}} - {{i18n "user_action_groups.6"}} - -
  • + + {{d-icon "reply"}} + {{i18n "user_action_groups.6"}} + -
  • - - {{d-icon "heart"}} - {{i18n "user_action_groups.2"}} - -
  • + + {{d-icon "heart"}} + {{i18n "user_action_groups.2"}} + {{#if @siteSettings.enable_mentions}} -
  • - - {{d-icon "at"}} - {{i18n "user_action_groups.7"}} - -
  • + + {{d-icon "at"}} + {{i18n "user_action_groups.7"}} + {{/if}} -
  • - - {{d-icon "pencil-alt"}} - {{i18n "user_action_groups.11"}} - -
  • + + {{d-icon "pencil-alt"}} + {{i18n "user_action_groups.11"}} +
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/preferences.hbs b/app/assets/javascripts/discourse/app/templates/preferences.hbs index 88f978b73a..37b0bbad13 100644 --- a/app/assets/javascripts/discourse/app/templates/preferences.hbs +++ b/app/assets/javascripts/discourse/app/templates/preferences.hbs @@ -1,11 +1,12 @@ {{#if this.currentUser.redesigned_user_page_nav_enabled}}
    - +
    diff --git a/app/assets/javascripts/discourse/app/templates/user-invited.hbs b/app/assets/javascripts/discourse/app/templates/user-invited.hbs index 1d3ebe4fe3..7997e48b74 100644 --- a/app/assets/javascripts/discourse/app/templates/user-invited.hbs +++ b/app/assets/javascripts/discourse/app/templates/user-invited.hbs @@ -3,7 +3,7 @@
    - + -
  • - - {{d-icon "envelope"}} - {{i18n "categories.latest"}} - -
  • + + + {{d-icon "envelope"}} + {{i18n "categories.latest"}} + {{#if this.viewingSelf}} -
  • - - {{d-icon "exclamation-circle"}} - {{this.newLinkText}} - -
  • + + {{d-icon "exclamation-circle"}} + {{this.newLinkText}} + -
  • - - {{d-icon "plus-circle"}} - {{this.unreadLinkText}} - -
  • + + {{d-icon "plus-circle"}} + {{this.unreadLinkText}} + -
  • - - {{d-icon "archive"}} - {{i18n "user.messages.archive"}} - -
  • + + {{d-icon "archive"}} + {{i18n "user.messages.archive"}} + {{/if}} diff --git a/app/assets/javascripts/discourse/app/templates/user-private-messages-user.hbs b/app/assets/javascripts/discourse/app/templates/user-private-messages-user.hbs index 0ddb6b1ee0..7106d8717d 100644 --- a/app/assets/javascripts/discourse/app/templates/user-private-messages-user.hbs +++ b/app/assets/javascripts/discourse/app/templates/user-private-messages-user.hbs @@ -5,50 +5,59 @@ {{/if}} -
  • - - {{d-icon "envelope"}} - {{i18n "categories.latest"}} - -
  • + + {{d-icon "envelope"}} + {{i18n "categories.latest"}} + -
  • - - {{d-icon "reply"}} - {{i18n "user.messages.sent"}} - -
  • + + {{d-icon "reply"}} + {{i18n "user.messages.sent"}} + {{#if this.viewingSelf}} -
  • - - {{d-icon "exclamation-circle"}} - {{this.newLinkText}} - -
  • + + {{d-icon "exclamation-circle"}} + {{this.newLinkText}} + + + + {{d-icon "plus-circle"}} + {{this.unreadLinkText}} + -
  • - - {{d-icon "plus-circle"}} - {{this.unreadLinkText}} - -
  • {{/if}} -
  • - - {{d-icon "archive"}} - {{i18n "user.messages.archive"}} - -
  • + + {{d-icon "archive"}} + {{i18n "user.messages.archive"}} + +
    {{outlet}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/user/activity.hbs b/app/assets/javascripts/discourse/app/templates/user/activity.hbs index cfbbdc5215..8277d60f62 100644 --- a/app/assets/javascripts/discourse/app/templates/user/activity.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/activity.hbs @@ -2,7 +2,7 @@
    - +
    diff --git a/app/assets/javascripts/discourse/app/templates/user/messages.hbs b/app/assets/javascripts/discourse/app/templates/user/messages.hbs index f4260d2692..86130270f3 100644 --- a/app/assets/javascripts/discourse/app/templates/user/messages.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/messages.hbs @@ -14,6 +14,7 @@ diff --git a/app/assets/javascripts/discourse/app/templates/user/notifications.hbs b/app/assets/javascripts/discourse/app/templates/user/notifications.hbs index 15ec2c6753..b0fb828997 100644 --- a/app/assets/javascripts/discourse/app/templates/user/notifications.hbs +++ b/app/assets/javascripts/discourse/app/templates/user/notifications.hbs @@ -2,10 +2,11 @@
    - + diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js index 9ac356da93..6875db5cf3 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-private-messages-test.js @@ -346,17 +346,31 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await publishUnreadToMessageBus({ topicId: 1 }); await publishNewToMessageBus({ topicId: 2 }); - assert.strictEqual( - query(".messages-nav li a.new").innerText.trim(), - I18n.t("user.messages.new_with_count", { count: 1 }), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query(".user-nav__messages-new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); - assert.strictEqual( - query(".messages-nav li a.unread").innerText.trim(), - I18n.t("user.messages.unread_with_count", { count: 1 }), - "displays the right count" - ); + assert.strictEqual( + query(".user-nav__messages-unread").innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav li a.new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); + + assert.strictEqual( + query(".messages-nav li a.unread").innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); + } }); test("incoming new messages while viewing new", async function (assert) { @@ -364,11 +378,19 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await publishNewToMessageBus({ topicId: 1 }); - assert.strictEqual( - query(".messages-nav li a.new").innerText.trim(), - I18n.t("user.messages.new_with_count", { count: 1 }), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query(".messages-nav .user-nav__messages-new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav li a.new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); + } assert.ok(exists(".show-mores"), "displays the topic incoming info"); }); @@ -378,11 +400,19 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await publishUnreadToMessageBus(); - assert.strictEqual( - query(".messages-nav li a.unread").innerText.trim(), - I18n.t("user.messages.unread_with_count", { count: 1 }), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query(".messages-nav .user-nav__messages-unread").innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav li a.unread").innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); + } assert.ok(exists(".show-mores"), "displays the topic incoming info"); }); @@ -393,33 +423,65 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await publishUnreadToMessageBus({ groupIds: [14], topicId: 1 }); await publishNewToMessageBus({ groupIds: [14], topicId: 2 }); - assert.strictEqual( - query(".messages-nav li a.unread").innerText.trim(), - I18n.t("user.messages.unread_with_count", { count: 1 }), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query( + ".messages-nav .user-nav__messages-group-unread" + ).innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); - assert.strictEqual( - query(".messages-nav li a.new").innerText.trim(), - I18n.t("user.messages.new_with_count", { count: 1 }), - "displays the right count" - ); + assert.strictEqual( + query(".messages-nav .user-nav__messages-group-new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); - assert.ok(exists(".show-mores"), "displays the topic incoming info"); + assert.ok(exists(".show-mores"), "displays the topic incoming info"); - await visit("/u/charlie/messages/unread"); + await visit("/u/charlie/messages/unread"); - assert.strictEqual( - query(".messages-nav li a.unread").innerText.trim(), - I18n.t("user.messages.unread"), - "displays the right count" - ); + assert.strictEqual( + query(".messages-nav .user-nav__messages-unread").innerText.trim(), + I18n.t("user.messages.unread"), + "displays the right count" + ); - assert.strictEqual( - query(".messages-nav li a.new").innerText.trim(), - I18n.t("user.messages.new"), - "displays the right count" - ); + assert.strictEqual( + query(".messages-nav .user-nav__messages-new").innerText.trim(), + I18n.t("user.messages.new"), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav a.unread").innerText.trim(), + I18n.t("user.messages.unread_with_count", { count: 1 }), + "displays the right count" + ); + + assert.strictEqual( + query(".messages-nav a.new").innerText.trim(), + I18n.t("user.messages.new_with_count", { count: 1 }), + "displays the right count" + ); + + assert.ok(exists(".show-mores"), "displays the topic incoming info"); + + await visit("/u/charlie/messages/unread"); + + assert.strictEqual( + query(".messages-nav a.unread").innerText.trim(), + I18n.t("user.messages.unread"), + "displays the right count" + ); + + assert.strictEqual( + query(".messages-nav a.new").innerText.trim(), + I18n.t("user.messages.new"), + "displays the right count" + ); + } }); test("incoming messages is not tracked on non user messages route", async function (assert) { @@ -452,11 +514,19 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await click(".btn.dismiss-read"); await click("#dismiss-read-confirm"); - assert.strictEqual( - query(".messages-nav li a.unread").innerText.trim(), - I18n.t("user.messages.unread"), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query(".user-nav__messages-unread").innerText.trim(), + I18n.t("user.messages.unread"), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav li a.unread").innerText.trim(), + I18n.t("user.messages.unread"), + "displays the right count" + ); + } assert.strictEqual( count(".topic-list-item"), @@ -518,11 +588,19 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { await click(".btn.dismiss-read"); - assert.strictEqual( - query(".messages-nav li a.new").innerText.trim(), - I18n.t("user.messages.new"), - "displays the right count" - ); + if (customUserProps?.redesigned_user_page_nav_enabled) { + assert.strictEqual( + query(".messages-nav .user-nav__messages-new").innerText.trim(), + I18n.t("user.messages.new"), + "displays the right count" + ); + } else { + assert.strictEqual( + query(".messages-nav li a.new").innerText.trim(), + I18n.t("user.messages.new"), + "displays the right count" + ); + } assert.strictEqual( count(".topic-list-item"), @@ -701,7 +779,11 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { "User personal inbox is selected in dropdown" ); - await click(".messages-sent"); + if (customUserProps?.redesigned_user_page_nav_enabled) { + await click(".user-nav__messages-sent"); + } else { + await click(".messages-sent"); + } assert.strictEqual( messagesDropdown.header().name(), @@ -724,7 +806,11 @@ function testUserPrivateMessagesWithGroupMessages(needs, customUserProps) { "Group inbox is selected in dropdown" ); - await click(".messages-group-new"); + if (customUserProps?.redesigned_user_page_nav_enabled) { + await click(".user-nav__messages-group-new"); + } else { + await click(".messages-group-new"); + } assert.strictEqual( messagesDropdown.header().name(), diff --git a/spec/system/page_objects/pages/user_preferences.rb b/spec/system/page_objects/pages/user_preferences.rb index 42bdc01628..dda52924af 100644 --- a/spec/system/page_objects/pages/user_preferences.rb +++ b/spec/system/page_objects/pages/user_preferences.rb @@ -16,7 +16,7 @@ module PageObjects find(".horizontal-overflow-nav__scroll-left").click end - INTERFACE_LINK_CSS_SELECTOR = ".nav-tracking" + INTERFACE_LINK_CSS_SELECTOR = ".user-nav__preferences-tracking" def has_interface_link_visible? horizontal_secondary_link_visible?(INTERFACE_LINK_CSS_SELECTOR, visible: true) @@ -26,7 +26,7 @@ module PageObjects horizontal_secondary_link_visible?(INTERFACE_LINK_CSS_SELECTOR, visible: false) end - ACCOUNT_LINK_CSS_SELECTOR = ".nav-account" + ACCOUNT_LINK_CSS_SELECTOR = ".user-nav__preferences-account" def has_account_link_visible? horizontal_secondary_link_visible?(ACCOUNT_LINK_CSS_SELECTOR, visible: true) From 3483285b89c72bec0f09d1b4dc9d2456259e70ba Mon Sep 17 00:00:00 2001 From: Kris Date: Tue, 17 Jan 2023 12:28:33 -0500 Subject: [PATCH 070/169] UX: restyle quote/share popup, fix hover jitter (#19561) --- .../stylesheets/common/base/topic-post.scss | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 6e5c0c4288..3304523255 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -1,5 +1,3 @@ -$quote-share-maxwidth: 150px; - .button-count.has-pending { span { background-color: var(--danger); @@ -555,8 +553,9 @@ aside.quote { display: none; position: absolute; z-index: z("dropdown"); - opacity: 0.9; - background-color: var(--secondary-high); + background-color: var(--secondary); + border: 1px solid var(--primary-low); + box-shadow: shadow("card"); flex-direction: column; &.animated { @@ -567,10 +566,6 @@ aside.quote { display: flex; } - &.fast-editing { - opacity: 1; - } - .buttons { display: flex; } @@ -599,24 +594,21 @@ aside.quote { } } - .btn, - .btn:hover, - .d-icon, - .btn:hover .d-icon { - color: var(--secondary-or-primary); - } - - .btn-primary, - .btn-primary:hover, - .btn-primary .d-icon, - .btn-primary:hover .d-icon { - color: var(--secondary); - } - .insert-quote + .quote-sharing { border-left: 1px solid rgba(255, 255, 255, 0.3); } + .btn-flat { + .d-icon { + color: var(--primary-high); + } + .discourse-no-touch & { + &:hover { + background-color: var(--tertiary-low); + } + } + } + .quote-sharing { vertical-align: middle; display: inline-flex; @@ -629,16 +621,34 @@ aside.quote { .quote-share-label { opacity: 1; - max-width: $quote-share-maxwidth; - transition: opacity 0.3s ease-in-out, max-width 0.3s ease-in-out, - padding 0.3s ease-in-out; + transition: opacity 0.3s ease-in-out; } - &:hover .quote-share-label { - background: transparent; - opacity: 0; - max-width: 0px; - padding: 6px 0px; + &:hover { + .quote-share-label { + background: transparent; + opacity: 0; + max-width: 0; + padding: 0; + overflow: hidden; + } + .quote-share-label + .quote-share-buttons { + max-width: 10em; + opacity: 1; + transition: opacity 0.3s ease-in-out; + } + // this psuedo element creates a transition buffer zone + // without it, the width change on hover can cause transition jitter + // the width is roughly wide enough to cover long translations of "share" + &:after { + content: ""; + position: absolute; + display: block; + background: transparent; + height: 100%; + padding: 0.5em 4em; + z-index: -1; // below the buttons + } } .quote-share-label + .quote-share-buttons { @@ -646,12 +656,7 @@ aside.quote { overflow: hidden; max-width: 0; display: inline-flex; - transition: opacity 0.3s ease-in-out, max-width 0.3s ease-in-out; - } - - &:hover .quote-share-label + .quote-share-buttons { - max-width: $quote-share-maxwidth; - opacity: 1; + transition: opacity 0.3s ease-in-out; } } } From 31f6811a93d6950b4e68b4da582e51baaf1481f2 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 18 Jan 2023 09:13:33 +1000 Subject: [PATCH 071/169] FIX: Change wording from title -> name in channel about page (#19889) We refer to the channel name rather than title elsewhere (including the new channel modal), so we should be consistent. Title is an internal abstraction, since DM channels cannot have names (currently). Also change the name field on channel edit to a input type="text" rather than a textarea, since we don't want a huge input here. --- .../components/chat-channel-about-view.hbs | 12 +++++----- .../components/chat-channel-about-view.js | 2 +- ...dit-title.js => chat-channel-edit-name.js} | 20 ++++++++-------- .../controllers/chat-channel-info-about.js | 4 ++-- .../templates/chat-channel-info-about.hbs | 4 ++-- .../modal/chat-channel-edit-name.hbs | 22 ++++++++++++++++++ .../modal/chat-channel-edit-title.hbs | 20 ---------------- .../stylesheets/common/chat-channel-info.scss | 7 +++--- plugins/chat/config/locales/client.en.yml | 9 ++++---- .../spec/system/channel_about_page_spec.rb | 23 ++++++++----------- 10 files changed, 62 insertions(+), 61 deletions(-) rename plugins/chat/assets/javascripts/discourse/controllers/{chat-channel-edit-title.js => chat-channel-edit-name.js} (68%) create mode 100644 plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs delete mode 100644 plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs index 29707eb757..05ebec3b06 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.hbs @@ -18,22 +18,22 @@
    -
    +
    {{replace-emoji this.channel.escapedTitle}}
    @@ -90,4 +90,4 @@ leaveIcon="sign-out-alt" }} /> -
    \ No newline at end of file +
    diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js index 0537ff51bd..c7e1aa5b65 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-about-view.js @@ -5,7 +5,7 @@ export default class ChatChannelAboutView extends Component { @service chat; tagName = ""; channel = null; - onEditChatChannelTitle = null; + onEditChatChannelName = null; onEditChatChannelDescription = null; isLoading = false; } diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-name.js similarity index 68% rename from plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js rename to plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-name.js index d57ad3f6ce..fcba0d7711 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-title.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-edit-name.js @@ -6,30 +6,30 @@ export default class ChatChannelEditTitleController extends Controller.extend( ModalFunctionality ) { @service chatApi; - editedTitle = ""; + editedName = ""; - @computed("model.title", "editedTitle") + @computed("model.title", "editedName") get isSaveDisabled() { return ( - this.model.title === this.editedTitle || - this.editedTitle?.length > this.siteSettings.max_topic_title_length + this.model.title === this.editedName || + this.editedName?.length > this.siteSettings.max_topic_title_length ); } onShow() { - this.set("editedTitle", this.model.title || ""); + this.set("editedName", this.model.title || ""); } onClose() { - this.set("editedTitle", ""); + this.set("editedName", ""); this.clearFlash(); } @action - onSaveChatChannelTitle() { + onSaveChatChannelName() { return this.chatApi .updateChannel(this.model.id, { - name: this.editedTitle, + name: this.editedName, }) .then((result) => { this.model.set("title", result.channel.title); @@ -43,8 +43,8 @@ export default class ChatChannelEditTitleController extends Controller.extend( } @action - onChangeChatChannelTitle(title) { + onChangeChatChannelName(title) { this.clearFlash(); - this.set("editedTitle", title); + this.set("editedName", title); } } diff --git a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js index d33ec8fd22..73514a7bd6 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/chat-channel-info-about.js @@ -7,8 +7,8 @@ export default class ChatChannelInfoAboutController extends Controller.extend( ModalFunctionality ) { @action - onEditChatChannelTitle() { - showModal("chat-channel-edit-title", { model: this.model }); + onEditChatChannelName() { + showModal("chat-channel-edit-name", { model: this.model }); } @action diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs index 5f5d5a8aa1..e7c3b8d6a4 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat-channel-info-about.hbs @@ -1,5 +1,5 @@ \ No newline at end of file +/> diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs new file mode 100644 index 0000000000..00492a2521 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-name.hbs @@ -0,0 +1,22 @@ + + + + {{i18n "chat.channel_edit_name_modal.description"}} + + + + diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs deleted file mode 100644 index 99fb42dcbc..0000000000 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/chat-channel-edit-title.hbs +++ /dev/null @@ -1,20 +0,0 @@ - - - - {{i18n "chat.channel_edit_title_modal.description"}} - - - - \ No newline at end of file diff --git a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss index 29f3cb30cd..091ea9366d 100644 --- a/plugins/chat/assets/stylesheets/common/chat-channel-info.scss +++ b/plugins/chat/assets/stylesheets/common/chat-channel-info.scss @@ -117,13 +117,14 @@ input.channel-members-view__search-input { } } -// Channel info edit title modal -.chat-channel-edit-title-modal__title-input { +// Channel info edit name modal +.chat-channel-edit-name-modal__name-input { display: flex; margin: 0; + width: 100%; } -.chat-channel-edit-title-modal__description { +.chat-channel-edit-name-modal__description { display: flex; padding: 0.5rem 0; color: var(--primary-medium); diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index e7761687bc..58e3b4d7ae 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -233,6 +233,7 @@ en: associated_topic: Linked topic associated_category: Linked category title: Title + name: Name description: Description channel_info: @@ -243,10 +244,10 @@ en: members: Members settings: Settings - channel_edit_title_modal: - title: Edit title - input_placeholder: Add a title - description: Give a short descriptive title to your channel + channel_edit_name_modal: + title: Edit name + input_placeholder: Add a name + description: Give a short descriptive name to your channel channel_edit_description_modal: title: Edit description diff --git a/plugins/chat/spec/system/channel_about_page_spec.rb b/plugins/chat/spec/system/channel_about_page_spec.rb index a1cd22bd85..192ddd94b6 100644 --- a/plugins/chat/spec/system/channel_about_page_spec.rb +++ b/plugins/chat/spec/system/channel_about_page_spec.rb @@ -16,14 +16,14 @@ RSpec.describe "Channel - Info - About page", type: :system, js: true do chat_page.visit_channel_about(channel_1) expect(page.find(".category-name")).to have_content(channel_1.chatable.name) - expect(page.find(".channel-info-about-view__title")).to have_content(channel_1.title) + expect(page.find(".channel-info-about-view__name")).to have_content(channel_1.title) end it "escapes channel title" do channel_1.update!(name: "") chat_page.visit_channel_about(channel_1) - expect(page.find(".channel-info-about-view__title")["innerHTML"].strip).to eq( + expect(page.find(".channel-info-about-view__name")["innerHTML"].strip).to eq( "<script>alert('hello')</script>", ) expect(page.find(".chat-channel-title__name")["innerHTML"].strip).to eq( @@ -31,10 +31,10 @@ RSpec.describe "Channel - Info - About page", type: :system, js: true do ) end - it "can’t edit title" do + it "can’t edit name" do chat_page.visit_channel_about(channel_1) - expect(page).to have_no_selector(".edit-title-btn") + expect(page).to have_no_selector(".edit-name-btn") end it "can’t edit description" do @@ -76,20 +76,17 @@ RSpec.describe "Channel - Info - About page", type: :system, js: true do before { sign_in(current_user) } - it "can edit title" do + it "can edit name" do chat_page.visit_channel_about(channel_1) - find(".edit-title-btn").click + find(".edit-name-btn").click - expect(page).to have_selector( - ".chat-channel-edit-title-modal__title-input", - text: channel_1.title, - ) + expect(find(".chat-channel-edit-name-modal__name-input").value).to eq(channel_1.title) - title = "A new title" - find(".chat-channel-edit-title-modal__title-input").fill_in(with: title) + name = "A new name" + find(".chat-channel-edit-name-modal__name-input").fill_in(with: name) find(".create").click - expect(page).to have_content(title) + expect(page).to have_content(name) end it "can edit description" do From 115dfccf3bafd7d18a474cb55d223f28c5014483 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 18 Jan 2023 09:13:45 +1000 Subject: [PATCH 072/169] FIX: Enqueue notify_mailing_list_subscribers when post is recovered (#19888) This commit fixes the following issue: * User creates a post * Akismet or some other thing like requiring posts to be approved puts the post in the review queue, deleting it * Admin approves the post * Email is never sent to mailing list mode subscribers We intentionally do not enqueue this for every single post when recovering a topic (i.e. recovering the first post) since the topics could have a lot of posts with emails already sent, and we don't want to clog sidekiq with thousands of notify jobs. --- lib/post_destroyer.rb | 1 + spec/lib/post_destroyer_spec.rb | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index 5dc04721a5..a7cd9a11b9 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -110,6 +110,7 @@ class PostDestroyer UserActionManager.post_created(@post) DiscourseEvent.trigger(:post_recovered, @post, @opts, @user) Jobs.enqueue(:sync_topic_user_bookmarked, topic_id: topic.id) if topic + Jobs.enqueue(:notify_mailing_list_subscribers, post_id: @post.id) if @post.is_first_post? UserActionManager.topic_created(topic) diff --git a/spec/lib/post_destroyer_spec.rb b/spec/lib/post_destroyer_spec.rb index 20a18a3e82..25fa3496dd 100644 --- a/spec/lib/post_destroyer_spec.rb +++ b/spec/lib/post_destroyer_spec.rb @@ -1201,4 +1201,26 @@ RSpec.describe PostDestroyer do ) end end + + describe "mailing_list_mode emails on recovery" do + fab!(:topic) { Fabricate(:topic) } + fab!(:post_1) { Fabricate(:post, topic: topic) } + fab!(:post_2) { Fabricate(:post, topic: topic) } + + it "enqueues the notify_mailing_list_subscribers_job for the post" do + PostDestroyer.new(admin, post_2).destroy + post_2.reload + expect_enqueued_with(job: :notify_mailing_list_subscribers, args: { post_id: post_2.id }) do + PostDestroyer.new(admin, post_2).recover + end + end + + it "enqueues the notify_mailing_list_subscribers_job for the op" do + PostDestroyer.new(admin, post_1).destroy + post_1.reload + expect_enqueued_with(job: :notify_mailing_list_subscribers, args: { post_id: post_1.id }) do + PostDestroyer.new(admin, post_1).recover + end + end + end end From 56a93f7532ffe240a05ff9fbb9ad3ec647c11eb4 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 18 Jan 2023 10:16:05 +1000 Subject: [PATCH 073/169] FEATURE: Add rake task to mark old hashtag format for rebake (#19876) Since the new hashtag format has been added, we want site admins to be able to rebake old posts with the old hashtag format. This can now be done with `rake hashtags:mark_old_format_for_rebake` which goes and marks posts with the old cooked version of hashtags in this format for rebake: ```
    #ux ``` c.f. https://meta.discourse.org/t/what-rebake-is-required-for-the-new-autocomplete-styling/249642/12 --- lib/tasks/hashtags.rake | 15 +++++++++++++++ spec/tasks/hashtags_spec.rb | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 lib/tasks/hashtags.rake create mode 100644 spec/tasks/hashtags_spec.rb diff --git a/lib/tasks/hashtags.rake b/lib/tasks/hashtags.rake new file mode 100644 index 0000000000..9810e653b7 --- /dev/null +++ b/lib/tasks/hashtags.rake @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +desc "Mark posts with the old hashtag cooked format (pre enable_experimental_hashtag_autocomplete) for rebake" +task "hashtags:mark_old_format_for_rebake" => :environment do + # See Post#rebake_old, which is called via the PeriodicalUpdates job + # on a schedule. + puts "Finding posts matching old format, this could take some time..." + posts_to_rebake = Post.where("cooked like '%class=\"hashtag\"%'") + STDOUT.puts( + "[!] You are about to mark #{posts_to_rebake.count} posts containing hashtags in the old format to rebake. [CTRL+c] to cancel, [ENTER] to continue", + ) + STDIN.gets.chomp if !Rails.env.test? + posts_to_rebake.update_all(baked_version: 0) + puts "Done, rebakes will happen when periodal updates job runs." +end diff --git a/spec/tasks/hashtags_spec.rb b/spec/tasks/hashtags_spec.rb new file mode 100644 index 0000000000..0186174da3 --- /dev/null +++ b/spec/tasks/hashtags_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +RSpec.describe "tasks/hashtags" do + before do + Rake::Task.clear + Discourse::Application.load_tasks + end + + describe "hashtag:mark_old_format_for_rebake" do + fab!(:category) { Fabricate(:category, slug: "support") } + + before { SiteSetting.enable_experimental_hashtag_autocomplete = false } + + it "sets the baked_version to 0 for matching posts" do + post_1 = Fabricate(:post, raw: "This is a cool #support hashtag") + post_2 = + Fabricate( + :post, + raw: + "Some other thing which will not match some weird custom thing", + ) + + SiteSetting.enable_experimental_hashtag_autocomplete = true + post_3 = Fabricate(:post, raw: "This is a cool #support hashtag") + SiteSetting.enable_experimental_hashtag_autocomplete = false + + Rake::Task["hashtags:mark_old_format_for_rebake"].invoke + + [post_1, post_2, post_3].each(&:reload) + + expect(post_1.baked_version).to eq(0) + expect(post_2.baked_version).to eq(Post::BAKED_VERSION) + expect(post_3.baked_version).to eq(Post::BAKED_VERSION) + end + end +end From d9eccb7409de3caf53b8d7efe0f6f1c615bd949b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Jan 2023 10:51:14 +0100 Subject: [PATCH 074/169] Build(deps): Bump rspec-mocks from 3.12.2 to 3.12.3 (#19901) Bumps [rspec-mocks](https://github.com/rspec/rspec-mocks) from 3.12.2 to 3.12.3. - [Release notes](https://github.com/rspec/rspec-mocks/releases) - [Changelog](https://github.com/rspec/rspec-mocks/blob/main/Changelog.md) - [Commits](https://github.com/rspec/rspec-mocks/compare/v3.12.2...v3.12.3) --- updated-dependencies: - dependency-name: rspec-mocks dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 e6bdb318d1..7a11c710fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -377,7 +377,7 @@ GEM rspec-html-matchers (0.10.0) nokogiri (~> 1) rspec (>= 3.0.0.a) - rspec-mocks (3.12.2) + rspec-mocks (3.12.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.1) From 2ccc0c96aa9093e53d2e4767f72826dc93e8326b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Jan 2023 11:19:21 +0100 Subject: [PATCH 075/169] Build(deps): Bump rack from 2.2.5 to 2.2.6.2 (#19902) Bumps [rack](https://github.com/rack/rack) from 2.2.5 to 2.2.6.2. - [Release notes](https://github.com/rack/rack/releases) - [Changelog](https://github.com/rack/rack/blob/main/CHANGELOG.md) - [Commits](https://github.com/rack/rack/commits) --- updated-dependencies: - dependency-name: rack dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 7a11c710fd..c9070fbc00 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -315,7 +315,7 @@ GEM nio4r (~> 2.0) r2 (0.2.7) racc (1.6.2) - rack (2.2.5) + rack (2.2.6.2) rack-mini-profiler (3.0.0) rack (>= 1.2.0) rack-protection (3.0.5) From 4ac37bbe0fd1cddc4ab7baa050b7d79fd9dc7e8c Mon Sep 17 00:00:00 2001 From: Discourse Translator Bot Date: Wed, 18 Jan 2023 05:42:54 -0500 Subject: [PATCH 076/169] Update translations (#19897) --- config/locales/client.ar.yml | 2 - config/locales/client.be.yml | 10 + config/locales/client.bg.yml | 44 + config/locales/client.bs_BA.yml | 12 + config/locales/client.ca.yml | 17 + config/locales/client.cs.yml | 55 + config/locales/client.da.yml | 7 + config/locales/client.de.yml | 2 - config/locales/client.el.yml | 13 + config/locales/client.en_GB.yml | 2 + config/locales/client.es.yml | 2 - config/locales/client.et.yml | 10 + config/locales/client.fa_IR.yml | 23 +- config/locales/client.fi.yml | 8 +- config/locales/client.fr.yml | 6 +- config/locales/client.gl.yml | 13 + config/locales/client.he.yml | 6 +- config/locales/client.hr.yml | 403 ++++- config/locales/client.hu.yml | 15 +- config/locales/client.hy.yml | 12 + config/locales/client.id.yml | 38 + config/locales/client.it.yml | 2 - config/locales/client.ja.yml | 2 - config/locales/client.ko.yml | 7 +- config/locales/client.lt.yml | 260 +++ config/locales/client.lv.yml | 66 + config/locales/client.nb_NO.yml | 30 + config/locales/client.nl.yml | 2 - config/locales/client.pl_PL.yml | 21 +- config/locales/client.pt.yml | 14 + config/locales/client.pt_BR.yml | 2 - config/locales/client.ro.yml | 46 + config/locales/client.ru.yml | 4 +- config/locales/client.sk.yml | 9 + config/locales/client.sl.yml | 15 + config/locales/client.sq.yml | 9 + config/locales/client.sr.yml | 30 +- config/locales/client.sv.yml | 3 +- config/locales/client.sw.yml | 11 + config/locales/client.te.yml | 1 + config/locales/client.th.yml | 10 + config/locales/client.tr_TR.yml | 2 - config/locales/client.uk.yml | 2 - config/locales/client.ur.yml | 5 +- config/locales/client.vi.yml | 2 +- config/locales/client.zh_CN.yml | 2 - config/locales/client.zh_TW.yml | 40 + config/locales/server.hr.yml | 885 ++++++++++ config/locales/server.hu.yml | 7 + config/locales/server.pl_PL.yml | 24 +- config/locales/server.ru.yml | 1436 ++++++++--------- config/locales/server.sv.yml | 2 + plugins/chat/config/locales/client.ar.yml | 1 - plugins/chat/config/locales/client.de.yml | 1 - plugins/chat/config/locales/client.es.yml | 1 - plugins/chat/config/locales/client.fi.yml | 1 - plugins/chat/config/locales/client.fr.yml | 1 - plugins/chat/config/locales/client.he.yml | 1 - plugins/chat/config/locales/client.hr.yml | 2 + plugins/chat/config/locales/client.hu.yml | 1 - plugins/chat/config/locales/client.it.yml | 1 - plugins/chat/config/locales/client.ja.yml | 1 - plugins/chat/config/locales/client.nl.yml | 1 - plugins/chat/config/locales/client.pl_PL.yml | 1 - plugins/chat/config/locales/client.pt_BR.yml | 1 - plugins/chat/config/locales/client.ru.yml | 1 - plugins/chat/config/locales/client.sv.yml | 3 +- plugins/chat/config/locales/client.tr_TR.yml | 1 - plugins/chat/config/locales/client.zh_CN.yml | 1 - plugins/chat/config/locales/server.ar.yml | 7 +- plugins/chat/config/locales/server.de.yml | 7 +- plugins/chat/config/locales/server.es.yml | 7 +- plugins/chat/config/locales/server.fa_IR.yml | 4 + plugins/chat/config/locales/server.fi.yml | 7 +- plugins/chat/config/locales/server.fr.yml | 7 +- plugins/chat/config/locales/server.he.yml | 8 +- plugins/chat/config/locales/server.hr.yml | 4 + plugins/chat/config/locales/server.hu.yml | 7 +- plugins/chat/config/locales/server.it.yml | 7 +- plugins/chat/config/locales/server.ja.yml | 7 +- plugins/chat/config/locales/server.nl.yml | 7 +- plugins/chat/config/locales/server.pt_BR.yml | 7 +- plugins/chat/config/locales/server.ru.yml | 13 +- plugins/chat/config/locales/server.sv.yml | 13 +- plugins/chat/config/locales/server.tr_TR.yml | 7 +- plugins/chat/config/locales/server.zh_CN.yml | 7 +- .../config/locales/server.ru.yml | 4 +- 87 files changed, 2951 insertions(+), 850 deletions(-) diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index a6f421687c..9daf4822c8 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -4160,7 +4160,6 @@ ar: this_week: "الأسبوع" today: "اليوم" browser_update: 'عذرًا، متصفحك غير مدعوم. يُرجى التبديل إلى متصفح مدعوم لعرض المحتوى الغني، وتسجيل الدخول والرد.' - safari_13_warning: سيزيل هذا الموقع الدعم لإصدارات 13 من iOS وSafari، والإصدارات الأقدم. ستظل هناك نسخة مبسَّطة متاحة للقراءة فقط. (المزيد من المعلومات) permission_types: full: "الإنشاء/الرد/العرض" create_post: "الرد/العرض" @@ -4854,7 +4853,6 @@ ar: topics: read: قراءة موضوع أو منشور محدَّد فيه. يتم دعم RSS أيضًا. write: إنشاء موضوع جديد أو النشر في موضوع موجود - update: تحديث الموضوع. غيِّر العنوان والفئة والوسوم، إلى آخره. read_lists: قراءة قوائم الموضوعات مثل الأكثر نشاطًا، والجديدة، والحديثة، وما إلى ذلك. يتم دعم RSS أيضًا. posts: edit: تعديل أي منشور أو منشور معيَّن. diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index ac4f163b92..2ab131def1 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -77,6 +77,8 @@ be: close: "схаваць" emails_are_disabled: "Адпраўка паведамленняў па электроннай пошце было глабальна адключана адміністратарам. Ні адно апавяшчэнне па электроннай пошце не будзе даслана." bootstrap_invite_button_title: "Адправіць запрашэнні" + themes: + default_description: "Default" s3: regions: ap_northeast_1: "Азія (Токіа)" @@ -545,6 +547,7 @@ be: email: title: "Электронная пошта" primary_label: "асноўнай" + resent_label: "Ліст адпраўлены" update_email: "Змяненне адрасы электроннай пошты" invalid: "Калі ласка, увядзіце верны email" associated_accounts: @@ -930,6 +933,7 @@ be: close_topics: "Закрыць тэму" archive_topics: "Заархіваваныя тэмы" move_messages_to_inbox: "Перамясціць у тэчцы Уваходныя" + notification_level: "Натыфікацыі..." none: unread: "У вас няма непрачытаных тэм." new: "У вас няма новых тэм." @@ -1080,6 +1084,7 @@ be: like: "упадабаць гэты пост" edit_action: "Рэдагаваць" more: "Болей" + grant_badge: "даць Значок..." delete_topic: "выдаліць тэму" actions: people: @@ -1173,6 +1178,7 @@ be: email: "Электронная пошта" flagging: action: "Пазначыць запіс" + take_action: "прыняць меры..." take_action_options: default: title: "прыняць меры" @@ -1459,6 +1465,7 @@ be: user: "Карыстальнік" title: "API" created: створаны + never_used: (ніколі) generate: "згенераваць" revoke: "Адклікнуць" all_users: "Усе карыстальнікі" @@ -1655,6 +1662,9 @@ be: user_placeholder: "username" address_placeholder: "name@example.com" type_placeholder: "digest, signup ..." + moderation_history: + actions: + delete_topic: "Тэма выдаленая" logs: title: "Логі" action: "дзеянне" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index 4ef80402aa..1856e88b6b 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -225,6 +225,9 @@ bg: now: "преди малко" read_more: "прочетете повече" more: "Повече" + x_more: + one: "Още %{count}" + other: "Още %{count}" never: "никога" every_30_minutes: "на всеки 30 минути" every_hour: "на всеки час" @@ -1905,6 +1908,7 @@ bg: desc: Шепотът се вижда само от служителите create_topic: label: "Нова тема" + ignore: "Игнорирай" notifications: title: "уведомления за @name споменавания, отговори на вашите публикации и теми, лични съобщения, и т.н." none: "В момента не могат да бъдат заредени уведомленията." @@ -2071,6 +2075,7 @@ bg: close_topics: "Затвори темите" archive_topics: "Архивирай темите" move_messages_to_inbox: "Премести във входящи" + notification_level: "Известия..." choose_new_category: "Избери нова категория за тези теми:" selected: one: "Вие сте избрали %{count} тема." @@ -2214,6 +2219,7 @@ bg: title: прогрес на темата jump_prompt: "отиди на..." jump_prompt_long: "Отиди на..." + jump_prompt_to_date: "до дата" jump_prompt_or: "или" notifications: reasons: @@ -2265,6 +2271,7 @@ bg: unarchive: "Разархивирай темата" archive: "Архивирай темата" reset_read: "Изчисти прочетените данни " + make_public: "Направи темата публична..." feature: pin: "Закови темата" unpin: "Отгови темата" @@ -2515,6 +2522,8 @@ bg: rebake: "Прегенерирай HTML " publish_page: "Публикуване на страници" unhide: "Покажи " + change_owner: "Смени Правомощията..." + grant_badge: "Присъдени значка..." lock_post: "Заключване на публикацията" lock_post_description: "не позволявайте на публикувалия да редактира тази публикация" unlock_post: "Отключи публикацията" @@ -2652,6 +2661,8 @@ bg: search_priority: options: normal: "Нормален" + ignore: "Игнорирай" + low: "Ниска" high: "Висок" sort_options: default: "по подразбиране" @@ -2709,6 +2720,10 @@ bg: clicks: one: "%{count} кликване" other: "%{count} кликвания" + post_links: + title: + one: "още %{count}" + other: "още %{count}" topic_statuses: warning: help: "Това е официално предупреждение." @@ -2780,6 +2795,9 @@ bg: lower_title_with_count: one: "%{count} непрочетен" other: "%{count} непрочетени" + unseen: + title: "Непрегледани" + lower_title: "непрегледани" new: lower_title_with_count: one: "%{count} нов" @@ -2889,6 +2907,9 @@ bg: others_count: "Други са такава значка (%{count})" title: Значки allow_title: "Може да използвате тази значка като титла." + more_badges: + one: "+Още %{count}" + other: "+Още %{count}" select_badge_for_title: Изберете значка за титла? none: "(никой)" successfully_granted: "Успешно дадохте %{badge}на %{username}" @@ -2943,6 +2964,7 @@ bg: save: "Запази" delete: "Изтрий" everyone_can_use: "Етикетите могат да се използват от всички" + parent_tag_placeholder: "По избор" topics: none: unread: "Нямате непрочетени теми." @@ -3007,6 +3029,8 @@ bg: content: "Администратор" badges: content: "Значки" + everything: + content: "Всичко" faq: content: "FAQ" groups: @@ -3040,6 +3064,7 @@ bg: latest_version: "Последни" new_features: dismiss: "Отмени" + learn_more: "Научете повече" last_checked: "Последна проверка" refresh_problems: "Обнови" no_problems: "Не бяха открити проблеми." @@ -3055,6 +3080,8 @@ bg: show_traffic_report: "Покажи детайлен репорт на трафика" general_tab: "Основни" security_tab: "Сигурност" + report_filter_any: "всеки" + disabled: Деактивирани reports: today: "Днес" yesterday: "Вчера" @@ -3112,6 +3139,7 @@ bg: user: "Потребител" title: "API" created: Създадени + never_used: (никога) generate: "Генраирай" revoke: "Анулирай" all_users: "Всички потребители" @@ -3129,6 +3157,7 @@ bg: details: "Когато има нов отговор, редакция, изтриване или възстановяване." delivery_status: failed: "Провалени" + disabled: "Деактивирани" events: request: "Заявка" headers: "Хедъри" @@ -3220,6 +3249,9 @@ bg: delete: "Изтрий" color: "Цвят" opacity: "Прозрачност" + copy_to_clipboard: "Копиране в клипборда" + copied_to_clipboard: "Копирано в клипборда" + copy_to_clipboard_error: "Грешка при копирането на данни в клипборда" email_templates: title: "Имейл" subject: "Тема" @@ -3228,6 +3260,7 @@ bg: revert: "Върни промените" revert_confirm: "Сигурен ли си, че искаш да върнеш промените?" theme: + theme: "Тема" customize_desc: "Персонализация:" create_type: "Тип" create_name: "Име" @@ -3236,6 +3269,7 @@ bg: settings: "Настройки" collapse: Намали upload: "Качване" + discard: "Отхвърляне" installed: "Инсталирани" install_popular: "Популярни" about_theme: "Относно" @@ -3291,6 +3325,7 @@ bg: description: "Цвят на бутона харесвам. " email_style: css: "CSS" + reset: "Обновявам до първоначалното" email: title: "Имейли" settings: "Настройки" @@ -3350,6 +3385,9 @@ bg: address_placeholder: "name@example.com" type_placeholder: "дайджест, регистрация" reply_key_placeholder: "reply key " + moderation_history: + actions: + delete_topic: "Тема е изтрита" logs: title: "Логове" action: "Действия" @@ -3473,6 +3511,8 @@ bg: flag: "Сигнализиране" form: add: "Добави" + test: + no_matches: "Няма намерени съвпадения" impersonate: title: "Представи" help: "Използвайте този инструмент, за да предоставите потребителски профили за отстраняване на грешките. Трябва да излезете когато приключите." @@ -3588,6 +3628,8 @@ bg: activate_failed: "Възникна проблем с активирането на потребителя. " deactivate_account: "Деактивирай профила" deactivate_failed: "Възникна проблем при деактивирането на потребителя. " + reset_bounce_score: + label: "Нулиране" deactivate_explanation: "Деактивирания потребител трябва повторно да валидира неговия имейл. " suspended_explanation: "Отстраненитят потребител не може да се логва . " staged_explanation: "Поставените потребители могат да пишат теми само чрез писма." @@ -3721,7 +3763,9 @@ bg: dashboard: "Работен плот" navigation: "Навигация" default_categories: + modal_description: "Искате ли да приложите тази промяна исторически? Това ще промени предпочитанията за %{count} съществуващи потребители." modal_yes: "Да" + modal_no: "Не, промяната се прилага само занапред" badges: title: Значки new_badge: Нова значка diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index a7536d0ad9..27490c8e24 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -1957,9 +1957,11 @@ bs_BA: dismiss_new: "Odpusti Nove" toggle: "preklopi masovno označavanje tema" actions: "Masovno odrađene akcije" + change_category: "Postavi kategoriju..." close_topics: "Zatvori teme" archive_topics: "Arhiviraj teme" move_messages_to_inbox: "Idi u inbox" + notification_level: "Obavijest..." choose_new_category: "Izaberi novu kategoriju za temu:" selected: one: "Označili ste %{count} temu." @@ -2149,6 +2151,7 @@ bs_BA: unarchive: "Vrati temu iz arhiva" archive: "Arhiviraj temu" reset_read: "Reset Read Data" + make_public: "Napiši temu javno..." make_private: "Pretvori u privatnu poruku" reset_bump_date: "Resetuj datum bumpa" feature: @@ -2405,6 +2408,8 @@ bs_BA: revert_to_regular: "Ukloni boje osoblja" rebake: "Popravi HTML" unhide: "Unhide" + change_owner: "Izmijeni vlasništvo..." + grant_badge: "Grant Badge..." lock_post: "Zaključaj objavu" lock_post_description: "spriječi objavljivača ove objave da izmijeni objavu" unlock_post: "Odključaj objavu" @@ -2412,6 +2417,7 @@ bs_BA: delete_topic_disallowed_modal: "Nemate dozvolu za brisanje ove teme. Ako zaista želite da bude obrisan, pošaljite kaznu za pažnju moderatora zajedno s obrazloženjem." delete_topic_disallowed: "nemate dozvolu za brisanje ove teme" delete_topic: "obriši temu" + add_post_notice: "Dodaj obavještenje moderatora..." remove_timer: "ukloni tajmer" actions: people: @@ -2603,6 +2609,7 @@ bs_BA: flagging: title: "Zašto prijavljujete ovaj post?" action: "Prijavi objavu" + take_action: "Poduzmi Akciju..." take_action_options: default: title: "Poduzmi Akciju" @@ -3013,6 +3020,7 @@ bs_BA: delete: "Delete" confirm_delete: "Jeste li sigurni da želite izbrisati ovu grupu oznaka?" everyone_can_use: "Oznake mogu koristiti svi" + parent_tag_placeholder: "Opciono" topics: none: unread: "Nemate više nepročitanih tema." @@ -3084,6 +3092,8 @@ bs_BA: content: "Admin" badges: content: "Bedž" + everything: + content: "Sve" faq: content: "Česta pitanja" groups: @@ -3094,6 +3104,7 @@ bs_BA: content: "Moje objave" review: content: "Pregled" + until: "Sve do:" admin_js: type_to_filter: "kucaj da sortiraš..." admin: @@ -3123,6 +3134,7 @@ bs_BA: problems_found: "Neki saveti na osnovu vaših trenutnih postavki sajta" new_features: dismiss: "Odpusti" + learn_more: "Saznaj više" last_checked: "Zadnje pogledani" refresh_problems: "Osvježi" no_problems: "No problems were found." diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index 7ce685788c..197c155291 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -1890,9 +1890,11 @@ ca: dismiss_new: "Descarta'n els nous" toggle: "commuta la selecció massiva de temes" actions: "Accions massives" + change_category: "Defineix categoria..." close_topics: "Tanca temes" archive_topics: "Arxiva temes" move_messages_to_inbox: "Mou a la safata d'entrada" + notification_level: "Notificacions..." choose_new_category: "Seleccioneu la categoria nova per als temes:" selected: one: "He seleccionat %{count} tema." @@ -2076,6 +2078,7 @@ ca: unarchive: "Desarxiva tema" archive: "Arxiva tema" reset_read: "Restableix data de lectura" + make_public: "Fes públic el tema..." make_private: "Converteix en missatge personal" reset_bump_date: "Restableix la data d'elevació" feature: @@ -2318,6 +2321,8 @@ ca: revert_to_regular: "Elimina el color de l'equip responsable" rebake: "Refés HTML" unhide: "Desfés amagar" + change_owner: "Canvia la propietat..." + grant_badge: "Atorga insígnia..." lock_post: "Bloca la publicació" lock_post_description: "impedeix que l'autor editi aquesta publicació" unlock_post: "Desbloca la publicació" @@ -2325,6 +2330,7 @@ ca: delete_topic_disallowed_modal: "No teniu permís per a suprimir aquest tema. Si realment voleu que se suprimeixi, envieu una bandera perquè un moderador hi pari atenció juntament amb una argumentació." delete_topic_disallowed: "no teniu permís per a suprimir aquest tema" delete_topic: "suprimeix el tema" + add_post_notice: "Afegeix un avís a l'equip responsable..." remove_timer: "elimina el temporitzador" actions: people: @@ -2377,9 +2383,14 @@ ca: title: "Mostra la part HTML del correu electrònic" button: "HTML" bookmarks: + create: "Crea un marcador" + edit: "Edita el marcador" updated: "Actualitzat" name: "Nom" options: "Opcions" + actions: + edit_bookmark: + name: "Edita el marcador" category: none: "(sense categoria)" all: "Totes les categories" @@ -2505,6 +2516,7 @@ ca: flagging: title: "Gràcies per ajudar a mantenir endreçada la comunitat!" action: "Marca la publicació amb bandera " + take_action: "Actua..." take_action_options: default: title: "Actua" @@ -2849,6 +2861,7 @@ ca: delete: "Suprimeix" confirm_delete: "Esteu segur que voleu suprimir aquest grup d'etiquetes?" everyone_can_use: "Etiquetes que poden ser utilitzades per tothom" + parent_tag_placeholder: "Opcional" topics: none: unread: "No teniu temes no llegits." @@ -2916,6 +2929,8 @@ ca: content: "Administració" badges: content: "Insígnies" + everything: + content: "Qualsevol cosa" faq: content: "PMF" groups: @@ -2926,6 +2941,7 @@ ca: content: "Les meves publicacions" review: content: "Revisa" + until: "Fins a:" admin_js: type_to_filter: "escriu per a filtrar..." admin: @@ -2955,6 +2971,7 @@ ca: problems_found: "Uns quants consells basats en la configuració actual del lloc web." new_features: dismiss: "Descarta-ho" + learn_more: "Per a saber-ne més" last_checked: "Comprovat per darrera vegada" refresh_problems: "Actualitza" no_problems: "No s'han trobat problemes." diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index 43fcbfd6c9..0a0de0bf29 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -565,11 +565,31 @@ cs: title: "Proč odmítáte tohoto uživatele?" send_email: "Odeslat e-mail o odmítnutí" relative_time_picker: + minutes: + one: "minuta" + few: "minut" + many: "minut" + other: "minut" + hours: + one: "hodina" + few: "hodin" + many: "hodin" + other: "hodin" days: one: "den" few: "dní" many: "dní" other: "dnů" + months: + one: "měsíc" + few: "měsíců" + many: "měsíců" + other: "měsíců" + years: + one: "rok" + few: "let" + many: "let" + other: "let" relative: "Relativní" time_shortcut: later_today: "Později dnes" @@ -841,9 +861,19 @@ cs: few: "%{count} témata" many: "%{count} témat" other: "%{count} témat" + topic_stat: + one: "%{number} / %{unit}" + few: "%{number} / %{unit}" + many: "%{number} / %{unit}" + other: "%{number} / %{unit}" topic_stat_unit: week: "týden" month: "měsíc" + topic_stat_all_time: + one: "Celkem %{number}" + few: "Celkem %{number}" + many: "Celkem %{number}" + other: "Celkem %{number}" n_more: "Kategorie (%{count} dalších)..." ip_lookup: title: Vyhledávání podle IP adresy @@ -916,6 +946,7 @@ cs: collapse_profile: "Sbalit" bookmarks: "Záložky" bio: "O mně" + timezone: "Časové pásmo" invited_by: "Pozvánka od" trust_level: "Důvěryhodnost" notifications: "Oznámení" @@ -1020,6 +1051,11 @@ cs: many: "Nepřečtená (%{count})" other: "Nepřečtená (%{count})" new: "Nová" + new_with_count: + one: "Nové" + few: "Nová (%{count})" + many: "Nové" + other: "Nové" archive: "Archív" groups: "Moje skupiny" move_to_inbox: "Přesunout do doručených" @@ -1878,9 +1914,11 @@ cs: dismiss_new: "Označit jako přečtené vše nové" toggle: "hromadný výběr témat" actions: "Hromadné akce" + change_category: "Nastavit kategorii..." close_topics: "Zavřít téma" archive_topics: "Archivovat témata" move_messages_to_inbox: "Přesunout do doručených" + notification_level: "Upozornění..." choose_new_category: "Zvolte novou kategorii pro témata:" selected: one: "Vybrali jste %{count} téma." @@ -2073,6 +2111,7 @@ cs: unarchive: "Navrátit z archivu" archive: "Archivovat téma" reset_read: "Vynulovat počet čtení" + make_public: "Vytvořit Veřejné Téma..." make_private: "Vytvořit soukromou zprávu " reset_bump_date: "Resetovat datum zvýraznění" feature: @@ -2309,6 +2348,8 @@ cs: revert_to_regular: "Zrušit zvýraznění" rebake: "Obnovit HTML" unhide: "Odkrýt" + change_owner: "Změnit autora..." + grant_badge: "Udělit odznak..." lock_post: "Uzamknout příspěvek " lock_post_description: "Zabránit přispěvatelům v úpravách tohoto příspěvku" unlock_post: "Odemknout příspěvek" @@ -2372,8 +2413,15 @@ cs: title: "Zobrazit html část emailu" button: "HTML" bookmarks: + create: "Vytvořit záložku" + edit: "Upravit záložku" name: "Jméno" options: "Možnosti" + actions: + delete_bookmark: + name: "Smazat záložku" + edit_bookmark: + name: "Upravit záložku" category: none: "(bez kategorie)" all: "Všechny kategorie" @@ -2484,6 +2532,7 @@ cs: flagging: title: "Děkujeme, že pomáháte udržovat komunitu zdvořilou!" action: "Nahlásit příspěvek" + take_action: "Zakročit..." take_action_options: default: title: "Zakročit" @@ -2843,6 +2892,7 @@ cs: delete: "Smazat" confirm_delete: "Jste si jistí, že chcete smazat tuto skupinu štítků?" everyone_can_use: "Tagy mohou být použity kýmkoliv" + parent_tag_placeholder: "Volitelné" topics: none: unread: "Nemáte žádná nepřečtená témata." @@ -2913,6 +2963,8 @@ cs: content: "Administrace" badges: content: "Odznaky" + everything: + content: "Vše" faq: content: "FAQ" groups: @@ -2921,6 +2973,7 @@ cs: content: "Uživatelé" my_posts: content: "Mé příspěvky" + until: "dokud:" admin_js: type_to_filter: "text pro filtrování..." admin: @@ -2946,6 +2999,7 @@ cs: latest_version: "Poslední verze" new_features: dismiss: "Označit jako přečtené" + learn_more: "Více informací" last_checked: "Naposledy zkontrolováno" refresh_problems: "Obnovit" no_problems: "Nenalezeny žádné problémy." @@ -3039,6 +3093,7 @@ cs: user: "Uživatel" title: "API" created: Vytvořený + never_used: (nikdy) generate: "Vygenerovat API klíč" revoke: "zrušit" all_users: "Všichni uživatelé" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index e6a5058f5d..3215e6df16 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -2532,6 +2532,7 @@ da: open: "Åbn emne" close: "Luk emne" multi_select: "Vælg indlæg..." + slow_mode: "Indstil langsom tilstand..." timed_update: "Indstil timerfunktion for emne..." pin: "Fastgør Emne..." unpin: "Fjern fastgøring af Emne..." @@ -2848,6 +2849,8 @@ da: delete_topic_confirm_modal_no: "Nej, behold dette emne" delete_topic_error: "Der opstod en fejl under sletning af dette emne" delete_topic: "slet emne" + add_post_notice: "Tilføj Hjælperteam Notits..." + change_post_notice: "Ændre Personale Meddelelse..." delete_post_notice: "Slet Personale Meddelelse" remove_timer: "fjern timer" edit_timer: "rediger timer" @@ -3617,7 +3620,9 @@ da: header_link_text: "Tags" configure_defaults: "Konfigurer standardindstillinger" categories: + click_to_get_started: "Klik her for at komme i gang." header_link_text: "Kategorier" + configure_defaults: "Konfigurer standardindstillinger" community: header_link_text: "Fællesskab" links: @@ -3627,6 +3632,8 @@ da: content: "Admin" badges: content: "Emblemer" + everything: + content: "Alting" faq: content: "OSS" groups: diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index a72bdc2647..2af85be213 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -3498,7 +3498,6 @@ de: this_week: "Woche" today: "Heute" browser_update: 'Leider wird dein Browser nicht unterstützt. Bitte wechsle zu einem unterstützten Browser, um die Inhalte in vollem Umfang zu sehen, dich anzumelden und zu antworten.' - safari_13_warning: Diese Website wird bald die Unterstützung für iOS und Safari Version 13 und darunter entfernen. Eine vereinfachte Nur-Lesen-Version wird weiterhin verfügbar sein. (Mehr Informationen) permission_types: full: "Erstellen/Antworten/Ansehen" create_post: "Antworten/Ansehen" @@ -4122,7 +4121,6 @@ de: topics: 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. - update: Aktualisiere ein Thema. Ändere den Titel, die Kategorie, Schlagwörter usw. read_lists: Lies Themenlisten wie „Angesagt“, „Neu“, „Aktuell“ usw. RSS wird ebenfalls unterstützt. posts: edit: Bearbeite jeden Beitrag oder einen bestimmten. diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index 4384c94a80..66a81097db 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -1941,9 +1941,11 @@ el: dismiss_new: "Αγνόησε τα νέα" toggle: "εναλλαγή μαζικής επιλογής νημάτων" actions: "Μαζικές Ενέργειες" + change_category: "Θέσε Κατηγορία..." close_topics: "Κλείσιμο Νημάτων" archive_topics: "Αρχειοθέτηση Νημάτων" move_messages_to_inbox: "Μετακίνηση στα Εισερχόμενα" + notification_level: "Ειδοποιήσεις..." choose_new_category: "Διάλεξε νέα κατηγορία για τα νήματα:" selected: one: "Έχεις διαλέξει %{count} νήμα." @@ -2025,6 +2027,7 @@ el: enable: "Ενεργοποίηση" remove: "Απενεργοποίηση" hours: "ώρες:" + minutes: "Λεπτά:" durations: 10_minutes: "10 Λεπτά" 15_minutes: "15 Λεπτά" @@ -2143,6 +2146,7 @@ el: unarchive: "Επαναφορά Νήματος από Αρχείο" archive: "Αρχειοθέτηση Νήματος" reset_read: "Μηδενισμός Διαβασμένων" + make_public: "Κάνε Δημόσιο το Νήμα..." make_private: "Κάνε προσωπικό μήνυμα" reset_bump_date: "Επαναφορά ημερομηνίας ώθησης" feature: @@ -2403,6 +2407,8 @@ el: rebake: "Ανανέωση HTML" publish_page: "Δημοσίευση σελίδας" unhide: "Επανεμφάνιση" + change_owner: "Αλλαγή Ιδιοκτησίας..." + grant_badge: "Απονομή Παράσημου..." lock_post: "Κλείδωμα ανάρτησης" lock_post_description: "αποτρέψτε τον συντάκτη από την επεξεργασία αυτής της ανάρτησης" unlock_post: "Ξεκλείδωμα ανάρτησης" @@ -2410,6 +2416,7 @@ el: delete_topic_disallowed_modal: "Δεν έχετε άδεια διαγραφής αυτού του θέματος. Εάν θέλετε πραγματικά να διαγραφεί, υποβάλετε μια επισήμανση για προσοχή από συντονιστή μαζί με το σκεπτικό." delete_topic_disallowed: "δεν έχετε άδεια να διαγράψετε αυτό το θέμα" delete_topic: "διαγραφή νήματος" + add_post_notice: "Προσθήκη ειδοποίησης προσωπικού..." remove_timer: "αφαίρεση χρονοδιακόπτη" actions: people: @@ -2614,6 +2621,7 @@ el: flagging: title: "Ευχαριστούμε για τη συνεισφορά σου!" action: "Επισήμανση Ανάρτησης" + take_action: "Λάβε Δράση..." take_action_options: default: title: "Λάβε Δράση" @@ -3000,6 +3008,7 @@ el: delete: "Διαγραφή" confirm_delete: "Είσαι βέβαιος πως θέλεις να διαγράψεις αυτή την ομάδα ετικετών;" everyone_can_use: "Οι ετικέτες μπορούν να χρησιμοποιηθούν από όλους" + parent_tag_placeholder: "Προεραιτικό" topics: none: unread: "Δεν έχεις αδιάβαστα νήματα." @@ -3073,6 +3082,8 @@ el: content: "Διαχείριση" badges: content: "Παράσημα" + everything: + content: "Τα πάνΤα" faq: content: "Συχνές ερωτήσεις" groups: @@ -3083,6 +3094,7 @@ el: content: "Οι αναρτήσεις μου" review: content: "Ανασκόπηση" + until: "Μέχρι:" admin_js: type_to_filter: "γράψε εδώ για φιλτράρισμα..." admin: @@ -3112,6 +3124,7 @@ el: problems_found: "Μερικές συμβουλές με βάση τις τρέχουσες ρυθμίσεις του ιστότοπού σας" new_features: dismiss: "Απόρριψη" + learn_more: "Μάθε περισσότερα" last_checked: "Τελευταίος έλεγχος" refresh_problems: "Ανανέωση" no_problems: "Δεν βρέθηκε κανένα πρόβλημα." diff --git a/config/locales/client.en_GB.yml b/config/locales/client.en_GB.yml index 2cdd3aa67f..f6bd76d107 100644 --- a/config/locales/client.en_GB.yml +++ b/config/locales/client.en_GB.yml @@ -93,6 +93,8 @@ en_GB: admin: dashboard: exception_error: Sorry, an error occurred whilst executing the query + reports: + no_data: "No data to display." api: scopes: descriptions: diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index 445470f110..8ebbc5e8d8 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -3496,7 +3496,6 @@ es: this_week: "Semana" today: "Hoy" browser_update: 'Por desgracia, tu navegador no es compatible. Por favor, usa otro navegador para ver el contenido completo, iniciar sesión y responder.' - safari_13_warning: Este sitio pronto dejará de ser compatible con las versiones 13 y anteriores de iOS y Safari. En su lugar, verás una versión simplificada y de solo lectura. (más información) permission_types: full: "Crear / Responder / Ver" create_post: "Responder / Ver" @@ -4117,7 +4116,6 @@ es: topics: read: Leer un tema o una publicación específica del mismo. También usando RSS. write: Crear un nuevo tema o publicar en uno existente. - update: Actualizar un tema. Cambiar el título, categoría, etiquetas, etc. read_lists: Lea listas de temas como destacados, nuevos, recientes, etc. También se admite RSS. posts: edit: Edita cualquier publicación o una específica. diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 2107eb1783..57ea330fe9 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -949,6 +949,7 @@ et: primary: "Peamine e-post" secondary: "Teine e-post" primary_label: "primaarne" + resent_label: "meil saadetud" update_email: "Muuda meiliaadressi" no_secondary: "Teist e-posti pole" instructions: "Ei näidata avalikult." @@ -1572,9 +1573,11 @@ et: dismiss_new: "Ignoreeri uusi" toggle: "lülita teemade massiline ära märkimine ümber" actions: "Masstoimingud" + change_category: "Määra kategooria..." close_topics: "Sulge Teemad" archive_topics: "Arhiveeri Teemad" move_messages_to_inbox: "Liiguta sisendkausta" + notification_level: "Teavitus..." change_notification_level: "Muuda teavituste taset" choose_new_category: "Vali teemadele uus foorum:" selected: @@ -1772,6 +1775,7 @@ et: unarchive: "Taasta teema arhiivist" archive: "Arhiveeri teema" reset_read: "Nulli andmed teema lugemise kohta" + make_public: "Loo avalik teema..." make_private: "Loo isiklik sõnum" feature: pin: "Tõsta teema esile" @@ -2010,6 +2014,8 @@ et: revert_to_regular: "Eemalda meeskonna värv" rebake: "Rekonstrueeri HTML" unhide: "Too nähtavale" + change_owner: "Omanikuvahetus..." + grant_badge: "Anna märgis..." lock_post: "Lukusta postitus" delete_topic: "kustuta teema" actions: @@ -2186,6 +2192,7 @@ et: flagging: title: "Täname, et aitad meie kogukonna viisakust säilitada!" action: "Tähista postitus" + take_action: "Tegutse..." take_action_options: default: title: "Tegutse" @@ -2485,6 +2492,7 @@ et: save: "Salvesta" delete: "Kustuta" confirm_delete: "Oled kindel, et soovid selle siltide grupi kustutada?" + parent_tag_placeholder: "Valikuline" topics: none: unread: "Sul ei ole lugemata teemasid." @@ -2587,6 +2595,7 @@ et: latest_version: "Viimased" new_features: dismiss: "Ignoreeri" + learn_more: "Uuri veel" last_checked: "Viimati kontrollitud" refresh_problems: "Värskenda" no_problems: "Probleeme ei tuvastatud." @@ -2683,6 +2692,7 @@ et: user: "Kasutaja" title: "API" created: Loodud + never_used: (mitte kunagi) generate: "Genereeri" revoke: "Tühista " all_users: "Kõik kasutajad" diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index b11a153e6c..2e19495437 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -1432,6 +1432,7 @@ fa_IR: edit_title: "ویرایش دعوت‌نامه" instructions: "این لینک را برای ارائه دسترسی فوری به سایت٬ به اشتراک بگذارید:" copy_link: "کپی پیوند" + expires_in_time: "در %{time} منقضی می شود" expired_at_time: "منقضی شده در %{time}" show_advanced: "نمایش تنظیمات پیشرفته" hide_advanced: "پنهان کردن تنظیمات پیشرفته" @@ -2254,6 +2255,7 @@ fa_IR: dismiss_new: "بستن جدید" toggle: "تغییر وضعیت انتخاب گروهی موضوعات" actions: "عملیات گروهی" + change_category: "تنظیم دسته‌بندی..." close_topics: "بستن موضوعات" archive_topics: "بایگانی موضوعات" move_messages_to_inbox: "انتقال به صندوق دریافت" @@ -2476,6 +2478,7 @@ fa_IR: unarchive: "موضوع بایگانی نشده" archive: "بایگانی کردن موضوع" reset_read: "تنظیم مجدد خواندن داده ها" + make_public: "ایجاد موضوع عمومی..." make_private: "به پیام تبدیل کن" reset_bump_date: "تنظیم مجدد خواندن داده ها" feature: @@ -2714,6 +2717,8 @@ fa_IR: revert_to_regular: "حذف رنگ همکاران" rebake: "ساخت مجدد HTML" unhide: "آشکار کردن" + change_owner: "تغییر مالکیت..." + grant_badge: "اعطای نشان..." lock_post: "قفل کردن پست" lock_post_description: "ممانعت از ویرایش پست توسط فرستنده" unlock_post: "بازکردن پست" @@ -2724,6 +2729,8 @@ fa_IR: delete_topic_confirm_modal_no: "خیر، این موضوع را نگه دار" delete_topic_error: "خطایی در هنگام حذف این موضوع رخ داده است." delete_topic: "حذف موضوع" + add_post_notice: "اطلاعیه کارکنان را اضافه کنید..." + change_post_notice: "تغییر اطلاعیه همکاران..." delete_post_notice: "حذف اطلاعیه همکاران" remove_timer: "حذف زمان‌سنج" actions: @@ -2922,9 +2929,12 @@ fa_IR: moderation: "معتدل" appearance: "ظاهر" email: "ایمیل" + list_filters: + all: "همه موضوعات" flagging: title: "تشکر برای کمک به نگه داشتن جامعه ما بصورت مدنی !" action: "پرچم‌گذاری نوشته" + take_action: "اقدام..." take_action_options: default: title: "اقدام" @@ -3051,6 +3061,9 @@ fa_IR: lower_title_with_count: one: "%{count} خوانده نشده" other: "%{count} خوانده نشده" + unseen: + title: "دیده نشده" + lower_title: "دیده نشده" new: lower_title_with_count: one: "%{count} تازه" @@ -3094,7 +3107,6 @@ fa_IR: this_month: "ماه" this_week: "هفته" today: "امروز" - safari_13_warning: این سایت به زودی از iOS و Safari، نسخه ۱۳ به بالا پشتیبانی نخواهد کرد. یک نسخه فقط خواندنی ساده در دسترس باقی خواهد ماند. (اطلاعات بیشتر) permission_types: full: "ساختن / پاسخ دادن / دیدن" create_post: "پاسخ دادن / دیدن" @@ -3293,9 +3305,11 @@ fa_IR: everyone_can_use: "برچسب‌ها می‌توانندتوسط همه استفاده شوند" usable_only_by_groups: "برچسب ها برای همه قابل مشاهده است، اما فقط گروه های زیر می توانند از آنها استفاده کنند" visible_only_to_groups: "برچسب ها فقط برای گروه های زیر قابل مشاهده است" + parent_tag_placeholder: "اختیاری" topics: none: unread: "شما موضوع خوانده نشده‌ای ندارید." + unseen: "شما هیچ موضوع دیده نشده‌ای ندارید." new: "شما موضوع جدیدی ندارید." read: "شما هیچ موضوعی را نخوانده‌اید." posted: "شما هیچ نوشته‌ای در موضوعات ندارید." @@ -3436,6 +3450,7 @@ fa_IR: problems_found: "چند توصیه براثاث تنظیمات فعلی سایت شما" new_features: dismiss: "نخواستیم" + learn_more: "بیشتر بدانید" last_checked: " آخرین بررسی" refresh_problems: "تازه کردن" no_problems: "هیچ مشکلات پیدا نشد." @@ -3554,6 +3569,7 @@ fa_IR: title: "API" created: ساخته شده updated: به روز شده + never_used: (هرگز) generate: "تولید کردن" revoke: "ابطال" all_users: "همه کاربران" @@ -3566,8 +3582,6 @@ fa_IR: global: جهانی action: اقدام descriptions: - topics: - update: به‌روزرسانی موضوع. تغییر عنوان، دسته‌بندی، برچسب‌ها و غیره. user_status: read: خواندن وضعیت کاربر. update: به‌روزرسانی وضعیت کاربر. @@ -3979,6 +3993,9 @@ fa_IR: address_placeholder: "name@example.com" type_placeholder: "خلاصه، ثبت نام..." reply_key_placeholder: "کلید پاسخ" + moderation_history: + actions: + delete_topic: "مبحث حذف شده" logs: title: "گزارش‌ها" action: "عمل" diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index f98d01409f..08f6914c42 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -322,7 +322,7 @@ fi: no_timezone: 'Et ole valinnut aikavyöhykettä, joten et voi asettaa muistutuksia. Aseta se profiilisivullasi.' invalid_custom_datetime: "Antamasi päivämäärä ja kellonaika ei kelpaa, yritä uudelleen." list_permission_denied: "Et voi nähdä tämän käyttäjän kirjanmerkkejä." - no_user_bookmarks: "Kirjanmerkeissäsi ei ole mitään. Kirjanmerkitsemällä viestin löydät sen myöhemmin helposti." + no_user_bookmarks: "Kirjanmerkeissäsi ei ole mitään. Kirjanmerkitsemällä viestejä löydät ne myöhemmin helposti." auto_delete_preference: label: "Kun sinulle on ilmoitettu" never: "Säilytä kirjanmerkki" @@ -455,7 +455,7 @@ fi: topic: "Ketju:" filtered_topic: "Olet suodattanut käsiteltävän sisällön yhdestä ketjusta." filtered_user: "Käyttäjä" - filtered_reviewed_by: "Käsittelijä:" + filtered_reviewed_by: "Käsittelijä" show_all_topics: "näytä kaikki ketjut" deleted_post: "(viesti poistettu)" deleted_user: "(käyttäjä poistettu)" @@ -3497,7 +3497,6 @@ fi: this_week: "Viikko" today: "Tänään" browser_update: 'Valitettavasti selaintasi ei tueta. Vaihda tuettuun selaimeen, niin voit katsella monipuolista sisältöä, kirjautua sisään ja vastata.' - safari_13_warning: Tämä sivusto poistaa pian iOS:n ja Safarin version 13 ja sitä vanhempien versioiden tuen. Yksinkertaistettu vain luku -versio on edelleen käytettävissä. (Lisätietoja) permission_types: full: "Luoda / Vastata / Nähdä" create_post: "Vastata / Nähdä" @@ -4118,7 +4117,6 @@ fi: topics: read: Lue ketjua tai tiettyä sen viestiä. RSS on myös tuettu. write: Aloita uusi ketju tai kirjoita olemassa olevaan. - update: Päivitä ketju. Muuta otsikkoa, aluetta, tunnisteita jne. read_lists: Lue ketjuluetteloita, kuten suositut, uudet, tuoreimmat jne. RSS on myös tuettu. posts: edit: Muokkaa mitä tahansa viestiä tai tiettyä viestiä. @@ -4718,7 +4716,7 @@ fi: actions: delete_user: "poista käyttäjä" change_trust_level: "muuta luottamustasoa" - change_username: "muuta käyttäjätunnusta" + change_username: "vaihda käyttäjätunnus" change_site_setting: "muuta sivuston asetusta" change_theme: "vaihda teema" delete_theme: "poista teema" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index 515ad29fdc..412bffa59c 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -3496,7 +3496,6 @@ fr: this_week: "Semaine" today: "Aujourd'hui" browser_update: 'Malheureusement, votre navigateur n''est pas pris en charge. Merci de mettre à jour votre navigateur pour afficher le contenu enrichi, vous connecter et répondre.' - safari_13_warning: Ce site va bientôt supprimer la prise en charge des versions 13 et inférieures d'iOS et de Safari. Une version simplifiée en lecture seule restera disponible. (plus d'informations) permission_types: full: "Créer/Répondre/Voir" create_post: "Répondre/Voir" @@ -4118,7 +4117,6 @@ fr: topics: read: Lire un sujet ou un message spécifique qu'il contient. Le RSS est aussi accepté. write: Créer un nouveau sujet ou publier sur un sujet existant. - update: Mettre à jour un sujet. Modifiez le titre, la catégorie, les étiquettes, etc. read_lists: Lire les listes de sujets comme top, nouveaux, récents, etc. Le RSS est aussi accepté. posts: edit: Modifiez n'importe quel message ou un message spécifique. @@ -4765,7 +4763,7 @@ fr: post_edit: "message modifié" post_unlocked: "message déverrouillé" check_personal_message: "vérifier un message direct" - disabled_second_factor: "désactiver la validation en deux étapes" + disabled_second_factor: "désactiver l'authentification à deux facteurs" topic_published: "sujet publié" post_approved: "message approuvé" post_rejected: "message rejeté" @@ -5019,7 +5017,7 @@ fr: private_topics_count: Sujets privés posts_read_count: Messages lus post_count: Messages créés - second_factor_enabled: Validation en deux étapes activée + second_factor_enabled: Authentification à deux facteurs activée topics_entered: Sujets vus flags_given_count: Signalements effectués flags_received_count: Signalements reçus diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index de173728ab..fdc2b9dbdd 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -1399,6 +1399,7 @@ gl: edit_title: "Editar convite" instructions: "Compartir esta ligazón para conceder acceso a este sitio:" copy_link: "copiar a ligazón" + expired_at_time: "Caduca o %{time}" show_advanced: "Amosar opcións avanzadas" hide_advanced: "Agochar opcións avanzadas" add_to_groups: "Engadir aos grupos" @@ -2127,9 +2128,11 @@ gl: dismiss_new: "Desbotar novas" toggle: "cambiar a selección en bloque dos temas" actions: "Accións en bloque" + change_category: "Estabelecer categoría..." close_topics: "Pechar temas" archive_topics: "Arquivar temas" move_messages_to_inbox: "Mover á caixa de entrada" + notification_level: "Notificacións..." change_notification_level: "Cambiar o nivel de notificacións" choose_new_category: "Seleccionar a nova categoría dos temas:" selected: @@ -2353,12 +2356,14 @@ gl: open: "Abrir tema" close: "Pechar tema" multi_select: "Seleccionar publicacións…" + slow_mode: "Estabelecer modo lento..." timed_update: "Estabelecer temporizador do tema..." pin: "Fixar tema…" unpin: "Desprender tema…" unarchive: "Desarquivar tema" archive: "Arquivar tema" reset_read: "Restabelecer datos de lecturas" + make_public: "Facer público o tema..." make_private: "Facer privada a mensaxe" reset_bump_date: "Restabelecer data de promoción" feature: @@ -2641,6 +2646,8 @@ gl: rebake: "Reconstruír HTML" publish_page: "Publicación da páxina" unhide: "Non agochar" + change_owner: "Cambiar propietario..." + grant_badge: "Conceder insignia..." lock_post: "Bloquear publicación" lock_post_description: "evitar que o autor edite esta publicación" unlock_post: "Desbloquear a publicación" @@ -2654,6 +2661,8 @@ gl: delete_topic_confirm_modal_no: "Non, manter este tema" delete_topic_error: "Produciuse un erro ao eliminar este tema" delete_topic: "eliminar tema" + add_post_notice: "Engadir aviso do equipo..." + change_post_notice: "Cambiar o aviso do equipo..." delete_post_notice: "Eliminar o aviso do equipo" remove_timer: "retirar o temporizador" edit_timer: "editar temporizador" @@ -3285,6 +3294,7 @@ gl: everyone_can_use: "Todos poden usar as etiquetas" usable_only_by_groups: "As etiquetas son visíbeis para todos, pero só os seguintes grupos poden usalas" visible_only_to_groups: "As etiquetas só son visíbeis para os seguintes grupos" + parent_tag_placeholder: "Opcional" topics: none: unread: "Non ten temas sen ler." @@ -3361,6 +3371,8 @@ gl: content: "Administración" badges: content: "Insignias" + everything: + content: "Todo" faq: content: "PMF" groups: @@ -3371,6 +3383,7 @@ gl: content: "As miñas publicacións" review: content: "Revisar" + until: "Ata:" admin_js: type_to_filter: "escriba para filtrar..." admin: diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index acae120830..b187a09ed6 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -1033,7 +1033,7 @@ he: posts: "פוסטים" topics: "נושאים" latest: "לאחרונה" - subcategories: "תתי קטגוריות" + subcategories: "תת־קטגוריות" muted: "קטגוריות מושתקות" topic_sentence: one: "נושא אחד" @@ -3848,7 +3848,6 @@ he: this_week: "שבוע" today: "היום" browser_update: 'אתרע מזלך וכי דפדפנך אינו נתמך עוד. נא לעבור לדפדפן נתמך כדי לצפות בתוכן עשיר, להיכנס ולהגיב.' - safari_13_warning: אתר זה יסיר בקרוב את התמיכה בגרסאות 13 ומטה של Safari ושל iOS. גרסה מופשאת לקריאה בלבד תישאר זמינה עבורם. (מידע נוסף) permission_types: full: "יצירה / תגובה / צפייה" create_post: "תגובה / צפייה" @@ -4533,7 +4532,7 @@ he: topics: read: קריאת נושא או פוסט מסוים בתוכו. יש גם תמיכה ב־RSS. write: יצירת נושא חדש או פרסום לאחד קיים. - update: עדכון נושא. החלפת הכותרת, הקטגוריה, התגיות וכו׳. + update: עדכון נושא. אפשר לערוך את הכותרת, הקטגוריה, התגיות, המצב, סוג שימוש, קישור מובילים וכו׳. read_lists: לקרוא רשימת נושאים כמו מובילים, חדשים, אחרונים וכו׳. יש גם תמיכה ב־RSS. posts: edit: לערוך כל פוסט שהוא או אחד מסוים. @@ -4685,6 +4684,7 @@ he: broken_route: "לא ניתן להגדיר את הקישור אל ‚%{name}’. כדאי לוודא שחוסמי פרסומות מושבתים ואז לנסות לטעון את הדף מחדש." navigation_menu: sidebar: "סרגל צד" + legacy: "מיושן" backups: title: "גיבויים" menu: diff --git a/config/locales/client.hr.yml b/config/locales/client.hr.yml index 1b13ea2b8c..c8835c0daf 100644 --- a/config/locales/client.hr.yml +++ b/config/locales/client.hr.yml @@ -204,6 +204,7 @@ hr: banner: enabled: "Pretvoreno u banner %{when}. Banner će biti prikazan na vrhu svake stranice dok ga korisnik ne isključi." disabled: "Maknuo banner %{when}. Banner se više neće prikazivati na vrhu svake stranice." + forwarded: "Prosljeđeno na gore navedenu adresu e-pošte." topic_admin_menu: "mogućnosti teme" skip_to_main_content: "Preskoči na glavni sadržaj" emails_are_disabled: "Svi emailovi prema van su blokirani od strane administratora. Ni jedna vrsta obavijesti putem emaila neće biti poslana." @@ -217,6 +218,7 @@ hr: other: "Kako bi vam olakšali pokretanje novog foruma, nalazite se u bootstrap načinu rada. Svim novim korisnicima biti će dodijeljena razina povjerenja 1 i omogućeni dnevni e-mailovi sa sažecima. Bootstrap će se automatski isključiti čim se učlani minimalno %{count} korisnika." bootstrap_mode_disabled: "Bootstrap mod biti će isključen u sljedećih 24 sata." bootstrap_invite_button_title: "Pošalji pozivnice" + bootstrap_wizard_link_title: "Završi postavljanje" themes: default_description: "Zadano" broken_theme_alert: "Vaša stranica možda neće raditi jer tema/komponenta ima pogreške." @@ -259,6 +261,7 @@ hr: delete: "Pobriši" generic_error: "Dogodila se greška, ispričavamo se." generic_error_with_reason: "Dogodila se greška: %{error}" + multiple_errors: "Došlo je do više grešaka: %{errors}" sign_up: "Učlani se" log_in: "Prijavi se" age: "Dob" @@ -322,6 +325,7 @@ hr: like_count: "Likeovi" topic_count: "Teme" post_count: "Objave" + user_count: "Učlani se" active_user_count: "Aktivni korisnici" contact: "Kontaktirajte nas" contact_info: "U slučaju kritičnog problema ili hitnosti koje utječu na rad stranice, kontaktirajte nas na %{contact_info}." @@ -631,6 +635,8 @@ hr: relative: "Relativno" time_shortcut: now: "Sada" + in_one_hour: "Za jedan sat" + in_two_hours: "Za dva sata" later_today: "Kasnije danas" two_days: "Dva dana" next_business_day: "Sljedeći radni dan" @@ -698,6 +704,8 @@ hr: reset_to_default: "Vrati na zadano" group: all: "sve grupe" + sort: + label: "Sortiraj po %{criteria}" group_histories: actions: change_group_setting: "Promijenite postavke grupe" @@ -863,6 +871,7 @@ hr: group_type: "Vrsta grupe" is_group_user: "Član" is_group_owner: "Vlasnik" + search_results: "Rezultati pretraživanja pojavit će se ispod." title: one: "Grupa" few: "Grupe" @@ -898,6 +907,7 @@ hr: no_filter_matches: "Nijedan član ne odgovara toj potrazi." topics: "Teme" posts: "Objave" + aria_post_number: "%{title} - članak #%{postNumber}" mentions: "Spomeni" messages: "Poruke" notification_level: "Zadani nivo obavijesti za grupne poruke" @@ -1017,6 +1027,7 @@ hr: user_fields: none: "(odaberi opciju)" required: 'Unesi vrijednost za "%{name}"' + same_as_password: "Vaša lozinka ne smije se ponavljati u drugim poljima." user: said: "%{username}:" profile: "Profil" @@ -1054,6 +1065,7 @@ hr: notification_schedule: title: "Raspored obavijesti (notifikacije)" label: "Omogući prilagođeni raspored obavijesti" + tip: "Izvan ovih sati vaše obavijesti biti će zaustavljene." midnight: "Ponoć" none: "Ništa" monday: "ponedjeljak" @@ -1097,10 +1109,16 @@ hr: perm_denied_expl: "Odbili ste dopuštenje za obavijesti. Dopusti obavijesti putem postavki preglednika." disable: "Isključi obavijesti" enable: "Uključi obavijesti" + each_browser_note: 'Napomena: ovu postavku morate promijeniti na svakom pregledniku koji koristite. Sve će obavijesti biti onemogućene ako pauzirate obavijesti iz korisničkog izbornika, bez obzira na ovu postavku.' consent_prompt: "Želite li obavijesti (uživo) kada ljudi odgovaraju na vaše postove?" dismiss: "Skloni" dismiss_notifications: "Skloni sve" dismiss_notifications_tooltip: "Označi sve nepročitane obavijesti kao pročitane" + dismiss_bookmarks_tooltip: "Označite sve nepročitane podsjetnike kao pročitane" + dismiss_messages_tooltip: "Označi sve nepročitane obavijesti o privatnim porukama kao pročitane" + no_likes_title: "Još niste dobili nijedan lajk" + no_likes_body: > + Biti ćete obaviješteni svaki put kada netko lajka neku od vaših objava kako biste mogli vidjeti što drugi smatraju vrijednim. Drugi će vidjeti isto kada i vi lajkate njihove objave!

    Obavijesti o lajkovima nikada vam se ne šalju e-poštom, ali možete podesiti kako ćete primati obavijesti o lajkovima na web mjestu u svojim postavkama obavijesti. no_messages_title: "Nemate nijednu poruku" no_messages_body: > Trebate voditi izravan osobni razgovor s nekim, izvan uobičajenog toka razgovora? Pošaljite im poruku odabirom avatara i %{icon} poruke.

    Ako trebate pomoć, možete poslati poruku osoblju. @@ -1111,12 +1129,16 @@ hr: no_notifications_title: "Još nemate nijednu obavijest" no_notifications_body: > Bit ćete obaviješteni u ovom panelu o aktivnosti izravno relevantne za vas, uključujući i odgovorima na vaše teme i postove, kada netko @označi Vas ili citira vas, i odgovori na temu koju gledate. Obavijesti će se slati i na vašu e-poštu kada se neko vrijeme niste prijavili.

    Potražite %{icon} da biste odlučili o kojim određenim temama, kategorijama i oznakama želite biti obaviješteni. Više informacija potražite u postavki obavijesti. + no_other_notifications_title: "Još nemate nijednu obavijest" + no_other_notifications_body: > + Na ovom ćete panelu biti obaviješteni o drugim vrstama aktivnosti koje mogu biti relevantne za vas - na primjer, kada netko linka vaš post ili uredi neki od vaših postova. no_notifications_page_title: "Još nemate nijednu obavijest" no_notifications_page_body: > Bit ćete obaviješteni o aktivnostima izravno relevantne za vas, uključujući i odgovorima na vaše teme i postove, kada netko @mentions Vi ili citati vas, i odgovori na temu koju gledate. Obavijesti će se također slati na vašu e -poštu ako se neko vrijeme niste prijavili.

    Potražite %{icon} da biste odlučili o kojim temama, kategorijama i oznakama želite biti obaviješteni. Za više informacija pogledajte postavki obavijesti. dynamic_favicon: "Prikaži brojeve na ikoni preglednika" skip_new_user_tips: description: "Preskoči savjete i značke novog korisnika" + reset_seen_user_tips: "Ponovno prikaži savjete za korisnike" theme_default_on_all_devices: "Postavi ovu temu kao zadanu na svim mojim uređajima" color_scheme_default_on_all_devices: "Postavljanje zadane sheme boja na svim mojim uređajima" color_scheme: "Shema boja" @@ -1136,8 +1158,16 @@ hr: enable_quoting: "Omogući citirani odgovor označenoj teksta" enable_defer: "Uključi odgodu označavanja nepročitanih tema" experimental_sidebar: + enable: "Omogući bočnu traku" options: "Mogućnosti" + categories_section: "Odjeljak s kategorijama" + categories_section_instruction: "Odabrane kategorije bit će prikazane u odjeljku kategorija na bočnoj traci." + tags_section: "Odjeljak s oznakama" + tags_section_instruction: "Odabrane oznake bit će prikazane u odjeljku oznaka na bočnoj traci." navigation_section: "Navigacija" + list_destination_instruction: "Kada se pojavi novi sadržaj na bočnoj traci..." + list_destination_default: "koristite zadanu vezu i prikažite značku za nove stavke" + list_destination_unread_new: "poveži na nepročitano/novo i prikaži broj novih stavki" change: "promijeni" featured_topic: "Istaknuta tema" moderator: "%{user} je moderator" @@ -1240,6 +1270,70 @@ hr: warnings: "Službena upozorenja" read_more_in_group: "Želite li pročitati više? Pregledajte ostale poruke u %{groupLink}." read_more: "Želite li pročitati više? Pregledajte ostale poruke u osobnim porukama." + read_more_group_pm_MF: | + { HAS_UNREAD_AND_NEW, select, + true { + { UNREAD, plural, + =0 {} + one {Postoji # nepročitano} + few {Ima ih # nepročitanih} + other {Ima ih # nepročitanih} + } + { NEW, plural, + =0 {} + one { i # nova preostala poruka ili pregledajte ostale poruke u {groupLink}} + few { i # novih preostalih poruka ili pregledajte ostale poruke u {groupLink}} + other { i # novih preostalih poruka ili pregledajte ostale poruke u {groupLink}} + } + } + false { + { UNREAD, plural, + =0 {} + one {Preostale su # nepročitane poruke ili pregledajte ostale poruke u {groupLink}} + few {Preostalo je # nepročitanih poruka ili pregledajte ostale poruke u {groupLink}} + other {Preostalo je # nepročitanih poruka ili pregledajte ostale poruke u {groupLink}} + } + { NEW, plural, + =0 {} + one {Preostalo je # novih poruka ili pregledajte ostale poruke u {groupLink}} + few {Preostalo je # novih poruka ili pregledajte ostale poruke u {groupLink}} + other {Preostalo je # novih poruka ili pregledajte ostale poruke u {groupLink}} + } + } + other {} + } + read_more_personal_pm_MF: | + { HAS_UNREAD_AND_NEW, select, + true { + { UNREAD, plural, + =0 {} + one {Postoji # nepročitanih} + few {Postoji # nepročitano} + other {Postoji # nepročitano} + } + { NEW, plural, + =0 {} + one { i # nova preostala poruka ili pregledajte ostale osobne poruke} + few { i # novih preostalih poruka ili pregledajte ostalih osobnih poruka} + other { i # novih preostalih poruka ili pregledajte ostalih osobnih poruka} + } + } + false { + { UNREAD, plural, + =0 {} + one {Preostalo je # nepročitanih poruka ili pregledajte ostalih osobnih poruka} + few {Preostalo je # nepročitanih poruka ili pregledajte ostalih osobnih poruka} + other {Preostalo je # nepročitanih poruka ili pregledajte ostalih osobnih poruka} + } + { NEW, plural, + =0 {} + one {Preostalo je # novih poruka ili pregledajte ostalih osobnih poruka} + few {Preostalo je # novih poruka ili pregledajte ostalih osobnih poruka} + other {Preostalo je # novih poruka ili pregledajte ostalih osobnih poruka} + } + } + other {} + } preferences_nav: account: "Račun" security: "Sigurnost" @@ -1252,6 +1346,7 @@ hr: tags: "Oznake" interface: "Sučelje" apps: "Aplikacije" + sidebar: "Bočna traka" change_password: success: "(email je poslan)" in_progress: "(email se šalje)" @@ -1307,12 +1402,18 @@ hr: disable: "Onemogući" disable_confirm: "Jeste li sigurni da želite onemogućiti dvofaktorsku provjeru autentičnosti?" delete: "Izbriši" + delete_confirm_header: "Ovi autentifikatori temeljeni na tokenima i fizički sigurnosni ključevi bit će izbrisani:" delete_confirm_instruction: "Za potvrdu unesite %{confirm} u okvir ispod." delete_single_confirm_title: "Brisanje autentifikatora" + delete_single_confirm_message: "Brišete %{name}. Ne možete poništiti ovu radnju. Ako se predomislite, morate ponovno registrirati ovaj autentifikator." + delete_backup_codes_confirm_title: "Brisanje pričuvnih kodova" + delete_backup_codes_confirm_message: "Brišete pričuvne kodove. Ne možete poništiti ovu radnju. Ako se predomislite, morate ponovno generirati pričuvne kodove." save: "Spremi" edit: "Izmijeni" edit_title: "Uredi autentifikator" edit_description: "Ime autentifikatora" + enable_security_key_description: | + Kada pripremite svoj hardverski sigurnosni ključ ili kompatibilni mobilni uređaj, pritisnite gumb za registraciju u nastavku. totp: title: "Autentifikatori temeljeni na Tokenu" add: "Dodaj provjeru autentičnosti" @@ -1320,12 +1421,16 @@ hr: name_and_code_required_error: "Morate navesti ime i kôd iz aplikacije za provjeru autentičnosti." security_key: register: "Registracija" + title: "Fizički sigurnosni ključevi" + add: "Dodajte fizički sigurnosni ključ" default_name: "Glavni sigurnosni ključ" iphone_default_name: "iPhone" android_default_name: "Android" not_allowed_error: "Postupak registracije sigurnosnog ključa ili je vremenski ograničen ili je otkazan." already_added_error: "Već ste registrirali ovaj sigurnosni ključ. Ne morate ga ponovno registrirati." + edit: "Uredi fizički sigurnosni ključ" save: "Spremi" + edit_description: "Naziv ključa fizičke sigurnosti" name_required_error: "Morate navesti ime za svoj sigurnosni ključ." change_about: title: "Promijeni O meni" @@ -1470,6 +1575,8 @@ hr: title: "Naslov pozadinske stranice prikazuje broj:" notifications: "Nove obavijesti" contextual: "Sadržaj nove stranice" + bookmark_after_notification: + title: "Nakon slanja obavijesti o podsjetniku oznake:" like_notification_frequency: title: "Obavijesti ako se sviđa" always: "Uvijek" @@ -1687,6 +1794,9 @@ hr: title: "Njuh" none: "(ništa)" instructions: "ikona prikazana uz sliku vašeg profila" + status: + title: "Prilagođeni status" + not_set: "Nije postavljeno" primary_group: title: "Primarna grupa" none: "(ništa)" @@ -1702,6 +1812,25 @@ hr: set_custom_status: "Postavi prilagođeni status" what_are_you_doing: "Što radiš?" pause_notifications: "Pauziraj obavijesti" + remove_status: "Ukloni status" + user_tips: + primary: "Kužim!" + secondary: "ne pokazuj mi ove savjete" + first_notification: + title: "Vaša prva obavijest!" + content: "Obavijesti se koriste kako biste bili u tijeku s onim što se događa u zajednici." + topic_timeline: + title: "Vremenski okvir teme" + content: "Brzo listajte kroz objavu pomoću vremenske trake teme." + post_menu: + title: "Izbornik za objave" + content: "Pogledajte kako još možete komunicirati s objavom klikom na tri točkice!" + topic_notification_levels: + title: "Sada pratite ovu temu" + content: "Potražite ovo zvono da prilagodite svoje postavke obavijesti za određene teme ili cijele kategorije." + suggested_topics: + title: "Nastavi čitati!" + content: "Evo nekih tema za koje mislimo da biste ih željeli pročitati sljedeće." loading: "Očitavanje..." errors: prev_page: "pri pokušaju očitanja" @@ -1734,6 +1863,36 @@ hr: enabled: "Ova stranica je u načinu rada samo za čitanje. Nastavite pregledavati, ali odgovaranje, sviđanja i druge radnje su za sada onemogućene." login_disabled: "Prijava je onemogućena dok je stranica u modelu \"samo čitanje\"," logout_disabled: "Odjava je onemogućena dok je forum u načinu rada samo za čitanje." + staff_writes_only_mode: + enabled: "Ova stranica je u načinu rada samo za osoblje. Nastavite s pregledavanjem, ali odgovaranje, sviđanje i druge radnje ograničeni su samo na članove osoblja." + too_few_topics_and_posts_notice_MF: | + raspravu! Tamo { currentTopics, plural, + one {je # tema} + few {su # tema} + other {su # tema} + } i { currentPosts, plural, + one {# post} + few {# postovi} + other {# postovi} + }. Posjetitelji trebaju više za čitanje i odgovaranje – preporučujemo barem { requiredTopics, plural, + one {# tema} + few {# tema} + other {# tema} + } i { requiredPosts, plural, + one {# post} + few {# postova} + other {# postova} + }. Samo osoblje može vidjeti ovu poruku. + too_few_topics_notice_MF: | + raspravu! Tamo { currentTopics, plural, + one {je # tema} + few {su # tema} + other {su # tema} + }. Posjetitelji trebaju više za čitanje i odgovaranje – preporučujemo barem { requiredTopics, plural, + one {# tema} + few {# tema} + other {# tema} + }. Samo osoblje može vidjeti ovu poruku. too_few_posts_notice_MF: | Hajde da započnemo diskusiju! Tamo {currentPosts, plural, one {je # tema} few {su # teme} other {su # teme}}. Posjetiteljima je potrebno više čitati i odgovoriti na — preporučujemo barem {requiredPosts, plural, one {# tema} few {# teme} other {# teme}}. Samo osoblje može vidjeti ovu poruku. logs_error_rate_notice: @@ -1761,9 +1920,11 @@ hr: other: odgovora signup_cta: sign_up: "Učlani se" + hide_session: "Možda kasnije" hide_forever: "ne hvala" hidden_for_session: "OK, pitat ćemo te sutra. Uvijek možete upotrijebiti 'Prijava' i za stvaranje računa." intro: "Zdravo! Čini se da uživate u raspravi, ali još se niste prijavili za račun." + value_prop: "Umorni ste od listanja istih postova? Kada kreirate račun uvijek ćete se vraćati tamo gdje ste stali. S računom također možete primati obavijesti o novim odgovorima, spremati oznake i koristiti lajkove da zahvalite drugima. Svi možemo raditi zajedno kako bismo ovu zajednicu učinili sjajnom. :heart:" summary: enabled_description: "Gledate pregled ove teme: najzanimljivije članke odabire zajednica." description: @@ -1791,6 +1952,7 @@ hr: remove_allowed_user: "Želite li zaista ukloniti %{name} iz ove poruke?" remove_allowed_group: "Želite li zaista ukloniti %{name} iz ove poruke?" leave: "Napustiti" + remove_group: "Ukloni grupu" remove_user: "Ukloni korisnika" email: "E-mail" username: "Korisničko ime" @@ -1842,12 +2004,16 @@ hr: username: "Korisnik" password: "Zaporka" show_password: "Pokaži" + hide_password: "Sakrij" + show_password_title: "Pokaži lozinku" + hide_password_title: "Sakrij lozinku" second_factor_title: "Dvofaktorska autentifikacija" second_factor_description: "Unesite kod za provjeru autentičnosti iz svoje aplikacije:" second_factor_backup: "Prijavite se pomoću rezervnog koda" second_factor_backup_title: "Sigurnosna kopija od dva faktora" second_factor_backup_description: "Unesite jedan od vaših rezervnih kodova:" second_factor: "Prijavite se pomoću aplikacije Authenticator" + security_key_description: "Kada pripremite fizički sigurnosni ključ ili kompatibilni mobilni uređaj, pritisnite gumb Autentifikacija pomoću sigurnosnog ključa u nastavku." security_key_alternative: "Pokušaj na drugi način" security_key_authenticate: "Autentifikacija sa sigurnosnim ključem" security_key_not_allowed_error: "Proces provjere autentičnosti sigurnosnog ključa je istekao ili je otkazan." @@ -1862,6 +2028,7 @@ hr: blank_username_or_password: "Molimo unesite e-mail ili korisničko ime i zaporku." reset_password: "Ponovno postavi zaporku" logging_in: "Prijavljivanje..." + previous_sign_up: "Već imate račun?" or: "Ili" authenticating: "Autentifikaciranje..." awaiting_activation: "Vaš račun čeka aktivaciju, poslužite se poveznicom za zaboravljenu zaporku da pošaljemo drugi aktivacijski e-mail." @@ -1920,6 +2087,7 @@ hr: success: "Vaš račun je kreiran i sada ste prijavljeni." name_label: "Ime" password_label: "Lozinka" + existing_user_can_redeem: "Iskoristite svoju pozivnicu za temu ili grupu." password_reset: continue: "Nastavite na %{site_name}" emoji_set: @@ -1933,6 +2101,7 @@ hr: categories_only: "Samo kategorije" categories_with_featured_topics: "Kategorije s istaknutim temama" categories_and_latest_topics: "Kategorije i najnovije teme" + categories_and_latest_topics_created_date: "Kategorije i najnovije teme (poredaj po datumu kreiranja teme)" categories_and_top_topics: "Kategorije i glavne teme" categories_boxes: "Kutije s potkategorijama" categories_boxes_with_topics: "Kutije s istaknutim temama" @@ -2026,6 +2195,9 @@ hr: similar_topics: "Vaša tema je slična..." drafts_offline: "nacrti offline" edit_conflict: "uredi sukob" + esc: "esc" + esc_label: "Kliknite ili pritisnite Esc za odbacivanje" + ok_proceed: "Ok, nastavi" group_mentioned_limit: one: "Upozorenje! Spomenuli ste %{group}, međutim ova grupa ima više članova od administratora konfiguriranog ograničenja spominjanja od %{count} korisnika. Nitko neće biti obaviješten." few: "Upozorenje! Spomenuli ste %{group}, međutim ova grupa ima više članova od administratora konfiguriranog ograničenja spominjanja od %{count} korisnika. Nitko neće biti obaviješten." @@ -2039,11 +2211,19 @@ hr: private: "Spomenuli ste @%{username} , ali oni neće biti obaviješteni jer ne mogu vidjeti ovu osobnu poruku. Morat ćete ih pozvati u ovu osobnu poruku." muted_topic: "Spomenuli ste @%{username} , ali oni neće biti obaviješteni jer su isključili ovu temu." not_allowed: "Spomenuli ste @%{username} , ali oni neće biti obaviješteni jer nisu pozvani na ovu temu." + cannot_see_group_mention: + not_mentionable: "Ne možete spomenuti grupu @%{group}." + some_not_allowed: + one: "Spomenuli ste @%{group} , ali samo %{count} član će biti obaviješten jer ostali članovi ne mogu vidjeti ovu osobnu poruku. Morat ćete ih pozvati u ovu osobnu poruku." + few: "Spomenuli ste @%{group} , ali samo %{count} članovi će biti obaviješteni jer ostali članovi ne mogu vidjeti ovu osobnu poruku. Morat ćete ih pozvati u ovu osobnu poruku." + other: "Spomenuli ste @%{group} , ali samo %{count} članovi će biti obaviješteni jer ostali članovi ne mogu vidjeti ovu osobnu poruku. Morat ćete ih pozvati na ovu osobnu poruku." + not_allowed: "Spomenuli ste @%{group} , ali nitko od njegovih članova neće biti obaviješten jer ne mogu vidjeti ovu osobnu poruku. Morat ćete ih pozvati u ovu osobnu poruku." here_mention: one: "Spominjanjem @%{here}obavijestit ćete %{count} korisnika – jeste li sigurni?" few: "Spominjanjem @%{here}obavijestit ćete %{count} korisnika – jeste li sigurni?" other: "Spominjanjem @%{here}obavijestit ćete %{count} korisnika – jeste li sigurni?" duplicate_link: "Čini se da je vašu vezu na %{domain} već objavio u temi @%{username} u odgovor na %{ago} - jeste li sigurni da ga želite ponovo objaviti?" + duplicate_link_same_user: "Čini se da ste već objavili vezu na %{domain} u ovoj temi u odgovoru na %{ago} - jeste li sigurni da je želite ponovno objaviti?" reference_topic_title: "RE: %{title}" error: title_missing: "Naslov je obavezan" @@ -2079,6 +2259,7 @@ hr: create_shared_draft: "Stvori zajedničku skicu" edit_shared_draft: "Uredi zajedničku skicu" title: "Ili pritisnite %{modifier}Enter" + users_placeholder: "Dodajte korisnike ili grupe" title_placeholder: "O čemu je ova rasprava u jednoj kratkoj rečenici?" title_or_link_placeholder: "Upišite naslov ili ovdje zalijepite vezu" edit_reason_placeholder: "zašto izmjenjujete?" @@ -2123,6 +2304,7 @@ hr: abandon: "zatvorite skladatelja i odbacite skicu" enter_fullscreen: "unesite kompozitor preko cijelog zaslona" exit_fullscreen: "izađi iz cijelog zaslona u kompozitoru" + exit_fullscreen_prompt: "Pritisnite ESC za izlaz iz cijelog zaslona" show_toolbar: "prikaži alatnu traku skladatelja" hide_toolbar: "sakrij alatnu traku skladatelja" modal_ok: "U redu" @@ -2133,6 +2315,9 @@ hr: body: "Trenutno će ova poruka biti poslana samo vama!" slow_mode: error: "Ova je tema u usporenom načinu rada. Već ste nedavno objavili; možete ponovo objaviti u %{timeLeft}." + user_not_seen_in_a_while: + single: "Osoba kojoj šaljete poruku, %{usernames}, nije ovdje viđena jako dugo – %{time_ago}. Možda neće primiti vašu poruku. Možda ćete htjeti potražiti alternativne metode kontaktiranja %{usernames}." + multiple: "Sljedeće osobe koje šaljete porukama: %{usernames}, nisu ovdje viđeni već dugo — %{time_ago}. Možda neće primiti vašu poruku. Možda ćete htjeti potražiti alternativne metode kontaktiranja s njima." admin_options_title: "Neobavezna pravila osoblja za ovu temu" composer_actions: reply: Odgovori @@ -2166,6 +2351,7 @@ hr: ignore: "Zanemari" image_alt_text: aria_label: Zamjenski tekst za sliku + delete_image_button: Izbriši sliku notifications: tooltip: regular: @@ -2180,12 +2366,21 @@ hr: one: "%{count} nepročitana obavijest visokog prioriteta" few: "%{count} nepročitanih obavijesti visokog prioriteta" other: "%{count} nepročitanih obavijesti visokih prioriteta" + new_message_notification: + one: "%{count} obavijesti o novoj poruci" + few: "%{count} obavijesti o novoj poruci" + other: "%{count} obavijesti o novim porukama" + new_reviewable: + one: "%{count} novih za pregled" + few: "%{count} novih za pregled" + other: "%{count} novih recenziranih" title: "obavijesti o spominjanju @ime, odgovori na vaše objave i teme, poruke itd." none: "Nemoguće očitati obavijesti u ovom trenutku." empty: "Obavijesti nisu pronađene." post_approved: "Vaš post je odobren" reviewable_items: "stavke koje zahtijevaju pregled" watching_first_post_label: "Nova tema" + user_moved_post: "%{username} preselio" mentioned: "%{username} %{description}" group_mentioned: "%{username} %{description}" quoted: "%{username} %{description}" @@ -2214,6 +2409,7 @@ hr: invited_to_private_message: "

    %{username} %{description}" invited_to_topic: "%{username} %{description}" invitee_accepted: "%{username} prihvatio/la je vašu pozivnicu" + invitee_accepted_your_invitation: "prihvatio vaš poziv" moved_post: "%{username} premješteno %{description}" linked: "%{username} %{description}" granted_badge: "Dobio '%{description}'" @@ -2234,6 +2430,14 @@ hr: one: "Jesi li siguran? Imate %{count} važnih obavijesti." few: "Jesi li siguran? Imate %{count} važnih obavijesti." other: "Jesi li siguran? Imate %{count} važnih obavijesti." + bookmarks: + one: "Jesi li siguran? Imate %{count} nepročitanih podsjetnika za oznake." + few: "Jesi li siguran? Imate %{count} nepročitanih podsjetnika za oznake." + other: "Jesi li siguran? Imate %{count} nepročitanih podsjetnika za oznake." + messages: + one: "Jesi li siguran? Imate %{count} nepročitanih osobnih poruka." + few: "Jesi li siguran? Imate %{count} nepročitanih osobnih poruka." + other: "Jesi li siguran? Imate %{count} nepročitanih osobnih poruka." dismiss: "Skloni" cancel: "Odustani" group_message_summary: @@ -2348,6 +2552,7 @@ hr: status: "filteri prema statusu teme" full_search: "pokreće pretraživanje cijele stranice" full_search_key: "%{modifier} + Enter" + me: "prikazuje samo vaše postove" advanced: title: Napredni filtri posted_by: @@ -2418,12 +2623,63 @@ hr: current_user: "idi na korisničku stranicu" view_all: "pogledaj sve %{tab}" user_menu: + generic_no_items: "Na ovom popisu nema stavki." + sr_menu_tabs: "Kartice korisničkog izbornika" + view_all_notifications: "pogledajte sve obavijesti" + view_all_bookmarks: "pogledajte sve oznake" + view_all_messages: "pogledajte sve osobne poruke" tabs: + all_notifications: "Sve obavijesti" replies: "Odgovori" + replies_with_unread: + one: "Odgovori - %{count} nepročitanih odgovora" + few: "Odgovori - %{count} nepročitanih odgovora" + other: "Odgovori - %{count} nepročitanih odgovora" mentions: "Spomeni" + mentions_with_unread: + one: "Spominjanja - %{count} nepročitanih spominjanja" + few: "Spominjanja - %{count} nepročitanih spominjanja" + other: "Spominjanja - %{count} nepročitanih spominjanja" likes: "Like-ova" + likes_with_unread: + one: "Lajkovi - %{count} nepročitanih lajkova" + few: "Lajkovi - %{count} nepročitanih lajkova" + other: "Lajkovi - %{count} nepročitanih lajkova" + watching: "Gledane teme" + watching_with_unread: + one: "Gledane teme - %{count} nepročitanih gledanih tema" + few: "Gledane teme - %{count} nepročitanih gledanih tema" + other: "Gledane teme - %{count} nepročitanih gledanih tema" + messages: "Osobne poruke" + messages_with_unread: + one: "Osobne poruke - %{count} nepročitanih poruka" + few: "Osobne poruke - %{count} nepročitanih poruka" + other: "Osobne poruke - %{count} nepročitanih poruka" bookmarks: "Zabilješke" + bookmarks_with_unread: + one: "Oznake - %{count} nepročitanih oznaka" + few: "Oznake - %{count} nepročitanih oznaka" + other: "Oznake - %{count} nepročitanih oznaka" + review_queue: "Red čekanja za pregled" + review_queue_with_unread: + one: "Red čekanja za pregled - %{count} stavki treba pregledati" + few: "Red čekanja za pregled - %{count} stavki treba pregledati" + other: "Red čekanja za pregled - %{count} stavki treba pregledati" + other_notifications: "Ostale obavijesti" + other_notifications_with_unread: + one: "Ostale obavijesti - %{count} nepročitanih obavijesti" + few: "Ostale obavijesti - %{count} nepročitanih obavijesti" + other: "Ostale obavijesti - %{count} nepročitanih obavijesti" profile: "Profil" + reviewable: + view_all: "pogledaj sve stavke pregleda" + queue: "Red čekanja" + deleted_user: "(izbrisani korisnik)" + deleted_post: "(izbrisana objava)" + post_number_with_topic_title: "Članak #%{post_number} - %{title}" + new_post_in_topic: "novi post u %{title}" + user_requires_approval: "%{username} zahtijeva odobrenje" + default_item: "pregledna stavka #%{reviewable_id}" topics: new_messages_marker: "posljednji posjet" bulk: @@ -2556,7 +2812,52 @@ hr: show_links: "pokaži poveznice u temi" collapse_details: "Sažmi detalje teme" expand_details: "proširite pojedinosti o temi" + read_more_in_category: "Želite čitati više? Pregledajte ostale teme u %{categoryLink} ili pogledajte najnovije teme." + read_more: "Želite čitati više? Pregledajte sve kategorije ili pogledajte najnovije teme." unread_indicator: "Još nijedan član nije pročitao zadnji post ove teme." + read_more_MF: | + { HAS_UNREAD_AND_NEW, select, + true { + { UNREAD, plural, + =0 {} + one {Postoji # nepročitano} + few {Ima ih # nepročitanih} + other {Ima ih # nepročitanih} + } + { NEW, plural, + =0 {} + one { i # nova preostala tema,} + few { i # nove preostale teme,} + other { i # nove preostale teme,} + } + } + false { + { UNREAD, plural, + =0 {} + one {Preostalo je # nepročitanih tema,} + few {Preostalo je # nepročitanih tema,} + other {Preostalo je # nepročitanih tema,} + } + { NEW, plural, + =0 {} + one {Preostalo je # novih tema,} + few {Preostalo je # novih tema,} + other {Preostalo je # novih tema,} + } + } + other {} + } + { HAS_CATEGORY, select, + true { ili pregledajte druge teme u {categoryLink}} + false { ili pogledajte najnovije teme} + other {} + } + bumped_at_title: | + Prvi post: %{createdAtDate} + Objavljeno: %{bumpedAtDate} + browse_all_categories_latest: "Pregledajte sve kategorije ili pogledajte najnovije teme." + browse_all_categories_latest_or_top: "Pregledajte sve kategorije, pogledajte najnovije teme ili pogledajte vrh:" + browse_all_tags_or_latest: "Pregledajte sve oznake ili pogledajte najnovije teme." suggest_create_topic: Spremni za započeti novi razgovor? jump_reply_up: skoči na raniji odgovor jump_reply_down: skoči na kasniji odgovor @@ -2951,6 +3252,7 @@ hr: one: "pogledaj %{count} skriveni odgovor" few: "pogledaj %{count} skrivenih odgovora" other: "pogledaj %{count} skrivenih odgovora" + sr_reply_to: "Odgovorite na post #%{post_number} sa @%{username}" notice: new_user: "Ovo je prva objava %{user} - poželimo mu dobrodošlicu u zajednicu." returning_user: "Prošlo je dosta vremena otkad smo vidjeli %{user} — njihov posljednji post bio je %{time}." @@ -3001,6 +3303,7 @@ hr: few: "Nažalost, istodobno možete prenijeti samo %{count} datoteka." other: "Nažalost, istodobno možete prenijeti samo %{count} datoteka." upload_not_authorized: "Nažalost, datoteka koju pokušavate prenijeti nije autorizirana (autorizirana proširenja: %{authorized_extensions})." + no_uploads_authorized: "Nažalost, nijedna datoteka nije ovlaštena za učitavanje." image_upload_not_allowed_for_new_user: "Žao nam je, novi korisnici ne mogu učitavati slike." attachment_upload_not_allowed_for_new_user: "Žao nam je, novi korisnici ne mogu učitavati privitke." attachment_download_requires_login: "Žao nam je, morate biti prijavljeni da biste preuzimali privitke." @@ -3012,6 +3315,7 @@ hr: via_email: "ova objava stigla je preko e-maila" via_auto_generated_email: "ovaj je post stigao putem automatski generirane e-pošte" whisper: "ovu objavu potiho pošaljite moderatorima" + whisper_groups: "ovaj post je privatan i vidljiv je samo %{groupNames}" wiki: about: "ova objava je zajednička wiki objava" few_likes_left: "Hvala za dijeljenje ljubavi! Za danas vam je ostalo samo nekoliko lajkova." @@ -3102,6 +3406,11 @@ hr: one: "Jeste li sigurni da želite izbrisati taj post?" few: "Jeste li sigurni da želite izbrisati %{count}\" postova?" other: "Jeste li sigurni da želite izbrisati te %{count} postove?" + merge: + confirm: + one: "Jeste li sigurni da želite spojiti ove objave?" + few: "Jeste li sigurni da želite spojiti %{count} postova?" + other: "Jeste li sigurni da želite spojiti ovih %{count} objava?" revisions: controls: first: "Prva revizija" @@ -3175,6 +3484,7 @@ hr: all: "Sve kategorije" choose: "kategorija…" edit: "Izmijeni" + edit_title: "Uredite ovu kategoriju" edit_dialog_title: "Uredi: %{categoryName}" view: "Prikaži teme u kategoriji" back: "Natrag u kategoriju" @@ -3206,6 +3516,7 @@ hr: name: "Ime kategorije" description: "Opis" logo: "Logo kategorije" + logo_dark: "Slika logotipa kategorije tamnog načina rada" background_image: "Pozadinska slika kategorije" badge_colors: "Boje značke" background_color: "Pozadinska boja" @@ -3236,6 +3547,7 @@ hr: pending_permission_change_alert: "Niste dodali %{group} ovoj kategoriji; kliknite ovaj gumb da biste ih dodali." images: "Slike" email_in: "Prilagođena adresa dolaznog e-maila." + email_in_tooltip: "Možete odvojiti na više adresa e-pošte s | znakom." email_in_allow_strangers: "Prihvati emailove anonimnih korisnika bez računa" email_in_disabled: "Objavljivanje novih tema preko emaila je onemogućeno postavkama stranice. Da to omogućite objavljivanje tema preko emaila" email_in_disabled_click: 'omogućite "pošalji email u" opciju.' @@ -3260,6 +3572,7 @@ hr: this_year: "ove godine" position: "Položaj na stranici kategorija:" default_position: "Zadana pozicija" + position_disabled: "Kategorije će biti prikazane prema redoslijedu aktivnosti. Da biste kontrolirali redoslijed kategorija na popisima, omogućite postavku 'fiksnih pozicija kategorija'." minimum_required_tags: "Minimalan broj oznaka potrebnih u temi:" default_slow_mode: 'Omogućite "Spori način rada" za nove teme u ovoj kategoriji.' parent: "Nadkategorija" @@ -3529,6 +3842,7 @@ hr: this_month: "Mjesec" this_week: "Tjedan" today: "Danas" + browser_update: 'Nažalost, vaš preglednik nije podržan. Prebacite se na podržani preglednik za pregled bogatog sadržaja, prijavite se i odgovorite.' permission_types: full: "Otvoriti / Odgovoriti / Vidjeti" create_post: "Odgovoriti / Vidjeti" @@ -3551,6 +3865,7 @@ hr: shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}" shortcut_delimiter_space: "%{shortcut1} %{shortcut2}" title: "Prečaći tipkovnice" + short_title: "Prečaci" jump_to: title: "Idi na" home: "%{shortcut} Početak" @@ -3618,6 +3933,7 @@ hr: edit: "%{shortcut} Uredi objavu" delete: "%{shortcut} Izbriši objavu" mark_muted: "%{shortcut} Utišaj objavu" + mark_regular: "%{shortcut} Normalna (zadana) tema" mark_tracking: "%{shortcut} Prati temu" mark_watching: "%{shortcut} Promatraj temu" print: "%{shortcut} Ispis teme" @@ -3680,6 +3996,7 @@ hr: google: "Google kalendar" ics: "ICS" tagging: + all_tags: "Sve oznake" other_tags: "Ostale oznake" selector_all_tags: "sve oznake" selector_no_tags: "bez oznaka" @@ -3826,6 +4143,8 @@ hr: enabled: "Omogućen je siguran način rada, da biste izašli iz sigurnog načina rada, zatvorite ovaj prozor preglednika" image_removed: "(slika uklonjena)" pause_notifications: + title: "Pauziraj obavijesti za..." + label: "Pauziraj obavijesti" remaining: "%{remaining} preostalih" options: half_hour: "30 minuta" @@ -3848,11 +4167,15 @@ hr: no_activity_title: "Još nema aktivnosti" no_activity_body: "Dobrodošli u našu zajednicu! Ovdje ste potpuno novi i još niste sudjelovali u raspravama. Kao prvi korak, posjetite Top ili kategorije i samo počnite čitati! Odaberite %{heartIcon} na objavama koje vam se sviđaju ili o kojima želite saznati više. Dok budete sudjelovali, vaša će aktivnost biti navedena ovdje." no_replies_title: "Još niste odgovorili ni na jednu temu" + no_replies_title_others: "%{username} još nije odgovorio ni na jednu temu" + no_replies_body: "Kada otkrijete zanimljiv razgovor kojem želite pridonijeti, pritisnite gumb Odgovori izravno ispod bilo koje objave kako biste počeli odgovarati na tu objavu. Ili, ako biste radije odgovorili na opću temu, a ne na bilo koji pojedinačni post ili osobu, potražite gumb Odgovori na samom dnu teme ili ispod vremenske trake teme." no_drafts_title: "Niste pokrenuli nikakve skice" no_drafts_body: "Niste baš spremni za objavu? Automatski ćemo spremiti novu skicu i navesti je ovdje kad god počnete sastavljati temu, odgovor ili osobnu poruku. Odaberite gumb za odustajanje da biste odbacili ili spremili skicu za nastavak kasnije." no_likes_title: "Još vam se nije svidjela nijedna tema" + no_likes_title_others: "%{username} još nije označio da mu se sviđa nijedna tema" no_likes_body: "Sjajan način da uskočite i počnete pridonositi je da počnete čitati razgovore koji su se već vodili i odaberete %{heartIcon} na objavama koje vam se sviđaju!" no_topics_title: "Još niste pokrenuli nijednu temu" + no_topics_body: "Uvijek je najbolje pretražiti postojeće teme za razgovor prije nego započnete novu, ali ako ste sigurni da tema koju želite još ne postoji, samo naprijed i započnite vlastitu novu temu. Potražite gumb + Nova tema u gornjem desnom kutu popisa tema, kategorije ili oznake za početak stvaranja nove teme u tom području." no_topics_title_others: "%{username} još nije pokrenuo nijednu temu" no_read_topics_title: "Još niste pročitali nijednu temu" no_read_topics_body: "Kada počnete čitati rasprave, ovdje ćete vidjeti popis. Da biste počeli čitati, potražite teme koje vas zanimaju u Top ili kategorije ili pretražite po ključnoj riječi %{searchIcon}" @@ -3865,6 +4188,8 @@ hr: second_factor_auth: redirect_after_success: "Provjera autentičnosti drugog faktora je uspješna. Preusmjeravanje na prethodnu stranicu…" sidebar: + show_sidebar: "Prikaži bočnu traku" + hide_sidebar: "Sakrij bočnu traku" unread_count: one: "%{count} nepročitano" few: "%{count} nepročitano" @@ -3873,13 +4198,16 @@ hr: one: "%{count} novi" few: "%{count} novih" other: "%{count} novi" + toggle_section: "Uključi/isključi odjeljak" more: "Više" all_categories: "Sve kategorije" + all_tags: "Sve oznake" sections: about: header_link_text: "O nama" messages: header_link_text: "Poruke" + header_action_title: "Napravite osobnu poruku" links: inbox: "Primljeno" sent: "Poslano" @@ -3889,31 +4217,63 @@ hr: unread_with_count: "Nepročitano (%{count})" archive: "Arhiva" tags: + links: + add_tags: + content: "Dodaj oznake" + title: "Niste dodali nijednu oznaku. Kliknite za početak." + none: "Niste dodali nijednu oznaku." + click_to_get_started: "Kliknite ovdje da biste započeli." header_link_text: "Oznake" + header_action_title: "Uredite oznake bočne trake" + configure_defaults: "Konfigurirajte zadane postavke" categories: + links: + add_categories: + content: "Dodajte kategorije" + title: "Niste dodali nijednu kategoriju. Kliknite za početak." + none: "Niste dodali nijednu kategoriju." + click_to_get_started: "Kliknite ovdje da biste započeli." header_link_text: "Kategorije" + header_action_title: "Uredite svoje kategorije bočne trake" + configure_defaults: "Konfigurirajte zadane postavke" community: header_link_text: "Zajednica" + header_action_title: "Napravite temu" links: about: content: "O nama" + title: "Više detalja o ovoj stranici" admin: content: "Administrator" + title: "Postavke stranice i izvješća" badges: content: "Značke" + title: "Sve značke dostupne za osvajanje" everything: content: "Sve" title: "Sve teme" faq: content: "ČPP" + title: "Smjernice za korištenje ove stranice" groups: content: "Grupe" + title: "Popis dostupnih korisničkih grupa" users: content: "Korisnika" + title: "Popis svih korisnika" my_posts: content: "Moje objave" + title: "Moja nedavna aktivnost vezana uz temu" + title_drafts: "Moje neobjavljene skice" review: content: "Osvrt" + title: "Označene objave i druge stavke u redu čekanja" + pending_count: "%{count} na čekanju" + welcome_topic_banner: + title: "Kreirajte svoju temu dobrodošlice" + description: "Vaša tema dobrodošlice prva je stvar koju će novi članovi pročitati. Zamislite to kao svoj \"elevator pitch\" ili \"izjavu o misiji\". Recite svima za koga je ova zajednica, što mogu očekivati da će ovdje pronaći i što biste željeli da prvo učine." + button_title: "Započnite s uređivanjem" + until: "Do:" admin_js: type_to_filter: "upiši za filtriranje" admin: @@ -4062,6 +4422,9 @@ hr: other: "%{count} korisnici imaju nove domene e-pošte i bit će dodani u grupu." automatic_membership_associated_groups: "Korisnici koji su članovi grupe na ovdje navedenoj usluzi automatski će se dodati u ovu grupu kada se prijave s uslugom." primary_group: "Automatski postavi kao primarnu grupu" + alert: + primary_group: "Budući da je ovo primarna grupa, naziv '%{group_name}' koristit će se u CSS klasama koje svatko može vidjeti." + flair_group: "Budući da ova grupa ima smisla za svoje članove, ime '%{group_name}' bit će vidljivo svima." name_placeholder: "Ime grupe, bez razmaka, ista pravila kao za korisničko ime" primary: "Primarna grupa" no_primary: "(nema primarne grupe)" @@ -4071,6 +4434,7 @@ hr: about: "Ovdje izmijenite svoje članstvo u grupama i imena grupa" group_members: "Članovi grupe" delete: "Obriši" + delete_confirm: "Jeste li sigurni da želite izbrisati ovu grupu?" delete_failed: "Nemoguće obrisati grupu. Ako je ovo automatska grupa, ne možete ju obrisati." delete_automatic_group: Ovo je automatska grupa i ne može se izbrisati. delete_owner_confirm: "Ukloniti vlasničku privilegiju za \"%{username}\"?" @@ -4136,7 +4500,6 @@ hr: topics: read: Pročitajte temu ili određeni post u njoj. RSS je također podržan. write: Napravite novu temu ili objavite postojeću. - update: Ažurirajte temu. Promijenite naslov, kategoriju, oznake itd. read_lists: Čitajte popise tema kao što su vrhunski, novi, najnoviji itd. RSS je također podržan. posts: edit: Uredite bilo koji post ili određeni. @@ -4155,6 +4518,9 @@ hr: anonymize: Anonimizirajte korisničke račune. delete: Izbrišite korisničke račune. list: Dobijte popis korisnika. + user_status: + read: Pročitaj status korisnika. + update: Ažurirajte korisnički status. email: receive_emails: Kombinirajte ovaj opseg s prijemnikom pošte za obradu dolaznih e-poruka. badges: @@ -4179,6 +4545,7 @@ hr: create: "Kreiraj" edit: "Uredi" save: "Spremi" + description_label: "Okidači događaja" controls: "Kontrole" go_back: "Povratak na popis" payload_url: "URL nosivosti" @@ -4280,6 +4647,10 @@ hr: change_settings_short: "Postavke" howto: "Kako instalirati dodatke?" official: "Službeni dodatak" + broken_route: "Nije moguće konfigurirati vezu na '%{name}'. Provjerite jesu li programi za blokiranje oglasa onemogućeni i pokušajte ponovno učitati stranicu." + navigation_menu: + sidebar: "Bočna traka" + legacy: "Naslijeđe" backups: title: "Sigurnosne kopije" menu: @@ -4465,6 +4836,8 @@ hr: import_web_advanced: "Napredna..." import_file_tip: ".tar.gz, .zip ili .dcstyle.json datoteka koja sadrži temu" is_private: "Tema je u privatnom git repozitoriju" + finish_install: "Završite instalaciju teme" + last_attempt: "Proces instalacije nije dovršen, posljednji pokušaj:" remote_branch: "Naziv podružnice (izborno)" public_key: "Omogućite pristup sljedećem javnom ključu repo:" install: "Instaliraj" @@ -4474,6 +4847,8 @@ hr: install_git_repo: "Iz git repozitorija" install_create: "Stvori novo" duplicate_remote_theme: "Komponenta teme “%{name}” je već instalirana, jeste li sigurni da želite instalirati još jednu kopiju?" + force_install: "Tema se ne može instalirati jer Git repozitorij nije dostupan. Jeste li sigurni da želite nastaviti s instaliranjem?" + create_placeholder: "Stvorite rezervirano mjesto" about_theme: "O nama" license: "Licenca" version: "Verzija:" @@ -4495,6 +4870,7 @@ hr: has_overwritten_history: "Trenutna verzija teme više ne postoji jer je povijest Gita prepisana prisilnim pritiskom." add: "Dodaj" theme_settings: "Postavke teme" + overriden_settings_explanation: "Nadjačane postavke označene su točkom i imaju istaknutu boju. Za resetiranje ovih postavki na zadanu vrijednost, pritisnite gumb za resetiranje pored njih." no_settings: "Ova tema nema postavke." theme_translations: "Tematski prijevodi" empty: "Nema stvari" @@ -4654,6 +5030,7 @@ hr: last_seen_user: "Posljednji viđeni korisnik:" no_result: "Nije pronađen rezultat za sažetak." reply_key: "Ključ odgovora" + post_link_with_smtp: "Post & SMTP Detalji" skipped_reason: "Preskoči razlog" incoming_emails: from_address: "Od" @@ -4683,6 +5060,7 @@ hr: address_placeholder: "ime@primjer.com" type_placeholder: "sažetak, prijava..." reply_key_placeholder: "ključ odgovora" + smtp_transaction_response_placeholder: "SMTP ID" moderation_history: performed_by: "Izvođeno od" no_results: "Povijest moderiranja nije dostupna." @@ -4866,6 +5244,7 @@ hr: one: "pokazati %{count} riječ" few: "Prikaži %{count} riječi" other: "Prikaži %{count} riječi" + case_sensitive: "(osjetljivo na velika i mala slova)" download: Preuzmi clear_all: Obriši sve clear_all_confirm: "Jeste li sigurni da želite očistiti sve promatrane riječi za %{action} akciju?" @@ -4903,6 +5282,8 @@ hr: exists: "Već postoji" upload: "Dodaj iz datoteke" upload_successful: "Prijenos uspješno. Riječi su dodane." + case_sensitivity_label: "Je li osjetljiv na velika i mala slova" + case_sensitivity_description: "Samo riječi s odgovarajućim malim slovima" test: button_label: "Test" modal_title: "%{action}: Testirajte gledane riječi" @@ -4956,6 +5337,7 @@ hr: user: suspend_failed: "Nešto je pošlo po krivu pri suspenziji ovog korisnika %{error}" unsuspend_failed: "Nešto je pošlo po krivu pri ukidanju suspenzije ovog korisnika %{error}" + suspend_duration: "Suspendirati korisnika do:" suspend_reason_label: "Zažto ga suspendiraš? Ovaj tekst će biti vidljiv svima na korisničkom profilu korisnika, i biti će prikazan korisniku kad se pokuša prijaviti. Neka je kratak." suspend_reason_hidden_label: "Zašto suspendiraš? Ovaj tekst će biti prikazan korisniku kada se pokuša prijaviti. Držite ga kratko." suspend_reason: "Razlog" @@ -4979,7 +5361,9 @@ hr: silence_message: "Pošaljite poruku emailom." silence_message_placeholder: "(ostavite praznim za slanje zadane poruke)" suspended_until: "(do %{until})" + suspend_forever: "Zaustavite zauvijek" cant_suspend: "Ovaj korisnik ne može biti suspendiran." + cant_silence: "Ovaj korisnik ne može biti ušutkan." delete_posts_failed: "Došlo je do problema pri brisanju postova." post_edits: "Post izmjene" view_edits: "Prikaz uređivanja" @@ -4989,6 +5373,8 @@ hr: penalty_post_edit: "Uredi post" penalty_post_none: "Ne radi ništa" penalty_count: "Broj kazni" + penalty_history_MF: >- + U posljednjih 6 mjeseci ovaj je korisnik suspendiran { SUSPENDED, plural, one {# vrijeme} few {# puta} other {# puta} } i ušutkan { SILENCED, plural, one {# vrijeme} few {# puta} other {# puta} }. clear_penalty_history: title: "Obriši povijest kazne" description: "korisnici s kaznama ne mogu doseći TL3" @@ -5002,6 +5388,7 @@ hr: suspended: "Suspendiran?" staged: "Priređen?" show_admin_profile: "Administrator" + manage_user: "Upravljanje korisnikom" show_public_profile: "Pokaži javni profil" impersonate: "Predstavi se kao" action_logs: "Dnevnici akcija" @@ -5102,6 +5489,8 @@ hr: one: "Ne možete obrisati sve objave jer korisnik ima više od %{count} objava. (briši_sve_objave_max)" few: "Ne možete obrisati sve objave jer korisnik ima više od %{count} objava. (briši_sve_objave_max)" other: "Ne možete obrisati sve objave jer korisnik ima više od %{count} objava. (briši_sve_objave_max)" + delete_confirm_title: "Jeste li SIGURNI da želite izbrisati ovog korisnika? Ovo je trajno!" + delete_confirm: "Općenito je bolje anonimizirati korisnike umjesto brisanja, kako bi se izbjeglo uklanjanje sadržaja iz postojećih rasprava." delete_and_block: "Obriši i block ovaj email i IP adresu" delete_dont_block: "Samo obriši" deleting_user: "Brisanje korisnika..." @@ -5137,15 +5526,21 @@ hr: trust_level_2_users: "Korisnici na razini povjerenja 2" trust_level_3_requirements: "Predispozicije za razinu povjerenja 3" trust_level_locked_tip: "razina povjerenja zaključana, sistem neće promovirati ili demotirati korisnika" + trust_level_unlocked_tip: "razina povjerenja odključana, sistem će promovirati ili demotirati korisnika" lock_trust_level: "Zaključaj razinu povjerenja" unlock_trust_level: "Odključaj razinu povjerenja" silenced_count: "Utišano" suspended_count: "Suspendirani" last_six_months: "Posljednjih 6 mjeseci" + other_matches: + one: "Postoji %{count} drugi korisnik s istom IP adresom. Pregledajte i odaberite sumnjive koje ćete kazniti zajedno s %{username}." + few: "Postoji %{count} drugi korisnik s istom IP adresom. Pregledajte i odaberite sumnjive koje ćete kazniti zajedno s %{username}." + other: "Postoji %{count} drugih korisnika s istom IP adresom. Pregledajte i odaberite sumnjive koje ćete kazniti zajedno s %{username}." other_matches_list: username: "Korisničko ime" trust_level: "Razina povjerenja" read_time: "Vrijeme čitanja" + topics_entered: "Unesene teme" posts: "Postovi" tl3_requirements: title: "Predispozicije za razinu povjerenja 3" @@ -5408,6 +5803,7 @@ hr: embedding: get_started: "Ako želite ugraditi Discourse na drugu web-stranicu, počnite dodavanjem njegovog hosta." confirm_delete: "Jeste li sigurni da želite izbrisati tog hosta?" + sample: "Zalijepite sljedeći HTML kod na svoju web-lokaciju kako biste stvorili i ugradili teme diskursa. Zamijenite ZAMJENI_ME kanonskim URL-om stranice na koju ga ugrađujete." title: "Ugrađivanje" host: "Dopušteni hostovi" class_name: "Naziv klase" @@ -5442,6 +5838,7 @@ hr: destination: "Odredište" copy_to_clipboard: "Kopirajte stalnu vezu u međuspremnik" delete_confirm: Jeste li sigurni da želite izbrisati ovu stalnu vezu? + no_permalinks: "Još nemate nijednu trajnu vezu. Napravite novu stalnu vezu iznad kako biste ovdje počeli vidjeti popis svojih stalnih veza." form: label: "Novo:" add: "Dodaj" @@ -5458,10 +5855,14 @@ hr: replace: "Zamijeni" wizard_js: wizard: + jump_in: "Uskoči!" + finish: "Završi postavljanje." back: "Natrag" next: "Sljedeći" + configure_more: "Konfiguriraj više..." step-text: "Korak" step: "%{current} od %{total}" + upload: "Učitaj datoteku" uploading: "Učitavanje..." upload_error: "Žao nam je, dogodila se greška pri učitavanju te datoteke. Molimo pokušajte ponovo." staff_count: diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index 165a1a7266..4e4a1d8cc3 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -2765,6 +2765,7 @@ hu: one: "Jelenleg %{count} kítűzött témája van. A túl sok kiemelt téma megzavarhatja az új vagy névtelen felhasználókat. Biztos, hogy kitűz egy újabb témát ebben a kategóriában?" other: "Jelenleg %{count} kítűzött témája van. A túl sok kiemelt téma megzavarhatja az új vagy névtelen felhasználókat. Biztos, hogy kitűz egy újabb témát ebben a kategóriában?" unpin_globally: "Távolítsa el ezt a témát az összes téma listájának tetejéről." + global_pin_note: "A felhasználók saját maguk számára feloldhatják a témát." not_pinned_globally: "Nincsenek globálisan rögzített témák." already_pinned_globally: one: "Jelenleg globálisan kitűzött témák: %{count}" @@ -2970,6 +2971,7 @@ hu: convert_to_moderator: "Stábszín hozzáadása" revert_to_regular: "Stábszín eltávolítása" rebake: "HTML újjáépítése" + publish_page: "Oldal közzététele" unhide: "Elrejtés visszavonása" change_owner: "Tulajdonjog módosítása..." grant_badge: "Jelvény adományozása..." @@ -3296,6 +3298,9 @@ hu: lower_title_with_count: one: "%{count} olvasatlan" other: "%{count} olvasatlan" + unseen: + title: "Nem látott" + lower_title: "nem látott" new: lower_title_with_count: one: "%{count} új" @@ -3491,6 +3496,9 @@ hu: manage_groups: "Címke csoport kezelése" upload: "Címkék feltöltése" upload_successful: "A címkék sikeresen feltöltve" + delete_unused_confirmation_more_tags: + one: "%{tags} és még %{count}" + other: "%{tags} és még %{count}" delete_no_unused_tags: "Nincsenek fel nem használt címkék." tag_list_joiner: ", " delete_unused: "A nem használt címkék törlése" @@ -3560,6 +3568,7 @@ hu: enabled: "A biztonságos mód be van kapcsolva, hogy kilépj a biztonságos módból lépj ki ebből a keresési lapból" image_removed: "(kép eltávolítva)" pause_notifications: + label: "Értesítések szüneteltetése" remaining: "%{remaining} van hátra" options: half_hour: "30 percig" @@ -3880,7 +3889,7 @@ hu: topics: read: Olvasson el egy témát vagy egy adott hozzászólást. Az RSS is támogatva van. write: Hozzon létre új témát, vagy írjon egy meglévő témához. - update: Téma frissítése. A cím, kategória, címkék stb. módosítása. + update: Téma frissítése. Módosíthatja a címet, kategóriát, címkéket, státuszt, archetípust, featured_linket stb. read_lists: Témalisták olvasása, mint a top, új, legújabb, stb. Az RSS is támogatva van. posts: edit: Bármelyik hozzászólás vagy egy adott hozzászólás szerkesztése. @@ -4463,6 +4472,7 @@ hu: censor: "Cenzúra" require_approval: "Jóváhagyást igényel" flag: "Jelölés" + replace: "Csere" silence: "Elnémítás" link: "Hivatkozás" action_descriptions: @@ -4725,7 +4735,9 @@ hu: dashboard: "Vezérlőpult" navigation: "Navigáció" default_categories: + modal_description: "Szeretné visszamenőleg alkalmazni ezt a változtatást? Ez %{count} felhasználó beállítását fogja módosítani." modal_yes: "Igen" + modal_no: "Nem, csak mostantól alkalmazza a változtatást" badges: title: Jelvények new_badge: Új jelvény @@ -4826,6 +4838,7 @@ hu: action: label: "Szöveg cseréje…" modal: + title: "Szöveg cseréje" categories: "Kategóriák" topics: "Témák" replace: "Csere" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index c283adcaa5..5420d02cdf 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -1026,6 +1026,7 @@ hy: primary: "Հիմնական Էլ. հասցե" secondary: "Երկրորդական Էլ. հասցեներ" primary_label: "հիմնական" + resent_label: "էլ. նամակն ուղարկված է" update_email: "Փոփոխել Էլ. Հասցեն" no_secondary: "Երկրորդական էլ. հասցեներ չկան" instructions: "Երբեք չի ցուցադրվում հանրությանը" @@ -1812,9 +1813,11 @@ hy: dismiss_new: "Չեղարկել Նորերը" toggle: "փոխանջատել թեմաների զանգվածային ընտրությունը" actions: "Զանգվածային Գործողությունները" + change_category: "Ավելացնել Կատեգորիա..." close_topics: "Փակել Թեմաները" archive_topics: "Արխիվացնել Թեմաները" move_messages_to_inbox: "Տեղափոխել Մուտքերի արկղ" + notification_level: "Ծանուցումներ..." choose_new_category: "Ընտրել նոր կատեգորիա թեմաների համար՝" selected: one: "Դուք ընտրել եք %{count} թեմա:" @@ -2001,6 +2004,7 @@ hy: unarchive: "Ապարխիվացնել Թեման" archive: "Արխիվացնել Թեման" reset_read: "Զրոյացնել Կարդացած Տվյալները" + make_public: "Ստեղծել Հրապարակային Թեմա..." make_private: "Ստեղծել Անձնական Նամակ" reset_bump_date: "Վերահաստատել Բարձրացման Ամսաթիվը" feature: @@ -2254,6 +2258,8 @@ hy: rebake: "Վերակառուցել HTML-ը" publish_page: "Էջի Հրատարակում" unhide: "Դարձնել Տեսանելի" + change_owner: "Փոխել Սեփականատիրոջը..." + grant_badge: "Շնորհել Կրծքանշան..." lock_post: "Արգելափակել Գրառումը" lock_post_description: "արգելել հրապարակողին խմբագրել այս գրառումը" unlock_post: "Արգելաբացել Գրառումը" @@ -2261,6 +2267,7 @@ hy: delete_topic_disallowed_modal: "Դուք թույլտվություն չունեք ջնջելու այս թեման: Եթե Դուք իսկապես ցանկանում եք, որ այն ջնջվի, դրոշակավորեք այն պատճառաբանության հետ միասին՝ մոդերատորի ուշադրությանը գրավելու համար:" delete_topic_disallowed: "Դուք թույլտվություն չունեք ջնջելու այս թեման" delete_topic: "ջնջել թեման" + add_post_notice: "Հաղորդագրություն Մոդերատորներից..." remove_timer: "հեռացնել ժամաչափիչը" actions: people: @@ -2454,6 +2461,7 @@ hy: flagging: title: "Շնորհակալ ենք, որ օգնում եք պահել մեր համայնքը քաղաքակիրթ:" action: "Դրոշակավորել Գրառումը" + take_action: "Ձեռնարկել Գործողություն..." take_action_options: default: title: "Ձեռնարկել Գործողություն" @@ -2826,6 +2834,7 @@ hy: delete: "Ջնջել" confirm_delete: "Դուք համոզվա՞ծ եք, որ ցանկանում եք ջնջել այս թեգի խումբը:" everyone_can_use: "Թեգերը կարող են օգտագործվել բոլորի կողմից:" + parent_tag_placeholder: "Ընտրովի" topics: none: unread: "Դուք չունեք չկարդացած թեմաներ:" @@ -2894,6 +2903,8 @@ hy: content: "Ադմին" badges: content: "Կրծքանշաններ" + everything: + content: "Բոլորը" faq: content: "ՀՏՀ" groups: @@ -2933,6 +2944,7 @@ hy: problems_found: "Որոշ խորհուրդներ՝ հիմնված Ձեր կայքի ընթացիկ կարգավորումների վրա" new_features: dismiss: "Չեղարկել" + learn_more: "Իմանալ ավելին" last_checked: "Վերջին ստուգումը՝ " refresh_problems: "Թարմացնել" no_problems: "Խնդիրներ չեն գտնվել:" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 55f0067e5c..13893df7de 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -1058,6 +1058,8 @@ id: title: "Surel" primary: "Email Utama" secondary: "Email Sekunder" + primary_label: "utama" + resent_label: "surel terkirim" update_email: "Ganti Alamat Surel" no_secondary: "Tidak ada email sekunder" instructions: "Jangan pernah tunjukkan ke publik." @@ -1173,9 +1175,13 @@ id: time_read: "Waktu Baca" days_visited: "Hari Berkunjung" account_age_days: "Umur akun dalam hari" + create: "Undang" valid_for: "Tautan undangan hanya berlaku untuk alamat surel: %{email}" invite_link: success: "Tautan undangan telah sukses dibuat!" + invite: + show_advanced: "Tampilkan Opsi Lanjutan" + hide_advanced: "Sembunyikan Opsi Lanjutan" bulk_invite: error: "Maaf, file harus dalam format CSV" password: @@ -1502,6 +1508,7 @@ id: title: "Cari" full_page_title: "Cari" results: "hasil" + post_format: "#%{post_number} oleh %{username}" search_google_button: "Google" search_button: "Cari" categories: "Kategori" @@ -1528,6 +1535,7 @@ id: select_all: "Pilih Semua" dismiss: "Bubar" move_messages_to_inbox: "Pindah ke Kotak Masuk" + notification_level: "Pemberitahuan..." choose_new_tags: "Silahkan pilih tag baru untuk topik-topik ini:" none: new: "Anda tidak memiliki topik baru" @@ -1561,8 +1569,10 @@ id: remove: "Nonaktifkan" topic_status_update: when: "Ketika:" + time_frame_required: "Silakan pilih kerangka waktu" duration: "Durasi" progress: + jump_prompt_to_date: "sampai saat ini" jump_prompt_or: "atau" notifications: reasons: @@ -1595,6 +1605,7 @@ id: title: "Balas" share: help: "share link ke topik ini" + invite_users: "Undang" invite_private: group_name: "nama grup" invite_reply: @@ -1633,9 +1644,17 @@ id: controls: first: "Revisi pertama" bookmarks: + create: "Buat penanda" + edit: "Sunting penanda" name: "Nama" options: "Pilihan" + actions: + delete_bookmark: + name: "Hapus penanda" + edit_bookmark: + name: "Sunting penanda" category: + all: "Semua kategori" edit: "Ubah" settings: "Pengaturan" tags: "Label" @@ -1661,6 +1680,7 @@ id: options: normal: "normal" ignore: "Abaikan" + low: "Rendah" high: "Tinggi" sort_options: default: "asal" @@ -1677,6 +1697,8 @@ id: notify_action: "Pesan" post_links: about: "perluas tautan untuk artikel ini" + title: + other: "%{count} lainnya" topic_statuses: warning: help: "Ini adalah peringatan resmi." @@ -1741,6 +1763,8 @@ id: actions: title: "Aksi" badges: + more_badges: + other: "+%{count} lainnya" none: "(kosong)" badge_grouping: trust_level: @@ -1774,6 +1798,7 @@ id: name_placeholder: "Nama" save: "Simpan" delete: "Hapus" + parent_tag_placeholder: "Opsional" topics: none: new: "Anda tidak memiliki topik baru" @@ -1790,6 +1815,7 @@ id: new_count: other: "%{count} baru" more: "Selengkapnya" + all_categories: "Semua kategori" sections: about: header_link_text: "Tentang" @@ -1812,6 +1838,8 @@ id: content: "Tentang" admin: content: "Admin" + everything: + content: "Semuanya" faq: content: "FAQ" groups: @@ -1820,6 +1848,7 @@ id: content: "Pengguna" review: content: "Ulasan" + until: "Sampai:" admin_js: type_to_filter: "ketik untuk memfilter..." admin: @@ -1838,6 +1867,7 @@ id: moderators: "Moderator:" private_messages_title: "Pesan" report_filter_any: "apa saja" + disabled: Dinonaktifkan filter_reports: Filter laporan reports: last_7_days: "7 terakhir" @@ -1872,6 +1902,7 @@ id: api: user: "Pengguna" created: Dibuat + never_used: (tidak pernah) revoke: "Cabut" show_details: Detil save: Simpan @@ -1884,6 +1915,7 @@ id: active: "Aktif" delivery_status: failed: "Gagal" + disabled: "Dinonaktifkan" events: request: "Pinta" body: "Konten" @@ -1964,6 +1996,9 @@ id: filters: title: "Filter" user_placeholder: "nama pengguna" + moderation_history: + actions: + delete_topic: "Topik Dihapus" logs: title: "Log" action: "Aksi" @@ -2043,6 +2078,8 @@ id: confirmation: cancel: "Batal" delete_and_block: "Hapus dan block email dan alamat IP ini" + reset_bounce_score: + label: "Reset" other_matches_list: username: "Nama Pengguna" trust_level: "Level Kepercayaan" @@ -2068,6 +2105,7 @@ id: site_text: edit: "ubah" settings: + reset: "reset" none: "Tak ada" site_settings: title: "Pengaturan" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 3b169081ee..0f1c41faed 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -3496,7 +3496,6 @@ it: this_week: "Settimana" today: "Oggi" browser_update: 'Purtroppo, il tuo browser non è supportato. Per visualizzare i contenuti multimediali, passa a un browser supportato, accedi e rispondi.' - safari_13_warning: Questo sito a breve non supporterà più iOS e Safari nelle versioni 13 e precedenti. Rimarrà disponibile una versione semplificata a sola lettura. (Altre informazioni) permission_types: full: "Crea / Rispondi / Visualizza" create_post: "Rispondi / Visualizza" @@ -4118,7 +4117,6 @@ it: topics: read: Leggi un argomento o un messaggio specifico in esso. È supportato anche RSS. write: Crea un nuovo argomento o pubblica un messaggio su uno esistente. - update: Aggiorna un argomento. Modifica il titolo, la categoria, le etichette, ecc. read_lists: Leggi liste di argomenti come Popolari, Nuovi, Recenti, ecc. E' supportato anche l'RSS. posts: edit: Modifica qualsiasi messaggio o uno specifico. diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index 456811fa38..c99c08abd2 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -3331,7 +3331,6 @@ ja: this_week: "今週" today: "今日" browser_update: '残念ながら、あなたのブラウザはサポートされていません。リッチコンテンツを表示するにはサポートされているブラウザに切り替えてから、ログインして返信してください。' - safari_13_warning: このサイトは、まもなく iOS と Safari バージョン 13 以下のサポートを終了します。簡易版の読み取り専用バージョンは、引き続き利用可能です。(詳細) permission_types: full: "作成 / 返信 / 閲覧" create_post: "返信 / 閲覧" @@ -3935,7 +3934,6 @@ ja: topics: read: トピックまたはその中の特定の投稿を読み取ります。RSS もサポートされています。 write: 新しいトピックまたは既存のトピックに投稿を作成します。 - update: トピックを更新します。タイトル、カテゴリ、タグなどを変更します。 read_lists: 人気、新規、最新などのトピックリストを読み取ります。RSS もサポートされています。 posts: edit: 任意の投稿または特定の投稿を編集します。 diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index 696db1a595..9532b58f0b 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -1798,6 +1798,7 @@ ko: categories_and_top_topics: "카테고리 및 주요 글" categories_boxes: "서브카테고리가 있는 박스" categories_boxes_with_topics: "추천 주제의 박스" + subcategories_with_featured_topics: "추천 글이 포함된 하위 카테고리" shortcut_modifier_key: shift: "Shift" ctrl: "Ctrl" @@ -3178,6 +3179,9 @@ ko: help: "읽지 않은 게시물을 포함한 현재 구독 또는 추적 중인 주제" lower_title_with_count: other: "%{count} unread" + unseen: + title: "읽지 않음" + lower_title: "읽지 않음" new: lower_title_with_count: other: "%{count} new" @@ -3219,7 +3223,6 @@ ko: this_week: "주" today: "오늘" browser_update: '브라우저 버전이 너무 낮아 이 사이트에서 작동하지 않습니다. 브라우저를 업그레이드하여 서식 있는 콘텐츠를 표시하고 로그인하여 댓글도 달아보세요.' - safari_13_warning: 이 사이트는 곧 iOS 및 Safari 버전 13 이하에 대한 지원을 중단할 예정입니다. 단순화된 읽기 전용 버전은 계속 사용할 수 있습니다. (더 보기) permission_types: full: "생성 / 댓글 / 보기" create_post: "댓글 / 보기" @@ -3559,6 +3562,7 @@ ko: content: "카테고리 추가" click_to_get_started: "시작하려면 여기를 클릭하십시오." header_link_text: "카테고리" + configure_defaults: "기본값 구성" community: header_link_text: "커뮤니티" links: @@ -3586,6 +3590,7 @@ ko: welcome_topic_banner: title: "환영 글 만들기" button_title: "편집 시작" + until: "까지:" admin_js: type_to_filter: "필터링하려면 입력..." admin: diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index f80713d935..cbd2870051 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -114,6 +114,11 @@ lt: few: "%{count} dienos" many: "%{count} dienų" other: "%{count} dienų" + x_months: + one: "%{count} mėnuo" + few: "%{count} mėnesių" + many: "%{count} mėnesių" + other: "%{count} mėnesių" date_year: "YYYY-MM-D" medium_with_ago: x_minutes: @@ -369,6 +374,21 @@ lt: confirm: "Jūs turite nebaigtą juodraštį šiai tema. Ką norėtumėte daryti su juo?" yes_value: "Išmesti" no_value: "Tęsti redagavimą" + topic_count_categories: + one: "Peržiūrėti %{count} naują arba atnaujintą temą" + few: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + many: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + other: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + topic_count_latest: + one: "Peržiūrėti %{count} naują arba atnaujintą temą" + few: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + many: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + other: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + topic_count_unseen: + one: "Peržiūrėti %{count} naują arba atnaujintą temą" + few: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + many: "Peržiūrėkite %{count} naujų ar atnaujintų temų" + other: "Peržiūrėkite %{count} naujų ar atnaujintų temų" topic_count_new: one: "Pamatyk %{count} naują temą" few: "Pamatyk %{count} naujas temas" @@ -449,6 +469,11 @@ lt: grouped_by_topic: "Grupuojama pagal temą" none: "Nėra elementų, kuriuos reikia peržiūrėti." view_pending: "laukiama peržiūros" + topic_has_pending: + one: "Šioje temoje yra %{count} pranešimas, laukiantis patvirtinimo" + few: "Šioje temoje yra %{count} pranešimų, laukiančių patvirtinimo" + many: "Šioje temoje yra %{count} pranešimų, laukiančių patvirtinimo" + other: "Šioje temoje yra %{count} pranešimų, laukiančių patvirtinimo" title: "Peržiūra" topic: "Tema:" filtered_topic: "Išfiltravote turinį, kurį galima peržiūrėti vienoje temoje." @@ -465,6 +490,22 @@ lt: name: "Vardas" fields: "Laukai" reject_reason: "Priežastis" + user_percentage: + agreed: + one: "%{count}% sutinka" + few: "%{count}% sutinka" + many: "%{count}% sutinka" + other: "%{count}% sutinka" + disagreed: + one: "%{count}% nesutinka" + few: "%{count}% nesutinka" + many: "%{count}% nesutinka" + other: "%{count}% nesutinka" + ignored: + one: "%{count}% ignoruoja" + few: "%{count}% ignoruoja" + many: "%{count}% ignoruoja" + other: "%{count}% ignoruoja" topics: topic: "Tema" reviewable_count: "Skaičiuoti" @@ -552,6 +593,16 @@ lt: title: "Kodėl atmetate šį vartotoją?" send_email: "Siųsti atmetimo laišką" relative_time_picker: + minutes: + one: "minutė" + few: "minutės" + many: "minutės" + other: "minutės" + hours: + one: "valanda" + few: "valandos" + many: "valandos" + other: "valandos" days: one: "diena" few: "dienos" @@ -903,9 +954,19 @@ lt: few: "%{count} temos" many: "%{count}temų" other: "%{count}temos" + topic_stat: + one: "%{number} / %{unit}" + few: "%{number} / %{unit}" + many: "%{number} / %{unit}" + other: "%{number} / %{unit}" topic_stat_unit: week: "savaitė" month: "mėnesis" + topic_stat_all_time: + one: "Iš viso %{number}" + few: "Iš viso %{number}" + many: "Iš viso %{number}" + other: "Iš viso %{number}" n_more: "Kategorijos (dar %{count} ) ..." ip_lookup: title: IP adreso peržiųra @@ -1492,6 +1553,16 @@ lt: time_read_title: "%{duration} (visas laikas)" recent_time_read: "paskutinis skaitymo laikas" recent_time_read_title: "%{duration} (per paskutines 60 dienų)" + topic_count: + one: "sukurta tema" + few: "sukurtos temos" + many: "sukurtos temos" + other: "sukurtos temos" + post_count: + one: "įrašas sukurtas" + few: "įrašai sukurti" + many: "įrašai sukurti" + other: "įrašai sukurti" likes_given: one: "duota" few: "duota" @@ -1502,6 +1573,16 @@ lt: few: "gauta" many: "gauta" other: "gauta" + days_visited: + one: "aplankyta diena" + few: "apsilankymo diena" + many: "apsilankymo dienų" + other: "apsilankymo dienų" + topics_entered: + one: "tema peržiūrėta" + few: "peržiūrėtos temos" + many: "peržiūrėtos temos" + other: "peržiūrėtos temos" bookmark_count: one: "žymės" few: "žymės" @@ -1722,6 +1803,7 @@ lt: twitter: name: "Twitter" title: "Prisijunkite naudodami “Twitter”" + sr_title: "Prisijunkite naudodami “Twitter”" instagram: name: "Instagram" title: "Prisijunkite naudodami “Instagram”" @@ -1778,12 +1860,27 @@ lt: few: "%{count} temos šioje kategorijoje" many: "%{count} temų šioje kategorijoje" other: "%{count} temos šioje kategorijoje" + plus_subcategories_title: + one: "%{name} ir viena subkategorija" + few: "%{name} ir %{count} subkategorijos" + many: "%{name} ir %{count} subkategorijos" + other: "%{name} ir %{count} subkategorijos" + plus_subcategories: + one: "+ %{count} subkategorija" + few: "+ %{count} subkategorijos" + many: "+ %{count} subkategorijos" + other: "+ %{count} subkategorijos" select_kit: delete_item: "Ištrinti %{name}" filter_by: "Filtruoti pagal: %{name}" select_to_filter: "Pasirinkite vertę, kurią norite filtruoti" default_header_text: Pasirinkti... no_content: Atitikmenų nerasta + results_count: + one: "%{count} rezultatas" + few: "%{count} rezultatai" + many: "%{count} rezultatai" + other: "%{count} rezultatai" filter_placeholder: Paieška... filter_placeholder_with_any: Ieškoti arba sukurti... create: "Sukurti: '%{content}'" @@ -1845,6 +1942,11 @@ lt: similar_topics: "Jūsų tema panaši į..." drafts_offline: "juodraščiai ne ryšio zonoje" edit_conflict: "redaguoti konfliktą" + group_mentioned: + one: "Paminėdami %{group}, jūs ketinate pranešti %{count} asmeniui - ar esate tikri?" + few: "Minėdami %{group}, jūs ketinate pranešti %{count} žmonėms - ar esate tikri?" + many: "Minėdami %{group}, jūs ketinate pranešti %{count} žmonėms - ar esate tikri?" + other: "Minėdami %{group}, jūs ketinate pranešti %{count} žmonėms - ar esate tikri?" cannot_see_mention: category: "Paminėjote @%{username} , tačiau jiems nebus pranešta, nes jie neturi prieigos prie šios kategorijos. Turėsite juos įtraukti į grupę, kuri turi prieigą prie šios kategorijos." private: "Paminėjote @%{username} , tačiau jiems nebus pranešta, nes jie negali matyti šio asmeninio pranešimo. Turėsite juos pakviesti į šią asmeninę žinutę." @@ -1957,6 +2059,11 @@ lt: few: "%{count}neskaitytos žinutės" many: "%{count}neskaitytų žinučių" other: "%{count}neskaitytos žinutės" + high_priority: + one: "%{count} neskaitytas aukšto prioriteto pranešimas" + few: "%{count} neskaitytų aukšto prioriteto pranešimų" + many: "%{count} neskaitytų aukšto prioriteto pranešimų" + other: "%{count} neskaitytų aukšto prioriteto pranešimų" title: "pranešimai kai paminimas @name , atsakomi tavo įrašai, temos, žinutės ir pan." none: "Šiuo metu neįmanoma pakrauti pranešimų." empty: "Pranešimų nėra" @@ -1984,6 +2091,11 @@ lt: few: "%{username}, %{username2}ir %{count} kiti" many: "%{username}, %{username2} ir %{count}kitų" other: "%{username}, %{username2} ir %{count}kitų" + liked_consolidated_description: + one: "patiko %{count} jūsų įrašų" + few: "patiko %{count} jūsų įrašų" + many: "patiko %{count} jūsų įrašų" + other: "patiko %{count} jūsų įrašų" liked_consolidated: "%{username} %{description}" private_message: "%{username} %{description}" invited_to_private_message: "

    %{username} %{description}" @@ -2116,6 +2228,7 @@ lt: tips: category_tag: "filtrai pagal kategoriją ar žymą" author: "filtruoti pagal įrašo autorių" + in: "filtruoti pagal metaduomenis (pvz. pavadinime)" status: "filtruoti pagal temos būseną" full_search: "paleidžiama viso puslapio paieška" full_search_key: "%{modifier} + Įveskite" @@ -2205,10 +2318,25 @@ lt: delete: "Ištrinti temas" dismiss: "Praleisti" dismiss_read: "Praleisti visas neperskaitytas" + dismiss_read_with_selected: + one: "Atmesti %{count} neskaitytą" + few: "Atmesti %{count} neskaitytų" + many: "Atmesti %{count} neskaitytų" + other: "Atmesti %{count} neskaitytų" dismiss_button: "Praleisti..." + dismiss_button_with_selected: + one: "Atsisakyti (%{count})…" + few: "Atsisakyti (%{count})…" + many: "Atsisakyti (%{count})…" + other: "Atsisakyti (%{count})…" dismiss_tooltip: "Praleisti tik naujus įrašus ar nebesekti temos" also_dismiss_topics: "Nebesekti šių temų, kad jos niekada nebūtų rodomos, kaip neperskaitytos" dismiss_new: "Praleisti Naujas" + dismiss_new_with_selected: + one: "Atsisakyti naujo (%{count})" + few: "Atsisakyti naujo (%{count})" + many: "Atsisakyti naujo (%{count})" + other: "Atsisakyti naujo (%{count})" toggle: "perjungti temų pasirinkimus" actions: "Veiksmai" change_category: "Nustatyti kategoriją..." @@ -2229,6 +2357,16 @@ lt: choose_append_tags: "Pasirinkite naujas žymas, kurias norite pridėti šioms temoms:" changed_tags: "Šių temų žymos buvo pakeistos." remove_tags: "Pašalinti visas žymes" + confirm_remove_tags: + one: "Visos žymos bus pašalintos iš šios temos. Ar esate tikras?" + few: "Visos žymės bus pašalintos iš %{count} temų. Ar esate tikras?" + many: "Visos žymės bus pašalintos iš %{count} temų. Ar esate tikras?" + other: "Visos žymės bus pašalintos iš %{count} temų. Ar esate tikras?" + progress: + one: "Pažanga: %{count} tema" + few: "Pažanga: %{count} temos" + many: "Pažanga: %{count} temos" + other: "Pažanga: %{count} temos" none: unread: "Jūs neturite neperskaitytų temų." unseen: "Jūs neturite nematytų temų." @@ -2383,6 +2521,16 @@ lt: auto_reminder: "Jums bus priminta apie šią temą %{timeLeft}." auto_delete_replies: "Atsakymai šia tema automatiškai ištrinami po %{duration}." auto_close_title: "Automatinio uždarymo nustatymai" + auto_close_immediate: + one: "Paskutinis įrašas temoje jau yra %{count} valandų, todėl tema bus nedelsiant uždaryta." + few: "Paskutinis įrašas temoje jau yra %{count} valandų, todėl tema bus nedelsiant uždaryta." + many: "Paskutinis įrašas temoje jau yra %{count} valandų, todėl tema bus nedelsiant uždaryta." + other: "Paskutinis įrašas temoje jau yra %{count} valandų, todėl tema bus nedelsiant uždaryta." + auto_close_momentarily: + one: "Paskutinis įrašas temoje jau yra %{count} valandų, todėl tema bus akimirksniu uždaryta." + few: "Paskutiniai įrašai temoje jau yra %{count} valandų, todėl temos bus akimirksniu uždaryta." + many: "Paskutiniai įrašai temoje jau yra %{count} valandų, todėl temos bus akimirksniu uždaryta." + other: "Paskutiniai įrašai temoje jau yra %{count} valandų, todėl temos bus akimirksniu uždaryta." timeline: back: "Atgal" back_description: "Grįžkite prie paskutinio neskaityto įrašo" @@ -2470,6 +2618,11 @@ lt: help: "Pasidalink šios temos nuoroda" instructions: "Dalintis nuoroda šioje temoje:" copied: "Temos nuoroda nukopijuota." + restricted_groups: + one: "Matoma tik grupės nariams: %{groupNames}" + few: "Matoma tik grupių nariams: %{groupNames}" + many: "Matoma tik grupių nariams: %{groupNames}" + other: "Matoma tik grupių nariams: %{groupNames}" invite_users: "Kviesti" print: title: "Spausdinti" @@ -2580,6 +2733,11 @@ lt: action: "pereiti prie esamo pranešimo" radio_label: "Esama žinutė" participants: "Dalyviai" + instructions: + one: "Pasirinkite pranešimą, į kurį norite perkelti tą įrašą." + few: "Pasirinkite pranešimą, į kurį norite perkelti tuos %{count} įrašus." + many: "Pasirinkite pranešimą, į kurį norite perkelti tuos %{count} įrašus." + other: "Pasirinkite pranešimą, į kurį norite perkelti tuos %{count} įrašus." merge_posts: title: "Sulieti pasirinktus įrašus" action: "sulieti pasirinktus įrašus" @@ -2602,6 +2760,11 @@ lt: action: "pakeisti valdymo teises" error: "Įvyko klaida keičiant įrašų valdymo teisę." placeholder: "naujojo valdytojo vartotojo vardas" + instructions_without_old_user: + one: "Pasirinkite naują įrašo savininką" + few: "Prašome pasirinkti naujų %{count} įrašų savininką" + many: "Prašome pasirinkti naujų %{count} įrašų savininką" + other: "Prašome pasirinkti naujų %{count} įrašų savininką" change_timestamp: action: "pakeisti laiko formatą" invalid_timestamp: "Laiko formatas negali būti ateityje." @@ -2685,6 +2848,16 @@ lt: few: "%{count} asmenims patiko šis pranešimas. Spustelėkite, jei norite peržiūrėti" many: "%{count} žmonių patiko šis įrašas. Spustelėkite norėdami peržiūrėti" other: "%{count} žmonėms patiko šis pranešimas. Spustelėkite, jei norite peržiūrėti" + filtered_replies_hint: + one: "Peržiūrėkite šį įrašą ir jo atsakymą" + few: "Peržiūrėkite šį įrašą ir jo %{count} atsakymus" + many: "Peržiūrėkite šį įrašą ir jo %{count} atsakymus" + other: "Peržiūrėkite šį įrašą ir jo %{count} atsakymus" + filtered_replies_viewing: + one: "Peržiūrėti %{count} atsakymą į" + few: "Peržiūrėti %{count} atsakymus į" + many: "Peržiūrėti %{count} atsakymus į" + other: "Peržiūrėti %{count} atsakymus į" in_reply_to: "Įkelti pirminį įrašą" view_all_posts: "Peržiūrėti visus įrašus" errors: @@ -2694,6 +2867,11 @@ lt: file_too_large: "Deja, failas per didelis (maksimalus dydis yra %{max_size_kb}kb). Kodėl neįkėlus didelio failo į bendrinimo debesyje paslaugą ir tuomet įklijuokite nuorodą?" file_too_large_humanized: "Deja, failas per didelis (maksimalus dydis yra %{max_size}kb). Kodėl neįkėlus didelio failo į bendrinimo debesyje paslaugą ir tuomet įklijuokite nuorodą?" too_many_uploads: "Atsiprašome, bet jūs galite įkelti tik vieną failą vienu metu." + too_many_dragged_and_dropped_files: + one: "Atsiprašome, galite įkelti tik %{count} failą vienu metu." + few: "Atsiprašome, galite įkelti tik %{count} failus vienu metu." + many: "Atsiprašome, galite įkelti tik %{count} failus vienu metu." + other: "Atsiprašome, galite įkelti tik %{count} failus vienu metu." upload_not_authorized: "Atsiprašome, failas, kurį bandote įkelti, nėra autorizuotas (įgalioti plėtiniai: %{authorized_extensions})." image_upload_not_allowed_for_new_user: "Atsiprašome, bet nauji vartotojai negali įkelti nuotraukų." attachment_upload_not_allowed_for_new_user: "Atsiprašome, bet nauji vartotojai negali įkelti priedų." @@ -2754,6 +2932,11 @@ lt: unlock_post_description: "leisti paskelbusiam asmeniui redaguoti šį įrašą" delete_topic_disallowed_modal: "Neturite leidimo ištrinti šios temos. Jei tikrai norite, kad jis būtų ištrintas, kartu su argumentais pateikite vėliavą moderatoriui." delete_topic_disallowed: "neturite leidimo ištrinti šios temos" + delete_topic_confirm_modal: + one: "Ši tema šiuo metu turi daugiau nei %{count} peržiūrą ir gali būti populiari paieškos vieta. Ar tikrai norite visiškai ištrinti šią temą, o ne ją redaguoti, kad patobulintumėte?" + few: "Ši tema šiuo metu turi daugiau nei %{count} peržiūrų ir gali būti populiari paieškos vieta. Ar tikrai norite visiškai ištrinti šią temą, o ne ją redaguoti, kad patobulintumėte?" + many: "Ši tema šiuo metu turi daugiau nei %{count} peržiūrų ir gali būti populiari paieškos vieta. Ar tikrai norite visiškai ištrinti šią temą, o ne ją redaguoti, kad patobulintumėte?" + other: "Ši tema šiuo metu turi daugiau nei %{count} peržiūrų ir gali būti populiari paieškos vieta. Ar tikrai norite visiškai ištrinti šią temą, o ne ją redaguoti, kad patobulintumėte?" delete_topic_confirm_modal_yes: "Taip, ištrinti šią temą" delete_topic_confirm_modal_no: "Ne, palikti šią temą" delete_topic_error: "Ištrinant šią temą įvyko klaida" @@ -2770,11 +2953,21 @@ lt: few: "mėgstate" many: "mėgstate" other: "mėgstate" + read: + one: "skaityti" + few: "skaityti" + many: "skaityti" + other: "skaityti" like_capped: one: "ir %{count}kitas mėgsta tai" few: "ir %{count}kitų mėgsta tai" many: "ir %{count}kitų mėgsta tai " other: "ir %{count} kitų mėgsta tai" + read_capped: + one: "ir %{count} kiti tai skaito" + few: "ir %{count} kiti tai skaito" + many: "ir %{count} kiti tai skaito" + other: "ir %{count} kiti tai skaito" sr_post_likers_list_description: "vartotojai, kuriems patiko šis pranešimas" sr_post_readers_list_description: "vartotojai, kurie perskaitė šį pranešimą" by_you: @@ -3022,6 +3215,11 @@ lt: few: "liko %{count}..." many: "liko %{count}..." other: "liko %{count}..." + left: + one: "Liko %{count}" + few: "Liko %{count}" + many: "Liko %{count}" + other: "Liko %{count}" flagging_topic: title: "Ačiū, kad padedi padedi išlaikyti forumą civilizuotu!" action: "Temos su vėliavomis" @@ -3340,19 +3538,39 @@ lt: changed: "žymės pakeistos:" tags: "Žymos" choose_for_topic: "pasirenkamos žymos" + choose_for_topic_required: + one: "pasirinkite bent %{count} žymę..." + few: "pasirinkite bent %{count} žymų..." + many: "pasirinkite bent %{count} žymų..." + other: "pasirinkite bent %{count} žymų..." info: "Informacija" default_info: "Ši žyma neapsiriboja jokiomis kategorijomis ir neturi sinonimų." staff_info: "Norėdami pridėti apribojimų, įtraukite šią žymą į žymų grupę." category_restricted: "Ši žyma skirta tik kategorijoms, prie kurių neturite prieigos teisės." synonyms: "Sinonimai" save: "Išsaugokite žymos pavadinimą ir aprašymą" + category_restrictions: + one: "Tai gali būti naudojama tik šioje kategorijoje:" + few: "Tai gali būti naudojama tik šiose kategorijose:" + many: "Tai gali būti naudojama tik šiose kategorijose:" + other: "Tai gali būti naudojama tik šiose kategorijose:" edit_synonyms: "Redaguoti sinonimus" add_synonyms_label: "Pridėti sinonimus:" add_synonyms: "Pridėti" remove_synonym: "Pašalinti sinonimą" delete_synonym_confirm: 'Ar tikrai norite ištrinti sinonimą "%{tag_name}“?' delete_tag: "Ištrinti žymą" + delete_confirm: + one: "Ar tikrai norite ištrinti šią žymą ir pašalinti ją iš %{count} temos, kuriai ji priskirta?" + few: "Ar tikrai norite ištrinti šią žymą ir pašalinti ją iš %{count} temų, kurioms ji priskirta?" + many: "Ar tikrai norite ištrinti šią žymą ir pašalinti ją iš %{count} temų, kurioms ji priskirta?" + other: "Ar tikrai norite ištrinti šią žymą ir pašalinti ją iš %{count} temų, kurioms ji priskirta?" delete_confirm_no_topics: "Ar tikrai norite ištrinti šią žymą?" + delete_confirm_synonyms: + one: "Jo sinonimas taip pat bus ištrintas." + few: "%{count} sinonimai taip pat bus ištrinti." + many: "%{count} sinonimai taip pat bus ištrinti." + other: "%{count} sinonimai taip pat bus ištrinti." edit_tag: "Redaguoti žymos pavadinimą ir aprašymą" description: "Aprašymas" sort_by: "Rūšiuoti pagal:" @@ -3363,6 +3581,11 @@ lt: upload: "Įkelti žymas" upload_description: "Įkelkite csv failą, kad sukurtumėte masines žymas" upload_successful: "Žymos sėkmingai įkeltos" + delete_unused_confirmation: + one: "%{count} žyma bus ištrinta: %{tags}" + few: "%{count} žymų bus ištrintos: %{tags}" + many: "%{count} žymų bus ištrintos: %{tags}" + other: "%{count} žymų bus ištrintos: %{tags}" delete_no_unused_tags: "Nėra nepanaudotų žymių." tag_list_joiner: ", " delete_unused: "Ištrinkite nepanaudotas žymas" @@ -3526,6 +3749,7 @@ lt: content: "Mano Įrašai" review: content: "Peržiūra" + until: "Iki:" admin_js: type_to_filter: "įrašyk kažką dėl filtro..." admin: @@ -3571,6 +3795,11 @@ lt: space_used_and_free: "%{usedSize} (%{freeSize} nemokama)" uploads: "Įkėlimai" backups: "Atsarginės kopijos" + backup_count: + one: "%{count} atsarginė kopija %{location}" + few: "%{count} atsarginių kopijų %{location}" + many: "%{count} atsarginių kopijų %{location}" + other: "%{count} atsarginių kopijų %{location}" lastest_backup: "Naujausi: %{date}" traffic_short: "Srautas" traffic: "Application web requests" @@ -3661,6 +3890,11 @@ lt: effects: Efektai trust_levels_none: "Nieko" automatic_membership_email_domains: "Vartotojai, kurie užsiregistravo su el. paštu, kuris sutampa su šiame sąraše esančiu el. paštu bus automatiškai pridėti į šią grupę:" + automatic_membership_user_count: + one: "%{count} vartotojas turi naujus el. pašto domenus ir bus pridėtas prie grupės." + few: "%{count} vartotojų turi naujus el. pašto domenus ir bus įtraukti į grupę." + many: "%{count} vartotojų turi naujus el. pašto domenus ir bus įtraukti į grupę." + other: "%{count} vartotojų turi naujus el. pašto domenus ir bus įtraukti į grupę." automatic_membership_associated_groups: "Vartotojai, kurie yra čia išvardytos paslaugos grupės nariai, bus automatiškai įtraukti į šią grupę, kai jie prisijungs prie paslaugos." primary_group: "Automatiškai nustatyk pagrindinę grupę" name_placeholder: "Grupės pavadinimas, be tarpų, taisyklės kaip ir slapyvardžiui" @@ -3814,8 +4048,19 @@ lt: events: none: "Nėra susijusių įvykių." redeliver: "Iš naujo pristatyti" + incoming: + one: "Yra naujas įvykis." + few: "Yra %{count} naujų įvykių." + many: "Yra %{count} naujų įvykių." + other: "Yra %{count} naujų įvykių." + completed_in: + one: "Baigta per %{count} sekundžių." + few: "Baigta per %{count} sekundžių." + many: "Baigta per %{count} sekundžių." + other: "Baigta per %{count} sekundžių." request: "Užklausa" response: "Atsakymas" + headers: "Antraštės" body: "Turinys" status: "Būsenos kodas" event_id: "ID" @@ -4362,6 +4607,11 @@ lt: title: "Peržiūrėti Žodžiai" search: "paieška" clear_filter: "Išvalyti" + show_words: + one: "rodyti %{count} žodį" + few: "rodyti %{count} žodžių" + many: "rodyti %{count} žodžių" + other: "rodyti %{count} žodžių" download: Atsisiųsti clear_all: Išvalyti viską clear_all_confirm: "Ar tikrai norite išvalyti visus stebėtus žodžius %{action} veiksmui?" @@ -4611,6 +4861,11 @@ lt: posts: "Pranešimai" tl3_requirements: title: "Reikalavimai 3 pasitikėjimo lygiui" + table_title: + one: "Paskutinę dieną:" + few: "Per pastarąsias %{count} dienų:" + many: "Per pastarąsias %{count} dienų:" + other: "Per pastarąsias %{count} dienų:" value_heading: "Reikšmė" requirement_heading: "Reikalavimas" visits: "Apsilankymai" @@ -4908,6 +5163,11 @@ lt: step: "%{current} iš %{total}" uploading: "Įkeliama..." upload_error: "Atsiprašome, įvyko klaida įkeliant šį dokumentą. Prašome pamėginti dar kartą." + staff_count: + one: "Jūsų bendruomenėje yra %{count} darbuotojų (jūs)." + few: "Jūsų bendruomenėje yra %{count} darbuotojų, įskaitant jus." + many: "Jūsų bendruomenėje yra %{count} darbuotojų, įskaitant jus." + other: "Jūsų bendruomenėje yra %{count} darbuotojų, įskaitant jus." invites: add_user: "pridėti" none_added: "Jūs nekvietėte jokių darbuotojų. Ar tikrai norite tęsti?" diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index eff9c32bcf..a175567468 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -69,6 +69,10 @@ lv: zero: "0 d" one: "%{count} d" other: "%{count} d" + x_months: + zero: "%{count}mēn" + one: "%{count}mēn" + other: "%{count}mēn" about_x_years: zero: "0 g" one: "%{count} g" @@ -409,6 +413,19 @@ lv: name: "Vārds" fields: "Lauki" reject_reason: "Iemesls" + user_percentage: + agreed: + zero: "%{count}% piekrīt" + one: "%{count}% piekrīt" + other: "%{count}% piekrīt" + disagreed: + zero: "%{count}% nepiekrīt" + one: "%{count}% nepiekrīt" + other: "%{count}% nepiekrīt" + ignored: + zero: "%{count}% ignorē" + one: "%{count}% ignorē" + other: "%{count}% ignorē" topics: topic: "Tēmas" reviewable_count: "Skaits" @@ -829,9 +846,17 @@ lv: zero: "Nav tēmu" one: "%{count} tēma" other: "%{count} tēmas" + topic_stat: + zero: "%{number} / %{unit}" + one: "%{number} / %{unit}" + other: "%{number} / %{unit}" topic_stat_unit: week: "nedēļa" month: "mēnesis" + topic_stat_all_time: + zero: "Kopā %{number}" + one: "Kopā %{number}" + other: "Kopā %{number}" n_more: "Kategorijas (vēl%{count} ) ..." ip_lookup: title: IP adreses meklēšana @@ -1668,6 +1693,10 @@ lv: filter_placeholder: Meklēt... filter_placeholder_with_any: Meklēt vai izveidot... create: "Izveidot: '%{content}'" + max_content_reached: + zero: "Jūs varat izvēlēties tikai %{count} vienumu." + one: "Jūs varat izvēlēties tikai %{count} vienumu." + other: "Jūs varat izvēlēties tikai %{count} vienumu." date_time_picker: from: "No" to: Kam @@ -1916,15 +1945,25 @@ lv: delete: "Dzēst tēmas" dismiss: "Nerādīt" dismiss_read: "Nerādīt visu neizlasīto" + dismiss_read_with_selected: + zero: "Noraidīt %{count} nelasītus" + one: "Noraidīt %{count} nelasītus" + other: "Noraidīt %{count} nelasītus" dismiss_button: "Nerādīt..." + dismiss_button_with_selected: + zero: "Noraidīt (%{count})…" + one: "Noraidīt (%{count})…" + other: "Noraidīt (%{count})…" dismiss_tooltip: "Nerādīt tikai jaunos ierakstus vai pārtraukt sekot tēmām" also_dismiss_topics: "Pārtraukt sekot šīm tēmām, lai tās man vairs nekad nerādītos kā neizlasītas" dismiss_new: "Nerādīt jaunus" toggle: "darbības ar vairākām tēmām" actions: "Darbības ar vairumu" + change_category: "Norādīt sadaļu..." close_topics: "Slēgt tēmas" archive_topics: "Arhivēt tēmas" move_messages_to_inbox: "Pārvietot uz iesūtni" + notification_level: "Paziņojumi..." choose_new_category: "Izvēlēties jaunu sadaļu šīm tēmām:" selected: zero: "Jūs izvēlējāties 0. tēmas." @@ -2014,6 +2053,7 @@ lv: remove: "Noņemt taimeri" publish_to: "Publicēt: " when: "Kad:" + time_frame_required: "Lūdzu, izvēlieties laika periodu" duration: "Ilgums" publish_to_category: title: "Ieplānot publicēšanu " @@ -2106,6 +2146,7 @@ lv: unarchive: "Izņemt tēmu no arhīva" archive: "Arhivēt tēmu" reset_read: "Atstatīt visu kā nelasītu" + make_public: "Izveidot publisku tēmu..." feature: pin: "Piespraust tēmu" unpin: "Atspraust tēmu" @@ -2276,6 +2317,8 @@ lv: image_upload_not_allowed_for_new_user: "Atvainojiet, jaunie lietotāji nevar augšuplādēt attēlus." attachment_upload_not_allowed_for_new_user: "Atvainojiet, jaunie lietotāji nevar augšuplādēt pielikumus." attachment_download_requires_login: "Atvainojiet, jums jābūt ienākušam forumā, lai varētu lejuplādēt pielikumus." + cancel_composer: + discard: "Izmest" via_email: "šis ieraksts atnāca e-pastā" via_auto_generated_email: "šis ieraksts atnāca automātiski ģenerētā e-pastā" whisper: "šis ieraksts ir privāts čuksts moderatoriem" @@ -2304,6 +2347,8 @@ lv: revert_to_regular: "Noņemt darbinieka krāsu" rebake: "Pārbūvēt HTML" unhide: "Noņemt slēpšanu" + change_owner: "Mainīt īpašnieku..." + grant_badge: "Piešķirt Žetonu..." delete_topic: "Dzēst tēmu" actions: people: @@ -2418,6 +2463,8 @@ lv: options: normal: "Normāls" ignore: "Ignorēt" + low: "Zems" + high: "Augsta" sort_options: default: "noklusējuma" likes: "Atzinības" @@ -2574,6 +2621,9 @@ lv: zero: "%{count} nelasītas" one: "%{count} nelasīta" other: "%{count} nelasītas" + unseen: + title: "Neredzēts" + lower_title: "neredzēts" new: lower_title_with_count: zero: "%{count} jaunas" @@ -2754,6 +2804,7 @@ lv: save: "Saglabāt" delete: "Dzēst" confirm_delete: "Vai jūs esat drošs, ka vēlaties dzēst šo tagu grupu?" + parent_tag_placeholder: "Pēc izvēles" topics: none: unread: "Jums nav nelasītu tēmu." @@ -2817,6 +2868,8 @@ lv: content: "Administrators" badges: content: "Žetoni" + everything: + content: "Viss" faq: content: "BUJ" groups: @@ -2850,6 +2903,7 @@ lv: latest_version: "Pēdējais" new_features: dismiss: "Nerādīt" + learn_more: "Uzzināt vairāk" last_checked: "Pēdējā pārbaude" refresh_problems: "Pārlādēt" no_problems: "Problēmas nav atrastas." @@ -2868,6 +2922,7 @@ lv: general_tab: "Vispārīgi" security_tab: "Drošība" report_filter_any: "jebkura" + disabled: Atslēgt reports: today: "Šodien" yesterday: "Vakar" @@ -2928,6 +2983,7 @@ lv: user: "Lietotājs" title: "API" created: Radīts + never_used: (nekad) generate: "Radīt" revoke: "Atsaukt" all_users: "Visi lietotāji" @@ -2967,6 +3023,7 @@ lv: inactive: "Neaktīvs" failed: "Neizdevies" successful: "Veiksmīgs" + disabled: "Atslēgt" events: none: "Nav saistītu notikumu." redeliver: "Atkārtot piegādi" @@ -3083,6 +3140,7 @@ lv: customize_desc: "Pielāgot:" title: "Dizaini" create: "Izveidot" + create_type: "Tips" create_name: "Vārds" long_title: "Labot jūsu vietnes krāsas, CSS un HTML saturu" edit: "Labot" @@ -3102,6 +3160,7 @@ lv: add_upload: "Pievienot augšupielādējamu resursu" upload_file_tip: "Izvēlēties resursu augšupielādei (png, woff2, u.c...)" upload: "Augšupielādēt" + discard: "Izmest" css_html: "Specifisks CSS/HTML" edit_css_html: "Labot CSS/HTML" edit_css_html_help: "Jūs neesat izmainījis nekādus CSS vai HTML failus" @@ -3132,6 +3191,7 @@ lv: description: "Atzinības pogas krāsa." email_style: css: "CSS" + reset: "Atiestatīt uz noklusējumu" email: title: "E-pasti" settings: "Iestatījumi" @@ -3193,6 +3253,9 @@ lv: address_placeholder: "Vārds@epasts.lv" type_placeholder: "apkopojums, reģistrācija ..." reply_key_placeholder: "atbildes atslēga" + moderation_history: + actions: + delete_topic: "Temats dzēsts" logs: title: "Žurnāls" action: "Darbība" @@ -3264,6 +3327,7 @@ lv: backup_destroy: "iznīcināt dublējumu (backup)" reviewed_post: "pārbaudītie ieraksti" custom_staff: "Iestatīt darbību" + post_approved: "ziņa apstiprināta" screened_emails: title: "Pārbaudītie e-pasti" description: "Kad kāds mēģina izveidot jaunu kontu sekojošās e-pasta adreses tiks pārbaudītas, un reģistrācija tiks apturēta, vai kāda cita darbība tiks veikta." @@ -3578,7 +3642,9 @@ lv: dashboard: "Administrācijas panelis" navigation: "Pārvietošanās" default_categories: + modal_description: "Vai vēlaties šīs izmaiņas piemērot vēsturiski? Tas mainīs %{count} esošo lietotāju preferences." modal_yes: "Jā" + modal_no: "Nē, piemērojiet izmaiņas tikai turpmāk" badges: title: Žetoni new_badge: Jauns Žetons diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index 1b6c7481d7..1f47fd798d 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -878,9 +878,15 @@ nb_NO: topic_sentence: one: "%{count} emne" other: "%{count} emner" + topic_stat: + one: "%{number} / %{unit}" + other: "%{number} / %{unit}" topic_stat_unit: week: "uke" month: "måned" + topic_stat_all_time: + one: "%{number} totalt" + other: "%{number} totalt" n_more: "Kategorier (ytterligere%{count})..." ip_lookup: title: Slå opp IP-adresse @@ -2157,14 +2163,22 @@ nb_NO: one: "Avvis %{count} uleste" other: "Avvis %{count} uleste" dismiss_button: "Forkast…" + dismiss_button_with_selected: + one: "Avvis (%{count})…" + other: "Avvis (%{count})…" dismiss_tooltip: "Forkast kun nye innlegg eller slutt å overvåke emner" also_dismiss_topics: "Slutt å overvåke disse emnene slik at de aldri igjen vises til meg som ulest" dismiss_new: "Forkast nye" + dismiss_new_with_selected: + one: "Avvis Ny (%{count})" + other: "Avvis Ny (%{count})" toggle: "slå på/av massevelging av emner" actions: "Massehandlinger" + change_category: "Velg kategori..." close_topics: "Lukk emner" archive_topics: "Arkiver emner" move_messages_to_inbox: "Flytt til innboks" + notification_level: "Varsler..." change_notification_level: "Endre varslingsnivå" choose_new_category: "Velg den nye kategorien for emnene:" selected: @@ -2393,12 +2407,14 @@ nb_NO: open: "Åpne emne" close: "Lukk emne" multi_select: "Velg innlegg…" + slow_mode: "Sett sakte modus..." timed_update: "Sett opp tidsbestemt handling for emne…" pin: "Fest emne…" unpin: "Løsne emne…" unarchive: "Opphev arkivering av emne" archive: "Arkiver emne" reset_read: "Tilbakestill lesedata" + make_public: "Gjør til offentlig emne..." make_private: "Gjør om til personlig melding" reset_bump_date: "Tilbakestille dato emnet ble flyttet øverst" feature: @@ -2682,6 +2698,8 @@ nb_NO: rebake: "Generer HTML på nytt" publish_page: "Side Publisering" unhide: "Vis" + change_owner: "Endre eierskap..." + grant_badge: "Tildel merke..." lock_post: "Lås innlegg" lock_post_description: "forhindre innleggsskriveren fra å redigere dette innlegget" unlock_post: "Lås opp innlegg" @@ -2695,6 +2713,8 @@ nb_NO: delete_topic_confirm_modal_no: "Nei, behold dette emnet" delete_topic_error: "Det oppstod en feil under sletting av emnet" delete_topic: "slett emne" + add_post_notice: "Legg til medarbeider varsel..." + change_post_notice: "Merknad om endring av personalet..." delete_post_notice: "Slett personalmerknad" remove_timer: "Fjern timer" edit_timer: "rediger timeren" @@ -3425,6 +3445,8 @@ nb_NO: content: "Administrator" badges: content: "Merker" + everything: + content: "Alt" faq: content: "O-S-S" groups: @@ -3435,6 +3457,7 @@ nb_NO: content: "Mine innlegg" review: content: "Gjennomgang" + until: "Inntil:" admin_js: type_to_filter: "skriv for å filtrere…" admin: @@ -3597,6 +3620,8 @@ nb_NO: user: "Bruker" title: "API" created: Opprettet + updated: Oppdatert + never_used: (aldri) generate: "Generer API-nøkkel" revoke: "Trekk tilbake" all_users: "Alle brukere" @@ -3877,6 +3902,7 @@ nb_NO: no_overwrite: "Ugyldig variabelnavn. Kan ikke overskrive en eksisterende variabel." must_be_unique: "Ugyldig variabelnavn. Må være unikt." upload: "Last opp" + discard: "Forkast" css_html: "Egendefinert CSS/HTML" edit_css_html: "Rediger CSS/HTML" edit_css_html_help: "Du har ikke redigert noe CSS eller HTML" @@ -3884,6 +3910,7 @@ nb_NO: import_web_tip: "Pakkebrønn inneholdende drakt" is_private: "Drakten er i et privat git repository" public_key: "Gi den følgende public keyen tilgang til repoet:" + install: "Installer" installed: "Installert" install_popular: "Populært" about_theme: "Om" @@ -3891,6 +3918,7 @@ nb_NO: version: "Versjon:" enable: "Aktiver" disable: "Deaktiver" + disabled: "Denne komponenten har blitt deaktivert." update_to_latest: "Oppdater til seneste" check_for_updates: "Se etter oppdateringer" updating: "Oppdaterer…" @@ -4538,7 +4566,9 @@ nb_NO: dashboard: "Dashbord" navigation: "Navigasjon" default_categories: + modal_description: "Ønsker du å bruke denne endringens historikk? Dette vil endre innstillinger for %{count} eksisterende brukere." modal_yes: "Ja" + modal_no: "Nei, bare gjelder endring fremover" badges: title: Merker new_badge: Nytt merke diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index 7792e8e4ba..53ec76a0d6 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -3510,7 +3510,6 @@ nl: this_week: "Week" today: "Vandaag" browser_update: 'Je browser wordt niet ondersteund helaas. Schakel over naar een ondersteunde browser om rijke inhoud te bekijken, je aan te melden en te antwoorden.' - safari_13_warning: Deze website verwijdert binnenkort de ondersteuning voor iOS- en Safari-versie 13 en lager. Er blijft een vereenvoudigde alleen-lezen versie beschikbaar. (Meer informatie) permission_types: full: "Maken / Antwoorden / Weergeven" create_post: "Antwoorden / Weergeven" @@ -4140,7 +4139,6 @@ nl: topics: read: Een topic of een specifiek bericht erin lezen. RSS wordt ook ondersteund. write: Een nieuw topic maken of bericht in een bestaand topic plaatsen. - update: Werk een topic bij. Wijzig de titel, categorie, tags, enz. read_lists: Topiclijsten zoals Top, Nieuw, Nieuwste, etc. lezen. RSS wordt ook ondersteund. posts: edit: Bewerk elk bericht of een specifieke. diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index 01c80d213f..e5d37aae0a 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -1166,6 +1166,7 @@ pl_PL: perm_denied_expl: "Odmówiłeś/łaś dostępu dla powiadomień. Pozwól na powiadomienia w ustawieniach przeglądarki." disable: "Wyłącz powiadomienia" enable: "Włącz powiadomienia" + each_browser_note: 'Uwaga: musisz zmienić to ustawienie w każdej używanej przeglądarce. Wszystkie powiadomienia zostaną wyłączone, jeśli wstrzymasz powiadomienia z menu użytkownika, niezależnie od tego ustawienia.' consent_prompt: "Czy chcesz otrzymywać natychmiastowe powiadomienia, gdy ktoś odpowiada na twoje posty?" dismiss: "Odrzuć" dismiss_notifications: "Odrzuć wszystkie" @@ -2355,6 +2356,11 @@ pl_PL: few: "%{count} powiadomienia o nowej wiadomości" many: "%{count} powiadomień o nowych wiadomościach" other: "%{count} powiadomień o nowych wiadomościach" + new_reviewable: + one: "%{count} nowy do sprawdzenia" + few: "%{count} nowe do sprawdzenia" + many: "%{count} nowych do sprawdzenia" + other: "%{count} nowych do sprawdzenia" title: "powiadomienia o wywołanej @nazwie, odpowiedzi do twoich wpisów i tematów, prywatne wiadomości, itp" none: "Nie udało się załadować listy powiadomień." empty: "Nie znaleziono powiadomień." @@ -3853,7 +3859,6 @@ pl_PL: this_week: "Tydzień" today: "Dzisiaj" browser_update: 'Niestety, twoja przeglądarka nie jest obsługiwana. Proszę przełączyć się na obsługiwaną przeglądarkę, aby móc oglądać bogatą zawartość, zalogować się i odpowiedzieć.' - safari_13_warning: Ta strona wkrótce przestanie obsługiwać systemy iOS i Safari w wersji 13 i niższej. Nadal będzie dostępna uproszczona wersja tylko do odczytu. (więcej informacji) permission_types: full: "tworzyć / odpowiadać / przeglądać" create_post: "odpowiadać / przeglądać" @@ -4195,6 +4200,7 @@ pl_PL: no_drafts_title: "Nie rozpocząłeś żadnych szkiców" no_drafts_body: "Nie jesteś gotowy do opublikowania? Automatycznie zapiszemy nową wersję roboczą i wyświetlimy ją tutaj za każdym razem, gdy zaczniesz pisać temat, odpowiedź lub wiadomość osobistą. Wybierz przycisk anulowania, aby odrzucić lub zapisać wersję roboczą, aby kontynuować później." no_likes_title: "Nie polubiłeś jeszcze żadnych tematów" + no_likes_title_others: "%{username} nie polubił jeszcze żadnego tematu" no_likes_body: "Świetnym sposobem na włączenie się do dyskusji i rozpoczęcie współtworzenia jest rozpoczęcie czytania rozmów, które już miały miejsce, i kliknięcie %{heartIcon} przy postach, które Ci się podobają!" no_topics_title: "Nie rozpocząłeś jeszcze żadnych tematów" no_topics_body: "Zawsze najlepiej jest przeszukać stronę w poszukiwaniu istniejących tematów konwersacji przed rozpoczęciem nowej, ale jeśli masz pewność, że temat, którego szukasz nie istnieje, śmiało rozpocznij nowy temat. Poszukaj przycisku + Nowy temat w prawym górnym rogu listy tematów, kategorii lub tagu, aby rozpocząć tworzenie nowego tematu w tym obszarze." @@ -4230,6 +4236,7 @@ pl_PL: header_link_text: "O stronie" messages: header_link_text: "Wiadomości" + header_action_title: "Utwórz osobistą wiadomość" links: inbox: "Skrzynka odbiorcza" sent: "Wysłane" @@ -4246,6 +4253,7 @@ pl_PL: none: "Nie dodałeś żadnych tagów." click_to_get_started: "Kliknij tutaj, aby rozpocząć." header_link_text: "Etykiety" + header_action_title: "Edytuj tagi paska bocznego" configure_defaults: "Skonfiguruj ustawienia domyślne" categories: links: @@ -4255,25 +4263,33 @@ pl_PL: none: "Nie dodałeś żadnych kategorii." click_to_get_started: "Kliknij tutaj, aby rozpocząć." header_link_text: "Kategorie" + header_action_title: "Edytuj kategorie paska bocznego" configure_defaults: "Skonfiguruj ustawienia domyślne" community: header_link_text: "Społeczność" + header_action_title: "Utwórz temat" links: about: content: "O stronie" + title: "Więcej szczegółów na temat tej witryny" admin: content: "Administracja" + title: "Ustawienia witryny i raporty" badges: content: "Odznaki" + title: "Wszystkie odznaki dostępne do zdobycia" everything: content: "Wszystko" title: "Wszystkie tematy" faq: content: "FAQ" + title: "Wskazówki dotyczące korzystania z tej witryny" groups: content: "Grupy" + title: "Lista dostępnych grup użytkowników" users: content: "Użytkownicy" + title: "Lista wszystkich użytkowników" my_posts: content: "Wysłane" title: "Moja ostatnia aktywność w temacie" @@ -4520,7 +4536,6 @@ pl_PL: topics: read: Przeczytaj temat lub konkretny post w nim. Obsługiwany jest również format RSS. write: Utwórz nowy temat lub post w istniejącym. - update: Zaktualizuj temat. Zmień tytuł, kategorię, tagi itp. read_lists: Czytaj listy tematów, takie jak najpopularniejsze, nowe, najnowsze itp. Obsługiwany jest również format RSS. posts: edit: Edytuj dowolny post lub konkretny. @@ -4672,6 +4687,7 @@ pl_PL: broken_route: "Nie można skonfigurować łącza do '%{name}'. Upewnij się, że blokery reklam są wyłączone i spróbuj ponownie załadować stronę." navigation_menu: sidebar: "Pasek boczny" + legacy: "Przestarzały" backups: title: "Kopie zapasowe" menu: @@ -5082,6 +5098,7 @@ pl_PL: address_placeholder: "nazwa@example.com" type_placeholder: "streszczenie, rejestracja…" reply_key_placeholder: "klucz odpowiedzi" + smtp_transaction_response_placeholder: "SMTP ID" moderation_history: performed_by: "Wykonane przez" no_results: "Brak dostępnej historii moderacji." diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index c87d064baa..0aed18210f 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -2531,6 +2531,7 @@ pt: unarchive: "Desarquivar Tópico" archive: "Arquivar Tópico" reset_read: "Repor Data de Leitura" + make_public: "Criar tópico publico..." make_private: "Tornar Mensagem Pessoal" reset_bump_date: "Reset à Data do Bump" feature: @@ -2813,6 +2814,7 @@ pt: publish_page: "Publicação de Página" unhide: "Mostrar" change_owner: "Alterar Proprietário..." + grant_badge: "Atribuir Crachá..." lock_post: "Bloquear Post" lock_post_description: "impedir o autor de editar esta publicação" unlock_post: "Desbloquear Post" @@ -2823,6 +2825,8 @@ pt: delete_topic_confirm_modal_no: "Não, mantenha este tópico" delete_topic_error: "Ocorreu um erro ao excluir este tópico" delete_topic: "eliminar tópico" + add_post_notice: "Adicionar Nota da Equipe..." + change_post_notice: "Alterar Aviso da Equipa..." delete_post_notice: "Apagar Aviso da Equipa" remove_timer: "remover timer" edit_timer: "editar temporizador" @@ -3435,6 +3439,7 @@ pt: topics: none: unread: "Não tem tópicos por ler." + unseen: "Não tem tópicos por ler." new: "Não tem novos tópicos." read: "Ainda não leu nenhum tópico." posted: "Ainda não publicou em qualquer tópico." @@ -3458,6 +3463,7 @@ pt: enabled: "O modo de segurança está activado, para sair do modo de segurança feche esta janela do navegador" image_removed: "(imagem removida)" pause_notifications: + label: "Pausar notificações" remaining: "%{remaining} restante" options: half_hour: "30 minutos" @@ -3509,6 +3515,8 @@ pt: content: "Administrador" badges: content: "Crachás" + everything: + content: "Tudo" faq: content: "FAQ" groups: @@ -3519,6 +3527,7 @@ pt: content: "As Minhas publicações" review: content: "Revisão" + until: "Até:" admin_js: type_to_filter: "digite para filtrar..." admin: @@ -3805,6 +3814,8 @@ pt: change_settings: "Alterar Configurações" change_settings_short: "Configurações" howto: "Como instalo plugins?" + navigation_menu: + sidebar: "Barra Lateral" backups: title: "Fazer Cópias de Segurança" menu: @@ -4133,6 +4144,9 @@ pt: address_placeholder: "nome@exemplo.com" type_placeholder: "resumo, subscrever..." reply_key_placeholder: "chave de resposta" + moderation_history: + actions: + delete_topic: "Tópico eliminado" logs: title: "Logs" action: "Ação" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index cab81e385a..dd433e0be5 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -3497,7 +3497,6 @@ pt_BR: this_week: "Semana" today: "Hoje" browser_update: 'Infelizmente, seu navegador não é compatível. Use um navegador compatível para visualizar um conteúdo interessante, entrar com a conta e responder.' - safari_13_warning: Este site em breve removerá o suporte para iOS e Safari versões 13 e anteriores. Uma versão simplificada somente para leitura permanecerá disponível. (mais informações) permission_types: full: "Criar/Responder/Ver" create_post: "Responder/Ver" @@ -4119,7 +4118,6 @@ pt_BR: topics: read: Leia um tópico ou uma postagem específica nele. RSS também é compatível. write: Crie um novo tópico ou poste em algum que já existe. - update: Atualize um tópico. Altere o título, categoria, etiquetas, etc. read_lists: Leia listas de tópico como melhores, novidades, mais recentes. RSS também é compatível. posts: edit: Edite qualquer postagem ou especifique uma. diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index 9895fa1c45..fd2bf003d0 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -87,6 +87,10 @@ ro: date_month: "DD MMMM" date_year: "MMM 'YY" medium: + less_than_x_minutes: + one: "mai puțin de %{count} de minute în urmă" + few: "cu mai puțin de %{count} minute în urmă" + other: "cu mai puțin de %{count} minute în urmă" x_minutes: one: "%{count} min" few: "%{count} min" @@ -103,6 +107,10 @@ ro: one: "%{count} zi" few: "%{count} zile" other: "%{count} de zile" + x_months: + one: "%{count} lună" + few: "%{count} luni" + other: "%{count} luni" about_x_years: one: "aproximativ %{count} an" few: "aproximativ %{count} ani" @@ -875,6 +883,14 @@ ro: topic_stat_unit: week: "săptămană" month: "lună" + topic_stat_sentence_week: + one: "%{count} subiect nou în ultima săptămână." + few: "%{count} subiecte noi în ultima săptămână." + other: "%{count} subiecte noi în ultima săptămână." + topic_stat_sentence_month: + one: "%{count} subiect nou în ultima lună." + few: "%{count} subiecte noi în ultima lună." + other: "%{count} subiecte noi în ultima lună." n_more: "Categorii (%{count} mai multe)..." ip_lookup: title: Căutare adresă IP @@ -1351,6 +1367,9 @@ ro: valid_for: "Link-ul de invitare este valid doar pentru următoarele adrese de email: %{email}" invite_link: success: "Link de invitare generat cu succes!" + invite: + show_advanced: "Afișați opțiunile avansate" + hide_advanced: "Ascundeți opțiunile avansate" bulk_invite: none: "Nu există invitații de afișat pe această pagină." text: "Invitație în masă" @@ -1940,9 +1959,11 @@ ro: dismiss_new: "Anulează cele noi" toggle: "activează selecția multiplă a subiectelor" actions: "Acțiuni multiple" + change_category: "Alege categoria..." close_topics: "Închide subiectele" archive_topics: "Arhivează subiectele" move_messages_to_inbox: "Mută în „Primite”" + notification_level: "Notificări..." choose_new_category: "Alege o nouă categorie pentru acest subiect" selected: one: "Ai selectat un subiect." @@ -2034,7 +2055,19 @@ ro: description: "Pentru a promova discuții aprofundate în mișcare rapidă sau controversate, utilizatorii trebuie să aștepte înainte de a posta din nou pe acest subiect." enable: "Activează" remove: "Dezactivează" + hours: "Ore:" + minutes: "De minute:" durations: + 10_minutes: "10 De minute" + 15_minutes: "15 De minute" + 30_minutes: "30 De minute" + 45_minutes: "45 De minute" + 1_hour: "1 Oră" + 2_hours: "2 Ore" + 4_hours: "4 Ore" + 8_hours: "8 Ore" + 12_hours: "12 Ore" + 24_hours: "24 Ore" custom: "Durată personalizată" topic_status_update: title: "Temporizator subiect" @@ -2080,6 +2113,7 @@ ro: title: Evoluția subiectului jump_prompt: "sari la..." jump_prompt_long: "Sari la..." + jump_prompt_to_date: "la data" jump_prompt_or: "sau" notifications: title: schimbă frecvența cu care vei fi notificat despre acest subiect @@ -2139,6 +2173,7 @@ ro: archive: "Arhivează subiect" invisible: "Ascunde subiectul" reset_read: "Resetează datele despre subiecte citite" + make_public: "Transformă în subiect public..." make_private: "Transformă în mesaj privat" feature: pin: "Fixează subiectul" @@ -2358,6 +2393,8 @@ ro: revert_to_regular: "Șterge culoarea pentru membrii echipei" rebake: "Reconstruieşte HTML" unhide: "Arată" + change_owner: "Schimbă proprietarul..." + grant_badge: "Acordă ecuson..." lock_post: "Blochează postarea" unlock_post: "Deblochează postarea" delete_topic_disallowed_modal: "Nu ai permisiuni suficiente pentru a șterge această discuție. Dacă vrei ca ea să fie ștearsă, marcheaz-o trimițând un mesaj explicativ moderatorului." @@ -2510,6 +2547,7 @@ ro: options: normal: "Normal" ignore: "Ignoră" + low: "Scăzut" high: "Ridicată" sort_options: default: "implicit" @@ -2532,6 +2570,7 @@ ro: flagging: title: "Îți mulțumim că ne ajuți să păstrăm o comunitate civilizată!" action: "Marcare" + take_action: "Acționează..." take_action_options: default: title: "Acționează" @@ -2872,6 +2911,7 @@ ro: save: "Salvează" delete: "Șterge" confirm_delete: "Ești sigur că vrei să ștergi acest grup de etichete?" + parent_tag_placeholder: "Opțional" topics: none: unread: "Nu ai niciun subiect necitit." @@ -2953,6 +2993,8 @@ ro: content: "Administrator" badges: content: "Ecusoane" + everything: + content: "Totul" faq: content: "Întrebări frecvente" groups: @@ -2963,6 +3005,7 @@ ro: content: "Postările mele" review: content: "Revizuire" + until: "Pana cand:" admin_js: type_to_filter: "tastează pentru a filtra..." admin: @@ -2986,6 +3029,7 @@ ro: latest_version: "Ultima" new_features: dismiss: "Înlătură" + learn_more: "Află mai multe" last_checked: "Ultima verificare" refresh_problems: "Reîmprospătează" no_problems: "Nu a apărut nicio problemă." @@ -3069,6 +3113,7 @@ ro: title: "API" created: Creat updated: Actualizat + never_used: (niciodată) generate: "Generare" revoke: "Revocă" all_users: "Toți utilizatorii" @@ -3341,6 +3386,7 @@ ro: description: "Culoarea butonului de apreciere." email_style: css: "CSS" + reset: "Resetare la valorile implicite" email: title: "Email" settings: "Opțiuni" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index 9aa24f4798..e404343c0b 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -1918,7 +1918,7 @@ ru: associate: "Уже есть аккаунт? Войдите в систему для привязки аккаунта %{provider}." forgot_password: title: "Сброс пароля" - action: "Я забыл(-а) свой пароль" + action: "Пароль утерян" invite: "Введите имя пользователя или адрес электронной почты, и мы отправим вам ссылку для сброса пароля." invite_no_username: "Введите адрес электронной почты, и мы отправим вам ссылку для сброса пароля." reset: "Сбросить пароль" @@ -3811,7 +3811,6 @@ ru: this_week: "За неделю" today: "Сегодня" browser_update: 'Ваш браузер не поддерживается. Обновите его для полноценной работы с сайтом.' - safari_13_warning: Этот сайт скоро прекратит поддержку iOS и Safari версии 13 и ниже. Упрощённая версия только для чтения останется доступной. (больше информации) permission_types: full: "Создавать / Отвечать / Просматривать" create_post: "Отвечать / Просматривать" @@ -4461,7 +4460,6 @@ ru: topics: read: Чтение темы или конкретного сообщения в ней. RSS также поддерживается. write: Создание новой темы или записи в уже существующей теме. - update: Обновление темы. Изменение названия, категории, тегов и т. д. read_lists: Чтение тем в разделах «Последние», «Новые», «Обсуждаемые» и т. д. RSS также поддерживается. posts: edit: Редактирование записи. diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 3f5b13f5cf..8dfaa53ab7 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -888,6 +888,7 @@ sk: primary: "Primárny e-mail" secondary: "Sekundárne e-maily" primary_label: "primárny" + resent_label: "email odoslaný" update_email: "Zmeniť email" no_secondary: "Žiadne sekundárne e-maily" ok: "Pošleme vám email pre potvrdenie" @@ -1441,6 +1442,7 @@ sk: close_topics: "Uzavrieť tému" archive_topics: "Archivuj témy" move_messages_to_inbox: "Presuň do prijatej pošty" + notification_level: "Upozornenia..." choose_new_category: "Vyberte pre tému novú kategóriu:" selected: one: "Označíli ste %{count} tému." @@ -1604,6 +1606,7 @@ sk: unarchive: "Zruš archiváciu témy" archive: "Archívuj tému" reset_read: "Zrušiť načítané údaje" + make_public: "Spraviť verejnou témou..." feature: pin: "Pripni tému" unpin: "Odopni tému" @@ -1806,6 +1809,8 @@ sk: revert_to_regular: "Odobrať farbu personálu" rebake: "Pregenerovať HTML" unhide: "Odokryť" + change_owner: "Zmeniť vlastníctvo..." + grant_badge: "Udeliť odznak..." lock_post: "Zamknúť príspevok" unlock_post: "Odblokovať príspevok" delete_topic: "odstrániť tému" @@ -1927,6 +1932,7 @@ sk: flagging: title: "Ďakujeme, že pomáhate udržiavať slušnosť v našej komunite!" action: "Označ príspevok" + take_action: "Vykonať akciu..." take_action_options: default: title: "Vykonať akciu" @@ -2240,6 +2246,7 @@ sk: save: "Uložiť" delete: "Vymazať" confirm_delete: "Ste si istý, že chcete zmazať túto skupinu štítkov?" + parent_tag_placeholder: "Nepovinné" topics: none: unread: "Nemáte žiadnu neprečítanú tému" @@ -2336,6 +2343,7 @@ sk: latest_version: "Najnovšie" new_features: dismiss: "Zahodiť" + learn_more: "Zistiť viac" last_checked: "Naposledy overené" refresh_problems: "Obnoviť" no_problems: "Nenašli sa žiadne problémy." @@ -2424,6 +2432,7 @@ sk: user: "Používateľ" title: "API" created: Vytvorené + never_used: (nikdy) generate: "Generovať" revoke: "Zrušiť" all_users: "Všetci používatelia" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index 2b016d0bf9..4a8d0ca02d 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -1416,6 +1416,7 @@ sl: invite: new_title: "Ustvari povabilo" instructions: "Delite to povezavo in takoj omogočite dostop do te strani:" + expires_in_time: "Poteče čez %{time}" show_advanced: "Pokaži dodatne možnosti" add_to_groups: "Dodaj v skupine" expires_at: "Poteče po" @@ -2131,9 +2132,11 @@ sl: dismiss_new: "Opusti nove" toggle: "preklopi množično izbiro tem" actions: "Množična dejanja" + change_category: "Določi kategorijo..." close_topics: "Zapri teme" archive_topics: "Arhiviraj teme" move_messages_to_inbox: "Prestavi v Prejeto" + notification_level: "Obvestila..." change_notification_level: "Spremeni raven obveščanja" choose_new_category: "Izberi novo kategorijo za temo:" selected: @@ -2364,12 +2367,14 @@ sl: open: "Odpri temo" close: "Zapri temo" multi_select: "Izberite prispevke..." + slow_mode: "Nastavi počasni način..." timed_update: "Nastavi opomnik teme..." pin: "Pripni temo" unpin: "Odpni temo" unarchive: "Odarhiviraj temo" archive: "Arhiviraj temo" reset_read: "Ponastavi podatke o branosti" + make_public: "Spremeni v javno temo..." make_private: "Spremeni v ZS" reset_bump_date: "Ponastavi izpostavljanje" feature: @@ -2664,6 +2669,8 @@ sl: rebake: "Obnovi HTML" publish_page: "Objavljanje strani" unhide: "Ponovni prikaži" + change_owner: "Spremeni lastnika..." + grant_badge: "Podeli značko..." lock_post: "Zakleni prispevek" lock_post_description: "onemogoči avtorju da ureja prispevek" unlock_post: "Odkleni prispevek" @@ -2676,6 +2683,8 @@ sl: few: "Ta tema ima trenutno več kot %{count} oglede in je morda priljubljena tarča iskanja. Ali ste prepričani, da želite to temo v celoti izbrisati, namesto da bi jo z urejanjem poskusili izboljšati?" other: "Ta tema ima trenutno več kot %{count} ogledov in je morda priljubljena tarča iskanja. Ali ste prepričani, da želite to temo v celoti izbrisati, namesto da bi jo z urejanjem poskusili izboljšati?" delete_topic: "izbriši temo" + add_post_notice: "Dodaj obvestilo osebja..." + change_post_notice: "Spremeni obvestilo osebja..." delete_post_notice: "Odstrani obvestilo osebja" remove_timer: "odstrani opomnik" edit_timer: "uredi časovnik" @@ -2876,6 +2885,7 @@ sl: flagging: title: "Hvala, da pomagate ohraniti prijazno skupnost!" action: "Prijavi prispevek" + take_action: "Ukrepaj..." take_action_options: default: title: "Ukrepaj" @@ -3316,6 +3326,7 @@ sl: everyone_can_use: "Oznake lahko uporablja kdorkoli" usable_only_by_groups: "Oznake so vidne vsem, vendar jih lahko uporabljajo le naslednje skupine" visible_only_to_groups: "Oznake so vidne le naslednjim skupinam" + parent_tag_placeholder: "Neobvezno" topics: none: unread: "Nimate neprebranih tem." @@ -3395,6 +3406,8 @@ sl: content: "Admin" badges: content: "Značke" + everything: + content: "Vse" faq: content: "Pravila skupnosti" groups: @@ -3405,6 +3418,7 @@ sl: content: "Moji prispevki" review: content: "Pregled" + until: "Do:" admin_js: type_to_filter: "vnesite za filter..." admin: @@ -3563,6 +3577,7 @@ sl: user: "Uporabnik" title: "API" created: Ustvarjeno + never_used: (nikoli) generate: "Ustvari" revoke: "Prekliči" all_users: "Vsi uporabniki" diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index 6d6ed9841a..259ec3d4ba 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -274,6 +274,7 @@ sq: save: "Ruaj" cancel: "Anulo" filters: + all_categories: "(të gjitha kategoritë)" type: title: "Lloji" refresh: "Rifresko" @@ -675,6 +676,7 @@ sq: email: title: "Email" primary_label: "parësor" + resent_label: "emaili u dërgua" update_email: "Ndrysho email" instructions: "Mos e shfaq në publik." ok: "Do ju nisim emailin e konfirmimit" @@ -1169,6 +1171,7 @@ sq: close_topics: "Mbyll temat" archive_topics: "Arkivo temat" move_messages_to_inbox: "Transfero në inbox" + notification_level: "Njoftimet..." choose_new_category: "Zgjidhni kategorinë e re për temat: " selected: one: "Keni zgjedhur %{count} temë." @@ -1313,6 +1316,7 @@ sq: unarchive: "Çarkivoje temën" archive: "Arkivoje temën" reset_read: "Reset Read Data" + make_public: "Bëje temën publike..." feature: pin: "Ngjite temën" unpin: "Çngjite temën" @@ -1488,6 +1492,8 @@ sq: revert_to_regular: "Hiq ngjyrën e stafit" rebake: "Rindërtoni HTML" unhide: "Çfshi" + change_owner: "Ndrysho zotëruesin..." + grant_badge: "Dhuroni Stemë..." delete_topic: "fshi temën" actions: people: @@ -1608,6 +1614,7 @@ sq: flagging: title: "Faleminderit për ndihmën që i jepni këtij komuniteti!" action: "Sinjalizo postimin" + take_action: "Vepro..." take_action_options: default: title: "Vepro" @@ -1993,6 +2000,7 @@ sq: latest_version: "Të fundit" new_features: dismiss: "Hiqe" + learn_more: "Mëso më shumë" last_checked: "Verifikimi i fundit" refresh_problems: "Rifresko" no_problems: "Nuk u gjet asnjë gabim." @@ -2066,6 +2074,7 @@ sq: user: "Përdorues" title: "API" created: Krijuar + never_used: (asnjëherë) generate: "Gjenero" revoke: "Revoko" all_users: "Gjithë Përdoruesit" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 1f53b9cdcd..56b3116227 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -130,6 +130,10 @@ sr: one: "%{count} дан раније" few: "%{count} дана раније" other: "%{count} дана раније" + x_months: + one: "pre %{count} mesec dana" + few: "pre %{count} mesec dana" + other: "pre %{count} mesec dana" later: x_days: one: "%{count} дан касније" @@ -451,6 +455,8 @@ sr: types: reviewable_user: title: "Korisnik" + reviewable_post: + title: "Post" approval: title: "Potrebno odobrenje" description: "Primili smo tvoj novi post ali on najpre mora biti odobren od strane moderatora. Budi strpljiv." @@ -611,7 +617,7 @@ sr: "12": "Poslato" "13": "Primljeno" "14": "U toku" - "15": "Drafts" + "15": "Нацрти" categories: all: "sve kategorije" all_subcategories: "sve" @@ -682,6 +688,7 @@ sr: trust_level: "Nivo poverenja" notifications: "Obaveštenja" statistics: "Statistike" + dismiss: "Одбаци" dismiss_notifications_tooltip: "Označi sve nepročitana obavestenja kao pročitana" color_schemes: regular: "Stalni član" @@ -786,6 +793,7 @@ sr: email: title: "E-mail" primary_label: "primarna" + resent_label: "email je poslat" update_email: "Promeni e-mail" ok: "Poslaćemo vam e-mail za potvrdu" invalid: "Molimo unesite validnu e-mail adresu" @@ -884,6 +892,9 @@ sr: create: "Pozovi" invite_link: success: "Link pozivnice je uspešno kreiran!" + invite: + show_advanced: "Прикажи напредне опције" + hide_advanced: "Сакриј напредне опције" password: title: "Šifra" too_short: "Vaša šifra je prekratka." @@ -954,8 +965,12 @@ sr: title: "Profilna Slika" title: title: "Naslov" + none: "(ništa)" + flair: + none: "(ništa)" primary_group: title: "Primarna Grupa" + none: "(ništa)" filters: all: "Sve" stream: @@ -1195,11 +1210,13 @@ sr: select_all: "Izaberi sve" defer: "Odloži" delete: "Obriši Teme" + dismiss: "Одбаци" dismiss_new: "Odbaci Novo" toggle: "uključi/isključi grupni odabir tema" actions: "Grupne aktivnosti" close_topics: "Zatvori Teme" archive_topics: "Arhiviraj teme" + notification_level: "Obaveštenja..." choose_new_category: "Izaberite novu kategoriju za vašu temu." selected: one: "Odabrali ste %{count} temu." @@ -1283,6 +1300,7 @@ sr: replies_short: "%{current} / %{total}" progress: title: napredak teme + jump_prompt_to_date: "до датума" jump_prompt_or: "ili" notifications: reasons: @@ -1324,6 +1342,7 @@ sr: unarchive: "Vrati temu iz arhiva" archive: "Arhiviraj temu" reset_read: "Poništi podatke o pročitanom" + make_public: "Napravi javnu temu..." feature: pin: "Zakači temu" unpin: "Otkači temu" @@ -1447,6 +1466,7 @@ sr: revert_to_regular: "Ukloni Boje Osoblja" rebake: "Popravi HTML" unhide: "Poništi sakrivanje" + grant_badge: "Dodeli Značku..." delete_topic: "obriši temu" actions: by_you: @@ -1546,6 +1566,7 @@ sr: flagging: title: "Hvala što pomažete u održavanju naše zajednice pristojnom." action: "Označi Poruku Zastavom" + take_action: "Preduzmi Akciju..." take_action_options: default: title: "Preduzmi Akciju" @@ -1743,6 +1764,7 @@ sr: few: "+još %{count} član" other: "+još %{count} član" select_badge_for_title: Odaberi značku koju češ koristit kao titulu + none: "(ništa)" badge_grouping: getting_started: name: Početak @@ -1788,6 +1810,7 @@ sr: footer_nav: back: "Nazad" share: "Podeli" + dismiss: "Одбаци" pause_notifications: options: custom: "Posebna" @@ -1859,6 +1882,9 @@ sr: version_check_pending: "Čini se da ste nedavno nadogradili. Fantastično!" installed_version: "Instalirano" latest_version: "Poslednje" + new_features: + dismiss: "Одбаци" + learn_more: "Saznaj više" last_checked: "Zadnje provereno" refresh_problems: "Osveži" no_problems: "Nisu pronađeni problemi." @@ -1917,6 +1943,7 @@ sr: user: "Korisnik" title: "API" created: Napravljeno + never_used: (nikad) generate: "Generiši" revoke: "Povuci" all_users: "Svi korisnici" @@ -2493,6 +2520,7 @@ sr: topic_id: "ID Teme" topic_title: "Tema" post_id: "ID Poruke" + post_title: "Post" category_title: "Kategorija" form: label: "Novo:" diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index 65b67bbc17..9b05a2815e 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -3638,7 +3638,6 @@ sv: this_week: "Vecka" today: "Idag" browser_update: 'Tyvärr stöds inte din webbläsare. Vänligen byt till en webbläsare som stöds för att se innehåll, logga in och svara.' - safari_13_warning: Webbplatsen har fullt stöd för redigering av innehåll med webbläsare på iOS 12.5 och 13 till januari 2023. Därefter krävs iOS 14 eller senare. Äldre versioner av iOS kan då endast läsa förenklat innehåll. (mer information) permission_types: full: "Skapa / svara / se" create_post: "Svara / se" @@ -4285,7 +4284,7 @@ sv: topics: read: Läs ett ämne eller ett specifikt inlägg i det. RSS stöds också. write: Skapa ett nytt ämne eller inlägg till en befintligt sådan. - update: Uppdatera ett ämne. Ändra titel, kategori, taggar, etc. + update: Uppdatera ett ämne. Ändra titel, kategori, taggar, status, arketyp, featured_link etc. read_lists: Läs ämneslistor som topp, ny, senaste osv. RSS stöds också. posts: edit: Redigera ett inlägg eller ett specifikt inlägg. diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index a984f674b4..20bab69224 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -287,6 +287,7 @@ sw: reject_reason: "Sababu" topics: topic: "Mada" + deleted: "[Mada Imefutwa]" details: "maelezo" unique_users: one: "Mtumiaji mmoja" @@ -790,6 +791,7 @@ sw: primary: "Barua pepe ya awali" secondary: "Barua pepe" primary_label: "msingi" + resent_label: "barua pepe imetumwa" update_email: "Badilisha Barua Pepe" ok: "Tutakutumia barua pepe kuthibitisha" invalid: "Andika barua pepe iliyo sahihi" @@ -1450,9 +1452,11 @@ sw: dismiss_new: "Ondosha Mpya" toggle: "Badili kwa wingi chaguo la topiki" actions: "Vitendo za Jumla" + change_category: "Seti Kategoria..." close_topics: "Funga Mada" archive_topics: "Hifadhi Mada kwenye nyaraka" move_messages_to_inbox: "Hamishia kwenye kisanduku-pokezi" + notification_level: "Taarifa..." choose_new_category: "Chagua kategoria mpya kwa ajili ya mada:" selected: one: "Umechagua mada %{count}." @@ -1622,6 +1626,7 @@ sw: unarchive: "Ondoa Mada kwenye Nyaraka" archive: "Weka Mada kwenye Nyaraka" reset_read: "Anzisha Upya Usomaji wa Taarifa" + make_public: "Fanya Mada iwe ya Umma..." make_private: "Tengeneza Ujumbe Binafsi" feature: pin: "Bandika Mada" @@ -1805,6 +1810,8 @@ sw: revert_to_regular: "Ondoa Rangi ya Wasaidizi" rebake: "Tengeneza upya HTML" unhide: "Onesha" + change_owner: "Badilisha Umiliki..." + grant_badge: "Toa Beji..." lock_post: "Funga Chapisho" lock_post_description: "mzuie mchapishaji kuhariri chapisho hili" unlock_post: "Fungua Chapisho" @@ -1952,6 +1959,7 @@ sw: flagging: title: "Asante kwa kuendeleza ustaarabu kwenye jumuiya yetu!" action: "Ripoti Chapisho" + take_action: "Fanya Kitendo..." take_action_options: default: title: "Fanya Kitendo" @@ -2221,6 +2229,7 @@ sw: delete: "Futa" confirm_delete: "Una uhakika unataka kufuta kikundi cha lebo hii?" everyone_can_use: "Lebo zinaweza kutumiwa na kila mtu" + parent_tag_placeholder: "Sio muhimu" topics: none: unread: "Hauna mada ambazo hazijasomwa." @@ -2315,6 +2324,7 @@ sw: latest_version: "Hivi Karibuni" new_features: dismiss: "Ondosha" + learn_more: "Jifunze zaidi" last_checked: "Mara ya Mwisho imeangaliwa" refresh_problems: "Rudisha Tena" no_problems: "Hakuna matatizo yaliyopatikana." @@ -2414,6 +2424,7 @@ sw: user: "Mtumiaji" title: "API" created: Imetengenezwa + never_used: (kamwe) generate: "Tengeneza" revoke: "Futa" all_users: "Watumiaji Wote" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index 441dc5a36b..fd1511ed9e 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -498,6 +498,7 @@ te: email: title: "ఈమెయిల్" primary_label: "ప్రాథమిక" + resent_label: "ఈమెయిల్ పంపిన" update_email: "ఈమెయిల్ మార్చు" ok: "ద్రువపరుచుటకు మీకు ఈమెయిల్ పంపాము" invalid: "దయచేసి చెల్లుబాటులోని ఈమెయిల్ చిరునామా రాయండి" diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index 336033fa66..01747bceeb 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -1627,6 +1627,7 @@ th: close_topics: "ปิดกระทู้" archive_topics: "คลังกระทู้" move_messages_to_inbox: "ย้ายไปกล่องขาเข้า" + notification_level: "การแจ้งเตือน..." choose_new_category: "เลือกหมวดหมู่ใหม่ให้กระทู้" selected: other: "คุณได้เลือก %{count} กระทู้" @@ -1793,6 +1794,7 @@ th: unarchive: "เลิกเก็บกระทู้เข้าคลัง" archive: "เก็บกระทู้เข้าคลัง" reset_read: "ล้างข้อมูลการอ่าน" + make_public: "ทำให้กระทู้เป็นสาธารณะ..." make_private: "สร้างข้อความส่วนตัว" feature: pin: "ปักหมุดกระทู้" @@ -2413,6 +2415,7 @@ th: save: "บันทึก" delete: "ลบ" everyone_can_use: "ทุกคนสามารถใช้แท็กได้" + parent_tag_placeholder: "ทางเลือก" topics: none: unread: "คุณไม่มีกระทู้ที่ยังไม่ได้อ่าน" @@ -2468,6 +2471,8 @@ th: content: "แอดมิน" badges: content: "เหรียญ" + everything: + content: "ทุกสิ่ง" faq: content: "คำถามที่พบบ่อย" groups: @@ -2480,6 +2485,7 @@ th: other: "%{count}แบบร่าง" review: content: "รีวิว" + until: "จนถึง:" admin_js: type_to_filter: "พิมพ์เพื่อกรอง..." admin: @@ -2500,6 +2506,7 @@ th: latest_version: "ล่าสุด" new_features: dismiss: "ซ่อน" + learn_more: "เรียนรู้เพิ่มเติม" last_checked: "ตรวจล่าสุด" refresh_problems: "รีเฟรช" no_problems: "ไม่พบปัญหา" @@ -2771,6 +2778,9 @@ th: filters: user_placeholder: "ผู้ใช้" address_placeholder: "name@example.com" + moderation_history: + actions: + delete_topic: "กระทู้ถูกลบ" logs: created_at: "สร้าง" ip_address: "ไอพี" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index 7ea2053253..5676aa46d8 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -3637,7 +3637,6 @@ tr_TR: this_week: "Hafta" today: "Bugün" browser_update: 'Ne yazık ki tarayıcınız desteklenmiyor. Lütfen zengin içeriği görüntülemek, giriş yapmak ve yanıtlamak için desteklenen bir tarayıcıya geçin.' - safari_13_warning: Bu site yakında iOS ve Safari sürüm 13 ve altını desteklemeyecek. Basitleştirilmiş ve salt okunur bir sürüm kullanılabilir olmaya devam edecek. (daha fazla bilgi) permission_types: full: "Oluştur / Yanıtla / Bak" create_post: "Yanıtla / Bak" @@ -4269,7 +4268,6 @@ tr_TR: topics: read: Bir konuyu veya içindeki belirli bir gönderiyi okuyun. RSS de desteklenir. write: Yeni bir konu oluşturun veya mevcut bir konuya gönderin. - update: Bir konuyu güncelleyin. Başlığı, kategoriyi, etiketleri vb. değiştirin. read_lists: En iyi, yeni, en son vb. gibi konu listelerini okuyun. RSS de desteklenir. posts: edit: Herhangi bir gönderiyi veya belirli bir gönderiyi düzenleyin. diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index 3b259f110d..80ec5c8a98 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -3830,7 +3830,6 @@ uk: this_week: "Тиждень" today: "Today" browser_update: 'На жаль, ваш браузер не підтримується. Будь ласка, перейдіть на підтримуваний браузер , щоб повноцінно переглянути вміст, увійти та відповісти.' - safari_13_warning: Цей сайт незабаром перестане підтримувати версії iOS і Safari 13 і нижче. Спрощена версія "тільки для читання" залишиться доступною. (більше інформації) permission_types: full: "Створювати / Відповідати / Бачити" create_post: "Відповісти / Див" @@ -4488,7 +4487,6 @@ uk: topics: read: Читати тему або певні дописи в ній. Також підтримується RSS. write: Створіть нову тему чи допис в наявній. - update: Оновіть тему. Змініть назву, розділ, теґи тощо. read_lists: Читайте списки тем, як топ, нові, останні тощо. RSS також підтримується. posts: edit: Редагуйте будь-який допис або якийсь конкретний. diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index e57e066fe6..32a4e0d072 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -2190,6 +2190,7 @@ ur: tips: category_tag: "زمرہ یا ٹیگ کے لحاظ سے فلٹرز" author: "پوسٹ مصنف کے ذریعہ فلٹرز" + in: "میٹا ڈیٹا کےذریعہ سے فلٹرز (مثلاً ان: عنوان، ان:ذاتی، ان:پن کیاہوا)" status: "موضوع کی حیثیت کے لحاظ سے فلٹرز" full_search: "مکمل صفحہ کی تلاش کا آغاز" full_search_key: "%{modifier} + درج کریں" @@ -3645,6 +3646,8 @@ ur: content: "ایڈمن" badges: content: "بَیج" + everything: + content: "تمام" faq: content: "عمومی سوالات" groups: @@ -3655,6 +3658,7 @@ ur: content: "میری پوسٹ" review: content: "جائزہ لیں" + until: "جب تک:" admin_js: type_to_filter: "فِلٹر کرنے کے لئے ٹائپ کریں..." admin: @@ -3875,7 +3879,6 @@ ur: topics: read: اس میں کوئی موضوع یا کوئی مخصوص پوسٹ پڑھیں۔ آر ایس ایس کی بھی حمایت حاصل ہے۔ write: ایک نیا موضوع بنائیں یا موجودہ موضوع پر پوسٹ کریں۔ - update: ایک موضوع کو اپ ڈیٹ کریں۔ عنوان، زمرہ، ٹیگز وغیرہ تبدیل کریں۔ read_lists: ٹاپک، نیا، تازہ ترین، وغیرہ جیسے عنوانات کی فہرستیں پڑھیں۔ RSS بھی تعاون یافتہ ہے۔ posts: edit: کسی بھی پوسٹ یا مخصوص میں ترمیم کریں۔ diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index 33d4c35905..e8ff17917a 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -3573,6 +3573,7 @@ vi: other: "%{count} mới" more: "Thêm" all_categories: "Tất cả danh mục" + all_tags: "Tất cả các thẻ" sections: about: header_link_text: "Giới thiệu" @@ -3837,7 +3838,6 @@ vi: topics: read: Đọc một chủ đề hoặc một bài viết cụ thể trong đó. RSS cũng được hỗ trợ. write: Tạo một chủ đề mới hoặc đăng một chủ đề hiện có. - update: Cập nhật chủ đề. Thay đổi tiêu đề, danh mục, thẻ, v.v. read_lists: Đọc danh sách chủ đề như hàng đầu, mới, mới nhất, v.v. RSS cũng được hỗ trợ. posts: edit: Chỉnh sửa bất kỳ bài đăng nào hoặc một bài đăng cụ thể. diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index cfe5b6e540..92976bde78 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -3331,7 +3331,6 @@ zh_CN: this_week: "周" today: "今天" browser_update: '很抱歉,您的浏览器不受支持。请切换到支持的浏览器查看富内容、登录和回复。' - safari_13_warning: 此站点将很快移除对 iOS 和 Safari 13 及以下版本的支持。简化的只读版本将保持可用。(更多信息) permission_types: full: "创建/回复/查看" create_post: "回复/查看" @@ -3935,7 +3934,6 @@ zh_CN: topics: read: 阅读一个话题或其中的一个帖子。也支持 RSS。 write: 创建一个新话题或发布到现有话题。 - update: 更新话题。更改标题、类别、标签等。 read_lists: 阅读诸如热门、新、最新等话题列表。也支持 RSS。 posts: edit: 编辑任意帖子或特定帖子。 diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index 690e925bab..dffe4a148f 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -1133,6 +1133,7 @@ zh_TW: title: "使用者卡背景" instructions: "背景會被置中,且默認寬度為850px。" change_featured_topic: + title: "特色主題" instructions: "此主題的連結將顯示您的使用者卡和個人檔案上。" email: title: "電子郵件" @@ -1303,6 +1304,10 @@ zh_TW: valid_for: "邀請連結只對這個郵件地址有效:%{email}" invite_link: success: "邀請連結生成成功!" + invite: + copy_link: "複製連結" + show_advanced: "顯示進階選項" + hide_advanced: "隱藏進階選項" bulk_invite: none: "此頁面上沒有邀請函顯示。" error: "上傳的檔案必須是 csv 格式。" @@ -1481,6 +1486,7 @@ zh_TW: username: "使用者" password: "密碼" show_password: "顯示" + second_factor_title: "兩步驟驗證" second_factor_description: "請輸入應用程式中的驗證碼:" second_factor_backup_description: "請輸入一組您的備用碼" caps_lock_warning: "大寫鎖定中" @@ -1767,6 +1773,7 @@ zh_TW: confirm_body: "成功! 通知已啟用" custom: "新的通知由%{username}在%{site_title}" titles: + edited: "已編輯" liked: "新的讚" watching_first_post: "新話題" liked_consolidated: "新的讚" @@ -1882,9 +1889,11 @@ zh_TW: dismiss_new: "設定新貼文為已讀" toggle: "批次切換選擇話題" actions: "批次操作" + change_category: "設定分類..." close_topics: "關閉話題" archive_topics: "已封存的話題" move_messages_to_inbox: "移動到收件匣" + notification_level: "通知..." choose_new_category: "為話題選擇新類別:" selected: other: "你已選擇了 %{count} 個話題。" @@ -1961,6 +1970,19 @@ zh_TW: enable: "啟用" enabled_until: "啟用直到:" remove: "停用" + hours: "小時:" + minutes: "分鐘:" + durations: + 10_minutes: "10 分鐘" + 15_minutes: "15 分鐘" + 30_minutes: "30 分鐘" + 45_minutes: "45 分鐘" + 1_hour: "1 小時" + 2_hours: "2 小時" + 4_hours: "4 小時" + 8_hours: "8 小時" + 12_hours: "12 小時" + 24_hours: "24 小時" topic_status_update: title: "話題計時器" save: "設定計時器" @@ -2068,6 +2090,7 @@ zh_TW: invisible: "隱藏主題" visible: "顯示主題" reset_read: "重置讀取資料" + make_public: "設置為公共話題..." make_private: "設置為私訊" reset_bump_date: "重設上浮日期" feature: @@ -2295,6 +2318,8 @@ zh_TW: revert_to_regular: "移除工作人員顏色" rebake: "重建 HTML" unhide: "取消隱藏" + change_owner: "更改作者..." + grant_badge: "升級徽章..." lock_post: "封鎖貼文" lock_post_description: "禁止發文者編輯此貼文" unlock_post: "解除封鎖貼文" @@ -2302,6 +2327,7 @@ zh_TW: delete_topic_disallowed_modal: "您沒有權限刪除此話題。若您認為它應被刪除,請向板主檢舉並附上原因。" delete_topic_disallowed: "您沒有刪除此話題的權限。" delete_topic: "刪除話題" + add_post_notice: "加入工作人員通知..." actions: people: like: @@ -2350,10 +2376,13 @@ zh_TW: title: "顯示電子郵件HTML格式" button: "HTML" bookmarks: + create: "建立書籤" edit: "編輯書籤" name: "名稱" options: "選項" actions: + delete_bookmark: + name: "刪除書籤" edit_bookmark: name: "編輯書籤" description: "編輯書籤名稱或更改提醒日期和時間" @@ -2480,9 +2509,12 @@ zh_TW: moderation: "管理" appearance: "外觀" email: "電子信箱" + list_filters: + all: "所有話題" flagging: title: "感謝幫助社群遠離邪惡!" action: "檢舉貼文" + take_action: "執行動作..." take_action_options: default: title: "執行動作" @@ -2597,6 +2629,9 @@ zh_TW: help: "你所關注或追蹤的話題有未讀貼文" lower_title_with_count: other: "%{count} 個未讀" + unseen: + title: "未讀" + lower_title: "未讀" new: lower_title_with_count: other: "%{count} 近期" @@ -2737,6 +2772,7 @@ zh_TW: download_calendar: download: "下載" tagging: + all_tags: "所有標籤" other_tags: "其他標籤" selector_all_tags: "所有標籤" selector_no_tags: "無標籤" @@ -2802,6 +2838,7 @@ zh_TW: delete: "刪除" confirm_delete: "確定要刪除此標籤組嗎?" everyone_can_use: "所有使用者都能使用標籤。" + parent_tag_placeholder: "選擇性" topics: none: unread: "你沒有未讀話題。" @@ -3046,6 +3083,7 @@ zh_TW: user: "使用者" title: "API" created: 已建立 + never_used: (永不) generate: "產生" revoke: "撤銷" all_users: "所有使用者" @@ -3413,6 +3451,7 @@ zh_TW: email_style: heading: "自訂電子郵件風格" css: "CSS" + reset: "重設為預設值" email: title: "電子郵件" settings: "設定" @@ -3908,6 +3947,7 @@ zh_TW: disabled: "在使用者卡片上隱藏" searchable: title: "可搜索" + enabled: "可搜索" field_types: text: "文字區域" confirm: "確認" diff --git a/config/locales/server.hr.yml b/config/locales/server.hr.yml index bbcf8a7c46..912bdaefcf 100644 --- a/config/locales/server.hr.yml +++ b/config/locales/server.hr.yml @@ -67,10 +67,12 @@ hr: modifier_values: "about.json modifikatori sadrže nevažeće vrijednosti: %{errors}" git: "Greška pri kloniranju git repozitorija, pristup je odbijen ili repozitorij nije pronađen" git_ref_not_found: "Nije moguće provjeriti git referencu: %{ref}" + git_unsupported_scheme: "Nije moguće klonirati git repo: shema nije podržana" unpack_failed: "Greška pri otpakiravanju datoteke" file_too_big: "Nekomprimirana datoteka je prevelika." unknown_file_type: "Datoteka koju ste učitali ne čini se važećom Discourse temom." not_allowed_theme: "`%{repo}` nije na popisu dopuštenih tema (provjerite globalnu postavku `allowed_theme_repos`)." + ssh_key_gone: "Predugo ste čekali da instalirate temu i SSH ključ je istekao. Molim te pokušaj ponovno." errors: component_no_user_selectable: "Komponente teme ne može birati korisnik" component_no_default: "Komponente teme ne mogu biti podrazumijevana tema." @@ -100,6 +102,7 @@ hr: incoming: default_subject: "Temi je nužno dodati naslov" show_trimmed_content: "Prikaži skraćeni sadržaj" + maximum_staged_user_per_email_reached: "Dostignut maksimalan broj postupno korisnika stvorenih po e-pošti." no_subject: "(bez naslova)" no_body: "(bez tijela poruke)" missing_attachment: "(Nedostaje prilog %{filename})" @@ -127,6 +130,7 @@ hr: unsubscribe_not_allowed: "Događa se kada ovom korisniku nije dopuštena odjava putem e-pošte." email_not_allowed: "Događa se kada adresa e-pošte nije na popisu dopuštenih ili je na popisu blokiranih." unrecognized_error: "Neprepoznata pogreška" + secure_uploads_placeholder: "Redigirano: Ova stranica ima omogućene sigurne prijenose, posjetite temu ili kliknite Prikaži medije kako biste vidjeli priložene prijenose." view_redacted_media: "Pregledajte medije" errors: &errors format: ! "%{attribute} %{message}" @@ -183,18 +187,55 @@ hr: auth_overrides_username: "Korisničko ime se mora ažurirati na strani davatelja provjere autentičnosti jer je omogućena postavka \"auth_overrides_username\"." template: body: ! "Bilo je problema sa sljedećim poljima:" + header: + one: "Pogreška %{count} zabranila je spremanje ovog %{model}" + few: ! "Pogreška %{count} zabranila je spremanje ovog %{model}" + other: ! "%{count} pogrešaka zabranilo je spremanje ove %{model}" embed: load_from_remote: "Dogodila se greška pri učitavanju objave." + site_settings: + invalid_category_id: "Naveli ste kategoriju koja ne postoji" + invalid_choice: + one: "Naveli ste nevažeći izbor %{name}" + few: "Naveli ste nevažeći izbor %{name}" + other: "Naveli ste nevažeće izbore %{name}" + default_categories_already_selected: "Ne možete odabrati kategoriju koja se koristi na drugom popisu." + default_tags_already_selected: "Ne možete odabrati oznaku koja se koristi na drugom popisu." + s3_upload_bucket_is_required: "Ne možete omogućiti prijenose na S3 osim ako niste naveli 's3_upload_bucket'." + enable_s3_uploads_is_required: "Ne možete omogućiti korištenje S3 inventara osim ako niste omogućili S3 prijenose." + page_publishing_requirements: "Objavljivanje stranice ne može se omogućiti ako je omogućen siguran medij." + s3_backup_requires_s3_settings: "Ne možete koristiti S3 kao pričuvnu lokaciju osim ako niste unijeli '%{setting_name}'." bulk_invite: error: "Dogodila se greška pri učitavanju te datoteke. Molimo kasnije pokušajte ponovno." + topic_invite: + sender_does_not_allow_pm: "Nažalost, ne dopuštate tom korisniku da vam šalje privatne poruke." + user_cannot_see_topic: "%{username} ne može vidjeti temu." backup: operation_already_running: "Operacija je trenutno u toku. Trenutno ne možemo započeti novi posao." backup_file_should_be_tar_gz: "Sigurnosna kopija bi trebala biti .tar.gz arhiv." not_enough_space_on_disk: "Nema dovoljno prostora na disku za učitanje sigurnosne kopije." + invalid_filename: "Naziv sigurnosne datoteke sadrži nevažeće znakove. Važeći znakovi su az 0-9. - _." + file_exists: "Datoteka koju pokušavate učitati već postoji." + invalid_params: "Zahtjevu ste dostavili nevažeće parametre: %{message}" not_logged_in: "Morate biti prijavljeni da to napravite." + not_found: "Zatraženi URL ili izvor nije moguće pronaći." + invalid_access: "Nemate dopuštenje za pregled traženog izvora." + authenticator_not_found: "Metoda provjere autentičnosti ne postoji ili je onemogućena." + authenticator_no_connect: "Ovaj pružatelj provjere autentičnosti ne dopušta povezivanje s postojećim forumskim računom." + invalid_api_credentials: "Nemate dopuštenje za pregled traženog izvora. API korisničko ime ili ključ je nevažeći." + provider_not_enabled: "Nemate dopuštenje za pregled traženog izvora. Davatelj autentifikacije nije omogućen." + provider_not_found: "Nemate dopuštenje za pregled traženog izvora. Davatelj autentifikacije ne postoji." read_only_mode_enabled: "Ova je stranica u \"samo čitanje\" modelu. Interakcije nemoguće." + invalid_grant_badge_reason_link: "Vanjska ili nevažeća veza diskursa nije dopuštena zbog razloga značke" + email_template_cant_be_modified: "Ovaj predložak e-pošte nije moguće mijenjati" + invalid_whisper_access: "Šaptanje nije omogućeno ili nemate pristup stvaranju šaputajućih objava" not_in_group: + title_topic: "Morate zatražiti članstvo u grupi '%{group}' da biste vidjeli ovu temu." + title_category: "Morate zatražiti članstvo u grupi '%{group}' da biste vidjeli ovu kategoriju." + request_membership: "Zahtjev za članstvo" join_group: "Pristupi grupi" + deleted_topic: "Ups! Ova je tema izbrisana i više nije dostupna." + delete_topic_failed: "Došlo je do pogreške prilikom brisanja te teme. Molimo kontaktirajte administratora stranice." reading_time: "Vrijeme čitanja" likes: "Like-ova" too_many_replies: @@ -208,6 +249,12 @@ hr: embed: start_discussion: "Pokreni raspravu" continue: "Nastavi raspravu" + error: "Pogreška pri ugrađivanju" + referer: "Referer:" + error_topics: "Postavka web mjesta `ugradi popis tema` nije bila omogućena" + mismatch: "Referer ili nije poslan ili nije odgovarao nijednom od sljedećih domaćina:" + no_hosts: "Nijedan host nije postavljen za ugrađivanje." + configure: "Konfigurirajte ugrađivanje" more_replies: one: "još %{count} odgovor" few: "još %{count} odgovora" @@ -226,6 +273,7 @@ hr: other: "%{count} likeova" last_reply: "Posljednji odgovor" created: "Stvoreno" + new_topic: "Stvori novu temu" no_mentions_allowed: "Žao nam je, ne možete spominjati druge korisnike." too_many_mentions: one: "Žao nam je, možete spomenuti samo jednog korisnika u objavi." @@ -236,6 +284,8 @@ hr: one: "Žao nam je, novi korisnici mogu spomenuti samo jednog korisnika u objavi." few: "Žao nam je, novi korisnici mogu spomenuti samo %{count} korisnika u objavi." other: "Žao nam je, novi korisnici mogu spomenuti samo %{count} korisnika u objavi." + no_embedded_media_allowed_trust: "Nažalost, ne možete ugraditi medijske stavke u post." + no_embedded_media_allowed: "Nažalost, novi korisnici ne mogu ugraditi medijske stavke u objave." no_attachments_allowed: "Žao nam je novi korisnici ne mogu stavljati privitke u objave." too_many_attachments: one: "Žao nam je, novi korisnici mogu dodati samo jedan prilog u objavu." @@ -247,10 +297,19 @@ hr: one: "Žao nam je, novi korisnici mogu dodati samo jednu poveznicu u objavu." few: "Žao nam je, novi korisnici mogu dodati najviše %{count} poveznica u objavu." other: "Žao nam je, novi korisnici mogu dodati najviše %{count} poveznica u objavu." + contains_blocked_word: "Nažalost, ne možete objaviti riječ '%{word}'; nije dopušteno." + contains_blocked_words: "Nažalost, ne možete to objaviti. Nije dopušteno: %{words}." spamming_host: "Žao nam je, ne možete objaviti poveznicu na tog domaćina." user_is_suspended: "Suspendiranim korisnicima nije dozvoljeno objavljivanje." + topic_not_found: "Nešto nije u redu. Možda je ova tema zatvorena ili obrisana dok ste je gledali?" not_accepting_pms: "Žao nam je, %{username} trenutno ne prihvaća poruke." + max_pm_recipients: "Nažalost, možete poslati poruku najviše %{recipients_limit} primatelja." pm_reached_recipients_limit: "Žao nam je, ne možete imati više od %{recipients_limit} primatelja u poruci." + removed_direct_reply_full_quotes: "Automatski uklonjen citat cijelog prethodnog posta." + watched_words_auto_tag: "Automatski označena tema" + secure_upload_not_allowed_in_public_topic: "Nažalost, sljedeći sigurni prijenosi ne mogu se koristiti u javnoj temi: %{upload_filenames}." + create_pm_on_existing_topic: "Nažalost, ne možete stvoriti PM na postojeću temu." + slow_mode_enabled: "Ova je tema u usporenom načinu rada." just_posted_that: "je pre slično nečemu što ste nedavno objavili" invalid_characters: "sadrži nevažeće znakove" is_invalid: "čini se nejasno, jeste li napisali potpunu rečenicu?" @@ -265,6 +324,10 @@ hr: one: "%{count} objava" few: "%{count} objava" other: "%{count} objava" + rss_num_participants: + one: "%{count} sudionika" + few: "%{count} sudionika" + other: "%{count} sudionika" read_full_topic: "Pročitao cijelu temu" private_message_abbrev: "Poruka" rss_description: @@ -286,8 +349,19 @@ hr: badge: "%{display_name} značka na %{site_title}" too_late_to_edit: "Ova je objava stvorena pre davno. Ne može se više izmijenjivati ni brisati." edit_conflict: "Ovu objavu uredio je drugi korisnik i vaše promjene ne mogu biti spremljene." + revert_version_same: "Trenutna verzija je ista kao verzija na koju se pokušavate vratiti." + cannot_edit_on_slow_mode: "Ova je tema u usporenom načinu rada. Kako bismo potaknuli promišljenu, promišljenu raspravu, uređivanje starih postova u ovoj temi trenutno nije dopušteno tijekom sporog načina rada." excerpt_image: "slika" bookmarks: + errors: + already_bookmarked_post: "Ne možete označiti istu objavu dvaput." + already_bookmarked: "Ne možete označiti istu %{type} dvaput." + too_many: "Nažalost, ne možete dodati više od %{limit} oznaka, posjetite %{user_bookmarks_url} da uklonite neke." + cannot_set_past_reminder: "Ne možete postaviti podsjetnik za oznaku u prošlosti." + cannot_set_reminder_in_distant_future: "Ne možete postaviti podsjetnik za oznaku više od 10 godina u budućnosti." + time_must_be_provided: "Za sve podsjetnike mora se osigurati vrijeme" + for_topic_must_use_first_post: "Za označavanje teme možete koristiti samo prvi post." + bookmarkable_id_type_required: "Potreban je naziv i vrsta zapisa za označavanje." reminders: at_desktop: "Sljedeći put kad sam kod svoje radne površine" later_today: "Kasnije danas" @@ -385,19 +459,57 @@ hr: self_parent: "Kategorija ne može biti sama sebi nadkategorija" depth: "Ne možete postaviti podkategoriju pod neku drugu" invalid_email_in: "'%{email}' nije važeća email adresa." + disallowed_topic_tags: "Ova tema ima oznake koje ova kategorija ne dopušta: '%{tags}'" + disallowed_tags_generic: "Ova tema ima nedopuštene oznake." + slug_contains_non_ascii_chars: "sadrži ne-ascii znakove" + is_already_in_use: "je već u upotrebi" cannot_delete: + uncategorized: "Ova kategorija je posebna. Namijenjen je kao prostor za držanje tema koje nemaju kategoriju; ne može se izbrisati." has_subcategories: "Ne možete obrisati ovu kategoriju jer ima podkategorije" + topic_exists: + one: "Nije moguće izbrisati ovu kategoriju jer ima %{count} tema. Najstarija tema je %{topic_link}." + few: "Nije moguće izbrisati ovu kategoriju jer ima %{count} tema. Najstarija tema je %{topic_link}." + other: "Nije moguće izbrisati ovu kategoriju jer ima %{count} tema. Najstarija tema je %{topic_link}." topic_exists_no_oldest: "Ne možete obrisati ovu kategoriju jer je broj tema %{count}." + uncategorized_description: "Teme za koje nije potrebna kategorija ili koje se ne uklapaju ni u jednu postojeću kategoriju." trust_levels: admin: "Administrator" staff: "Osoblje" change_failed_explanation: "Pokušali ste sanjiti razinu povjerenja %{user_name} na '%{new_trust_level}'. Ali njihova razina povjerenja već jest '%{current_trust_level}'. %{user_name} će ostati na '%{current_trust_level}' - ako želite korisniku smanjiti razinu povjerenja prvo je zaključajte." post: + image_placeholder: + broken: "Ova slika je pokvarena" + blocked_hotlinked_title: "Slika se nalazi na drugom mjestu. Kliknite za otvaranje u novoj kartici." + blocked_hotlinked: "Vanjska slika" + media_placeholder: + blocked_hotlinked_title: "Mediji smješteni na drugom mjestu. Kliknite za otvaranje u novoj kartici." + blocked_hotlinked: "Vanjski mediji" + hidden_bidi_character: "Dvosmjerni znakovi mogu promijeniti redoslijed prikaza teksta. To se može koristiti za prikrivanje zlonamjernog koda." has_likes: one: "%{count} Like" few: "%{count} Likeova" other: "%{count} Likeova" + cannot_permanently_delete: + many_posts: "Ova tema ima poništene postove. Molimo vas da ih trajno izbrišete prije trajnog brisanja teme." + wait_or_different_admin: "Morate pričekati %{time_left} prije trajnog brisanja ove objave ili to mora učiniti drugi administrator." rate_limiter: + slow_down: "Izveli ste ovu radnju previše puta, pokušajte ponovno kasnije." + too_many_requests: "Izveli ste ovu radnju previše puta. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + by_type: + first_day_replies_per_day: "Cijenimo vaš entuzijazam, samo tako nastavite! Ipak, radi sigurnosti naše zajednice, dosegli ste najveći broj odgovora koje novi korisnik može stvoriti prvog dana. Molimo pričekajte %{time_left} i moći ćete stvoriti više odgovora." + first_day_topics_per_day: "Cijenimo vaš entuzijazam! Ipak, radi sigurnosti naše zajednice, dosegli ste najveći broj tema koje novi korisnik može stvoriti prvog dana. Molimo pričekajte %{time_left} i moći ćete stvoriti više novih tema." + create_topic: "Malo prebrzo stvaraš teme. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + create_post: "Malo prebrzo odgovaraš. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + delete_post: "Malo prebrzo brišete postove. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + public_group_membership: "Prečesto se pridružujete/napuštate grupe. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + topics_per_day: "Dosegli ste maksimalni dopušteni broj novih tema po danu. Možete stvoriti više novih tema u %{time_left}." + pms_per_day: "Dosegli ste maksimalan broj dopuštenih poruka po danu. Možete stvoriti više novih poruka u %{time_left}." + create_like: "Wow! Dijelili ste puno ljubavi! Dosegli ste maksimalni broj lajkova unutar razdoblja od 24 sata, ali kako budete stjecali razinu povjerenja, zarađivat ćete više dnevnih lajkova. Ponovno ćete moći označiti da vam se objave sviđaju za %{time_left}." + create_bookmark: "Dosegli ste maksimalan broj dnevnih oznaka. Možete stvoriti više oznaka u %{time_left}." + edit_post: "Dosegli ste najveći broj dnevnih uređivanja. Možete podnijeti više izmjena u %{time_left}." + live_post_counts: "Prebrzo tražite brojanje postova uživo. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + unsubscribe_via_email: "Dosegli ste maksimalni broj odjava putem e-pošte. Molimo pričekajte %{time_left} prije ponovnog pokušaja." + topic_invitations_per_day: "Dosegli ste najveći broj pozivnica za teme. Više pozivnica možete poslati u %{time_left}." hours: one: "%{count} sat" few: "%{count} sati" @@ -410,6 +522,7 @@ hr: one: "%{count} sekunda" few: "%{count} sekundi" other: "%{count} sekundi" + short_time: "nekoliko sekundi" datetime: distance_in_words: half_a_minute: "< 1m" @@ -501,10 +614,12 @@ hr: few: "prije skoro %{count} godina" other: "prije skoro %{count} godina" password_reset: + no_token: 'Ups! Link koji ste koristili više ne radi. Možete se Prijaviti sada. Ako ste zaboravili lozinku, možete zatražiti vezu da je poništite.' title: "Ponovo postavite zaporku" success: "Uspiješno ste promijenili zaporku i sad ste prijavljeni." success_unapproved: "Uspiješno ste promijenili zaporku." email_login: + invalid_token: 'Ups! Link koji ste koristili više ne radi. Možete se Prijaviti sada. Ako ste zaboravili lozinku, možete zatražiti vezu da je poništite.' title: "Prijava emailom" user_auth_tokens: browser: @@ -518,6 +633,7 @@ hr: unknown: "nepoznati preglednik" device: android: "Android uređaj" + chromebook: "OS Chrome" ipad: "iPad" iphone: "iPhone" ipod: "iPod" @@ -528,17 +644,34 @@ hr: unknown: "nepoznati uređaj" os: android: "Android" + chromeos: "OS Chrome" ios: "iOS" linux: "Linux" macos: "macOS" windows: "Microsoft Windows" unknown: "nepoznati operacijski sustav" change_email: + wrong_account_error: "Prijavljeni ste na krivi račun, odjavite se i pokušajte ponovo." confirmed: "Email vam je ažuriran." please_continue: "Nastavite na %{site_name}" error: "Dogodila se greška u promijeni tvog e-maila. Možda se ta adresa već koristi?" + doesnt_exist: "Ta adresa e-pošte nije povezana s vašim računom." error_staged: "Dogodila se greška pri promjeni vaše email adrese. Adresu već koristi drugi korisnik." already_done: "Žao nam je, ova poveznica za potvrdu nije više važeća. Možda je vaš email već promijenjen?" + confirm: "Potvrdi" + max_secondary_emails_error: "Dosegli ste maksimalno dopušteno ograničenje sekundarne e-pošte." + authorizing_new: + title: "Potvrdite svoju novu e-poštu" + description: "Molimo potvrdite da želite da se vaša adresa e-pošte promijeni u:" + description_add: "Potvrdite da želite dodati zamjensku adresu e-pošte:" + authorizing_old: + title: "Promijenite adresu e-pošte" + description: "Molimo potvrdite promjenu adrese e-pošte" + description_add: "Potvrdite da želite dodati zamjensku adresu e-pošte:" + old_email: "Stari email: %{email}" + new_email: "Novi email: %{email}" + almost_done_title: "Potvrđivanje nove adrese e-pošte" + almost_done_description: "Poslali smo e-poruku na vašu novu adresu e-pošte kako bismo potvrdili promjenu!" associated_accounts: connected: "(povezano)" activation: @@ -614,9 +747,17 @@ hr: title: "Odjava" stop_watching_topic: "Prestanite pratiti ovu temu, %{link}" mute_topic: "Prigušite sve obavijesti za ovu temu, %{link}" + mailing_list_mode: "Isključite način popisa za slanje e-pošte" + all: "Ne šalji mi nikakvu poštu od %{sitename}" + different_user_description: "Trenutno ste prijavljeni kao drugi korisnik od onog kojem smo poslali e-poštu. Odjavite se ili uđite u anonimni način rada i pokušajte ponovno." + not_found_description: "Nažalost, nismo mogli pronaći tu pretplatu. Moguće je da je poveznica u vašoj e-pošti prestara i da je istekla?" + user_not_found_description: "Nažalost, nismo mogli pronaći korisnika za ovu pretplatu. Vjerojatno pokušavate otkazati pretplatu na račun koji više ne postoji." log_out: "Odjava" submit: "Spremite postavke" digest_frequency: + title: "Primate e-poštu sa sažetkom %{frequency}" + never_title: "Ne primate e-poruke sa sažetkom" + select_title: "Postavite učestalost e-pošte sa sažetkom na:" never: "nikad" every_30_minutes: "svakih 30 minuta" every_hour: "svaki sat" @@ -625,32 +766,77 @@ hr: every_month: "svaki mjesec" every_six_months: "svakih šest mjeseci" user_api_key: + title: "Odobrite pristup aplikaciji" + authorize: "Odobriti" read: "pročitano" + read_write: "čitati/pisati" + description: '"%{application_name}" zahtijeva sljedeći pristup vašem računu:' + instructions: 'Upravo smo generirali novi korisnički API ključ za korištenje s "%{application_name}", zalijepite sljedeći ključ u svoju aplikaciju:' + otp_description: 'Želite li dopustiti "%{application_name}" pristup ovoj stranici?' otp_confirmation: confirm_title: Nastavite na %{site_name} logging_in_as: Prijava kao %{username} confirm_button: Završi prijavu + no_trust_level: "Nažalost, nemate potrebnu razinu povjerenja za pristup korisničkom API-ju" + generic_error: "Žao nam je, ne možemo izdati korisničke API ključeve, ovu je značajku možda onemogućio administrator stranice" scopes: + message_bus: "Ažuriranja uživo" + notifications: "Čitanje i brisanje obavijesti" push: "Push obavijesti vanjskim uslugama" + session_info: "Pročitajte podatke o korisničkoj sesiji" + read: "Pročitaj sve" + write: "Napiši sve" + one_time_password: "Stvorite jednokratni token za prijavu" + bookmarks_calendar: "Čitajte podsjetnike za oznake" + user_status: "Pročitajte i ažurirajte status korisnika" + invalid_public_key: "Nažalost, javni ključ je nevažeći." + invalid_auth_redirect: "Nažalost, ovaj auth_redirect host nije dopušten." + invalid_token: "Token nedostaje, nije valjan ili je istekao." + flags: + errors: + already_handled: "Zastava je već obrađena" reports: default: labels: count: Količina + percent: Postotak day: Dan post_edits: + title: "Post uređivanja" labels: edited_at: Datum post: Objava editor: Urednik + author: Autor edit_reason: Razlog + description: "Broj novih izmjena posta." user_flagging_ratio: + title: "Omjer označavanja korisnika" labels: user: Korisnik + agreed_flags: Dogovorene zastave + disagreed_flags: Nesložene zastave + ignored_flags: Ignorirane zastave score: Ocjena + description: "Popis korisnika poredan prema omjeru odgovora osoblja na njihove oznake (ne slažem se slažem se)." moderators_activity: + title: "Aktivnost moderatora" labels: moderator: Moderator + flag_count: Zastavice pregledane + time_read: Vrijeme čitanja + topic_count: Teme stvorene + post_count: Postovi stvoreni + pm_count: Stvoreni PM-ovi + revision_count: Revizije + description: Popis aktivnosti moderatora uključujući pregledane zastavice, vrijeme čitanja, stvorene teme, stvorene postove, stvorene osobne poruke i revizije. flags_status: + title: "Status zastava" + values: + agreed: Dogovoreno + disagreed: Ne slažem se + deferred: Odgođeno + no_action: Nema akcije labels: flag: Tip visits: @@ -722,20 +908,33 @@ hr: yaxis: "Broj emailova" user_to_user_private_messages: xaxis: "Dan" + yaxis: "Broj poruka" + description: "Broj novopokrenutih osobnih poruka." user_to_user_private_messages_with_replies: + title: "Od korisnika do korisnika (s odgovorima)" xaxis: "Dan" + yaxis: "Broj poruka" + description: "Broj svih novih osobnih poruka i odgovora." system_private_messages: title: "Sistem" xaxis: "Dan" + yaxis: "Broj poruka" + description: "Broj osobnih poruka koje je sustav automatski poslao." moderator_warning_private_messages: title: "Moderatorskih upozorenja" xaxis: "Dan" + yaxis: "Broj poruka" + description: "Broj upozorenja poslanih osobnim porukama moderatora." notify_moderators_private_messages: title: "Obavijesti moderatore" xaxis: "Dan" + yaxis: "Broj poruka" + description: "Koliko su puta moderatori privatno obaviješteni oznakom." notify_user_private_messages: title: "Obavijesti korisnika" xaxis: "Dan" + yaxis: "Broj poruka" + description: "Koliko su puta korisnici privatno obaviješteni oznakom." top_referrers: title: "Najaktivniji preporučitelji" xaxis: "Korisnik" @@ -745,6 +944,7 @@ hr: user: "Korisnik" num_clicks: "Klikova" num_topics: "Teme" + description: "Korisnici navedeni prema broju klikova na poveznice koje su podijelili." top_traffic_sources: title: "Najaktivnijih izvora prometa" xaxis: "Domena" @@ -755,57 +955,93 @@ hr: domain: Domena num_clicks: Klikova num_topics: Teme + description: "Vanjski izvori koji su imali najviše poveznica na ovu stranicu." top_referred_topics: title: "Najpreporučavanije teme" labels: num_clicks: "Klikova" topic: "Teme" + description: "Teme koje su dobile najviše klikova iz vanjskih izvora." page_view_anon_reqs: title: "Anonimno" xaxis: "Dan" + yaxis: "Anonimni prikazi stranice" + description: "Broj novih prikaza stranica od strane posjetitelja koji nisu prijavljeni na račun." page_view_logged_in_reqs: + title: "Prijavljeni" xaxis: "Dan" + yaxis: "Prijavljeni prikazi stranica" + description: "Broj novih prikaza stranica od prijavljenih korisnika." page_view_crawler_reqs: + title: "Web Crawler prikazi stranica" xaxis: "Dan" + yaxis: "Web Crawler prikazi stranica" + description: "Ukupni broj prikaza stranica od alata za indeksiranje tijekom vremena." page_view_total_reqs: title: "Pregledi stranica" xaxis: "Dan" + yaxis: "Ukupni broj prikaza stranice" + description: "Broj novih prikaza stranica od svih posjetitelja." page_view_logged_in_mobile_reqs: + title: "Prijavljeni prikazi stranica" xaxis: "Dan" + yaxis: "Prikazi stranice prijavljeni s mobilnog uređaja" + description: "Broj novih prikaza stranica od korisnika na mobilnim uređajima i prijavljenih na račun." page_view_anon_mobile_reqs: + title: "Anon prikazi stranice" xaxis: "Dan" + yaxis: "Mobilni Anon prikazi stranica" + description: "Broj novih prikaza stranica od posjetitelja na mobilnom uređaju koji nisu prijavljeni." http_background_reqs: + title: "Pozadina" xaxis: "Dan" + yaxis: "Zahtjevi koji se koriste za ažuriranje uživo i praćenje" http_2xx_reqs: title: "Status 2xx (OK)" xaxis: "Dan" + yaxis: "Uspješni zahtjevi (status 2xx)" http_3xx_reqs: + title: "HTTP 3xx (Preusmjeravanje)" xaxis: "Dan" + yaxis: "Zahtjevi za preusmjeravanje (Status 3xx)" http_4xx_reqs: title: "HTTP 4xx (Klijentska greška)" xaxis: "Dan" yaxis: "Klijentske greške (Status 4xx)" http_5xx_reqs: + title: "HTTP 5xx (pogreška poslužitelja)" xaxis: "Dan" + yaxis: "Pogreške poslužitelja (status 5xx)" http_total_reqs: title: "Ukupno" xaxis: "Dan" + yaxis: "Ukupno zahtjeva" time_to_first_response: + title: "Vrijeme do prvog odgovora" xaxis: "Dan" yaxis: "Prosječno vrijeme (u satima)" description: "Prosječno vrijeme (u satima) do prvog odgovora u novim temama." topics_with_no_response: + title: "Teme bez odgovora" xaxis: "Dan" yaxis: "Ukupno" + description: "Broj stvorenih novih tema koje nisu dobile odgovor." mobile_visits: + title: "Korisničke posjete (mobilni)" xaxis: "Dan" yaxis: "Broj posjeta" + description: "Broj jedinstvenih korisnika koji su posjetili putem mobilnog uređaja." web_crawlers: + title: "Korisnički agenti web indeksiranja" labels: + user_agent: "Korisnički agent" page_views: "Pregledi stranica" + description: "Popis korisničkih agenata alata za indeksiranje, poredanih po prikazima stranica." suspicious_logins: + title: "Sumnjive prijave" labels: user: Korisnik + client_ip: IP klijenta location: Lokacija staff_logins: labels: @@ -834,12 +1070,412 @@ hr: memory_warning: "Server vam radi s manje od 1 GB ukupne memorije. Barem 1 GB se preporuča." site_settings: disabled: "onemogućen" + allow_user_locale: "Dopustite korisnicima da odaberu vlastite postavke jezika sučelja" + set_locale_from_accept_language_header: "postaviti jezik sučelja za anonimne korisnike iz jezičnih zaglavlja njihovih web preglednika" + support_mixed_text_direction: "Podržava mješoviti smjer teksta slijeva nadesno i zdesna nalijevo." + min_post_length: "Minimalna dopuštena duljina posta u znakovima" + min_first_post_length: "Minimalna dopuštena duljina prve objave (tijelo teme) u znakovima" + min_personal_message_post_length: "Minimalna dopuštena duljina posta u znakovima za poruke" + max_post_length: "Najveća dopuštena duljina posta u znakovima" + topic_featured_link_enabled: "Omogući objavljivanje poveznice s temama." + show_topic_featured_link_in_digest: "Pokažite istaknutu vezu na temu u sažetku e-pošte." + min_topic_views_for_delete_confirm: "Minimalni broj prikaza koje tema mora imati da bi se pojavio skočni prozor za potvrdu kada se izbriše" + min_topic_title_length: "Minimalna dopuštena duljina naslova teme u znakovima" + max_topic_title_length: "Najveća dopuštena duljina naslova teme u znakovima" + min_personal_message_title_length: "Minimalna dopuštena duljina naslova za poruku u znakovima" + max_emojis_in_title: "Maksimalno dopušteni emotikoni u naslovu teme" + min_search_term_length: "Minimalna važeća duljina pojma za pretraživanje u znakovima" + search_tokenize_chinese: "Prisilno pretraživanje tokenizira kineski čak i na stranicama koje nisu kineske" + search_tokenize_japanese: "Prisilno pretraživanje tokenizira japanski čak i na stranicama koje nisu japanske" + search_prefer_recent_posts: "Ako je pretraživanje vašeg velikog foruma sporo, ova opcija prvo pokušava indeksirati novije postove" + search_recent_posts_size: "Koliko nedavnih postova zadržati u indeksu" + log_search_queries: "Zabilježite upite pretraživanja koje su izvršili korisnici" + search_query_log_max_size: "Maksimalna količina upita za pretraživanje koje treba zadržati" + search_query_log_max_retention_days: "Maksimalno vrijeme za čuvanje upita za pretraživanje, u danima." + search_ignore_accents: "Zanemarite naglaske kada tražite tekst." + category_search_priority_low_weight: "Težina primijenjena na rangiranje za niski prioritet pretraživanja kategorije." + category_search_priority_high_weight: "Težina primijenjena na rangiranje za visoku kategoriju prioriteta pretraživanja." + default_composer_category: "Kategorija koja se koristi za prethodno popunjavanje padajućeg izbornika kategorija prilikom stvaranja nove teme." + allow_uncategorized_topics: "Dopusti stvaranje tema bez kategorije. UPOZORENJE: Ako postoje nekategorizirane teme, morate ih ponovno kategorizirati prije nego što ovo isključite." + allow_duplicate_topic_titles: "Dopusti teme s identičnim, dvostrukim naslovima." + allow_duplicate_topic_titles_category: "Dopusti teme s identičnim, dvostrukim naslovima ako je kategorija drugačija. allow_duplicate_topic_titles mora biti onemogućen." + unique_posts_mins: "Koliko minuta prije nego što korisnik može ponovno objaviti objavu s istim sadržajem" + educate_until_posts: "Kada korisnik počne tipkati svojih prvih (n) novih postova, prikaži skočnu ploču za obrazovanje novog korisnika u sastavljaču." + title: "Naziv ove stranice. Vidljivo svim posjetiteljima uključujući anonimne korisnike." + site_description: "Opišite ovo mjesto jednom rečenicom. Vidljivo svim posjetiteljima uključujući anonimne korisnike." + short_site_description: "Kratak opis u nekoliko riječi. Vidljivo svim posjetiteljima uključujući anonimne korisnike." + contact_email: "Adresa e-pošte ključnog kontakta odgovornog za ovu stranicu. Koristi se za kritične obavijesti, a također se prikazuje na /oko. Vidljivo anonimnim korisnicima na javnim stranicama." + contact_url: "Kontakt URL za ovu stranicu. Kada je prisutna, zamjenjuje adresu e-pošte na /oko i vidljiva je anonimnim korisnicima na javnim stranicama." + crawl_images: "Dohvatite slike s udaljenih URL-ova da biste umetnuli ispravne dimenzije širine i visine." + download_remote_images_to_local: "Pretvorite udaljene (hotlinked) slike u lokalne slike njihovim preuzimanjem; Ovo čuva sadržaj čak i ako se slike u budućnosti uklone s udaljenog mjesta." + download_remote_images_threshold: "Minimalni prostor na disku potreban za lokalno preuzimanje udaljenih slika (u postocima)" + disabled_image_download_domains: "Udaljene slike nikada neće biti preuzete s ovih domena. Popis odijeljen crtom." + block_hotlinked_media: "Spriječite korisnike da u svoje postove uvedu udaljene (povezane) medije. Udaljeni medij koji nije preuzet putem 'download_remote_images_to_local' bit će zamijenjen vezom rezerviranog mjesta." + block_hotlinked_media_exceptions: "Popis osnovnih URL-ova koji su izuzeti iz postavke block_hotlinked_media. Uključite protokol (npr. https://example.com)." + editing_grace_period: "(n) sekundi nakon objavljivanja, uređivanje neće stvoriti novu verziju u povijesti postova." + editing_grace_period_max_diff: "Maksimalan broj izmjena znakova dopušten u razdoblju odgode uređivanja, ako je više promjena pohrani drugu objavu revizije (razina pouzdanosti 0 i 1)" + editing_grace_period_max_diff_high_trust: "Maksimalan broj izmjena znakova dopušten u razdoblju odgode uređivanja, ako se više promijeni pohrani drugu objavu revizije (razina povjerenja 2 i više)" + staff_edit_locks_post: "Postovi će biti zaključani za uređivanje ako ih uređuju članovi osoblja" + post_edit_time_limit: "Tl0 ili tl1 autor može uređivati svoju objavu (n) minuta nakon objave. Postavite na 0 zauvijek." + tl2_post_edit_time_limit: "Tl2+ autor može uređivati svoju objavu (n) minuta nakon objave. Postavite na 0 zauvijek." + edit_history_visible_to_public: "Dopustite svima da vide prethodne verzije uređenog posta. Kada je onemogućeno, samo članovi osoblja mogu vidjeti." + delete_removed_posts_after: "Postovi koje je autor uklonio automatski će se izbrisati nakon (n) sati. Ako je postavljeno na 0, postovi će se odmah izbrisati." + enable_chunked_encoding: "Omogući odgovore kodiranja u komadima od strane poslužitelja. Ova značajka radi na većini postavki, no neki proxyji mogu raditi u međuspremniku, uzrokujući kašnjenje odgovora" + long_polling_base_url: "Osnovni URL koji se koristi za dugo ispitivanje (kada CDN poslužuje dinamički sadržaj, svakako ovo postavite na izvorno povlačenje), npr.: http://origin.site.com" + polling_interval: "Kada anketiranje nije dugo, koliko često bi prijavljeni klijenti trebali anketirati u milisekundama" + anon_polling_interval: "Koliko često bi anonimni klijenti trebali anketirati u milisekundama" + background_polling_interval: "Koliko često bi klijenti trebali anketirati u milisekundama (kada je prozor u pozadini)" + hide_post_sensitivity: "Vjerojatnost da će označena objava biti skrivena" + silence_new_user_sensitivity: "Vjerojatnost da će novi korisnik biti ušutkan na temelju oznaka neželjene pošte" + auto_close_topic_sensitivity: "Vjerojatnost da će označena tema biti automatski zatvorena" + cooldown_minutes_after_hiding_posts: "Broj minuta koje korisnik mora čekati prije nego što može urediti post skriven putem označavanja zajednice" + max_topics_in_first_day: "Maksimalan broj tema koje korisnik smije kreirati u razdoblju od 24 sata nakon što je napravio svoju prvu objavu" + max_replies_in_first_day: "Maksimalan broj odgovora koje korisnik smije stvoriti u razdoblju od 24 sata nakon što je napravio svoju prvu objavu" + tl2_additional_likes_per_day_multiplier: "Povećajte ograničenje lajkova po danu za tl2 (član) množenjem s ovim brojem" + tl3_additional_likes_per_day_multiplier: "Povećajte ograničenje lajkova po danu za tl3 (regular) množenjem s ovim brojem" + tl4_additional_likes_per_day_multiplier: "Povećajte ograničenje lajkova po danu za tl4 (voditelj) množenjem s ovim brojem" + tl2_additional_edits_per_day_multiplier: "Povećajte ograničenje uređivanja po danu za tl2 (član) množenjem s ovim brojem" + tl3_additional_edits_per_day_multiplier: "Povećajte ograničenje uređivanja po danu za tl3 (regular) množenjem s ovim brojem" + tl4_additional_edits_per_day_multiplier: "Povećajte ograničenje uređivanja po danu za tl4 (voditelj) množenjem s ovim brojem" + tl2_additional_flags_per_day_multiplier: "Povećajte ograničenje zastavica po danu za tl2 (član) množenjem s ovim brojem" + tl3_additional_flags_per_day_multiplier: "Povećajte ograničenje zastavica po danu za tl3 (regular) množenjem s ovim brojem" + tl4_additional_flags_per_day_multiplier: "Povećajte ograničenje zastavica po danu za tl4 (voditelj) množenjem s ovim brojem" + num_users_to_silence_new_user: "Ako postovi novog korisnika dobiju num_spam_flags_to_silence_new_user zastavice neželjene pošte od toliko različitih korisnika, sakrijte sve njihove postove i spriječite buduće objavljivanje. 0 za onemogućavanje." + num_tl3_flags_to_silence_new_user: "Ako postovi novog korisnika dobiju ovoliko oznaka od num_tl3_users_to_silence_new_user različitih korisnika razine povjerenja 3, sakrijte sve njihove postove i spriječite buduća objavljivanja. 0 za onemogućavanje." + num_tl3_users_to_silence_new_user: "Ako postovi novog korisnika dobiju oznake num_tl3_flags_to_silence_new_user od toliko različitih korisnika razine povjerenja 3, sakrijte sve njihove postove i spriječite buduća objavljivanja. 0 za onemogućavanje." + notify_mods_when_user_silenced: "Ako je korisnik automatski utišan, pošaljite poruku svim moderatorima." + flag_sockpuppets: "Ako novi korisnik odgovori na temu s iste IP adrese kao i korisnik koji je otvorio temu, obje njihove objave označite kao potencijalnu neželjenu poštu." + traditional_markdown_linebreaks: "Koristite tradicionalne prijelome redaka u Markdownu, koji zahtijevaju dva razmaka na kraju za prijelom retka." + enable_markdown_typographer: "Koristite pravila tipografije za poboljšanje čitljivosti teksta: zamijenite ravne navodnike ' s vitičastim navodnicima ', (c) (tm) sa simbolima, -- s emdash –, itd." + enable_markdown_linkify: "Automatski tretiraj tekst koji izgleda kao veza kao vezu: www.example.com i https://example.com bit će automatski povezani" + markdown_linkify_tlds: "Popis domena najviše razine koje se automatski tretiraju kao veze" + markdown_typographer_quotation_marks: "Popis zamjenskih parova dvostrukih i jednostrukih navodnika" + post_undo_action_window_mins: "Broj minuta koliko je korisnicima dopušteno poništiti nedavne radnje na objavi (sviđa mi se, označiti itd.)." + must_approve_users: "Osoblje mora odobriti sve nove korisničke račune prije nego im se dopusti pristup stranici." + invite_code: "Korisnik mora upisati ovaj kod kako bi mu se omogućila registracija računa, zanemaruje se kada je prazan (neosjetljivo je na velika i mala slova)" + approve_suspect_users: "Dodajte sumnjive korisnike u red čekanja za pregled. Sumnjivi korisnici ušli su u biografiju/web stranicu, ali nemaju aktivnosti čitanja." + review_every_post: "Sve objave moraju biti pregledane. UPOZORENJE! NE PREPORUČUJE SE ZA PROMETNA MJESTA." + pending_users_reminder_delay_minutes: "Obavijesti moderatore ako novi korisnici čekaju na odobrenje dulje od ovoliko minuta. Postavite na -1 da biste onemogućili obavijesti." + persistent_sessions: "Korisnici će ostati prijavljeni kada se web preglednik zatvori" + maximum_session_age: "Korisnik će ostati prijavljen n sati od zadnje posjete" + ga_version: "Verzija Google Universal Analyticsa za korištenje: v3 (analytics.js), v4 (gtag)" + ga_universal_tracking_code: "Google Universal Analytics ID koda za praćenje, npr.: UA-12345678-9; pogledajte https://google.com/analytics" + ga_universal_domain_name: "Google Universal Analytics naziv domene, npr.: mysite.com; pogledajte https://google.com/analytics" + ga_universal_auto_link_domains: "Omogućite Google Universal Analytics praćenje među domenama. Odlazne veze na te domene imat će dodan ID klijenta. Pogledajte Googleov vodič za praćenje među domenama." + gtm_container_id: "ID spremnika Google upravitelja oznaka. npr.: GTM-ABCDEF.
    Napomena: Skripte trećih strana koje učitava GTM možda će morati biti stavljene na popis dopuštenih u 'src skripte sigurnosne politike sadržaja'." + enable_escaped_fragments: "Vratite se na Googleov Ajax-Crawling API ako se ne otkrije alat za indeksiranje. Pogledajte https://developers.google.com/webmasters/ajax-crawling/docs/learn-more" + moderators_manage_categories_and_groups: "Dopustite moderatorima stvaranje i upravljanje kategorijama i grupama" + moderators_change_post_ownership: "Dopusti moderatorima promjenu vlasništva posta" + cors_origins: "Dopušteni izvori za zahtjeve s različitim izvorima (CORS). Svako podrijetlo mora sadržavati http:// ili https://. Varijabla env DISCOURSE_ENABLE_CORS mora biti postavljena na true da bi se omogućio CORS." + use_admin_ip_allowlist: "Administratori se mogu prijaviti samo ako su na IP adresi definiranoj na popisu Screened IPs (Administrator > Dnevnici > Screened Ips)." + blocked_ip_blocks: "Popis privatnih IP blokova koje Discourse nikada ne bi trebao indeksirati" + allowed_internal_hosts: "Popis internih hostova koje diskurs može sigurno indeksirati za oneboxing i druge svrhe" version_checks: "Pingaj Discourse Hub za dostupna ažuriranja i prikaži obavijesti o novim verzijama na /admin nadzornoj ploči" + min_username_length: "Minimalna duljina korisničkog imena u znakovima. UPOZORENJE: ako bilo koji postojeći korisnik ili grupa ima imena kraća od ovoga, vaša stranica će se pokvariti!" + max_username_length: "Maksimalna duljina korisničkog imena u znakovima. UPOZORENJE: ako postojeći korisnici ili grupe imaju imena duža od ovog, vaša će stranica pokvariti!" + unicode_usernames: "Dopustite da korisnička imena i nazivi grupa sadrže Unicode slova i brojke." + allowed_unicode_username_characters: "Regularni izraz koji dopušta samo neke Unicode znakove unutar korisničkih imena. ASCII slova i brojevi uvijek će biti dopušteni i ne moraju biti uključeni u popis dopuštenih." + reserved_usernames: "Korisnička imena za koja nije dopuštena prijava. Simbol zamjenskog znaka * može se koristiti za podudaranje bilo kojeg znaka nula ili više puta." + min_password_length: "Minimalna duljina lozinke." + min_admin_password_length: "Minimalna duljina lozinke za Admin." + password_unique_characters: "Minimalni broj jedinstvenih znakova koje lozinka mora sadržavati." + block_common_passwords: "Nemojte dopustiti lozinke koje se nalaze među 10.000 najčešćih lozinki." + auth_skip_create_confirm: Kada se prijavljujete putem vanjske autorizacije, preskočite skočni prozor za kreiranje računa. Najbolje se upotrebljava uz auth_overrides_email, auth_overrides_username i auth_overrides_name. + auth_immediately: "Automatsko preusmjeravanje na vanjski sustav prijave bez interakcije korisnika. Ovo stupa na snagu samo kada je login_required istinito i postoji samo jedna vanjska metoda provjere autentičnosti" + enable_discourse_connect: "Omogućite prijavu putem DiscourseConnect (ranije 'Discourse SSO') (UPOZORENJE: ADRESE E-POŠTE KORISNIKA *MORAJU* BITI POTVRĐENE NA VANJSKOJ MJESTO!)" + verbose_discourse_connect_logging: "Zabilježite opširnu dijagnostiku vezanu uz DiscourseConnect na /zapisuje" + enable_discourse_connect_provider: "Implementacija protokola pružatelja usluga DiscourseConnect (prije 'Discourse SSO') na krajnjoj točki /session/sso_provider, zahtijeva postavljanje discourse_connect_provider_secrets" + discourse_connect_url: "URL krajnje točke DiscourseConnect (mora sadržavati http:// ili https://)" + discourse_connect_secret: "Tajni niz koji se koristi za kriptografsku provjeru autentičnosti informacija DiscourseConnecta, pazite da ima 10 znakova ili više" + discourse_connect_provider_secrets: "Popis parova domena-tajna koji koriste DiscourseConnect. Provjerite ima li DiscourseConnect tajna 10 znakova ili više. Zamjenski simbol * može se koristiti za podudaranje bilo koje domene ili samo njenog dijela (npr. *.example.com)." + discourse_connect_overrides_bio: "Nadjačava korisničku biografiju u korisničkom profilu i sprječava korisnika da je promijeni" + discourse_connect_overrides_groups: "Sinkronizirajte sva ručna članstva u grupama s grupama navedenim u atributu grupa (UPOZORENJE: ako ne navedete grupe, sva ručna članstva u grupama bit će izbrisana za korisnika)" + auth_overrides_email: "Zamjenjuje lokalnu e-poštu e-poštom vanjske stranice pri svakoj prijavi i sprječava lokalne promjene. Primjenjuje se na sve pružatelje autentifikacije. (UPOZORENJE: zbog normalizacije lokalne e-pošte može doći do odstupanja)" + auth_overrides_username: "Zamjenjuje lokalno korisničko ime korisničkim imenom vanjske stranice pri svakoj prijavi i sprječava lokalne promjene. Primjenjuje se na sve pružatelje autentifikacije. (UPOZORENJE: odstupanja mogu nastati zbog razlika u duljini/zahtjevima korisničkog imena)" + auth_overrides_name: "Zamjenjuje lokalni puni naziv s punim imenom vanjske stranice pri svakoj prijavi i sprječava lokalne promjene. Primjenjuje se na sve pružatelje autentifikacije." + discourse_connect_overrides_avatar: "Zamjenjuje korisnički avatar vrijednošću iz sadržaja DiscourseConnecta. Ako je omogućeno, korisnicima neće biti dopušteno učitavanje avatara na Discourse." + discourse_connect_overrides_location: "Nadjačava korisničku lokaciju vrijednošću iz sadržaja DiscourseConnect i sprječava lokalne promjene." + discourse_connect_overrides_website: "Nadjačava korisničku web stranicu vrijednošću iz DiscourseConnecta i sprječava lokalne promjene." + discourse_connect_overrides_profile_background: "Nadjačava pozadinu korisničkog profila vrijednošću iz nosivosti DiscourseConnecta." + discourse_connect_overrides_card_background: "Zamjenjuje pozadinu korisničke kartice s vrijednošću iz sadržaja DiscourseConnect." + discourse_connect_not_approved_url: "Preusmjerite neodobrene DiscourseConnect račune na ovaj URL" + discourse_connect_allows_all_return_paths: "Nemojte ograničavati domenu za return_paths koje pruža DiscourseConnect (prema zadanim postavkama povratna staza mora biti na trenutnom mjestu)" + enable_local_logins: "Omogućite račune temeljene na prijavi s lokalnim korisničkim imenom i lozinkom. UPOZORENJE: ako je onemogućeno, možda se nećete moći prijaviti ako prethodno niste konfigurirali barem jedan alternativni način prijave." + enable_local_logins_via_email: "Omogućite korisnicima da zatraže vezu za prijavu jednim klikom da im se pošalje putem e-pošte." + allow_new_registrations: "Dopusti registracije novih korisnika. Poništite ovu opciju kako biste spriječili bilo koga da stvori novi račun." + enable_signup_cta: "Pokaži obavijest anonimnim korisnicima koji se vraćaju i traži ih da se prijave za račun." + enable_google_oauth2_logins: "Omogući Google Oauth2 autentifikaciju. Ovo je metoda provjere autentičnosti koju Google trenutačno podržava. Zahtijeva ključ i tajnu. Pogledajte Konfiguriranje Google prijave za Discourse." + google_oauth2_client_id: "ID klijenta vaše Google aplikacije." + google_oauth2_client_secret: "Tajna klijenta vaše Google aplikacije." + google_oauth2_prompt: "Izborni razmakom razgraničeni popis vrijednosti niza koji navodi hoće li autorizacijski poslužitelj tražiti od korisnika ponovnu provjeru autentičnosti i pristanak. Pogledajte https://developers.google.com/identity/protocols/OpenIDConnect#prompt za moguće vrijednosti." + google_oauth2_hd: "Neobavezna domena s hostom za Google Apps na koju će prijava biti ograničena. Pogledajte https://developers.google.com/identity/protocols/OpenIDConnect#hd-param za više pojedinosti." + google_oauth2_hd_groups: "(eksperimentalno) Dohvaćanje korisničkih Google grupa na hostiranoj domeni nakon provjere autentičnosti. Dohvaćene Google grupe mogu se koristiti za dodjelu automatskog članstva u Discourse grupi (pogledajte postavke grupe). Za više informacija pogledajte https://meta.discourse.org/t/226850" + google_oauth2_hd_groups_service_account_admin_email: "Adresa e-pošte koja pripada Google Workspace administratorskom računu. Koristit će se s vjerodajnicama računa usluge za dohvaćanje informacija o grupi." + google_oauth2_hd_groups_service_account_json: "JSON formatirane ključne informacije za račun usluge. Koristit će se za dohvaćanje informacija o grupi." + enable_twitter_logins: "Omogući provjeru autentičnosti Twittera, zahtijeva twitter_consumer_key i twitter_consumer_secret. Pogledajte Konfiguriranje prijave na Twitter (i obogaćenih umetanja) za Discourse." + twitter_consumer_key: "Potrošački ključ za Twitter autentifikaciju, registriran na https://developer.twitter.com/apps" + twitter_consumer_secret: "Potrošačka tajna za autentifikaciju na Twitteru, registrirana na https://developer.twitter.com/apps" + enable_facebook_logins: "Omogući Facebook autentifikaciju, zahtijeva facebook_app_id i facebook_app_secret. Pogledajte Konfiguriranje Facebook prijave za Discourse." + facebook_app_id: "ID aplikacije za Facebook autentifikaciju i dijeljenje, registriran na https://developers.facebook.com/apps" + facebook_app_secret: "Tajna aplikacije za Facebook autentifikaciju, registrirana na https://developers.facebook.com/apps" + enable_github_logins: "Omogući GitHub autentifikaciju, zahtijeva github_client_id i github_client_secret. Pogledajte Konfiguriranje prijave na GitHub za Discourse." + github_client_id: "ID klijenta za GitHub autentifikaciju, registriran na https://github.com/settings/developers" + github_client_secret: "Tajna klijenta za GitHub autentifikaciju, registrirana na https://github.com/settings/developers" + suggested_topics_max_days_old: "Predložene teme ne smiju biti starije od n dana." + suggested_topics_unread_max_days_old: "Predložene nepročitane teme ne smiju biti starije od n dana." + clean_up_uploads: "Uklonite nereferencirana prijenosa siročadi kako biste spriječili ilegalno hosting. UPOZORENJE: možda ćete htjeti napraviti sigurnosnu kopiju svog direktorija /uploads prije nego omogućite ovu postavku." + clean_orphan_uploads_grace_period_hours: "Razdoblje odgode (u satima) prije nego što se otpremanje siroče ukloni." + purge_deleted_uploads_grace_period_days: "Razdoblje odgode (u danima) prije brisanja izbrisanog prijenosa." + purge_unactivated_users_grace_period_days: "Razdoblje odgode (u danima) prije brisanja korisnika koji nije aktivirao svoj račun. Postavite na 0 da nikada ne brišete neaktivirane korisnike." + enable_s3_uploads: "Postavite prijenose na Amazon S3 pohranu. VAŽNO: zahtijeva važeće S3 vjerodajnice (i ID pristupnog ključa i tajni pristupni ključ)." + s3_use_iam_profile: 'Upotrijebite AWS EC2 profil instance da biste odobrili pristup S3 spremniku. NAPOMENA: omogućavanje ovoga zahtijeva da se Discourse izvodi u prikladno konfiguriranoj EC2 instanci i nadjačava postavke "s3 pristupni ključ ID" i "s3 tajni pristupni ključ".' + s3_upload_bucket: "Ime Amazon S3 spremnika u koji će se datoteke učitati. UPOZORENJE: mora biti malim slovima, bez točaka, bez podvlaka." + s3_access_key_id: "ID pristupnog ključa Amazon S3 koji će se koristiti za učitavanje slika, privitaka i sigurnosnih kopija." + s3_secret_access_key: "Amazon S3 tajni pristupni ključ koji će se koristiti za učitavanje slika, privitaka i sigurnosnih kopija." + s3_region: "Naziv regije Amazon S3 koji će se koristiti za učitavanje slika i sigurnosnih kopija." + s3_cdn_url: "CDN URL koji se koristi za sva s3 sredstva (na primjer: https://cdn.somewhere.com). UPOZORENJE: nakon promjene ove postavke morate ponovno ispeći sve stare objave." + avatar_sizes: "Popis automatski generiranih veličina avatara." + external_system_avatars_enabled: "Koristite uslugu avatara vanjskog sustava." + external_system_avatars_url: "URL usluge avatara vanjskog sustava. Dopuštene zamjene su {username} {first_letter} {color} {size}" + external_emoji_url: "URL vanjske usluge za emoji slike. Ostavite prazno za onemogućavanje." + use_site_small_logo_as_system_avatar: "Koristite mali logo stranice umjesto avatara korisnika sustava. Zahtijeva da logo bude prisutan." + restrict_letter_avatar_colors: "Popis 6-znamenkastih heksadecimalnih vrijednosti boja koje će se koristiti za pozadinu avatara slova." + enable_listing_suspended_users_on_search: "Omogućite redovnim korisnicima da pronađu suspendirane korisnike." + selectable_avatars_mode: "Dopustite korisnicima da odaberu avatar s popisa selectable_avatars i ograničite prijenos prilagođenih avatara na odabranu razinu povjerenja." + selectable_avatars: "Popis avatara koje korisnici mogu birati." + allow_all_attachments_for_group_messages: "Dopusti sve privitke e-pošte za grupne poruke." + png_to_jpg_quality: "Kvaliteta pretvorene JPG datoteke (1 je najniža kvaliteta, 99 je najbolja kvaliteta, 100 za onemogućiti)." + recompress_original_jpg_quality: "Kvaliteta učitanih slikovnih datoteka (1 je najniža kvaliteta, 99 najbolja kvaliteta, 100 za onemogućavanje)." + image_preview_jpg_quality: "Kvaliteta slikovnih datoteka promijenjene veličine (1 je najniža kvaliteta, 99 najbolja kvaliteta, 100 za onemogućavanje)." + allow_staff_to_upload_any_file_in_pm: "Dopustite članovima osoblja da učitaju bilo koje datoteke u PM." + strip_image_metadata: "Skini metapodatke slike." + composer_media_optimization_image_enabled: "Omogućuje medijsku optimizaciju učitanih slikovnih datoteka na strani klijenta." + composer_media_optimization_image_bytes_optimization_threshold: "Minimalna veličina slikovne datoteke za pokretanje optimizacije na strani klijenta" + composer_media_optimization_image_resize_dimensions_threshold: "Minimalna širina slike za pokretanje promjene veličine na strani klijenta" + composer_media_optimization_image_resize_width_target: "Slikama čija je širina veća od `composer_media_optimization_image_dimensions_resize_threshold` bit će promijenjena veličina na ovu širinu. Mora biti >= od `composer_media_optimization_image_dimensions_resize_threshold`." + composer_media_optimization_image_encode_quality: "Kvaliteta JPEG kodiranja koja se koristi u procesu ponovnog kodiranja." + min_ratio_to_crop: "Omjer koji se koristi za obrezivanje visokih slika. Unesite rezultat širine / visine." + simultaneous_uploads: "Maksimalni broj datoteka koje se mogu povući i ispustiti u skladatelj" + default_invitee_trust_level: "Zadana razina povjerenja (0-4) za pozvane korisnike." + default_trust_level: "Zadana razina povjerenja (0-4) za sve nove korisnike. UPOZORENJE! Ako ovo promijenite, izložit ćete se ozbiljnom riziku od neželjene pošte." + tl1_requires_topics_entered: "Koliko tema novi korisnik mora unijeti prije promicanja na razinu povjerenja 1." + tl1_requires_read_posts: "Koliko postova novi korisnik mora pročitati prije promicanja na razinu povjerenja 1." + tl1_requires_time_spent_mins: "Koliko minuta novi korisnik mora čitati postove prije promicanja na razinu povjerenja 1." + tl2_requires_topics_entered: "Koliko tema korisnik mora unijeti prije promicanja na razinu povjerenja 2." + tl2_requires_read_posts: "Koliko postova korisnik mora pročitati prije promicanja u razinu povjerenja 2." + tl2_requires_time_spent_mins: "Koliko minuta korisnik mora čitati postove prije promicanja na razinu povjerenja 2." + tl2_requires_days_visited: "Koliko dana korisnik mora posjetiti stranicu prije promicanja na razinu povjerenja 2." + tl2_requires_likes_received: "Koliko lajkova korisnik mora dobiti prije promicanja na razinu povjerenja 2." + tl2_requires_likes_given: "Koliko lajkova korisnik mora dati prije promicanja na razinu povjerenja 2." + tl2_requires_topic_reply_count: "Na koliko tema korisnik mora odgovoriti prije promicanja u razinu povjerenja 2." + tl3_time_period: "Razdoblje zahtjeva razine povjerenja 3 (u danima)" + tl3_requires_days_visited: "Minimalni broj dana koje korisnik mora posjetiti web-mjesto u zadnjih (tl3 vremensko razdoblje) dana da bi se kvalificirao za promaknuće na razinu povjerenja 3. Postavite više od tl3 vremensko razdoblje da onemogućite promocije na tl3. (0 ili više)" + tl3_requires_topics_replied_to: "Minimalni broj tema na koje korisnik mora odgovoriti u zadnjih (tl3 vremensko razdoblje) dana da bi se kvalificirao za promaknuće u razinu povjerenja 3. (0 ili više)" + min_title_similar_length: "Minimalna duljina naslova prije nego što će se provjeriti za slične teme." + desktop_category_page_style: "Vizualni stil za stranicu /kategorije." + category_colors: "Popis heksadecimalnih vrijednosti boja dopuštenih za kategorije." + category_style: "Vizualni stil za bedževe kategorije." + default_dark_mode_color_scheme_id: "Shema boja koja se koristi u tamnom načinu rada." dark_mode_none: "ništa" + max_image_size_kb: "Maksimalna veličina slike za učitavanje u kB. Ovo također mora biti konfigurirano u nginx (client_max_body_size) / apache ili proxy. Slike veće od ovoga i manje od client_max_body_size bit će promijenjene u veličini kako bi stale pri prijenosu." + max_attachment_size_kb: "Maksimalna veličina učitanih datoteka privitka u kB. Ovo također mora biti konfigurirano u nginx (client_max_body_size) / apache ili proxy." + authorized_extensions: "Popis ekstenzija datoteka dopuštenih za prijenos (upotrijebite '*' da biste omogućili sve vrste datoteka)" + authorized_extensions_for_staff: "Popis ekstenzija datoteka dopuštenih za učitavanje za korisnike osoblja uz popis definiran u postavci web mjesta `authorized_extensions`. (koristite '*' da omogućite sve vrste datoteka)" + theme_authorized_extensions: "Popis ekstenzija datoteka dopuštenih za učitavanje tema (upotrijebite '*' da omogućite sve vrste datoteka)" + max_similar_results: "Koliko sličnih tema prikazati iznad uređivača prilikom sastavljanja nove teme. Usporedba se temelji na naslovu i tijelu." + max_image_megapixels: "Maksimalni dopušteni broj megapiksela za sliku. Slike s većim brojem megapiksela bit će odbijene." + title_prettify: "Spriječite uobičajene tipfelere i pogreške u naslovu, uključujući velika slova, prvi znak malim slovima, više! i ?, ekstra . na kraju itd." + title_remove_extraneous_space: "Uklonite početne razmake ispred interpunkcijskih znakova na kraju." + automatic_topic_heat_values: 'Automatski ažurirajte postavke "topic views heat" i "topic post like heat" na temelju aktivnosti web-mjesta.' + topic_views_heat_low: "Nakon ovoliko pogleda, polje prikaza je malo istaknuto." + topic_views_heat_medium: "Nakon ovoliko pogleda, polje prikaza je umjereno istaknuto." + topic_views_heat_high: "Nakon ovoliko pogleda, polje prikaza je jako istaknuto." + cold_age_days_low: "Nakon ovoliko dana razgovora, posljednji datum aktivnosti malo je zatamnjen." + cold_age_days_medium: "Nakon ovoliko dana razgovora, posljednji datum aktivnosti umjereno je zatamnjen." + cold_age_days_high: "Nakon ovoliko dana razgovora, posljednji datum aktivnosti je jako zatamnjen." + history_hours_low: "Post uređen u roku od toliko sati ima indikator uređivanja malo istaknut" + history_hours_medium: "Post uređen unutar toliko sati ima indikator uređivanja umjereno istaknut." + history_hours_high: "Post uređen unutar toliko sati ima jako istaknut indikator uređivanja." + topic_post_like_heat_low: "Nakon što omjer lajkova:post premaši ovaj omjer, polje za broj postova je malo istaknuto." + topic_post_like_heat_medium: "Nakon što omjer lajkova:post premaši ovaj omjer, polje za broj objava je umjereno istaknuto." + topic_post_like_heat_high: "Nakon što omjer lajkova:post premaši ovaj omjer, polje za broj objava je jako istaknuto." + faq_url: "Ako imate FAQ hostiran negdje drugdje koje želite koristiti, navedite cijeli URL ovdje." + tos_url: "Ako negdje drugdje imate hostiran dokument o Uvjetima pružanja usluge koji želite koristiti, ovdje navedite puni URL." + privacy_policy_url: "Ako negdje drugdje imate hostiran dokument o politici privatnosti koji želite koristiti, ovdje navedite puni URL." + log_anonymizer_details: "Treba li zadržati podatke o korisniku u zapisniku nakon anonimizacije." + newuser_spam_host_threshold: "Koliko puta novi korisnik može objaviti vezu na isti host unutar svojih postova `newuser_spam_host_threshold` prije nego što se smatra neželjenim." + allowed_spam_host_domains: "Popis domena izuzetih iz testiranja hosta neželjene pošte. Novim korisnicima nikada neće biti zabranjeno stvarati postove s poveznicama na te domene." + staff_like_weight: "Koju težinu dati lajkovima osoblja (lajkovi neosoblja imaju težinu 1.)" + topic_view_duration_hours: "Broji nove preglede teme jednom po IP-u/korisniku svakih N sati" + user_profile_view_duration_hours: "Brojite prikaz profila novog korisnika jednom po IP-u/korisniku svakih N sati" + levenshtein_distance_spammer_emails: "Prilikom uparivanja neželjene e-pošte, razlika u broju znakova koja će još uvijek omogućiti nejasno podudaranje." + max_new_accounts_per_registration_ip: "Ako već postoji (n) računa razine povjerenja 0 s ovog IP-a (i nijedan nije član osoblja ili na TL2 ili višem), prestanite prihvaćati nove prijave s tog IP-a. Postavite na 0 da biste onemogućili ograničenje." + min_ban_entries_for_roll_up: "Kada se klikne gumb Skupi, stvorit će se novi unos zabrane podmreže ako postoji najmanje (N) unosa." + max_age_unmatched_emails: "Izbrišite neusklađene pregledane unose e-pošte nakon (N) dana." + max_age_unmatched_ips: "Izbrišite neusklađene zaštićene IP unose nakon (N) dana." + num_flaggers_to_close_topic: "Minimalan broj jedinstvenih označavača koji je potreban za automatsko pauziranje teme radi intervencije" + num_hours_to_close_topic: "Broj sati za pauziranje teme za intervenciju." + auto_respond_to_flag_actions: "Omogući automatski odgovor pri uklanjanju oznake." + min_first_post_typing_time: "Minimalno vrijeme u milisekundama koje korisnik mora upisati tijekom prve objave, ako prag nije dostignut, objava će automatski ući u red čekanja za odobrenje. Postavite na 0 za onemogućavanje (ne preporučuje se)" + auto_silence_fast_typers_on_first_post: "Automatski utišaj korisnike koji ne ispunjavaju min_first_post_typing_time" + auto_silence_fast_typers_max_trust_level: "Maksimalna razina povjerenja za automatsko utišavanje brzih tipkača" + auto_silence_first_post_regex: "Regularni izraz koji ne razlikuje velika i mala slova koji će, ako se proslijedi, utišati prvu objavu korisnika i poslati je u red čekanja za odobrenje. Primjer: raging|a[bc]a , uzrokovat će prvo utišavanje svih postova koji sadrže raging ili aba ili aca. Odnosi se samo na prvi post. ZASTARJELO: Umjesto toga koristite utišavanje promatranih riječi." + reviewable_claiming: "Treba li potraživati sadržaj koji se može pregledati prije nego što se na njega može djelovati?" + reviewable_default_topics: "Prikaži sadržaj koji se može pregledati prema zadanim postavkama grupiran prema temi" + pop3_polling_password: "Lozinka za POP3 račun za anketu za e-poštu." + pop3_polling_delete_from_server: "Izbrišite e-poštu s poslužitelja. NAPOMENA: Ako ovo onemogućite, trebali biste ručno očistiti svoj poštanski sandučić" + log_mail_processing_failures: "Zabilježite sve pogreške obrade e-pošte na /zapisi" + email_in: 'Omogućite korisnicima objavljivanje novih tema putem e-pošte (zahtijeva ručno ili pop3 prozivanje). Konfigurirajte adrese u kartici "Postavke" svake kategorije.' + email_in_min_trust: "Minimalna razina povjerenja koju korisnik mora imati da bi mu se omogućilo objavljivanje novih tema putem e-pošte." + email_in_authserv_id: "Identifikator usluge koja vrši provjeru autentičnosti dolazne e-pošte. Pogledajte https://meta.discourse.org/t/134358 za upute o tome kako to konfigurirati." + email_in_spam_header: "Zaglavlje e-pošte za otkrivanje neželjene pošte." + enable_imap: "Omogućite IMAP za sinkronizaciju grupnih poruka." + enable_imap_write: "Omogućite dvosmjernu IMAP sinkronizaciju. Ako je onemogućeno, sve operacije pisanja na IMAP računima su onemogućene." + enable_imap_idle: "Koristite IMAP IDLE mehanizam za čekanje novih e-poruka." + enable_smtp: "Omogućite SMTP za slanje obavijesti za grupne poruke." + imap_polling_period_mins: "Razdoblje u minutama između provjere IMAP računa za e-poštu." + imap_polling_old_emails: "Maksimalan broj starih e-poruka (obrađenih) koje će se ažurirati svaki put kada se IMAP pretinac ispita (0 za sve)." + imap_polling_new_emails: "Maksimalan broj novih e-poruka (neobrađenih) koje će se ažurirati svaki put kada se traži IMAP okvir." + imap_batch_import_email: "Najmanji broj novih poruka e-pošte koji pokreću način uvoza (onemogućuje obavijesti za uvezene objave)." + email_prefix: "[label] koji se koristi u predmetu e-pošte. Zadano će biti \"naslov\" ako nije postavljen." + email_site_title: "Naslov stranice koja se koristi kao pošiljatelj e-pošte s stranice. Zadano je 'title' ako nije postavljeno. Ako vaš 'naslov' sadrži znakove koji nisu dopušteni u nizovima pošiljatelja e-pošte, koristite ovu postavku." + find_related_post_with_key: "Koristite samo 'tipku za odgovor' da biste pronašli objavu na koju ste odgovorili. UPOZORENJE: onemogućivanje ovoga dopušta lažno predstavljanje korisnika na temelju adrese e-pošte." + minimum_topics_similar: "Koliko tema treba postojati prije nego što se slične teme prikažu pri sastavljanju novih tema." + relative_date_duration: "Broj dana nakon objave gdje će datumi objave biti prikazani kao relativni (7d) umjesto apsolutni (20. veljače)." + delete_user_max_post_age: "Ne dopusti brisanje korisnika čiji je prvi post stariji od (x) dana." + delete_all_posts_max: "Maksimalan broj postova koji se mogu izbrisati odjednom pomoću gumba Izbriši sve postove. Ako korisnik ima više od ovog broja postova, ne mogu se svi postovi izbrisati odjednom niti se korisnik ne može izbrisati." + delete_user_self_max_post_count: "Maksimalan broj objava koje korisnik može imati dok dopušta samoposlužno brisanje računa. Postavite na -1 da onemogućite samouslužno brisanje računa." + username_change_period: "Maksimalan broj dana nakon registracije u kojem računi mogu promijeniti svoje korisničko ime (0 za onemogućavanje promjene korisničkog imena)." + email_editable: "Omogućite korisnicima promjenu svoje e-mail adrese nakon registracije." + logout_redirect: "Lokacija za preusmjeravanje preglednika nakon odjave (npr.: https://example.com/logout)" + allow_uploaded_avatars: "Dopustite korisnicima da učitaju prilagođene profilne slike." + default_avatars: "URL-ovi avatara koji će se prema zadanim postavkama koristiti za nove korisnike dok ih ne promijene." + automatically_download_gravatars: "Preuzmite Gravatare za korisnike nakon izrade računa ili promjene e-pošte." + digest_topics: "Najveći broj popularnih tema za prikaz u sažetku e-pošte." + digest_posts: "Maksimalni broj popularnih postova za prikaz u sažetku e-pošte." + digest_other_topics: "Maksimalni broj tema za prikaz u odjeljku \"Novo u temama i kategorijama koje pratite\" sažetka e-pošte." + digest_min_excerpt_length: "Minimalni izvadak posta u sažetku e-pošte, u znakovima." + suppress_digest_email_after_days: "Zaustavi e-poštu sa sažetkom za korisnike koji nisu viđeni na web mjestu dulje od (n) dana." + digest_suppress_categories: "Izostavi te kategorije iz sažetaka e-pošte." + disable_digest_emails: "Onemogući e-poštu sa sažetkom za sve korisnike." + apply_custom_styles_to_digest: "Prilagođeni predložak e-pošte i css primjenjuju se na e-poruke sa sažetkom." + email_accent_bg_color: "Boja naglaska koja će se koristiti kao pozadina nekih elemenata u HTML porukama e-pošte. Unesite naziv boje ('red') ili hex vrijednost ('#FF0000')." + email_accent_fg_color: "Boja teksta prikazanog na e-pošti bg boja u HTML e-pošti. Unesite naziv boje ('bijela') ili hex vrijednost ('#FFFFFF')." + email_link_color: "Boja poveznica u HTML porukama e-pošte. Unesite naziv boje ('plava') ili hex vrijednost ('#0000FF')." + detect_custom_avatars: "Treba li provjeriti jesu li korisnici učitali prilagođene profilne slike." + max_daily_gravatar_crawls: "Maksimalni broj puta Discourse će provjeriti Gravatar za prilagođene avatare u jednom danu" + public_user_custom_fields: "Popis korisnički prilagođenih polja koja se mogu dohvatiti pomoću API-ja." + staff_user_custom_fields: "Popis korisnički prilagođenih polja koja se mogu dohvatiti za članove osoblja pomoću API-ja." + enable_user_directory: "Omogućite imenik korisnika za pregledavanje" + enable_group_directory: "Omogućite direktorij grupa za pregledavanje" + enable_category_group_moderation: "Dopustite grupama moderiranje sadržaja u određenim kategorijama" + group_in_subject: "Postavite %%{optional_pm} u predmetu e-pošte prema imenu prve grupe u PM-u, pogledajte: Prilagodite format predmeta za standardne e-poruke" + allow_anonymous_posting: "Dopusti korisnicima da prijeđu na anonimni način rada" + anonymous_posting_min_trust_level: "Minimalna razina povjerenja potrebna za omogućavanje anonimnog objavljivanja" + highlighted_languages: "Uključena pravila za označavanje sintakse. (Upozorenje: uključivanje previše jezika može utjecati na performanse) pogledajte: https://highlightjs.org/static/demo za demo" + show_copy_button_on_codeblocks: "Dodajte gumb kodnim blokovima za kopiranje sadržaja bloka u korisnički međuspremnik." + embed_any_origin: "Dopusti sadržaj koji se može ugraditi bez obzira na podrijetlo. Ovo je potrebno za mobilne aplikacije sa statičnim HTML-om." + embed_topics_list: "Podržava HTML ugradnju popisa tema" + embed_set_canonical_url: "Postavite kanonski URL za ugrađene teme na URL ugrađenog sadržaja." + embed_truncate: "Skratite ugrađene postove." + embed_unlisted: "Uvezene teme neće biti navedene dok korisnik ne odgovori." + embed_support_markdown: "Podržava Markdown formatiranje za ugrađene objave." + allowed_embed_selectors: "Popis CSS elemenata odvojenih zarezima koji su dopušteni u ugrađivanju." + allowed_href_schemes: "Sheme dopuštene u vezama uz http i https." + embed_post_limit: "Maksimalan broj postova za ugradnju." + embed_username_required: "Potrebno je korisničko ime za kreiranje teme." + notify_about_flags_after: "Ako postoje zastavice koje nisu obrađene nakon toliko sati, pošaljite osobnu poruku moderatorima. Postavite na 0 za onemogućavanje." + show_create_topics_notice: "Ako stranica ima manje od 5 javnih tema, pokažite obavijest u kojoj se od administratora traži da kreiraju neke teme." + delete_drafts_older_than_n_days: "Izbriši skice starije od (n) dana." + delete_merged_stub_topics_after_days: "Broj dana čekanja prije automatskog brisanja potpuno spojenih tema. Postavite na 0 da nikada ne brišete nezavršene teme." + bootstrap_mode_min_users: "Minimalan broj korisnika potreban za onemogućavanje načina pokretanja (postavite na 0 za onemogućavanje)" + prevent_anons_from_downloading_files: "Spriječite anonimne korisnike u preuzimanju privitaka." + secure_media: "ZASTARJELO: Umjesto toga koristite postavku secure_uploads, bit će uklonjena u Discourse 3.0." + secure_uploads: 'Ograničava pristup SVIM prijenosima (slike, video, audio, tekst, pdf-ovi, zipovi i ostalo). Ako je "potrebna prijava" omogućena, samo prijavljeni korisnici mogu pristupiti učitavanjima. U suprotnom, pristup će biti ograničen samo na učitavanje medija u privatnim porukama i privatnim kategorijama. UPOZORENJE: Ova je postavka složena i zahtijeva duboko administrativno razumijevanje. Pogledajte sigurnu učitava temu na Meta za detalje.' + secure_media_allow_embed_images_in_emails: "ZASTARJELO: Koristite secure_uploads_allow_embed_images_in_emails, uklonit će se u Discourse 3.0." + secure_uploads_allow_embed_images_in_emails: "Omogućuje ugrađivanje sigurnih slika koje bi se inače redigirale u e-porukama, ako je njihova veličina manja od postavke 'maksimalna veličina slike za ugrađivanje e-pošte u sigurne prijenose kb'." + secure_media_max_email_embed_image_size_kb: "ZASTARJELO: Koristite secure_uploads_max_email_embed_image_size_kb, bit će uklonjeno u Discourse 3.0." + secure_uploads_max_email_embed_image_size_kb: "Ograničenje veličine za sigurne slike koje će biti ugrađene u e-poštu ako je omogućena postavka \"sigurni prijenosi dopuštaju ugradnju u e-poštu\". Bez uključene te postavke, ova postavka nema učinka." + slug_generation_method: "Odaberite metodu stvaranja puževa. 'encoded' će generirati niz postotnog kodiranja. 'none' će uopće onemogućiti puž." + enable_emoji: "Omogući emoji" + enable_emoji_shortcuts: "Uobičajeni tekst smajlića kao što je :) :p :( bit će pretvoren u emojije" emoji_set: "Kakave emoji-e želite?" + emoji_autocomplete_min_chars: "Minimalan broj znakova potreban za pokretanje skočnog prozora s emojijima za automatsko dovršavanje" + enable_inline_emoji_translation: "Omogućuje prijevod za ugrađene emojije (bez razmaka ili interpunkcijskih znakova prije)" + approve_post_count: "Količina objava novog ili osnovnog korisnika koja mora biti odobrena" + approve_unless_trust_level: "Objave za korisnike ispod ove razine povjerenja moraju biti odobrene" + approve_new_topics_unless_trust_level: "Nove teme za korisnike ispod ove razine povjerenja moraju biti odobrene" + approve_unless_staged: "Nove teme i postovi za stupnjevite korisnike moraju biti odobreni" + notify_about_queued_posts_after: "Ako postoje postovi koji čekaju na pregled dulje od ovog broja sati, pošaljite obavijest svim moderatorima. Postavite na 0 da biste onemogućili ove obavijesti." + auto_close_messages_post_count: "Maksimalan broj dopuštenih postova u poruci prije nego što se automatski zatvori (0 za onemogućiti)" + auto_close_topics_post_count: "Maksimalan broj dopuštenih postova u temi prije nego što se automatski zatvori (0 za onemogućiti)" + auto_close_topics_create_linked_topic: "Stvorite novu povezanu temu kada je tema automatski zatvorena na temelju postavke 'automatsko zatvaranje tema za postove'" + code_formatting_style: "Gumb koda u sastavljaču zadano će koristiti ovaj stil oblikovanja koda" + max_allowed_message_recipients: "Maksimalan broj dopuštenih primatelja u poruci." + watched_words_regular_expressions: "Promatrane riječi su regularni izrazi." + enable_diffhtml_preview: "Eksperimentalna značajka koja koristi diffHTML za sinkronizaciju pregleda umjesto potpunog ponovnog renderiranja" + enable_fast_edit: "Omogućuje mali odabir teksta objave koji se može uređivati na liniji." + old_post_notice_days: "Dana prije nego što obavijest o pošti postane stara" + new_user_notice_tl: "Minimalna razina povjerenja potrebna da biste vidjeli obavijesti o novim objavama korisnika." + returning_user_notice_tl: "Minimalna razina povjerenja potrebna da biste vidjeli obavijesti o objavama korisnika koji se vraćaju." + returning_users_days: "Koliko dana treba proći prije nego što se smatra da se korisnik vraća." + review_media_unless_trust_level: "Osoblje će pregledati objave korisnika s nižim razinama povjerenja ako sadrže ugrađene medije." + blur_tl0_flagged_posts_media: "Zamutite slike označenih objava kako biste sakrili potencijalno NSFW sadržaj." + enable_page_publishing: "Dopustite članovima osoblja da objavljuju teme na novim URL-ovima s vlastitim stilom." + show_published_pages_login_required: "Anonimni korisnici mogu vidjeti objavljene stranice, čak i kada je potrebna prijava." default_other_skip_new_user_tips: "Preskoči savjete i značke novog korisnika." + pm_tags_allowed_for_groups: "Dopustite članovima uključenih grupa da označe bilo koju osobnu poruku" + min_trust_level_to_tag_topics: "Minimalna razina povjerenja potrebna za označavanje tema" + suppress_overlapping_tags_in_list: "Ako oznake odgovaraju točnim riječima u naslovima tema, nemojte prikazivati oznaku" + remove_muted_tags_from_latest: "Ne prikazuj teme označene samo prigušenim oznakama na najnovijem popisu tema." + force_lowercase_tags: "Prisilno navedite da sve nove oznake budu ispisane malim slovima." company_name: "Ime tvrtke" + governing_law: "Mjerodavno pravo" + city_for_disputes: "Grad za sporove" + shared_drafts_category: "Omogućite značajku Dijeljene skice određivanjem kategorije za skice tema. Teme u ovoj kategoriji bit će potisnute s popisa tema za korisnike osoblja." + shared_drafts_min_trust_level: "Dopustite korisnicima da vide i uređuju dijeljene skice." + push_notifications_prompt: "Prikaži upit za pristanak korisnika." + push_notifications_icon: "Ikona značke koja se pojavljuje u kutu obavijesti. Preporučuje se monokromatski PNG s prozirnošću veličine 96×96." + enable_desktop_push_notifications: "Omogućite push obavijesti na radnoj površini" + push_notification_time_window_mins: "Pričekajte (n) minuta prije slanja push obavijesti. Pomaže u sprječavanju slanja push obavijesti aktivnom online korisniku." + base_font: "Osnovni font koji se koristi za većinu teksta na web mjestu. Teme se mogu nadjačati putem CSS prilagođenog svojstva `--font-family`." + heading_font: "Font koji se koristi za naslove na stranici. Teme se mogu nadjačati putem CSS prilagođenog svojstva `--heading-font-family`." + enable_sitemap: "Generirajte kartu web stranice za svoju web stranicu i uključite je u datoteku robots.txt." + sitemap_page_size: "Broj URL-ova koje treba uključiti u svaku stranicu karte web-lokacije. Najviše 50.000" + enable_user_status: "(eksperimentalno) Omogućuje korisnicima postavljanje prilagođene statusne poruke (emoji + opis)." + enable_user_tips: "(eksperimentalno) Omogućite nove korisničke savjete koji korisnicima opisuju ključne značajke" + short_title: "Kratki naslov koristit će se na korisničkom početnom zaslonu, pokretaču ili drugim mjestima gdje je prostor ograničen. Trebao bi biti ograničen na 12 znakova." + dashboard_hidden_reports: "Omogućuje skrivanje navedenih izvješća s nadzorne ploče." + dashboard_visible_tabs: "Odaberite koje su kartice nadzorne ploče vidljive." + dashboard_general_tab_activity_metrics: "Odaberite izvješća koja će se prikazivati kao metrika aktivnosti na kartici općenito." + gravatar_name: "Naziv pružatelja usluge Gravatar" + gravatar_base_url: "Url API baze Gravatar pružatelja usluga" + gravatar_login_url: "Url u odnosu na gravatar_base_url, koji korisniku omogućuje prijavu na uslugu Gravatar" + share_quote_buttons: "Odredite koje se stavke pojavljuju u widgetu za dijeljenje ponuda i kojim redoslijedom." + share_quote_visibility: "Odredite kada prikazati gumbe za dijeljenje ponuda: nikada, samo anonimnim korisnicima ili svim korisnicima. " + create_revision_on_bulk_topic_moves: "Napravite reviziju za prve postove kada se teme skupno premještaju u novu kategoriju." + allow_changing_staged_user_tracking: "Dopustite korisniku administratoru da promijeni postavke obavijesti o kategorijama i oznakama postupnog korisnika." + use_email_for_username_and_name_suggestions: "Koristite prvi dio adresa e-pošte za korisničko ime i prijedloge imena. Imajte na umu da ovo javnosti olakšava pogađanje pune adrese e-pošte korisnika (jer velik dio ljudi dijeli zajedničke usluge poput `gmail.com`)." + use_name_for_username_suggestions: "Koristite puno ime korisnika kada predlažete korisnička imena." + suggest_weekends_in_date_pickers: "Uključite vikende (subota i nedjelja) u prijedloge za odabir datuma (onemogućite ovo ako Discourse koristite samo radnim danima, od ponedjeljka do petka)." + splash_screen: "Prikazuje privremeni zaslon za učitavanje dok se sredstva web-mjesta učitavaju" + navigation_menu: "Odredite koji navigacijski izbornik koristiti. Bočnu traku i navigaciju u zaglavlju korisnici mogu prilagoditi. Naslijeđena opcija dostupna je za kompatibilnost s prethodnim verzijama." + default_sidebar_categories: "Odabrane kategorije će prema zadanim postavkama biti prikazane u odjeljku Kategorije na bočnoj traci." + default_sidebar_tags: "Odabrane oznake će prema zadanim postavkama biti prikazane u odjeljku Oznake bočne trake." + enable_new_user_profile_nav_groups: "EKSPERIMENTALNO: Korisnicima odabranih grupa bit će prikazan novi navigacijski izbornik korisničkog profila" + enable_experimental_topic_timeline_groups: "EKSPERIMENTALNO: Korisnicima odabranih grupa prikazat će se refaktorirana vremenska traka teme" + enable_experimental_hashtag_autocomplete: "EKSPERIMENTALNO: Upotrijebite novi #hashtag sustav automatskog dovršavanja za kategorije i oznake koji drugačije prikazuje odabranu stavku i ima poboljšano pretraživanje" errors: + invalid_css_color: "Nevažeća boja. Unesite naziv boje ili hex vrijednost." + invalid_email: "Nevažeća adresa e-pošte." + invalid_username: "Ne postoji korisnik s tim korisničkim imenom." + valid_username: "Postoji korisnik s tim korisničkim imenom." + invalid_group: "Ne postoji grupa s tim nazivom." + invalid_integer_min_max: "Vrijednost mora biti između %{min} i %{max}." + invalid_integer_min: "Vrijednost mora biti %{min} ili veća." + invalid_integer_max: "Vrijednost ne može biti veća od %{max}." + invalid_integer: "Vrijednost mora biti cijeli broj." + regex_mismatch: "Vrijednost ne odgovara potrebnom formatu." invalid_string_max: "Ne smije imati više od %{max} znakova." search: within_post: "#%{post_number} od %{username}" @@ -848,18 +1484,132 @@ hr: topic: "Rezultati" user: "Korisnici" results_page: "Rezultati pretraživanja za '%{term}'" + discourse_connect: + account_not_approved: "Vaš račun čeka odobrenje. Primit ćete obavijest e-poštom kada budete odobreni." + unknown_error: "Postoji problem s vašim računom. Molimo kontaktirajte administratora stranice." + timeout_expired: "Prijava na račun je istekla, pokušajte se ponovno prijaviti." + no_email: "Nije navedena adresa e-pošte. Molimo kontaktirajte administratora stranice." + blank_id_error: "`external_id` je obavezan, ali je bio prazan" + email_error: "Račun nije mogao biti registriran s adresom e-pošte %{email}. Molimo kontaktirajte administratora stranice." + missing_secret: "Autentifikacija nije uspjela zbog nedostatka tajne. Obratite se administratorima stranice kako biste riješili ovaj problem." + invite_redeem_failed: "Iskorištavanje pozivnice nije uspjelo. Molimo kontaktirajte administratora stranice." + original_poster: "Originalni plakat" + most_recent_poster: "Najnoviji poster" + frequent_poster: "Česti poster" + poster_description_joiner: ", " + redirected_to_top_reasons: + new_user: "Dobrodošli u našu zajednicu! Ovo su najpopularnije novije teme." + not_seen_in_a_month: "Dobrodošao natrag! Nismo te vidjeli neko vrijeme. Ovo su najpopularnije teme otkako vas nema." + merge_posts: + edit_reason: + one: "Objavu je spojio %{username}" + few: "Objave %{count} je spojio %{username}" + other: "%{count} postova spojeno je s %{username}" + errors: + different_topics: "Postovi koji pripadaju različitim temama ne mogu se spajati." + different_users: "Postovi koji pripadaju različitim korisnicima ne mogu se spajati." + max_post_length: "Postovi se ne mogu spojiti jer je duljina kombiniranog posta veća od dopuštene." + move_posts: + new_topic_moderator_post: + one: "Post je podijeljen u novu temu: %{topic_link}" + few: "%{count} postova je podijeljeno u novu temu: %{topic_link}" + other: "%{count} postova je podijeljeno u novu temu: %{topic_link}" + new_message_moderator_post: + one: "Post je podijeljen u novu poruku: %{topic_link}" + few: "%{count} postova je podijeljeno u novu poruku: %{topic_link}" + other: "%{count} postova je podijeljeno u novu poruku: %{topic_link}" + existing_topic_moderator_post: + one: "Post je spojen s postojećom temom: %{topic_link}" + few: "%{count} postova je spojeno s postojećom temom: %{topic_link}" + other: "%{count} postova je spojeno u postojeću temu: %{topic_link}" + existing_message_moderator_post: + one: "Post je spojen u postojeću poruku: %{topic_link}" + few: "%{count} postova je spojeno u postojeću poruku: %{topic_link}" + other: "%{count} postova je spojeno u postojeću poruku: %{topic_link}" + change_owner: + post_revision_text: "Vlasništvo preneseno" publish_page: slug_errors: blank: "ne može biti prazno" + unavailable: "je nedostupan" invalid: "sadrži nevažeće znakove" + topic_statuses: + autoclosed_message_max_posts: + one: "Ova poruka je automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + few: "Ova poruka je automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + other: "Ova je poruka automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + autoclosed_topic_max_posts: + one: "Ova je tema automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + few: "Ova je tema automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + other: "Ova je tema automatski zatvorena nakon što je dosegnuto maksimalno ograničenje od %{count} odgovora." + autoclosed_enabled_days: + one: "Ova je tema automatski zatvorena nakon %{count} dana. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena nakon %{count} dana. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena nakon %{count} dana. Novi odgovori više nisu dopušteni." + autoclosed_enabled_hours: + one: "Ova je tema automatski zatvorena nakon %{count} sati. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena nakon %{count} sati. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena nakon %{count} sati. Novi odgovori više nisu dopušteni." + autoclosed_enabled_minutes: + one: "Ova je tema automatski zatvorena nakon %{count} minuta. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena nakon %{count} minuta. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena nakon %{count} minuta. Novi odgovori više nisu dopušteni." + autoclosed_enabled_lastpost_days: + one: "Ova je tema automatski zatvorena %{count} dana nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena %{count} dana nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena %{count} dana nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + autoclosed_enabled_lastpost_hours: + one: "Ova je tema automatski zatvorena %{count} sati nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena %{count} sati nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena %{count} sati nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + autoclosed_enabled_lastpost_minutes: + one: "Ova je tema automatski zatvorena %{count} minuta nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + few: "Ova je tema automatski zatvorena %{count} minuta nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + other: "Ova je tema automatski zatvorena %{count} minuta nakon zadnjeg odgovora. Novi odgovori više nisu dopušteni." + autoclosed_disabled_days: + one: "Ova je tema automatski otvorena nakon %{count} dana." + few: "Ova je tema automatski otvorena nakon %{count} dana." + other: "Ova je tema automatski otvorena nakon %{count} dana." + autoclosed_disabled_hours: + one: "Ova je tema automatski otvorena nakon %{count} sati." + few: "Ova je tema automatski otvorena nakon %{count} sati." + other: "Ova je tema automatski otvorena nakon %{count} sati." + autoclosed_disabled_minutes: + one: "Ova je tema automatski otvorena nakon %{count} minuta." + few: "Ova je tema automatski otvorena nakon %{count} minuta." + other: "Ova je tema automatski otvorena nakon %{count} minuta." + autoclosed_disabled_lastpost_days: + one: "Ova je tema automatski otvorena %{count} dana nakon zadnjeg odgovora." + few: "Ova je tema automatski otvorena %{count} dana nakon zadnjeg odgovora." + other: "Ova je tema automatski otvorena %{count} dana nakon zadnjeg odgovora." + autoclosed_disabled_lastpost_hours: + one: "Ova je tema automatski otvorena %{count} sati nakon zadnjeg odgovora." + few: "Ova je tema automatski otvorena %{count} sati nakon zadnjeg odgovora." + other: "Ova je tema automatski otvorena %{count} sati nakon zadnjeg odgovora." + autoclosed_disabled_lastpost_minutes: + one: "Ova je tema automatski otvorena %{count} minuta nakon zadnjeg odgovora." + few: "Ova je tema automatski otvorena %{count} minuta nakon zadnjeg odgovora." + other: "Ova je tema automatski otvorena %{count} minuta nakon zadnjeg odgovora." + autoclosed_disabled: "Ova tema je sada otvorena. Dopušteni su novi odgovori." + autoclosed_disabled_lastpost: "Ova tema je sada otvorena. Dopušteni su novi odgovori." + auto_deleted_by_timer: "Automatski izbrisano timerom." login: + invalid_second_factor_method: "Odabrana metoda dva faktora nije važeća." + not_enabled_second_factor_method: "Odabrana metoda dva faktora nije omogućena za vaš račun." security_key_description: "Kada pripremite svoj fizički sigurnosni ključ, pritisnite gumb Autentifikacija sa sigurnosnim ključem u nastavku." security_key_alternative: "Pokušaj na drugi način" security_key_authenticate: "Autentifikacija sa sigurnosnim ključem" security_key_not_allowed_error: "Proces provjere autentičnosti sigurnosnog ključa je istekao ili je otkazan." security_key_no_matching_credential_error: "U priloženom sigurnosnom ključu nije moguće pronaći odgovarajuće vjerodajnice." security_key_support_missing_error: "Vaš trenutni uređaj ili preglednik ne podržava korištenje sigurnosnih tipki. Molimo koristite drugu metodu." + security_key_invalid: "Došlo je do pogreške prilikom provjere sigurnosnog ključa." not_approved: "Vaš račun još nije odobren. Bit ćete obaviješteni putem e-maila kada budete spremni za prijavu." + incorrect_username_email_or_password: "Netočno korisničko ime, email ili lozinka" + incorrect_password: "Netočna lozinka" + wait_approval: "Hvala što ste se prijavili. Obavijestit ćemo vas kada vaš račun bude odobren." + active: "Vaš račun je aktiviran i spreman za korištenje." + activate_email: "

    Skoro ste gotovi! Poslali smo aktivacijski mail na %{email}. Slijedite upute u e-pošti da aktivirate svoj račun.

    Ako ne stigne, provjerite svoju spam mapu.

    " + not_activated: "Još se ne možete prijaviti. Poslali smo vam aktivacijski e-mail. Slijedite upute u e-poruci kako biste aktivirali svoj račun." admin_not_allowed_from_ip_address: "Ne možete se prijaviti kao administrator s te IP adrese." errors: "%{errors}" not_available: "Nije dostupno. Pokušajte %{suggestion}?" @@ -872,9 +1622,144 @@ hr: email: sent_test: "poslano!" user: + content_matches_auto_block_regex: "Sadržaj odgovara regularnom izrazu automatskog blokiranja" username: + short: "mora imati najmanje %{min} znakova" long: "ne smije imati više od %{max} znakova" + too_long: "je predug" + characters: "smije sadržavati samo brojeve, slova, crtice, točke i podvlake" + unique: "mora biti jedinstven" + blank: "mora biti prisutan" + must_begin_with_alphanumeric_or_underscore: "mora započeti slovom, brojem ili podvlakom" + must_end_with_alphanumeric: "mora završavati slovom ili brojem" must_not_contain_two_special_chars_in_seq: "ne smije sadržavati niz od 2 ili više specijalna znaka (.-_)" + must_not_end_with_confusing_suffix: "ne smije završavati zbunjujućim sufiksom poput .json ili .png itd." + email: + invalid: "je nevažeće." + not_allowed: "nije dopušten od tog davatelja usluga e-pošte. Molimo koristite drugu adresu e-pošte." + blocked: "nije dopušteno." + revoked: "Neće slati e-poštu na '%{email}' do %{date}." + does_not_exist: "N/A" + ip_address: + blocked: "Nove registracije nisu dopuštene s vaše IP adrese." + max_new_accounts_per_registration_ip: "Nove registracije nisu dopuštene s vaše IP adrese (dostignuto je maksimalno ograničenje). Kontaktirajte člana osoblja." + website: + domain_not_allowed: "Web stranica nije važeća. Dopuštene domene su: %{domains}" + auto_rejected: "Automatski odbijeno zbog starosti. Pogledajte postavku web mjesta auto_handle_queued_age." + destroy_reasons: + unused_staged_user: "Neiskorišteni postupni korisnik" + fixed_primary_email: "Ispravljena primarna e-pošta za stupnjevitog korisnika" + same_ip_address: "Ista IP adresa (%{ip_address}) kao i drugi korisnici" + inactive_user: "Neaktivan korisnik" + reviewable_reject_auto: "Automatska obrada u redu za pregled" + reviewable_reject: "Korisnik koji se može pregledati odbijen" + email_in_spam_header: "Korisnikova prva e-pošta označena je kao neželjena pošta" + already_silenced: "Korisnika je već utišao %{staff} %{time_ago}." + already_suspended: "Korisnik je već suspendiran od %{staff} %{time_ago}." + cannot_delete_has_posts: + one: "Korisnik %{username} ima %{count} objavu u javnoj temi ili osobnoj poruci, tako da se ne može izbrisati." + few: "Korisnik %{username} ima %{count} objavu u javnoj temi ili osobnoj poruci, tako da se ne može izbrisati." + other: "Korisnik %{username} ima %{count} postova u javnim temama ili osobnim porukama, tako da ih nije moguće izbrisati." + unsubscribe_mailer: + title: "Otkaži pretplatu na Mailer" + subject_template: "Potvrdite da više ne želite primati ažuriranja putem e-pošte od %{site_title}" + text_body_template: | + Netko (možda vi?) je zatražio da se više ne šalju ažuriranja e-poštom od %{site_domain_name} na ovu adresu. + Ako to želite potvrditi, kliknite ovu poveznicu: + + %{confirm_unsubscribe_link} + + + Ako želite nastaviti primati ažuriranja e-poštom, možete zanemariti ovu e-poruku. + invite_mailer: + title: "Pozovite Mailera" + subject_template: "%{inviter_name} vas je pozvao na '%{topic_title}' na %{site_domain_name}" + text_body_template: | + %{inviter_name} vas je pozvao na raspravu + + > **%{topic_title}** + > + > %{topic_excerpt} + + u + + > %{site_title} -- %{site_description} + + Ako ste zainteresirani, kliknite na poveznicu ispod: + + %{invite_link} + custom_invite_mailer: + title: "Custom Invite Mailer" + subject_template: "%{inviter_name} vas je pozvao na '%{topic_title}' na %{site_domain_name}" + text_body_template: | + %{inviter_name} vas je pozvao na raspravu + + > **%{topic_title}** + > + > %{topic_excerpt} + + u + + > %{site_title} -- %{site_description} + + S ovom bilješkom + + > %{user_custom_message} + + Ako ste zainteresirani, kliknite na poveznicu ispod: + + %{invite_link} + invite_forum_mailer: + title: "Pozovite Forum Mailer" + subject_template: "%{inviter_name} vas je pozvao da se pridružite %{site_domain_name}" + text_body_template: | + %{inviter_name} vas je pozvao da se pridružite + + > **%{site_title}** + > + > %{site_description} + + Ako ste zainteresirani, kliknite na poveznicu ispod: + + %{invite_link} + custom_invite_forum_mailer: + title: "Custom Poziv Forum Mailer" + subject_template: "%{inviter_name} vas je pozvao da se pridružite %{site_domain_name}" + text_body_template: | + %{inviter_name} vas poziva da se pridružite + + > **%{site_title}** + > + > %{site_description} + + S ovom bilješkom + + > %{user_custom_message} + + Ako ste zainteresirani, kliknite na poveznicu ispod: + + %{invite_link} + invite_password_instructions: + title: "Invite Password Upute" + subject_template: "Postavite lozinku za svoj %{site_name} račun" + text_body_template: | + Hvala što ste prihvatili poziv za %{site_name} -- dobrodošli! + + Pritisnite ovu poveznicu kako biste sada odabrali lozinku: + %{base_url}/u/password-reset/%{email_token} + + (Ako je gornja poveznica istekla, odaberite "Zaboravio sam lozinku" kada se prijavljujete sa svojom adresom e-pošte.) + download_backup_mailer: + title: "Preuzmite Backup Mailer" + subject_template: "[%{email_prefix}] Preuzimanje sigurnosne kopije stranice" + text_body_template: | + Ovdje je [preuzimanje sigurnosne kopije web-mjesta](%{backup_file_path}) koje ste tražili. + + Poslali smo ovu vezu za preuzimanje na vašu potvrđenu adresu e-pošte iz sigurnosnih razloga. + + (Ako *niste* zatražili ovo preuzimanje, trebali biste biti ozbiljno zabrinuti - netko ima administratorski pristup vašoj stranici.) + no_token: | + Nažalost, ova veza za preuzimanje sigurnosne kopije već je korištena ili je istekla. system_messages: queued_by_staff: title: "natpis čeka potvrdu" diff --git a/config/locales/server.hu.yml b/config/locales/server.hu.yml index 73774d3aaf..373c287a63 100644 --- a/config/locales/server.hu.yml +++ b/config/locales/server.hu.yml @@ -995,6 +995,11 @@ hu: log_anonymizer_details: "Anonimizálás után meg kell-e őrizni a felhasználó adatait a naplóban." reply_by_email_enabled: "E-mail válaszok engedélyezése." block_auto_generated_emails: "Az automatikusan generáltként azonosított bejövő e-mailek letiltása." + sendgrid_verification_key: "Sendgrid ellenőrző kulcs, amelyet a webhook üzenetek ellenőrzésére használnak." + mailjet_webhook_token: "A webhook hasznos terhelésének ellenőrzésére használt token. Ezt a webhook 't' lekérdezési paramétereként kell átadni, például: https://example.com/webhook/mailjet?t=supersecret" + mandrill_authentication_key: "Mandrill hitelesítési kulcs a webhook üzenetek ellenőrzésére." + postmark_webhook_token: "A webhook hasznos terhelésének ellenőrzésére használt token. Ezt a webhook „t” lekérdezési paramétereként kell átadni, például: https://example.com/webhook/postmark?t=supersecret" + sparkpost_webhook_token: "A webhook hasznos terhelésének ellenőrzésére használt token. Ezt a webhook 't' lekérdezési paramétereként kell átadni, például: https://example.com/webhook/sparkpost?t=supersecret" email_editable: "Engedélyezi a felhasználóknak, hogy a regisztráció után megváltoztassák az e-mail címüket." suppress_digest_email_after_days: "Letiltja az összefoglaló e-maileket azon felhasználók számára, akik nem láttak a webhelyen (n) napnál hosszabb ideig." digest_suppress_categories: "Letiltom ezeket a kategóriákat az összefoglaló e-mailekből." @@ -1011,6 +1016,7 @@ hu: company_name: "Cégnév" use_email_for_username_and_name_suggestions: "Használja az e-mail címek első részét a felhasználónév- és névjavaslatokhoz. Vegye figyelembe, hogy ez megkönnyíti a nyilvánosság számára a teljes felhasználói e-mail címek kitalálását (mivel az emberek nagy része olyan gyakori szolgáltatásokat használ, mint a „gmail.com”)." navigation_menu: "Határozza meg, melyik navigációs menüt használja. Az oldalsáv és a fejléc navigációja a felhasználók által testreszabható. A visszamenőleges kompatibilitás érdekében a régebbi opció is elérhető." + enable_new_notifications_menu: "Engedélyezi az új értesítési menü használatát a régi navigációs menüben." enable_experimental_topic_timeline_groups: "KÍSÉRLETI: A kiválasztott csoportok felhasználóinak megjelenik az átdolgozott téma idővonala" enable_experimental_hashtag_autocomplete: "KÍSÉRLETI: Használja az új #hashtag automatikus kiegészítési rendszert a kategóriákhoz és címkékhez, amelyek másképp jelenítik meg a kiválasztott elemet, és javítják a keresést" errors: @@ -1033,6 +1039,7 @@ hu: search_tokenize_chinese_enabled: "A beállítás engedélyezése előtt le kell tiltania a „search_tokenize_chinese” beállítást." search_tokenize_japanese_enabled: "A beállítás engedélyezése előtt le kell tiltania a „search_tokenize_japanese” beállítást." discourse_connect_cannot_be_enabled_if_second_factor_enforced: "A DiscourseConnect nem engedélyezhető, ha a 2FA kényszerítve van." + enable_new_notifications_menu_not_legacy_navigation_menu: "A beállítás engedélyezése előtt a `navigation_menu`-t `legacy` értékre kell állítani." search: within_post: "#%{post_number}, szerző: %{username}" types: diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index 4ba9e8c5d0..948e86d948 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -131,6 +131,7 @@ pl_PL: unsubscribe_not_allowed: "Zdarza się, kiedy anulowanie subskrypcji przez e‐mail nie jest dozwolone dla tego użytkownika." email_not_allowed: "Zdarza się, gdy adres e-mail nie znajduje się na liście dozwolonych adresów lub jest na liście zablokowanych." unrecognized_error: "Niezidentyfikowany błąd" + secure_uploads_placeholder: "Redacted: Ta strona ma włączone bezpieczne przesyłanie plików, odwiedź temat lub kliknij wyświetl media, aby zobaczyć załączone pliki." view_redacted_media: "Wyświetl załączniki" errors: &errors format: ! "%{attribute} %{message}" @@ -297,7 +298,7 @@ pl_PL: provider_not_enabled: "Nie masz dostępu do żądanego zasobu. Poświadczający uwierzytelnienie nie został upoważniony w systemie." provider_not_found: "Nie masz dostępu do żądanego zasobu. Poświadczający uwierzytelnienie nie istnieje." read_only_mode_enabled: "Strona jest w trybie tylko do odczytu. Możliwość interakcji jest wyłączona." - invalid_grant_badge_reason_link: "Zewnętrzny lub nieprawidłowy link do dyskursu jest niedozwolony z przyczyn związanych z odznaką" + invalid_grant_badge_reason_link: "Zewnętrzny lub nieprawidłowy link do Disocurse jest niedozwolony z przyczyn związanych z odznaką" email_template_cant_be_modified: "Ten szablon e-mail nie może być zmodyfikowany" invalid_whisper_access: "Albo szepty nie są włączone, albo nie masz dostępu do tworzenia wpisów szeptanych" not_in_group: @@ -1489,7 +1490,7 @@ pl_PL: display_local_time_in_user_card: "Wyświetl czas lokalny w oparciu o strefę czasową użytkownika po otwarciu karty użytkownika." censored_words: "Wskazane słowa będą automatycznie zamieniane na ■■■■" delete_old_hidden_posts: "Automatycznie kasuj wpisy ukryte dłużej niż 30 dni." - default_locale: "Domyślny język tego wystąpienia dyskursu. Tekst kategorii i tematów generowanych przez system można zastąpić w opcji Dostosuj / Tekst." + default_locale: "Domyślny język tej instancji Discourse. Możesz zastąpić tekst kategorii i tematów generowanych przez system na stronie Dostosuj / Tekst." allow_user_locale: "Zezwól użytkownikom na zmianę języka interfejsu we własnych ustawieniach" set_locale_from_accept_language_header: "ustaw język interfejsu dla anonimowych użytkowników na podstawie nagłówków języka z ich przeglądarki" support_mixed_text_direction: "Wspieraj mieszane kierunki tekstu od lewej do prawej i od prawej do lewej strony." @@ -1578,6 +1579,7 @@ pl_PL: summary_percent_filter: "Gdy użytkownik kliknie na 'Podsumowaniu tematu', pokaż % najlepszych wpisów" summary_max_results: "Maksymalna liczba wpisów zwróconych przez „Podsumuj ten temat”" summary_timeline_button: "Pokaż przycisk „Podsumuj” na osi czasu" + enable_personal_messages: "PRZESTARZAŁE, zamiast tego użyj ustawienia 'personal message enabled groups'. Zezwalaj użytkownikom na poziomie zaufania 1 (konfigurowalnym za pomocą minimalnego zaufania do wysyłania wiadomości) na tworzenie wiadomości i odpowiadanie na wiadomości. Pamiętaj, że personel zawsze może wysyłać wiadomości bez względu na wszystko." enable_system_message_replies: "Pozwala użytkownikom odpowiadać na wiadomości systemowe, nawet jeśli wiadomości osobiste są wyłączone" enable_chunked_encoding: "Włącz odpowiedzi fragmentaryczne serwera. Ta funkcja działa w większości konfiguracji, jednak niektóre serwery proxy mogą buforować odpowiedzi, powodując opóźnienia." long_polling_base_url: "Podstawowy URL używany dla long polling (kiedy CDN dostarcza dynamicznych treści, upewnij się że ustawiłeś/łaś to w origin pull) np.: http://origin.site.com" @@ -1627,7 +1629,7 @@ pl_PL: blocked_ip_blocks: "Lista prywatnych bloków IP, które nigdy nie powinny być indeksowane przez Discourse" allowed_internal_hosts: "Lista wewnętrznych hostów, które Discourse mogą bezpiecznie indeksować do oneboxingu i innych celów" allowed_onebox_iframes: "Lista domen iframe src, które są dozwolone do osadzania w Onebox. `*` pozwoli na wszystkie domyślne silniki Onebox." - allowed_iframes: "Lista prefiksów domeny src iframe, które dyskurs może bezpiecznie dopuścić w wpisach" + allowed_iframes: "Lista prefiksów domeny src iframe, które Discourse może bezpiecznie dopuścić w wpisach" allowed_crawler_user_agents: "Agenci użytkowników dla robotów indeksujących, które powinny mieć dostęp do strony. OSTRZEŻENIE! USTAWIENIE TEGO SPOWODUJE BLOKADE ROBOTÓW, KTÓRE NIE SĄ NA LIŚCIE!" blocked_crawler_user_agents: "Unikalne słowo bez rozróżniania wielkości liter w ciągu znaków, które identyfikują crawlery internetowe, które nie powinny mieć dostępu do witryny. Nie ma zastosowania, jeśli dozwolona lista jest zdefiniowana." slow_down_crawler_user_agents: 'User-Agent''y robotów indeksujących, którym należy ograniczyć prędkość, zgodnie z ustawieniami "spowolnij szybkość indeksowania". Każda wartość musi mieć co najmniej 3 znaki.' @@ -1780,7 +1782,7 @@ pl_PL: purge_deleted_uploads_grace_period_days: "Okres karencji (w dniach) przed usunięciem upload zostanie skasowany." purge_unactivated_users_grace_period_days: "Okres karencji w dniach, po upływie którego nieaktywowane konto użytkownika zostaje usunięte. Ustaw na 0, aby nigdy nie usuwać nieaktywowanych użytkowników." enable_s3_uploads: "Umieść przesyły w pamięci Amazon S3. Ważne: wymaga ważnych danych uwierzytelniających (zarówno klucza id i tajnego klucza dostępu)" - s3_use_iam_profile: 'Użyj profilu instancji AWS EC2, aby przyznać dostęp do segmentu S3. UWAGA: włączenie tej opcji wymaga, aby dyskurs działał w odpowiednio skonfigurowanej instancji EC2 i zastępuje ustawienia „Identyfikatora klucza dostępu s3” i „Tajnego klucza dostępu s3”.' + s3_use_iam_profile: 'Użyj profilu instancji AWS EC2, aby przyznać dostęp do segmentu S3. UWAGA: włączenie tej opcji wymaga, aby Discourse działał w odpowiednio skonfigurowanej instancji EC2 i zastępuje ustawienia "s3 access key id" i "s3 secret access key".' s3_upload_bucket: "Nazwa koszyka Amazon S3, do którego zostaną przesłane pliki. Ostrzeżenie: bez wielkich liter, kropek czy podkreślenia." s3_access_key_id: "Identyfikator klucza dostępu Amazon S3, który będzie używany do przesyłania zdjęć, załączników i kopii zapasowych." s3_secret_access_key: "Tajny klucz dostępu Amazon S3, który będzie używany do przesyłania zdjęć, załączników i kopii zapasowych." @@ -1840,6 +1842,7 @@ pl_PL: min_trust_to_edit_wiki_post: "Minimalny poziom zaufania wymagany do edycji wpisu oznaczonego jako wiki." min_trust_to_edit_post: "Minimalny poziom zaufania potrzebny do edytowania wpisów." min_trust_to_allow_self_wiki: "Wymagany poziom zaufania, by wpis użytkownika uczynić wiki." + min_trust_to_send_messages: "PRZESTARZAŁE, zamiast tego użyj ustawienia 'personal message enabled groups'. Minimalny poziom zaufania wymagany do tworzenia nowych wiadomości osobistych." min_trust_to_send_email_messages: "Minimalny poziom zaufania jest wymagany do wysyłania osobistych wiadomości pocztą e-mail." min_trust_to_flag_posts: "Minimalny poziom zaufania potrzebny do flagowania wpisów" min_trust_to_post_links: "Minimalny poziom zaufania potrzebny do umieszczania linków we wpisach" @@ -1931,7 +1934,7 @@ pl_PL: alternative_reply_by_email_addresses: "Lista alternatywnych szablonów do odpowiedzi przez adres email. Przykład: %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" incoming_email_prefer_html: "Do przychodzących wiadomości e-mail używaj HTML zamiast tekstu." strip_incoming_email_lines: "Usuń wiodące i końcowe białe spacje z każdej linii przychodzących wiadomości e-mail." - disable_emails: "Zapobiegaj wysyłaniu przez dyskurs jakichkolwiek wiadomości e-mail. Wybierz „tak”, aby wyłączyć e-maile dla wszystkich użytkowników. Wybierz opcję „nie będący pracownikami”, aby wyłączyć wiadomości e-mail tylko dla użytkowników niebędących członkami personelu." + disable_emails: "Zapobiegaj wysyłaniu przez Discourse jakichkolwiek wiadomości e-mail. Wybierz „tak”, aby wyłączyć e-maile dla wszystkich użytkowników. Wybierz opcję „nie będący pracownikami”, aby wyłączyć wiadomości e-mail tylko dla użytkowników niebędących członkami personelu." strip_images_from_short_emails: "Pasek zdjęć z emaila o rozmiarze mniejszym niż 2800 bajtów" short_email_length: "Długość krótkiego emaila w bajtach" display_name_on_email_from: "Wyświetl pełne nazwy na emailach z pól" @@ -2086,6 +2089,10 @@ pl_PL: delete_merged_stub_topics_after_days: "Liczba dni, po których wątki, z których zostały przeniesione wszystkie posty, zostaną automatycznie usunięte. Aby to wyłączyć, ustaw wartość na 0." bootstrap_mode_min_users: "Minimalna liczba użytkowników potrzebnych do wyłączenia trybu rozruchowego (ustaw 0, aby wyłączyć)" prevent_anons_from_downloading_files: "Zablokuj możliwość pobierania załączników przez anonimowych użytkowników." + secure_media: "PRZESTARZAŁE: Zamiast tego użyj ustawienia secure_uploads, to ustawienie zostanie usunięte w Discourse 3.0." + secure_media_allow_embed_images_in_emails: "PRZESTARZAŁE: Użyj secure_uploads_allow_embed_images_in_emails, to ustawienie zostanie usunięte w Discourse 3.0." + secure_uploads_allow_embed_images_in_emails: "Umożliwia osadzanie w wiadomościach e-mail bezpiecznych obrazów, które normalnie byłyby pominięte, jeśli ich rozmiar jest mniejszy niż ustawienie 'secure uploads max email embed image size kb'." + secure_media_max_email_embed_image_size_kb: "PRZESTARZAŁE: Użyj secure_uploads_max_email_embed_image_size_kb, to ustawienie zostanie usunięte w Discourse 3.0." slug_generation_method: "Wybierz metodę tworzenia ślimaka. 'Zakodowane\" stworzy procentowy ciąg kodujący, \"żadne\" wyłączy tą metodę." enable_emoji: "Włącz obsługę emoji" enable_emoji_shortcuts: "Typowe tekstowe buźki, takie jak :) :p :( zostaną przekonwertowane na emoji" @@ -2179,6 +2186,9 @@ pl_PL: base_font: "Podstawowa czcionka używana dla większości tekstu w witrynie. Motywy można nadpisywać za pomocą niestandardowej właściwości CSS `--font-family`." heading_font: "Czcionka używana w nagłówkach witryny. Motywy można nadpisywać za pomocą niestandardowej właściwości CSS `--heading-font-family`." enable_sitemap: "Wygeneruj mapę witryny dla swojej strony i umieść ją w pliku robots.txt." + sitemap_page_size: "Liczba adresów URL do uwzględnienia na każdej stronie mapy witryny. Maksymalnie 50.000" + enable_user_status: "(eksperymentalne) Zezwól użytkownikom na ustawienie niestandardowego statusu (emoji + opis)." + enable_user_tips: "(eksperymentalne) Włącz nowe wskazówki dla użytkowników, które opisują najważniejsze funkcje witryny" short_title: "Krótki tytuł będzie używany na ekranie głównym użytkownika, launcherze lub w innych miejscach, gdzie przestrzeń może być ograniczona. Powinien składać się maksymalnie z 12 znaków." dashboard_hidden_reports: "Zezwalaj na ukrycie określonych raportów z panelu administracyjnego." dashboard_visible_tabs: "Wybierz, które karty panelu administracyjnego są widoczne." @@ -2194,6 +2204,9 @@ pl_PL: use_name_for_username_suggestions: "Proponując nazwy użytkownika, używaj imienia i nazwiska użytkownika." suggest_weekends_in_date_pickers: "Uwzględnij weekendy (sobota i niedziela) w sugestiach selektora dat (wyłącz tę opcję, jeśli używasz Discourse tylko w dni powszednie, od poniedziałku do piątku)." splash_screen: "Wyświetla tymczasowy ekran ładowania podczas ładowania zasobów witryny" + navigation_menu: "Określ, którego menu nawigacyjnego użyć. Pasek boczny i nawigacja z nagłówka są konfigurowalne przez użytkowników. Opcja przestarzała jest dostępna dla kompatybilności wstecznej." + default_sidebar_categories: "Wybrane kategorie będą domyślnie wyświetlane w sekcji Kategorie paska bocznego." + default_sidebar_tags: "Wybrane tagi będą domyślnie wyświetlane w sekcji Tagi paska bocznego." enable_new_user_profile_nav_groups: "EKSPERYMENTALNE: Użytkownikom wybranych grup zostanie wyświetlone nowe menu nawigacyjne profilu użytkownika" enable_experimental_topic_timeline_groups: "EKSPERYMENTALNE: Użytkownikom wybranych grup zostanie pokazana odświeżona oś czasu tematu" enable_experimental_hashtag_autocomplete: "EKSPERYMENTALNIE: Użyj nowego systemu autouzupełniania #hashtagów dla kategorii i tagów, który inaczej renderuje wybrany element i usprawnia wyszukiwanie" @@ -2224,6 +2237,7 @@ pl_PL: reply_by_email_address_is_empty: "Musisz ustawić \"odpowiedz poprzez adres email' przed włączeniem odpowiedzi przez email." email_polling_disabled: "Musisz włączyć manualne lub cykliczne przeglądanie POP3 przed włączeniem odpowiedzi przez email." user_locale_not_enabled: "Musisz najpierw włączyć ''pozwól na ustawienia lokalne\" przed włączeniem tych ustawień." + personal_message_enabled_groups_invalid: "Musisz określić co najmniej jedną grupę dla tego ustawienia. Jeśli nie chcesz, aby ktokolwiek poza personelem wysyłał PW, wybierz grupę personelu." invalid_regex: "Wyrażenie regularne jest niepoprawne lub niedozwolone." invalid_regex_with_message: "Wyrażenie regularne '%{regex}' zawiera błąd: %{message}" email_editable_enabled: "Musisz wyłączyć 'możliwość edycji emaili' przed włączeniem tych ustawień." diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index fc1e2bfedb..245e527aa0 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -1090,10 +1090,10 @@ ru: session_info: "Информация о сеансе пользователя" read: "Читать всё" write: "Писать всё" - one_time_password: "Создать одноразовый токен для входа" - bookmarks_calendar: "Чтение напоминаний о закладках." + one_time_password: "Создавать одноразовый токен для входа" + bookmarks_calendar: "Чтение напоминаний о закладках" invalid_public_key: "Открытый ключ недействителен." - invalid_auth_redirect: "Этот хост auth_redirect не разрешён." + invalid_auth_redirect: "Этот хост «auth_redirect» не разрешён." invalid_token: "Токен отсутствует, недействителен или просрочен." flags: errors: @@ -1641,7 +1641,7 @@ ru: post_menu_hidden_items: "Пункты меню действий над записью, которые по умолчанию скрыты и появляются по нажатию на кнопку «Показать ещё»." share_links: "Укажите, какие элементы должны отображаться в окне «Поделиться» и в какой последовательности." allow_username_in_share_links: "Разрешить включать имена пользователей в ссылки доступа. Может быть полезно для награждения за привлечение уникальных посетителей." - site_contact_username: "Все автоматические сообщения будут отправляться от имени указанного здесь пользователя. Если оставить поле пустым, то будет использован системный пользователь по умолчанию — System." + site_contact_username: "Все автоматические сообщения будут отправляться от имени указанного здесь пользователя. Если оставить поле пустым, то будет использован системный аккаунт по умолчанию — System." site_contact_group_name: "Допустимое имя группы для приглашения ко всем автоматическим сообщениям." send_welcome_message: "Отправлять всем новым пользователям приветственное сообщение с коротким описанием возможностей форума." send_tl1_welcome_message: "Отправлять всем новым пользователям с уровнем доверия 1 приветственное сообщение." @@ -1659,7 +1659,7 @@ ru: prioritize_username_in_ux: "Показывать имя пользователя первым на странице пользователя, карточке пользователя и записях (когда отключённое имя показывается первым)" enable_rich_text_paste: "Включить автоматическое преобразование HTML в Markdown при вставке текста в редактор. (Экспериментальная функция.)" send_old_credential_reminder_days: "Напомнить о старых учётных данных через указанное количество дней" - email_token_valid_hours: "Ссылка на восстановление пароля / активацию учётной записи будет действовать в течение указанного здесь количества часов." + email_token_valid_hours: "Ссылка на восстановление пароля (активацию аккаунта) будет действовать в течение указанного здесь количества часов." enable_badges: "Включить систему наград" max_favorite_badges: "Максимальное количество наград, которое может выбрать пользователь" whispers_allowed_groups: "Разрешать участникам указанных групп создавать скрытые сообщения в темах." @@ -2088,72 +2088,72 @@ ru: notify_about_flags_after: "Отправлять личное сообщение модератору, если есть жалобы, не обработанные в течение указанного здесь количества часов. Для отключения параметра установите это значение в 0." show_create_topics_notice: "Если общее количество тем на сайте меньше 5, показывать персоналу сообщение с просьбой создать новые темы." delete_drafts_older_than_n_days: "Удалять черновики, которые старше указанного здесь количества дней." - delete_merged_stub_topics_after_days: "Количество дней, после которого темы, из которых были перемещены все сообщения, будут автоматически удалены. Для отключения параметра установите значение в 0." - bootstrap_mode_min_users: "Минимальное количество пользователей, необходимое для отключения специального режима (Для отключения этого параметра установите значение в 0)" + delete_merged_stub_topics_after_days: "Количество дней, после которого темы, из которых были перемещены все записи, будут автоматически удалены. Для отключения параметра установите значение в 0." + bootstrap_mode_min_users: "Минимальное количество пользователей, необходимое для отключения специального режима (для отключения этого параметра установите значение в 0)" prevent_anons_from_downloading_files: "Запретить анонимным пользователям скачивать вложения." - secure_media: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0, вместо него используйте параметр «secure_uploads_max_email_embed_image_size_kb»." + secure_media: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0 — вместо него используйте «secure_uploads»." secure_uploads: 'Ограничивать доступ ко ВСЕМ загрузкам (изображения, видео, аудио, текст, PDF, ZIP-файлы и другие). Если включено требования входа, только авторизованные пользователи будут получать доступ к загрузкам. При отключённом параметре загрузки будут недоступны только в личных сообщениях и в закрытых категориях. ВНИМАНИЕ! Этот сложный параметр. Аминистратор, используя его, должен отдавать себе отчёт в своих действиях. См. тему об ограничении доступа к загружаемому контенту на Meta.' - secure_media_allow_embed_images_in_emails: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0, вместо него используйте параметр «secure_uploads_allow_embed_images_in_emails»." + secure_media_allow_embed_images_in_emails: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0 — вместо него используйте «secure_uploads_allow_embed_images_in_emails»." secure_uploads_allow_embed_images_in_emails: "Разрешать встраивать изображения с ограниченным доступом в письма, если их размер не превышает значения, указанного в параметре «secure uploads max email embed image size kb»." - secure_media_max_email_embed_image_size_kb: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0, вместо него используйте параметр «secure_uploads_max_email_embed_image_size_kb»." + secure_media_max_email_embed_image_size_kb: "УСТАРЕЛО: параметр будет удалён в Discourse 3.0 — вместо него используйте «secure_uploads_max_email_embed_image_size_kb»." secure_uploads_max_email_embed_image_size_kb: "Размер, до которого будут уменьшены встроенные в письма изображения с ограниченным доступом, если включён параметр «secure uploads allow embed in emails». Если этот параметр не включен, это значение не учитывается." slug_generation_method: "Метод создания слагов. При выборе «encoded» будет выполняться процентное кодирование строки. При выборе «none» слаги создаваться не будут." enable_emoji: "Использовать эмодзи" - enable_emoji_shortcuts: "Текстовая альтернатива смайлика, например, сочетания :), :p или :( — будет преобразовываться в полноценный смайлик" + enable_emoji_shortcuts: "Обычные смайлы, например :), :p и :(, будут преобразовываться в эмодзи" emoji_set: "Какую коллекцию эмодзи использовать?" emoji_autocomplete_min_chars: "Минимальное количество символов, необходимое для появления всплывающего окна с эмодзи" enable_inline_emoji_translation: "Включить перевод для встроенных эмодзи (без пробелов и знаков препинания)" - approve_post_count: "Минимальное количество сообщений от новичка или обычного пользователя, которые должны быть утверждены" - approve_unless_trust_level: "Сообщения от пользователей с уровнем доверия ниже указанного должны быть одобрены" + approve_post_count: "Минимальное количество записей от новичка или обычного пользователя, которые должны быть утверждены" + approve_unless_trust_level: "Записи от пользователей с уровнем доверия ниже указанного должны быть одобрены" approve_new_topics_unless_trust_level: "Новые темы от пользователей с уровнем доверия ниже указанного должны быть одобрены" - approve_unless_staged: "Новые темы и сообщения сымитированных пользователей должны быть одобрены" - notify_about_queued_posts_after: "Отправлять уведомления всем модераторам, если сообщения находятся в очереди на модерацию дольше, чем указанное здесь количество часов. Для отключения уведомлений установите это значение в 0." - auto_close_messages_post_count: "Максимальное количество сообщений, разрешённых в личных сообщениях, после чего они будут автоматически закрыты. Для отключения параметра установите это значение в 0." - auto_close_topics_post_count: "Максимальное количество сообщений, разрешённых в теме, после чего она будет автоматически закрыта. Для отключения параметра установите это значение в 0." + approve_unless_staged: "Новые темы и записи сымитированных пользователей должны быть одобрены" + notify_about_queued_posts_after: "Отправлять уведомления всем модераторам, если записи находятся в очереди на проверку дольше, чем указанное здесь количество часов. Для отключения уведомлений установите это значение в 0." + auto_close_messages_post_count: "Максимальное количество записей, разрешённых в сообщении, после чего оно будет автоматически закрыто. Для отключения параметра установите это значение в 0." + auto_close_topics_post_count: "Максимальное количество записей, разрешённых в теме, после чего она будет автоматически закрыта. Для отключения параметра установите это значение в 0." auto_close_topics_create_linked_topic: "Создавать новую связанную тему, если тема автоматически закрывается на основе настроенного параметра «auto close topics post count»" code_formatting_style: "Кнопка кода в редакторе будет использовать по умолчанию этот стиль форматирования кода" - max_allowed_message_recipients: "Максимальное число получателей личного сообщения." + max_allowed_message_recipients: "Максимальное число получателей сообщения." watched_words_regular_expressions: "Контролируемые слова представлены регулярными выражениями." enable_diffhtml_preview: "Экспериментальная функция, которая использует diffHTML для синхронизации предпросмотра вместо полного повторного рендеринга." - enable_fast_edit: "Включить возможность быстрой правки выделенного текста в недавно созданном сообщении." - old_post_notice_days: "Количество дней, после которого почтовое уведомление станет устаревшим" - new_user_notice_tl: "Минимальный уровень доверия, необходимый для просмотра новых сообщений пользователей." - returning_user_notice_tl: "Минимальный уровень доверия, необходимый для просмотра уведомлений о возвращающихся сообщениях." + enable_fast_edit: "Возможность быстрой правки выделенного текста в недавно созданной записи." + old_post_notice_days: "Количество дней, после которого уведомление о записи станет устаревшим" + new_user_notice_tl: "Минимальный уровень доверия, необходимый для просмотра уведомлений о записях новых пользователей." + returning_user_notice_tl: "Минимальный уровень доверия, необходимый для просмотра уведомлений о записях возвращающихся пользователей." returning_users_days: "Количество дней, после которого пользователь будет считаться вернувшимся на форум." - review_media_unless_trust_level: "Отправлять сообщения пользователей с низким уровнем доверия на премодерацию, если они содержат встроенные медиафайлы." - blur_tl0_flagged_posts_media: "Размывать изображения в находящихся на премодерации сообщениях для сокрытия возможного NSFW-контента." + review_media_unless_trust_level: "Отправлять записи пользователей с низким уровнем доверия на проверку, если они содержат встроенные медиафайлы." + blur_tl0_flagged_posts_media: "Размывать изображения в находящихся на проверке записях для сокрытия возможного NSFW-контента." enable_page_publishing: "Разрешить сотрудникам публиковать темы на новых URL-адресах с их собственным стилем." show_published_pages_login_required: "Опубликованные страницы доступны анонимным пользователям, для этого не требуется регистрация на форуме." - skip_auto_delete_reply_likes: "При автоматическом удалении старых ответов не удалять сообщения, количество лайков в которых равно или превышает указанное здесь значение." + skip_auto_delete_reply_likes: "При автоматическом удалении старых ответов не удалять записи, количество лайков в которых равно или превышает указанное здесь значение." default_email_digest_frequency: "Как часто пользователи получают письма со сводками по умолчанию." - default_include_tl0_in_digests: "Включать по умолчанию сообщения от новых пользователей в письма со сводками. Пользователи могут изменить эту настройку в своём профиле." + default_include_tl0_in_digests: "Включать по умолчанию записи от новых пользователей в письма со сводками. Пользователи могут изменить эту настройку в своём профиле." default_email_level: "Уровень уведомления по электронной почте по умолчанию для обычных тем." - default_email_messages_level: "Уровень уведомления по электронной почте по умолчанию, когда кто-то отправляет личное сообщение пользователю." - default_email_mailing_list_mode: "По умолчанию присылать почтовое уведомление при каждом новом сообщении." + default_email_messages_level: "Уровень уведомления по электронной почте по умолчанию, когда кто-то отправляет сообщение пользователю." + default_email_mailing_list_mode: "По умолчанию присылать письмо при каждой новой записи." default_email_mailing_list_mode_frequency: "Пользователи, которые включают режим почтовой рассылки, будут получать электронные письма с указанной здесь периодичностью." - disable_mailing_list_mode: "Запретить пользователям включать режим почтовой рассылки (запрет отправки уведомлений о новых сообщениях)." - default_email_previous_replies: "Включать по умолчанию предыдущие ответы в электронные письма." - default_email_in_reply_to: "Включать по умолчанию выдержку из ответов на сообщения в электронные письма." + disable_mailing_list_mode: "Запретить пользователям включать режим почтовой рассылки (запрет отправки уведомлений о новых записях)." + default_email_previous_replies: "По умолчанию включать предыдущие ответы в письма." + default_email_in_reply_to: "По умолчанию включать выдержку из ответов на записи в письма." default_other_new_topic_duration_minutes: "Глобальное условие по умолчанию, по которому темы считаются новыми." default_other_auto_track_topics_after_msecs: "Глобальное время по умолчанию перед автоматическим отслеживанием темы." default_other_notification_level_when_replying: "Глобальный уровень уведомлений по умолчанию при ответе пользователя в теме." default_other_external_links_in_new_tab: "По умолчанию открывать внешние ссылки в новой вкладке." default_other_enable_quoting: "Включить по умолчанию ответ с цитированием для выделенного текста." - default_other_enable_defer: "Включить отложенную тему по умолчанию." + default_other_enable_defer: "Включить функцию отложенных тем по умолчанию." default_other_dynamic_favicon: "Показывать количество новых/обновлённых тем на значке браузера по умолчанию." default_other_skip_new_user_tips: "Не выдавать награды и не показывать советы новым пользователям." - default_other_like_notification_frequency: "Уведомлять пользователей о лайках (по умолчанию)" + default_other_like_notification_frequency: "По умолчанию уведомлять пользователей о лайках" default_topics_automatic_unpin: "По умолчанию автоматически откреплять полностью прочтённые темы." default_categories_watching: "Список наблюдаемых категорий по умолчанию." default_categories_tracking: "Список отслеживаемых категорий по умолчанию." default_categories_muted: "Список категорий, в которых уведомления по умолчанию отключены." - default_categories_watching_first_post: "Список категорий, в которых по умолчанию будет включено наблюдение за первым сообщением в каждой новой теме." + default_categories_watching_first_post: "Список категорий, в которых по умолчанию будет включено наблюдение за первой записью в каждой новой теме." default_categories_normal: "Перечень категорий, в которых по умолчанию уведомления всегда включены. Эта настройка может быть полезна, когда включён параметр `mute_all_categories_by_default`." - mute_all_categories_by_default: "Установить по умолчанию уровень уведомлений для всех категорий на «Без уведомлений». Требовать от пользователей подписки на разделы, чтобы они появлялись в секциях «Последние» и «Разделы». Если вы хотите изменить настройки по умолчанию для анонимных пользователей — измените настройки «default_categories_»." + mute_all_categories_by_default: "Установить по умолчанию уровень уведомлений для всех категорий на «Без уведомлений». Требовать от пользователей подписки на категории, чтобы они появлялись на страницах «Последние» и «Категории». Если вы хотите изменить настройки по умолчанию для анонимных пользователей, измените настройки «default_categories_»." default_tags_watching: "Список тегов по умолчанию, которыми помечаются наблюдаемые темы." default_tags_tracking: "Список тегов по умолчанию, которыми помечаются отслеживаемые темы." - default_tags_muted: "Список тегов по умолчанию, которыми помечаются выключенные темы." - default_tags_watching_first_post: "Список тегов по умолчанию, которыми помечаются темы с одним сообщением." + default_tags_muted: "Список тегов по умолчанию, которыми помечаются темы с отключенными уведомлениями." + default_tags_watching_first_post: "Список тегов, в которых по умолчанию будет включено наблюдение за первой записью в каждой новой теме." default_text_size: "Размер текста, выбранный по умолчанию" default_title_count_mode: "Режим по умолчанию для счётчика заголовка страницы" retain_web_hook_events_period_days: "Количество дней, в течение которых сохраняются записи событий вебхука." @@ -2173,15 +2173,15 @@ ru: max_tags_in_filter_list: "Максимальное количество тегов для отображения в списке фильтрации. Будут показаны самые часто используемые теги." tags_sort_alphabetically: "Отображать теги в алфавитном порядке. По умолчанию теги отображаются в порядке популярности." tags_listed_by_group: "Список тегов с группировкой тегов по группам на странице тегов." - tag_style: "Стиль отображения для значков тегов." + tag_style: "Стили для выделения тегов." pm_tags_allowed_for_groups: "Разрешать участникам групп помечать тегом любое личное сообщение" min_trust_level_to_tag_topics: "Минимальный уровень доверия для отметки тем тегами." suppress_overlapping_tags_in_list: "Не отображать тег, если он полностью совпадает с заголовком темы" - remove_muted_tags_from_latest: "Не показывать темы в списке последних тем, если они помечены только выключающими тегами." + remove_muted_tags_from_latest: "Не показывать темы в списке последних тем, если они помечены только тегами, отключающими уведомления." force_lowercase_tags: "Вводить все новые теги только строчными буквами." company_name: "Название компании" - governing_law: "Регулирующий Закон" - city_for_disputes: "Город для решения споров" + governing_law: "Регулирующее законодательство" + city_for_disputes: "Город для разрешения споров" shared_drafts_category: "Включить функцию «Общие черновики», назначив категорию для черновиков тем. Персонал не будет видеть подобные темы в разделе «Обсуждаемые»." shared_drafts_min_trust_level: "Разрешить пользователям просматривать и редактировать общие черновики." push_notifications_prompt: "Отображать запрос согласия пользователя." @@ -2189,82 +2189,82 @@ ru: base_font: "Основной шрифт, используемый для большей части текста на сайте. Шрифт тем можно переопределить с помощью настраиваемого свойства CSS `--font-family`." heading_font: "Шрифт, используемый для заголовков. Шрифт тем можно переопределить с помощью настраиваемого свойства CSS `--font-family`." enable_sitemap: "Создать карту сайта и включить её в файл robots.txt." - sitemap_page_size: "Количество URL-адресов для включения в каждую страницу карты сайта. Значение не должно превышать 50 000" + sitemap_page_size: "Количество URL-адресов для включения в каждую страницу карты сайта, максимум 50 000" enable_user_status: "(экспериментально) Разрешить пользователям устанавливать собственное сообщение о статусе (эмодзи + описание)." - short_title: "Краткое название будет использоваться на домашней странице, в панели запуска или в других местах, где пространство может быть ограничено. Оно должен быть не более 12 символов." - dashboard_hidden_reports: "Разрешать скрывать отдельные отчёты из админки." - dashboard_visible_tabs: "Закладки, которые будут отображаться в админке." - dashboard_general_tab_activity_metrics: "Отчёты, отображаемые в метриках активности на основной вкладке админки." - gravatar_name: "Название провайдера Gravatar" - gravatar_base_url: "URL базы API провайдера Gravatar" - gravatar_login_url: "URL относительно gravatar_base_url, который предоставляет пользователю логин для службы Gravatar" - share_quote_buttons: "Определите, какие элементы должны отображаться в окне «Поделиться» и в какой последовательности." - share_quote_visibility: "Определите, когда показывать кнопки обмена цитатами: никогда, только анонимным пользователям или всем пользователям. " - create_revision_on_bulk_topic_moves: "Добавлять первые сообщения в историю редактирования при массовом перемещении тем в другой раздел." + short_title: "Краткое название будет использоваться на домашней странице, в панели запуска и в других местах, где пространство может быть ограничено. Оно должен быть не более 12 символов." + dashboard_hidden_reports: "Разрешать скрывать отдельные отчёты в панели управления." + dashboard_visible_tabs: "Закладки, которые будут отображаться в панели управления." + dashboard_general_tab_activity_metrics: "Отчёты, отображаемые в метриках активности на основной вкладке панели управления." + gravatar_name: "Название поставщика Gravatar" + gravatar_base_url: "URL базы API поставщика Gravatar" + gravatar_login_url: "URL относительно «gravatar_base_url», который обеспечивает вход пользователя в сервис Gravatar" + share_quote_buttons: "Какие элементы должны отображаться в окне «Поделиться» и в какой последовательности." + share_quote_visibility: "Когда показывать кнопки публикации цитат: никогда, только анонимным пользователям или всем пользователям. " + create_revision_on_bulk_topic_moves: "Добавлять первые записи в историю редактирования при массовом перемещении тем в другую категорию." allow_changing_staged_user_tracking: "Разрешить пользователю с правами администратора изменять настройки уведомлений для сымитированных пользователей в категориях и тегах." - use_email_for_username_and_name_suggestions: "Предлагать первую часть адресов электронной почты в качестве имени и псевдонима пользователя. Обратите внимание, что это упрощает угадывание полных адресов электронной почты пользователей (поскольку большая часть людей использует такие известные службы как `gmail.com`)." - use_name_for_username_suggestions: "Предлагать в качестве псевдонима полное имя пользователя." + use_email_for_username_and_name_suggestions: "Предлагать первую часть адресов электронной почты в качестве имени пользователя и учетного имени. Это упрощает угадывание полных адресов электронной почты пользователей (поскольку большинство использует популярные сервисы, например `gmail.com`)." + use_name_for_username_suggestions: "Предлагать в качестве имени пользователя полное имя." suggest_weekends_in_date_pickers: "Отображать выходные (субботу и воскресенье) при выборе даты (отключите этот параметр, если вы используете Discourse только в будние дни, с понедельника по пятницу)." splash_screen: "Отображать временный экран загрузки во время загрузки ресурсов сайта" - default_sidebar_categories: "Выбранные разделы по умолчанию будут отображаться в соответствующей секции боковой панели." - default_sidebar_tags: "Выбранные теги по умолчанию будут отображаться в соответствующей секции боковой панели." + default_sidebar_categories: "Выбранные категории по умолчанию будут отображаться в соответствующем разделе боковой панели." + default_sidebar_tags: "Выбранные теги по умолчанию будут отображаться в соответствующем разделе боковой панели." enable_new_user_profile_nav_groups: "ЭКСПЕРИМЕНТАЛЬНО: для пользователей выбранных групп будет отображаться новое меню навигации профиля пользователя." errors: invalid_css_color: "Недопустимый цвет. Введите название цвета или шестнадцатеричное значение." invalid_email: "Неправильный адрес электронной почты." - invalid_username: "Пользователя с таким именем пользователя не обнаружено." - valid_username: "Пользователь с таким именем уже существует." + invalid_username: "Такое имя пользователя не обнаружено." + valid_username: "Такое имя пользователя уже существует." invalid_group: "Нет группы с таким названием." 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} знаков." + must_include_latest: "Верхнее меню должно содержать вкладку «Последние»." + invalid_string: "Недопустимое значение." + invalid_string_min_max: "Допустимое количество символов: от %{min} до %{max}." + invalid_string_min: "Минимум символов: %{min}." + invalid_string_max: "Максимум символов: %{max}." invalid_json: "Недопустимый формат JSON." invalid_reply_by_email_address: "Значение должно содержать «%{reply_key}» и должно отличаться от письма уведомления." invalid_alternative_reply_by_email_addresses: "Все значения должны содержать «%{reply_key}» и должны отличаться от письма уведомления." invalid_domain_hostname: "Не может содержать символы * и ?." - pop3_polling_host_is_empty: "Вы должны установить «pop3 polling host» перед включением POP3 polling." - pop3_polling_username_is_empty: "Вы должны установить «pop3 polling username» перед включением POP3 polling." - pop3_polling_password_is_empty: "Вы должны установить «pop3 polling password» перед включением POP3 polling." - pop3_polling_authentication_failed: "Ошибка аутентификации POP3. Проверьте ваши учетные данные pop3." - reply_by_email_address_is_empty: "Вы должны установить «ответить по адресу электронной почты» перед включением возможности ответа по эл. почте." - email_polling_disabled: "Перед включением ответа по эл. почте необходимо включить polling вручную или POP3." + 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: "Перед включением возможности ответа по эл. почте нужно указать значение «ответить по адресу электронной почты»." + email_polling_disabled: "Перед включением ответа по эл. почте необходимо включить проверку почты вручную или через POP3." user_locale_not_enabled: "Перед включением данной настройки необходимо включить «allow user locale»." personal_message_enabled_groups_invalid: "Для этого параметра необходимо указать хотя бы одну группу. Если вы не хотите, чтобы кто-либо, кроме сотрудников, отправлял личные сообщения, выберите группу сотрудников." invalid_regex: "Регулярное выражение недействительно или не разрешено." invalid_regex_with_message: "В регулярном выражении «%{regex}» содержится ошибка: %{message}" - email_editable_enabled: "Вы должны отключить «редактирование электронной почты» перед включением этого параметра." + email_editable_enabled: "Перед включением этого параметра нужно отключить «редактирование адреса электронной почты»." staged_users_disabled: "Перед включением этого параметра необходимо включить параметр «Сымитированные пользователи»." - reply_by_email_disabled: "Вы должны сначала включить параметр «Ответить по электронной почте», прежде чем включить эту настройку." - discourse_connect_url_is_empty: "Вы должны ввести «discourse connect url» перед включением этой настройки." + reply_by_email_disabled: "Перед включением этого параметра необходимо включить параметр «Ответить по электронной почте»." + discourse_connect_url_is_empty: "Перед включением этого параметра необходимо настроить параметр «discourse connect url»." discourse_connect_invite_only: "Нельзя одновременно включить DiscourseConnect и регистрировать пользователей на форуме только через приглашения." - enable_local_logins_disabled: "Перед включением этого параметра вы должны включить параметр «Включить локальные аккаунты»." - min_username_length_exists: "Вы не можете установить минимальную длину псевдонима более самого короткого —(%{username})." + enable_local_logins_disabled: "Перед включением этого параметра необходимо включить параметр «Включить локальные аккаунты»." + min_username_length_exists: "Вы не можете установить минимальную длину имени пользователя более самого короткого (%{username})." min_username_length_range: "Нельзя установить минимум больше максимума." - max_username_length_exists: "Вы не можете установить максимальную длину псевдонима менее самого короткого —(%{username})." + max_username_length_exists: "Вы не можете установить максимальную длину имени пользователя менее самого длинного (%{username})." max_username_length_range: "Нельзя установить максимум ниже минимума." - invalid_hex_value: "Значения цвета должны быть шестнадцатеричными кодами из 6 цифр." + invalid_hex_value: "Значения цвета — шесть цифр в шестнадцатеричной системе исчисления." empty_selectable_avatars: "Для включения этого параметра необходимо загрузить не менее двух аватаров, из которых можно будет сделать выбор." category_search_priority: low_weight_invalid: "Вы не можете установить вес больше или равным 1." high_weight_invalid: "Вы не можете установить вес меньше или равным 1." allowed_unicode_usernames: regex_invalid: "Недопустимое регулярное выражение: %{error}" - leading_trailing_slash: "Регулярное выражение не должно начинаться и заканчиваться косой чертой (слешем)." - unicode_usernames_avatars: "Внутренние системные аватары не поддерживают псевдонимы в формате Unicode." - list_value_count: "Список должен содержать именно %{count} значений." + leading_trailing_slash: "Регулярное выражение не должно начинаться или заканчиваться прямой косой чертой." + unicode_usernames_avatars: "Внутренние системные аватары не поддерживают имена пользователей в формате Unicode." + list_value_count: "Значений в списке должно быть ровно %{count}." markdown_linkify_tlds: "Нельзя использовать значение «*»." - google_oauth2_hd_groups: "Перед включением этого параметра необходимо настроить параметр «google oauth2 hd»." - search_tokenize_chinese_enabled: "Вы должны отключить «search_tokenize_chinese», прежде чем включить этот параметр." - search_tokenize_japanese_enabled: "Вы должны отключить «search_tokenize_japanese», прежде чем включить этот параметр." - discourse_connect_cannot_be_enabled_if_second_factor_enforced: "Вы не можете включить DiscourseConnect, если принудительно включена двухфакторная аутентификация." - delete_rejected_email_after_days: "Значение этого параметра не может быть меньше значения параметра «delete_email_logs_after_days» или больше %{max}." + google_oauth2_hd_groups: "Перед включением этого параметра необходимо задать все настройки «google oauth2 hd»." + search_tokenize_chinese_enabled: "Перед включением этого параметра нужно отключить «search_tokenize_chinese»." + search_tokenize_japanese_enabled: "Перед включением этого параметра нужно отключить «search_tokenize_japanese»." + discourse_connect_cannot_be_enabled_if_second_factor_enforced: "Нельзя включить DiscourseConnect, если принудительно включена двухфакторная аутентификация." + delete_rejected_email_after_days: "Значение этого параметра не может быть меньше значения «delete_email_logs_after_days» или больше %{max}." placeholder: discourse_connect_provider_secrets: key: "www.example.com" @@ -2281,53 +2281,53 @@ ru: 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» является обязательным, но он не установлен" - email_error: "Учётная запись не может быть зарегистрирована с электронным адресом %{email}. Свяжитесь с администратором сайта." - missing_secret: "Сбой аутентификации из-за отсутствия ключа. Обратитесь к администраторам сайта для решения этой проблемы." - invite_redeem_failed: "Не удалось осуществить приглашение по ссылке. Свяжитесь с администратором сайта." - original_poster: "Автор" + email_error: "Не удается зарегистрировать аккаунт с адресом %{email}. Свяжитесь с администратором сайта." + missing_secret: "Сбой аутентификации из-за отсутствия ключа. Для решения проблемы обратитесь к администраторам сайта." + invite_redeem_failed: "Не удалось активировать приглашение по ссылке. Свяжитесь с администратором сайта." + original_poster: "Исходный автор" most_recent_poster: "Последний автор" frequent_poster: "Частый автор" poster_description_joiner: ", " redirected_to_top_reasons: new_user: "Добро пожаловать в наше сообщество! Вот самые популярные недавние темы." - not_seen_in_a_month: "С возвращением! Поскольку вас не было какое-то время, мы представляем вам список наиболее популярных тем, появившихся за время вашего отсутствия." + not_seen_in_a_month: "С возвращением! Вас не было какое-то время, поэтому мы подготовили список наиболее популярных тем, появившихся за время вашего отсутствия." merge_posts: edit_reason: - one: "Сообщение было объединено пользователем %{username}" - few: "%{count} сообщения было объединено пользователем %{username}" - many: "%{count} сообщений было объединено пользователем %{username}" - other: "%{count} сообщений было объединено пользователем %{username}" + one: "Запись была объединена пользователем %{username}" + few: "%{count} записи были объединены пользователем %{username}" + many: "%{count} записей было объединено пользователем %{username}" + other: "%{count} записи были объединены пользователем %{username}" errors: - different_topics: "Сообщения, принадлежащие разным темам, не могут быть объединены." - different_users: "Сообщения, принадлежащие разным пользователям, не могут быть объединены." - max_post_length: "Сообщения нельзя объединить, поскольку количество символов в объединённом сообщении превышает допустимое значение." + different_topics: "Записи, принадлежащие разным темам, не могут быть объединены." + different_users: "Записи, принадлежащие разным пользователям, не могут быть объединены." + max_post_length: "Записи нельзя объединить, поскольку количество символов в объединённой записи превышает допустимое значение." move_posts: new_topic_moderator_post: - one: "Сообщение было перенесено в новую тему: %{topic_link}" - few: "%{count} сообщения были перенесены в новую тему: %{topic_link}" - many: "%{count} сообщений были перенесены в новую тему: %{topic_link}" - other: "%{count} сообщений были перенесены в новую тему: %{topic_link}" + one: "Запись была перенесена в новую тему: %{topic_link}" + few: "%{count} записи были перенесены в новую тему: %{topic_link}" + many: "%{count} записей было перенесено в новую тему: %{topic_link}" + other: "%{count} записи были перенесены в новую тему: %{topic_link}" new_message_moderator_post: - one: "Сообщение было перенесено в новое сообщение: %{topic_link}" - few: "%{count} сообщения были перенесены в новое сообщение: %{topic_link}" - many: "%{count} сообщений были перенесены в новое сообщение: %{topic_link}" - other: "%{count} сообщений были перенесены в новое сообщение: %{topic_link}" + one: "Запись была перенесена в новое сообщение: %{topic_link}" + few: "%{count} записи были перенесены в новое сообщение: %{topic_link}" + many: "%{count} записей было перенесено в новое сообщение: %{topic_link}" + other: "%{count} записи были перенесены в новое сообщение: %{topic_link}" existing_topic_moderator_post: - one: "Сообщение было перемещено в эту тему: %{topic_link}" - few: "%{count} сообщения было перемещено в эту тему: %{topic_link}" - many: "%{count} сообщений было перемещено в эту тему: %{topic_link}" - other: "%{count} сообщений было перемещено в эту тему: %{topic_link}" + one: "Запись была перемещена в эту тему: %{topic_link}" + few: "%{count} записи были перемещены в эту тему: %{topic_link}" + many: "%{count} записей было перемещено в эту тему: %{topic_link}" + other: "%{count} записи были перемещены в эту тему: %{topic_link}" existing_message_moderator_post: - one: "Сообщение было перемещено в это личное сообщение: %{topic_link}" - few: "%{count} сообщения было перемещено в это личное сообщение: %{topic_link}" - many: "%{count} сообщений было перемещено в это личное сообщение: %{topic_link}" - other: "%{count} сообщений было перемещено в это личное сообщение: %{topic_link}" + one: "Запись была перемещена в это сообщение: %{topic_link}" + few: "%{count} записи были перемещены в это сообщение: %{topic_link}" + many: "%{count} записей было перемещено в это сообщение: %{topic_link}" + other: "%{count} записи были перемещены в это сообщение: %{topic_link}" change_owner: post_revision_text: "Право собственности передано" publish_page: @@ -2337,10 +2337,10 @@ ru: invalid: "содержит недопустимые символы" topic_statuses: autoclosed_message_max_posts: - one: "Это личное сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." - few: "Это личное сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." - many: "Это личное сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." - other: "Это личное сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." + one: "Это сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." + few: "Это сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." + many: "Это сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." + other: "Это сообщение было автоматически закрыто, когда количество ответов достигло максимума: %{count}." autoclosed_topic_max_posts: one: "Эта тема была автоматически закрыта, когда количество ответов достигло максимума: %{count}." few: "Эта тема была автоматически закрыта, когда количество ответов достигло максимума: %{count}." @@ -2350,138 +2350,138 @@ ru: one: "Эта тема была автоматически закрыта спустя %{count} день. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта спустя %{count} дня. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта спустя %{count} дней. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта спустя %{count} дней. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта спустя %{count} дня. В ней больше нельзя отвечать." autoclosed_enabled_hours: one: "Эта тема была автоматически закрыта спустя %{count} час. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта спустя %{count} часа. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта спустя %{count} часов. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта спустя %{count} часов. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта спустя %{count} часа. В ней больше нельзя отвечать." autoclosed_enabled_minutes: one: "Эта тема была автоматически закрыта спустя %{count} минуту. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта спустя %{count} минуты. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта спустя %{count} минут. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта спустя %{count} минут. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта спустя %{count} минуты. В ней больше нельзя отвечать." autoclosed_enabled_lastpost_days: one: "Эта тема была автоматически закрыта через %{count} день после последнего ответа. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта через %{count} дня после последнего ответа. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта через %{count} дней после последнего ответа. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта через %{count} дней после последнего ответа. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта через %{count} дня после последнего ответа. В ней больше нельзя отвечать." autoclosed_enabled_lastpost_hours: one: "Эта тема была автоматически закрыта через %{count} час после последнего ответа. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта через %{count} часа после последнего ответа. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта через %{count} часов после последнего ответа. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта через %{count} часов после последнего ответа. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта через %{count} часа после последнего ответа. В ней больше нельзя отвечать." autoclosed_enabled_lastpost_minutes: one: "Эта тема была автоматически закрыта через %{count} минуту после последнего ответа. В ней больше нельзя отвечать." few: "Эта тема была автоматически закрыта через %{count} минуты после последнего ответа. В ней больше нельзя отвечать." many: "Эта тема была автоматически закрыта через %{count} минут после последнего ответа. В ней больше нельзя отвечать." - other: "Эта тема была автоматически закрыта через %{count} минут после последнего ответа. В ней больше нельзя отвечать." + other: "Эта тема была автоматически закрыта через %{count} минуты после последнего ответа. В ней больше нельзя отвечать." autoclosed_disabled_days: one: "Эта тема была автоматически открыта через %{count} день." few: "Эта тема была автоматически открыта через %{count} дня." many: "Эта тема была автоматически открыта через %{count} дней." - other: "Эта тема была автоматически открыта через %{count} дней." + other: "Эта тема была автоматически открыта через %{count} дня." autoclosed_disabled_hours: one: "Эта тема была автоматически открыта через %{count} час." few: "Эта тема была автоматически открыта через %{count} часа." many: "Эта тема была автоматически открыта через %{count} часов." - other: "Эта тема была автоматически открыта через %{count} часов." + other: "Эта тема была автоматически открыта через %{count} часа." autoclosed_disabled_minutes: one: "Эта тема была автоматически открыта через %{count} минуту." few: "Эта тема была автоматически открыта через %{count} минуты." many: "Эта тема была автоматически открыта через %{count} минут." - other: "Эта тема была автоматически открыта через %{count} минут." + other: "Эта тема была автоматически открыта после %{count} минуты." autoclosed_disabled_lastpost_days: one: "Эта тема была автоматически открыта через %{count} день после последнего ответа." few: "Эта тема была автоматически открыта через %{count} дня после последнего ответа." many: "Эта тема была автоматически открыта через %{count} дней после последнего ответа." - other: "Эта тема была автоматически открыта через %{count} дней после последнего ответа." + other: "Эта тема была автоматически открыта через %{count} дня после последнего ответа." autoclosed_disabled_lastpost_hours: one: "Эта тема была автоматически открыта через %{count} час после последнего ответа." few: "Эта тема была автоматически открыта через %{count} часа после последнего ответа." many: "Эта тема была автоматически открыта через %{count} часов после последнего ответа." - other: "Эта тема была автоматически открыта через %{count} часов после последнего ответа." + other: "Эта тема была автоматически открыта через %{count} часа после последнего ответа." autoclosed_disabled_lastpost_minutes: one: "Эта тема была автоматически открыта через %{count} минуту после последнего ответа." few: "Эта тема была автоматически открыта через %{count} минуты после последнего ответа." many: "Эта тема была автоматически открыта через %{count} минут после последнего ответа." - other: "Эта тема была автоматически открыта через %{count} минут после последнего ответа." + other: "Эта тема была автоматически открыта через %{count} минуты после последнего ответа." autoclosed_disabled: "Эта тема открыта. В ней можно отвечать." autoclosed_disabled_lastpost: "Эта тема открыта. В ней можно отвечать." auto_deleted_by_timer: "Автоматически удалить по таймеру." login: invalid_second_factor_method: "Выбранный метод двухфакторной аутентификация недействителен." not_enabled_second_factor_method: "Выбранный метод двухфакторной аутентификации не включен для аккаунта." - security_key_description: "Когда вы подготовите свой физический ключ безопасности, нажмите на кнопку «Аутентификация при помощи ключа безопасности»." + 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: "Ваш аккаунт ещё не одобрен. Когда появится возможность войти, вы получите письмо." + security_key_authenticate: "Аутентификация при помощи электронного ключа" + security_key_not_allowed_error: "Время проверки подлинности электронного ключа истекло или регистрация была отменена." + security_key_no_matching_credential_error: "В указанном электронном ключе не найдено подходящих учётных данных." + security_key_support_missing_error: "Текущее устройство или браузер не поддерживает использование электронных ключей. Используйте другой метод." + security_key_invalid: "При проверке электронного ключа произошла ошибка." + not_approved: "Аккаунт ещё не одобрен. Когда появится возможность войти, вы получите письмо." incorrect_username_email_or_password: "Неверное имя пользователя, адрес электронной почты или пароль" incorrect_password: "Неверный пароль" wait_approval: "Спасибо за регистрацию. Мы оповестим вас, когда аккаунт будет одобрен." - active: "Ваш аккаунт активирован и готов к использованию." - activate_email: "

    Вы почти закончили! Мы выслали письмо на %{email}. Следуйте инструкциям в этом письме для активации аккаунта.

    Если письмо не пришло, проверьте папку «спам», или попробуйте войти ещё раз, чтобы выслать активационное письмо повторно.

    " - not_activated: "Вы пока что не можете войти. Следуйте инструкциям по активации аккаунта, Следуйте инструкциям в этом письме для активации аккаунта." + active: "Аккаунт активирован и готов к использованию." + activate_email: "

    Вы почти закончили! Мы выслали письмо на %{email}. Следуйте инструкциям в этом письме для активации аккаунта.

    Если письмо не пришло, проверьте папку спама.

    " + not_activated: "Вы пока что не можете войти. Вам необходимо активировать аккаунт. Мы отправили вам письмо с инструкциями о том, как это сделать." not_allowed_from_ip_address: "Вход с этого IP-адреса в качестве пользователя %{username} запрещён." admin_not_allowed_from_ip_address: "Вход с этого IP-адреса в качестве администратора запрещён." reset_not_allowed_from_ip_address: "Запрос на сброс пароля с этого IP-адреса запрещён." suspended: "Вы не можете войти до %{date}." - suspended_with_reason: "Учётная запись заморожена до %{date}: %{reason}" - suspended_with_reason_forever: "Учётная запись заморожена: %{reason}" + suspended_with_reason: "Аккаунт заморожен до %{date}: %{reason}" + suspended_with_reason_forever: "Аккаунт заморожен: %{reason}" errors: "%{errors}" not_available: "Недоступно. Попробуйте %{suggestion}." - something_already_taken: "Что-то пошло не так, возможно, имя пользователя или электронный ящик уже используются. Попробуйте восстановить ваш пароль." + something_already_taken: "Произошла ошибка. Возможно, это имя пользователя или адрес электронной почты уже используются. Попробуйте восстановить пароль." omniauth_error: generic: "Произошла ошибка при авторизации аккаунта. Попробуйте ещё раз." csrf_detected: "Время авторизации истекло, или вы использовали другой браузер для входа в систему. Попробуйте ещё раз." request_error: "Произошла ошибка при запуске авторизации. Попробуйте ещё раз." invalid_iat: "Невозможно проверить токен авторизации из-за разницы часов сервера. Попробуйте ещё раз." omniauth_error_unknown: "В процессе входа на сайт произошла ошибка. Повторите попытку." - omniauth_confirm_title: "Войти, используя %{provider}" + omniauth_confirm_title: "Вход с помощью %{provider}" omniauth_confirm_button: "Продолжить" - authenticator_error_no_valid_email: "Нет разрешённых эл. адресов, связанных с учётной записью %{account}. Вам необходимо настроить учётную запись с помощью другого эл. адреса." - new_registrations_disabled: "Новые регистрации сейчас недоступны." + authenticator_error_no_valid_email: "Нет разрешённых адресов электронной почты, связанных с аккаунтом %{account}. Настройте аккаунт с помощью другого адреса." + new_registrations_disabled: "Регистрация пока что недоступна." password_too_long: "Максимальная длина пароля —200 символов." email_too_long: "Эл. почта, которую вы указали, слишком длинная. Имя почтового ящика должно быть не длиннее 254 символов, а доменное имя не более 253 символов." wrong_invite_code: "Вы указали неверный код приглашения." - reserved_username: "Такой псевдоним не разрешён." + reserved_username: "Запрещенное имя пользователя." missing_user_field: "Вы не заполнили все поля пользователя" - auth_complete: "Проверка подлинности завершена." + auth_complete: "Аутентификация завершена." click_to_continue: "Нажмите здесь для продолжения." - already_logged_in: "Извините! Это приглашение предназначено для новых пользователей, у которых ещё нет существующей учётной записи." + already_logged_in: "Это приглашение предназначено для новых пользователей, у которых ещё нет аккаунта." second_factor_title: "Двухфакторная аутентификация" second_factor_description: "Введите код аутентификации из вашего приложения:" second_factor_backup_description: "Введите один из резервных кодов:" second_factor_backup_title: "Двухфакторные резервные коды" - invalid_second_factor_code: "Неверный код аутентификации. Каждый код можно использовать только один раз." - invalid_security_key: "Неверный ключ безопасности." + invalid_second_factor_code: "Неверный код аутентификации. Коды являются одноразовыми." + invalid_security_key: "Неверный электронный ключ." missing_second_factor_name: "Укажите имя." missing_second_factor_code: "Укажите код." second_factor_toggle: - totp: "Использовать аутентификацию через приложение или ключ безопасности" + totp: "Использовать приложение для аутентификации или электронный ключ" backup_code: "Использовать резервный код" second_factor_auth: challenge_not_found: "Не удалось найти запрос на двухфакторную аутентификацию в текущем сеансе." challenge_expired: "Прошло слишком много времени с момента запроса на двухфакторную аутентификацию, и этот запрос более недействителен. Попробуйте ещё раз." - challenge_not_completed: "Вы не выполнили двухфакторную аутентификацию для выполнения этого действия. Завершите двухфакторную аутентификацию и повторите попытку." + challenge_not_completed: "Вы не прошли двухфакторную аутентификацию для выполнения этого действия. Пройдите ее и повторите попытку." actions: grant_admin: - description: "В целях обеспечения дополнительной безопасности вам необходимо подтвердить свою двухфакторную аутентификацию, прежде чем пользователь %{username} получит доступ администратора." + description: "В целях обеспечения дополнительной безопасности вам необходимо подтвердить двухфакторную аутентификацию, прежде чем пользователь %{username} получит доступ администратора." discourse_connect_provider: description: "Сайт %{hostname} запросил подтверждение двухфакторной аутентификации. Вы будете перенаправлены обратно на сайт после подтверждения." admin: email: - sent_test: "Отправлено!" + sent_test: "отправлено!" user: merge_user: updating_username: "Обновление имени пользователя …" - changing_post_ownership: "Изменение владельца сообщения…" - merging_given_daily_likes: "Объединение выданных ежедневных лайков…" - merging_post_timings: "Объединение времени создания сообщений…" + changing_post_ownership: "Изменение владельца записи…" + merging_given_daily_likes: "Объединение поставленных ежедневных лайков…" + merging_post_timings: "Объединение времени создания записей…" merging_user_visits: "Объединение статистики посещений пользователей…" updating_site_settings: "Обновление настроек сайта…" updating_user_stats: "Обновление статистики пользователя…" @@ -2492,17 +2492,17 @@ ru: deactivated: "Была выключена из-за слишком большого количества возвращённых писем на адрес «%{email}»." deactivated_by_staff: "Выключено персоналом" deactivated_by_inactivity: - one: "Автоматически деактивируется после %{count} дня бездействия" - few: "Автоматически деактивируется после %{count} дней бездействия" - many: "Автоматически деактивируется после %{count} дней бездействия" - other: "Автоматически выключается после %{count} дней бездействия" + one: "Автоматически выключается после %{count} дня бездействия" + few: "Автоматически выключается после %{count} дней бездействия" + many: "Автоматически выключается после %{count} дней бездействия" + other: "Автоматически выключается после %{count} дня бездействия" activated_by_staff: "Включено персоналом" - new_user_typed_too_fast: "Новый пользователь печатает слишком быстро" - content_matches_auto_silence_regex: "Содержимое соответствует регулярному выражению автоматической блокировки первых сообщений" - content_matches_auto_block_regex: "Содержимое соответствует регулярному выражению автоматической блокировки" + new_user_typed_too_fast: "Новый пользователь набирает текст слишком быстро" + content_matches_auto_silence_regex: "Контент соответствует регулярному выражению автоматической блокировки первых записей" + content_matches_auto_block_regex: "Контент соответствует регулярному выражению автоматической блокировки" username: - short: "должно быть как минимум %{min} знаков" - long: "должно быть не более %{max} знаков" + short: "минимум символов: %{min}" + long: "максимум символов: %{max}" too_long: "слишком длинное" characters: "может содержать только цифры, буквы, тире, точки и символ подчёркивания" unique: "должно быть уникально" @@ -2510,16 +2510,16 @@ ru: must_begin_with_alphanumeric_or_underscore: "должно начинаться с буквы, цифры или символа подчёркивания" must_end_with_alphanumeric: "должно заканчиваться буквой или цифрой" must_not_contain_two_special_chars_in_seq: "не должно содержать последовательности из двух или более специальных символов (.-_)" - must_not_end_with_confusing_suffix: "не должно заканчиваться на .json или .png и т. д. Такие окончания могут ввести в заблуждение." + must_not_end_with_confusing_suffix: "не должно заканчиваться на .json или .png и т. д.: такие окончания могут ввести в заблуждение" email: - invalid: "недействительна." + invalid: "недействителен." not_allowed: "недопустим в этом почтовом домене. Используйте другой адрес." blocked: "не разрешён." - revoked: "Отправка эл. писем на адрес «%{email}» не будет осуществляться до %{date}." - does_not_exist: "Нет данных" + revoked: "Отправка писем на адрес «%{email}» не будет осуществляться до %{date}." + does_not_exist: "Н/Д" 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: "Отклонён в результате автоматической модерации. См. настройку `auto_handle_queued_age`." @@ -2528,32 +2528,32 @@ ru: fixed_primary_email: "Исправлен основной электронный адрес для сымитированного пользователя" same_ip_address: "Тот же IP-адрес (%{ip_address}), что и у других пользователей" inactive_user: "Неактивный пользователь" - reviewable_reject_auto: "Автоматическая обработка очереди просмотра" + reviewable_reject_auto: "Автоматическая обработка очереди проверки" reviewable_reject: "Пользователь отклонён после проверки" - email_in_spam_header: "Первое электронное письмо пользователя было помечено как спам" + 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} сообщения, (публичных или личных), поэтому его нельзя удалить." + one: "У пользователя %{username} есть %{count} запись или личное сообщение, поэтому его нельзя удалить." + few: "У пользователя %{username} есть %{count} записи или личных сообщения, поэтому его нельзя удалить." + many: "У пользователя %{username} есть %{count} записей или личных сообщений, поэтому его нельзя удалить." + other: "У пользователя %{username} есть %{count} записи или личного сообщения, поэтому его нельзя удалить." unsubscribe_mailer: - title: "Отписаться от рассылки" - subject_template: "Подтвердите, что вы больше не желаете получать обновления по электронной почте с сайта %{site_title}" + 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}** > @@ -2567,10 +2567,10 @@ ru: %{invite_link} custom_invite_mailer: - title: "Личное приглашение к дискуссии" - subject_template: "%{inviter_name} пригласил вас присоединиться к теме «%{topic_title}» на сайте %{site_domain_name}" + title: "Настраиваемое приглашение к дискуссии" + subject_template: "%{inviter_name} приглашает присоединиться к теме «%{topic_title}» на сайте %{site_domain_name}" text_body_template: | - Пользователь %{inviter_name} приглашает вас принять участие в дискуссии + %{inviter_name} приглашает вас принять участие в дискуссии > **%{topic_title}** > @@ -2580,7 +2580,7 @@ ru: > %{site_title} -- %{site_description} - дополнительно сообщая следующее: + Дополнительная информация: > %{user_custom_message} @@ -2591,7 +2591,7 @@ ru: title: "Приглашение на форум" subject_template: "%{inviter_name} приглашает вас на сайт %{site_domain_name}" text_body_template: | - Пользователь %{inviter_name} приглашает вас на сайт + %{inviter_name} приглашает вас на сайт > **%{site_title}** > @@ -2601,16 +2601,16 @@ ru: %{invite_link} custom_invite_forum_mailer: - title: "Личное приглашение на форум" + title: "Настраиваемое приглашение на форум" subject_template: "%{inviter_name} приглашает вас на сайт %{site_domain_name}" text_body_template: | - Пользователь %{inviter_name} приглашает вас на сайт + %{inviter_name} приглашает вас на сайт > **%{site_title}** > > %{site_description} - дополнительно сообщая следующее: + Дополнительная информация: > %{user_custom_message} @@ -2619,23 +2619,23 @@ ru: %{invite_link} invite_password_instructions: title: "Создание пароля" - subject_template: "Создание пароля для вашей учетной записи на сайте %{site_name}" + subject_template: "Создание пароля для аккаунта на сайте %{site_name}" text_body_template: | Благодарим вас за принятие приглашения на сайт %{site_name}, и добро пожаловать! - Нажмите на эту ссылку, чтобы выбрать пароль: + Установите пароль по следующей ссылке: %{base_url}/u/password-reset/%{email_token} - (Если срок действия указанной выше ссылки истёк, нажмите «Я забыл свой пароль» при входе на сайт и укажите свой адрес электронной почты.) + (Если срок действия ссылки истёк, нажмите «Пароль утерян» при входе на сайт и укажите свой адрес электронной почты.) download_backup_mailer: - title: "Загрузка резервной копии" - subject_template: "[%{email_prefix}] загрузка резервной копии сайта" + title: "Скачивание резервной копии" + subject_template: "[%{email_prefix}] Скачивание резервной копии сайта" text_body_template: | - Вот ссылка на запрошенную вами загрузку [резервной копии сайта](%{backup_file_path}). + Вот ссылка на запрошенное вами скачивание [резервной копии сайта](%{backup_file_path}). - В целях безопасности мы отправили эту ссылку на ваш подтверждённый адрес электронной почты . + В целях безопасности мы отправили эту ссылку на ваш подтверждённый адрес электронной почты. - (Если вы *не запрашивали* эту загрузку, то это лишний повод для беспокойства — кто-то посторонний имеет административный доступ к вашему сайту.) + (Если вы *не запрашивали* скачивание, это повод для беспокойства: кто-то посторонний имеет административный доступ к вашему сайту.) no_token: | Ссылка на скачивание резервной копии уже была использована или срок её действия истёк. admin_confirmation_mailer: @@ -2653,44 +2653,44 @@ ru: [**%{base_url}**][0] - Мы надеемся, что вы получили это тестовое письмо! + Мы надеемся, что вы его получили! - Вот [удобный чек-лист для проверки настроек электронной почты][1]. + Вот [удобный список для проверки настроек электронной почты][1]. - Удачи, + Удачи! - Ваши друзья с сайта [Discourse](https://www.discourse.org) + Ваши друзья с проекта [Discourse](https://www.discourse.org) [0]: %{base_url} [1]: https://meta.discourse.org/t/email-delivery-configuration-checklist/209839 new_version_mailer: - title: "Установка обновления" - subject_template: "[%{email_prefix}] Доступна новая версия Discourse" + title: "Выход новой версии" + subject_template: "[%{email_prefix}] Вышла новая версия Discourse" text_body_template: | - Ура, доступна свежая версия [Discourse](https://www.discourse.org)! + Ура! Вышла свежая версия [Discourse](https://www.discourse.org)! Установленная версия: %{installed_version} Новая версия: **%{new_version}** - - Обновитесь, используя наш способ онлайн-обновления **[в один клик](%{base_url}/admin/upgrade)** + - Обновитесь, используя наш способ онлайн-обновления **[в один клик](%{base_url}/admin/upgrade)**. - - Посмотрите, что появилось нового в последней версии: либо на [нашем сайте](https://meta.discourse.org/tag/release-notes), либо на сайте [GitHub](https://github.com/discourse/discourse/commits/main) + - Посмотрите, что появилось в последней версии: либо на [нашем сайте](https://meta.discourse.org/tag/release-notes), либо на сайте [GitHub](https://github.com/discourse/discourse/commits/main). - - Посетите наш форум [meta.discourse.org](https://meta.discourse.org) для получения последних новостей, обсуждений и (в случае необходимости) оперативной технической поддержки. + - На форуме [meta.discourse.org](https://meta.discourse.org) вы найдете актуальные новости, обсуждения и оперативную техническую поддержку. new_version_mailer_with_notes: - title: "Установка обновления (описание обновления прилагается)" - subject_template: "[%{email_prefix}] доступно обновление с подробным описанием новой версии" + title: "Выход новой версии (с описанием)" + subject_template: "[%{email_prefix}] Вышло обновление" text_body_template: | - Ура, доступна свежая версия [Discourse](https://www.discourse.org)! + Ура! Вышла свежая версия [Discourse](https://www.discourse.org)! Установленная версия: %{installed_version} Новая версия: **%{new_version}** - - Обновитесь, используя наш способ онлайн-обновления **[в один клик](%{base_url}/admin/upgrade)** + - Обновитесь, используя наш способ онлайн-обновления **[в один клик](%{base_url}/admin/upgrade)**. - - Посмотрите, что появилось нового в последней версии: либо на [нашем сайте](https://meta.discourse.org/tag/release-notes), либо на сайте [GitHub](https://github.com/discourse/discourse/commits/main) + - Посмотрите, что появилось в последней версии: либо на [нашем сайте](https://meta.discourse.org/tag/release-notes), либо на сайте [GitHub](https://github.com/discourse/discourse/commits/main). - - Посетите наш форум [meta.discourse.org](https://meta.discourse.org) для получения последних новостей, обсуждений и (в случае необходимости) оперативной технической поддержки. + - На форуме [meta.discourse.org](https://meta.discourse.org) вы найдете актуальные новости, обсуждения и оперативную техническую поддержку. ### Что нового @@ -2708,25 +2708,25 @@ ru: agreed_and_deleted: "Спасибо за информацию. Мы согласны с вами и уже удалили запись." disagreed: "Спасибо за информацию. Уже рассматриваем." ignored: "Спасибо за информацию. Уже рассматриваем." - ignored_and_deleted: "Спасибо за информацию. Сообщение удалено." + ignored_and_deleted: "Спасибо за информацию. Запись удалена." temporarily_closed_due_to_flags: one: "Эта тема временно закрыта как минимум на %{count} час из-за большого количества жалоб со стороны сообщества." few: "Эта тема временно закрыта как минимум на %{count} часа из-за большого количества жалоб со стороны сообщества." many: "Эта тема временно закрыта как минимум на %{count} часов из-за большого количества жалоб со стороны сообщества." - other: "Эта тема временно закрыта как минимум на %{count} часов из-за большого количества жалоб со стороны сообщества." + other: "Эта тема временно закрыта как минимум на %{count} часа из-за большого количества жалоб со стороны сообщества." system_messages: reviewables_reminder: - subject_template: "В очереди на премодерацию есть сообщения, которые необходимо проверить." + subject_template: "В очереди на проверку есть необработанные записи" text_body_template: - one: "%{mentions} сообщение было отправлено более %{count} часа назад. [Просмотрите их](%{base_url}/review)." - few: "%{mentions} Сообщения были отправлены более %{count} часов назад. [Просмотрите их](%{base_url}/review)." - many: "%{mentions} сообщения были отправлены более %{count} часов назад. [Просмотрите их](%{base_url}/review)." - other: "%{mentions} сообщения были отправлены более %{count} часа назад. [Просмотрите их](%{base_url}/review)." + one: "%{mentions} Записи были отправлены более %{count} часа назад. [Просмотрите их](%{base_url}/review)." + few: "%{mentions} Записи были отправлены более %{count} часов назад. [Просмотрите их](%{base_url}/review)." + many: "%{mentions} Записи были отправлены более %{count} часов назад. [Просмотрите их](%{base_url}/review)." + other: "%{mentions} Записи были отправлены более %{count} часа назад. [Просмотрите их](%{base_url}/review)." private_topic_title: "Тема #%{id}" - contents_hidden: "Откройте сообщение, чтобы увидеть его содержимое." + contents_hidden: "Откройте запись, чтобы увидеть ее содержимое." post_hidden: - title: "Сообщение скрыто" - subject_template: "Сообщение скрыто из-за жалоб со стороны сообщества" + title: "Запись скрыта" + subject_template: "Запись скрыта из-за жалоб со стороны сообщества" text_body_template: | Здравствуйте! @@ -2736,24 +2736,24 @@ ru: %{flag_reason} - Эта запись была скрыта из-за жалоб сообщества, поэтому подумайте, как вы можете ее улучшить с учётом пожеланий сообщества. **Вы можете отредактировать это сообщение через %{edit_delay} минут, и оно будет вновь доступно.** + Эта запись была скрыта из-за жалоб сообщества, поэтому подумайте, как вы можете ее улучшить с учётом пожеланий сообщества. **Отредактировать запись можно через %{edit_delay} минут, и тогда она будет вновь доступна.** - Однако, если сообщение скрыто во второй раз, оно не появится на форуме до тех пор, пока не будет обработано персоналом. + Однако если запись будет скрыта снова, она не появится на форуме до тех пор, пока не будет обработана персоналом. Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). post_hidden_again: - title: "Сообщение снова скрыто" - subject_template: "Сообщение скрыто из-за жалоб со стороны сообщества, персонал уведомлён" + title: "Запись снова скрыта" + subject_template: "Запись скрыта из-за жалоб со стороны сообщества, персонал уведомлён" text_body_template: | Здравствуйте! - Это автоматическое извещение с сайта %{site_name}, уведомляющее, что ваше сообщение было вновь скрыто. + Это автоматическое извещение с сайта %{site_name}, уведомляющее, что ваша запись была вновь скрыта. <%{base_url}%{url}> %{flag_reason} - Сообщество пожаловалось на это сообщение, и теперь оно скрыто. **Поскольку сообщение было скрыто более одного раза, оно не появится на форуме до тех пор, пока не будет обработано персоналом**. + Сообщество пожаловалось на запись, и теперь она скрыта. **Запись была скрыта повторно, поэтому она не появится на форуме до тех пор, пока не будет обработана персоналом**. Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). queued_by_staff: @@ -2762,7 +2762,7 @@ ru: text_body_template: | Здравствуйте! - Это автоматическое извещение с сайта %{site_name}, уведомляющее, что ваша запись была скрыта. + Это автоматическое извещение с сайта %{site_name}, уведомляющее о том, что ваша запись была скрыта. <%{base_url}%{url}> @@ -2770,31 +2770,31 @@ ru: Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). flags_disagreed: - title: "Сообщение, на которое поступила жалоба, восстановлено персоналом" - subject_template: "Сообщение, на которое поступила жалоба, восстановлено персоналом" + title: "Запись, на которую поступила жалоба, восстановлена персоналом" + subject_template: "Запись, на которую поступила жалоба, восстановлена персоналом" text_body_template: | Здравствуйте! - Это автоматическое сообщение с сайта %{site_name}, уведомляющее, что [ваше сообщение](%{base_url}%{url}) было восстановлено. + Это автоматическое сообщение с сайта %{site_name}, уведомляющее, что [ваша запись](%{base_url}%{url}) восстановлена. На эту запись поступила жалоба со стороны сообщества, но один из сотрудников решил восстановить ее. - [details="Нажмите, чтобы развернуть восстановленное сообщение"] + [details="Нажмите, чтобы развернуть восстановленную запись"] ``` markdown %{flagged_post_raw_content} ``` [/details] flags_agreed_and_post_deleted: - title: "Сообщение, на которое поступила жалоба, удалено персоналом" - subject_template: "Сообщение, на которое поступила жалоба, удалено персоналом" + title: "Запись, на которую поступила жалоба, удалена персоналом" + subject_template: "Запись, на которую поступила жалоба, удалена персоналом" text_body_template: | Здравствуйте! - Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что [ваше сообщение](%{base_url}%{url}) было удалено. + Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что [ваша запись](%{base_url}%{url}) удалена. %{flag_reason} - На эту тему поступила жалоба от сообщества, и модератор решил её удалить. + На эту запись поступила жалоба от сообщества, и модератор решил её удалить. ``` markdown %{flagged_post_raw_content} @@ -2811,7 +2811,7 @@ ru: %{flag_reason} - На эту тему поступила жалоба от сообщества, и модератор решил её удалить. + На эту запись поступила жалоба от сообщества, и модератор решил её удалить. ``` markdown %{flagged_post_raw_content} @@ -2826,49 +2826,49 @@ ru: Подробнее о причинах удаления смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). usage_tips: text_body_template: | - Чтобы получить несколько быстрых советов по началу работы в качестве нового пользователя форума, [ознакомьтесь с этим блогом](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). + Короткие советы по началу работы в качестве нового пользователя форума [смотрите в блоге](https://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). + Старайтесь следовать [рекомендациям для сообщества](%{base_url}/guidelines). Приятного времяпровождения! welcome_tl1_user: title: "Приветствуем пользователя с уровнем доверия 1" subject_template: "Спасибо, что уделили нам время" text_body_template: | - Привет. Мы видим, что вы были заняты чтением нашего форума, что нам весьма приятно, поэтому мы повысили вас до [уровня доверия 1](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) + Привет. Мы видим, что вы были заняты чтением нашего форума, что нам весьма приятно, поэтому мы повысили вас до [уровня доверия 1](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/). Мы очень рады, что вы проводите время с нами, и мы хотели бы узнать о вас больше. Найдите минутку, чтобы [заполнить свой профиль](%{base_url}/my/preferences/profile) и не стесняйтесь [начать новую тему](%{base_url}/categories). welcome_staff: title: "Приветствуем нового сотрудника форума" - subject_template: "Поздравляем, вы получили статус «%{role}»!" + subject_template: "Поздравляем: вы получили статус «%{role}»!" text_body_template: | Сотрудник присвоил вам статус «%{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}, и добро пожаловать! - — Для вас создали новый аккаунт — **%{username}**. Измените своё имя или пароль, посетив [профиль пользователя][prefs]. + - Для вас создали новый аккаунт — **%{username}**. Измените своё имя или пароль, посетив [профиль пользователя][prefs]. - — При входе в систему **используйте тот же адрес электронной почты, что был в исходном приглашении**, иначе мы не сможем убедиться в том, что это именно вы! + - При входе в систему **используйте тот же адрес электронной почты, что был в исходном приглашении**, иначе мы не сможем убедиться в том, что это именно вы! %{new_user_tips} - Старайтесь следовать [основным принципам сообщества](%{base_url}/guidelines). + Старайтесь следовать [рекомендациям для сообщества](%{base_url}/guidelines). Приятного времяпровождения! @@ -2880,61 +2880,61 @@ ru: Достижение уровня доверия 2 означает, что вы прочитали достаточное количество тем и активно участвовали в обсуждении, чтобы считаться членом этого сообщества. - Как опытный пользователь вы могли бы оценить [этот список полезных советов и трюков](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). + Как опытному пользователю вам может быть полезен [список полезных советов и хитростей](https://blog.discourse.org/2016/12/discourse-new-user-tips-and-tricks/). Продолжайте в том же духе — нам нравится, что вы с нами. backup_succeeded: - title: "Успешное резервное копирование" - subject_template: "Резервное копирование успешно завершено" + title: "Резервное копирование выполнено" + subject_template: "Резервное копирование выполнено" text_body_template: | Резервное копирование прошло успешно. - Посетите [admin > раздел резервного копирования](%{base_url}/admin/backups), чтобы загрузить новую резервную копию. + Скачать резервную копию можно в разделе [администрирование > резервное копирование](%{base_url}/admin/backups). Вот журнал: %{logs} backup_failed: - title: "Неудачное резервное копирование" - subject_template: "Резервное копирование не удалось" + title: "Ошибка резервного копирования" + subject_template: "Резервное копирование не выполнено" text_body_template: | - Резервное копирование не удалось. + Резервное копирование выполнить не удалось. Вот журнал: %{logs} restore_succeeded: - title: "Успешное восстановление данных" - subject_template: "Восстановление данных успешно завершено" + title: "Восстановление данных выполнено" + subject_template: "Восстановление данных выполнено" text_body_template: | - Восстановление прошло успешно. + Восстановление выполнено. Вот журнал: %{logs} restore_failed: - title: "Неудачное восстановление данных" - subject_template: "Восстановление данных не удалось" + title: "Ошибка восстановления данных" + subject_template: "Восстановление данных не выполнено" text_body_template: | - Восстановление не удалось. + Восстановление выполнить не удалось. Вот журнал: %{logs} bulk_invite_succeeded: - title: "Массовое приглашение" - subject_template: "Массовое приглашение пользователей успешно выполнено" + title: "Массовое приглашение выполнено" + subject_template: "Массовое приглашение пользователей выполнено" text_body_template: | - Файл массового приглашения пользователей успешно обработан, приглашений отправлено: %{sent}, пропущено: %{skipped}, выдано предупреждений: %{warnings}. + Файл массового приглашения пользователей обработан, приглашений отправлено: %{sent}, пропущено: %{skipped}, выдано предупреждений: %{warnings}. ``` text %{logs} ``` bulk_invite_failed: - title: "Не удалось выполнить массовое приглашение" + title: "Массовое приглашение не выполнено" subject_template: "Массовое приглашение пользователей выполнено с ошибками" text_body_template: | - Файл массового приглашения пользователей был обработан, приглашений отправлено: %{sent}, пропущено: %{skipped}, выдано предупреждений: %{warnings}, возникло ошибок: %{failed}. + Файл массового приглашения пользователей обработан, приглашений отправлено: %{sent}, пропущено: %{skipped}, выдано предупреждений: %{warnings}, возникло ошибок: %{failed}. Вот журнал: @@ -2943,118 +2943,118 @@ ru: ``` 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" + title: "Экспорт в формат CSV выполнен" subject_template: "[%{export_title}] Экспорт данных завершён" text_body_template: | - Экспорт данных был успешным! :dvd: + Экспорт данных выполнен! :dvd: %{download_link} - Приведённая выше ссылка для скачивания будет действовать в течение 48 часов. + Ссылка для скачивания будет действовать в течение 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)." + title: "Ошибка экспорта в формат CSV" + subject_template: "Экспорт не выполнен" + text_body_template: "Экспорт данных выполнить не удалось. Посмотрите журнал или [свяжитесь с сотрудником](%{base_url}/about)." email_reject_insufficient_trust_level: title: "Письмо отклонено: недостаточный уровень доверия" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — недостаточный уровень доверия" + subject_template: "[%{email_prefix}] Проблема с письмом: недостаточный уровень доверия" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваша учетная запись не имеет требуемого уровня доверия для размещения новых тем. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). + У вашего аккаунта нет уровня доверия, необходимого для создания новых тем. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_user_not_found: title: "Письмо отклонено: пользователь не найден" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — пользователь не найден" + subject_template: "[%{email_prefix}] Проблема с письмом: пользователь не найден" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш ответ был отправлен с неизвестного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). + Ответ был отправлен с неизвестного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). email_reject_screened_email: title: "Письмо отклонено: адрес отправителя заблокирован" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — заблокированная электронная почта" + subject_template: "[%{email_prefix}] Проблема с письмом: адрес электронной почты заблокирован" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш ответ был отправлен с заблокированного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). + Ответ был отправлен с заблокированного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). email_reject_not_allowed_email: - title: "Письмо отклонено: Email не разрешён" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — заблокированная электронная почта" + title: "Письмо отклонено: запрещенный адрес электронной почты" + subject_template: "[%{email_prefix}] Проблема с письмом: адрес электронной почты заблокирован" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш ответ был отправлен с заблокированного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). + Ответ был отправлен с заблокированного адреса электронной почты. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). email_reject_inactive_user: title: "Письмо отклонено: неактивный пользователь" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — неактивный пользователь" + subject_template: "[%{email_prefix}] Проблема с письмом: неактивный пользователь" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш аккаунт, связанный с этим адресом электронной почты, не активирован. Перед отправкой писем необходимо активировать аккаунт. + Аккаунт, связанный с этим адресом электронной почты, не активирован. Перед отправкой писем необходимо активировать аккаунт. email_reject_silenced_user: title: "Письмо отклонено: заблокированный пользователь" - subject_template: "[%{email_prefix}] Проблема с электронной почтой: заблокированный пользователь" + subject_template: "[%{email_prefix}] Проблема с письмом: заблокированный пользователь" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш аккаунт, связанный с этим адресом электронной почты, заблокирован. + Аккаунт, связанный с этим адресом электронной почты, заблокирован. email_reject_reply_user_not_matching: title: "Письмо отклонено: адрес не соответствует пользователю" - subject_template: "[%{email_prefix}] Проблема с электронной почтой: неожиданный адрес отправителя ответа" + subject_template: "[%{email_prefix}] Проблема с письмом: неожиданный адрес отправителя ответа" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш ответ был отправлен с адреса электронной почты, отличного от того, который мы ожидали, поэтому мы не уверены, что вы тот, за кого себя выдаёте. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). + Ответ был отправлен с адреса электронной почты, отличного от того, который мы ожидали, поэтому мы не уверены, что вы тот, за кого себя выдаёте. Попробуйте отправить письмо с другого адреса электронной почты или [свяжитесь с сотрудником](%{base_url}/about). email_reject_empty: title: "Письмо отклонено: нет содержимого" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — нет содержимого" + subject_template: "[%{email_prefix}] Проблема с письмом: нет контента" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Мы не смогли найти ответ в вашем письме. + Мы не смогли найти ответ в письме. - Если вы получили это сообщение не смотря на то, что _написали ответ_, попробуйте написать его ещё раз, но с более простым форматированием. + Если ответ в письме _был_, попробуйте написать его ещё раз, но с более простым форматированием. email_reject_parsing: - title: "Письмо отклонено: ошибка парсинга содержимого" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — контент не распознан" + title: "Письмо отклонено: ошибка анализа контента" + subject_template: "[%{email_prefix}] Проблема с письмом: контент не распознан" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Система не смогла обнаружить текст сообщения в теле письма. Советуем убедиться в том, что вы написали ответ **в верхней части письма**. + Система не смогла обнаружить текст ответа в теле письма. **Ответ должен быть в верхней части письма**. email_reject_invalid_access: title: "Письмо отклонено: ошибка доступа" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — ошибка доступа" + subject_template: "[%{email_prefix}] Проблема с письмом: ошибка доступа" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваш аккаунт не имеет прав для публикации новых тем в этой категории. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). + Аккаунт не имеет прав на публикацию новых тем в этой категории. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_strangers_not_allowed: title: "Письмо отклонено: ответ от неизвестного пользователя" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — ответ от неизвестного пользователя" + subject_template: "[%{email_prefix}] Проблема с письмом: ответ от неизвестного пользователя" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Раздел, в который вы отправили это письмо, позволяет публиковать ответы только от пользователей с активными учётными записями и зарегистрированными в системе адресами электронной почты. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). + Категория, в которую вы отправили письмо, позволяет публиковать ответы только от пользователей с активными аккаунтами и зарегистрированными в системе адресами электронной почты. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_invalid_post: title: "Письмо отклонено: ошибка публикации" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — ошибка публикации" + subject_template: "[%{email_prefix}] Проблема с письмом: ошибка публикации" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. Возможные причины: слишком сложное форматирование, слишком длинное или слишком короткое сообщение. Попробуйте ещё раз или отправьте сообщение через веб-сайт, если ситуация не изменится. email_reject_invalid_post_specified: - title: "Письмо отклонено: неверное сообщение" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — неверное сообщение" + title: "Письмо отклонено: неверная запись" + subject_template: "[%{email_prefix}] Проблема с письмом: неверная запись" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. @@ -3063,83 +3063,83 @@ ru: %{post_error} Если вы можете исправить ошибку, попробуйте ещё раз. - date_invalid: "Дата создания сообщения не обнаружена. Возможно, в электронном письме отсутствует заголовок «Date:» ?" + date_invalid: "Дата создания записи не обнаружена. Возможно, в письме отсутствует заголовок «Date:»?" email_reject_post_too_short: - title: "Письмо отклонено: сообщение слишком короткое" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — сообщение слишком короткое" + title: "Письмо отклонено: запись слишком короткая" + subject_template: "[%{email_prefix}] Проблема с письмом: запись слишком короткая" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Очень короткие ответы не допускаются. Можете ли вы ответить, включив в ответ по крайней мере %{count} символов? Как вариант, вы можете лайкнуть запись собеседника, написав «+1». + Очень короткие ответы не допускаются: минимальное количество символов — %{count}. Помните, что вы всегда можете лайкнуть запись собеседника, написав «+1». email_reject_invalid_post_action: title: "Письмо отклонено: действие не распознано" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — действие не распознано" + subject_template: "[%{email_prefix}] Проблема с письмом: действие не распознано" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Почтовое действие не было распознано. Попробуйте ещё раз или отправьте сообщение через веб-сайт, если ситуация не изменится. + Действие с записью не распознано. Попробуйте ещё раз или отправьте сообщение через веб-сайт, если ситуация не изменится. email_reject_reply_key: title: "Письмо отклонено: неизвестный ключ ответа" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — неизвестный ключ ответа" + subject_template: "[%{email_prefix}] Проблема с письмом: неизвестный ключ ответа" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ключ ответа в электронном письме недействителен или неизвестен, поэтому мы не можем выяснить, на какое сообщение это письмо отвечает. [Свяжитесь с персоналом](%{base_url}/about). + Ключ ответа в письме недействителен или неизвестен, поэтому мы не можем выяснить, на какую запись это письмо отвечает. [Свяжитесь с персоналом](%{base_url}/about). email_reject_bad_destination_address: title: "Письмо отклонено: неверный адрес получателя" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — неверный адрес получателя" + subject_template: "[%{email_prefix}] Проблема с письмом: неверный адрес получателя" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Вот несколько вещей, которые следует проверить: + Что следует проверить: - Вы используете более одного адреса электронной почты? Вы ответили с адреса электронной почты, отличающегося от того, который использовали изначально? Для ответов по электронной почте необходимо, чтобы вы использовали один и тот же адрес электронной почты. - Правильно ли ваш почтовый клиент использует заголовок «Reply-To» при отправке ответа? Некоторые программы неправильно отправляют ответы на адрес отправителя, который может быть не предназначен для получения ответов. - - Был ли изменён идентификатор сообщения (Message-ID) в электронном письме? Идентификатор должен быть согласованным и неизменным. + - Был ли изменён идентификатор сообщения (Message-ID) в письме? Идентификатор должен быть согласованным и неизменным. - Нужна дополнительная помощь? Свяжитесь с нами по электронному адресу, указанному на странице %{base_url}/about + Нужна помощь? Свяжитесь с нами по контактам на странице %{base_url}/about. email_reject_old_destination: title: "Письмо отклонено: ответ на просроченное уведомление" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — вы пытаетесь ответить на старое уведомление" + subject_template: "[%{email_prefix}] Проблема с письмом: вы пытаетесь ответить на старое уведомление" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Мы принимаем ответы на уведомления только в течение %{number_of_days} дней. [Посетите эту тему](%{short_url}) для продолжения беседы. + Мы принимаем ответы на уведомления только в течение %{number_of_days} сут. Продолжить беседу можно в [самой теме](%{short_url}). email_reject_topic_not_found: title: "Письмо отклонено: тема не найдена" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — тема не найдена" + subject_template: "[%{email_prefix}] Проблема с письмом: тема не найдена" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. Тема, на которую вы отвечаете, больше не существует — возможно, она была удалена? Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_topic_closed: title: "Письмо отклонено: тема закрыта" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — тема закрыта" + subject_template: "[%{email_prefix}] Проблема с письмом: тема закрыта" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. Тема, на которую вы отвечаете, закрыта и больше не принимает ответы. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_auto_generated: title: "Письмо отклонено: автоматический ответ" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — автоматический ответ" + subject_template: "[%{email_prefix}] Проблема с письмом: автоматический ответ" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Ваше электронное письмо было помечено как «автоматически сгенерированное», что означает, что оно было автоматически создано компьютером, а не напечатано человеком; мы не можем принимать такие письма. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). + Письмо было помечено как «автоматически сгенерированное»: оно было автоматически создано компьютером, а не набрано человеком. Мы не принимаем такие письма. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_unrecognized_error: title: "Письмо отклонено: нераспознанная ошибка" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — нераспознанная ошибка" + subject_template: "[%{email_prefix}] Проблема с письмом: нераспознанная ошибка" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - При обработке вашего письмо произошла неизвестная ошибка. Повторите попытку или [свяжитесь с сотрудником](%{base_url}/about). + При обработке письма произошла неизвестная ошибка. Повторите попытку или [свяжитесь с сотрудником](%{base_url}/about). email_reject_attachment: title: "Вложение электронной почты отклонено" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — вложение отклонено" + subject_template: "[%{email_prefix}] Проблема с письмом: вложение отклонено" text_body_template: | - Некоторые вложения в вашем сообщении на %{destination} (озаглавленные %{former_title}) были отклонены. + Некоторые вложения в письме на адрес %{destination} (озаглавленном %{former_title}) были отклонены. Подробности: %{rejected_errors} @@ -3147,14 +3147,14 @@ ru: Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_reply_not_allowed: title: "Письмо отклонено: ответ не разрешён" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — ответ не разрешён" + subject_template: "[%{email_prefix}] Проблема с письмом: ответ не разрешён" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - У вас нет прав для ответа в эту тему. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). + У вас нет прав для ответа в этой теме. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_reply_to_digest: title: "Письмо отклонено: ответ на сводку" - subject_template: "[%{email_prefix}] Письмо не доставлено — ответ на сводку" + subject_template: "[%{email_prefix}] Проблема с письмом: ответ на сводку" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. @@ -3163,29 +3163,29 @@ ru: Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_reject_too_many_recipients: title: "Письмо отклонено: слишком много получателей" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — слишком много получателей" + subject_template: "[%{email_prefix}] Проблема с письмом: слишком много получателей" text_body_template: | Ваше письмо, отправленное на адрес %{destination} (озаглавленное как %{former_title}), не опубликовано. - Вы попытались отправить сообщение более чем %{max_recipients_count} получателям и система автоматически пометила ваше сообщение как спам. + Вы попытались отправить письмо более чем %{max_recipients_count} получателям, и система автоматически пометила его как спам. Если вы считаете, что это ошибка, [свяжитесь с сотрудником](%{base_url}/about). email_error_notification: title: "Письмо отклонено: ошибка аутентификации" - subject_template: "[%{email_prefix}] Проблема с электронной почтой — ошибка аутентификации POP" + subject_template: "[%{email_prefix}] Проблема с письмом: ошибка аутентификации POP" text_body_template: | Произошла ошибка аутентификации при опросе почты с POP-сервера. - Убедитесь, что вы правильно настроили учётные данные POP в [настройках сайта](%{base_url}/admin/site_settings/category/email). + Проверьте правильность настройки учётных данных POP в [настройках сайта](%{base_url}/admin/site_settings/category/email). - Если для аккаунта электронной почты POP есть веб-интерфейс, вам может потребоваться войти в сеть и проверить свои настройки через веб-интерфейс. + Если для аккаунта электронной почты POP есть веб-интерфейс, вам может потребоваться войти через веб-интерфейс и проверить настройки там. email_revoked: - title: "Эл. письмо отозвано" - subject_template: "Ваша эл. почта правильно указана?" + title: "Письмо отозвано" + subject_template: "Правильно ли указан адрес электронной почты?" text_body_template: | - Приносим извинения, но мы не можем связаться с вами по электронной почте. Наши последние несколько электронных писем были возвращены как недоставленные. + Мы не можем связаться с вами по электронной почте. Последние несколько писем были возвращены как недоставленные. - Можете ли вы убедиться, что [ваш адрес электронной почты](%{base_url}/my/preferences/email) действителен и работает? Вы также можете добавить наш адрес электронной почты в адресную книгу или в список контактов для возможности быстрого ответа. + Проверьте, действителен ли [ваш адрес электронной почты](%{base_url}/my/preferences/email) и работает ли он. Также можно попробовать добавить наш адрес электронной почты в адресную книгу или в список контактов. email_bounced: | Сообщение, отправленное на адрес %{email}, отклонено. @@ -3200,31 +3200,31 @@ ru: 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). too_many_spam_flags: - title: "Слишком много жалоб на сообщения пользователя (спам)" - subject_template: "Новая учётная запись временно заблокирована" + title: "Слишком много жалоб на спам от пользователя" + subject_template: "Новый аккаунт временно заблокирован" text_body_template: | Здравствуйте! - Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что ваши сообщения были временно скрыты, поскольку на них поступили жалобы сообщества. + Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что ваши записи были временно скрыты, поскольку на них поступили жалобы сообщества. - В качестве меры предосторожности ваш аккаунт был заблокирован. Вы не сможете создавать сообщения и темы до тех пор, пока сотрудник не просмотрит ваш аккаунт. Приносим извинения за доставленные неудобства. + В качестве меры предосторожности ваш аккаунт был заблокирован. Вы не сможете создавать записи и темы до тех пор, пока сотрудник не просмотрит ваш аккаунт. Приносим извинения за доставленные неудобства. Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). too_many_tl3_flags: - title: "Слишком много жалоб на сообщения пользователя (TL3)" - subject_template: "Новая учётная запись временно заблокирована" + title: "Слишком много жалоб (уровень доверия 3)" + subject_template: "Новый аккаунт временно заблокирован" text_body_template: | Здравствуйте! Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что действие вашего аккаунта было приостановлено из-за большого количества жалоб со стороны сообщества. - В качестве меры предосторожности ваш аккаунт был заблокирована — вы не сможете создавать сообщения или темы до тех пор, пока сотрудник не просмотрит его. Приносим извинения за доставленные неудобства. + В качестве меры предосторожности ваш аккаунт был заблокирована — вы не сможете создавать записи и темы до тех пор, пока сотрудник не просмотрит его. Приносим извинения за доставленные неудобства. Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). silenced_by_staff: @@ -3233,9 +3233,9 @@ ru: text_body_template: | Здравствуйте! - Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что действие вашего аккаунта была временно приостановлено в качестве меры предосторожности. + Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что действие вашего аккаунта было приостановлено в качестве меры предосторожности. - Вы можете просматривать содержимое форума, но вы не сможете создавать сообщения или темы до тех пор, пока один из [сотрудников](%{base_url}/about) не просмотрит ваши последние записи. Приносим извинения за доставленные неудобства. + Вы можете просматривать содержимое форума, но не сможете создавать записи и темы до тех пор, пока один из [сотрудников](%{base_url}/about) не просмотрит ваши последние записи. Приносим извинения за доставленные неудобства. Подробнее смотрите в [рекомендациях для сообщества](%{base_url}/guidelines). user_automatically_silenced: @@ -3244,22 +3244,22 @@ ru: text_body_template: | Это автоматическое сообщение. - Новый пользователь [%{username}](%{user_url}) был автоматически заблокирован, поскольку несколько пользователей пожаловались на сообщения %{username}. + Новый пользователь [%{username}](%{user_url}) был автоматически заблокирован, поскольку несколько пользователей пожаловались на записи %{username}. Просмотрите [список жалоб](%{base_url}/admin/flags). Если пользователь %{username} был заблокирован по ошибке, нажмите кнопку «Разблокировать» на [странице администратора для этого пользователя](%{user_url}). Максимальное количество жалоб и пожаловавшихся для автоблокировки пользователя может быть изменено через настройки сайта `silence_new_user`. spam_post_blocked: title: "Спам заблокирован" - subject_template: "Сообщения нового пользователя %{username} заблокированы из-за повторяющихся ссылок" + subject_template: "Записи нового пользователя %{username} заблокированы из-за повторяющихся ссылок" text_body_template: | Это автоматическое сообщение. - Новый пользователь [%{username}](%{user_url}) пытался создать несколько сообщений со ссылками на %{domains}, но эти сообщения были заблокированы как потенциальный спам. Пользователь по-прежнему может создавать новые сообщения, которые не ссылаются на %{domains}. + Новый пользователь [%{username}](%{user_url}) пытался создать несколько записей со ссылками на %{domains}, но они были заблокированы как потенциальный спам. Пользователь по-прежнему может создавать новые записи, которые не ссылаются на %{domains}. - [Обратите внимание на пользователя](%{user_url}). + [Проверьте действия пользователя](%{user_url}). - Параметры блокировки можно изменить через настройки сайта `newuser_spam_host_threshold` и `allowed_spam_host_domains`. Рассмотрите возможность добавления %{domains} в белый список, если подобные сообщения не должны блокироваться. + Параметры блокировки можно изменить через настройки сайта `newuser_spam_host_threshold` и `allowed_spam_host_domains`. Если подобные записи не должны блокироваться, можно добавить %{domains} в белый список. unsilenced: title: "Аккаунт разблокирован" subject_template: "Аккаунт разблокирован" @@ -3275,13 +3275,13 @@ ru: one: "%{count} пользователь ожидает рассмотрение" few: "%{count} пользователя ожидают рассмотрения" many: "%{count} пользователей ожидают рассмотрения" - other: "%{count} пользователей ожидают рассмотрения" + other: "%{count} пользователя ожидают рассмотрения" text_body_template: | - Появились новые регистрации пользователей, ожидающие подтверждения (или отклонения), прежде чем они смогут получить доступ к этому форуму. + Зарегистрировались новые пользователи, ожидающие подтверждения (или отклонения), прежде чем они смогут получить доступ к форуму. [Просмотрите их](%{base_url}/review). download_remote_images_disabled: - title: "Загрузка удалённых изображений отключена" + title: "Скачивание изображений со сторонних доменов отключено" subject_template: "Загрузка копий изображений на сервер была отключена" text_body_template: "Настройка `download_remote_images_to_local` была отключена, поскольку диск заполнился до отметки, указанной в настройке `download_remote_images_threshold`." dashboard_problems: @@ -3292,27 +3292,27 @@ ru: [Посетите панель администратора](%{base_url}/admin) для их просмотра. - Если на панели не отображается никакой новой информации, возможно, другой сотрудник уже воспользовался нашими советами. Список действий персонала можно просмотреть в [журнале действий персонала](%{base_url}/admin/logs/staff_action_logs). + Если на панели не отображается никакой новой информации, возможно, другой сотрудник уже воспользовался нашими советами. Список действий персонала можно просмотреть в [журнале действий](%{base_url}/admin/logs/staff_action_logs). new_user_of_the_month: title: "Вы новый пользователь месяца!" subject_template: "Вы новый пользователь месяца!" text_body_template: | - Поздравляем, вы заработали награду **Новый Пользователь Месяца за %{month_year}**. :trophy: + Поздравляем: вы заработали награду **Новый пользователь месяца за %{month_year}**. :trophy: - Эта награда предоставляется только двум новым пользователям в месяц, и она будет постоянно отображаться на [странице значков](%{url}). + Эта награда выдаётся только двум новым пользователям в месяц, и она будет постоянно отображаться на [странице наград](%{url}). Вы быстро стали ценным участником нашего сообщества. Спасибо, и продолжайте в том же духе! queued_posts_reminder: - title: "Напоминание о премодерируемых сообщениях" + title: "Напоминание о записях в очереди на проверку" subject_template: - one: "%{count} сообщение ожидает рассмотрения" - few: "%{count} сообщения ожидают рассмотрения" - many: "%{count} сообщений ожидают рассмотрения" - other: "%{count} сообщений ожидают рассмотрения" + one: "%{count} запись ожидает проверки" + few: "%{count} записи ожидают проверки" + many: "%{count} записей ожидают проверки" + other: "%{count} записи ожидают проверки" text_body_template: | Здравствуйте! - Сообщения от новых пользователей были переданы на модерацию и в настоящее время ожидают рассмотрения. [Утвердите или отклоните их здесь](%{base_url}/review?type=ReviewableQueuedPost). + Записи от новых пользователей были переданы на проверку и ожидают рассмотрения. [Утвердите или отклоните их](%{base_url}/review?type=ReviewableQueuedPost). unsubscribe_link: | [Нажмите сюда](%{unsubscribe_url}), чтобы отписаться от таких писем. unsubscribe_link_and_mail: | @@ -3328,20 +3328,20 @@ ru: user_notifications: previous_discussion: "Предыдущие ответы" reached_limit: - one: "Внимание: мы отправляем максимум %{count} ежедневное письмо. Проверьте сайт, чтобы увидеть те письма, которые, вероятно, не будут отправлены." - few: "Внимание: мы отправляем максимум %{count} ежедневных письма. Проверьте сайт, чтобы увидеть те письма, которые, вероятно, не будут отправлены. Благодарим за активность!" - many: "Внимание: мы отправляем максимум %{count} ежедневных писем. Проверьте сайт, чтобы увидеть те письма, которые, вероятно, не будут отправлены. Благодарим за активность!" - other: "Внимание: мы отправляем максимум %{count} ежедневного письма. Проверьте сайт, чтобы увидеть те письма, которые, вероятно, не будут отправлены. Благодарим за активность!" + one: "Внимание: мы отправляем максимум %{count} письмо в день. На сайте можно посмотреть письма, которые, вероятно, не будут отправлены." + few: "Внимание: мы отправляем максимум %{count} письма в день. На сайте можно посмотреть письма, которые, вероятно, не будут отправлены." + many: "Внимание: мы отправляем максимум %{count} писем в день. На сайте можно посмотреть письма, которые, вероятно, не будут отправлены." + other: "Внимание: мы отправляем максимум %{count} письма в день. На сайте можно посмотреть письма, которые, вероятно, не будут отправлены." in_reply_to: "В ответ на" unsubscribe: title: "Отписаться" description: "Не заинтересованы в получении данных писем? Нет проблем! Нажмите на ссылку ниже, чтобы немедленно отписаться от рассылки:" reply_by_email: "Для ответа [посетите эту тему](%{base_url}%{url}) или ответьте на это письмо." - reply_by_email_pm: "[Откройте это личное сообщение](%{base_url}%{url}) или ответьте на это письмо от %{participants}." + reply_by_email_pm: "[Откройте сообщение](%{base_url}%{url}) или ответьте на это письмо от %{participants}." only_reply_by_email: "Ответьте на это письмо по электронной почте." - only_reply_by_email_pm: "Ответьте на это личное сообщение от %{participants} по электронной почте." + only_reply_by_email_pm: "Ответьте на это письмо от %{participants} по электронной почте." visit_link_to_respond: "Для ответа [посетите эту тему](%{base_url}%{url})." - visit_link_to_respond_pm: "Для ответа на письмо [откройте это личное сообщение](%{base_url}%{url}). Адресаты: %{participants}." + visit_link_to_respond_pm: "Для ответа на письмо [откройте это сообщение](%{base_url}%{url}). Адресаты: %{participants}." reply_above_line: "## Наберите ответ над этой линией. ##" posted_by: "Отправлено пользователем %{username} %{post_date}" pm_participants: "Участники: %{participants}" @@ -3351,7 +3351,7 @@ ru: many: "%{participants} и ещё %{count}" other: "%{participants} и ещё %{count}" invited_group_to_private_message_body: | - Пользователь %{username} приглашает группу @%{group_name} принять участие в личной беседе на тему: + %{username} приглашает группу @%{group_name} принять участие в личной беседе на тему: > **[%{topic_title}](%{topic_url})** > @@ -3361,11 +3361,11 @@ ru: > %{site_title} -- %{site_description} - Чтобы присоединиться к беседе, смело жмите на ссылку ниже: + Чтобы присоединиться к беседе, нажмите ссылку ниже: %{topic_url} invited_to_private_message_body: | - Пользователь %{username} приглашает вас принять участие в личной беседе на тему: + %{username} приглашает вас принять участие в личной беседе на тему: > **[%{topic_title}](%{topic_url})** > @@ -3375,11 +3375,11 @@ ru: > %{site_title} -- %{site_description} - Чтобы присоединиться к беседе, смело жмите на ссылку ниже: + Чтобы присоединиться к беседе, нажмите ссылку ниже: %{topic_url} invited_to_topic_body: | - Пользователь %{username} приглашает вас принять участие в обсуждении на тему: + %{username} приглашает вас принять участие в обсуждении на тему: > **[%{topic_title}](%{topic_url})** > @@ -3389,12 +3389,12 @@ ru: > %{site_title} -- %{site_description} - Чтобы присоединиться к беседе, смело жмите на ссылку ниже: + Чтобы присоединиться к беседе, нажмите ссылку ниже: %{topic_url} user_invited_to_private_message_pm_group: title: "Приглашение группы к личной беседе" - subject_template: "[%{email_prefix}] Пользователь %{username} приглашает группу @%{group_name} принять участие в личной беседе на тему «%{topic_title}»" + subject_template: "[%{email_prefix}] %{username} приглашает группу @%{group_name} принять участие в личной беседе на тему «%{topic_title}»" text_body_template: | %{header_instructions} @@ -3403,7 +3403,7 @@ ru: %{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} @@ -3411,8 +3411,8 @@ ru: %{respond_instructions} user_invited_to_private_message_pm_staged: - title: "Приглашение пользователя к сымитированной личной беседе" - subject_template: "[%{email_prefix}] Пользователь %{username} приглашает вас принять участие в личной беседе на тему «%{topic_title}»" + title: "Сымитированный пользователь приглашает к личной беседе" + subject_template: "[%{email_prefix}] %{username} приглашает вас принять участие в личной беседе на тему «%{topic_title}»" text_body_template: | %{header_instructions} @@ -3421,7 +3421,7 @@ ru: %{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} @@ -3495,7 +3495,7 @@ ru: %{respond_instructions} user_group_mentioned: - title: "Упомянута группа пользователей" + title: "Упомянута группа пользователя" subject_template: "[%{email_prefix}] %{topic_title}" text_body_template: | %{header_instructions} @@ -3506,7 +3506,7 @@ ru: %{respond_instructions} user_group_mentioned_pm: - title: "Упомянута группа пользователя" + title: "Группа пользователя упомянута в личном сообщении" subject_template: "[%{email_prefix}] [PM] %{topic_title}" text_body_template: | %{header_instructions} @@ -3517,7 +3517,7 @@ ru: %{respond_instructions} user_group_mentioned_pm_group: - title: "Упомянута группа пользователя" + title: "Группа пользователя упомянута в личном сообщении" subject_template: "[%{email_prefix}] [PM] %{topic_title}" text_body_template: | %{header_instructions} @@ -3528,7 +3528,7 @@ ru: %{respond_instructions} user_posted: - title: "Публикация пользователем сообщения" + title: "Публикация пользователем записи" subject_template: "[%{email_prefix}] %{topic_title}" text_body_template: | %{header_instructions} @@ -3560,7 +3560,7 @@ ru: %{respond_instructions} user_posted_pm: - title: "Публикация пользователем личного сообщения" + title: "Отправка пользователем личного сообщения" subject_template: "[%{email_prefix}] [PM] %{topic_title}" text_body_template: | %{header_instructions} @@ -3571,35 +3571,35 @@ ru: %{respond_instructions} user_posted_pm_staged: - title: "Публикация пользователем сымитированного личного сообщения" + title: "Отправка сымитированным пользователем личного сообщения" subject_template: "%{optional_re}%{topic_title}" text_body_template: |2 %{message} account_suspended: title: "Аккаунт заморожен" - subject_template: "[%{email_prefix}] Ваша учётная запись была заморожена" + subject_template: "[%{email_prefix}] Ваш аккаунт был заморожен" text_body_template: | Вы были заморожены на форуме до %{suspended_till}. Причина: %{reason} account_suspended_forever: title: "Аккаунт заморожен" - subject_template: "[%{email_prefix}] Ваша учётная запись была заморожена" + subject_template: "[%{email_prefix}] Ваш аккаунт был заморожен" text_body_template: | - Вы были отстранены от форума. + Вы были заморожены на форуме. Причина: %{reason} account_silenced: title: "Аккаунт заблокирован" - subject_template: "[%{email_prefix}] Ваша учётная запись была заблокирована" + subject_template: "[%{email_prefix}] Ваш аккаунт был заблокирован" text_body_template: | Вы были заблокированы на форуме до %{silenced_till}. Причина: %{reason} account_silenced_forever: title: "Аккаунт заблокирован" - subject_template: "[%{email_prefix}] Ваша учётная запись была заблокирована" + subject_template: "[%{email_prefix}] Ваш аккаунт был заблокирован" text_body_template: | Вы были заблокированы на форуме. @@ -3608,22 +3608,22 @@ ru: 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). + Если у вас есть вопросы, [свяжитесь с нашим дружелюбным персоналом](%{base_url}/about). digest: why: "Краткая сводка с сайта %{site_link}, составленная с момента вашего последнего визита %{last_seen_at}" since_last_visit: "Что нового появилось за время вашего отсутствия:" @@ -3635,14 +3635,14 @@ ru: popular_topics: "Популярные темы" follow_topic: "Следить за этой темой" join_the_discussion: "Читать далее" - popular_posts: "Популярные сообщения" + popular_posts: "Популярные записи" more_new: "Новинки" subject_template: "[%{email_prefix}] — сводка" - unsubscribe: "Этот дайджест отправлен с сайта %{site_link} и состоит из новостей, появившихся за время вашего отсутствия на форуме. Для отмены подписки измените %{email_preferences_link} или %{unsubscribe_link}." - your_email_settings: "ваши настройки электронной почты" - click_here: "нажмите здесь" + 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}] Восстановление пароля" @@ -3651,45 +3651,45 @@ ru: Если это были не вы, то можете смело проигнорировать это письмо. - Нажмите следующую ссылку, чтобы выбрать новый пароль: + Чтобы указать новый пароль, нажмите следующую ссылку: %{base_url}/u/password-reset/%{email_token} email_login: title: "Вход по ссылке" 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: "Установка пароля" subject_template: "[%{email_prefix}] Установка пароля" text_body_template: | - Кто-то попросил добавить пароль к вашему аккаунту на сайте [%{site_name}](%{base_url}). Вы можете входить в систему с помощью любого поддерживаемого онлайн-сервиса (Google, Facebook и т. д.), связанного с этим подтверждённым адресом электронной почты. + Кто-то попросил добавить пароль к вашему аккаунту на сайте [%{site_name}](%{base_url}). Сейчас вы можете входить в систему с помощью поддерживаемого онлайн-сервиса (Google, Facebook и т. д.), связанного с этим подтверждённым адресом электронной почты. Если вы не делали этот запрос, можете смело проигнорировать это письмо. - Нажмите следующую ссылку, чтобы выбрать пароль: + Чтобы указать пароль, нажмите следующую ссылку: %{base_url}/u/password-reset/%{email_token} admin_login: - title: "Логин администратора" - subject_template: "[%{email_prefix}] Войти" + title: "Вход администратора" + subject_template: "[%{email_prefix}] Вход" text_body_template: | Кто-то попросил войти под вашим аккаунтом на сайт [%{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: "Подтверждение нового адреса электронной почты" @@ -3701,7 +3701,7 @@ ru: Если вы не запрашивали это изменение, обратитесь к [администратору сайта](%{base_url}/about). confirm_new_email_via_admin: - title: "Подтвердить адрес электронной почты" + title: "Подтверждение нового адреса электронной почты" subject_template: "[%{email_prefix}] Подтверждение нового адреса электронной почты" text_body_template: | Подтвердите адрес электронной почты для сайта %{site_name}, перейдя по ссылке: @@ -3710,22 +3710,22 @@ ru: Это изменение адреса электронной почты было запрошено администратором сайта. Если вы не запрашивали это изменение, обратитесь к [администратору сайта](%{base_url}/about). confirm_old_email: - title: "Подтвердите старый адрес электронной почты" + title: "Подтверждение старого адреса электронной почты" subject_template: "[%{email_prefix}] Подтверждение текущего адреса электронной почты" text_body_template: | Прежде чем мы сможем изменить ваш адрес электронной почты, нам необходимо подтверждение, - что вы имеете доступ к текущему аккаунту электронной почты. После выполнения этого шага мы попросим вас подтвердить + что вы имеете доступ к текущему аккаунту электронной почты. После этого мы попросим вас подтвердить новый адрес электронной почты. Подтвердите текущий адрес электронной почты для сайта %{site_name}, нажав на следующую ссылку: %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email_add: - title: "Подтвердите старый адрес электронной почты (добавление)" + title: "Подтверждение старого адреса электронной почты (добавление)" subject_template: "[%{email_prefix}] Подтверждение текущего адреса электронной почты" text_body_template: | Прежде чем мы сможем добавить новый адрес электронной почты, нам необходимо подтверждение, - что вы имеете доступ к текущему аккаунту электронной почты. После выполнения этого шага мы попросим вас подтвердить + что вы имеете доступ к текущему аккаунту электронной почты. После этого мы попросим вас подтвердить новый адрес электронной почты. Подтвердите текущий адрес электронной почты для сайта %{site_name}, нажав на следующую ссылку: @@ -3735,7 +3735,7 @@ ru: title: "Уведомление на старый адрес электронной почты" subject_template: "[%{email_prefix}] Ваш адрес электронной почты был изменён" text_body_template: | - Это автоматическое сообщение, сообщающее, что ваш адрес электронной почты для сайта + Это автоматическое сообщение о том, что ваш адрес электронной почты для сайта %{site_name} был изменён. Если это было сделано по ошибке, обратитесь к администратору сайта. @@ -3743,18 +3743,18 @@ ru: %{new_email} notify_old_email_add: - title: "Уведомить на старый адрес электронной почты (добавление)" + title: "Уведомление на старый адрес электронной почты (добавление)" subject_template: "[%{email_prefix}] Новый адрес электронной почты добавлен" text_body_template: | - Это автоматическое сообщение, уведомляющее, что для сайта %{site_name} - был добавлен адрес электронной почты. Если это было сделано по ошибке, обратитесь - к администратору сайта. + Это автоматическое сообщение о том, что для сайта %{site_name} + был добавлен адрес электронной почты. Если это было сделано по ошибке, + обратитесь к администратору сайта. Добавленный адрес электронной почты: %{new_email} signup_after_approval: - title: "Авторизация после одобрения" + title: "Оповещение после одобрения аккаунта" subject_template: "Ваш аккаунт на сайте %{site_name} одобрен!" text_body_template: | Добро пожаловать на сайт %{site_name}! @@ -3768,12 +3768,12 @@ ru: %{new_user_tips} - Старайтесь следовать [основным принципам сообщества](%{base_url}/guidelines). + Старайтесь следовать [рекомендациям для сообщества](%{base_url}/guidelines). Приятного времяпровождения! signup_after_reject: title: "Оповещение об отказе в регистрации" - subject_template: "Отклонение учётной записи на сайте %{site_name}" + subject_template: "Ваш аккаунт на сайте %{site_name} отклонен" text_body_template: | Сотрудник отклонил регистрацию аккаунта на сайте %{site_name}. @@ -3784,25 +3784,25 @@ ru: 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} Если ссылка выше не активна, попробуйте скопировать и вставить её в адресную строку браузера. suspicious_login: title: "Оповещение о новом входе" - subject_template: "[%{site_name}] Новый вход на сайт с %{location}" + subject_template: "[%{site_name}] Новый вход на сайт (место: %{location})" text_body_template: | Здравствуйте! @@ -3816,14 +3816,14 @@ ru: Если это были не вы, [проверьте список сеансов](%{base_url}/my/preferences/security) и при необходимости смените пароль. post_approved: - title: "Ваша запись одобрена" + title: "Запись одобрена" subject_template: "[%{site_name}] Ваша запись одобрена" text_body_template: | Здравствуйте! - Это автоматическое сообщение с сайта %{site_name}, уведомляющее о том, что [ваша запись](%{base_url}%{post_url}) была одобрена. + Это автоматическое сообщение с сайта %{site_name} о том, что [ваша запись](%{base_url}%{post_url}) была одобрена. page_forbidden: - title: "Ошибка. Вы не можете просмотреть эту страницу." + title: "Ошибка. Эта страница скрыта от публичного просмотра." site_setting_missing: "Настройка сайта `%{name}` должна быть установлена." page_not_found: page_title: "Страница не обнаружена" @@ -3835,77 +3835,77 @@ ru: search_button: "Поиск" offline: title: "Не удаётся загрузить приложение" - offline_page_message: "Похоже, вы не в сети! Проверьте ваше сетевое соединение и попробуйте снова." + offline_page_message: "Вы не в сети. Проверьте сетевое подключение и попробуйте снова." login_required: welcome_message: | - ## [Добро пожаловать на %{title}](#welcome) - Требуется авторизация. Создайте учётную запись или используйте существующую для входа на сайт. + ## [Добро пожаловать на сайт %{title}](#welcome) + Требуется авторизация. Создайте новый аккаунт или войдите в существующий. welcome_message_invite_only: | - ## [Добро пожаловать на %{title}](#welcome) - Требуется авторизация. Попросите существующего участника пригласить вас или используйте существующую учётную запись для входа на сайт. + ## [Добро пожаловать на сайт %{title}](#welcome) + Требуется авторизация. Попросите участника форума пригласить вас или используйте существующий аккаунт для входа на сайт. deleted: "удалено" image: "изображение" upload: - edit_reason: "Изображения загружены и сохранены локально" - unauthorized: "Вы не можете загрузить файл данного типа (список разрешённых типов файлов: %{authorized_extensions})." + edit_reason: "изображения скачаны и сохранены локально" + unauthorized: "Вы не можете загрузить файл данного типа (разрешённые типы файлов: %{authorized_extensions})." pasted_image_filename: "Имя файла изображения" store_failure: "Ошибка при загрузке #%{upload_id} для пользователя #%{user_id}." - file_missing: "Вы должны указать файл для загрузки." + file_missing: "Необходимо указать файл для загрузки." empty: "Указанный вами файл пуст." failed: "Ошибка загрузки. Попробуйте ещё раз." png_to_jpg_conversion_failure_message: "Произошла ошибка при конвертации из PNG в JPG." optimize_failure_message: "При оптимизации загруженного изображения произошла ошибка." - download_failure: "Не удалось загрузить файл от внешнего провайдера." + download_failure: "Не удалось скачать файл от внешнего поставщика." create_multipart_failure: "Не удалось создать многокомпонентную загрузку во внешнем хранилище." abort_multipart_failure: "Не удалось прервать многокомпонентную загрузку во внешнем хранилище." complete_multipart_failure: "Не удалось выполнить многокомпонентную загрузку во внешнем хранилище." external_upload_not_found: "Загрузка не найдена во внешнем хранилище. %{additional_detail}" - checksum_mismatch_failure: "Контрольная сумма загруженного файла не совпадает. В процессе загрузки содержимое файла могло измениться. Попробуйте ещё раз." - cannot_promote_failure: "Загрузка не может быть завершена, возможно, она уже была завершена или ранее не удалась." - size_zero_failure: "Похоже, что-то пошло не так, файл, который вы пытаетесь загрузить, имеет нулевой размер. Попробуйте ещё раз." + checksum_mismatch_failure: "Контрольная сумма загруженного файла неправильная. В процессе загрузки контент файла мог измениться. Попробуйте ещё раз." + cannot_promote_failure: "Не удается завершить загрузку: возможно, она уже была завершена или ее не удалось выполнить ранее." + size_zero_failure: "Ошибка. Файл, который вы пытаетесь загрузить, имеет нулевой размер. Попробуйте ещё раз." attachments: - too_large: "Файл, который вы пытаетесь загрузить, слишком большой (максимальный разрешённый размер %{max_size_kb}%kb)." - too_large_humanized: "Файл, который вы пытаетесь загрузить, слишком большой (максимально допустимый размер —%{max_size})." + too_large: "Вы пытаетесь загрузить слишком большой файл (максимальный размер — %{max_size_kb} кБ)." + too_large_humanized: "Вы пытаетесь загрузить слишком большой файл (максимальный размер — %{max_size})." images: - too_large: "Изображение, которое вы пытаетесь загрузить, слишком большое (максимальный разрешённый размер %{max_size_kb}%kb), уменьшите размер изображения и повторите попытку." - too_large_humanized: "Изображение, которое вы пытаетесь загрузить, слишком большое (максимально допустимый размер —%{max_size}), уменьшите размер изображения и повторите попытку." - larger_than_x_megapixels: "Изображение, которое вы пытаетесь загрузить, слишком большое (максимально допустимый размер %{max_image_megapixels} мегапикселей), уменьшите размер изображения и повторите попытку." + too_large: "Вы пытаетесь загрузить слишком большое изображение (максимальный размер — %{max_size_kb} кБ). Уменьшите размер изображения и повторите попытку." + too_large_humanized: "Вы пытаетесь загрузить слишком большое изображение (максимальный размер — %{max_size}). Уменьшите размер изображения и повторите попытку." + larger_than_x_megapixels: "Вы пытаетесь загрузить слишком большое изображение (максимальный размер — %{max_image_megapixels} Мпикс). Уменьшите размер изображения и повторите попытку." size_not_found: "Мы не можем определить размер изображения. Возможно, изображение повреждено?" placeholders: - too_large: "(размер изображения больше чем %{max_size_kb}KB)" - too_large_humanized: "(размер изображения больше чем %{max_size})" + too_large: "(размер изображения превышает %{max_size_kb} кБ)" + too_large_humanized: "(размер изображения превышает %{max_size})" avatar: - missing: "Нам не удалось найти ни одного аватара, связанного с этим электронным адресом. Попытаетесь загрузить аватар снова?" + missing: "Не удалось найти ни одного аватара, связанного с этим адресом электронной почты. Попытаетесь загрузить аватар снова?" flag_reason: sockpuppet: "Новый пользователь создал тему, а другой новый пользователь ответил с этого же IP адреса (%{ip_address}). См. параметр `flag_sockpuppets`." - spam_hosts: "Этот новый пользователь пытался создать несколько сообщений со ссылками на один и тот же домен. Все сообщения от этого пользователя, содержащие ссылки, должны быть промодерированы. См. параметр `newuser_spam_host_threshold`." + spam_hosts: "Этот новый пользователь пытался создать несколько записей со ссылками на один и тот же домен. Все записи от него, содержащие ссылки, должны быть проверены. См. параметр `newuser_spam_host_threshold`." skipped_email_log: - exceeded_emails_limit: "Превышен лимит max_emails_per_day_per_user" - exceeded_bounces_limit: "Превышен лимит bounce_score_threshold" - mailing_list_no_echo_mode: "Уведомления о рассылке отключены для собственных сообщений пользователя" - user_email_no_user: "Не удалось найти пользователя с ID %{user_id}" - user_email_post_not_found: "Не удалось найти сообщение с ID %{post_id}" + exceeded_emails_limit: "Превышен лимит «max_emails_per_day_per_user»" + exceeded_bounces_limit: "Превышен лимит «bounce_score_threshold»" + mailing_list_no_echo_mode: "Уведомления о рассылке отключены для собственных записей пользователя" + user_email_no_user: "Не удалось найти пользователя с идентификатором %{user_id}" + user_email_post_not_found: "Не удалось найти запись с идентификатором %{post_id}" user_email_anonymous_user: "Анонимный пользователь" - user_email_user_suspended_not_pm: "Заморожен пользователь, но не личное сообщение" + user_email_user_suspended_not_pm: "Заморожен пользователь, но не сообщение" user_email_seen_recently: "Пользователь был недавно на форуме" user_email_notification_already_read: "Уведомление, о котором говорится в этом письме, уже прочтено" - user_email_notification_topic_nil: "Тема сообщения равна nil" - user_email_post_user_deleted: "Учётная запись автора сообщения была удалена." - user_email_post_deleted: "Сообщение удалено автором" - user_email_user_suspended: "Пользователь был заморожен" - user_email_already_read: "Пользователь уже прочитал это сообщение" - user_email_access_denied: "Пользователь не может видеть это сообщение" - user_email_no_email: "С пользовательским ID %{user_id} не связан ни один Email" - sender_message_blank: "Пустое личное сообщение" - sender_message_to_blank: "Адрес получателя личного сообщения пуст" - sender_text_part_body_blank: "Текстовая часть сообщения пуста" - sender_body_blank: "Пустое сообщение" - sender_post_deleted: "Сообщение было удалено" - sender_message_to_invalid: "Неверный адрес электронной почты получателя" - sender_topic_deleted: "Тема была удалена" - group_smtp_post_deleted: "Сообщение было удалено" - group_smtp_topic_deleted: "Тема была удалена" - group_smtp_disabled_for_group: "Не был включён smtp для группы" + user_email_notification_topic_nil: "Значение «post.topic» — «nil»" + user_email_post_user_deleted: "Аккаунт автора записи был удалён." + user_email_post_deleted: "запись удалена автором" + user_email_user_suspended: "пользователь был заморожен" + user_email_already_read: "пользователь уже прочитал эту запись" + user_email_access_denied: "пользователь не может видеть эту запись" + user_email_no_email: "С идентификатором пользователя %{user_id} не связан ни один адрес электронной почты" + sender_message_blank: "пустое сообщение" + sender_message_to_blank: "Значение «message.to» не указано" + sender_text_part_body_blank: "Значение «text_part.body» отсутствует" + sender_body_blank: "Значение «body» отсутствует" + sender_post_deleted: "запись была удалена" + sender_message_to_invalid: "неверный адрес электронной почты получателя" + sender_topic_deleted: "тема была удалена" + group_smtp_post_deleted: "запись была удалена" + group_smtp_topic_deleted: "тема была удалена" + group_smtp_disabled_for_group: "smtp для группы был отключён" color_schemes: base_theme_name: "Базовая" light: "Светлая" @@ -3916,17 +3916,17 @@ ru: latte: "Латте" summer: "Летняя" dark_rose: "Тёмная роза" - wcag: "WCAG Light" - wcag_theme_name: "WCAG Light" + wcag: "WCAG светлая" + wcag_theme_name: "WCAG светлая" dracula: "Дракула" dracula_theme_name: "Дракула" solarized_light: "Солнечная светлая" solarized_light_theme_name: "Солнечная светлая" solarized_dark: "Солнечная тёмная" solarized_dark_theme_name: "Солнечная тёмная" - wcag_dark: "WCAG Dark" - wcag_dark_theme_name: "WCAG Dark" - default_theme_name: "Цветовая схема по умолчанию" + wcag_dark: "WCAG тёмная" + wcag_dark_theme_name: "WCAG тёмная" + default_theme_name: "По умолчанию" light_theme_name: "Светлая" dark_theme_name: "Тёмная" neutral_theme_name: "Нейтральная" @@ -3941,9 +3941,9 @@ ru: boolean_no: "Нет" rate_limit_error: "Записи могут быть скачаны лишь раз в день, попробуйте завтра." static_topic_first_reply: | - Отредактируйте первое сообщение этой темы, чтобы изменить название %{page_name}. + Чтобы изменить название страницы «%{page_name}», отредактируйте первую запись этой темы. guidelines_topic: - title: "Основные принципы сообщества и часто задаваемые вопросы" + title: "Ответы на вопросы и рекомендации для сообщества" body: | @@ -3957,7 +3957,7 @@ ru: ## [Сделаем форум интереснее](#improve) - Помогите нам сделать этот форум привлекательным местом для обмена мнениями, работая над улучшением обсуждения, пусть даже время от времени. Если вы не уверены в том, что ваша запись привносит в беседу что-то полезное, обдумайте ещё раз то, что вы хотите сказать и повторите попытку позже. + Помогите нам сделать этот форум привлекательным местом для обмена мнениями, работая над улучшением обсуждения, пусть даже время от времени. Если вы не уверены в том, что ваша запись привносит в беседу что-то полезное, обдумайте ещё раз то, что вы хотите сказать, и вернитесь позже. Один из способов улучшить обсуждение — использовать уже существующие темы. Потратьте немного времени и воспользуйтесь поиском по форуму, прежде чем создавать новую тему, и у вас будет больше шансов встретить тех, кто разделяет ваши интересы. @@ -3974,15 +3974,15 @@ ru: * реагирования на тон сообщения, а не на его фактическое содержание, * необдуманного поведения. - Приведите контраргументы, сделав общение более содержательным. + Приведите контраргументы — это сделает общение более содержательным. ## [Ваш вклад имеет значение](#participate) - Вопросы, поднимаемые на форуме, задают общий тон дискуссии. Помогите нам повлиять на будущее этого сообщества, приняв участие в тех обсуждениях, которые делают этот форум интереснее, и избегая те обсуждения, которые, по вашему мнению, этого не стоят. + Вопросы, поднимаемые на форуме, задают общий тон дискуссии. Помогите нам повлиять на будущее этого сообщества: участвуйте в обсуждениях, которые делают форум интереснее, и избегайте тех, что, по вашему мнению, этого не стоят. - Discourse предоставляет инструменты, которые позволяют сообществу коллективно определять лучший (и худший) вклад: избранное, закладки, лайки, жалобы, ответы, историю правок сообщений и т. д. Используйте этот инструментарий для обогащения как своего опыта, так и опыта остальных участников. + Discourse предоставляет инструменты, которые позволяют сообществу коллективно определять лучший (и худший) вклад: избранное, закладки, лайки, жалобы, ответы, историю правок и т. д. Используйте этот инструментарий на пользу себе и остальным участникам. Сделаем этот форум лучше, чем он был до нашего прихода. @@ -3992,7 +3992,7 @@ ru: Модераторам даны особые полномочия; они несут ответственность за этот форум. Но и вы тоже. С вашей активной помощью модераторы могут быть координаторами сообщества, а не только дворниками или полицией. - Если вы встречаете неадекватное сообщение — не отвечайте на него. Ответ лишь поощряет плохое поведение, признавая его, потребляя вашу энергию и тратя ваше время впустую. Просто _пожалуйтесь_ на такое сообщение. Если будет набрано достаточно жалоб, необходимое действие будет выполнено автоматически или при вмешательстве модератора. + Если вы встречаете неадекватную запись — не отвечайте на нее. Ответ лишь поощряет плохое поведение, признавая его, потребляя вашу энергию и тратя ваше время впустую. Просто _пожалуйтесь_ на такую запись. Если будет набрано достаточно жалоб, необходимое действие будет выполнено автоматически или при вмешательстве модератора. В целях сохранения нашего сообщества модераторы оставляют за собой право удалить любой контент или аккаунт пользователя по любой причине и в любое время. Модераторы не просматривают все новые записи; модераторы и администраторы сайта не несут ответственности за контент, публикуемый сообществом. @@ -4007,9 +4007,9 @@ ru: * Уважайте друг друга. Не унижайте собеседников, не будьте назойливы, не выдавайте себя за других и не разглашайте личную информацию остальных пользователей. * Уважайте форум. Не размещайте спам и иные ненужные сообщения. - Речь не идёт о каких-то конкретных терминах — избегайте публиковать _любую_ подобную информацию. Если вы не уверены в качестве публикуемого сообщения, спросите себя, как бы вы себя чувствовали, если эта информация была бы размещена на первой странице популярного новостного сайта. + Речь не идёт о каких-то конкретных терминах — избегайте публиковать _любую_ подобную информацию. Если вы не уверены в качестве публикуемой записи, спросите себя, как бы вы себя чувствовали, если бы эта информация была бы размещена на первой странице популярного новостного сайта. - Это открытый форум, поэтому поисковые системы индексируют все обсуждения. Сделайте так, чтобы вам не пришлось краснеть за публикуемые сообщения, ссылки и изображения перед друзьями и близкими. + Это открытый форум, поэтому поисковые системы индексируют все обсуждения. Сделайте так, чтобы вам не пришлось краснеть за публикуемые записи, ссылки и изображения перед друзьями и близкими. @@ -4021,9 +4021,9 @@ ru: * Не публикуйте одно и то же в нескольких темах. * Не публикуйте бессодержательные ответы. * Не уходите в сторону от сути обсуждения. - * Не подписывайте свои сообщения — в каждом сообщении есть информация из вашего профиля. + * Не подписывайте свои записи — в них всегда есть информация из вашего профиля. - Вместо того, чтобы публиковать «+1» или «Согласен», используйте кнопку «Лайк». Если необходимо направить существующую тему разговора в совершенно другом направлении, используйте вариант «Ответить в новой связанной теме». + Не пишите «+1» или «Согласен» — используйте кнопку «Лайк». Если необходимо направить существующую тему разговора в совершенно другом направлении, используйте вариант «Ответить в новой связанной теме». @@ -4035,7 +4035,7 @@ ru: ## [Создано вами](#power) - Этот форум обслуживается как нашим [дружелюбным персоналом](%{base_path}/about), так и *вами* — нашим сообществом. Если у вас есть дополнительные вопросы по работе форума — откройте новую тему в категории [отзывов по сайту](%{base_path}/c/site-feedback), и мы обсудим их! Если у вас срочная проблема, которая не является жалобой или не может быть рассмотрена публично — свяжитесь с нами через [страницу персонала](%{base_path}/about). + Этот форум обслуживается как нашим [дружелюбным персоналом](%{base_path}/about), так и *вами* — нашим сообществом. Если у вас есть вопросы по работе форума — откройте новую тему в категории [отзывов по сайту](%{base_path}/c/site-feedback), и мы обсудим их! Если у вас срочная проблема, которая не является жалобой или не может быть рассмотрена публично — свяжитесь с нами через [страницу персонала](%{base_path}/about). @@ -4049,71 +4049,71 @@ ru: body: | - ## [Какую информацию мы собираем?](#collect) + ## [Собираемая информация](#collect) - Мы собираем информацию от вас, когда вы регистрируетесь на нашем сайте, и собираем данные, когда вы участвуете в форуме, читая, пиша и оценивая контент, которым здесь делятся. + Мы собираем информацию от вас, когда вы регистрируетесь на нашем сайте, и собираем данные, когда вы участвуете в работе форума, читая, публикуя и оценивая контент, которым здесь делятся. - При регистрации на нашем сайте вас могут попросить ввести своё имя и адрес электронной почты. Однако вы можете посещать наш сайт без регистрации. Ваш адрес электронной почты будет проверен с помощью письма, содержащего уникальную ссылку. Если эта ссылка будет посещена, мы будем знать, что вы контролируете адрес электронной почты. + При регистрации на сайте вас могут попросить ввести своё имя и адрес электронной почты. Однако вы можете посещать наш сайт без регистрации. Ваш адрес электронной почты будет проверен с помощью письма, содержащего уникальную ссылку. Если эта ссылка будет открыта, мы будем знать, что вы контролируете адрес электронной почты. - При регистрации и размещении сообщений мы фиксируем IP-адрес, с которого пришло сообщение. Мы также можем сохранять журналы сервера, которые включают IP-адрес каждого запроса к нашему серверу. + При регистрации и размещении записей мы фиксируем IP-адрес, с которого пришла запись. Мы также можем сохранять журналы сервера, которые включают в себя IP-адрес каждого запроса к нашему серверу. - ## [Для чего мы используем вашу информацию?](#использование) + ## [Цели сбора информации](#use) - Любая информация, которую мы собираем от вас, может быть использована одним из следующих способов: + Информация, которую мы собираем от вас, может быть использована одним из следующих способов: - * Для персонализации вашего опыта — ваша информация помогает нам лучше реагировать на ваши индивидуальные потребности. - * Для улучшения нашего сайта — мы постоянно стремимся улучшить предложения нашего сайта на основе информации и отзывов, которые мы получаем от вас. - * Для улучшения обслуживания клиентов — ваша информация помогает нам более эффективно отвечать на ваши запросы и потребности в поддержке. - * Для периодической отправки электронных писем — Указанный вами адрес электронной почты может использоваться для отправки тебе информации, уведомлений по вашему запросу об изменениях в темах или в ответ на ваше имя пользователя, ответов на запросы и/или другие просьбы или вопросы. + * Для персонализации функций: ваша информация помогает нам лучше реагировать на ваши индивидуальные потребности. + * Для улучшения нашего сайта: мы постоянно стремимся улучшить предложения нашего сайта на основе информации и отзывов, которые мы получаем от вас. + * Для улучшения обслуживания клиентов: ваша информация помогает нам более эффективно отвечать на ваши запросы и потребности в поддержке. + * Для периодической отправки электронных писем: указанный вами адрес электронной почты может использоваться для отправки информации, уведомлений по вашему запросу об изменениях в темах или в ответ на ваше имя пользователя, ответов на запросы и (или) другие просьбы и вопросы. - ## [Как мы защищаем вашу информацию?](#protect) + ## [Защита вашей информации](#protect) - Мы применяем различные меры безопасности для поддержания безопасности вашей личной информации, когда вы вводите, отправляете или получаете доступ к вашей личной информации. + Мы применяем различные меры безопасности для поддержания безопасности вашей личной информации, когда вы вводите, отправляете или получаете доступ к своей личной информации. - ## [Какова ваша политика хранения данных?](#data-retention) + ## [Политика хранения данных](#data-retention) - Мы будем прилагать добросовестные усилия, чтобы: + Мы будем прилагать разумные усилия, чтобы: * Сохранять журналы сервера, содержащие IP-адрес всех запросов к этому серверу, не более 90 дней. - * Хранить IP-адреса, связанные с зарегистрированными пользователями и их сообщениями, не более 5 лет. + * Хранить IP-адреса, связанные с зарегистрированными пользователями и их записями, не более 5 лет. - ## [Используем ли мы cookies?](#cookies) + ## [Использованием файлов cookie](#cookies) - Да. Cookies — это небольшие файлы, которые сайт или его поставщик услуг передаёт на жесткий диск вашего компьютера через ваш веб-браузер (если вы разрешили). Эти куки позволяют сайту распознать ваш браузер и, если у вас есть зарегистрированная учётная запись, связать его с вашей зарегистрированной учётной записью. + Мы используем файлы cookie — это небольшие файлы, которые сайт или его поставщик услуг передаёт на жесткий диск компьютера через веб-браузер (если вы разрешили). Эти файлы позволяют сайту распознать браузер и связать его с зарегистрированным аккаунтом (если он у вас есть). - Мы используем куки, чтобы понять и сохранить ваши предпочтения для будущих посещений и собрать совокупные данные о посещаемости сайта и взаимодействии с ним, чтобы в будущем мы могли предложить лучшие возможности и инструменты сайта. Мы можем заключать контракты со сторонними поставщиками услуг, чтобы помочь нам лучше понять посетителей нашего сайта. Этим поставщикам услуг не разрешается использовать информацию, собранную от нашего имени, кроме как для помощи нам в ведении и улучшении нашего бизнеса. + Мы используем файлы cookie, чтобы узнавать и сохранять ваши предпочтения для будущих посещений и собирать совокупные данные о посещаемости сайта и взаимодействии с ним. Это позволит нам в будущем предложить лучшие возможности и инструменты сайта. Мы можем заключать контракты со сторонними поставщиками услуг, чтобы лучше понимать действия посетителей сайта. Этим поставщикам услуг не разрешается использовать информацию, собранную от нашего имени, кроме как для помощи нам в ведении и улучшении нашего бизнеса. - ## [Раскрываем ли мы какую-либо информацию сторонним лицам?](#disclose) + ## [Раскрытие информации сторонним лицам](#disclose) - Мы не продаём, не торгуем и не передаём сторонним лицам вашу персонально идентифицируемую информацию. Это не касается доверенных третьих лиц, которые помогают нам в работе нашего сайта, ведении бизнеса или обслуживании вас, при условии, что эти лица согласны сохранять конфиденциальность этой информации. Мы также можем раскрыть вашу информацию, если считаем, что это необходимо для соблюдения закона, обеспечения соблюдения политик нашего сайта или защиты наших или чужих прав, собственности или безопасности. Однако неличная информация о посетителях может быть предоставлена другим сторонам для маркетинга, рекламы или других целей. + Мы не продаём, не торгуем и не передаём сторонним лицам вашу персонально идентифицируемую информацию. Это не касается доверенных третьих лиц, которые помогают нам в работе нашего сайта, ведении бизнеса или обслуживании вас, при условии, что эти лица согласны сохранять конфиденциальность этой информации. Мы также можем раскрыть вашу информацию, если считаем, что это необходимо для соблюдения закона, соблюдения политик нашего сайта или защиты наших и чужих прав, собственности или безопасности. Однако неличная информация о посетителях может быть предоставлена другим сторонам для маркетинга, рекламы или других целей. ## [Сторонние ссылки](#third-party) - Иногда, по нашему усмотрению, мы можем включать или предлагать сторонние продукты или услуги на нашем сайте. Эти сторонние сайты имеют отдельную и независимую политику конфиденциальности. Поэтому мы не несём никакой ответственности или обязательств за содержание и деятельность этих сайтов, на которые ведут ссылки. Тем не менее, мы стремимся защитить целостность нашего сайта и приветствуем любые отзывы об этих сайтах. + Иногда мы по собственному усмотрению можем включать или предлагать сторонние продукты или услуги на нашем сайте. Эти сторонние сайты имеют отдельную политику конфиденциальности, не зависящую от нас. Поэтому мы не несём никакой ответственности за содержание и деятельность сайтов, на которые ведут ссылки. Тем не менее мы стремимся защитить целостность нашего сайта и приветствуем отзывы об этих сайтах. ## [Соблюдение закона о защите конфиденциальности детей в Интернете](#coppa) - Наш сайт, продукты и услуги предназначены для людей не моложе 13 лет. Если этот сервер находится в США, а вам меньше 13 лет, в соответствии с требованиями COPPA ([Children's Online Privacy Protection Act](https://ru.wikipedia.org/wiki/Children’s_Online_Privacy_Protection_Act)), не пользуйтесь этим сайтом. + Наш сайт, продукты и услуги предназначены для лиц не моложе 13 лет. Если этот сервер находится в США, а вам меньше 13 лет, то в соответствии с требованиями COPPA ([Children's Online Privacy Protection Act](https://ru.wikipedia.org/wiki/Children’s_Online_Privacy_Protection_Act)) вам запрещено пользоваться этим сайтом. - ## [Только политика конфиденциальности в Интернете](#online) + ## [Политика конфиденциальности информации в Интернете](#online) - Эта онлайн политика конфиденциальности распространяется только на информацию, собранную через наш сайт, и не распространяется на информацию, собранную оффлайн. + Эта политика конфиденциальности распространяется только на информацию, собранную через наш сайт, и не распространяется на информацию, собранную офлайн. @@ -4123,78 +4123,78 @@ ru: - ## [Изменения в нашей политике конфиденциальности](#changes) + ## [Изменения в политике конфиденциальности](#changes) - Если мы решим изменить нашу политику конфиденциальности, мы опубликуем эти изменения на этой странице. + Если мы решим изменить политику конфиденциальности, мы опубликуем эти изменения на этой странице. - Этот документ имеет статус CC-BY-SA. Последний раз он был обновлён 31 мая 2013 года. + Этот документ имеет лицензию CC-BY-SA. Последний раз он был обновлён 31 мая 2013 года. badges: mass_award: errors: - invalid_csv: Мы обнаружили ошибку в строке %{line_number}. Проверьте, что в CSV-файле указано одно электронное письмо на строку. + invalid_csv: Мы обнаружили ошибку в строке %{line_number}. В CSV-файле должен быть указан один адрес электронной почты на строку. too_many_csv_entries: Слишком много записей в CSV-файле. Загрузите файл с не более чем %{count} записями. - badge_disabled: Сначала активируйте использование награды %{badge_name}. - cant_grant_multiple_times: Невозможно присвоить награду %{badge_name} несколько раз одному и тому же пользователю. + badge_disabled: Сначала активируйте использование награды «%{badge_name}». + cant_grant_multiple_times: Награду «%{badge_name}» нельзя присвоить одному пользователю несколько раз. editor: name: Редактор - description: Первое редактирование сообщения + description: Первое редактирование записи long_description: | - Эта награда выдаётся при первом редактировании одного из ваших сообщений. И хотя вы не сможете бесконечно редактировать свои сообщения, редактирование всё же приветствуется - вы можете улучшить форматирование, исправить мелкие ошибки или добавить всё, что вы пропустили при первоначальной публикации. Сделайте ваши сообщения ещё лучше! + Эта награда выдаётся при первом редактировании одной из ваших записей. Вы не сможете бесконечно редактировать свои записи, но редактирование всё же приветствуется: вы можете улучшить форматирование, исправить мелкие ошибки или добавить всё, что пропустили при первоначальной публикации. Делайте свои записи ещё лучше! wiki_editor: name: Wiki-редактор - description: Первое редактирование вики-сообщения + description: Первое редактирование вики-записи long_description: | - Эта награда выдаётся при первом редактировании одного из вики-сообщений. + Эта награда выдаётся при первом редактировании одной из вики-записей. basic_user: name: Обычный пользователь description: Разрешён доступ ко всем основным функциям форума long_description: | - Эта награда выдаётся, когда вы достигаете уровня доверия 1. Спасибо, что остаётесь на форуме и читаете темы, погружаясь в интересы нашего сообщества. Ограничения, выставляемые новичкам, теперь сняты; вам предоставлены все основные возможности сообщества, такие как отправка личных сообщений, выставление жалоб, редактирование вики и возможность публиковать сразу несколько изображений и ссылок. + Эта награда выдаётся, когда вы достигаете уровня доверия 1. Спасибо, что остаётесь на форуме и читаете темы, погружаясь в интересы нашего сообщества. Ограничения, выставляемые новичкам, теперь сняты; вам предоставлены все основные возможности сообщества, такие как отправка личных сообщений, выставление жалоб, редактирование вики-записей и возможность публиковать сразу несколько изображений и ссылок. member: name: Участник - description: Разрешены приглашения, групповые сообщения. Теперь вы можете ставить больше ежедневных лайков. + description: Разрешены приглашения, групповые сообщения, можно ставить больше лайков long_description: | - Эта награда выдаётся, когда вы достигаете уровня доверия 2. Благодарим вас за активное участие в течение этих нескольких недель, теперь вы истинный участник нашего сообщества. Вам предоставлены дополнительные возможности: теперь вы можете отправлять приглашения со своей страницы пользователя или из отдельных тем, создавать групповые личные сообщения и ставить больше ежедневных лайков. + Эта награда выдаётся, когда вы достигаете уровня доверия 2. Благодарим вас за активное участие в течение этих нескольких недель, теперь вы истинный участник нашего сообщества. Вам предоставлены дополнительные возможности: теперь вы можете отправлять приглашения со своей страницы пользователя или из отдельных тем, создавать групповые личные сообщения и ставить больше лайков за день. regular: name: Активный пользователь - description: Разрешено перемещать темы в другие разделы, переименовать темы, переходить по ссылкам, создавать вики-сообщения, ставить больше лайков + description: Разрешено перемещать темы в другие категории, переименовать темы, переходить по ссылкам, создавать вики-записи, ставить больше лайков long_description: | - Эта награда выдаётся, когда вы достигаете уровня доверия 3. Спасибо за то, что вы стали частью нашего сообщества в течение этих месяцев. Теперь вы один из самых активных читателей и надёжный участник, который делает наше сообщество просто великолепным. Теперь вы можете перемещать темы в другие разделы, переименовывать их, использовать более функциональные спам-флаги и ставить гораздо больше лайков за день. Вы также получили доступ к закрытым разделам форума. + Эта награда выдаётся, когда вы достигаете уровня доверия 3. Спасибо за то, что вы стали частью нашего сообщества в течение этих месяцев. Теперь вы один из самых активных читателей и надёжный участник, который делает наше сообщество просто великолепным. Теперь вы можете перемещать темы в другие категории, переименовывать их, использовать более функциональные жалобы на спам и ставить гораздо больше лайков за день. Вы также получили доступ к закрытым разделам форума. leader: name: Лидер - description: Разрешено глобальное редактирование, закрепление, закрытие, архивирование, разделение и объединение тем и ещё больше ежедневных лайков + description: Разрешено глобальное редактирование, закрепление, закрытие, архивирование, разделение и объединение тем и ещё больше лайков за день long_description: | - Эта награда выдаётся, когда вы достигаете уровня доверия 4. Вы являетесь одним из лидеров этого сообщества, которого выбрали сотрудники, и вы подаёте положительный пример остальному сообществу своими словами и действиями. Теперь у вас есть возможность редактировать все сообщения на форуме и выполнять основные действия модератора над темами, такие как: закрепление, закрытие, скрытие, архивирование, разделение и объединение. + Эта награда выдаётся, когда вы достигаете уровня доверия 4. Вы являетесь одним из лидеров этого сообщества, которого выбрали сотрудники, и вы подаёте положительный пример остальным своими словами и действиями. Теперь у вас есть возможность редактировать все записи на форуме и выполнять основные действия модератора над темами, такие как: закрепление, закрытие, скрытие, архивирование, разделение и объединение. welcome: name: Добро пожаловать - description: Получил симпатию + description: Получен лайк long_description: | - Эта награда выдаётся, когда вы получаете свою первую симпатию за сообщение. Поздравляем, вы опубликовали что-то, что ваши коллеги по сообществу нашли интересным, классным или полезным! + Эта награда выдаётся, когда вы получаете свой первый лайк к записи. Поздравляем: вы опубликовали что-то, что ваши коллеги по сообществу нашли интересным, классным или полезным! autobiographer: name: Автобиограф - description: Заполнил профиль пользователя + description: Заполнил(а) профиль пользователя long_description: | - Эта награда выдаётся при заполнении вашего профиля и выборе аватара. Если вы сообщите больше информации о том, кто вы есть, и что вас интересует, это поможет создать лучшее, более сплочённое сообщество. Присоединяйтесь к нам! + Эта награда выдаётся при заполнении профиля и выборе аватара. Если вы сообщите больше информации о себе и о том, что вас интересует, это поможет создать лучшее, более сплочённое сообщество. Присоединяйтесь к нам! anniversary: name: Годовщина - description: Активный пользователь в течении года, создал как минимум одну тему. + description: Активный пользователь в течение года, опубликована как минимум одна запись long_description: | - Эта награда выдаётся, когда вы являетесь участником сообщества в течение года и опубликовали хотя бы одно сообщение в этом году. Спасибо за то, что вы остаётесь с нами и вносите свой вклад в сообщество. Возможно, мы не смогли бы сделать этого без вас. + Эта награда выдаётся, когда вы являетесь участником сообщества в течение года и опубликовали хотя бы одну запись в этом году. Спасибо за то, что вы остаётесь с нами и вносите свой вклад в сообщество. Возможно, мы не смогли бы сделать этого без вас. nice_post: name: Славный ответ - description: Получил 10 лайков за ответ + description: Получено 10 лайков за ответ long_description: | Эта награда выдаётся, когда ваш ответ получает 10 лайков. Ваш ответ произвёл впечатление на сообщество и перевёл обсуждение на новый уровень. good_post: name: Хороший ответ - description: Получил 25 лайков за ответ + description: Получено 25 лайков за ответ long_description: | Эта награда выдаётся, когда ваш ответ получает 25 лайков. Ваш ответ был исключительно полезным и сделал общение намного интереснее. great_post: name: Отличный ответ - description: Получил 50 лайков за ответ + description: Получено 50 лайков за ответ long_description: | - Эта награда выдаётся, когда ваш ответ получает 50 лайков. Ого! Ваш ответ был вдохновляющим, увлекательным, весёлым или проницательным, и сообществу это явно понравилось! + Эта награда выдаётся, когда ваш ответ получает 50 лайков. Поздравляем! Ваш ответ был вдохновляющим, увлекательным, весёлым или проницательным, и сообществу это явно понравилось! nice_topic: name: Славная тема description: Тема получила 10 симпатий @@ -4202,180 +4202,180 @@ ru: Эта награда выдаётся, когда ваша тема получает 10 лайков. Вы начали интересную беседу, которая понравилась сообществу. good_topic: name: Хорошая тема - description: Получил 25 лайков за тему + description: Тема получила 25 лайков long_description: | - Эта награда выдаётся, когда ваша тема получает 25 симпатий. Вы начали оживлённую беседу, вокруг которой сплотилось сообщество. + Эта награда выдаётся, когда ваша тема получает 25 лайков. Вы начали оживлённую беседу, вокруг которой сплотилось сообщество. great_topic: name: Отличная тема - description: Получил 50 лайков за тему + description: Тема получила 50 лайков long_description: | - Эта награда выдаётся, когда ваша тема получает 50 симпатий. Вы начали увлекательную беседу, и сообществу весьма понравилась оживлённая дискуссия! + Эта награда выдаётся, когда ваша тема получает 50 лайков. Вы начали увлекательную беседу, и сообществу весьма понравилась оживлённая дискуссия! nice_share: name: Славный пиар - description: Поделился сообщением с 25 уникальными посетителями + description: Поделился записью с 25 уникальными посетителями long_description: | Эта награда выдаётся после размещения ссылки, на которую нажали 25 внешних посетителей. Спасибо за распространение информации о наших дискуссиях и этом сообществе. good_share: name: Хороший пиар - description: Поделился сообщением с 300 уникальными посетителями + description: Поделился записью с 300 уникальными посетителями long_description: | - Эта награда выдаётся, когда вы поделидись ссылкой, на которую нажали 300 внешних посетителей. Хорошая работа! Вы показали отличную дискуссию большому количеству новых людей и помогли этому сообществу расти. + Эта награда выдаётся, когда вы поделились ссылкой, на которую нажали 300 внешних посетителей. Хорошая работа! Вы показали отличную дискуссию большому количеству новых людей и помогли этому сообществу расти. great_share: name: Отличный пиар - description: Поделился сообщением с 1000 уникальными посетителями + description: Поделился записью с 1000 уникальных посетителей long_description: | - Эта награда выдаётся за предоставления ссылки, на которую нажали 1000 внешних посетителей. Вот это да! Вы показали интересную дискуссию огромной новой аудитории и помогли нам значительно расширить наше сообщество! + Эта награда выдаётся, когда вы поделились ссылкой, на которую нажала 1000 внешних посетителей. Поздравляем! Вы показали интересную дискуссию огромной новой аудитории и помогли нам значительно расширить наше сообщество! first_like: name: Первый лайк - description: Понравилось сообщение + description: Понравилась запись long_description: | - Эта награда выдаётся один раз, когда вам нравится публикация и вы используете кнопку :heart:. Подобные действия - это отличный способ сообщить участникам сообщества, что они опубликовали интересную, полезную, классную или весёлую статью! + Эта награда выдаётся один раз, когда вам понравилась запись и вы нажали кнопку :heart:. Подобные действия — это отличный способ сообщить участникам сообщества, что они опубликовали интересную, полезную, классную или весёлую запись! first_flag: name: Первая жалоба - description: Оставил жалобу на запись + description: Оставил(а) жалобу на запись long_description: | - Эта награда выдаётся при выставлении первой жалобы. Если вы заметили какие-либо сообщение, требующие внимания модератора по любой причине, пожалуйста, не стесняйтесь воспользоваться кнопкой 'Пожаловаться'. Если вы видите нарушение, :flag_black: то сообщите об этом! + Эта награда выдаётся при выставлении первой жалобы. Жалобы помогают сохранить приятную атмосферу в обсуждении. Если вы заметили запись, требующую внимания модератора, смело жалуйтесь. Если вы видите нарушение, :flag_black: сообщите об этом! promoter: name: Промоутер - description: Пригласил нового пользователя + description: Пригласил(а) нового пользователя long_description: | - Эта награда выдаётся, когда вы приглашаете кого-то присоединиться к сообществу с помощью кнопки приглашения на вашей странице пользователя или в нижней части темы. Приглашение друзей, которых могут заинтересовать конкретные дискуссии, это отличный способ представить новых людей нашему сообществу, поэтому - спасибо! + Эта награда выдаётся, когда вы приглашаете кого-то присоединиться к сообществу с помощью кнопки приглашения на вашей странице пользователя или в нижней части темы. Приглашение друзей, которых могут заинтересовать конкретные дискуссии, это отличный способ представить новых людей нашему сообществу, поэтому — спасибо! campaigner: name: Активист - description: Пригласил 3 новичков + description: Пригласил(а) трех новичков long_description: | - Эта награда выдаётся, когда вы пригласили 3 новичков, которые впоследствии провели достаточно времени на сайте, чтобы стать обычными пользователями. Живое сообщество нуждается в регулярном вливании новичков, которые постоянно участвуют в форуме и добавляют новую ценность общению. + Эта награда выдаётся, когда вы пригласили трёх новичков, которые впоследствии провели достаточно времени на сайте, чтобы стать обычными пользователями. Живое сообщество нуждается в регулярном вливании новичков, которые постоянно участвуют в работе форума и вносят полезный вклад. champion: name: Чемпион - description: Пригласил 5 участников + description: Пригласил(а) 5 участников long_description: | Эта награда выдаётся, когда вы пригласили 5 человек, которые впоследствии провели достаточно времени на сайте, чтобы стать полноправными участниками. Вот это да! Спасибо за расширение круга общения нашего сообщества! first_share: name: Первый пиар - description: Поделился сообщением + description: Поделился записью long_description: | - Эта награда выдаётся при первом предоставлении ссылки на ответ или тему с помощью кнопки 'Поделиться'. Обмен ссылками - отличный способ показать интересные дискуссии остальному миру и развить своё сообщество. + Эта награда выдаётся при первой публикации ссылки на ответ или тему с помощью кнопки «Поделиться». Публикация ссылок — отличный способ показать интересные дискуссии остальному миру и развить сообщество. first_link: name: Первая ссылка - description: Добавил ссылку на другую тему + description: Добавил(а) ссылку на другую тему long_description: | - Эта награда предоставляется при первом добавлении ссылки на другую тему. Связывание тем помогает другим читателям находить интересные связанные дискуссии, показывая связи между темами в обоих направлениях! Пользуйтесь этой функцией! + Эта награда выдаётся при первом добавлении ссылки на другую тему. Связывание тем помогает другим читателям находить интересные связанные дискуссии, показывая связи между темами в обоих направлениях! Пользуйтесь этой функцией! first_quote: name: Первая цитата - description: Процитировал сообщение + description: Процитировал(а) запись long_description: | - Эта награда выдаётся при первом цитировании сообщения в вашем ответе. Цитирование соответствующих частей более ранних сообщений в вашем ответе помогает поддерживать связь между ответом и остальным содержанием. Самый простой способ процитировать — это выделить часть сообщения, а затем нажать на кнопку «Ответить с цитированием». Цитируйте щедро! + Эта награда выдаётся при первом цитировании записи в вашем ответе. Цитирование соответствующих частей более ранних записей в ответе помогает поддерживать связь между ответом и остальным содержанием. Самый простой способ процитировать — это выделить часть записи, а затем нажать на кнопку «Ответить с цитированием». Цитируйте смело! read_guidelines: name: Прочтены рекомендации для сообщества description: Прочтены рекомендации для сообщества long_description: | - Этот значок дается после прочтения рекомендаций для сообщества. Следование этим простым правилам помогает создать безопасное, целеустремлённое и устойчивое сообщество. Всегда помните, что по ту сторону экрана есть другой человек, очень похожий на вас. И старайтесь следовать золотому правилу: "Не делай другим того, чего не желаешь себе" (Конфуций). + Этот значок даётся после прочтения рекомендаций для сообщества. Следование этим простым правилам помогает создать безопасное, целеустремлённое и устойчивое сообщество. Всегда помните, что по ту сторону экрана есть другой человек, очень похожий на вас. И старайтесь следовать золотому правилу: «Не делай другим того, чего не желаешь себе» (Конфуций). reader: name: Читатель - description: Прочитал каждый ответ в теме с более чем 100 ответами + description: Прочитал(а) каждый ответ в теме с более чем 100 ответами long_description: | - Эта награда предоставляется при первом прочтении длинной темы с более чем 100 ответами. Внимательное чтение беседы помогает вам следить за обсуждением, помогает понимать различные точки зрения и вести более интересные дискуссии. Чем больше вы читаете, тем продуктивнее становится разговор. Другими словами, чтение — основа основ! :slight_smile: + Эта награда выдаётся при первом прочтении длинной темы с более чем 100 ответами. Внимательное чтение беседы помогает вам следить за обсуждением, помогает понимать различные точки зрения и вести более интересные дискуссии. Чем больше вы читаете, тем продуктивнее становится разговор. Другими словами, чтение — основа основ! :slight_smile: popular_link: name: Популярная ссылка - description: Опубликовал ссылку на внешний ресурс, которую открыли более 50 раз + description: Опубликовал(а) ссылку на внешний ресурс, которую открыли более 50 раз long_description: | Эта награда выдаётся, когда ссылка, которой вы поделились, получает 50 кликов. Спасибо за размещение полезной ссылки, которая добавила интересный контекст в разговор! hot_link: name: Горячая ссылка - description: Опубликовал ссылку на внешний ресурс, которую открыли более 300 раз + description: Опубликовал(а) ссылку на внешний ресурс, которую открыли более 300 раз long_description: | Эта награда выдаётся, когда ссылка, которой вы поделились, получает 300 кликов. Спасибо за размещение увлекательной ссылки, которая подтолкнула разговор вперёд и осветила дискуссию! famous_link: name: Легендарная ссылка - description: Опубликовал ссылку на внешний ресурс, которую открыли более 1000 раз + description: Опубликовал(а) ссылку на внешний ресурс, которую открыли более 1000 раз long_description: | Эта награда выдаётся, когда ссылка, которой вы поделились, получает 1000 кликов. Вот это да! Вы разместили ссылку, которая значительно улучшила разговор, добавив важные детали, контекст и информацию. Отличная работа! appreciated: - name: Оценённый - description: Получил 1 симпатию, отправив 20 сообщений + name: Высокая оценка + description: Получен 1 лайк на 20 записей long_description: | - Эта награда выдаётся, когда вы получаете хотя бы одну симпатию за 20 различных сообщений. Сообщество наслаждается вашим вкладом в дискуссии! + Эта награда выдаётся, когда вы получаете хотя бы один лайк за 20 различных записей. Сообщество радуется вашему вкладу в дискуссию! respected: - name: Уважаемый - description: Получил 2 симпатии, отправив 100 сообщений + name: Уважение + description: Получено 2 лайка на 100 записей long_description: | - Эта награда выдаётся, когда вы получаете хотя бы 2 симпатии за 100 различных сообщений. Сообщество всё больше уважает ваш значительный вклад в дискуссии. + Эта награда выдаётся, когда вы получаете хотя бы 2 лайка за 100 различных записей. Сообщество всё больше уважает ваш значительный вклад в дискуссии. admired: - name: Почитаемый - description: Получил 5 лайков, отправив 300 сообщений + name: Почитание + description: Получено 5 лайков на 300 записей long_description: | - Эта награда выдаётся, когда вы получаете хотя бы 5 симпатий за 300 различных сообщений. Ух ты! Сообщество восхищается вашим постоянным и высококачественным вкладом в дискуссии. + Эта награда выдаётся, когда вы получаете хотя бы 5 лайков за 300 различных записей. Вот это да! Сообщество восхищается вашим постоянным качественным вкладом в дискуссии. out_of_love: name: За любовь - description: Использовано %{max_likes_per_day} доступных ежедневных лайков + description: 'Использовано доступных на день лайков: %{max_likes_per_day}' long_description: | - Этот значок предоставляется, когда вы используете все %{max_likes_per_day} из ваших ежедневных лайков. Не забывайте уделять время и выдавать симпатии сообщениям, которые вам нравятся и которые вы цените, что побуждает ваших коллег по сообществу создавать в дальнейшем ещё более интересные дискуссии. + Эта награда выдаётся, когда вы используете все лайки, которые даются на день (%{max_likes_per_day}). Внимательно читайте записи и ставьте лайки тем, которые вам нравятся и которые вы цените: это побуждает ваших коллег по сообществу создавать в дальнейшем ещё более интересные дискуссии. higher_love: - name: За высшую любовь - description: Использовано %{max_likes_per_day} доступных ежедневных лайков 5 дней подряд + name: За высокую любовь + description: 'Использовано доступных на день лайков (5 дней): %{max_likes_per_day}' long_description: | - Этот значок предоставляется, когда вы используете все %{max_likes_per_day} ваших доступных ежедневных лайков в течение 5 дней. Спасибо, что нашли время ежедневно активно поощрять лучшие обсуждения! + Эта награда выдаётся, когда вы используете все лайки, которые даются на день (%{max_likes_per_day}), в течение 5 дней. Спасибо, что нашли время ежедневно активно поощрять лучшие обсуждения! crazy_in_love: name: За безумную любовь - description: Использовано %{max_likes_per_day} доступных ежедневных лайков 20 дней подряд + description: 'Использовано доступных на день лайков (20 дней): %{max_likes_per_day}' long_description: | - Этот значок предоставляется, когда вы используете все %{max_likes_per_day} ваших доступных ежедневных лайков в течение 20 дней. Ого! Вы просто ходячий образец для подражания! + Эта награда выдаётся, когда вы используете все лайки, которые даются на день (%{max_likes_per_day}), в течение 20 дней. Ничего себе! Вы просто ходячий образец для подражания! thank_you: - name: Благодарный - description: Получил 20 лайков за сообщения и поставил 10 лайков + name: Благодарность + description: Получено 20 лайков за записи и поставлено 10 лайков long_description: | - Эта награда выдаётся, когда вы получили 20 симпатий и 10 или более симпатий выразили взамен. Когда кому-то нравятся ваши сообщения, вы также находите время для выражений симпатий публикациям других пользователей. + Эта награда выдаётся, когда вы получили 20 лайков и 10 или более лайков поставили в ответ. Когда кому-то нравятся ваши записи, вы также находите время для лайков записям других пользователей. gives_back: name: Взаимный лайк - description: Поставил 100 лайков и 100 лайков получил взамен + description: Получено 100 лайков за записи и поставлено 100 лайков long_description: | - Эта награда выдаётся, когда вы получили 100 симпатий и 100 или более симпатий выразили взамен. Спасибо за вашу поддержку! + Эта награда выдаётся, когда вы получили 100 лайков и 100 или более лайков поставили в ответ. Спасибо за поддержку! empathetic: - name: Чуткий - description: Получил 500 лайков за сообщения и поставил 1000 лайков + name: Чуткость + description: Получено 500 лайков за записи и поставлено 1000 лайков long_description: | - Эта награда выдаётся, когда вы получили 500 симпатий и 1000 или более симпатий выразили взамен. Вот это да! Вы просто образец щедрости и взаимного обожания :two_hearts:. + Эта награда выдаётся, когда вы получили 500 лайков и 1000 или более лайков поставили в ответ. Ничего себе! Вы просто образец щедрости и взаимного обожания :two_hearts:. first_emoji: name: Первый эмодзи - description: Использовал эмодзи в записи + description: Использовал(а) эмодзи в записи long_description: | - Эта награда выдается при первом добавлении смайлика в ваш пост :thumbsup:. Смайлики позволяет вам передавать эмоции в сообщениях: от счастья :smiley: и грусти :anguished:, до гнева :angry: и всего, что между ними :sunglasses:. Просто введите : (двоеточие) или нажмите кнопку эмодзи в панели инструментов редактора, чтобы выбрать нужный смайлик или эмодзи из сотен вариантов :ok_hand: + Эта награда выдаётся при первом добавлении эмодзи в запись :thumbsup:. Эмодзи позволяют передавать эмоции в записях: от радости :smiley: и грусти :anguished: до гнева :angry: и всего остального :sunglasses:. Просто введите «:» (двоеточие) или нажмите кнопку эмодзи в панели инструментов редактора и выберите нужный вариант :ok_hand:. first_mention: name: Первое обращение - description: Упомянул пользователя в записи + description: Упомянул(а) пользователя в записи long_description: | - Эта награда выдаётся при первом упоминании чьего-либо @username в вашем сообщении. Каждое упоминание создаёт уведомление для этого человека, чтобы он знал о вашем сообщении. Просто начните вводить @ (как символ), чтобы упомянуть любого пользователя или, если это разрешено, группу - это удобный способ привлечь внимание. + Эта награда выдаётся при первом упоминании чьего-либо @имени_пользователя в записи. Каждое упоминание создаёт уведомление для этого человека, чтобы он знал о вашей записи. Чтобы упомянуть пользователя или, если это разрешено, группу, начните вводить «@» (символ «собачка») — это удобный способ привлечь внимание. first_onebox: name: Первая умная вставка - description: Опубликовал ссылку, которая была преобразована в умную вставку + description: Опубликовал(а) ссылку, которая была преобразована в умную вставку long_description: | - Эта награда выдаётся при первом размещении ссылки в отдельной строке, которая автоматически разворачивается в умную вставку, содержащую сводку, заголовок и (при наличии) изображение. + Эта награда выдаётся при первом размещении ссылки в отдельной строке, которая автоматически разворачивается в умную вставку, содержащую сводку, заголовок и изображение (при наличии). first_reply_by_email: name: Первый ответ по почте - description: Ответил на публикацию по электронной почте + description: Ответил(а) на запись по почте long_description: | - Эта награда выдаётся при первом ответе на сообщение по e-mail :e-mail:. + Эта награда выдаётся при первом ответе на запись по электронной почте :e-mail:. new_user_of_the_month: name: "Новый пользователь месяца" description: Великолепный вклад за первый месяц long_description: | - Эта награда выдаётся, чтобы поздравить двух новых пользователей каждый месяц за их отличный общий вклад, измеряемый тем, как часто их сообщения нравились, и кому. + Эта награда выдаётся двум новым пользователям каждый месяц за их отличный общий вклад, измеряемый тем, как часто их записи нравились, и кому. enthusiast: name: Энтузиаст - description: Посещал форум 10 дней подряд + description: Посещал(а) форум 10 дней подряд long_description: | Эта награда выдаётся при посещении форума 10 дней подряд. Спасибо, что остаётесь с нами более недели! aficionado: name: Поклонник - description: Посещал форум 100 дней подряд + description: Посещал(а) форум 100 дней подряд long_description: | Эта награда выдаётся при посещении форума 100 дней подряд. Это более трёх месяцев! devotee: - name: Преданный - description: Посещал форум 365 дней подряд + name: Преданность + description: Посещал(а) форум 365 дней подряд long_description: | - Этот награда выдаётся при посещении форума 365 дней подряд. Ух ты, целый год! - badge_title_metadata: "Значок «%{display_name}» на сайте %{site_title}" + Эта награда выдаётся при посещении форума 365 дней подряд. Ух ты, целый год! + badge_title_metadata: "Награда «%{display_name}» на сайте %{site_title}" admin_login: success: "Письмо отправлено" errors: @@ -4392,10 +4392,10 @@ ru: restricted_tag_disallowed: 'Вы не можете применить тег «%{tag}».' restricted_tag_remove_disallowed: 'Вы не можете удалить тег «%{tag}».' minimum_required_tags: - one: "Вы должны выбрать как минимум %{count} тег." - few: "Вы должны выбрать как минимум %{count} тега." - many: "Вы должны выбрать как минимум %{count} тегов." - other: "Вы должны выбрать как минимум %{count} тега." + one: "Нужно выбрать как минимум %{count} тег." + few: "Нужно выбрать как минимум %{count} тега." + many: "Нужно выбрать как минимум %{count} тегов." + other: "Нужно выбрать как минимум %{count} тега." upload_row_too_long: "CSV-файл должен содержать один тег на строку. При необходимости за тегом может следовать запятая, а затем название группы тегов." forbidden: invalid: @@ -4405,27 +4405,27 @@ ru: other: "Ни один из выбранных тегов не может быть использован" in_this_category: 'Тег «%{tag_name}» нельзя использовать в этой категории' restricted_to: - one: 'Тег «%{tag_name}» может быть использован только в разделе «%{category_names}»' + one: 'Тег «%{tag_name}» может быть использован только в категории «%{category_names}»' few: 'Тег «%{tag_name}» может быть использован только в следующих категориях: «%{category_names}»' many: 'Тег «%{tag_name}» может быть использован только в следующих категориях: «%{category_names}»' other: 'Тег «%{tag_name}» может быть использован только в следующих категориях: «%{category_names}»' synonym: 'Синонимы не допускаются. Вместо них используйте тег «%{tag_name}».' has_synonyms: 'Тег «%{tag_name}» не может быть использован, поскольку у него есть синонимы.' restricted_tags_cannot_be_used_in_category: - one: 'Тег «%{tags}» нельзя использовать в разделе «%{category}». Удалите его.' + one: 'Следующие теги нельзя использовать в категории «%{category}»: %{tags}. Удалите их.' few: 'Следующие теги нельзя использовать в разделе «%{category}»: %{tags}. Удалите их.' - many: 'Следующие теги нельзя использовать в разделе «%{category}»: %{tags}. Удалите их.' - other: 'Следующие теги нельзя использовать в разделе «%{category}»: %{tags}. Удалите их.' + many: 'Следующие теги нельзя использовать в категории «%{category}»: %{tags}. Удалите их.' + other: 'Следующие теги нельзя использовать в категории «%{category}»: %{tags}. Удалите их.' category_does_not_allow_tags: - one: 'В разделе «%{category}» не разрешается использовать тег «%{tags}». Удалите его.' - few: 'В разделе «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' - many: 'В разделе «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' - other: 'В разделе «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' + one: 'В категории «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' + few: 'В категории «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' + many: 'В категории «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' + other: 'В категории «%{category}» не разрешается использовать следующие теги: %{tags}. Удалите их.' required_tags_from_group: - one: "Вы должны указать как минимум %{count} тег из группы %{tag_group_name}. Группа содержит следующие теги: %{tags}." - few: "Вы должны указать как минимум %{count} тега из группы %{tag_group_name}. Группа содержит следующие теги: %{tags}." - many: "Вы должны указать как минимум %{count} тегов из группы %{tag_group_name}. Группа содержит следующие теги: %{tags}." - other: "Вы должны указать как минимум %{count} тега из группы %{tag_group_name}. Группа содержит следующие теги: %{tags}." + one: "Укажите как минимум %{count} тег из группы «%{tag_group_name}». Группа содержит следующие теги: %{tags}." + few: "Укажите как минимум %{count} тега из группы «%{tag_group_name}». Группа содержит следующие теги: %{tags}." + many: "Укажите как минимум %{count} тегов из группы «%{tag_group_name}». Группа содержит следующие теги: %{tags}." + other: "Укажите как минимум %{count} тега из группы «%{tag_group_name}». Группа содержит следующие теги: %{tags}." invalid_target_tag: "не может быть синонимом синонима" synonyms_exist: "не допускается, пока существуют синонимы" rss_by_tag: "Темы, отмеченные тегом %{tag}" @@ -4433,22 +4433,22 @@ ru: congratulations: "Поздравляем, вы установили Discourse!" register: button: "Зарегистрироваться" - title: "Зарегистрировать аккаунт администратора" - help: "Зарегистрируйте новый аккаунт для начала работы с системой." + title: "Зарегистрируйте аккаунт администратора" + help: "Для начала работы с системой необходимо зарегистрировать аккаунт." no_emails: "В процессе установки не была указана электронная почта администратора, поэтому завершение установки может быть проблематично. Укажите адрес электронной почты в файле конфигурации или создайте административный аккаунт из командной строки." confirm_email: title: "Подтвердите адрес электронной почты" - message: "

    Мы выслали письмо на %{email}. Следуйте инструкциям в этом письме для активации аккаунта.

    Если письмо не приходит, проверьте папку со спамом и убедитесь, что вы правильно настроили электронную почту.

    " + message: "

    Мы выслали письмо на %{email}. Следуйте инструкциям по активации аккаунта в письме.

    Если письмо не приходит, проверьте папку со спамом и убедитесь, что вы правильно настроили электронную почту.

    " resend_email: title: "Отправить повторно письмо для активации" message: "

    Мы повторно отправили письмо для активации на адрес %{email}" safe_mode: title: "Войти в безопасный режим" - description: "Безопасный режим позволяет протестировать сайт без загрузки плагинов или тем." + description: "Безопасный режим позволяет протестировать сайт без загрузки плагинов и тем." no_themes: "Отключить темы и компоненты тем" no_unofficial_plugins: "Отключить неофициальные плагины" no_plugins: "Отключить все плагины" - enter: "Войти в Безопасный Режим" + enter: "Войти в безопасный режим" must_select: "Для перехода в безопасный режим необходимо выбрать хотя бы один параметр." wizard: title: "Настройка Discourse" @@ -4459,17 +4459,17 @@ ru: label: "Название сообщества" placeholder: "Женина тусовка" site_description: - label: "Опишите ваше сообщество одним предложением" + label: "Опишите сообщество одним предложением" placeholder: "Место, где Женя и её друзья обсуждают интересные новости" default_locale: label: "Язык" privacy: fields: login_required: - placeholder: "Приватная" + placeholder: "Закрытое сообщество" extra_description: "Только зарегистрированные пользователи могут получить доступ к этому сообществу" invite_only: - extra_description: "Пользователи должны быть приглашены доверенными пользователями или сотрудниками." + extra_description: "Пользователи должны быть приглашены доверенными пользователями или сотрудниками" must_approve_users: extra_description: "Пользователи должны быть одобрены персоналом" chat_enabled: @@ -4477,7 +4477,7 @@ ru: enable_sidebar: placeholder: "Включить боковую панель" ready: - description: "Вот и все! Вы выполнили основные действия по настройке сообщества. Теперь вы можете зайти на форум и осмотреться, написать приветственную тему и отправить приглашения!

    Удачи!" + description: "Вот и всё! Вы выполнили основные действия по настройке сообщества. Теперь вы можете зайти и осмотреться, написать приветственную тему и отправить приглашения!

    Удачи!" styling: fields: color_scheme: @@ -4494,21 +4494,21 @@ ru: latest: label: "Последние темы" categories_only: - label: "Только разделы" + label: "Только категории" categories_with_featured_topics: - label: "Разделы с избранными темами" + label: "Категории с избранными темами" categories_and_latest_topics: - label: "Разделы и список последних тем форума" + label: "Категории и список последних тем форума" categories_and_latest_topics_created_date: - label: "Разделы и последние темы (сортировка по дате создания темы)" + label: "Категории и последние темы (сортировка по дате создания темы)" categories_and_top_topics: - label: "Разделы и популярные темы" + label: "Категории и популярные темы" categories_boxes: - label: "Блоки с подразделами" + label: "Блоки с категориями" categories_boxes_with_topics: - label: "Блоки с избранными темами" + label: "Блоки категорий с темами" subcategories_with_featured_topics: - label: "Подразделы с избранными темами" + label: "Подкатегории с избранными темами" corporate: fields: governing_law: @@ -4518,20 +4518,20 @@ ru: city_for_disputes: placeholder: "Глупов, Российская империя" site_contact: - description: "Все автоматические личные сообщения будут отправляться от имени этого пользователя, такие как предупреждения о поступивших жалобах или уведомления о завершении резервного копирования." + description: "Все автоматические личные сообщения Discourse будут отправляться от имени этого пользователя: например, предупреждения о поступивших жалобах и уведомления о завершении резервного копирования." contact_email: label: "Контактное лицо" placeholder: "example@user.com" invites: title: "Пригласить персонал" description: "Вы почти закончили! Давайте пригласим сюда людей, которые помогут вам начать обсуждение с интересных тем и ответов, чтобы ваше сообщество начало работу." - disabled: "Поскольку локальные аккаунты отключены, отправлять приглашения кому-либо невозможно. Перейдите к следующему шагу." + disabled: "Локальные аккаунты отключены, поэтому отправлять приглашения невозможно. Перейдите к следующему шагу." finished: - title: "Ваш форум готов к обсуждениям!" + title: "Ваш форум на платформе Discourse готов!" description: | -

    Если вы когда-нибудь захотите изменить эти настройки, повторно запустите этот мастер в любое время или посетите раздел администратора, который расположен справа от значка гаечного ключа в меню сайта.

    -

    С помощью нашей мощной системы тем настроить внешний вид форума очень легко. В качестве примеров просмотрите популярные темы и компоненты на сайте meta.discourse.org.

    -

    Хорошего дня и удачи в создании нового сообщества!

    +

    Если вы захотите изменить эти настройки, запустите этот мастер ещё раз или зайдите в раздел администратора, который расположен около значка гаечного ключа в меню сайта.

    +

    С помощью нашей мощной системы тем настроить внешний вид Discourse очень легко. В качестве примеров просмотрите популярные темы и компоненты на сайте meta.discourse.org.

    +

    Хорошего дня и удачи в создании нового сообщества!

    search_logs: graph_title: "Количество поисковых запросов" joined: "Регистрация" @@ -4541,9 +4541,9 @@ ru: group_mentioned: '%{username} упомянул(а) вас в теме «%{topic}» —%{site_title}' quoted: '%{username} процитировал(а) вас в теме «%{topic}» —%{site_title}' replied: '%{username} ответил(а) вам в теме «%{topic}» —%{site_title}' - posted: '%{username} опубликовал(а) сообщение в теме «%{topic}» —%{site_title}' - private_message: '%{username} отправил вам личное сообщение в теме «%{topic}» —%{site_title}' - linked: '%{username} добавил(а) ссылку на ваше сообщение в теме «%{topic}» —%{site_title}' + posted: '%{username} опубликовал(а) запись в теме «%{topic}» —%{site_title}' + private_message: '%{username} отправил(а) вам личное сообщение в теме «%{topic}» —%{site_title}' + linked: '%{username} добавил(а) ссылку на вашу запись в теме «%{topic}» —%{site_title}' watching_first_post: '%{username} создал(а) новую тему «%{topic}» —%{site_title}' confirm_title: "Уведомления включены —%{site_title}" confirm_body: "Готово! Уведомления включены." @@ -4551,20 +4551,20 @@ ru: staff_action_logs: not_found: "не найдено" unknown: "неизвестно" - user_merged: "Пользователь %{username} был объединён с этой учётной записью" + user_merged: "Пользователь %{username} был объединён с этим аккаунтом" user_delete_self: "Пользователь удалил свой аккаунт на сайте %{url}" - webhook_deactivation_reason: "Ваш вебхук был автоматически выключен. Мы получили несколько ответов «%{status}» о состоянии HTTP." + webhook_deactivation_reason: "Вебхук был автоматически выключен. Мы получили несколько ответов «%{status}» с ошибкой HTTP." api_key: automatic_revoked: one: "Отозван автоматически, последнее действие зафиксировано более %{count} дня назад" few: "Отозван автоматически, последнее действие зафиксировано более %{count} дней назад" many: "Отозван автоматически, последнее действие зафиксировано более %{count} дней назад" - other: "Отозван автоматически, последнее действие зафиксировано более %{count} дней назад" + other: "Отозван автоматически, последнее действие зафиксировано более %{count} дня назад" revoked: Отозван restored: Восстановлен reviewables: - already_handled: "Спасибо, но мы уже просмотрели этот пост и решили, что на него не нужно снова составлять жалобу." - already_handled_and_user_not_exist: "Спасибо, но кто-то уже просмотрел это сообщение, и указанный пользователь более не существует." + already_handled: "Спасибо, но мы уже просмотрели эту запись и решили, что на нее не нужно снова составлять жалобу." + already_handled_and_user_not_exist: "Спасибо, но кто-то уже проверил эту запись, и указанный пользователь более не существует." priorities: low: "Низкий" medium: "Средний" @@ -4574,38 +4574,38 @@ ru: low: "Низкая" medium: "Средняя" high: "Высокая" - must_claim: "Вы должны зарезервировать контент за собой, прежде чем заняться его модерацией." + must_claim: "Прежде чем заняться контентом, его нужно зарезервировать за собой." user_claimed: "Этот контент уже зарезервирован за другим сотрудником." - missing_version: "Вы должны указать версию" + missing_version: "Нужно указать параметр версии" conflict: "Зафиксирован конфликт обновления, мешающий вам сделать это." reasons: - post_count: "Первые несколько сообщений от каждого пользователя должны быть одобрены администрацией форума. См. %{link}." - trust_level: "Пользователи с низким уровнем доверия должны иметь ответы, одобренные администрацией форума. См. %{link}." - new_topics_unless_trust_level: "Пользователи с низким уровнем доверия должны иметь темы, одобренные администрацией форума. См. %{link}." - fast_typer: "Сообщение было напечатано подозрительно быстро, такая активность похожа на поведение бота или спамера. См. %{link}." - auto_silence_regex: "Новый пользователь, первое сообщение которого соответствует параметру %{link}." - watched_word: "Это сообщение содержит контролируемое слово. См. %{link}." - staged: "Новые темы и сообщения для сымитированных пользователей должны быть одобрены администрацией форума. См. %{link}." - category: "Сообщения в этой категории требуют предварительного одобрения администрацией форума. См. %{link}." + post_count: "Первые несколько записей от каждого пользователя должны быть одобрены персоналом. См. %{link}." + trust_level: "Пользователи с низким уровнем доверия должны иметь ответы, одобренные персоналом. См. %{link}." + new_topics_unless_trust_level: "Пользователи с низким уровнем доверия должны иметь темы, одобренные персоналом. См. %{link}." + fast_typer: "Запись была набрана подозрительно быстро — похоже на поведение бота или спамера. См. %{link}." + auto_silence_regex: "Новый пользователь, первая запись которого соответствует параметру %{link}." + watched_word: "Эта запись содержит контролируемое слово. См. %{link}." + staged: "Новые темы и записи для сымитированных пользователей должны быть одобрены персоналом. См. %{link}." + category: "Записи в этой категории требуют предварительного одобрения персоналом. См. %{link}." must_approve_users: "Все новые пользователи должны быть одобрены персоналом. См. %{link}." - invite_only: "Все новые пользователи должны быть приглашены. См. %{link}." - email_auth_res_enqueue: "Это письмо не прошло DMARC-проверку, скорее всего, оно не от того, кто указан в адресе отправителя. Проверьте заголовки в исходном тексте письма для получения дополнительной информации." + invite_only: "Все новые пользователи должны получить приглашение. См. %{link}." + email_auth_res_enqueue: "Это письмо не прошло DMARC-проверку. Скорее всего, оно не от того, кто указан в адресе отправителя. Дополнительную информацию смотрите в заголовках в исходном тексте письма." email_spam: "Это письмо было помечено как спам на основании заголовка, указанного в %{link}." - suspect_user: "Профиль нового пользователя не содержит информации о прочтённых темах или сообщениях. С большой долей вероятности можно предположить, что это спамер. См. %{link}." - contains_media: "Это сообщение содержит медиаконтент. См. %{link}." - queued_by_staff: "Сотрудник считает, что это сообщение нуждается в проверке. До тех пор оно будет скрыто." + suspect_user: "Профиль нового пользователя не содержит информации о прочтённых темах или записях. С большой долей вероятности можно предположить, что это спамер. См. %{link}." + contains_media: "Эта запись содержит медиаконтент. См. %{link}." + queued_by_staff: "Сотрудник считает, что эта запись нуждается в проверке. До завершения проверки запись будет скрыта." links: - watched_word: Перечень контролируемых слов - category: Настройки раздела + watched_word: перечень контролируемых слов + category: настройки категории actions: agree: title: "Согласиться…" agree_and_keep: - title: "Оставить сообщение" - description: "Согласиться с жалобой, но оставить сообщение без изменений." + title: "Оставить запись" + description: "Согласиться с жалобой, но оставить запись без изменений." agree_and_keep_hidden: - title: "Оставить сообщение скрытым" - description: "Согласиться с жалобой и оставить сообщение скрытым" + title: "Оставить запись скрытой" + description: "Согласиться с жалобой и оставить запись скытой" agree_and_suspend: title: "Заморозить пользователя" description: "Согласиться с жалобой и заморозить пользователя." @@ -4613,32 +4613,32 @@ ru: title: "Заблокировать пользователя" description: "Согласиться с жалобой и заблокировать пользователя." agree_and_restore: - title: "Восстановить сообщение" - description: "Восстановить сообщение, чтобы все пользователи могли его видеть." + title: "Восстановить запись" + description: "Восстановить запись, чтобы все пользователи могли ее видеть." agree_and_hide: - title: "Скрыть сообщение" - description: "Скрыть это сообщение и автоматически отправить пользователю уведомление с просьбой его отредактировать." + title: "Скрыть запись" + description: "Скрыть запись и автоматически отправить пользователю уведомление с просьбой ее отредактировать." delete_single: title: "Удалить" delete: title: "Удалить…" delete_and_ignore: - title: "Удалить сообщение и игнорировать" - description: "Удалить сообщение; если это первое сообщение, удалится вся тема" + title: "Удалить запись и игнорировать" + description: "Удалить запись; если это первая запись, удалится вся тема" delete_and_ignore_replies: - title: "Удалить сообщение + ответы и игнорировать" - description: "Удалить сообщение и все ответы на него; если это первое сообщение, удалится вся тема" + title: "Удалить запись + ответы и игнорировать" + description: "Удалить запись и все ответы на нее; если это первая запись, удалится вся тема" confirm: "Действительно удалить ответы на запись?" delete_and_agree: - title: "Удалить сообщение и согласиться" - description: "Удалить сообщение; если это первое сообщение, удалится вся тема" + title: "Удалить запись и согласиться" + description: "Удалить запись; если это первая запись, удалится вся тема" delete_and_agree_replies: - title: "Удалить сообщение + ответы и согласиться" - description: "Удалить сообщение и все ответы на него; если это первое сообщение, удалится вся тема" + title: "Удалить запись + ответы и согласиться" + description: "Удалить запись и все ответы на нее; если это первая запись, удалится вся тема" confirm: "Действительно удалить ответы на запись?" disagree_and_restore: - title: "Не согласен, восстановить сообщение" - description: "Восстановить сообщение, чтобы все пользователи могли его видеть." + title: "Отклонить жалобу и восстановить запись" + description: "Восстановить запись, чтобы все пользователи могли ее видеть." disagree: title: "Отклонить" ignore: @@ -4646,10 +4646,10 @@ ru: approve: title: "Одобрить" approve_post: - title: "Одобрить сообщение" - confirm_closed: "Эта тема закрыта. Вы хотите создать сообщение в любом случае?" + title: "Одобрить запись" + confirm_closed: "Эта тема закрыта. Всё равно создать запись?" reject_post: - title: "Отклонить сообщение" + title: "Отклонить запись" approve_user: title: "Утвердить пользователя" reject_user: @@ -4668,13 +4668,13 @@ ru: reject_and_silence: title: "Отклонить и заблокировать пользователя" reject_and_delete: - title: "Отклонить и удалить сообщение" + title: "Отклонить и удалить запись" reject_and_keep_deleted: - title: "Оставить сообщение удалённым" + title: "Оставить запись удалённой" approve_and_restore: - title: "Одобрить и восстановить сообщение" + title: "Одобрить и восстановить запись" delete_user: - reason: "Удалено после модерации" + reason: "Удалено после проверки" email_style: html_missing_placeholder: "HTML-шаблон должен содержать %{placeholder}" notification_level: @@ -4702,10 +4702,10 @@ ru: other: "%{topic_title} (часть %{count})" post_raw: "Продолжение обсуждения, начатого в теме %{parent_url}.\n\nПредыдущие обсуждения:\n\n%{previous_topics}" small_action_post_raw: "Продолжить обсуждение в теме %{new_title}." - fallback_username: "Пользователь" + fallback_username: "пользователь" user_status: errors: - ends_at_should_be_greater_than_set_at: "Значение переменной ends_at должно быть больше, чем set_at" + ends_at_should_be_greater_than_set_at: "Значение переменной «ends_at» должно быть больше, чем «set_at»" activemodel: errors: <<: *errors diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index bea6eadc90..2fe4251d44 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -2137,6 +2137,7 @@ sv: navigation_menu: "Bestäm vilken navigeringsmeny som ska användas. Sidofälts- och rubriknavigering kan anpassas av användare. Det äldre alternativet är tillgängligt för bakåtkompatibilitet." default_sidebar_categories: "Valda kategorier kommer att visas under sidofältets sektion Kategorier som standard." default_sidebar_tags: "Valda taggar kommer att visas under sidofältets sektion Taggar som standard." + enable_new_notifications_menu: "Aktiverar den nya aviseringsmenyn för den gamla navigeringsmenyn." enable_new_user_profile_nav_groups: "EXPERIMENTELL: Användare i de valda grupperna visas den nya navigeringsmenyn för användarprofiler." enable_experimental_topic_timeline_groups: "EXPERIMENTELLT: Användare i de valda grupperna visas det omstrukturerade ämnets tidslinje" enable_experimental_hashtag_autocomplete: "EXPERIMENTELLT: Använd det nya #hashtag-autokompletteringssystemet för kategorier och taggar som renderar det valda objektet annorlunda och har förbättrad sökning" @@ -2197,6 +2198,7 @@ sv: discourse_connect_cannot_be_enabled_if_second_factor_enforced: "Du kan inte aktivera DiscourseConnect om 2FA är obligatoriskt." delete_rejected_email_after_days: "Den här inställningen kan inte vara lägre än inställningen delete_email_logs_after_days eller större än %{max}" invalid_uncategorized_category_setting: "Kategorin Okategoriserade kan inte väljas om tillåt okategoriserade ämnen inte är tillåtet" + enable_new_notifications_menu_not_legacy_navigation_menu: "Du måste ställa in `navigation_menu` till `legacy` innan du aktiverar den här inställningen." placeholder: discourse_connect_provider_secrets: key: "www.exempel.se" diff --git a/plugins/chat/config/locales/client.ar.yml b/plugins/chat/config/locales/client.ar.yml index aa061c3d69..7afecb0518 100644 --- a/plugins/chat/config/locales/client.ar.yml +++ b/plugins/chat/config/locales/client.ar.yml @@ -191,7 +191,6 @@ ar: read_only: "للقراءة فقط" archived_header: "القناة مؤرشفة" archived: "مؤرشفة" - archive_failed: "فشلت أرشفة القناة. تمت أرشفة %{completed}/%{total} من الرسائل في الموضوع المستهدف. اضغط على إعادة المحاولة لمحاولة إكمال الأرشيف." archive_completed: "راجع موضوع الأرشيف" closed_header: "القناة مغلقة" closed: "مغلقة" diff --git a/plugins/chat/config/locales/client.de.yml b/plugins/chat/config/locales/client.de.yml index 2284011584..bf1ba0b5d9 100644 --- a/plugins/chat/config/locales/client.de.yml +++ b/plugins/chat/config/locales/client.de.yml @@ -175,7 +175,6 @@ de: read_only: "Schreibgeschützt" archived_header: "Kanal ist archiviert" archived: "Archiviert" - archive_failed: "Archivieren des Kanals fehlgeschlagen. %{completed}/%{total} Nachrichten wurden im Zielthema archiviert. Drücke auf „Erneut versuchen“, um zu versuchen, die Archivierung abzuschließen." archive_completed: "Archivthema anzeigen" closed_header: "Kanal ist geschlossen" closed: "Geschlossen" diff --git a/plugins/chat/config/locales/client.es.yml b/plugins/chat/config/locales/client.es.yml index 72205d9223..aa38614bcf 100644 --- a/plugins/chat/config/locales/client.es.yml +++ b/plugins/chat/config/locales/client.es.yml @@ -175,7 +175,6 @@ es: read_only: "Solo lectura" archived_header: "El canal está archivado" archived: "Archivado" - archive_failed: "La archivación del canal ha fallado. Se han archivado %{completed}/%{total} mensajes en el tema de destino. Pulsa reintentar para intentar completar el archivo." archive_completed: "Ver el tema del archivo" closed_header: "El canal está cerrado" closed: "Cerrado" diff --git a/plugins/chat/config/locales/client.fi.yml b/plugins/chat/config/locales/client.fi.yml index 7a03a90e74..89947e861e 100644 --- a/plugins/chat/config/locales/client.fi.yml +++ b/plugins/chat/config/locales/client.fi.yml @@ -175,7 +175,6 @@ fi: read_only: "Vain luku" archived_header: "Kanava on arkistoitu" archived: "Arkistoitu" - archive_failed: "Kanavan arkistointi epäonnistui. %{completed}/%{total} viestiä on arkistoitu kohdeketjuun. Yritä arkistoinnin viimeistelyä uudelleen painamalla Yritä uudelleen -painiketta." archive_completed: "Katso arkistoketju" closed_header: "Kanava on suljettu" closed: "Suljettu" diff --git a/plugins/chat/config/locales/client.fr.yml b/plugins/chat/config/locales/client.fr.yml index aeea6c8817..1c0a7544c3 100644 --- a/plugins/chat/config/locales/client.fr.yml +++ b/plugins/chat/config/locales/client.fr.yml @@ -175,7 +175,6 @@ fr: read_only: "Lecture seule" archived_header: "Le canal est archivé" archived: "Archivé" - archive_failed: "Échec de l'archivage du canal. %{completed}/%{total} messages ont été archivés dans le sujet de destination. Appuyez sur Réessayer pour tenter de terminer l'archivage." archive_completed: "Voir le sujet de l'archive" closed_header: "Le canal est fermé" closed: "Fermé" diff --git a/plugins/chat/config/locales/client.he.yml b/plugins/chat/config/locales/client.he.yml index 5938459677..d05398baaa 100644 --- a/plugins/chat/config/locales/client.he.yml +++ b/plugins/chat/config/locales/client.he.yml @@ -215,7 +215,6 @@ he: read_only: "לקריאה בלבד" archived_header: "הערוץ בארכיון" archived: "בארכיון" - archive_failed: "העברת הערוץ לארכיון נכשלה. %{completed}/%{total} הודעות הועברו לארכיון תחת נושא היעד. נא לנסות להשלים את ההעברה לארכיון פעם נוספת." archive_completed: "אפשר לעיין בארכיון הנושא" closed_header: "הערוץ סגור" closed: "סגורה" diff --git a/plugins/chat/config/locales/client.hr.yml b/plugins/chat/config/locales/client.hr.yml index 7ae25a592b..1f9fcf4e79 100644 --- a/plugins/chat/config/locales/client.hr.yml +++ b/plugins/chat/config/locales/client.hr.yml @@ -111,6 +111,8 @@ hr: title_capitalized: "Čet" exit: "natrag" channel_status: + archive_failed: "Arhiviranje kanala nije uspjelo. %{completed}/%{total} poruke su arhivirane. tema odredišta. Pritisnite ponovno za pokušaj dovršetka arhiviranja." + archive_failed_no_topic: "Arhiviranje kanala nije uspjelo. %{completed}/%{total} poruka je arhivirano, odredišna tema nije kreirana. Pritisnite ponovno za pokušaj dovršetka arhiviranja." closed: "Zatvoreno" open: "Otvori" browse: diff --git a/plugins/chat/config/locales/client.hu.yml b/plugins/chat/config/locales/client.hu.yml index bde8e8a596..ecf04f43f0 100644 --- a/plugins/chat/config/locales/client.hu.yml +++ b/plugins/chat/config/locales/client.hu.yml @@ -168,7 +168,6 @@ hu: read_only: "Csak olvasható" archived_header: "A csatorna archiválva van" archived: "Archivált" - archive_failed: "A csatorna archiválása nem sikerült.%{completed}/%{total} üzenet archiválva lett a céltémában . Nyomja meg az Újra gombot az archiválás befejezéséhez." archive_completed: "Lásd az archív témát" closed_header: "A csatorna zárolt" closed: "Zárt" diff --git a/plugins/chat/config/locales/client.it.yml b/plugins/chat/config/locales/client.it.yml index b23f8b1474..dd965b2c14 100644 --- a/plugins/chat/config/locales/client.it.yml +++ b/plugins/chat/config/locales/client.it.yml @@ -175,7 +175,6 @@ it: read_only: "Sola lettura" archived_header: "Il canale è archiviato" archived: "Archiviati" - archive_failed: "Archiviazione del canale non riuscita. %{completed}/%{total} messaggi sono stati archiviati nell'argomento di destinazione. Premi Riprova per tentare di completare l'archiviazione." archive_completed: "Vedi l'argomento di archiviazione" closed_header: "Il canale è chiuso" closed: "Chiusi" diff --git a/plugins/chat/config/locales/client.ja.yml b/plugins/chat/config/locales/client.ja.yml index c7cc4e40ca..db11630b89 100644 --- a/plugins/chat/config/locales/client.ja.yml +++ b/plugins/chat/config/locales/client.ja.yml @@ -173,7 +173,6 @@ ja: read_only: "読み取り専用" archived_header: "チャンネルはアーカイブされています" archived: "アーカイブ済み" - archive_failed: "チャンネルのアーカイブに失敗しました。%{completed} / 全 %{total} 件のメッセージがアーカイブ先のトピックにアーカイブされました。再試行を押して、アーカイブの完了を試してください。" archive_completed: "アーカイブ済みのトピックを見る" closed_header: "チャンネルは閉鎖されています" closed: "閉鎖" diff --git a/plugins/chat/config/locales/client.nl.yml b/plugins/chat/config/locales/client.nl.yml index 3ee38c4f15..81841b1b48 100644 --- a/plugins/chat/config/locales/client.nl.yml +++ b/plugins/chat/config/locales/client.nl.yml @@ -175,7 +175,6 @@ nl: read_only: "Alleen-lezen" archived_header: "Kanaal is gearchiveerd" archived: "Gearchiveerd" - archive_failed: "Archiveren van kanaal mislukt. %{completed}/%{total} berichten zijn gearchiveerd in het bestemmingstopic. Druk op Opnieuw proberen om te proberen de archivering te voltooien." archive_completed: "Zie het archieftopic" closed_header: "Kanaal is gesloten" closed: "Gesloten" diff --git a/plugins/chat/config/locales/client.pl_PL.yml b/plugins/chat/config/locales/client.pl_PL.yml index 84fb41b375..9244d2be96 100644 --- a/plugins/chat/config/locales/client.pl_PL.yml +++ b/plugins/chat/config/locales/client.pl_PL.yml @@ -181,7 +181,6 @@ pl_PL: read_only: "Tylko do odczytu" archived_header: "Kanał został zarchiwizowany" archived: "Zarchiwizowany" - archive_failed: "Archiwizacja kanału nie powiodła się. %{completed}/%{total} wiadomości zostało zarchiwizowanych w temacie docelowym. Naciśnij przycisk ponów, aby spróbować zakończyć archiwizację." closed_header: "Kanał jest zamknięty" closed: "Zamknięta" open_header: "Kanał jest otwarty" diff --git a/plugins/chat/config/locales/client.pt_BR.yml b/plugins/chat/config/locales/client.pt_BR.yml index 04b1c05651..ec87341773 100644 --- a/plugins/chat/config/locales/client.pt_BR.yml +++ b/plugins/chat/config/locales/client.pt_BR.yml @@ -175,7 +175,6 @@ pt_BR: read_only: "Somente leitura" archived_header: "O canal está arquivado" archived: "Arquivados" - archive_failed: "Falha no canal de arquivamento. %{completed}/%{total} mensagens foram arquivadas no tópico de destino. Pressione repetir para tentar concluir o arquivo." archive_completed: "Veja o tópico de arquivo" closed_header: "Canal fechado" closed: "Fechados" diff --git a/plugins/chat/config/locales/client.ru.yml b/plugins/chat/config/locales/client.ru.yml index 9270a8a218..da4a54f503 100644 --- a/plugins/chat/config/locales/client.ru.yml +++ b/plugins/chat/config/locales/client.ru.yml @@ -179,7 +179,6 @@ ru: read_only: "Только для чтения" archived_header: "Канал заархивирован" archived: "Архивные" - archive_failed: "Во время архивации поизошла ошибка. %{completed}/%{total} сообщений были заархивированы в целевой теме. Нажмите «Повторить», чтобы попытаться завершить архивирование." archive_completed: "См. архивную тему." closed_header: "Канал закрыт" closed: "Закрытые" diff --git a/plugins/chat/config/locales/client.sv.yml b/plugins/chat/config/locales/client.sv.yml index 5a93df4ef8..87b58ff233 100644 --- a/plugins/chat/config/locales/client.sv.yml +++ b/plugins/chat/config/locales/client.sv.yml @@ -206,7 +206,8 @@ sv: read_only: "Endast läsning" archived_header: "Kanalen är arkiverad" archived: "Arkiverad" - archive_failed: "Arkivering av kanalen misslyckades. %{completed}/%{total} meddelanden har arkiverats i destinationsämnet. Tryck på försök igen för att försöka slutföra arkivet." + archive_failed: "Arkivkanalen misslyckades. %{completed}/%{total} meddelanden har arkiverats. målämnet. Tryck på försök igen för att försöka slutföra arkiveringen." + archive_failed_no_topic: "Arkivkanalen misslyckades. %{completed}/%{total} meddelanden har arkiverats. Målämnet skapades inte. Tryck på försök igen för att försöka slutföra arkiveringen." archive_completed: "Se det arkiverade ämnet" closed_header: "Kanalen är stängd" closed: "Stängd" diff --git a/plugins/chat/config/locales/client.tr_TR.yml b/plugins/chat/config/locales/client.tr_TR.yml index 945dc66c86..12b2e8bfb9 100644 --- a/plugins/chat/config/locales/client.tr_TR.yml +++ b/plugins/chat/config/locales/client.tr_TR.yml @@ -206,7 +206,6 @@ tr_TR: read_only: "Salt Okunur" archived_header: "Kanal arşivlendi" archived: "Arşivlendi" - archive_failed: "Kanal arşivlemesi başarısız oldu. %{completed}/%{total} mesaj hedef konuya arşivlendi. Arşivi tamamlamayı denemek için tekrar deneye basın." archive_completed: "Arşiv konusuna bakın" closed_header: "Kanal kapalı" closed: "Kapalı" diff --git a/plugins/chat/config/locales/client.zh_CN.yml b/plugins/chat/config/locales/client.zh_CN.yml index f3c76d2cd5..0e41bcae67 100644 --- a/plugins/chat/config/locales/client.zh_CN.yml +++ b/plugins/chat/config/locales/client.zh_CN.yml @@ -173,7 +173,6 @@ zh_CN: read_only: "只读" archived_header: "频道已被归档" archived: "已归档" - archive_failed: "频道归档失败。%{completed} 条(共 %{total} 条)消息已被归档到目标话题。按“重试”,尝试完成存档。" archive_completed: "请参阅归档话题" closed_header: "频道已被关闭" closed: "已关闭" diff --git a/plugins/chat/config/locales/server.ar.yml b/plugins/chat/config/locales/server.ar.yml index e18a7d3120..4b2d0dc98b 100644 --- a/plugins/chat/config/locales/server.ar.yml +++ b/plugins/chat/config/locales/server.ar.yml @@ -32,13 +32,12 @@ ar: chat_channel_archive_complete: title: "اكتمل أرشيف قناة الدردشة" subject_template: "اكتمل أرشيف قناة الدردشة بنجاح" - text_body_template: | - اكتملت أرشفة قناة الدردشة **\#%{channel_name}** بنجاح. وتم نسخ الرسائل إلى الموضوع [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "فشلت أرشفة قناة الدردشة" subject_template: "فشلت أرشفة قناة الدردشة" - text_body_template: | - فشلت أرشفة قناة الدردشة **\#%{channel_name}**. تم وضع الرسائل %{messages_archived} في الأرشيف. تم نسخ الرسائل المؤرشفة جزئيًا في الموضوع [%{topic_title}](%{topic_url}). انتقل إلى القناة في %{channel_url} لإعادة المحاولة. + chat_channel_archive_failed_no_topic: + title: "فشلت أرشفة قناة الدردشة" + subject_template: "فشلت أرشفة قناة الدردشة" chat: deleted_chat_username: تم الحذف errors: diff --git a/plugins/chat/config/locales/server.de.yml b/plugins/chat/config/locales/server.de.yml index 27cc0bccc6..c462f66d43 100644 --- a/plugins/chat/config/locales/server.de.yml +++ b/plugins/chat/config/locales/server.de.yml @@ -32,13 +32,12 @@ de: chat_channel_archive_complete: title: "Chat-Kanal-Archivierung abgeschlossen" subject_template: "Chat-Kanal-Archivierung erfolgreich abgeschlossen" - text_body_template: | - Die Archivierung des Chat-Kanals **\#%{channel_name}** wurde erfolgreich abgeschlossen. Die Nachrichten wurden in das Thema [%{topic_title}](%{topic_url}) kopiert. chat_channel_archive_failed: title: "Chat-Kanal-Archivierung fehlgeschlagen" subject_template: "Chat-Kanal-Archivierung fehlgeschlagen" - text_body_template: | - Die Archivierung des Chat-Kanals **\#%{channel_name}** ist fehlgeschlagen. %{messages_archived} Nachrichten wurden archiviert. Teilweise archivierte Nachrichten wurden in das Thema [%{topic_title}](%{topic_url}) kopiert. Besuche den Kanal unter %{channel_url}, um es erneut zu versuchen. + chat_channel_archive_failed_no_topic: + title: "Chat-Kanal-Archivierung fehlgeschlagen" + subject_template: "Chat-Kanal-Archivierung fehlgeschlagen" chat: deleted_chat_username: gelöscht errors: diff --git a/plugins/chat/config/locales/server.es.yml b/plugins/chat/config/locales/server.es.yml index 86164dbcc6..387a8bb2aa 100644 --- a/plugins/chat/config/locales/server.es.yml +++ b/plugins/chat/config/locales/server.es.yml @@ -32,13 +32,12 @@ es: chat_channel_archive_complete: title: "Archivado del canal de chat completado" subject_template: "El archivado del canal de chat se ha completado con éxito" - text_body_template: | - El archivado del canal de chat **\#%{channel_name}** se completó con éxito. Los mensajes se copiaron en el tema [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "No se pudo archivar el canal" subject_template: "No se pudo archivar el canal" - text_body_template: | - El archivo del canal de chat **\#%{channel_name}** ha fallado. Se han archivado los mensajes de %{messages_archived}. Los mensajes parcialmente archivados se han copiado en el tema [%{topic_title}](%{topic_url}). Visita el canal en %{channel_url} para volver a intentarlo. + chat_channel_archive_failed_no_topic: + title: "No se pudo archivar el canal" + subject_template: "No se pudo archivar el canal" chat: deleted_chat_username: eliminado errors: diff --git a/plugins/chat/config/locales/server.fa_IR.yml b/plugins/chat/config/locales/server.fa_IR.yml index 3d3731e2e7..d8d0cf7981 100644 --- a/plugins/chat/config/locales/server.fa_IR.yml +++ b/plugins/chat/config/locales/server.fa_IR.yml @@ -12,6 +12,10 @@ fa_IR: chat_message_flag_allowed_groups: "کاربران در این گروه‌ها مجاز به گزارش دادن، پیام‌های گفتگو هستند." errors: chat_upload_not_allowed_secure_uploads: "وقتی که در تنظیمات سایت آپلودهای ایمن فعال باشد، آپلود گفتگو مجاز نیست." + system_messages: + chat_channel_archive_complete: + text_body_template: | + بایگانی کانال گفتگو %{channel_hashtag_or_name} با موفقیت انجام شد. پیام‌ها در موضوع [%{topic_title}](%{topic_url}) کپی شده‌اند. chat: deleted_chat_username: حذف شد errors: diff --git a/plugins/chat/config/locales/server.fi.yml b/plugins/chat/config/locales/server.fi.yml index 4d00a82532..fe50d51fdd 100644 --- a/plugins/chat/config/locales/server.fi.yml +++ b/plugins/chat/config/locales/server.fi.yml @@ -32,13 +32,12 @@ fi: chat_channel_archive_complete: title: "Chat-kanavan arkistointi on valmis" subject_template: "Chat-kanavan arkistointi on valmis" - text_body_template: | - Chat-kanavan **\#%{channel_name}** arkistointi on valmis. Viestit kopioitiin ketjuun [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Chat-kanavan arkistointi epäonnistui" subject_template: "Chat-kanavan arkistointi epäonnistui" - text_body_template: | - Chat-kanavan **\#%{channel_name}** arkistointi epäonnistui. %{messages_archived} viestiä on arkistoitu. Osittain arkistoidut viestit kopioitiin ketjuun [%{topic_title}](%{topic_url}). Yritä uudelleen vierailemalla kanavalla osoitteessa %{channel_url}. + chat_channel_archive_failed_no_topic: + title: "Chat-kanavan arkistointi epäonnistui" + subject_template: "Chat-kanavan arkistointi epäonnistui" chat: deleted_chat_username: poistettu errors: diff --git a/plugins/chat/config/locales/server.fr.yml b/plugins/chat/config/locales/server.fr.yml index f97b61f5ea..e06ec1aaa1 100644 --- a/plugins/chat/config/locales/server.fr.yml +++ b/plugins/chat/config/locales/server.fr.yml @@ -32,13 +32,12 @@ fr: chat_channel_archive_complete: title: "Archivage du canal de discussion terminé" subject_template: "L'archivage du canal de discussion est terminé" - text_body_template: | - L'archivage du canal de discussion **\#%{channel_name}** a bien été effectué. Les messages ont été copiés dans le sujet [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Échec de l'archivage du canal de discussion" subject_template: "Échec de l'archivage du canal de discussion" - text_body_template: | - L'archivage du canal de discussion **\#%{channel_name}** a échoué. Les messages de %{messages_archived} ont été archivés. Les messages partiellement archivés ont été copiés dans le sujet [%{topic_title}](%{topic_url}). Visitez le canal à l'adresse %{channel_url} pour réessayer. + chat_channel_archive_failed_no_topic: + title: "Échec de l'archivage du canal de discussion" + subject_template: "Échec de l'archivage du canal de discussion" chat: deleted_chat_username: supprimé errors: diff --git a/plugins/chat/config/locales/server.he.yml b/plugins/chat/config/locales/server.he.yml index 2857b27aba..e80192c527 100644 --- a/plugins/chat/config/locales/server.he.yml +++ b/plugins/chat/config/locales/server.he.yml @@ -23,6 +23,7 @@ he: default_emoji_reactions: "רגשות אמוג׳י כברירת מחדל להודעות צ׳אט. ניתן להוסיף עד 5 אמוג׳ים לתגובה מהירה." direct_message_enabled_groups: "לאפשר למשתמשים בקבוצות אלה ליצור צ׳אטים אישיים בין המשתמשים לבין עצמם. הערה: הסגל תמיד יכול ליצור צ׳אטים אישיים, ומשתמשים יוכלו להשיב לצ׳אטים אישיים שיזמו משתמשים שיש להם הרשאה ליצור אותם." chat_message_flag_allowed_groups: "משתמשים בקבוצות אלו רשאים לסמן הודעות צ׳אט בדגל." + max_mentions_per_chat_message: "מספר התראות מרבי של @שם בהן יכול להשתמש משתמש בהודעת צ׳אט." chat_max_direct_message_users: "משתמשים לא יכולים להוסיף יותר מכמות זו של משתמשים אחרים בעת יצירת הודעה ישירה חדשה. יש להגדיר ל־0 כדי לאפשר הודעות עצמיות. הסגל מוחרג מההגדרה הזאת." chat_allow_archiving_channels: "לאפשר לסגל להעביר הודעות לארכיון בנושא בעת סגירת ערוץ." errors: @@ -33,13 +34,12 @@ he: chat_channel_archive_complete: title: "העברת ערוץ הצ׳אט לארכיון הושלמה" subject_template: "העברת ערוץ הצ׳אט לארכיון הושלמה בהצלחה" - text_body_template: | - העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון הושלמה בהצלחה. ההודעות הועתקו לנושא [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "העברת הצ׳אט לארכיון נכשלה" subject_template: "העברת הצ׳אט לארכיון נכשלה" - text_body_template: | - העברת ערוץ הצ׳אט **‎\#%{channel_name}** לארכיון נכשלה. %{messages_archived} הודעות הועברו לארכיון. הודעות שהועברו לארכיון באופן חלקי הועתקו לנושא [%{topic_title}](%{topic_url}). יש לבקר בכתובת הערוץ %{channel_url} כדי לנסות שוב. + chat_channel_archive_failed_no_topic: + title: "העברת הצ׳אט לארכיון נכשלה" + subject_template: "העברת הצ׳אט לארכיון נכשלה" chat: deleted_chat_username: נמחק errors: diff --git a/plugins/chat/config/locales/server.hr.yml b/plugins/chat/config/locales/server.hr.yml index 0b008b6962..4c1336053b 100644 --- a/plugins/chat/config/locales/server.hr.yml +++ b/plugins/chat/config/locales/server.hr.yml @@ -5,6 +5,10 @@ # https://translate.discourse.org/ hr: + system_messages: + chat_channel_archive_failed_no_topic: + title: "Arhiva Chat kanala nije uspjela" + subject_template: "Arhiva kanala za chat nije uspjela" chat: deleted_chat_username: izbrisao errors: diff --git a/plugins/chat/config/locales/server.hu.yml b/plugins/chat/config/locales/server.hu.yml index 2b4c59fd9a..946223ee3f 100644 --- a/plugins/chat/config/locales/server.hu.yml +++ b/plugins/chat/config/locales/server.hu.yml @@ -23,13 +23,12 @@ hu: chat_channel_archive_complete: title: "A csevegőcsatorna archiválása kész" subject_template: "A csevegőcsatorna archiválása sikeresen befejeződött" - text_body_template: | - A(z) **\#%{channel_name}** csevegőcsatorna archiválása sikeresen befejeződött. Az üzenetek átmásolásra kerültek a(z) [ <%{topic_title}](%{topic_url}) témába. chat_channel_archive_failed: title: "A csevegőcsatorna archiválása sikertelen" subject_template: "A csevegőcsatorna archiválása sikertelen" - text_body_template: | - A(z) **\#%{channel_name}** csevegőcsatorna archiválása nem sikerült. %{messages_archived} üzenet archiválásra került. A részben archivált üzeneteket a(z) [%{topic_title}](%{topic_url}) témába lettek másolva. Keresse fel a(z) %{channel_url} csatornát az újbóli próbálkozáshoz. + chat_channel_archive_failed_no_topic: + title: "A csevegőcsatorna archiválása sikertelen" + subject_template: "A csevegőcsatorna archiválása sikertelen" chat: deleted_chat_username: törölt errors: diff --git a/plugins/chat/config/locales/server.it.yml b/plugins/chat/config/locales/server.it.yml index d4d7e27912..be35144a79 100644 --- a/plugins/chat/config/locales/server.it.yml +++ b/plugins/chat/config/locales/server.it.yml @@ -32,13 +32,12 @@ it: chat_channel_archive_complete: title: "Archiviazione canale chat completata" subject_template: "Archiviazione del canale di chat completata correttamente" - text_body_template: | - L'archiviazione del canale di chat **\#%{channel_name}** è stata completata con successo. I messaggi sono stati copiati nell'argomento [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Archiviazione canale chat non riuscita" subject_template: "Archiviazione canale chat non riuscita" - text_body_template: | - L'archiviazione del canale di chat **\#%{channel_name}** non è riuscita. %{messages_archived} messaggi sono stati archiviati. I messaggi parzialmente archiviati sono stati copiati nell'argomento [%{topic_title}](%{topic_url}). Visita il canale in %{channel_url} per riprovare. + chat_channel_archive_failed_no_topic: + title: "Archiviazione canale chat non riuscita" + subject_template: "Archiviazione canale chat non riuscita" chat: deleted_chat_username: eliminato errors: diff --git a/plugins/chat/config/locales/server.ja.yml b/plugins/chat/config/locales/server.ja.yml index 1947d4b2a8..74cccff6b5 100644 --- a/plugins/chat/config/locales/server.ja.yml +++ b/plugins/chat/config/locales/server.ja.yml @@ -33,13 +33,12 @@ ja: chat_channel_archive_complete: title: "チャットチャンネルのアーカイブ完了" subject_template: "チャットチャンネルのアーカイブが正常に完了しました" - text_body_template: | - チャットチャンネル **\#%{channel_name}** のアーカイブが正常に完了しました。メッセージはトピック [%{topic_title}](%{topic_url}) にコピーされました。 chat_channel_archive_failed: title: "チャットチャンネルのアーカイブ失敗" subject_template: "チャットチャンネルのアーカイブに失敗しました" - text_body_template: | - チャットチャンネル **\#%{channel_name}** のアーカイブに失敗しました。%{messages_archived} 件のメッセージがアーカイブされました。部分的にアーカイブされたメッセージは、トピック [%{topic_title}](%{topic_url}) にコピーされました。%{channel_url} よりチャンネルにアクセスして、再試行してください。 + chat_channel_archive_failed_no_topic: + title: "チャットチャンネルのアーカイブ失敗" + subject_template: "チャットチャンネルのアーカイブに失敗しました" chat: deleted_chat_username: 削除済み errors: diff --git a/plugins/chat/config/locales/server.nl.yml b/plugins/chat/config/locales/server.nl.yml index b57936113e..391e00314a 100644 --- a/plugins/chat/config/locales/server.nl.yml +++ b/plugins/chat/config/locales/server.nl.yml @@ -33,13 +33,12 @@ nl: chat_channel_archive_complete: title: "Archiveren van chatkanaal voltooid" subject_template: "Archiveren van chatkanaal voltooid" - text_body_template: | - Het archiveren van het chatkanaal **\#%{channel_name}** is voltooid. De berichten zijn gekopieerd naar het topic [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Archiveren van chatkanaal mislukt" subject_template: "Archiveren van chatkanaal mislukt" - text_body_template: | - Het archiveren van het chatkanaal **\#%{channel_name}** is mislukt. %{messages_archived} berichten zijn gearchiveerd. Gedeeltelijk gearchiveerde berichten zijn gekopieerd naar het topic [%{topic_title}](%{topic_url}). Ga naar het kanaal op %{channel_url} om het opnieuw te proberen. + chat_channel_archive_failed_no_topic: + title: "Archiveren van kanaal mislukt" + subject_template: "Chatkanaal archiveren mislukt" chat: deleted_chat_username: verwijderd errors: diff --git a/plugins/chat/config/locales/server.pt_BR.yml b/plugins/chat/config/locales/server.pt_BR.yml index 68de3506f5..db2d79b1e2 100644 --- a/plugins/chat/config/locales/server.pt_BR.yml +++ b/plugins/chat/config/locales/server.pt_BR.yml @@ -33,13 +33,12 @@ pt_BR: chat_channel_archive_complete: title: "Arquivamento do Canal de Chat Concluído" subject_template: "Arquivo do canal de chat concluído com sucesso" - text_body_template: | - O arquivamento do canal de chat **\#%{channel_name}** foi concluído com êxito. As mensagens foram copiadas para o tópico [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Falha no Arquivamento do Canal de Chat" subject_template: "Falha no arquivamento do canal de chat" - text_body_template: | - Falha ao realizar o arquivamento do canal de chat **\#%{channel_name}**. %{messages_archived} mensagens foram arquivadas. As mensagens parcialmente arquivadas foram copiadas para o tópico [%{topic_title}](%{topic_url}). Visite o canal em %{channel_url} para tentar novamente. + chat_channel_archive_failed_no_topic: + title: "Falha no Arquivamento do Canal de Chat" + subject_template: "Falha no arquivamento do canal de chat" chat: deleted_chat_username: excluído errors: diff --git a/plugins/chat/config/locales/server.ru.yml b/plugins/chat/config/locales/server.ru.yml index 61b76202e5..ad56d2df82 100644 --- a/plugins/chat/config/locales/server.ru.yml +++ b/plugins/chat/config/locales/server.ru.yml @@ -30,13 +30,12 @@ ru: chat_channel_archive_complete: title: "Архивация канала завершена" subject_template: "Архивация канала успешно завершена" - text_body_template: | - Архивация канала **\#%{channel_name}** успешно завершена. Сообщения были скопированы в тему [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Не удалось заархивировать канал" subject_template: "Не удалось заархивировать канал" - text_body_template: | - Не удалось заархивировать канал **\#%{channel_name}**. Сообщения %{messages_archived} были заархивированы. Частично заархивированные сообщения были скопированы в тему [%{topic_title}](%{topic_url}). Посетите канал %{channel_url} и повторите попытку. + chat_channel_archive_failed_no_topic: + title: "Не удалось заархивировать канал" + subject_template: "Не удалось заархивировать канал" chat: deleted_chat_username: удалён errors: @@ -114,7 +113,7 @@ ru: multi_user_truncated: "%{users} и ещё %{leftover}" category_channel: errors: - slug_contains_non_ascii_chars: "Содержит символы не в ascii-кодировке" + slug_contains_non_ascii_chars: "содержит символы не в ascii-кодировке" bookmarkable: notification_title: "Сообщение в канале %{channel_name}" personal_chat: "личный чат" @@ -126,7 +125,7 @@ ru: one: "%{count} участник" few: "%{count} участника" many: "%{count} участников" - other: "%{count} участников" + other: "%{count} участника" and_x_others: one: "и ещё %{count}" few: "и ещё %{count}" @@ -169,7 +168,7 @@ ru: from: "%{site_name}" subject: direct_message_from_1: "[%{email_prefix}] Новое сообщение от %{username}" - chat_channel_1: "[%{email_prefix}] Новое сообщение в %{channel}" + chat_channel_1: "[%{email_prefix}] Новое сообщение в канале «%{channel}»" unsubscribe: "Этот дайджест чата рассылается с сайта %{site_link} в период вашего отсутствия на форуме. Для отмены подписки измените %{email_preferences_link} или %{unsubscribe_link}." unsubscribe_no_link: "Этот дайджест чата рассылается с сайта %{site_link} в период вашего отсутствия на форуме. Настройка рассылки: %{email_preferences_link}." view_messages: diff --git a/plugins/chat/config/locales/server.sv.yml b/plugins/chat/config/locales/server.sv.yml index f26863f495..b979616ca0 100644 --- a/plugins/chat/config/locales/server.sv.yml +++ b/plugins/chat/config/locales/server.sv.yml @@ -35,12 +35,21 @@ sv: title: "Arkivering av chattkanalen är färdigt" subject_template: "Arkivering av chattkanalen slutfördes framgångsrikt" text_body_template: | - Arkivering av chattkanalen **\#%{channel_name}** har slutförts. Meddelandena har kopierats till ämnet [%{topic_title}](%{topic_url}). + Arkiveringen av chattkanalen %{channel_hashtag_or_name} har slutförts. Meddelanden kopierades till ämnet [%{topic_title}](%{topic_url}). chat_channel_archive_failed: title: "Arkivering av chattkanalen misslyckades" subject_template: "Arkivering av chattkanalen misslyckades" text_body_template: | - Arkivering av chatt kanalen **\#%{channel_name}** misslyckades. %{messages_archived} meddelanden har arkiverats. Delvis arkiverade meddelanden kopierades till ämnet [%{topic_title}](%{topic_url}). Besök kanalen på %{channel_url} för att försöka igen. + Arkivering av chattkanalen %{channel_hashtag_or_name} misslyckades. %{messages_archived} meddelanden har arkiverats. Delvis arkiverade meddelanden kopierades till ämnet [%{topic_title}](%{topic_url}). Besök kanalen kl. %{channel_url} för att försöka igen. + chat_channel_archive_failed_no_topic: + title: "Arkivering av chattkanalen misslyckades" + subject_template: "Arkivering av chattkanalen misslyckades" + text_body_template: | + Arkivering av chattkanalen %{channel_hashtag_or_name} misslyckades. Inga meddelanden har arkiverats. Ämnet kunde inte skapas, av följande skäl: + + %{topic_validation_errors} + + Besök kanalen på %{channel_url} för att försöka igen. chat: deleted_chat_username: raderat errors: diff --git a/plugins/chat/config/locales/server.tr_TR.yml b/plugins/chat/config/locales/server.tr_TR.yml index 0cdb8d7ca6..6e61df1109 100644 --- a/plugins/chat/config/locales/server.tr_TR.yml +++ b/plugins/chat/config/locales/server.tr_TR.yml @@ -34,13 +34,12 @@ tr_TR: chat_channel_archive_complete: title: "Sohbet Kanalı Arşivlemesi Tamamlandı" subject_template: "Sohbet kanalı arşivlemesi başarıyla tamamlandı" - text_body_template: | - **\#%{channel_name}** sohbet kanalının arşivlenmesi başarıyla tamamlandı. Mesajlar [%{topic_title}](%{topic_url}) konusuna kopyalandı. chat_channel_archive_failed: title: "Sohbet Kanalı Arşivleme Başarısız" subject_template: "Sohbet kanalı arşivleme başarısız" - text_body_template: | - **\#%{channel_name}** sohbet kanalının arşivlenmesi başarısız oldu. %{messages_archived} mesaj arşivlendi. Kısmen arşivlenen mesajlar [%{topic_title}](%{topic_url}) konusuna kopyalandı. Yeniden denemek için %{channel_url} adresinden kanalı ziyaret edin. + chat_channel_archive_failed_no_topic: + title: "Sohbet Kanalı Arşivlenemedi" + subject_template: "Sohbet kanalı arşivlenemedi" chat: deleted_chat_username: silindi errors: diff --git a/plugins/chat/config/locales/server.zh_CN.yml b/plugins/chat/config/locales/server.zh_CN.yml index fa3c06623e..e1385af9e1 100644 --- a/plugins/chat/config/locales/server.zh_CN.yml +++ b/plugins/chat/config/locales/server.zh_CN.yml @@ -33,13 +33,12 @@ zh_CN: chat_channel_archive_complete: title: "聊天频道归档完成" subject_template: "聊天频道归档成功完成" - text_body_template: | - 聊天频道**\#%{channel_name}**归档已成功完成。消息已被复制到话题[%{topic_title}](%{topic_url})中。 chat_channel_archive_failed: title: "聊天频道归档失败" subject_template: "聊天频道归档失败" - text_body_template: | - 聊天频道**#%{channel_name}**归档失败。%{messages_archived} 条消息已被归档。部分归档的消息已被复制到话题[%{topic_title}](%{topic_url})。请访问 %{channel_url} 下的频道以重试。 + chat_channel_archive_failed_no_topic: + title: "聊天频道归档失败" + subject_template: "聊天频道归档失败" chat: deleted_chat_username: 已删除 errors: diff --git a/plugins/discourse-narrative-bot/config/locales/server.ru.yml b/plugins/discourse-narrative-bot/config/locales/server.ru.yml index a79987bcf9..080cde180b 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.ru.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.ru.yml @@ -18,7 +18,7 @@ ru: name: Квалифицированный участник description: "Основное обучение завершено" long_description: | - Эта награда выдается при успешном завершении интерактивного обучения нового пользователя. Вы успешно прошли основное обучение, и теперь вы квалифицированный участник форума! + Эта награда выдаётся при успешном завершении интерактивного обучения нового пользователя. Вы успешно прошли основное обучение, и теперь вы квалифицированный участник форума! licensed: name: Лицензированный участник description: "Дополнительное обучение завершено" @@ -169,7 +169,7 @@ ru: - По соображениям безопасности мы временно ограничиваем возможности новых пользователей. Прочитать об уровнях доверия [можно в нашем блоге](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) (а также в разделе [награды](%{base_uri}/badges)) на этом сайте. - - Мы всегда следуем [основным принципам сообщества](%{base_uri}/guidelines). + - Мы всегда следуем [рекомендациям для сообщества](%{base_uri}/guidelines). onebox: instructions: |- Не могли бы вы отправить одну из этих ссылок мне? Отправьте **только ссылку**, в этом случае при просмотре сообщения ссылка автоматически преобразуется в умную вставку, которая отобразит краткую информацию о просматриваемой странице Википедии. From f29b956339d44dc22c6c911797d43bd8415ec1c9 Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 18 Jan 2023 12:36:16 +0100 Subject: [PATCH 077/169] DEV: introduces documentation for chat (#19772) Note this commit also slightly changes internal API: channel instead of getChannel and updateCurrentUserChannelNotificationsSettings instead of updateCurrentUserChatChannelNotificationsSettings. Also destroyChannel takes a second param which is the name confirmation instead of an optional object containing this confirmation. This is to enforce the fact that it's required. In the future a top level jsdoc config file could be used instead of the hack tempfile, but while it's only an experiment for chat, it's probably good enough. --- .github/workflows/documentation.yml | 76 +++ .jsdoc | 7 + package.json | 1 + .../chat-channel-delete-modal-inner.js | 4 +- .../components/chat-channel-settings-view.js | 5 +- .../javascripts/discourse/lib/collection.js | 128 +++++ .../discourse/services/chat-api.js | 257 +++++---- .../services/chat-channels-manager.js | 2 +- plugins/chat/docs/FRONTEND.md | 352 ++++++++++++ plugins/chat/lib/tasks/chat_doc.rake | 13 + yarn.lock | 531 +++++++++++++++++- 11 files changed, 1252 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/documentation.yml create mode 100644 .jsdoc create mode 100644 plugins/chat/assets/javascripts/discourse/lib/collection.js create mode 100644 plugins/chat/docs/FRONTEND.md create mode 100644 plugins/chat/lib/tasks/chat_doc.rake diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 0000000000..aec086257a --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,76 @@ +name: Documentation + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + if: "!(github.event_name == 'push' && github.repository == 'discourse/discourse-private-mirror')" + name: run + runs-on: ubuntu-latest + container: discourse/discourse_test:slim + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + + - name: Setup Git + run: | + git config --global user.email "ci@ci.invalid" + git config --global user.name "Discourse CI" + + - name: Bundler cache + uses: actions/cache@v3 + with: + path: vendor/bundle + key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gem- + + - name: Setup gems + run: | + gem install bundler --conservative -v $(awk '/BUNDLED WITH/ { getline; gsub(/ /,""); print $0 }' Gemfile.lock) + bundle config --local path vendor/bundle + bundle config --local deployment true + bundle config --local without development + bundle install --jobs 4 + bundle clean + + - name: Get yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT + + - name: Yarn cache + uses: actions/cache@v3 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Yarn install + run: yarn install + + - name: Check Chat documentation + run: | + LOAD_PLUGINS=1 bin/rake chat:doc + + if [ ! -z "$(git status --porcelain plugins/chat/docs/)" ]; then + echo "Chat documentation is not up to date. To resolve, run:" + echo " LOAD_PLUGINS=1 bin/rake chat:doc" + echo + echo "Or manually apply the diff printed below:" + echo "---------------------------------------------" + git -c color.ui=always diff plugins/chat/docs/ + exit 1 + fi + timeout-minutes: 30 diff --git a/.jsdoc b/.jsdoc new file mode 100644 index 0000000000..92345fda22 --- /dev/null +++ b/.jsdoc @@ -0,0 +1,7 @@ +// jsdoc doesn't accept paths starting with _ (which is the case on github runners) +// so we need to alter the default config +{ + "source": { + "excludePattern": "" + } +} diff --git a/package.json b/package.json index d39655d49c..922906ef92 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "chrome-launcher": "^0.15.1", "chrome-remote-interface": "^0.31.3", "eslint-config-discourse": "^3.3.0", + "jsdoc-to-markdown": "^8.0.0", "lefthook": "^1.2.0", "puppeteer-core": "^13.7.0" }, diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js index 4943ae1e34..9a34dd80e5 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-delete-modal-inner.js @@ -40,9 +40,7 @@ export default Component.extend(ModalFunctionality, { this.set("deleting", true); return this.chatApi - .destroyChannel(this.chatChannel.id, { - name_confirmation: this.channelNameConfirmation, - }) + .destroyChannel(this.chatChannel.id, this.channelNameConfirmation) .then(() => { this.set("confirmed", true); this.flash(I18n.t("chat.channel_delete.process_started"), "success"); diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js index 5367f4af96..7ce1a4b497 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel-settings-view.js @@ -87,10 +87,7 @@ export default class ChatChannelSettingsView extends Component { const settings = {}; settings[key] = value; return this.chatApi - .updateCurrentUserChatChannelNotificationsSettings( - this.channel.id, - settings - ) + .updateCurrentUserChannelNotificationsSettings(this.channel.id, settings) .then((result) => { [ "muted", diff --git a/plugins/chat/assets/javascripts/discourse/lib/collection.js b/plugins/chat/assets/javascripts/discourse/lib/collection.js new file mode 100644 index 0000000000..a001121e2d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/lib/collection.js @@ -0,0 +1,128 @@ +/** @module Collection */ + +import { ajax } from "discourse/lib/ajax"; +import { tracked } from "@glimmer/tracking"; +import { bind } from "discourse-common/utils/decorators"; +import { Promise } from "rsvp"; + +/** + * Handles a paginated API response. + * + * @class + */ +export default class Collection { + @tracked items = []; + @tracked meta = {}; + @tracked loading = false; + + /** + * Create a Collection instance + * @param {string} resourceURL - the API endpoint to call + * @param {callback} handler - anonymous function used to handle the response + */ + constructor(resourceURL, handler) { + this._resourceURL = resourceURL; + this._handler = handler; + this._fetchedAll = false; + } + + get loadMoreURL() { + return this.meta.load_more_url; + } + + get totalRows() { + return this.meta.total_rows; + } + + get length() { + return this.items.length; + } + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols + [Symbol.iterator]() { + let index = 0; + + return { + next: () => { + if (index < this.items.length) { + return { value: this.items[index++], done: false }; + } else { + return { done: true }; + } + }, + }; + } + + /** + * Loads first batch of results + * @returns {Promise} + */ + @bind + load(params = {}) { + this._fetchedAll = false; + + if (this.loading) { + return Promise.resolve(); + } + + this.loading = true; + + const filteredQueryParams = Object.entries(params).filter( + ([, v]) => v !== undefined + ); + const queryString = new URLSearchParams(filteredQueryParams).toString(); + + const endpoint = this._resourceURL + (queryString ? `?${queryString}` : ""); + return this.#fetch(endpoint) + .then((result) => { + this.items = this._handler(result); + this.meta = result.meta; + }) + .finally(() => { + this.loading = false; + }); + } + + /** + * Attempts to load more results + * @returns {Promise} + */ + @bind + loadMore() { + let promise = Promise.resolve(); + + if (this.loading) { + return promise; + } + + if ( + this._fetchedAll || + (this.totalRows && this.items.length >= this.totalRows) + ) { + return promise; + } + + this.loading = true; + + if (this.loadMoreURL) { + promise = this.#fetch(this.loadMoreURL).then((result) => { + const newItems = this._handler(result); + + if (newItems.length) { + this.items = this.items.concat(newItems); + } else { + this._fetchedAll = true; + } + this.meta = result.meta; + }); + } + + return promise.finally(() => { + this.loading = false; + }); + } + + #fetch(url) { + return ajax(url, { type: "GET" }); + } +} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index e88cb954cc..e0157f0f61 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -1,123 +1,42 @@ +/** @module ChatApi */ + import Service, { inject as service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership"; -import { tracked } from "@glimmer/tracking"; -import { bind } from "discourse-common/utils/decorators"; -import { Promise } from "rsvp"; - -class Collection { - @tracked items = []; - @tracked meta = {}; - @tracked loading = false; - - constructor(resourceURL, handler) { - this._resourceURL = resourceURL; - this._handler = handler; - this._fetchedAll = false; - } - - get loadMoreURL() { - return this.meta.load_more_url; - } - - get totalRows() { - return this.meta.total_rows; - } - - get length() { - return this.items.length; - } - - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols - [Symbol.iterator]() { - let index = 0; - - return { - next: () => { - if (index < this.items.length) { - return { value: this.items[index++], done: false }; - } else { - return { done: true }; - } - }, - }; - } - - @bind - load(params = {}) { - this._fetchedAll = false; - - if (this.loading) { - return; - } - - this.loading = true; - - const filteredQueryParams = Object.entries(params).filter( - ([, v]) => v !== undefined - ); - const queryString = new URLSearchParams(filteredQueryParams).toString(); - - const endpoint = this._resourceURL + (queryString ? `?${queryString}` : ""); - return this.#fetch(endpoint) - .then((result) => { - this.items = this._handler(result); - this.meta = result.meta; - }) - .finally(() => { - this.loading = false; - }); - } - - @bind - loadMore() { - let promise = Promise.resolve(); - - if (this.loading) { - return promise; - } - - if ( - this._fetchedAll || - (this.totalRows && this.items.length >= this.totalRows) - ) { - return promise; - } - - this.loading = true; - - if (this.loadMoreURL) { - promise = this.#fetch(this.loadMoreURL).then((result) => { - const newItems = this._handler(result); - - if (newItems.length) { - this.items = this.items.concat(newItems); - } else { - this._fetchedAll = true; - } - this.meta = result.meta; - }); - } - - return promise.finally(() => { - this.loading = false; - }); - } - - #fetch(url) { - return ajax(url, { type: "GET" }); - } -} +import Collection from "../lib/collection"; +/** + * Chat API service. Provides methods to interact with the chat API. + * + * @class + * @implements {@ember/service} + */ export default class ChatApi extends Service { @service chatChannelsManager; - getChannel(channelId) { + /** + * Get a channel by its ID. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + * + * @example + * + * this.chatApi.channel(1).then(channel => { ... }) + */ + channel(channelId) { return this.#getRequest(`/channels/${channelId}`).then((result) => this.chatChannelsManager.store(result.channel) ); } + /** + * List all accessible category channels of the current user. + * @returns {module:Collection} + * + * @example + * + * this.chatApi.channels.then(channels => { ... }) + */ channels() { return new Collection(`${this.#basePath}/channels`, (response) => { return response.channels.map((channel) => @@ -126,26 +45,85 @@ export default class ChatApi extends Service { }); } + /** + * Moves messages from one channel to another. + * @param {number} channelId - The ID of the original channel. + * @param {object} data - Params of the move. + * @param {Array.} data.message_ids - IDs of the moved messages. + * @param {number} data.destination_channel_id - ID of the channel where the messages are moved to. + * @returns {Promise} + * + * @example + * + * this.chatApi + * .moveChannelMessages(1, { + * message_ids: [2, 3], + * destination_channel_id: 4, + * }).then(() => { ... }) + */ moveChannelMessages(channelId, data = {}) { return this.#postRequest(`/channels/${channelId}/messages/moves`, { move: data, }); } - destroyChannel(channelId, data = {}) { - return this.#deleteRequest(`/channels/${channelId}`, { channel: data }); + /** + * Destroys a channel. + * @param {number} channelId - The ID of the channel. + * @param {string} channelName - The name of the channel to be destroyed, used as confirmation. + * @returns {Promise} + * + * @example + * + * this.chatApi.destroyChannel(1, "foo").then(() => { ... }) + */ + destroyChannel(channelId, channelName) { + return this.#deleteRequest(`/channels/${channelId}`, { + channel: { name_confirmation: channelName }, + }); } + /** + * Creates a channel. + * @param {object} data - Params of the channel. + * @param {string} data.name - The name of the channel. + * @param {string} data.chatable_id - The category of the channel. + * @param {string} data.description - The description of the channel. + * @param {boolean} [data.auto_join_users] - Should users join this channel automatically. + * @returns {Promise} + * + * @example + * + * this.chatApi + * .createChannel({ name: "foo", chatable_id: 1, description "bar" }) + * .then((channel) => { ... }) + */ createChannel(data = {}) { return this.#postRequest("/channels", { channel: data }).then((response) => this.chatChannelsManager.store(response.channel) ); } + /** + * Lists chat permissions for a category. + * @param {number} categoryId - ID of the category. + * @returns {Promise} + */ categoryPermissions(categoryId) { - return ajax(`/chat/api/category-chatables/${categoryId}/permissions`); + return this.#getRequest(`/category-chatables/${categoryId}/permissions`); } + /** + * Sends a message. + * @param {number} channelId - ID of the channel. + * @param {object} data - Params of the message. + * @param {string} data.message - The raw content of the message in markdown. + * @param {string} data.cooked - The cooked content of the message. + * @param {number} [data.in_reply_to_id] - The ID of the replied-to message. + * @param {number} [data.staged_id] - The staged ID of the message before it was persisted. + * @param {Array.} [data.upload_ids] - Array of upload ids linked to the message. + * @returns {Promise} + */ sendMessage(channelId, data = {}) { return ajax(`/chat/${channelId}`, { ignoreUnsent: false, @@ -154,20 +132,50 @@ export default class ChatApi extends Service { }); } + /** + * Creates a channel archive. + * @param {number} channelId - The ID of the channel. + * @param {object} data - Params of the archive. + * @param {string} data.selection - "new_topic" or "existing_topic". + * @param {string} [data.title] - Title of the topic when creating a new topic. + * @param {string} [data.category_id] - ID of the category used when creating a new topic. + * @param {Array.} [data.tags] - tags used when creating a new topic. + * @param {string} [data.topic_id] - ID of the topic when using an existing topic. + * @returns {Promise} + */ createChannelArchive(channelId, data = {}) { return this.#postRequest(`/channels/${channelId}/archives`, { archive: data, }); } + /** + * Updates a channel. + * @param {number} channelId - The ID of the channel. + * @param {object} data - Params of the archive. + * @param {string} [data.description] - Description of the channel. + * @param {string} [data.name] - Name of the channel. + * @returns {Promise} + */ updateChannel(channelId, data = {}) { return this.#putRequest(`/channels/${channelId}`, { channel: data }); } + /** + * Updates the status of a channel. + * @param {number} channelId - The ID of the channel. + * @param {string} status - The new status, can be "open" or "closed". + * @returns {Promise} + */ updateChannelStatus(channelId, status) { return this.#putRequest(`/channels/${channelId}/status`, { status }); } + /** + * Lists members of a channel. + * @param {number} channelId - The ID of the channel. + * @returns {module:Collection} + */ listChannelMemberships(channelId) { return new Collection( `${this.#basePath}/channels/${channelId}/memberships`, @@ -179,27 +187,50 @@ export default class ChatApi extends Service { ); } + /** + * Lists public and direct message channels of the current user. + * @returns {Promise} + */ listCurrentUserChannels() { - return this.#getRequest(`/channels/me`).then((result) => { + return this.#getRequest("/channels/me").then((result) => { return (result?.channels || []).map((channel) => this.chatChannelsManager.store(channel) ); }); } + /** + * Makes current user follow a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ followChannel(channelId) { return this.#postRequest(`/channels/${channelId}/memberships/me`).then( (result) => UserChatChannelMembership.create(result.membership) ); } + /** + * Makes current user unfollow a channel. + * @param {number} channelId - The ID of the channel. + * @returns {Promise} + */ unfollowChannel(channelId) { return this.#deleteRequest(`/channels/${channelId}/memberships/me`).then( (result) => UserChatChannelMembership.create(result.membership) ); } - updateCurrentUserChatChannelNotificationsSettings(channelId, data = {}) { + /** + * Update notifications settings of current user for a channel. + * @param {number} channelId - The ID of the channel. + * @param {object} data - The settings to modify. + * @param {boolean} [data.muted] - Mutes the channel. + * @param {string} [data.desktop_notification_level] - Notifications level on desktop: never, mention or always. + * @param {string} [data.mobile_notification_level] - Notifications level on mobile: never, mention or always. + * @returns {Promise} + */ + updateCurrentUserChannelNotificationsSettings(channelId, data = {}) { return this.#putRequest( `/channels/${channelId}/notifications-settings/me`, { notifications_settings: data } diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js index 1a73752a07..9d2cae5539 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channels-manager.js @@ -117,7 +117,7 @@ export default class ChatChannelsManager extends Service { async #find(id) { return this.chatApi - .getChannel(id) + .channel(id) .catch(popupAjaxError) .then((channel) => { this.#cache(channel); diff --git a/plugins/chat/docs/FRONTEND.md b/plugins/chat/docs/FRONTEND.md new file mode 100644 index 0000000000..8d81aafc50 --- /dev/null +++ b/plugins/chat/docs/FRONTEND.md @@ -0,0 +1,352 @@ +## Modules + +
    +
    Collection
    +
    +
    ChatApi
    +
    +
    + + + +## Collection + +* [Collection](#module_Collection) + * [module.exports](#exp_module_Collection--module.exports) ⏏ + * [new module.exports(resourceURL, handler)](#new_module_Collection--module.exports_new) + * [.load()](#module_Collection--module.exports+load) ⇒ Promise + * [.loadMore()](#module_Collection--module.exports+loadMore) ⇒ Promise + + +* * * + + + +### module.exports ⏏ +Handles a paginated API response. + +**Kind**: Exported class + +* * * + + + +#### new module.exports(resourceURL, handler) +Create a Collection instance + + +| Param | Type | Description | +| --- | --- | --- | +| resourceURL | string | the API endpoint to call | +| handler | callback | anonymous function used to handle the response | + + +* * * + + + +#### module.exports.load() ⇒ Promise +Loads first batch of results + +**Kind**: instance method of [module.exports](#exp_module_Collection--module.exports) + +* * * + + + +#### module.exports.loadMore() ⇒ Promise +Attempts to load more results + +**Kind**: instance method of [module.exports](#exp_module_Collection--module.exports) + +* * * + + + +## ChatApi + +* [ChatApi](#module_ChatApi) + * [module.exports](#exp_module_ChatApi--module.exports) ⏏ + * [.channel(channelId)](#module_ChatApi--module.exports+channel) ⇒ Promise + * [.channels()](#module_ChatApi--module.exports+channels) ⇒ [module.exports](#exp_module_Collection--module.exports) + * [.moveChannelMessages(channelId, data)](#module_ChatApi--module.exports+moveChannelMessages) ⇒ Promise + * [.destroyChannel(channelId, channelName)](#module_ChatApi--module.exports+destroyChannel) ⇒ Promise + * [.createChannel(data)](#module_ChatApi--module.exports+createChannel) ⇒ Promise + * [.categoryPermissions(categoryId)](#module_ChatApi--module.exports+categoryPermissions) ⇒ Promise + * [.sendMessage(channelId, data)](#module_ChatApi--module.exports+sendMessage) ⇒ Promise + * [.createChannelArchive(channelId, data)](#module_ChatApi--module.exports+createChannelArchive) ⇒ Promise + * [.updateChannel(channelId, data)](#module_ChatApi--module.exports+updateChannel) ⇒ Promise + * [.updateChannelStatus(channelId, status)](#module_ChatApi--module.exports+updateChannelStatus) ⇒ Promise + * [.listChannelMemberships(channelId)](#module_ChatApi--module.exports+listChannelMemberships) ⇒ [module.exports](#exp_module_Collection--module.exports) + * [.listCurrentUserChannels()](#module_ChatApi--module.exports+listCurrentUserChannels) ⇒ Promise + * [.followChannel(channelId)](#module_ChatApi--module.exports+followChannel) ⇒ Promise + * [.unfollowChannel(channelId)](#module_ChatApi--module.exports+unfollowChannel) ⇒ Promise + * [.updateCurrentUserChannelNotificationsSettings(channelId, data)](#module_ChatApi--module.exports+updateCurrentUserChannelNotificationsSettings) ⇒ Promise + + +* * * + + + +### module.exports ⏏ +Chat API service. Provides methods to interact with the chat API. + +**Kind**: Exported class +**Implements**: {@ember/service} + +* * * + + + +#### module.exports.channel(channelId) ⇒ Promise +Get a channel by its ID. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | + +**Example** +```js +this.chatApi.channel(1).then(channel => { ... }) +``` + +* * * + + + +#### module.exports.channels() ⇒ [module.exports](#exp_module_Collection--module.exports) +List all accessible category channels of the current user. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) +**Example** +```js +this.chatApi.channels.then(channels => { ... }) +``` + +* * * + + + +#### module.exports.moveChannelMessages(channelId, data) ⇒ Promise +Moves messages from one channel to another. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the original channel. | +| data | object | Params of the move. | +| data.message_ids | Array.<number> | IDs of the moved messages. | +| data.destination_channel_id | number | ID of the channel where the messages are moved to. | + +**Example** +```js +this.chatApi + .moveChannelMessages(1, { + message_ids: [2, 3], + destination_channel_id: 4, + }).then(() => { ... }) +``` + +* * * + + + +#### module.exports.destroyChannel(channelId, channelName) ⇒ Promise +Destroys a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | +| channelName | string | The name of the channel to be destroyed, used as confirmation. | + +**Example** +```js +this.chatApi.destroyChannel(1, "foo").then(() => { ... }) +``` + +* * * + + + +#### module.exports.createChannel(data) ⇒ Promise +Creates a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| data | object | Params of the channel. | +| data.name | string | The name of the channel. | +| data.chatable_id | string | The category of the channel. | +| data.description | string | The description of the channel. | +| [data.auto_join_users] | boolean | Should users join this channel automatically. | + +**Example** +```js +this.chatApi + .createChannel({ name: "foo", chatable_id: 1, description "bar" }) + .then((channel) => { ... }) +``` + +* * * + + + +#### module.exports.categoryPermissions(categoryId) ⇒ Promise +Lists chat permissions for a category. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| categoryId | number | ID of the category. | + + +* * * + + + +#### module.exports.sendMessage(channelId, data) ⇒ Promise +Sends a message. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | ID of the channel. | +| data | object | Params of the message. | +| data.message | string | The raw content of the message in markdown. | +| data.cooked | string | The cooked content of the message. | +| [data.in_reply_to_id] | number | The ID of the replied-to message. | +| [data.staged_id] | number | The staged ID of the message before it was persisted. | +| [data.upload_ids] | Array.<number> | Array of upload ids linked to the message. | + + +* * * + + + +#### module.exports.createChannelArchive(channelId, data) ⇒ Promise +Creates a channel archive. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | +| data | object | Params of the archive. | +| data.selection | string | "new_topic" or "existing_topic". | +| [data.title] | string | Title of the topic when creating a new topic. | +| [data.category_id] | string | ID of the category used when creating a new topic. | +| [data.tags] | Array.<string> | tags used when creating a new topic. | +| [data.topic_id] | string | ID of the topic when using an existing topic. | + + +* * * + + + +#### module.exports.updateChannel(channelId, data) ⇒ Promise +Updates a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | +| data | object | Params of the archive. | +| [data.description] | string | Description of the channel. | +| [data.name] | string | Name of the channel. | + + +* * * + + + +#### module.exports.updateChannelStatus(channelId, status) ⇒ Promise +Updates the status of a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | +| status | string | The new status, can be "open" or "closed". | + + +* * * + + + +#### module.exports.listChannelMemberships(channelId) ⇒ [module.exports](#exp_module_Collection--module.exports) +Lists members of a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | + + +* * * + + + +#### module.exports.listCurrentUserChannels() ⇒ Promise +Lists public and direct message channels of the current user. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +* * * + + + +#### module.exports.followChannel(channelId) ⇒ Promise +Makes current user follow a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | + + +* * * + + + +#### module.exports.unfollowChannel(channelId) ⇒ Promise +Makes current user unfollow a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | + + +* * * + + + +#### module.exports.updateCurrentUserChannelNotificationsSettings(channelId, data) ⇒ Promise +Update notifications settings of current user for a channel. + +**Kind**: instance method of [module.exports](#exp_module_ChatApi--module.exports) + +| Param | Type | Description | +| --- | --- | --- | +| channelId | number | The ID of the channel. | +| data | object | The settings to modify. | +| [data.muted] | boolean | Mutes the channel. | +| [data.desktop_notification_level] | string | Notifications level on desktop: never, mention or always. | +| [data.mobile_notification_level] | string | Notifications level on mobile: never, mention or always. | + + +* * * + diff --git a/plugins/chat/lib/tasks/chat_doc.rake b/plugins/chat/lib/tasks/chat_doc.rake new file mode 100644 index 0000000000..98fb9553e3 --- /dev/null +++ b/plugins/chat/lib/tasks/chat_doc.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +task "chat:doc" do + destination = File.join(Rails.root, "plugins/chat/docs/FRONTEND.md") + config = File.join(Rails.root, ".jsdoc") + + files = %w[ + plugins/chat/assets/javascripts/discourse/lib/collection.js + plugins/chat/assets/javascripts/discourse/services/chat-api.js + ] + + `yarn --silent jsdoc2md --separators -c #{config} -f #{files.join(" ")} > #{destination}` +end diff --git a/yarn.lock b/yarn.lock index 40febf39c5..402409e17c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -209,6 +209,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.6.tgz#845338edecad65ebffef058d3be851f1d28a63bc" integrity sha512-uQVSa9jJUe/G/304lXspfWVpKpK4euFLgGiMQFOCpM/bgcAdeoHwi/OQz23O9GK2osz26ZiXRRV9aV+Yl1O8tw== +"@babel/parser@^7.9.4": + version "7.20.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b" + integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg== + "@babel/plugin-proposal-decorators@^7.18.2": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.18.6.tgz#68e9fd0f022b944f84a8824bb28bfaee724d2595" @@ -411,6 +416,13 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jsdoc/salty@^0.2.1": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@jsdoc/salty/-/salty-0.2.2.tgz#567017ddda2048c5ff921aeffd38564a0578fdca" + integrity sha512-A1FrVnc7L9qI2gUGsfN0trTiJNK72Y0CL/VAyrmYEmeKI3pnHDawP64CEev31XLyAAOx2xmDo3tbadPxC0CSbw== + dependencies: + lodash "^4.17.21" + "@json-editor/json-editor@^2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@json-editor/json-editor/-/json-editor-2.6.1.tgz#169e8b88305d71ccac391c3ae22d4145bc63c9f7" @@ -493,6 +505,24 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== +"@types/linkify-it@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-3.0.2.tgz#fd2cd2edbaa7eaac7e7f3c1748b52a19143846c9" + integrity sha512-HZQYqbiFVWufzCwexrvh694SOim8z2d+xJl5UNamcvQFejLY/2YUtzXHYi3cHdI7PMlS8ejH2slRAOJQ32aNbA== + +"@types/markdown-it@^12.2.3": + version "12.2.3" + resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-12.2.3.tgz#0d6f6e5e413f8daaa26522904597be3d6cd93b51" + integrity sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ== + dependencies: + "@types/linkify-it" "*" + "@types/mdurl" "*" + +"@types/mdurl@*": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9" + integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA== + "@types/node@*": version "14.11.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.2.tgz#2de1ed6670439387da1c9f549a2ade2b0a799256" @@ -537,6 +567,13 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ansi-escape-sequences@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-escape-sequences/-/ansi-escape-sequences-4.1.0.tgz#2483c8773f50dd9174dd9557e92b1718f1816097" + integrity sha512-dzW9kHxH011uBsidTXd14JXgzye/YLb2LzeKZ4bsgl/Knwx8AtbSFkkGxagdNOoh0DlqHCmfiEjWKBaqjOanVw== + dependencies: + array-back "^3.0.1" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -567,6 +604,40 @@ aria-query@^5.0.0: resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== +array-back@^1.0.2, array-back@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b" + integrity sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw== + dependencies: + typical "^2.6.0" + +array-back@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022" + integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw== + dependencies: + typical "^2.6.1" + +array-back@^3.0.1, array-back@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0" + integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q== + +array-back@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e" + integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg== + +array-back@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-5.0.0.tgz#e196609edcec48376236d163958df76e659a0d36" + integrity sha512-kgVWwJReZWmVuWOQKEOohXKJX+nD02JAZ54D1RRWlv8L0NebauKAaFxACKzB74RTclt1+WNz5KHaLRDAPZbDEw== + +array-back@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.2.tgz#f567d99e9af88a6d3d2f9dfcc21db6f9ba9fd157" + integrity sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw== + array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" @@ -611,6 +682,11 @@ bl@^4.0.3, bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +bluebird@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" + integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -649,6 +725,15 @@ buffer@^5.2.1, buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +cache-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cache-point/-/cache-point-2.0.0.tgz#91e03c38da9cfba9d95ac6a34d24cfe6eff8920f" + integrity sha512-4gkeHlFpSKgm3vm2gJN5sPqfmijYRFYCQ6tv5cLw0xVmT6r1z1vd4FNnpuOREco3cBs1G709sZ72LdgddKvL5w== + dependencies: + array-back "^4.0.1" + fs-then-native "^2.0.0" + mkdirp2 "^1.0.4" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -659,6 +744,13 @@ caniuse-lite@^1.0.30001359: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001363.tgz#26bec2d606924ba318235944e1193304ea7c4f15" integrity sha512-HpQhpzTGGPVMnCjIomjt+jvyUu8vNFo3TaDiZ/RcoTrlOq/5+tC8zHdsbgFB6MxmaY+jCpsH09aD80Bb4Ow3Sg== +catharsis@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/catharsis/-/catharsis-0.9.0.tgz#40382a168be0e6da308c277d3a2b3eb40c7d2121" + integrity sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A== + dependencies: + lodash "^4.17.15" + chalk@^2.0.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -740,6 +832,14 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +collect-all@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/collect-all/-/collect-all-1.0.4.tgz#50cd7119ac24b8e12a661f0f8c3aa0ea7222ddfc" + integrity sha512-RKZhRwJtJEP5FWul+gkSMEnaK6H3AGPTTWOiRimCcs+rc/OmQE3Yhy1Q7A7KsdkG3ZXVdZq68Y6ONSdvkeEcKA== + dependencies: + stream-connect "^1.0.2" + stream-via "^1.0.4" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -769,6 +869,37 @@ colors@^1.4.0: resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78" integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA== +command-line-args@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e" + integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg== + dependencies: + array-back "^3.1.0" + find-replace "^3.0.0" + lodash.camelcase "^4.3.0" + typical "^4.0.0" + +command-line-tool@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/command-line-tool/-/command-line-tool-0.8.0.tgz#b00290ef1dfc11cc731dd1f43a92cfa5f21e715b" + integrity sha512-Xw18HVx/QzQV3Sc5k1vy3kgtOeGmsKIqwtFFoyjI4bbcpSgnw2CWVULvtakyw4s6fhyAdI6soQQhXc2OzJy62g== + dependencies: + ansi-escape-sequences "^4.0.0" + array-back "^2.0.0" + command-line-args "^5.0.0" + command-line-usage "^4.1.0" + typical "^2.6.1" + +command-line-usage@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-4.1.0.tgz#a6b3b2e2703b4dcf8bd46ae19e118a9a52972882" + integrity sha512-MxS8Ad995KpdAC0Jopo/ovGIroV/m0KHwzKfXxKag6FHOkGsH8/lv5yjgablcRxCJJC0oJeUMuO/gmaq+Wq46g== + dependencies: + ansi-escape-sequences "^4.0.0" + array-back "^2.0.0" + table-layout "^0.4.2" + typical "^2.6.1" + commander@2.11.x: version "2.11.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" @@ -784,11 +915,23 @@ commander@^8.3.0: resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== +common-sequence@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/common-sequence/-/common-sequence-2.0.2.tgz#accc76bdc5876a1fcd92b73484d4285fff99d838" + integrity sha512-jAg09gkdkrDO9EWTdXfv80WWH3yeZl5oT69fGfedBNS9pXUKYInVJ1bJ+/ht2+Moeei48TmSbQDYMc8EOx9G0g== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= +config-master@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/config-master/-/config-master-3.1.0.tgz#667663590505a283bf26a484d68489d74c5485da" + integrity sha512-n7LBL1zBzYdTpF1mx5DNcZnZn05CWIdsdvtPL4MosvqbBUK3Rq6VWEtGUuF3Y0s9/CIhMejezqlSkP6TnCJ/9g== + dependencies: + walk-back "^2.0.1" + convert-source-map@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" @@ -849,6 +992,11 @@ debug@^2.6.8: dependencies: ms "2.0.0" +deep-extend@~0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" @@ -878,6 +1026,24 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +dmd@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/dmd/-/dmd-6.2.0.tgz#d267a9fb1ce62b74edca8bf5bcbd3b8e08574fe7" + integrity sha512-uXWxLF1H7TkUAuoHK59/h/ts5cKavm2LnhrIgJWisip4BVzPoXavlwyoprFFn2CzcahKYgvkfaebS6oxzgflkg== + dependencies: + array-back "^6.2.2" + cache-point "^2.0.0" + common-sequence "^2.0.2" + file-set "^4.0.2" + handlebars "^4.7.7" + marked "^4.2.3" + object-get "^2.1.1" + reduce-flatten "^3.0.1" + reduce-unique "^2.0.1" + reduce-without "^1.0.1" + test-value "^3.0.0" + walk-back "^5.1.0" + doctrine@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" @@ -969,6 +1135,11 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +entities@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" + integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -979,6 +1150,11 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + escape-string-regexp@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" @@ -1240,6 +1416,14 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-set@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/file-set/-/file-set-4.0.2.tgz#8d67c92a864202c2085ac9f03f1c9909c7e27030" + integrity sha512-fuxEgzk4L8waGXaAkd8cMr73Pm0FxOVkn8hztzUW7BAHhOGH90viQNXbiOsnecCWmfInqU6YmAMwxRMdKETceQ== + dependencies: + array-back "^5.0.0" + glob "^7.1.6" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" @@ -1247,6 +1431,13 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-replace@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" + integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ== + dependencies: + array-back "^3.0.1" + find-up@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -1299,6 +1490,11 @@ fs-extra@^9.1.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-then-native@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fs-then-native/-/fs-then-native-2.0.0.tgz#19a124d94d90c22c8e045f2e8dd6ebea36d48c67" + integrity sha512-X712jAOaWXkemQCAmWeg5rOT2i+KOpWz1Z/txk/cW0qlOu2oQ9H61vc5w3X/iyuUEfq/OyaFJ78/cZAQD1/bgA== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1371,7 +1567,7 @@ glob-stream@^7.0.0: to-absolute-glob "^2.0.2" unique-stream "^2.3.1" -glob@^7.1.3, glob@^7.2.0: +glob@^7.1.3, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -1418,11 +1614,23 @@ globby@^13.1.2: merge2 "^1.4.1" slash "^4.0.0" -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: +graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.10" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== +handlebars@^4.7.7: + version "4.7.7" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" + integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.0" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -1589,6 +1797,74 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +js2xmlparser@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/js2xmlparser/-/js2xmlparser-4.0.2.tgz#2a1fdf01e90585ef2ae872a01bc169c6a8d5e60a" + integrity sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA== + dependencies: + xmlcreate "^2.0.4" + +jsdoc-api@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/jsdoc-api/-/jsdoc-api-8.0.0.tgz#4b2c25ff60f91b80da51b6cd33943acc7b2cab74" + integrity sha512-Rnhor0suB1Ds1abjmFkFfKeD+kSMRN9oHMTMZoJVUrmtCGDwXty+sWMA9sa4xbe4UyxuPjhC7tavZ40mDKK6QQ== + dependencies: + array-back "^6.2.2" + cache-point "^2.0.0" + collect-all "^1.0.4" + file-set "^4.0.2" + fs-then-native "^2.0.0" + jsdoc "^4.0.0" + object-to-spawn-args "^2.0.1" + temp-path "^1.0.0" + walk-back "^5.1.0" + +jsdoc-parse@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/jsdoc-parse/-/jsdoc-parse-6.2.0.tgz#2b71d3925acfc4badc72526f2470766e0561f6b5" + integrity sha512-Afu1fQBEb7QHt6QWX/6eUWvYHJofB90Fjx7FuJYF7mnG9z5BkAIpms1wsnvYLytfmqpEENHs/fax9p8gvMj7dw== + dependencies: + array-back "^6.2.2" + lodash.omit "^4.5.0" + lodash.pick "^4.4.0" + reduce-extract "^1.0.0" + sort-array "^4.1.5" + test-value "^3.0.0" + +jsdoc-to-markdown@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/jsdoc-to-markdown/-/jsdoc-to-markdown-8.0.0.tgz#27f32ed200d3b84dbf22a49beed485790f93b3ce" + integrity sha512-2FQvYkg491+FP6s15eFlgSSWs69CvQrpbABGYBtvAvGWy/lWo8IKKToarT283w59rQFrpcjHl3YdhHCa3l7gXg== + dependencies: + array-back "^6.2.2" + command-line-tool "^0.8.0" + config-master "^3.1.0" + dmd "^6.2.0" + jsdoc-api "^8.0.0" + jsdoc-parse "^6.2.0" + walk-back "^5.1.0" + +jsdoc@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsdoc/-/jsdoc-4.0.0.tgz#9569f79ea5b14ba4bc726da1a48fe6a241ad7893" + integrity sha512-tzTgkklbWKrlaQL2+e3NNgLcZu3NaK2vsHRx7tyHQ+H5jcB9Gx0txSd2eJWlMC/xU1+7LQu4s58Ry0RkuaEQVg== + dependencies: + "@babel/parser" "^7.9.4" + "@jsdoc/salty" "^0.2.1" + "@types/markdown-it" "^12.2.3" + bluebird "^3.7.2" + catharsis "^0.9.0" + escape-string-regexp "^2.0.0" + js2xmlparser "^4.0.2" + klaw "^3.0.0" + markdown-it "^12.3.2" + markdown-it-anchor "^8.4.1" + marked "^4.0.10" + mkdirp "^1.0.4" + requizzle "^0.2.3" + strip-json-comments "^3.1.0" + underscore "~1.13.2" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -1618,6 +1894,13 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +klaw@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/klaw/-/klaw-3.0.0.tgz#b11bec9cf2492f06756d6e809ab73a2910259146" + integrity sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g== + dependencies: + graceful-fs "^4.1.9" + language-subtag-registry@~0.3.2: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -1688,6 +1971,13 @@ lighthouse-logger@^1.0.0: debug "^2.6.8" marky "^1.2.0" +linkify-it@^3.0.1: + version "3.0.3" + resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.3.tgz#a98baf44ce45a550efb4d49c769d07524cc2fa2e" + integrity sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ== + dependencies: + uc.micro "^1.0.1" + locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -1709,6 +1999,11 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.kebabcase@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" @@ -1719,7 +2014,22 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.14: +lodash.omit@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" + integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg== + +lodash.padend@^4.6.1: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e" + integrity sha512-sOQs2aqGpbl27tmCS1QNZA09Uqp01ZzWfDUoD+xzTii0E7dSQfRKcRetFwa+uXaxaqL+TKm7CgD2JdKP7aZBSw== + +lodash.pick@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" + integrity sha512-hXt6Ul/5yWjfklSGvLQl8vM//l3FtyHZeuelpzK6mm99pNvN9yTDruNZPEJZD1oWrqo+izBmB7oUfWgcCX7s4Q== + +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -1744,6 +2054,27 @@ magnific-popup@1.1.0: resolved "https://registry.yarnpkg.com/magnific-popup/-/magnific-popup-1.1.0.tgz#3e7362c5bd18f6785fe99e59d013e20af33d3049" integrity sha1-PnNixb0Y9nhf6Z5Z0BPiCvM9MEk= +markdown-it-anchor@^8.4.1: + version "8.6.6" + resolved "https://registry.yarnpkg.com/markdown-it-anchor/-/markdown-it-anchor-8.6.6.tgz#4a12e358c9c2167ee28cb7a5f10e29d6f1ffd7ca" + integrity sha512-jRW30YGywD2ESXDc+l17AiritL0uVaSnWsb26f+68qaW9zgbIIr1f4v2Nsvc0+s0Z2N3uX6t/yAw7BwCQ1wMsA== + +markdown-it@^12.3.2: + version "12.3.2" + resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.3.2.tgz#bf92ac92283fe983fe4de8ff8abfb5ad72cd0c90" + integrity sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg== + dependencies: + argparse "^2.0.1" + entities "~2.1.0" + linkify-it "^3.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + +marked@^4.0.10, marked@^4.2.3: + version "4.2.5" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.2.5.tgz#979813dfc1252cc123a79b71b095759a32f42a5d" + integrity sha512-jPueVhumq7idETHkb203WDD4fMA3yV9emQ5vLwop58lu8bTclMghBWcYAavlDqIEMaisADinV1TooIFCfqOsYQ== + marky@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.1.tgz#a3fcf82ffd357756b8b8affec9fdbf3a30dc1b02" @@ -1754,6 +2085,11 @@ mdn-data@2.0.27: resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.27.tgz#1710baa7b0db8176d3b3d565ccb7915fc69525ab" integrity sha512-kwqO0I0jtWr25KcfLm9pia8vLZ8qoAKhWZuZMbneJq3jjBD3gl5nZs8l8Tu3ZBlBAHVQtDur9rdDGyvtfVraHQ== +mdurl@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" + integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== + merge2@^1.3.0, merge2@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -1779,11 +2115,26 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimist@^1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" + integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== + mkdirp-classic@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mkdirp2@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/mkdirp2/-/mkdirp2-1.0.5.tgz#68bbe61defefafce4b48948608ec0bac942512c2" + integrity sha512-xOE9xbICroUDmG1ye2h4bZ8WBie9EGmACaco8K8cx6RlkJJrxGIqjGqztAI+NMhexXBcdGbSEzI6N3EJPevxZw== + +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + moment-timezone@0.5.39: version "0.5.39" resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.39.tgz#342625a3b98810f04c8f4ea917e448d3525e600b" @@ -1811,6 +2162,11 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +neo-async@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -1831,6 +2187,16 @@ node-releases@^2.0.5: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.5.tgz#280ed5bc3eba0d96ce44897d8aee478bfb3d9666" integrity sha512-U9h1NLROZTq9uE1SNffn6WuPDg8icmi3ns4rEl/oTfIle4iLjTliCzgTsbaIFMq/Xn078/lfY/BL0GWZ+psK4Q== +object-get@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/object-get/-/object-get-2.1.1.tgz#1dad63baf6d94df184d1c58756cc9be55b174dac" + integrity sha512-7n4IpLMzGGcLEMiQKsNR7vCe+N5E9LORFrtNUVy4sO3dj9a3HedZCxEL2T7QuLhcHN1NBuBsMOKaOsAYI9IIvg== + +object-to-spawn-args@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object-to-spawn-args/-/object-to-spawn-args-2.0.1.tgz#cf8b8e3c9b3589137a469cac90391f44870144a5" + integrity sha512-6FuKFQ39cOID+BMZ3QaphcC8Y4cw6LXBLyIgPU+OhIYwviJamPAn+4mITapnSBQrejB+NNp+FMskhD8Cq+Ys3w== + once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -2086,6 +2452,35 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +reduce-extract@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/reduce-extract/-/reduce-extract-1.0.0.tgz#67f2385beda65061b5f5f4312662e8b080ca1525" + integrity sha512-QF8vjWx3wnRSL5uFMyCjDeDc5EBMiryoT9tz94VvgjKfzecHAVnqmXAwQDcr7X4JmLc2cjkjFGCVzhMqDjgR9g== + dependencies: + test-value "^1.0.1" + +reduce-flatten@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327" + integrity sha512-j5WfFJfc9CoXv/WbwVLHq74i/hdTUpy+iNC534LxczMRP67vJeK3V9JOdnL0N1cIRbn9mYhE2yVjvvKXDxvNXQ== + +reduce-flatten@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-3.0.1.tgz#3db6b48ced1f4dbe4f4f5e31e422aa9ff0cd21ba" + integrity sha512-bYo+97BmUUOzg09XwfkwALt4PQH1M5L0wzKerBt6WLm3Fhdd43mMS89HiT1B9pJIqko/6lWx3OnV4J9f2Kqp5Q== + +reduce-unique@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/reduce-unique/-/reduce-unique-2.0.1.tgz#fb34b90e89297c1e08d75dcf17e9a6443ea71081" + integrity sha512-x4jH/8L1eyZGR785WY+ePtyMNhycl1N2XOLxhCbzZFaqF4AXjLzqSxa2UHgJ2ZVR/HHyPOvl1L7xRnW8ye5MdA== + +reduce-without@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/reduce-without/-/reduce-without-1.0.1.tgz#68ad0ead11855c9a37d4e8256c15bbf87972fc8c" + integrity sha512-zQv5y/cf85sxvdrKPlfcRzlDn/OqKFThNimYmsS3flmkioKvkUGn2Qg9cJVoQiEvdxFGLE0MQER/9fZ9sUqdxg== + dependencies: + test-value "^2.0.0" + regexpp@^3.0.0, regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" @@ -2111,6 +2506,13 @@ requireindex@~1.1.0: resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.1.0.tgz#e5404b81557ef75db6e49c5a72004893fe03e162" integrity sha1-5UBLgVV+91225JxacgBIk/4D4WI= +requizzle@^0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/requizzle/-/requizzle-0.2.4.tgz#319eb658b28c370f0c20f968fa8ceab98c13d27c" + integrity sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw== + dependencies: + lodash "^4.17.21" + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2217,22 +2619,47 @@ snake-case@^3.0.3: dot-case "^3.0.4" tslib "^2.0.3" +sort-array@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/sort-array/-/sort-array-4.1.5.tgz#64b92aaba222aec606786f4df28ae4e3e3e68313" + integrity sha512-Ya4peoS1fgFN42RN1REk2FgdNOeLIEMKFGJvs7VTP3OklF8+kl2SkpVliZ4tk/PurWsrWRsdNdU+tgyOBkB9sA== + dependencies: + array-back "^5.0.0" + typical "^6.0.1" + source-map-js@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + squoosh@discourse/squoosh#dc9649d: version "2.0.0" resolved "https://codeload.github.com/discourse/squoosh/tar.gz/dc9649d0a4d396d1251c22291b17d99f1716da44" dependencies: wasm-feature-detect "^1.2.11" +stream-connect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stream-connect/-/stream-connect-1.0.2.tgz#18bc81f2edb35b8b5d9a8009200a985314428a97" + integrity sha512-68Kl+79cE0RGKemKkhxTSg8+6AGrqBt+cbZAXevg2iJ6Y3zX4JhA/sZeGzLpxW9cXhmqAcE7KnJCisUmIUfnFQ== + dependencies: + array-back "^1.0.2" + stream-shift@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +stream-via@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/stream-via/-/stream-via-1.0.4.tgz#8dccbb0ac909328eb8bc8e2a4bd3934afdaf606c" + integrity sha512-DBp0lSvX5G9KGRDTkR/R+a29H+Wk2xItOF+MpZLLNDWbEV9tGPnqLPxHEYjmiz8xGtJHRIqmI+hCjmNzqoA4nQ== + string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -2287,6 +2714,17 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +table-layout@^0.4.2: + version "0.4.5" + resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378" + integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw== + dependencies: + array-back "^2.0.0" + deep-extend "~0.6.0" + lodash.padend "^4.6.1" + typical "^2.6.1" + wordwrapjs "^3.0.0" + tar-fs@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" @@ -2308,6 +2746,35 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" +temp-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/temp-path/-/temp-path-1.0.0.tgz#24b1543973ab442896d9ad367dd9cbdbfafe918b" + integrity sha512-TvmyH7kC6ZVTYkqCODjJIbgvu0FKiwQpZ4D1aknE7xpcDf/qEOB8KZEK5ef2pfbVoiBhNWs3yx4y+ESMtNYmlg== + +test-value@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/test-value/-/test-value-1.1.0.tgz#a09136f72ec043d27c893707c2b159bfad7de93f" + integrity sha512-wrsbRo7qP+2Je8x8DsK8ovCGyxe3sYfQwOraIY/09A2gFXU9DYKiTF14W4ki/01AEh56kMzAmlj9CaHGDDUBJA== + dependencies: + array-back "^1.0.2" + typical "^2.4.2" + +test-value@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/test-value/-/test-value-2.1.0.tgz#11da6ff670f3471a73b625ca4f3fdcf7bb748291" + integrity sha512-+1epbAxtKeXttkGFMTX9H42oqzOTufR1ceCF+GYA5aOmvaPq9wd4PUS8329fn2RRLGNeUkgRLnVpycjx8DsO2w== + dependencies: + array-back "^1.0.3" + typical "^2.6.0" + +test-value@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/test-value/-/test-value-3.0.0.tgz#9168c062fab11a86b8d444dd968bb4b73851ce92" + integrity sha512-sVACdAWcZkSU9x7AOmJo5TqE+GyNJknHaHsMrR6ZnhjVlVN9Yx6FjHrsKZ3BjIpPCT68zYesPWkakrNupwfOTQ== + dependencies: + array-back "^2.0.0" + typical "^2.6.1" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -2388,6 +2855,31 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +typical@^2.4.2, typical@^2.6.0, typical@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d" + integrity sha512-ofhi8kjIje6npGozTip9Fr8iecmYfEbS06i0JnIg+rh51KakryWF4+jX8lLKZVhy6N+ID45WYSFCxPOdTWCzNg== + +typical@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" + integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw== + +typical@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/typical/-/typical-6.0.1.tgz#89bd1a6aa5e5e96fa907fb6b7579223bff558a06" + integrity sha512-+g3NEp7fJLe9DPa1TArHm9QAA7YciZmWnfAqEaFrBihQ7epOv9i99rjtgb6Iz0wh3WuQDjsCTDfgRoGnmHN81A== + +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + unbzip2-stream@1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" @@ -2401,6 +2893,11 @@ unc-path-regex@^0.1.2: resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= +underscore@~1.13.2: + version "1.13.6" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" + integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== + unique-stream@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac" @@ -2444,6 +2941,16 @@ v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== +walk-back@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/walk-back/-/walk-back-2.0.1.tgz#554e2a9d874fac47a8cb006bf44c2f0c4998a0a4" + integrity sha512-Nb6GvBR8UWX1D+Le+xUq0+Q1kFmRBIWVrfLnQAOmcpEzA9oAxwJ9gIr36t9TWYfzvWRvuMtjHiVsJYEkXWaTAQ== + +walk-back@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/walk-back/-/walk-back-5.1.0.tgz#486d6f29e67f56ab89b952d987028bbb1a4e956c" + integrity sha512-Uhxps5yZcVNbLEAnb+xaEEMdgTXl9qAQDzKYejG2AZ7qPwRQ81lozY9ECDbjLPNWm7YsO1IK5rsP1KoQzXAcGA== + wasm-feature-detect@^1.2.11: version "1.3.0" resolved "https://registry.yarnpkg.com/wasm-feature-detect/-/wasm-feature-detect-1.3.0.tgz#fb3fc5dd4a1ba950a429be843daad67fe048bc42" @@ -2481,6 +2988,19 @@ word-wrap@^1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +wordwrapjs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e" + integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw== + dependencies: + reduce-flatten "^1.0.1" + typical "^2.6.1" + workbox-cacheable-response@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-4.3.1.tgz#f53e079179c095a3f19e5313b284975c91428c91" @@ -2548,6 +3068,11 @@ ws@^7.2.0: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.7.tgz#9e0ac77ee50af70d58326ecff7e85eb3fa375e67" integrity sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A== +xmlcreate@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.4.tgz#0c5ab0f99cdd02a81065fa9cd8f8ae87624889be" + integrity sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg== + xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" From 1d4c1fe002ed5ade80a7f4839cd38979ba75431e Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Wed, 18 Jan 2023 13:12:08 +0100 Subject: [PATCH 078/169] UX: reorders chat-channel fields (#19905) This commit also adds a long description to the auto join field. This is the same description used in channel settings. --- .../discourse/controllers/create-channel.js | 9 +- .../templates/modal/create-channel.hbs | 98 ++++++++++++------- .../assets/stylesheets/common/common.scss | 37 ------- .../common/create-channel-modal.scss | 44 +++++++++ plugins/chat/config/locales/client.en.yml | 4 +- plugins/chat/plugin.rb | 1 + 6 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 plugins/chat/assets/stylesheets/common/create-channel-modal.scss diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js index ae2ed12f48..e7c2bf12ee 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -5,7 +5,7 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; import { action, computed } from "@ember/object"; import { gt, notEmpty } from "@ember/object/computed"; import { inject as service } from "@ember/service"; -import { isBlank } from "@ember/utils"; +import { isBlank, isPresent } from "@ember/utils"; import { htmlSafe } from "@ember/template"; const DEFAULT_HINT = htmlSafe( @@ -39,6 +39,13 @@ export default class CreateChannelController extends Controller.extend( return !this.categorySelected || isBlank(this.name); } + @computed("categorySelected", "name") + get categoryName() { + return this.categorySelected && isPresent(this.name) + ? escapeExpression(this.name) + : null; + } + onShow() { this.set("categoryPermissionsHint", DEFAULT_HINT); } diff --git a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs index 13b2ff45e2..2ed1417f4d 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/modal/create-channel.hbs @@ -1,45 +1,67 @@ - - +
    + + +
    - {{#if this.categoryPermissionsHint}} -
    - {{this.categoryPermissionsHint}} -
    - {{/if}} +
    + + +
    + +
    + + + + {{#if this.categoryPermissionsHint}} +
    + {{this.categoryPermissionsHint}} +
    + {{/if}} +
    {{#if this.autoJoinAvailable}} - +
    + +
    {{/if}} - - - - - -
    \ No newline at end of file +
    diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/common.scss index 4290dda413..0b5597e20f 100644 --- a/plugins/chat/assets/stylesheets/common/common.scss +++ b/plugins/chat/assets/stylesheets/common/common.scss @@ -525,43 +525,6 @@ body.has-full-page-chat { margin-bottom: 0; } -.create-channel-modal { - .modal-inner-container { - width: 500px; - } - .choose-topic-results-list { - max-height: 200px; - overflow-y: scroll; - } - .select-kit.combo-box, - .create-channel-name-input, - .create-channel-description-input, - #choose-topic-title { - width: 100%; - margin-bottom: 0; - } - .category-chooser { - .select-kit-selected-name.selected-name.choice { - color: var( - --primary-high - ); // Make consistent with color of placeholder text when choosing topic - } - } - - .create-channel-hint { - font-size: 0.8em; - margin-top: 0.2em; - } - - .create-channel-label, - label[for="choose-topic-title"] { - margin: 1em 0 0.35em; - } - .chat-channel-title { - margin: 1em 0 0 0; - } -} - .chat-message-collapser, .chat-message-text { > p { diff --git a/plugins/chat/assets/stylesheets/common/create-channel-modal.scss b/plugins/chat/assets/stylesheets/common/create-channel-modal.scss new file mode 100644 index 0000000000..e270668398 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/create-channel-modal.scss @@ -0,0 +1,44 @@ +.create-channel-modal { + .modal-inner-container { + width: 500px; + } + + .choose-topic-results-list { + max-height: 200px; + overflow-y: scroll; + } + + .select-kit.combo-box, + .create-channel-name-input, + .create-channel-description-input, + #choose-topic-title { + width: 100%; + margin-bottom: 0; + } + .category-chooser { + .select-kit-selected-name.selected-name.choice { + color: var( + --primary-high + ); // Make consistent with color of placeholder text when choosing topic + } + } + + .create-channel-hint { + font-size: var(--font-down-1); + padding-top: 0.25rem; + color: var(--secondary-low); + } + + .create-channel-control { + margin-bottom: 1rem; + } + + .auto-join-channel { + &__description { + margin: 0; + padding-top: 0.25rem; + color: var(--secondary-low); + font-size: var(--font-down-1) !important; + } + } +} diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 58e3b4d7ae..6c27e8346c 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -324,8 +324,8 @@ en: channel_wide_mentions_label: "Allow @all and @here mentions" channel_wide_mentions_description: "Allow users to notify all members of #%{channel} with @all or only those who are active in the moment with @here" auto_join_users_label: "Automatically add users" - auto_join_users_info: "Check hourly which users have been active in the last 3 months and, if they have access to the %{category} category, add them to this channel." - enable_auto_join_users: "Automatically add all recently active users" + auto_join_users_info: "Check hourly which users have been active in the last 3 months. Add them to this channel if they have access to the %{category} category." + auto_join_users_info_no_category: "Check hourly which users have been active in the last 3 months. Add them to this channel if they have access to the selected category." auto_join_users_warning: "Every user who isn't a member of this channel and has access to the %{category} category will join. Are you sure?" desktop_notification_level: "Desktop notifications" follow: "Join" diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index b3b5070a19..0766b82cc9 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -13,6 +13,7 @@ register_asset "stylesheets/mixins/chat-scrollbar.scss" register_asset "stylesheets/common/core-extensions.scss" register_asset "stylesheets/common/chat-emoji-picker.scss" register_asset "stylesheets/common/chat-channel-card.scss" +register_asset "stylesheets/common/create-channel-modal.scss" register_asset "stylesheets/common/dc-filter-input.scss" register_asset "stylesheets/common/common.scss" register_asset "stylesheets/common/chat-browse.scss" From 20f5a69427881f688668128e4658d5d6f96a7ac4 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 18 Jan 2023 09:40:38 -0500 Subject: [PATCH 079/169] UX: add missing space and other minor search adjustments (#19899) --- .../app/widgets/search-menu-results.js | 6 ++---- .../stylesheets/common/base/search-menu.scss | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js index 6b4464313d..c30d8d1745 100644 --- a/app/assets/javascripts/discourse/app/widgets/search-menu-results.js +++ b/app/assets/javascripts/discourse/app/widgets/search-menu-results.js @@ -740,8 +740,7 @@ createWidget("search-menu-assistant-item", { // category and tag combination if (attrs.tag && attrs.isIntersection) { attributes.href = getURL(`/tag/${attrs.tag}`); - content.push(iconNode("tag")); - content.push(h("span.search-item-tag", attrs.tag)); + content.push(h("span.search-item-tag", [iconNode("tag"), attrs.tag])); } } else if (attrs.tag) { if (attrs.isIntersection && attrs.additionalTags?.length) { @@ -749,8 +748,7 @@ createWidget("search-menu-assistant-item", { content.push(h("span.search-item-tag", `tags:${tags.join("+")}`)); } else { attributes.href = getURL(`/tag/${attrs.tag}`); - content.push(iconNode("tag")); - content.push(h("span.search-item-tag", attrs.tag)); + content.push(h("span.search-item-tag", [iconNode("tag"), attrs.tag])); } } else if (attrs.user) { const userResult = [ diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 95a2353e03..52c40ceb43 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -194,23 +194,24 @@ $search-pad-horizontal: 0.5em; .search-item-user img.avatar { width: 20px; height: 20px; - margin-right: 5px; + margin-right: 0.5em; } .label-suffix { color: var(--primary-medium); + margin-right: 0.33em; + } + + .search-item-tag { + color: var(--primary-high); } .extra-hint { color: var(--primary-low-mid); font-size: var(--font-down-1); - float: right; - margin-top: 2px; } .search-item-slug { - margin-right: 5px; - .badge-wrapper { font-size: var(--font-0); margin-left: 2px; @@ -230,15 +231,19 @@ $search-pad-horizontal: 0.5em; .search-result-tag, .search-menu-assistant { .search-item-prefix { - padding-right: 5px; + margin-right: 0.33em; + } + .badge-wrapper { + font-size: var(--font-0); + margin-right: 0.5em; } .search-link { display: flex; flex-wrap: wrap; - align-items: center; + align-items: baseline; @include ellipsis; .d-icon { - margin-right: 5px; + margin-right: 0.33em; vertical-align: middle; } .d-icon-tag { From 2b36a9f7b85e99b8e9caac10d60ddba246a2e729 Mon Sep 17 00:00:00 2001 From: Kris Date: Wed, 18 Jan 2023 09:40:56 -0500 Subject: [PATCH 080/169] UX: prevent search context btn text from wrapping (#19904) --- app/assets/stylesheets/common/base/search-menu.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 52c40ceb43..725fa1e43d 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -39,6 +39,7 @@ $search-pad-horizontal: 0.5em; .btn.search-context { margin: 2px; margin-right: 0; + white-space: nowrap; } &:focus-within { @include default-focus; From 60ebbfd7e79384009c832b7cbb5c4621e6920fda Mon Sep 17 00:00:00 2001 From: Rafael dos Santos Silva Date: Wed, 18 Jan 2023 12:04:49 -0300 Subject: [PATCH 081/169] DEV: Stop testing with Ruby 3.2 for now (#19909) --- .github/workflows/tests.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 706057c01b..9374c85d70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,16 +38,12 @@ jobs: matrix: build_type: [backend, frontend, system, annotations] target: [core, plugins] - ruby: ['3.1', '3.2'] + ruby: ['3.1'] exclude: - build_type: annotations target: plugins - build_type: frontend target: core # Handled by core_frontend_tests job (below) - - build_type: frontend - ruby: '3.2' - - build_type: annotations - ruby: '3.2' steps: - uses: actions/checkout@v3 From 3e197deec9f7ff5ffc0d9f9bf56b79c84f5be12b Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 18 Jan 2023 14:55:20 -0500 Subject: [PATCH 082/169] FEATURE: Add `in:polls` filter to search (#19885) Allows users to filter search results to posts with polls (if the plugin is enabled). --- .../initializers/extend-for-poll.js | 5 +++ plugins/poll/plugin.rb | 8 ++++ plugins/poll/spec/lib/search_spec.rb | 42 +++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 plugins/poll/spec/lib/search_spec.rb diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js index a22b322130..109fc1a081 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js @@ -134,6 +134,11 @@ function initializePolls(api) { id: "discourse-poll", }); api.cleanupStream(cleanUpPolls); + + const siteSettings = api.container.lookup("site-settings:main"); + if (siteSettings.poll_enabled) { + api.addSearchSuggestion("in:polls"); + } } export default { diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index b681593027..84b337a823 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -231,4 +231,12 @@ after_initialize do SiteSetting.poll_enabled && scope.user&.id.present? && preloaded_polls.present? && preloaded_polls.any? { |p| p.has_voted?(scope.user) } end + + register_search_advanced_filter(/in:polls/) do |posts, match| + if SiteSetting.poll_enabled + posts.joins(:polls) + else + posts + end + end end diff --git a/plugins/poll/spec/lib/search_spec.rb b/plugins/poll/spec/lib/search_spec.rb new file mode 100644 index 0000000000..e102d9ff05 --- /dev/null +++ b/plugins/poll/spec/lib/search_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe Search do + fab!(:topic) { Fabricate(:topic) } + fab!(:topic2) { Fabricate(:topic) } + fab!(:regular_post) { Fabricate(:post, topic: topic, raw: <<~RAW) } + Somewhere over the rainbow but no poll. + RAW + + fab!(:post_with_poll) { Fabricate(:post, topic: topic2, raw: <<~RAW) } + Somewhere over the rainbow with a poll. + [poll] + * Like + * Dislike + [/poll] + RAW + + before do + SearchIndexer.enable + Jobs.run_immediately! + + SearchIndexer.index(topic2, force: true) + SearchIndexer.index(topic, force: true) + end + + after { SearchIndexer.disable } + + context "when using in:polls" do + it "displays only posts containing polls" do + results = Search.execute("rainbow in:polls", guardian: Guardian.new) + expect(results.posts).to contain_exactly(post_with_poll) + end + end + + context "when polls are disabled" do + it "ignores in:polls filter" do + SiteSetting.poll_enabled = false + results = Search.execute("rainbow in:polls", guardian: Guardian.new) + expect(results.posts).to contain_exactly(regular_post, post_with_poll) + end + end +end From 46adcfa5e9fdec725d4c28d24ad9c01659e5f221 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Thu, 19 Jan 2023 18:35:59 +1000 Subject: [PATCH 083/169] FIX: Do not override channel name when category selected (#19920) --- .../discourse/controllers/create-channel.js | 2 +- plugins/chat/spec/system/create_channel_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js index e7c2bf12ee..9d02436947 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/create-channel.js @@ -156,7 +156,7 @@ export default class CreateChannelController extends Controller.extend( this.setProperties({ categoryId, category, - name: category?.name || "", + name: this.name || category?.name || "", }); } diff --git a/plugins/chat/spec/system/create_channel_spec.rb b/plugins/chat/spec/system/create_channel_spec.rb index d3d4007d47..a525f09371 100644 --- a/plugins/chat/spec/system/create_channel_spec.rb +++ b/plugins/chat/spec/system/create_channel_spec.rb @@ -22,6 +22,16 @@ RSpec.describe "Create channel", type: :system, js: true do expect(find(".create-channel-hint")).to have_content(Group[:everyone].name) end + it "does not override channel name if that was already specified" do + visit("/chat") + find(".new-channel-btn").click + fill_in("channel-name", with: "My Cool Channel") + find(".category-chooser").click + find(".category-row[data-value=\"#{category_1.id}\"]").click + + expect(page).to have_field("channel-name", with: "My Cool Channel") + end + context "when category is private" do fab!(:group_1) { Fabricate(:group) } fab!(:private_category_1) { Fabricate(:private_category, group: group_1) } @@ -95,6 +105,7 @@ RSpec.describe "Create channel", type: :system, js: true do name = "Cats" find(".category-chooser").click find(".category-row[data-value=\"#{category_1.id}\"]").click + expect(page).to have_field("channel-name", with: category_1.name) fill_in("channel-name", with: name) fill_in("channel-description", with: "All kind of cute cats") find(".create-channel-modal .create").click From 998c47cf8213ce1f52fdd5a3e8decc6568b63575 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jan 2023 10:57:59 +0100 Subject: [PATCH 084/169] Build(deps): Bump globalid from 1.0.0 to 1.0.1 (#19914) Bumps [globalid](https://github.com/rails/globalid) from 1.0.0 to 1.0.1. - [Release notes](https://github.com/rails/globalid/releases) - [Commits](https://github.com/rails/globalid/compare/v1.0.0...v1.0.1) --- updated-dependencies: - dependency-name: globalid dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] 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 c9070fbc00..86eec0d310 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,7 +156,7 @@ GEM ffi (1.15.5) fspath (3.1.2) gc_tracer (1.5.1) - globalid (1.0.0) + globalid (1.0.1) activesupport (>= 5.0) guess_html_encoding (0.0.11) hana (1.3.7) From 5406e24acb604f96ed40ed4b74f29652983c2409 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Thu, 19 Jan 2023 13:59:11 +0000 Subject: [PATCH 085/169] FEATURE: Introduce pg_force_readonly_mode GlobalSetting (#19612) This allows the entire cluster to be forced into pg readonly mode. Equivalent to running `Discourse.enable_pg_force_readonly_mode` on the console. --- config/discourse_defaults.conf | 3 +++ config/initializers/002-rails_failover.rb | 6 +++++- lib/discourse.rb | 2 +- spec/lib/discourse_spec.rb | 6 ++++++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/config/discourse_defaults.conf b/config/discourse_defaults.conf index 74b202d064..b5cc812af9 100644 --- a/config/discourse_defaults.conf +++ b/config/discourse_defaults.conf @@ -365,3 +365,6 @@ preload_link_header = false # When using an external upload store, redirect `user_avatar` requests instead of proxying redirect_avatar_requests = false + +# Force the entire cluster into postgres readonly mode. Equivalent to running `Discourse.enable_pg_force_readonly_mode` +pg_force_readonly_mode = false diff --git a/config/initializers/002-rails_failover.rb b/config/initializers/002-rails_failover.rb index 276e6d951b..d5ee5c2da2 100644 --- a/config/initializers/002-rails_failover.rb +++ b/config/initializers/002-rails_failover.rb @@ -69,7 +69,11 @@ if defined?(RailsFailover::ActiveRecord) end RailsFailover::ActiveRecord.register_force_reading_role_callback do - Discourse.redis.exists?(Discourse::PG_READONLY_MODE_KEY, Discourse::PG_FORCE_READONLY_MODE_KEY) + GlobalSetting.pg_force_readonly_mode || + Discourse.redis.exists?( + Discourse::PG_READONLY_MODE_KEY, + Discourse::PG_FORCE_READONLY_MODE_KEY, + ) rescue => e if !e.is_a?(Redis::CannotConnectError) Rails.logger.warn "#{e.class} #{e.message}: #{e.backtrace.join("\n")}" diff --git a/lib/discourse.rb b/lib/discourse.rb index 0fa1598d3c..dfb3a0cc06 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -691,7 +691,7 @@ module Discourse end def self.readonly_mode?(keys = READONLY_KEYS) - recently_readonly? || Discourse.redis.exists?(*keys) + recently_readonly? || GlobalSetting.pg_force_readonly_mode || Discourse.redis.exists?(*keys) end def self.staff_writes_only_mode? diff --git a/spec/lib/discourse_spec.rb b/spec/lib/discourse_spec.rb index 1a4227fe11..c0321ec6d7 100644 --- a/spec/lib/discourse_spec.rb +++ b/spec/lib/discourse_spec.rb @@ -300,6 +300,12 @@ RSpec.describe Discourse do Discourse.disable_readonly_mode(user_readonly_mode_key) expect(Discourse.readonly_mode?).to eq(false) end + + it "returns true when forced via global setting" do + expect(Discourse.readonly_mode?).to eq(false) + global_setting :pg_force_readonly_mode, true + expect(Discourse.readonly_mode?).to eq(true) + end end describe ".received_postgres_readonly!" do From cc39effe0e59546852767b0e50142b9d51d754f5 Mon Sep 17 00:00:00 2001 From: Selase Krakani <849886+s3lase@users.noreply.github.com> Date: Thu, 19 Jan 2023 16:07:59 +0000 Subject: [PATCH 086/169] FIX: Switch email domain site settings type to host_list (#19922) Specifying wildcard characters which also happen to be regex meta characters for `auto_approve_email_domains`, `allowed_email_domains` and `blocked_email_domains` site settings currently breaks email validation. This change prevents these characters from being specified for these site settings. It does this by switching the site setting type from `list` to `host_list`. The `host_list` validator checks for these characters. In addition, this change also improves the site setting descriptions and introduces a migration to fix existing records. --- config/locales/server.en.yml | 6 +++--- config/site_settings.yml | 6 +++--- ...ildcard_from_email_domain_site_settings.rb | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20230119094939_remove_wildcard_from_email_domain_site_settings.rb diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 2b656f5ec0..88121e6d87 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1682,10 +1682,10 @@ en: whispers_allowed_groups: "Allow private communication within topics for members of specified groups." 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." - blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" - allowed_email_domains: "A pipe-delimited list of email domains that users MUST register accounts with. WARNING: Users with email domains other than those listed will not be allowed!" + blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Subdomains are automatically handled for the specified domains. Wildcard symbols * and ? are not supported. Example: mailinator.com|trashmail.net" + allowed_email_domains: "A pipe-delimited list of email domains that users MUST register accounts with. Subdomains are automatically handled for the specified domains. Wildcard symbols * and ? are not supported. WARNING: Users with email domains other than those listed will not be allowed!" normalize_emails: "Check if normalized email is unique. Normalized email removes all dots from the username and everything between + and @ symbols." - auto_approve_email_domains: "Users with email addresses from this list of domains will be automatically approved." + auto_approve_email_domains: "Users with email addresses from this list of domains will be automatically approved. Subdomains are automatically handled for the specified domains. Wildcard symbols * and ? are not supported." hide_email_address_taken: "Don't inform users that an account exists with a given email address during signup or during forgot password flow. Require full email for 'forgotten password' requests." log_out_strict: "When logging out, log out ALL sessions for the user on all devices" version_checks: "Ping the Discourse Hub for version updates and show new version messages on the /admin dashboard" diff --git a/config/site_settings.yml b/config/site_settings.yml index 5e917c9d9b..e565dc1725 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -532,17 +532,17 @@ login: value: "sso_provider.value_placeholder" blocked_email_domains: default: "mailinator.com" - type: list + type: host_list list_type: simple allowed_email_domains: default: "" - type: list + type: host_list list_type: simple normalize_emails: default: false auto_approve_email_domains: default: "" - type: list + type: host_list list_type: simple hide_email_address_taken: client: true diff --git a/db/migrate/20230119094939_remove_wildcard_from_email_domain_site_settings.rb b/db/migrate/20230119094939_remove_wildcard_from_email_domain_site_settings.rb new file mode 100644 index 0000000000..77a0d4cc48 --- /dev/null +++ b/db/migrate/20230119094939_remove_wildcard_from_email_domain_site_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveWildcardFromEmailDomainSiteSettings < ActiveRecord::Migration[7.0] + def up + execute <<~'SQL' + UPDATE site_settings + SET value = regexp_replace(value, '\*(\.)?|\?', '', 'g') + WHERE name IN ( + 'auto_approve_email_domains', + 'allowed_email_domains', + 'blocked_email_domains' + ) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end From f66e798ed74cb39cdcc03b007c5e227d359cb320 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 19 Jan 2023 12:45:45 -0500 Subject: [PATCH 087/169] A11Y: more descriptive user page titles (#19819) --- .../discourse/app/controllers/preferences/emails.js | 1 + .../app/controllers/preferences/interface.js | 1 + .../app/controllers/preferences/notifications.js | 2 ++ .../discourse/app/controllers/preferences/profile.js | 1 + .../app/controllers/preferences/security.js | 2 +- .../discourse/app/controllers/preferences/sidebar.js | 1 + .../discourse/app/routes/preferences-account.js | 2 ++ .../javascripts/discourse/app/routes/preferences.js | 12 ++++++++++++ .../discourse/app/routes/user-activity-bookmarks.js | 5 +++++ .../discourse/app/routes/user-activity-drafts.js | 4 ++++ .../discourse/app/routes/user-activity-index.js | 4 ++++ .../app/routes/user-activity-likes-given.js | 4 ++++ .../discourse/app/routes/user-activity-read.js | 4 ++++ .../discourse/app/routes/user-activity-replies.js | 4 ++++ .../discourse/app/routes/user-activity-topics.js | 4 ++++ .../discourse/app/routes/user-activity.js | 5 +++++ .../javascripts/discourse/app/routes/user-badges.js | 5 +++++ .../discourse/app/routes/user-invited-show.js | 5 +++++ .../javascripts/discourse/app/routes/user-invited.js | 5 +++++ .../discourse/app/routes/user-notifications-edits.js | 5 +++++ .../discourse/app/routes/user-notifications-index.js | 5 +++++ .../app/routes/user-notifications-likes-received.js | 5 +++++ .../app/routes/user-notifications-mentions.js | 5 +++++ .../app/routes/user-notifications-responses.js | 5 +++++ .../discourse/app/routes/user-notifications.js | 5 +++++ .../javascripts/discourse/app/routes/user-summary.js | 5 +++++ app/assets/javascripts/discourse/app/routes/user.js | 5 +---- .../discourse/controllers/preferences-chat.js | 1 + 28 files changed, 107 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/emails.js b/app/assets/javascripts/discourse/app/controllers/preferences/emails.js index a332882af5..c370e6f3d1 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/emails.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/emails.js @@ -11,6 +11,7 @@ const EMAIL_LEVELS = { }; export default Controller.extend({ + subpageTitle: I18n.t("user.preferences_nav.emails"), emailMessagesLevelAway: equal( "model.user_option.email_messages_level", EMAIL_LEVELS.ONLY_WHEN_AWAY diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js index d1e2c25382..92c3d46c03 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/interface.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/interface.js @@ -36,6 +36,7 @@ export default Controller.extend({ preferencesController: controller("preferences"), makeColorSchemeDefault: true, canPreviewColorScheme: propertyEqual("model.id", "currentUser.id"), + subpageTitle: I18n.t("user.preferences_nav.interface"), init() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js b/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js index 6db87bbe84..468e5052b6 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/notifications.js @@ -5,6 +5,8 @@ import { NotificationLevels } from "discourse/lib/notification-levels"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend({ + subpageTitle: I18n.t("user.preferences_nav.notifications"), + init() { this._super(...arguments); diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js index f5db9651ec..303bfe3234 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js @@ -12,6 +12,7 @@ import { inject as service } from "@ember/service"; export default Controller.extend({ dialog: service(), + subpageTitle: I18n.t("user.preferences_nav.profile"), init() { this._super(...arguments); this.saveAttrNames = [ diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/security.js b/app/assets/javascripts/discourse/app/controllers/preferences/security.js index 800d597c65..932235db30 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/security.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/security.js @@ -15,7 +15,7 @@ const DEFAULT_AUTH_TOKENS_COUNT = 2; export default Controller.extend(CanCheckEmails, { passwordProgress: null, - + subpageTitle: I18n.t("user.preferences_nav.security"), showAllAuthTokens: false, @discourseComputed("model.is_anonymous") diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js b/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js index 8a2ac60722..101a29dc18 100644 --- a/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js +++ b/app/assets/javascripts/discourse/app/controllers/preferences/sidebar.js @@ -12,6 +12,7 @@ export default class extends Controller { @tracked saved = false; @tracked selectedSidebarCategories = []; @tracked selectedSidebarTagNames = []; + subpageTitle = I18n.t("user.preferences_nav.sidebar"); saveAttrNames = [ "sidebar_category_ids", diff --git a/app/assets/javascripts/discourse/app/routes/preferences-account.js b/app/assets/javascripts/discourse/app/routes/preferences-account.js index e7516e2f20..032ef98f34 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences-account.js +++ b/app/assets/javascripts/discourse/app/routes/preferences-account.js @@ -2,6 +2,7 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; import UserBadge from "discourse/models/user-badge"; import showModal from "discourse/lib/show-modal"; import { action } from "@ember/object"; +import I18n from "I18n"; export default RestrictedUserRoute.extend({ showFooter: true, @@ -32,6 +33,7 @@ export default RestrictedUserRoute.extend({ newPrimaryGroupInput: user.get("primary_group_id"), newFlairGroupId: user.get("flair_group_id"), newStatus: user.status, + subpageTitle: I18n.t("user.preferences_nav.account"), }); }, diff --git a/app/assets/javascripts/discourse/app/routes/preferences.js b/app/assets/javascripts/discourse/app/routes/preferences.js index 8c157859da..83bc53b2f2 100644 --- a/app/assets/javascripts/discourse/app/routes/preferences.js +++ b/app/assets/javascripts/discourse/app/routes/preferences.js @@ -1,7 +1,19 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; +import I18n from "I18n"; +import { inject as service } from "@ember/service"; export default RestrictedUserRoute.extend({ + router: service(), + model() { return this.modelFor("user"); }, + + titleToken() { + let controller = this.controllerFor(this.router.currentRouteName); + let subpageTitle = controller?.subpageTitle; + return subpageTitle + ? `${subpageTitle} - ${I18n.t("user.preferences")}` + : I18n.t("user.preferences"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-bookmarks.js b/app/assets/javascripts/discourse/app/routes/user-activity-bookmarks.js index a29d1124f4..af88f4f95c 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-bookmarks.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-bookmarks.js @@ -2,6 +2,7 @@ import { action } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import DiscourseRoute from "discourse/routes/discourse"; import { Promise } from "rsvp"; +import I18n from "I18n"; export default DiscourseRoute.extend({ queryParams: { @@ -50,6 +51,10 @@ export default DiscourseRoute.extend({ this.render("user_bookmarks"); }, + titleToken() { + return I18n.t("user_action_groups.3"); + }, + @action didTransition() { this.controllerFor("user-activity")._showFooter(); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js b/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js index 5a5d30507b..c73975624e 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js @@ -38,6 +38,10 @@ export default DiscourseRoute.extend({ this.appEvents.off("draft:destroyed", this, this.refresh); }, + titleToken() { + return I18n.t("user_action_groups.15"); + }, + @action didTransition() { this.controllerFor("user-activity")._showFooter(); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-index.js b/app/assets/javascripts/discourse/app/routes/user-activity-index.js index 88bb0b37cd..4907997ed0 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-index.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-index.js @@ -25,4 +25,8 @@ export default UserActivityStreamRoute.extend({ return { title, body }; }, + + titleToken() { + return I18n.t("user.filters.all"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-likes-given.js b/app/assets/javascripts/discourse/app/routes/user-activity-likes-given.js index 901c890e24..0965c24d57 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-likes-given.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-likes-given.js @@ -25,6 +25,10 @@ export default UserActivityStreamRoute.extend({ return { title, body }; }, + titleToken() { + return I18n.t("user_action_groups.1"); + }, + @action didTransition() { this.controllerFor("application").set("showFooter", true); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-read.js b/app/assets/javascripts/discourse/app/routes/user-activity-read.js index 5746b96d1a..c988fad68e 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-read.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-read.js @@ -42,6 +42,10 @@ export default UserTopicListRoute.extend({ return { title, body }; }, + titleToken() { + return `${I18n.t("user.read")}`; + }, + @action triggerRefresh() { this.refresh(); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-replies.js b/app/assets/javascripts/discourse/app/routes/user-activity-replies.js index 2db4981c4f..04ab75c18e 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-replies.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-replies.js @@ -29,6 +29,10 @@ export default UserActivityStreamRoute.extend({ return { title, body }; }, + titleToken() { + return I18n.t("user_action_groups.5"); + }, + @action didTransition() { this.controllerFor("application").set("showFooter", true); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-topics.js b/app/assets/javascripts/discourse/app/routes/user-activity-topics.js index 7059e4acb8..6e02037447 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity-topics.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity-topics.js @@ -50,6 +50,10 @@ export default UserTopicListRoute.extend({ return { title, body }; }, + titleToken() { + return I18n.t("user_action_groups.4"); + }, + @action triggerRefresh() { this.refresh(); diff --git a/app/assets/javascripts/discourse/app/routes/user-activity.js b/app/assets/javascripts/discourse/app/routes/user-activity.js index 9a94a47d8d..2638e1d7af 100644 --- a/app/assets/javascripts/discourse/app/routes/user-activity.js +++ b/app/assets/javascripts/discourse/app/routes/user-activity.js @@ -1,4 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; export default DiscourseRoute.extend({ model() { @@ -19,4 +20,8 @@ export default DiscourseRoute.extend({ setupController(controller, user) { this.controllerFor("user-activity").set("model", user); }, + + titleToken() { + return I18n.t("user.activity_stream"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-badges.js b/app/assets/javascripts/discourse/app/routes/user-badges.js index 6feb940c50..e604e7ccb3 100644 --- a/app/assets/javascripts/discourse/app/routes/user-badges.js +++ b/app/assets/javascripts/discourse/app/routes/user-badges.js @@ -2,6 +2,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import UserBadge from "discourse/models/user-badge"; import ViewingActionType from "discourse/mixins/viewing-action-type"; import { action } from "@ember/object"; +import I18n from "I18n"; export default DiscourseRoute.extend(ViewingActionType, { model() { @@ -20,6 +21,10 @@ export default DiscourseRoute.extend(ViewingActionType, { this.render("user/badges"); }, + titleToken() { + return I18n.t("badges.title"); + }, + @action didTransition() { this.controllerFor("application").set("showFooter", true); diff --git a/app/assets/javascripts/discourse/app/routes/user-invited-show.js b/app/assets/javascripts/discourse/app/routes/user-invited-show.js index af6f4a3eeb..bfa5ce2136 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited-show.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited-show.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import Invite from "discourse/models/invite"; import { action } from "@ember/object"; +import I18n from "I18n"; export default DiscourseRoute.extend({ model(params) { @@ -27,6 +28,10 @@ export default DiscourseRoute.extend({ }); }, + titleToken() { + return I18n.t("user.invited." + this.inviteFilter + "_tab"); + }, + @action triggerRefresh() { this.refresh(); diff --git a/app/assets/javascripts/discourse/app/routes/user-invited.js b/app/assets/javascripts/discourse/app/routes/user-invited.js index ae432d3ecc..11053e1660 100644 --- a/app/assets/javascripts/discourse/app/routes/user-invited.js +++ b/app/assets/javascripts/discourse/app/routes/user-invited.js @@ -1,4 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; export default DiscourseRoute.extend({ setupController(controller) { @@ -10,4 +11,8 @@ export default DiscourseRoute.extend({ can_see_invite_details, }); }, + + titleToken() { + return I18n.t("user.invited.title"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications-edits.js b/app/assets/javascripts/discourse/app/routes/user-notifications-edits.js index 8be7b15b7b..90b469a3c4 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications-edits.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications-edits.js @@ -1,6 +1,11 @@ import UserAction from "discourse/models/user-action"; import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import I18n from "I18n"; export default UserActivityStreamRoute.extend({ userActionType: UserAction.TYPES["edits"], + + titleToken() { + return I18n.t("user_action_groups.11"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications-index.js b/app/assets/javascripts/discourse/app/routes/user-notifications-index.js index 45af6681c7..99709d47de 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications-index.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications-index.js @@ -1,4 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; export default DiscourseRoute.extend({ controllerName: "user-notifications", @@ -6,6 +7,10 @@ export default DiscourseRoute.extend({ this.render("user/notifications-index"); }, + titleToken() { + return I18n.t("user.filters.all"); + }, + afterModel(model) { if (!model) { this.transitionTo("userNotifications.responses"); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications-likes-received.js b/app/assets/javascripts/discourse/app/routes/user-notifications-likes-received.js index 1bfaa46b84..c913ae16df 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications-likes-received.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications-likes-received.js @@ -1,6 +1,11 @@ import UserAction from "discourse/models/user-action"; import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import I18n from "I18n"; export default UserActivityStreamRoute.extend({ userActionType: UserAction.TYPES["likes_received"], + + titleToken() { + return I18n.t("user_action_groups.1"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications-mentions.js b/app/assets/javascripts/discourse/app/routes/user-notifications-mentions.js index 2d8c2622da..449a2dbf33 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications-mentions.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications-mentions.js @@ -1,6 +1,11 @@ import UserAction from "discourse/models/user-action"; import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import I18n from "I18n"; export default UserActivityStreamRoute.extend({ userActionType: UserAction.TYPES["mentions"], + + titleToken() { + return I18n.t("user_action_groups.7"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications-responses.js b/app/assets/javascripts/discourse/app/routes/user-notifications-responses.js index 52604ccdc5..12f43ff2c6 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications-responses.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications-responses.js @@ -1,6 +1,11 @@ import UserAction from "discourse/models/user-action"; import UserActivityStreamRoute from "discourse/routes/user-activity-stream"; +import I18n from "I18n"; export default UserActivityStreamRoute.extend({ userActionType: UserAction.TYPES["replies"], + + titleToken() { + return I18n.t("user_action_groups.6"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-notifications.js b/app/assets/javascripts/discourse/app/routes/user-notifications.js index aeb118579c..aa93fa7a8a 100644 --- a/app/assets/javascripts/discourse/app/routes/user-notifications.js +++ b/app/assets/javascripts/discourse/app/routes/user-notifications.js @@ -1,6 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import ViewingActionType from "discourse/mixins/viewing-action-type"; import { action } from "@ember/object"; +import I18n from "I18n"; export default DiscourseRoute.extend(ViewingActionType, { controllerName: "user-notifications", @@ -31,4 +32,8 @@ export default DiscourseRoute.extend(ViewingActionType, { controller.set("user", this.modelFor("user")); this.viewingActionType(-1); }, + + titleToken() { + return I18n.t("user.notifications"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user-summary.js b/app/assets/javascripts/discourse/app/routes/user-summary.js index d7fefead28..7f06eabd8c 100644 --- a/app/assets/javascripts/discourse/app/routes/user-summary.js +++ b/app/assets/javascripts/discourse/app/routes/user-summary.js @@ -1,4 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "I18n"; export default DiscourseRoute.extend({ showFooter: true, @@ -11,4 +12,8 @@ export default DiscourseRoute.extend({ return user.summary(); }, + + titleToken() { + return I18n.t("user.summary.title"); + }, }); diff --git a/app/assets/javascripts/discourse/app/routes/user.js b/app/assets/javascripts/discourse/app/routes/user.js index b3efdef687..aa0af01979 100644 --- a/app/assets/javascripts/discourse/app/routes/user.js +++ b/app/assets/javascripts/discourse/app/routes/user.js @@ -1,5 +1,4 @@ import DiscourseRoute from "discourse/routes/discourse"; -import I18n from "I18n"; import User from "discourse/models/user"; import { action } from "@ember/object"; import { bind } from "discourse-common/utils/decorators"; @@ -98,9 +97,7 @@ export default DiscourseRoute.extend({ titleToken() { const username = this.modelFor("user").username; - if (username) { - return [I18n.t("user.profile"), username]; - } + return username ? username : null; }, @action diff --git a/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js index bf4ef8a8ae..5774f44b4b 100644 --- a/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js +++ b/plugins/chat/assets/javascripts/discourse/controllers/preferences-chat.js @@ -22,6 +22,7 @@ const EMAIL_FREQUENCY_OPTIONS = [ export default class PreferencesChatController extends Controller { @service chatAudioManager; + subpageTitle = I18n.t("chat.admin.title"); emailFrequencyOptions = EMAIL_FREQUENCY_OPTIONS; From 2fb2b0a5383b51fcb7e5a3392b40e2c960ebf297 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 19 Jan 2023 12:48:58 -0500 Subject: [PATCH 088/169] UX: switch categories-boxes layouts from flexbox to grid (#19501) --- app/assets/stylesheets/common/base/category-list.scss | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/common/base/category-list.scss b/app/assets/stylesheets/common/base/category-list.scss index d6c4154eb6..80eedde014 100644 --- a/app/assets/stylesheets/common/base/category-list.scss +++ b/app/assets/stylesheets/common/base/category-list.scss @@ -43,9 +43,8 @@ .category-boxes, .category-boxes-with-topics { - display: flex; - flex-wrap: wrap; - justify-content: flex-start; + display: grid; + gap: 1.5em; margin-top: 1em; margin-bottom: 1em; width: 100%; @@ -117,9 +116,8 @@ } .category-boxes { + grid-template-columns: repeat(auto-fit, minmax(15em, 1fr)); .category-box { - width: 23%; - margin: 0 1% 1.5em 1%; > a { width: 100%; padding: 0; @@ -253,9 +251,8 @@ } .category-boxes-with-topics { + grid-template-columns: repeat(auto-fit, minmax(18em, 1fr)); .category-box { - width: 31%; - margin: 0 1% 1.5em 1%; padding: 0; } From 292d3677e95300088307b4c85672ac3ab9c4ddd2 Mon Sep 17 00:00:00 2001 From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com> Date: Thu, 19 Jan 2023 15:09:01 -0600 Subject: [PATCH 089/169] FEATURE: Allow admins to permanently delete revisions (#19913) # Context This PR introduces the ability to permanently delete revisions from a post while maintaining the changes implemented by the revisions. Additional Context: /t/90301 # Functionality In the case a staff member wants to _remove the visual cue_ that a post has been edited eg. Screenshot 2023-01-18 at 2 59 12 PM while maintaining the changes made in the edits, they can enable the (hidden) site setting of `can_permanently_delete`. When this is enabled, after _hiding_ the revisions Screenshot 2023-01-19 at 1 53 35 PM there will be an additional button in the history modal to Delete revisions on a post. Screenshot 2023-01-19 at 1 49 51 PM Since this action is permanent, we display a confirmation dialog prior to triggering the destroy call Screenshot 2023-01-19 at 1 55 59 PM Once confirmed the history modal will close and the post will `rebake` to display an _unedited_ post. Screenshot 2023-01-19 at 1 56 35 PM see that there is not a visual que for _revision have been made on this post_ for a post that **HAS** been edited. In addition to this, a user history log for `purge_post_revisions` will be added for each action completed. # Limits - Admins are rate limited to 20 posts per minute --- .../discourse/app/controllers/history.js | 24 +++++++ .../javascripts/discourse/app/models/post.js | 6 ++ .../discourse/app/templates/modal/history.hbs | 12 +++- app/controllers/posts_controller.rb | 27 +++++++ app/models/user_history.rb | 2 + app/services/staff_action_logger.rb | 10 +++ config/locales/client.en.yml | 4 +- config/routes.rb | 1 + lib/guardian/post_revision_guardian.rb | 4 ++ spec/requests/posts_controller_spec.rb | 70 +++++++++++++++++++ 10 files changed, 158 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/discourse/app/controllers/history.js b/app/assets/javascripts/discourse/app/controllers/history.js index 00e64f86da..0d428b226b 100644 --- a/app/assets/javascripts/discourse/app/controllers/history.js +++ b/app/assets/javascripts/discourse/app/controllers/history.js @@ -114,6 +114,17 @@ export default Controller.extend(ModalFunctionality, { ); }, + permanentlyDeleteRevisions(postId) { + this.dialog.yesNoConfirm({ + message: I18n.t("post.revisions.controls.destroy_confirm"), + didConfirm: () => { + Post.permanentlyDeleteRevisions(postId).then(() => { + this.send("closeModal"); + }); + }, + }); + }, + show(postId, postVersion) { Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion) @@ -162,6 +173,7 @@ export default Controller.extend(ModalFunctionality, { }, displayRevisions: gt("model.version_count", 2), + displayGoToFirst: propertyGreaterThan( "model.current_revision", "model.first_revision" @@ -215,6 +227,15 @@ export default Controller.extend(ModalFunctionality, { return this.currentUser && this.currentUser.get("staff"); }, + @discourseComputed("model.previous_hidden") + displayPermanentlyDeleteButton(previousHidden) { + return ( + this.siteSettings.can_permanently_delete && + this.currentUser?.staff && + previousHidden + ); + }, + isEitherRevisionHidden: or("model.previous_hidden", "model.current_hidden"), @discourseComputed( @@ -352,6 +373,9 @@ export default Controller.extend(ModalFunctionality, { hideVersion() { this.hide(this.get("model.post_id"), this.get("model.current_revision")); }, + permanentlyDeleteVersions() { + this.permanentlyDeleteRevisions(this.get("model.post_id")); + }, showVersion() { this.show(this.get("model.post_id"), this.get("model.current_revision")); }, diff --git a/app/assets/javascripts/discourse/app/models/post.js b/app/assets/javascripts/discourse/app/models/post.js index 25a8f40168..1f9f1573dd 100644 --- a/app/assets/javascripts/discourse/app/models/post.js +++ b/app/assets/javascripts/discourse/app/models/post.js @@ -461,6 +461,12 @@ Post.reopenClass({ }); }, + permanentlyDeleteRevisions(postId) { + return ajax(`/posts/${postId}/revisions/permanently_delete`, { + type: "DELETE", + }); + }, + showRevision(postId, version) { return ajax(`/posts/${postId}/revisions/${version}/show`, { type: "PUT", diff --git a/app/assets/javascripts/discourse/app/templates/modal/history.hbs b/app/assets/javascripts/discourse/app/templates/modal/history.hbs index 4a55aae787..175a3dbc1f 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/history.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/history.hbs @@ -250,6 +250,16 @@ @disabled={{this.loading}} /> {{/if}} + + {{#if this.displayPermanentlyDeleteButton}} + + {{/if}}
    -{{/if}} \ No newline at end of file +{{/if}} diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b5c9ad2e0e..a9f061d426 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -466,6 +466,33 @@ class PostsController < ApplicationController render body: nil end + def permanently_delete_revisions + guardian.ensure_can_permanently_delete_post_revisions! + + post = find_post_from_params + raise Discourse::InvalidParameters.new(:post) if post.blank? + raise Discourse::NotFound unless post.revisions.present? + + RateLimiter.new( + current_user, + "admin_permanently_delete_post_revisions", + 20, + 1.minute, + apply_limit_to_staff: true, + ).performed! + + ActiveRecord::Base.transaction do + updated_at = Time.zone.now + post.revisions.destroy_all + post.update(version: 1, public_version: 1, last_version_at: updated_at) + StaffActionLogger.new(current_user).log_permanently_delete_post_revisions(post) + end + + post.rebake! + + render body: nil + end + def show_revision post_revision = find_post_revision_from_params guardian.ensure_can_show_post_revision!(post_revision) diff --git a/app/models/user_history.rb b/app/models/user_history.rb index e3a98fe9b2..53cae31249 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -119,6 +119,7 @@ class UserHistory < ActiveRecord::Base watched_word_create: 97, watched_word_destroy: 98, delete_group: 99, + permanently_delete_post_revisions: 100, ) end @@ -213,6 +214,7 @@ class UserHistory < ActiveRecord::Base watched_word_create watched_word_destroy delete_group + permanently_delete_post_revisions ] end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index 70c937e561..9de363d855 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -954,6 +954,16 @@ class StaffActionLogger ) end + def log_permanently_delete_post_revisions(post) + raise Discourse::InvalidParameters.new(:post) if post.nil? + + UserHistory.create!( + action: UserHistory.actions[:permanently_delete_post_revisions], + acting_user_id: @admin.id, + post_id: post.id, + ) + end + private def get_changes(changes) diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 308b01f608..3f3c0a7145 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1121,7 +1121,7 @@ en: perm_denied_expl: "You denied permission for notifications. Allow notifications via your browser settings." disable: "Disable Notifications" enable: "Enable Notifications" - each_browser_note: 'Note: You have to change this setting on every browser you use. All notifications will be disabled if you pause notifications from user menu, regardless of this setting.' + each_browser_note: "Note: You have to change this setting on every browser you use. All notifications will be disabled if you pause notifications from user menu, regardless of this setting." consent_prompt: "Do you want live notifications when people reply to your posts?" dismiss: "Dismiss" dismiss_notifications: "Dismiss All" @@ -3548,6 +3548,8 @@ en: last: "Last revision" hide: "Hide revision" show: "Show revision" + destroy: "Delete revisions" + destroy_confirm: "Are you sure you want to delete all of the revisions on this post? This action is permanent." revert: "Revert to revision %{revision}" edit_wiki: "Edit Wiki" edit_post: "Edit Post" diff --git a/config/routes.rb b/config/routes.rb index 8531638a99..606fffd6d5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1082,6 +1082,7 @@ Discourse::Application.routes.draw do put "revisions/:revision/hide" => "posts#hide_revision", :constraints => { revision: /\d+/ } put "revisions/:revision/show" => "posts#show_revision", :constraints => { revision: /\d+/ } put "revisions/:revision/revert" => "posts#revert", :constraints => { revision: /\d+/ } + delete "revisions/permanently_delete" => "posts#permanently_delete_revisions" put "recover" collection do delete "destroy_many" diff --git a/lib/guardian/post_revision_guardian.rb b/lib/guardian/post_revision_guardian.rb index 1e61b19e74..9d2108ba2e 100644 --- a/lib/guardian/post_revision_guardian.rb +++ b/lib/guardian/post_revision_guardian.rb @@ -13,6 +13,10 @@ module PostRevisionGuardian is_staff? end + def can_permanently_delete_post_revisions? + is_staff? && SiteSetting.can_permanently_delete + end + def can_show_post_revision?(post_revision) is_staff? end diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index a8e12fcf0b..3feb0b52b4 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -2049,6 +2049,76 @@ RSpec.describe PostsController do end end + describe "#permanently_delete_revisions" do + before { SiteSetting.can_permanently_delete = true } + + fab!(:post) do + Fabricate( + :post, + user: Fabricate(:user), + raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", + ) + end + + fab!(:post_with_no_revisions) do + Fabricate( + :post, + user: Fabricate(:user), + raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", + ) + end + + fab!(:post_revision) { Fabricate(:post_revision, post: post) } + fab!(:post_revision_2) { Fabricate(:post_revision, post: post) } + + let(:post_id) { post.id } + + describe "when logged in as a regular user" do + it "does not delete revisions" do + sign_in(user) + delete "/posts/#{post_id}/revisions/permanently_delete.json" + expect(response).to_not be_successful + end + end + + describe "when logged in as staff" do + before { sign_in(admin) } + + it "fails when post record is not found" do + delete "/posts/#{post_id + 1}/revisions/permanently_delete.json" + expect(response).to_not be_successful + end + + it "fails when no post revisions are found" do + delete "/posts/#{post_with_no_revisions.id}/revisions/permanently_delete.json" + expect(response).to_not be_successful + end + + it "fails when 'can_permanently_delete' setting is false" do + SiteSetting.can_permanently_delete = false + delete "/posts/#{post_id}/revisions/permanently_delete.json" + expect(response).to_not be_successful + end + + it "permanently deletes revisions from post and adds a staff log" do + delete "/posts/#{post_id}/revisions/permanently_delete.json" + expect(response.status).to eq(200) + + # It creates a staff log + logs = + UserHistory.find_by( + action: UserHistory.actions[:permanently_delete_post_revisions], + acting_user_id: admin.id, + post_id: post_id, + ) + expect(logs).to be_present + + # ensure post revisions are deleted + expect(PostRevision.where(post: post)).to eq([]) + end + end + end + describe "#revert" do include_examples "action requires login", :put, "/posts/123/revisions/2/revert.json" From 6aae64d6f8c117833cc1cfa77aeb4720cc72ac71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Jan 2023 23:41:05 +0100 Subject: [PATCH 090/169] Build(deps): Bump rubocop-rspec from 2.18.0 to 2.18.1 (#19924) Bumps [rubocop-rspec](https://github.com/rubocop/rubocop-rspec) from 2.18.0 to 2.18.1. - [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.18.0...v2.18.1) --- updated-dependencies: - dependency-name: rubocop-rspec dependency-type: indirect update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 86eec0d310..5845c1f6ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -413,9 +413,9 @@ GEM rubocop-discourse (3.0.3) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.18.0) + rubocop-rspec (2.18.1) rubocop (~> 1.33) - rubocop-capybara + rubocop-capybara (~> 2.17) ruby-prof (1.4.5) ruby-progressbar (1.11.0) ruby-readability (0.7.0) From b00e160dae0aa3bd72e45179794837cb95bafc31 Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 20 Jan 2023 07:58:00 +0800 Subject: [PATCH 091/169] PERF: Don't parse posts for mentions when user status is disabled (#19915) Prior to this change, we were parsing `Post#cooked` every time we serialize a post to extract the usernames of mentioned users in the post. However, the only reason we have to do this is to support displaying a user's status beside each mention in a post on the client side when the `enable_user_status` site setting is enabled. When `enable_user_status` is disabled, we should avoid having to parse `Post#cooked` since there is no point in doing so. --- app/serializers/post_serializer.rb | 15 ++++-- lib/topic_view.rb | 27 +++++------ spec/requests/posts_controller_spec.rb | 64 ++++++++++--------------- spec/requests/topics_controller_spec.rb | 49 ++++++++----------- 4 files changed, 69 insertions(+), 86 deletions(-) diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 78e559c031..0b35974241 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -561,15 +561,20 @@ class PostSerializer < BasicPostSerializer end def mentioned_users - if @topic_view && (mentions = @topic_view.mentions[object.id]) - users = mentions.map { |username| @topic_view.mentioned_users[username] }.compact - else - users = User.where(username: object.mentions) - end + users = + if @topic_view && (mentioned_users = @topic_view.mentioned_users[object.id]) + mentioned_users + else + User.where(username: object.mentions) + end users.map { |user| BasicUserWithStatusSerializer.new(user, root: false) } end + def include_mentioned_users? + SiteSetting.enable_user_status + end + private def can_review_topic? diff --git a/lib/topic_view.rb b/lib/topic_view.rb index ebfc6d04c7..f6b9c19259 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -31,8 +31,6 @@ class TopicView :personal_message, :can_review_topic, :page, - :mentioned_users, - :mentions, ) alias queued_posts_enabled? queued_posts_enabled @@ -144,9 +142,6 @@ class TopicView end end - parse_mentions - load_mentioned_users - TopicView.preload(self) @draft_key = @topic.draft_key @@ -700,17 +695,21 @@ class TopicView @topic.published_page end - def parse_mentions - @mentions = @posts.to_h { |p| [p.id, p.mentions] }.reject { |_, v| v.empty? } - end + def mentioned_users + @mentioned_users ||= + begin + mentions = @posts.to_h { |p| [p.id, p.mentions] }.reject { |_, v| v.empty? } + usernames = mentions.values + usernames.flatten! + usernames.uniq! - def load_mentioned_users - usernames = @mentions.values.flatten.uniq - mentioned_users = User.where(username: usernames) + users = User.where(username: usernames).includes(:user_status).index_by(&:username) - mentioned_users = mentioned_users.includes(:user_status) if SiteSetting.enable_user_status - - @mentioned_users = mentioned_users.to_h { |u| [u.username, u] } + mentions.reduce({}) do |hash, (post_id, post_mentioned_usernames)| + hash[post_id] = post_mentioned_usernames.map { |username| users[username] }.compact + hash + end + end end def tags diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index 3feb0b52b4..ad5310dd38 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -1578,10 +1578,30 @@ RSpec.describe PostsController do end end - context "with mentions" do + context "when `enable_user_status` site setting is enabled" do fab!(:user_to_mention) { Fabricate(:user) } + before { SiteSetting.enable_user_status = true } + + it "does not return mentioned users when `enable_user_status` site setting is disabled" do + SiteSetting.enable_user_status = false + + post "/posts.json", + params: { + raw: "I am mentioning @#{user_to_mention.username}", + topic_id: topic.id, + } + + expect(response.status).to eq(200) + + json = response.parsed_body + + expect(json["mentioned_users"]).to eq(nil) + end + it "returns mentioned users" do + user_to_mention.set_status!("off to dentist", "tooth") + post "/posts.json", params: { raw: "I am mentioning @#{user_to_mention.username}", @@ -1596,6 +1616,11 @@ RSpec.describe PostsController do expect(mentioned_user["id"]).to be(user_to_mention.id) expect(mentioned_user["name"]).to eq(user_to_mention.name) expect(mentioned_user["username"]).to eq(user_to_mention.username) + + status = mentioned_user["status"] + expect(status).to be_present + expect(status["emoji"]).to eq(user_to_mention.user_status.emoji) + expect(status["description"]).to eq(user_to_mention.user_status.description) end it "returns an empty list of mentioned users if nobody was mentioned" do @@ -1611,43 +1636,6 @@ RSpec.describe PostsController do expect(response.status).to eq(200) expect(response.parsed_body["mentioned_users"].length).to be(0) end - - it "doesn't return user status on mentions by default" do - user_to_mention.set_status!("off to dentist", "tooth") - - post "/posts.json", - params: { - raw: "I am mentioning @#{user_to_mention.username}", - topic_id: topic.id, - } - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["mentioned_users"].length).to be(1) - - status = json["mentioned_users"][0]["status"] - expect(status).to be_nil - end - - it "returns user status on mentions if status is enabled in site settings" do - SiteSetting.enable_user_status = true - user_to_mention.set_status!("off to dentist", "tooth") - - post "/posts.json", - params: { - raw: "I am mentioning @#{user_to_mention.username}", - topic_id: topic.id, - } - - expect(response.status).to eq(200) - json = response.parsed_body - expect(json["mentioned_users"].length).to be(1) - - status = json["mentioned_users"][0]["status"] - expect(status).to be_present - expect(status["emoji"]).to eq(user_to_mention.user_status.emoji) - expect(status["description"]).to eq(user_to_mention.user_status.description) - end end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 3c00a04b90..5f9c62c03b 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -2804,7 +2804,7 @@ RSpec.describe TopicsController do expect(response.status).to eq(200) end - context "with mentions" do + context "when `enable_user_status` site setting is enabled" do fab!(:post) { Fabricate(:post, user: post_author1) } fab!(:topic) { post.topic } fab!(:post2) do @@ -2816,7 +2816,23 @@ RSpec.describe TopicsController do ) end - it "returns mentions" do + before { SiteSetting.enable_user_status = true } + + it "does not return mentions when `enable_user_status` site setting is disabled" do + SiteSetting.enable_user_status = false + + get "/t/#{topic.slug}/#{topic.id}.json" + + expect(response.status).to eq(200) + + json = response.parsed_body + + expect(json["post_stream"]["posts"][1]["mentioned_users"]).to eq(nil) + end + + it "returns mentions with status" do + post_author1.set_status!("off to dentist", "tooth") + get "/t/#{topic.slug}/#{topic.id}.json" expect(response.status).to eq(200) @@ -2828,39 +2844,14 @@ RSpec.describe TopicsController do expect(mentioned_user["id"]).to be(post_author1.id) expect(mentioned_user["name"]).to eq(post_author1.name) expect(mentioned_user["username"]).to eq(post_author1.username) - end - it "doesn't return status on mentions by default" do - post_author1.set_status!("off to dentist", "tooth") - - get "/t/#{topic.slug}/#{topic.id}.json" - - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json["post_stream"]["posts"][1]["mentioned_users"].length).to be(1) - status = json["post_stream"]["posts"][1]["mentioned_users"][0]["status"] - expect(status).to be_nil - end - - it "returns mentions with status if user status is enabled" do - SiteSetting.enable_user_status = true - post_author1.set_status!("off to dentist", "tooth") - - get "/t/#{topic.slug}/#{topic.id}.json" - - expect(response.status).to eq(200) - - json = response.parsed_body - expect(json["post_stream"]["posts"][1]["mentioned_users"].length).to be(1) - - status = json["post_stream"]["posts"][1]["mentioned_users"][0]["status"] + status = mentioned_user["status"] expect(status).to be_present expect(status["emoji"]).to eq(post_author1.user_status.emoji) expect(status["description"]).to eq(post_author1.user_status.description) end - it "returns an empty list of mentioned users if there is no mentions in a post" do + it "returns an empty list of mentioned users if there are no mentions in a post" do Fabricate(:post, user: post_author2, topic: topic, raw: "Post without mentions.") get "/t/#{topic.slug}/#{topic.id}.json" From df4a9f96ae683b87564054b0882b0c4728fa9821 Mon Sep 17 00:00:00 2001 From: Michael Fitz-Payne Date: Thu, 19 Jan 2023 14:12:25 +1000 Subject: [PATCH 092/169] DEV(cache_critical_dns): add additional service runtime variable We'd like to lean on the DNS caching service for more than the standard DB and Redis hosts, but without having to add additional code each time. Define a new environment variable DISCOURSE_DNS_CACHE_ADDITIONAL_SERVICE_NAMES (admittedly a mouthful) which is a list of service names to be added to the static list at process execution time. For example, plugin foo may reference two services that you want to cache the address of. By specifying the following two variables in the process environment, cache_critical_dns will perform the lookup alongside the DB and Redis host variables. ``` DISCOURSE_DNS_CACHE_ADDITIONAL_SERVICE_NAMES='FOO_SERVICE1,FOO_SERVICE2' FOO_SERVICE1='foo.service1.example.com' FOO_SERVICE1_SRV='foo._tcp.example.com' FOO_SERVICE2='foo.service2.example.com' ``` The behaviour when it comes to SRV record lookup is the same as previously implemented for the `DISCOURSE_DB_..` and `DISCOURSE_REDIS_..` variables. For the purposes of the health checks, services defined in the list _are always considered healthy_. This is a compromise for conveniences sake. Defining a dynamic method for health checks at runtime is not practical. See t/88457/32. --- script/cache_critical_dns | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/script/cache_critical_dns b/script/cache_critical_dns index 929770b4a4..76b679cdbb 100755 --- a/script/cache_critical_dns +++ b/script/cache_critical_dns @@ -15,6 +15,18 @@ require 'optparse' # service in the process environment. Any hosts that fail the healthcheck will # never be cached. # +# The list of environment variables that cache_critical_dns will read for +# critical service hostnames can be extended at process execution time by +# specifying environment variable names within the +# DISCOURSE_DNS_CACHE_ADDITIONAL_SERVICE_NAMES environment variable. This is a +# comma-delimited string of extra environment variables to be added to the list +# defined in the static CRITICAL_HOST_ENV_VARS hash. +# +# DISCOURSE_DNS_CACHE_ADDITIONAL_SERVICE_NAMES serves as a kind of lookup table +# for extra services for caching. Any environment variable names within this +# list are treated with the same rules as the DISCOURSE_DB_HOST (and co.) +# variables, as described below. +# # This is as far as you need to read if you are using CNAME or A records for # your services. # @@ -88,7 +100,11 @@ CRITICAL_HOST_ENV_VARS = %w{ DISCOURSE_REDIS_REPLICA_HOST DISCOURSE_MESSAGE_BUS_REDIS_HOST DISCOURSE_MESSAGE_BUS_REDIS_REPLICA_HOST -} +}.union( + ENV.fetch('DISCOURSE_DNS_CACHE_ADDITIONAL_SERVICE_NAMES', '') + .split(',') + .map(&:strip) +) DEFAULT_DB_NAME = "discourse" DEFAULT_REDIS_PORT = 6379 @@ -243,7 +259,10 @@ ensure client.close if client end -HEALTH_CHECKS = { +HEALTH_CHECKS = Hash.new( + # unknown keys (like services defined at runtime) are assumed to be healthy + lambda { |addr| true } +).merge!({ "DISCOURSE_DB_HOST": lambda { |addr| postgres_healthcheck( host: addr, @@ -274,7 +293,7 @@ HEALTH_CHECKS = { host: addr, port: env_as_int("DISCOURSE_MESSAGE_BUS_REDIS_REPLICA_PORT", DEFAULT_REDIS_PORT), password: ENV["DISCOURSE_MESSAGE_BUS_REDIS_PASSWORD"])}, -} +}) def log(msg) STDERR.puts "#{Time.now.utc.iso8601}: #{msg}" From 4d2a95ffe6d63b8d2b5019654dd086f6a9bbbd44 Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Fri, 20 Jan 2023 10:24:52 +1000 Subject: [PATCH 093/169] FIX: Query UploadReference in UploadSecurity for existing uploads (#19917) This fixes a longstanding issue for sites with the secure_uploads setting enabled. What would happen is a scenario like this, since we did not check all places an upload could be linked to whenever we used UploadSecurity to check whether an upload should be secure: * Upload is created and used for site setting, set to secure: false since site setting uploads should not be secure. Let's say favicon * Favicon for the site is used inside a post in a private category, e.g. via a Onebox * We changed the secure status for the upload to true, since it's been used in a private category and we don't check if it's originator was a public place * The site favicon breaks :'( This was a source of constant consternation. Now, when an upload is _not_ being created, and we are checking if an existing upload should be secure, we now check to see what the first record in the UploadReference table is for that upload. If it's something public like a site setting, then we will never change the upload to `secure`. --- lib/tasks/uploads.rake | 8 + lib/upload_security.rb | 125 ++++--- spec/integration/secure_uploads_spec.rb | 124 +++++++ spec/lib/upload_security_spec.rb | 426 +++++++++++++++--------- 4 files changed, 484 insertions(+), 199 deletions(-) create mode 100644 spec/integration/secure_uploads_spec.rb diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index 8410987d8e..6314efff69 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -537,6 +537,10 @@ task "uploads:sync_s3_acls" => :environment do end end +# +# TODO (martin) Update this rake task to use the _first_ UploadReference +# record for each upload to determine security, and do not mark things +# as secure if the first record is something public e.g. a site setting. task "uploads:disable_secure_uploads" => :environment do RailsMultisite::ConnectionManagement.each_connection do |db| unless Discourse.store.external? @@ -584,6 +588,10 @@ end # the upload secure flag and S3 upload ACLs. Any uploads that # have their secure status changed will have all associated posts # rebaked. +# +# TODO (martin) Update this rake task to use the _first_ UploadReference +# record for each upload to determine security, and do not mark things +# as secure if the first record is something public e.g. a site setting. task "uploads:secure_upload_analyse_and_update" => :environment do RailsMultisite::ConnectionManagement.each_connection do |db| unless Discourse.store.external? diff --git a/lib/upload_security.rb b/lib/upload_security.rb index ccaf86f1ed..c5de6c17bf 100644 --- a/lib/upload_security.rb +++ b/lib/upload_security.rb @@ -13,8 +13,12 @@ # original post the upload is linked to has far more bearing on its security context # post-upload. If the access_control_post_id does not exist then we just rely # on the current secure? status, otherwise there would be a lot of additional -# complex queries and joins to perform. Over time more of these specific -# queries will be implemented. +# complex queries and joins to perform. +# +# These queries will be performed only if the @creating option is false. So if +# an upload is included in a post, and it's an upload from a different source +# (e.g. a category logo, site setting upload) then we will determine secure +# state _based on the first place the upload was referenced_. # # NOTE: When updating this to add more cases where uploads will be marked # secure, consider uploads:secure_upload_analyse_and_update as well, which @@ -35,6 +39,18 @@ class UploadSecurity badge_image ] + PUBLIC_UPLOAD_REFERENCE_TYPES = %w[ + Badge + Category + CustomEmoji + Group + SiteSetting + ThemeField + User + UserAvatar + UserProfile + ] + def self.register_custom_public_type(type) @@custom_public_types << type if !@@custom_public_types.include?(type) end @@ -65,6 +81,46 @@ class UploadSecurity [false, "no checks satisfied"] end + private + + def access_control_post + @access_control_post ||= + @upload.access_control_post_id.present? ? @upload.access_control_post : nil + end + + def insecure_context_checks + { + secure_uploads_disabled: "secure uploads is disabled", + insecure_creation_for_modifiers: "one or more creation for_modifiers was satisfied", + public_type: "upload is public type", + regular_emoji: "upload is used for regular emoji", + publicly_referenced_first: "upload was publicly referenced when it was first created", + } + end + + def secure_context_checks + { + login_required: "login is required", + access_control_post_has_secure_uploads: "access control post dictates security", + secure_creation_for_modifiers: "one or more creation for_modifiers was satisfied", + uploading_in_composer: "uploading via the composer", + already_secure: "upload is already secure", + } + end + + # The access control check is important because that is the truest indicator + # of whether an upload should be secure or not, and thus should be returned + # immediately if there is an access control post. + def priority_check?(check) + check == :access_control_post_has_secure_uploads && access_control_post + end + + def perform_check(check) + send("#{check}_check") + end + + #### START PUBLIC CHECKS #### + def secure_uploads_disabled_check !SiteSetting.secure_uploads? end @@ -78,8 +134,11 @@ class UploadSecurity PUBLIC_TYPES.include?(@upload_type) || @@custom_public_types.include?(@upload_type) end - def custom_emoji_check - @upload.id.present? && CustomEmoji.exists?(upload_id: @upload.id) + def publicly_referenced_first_check + return false if @creating + first_reference = @upload.upload_references.order(created_at: :asc).first + return false if first_reference.blank? + PUBLIC_UPLOAD_REFERENCE_TYPES.include?(first_reference.target_type) end def regular_emoji_check @@ -89,18 +148,26 @@ class UploadSecurity uri.path.include?("images/emoji") end + #### END PUBLIC CHECKS #### + + #--------------------------# + + #### START PRIVATE CHECKS #### + def login_required_check SiteSetting.login_required? end - # whether the upload should remain secure or not after posting depends on its context, + # Whether the upload should remain secure or not after posting depends on its context, # which is based on the post it is linked to via access_control_post_id. - # if that post is with_secure_uploads? then the upload should also be secure. - # this may change to false if the upload was set to secure on upload e.g. in - # a post composer then it turned out that the post itself was not in a secure context # - # a post is with secure uploads if it is a private message or in a read restricted - # category + # If that post is with_secure_uploads? then the upload should also be secure. + # + # This may change to false if the upload was set to secure on upload e.g. in + # a post composer then it turned out that the post itself was not in a secure context. + # + # A post is with secure uploads if it is a private message or in a read restricted + # category. See `Post#with_secure_uploads?` for the full definition. def access_control_post_has_secure_uploads_check access_control_post&.with_secure_uploads? end @@ -118,41 +185,5 @@ class UploadSecurity @upload.secure? end - private - - def access_control_post - @access_control_post ||= - @upload.access_control_post_id.present? ? @upload.access_control_post : nil - end - - def insecure_context_checks - { - secure_uploads_disabled: "secure uploads is disabled", - insecure_creation_for_modifiers: "one or more creation for_modifiers was satisfied", - public_type: "upload is public type", - custom_emoji: "upload is used for custom emoji", - regular_emoji: "upload is used for regular emoji", - } - end - - def secure_context_checks - { - login_required: "login is required", - access_control_post_has_secure_uploads: "access control post dictates security", - secure_creation_for_modifiers: "one or more creation for_modifiers was satisfied", - uploading_in_composer: "uploading via the composer", - already_secure: "upload is already secure", - } - end - - # the access control check is important because that is the truest indicator - # of whether an upload should be secure or not, and thus should be returned - # immediately if there is an access control post - def priority_check?(check) - check == :access_control_post_has_secure_uploads && access_control_post - end - - def perform_check(check) - send("#{check}_check") - end + #### END PRIVATE CHECKS #### end diff --git a/spec/integration/secure_uploads_spec.rb b/spec/integration/secure_uploads_spec.rb new file mode 100644 index 0000000000..b9c3c4d067 --- /dev/null +++ b/spec/integration/secure_uploads_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +describe "Secure uploads" do + fab!(:user) { Fabricate(:user) } + fab!(:group) { Fabricate(:group) } + fab!(:secure_category) { Fabricate(:private_category, group: group) } + + before do + Jobs.run_immediately! + + # this is done so the after_save callbacks for site settings to make + # UploadReference records works + @original_provider = SiteSetting.provider + SiteSetting.provider = SiteSettings::DbProvider.new(SiteSetting) + setup_s3 + stub_s3_store + SiteSetting.secure_uploads = true + group.add(user) + user.reload + end + + after { SiteSetting.provider = @original_provider } + + def create_upload + filename = "logo.png" + file = file_from_fixtures(filename) + UploadCreator.new(file, filename).create_for(user.id) + end + + def stub_presign_upload_get(upload) + # this is necessary because by default any upload inside a secure post is considered "secure" + # for the purposes of fetching hotlinked images until proven otherwise, and this is easier + # than trying to stub the presigned URL for s3 in a different way + stub_request(:get, "https:#{upload.url}").to_return( + status: 200, + body: file_from_fixtures("logo.png"), + ) + Upload.stubs(:signed_url_from_secure_uploads_url).returns("https:#{upload.url}") + end + + it "does not convert an upload to secure when it was first used in a site setting then in a post" do + upload = create_upload + SiteSetting.favicon = upload + expect(upload.reload.upload_references.count).to eq(1) + create_post( + title: "Secure upload post", + raw: "This is a new post ", + category: secure_category, + user: user, + ) + upload.reload + expect(upload.upload_references.count).to eq(2) + expect(upload.secure).to eq(false) + end + + it "does not convert an upload to insecure when it was first used in a secure post then a site setting" do + upload = create_upload + create_post( + title: "Secure upload post", + raw: "This is a new post ", + category: secure_category, + user: user, + ) + expect(upload.reload.upload_references.count).to eq(1) + SiteSetting.favicon = upload + upload.reload + expect(upload.upload_references.count).to eq(2) + expect(upload.secure).to eq(true) + end + + it "does not convert an upload to secure when it was first used in a public post then in a secure post" do + upload = create_upload + + post = + create_post( + title: "Public upload post", + raw: "This is a new post ", + user: user, + ) + upload.reload + expect(upload.upload_references.count).to eq(1) + expect(upload.secure).to eq(false) + expect(upload.access_control_post).to eq(post) + + stub_presign_upload_get(upload) + create_post( + title: "Secure upload post", + raw: "This is a new post ", + category: secure_category, + user: user, + ) + upload.reload + expect(upload.upload_references.count).to eq(2) + expect(upload.secure).to eq(false) + expect(upload.access_control_post).to eq(post) + end + + it "does not convert an upload to insecure when it was first used in a secure post then in a public post" do + upload = create_upload + + stub_presign_upload_get(upload) + post = + create_post( + title: "Secure upload post", + raw: "This is a new post ", + category: secure_category, + user: user, + ) + upload.reload + expect(upload.upload_references.count).to eq(1) + expect(upload.secure).to eq(true) + expect(upload.access_control_post).to eq(post) + + create_post( + title: "Public upload post", + raw: "This is a new post ", + user: user, + ) + upload.reload + expect(upload.upload_references.count).to eq(2) + expect(upload.secure).to eq(true) + expect(upload.access_control_post).to eq(post) + end +end diff --git a/spec/lib/upload_security_spec.rb b/spec/lib/upload_security_spec.rb index b5a307997f..1a70bfecfa 100644 --- a/spec/lib/upload_security_spec.rb +++ b/spec/lib/upload_security_spec.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true RSpec.describe UploadSecurity do - let(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } - let(:post_in_secure_context) do + fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } + fab!(:post_in_secure_context) do Fabricate(:post, topic: Fabricate(:topic, category: private_category)) end fab!(:upload) { Fabricate(:upload) } let(:type) { nil } - let(:opts) { { type: type, creating: true } } + let(:opts) { { type: type, creating: creating } } + subject { described_class.new(upload, opts) } context "when secure uploads is enabled" do @@ -16,172 +17,293 @@ RSpec.describe UploadSecurity do SiteSetting.secure_uploads = true end - context "when login_required (everything should be secure except public context items)" do - before { SiteSetting.login_required = true } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end + context "when creating the upload" do + let(:creating) { true } - context "when uploading in public context" do - describe "for a public type badge_image" do - let(:type) { "badge_image" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type group_flair" do - let(:type) { "group_flair" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type avatar" do - let(:type) { "avatar" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type custom_emoji" do - let(:type) { "custom_emoji" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type profile_background" do - let(:type) { "profile_background" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type avatar" do - let(:type) { "avatar" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type category_logo" do - let(:type) { "category_logo" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a public type category_background" do - let(:type) { "category_background" } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for a custom public type" do - let(:type) { "my_custom_type" } + context "when login_required (everything should be secure except public context items)" do + before { SiteSetting.login_required = true } - it "returns true if the custom type has not been added" do - expect(subject.should_be_secure?).to eq(true) - end - - it "returns false if the custom type has been added" do - UploadSecurity.register_custom_public_type(type) - expect(subject.should_be_secure?).to eq(false) - UploadSecurity.reset_custom_public_types - end - end - describe "for_theme" do - before { upload.stubs(:for_theme).returns(true) } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for_site_setting" do - before { upload.stubs(:for_site_setting).returns(true) } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - describe "for_gravatar" do - before { upload.stubs(:for_gravatar).returns(true) } - it "returns false" do - expect(subject.should_be_secure?).to eq(false) - end - end - - describe "when the upload is used for a custom emoji" do - it "returns false" do - CustomEmoji.create(name: "meme", upload: upload) - expect(subject.should_be_secure?).to eq(false) - end - end - - describe "when it is based on a regular emoji" do - it "returns false" do - falafel = - Emoji.all.find do |e| - e.url == "/images/emoji/twitter/falafel.png?v=#{Emoji::EMOJI_VERSION}" - end - upload.update!(origin: "http://localhost:3000#{falafel.url}") - expect(subject.should_be_secure?).to eq(false) - end - end - end - end - - context "when the access control post has_secure_uploads?" do - before { upload.update(access_control_post_id: post_in_secure_context.id) } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end - - context "when the post is deleted" do - before { post_in_secure_context.trash! } - it "still determines whether the post has secure uploads; returns true" do - expect(subject.should_be_secure?).to eq(true) - end - end - end - - context "when uploading in the composer" do - let(:type) { "composer" } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end - end - context "when uploading for a group message" do - before { upload.stubs(:for_group_message).returns(true) } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end - end - context "when uploading for a PM" do - before { upload.stubs(:for_private_message).returns(true) } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end - end - context "when upload is already secure" do - before { upload.update(secure: true) } - it "returns true" do - expect(subject.should_be_secure?).to eq(true) - end - end - - context "for attachments" do - before { upload.update(original_filename: "test.pdf") } - - context "when the access control post has_secure_uploads?" do - before { upload.update(access_control_post: post_in_secure_context) } it "returns true" do expect(subject.should_be_secure?).to eq(true) end + + context "when uploading in public context" do + describe "for a public type badge_image" do + let(:type) { "badge_image" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type group_flair" do + let(:type) { "group_flair" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type avatar" do + let(:type) { "avatar" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type custom_emoji" do + let(:type) { "custom_emoji" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type profile_background" do + let(:type) { "profile_background" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type avatar" do + let(:type) { "avatar" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type category_logo" do + let(:type) { "category_logo" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a public type category_background" do + let(:type) { "category_background" } + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for a custom public type" do + let(:type) { "my_custom_type" } + + it "returns true if the custom type has not been added" do + expect(subject.should_be_secure?).to eq(true) + end + + it "returns false if the custom type has been added" do + UploadSecurity.register_custom_public_type(type) + expect(subject.should_be_secure?).to eq(false) + UploadSecurity.reset_custom_public_types + end + end + + describe "for_theme" do + before { upload.stubs(:for_theme).returns(true) } + + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for_site_setting" do + before { upload.stubs(:for_site_setting).returns(true) } + + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "for_gravatar" do + before { upload.stubs(:for_gravatar).returns(true) } + + it "returns false" do + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when it is based on a regular emoji" do + it "returns false" do + falafel = + Emoji.all.find do |e| + e.url == "/images/emoji/twitter/falafel.png?v=#{Emoji::EMOJI_VERSION}" + end + upload.update!(origin: "http://localhost:3000#{falafel.url}") + expect(subject.should_be_secure?).to eq(false) + end + end + end + end + + context "when the access control post has_secure_uploads?" do + before { upload.update(access_control_post_id: post_in_secure_context.id) } + + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + + context "when the post is deleted" do + before { post_in_secure_context.trash! } + + it "still determines whether the post has secure uploads; returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + end + + context "when uploading in the composer" do + let(:type) { "composer" } + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + + context "when uploading for a group message" do + before { upload.stubs(:for_group_message).returns(true) } + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + + context "when uploading for a PM" do + before { upload.stubs(:for_private_message).returns(true) } + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + + context "when upload is already secure" do + before { upload.update(secure: true) } + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + + context "for attachments" do + before { upload.update(original_filename: "test.pdf") } + + context "when the access control post has_secure_uploads?" do + before { upload.update(access_control_post: post_in_secure_context) } + it "returns true" do + expect(subject.should_be_secure?).to eq(true) + end + end + end + end + + context "when checking an existing upload" do + let(:creating) { false } + + before do + # this is done so the after_save callbacks for site settings to make + # UploadReference records works + @original_provider = SiteSetting.provider + SiteSetting.provider = SiteSettings::DbProvider.new(SiteSetting) + setup_s3 + SiteSetting.secure_uploads = true + end + + after { SiteSetting.provider = @original_provider } + + def create_secure_post_reference + UploadReference.ensure_exist!(upload_ids: [upload.id], target: post_in_secure_context) + upload.update!(access_control_post: post_in_secure_context) + end + + describe "when the upload is first used for a post in a secure context" do + it "returns true" do + create_secure_post_reference + expect(subject.should_be_secure?).to eq(true) + end + end + + describe "when the upload is first used for a site setting" do + it "returns false" do + SiteSetting.favicon = upload + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a theme field" do + it "returns false" do + Fabricate(:theme_field, type_id: ThemeField.types[:theme_upload_var], upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a group flair image" do + it "returns false" do + Fabricate(:group, flair_upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a custom emoji" do + it "returns false" do + CustomEmoji.create(name: "meme", upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a badge" do + it "returns false" do + Fabricate(:badge, image_upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a category image (logo, dark logo, background)" do + it "returns false" do + Fabricate(:category, uploaded_logo: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a user profile (profile background, card background)" do + it "returns false" do + user = Fabricate(:user) + user.user_profile.update!(card_background_upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a user uploaded avatar" do + it "returns false" do + Fabricate(:user, uploaded_avatar: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end + end + + describe "when the upload is first used for a UserAvatar" do + it "returns false" do + Fabricate(:user_avatar, custom_upload: upload) + create_secure_post_reference + expect(subject.should_be_secure?).to eq(false) + end end end end context "when secure uploads is disabled" do + let(:creating) { true } + before { SiteSetting.secure_uploads = false } + it "returns false" do expect(subject.should_be_secure?).to eq(false) end context "for attachments" do before { upload.update(original_filename: "test.pdf") } + it "returns false" do expect(subject.should_be_secure?).to eq(false) end From f122f24b35a8116e47649ae139910d44e3d7488e Mon Sep 17 00:00:00 2001 From: Alan Guo Xiang Tan Date: Fri, 20 Jan 2023 09:50:24 +0800 Subject: [PATCH 094/169] SECURITY: Default tags to show count of topics in unrestricted categories (#19916) Currently, `Tag#topic_count` is a count of all regular topics regardless of whether the topic is in a read restricted category or not. As a result, any users can technically poll a sensitive tag to determine if a new topic is created in a category which the user has not excess to. We classify this as a minor leak in sensitive information. The following changes are introduced in this commit: 1. Introduce `Tag#public_topic_count` which only count topics which have been tagged with a given tag in public categories. 2. Rename `Tag#topic_count` to `Tag#staff_topic_count` which counts the same way as `Tag#topic_count`. In other words, it counts all topics tagged with a given tag regardless of the category the topic is in. The rename is also done so that we indicate that this column contains sensitive information. 3. Change all previous spots which relied on `Topic#topic_count` to rely on `Tag.topic_column_count(guardian)` which will return the right "topic count" column to use based on the current scope. 4. Introduce `SiteSetting.include_secure_categories_in_tag_counts` site setting to allow site administrators to always display the tag topics count using `Tag#staff_topic_count` instead. --- app/controllers/tags_controller.rb | 27 ++--- app/models/tag.rb | 69 +++++++++--- app/models/topic.rb | 13 +++ app/models/topic_tag.rb | 31 +++-- app/serializers/concerns/topic_tags_mixin.rb | 3 +- .../concerns/user_sidebar_mixin.rb | 4 +- app/serializers/detailed_tag_serializer.rb | 2 +- app/serializers/tag_serializer.rb | 4 + app/services/tag_hashtag_data_source.rb | 24 ++-- config/locales/server.en.yml | 1 + config/site_settings.yml | 2 + ...18020114_add_public_topic_count_to_tags.rb | 28 +++++ ...119000943_add_staff_topic_count_to_tags.rb | 27 +++++ ...0119024157_remove_topic_count_from_tags.rb | 15 +++ lib/discourse_tagging.rb | 12 +- lib/topic_view.rb | 3 +- spec/integration/tag_counts_spec.rb | 104 +++++++++++++++++ spec/lib/discourse_tagging_spec.rb | 14 ++- spec/lib/topic_view_spec.rb | 4 +- spec/models/tag_spec.rb | 106 ++++++++++++++++-- spec/models/topic_tag_spec.rb | 39 +++++-- spec/requests/tags_controller_spec.rb | 98 +++++++++++----- spec/serializers/tag_serializer_spec.rb | 25 +++++ .../serializers/topic_view_serializer_spec.rb | 30 ++++- .../hashtag_autocomplete_service_spec.rb | 20 ++-- spec/services/tag_hashtag_data_source_spec.rb | 16 +-- .../user_sidebar_serializer_attributes.rb | 4 +- spec/system/hashtag_autocomplete_spec.rb | 4 +- 28 files changed, 602 insertions(+), 127 deletions(-) create mode 100644 db/migrate/20230118020114_add_public_topic_count_to_tags.rb create mode 100644 db/migrate/20230119000943_add_staff_topic_count_to_tags.rb create mode 100644 db/post_migrate/20230119024157_remove_topic_count_from_tags.rb create mode 100644 spec/integration/tag_counts_spec.rb create mode 100644 spec/serializers/tag_serializer_spec.rb diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 5a8a8137b7..1108b618cc 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -40,7 +40,7 @@ class TagsController < ::ApplicationController if SiteSetting.tags_listed_by_group ungrouped_tags = Tag.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships)") - ungrouped_tags = ungrouped_tags.where("tags.topic_count > 0") unless show_all_tags + ungrouped_tags = ungrouped_tags.used_tags_in_regular_topics(guardian) unless show_all_tags grouped_tag_counts = TagGroup @@ -51,18 +51,14 @@ class TagsController < ::ApplicationController { id: tag_group.id, name: tag_group.name, - tags: - self.class.tag_counts_json( - tag_group.none_synonym_tags, - show_pm_tags: guardian.can_tag_pms?, - ), + tags: self.class.tag_counts_json(tag_group.none_synonym_tags, guardian), } end - @tags = self.class.tag_counts_json(ungrouped_tags, show_pm_tags: guardian.can_tag_pms?) + @tags = self.class.tag_counts_json(ungrouped_tags, guardian) @extras = { tag_groups: grouped_tag_counts } else - tags = show_all_tags ? Tag.all : Tag.where("tags.topic_count > 0") + tags = show_all_tags ? Tag.all : Tag.used_tags_in_regular_topics(guardian) unrestricted_tags = DiscourseTagging.filter_visible(tags.where(target_tag_id: nil), guardian) categories = @@ -77,13 +73,14 @@ class TagsController < ::ApplicationController category_tags = self.class.tag_counts_json( DiscourseTagging.filter_visible(c.tags.where(target_tag_id: nil), guardian), + guardian, ) next if category_tags.empty? { id: c.id, tags: category_tags } end .compact - @tags = self.class.tag_counts_json(unrestricted_tags, show_pm_tags: guardian.can_tag_pms?) + @tags = self.class.tag_counts_json(unrestricted_tags, guardian) @extras = { categories: category_tag_counts } end @@ -264,7 +261,7 @@ class TagsController < ::ApplicationController tags_with_counts, filter_result_context = DiscourseTagging.filter_allowed_tags(guardian, **filter_params, with_context: true) - tags = self.class.tag_counts_json(tags_with_counts, show_pm_tags: guardian.can_tag_pms?) + tags = self.class.tag_counts_json(tags_with_counts, guardian) json_response = { results: tags } @@ -388,18 +385,22 @@ class TagsController < ::ApplicationController end end - def self.tag_counts_json(tags, show_pm_tags: true) + def self.tag_counts_json(tags, guardian) + show_pm_tags = guardian.can_tag_pms? target_tags = Tag.where(id: tags.map(&:target_tag_id).compact.uniq).select(:id, :name) + tags .map do |t| - next if t.topic_count == 0 && t.pm_topic_count > 0 && !show_pm_tags + topic_count = t.public_send(Tag.topic_count_column(guardian)) + + next if topic_count == 0 && t.pm_topic_count > 0 && !show_pm_tags { id: t.name, text: t.name, name: t.name, description: t.description, - count: t.topic_count, + count: topic_count, pm_count: show_pm_tags ? t.pm_topic_count : 0, target_tag: t.target_tag_id ? target_tags.find { |x| x.id == t.target_tag_id }&.name : nil, diff --git a/app/models/tag.rb b/app/models/tag.rb index 64b932d02e..71435ef046 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -4,6 +4,10 @@ class Tag < ActiveRecord::Base include Searchable include HasDestroyedWebHook + self.ignored_columns = [ + "topic_count", # TODO(tgxworld): Remove on 1 July 2023 + ] + RESERVED_TAGS = [ "none", "constructor", # prevents issues with javascript's constructor of objects @@ -25,11 +29,14 @@ class Tag < ActiveRecord::Base # tags that have never been used and don't belong to a tag group scope :unused, -> { - where(topic_count: 0, pm_topic_count: 0).joins( + where(staff_topic_count: 0, pm_topic_count: 0).joins( "LEFT JOIN tag_group_memberships tgm ON tags.id = tgm.tag_id", ).where("tgm.tag_id IS NULL") } + scope :used_tags_in_regular_topics, + ->(guardian) { where("tags.#{Tag.topic_count_column(guardian)} > 0") } + scope :base_tags, -> { where(target_tag_id: nil) } has_many :tag_users, dependent: :destroy # notification settings @@ -62,7 +69,7 @@ class Tag < ActiveRecord::Base def self.update_topic_counts DB.exec <<~SQL UPDATE tags t - SET topic_count = x.topic_count + SET staff_topic_count = x.topic_count FROM ( SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id FROM tags @@ -73,7 +80,31 @@ class Tag < ActiveRecord::Base GROUP BY tags.id ) x WHERE x.tag_id = t.id - AND x.topic_count <> t.topic_count + AND x.topic_count <> t.staff_topic_count + SQL + + DB.exec <<~SQL + UPDATE tags t + SET public_topic_count = x.topic_count + FROM ( + WITH tags_with_public_topics AS ( + SELECT + COUNT(topics.id) AS topic_count, + tags.id AS tag_id + FROM tags + INNER JOIN topic_tags ON tags.id = topic_tags.tag_id + INNER JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message' + INNER JOIN categories ON categories.id = topics.category_id AND NOT categories.read_restricted + GROUP BY tags.id + ) + SELECT + COALESCE(tags_with_public_topics.topic_count, 0 ) AS topic_count, + tags.id AS tag_id + FROM tags + LEFT JOIN tags_with_public_topics ON tags_with_public_topics.tag_id = tags.id + ) x + WHERE x.tag_id = t.id + AND x.topic_count <> t.public_topic_count; SQL DB.exec <<~SQL @@ -97,19 +128,18 @@ class Tag < ActiveRecord::Base self.find_by("lower(name) = ?", name.downcase) end - def self.top_tags(limit_arg: nil, category: nil, guardian: nil) + def self.top_tags(limit_arg: nil, category: nil, guardian: Guardian.new) # we add 1 to max_tags_in_filter_list to efficiently know we have more tags # than the limit. Frontend is responsible to enforce limit. limit = limit_arg || (SiteSetting.max_tags_in_filter_list + 1) - scope_category_ids = (guardian || Guardian.new).allowed_category_ids - + scope_category_ids = guardian.allowed_category_ids scope_category_ids &= ([category.id] + category.subcategories.pluck(:id)) if category return [] if scope_category_ids.empty? filter_sql = ( - if guardian&.is_staff? + if guardian.is_staff? "" else " AND tags.id IN (#{DiscourseTagging.visible_tags(guardian).select(:id).to_sql})" @@ -130,6 +160,14 @@ class Tag < ActiveRecord::Base tag_names_with_counts.map { |row| row.tag_name } end + def self.topic_count_column(guardian) + if guardian&.is_staff? || SiteSetting.include_secure_categories_in_tag_counts + "staff_topic_count" + else + "public_topic_count" + end + end + def self.pm_tags(limit: 1000, guardian: nil, allowed_user: nil) return [] if allowed_user.blank? || !(guardian || Guardian.new).can_tag_pms? user_id = allowed_user.id @@ -214,14 +252,15 @@ end # # Table name: tags # -# id :integer not null, primary key -# name :string not null -# topic_count :integer default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# pm_topic_count :integer default(0), not null -# target_tag_id :integer -# description :string +# id :integer not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# pm_topic_count :integer default(0), not null +# target_tag_id :integer +# description :string +# public_topic_count :integer default(0), not null +# staff_topic_count :integer default(0), not null # # Indexes # diff --git a/app/models/topic.rb b/app/models/topic.rb index b15bbc519f..10a0e7a538 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -958,6 +958,7 @@ class Topic < ActiveRecord::Base def changed_to_category(new_category) return true if new_category.blank? || Category.exists?(topic_id: id) + if new_category.id == SiteSetting.uncategorized_category_id && !SiteSetting.allow_uncategorized_topics return false @@ -971,6 +972,15 @@ class Topic < ActiveRecord::Base if old_category Category.where(id: old_category.id).update_all("topic_count = topic_count - 1") + + count = + if old_category.read_restricted && !new_category.read_restricted + 1 + elsif !old_category.read_restricted && new_category.read_restricted + -1 + end + + Tag.update_counters(self.tags, { public_topic_count: count }) if count end # when a topic changes category we may have to start watching it @@ -1781,12 +1791,15 @@ class Topic < ActiveRecord::Base def convert_to_public_topic(user, category_id: nil) public_topic = TopicConverter.new(self, user).convert_to_public_topic(category_id) + Tag.update_counters(public_topic.tags, { public_topic_count: 1 }) if !category.read_restricted add_small_action(user, "public_topic") if public_topic public_topic end def convert_to_private_message(user) + read_restricted = category.read_restricted private_topic = TopicConverter.new(self, user).convert_to_private_message + Tag.update_counters(private_topic.tags, { public_topic_count: -1 }) if !read_restricted add_small_action(user, "private_topic") if private_topic private_topic end diff --git a/app/models/topic_tag.rb b/app/models/topic_tag.rb index 67a8218d94..b2ed69791a 100644 --- a/app/models/topic_tag.rb +++ b/app/models/topic_tag.rb @@ -9,14 +9,18 @@ class TopicTag < ActiveRecord::Base if topic.archetype == Archetype.private_message tag.increment!(:pm_topic_count) else - tag.increment!(:topic_count) + counters_to_update = { staff_topic_count: 1 } - if topic.category_id - if stat = CategoryTagStat.find_by(tag_id: tag_id, category_id: topic.category_id) - stat.increment!(:topic_count) - else - CategoryTagStat.create(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) - end + if Category.exists?(id: topic.category_id, read_restricted: false) + counters_to_update[:public_topic_count] = 1 + end + + Tag.update_counters(tag.id, counters_to_update) + + if stat = CategoryTagStat.find_by(tag_id: tag_id, category_id: topic.category_id) + stat.increment!(:topic_count) + else + CategoryTagStat.create!(tag_id: tag_id, category_id: topic.category_id, topic_count: 1) end end end @@ -27,12 +31,17 @@ class TopicTag < ActiveRecord::Base if topic.archetype == Archetype.private_message tag.decrement!(:pm_topic_count) else - if topic.category_id && - stat = CategoryTagStat.find_by(tag_id: tag_id, category: topic.category_id) - stat.topic_count == 1 ? stat.destroy : stat.decrement!(:topic_count) + if stat = CategoryTagStat.find_by(tag_id: tag_id, category: topic.category_id) + stat.topic_count == 1 ? stat.destroy! : stat.decrement!(:topic_count) end - tag.decrement!(:topic_count) + counters_to_update = { staff_topic_count: -1 } + + if Category.exists?(id: topic.category_id, read_restricted: false) + counters_to_update[:public_topic_count] = -1 + end + + Tag.update_counters(tag.id, counters_to_update) end end end diff --git a/app/serializers/concerns/topic_tags_mixin.rb b/app/serializers/concerns/topic_tags_mixin.rb index 2704ba3361..0c001d4633 100644 --- a/app/serializers/concerns/topic_tags_mixin.rb +++ b/app/serializers/concerns/topic_tags_mixin.rb @@ -32,7 +32,8 @@ module TopicTagsMixin if SiteSetting.tags_sort_alphabetically topic.tags.sort_by(&:name) else - topic.tags.sort_by(&:topic_count).reverse + topic_count_column = Tag.topic_count_column(scope) + topic.tags.sort_by { |tag| tag.public_send(topic_count_column) }.reverse end ) tags = tags.reject { |tag| scope.hidden_tag_names.include?(tag[:name]) } if !scope.is_staff? diff --git a/app/serializers/concerns/user_sidebar_mixin.rb b/app/serializers/concerns/user_sidebar_mixin.rb index f70b97bec2..5f8c5add0a 100644 --- a/app/serializers/concerns/user_sidebar_mixin.rb +++ b/app/serializers/concerns/user_sidebar_mixin.rb @@ -2,9 +2,11 @@ module UserSidebarMixin def sidebar_tags + topic_count_column = Tag.topic_count_column(scope) + object .visible_sidebar_tags(scope) - .pluck(:name, :topic_count, :pm_topic_count) + .pluck(:name, topic_count_column, :pm_topic_count) .reduce([]) do |tags, sidebar_tag| tags.push(name: sidebar_tag[0], pm_only: sidebar_tag[1] == 0 && sidebar_tag[2] > 0) end diff --git a/app/serializers/detailed_tag_serializer.rb b/app/serializers/detailed_tag_serializer.rb index 83570c3d5e..7fd7a3943b 100644 --- a/app/serializers/detailed_tag_serializer.rb +++ b/app/serializers/detailed_tag_serializer.rb @@ -6,7 +6,7 @@ class DetailedTagSerializer < TagSerializer has_many :categories, serializer: BasicCategorySerializer def synonyms - TagsController.tag_counts_json(object.synonyms) + TagsController.tag_counts_json(object.synonyms, scope) end def categories diff --git a/app/serializers/tag_serializer.rb b/app/serializers/tag_serializer.rb index 6fa46a80bd..81e5515696 100644 --- a/app/serializers/tag_serializer.rb +++ b/app/serializers/tag_serializer.rb @@ -3,6 +3,10 @@ class TagSerializer < ApplicationSerializer attributes :id, :name, :topic_count, :staff, :description + def topic_count + object.public_send(Tag.topic_count_column(scope)) + end + def staff DiscourseTagging.staff_tag_names.include?(name) end diff --git a/app/services/tag_hashtag_data_source.rb b/app/services/tag_hashtag_data_source.rb index 11b089c84f..b186ca12c4 100644 --- a/app/services/tag_hashtag_data_source.rb +++ b/app/services/tag_hashtag_data_source.rb @@ -12,26 +12,30 @@ class TagHashtagDataSource "tag" end - def self.tag_to_hashtag_item(tag) - tag = Tag.new(tag.slice(:id, :name, :description).merge(topic_count: tag[:count])) if tag.is_a?( - Hash, - ) + def self.tag_to_hashtag_item(tag, guardian) + topic_count_column = Tag.topic_count_column(guardian) + + tag = + Tag.new( + tag.slice(:id, :name, :description).merge(topic_count_column => tag[:count]), + ) if tag.is_a?(Hash) HashtagAutocompleteService::HashtagItem.new.tap do |item| item.text = tag.name - item.secondary_text = "x#{tag.topic_count}" + item.secondary_text = "x#{tag.public_send(topic_count_column)}" item.description = tag.description item.slug = tag.name item.relative_url = tag.url item.icon = icon end end + private_class_method :tag_to_hashtag_item def self.lookup(guardian, slugs) return [] if !SiteSetting.tagging_enabled DiscourseTagging .filter_visible(Tag.where_name(slugs), guardian) - .map { |tag| tag_to_hashtag_item(tag) } + .map { |tag| tag_to_hashtag_item(tag, guardian) } end def self.search( @@ -60,9 +64,9 @@ class TagHashtagDataSource ) TagsController - .tag_counts_json(tags_with_counts) + .tag_counts_json(tags_with_counts, guardian) .take(limit) - .map { |tag| tag_to_hashtag_item(tag) } + .map { |tag| tag_to_hashtag_item(tag, guardian) } end def self.search_sort(search_results, _) @@ -82,8 +86,8 @@ class TagHashtagDataSource ) TagsController - .tag_counts_json(tags_with_counts) + .tag_counts_json(tags_with_counts, guardian) .take(limit) - .map { |tag| tag_to_hashtag_item(tag) } + .map { |tag| tag_to_hashtag_item(tag, guardian) } end end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 88121e6d87..6fa88945fa 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1651,6 +1651,7 @@ en: content_security_policy_frame_ancestors: "Restrict who can embed this site in iframes via CSP. Control allowed hosts on Embedding" content_security_policy_script_src: "Additional allowlisted script sources. The current host and CDN are included by default. See Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Admin accounts that have not visited the site in this number of days will need to re-validate their email address before logging in. Set to 0 to disable." + include_secure_categories_in_tag_counts: "When enabled, count of topics for a tag will include topics that are in read restricted categories for all users. When disabled, normal users are only shown a count of topics for a tag where all the topics are in public categories." top_menu: "Determine which items appear in the homepage navigation, and in what order. Example latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determine which items appear on the post menu, and in what order. Example like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "The menu items to hide by default in the post menu unless an expansion ellipsis is clicked on." diff --git a/config/site_settings.yml b/config/site_settings.yml index e565dc1725..5967fe135b 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1798,6 +1798,8 @@ security: suppress_secured_categories_from_admin: default: false hidden: true + include_secure_categories_in_tag_counts: + default: false onebox: post_onebox_maxlength: diff --git a/db/migrate/20230118020114_add_public_topic_count_to_tags.rb b/db/migrate/20230118020114_add_public_topic_count_to_tags.rb new file mode 100644 index 0000000000..73b9064635 --- /dev/null +++ b/db/migrate/20230118020114_add_public_topic_count_to_tags.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddPublicTopicCountToTags < ActiveRecord::Migration[7.0] + def up + add_column :tags, :public_topic_count, :integer, default: 0, null: false + + execute <<~SQL + UPDATE tags t + SET public_topic_count = x.topic_count + FROM ( + SELECT + COUNT(topics.id) AS topic_count, + tags.id AS tag_id + FROM tags + INNER JOIN topic_tags ON tags.id = topic_tags.tag_id + INNER JOIN topics ON topics.id = topic_tags.topic_id AND topics.deleted_at IS NULL AND topics.archetype != 'private_message' + INNER JOIN categories ON categories.id = topics.category_id AND NOT categories.read_restricted + GROUP BY tags.id + ) x + WHERE x.tag_id = t.id + AND x.topic_count <> t.public_topic_count; + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20230119000943_add_staff_topic_count_to_tags.rb b/db/migrate/20230119000943_add_staff_topic_count_to_tags.rb new file mode 100644 index 0000000000..0154c12624 --- /dev/null +++ b/db/migrate/20230119000943_add_staff_topic_count_to_tags.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class AddStaffTopicCountToTags < ActiveRecord::Migration[7.0] + def up + add_column :tags, :staff_topic_count, :integer, default: 0, null: false + + execute <<~SQL + UPDATE tags t + SET staff_topic_count = x.topic_count + FROM ( + SELECT COUNT(topics.id) AS topic_count, tags.id AS tag_id + FROM tags + LEFT JOIN topic_tags ON tags.id = topic_tags.tag_id + LEFT JOIN topics ON topics.id = topic_tags.topic_id + AND topics.deleted_at IS NULL + AND topics.archetype != 'private_message' + GROUP BY tags.id + ) x + WHERE x.tag_id = t.id + AND x.topic_count <> t.staff_topic_count + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20230119024157_remove_topic_count_from_tags.rb b/db/post_migrate/20230119024157_remove_topic_count_from_tags.rb new file mode 100644 index 0000000000..f51e3a1bcd --- /dev/null +++ b/db/post_migrate/20230119024157_remove_topic_count_from_tags.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "migration/column_dropper" + +class RemoveTopicCountFromTags < ActiveRecord::Migration[7.0] + DROPPED_COLUMNS ||= { tags: %i[topic_count] } + + def up + DROPPED_COLUMNS.each { |table, columns| Migration::ColumnDropper.execute_drop(table, columns) } + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 8a8c2d7f87..0fd1583be1 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -323,17 +323,19 @@ module DiscourseTagging outer_join = category.nil? || category.allow_global_tags || !category_has_restricted_tags + topic_count_column = Tag.topic_count_column(guardian) + distinct_clause = if opts[:order_popularity] - "DISTINCT ON (topic_count, name)" + "DISTINCT ON (#{topic_count_column}, name)" elsif opts[:order_search_results] && opts[:term].present? - "DISTINCT ON (lower(name) = lower(:cleaned_term), topic_count, name)" + "DISTINCT ON (lower(name) = lower(:cleaned_term), #{topic_count_column}, name)" else "" end sql << <<~SQL - SELECT #{distinct_clause} t.id, t.name, t.topic_count, t.pm_topic_count, t.description, + SELECT #{distinct_clause} t.id, t.name, t.#{topic_count_column}, t.pm_topic_count, t.description, tgr.tgm_id as tgm_id, tgr.tag_group_id as tag_group_id, tgr.parent_tag_id as parent_tag_id, tgr.one_per_topic as one_per_topic, t.target_tag_id FROM tags t @@ -479,9 +481,9 @@ module DiscourseTagging end if opts[:order_popularity] - builder.order_by("topic_count DESC, name") + builder.order_by("#{topic_count_column} DESC, name") elsif opts[:order_search_results] && !term.blank? - builder.order_by("lower(name) = lower(:cleaned_term) DESC, topic_count DESC, name") + builder.order_by("lower(name) = lower(:cleaned_term) DESC, #{topic_count_column} DESC, name") end result = builder.query(builder_params).uniq { |t| t.id } diff --git a/lib/topic_view.rb b/lib/topic_view.rb index f6b9c19259..576b6d3c5c 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -245,7 +245,8 @@ class TopicView @topic.category title += " - #{@topic.category.name}" elsif SiteSetting.tagging_enabled && @topic.tags.exists? - title += " - #{@topic.tags.order("tags.topic_count DESC").first.name}" + title += + " - #{@topic.tags.order("tags.#{Tag.topic_count_column(@guardian)} DESC").first.name}" end end title diff --git a/spec/integration/tag_counts_spec.rb b/spec/integration/tag_counts_spec.rb new file mode 100644 index 0000000000..82ec44d7b3 --- /dev/null +++ b/spec/integration/tag_counts_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +RSpec.describe "Updating tag counts" do + fab!(:tag1) { Fabricate(:tag) } + fab!(:tag2) { Fabricate(:tag) } + fab!(:group) { Fabricate(:group) } + fab!(:public_category) { Fabricate(:category) } + fab!(:public_category2) { Fabricate(:category) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + fab!(:private_category2) { Fabricate(:private_category, group: group) } + + fab!(:topic_in_public_category) do + Fabricate(:topic, category: public_category, tags: [tag1, tag2]).tap do |topic| + Fabricate(:post, topic: topic) + end + end + + fab!(:topic_in_private_category) do + Fabricate(:topic, category: private_category, tags: [tag1, tag2]).tap do |topic| + Fabricate(:post, topic: topic) + end + end + + fab!(:private_message) do + topic = Fabricate(:private_message_post).topic + topic.update!(tags: [tag1, tag2]) + topic + end + + before do + expect(tag1.public_topic_count).to eq(1) + expect(tag1.staff_topic_count).to eq(2) + expect(tag1.pm_topic_count).to eq(1) + expect(tag2.reload.public_topic_count).to eq(1) + expect(tag2.staff_topic_count).to eq(2) + expect(tag2.pm_topic_count).to eq(1) + end + + it "should decrease Tag#public_topic_count for all tags when topic's category is changed from a public category to a read restricted category" do + expect { topic_in_public_category.change_category_to_id(private_category.id) }.to change { + tag1.reload.public_topic_count + }.by(-1).and change { tag2.reload.public_topic_count }.by(-1) + end + + it "should increase Tag#public_topic_count for all tags when topic's category is changed from a read restricted category to a public category" do + expect { topic_in_private_category.change_category_to_id(public_category.id) }.to change { + tag1.reload.public_topic_count + }.by(1).and change { tag2.reload.public_topic_count }.by(1) + end + + it "should not change Tag#public_topic_count for all tags when topic's category is changed from a public category to another public category" do + expect do + topic_in_public_category.change_category_to_id(public_category2.id) + end.to not_change { tag1.reload.public_topic_count }.and not_change { + tag2.reload.public_topic_count + } + end + + it "should not change Tag#public_topic_count for all tags when topic's category is changed from a read restricted category to another read restricted category" do + expect do + topic_in_private_category.change_category_to_id(private_category2.id) + end.to not_change { tag1.reload.public_topic_count }.and not_change { + tag2.reload.public_topic_count + } + end + + it "increases Tag#public_topic_count for all tags when topic is converted from private message to a regular topic in a public category" do + expect do + private_message.convert_to_public_topic( + Discourse.system_user, + category_id: public_category.id, + ) + end.to change { tag1.reload.public_topic_count }.by(1).and change { + tag2.reload.public_topic_count + }.by(1) + end + + it "should not change Tag#public_topic_count for all tags when topic is converted from private message to a regular topic in a read restricted category" do + expect do + private_message.convert_to_public_topic( + Discourse.system_user, + category_id: private_category.id, + ) + end.to not_change { tag1.reload.public_topic_count }.and not_change { + tag2.reload.public_topic_count + } + end + + it "should decrease Tag#public_topic_count for all tags when regular topic in public category is converted to a private message" do + expect do + topic_in_public_category.convert_to_private_message(Discourse.system_user) + end.to change { tag1.reload.public_topic_count }.by(-1).and change { + tag2.reload.public_topic_count + }.by(-1) + end + + it "should not change Tag#public_topic_count for all tags when regular topic in read restricted category is converted to a private message" do + expect do + topic_in_private_category.convert_to_private_message(Discourse.system_user) + end.to not_change { tag1.reload.public_topic_count }.and not_change { + tag2.reload.public_topic_count + } + end +end diff --git a/spec/lib/discourse_tagging_spec.rb b/spec/lib/discourse_tagging_spec.rb index 1188243c57..02782eefd5 100644 --- a/spec/lib/discourse_tagging_spec.rb +++ b/spec/lib/discourse_tagging_spec.rb @@ -641,9 +641,11 @@ RSpec.describe DiscourseTagging do it "user does not get an error when editing their topic with a hidden tag" do PostRevisor.new(post).revise!(admin, raw: post.raw, tags: [hidden_tag.name]) + expect( PostRevisor.new(post).revise!(topic.user, raw: post.raw + " edit", tags: []), ).to be_truthy + expect(topic.reload.tags).to eq([hidden_tag]) end end @@ -967,8 +969,16 @@ RSpec.describe DiscourseTagging do topic = Fabricate(:topic, tags: [tag2]) expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])).to eq(true) expect_same_tag_names(topic.reload.tags, [tag1]) - expect(tag1.reload.topic_count).to eq(1) - expect(tag2.reload.topic_count).to eq(0) + + tag1.reload + + expect(tag1.public_topic_count).to eq(1) + expect(tag1.staff_topic_count).to eq(1) + + tag2.reload + + expect(tag2.public_topic_count).to eq(0) + expect(tag2.staff_topic_count).to eq(0) end end end diff --git a/spec/lib/topic_view_spec.rb b/spec/lib/topic_view_spec.rb index 7dc2ff115f..5032846488 100644 --- a/spec/lib/topic_view_spec.rb +++ b/spec/lib/topic_view_spec.rb @@ -750,8 +750,8 @@ RSpec.describe TopicView do end describe "page_title" do - fab!(:tag1) { Fabricate(:tag) } - fab!(:tag2) { Fabricate(:tag, topic_count: 2) } + fab!(:tag1) { Fabricate(:tag, staff_topic_count: 0, public_topic_count: 0) } + fab!(:tag2) { Fabricate(:tag, staff_topic_count: 2, public_topic_count: 2) } fab!(:op_post) { Fabricate(:post, topic: topic) } fab!(:post1) { Fabricate(:post, topic: topic) } fab!(:whisper) { Fabricate(:post, topic: topic, post_type: Post.types[:whisper]) } diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 7aff07ab79..402c6c7334 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -202,29 +202,101 @@ RSpec.describe Tag do end end - describe "topic counts" do + describe ".ensure_consistency!" do it "should exclude private message topics" do topic Fabricate(:private_message_topic, tags: [tag]) Tag.ensure_consistency! tag.reload - expect(tag.topic_count).to eq(1) + expect(tag.staff_topic_count).to eq(1) + expect(tag.public_topic_count).to eq(1) + end + + it "should update Tag#topic_count and Tag#public_topic_count correctly" do + tag = Fabricate(:tag, name: "tag1") + tag2 = Fabricate(:tag, name: "tag2") + tag3 = Fabricate(:tag, name: "tag3") + group = Fabricate(:group) + category = Fabricate(:category) + private_category = Fabricate(:private_category, group: group) + private_category2 = Fabricate(:private_category, group: group) + + _topic_with_tag = Fabricate(:topic, category: category, tags: [tag]) + + _topic_with_tag_in_private_category = + Fabricate(:topic, category: private_category, tags: [tag]) + + _topic_with_tag2_in_private_category2 = + Fabricate(:topic, category: private_category2, tags: [tag2]) + + tag.update!(staff_topic_count: 123, public_topic_count: 456) + tag2.update!(staff_topic_count: 123, public_topic_count: 456) + tag3.update!(staff_topic_count: 123, public_topic_count: 456) + + Tag.ensure_consistency! + + tag.reload + tag2.reload + tag3.reload + + expect(tag.staff_topic_count).to eq(2) + expect(tag.public_topic_count).to eq(1) + expect(tag2.staff_topic_count).to eq(1) + expect(tag2.public_topic_count).to eq(0) + expect(tag3.staff_topic_count).to eq(0) + expect(tag3.public_topic_count).to eq(0) end end describe "unused tags scope" do let!(:tags) do [ - Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0), - Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3), - Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3), - Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0), - Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0), + Fabricate( + :tag, + name: "used_publically", + staff_topic_count: 2, + public_topic_count: 2, + pm_topic_count: 0, + ), + Fabricate( + :tag, + name: "used_privately", + staff_topic_count: 0, + public_topic_count: 0, + pm_topic_count: 3, + ), + Fabricate( + :tag, + name: "used_everywhere", + staff_topic_count: 0, + public_topic_count: 0, + pm_topic_count: 3, + ), + Fabricate( + :tag, + name: "unused1", + staff_topic_count: 0, + public_topic_count: 0, + pm_topic_count: 0, + ), + Fabricate( + :tag, + name: "unused2", + staff_topic_count: 0, + public_topic_count: 0, + pm_topic_count: 0, + ), ] end let(:tag_in_group) do - Fabricate(:tag, name: "unused_in_group", topic_count: 0, pm_topic_count: 0) + Fabricate( + :tag, + name: "unused_in_group", + public_topic_count: 0, + staff_topic_count: 0, + pm_topic_count: 0, + ) end let!(:tag_group) { Fabricate(:tag_group, tag_names: [tag_in_group.name]) } @@ -292,4 +364,22 @@ RSpec.describe Tag do expect(category.reload.tags).to include(tag2) end end + + describe ".topic_count_column" do + fab!(:admin) { Fabricate(:admin) } + + it "returns 'staff_topic_count' when user is staff" do + expect(Tag.topic_count_column(Guardian.new(admin))).to eq("staff_topic_count") + end + + it "returns 'public_topic_count' when user is not staff" do + expect(Tag.topic_count_column(Guardian.new(user))).to eq("public_topic_count") + end + + it "returns 'staff_topic_count' when user is not staff but `include_secure_categories_in_tag_counts` site setting is enabled" do + SiteSetting.include_secure_categories_in_tag_counts = true + + expect(Tag.topic_count_column(Guardian.new(user))).to eq("staff_topic_count") + end + end end diff --git a/spec/models/topic_tag_spec.rb b/spec/models/topic_tag_spec.rb index 33779f1e34..b4a8e231ce 100644 --- a/spec/models/topic_tag_spec.rb +++ b/spec/models/topic_tag_spec.rb @@ -1,33 +1,56 @@ # frozen_string_literal: true RSpec.describe TopicTag do + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } fab!(:topic) { Fabricate(:topic) } + fab!(:topic_in_private_category) { Fabricate(:topic, category: private_category) } fab!(:tag) { Fabricate(:tag) } let(:topic_tag) { Fabricate(:topic_tag, topic: topic, tag: tag) } describe "#after_create" do - it "tag topic_count should be increased" do - expect { topic_tag }.to change(tag, :topic_count).by(1) + it "should increase Tag#staff_topic_count and Tag#public_topic_count for a regular topic in a public category" do + expect { topic_tag }.to change { tag.reload.staff_topic_count }.by(1).and change { + tag.reload.public_topic_count + }.by(1) end - it "tag topic_count should not be increased" do + it "should only increase Tag#staff_topic_count for a regular topic in a read restricted category" do + expect { Fabricate(:topic_tag, topic: topic_in_private_category, tag: tag) }.to change { + tag.reload.staff_topic_count + }.by(1) + + expect(tag.reload.public_topic_count).to eq(0) + end + + it "should increase Tag#pm_topic_count for a private message topic" do topic.archetype = Archetype.private_message - expect { topic_tag }.not_to change(tag, :topic_count) + expect { topic_tag }.to change { tag.reload.pm_topic_count }.by(1) end end describe "#after_destroy" do - it "tag topic_count should be decreased" do + it "should decrease Tag#staff_topic_count and Tag#public_topic_count for a regular topic in a public category" do topic_tag - expect { topic_tag.destroy }.to change(tag, :topic_count).by(-1) + + expect { topic_tag.destroy! }.to change { tag.reload.staff_topic_count }.by(-1).and change { + tag.reload.public_topic_count + }.by(-1) end - it "tag topic_count should not be decreased" do + it "should only decrease Topic#topic_count for a regular topic in a read restricted category" do + topic_tag = Fabricate(:topic_tag, topic: topic_in_private_category, tag: tag) + + expect { topic_tag.destroy! }.to change { tag.reload.staff_topic_count }.by(-1) + expect(tag.reload.public_topic_count).to eq(0) + end + + it "should decrease Tag#pm_topic_count for a private message topic" do topic.archetype = Archetype.private_message topic_tag - expect { topic_tag.destroy }.not_to change(tag, :topic_count) + expect { topic_tag.destroy! }.to change { tag.reload.pm_topic_count }.by(-1) end end end diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index 8664605a4c..834589618b 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -12,18 +12,43 @@ RSpec.describe TagsController do describe "#index" do fab!(:test_tag) { Fabricate(:tag, name: "test") } - fab!(:topic_tag) { Fabricate(:tag, name: "topic-test", topic_count: 1) } + fab!(:topic_tag) do + Fabricate(:tag, name: "topic-test", public_topic_count: 1, staff_topic_count: 1) + end fab!(:synonym) { Fabricate(:tag, name: "synonym", target_tag: topic_tag) } - shared_examples "successfully retrieve tags with topic_count > 0" do - it "should return the right response" do + shared_examples "retrieves the right tags" do + it "retrieves all tags as a staff user" do + sign_in(admin) + + get "/tags.json" + + expect(response.status).to eq(200) + + tags = response.parsed_body["tags"] + + expect(tags[0]["name"]).to eq(test_tag.name) + expect(tags[0]["count"]).to eq(0) + expect(tags[0]["pm_count"]).to eq(0) + + expect(tags[1]["name"]).to eq(topic_tag.name) + expect(tags[1]["count"]).to eq(1) + expect(tags[1]["pm_count"]).to eq(0) + end + + it "only retrieve tags that have been used in public topics for non-staff user" do + sign_in(user) + get "/tags.json" expect(response.status).to eq(200) tags = response.parsed_body["tags"] expect(tags.length).to eq(1) - expect(tags[0]["text"]).to eq("topic-test") + + expect(tags[0]["name"]).to eq(topic_tag.name) + expect(tags[0]["count"]).to eq(1) + expect(tags[0]["pm_count"]).to eq(0) end end @@ -76,7 +101,7 @@ RSpec.describe TagsController do context "with tags_listed_by_group enabled" do before { SiteSetting.tags_listed_by_group = true } - include_examples "successfully retrieve tags with topic_count > 0" + include_examples "retrieves the right tags" it "works for tags in groups" do tag_group = Fabricate(:tag_group, tags: [test_tag, topic_tag, synonym]) @@ -136,20 +161,7 @@ RSpec.describe TagsController do context "with tags_listed_by_group disabled" do before { SiteSetting.tags_listed_by_group = false } - include_examples "successfully retrieve tags with topic_count > 0" - end - - context "when user can admin tags" do - it "successfully retrieve all tags" do - sign_in(admin) - - get "/tags.json" - - expect(response.status).to eq(200) - - tags = response.parsed_body["tags"] - expect(tags.length).to eq(2) - end + include_examples "retrieves the right tags" end context "with hidden tags" do @@ -763,10 +775,10 @@ RSpec.describe TagsController do expect(response.parsed_body["results"].map { |j| j["id"] }.sort).to eq(%w[stuff stumped]) end - it "returns tags ordered by topic_count, and prioritises exact matches" do - Fabricate(:tag, name: "tag1", topic_count: 10) - Fabricate(:tag, name: "tag2", topic_count: 100) - Fabricate(:tag, name: "tag", topic_count: 1) + it "returns tags ordered by public_topic_count, and prioritises exact matches" do + Fabricate(:tag, name: "tag1", public_topic_count: 10, staff_topic_count: 10) + Fabricate(:tag, name: "tag2", public_topic_count: 100, staff_topic_count: 100) + Fabricate(:tag, name: "tag", public_topic_count: 1, staff_topic_count: 1) get "/tags/filter/search.json", params: { q: "tag", limit: 2 } expect(response.status).to eq(200) @@ -976,11 +988,41 @@ RSpec.describe TagsController do context "with some tags" do let!(:tags) do [ - Fabricate(:tag, name: "used_publically", topic_count: 2, pm_topic_count: 0), - Fabricate(:tag, name: "used_privately", topic_count: 0, pm_topic_count: 3), - Fabricate(:tag, name: "used_everywhere", topic_count: 0, pm_topic_count: 3), - Fabricate(:tag, name: "unused1", topic_count: 0, pm_topic_count: 0), - Fabricate(:tag, name: "unused2", topic_count: 0, pm_topic_count: 0), + Fabricate( + :tag, + name: "used_publically", + public_topic_count: 2, + staff_topic_count: 2, + pm_topic_count: 0, + ), + Fabricate( + :tag, + name: "used_privately", + public_topic_count: 0, + staff_topic_count: 0, + pm_topic_count: 3, + ), + Fabricate( + :tag, + name: "used_everywhere", + public_topic_count: 0, + staff_topic_count: 0, + pm_topic_count: 3, + ), + Fabricate( + :tag, + name: "unused1", + public_topic_count: 0, + staff_topic_count: 0, + pm_topic_count: 0, + ), + Fabricate( + :tag, + name: "unused2", + public_topic_count: 0, + staff_topic_count: 0, + pm_topic_count: 0, + ), ] end diff --git a/spec/serializers/tag_serializer_spec.rb b/spec/serializers/tag_serializer_spec.rb new file mode 100644 index 0000000000..c9eab114fc --- /dev/null +++ b/spec/serializers/tag_serializer_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe TagSerializer do + fab!(:user) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + fab!(:tag) { Fabricate(:tag) } + fab!(:group) { Fabricate(:group) } + fab!(:private_category) { Fabricate(:private_category, group: group) } + fab!(:topic_in_public_category) { Fabricate(:topic, tags: [tag]) } + fab!(:topic_in_private_category) { Fabricate(:topic, category: private_category, tags: [tag]) } + + describe "#topic_count" do + it "should return the value of `Tag#public_topic_count` for a non-staff user" do + serialized = described_class.new(tag, scope: Guardian.new(user), root: false).as_json + + expect(serialized[:topic_count]).to eq(1) + end + + it "should return the vavlue of `Tag#topic_count` for a staff user" do + serialized = described_class.new(tag, scope: Guardian.new(admin), root: false).as_json + + expect(serialized[:topic_count]).to eq(2) + end + end +end diff --git a/spec/serializers/topic_view_serializer_spec.rb b/spec/serializers/topic_view_serializer_spec.rb index 1d064811c8..015ef3d1f3 100644 --- a/spec/serializers/topic_view_serializer_spec.rb +++ b/spec/serializers/topic_view_serializer_spec.rb @@ -279,9 +279,33 @@ RSpec.describe TopicViewSerializer do end describe "tags order" do - fab!(:tag1) { Fabricate(:tag, name: "ctag", description: "c description", topic_count: 5) } - fab!(:tag2) { Fabricate(:tag, name: "btag", description: "b description", topic_count: 9) } - fab!(:tag3) { Fabricate(:tag, name: "atag", description: "a description", topic_count: 3) } + fab!(:tag1) do + Fabricate( + :tag, + name: "ctag", + description: "c description", + staff_topic_count: 5, + public_topic_count: 5, + ) + end + fab!(:tag2) do + Fabricate( + :tag, + name: "btag", + description: "b description", + staff_topic_count: 9, + public_topic_count: 9, + ) + end + fab!(:tag3) do + Fabricate( + :tag, + name: "atag", + description: "a description", + staff_topic_count: 3, + public_topic_count: 3, + ) + end before do topic.tags << tag1 diff --git a/spec/services/hashtag_autocomplete_service_spec.rb b/spec/services/hashtag_autocomplete_service_spec.rb index f37eb5810c..986124548d 100644 --- a/spec/services/hashtag_autocomplete_service_spec.rb +++ b/spec/services/hashtag_autocomplete_service_spec.rb @@ -3,7 +3,9 @@ RSpec.describe HashtagAutocompleteService do fab!(:user) { Fabricate(:user) } fab!(:category1) { Fabricate(:category, name: "The Book Club", slug: "the-book-club") } - fab!(:tag1) { Fabricate(:tag, name: "great-books", topic_count: 22) } + fab!(:tag1) do + Fabricate(:tag, name: "great-books", staff_topic_count: 22, public_topic_count: 22) + end fab!(:topic1) { Fabricate(:topic) } let(:guardian) { Guardian.new(user) } @@ -68,7 +70,7 @@ RSpec.describe HashtagAutocompleteService do end it "includes the tag count" do - tag1.update!(topic_count: 78) + tag1.update!(staff_topic_count: 78, public_topic_count: 78) expect(subject.search("book", %w[tag category]).map(&:text)).to eq( ["great-books", "The Book Club"], ) @@ -149,8 +151,8 @@ RSpec.describe HashtagAutocompleteService do category6 = Fabricate(:category, name: "Book Reviews", slug: "book-reviews") Fabricate(:category, name: "Good Books", slug: "book", parent_category: category6) - Fabricate(:tag, name: "bookmania", topic_count: 15) - Fabricate(:tag, name: "awful-books", topic_count: 56) + Fabricate(:tag, name: "bookmania", staff_topic_count: 15, public_topic_count: 15) + Fabricate(:tag, name: "awful-books", staff_topic_count: 56, public_topic_count: 56) expect(subject.search("book", %w[category tag]).map(&:ref)).to eq( [ @@ -220,9 +222,13 @@ RSpec.describe HashtagAutocompleteService do end fab!(:category4) { Fabricate(:category, name: "Bookworld", slug: "book", topic_count: 56) } fab!(:category5) { Fabricate(:category, name: "Media", slug: "media", topic_count: 446) } - fab!(:tag2) { Fabricate(:tag, name: "mid-books", topic_count: 33) } - fab!(:tag3) { Fabricate(:tag, name: "terrible-books", topic_count: 2) } - fab!(:tag4) { Fabricate(:tag, name: "book", topic_count: 1) } + fab!(:tag2) do + Fabricate(:tag, name: "mid-books", staff_topic_count: 33, public_topic_count: 33) + end + fab!(:tag3) do + Fabricate(:tag, name: "terrible-books", staff_topic_count: 2, public_topic_count: 2) + end + fab!(:tag4) { Fabricate(:tag, name: "book", staff_topic_count: 1, public_topic_count: 1) } it "returns the 'most polular' categories and tags (based on topic_count) that the user can access" do category1.update!(read_restricted: true) diff --git a/spec/services/tag_hashtag_data_source_spec.rb b/spec/services/tag_hashtag_data_source_spec.rb index 3598c664f9..f1475973ac 100644 --- a/spec/services/tag_hashtag_data_source_spec.rb +++ b/spec/services/tag_hashtag_data_source_spec.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true RSpec.describe TagHashtagDataSource do - fab!(:tag1) { Fabricate(:tag, name: "fact", topic_count: 0) } - fab!(:tag2) { Fabricate(:tag, name: "factor", topic_count: 5) } - fab!(:tag3) { Fabricate(:tag, name: "factory", topic_count: 4) } - fab!(:tag4) { Fabricate(:tag, name: "factorio", topic_count: 3) } - fab!(:tag5) { Fabricate(:tag, name: "factz", topic_count: 1) } + fab!(:tag1) { Fabricate(:tag, name: "fact", public_topic_count: 0) } + fab!(:tag2) { Fabricate(:tag, name: "factor", public_topic_count: 5) } + fab!(:tag3) { Fabricate(:tag, name: "factory", public_topic_count: 4) } + fab!(:tag4) { Fabricate(:tag, name: "factorio", public_topic_count: 3) } + fab!(:tag5) { Fabricate(:tag, name: "factz", public_topic_count: 1) } fab!(:user) { Fabricate(:user) } let(:guardian) { Guardian.new(user) } describe "#search" do - it "orders tag results by exact search match, then topic count, then name" do + it "orders tag results by exact search match, then public topic count, then name" do expect(described_class.search(guardian, "fact", 5).map(&:slug)).to eq( %w[fact factor factory factorio factz], ) @@ -31,7 +31,7 @@ RSpec.describe TagHashtagDataSource do ) end - it "includes the topic count for the text of the tag in secondary text" do + it "includes the public topic count for the text of the tag in secondary text" do expect(described_class.search(guardian, "fact", 5).map(&:secondary_text)).to eq( %w[x0 x5 x4 x3 x1], ) @@ -52,7 +52,7 @@ RSpec.describe TagHashtagDataSource do end describe "#search_without_term" do - it "returns distinct tags sorted by topic_count" do + it "returns distinct tags sorted by public topic count" do expect(described_class.search_without_term(guardian, 5).map(&:slug)).to eq( %w[factor factory factorio factz fact], ) diff --git a/spec/support/user_sidebar_serializer_attributes.rb b/spec/support/user_sidebar_serializer_attributes.rb index f7cea951b3..9bf6eaa5d5 100644 --- a/spec/support/user_sidebar_serializer_attributes.rb +++ b/spec/support/user_sidebar_serializer_attributes.rb @@ -65,7 +65,9 @@ RSpec.shared_examples "User Sidebar Serializer Attributes" do |serializer_klass| describe "#sidebar_tags" do fab!(:tag) { Fabricate(:tag, name: "foo") } - fab!(:pm_tag) { Fabricate(:tag, name: "bar", pm_topic_count: 5, topic_count: 0) } + fab!(:pm_tag) do + Fabricate(:tag, name: "bar", pm_topic_count: 5, staff_topic_count: 0, public_topic_count: 0) + end fab!(:hidden_tag) { Fabricate(:tag, name: "secret") } fab!(:staff_tag_group) do Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: ["secret"]) diff --git a/spec/system/hashtag_autocomplete_spec.rb b/spec/system/hashtag_autocomplete_spec.rb index 131c19d32f..254f6a686c 100644 --- a/spec/system/hashtag_autocomplete_spec.rb +++ b/spec/system/hashtag_autocomplete_spec.rb @@ -10,8 +10,8 @@ describe "Using #hashtag autocompletion to search for and lookup categories and fab!(:category2) do Fabricate(:category, name: "Other Category", slug: "other-cat", topic_count: 23) end - fab!(:tag) { Fabricate(:tag, name: "cooltag", topic_count: 324) } - fab!(:tag2) { Fabricate(:tag, name: "othertag", topic_count: 66) } + fab!(:tag) { Fabricate(:tag, name: "cooltag", staff_topic_count: 324, public_topic_count: 324) } + fab!(:tag2) { Fabricate(:tag, name: "othertag", staff_topic_count: 66, public_topic_count: 66) } fab!(:topic) { Fabricate(:topic, category: category, tags: [tag]) } fab!(:post) { Fabricate(:post, topic: topic) } let(:uncategorized_category) { Category.find(SiteSetting.uncategorized_category_id) } From f409e977a91eec3e454fe3987cb0a64264a1f21f Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Fri, 20 Jan 2023 13:29:49 +1100 Subject: [PATCH 095/169] FIX: deleted misconfigured embeddable hosts (#19833) When EmbeddableHost is configured for a specific category and that category is deleted, then EmbeddableHost should be deleted as well. In addition, migration was added to fix existing data. --- app/models/category.rb | 1 + ...3_delete_misconfigured_embeddable_hosts.rb | 19 +++++++++++++++++++ spec/models/category_spec.rb | 6 ++++++ 3 files changed, 26 insertions(+) create mode 100644 db/migrate/20230111223803_delete_misconfigured_embeddable_hosts.rb diff --git a/app/models/category.rb b/app/models/category.rb index fc721f0940..220ca006cd 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -130,6 +130,7 @@ class Category < ActiveRecord::Base has_many :category_required_tag_groups, -> { order(order: :asc) }, dependent: :destroy has_many :sidebar_section_links, as: :linkable, dependent: :delete_all + has_many :embeddable_hosts, dependent: :destroy belongs_to :reviewable_by_group, class_name: "Group" diff --git a/db/migrate/20230111223803_delete_misconfigured_embeddable_hosts.rb b/db/migrate/20230111223803_delete_misconfigured_embeddable_hosts.rb new file mode 100644 index 0000000000..89c54d1052 --- /dev/null +++ b/db/migrate/20230111223803_delete_misconfigured_embeddable_hosts.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class DeleteMisconfiguredEmbeddableHosts < ActiveRecord::Migration[7.0] + def up + execute(<<~SQL) + DELETE FROM embeddable_hosts eh1 + WHERE eh1.id IN ( + SELECT eh2.id FROM embeddable_hosts eh2 + LEFT JOIN categories ON categories.id = eh2.category_id + WHERE eh2.category_id IS NOT NULL + AND categories.id IS NULL + ) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index ecabfc50c6..da44f31eb4 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -577,6 +577,12 @@ RSpec.describe Category do expect(SiteSetting.shared_drafts_category).to be_blank end + it "deletes related embeddable host" do + embeddable_host = Fabricate(:embeddable_host, category: @category) + @category.destroy! + expect { embeddable_host.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + it "triggers a extensibility event" do event = DiscourseEvent.track(:category_destroyed) { @category.destroy } From b05f193cf0dd1c11a60042675c9e14f47f3725ea Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Fri, 20 Jan 2023 13:30:39 +1100 Subject: [PATCH 096/169] FIX: move min tag setting to tags section in edit category (#19789) `Minimum number of tags required in a topic` should be in `Tags` panel instead of `Settings` --- .../components/edit-category-settings.hbs | 16 +--------------- .../templates/components/edit-category-tags.hbs | 13 ++++++++++++- .../tests/acceptance/category-edit-test.js | 2 ++ .../stylesheets/common/base/edit-category.scss | 3 ++- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs index 2080982b7a..5d1ab2c237 100644 --- a/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/edit-category-settings.hbs @@ -181,20 +181,6 @@
    - {{#if this.siteSettings.tagging_enabled}} -
    - - -
    - {{/if}} -
    \ No newline at end of file + diff --git a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js index 9f777d5877..a315b60e72 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/category-edit-test.js @@ -77,6 +77,8 @@ acceptance("Category Edit", function (needs) { test("Editing required tag groups", async function (assert) { await visit("/c/bug/edit/tags"); + assert.ok(exists(".minimum-required-tags")); + assert.ok(exists(".required-tag-groups")); assert.strictEqual(count(".required-tag-group-row"), 0); diff --git a/app/assets/stylesheets/common/base/edit-category.scss b/app/assets/stylesheets/common/base/edit-category.scss index f93326e2c9..0365781b45 100644 --- a/app/assets/stylesheets/common/base/edit-category.scss +++ b/app/assets/stylesheets/common/base/edit-category.scss @@ -102,7 +102,8 @@ div.edit-category { } } - .edit-category-tab-settings { + .edit-category-tab-settings, + .edit-category-tab-tags { > section { margin-bottom: 1.5em; } From 019ec74076e271059123cbcf26f832e8d8c99e6c Mon Sep 17 00:00:00 2001 From: Krzysztof Kotlarek Date: Fri, 20 Jan 2023 13:31:51 +1100 Subject: [PATCH 097/169] FEATURE: setting which allows TL4 users to deleted posts (#19766) New setting which allows TL4 users to delete/view/recover posts and topics --- config/locales/server.en.yml | 1 + config/site_settings.yml | 3 +++ lib/guardian/post_guardian.rb | 5 ++++- lib/guardian/topic_guardian.rb | 6 ++++-- spec/lib/guardian_spec.rb | 35 ++++++++++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6fa88945fa..c5f9d0ff42 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1894,6 +1894,7 @@ en: tl3_requires_likes_given: "The minimum number of likes that must be given in the last (tl3 time period) days to qualify for promotion to trust level 3." tl3_requires_likes_received: "The minimum number of likes that must be received in the last (tl3 time period) days to qualify for promotion to trust level 3." tl3_links_no_follow: "Do not remove rel=nofollow from links posted by trust level 3 users." + tl4_delete_posts_and_topics: "Allow TL4 users to delete posts and topics created by other users. TL4 users will also be able to see deleted topics and posts." trusted_users_can_edit_others: "Allow users with high trust levels to edit content from other users" min_trust_to_create_topic: "The minimum trust level required to create a new topic." diff --git a/config/site_settings.yml b/config/site_settings.yml index 5967fe135b..a39482aec9 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -1672,6 +1672,9 @@ trust: tl3_links_no_follow: default: false client: true + tl4_delete_posts_and_topics: + default: false + client: true trusted_users_can_edit_others: default: true diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 6ca1256896..1ecd956f1b 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -191,6 +191,8 @@ module PostGuardian return true if is_staff? || is_category_group_moderator?(post.topic&.category) + return true if SiteSetting.tl4_delete_posts_and_topics && user.has_trust_level?(TrustLevel[4]) + # Can't delete posts in archived topics unless you are staff return false if post.topic&.archived? @@ -311,7 +313,8 @@ module PostGuardian end def can_see_deleted_posts?(category = nil) - is_staff? || is_category_group_moderator?(category) + is_staff? || is_category_group_moderator?(category) || + (SiteSetting.tl4_delete_posts_and_topics && @user.has_trust_level?(TrustLevel[4])) end def can_view_raw_email?(post) diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 0846a9feb4..bb209c0aaa 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -142,7 +142,8 @@ module TopicGuardian end def can_recover_topic?(topic) - if is_staff? || (topic&.category && is_category_group_moderator?(topic.category)) + if is_staff? || (topic&.category && is_category_group_moderator?(topic.category)) || + (SiteSetting.tl4_delete_posts_and_topics && user.has_trust_level?(TrustLevel[4])) !!(topic && topic.deleted_at) else topic && can_recover_post?(topic.ordered_posts.first) @@ -156,7 +157,8 @@ module TopicGuardian ( is_my_own?(topic) && topic.posts_count <= 1 && topic.created_at && topic.created_at > 24.hours.ago - ) || is_category_group_moderator?(topic.category) + ) || is_category_group_moderator?(topic.category) || + (SiteSetting.tl4_delete_posts_and_topics && user.has_trust_level?(TrustLevel[4])) ) && !topic.is_category_topic? && !Discourse.static_doc_topic_ids.include?(topic.id) end diff --git a/spec/lib/guardian_spec.rb b/spec/lib/guardian_spec.rb index 4e5bf8d88f..8bdfe06e42 100644 --- a/spec/lib/guardian_spec.rb +++ b/spec/lib/guardian_spec.rb @@ -1347,6 +1347,13 @@ RSpec.describe Guardian do expect(Guardian.new(user).can_recover_topic?(topic)).to be_falsey end + it "returns true when tl4 can delete posts and topics" do + PostDestroyer.new(moderator, topic.first_post).destroy + expect(Guardian.new(trust_level_4).can_recover_topic?(topic)).to be_falsey + SiteSetting.tl4_delete_posts_and_topics = true + expect(Guardian.new(trust_level_4).can_recover_topic?(topic.reload)).to be_truthy + end + context "as a moderator" do describe "when post has been deleted" do it "should return the right value" do @@ -2195,6 +2202,12 @@ RSpec.describe Guardian do expect(Guardian.new(topic.user).can_delete?(topic)).to be_falsey end + it "returns true when tl4 can delete posts and topics" do + expect(Guardian.new(trust_level_4).can_delete?(topic)).to be_falsey + SiteSetting.tl4_delete_posts_and_topics = true + expect(Guardian.new(trust_level_4).can_delete?(topic)).to be_truthy + end + it "returns false if topic was created > 24h ago" do topic.update!(posts_count: 1, created_at: 48.hours.ago) expect(Guardian.new(topic.user).can_delete?(topic)).to be_falsey @@ -2241,6 +2254,12 @@ RSpec.describe Guardian do expect(Guardian.new(trust_level_4).can_delete?(post)).to be_falsey end + it "returns true when tl4 can delete posts and topics" do + expect(Guardian.new(trust_level_4).can_delete?(post)).to be_falsey + SiteSetting.tl4_delete_posts_and_topics = true + expect(Guardian.new(trust_level_4).can_delete?(post)).to be_truthy + end + it "returns false when self deletions are disabled" do SiteSetting.max_post_deletions_per_day = 0 expect(Guardian.new(user).can_delete?(post)).to be_falsey @@ -2384,6 +2403,22 @@ RSpec.describe Guardian do end end + describe "#can_see_deleted_posts?" do + it "returns true if the user is an admin" do + expect(Guardian.new(admin).can_see_deleted_posts?(post.topic.category)).to be_truthy + end + + it "returns true if the user is a moderator of category" do + expect(Guardian.new(moderator).can_see_deleted_posts?(post.topic.category)).to be_truthy + end + + it "returns true when tl4 can delete posts and topics" do + expect(Guardian.new(trust_level_4).can_see_deleted_posts?(post)).to be_falsey + SiteSetting.tl4_delete_posts_and_topics = true + expect(Guardian.new(trust_level_4).can_see_deleted_posts?(post)).to be_truthy + end + end + describe "#can_approve?" do it "wont allow a non-logged in user to approve" do expect(Guardian.new.can_approve?(user)).to be_falsey From 90d452ab6cb0567d713cf71ebdd6590232cd8616 Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Fri, 20 Jan 2023 11:16:04 +0800 Subject: [PATCH 098/169] FIX: Don't display staff-only options to non-staff in group member bulk menu (#19907) In the group member bulk edit menu we are displaying staff-only options to non-staff. The requests are blocked by the back-end, so there is no harm other than to the user experience. Notably the individual user edit menu is correctly filtering out unavailable options. This change brings the bulk edit menu in line with that. --- .../components/bulk-group-member-dropdown.js | 32 +++++++------ .../tests/acceptance/group-index-test.js | 48 +++++++++++++++++-- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/discourse/app/components/bulk-group-member-dropdown.js b/app/assets/javascripts/discourse/app/components/bulk-group-member-dropdown.js index baeddb0746..76fe26e6d5 100644 --- a/app/assets/javascripts/discourse/app/components/bulk-group-member-dropdown.js +++ b/app/assets/javascripts/discourse/app/components/bulk-group-member-dropdown.js @@ -39,22 +39,24 @@ export default DropdownSelectBoxComponent.extend({ }); } - if (this.bulkSelection.some((m) => !m.primary)) { - items.push({ - id: "setPrimary", - name: I18n.t("groups.members.make_all_primary"), - description: I18n.t("groups.members.make_all_primary_description"), - icon: "id-card", - }); - } + if (this.currentUser.staff) { + if (this.bulkSelection.some((m) => !m.primary)) { + items.push({ + id: "setPrimary", + name: I18n.t("groups.members.make_all_primary"), + description: I18n.t("groups.members.make_all_primary_description"), + icon: "id-card", + }); + } - if (this.bulkSelection.some((m) => m.primary)) { - items.push({ - id: "unsetPrimary", - name: I18n.t("groups.members.remove_all_primary"), - description: I18n.t("groups.members.remove_all_primary_description"), - icon: "id-card", - }); + if (this.bulkSelection.some((m) => m.primary)) { + items.push({ + id: "unsetPrimary", + name: I18n.t("groups.members.remove_all_primary"), + description: I18n.t("groups.members.remove_all_primary_description"), + icon: "id-card", + }); + } } return items; 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 226775a680..2707adf91d 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/group-index-test.js @@ -72,10 +72,9 @@ acceptance("Group Members", function (needs) { ); }); - test("Shows bulk actions", async function (assert) { + test("Shows bulk actions as an admin user", async function (assert) { await visit("/g/discourse"); - assert.ok(exists("button.bulk-select")); await click("button.bulk-select"); await click(queryAll("input.bulk-select")[0]); @@ -83,7 +82,50 @@ acceptance("Group Members", function (needs) { const memberDropdown = selectKit(".bulk-group-member-dropdown"); await memberDropdown.expand(); - await memberDropdown.selectRowByValue("makeOwners"); + + assert.ok( + exists('[data-value="removeMembers"]'), + "it includes remove member option" + ); + + assert.ok( + exists('[data-value="makeOwners"]'), + "it includes make owners option" + ); + + assert.ok( + exists('[data-value="setPrimary"]'), + "it includes set primary option" + ); + }); + + test("Shows bulk actions as a group owner", async function (assert) { + updateCurrentUser({ moderator: false, admin: false }); + + await visit("/g/discourse"); + + await click("button.bulk-select"); + + await click(queryAll("input.bulk-select")[0]); + await click(queryAll("input.bulk-select")[1]); + + const memberDropdown = selectKit(".bulk-group-member-dropdown"); + await memberDropdown.expand(); + + assert.ok( + exists('[data-value="removeMembers"]'), + "it includes remove member option" + ); + + assert.ok( + exists('[data-value="makeOwners"]'), + "it includes make owners option" + ); + + assert.notOk( + exists('[data-value="setPrimary"]'), + "it does not include set primary (staff only) option" + ); }); test("Bulk actions - Menu, Select all and Clear all buttons", async function (assert) { From 0c30f31f17c7d118efa39431b2229fc14c1d3328 Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Fri, 20 Jan 2023 14:23:19 +0300 Subject: [PATCH 099/169] FIX: Allow modals to scroll on mobile when keyboard is open (#19930) Meta topic: https://meta.discourse.org/t/android-keyboard-overlaps-text-when-flagging-with-something-else/249687?u=osama On Android, it's currently not possible to scroll modals that take input from the user (such as the flagging modal) when the keyboard is open which means that the keyboard can cover up part of the modal with no way for the user to see the covered part without closing the keyboard. This commit adds some CSS to make these modals scrollable when the keyboard is open. --- app/assets/stylesheets/mobile/modal.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/assets/stylesheets/mobile/modal.scss b/app/assets/stylesheets/mobile/modal.scss index fb69050d3d..d004c9309e 100644 --- a/app/assets/stylesheets/mobile/modal.scss +++ b/app/assets/stylesheets/mobile/modal.scss @@ -106,6 +106,14 @@ &.insert-hyperlink-modal .modal-inner-container { overflow: visible; } + + html.keyboard-visible:not(.ios-device) & { + height: calc(100% - env(keyboard-inset-height)); + + .modal-inner-container { + margin: auto; + } + } } .modal .modal-body.reorder-categories { From 62aa2adc740db01f81503bda7a919e7677b38d28 Mon Sep 17 00:00:00 2001 From: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> Date: Fri, 20 Jan 2023 07:40:48 -0600 Subject: [PATCH 100/169] UX: Add margin to search keyword (#19931) --- app/assets/stylesheets/common/base/search-menu.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 725fa1e43d..1c3b6a5a40 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -213,6 +213,9 @@ $search-pad-horizontal: 0.5em; } .search-item-slug { + .keyword { + margin-right: 0.33em; + } .badge-wrapper { font-size: var(--font-0); margin-left: 2px; From b412f03b2908727ef299551d8152a86bb2716918 Mon Sep 17 00:00:00 2001 From: Jordan Vidrine <30537603+jordanvidrine@users.noreply.github.com> Date: Fri, 20 Jan 2023 07:59:25 -0600 Subject: [PATCH 101/169] UX: Remove left margin (#19932) --- app/assets/stylesheets/common/base/compose.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 76e835deb7..d5c529c4b4 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -308,7 +308,7 @@ html.composer-open { .mini-tag-chooser { flex-grow: 1; max-width: calc(50% - 4px); - margin: 0 0 8px 8px; + margin: 0 0 8px 0px; z-index: z("composer", "dropdown"); .select-kit-header { From 7ebd8a44f5c4fd5ed935bd2b016c5565c7f5d568 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 20 Jan 2023 10:05:16 -0500 Subject: [PATCH 102/169] UX: hide date in timeline when wrapping (#19912) --- 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 dcf6afb523..ea13ee5bc3 100644 --- a/app/assets/stylesheets/common/topic-timeline.scss +++ b/app/assets/stylesheets/common/topic-timeline.scss @@ -259,6 +259,7 @@ overflow: hidden; padding-left: 1em; position: absolute; // prevents text length from impacting width + max-height: 3em; // this hides the date when the count + date would wrap to more than 2 lines } .timeline-ago { From 1521bace4f8682a1bc9b2706bb3bb85eb32aff83 Mon Sep 17 00:00:00 2001 From: Kris Date: Fri, 20 Jan 2023 10:30:57 -0500 Subject: [PATCH 103/169] A11Y: add secondary skip link to user profiles (#19926) --- .../discourse/app/templates/components/badge-title.hbs | 2 +- .../javascripts/discourse/app/templates/group/activity.hbs | 2 +- .../javascripts/discourse/app/templates/group/manage.hbs | 2 +- .../javascripts/discourse/app/templates/group/messages.hbs | 2 +- .../javascripts/discourse/app/templates/group/permissions.hbs | 2 +- app/assets/javascripts/discourse/app/templates/preferences.hbs | 2 +- .../javascripts/discourse/app/templates/user-invited-show.hbs | 1 + app/assets/javascripts/discourse/app/templates/user.hbs | 3 +++ .../javascripts/discourse/app/templates/user/activity.hbs | 2 +- app/assets/javascripts/discourse/app/templates/user/badges.hbs | 2 +- .../javascripts/discourse/app/templates/user/messages.hbs | 2 +- .../javascripts/discourse/app/templates/user/notifications.hbs | 2 +- .../javascripts/discourse/app/templates/user/summary.hbs | 2 +- config/locales/client.en.yml | 1 + 14 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs b/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs index 62e7b1e886..4423882019 100644 --- a/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/badge-title.hbs @@ -1,5 +1,5 @@
    -
    +
    diff --git a/app/assets/javascripts/discourse/app/templates/group/activity.hbs b/app/assets/javascripts/discourse/app/templates/group/activity.hbs index b916428639..98e6fa05b7 100644 --- a/app/assets/javascripts/discourse/app/templates/group/activity.hbs +++ b/app/assets/javascripts/discourse/app/templates/group/activity.hbs @@ -16,6 +16,6 @@
    -
    +
    {{outlet}}
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/group/manage.hbs b/app/assets/javascripts/discourse/app/templates/group/manage.hbs index 4e1d80cfc3..f103e7f3ed 100644 --- a/app/assets/javascripts/discourse/app/templates/group/manage.hbs +++ b/app/assets/javascripts/discourse/app/templates/group/manage.hbs @@ -12,6 +12,6 @@ {{/each}}
    -
    +
    {{outlet}}
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/group/messages.hbs b/app/assets/javascripts/discourse/app/templates/group/messages.hbs index dc985edf67..b5919924c6 100644 --- a/app/assets/javascripts/discourse/app/templates/group/messages.hbs +++ b/app/assets/javascripts/discourse/app/templates/group/messages.hbs @@ -12,6 +12,6 @@
    -
    +
    {{outlet}}
    \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/group/permissions.hbs b/app/assets/javascripts/discourse/app/templates/group/permissions.hbs index 6ad2d05354..fcd231f6b1 100644 --- a/app/assets/javascripts/discourse/app/templates/group/permissions.hbs +++ b/app/assets/javascripts/discourse/app/templates/group/permissions.hbs @@ -1,4 +1,4 @@ -
    +
    {{#if this.model.permissions}}