diff --git a/.eslintrc b/.eslintrc index 8706c9f23d..511724fb50 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,14 +5,41 @@ "eol-last": 2 }, "globals": { + "_": "off", + "acceptance": "off", + "asyncRender": "off", + "bootbox": "off", + "click": "off", + "count": "off", + "currentPath": "off", + "currentRouteName": "off", + "currentURL": "off", + "currentUser": "off", + "Discourse": "off", + "exists": "off", + "fillIn": "off", + "find": "off", + "getSettledState": "off", + "hasModule": "off", + "invisible": "off", + "jQuery": "off", + "keyboardHelper": "off", + "keyEvent": "off", "moduleFor": "off", "moduleForComponent": "off", - "testStart": "off", - "testDone": "off", + "pauseTest": "off", + "Pretender": "off", + "query": "off", + "queryAll": "off", + "QUnit": "off", + "sandbox": "off", "sinon": "off", - "currentURL": "off", - "invisible": "off", + "test": "off", + "testDone": "off", + "testStart": "off", + "triggerEvent": "off", "visible": "off", - "count": "off" + "visit": "off", + "waitUntil": "off" } } diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f1ca33ad32..ac5b712d03 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -61,11 +61,11 @@ jobs: - name: ESLint (core) if: ${{ always() }} - run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern app/assets/javascripts + run: yarn eslint app/assets/javascripts - name: ESLint (core plugins) if: ${{ always() }} - run: yarn eslint --ext .js,.js.es6 --no-error-on-unmatched-pattern plugins/**/{test,assets}/javascripts + run: yarn eslint plugins - name: Prettier if: ${{ always() }} @@ -73,9 +73,9 @@ jobs: yarn prettier -v yarn prettier --list-different \ "app/assets/stylesheets/**/*.scss" \ - "app/assets/javascripts/**/*.{js,es6}" \ + "app/assets/javascripts/**/*.js" \ "plugins/**/assets/stylesheets/**/*.scss" \ - "plugins/**/assets/javascripts/**/*.{js,es6}" + "plugins/**/assets/javascripts/**/*.js" - name: Ember template lint if: ${{ always() }} diff --git a/Gemfile b/Gemfile index e09839e88a..9cdfbf21a9 100644 --- a/Gemfile +++ b/Gemfile @@ -181,9 +181,14 @@ group :development do gem 'yaml-lint' end -group ENV["ALLOW_DEV_POPULATE"] == "1" ? :production : :development do +if ENV["ALLOW_DEV_POPULATE"] == "1" gem 'discourse_dev_assets' gem 'faker', "~> 2.16" +else + group :development do + gem 'discourse_dev_assets' + gem 'faker', "~> 2.16" + end end # this is an optional gem, it provides a high performance replacement diff --git a/Gemfile.lock b/Gemfile.lock index 0e75936277..6cbb820298 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,7 @@ GEM sprockets (>= 3.3, < 4.1) ember-source (2.18.2) erubi (1.10.0) - excon (0.87.0) + excon (0.88.0) execjs (2.8.1) exifr (1.3.9) fabrication (2.22.0) @@ -171,23 +171,23 @@ GEM hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.1.1) - i18n (1.8.10) + i18n (1.8.11) concurrent-ruby (~> 1.0) - image_optim (0.31.0) + image_optim (0.31.1) exifr (~> 1.2, >= 1.2.2) fspath (~> 3.0) - image_size (>= 1.5, < 3) + image_size (>= 1.5, < 4) in_threads (~> 1.3) progress (~> 3.0, >= 3.0.1) - image_size (2.1.2) + image_size (3.0.1) in_threads (1.5.4) - ipaddr (1.2.2) + ipaddr (1.2.3) jmespath (1.4.0) jquery-rails (4.4.0) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.0) + json (2.6.1) json-schema (2.8.1) addressable (>= 2.4) json_schemer (0.2.18) @@ -197,12 +197,10 @@ GEM uri_template (~> 0.7) jwt (2.3.0) kgio (2.11.4) - libv8-node (15.14.0.1) - libv8-node (15.14.0.1-arm64-darwin-20) - libv8-node (15.14.0.1-x86_64-darwin-18) - libv8-node (15.14.0.1-x86_64-darwin-19) - libv8-node (15.14.0.1-x86_64-darwin-20) - libv8-node (15.14.0.1-x86_64-linux) + libv8-node (16.10.0.0) + libv8-node (16.10.0.0-x86_64-darwin) + libv8-node (16.10.0.0-x86_64-darwin-19) + libv8-node (16.10.0.0-x86_64-linux) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -215,7 +213,7 @@ GEM logstash-event (1.2.02) logstash-logger (0.26.1) logstash-event (~> 1.2) - logster (2.9.7) + logster (2.9.8) loofah (2.12.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -228,8 +226,8 @@ GEM method_source (1.0.0) mini_mime (1.1.2) mini_portile2 (2.6.1) - mini_racer (0.4.0) - libv8-node (~> 15.14.0.0) + mini_racer (0.5.0) + libv8-node (~> 16.10.0.0) mini_scheduler (0.13.0) sidekiq (>= 4.2.3) mini_sql (1.1.3) @@ -254,7 +252,7 @@ GEM racc (~> 1.4) nokogiri (1.12.5-x86_64-linux) racc (~> 1.4) - oauth (0.5.6) + oauth (0.5.8) oauth2 (1.4.7) faraday (>= 0.8, < 2.0) jwt (>= 1.0, < 3.0) @@ -265,7 +263,7 @@ GEM omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) - omniauth-facebook (8.0.0) + omniauth-facebook (9.0.0) omniauth-oauth2 (~> 1.2) omniauth-github (1.4.0) omniauth (~> 1.5) @@ -278,7 +276,7 @@ GEM omniauth-oauth (1.2.0) oauth omniauth (>= 1.0, < 3) - omniauth-oauth2 (1.7.1) + omniauth-oauth2 (1.7.2) oauth2 (~> 1.4) omniauth (>= 1.9, < 3) omniauth-twitter (1.4.0) @@ -325,7 +323,7 @@ GEM activerecord (~> 6.0) concurrent-ruby railties (~> 6.0) - rails_multisite (3.1.0) + rails_multisite (4.0.0) activerecord (> 5.0, < 7) railties (> 5.0, < 7) railties (6.1.4.1) @@ -381,7 +379,7 @@ GEM rspec-expectations (~> 3.10) rspec-mocks (~> 3.10) rspec-support (~> 3.10) - rspec-support (3.10.2) + rspec-support (3.10.3) rss (0.2.9) rexml rswag-specs (2.4.0) @@ -389,7 +387,7 @@ GEM json-schema (~> 2.2) railties (>= 3.1, < 7.0) rtlit (0.0.5) - rubocop (1.22.1) + rubocop (1.22.3) parallel (~> 1.10) parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) @@ -398,12 +396,12 @@ GEM rubocop-ast (>= 1.12.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.12.0) + rubocop-ast (1.13.0) parser (>= 3.0.1.1) rubocop-discourse (2.4.2) rubocop (>= 1.1.0) rubocop-rspec (>= 2.0.0) - rubocop-rspec (2.5.0) + rubocop-rspec (2.6.0) rubocop (~> 1.19) ruby-prof (1.4.3) ruby-progressbar (1.11.0) @@ -429,7 +427,7 @@ GEM activesupport (>= 3.1) shoulda-matchers (5.0.0) activesupport (>= 5.2.0) - sidekiq (6.2.2) + sidekiq (6.3.1) connection_pool (>= 2.2.2) rack (~> 2.0) redis (>= 4.2.0) @@ -442,9 +440,9 @@ GEM sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) + sprockets-rails (3.3.0) + actionpack (>= 5.2) + activesupport (>= 5.2) sprockets (>= 3.0.0) sshkey (2.0.0) stackprof (0.2.17) @@ -473,7 +471,7 @@ GEM jwt (~> 2.0) xorcist (1.1.2) yaml-lint (0.0.10) - zeitwerk (2.4.2) + zeitwerk (2.5.1) PLATFORMS arm64-darwin-20 diff --git a/app/assets/javascripts/admin/addon/components/admin-backups-logs.js b/app/assets/javascripts/admin/addon/components/admin-backups-logs.js index f691f3ffc6..c707c99b70 100644 --- a/app/assets/javascripts/admin/addon/components/admin-backups-logs.js +++ b/app/assets/javascripts/admin/addon/components/admin-backups-logs.js @@ -33,7 +33,7 @@ export default Component.extend({ } }, - _updateFormattedLogsFunc: function () { + _updateFormattedLogsFunc() { const logs = this.logs; if (logs.length === 0) { return; @@ -48,7 +48,7 @@ export default Component.extend({ } // update the formatted logs & cache index this.setProperties({ - formattedLogs: formattedLogs, + formattedLogs, index: logs.length, }); // force rerender diff --git a/app/assets/javascripts/admin/addon/components/admin-graph.js b/app/assets/javascripts/admin/addon/components/admin-graph.js index 8df160aa85..1107abeb2c 100644 --- a/app/assets/javascripts/admin/addon/components/admin-graph.js +++ b/app/assets/javascripts/admin/addon/components/admin-graph.js @@ -24,7 +24,7 @@ export default Component.extend({ const config = { type: this.type, - data: data, + data, options: { responsive: true, plugins: { diff --git a/app/assets/javascripts/admin/addon/components/admin-theme-editor.js b/app/assets/javascripts/admin/addon/components/admin-theme-editor.js index 36d861779a..6910e15f87 100644 --- a/app/assets/javascripts/admin/addon/components/admin-theme-editor.js +++ b/app/assets/javascripts/admin/addon/components/admin-theme-editor.js @@ -114,7 +114,7 @@ export default Component.extend({ this.fieldAdded(this.currentTargetName, name); }, - toggleMaximize: function () { + toggleMaximize() { this.toggleProperty("maximized"); next(() => this.appEvents.trigger("ace:resize")); }, diff --git a/app/assets/javascripts/admin/addon/components/color-input.js b/app/assets/javascripts/admin/addon/components/color-input.js index 91aeaec6e0..c7a8da4b78 100644 --- a/app/assets/javascripts/admin/addon/components/color-input.js +++ b/app/assets/javascripts/admin/addon/components/color-input.js @@ -40,7 +40,7 @@ export default Component.extend({ }, @observes("hexValue", "brightnessValue", "valid") - hexValueChanged: function () { + hexValueChanged() { const hex = this.hexValue; let text = this.element.querySelector("input.hex-input"); diff --git a/app/assets/javascripts/admin/addon/components/secret-value-list.js b/app/assets/javascripts/admin/addon/components/secret-value-list.js index cd63e3b0ad..ab79dad367 100644 --- a/app/assets/javascripts/admin/addon/components/secret-value-list.js +++ b/app/assets/javascripts/admin/addon/components/secret-value-list.js @@ -63,7 +63,7 @@ export default Component.extend({ }, _addValue(value, secret) { - this.collection.addObject({ key: value, secret: secret }); + this.collection.addObject({ key: value, secret }); this._saveValues(); }, diff --git a/app/assets/javascripts/admin/addon/components/tags-uploader.js b/app/assets/javascripts/admin/addon/components/tags-uploader.js index 1dc3bfc467..dcd57a45fd 100644 --- a/app/assets/javascripts/admin/addon/components/tags-uploader.js +++ b/app/assets/javascripts/admin/addon/components/tags-uploader.js @@ -1,14 +1,15 @@ import Component from "@ember/component"; import I18n from "I18n"; -import UploadMixin from "discourse/mixins/upload"; +import UppyUploadMixin from "discourse/mixins/uppy-upload"; import { alias } from "@ember/object/computed"; import bootbox from "bootbox"; -export default Component.extend(UploadMixin, { +export default Component.extend(UppyUploadMixin, { type: "csv", uploadUrl: "/tags/upload", addDisabled: alias("uploading"), elementId: "tag-uploader", + preventDirectS3Uploads: true, validateUploadedFilesOptions() { return { csvOnly: true }; diff --git a/app/assets/javascripts/admin/addon/components/themes-list-item.js b/app/assets/javascripts/admin/addon/components/themes-list-item.js index edc1783535..b619f160b4 100644 --- a/app/assets/javascripts/admin/addon/components/themes-list-item.js +++ b/app/assets/javascripts/admin/addon/components/themes-list-item.js @@ -1,10 +1,8 @@ import { and, gt } from "@ember/object/computed"; -import discourseComputed, { observes } from "discourse-common/utils/decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { escape } from "pretty-text/sanitizer"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { isTesting } from "discourse-common/config/environment"; -import { schedule } from "@ember/runloop"; const MAX_COMPONENTS = 4; @@ -22,36 +20,6 @@ export default Component.extend({ } }, - init() { - this._super(...arguments); - this.scheduleAnimation(); - }, - - @observes("theme.selected") - triggerAnimation() { - this.animate(); - }, - - scheduleAnimation() { - schedule("afterRender", () => { - this.animate(true); - }); - }, - - animate(isInitial) { - const $container = $(this.element); - const $list = $(this.element.querySelector(".components-list")); - if ($list.length === 0 || isTesting()) { - return; - } - const duration = 300; - if (this.get("theme.selected")) { - this.collapseComponentsList($container, $list, duration); - } else if (!isInitial) { - this.expandComponentsList($container, $list, duration); - } - }, - @discourseComputed( "theme.component", "theme.childThemes.@each.name", @@ -91,54 +59,6 @@ export default Component.extend({ return childrenCount - MAX_COMPONENTS; }, - expandComponentsList($container, $list, duration) { - $container.css("height", `${$container.height()}px`); - $list.css("display", ""); - $container.animate( - { - height: `${$container.height() + $list.outerHeight(true)}px`, - }, - { - duration, - done: () => { - $list.css("display", ""); - $container.css("height", ""); - }, - } - ); - $list.animate( - { - opacity: 1, - }, - { - duration, - } - ); - }, - - collapseComponentsList($container, $list, duration) { - $container.animate( - { - height: `${$container.height() - $list.outerHeight(true)}px`, - }, - { - duration, - done: () => { - $list.css("display", "none"); - $container.css("height", ""); - }, - } - ); - $list.animate( - { - opacity: 0, - }, - { - duration, - } - ); - }, - actions: { toggleChildrenExpanded() { this.toggleProperty("childrenExpanded"); diff --git a/app/assets/javascripts/admin/addon/components/watched-word-uploader.js b/app/assets/javascripts/admin/addon/components/watched-word-uploader.js index 040154d30b..8c2864b69b 100644 --- a/app/assets/javascripts/admin/addon/components/watched-word-uploader.js +++ b/app/assets/javascripts/admin/addon/components/watched-word-uploader.js @@ -1,15 +1,16 @@ import Component from "@ember/component"; import I18n from "I18n"; -import UploadMixin from "discourse/mixins/upload"; +import UppyUploadMixin from "discourse/mixins/uppy-upload"; import { alias } from "@ember/object/computed"; import bootbox from "bootbox"; import discourseComputed from "discourse-common/utils/decorators"; -export default Component.extend(UploadMixin, { +export default Component.extend(UppyUploadMixin, { type: "txt", classNames: "watched-words-uploader", uploadUrl: "/admin/customize/watched_words/upload", addDisabled: alias("uploading"), + preventDirectS3Uploads: true, validateUploadedFilesOptions() { return { skipValidation: true }; diff --git a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-index.js b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-index.js index 5b4ee4ee0a..7b98fdba1d 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-index.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-index.js @@ -1,14 +1,39 @@ import Controller from "@ember/controller"; +import { action } from "@ember/object"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend({ - actions: { - revokeKey(key) { - key.revoke().catch(popupAjaxError); - }, + loading: false, - undoRevokeKey(key) { - key.undoRevoke().catch(popupAjaxError); - }, + @action + revokeKey(key) { + key.revoke().catch(popupAjaxError); + }, + + @action + undoRevokeKey(key) { + key.undoRevoke().catch(popupAjaxError); + }, + + @action + loadMore() { + if (this.loading || this.model.loaded) { + return; + } + + const limit = 50; + + this.set("loading", true); + this.store + .findAll("api-key", { offset: this.model.length, limit }) + .then((keys) => { + this.model.addObjects(keys); + if (keys.length < limit) { + this.model.set("loaded", true); + } + }) + .finally(() => { + this.set("loading", false); + }); }, }); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js index 961c1a5c4b..a95c7845c0 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-api-keys-new.js @@ -10,7 +10,8 @@ import { ajax } from "discourse/lib/ajax"; export default Controller.extend({ userModes: null, - useGlobalKey: false, + scopeModes: null, + globalScopes: null, scopes: null, init() { @@ -20,6 +21,13 @@ export default Controller.extend({ { id: "all", name: I18n.t("admin.api.all_users") }, { id: "single", name: I18n.t("admin.api.single_user") }, ]); + + this.set("scopeModes", [ + { id: "granular", name: I18n.t("admin.api.scopes.granular") }, + { id: "read_only", name: I18n.t("admin.api.scopes.read_only") }, + { id: "global", name: I18n.t("admin.api.scopes.global") }, + ]); + this._loadScopes(); }, @@ -49,14 +57,23 @@ export default Controller.extend({ this.set("userMode", userMode); }, + @action + changeScopeMode(scopeMode) { + this.set("scopeMode", scopeMode); + }, + @action save() { - if (!this.useGlobalKey) { + if (this.scopeMode === "granular") { const selectedScopes = Object.values(this.scopes) .flat() .filterBy("selected"); this.model.set("scopes", selectedScopes); + } else if (this.scopeMode === "read_only") { + this.model.set("scopes", [this.globalScopes.findBy("key", "read")]); + } else if (this.scopeMode === "all") { + this.model.set("scopes", null); } return this.model.save().catch(popupAjaxError); @@ -78,6 +95,10 @@ export default Controller.extend({ _loadScopes() { return ajax("/admin/api/keys/scopes.json") .then((data) => { + // remove global scopes because there is a different dropdown + this.set("globalScopes", data.scopes.global); + delete data.scopes.global; + this.set("scopes", data.scopes); }) .catch(popupAjaxError); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js b/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js index c53adeecf9..7b53b2170d 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-backups-index.js @@ -12,6 +12,9 @@ export default Controller.extend({ uploadLabel: i18n("admin.backups.upload.label"), backupLocation: setting("backup_location"), localBackupStorage: equal("backupLocation", "local"), + enableExperimentalBackupUploader: setting( + "enable_experimental_backup_uploader" + ), @discourseComputed("status.allowRestore", "status.isOperationRunning") restoreTitle(allowRestore, isOperationRunning) { diff --git a/app/assets/javascripts/admin/addon/controllers/admin-badges-show.js b/app/assets/javascripts/admin/addon/controllers/admin-badges-show.js index 5e1ed5bb21..33c8ad620b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-badges-show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-badges-show.js @@ -70,7 +70,7 @@ export default Controller.extend(bufferedProperty("model"), { }, @observes("model.id") - _resetSaving: function () { + _resetSaving() { this.set("saving", false); this.set("savingStatus", ""); }, diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-colors-show.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-colors-show.js index 37394fd17f..f4b6df1756 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-customize-colors-show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-colors-show.js @@ -15,11 +15,11 @@ export default Controller.extend({ }, actions: { - revert: function (color) { + revert(color) { color.revert(); }, - undo: function (color) { + undo(color) { color.undo(); }, @@ -68,7 +68,7 @@ export default Controller.extend({ }); }, - save: function () { + save() { this.model.save(); }, @@ -76,7 +76,7 @@ export default Controller.extend({ this.model.updateUserSelectable(this.get("model.user_selectable")); }, - destroy: function () { + destroy() { const model = this.model; return bootbox.confirm( I18n.t("admin.customize.colors.delete_confirm"), diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-edit.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-edit.js index 2e1536d181..2c0cd18a70 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-edit.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-edit.js @@ -12,7 +12,7 @@ export default Controller.extend({ editRouteName: "adminCustomizeThemes.edit", showRouteName: "adminCustomizeThemes.show", - setTargetName: function (name) { + setTargetName(name) { const target = this.get("model.targets").find((t) => t.name === name); this.set("currentTarget", target && target.id); }, diff --git a/app/assets/javascripts/admin/addon/controllers/admin-email-index.js b/app/assets/javascripts/admin/addon/controllers/admin-email-index.js index 18bfd4a8c4..cc808ad094 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-email-index.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-email-index.js @@ -19,7 +19,7 @@ export default Controller.extend({ @method testEmailAddressChanged **/ @observes("testEmailAddress") - testEmailAddressChanged: function () { + testEmailAddressChanged() { this.set("sentTestEmail", false); }, @@ -29,7 +29,7 @@ export default Controller.extend({ @method sendTestEmail **/ - sendTestEmail: function () { + sendTestEmail() { this.setProperties({ sendingEmail: true, sentTestEmail: false, diff --git a/app/assets/javascripts/admin/addon/controllers/admin-logs-staff-action-logs.js b/app/assets/javascripts/admin/addon/controllers/admin-logs-staff-action-logs.js index 2758a10aa1..670d48c17b 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-logs-staff-action-logs.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-logs-staff-action-logs.js @@ -121,7 +121,7 @@ export default Controller.extend({ }, filterBySubject(subject) { - this.changeFilters({ subject: subject }); + this.changeFilters({ subject }); }, exportStaffActionLogs() { diff --git a/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js b/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js index b11b397311..d4f354d321 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-permalinks.js @@ -37,7 +37,7 @@ export default Controller.extend({ textArea.remove(); }, - destroy: function (record) { + destroy(record) { return bootbox.confirm( I18n.t("admin.permalink.delete_confirm"), I18n.t("no_value"), diff --git a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js index d19c5584aa..54551537da 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-plugins.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-plugins.js @@ -3,7 +3,7 @@ import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ @discourseComputed - adminRoutes: function () { + adminRoutes() { return this.model .map((p) => { if (p.get("enabled")) { diff --git a/app/assets/javascripts/admin/addon/controllers/admin-user-badges.js b/app/assets/javascripts/admin/addon/controllers/admin-user-badges.js index 2c5212e4ba..9461374085 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-user-badges.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-user-badges.js @@ -49,7 +49,7 @@ export default Controller.extend(GrantBadgeController, { let result = { badge: badges[0].badge, granted_at: lastGranted, - badges: badges, + badges, count: badges.length, grouped: true, }; @@ -61,7 +61,7 @@ export default Controller.extend(GrantBadgeController, { }, actions: { - expandGroup: function (userBadge) { + expandGroup(userBadge) { const model = this.model; model.set("expandedBadges", model.get("expandedBadges") || []); model.get("expandedBadges").pushObject(userBadge.badge.id); diff --git a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js index 22345e8b82..fdd21f7332 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-user-index.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-user-index.js @@ -592,7 +592,7 @@ export default Controller.extend(CanCheckEmails, { (deletedPosts * 100) / user.get("post_count") ); progressModal.setProperties({ - deletedPercentage: deletedPercentage, + deletedPercentage, }); performDelete(progressModal); } diff --git a/app/assets/javascripts/admin/addon/models/email-settings.js b/app/assets/javascripts/admin/addon/models/email-settings.js index f959df408f..aa4a245f00 100644 --- a/app/assets/javascripts/admin/addon/models/email-settings.js +++ b/app/assets/javascripts/admin/addon/models/email-settings.js @@ -4,7 +4,7 @@ import { ajax } from "discourse/lib/ajax"; const EmailSettings = EmberObject.extend({}); EmailSettings.reopenClass({ - find: function () { + find() { return ajax("/admin/email.json").then(function (settings) { return EmailSettings.create(settings); }); diff --git a/app/assets/javascripts/admin/addon/models/permalink.js b/app/assets/javascripts/admin/addon/models/permalink.js index a2e8445b08..25a3c90e03 100644 --- a/app/assets/javascripts/admin/addon/models/permalink.js +++ b/app/assets/javascripts/admin/addon/models/permalink.js @@ -5,7 +5,7 @@ import { ajax } from "discourse/lib/ajax"; import discourseComputed from "discourse-common/utils/decorators"; const Permalink = EmberObject.extend({ - save: function () { + save() { return ajax("/admin/permalinks.json", { type: "POST", data: { @@ -17,16 +17,16 @@ const Permalink = EmberObject.extend({ }, @discourseComputed("category_id") - category: function (category_id) { + category(category_id) { return Category.findById(category_id); }, @discourseComputed("external_url") - linkIsExternal: function (external_url) { + linkIsExternal(external_url) { return !DiscourseURL.isInternal(external_url); }, - destroy: function () { + destroy() { return ajax("/admin/permalinks/" + this.id + ".json", { type: "DELETE", }); @@ -34,12 +34,12 @@ const Permalink = EmberObject.extend({ }); Permalink.reopenClass({ - findAll: function (filter) { - return ajax("/admin/permalinks.json", { data: { filter: filter } }).then( - function (permalinks) { - return permalinks.map((p) => Permalink.create(p)); - } - ); + findAll(filter) { + return ajax("/admin/permalinks.json", { data: { filter } }).then(function ( + permalinks + ) { + return permalinks.map((p) => Permalink.create(p)); + }); }, }); diff --git a/app/assets/javascripts/admin/addon/models/report.js b/app/assets/javascripts/admin/addon/models/report.js index 57d9bb69a5..fb1eef051f 100644 --- a/app/assets/javascripts/admin/addon/models/report.js +++ b/app/assets/javascripts/admin/addon/models/report.js @@ -672,7 +672,7 @@ Report.reopenClass({ Report.fillMissingDates(json.report); } - const model = Report.create({ type: type }); + const model = Report.create({ type }); model.setProperties(json.report); if (json.report.related_report) { diff --git a/app/assets/javascripts/admin/addon/models/screened-email.js b/app/assets/javascripts/admin/addon/models/screened-email.js index 857cca0d63..62e985949d 100644 --- a/app/assets/javascripts/admin/addon/models/screened-email.js +++ b/app/assets/javascripts/admin/addon/models/screened-email.js @@ -9,7 +9,7 @@ const ScreenedEmail = EmberObject.extend({ return I18n.t("admin.logs.screened_actions." + action); }, - clearBlock: function () { + clearBlock() { return ajax("/admin/logs/screened_emails/" + this.id, { type: "DELETE", }); @@ -17,7 +17,7 @@ const ScreenedEmail = EmberObject.extend({ }); ScreenedEmail.reopenClass({ - findAll: function () { + findAll() { return ajax("/admin/logs/screened_emails.json").then(function ( screened_emails ) { diff --git a/app/assets/javascripts/admin/addon/models/screened-ip-address.js b/app/assets/javascripts/admin/addon/models/screened-ip-address.js index a7d29aab31..7389509f89 100644 --- a/app/assets/javascripts/admin/addon/models/screened-ip-address.js +++ b/app/assets/javascripts/admin/addon/models/screened-ip-address.js @@ -42,7 +42,7 @@ const ScreenedIpAddress = EmberObject.extend({ ScreenedIpAddress.reopenClass({ findAll(filter) { return ajax("/admin/logs/screened_ip_addresses.json", { - data: { filter: filter }, + data: { filter }, }).then((screened_ips) => screened_ips.map((b) => ScreenedIpAddress.create(b)) ); diff --git a/app/assets/javascripts/admin/addon/models/screened-url.js b/app/assets/javascripts/admin/addon/models/screened-url.js index f3769c7d2f..9a0d4e1b73 100644 --- a/app/assets/javascripts/admin/addon/models/screened-url.js +++ b/app/assets/javascripts/admin/addon/models/screened-url.js @@ -11,7 +11,7 @@ const ScreenedUrl = EmberObject.extend({ }); ScreenedUrl.reopenClass({ - findAll: function () { + findAll() { return ajax("/admin/logs/screened_urls.json").then(function ( screened_urls ) { diff --git a/app/assets/javascripts/admin/addon/models/web-hook.js b/app/assets/javascripts/admin/addon/models/web-hook.js index 8dd568a9fb..1b74d50b7b 100644 --- a/app/assets/javascripts/admin/addon/models/web-hook.js +++ b/app/assets/javascripts/admin/addon/models/web-hook.js @@ -44,7 +44,7 @@ export default RestModel.extend({ }, groupFinder(term) { - return Group.findAll({ term: term, ignore_automatic: false }); + return Group.findAll({ term, ignore_automatic: false }); }, @discourseComputed("wildcard_web_hook", "web_hook_event_types.[]") diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges.js b/app/assets/javascripts/admin/addon/routes/admin-badges.js index ac4b9e9b53..c966b7a161 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-badges.js +++ b/app/assets/javascripts/admin/addon/routes/admin-badges.js @@ -32,7 +32,7 @@ export default DiscourseRoute.extend({ }); controller.setProperties({ - badgeGroupings: badgeGroupings, + badgeGroupings, badgeTypes: json.badge_types, protectedSystemFields: json.admin_badges.protected_system_fields, badgeTriggers, diff --git a/app/assets/javascripts/admin/addon/routes/admin-customize-themes-show.js b/app/assets/javascripts/admin/addon/routes/admin-customize-themes-show.js index 5bcf95d3d2..6c23a36109 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-customize-themes-show.js +++ b/app/assets/javascripts/admin/addon/routes/admin-customize-themes-show.js @@ -2,6 +2,7 @@ import { COMPONENTS, THEMES } from "admin/models/theme"; import I18n from "I18n"; import Route from "@ember/routing/route"; import { scrollTop } from "discourse/mixins/scroll-top"; +import bootbox from "bootbox"; export function showUnassignedComponentWarning(theme, callback) { bootbox.confirm( @@ -39,8 +40,8 @@ export default Route.extend({ }); controller.setProperties({ - model: model, - parentController: parentController, + model, + parentController, allThemes: parentController.get("model"), colorSchemeId: model.get("color_scheme_id"), colorSchemes: parentController.get("model.extras.color_schemes"), diff --git a/app/assets/javascripts/admin/addon/routes/admin-emojis.js b/app/assets/javascripts/admin/addon/routes/admin-emojis.js index 5047bd6f82..6d5b1ee648 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-emojis.js +++ b/app/assets/javascripts/admin/addon/routes/admin-emojis.js @@ -3,7 +3,7 @@ import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; export default DiscourseRoute.extend({ - model: function () { + model() { return ajax("/admin/customize/emojis.json").then(function (emojis) { return emojis.map(function (emoji) { return EmberObject.create(emoji); diff --git a/app/assets/javascripts/admin/addon/routes/admin-logs-index.js b/app/assets/javascripts/admin/addon/routes/admin-logs-index.js index db287d0533..1fff3f4068 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-logs-index.js +++ b/app/assets/javascripts/admin/addon/routes/admin-logs-index.js @@ -1,7 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; export default DiscourseRoute.extend({ - redirect: function () { + redirect() { this.transitionTo("adminLogs.staffActionLogs"); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-logs-screened-emails.js b/app/assets/javascripts/admin/addon/routes/admin-logs-screened-emails.js index 9f3841ee04..1778b561c7 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-logs-screened-emails.js +++ b/app/assets/javascripts/admin/addon/routes/admin-logs-screened-emails.js @@ -1,11 +1,11 @@ import DiscourseRoute from "discourse/routes/discourse"; export default DiscourseRoute.extend({ - renderTemplate: function () { + renderTemplate() { this.render("admin/templates/logs/screened-emails", { into: "adminLogs" }); }, - setupController: function () { + setupController() { return this.controllerFor("adminLogsScreenedEmails").show(); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-logs-screened-urls.js b/app/assets/javascripts/admin/addon/routes/admin-logs-screened-urls.js index 99677710ca..230cf85ceb 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-logs-screened-urls.js +++ b/app/assets/javascripts/admin/addon/routes/admin-logs-screened-urls.js @@ -1,11 +1,11 @@ import DiscourseRoute from "discourse/routes/discourse"; export default DiscourseRoute.extend({ - renderTemplate: function () { + renderTemplate() { this.render("admin/templates/logs/screened-urls", { into: "adminLogs" }); }, - setupController: function () { + setupController() { return this.controllerFor("adminLogsScreenedUrls").show(); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-site-text-edit.js b/app/assets/javascripts/admin/addon/routes/admin-site-text-edit.js index 40c74fced3..9a9677b687 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-site-text-edit.js +++ b/app/assets/javascripts/admin/addon/routes/admin-site-text-edit.js @@ -24,7 +24,7 @@ export default Route.extend({ controller.setProperties({ siteText, saved: false, - localeFullName: localeFullName, + localeFullName, }); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-user-fields.js b/app/assets/javascripts/admin/addon/routes/admin-user-fields.js index 25e7eb0188..79c908af64 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-user-fields.js +++ b/app/assets/javascripts/admin/addon/routes/admin-user-fields.js @@ -2,11 +2,11 @@ import DiscourseRoute from "discourse/routes/discourse"; import UserField from "admin/models/user-field"; export default DiscourseRoute.extend({ - model: function () { + model() { return this.store.findAll("user-field"); }, - setupController: function (controller, model) { + setupController(controller, model) { controller.setProperties({ model, fieldTypes: UserField.fieldTypes() }); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-users-index.js b/app/assets/javascripts/admin/addon/routes/admin-users-index.js index 90a3f8a271..02d9cce444 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-users-index.js +++ b/app/assets/javascripts/admin/addon/routes/admin-users-index.js @@ -1,7 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; export default DiscourseRoute.extend({ - redirect: function () { + redirect() { this.transitionTo("adminUsersList"); }, }); diff --git a/app/assets/javascripts/admin/addon/routes/admin-users-list-index.js b/app/assets/javascripts/admin/addon/routes/admin-users-list-index.js index e2e45b16a3..29ae18db79 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-users-list-index.js +++ b/app/assets/javascripts/admin/addon/routes/admin-users-list-index.js @@ -1,7 +1,7 @@ import DiscourseRoute from "discourse/routes/discourse"; export default DiscourseRoute.extend({ - beforeModel: function () { + beforeModel() { this.transitionTo("adminUsersList.show", "active"); }, }); diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs index ac5dbf7c6b..f5608434ad 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-index.hbs @@ -5,67 +5,71 @@ label="admin.api.new_key"}} {{#if model}} - - - - - - - - - - - {{#each model as |k|}} - - - - - - - - - {{/each}} - -
{{i18n "admin.api.key"}}{{i18n "admin.api.description"}}{{i18n "admin.api.user"}}{{i18n "admin.api.created"}}{{i18n "admin.api.last_used"}} 
- {{#if k.revoked_at}}{{d-icon "times-circle"}}{{/if}} - {{k.truncatedKey}} - - {{k.shortDescription}} - -
{{i18n "admin.api.user"}}
- {{#if k.user}} - {{#link-to "adminUser" k.user}} - {{avatar k.user imageSize="small"}} - {{/link-to}} - {{else}} - {{i18n "admin.api.all_users"}} - {{/if}} -
-
{{i18n "admin.api.created"}}
- {{format-date k.created_at}} -
-
{{i18n "admin.api.last_used"}}
- {{#if k.last_used_at}} - {{format-date k.last_used_at}} - {{else}} - {{i18n "admin.api.never_used"}} - {{/if}} -
- {{d-button action=(route-action "show" k) icon="far-eye" title="admin.api.show_details"}} - {{#if k.revoked_at}} - {{d-button - action=(action "undoRevokeKey") - actionParam=k icon="undo" - title="admin.api.undo_revoke"}} - {{else}} - {{d-button - class="btn-danger" - action=(action "revokeKey") - actionParam=k - icon="times" - title="admin.api.revoke"}} - {{/if}} -
+ {{#load-more selector=".api-keys tr" action=(action "loadMore")}} + + + + + + + + + + + {{#each model as |k|}} + + + + + + + + + {{/each}} + +
{{i18n "admin.api.key"}}{{i18n "admin.api.description"}}{{i18n "admin.api.user"}}{{i18n "admin.api.created"}}{{i18n "admin.api.last_used"}} 
+ {{#if k.revoked_at}}{{d-icon "times-circle"}}{{/if}} + {{k.truncatedKey}} + + {{k.shortDescription}} + +
{{i18n "admin.api.user"}}
+ {{#if k.user}} + {{#link-to "adminUser" k.user}} + {{avatar k.user imageSize="small"}} + {{/link-to}} + {{else}} + {{i18n "admin.api.all_users"}} + {{/if}} +
+
{{i18n "admin.api.created"}}
+ {{format-date k.created_at}} +
+
{{i18n "admin.api.last_used"}}
+ {{#if k.last_used_at}} + {{format-date k.last_used_at}} + {{else}} + {{i18n "admin.api.never_used"}} + {{/if}} +
+ {{d-button action=(route-action "show" k) icon="far-eye" title="admin.api.show_details"}} + {{#if k.revoked_at}} + {{d-button + action=(action "undoRevokeKey") + actionParam=k icon="undo" + title="admin.api.undo_revoke"}} + {{else}} + {{d-button + class="btn-danger" + action=(action "revokeKey") + actionParam=k + icon="times" + title="admin.api.revoke"}} + {{/if}} +
+ {{/load-more}} + + {{conditional-loading-spinner condition=loading}} {{else}}

{{i18n "admin.api.none"}}

{{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs index 2b9e40cf80..7f703c3bc2 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-new.hbs @@ -36,12 +36,18 @@ {{/admin-form-row}} {{/if}} - {{#admin-form-row label="admin.api.use_global_key"}} - {{input type="checkbox" checked=useGlobalKey}} + {{#admin-form-row label="admin.api.scope_mode"}} + {{combo-box content=scopeModes value=scopeMode onChange=(action "changeScopeMode")}} + + {{#if (eq scopeMode "read_only")}} +

{{i18n "admin.api.scopes.descriptions.global.read"}}

+ {{else if (eq scopeMode "global")}} +

{{i18n "admin.api.scopes.global_description"}}

+ {{/if}} {{/admin-form-row}} - {{#unless useGlobalKey}} -
{{i18n "admin.api.scopes.title"}}
+ {{#if (eq scopeMode "granular")}} +

{{i18n "admin.api.scopes.title"}}

{{i18n "admin.api.scopes.description"}}

@@ -82,7 +88,7 @@ {{/each-in}}
- {{/unless}} + {{/if}} {{d-button icon="check" label="admin.api.save" action=(action "save") class="btn-primary" disabled=saveDisabled}} {{/if}} diff --git a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs index 771ecbd1c4..436e0cf004 100644 --- a/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/api-keys-show.hbs @@ -83,7 +83,7 @@ {{/admin-form-row}} {{#if model.api_key_scopes.length}} -
{{i18n "admin.api.scopes.title"}}
+

{{i18n "admin.api.scopes.title"}}

diff --git a/app/assets/javascripts/admin/addon/templates/backups-index.hbs b/app/assets/javascripts/admin/addon/templates/backups-index.hbs index 8c5e593a63..1a3ae7181b 100644 --- a/app/assets/javascripts/admin/addon/templates/backups-index.hbs +++ b/app/assets/javascripts/admin/addon/templates/backups-index.hbs @@ -8,7 +8,11 @@ title="admin.backups.upload.title" class="btn-default"}} {{else}} - {{backup-uploader done=(route-action "remoteUploadSuccess")}} + {{#if enableExperimentalBackupUploader}} + {{uppy-backup-uploader done=(route-action "remoteUploadSuccess")}} + {{else}} + {{backup-uploader done=(route-action "remoteUploadSuccess")}} + {{/if}} {{/if}} {{#if site.isReadOnly}} diff --git a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs index ff098b6038..a76822c955 100644 --- a/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs +++ b/app/assets/javascripts/admin/addon/templates/components/watched-word-form.hbs @@ -18,6 +18,7 @@ class="watched-word-input-field" tags=selectedTags onChange=(action "changeSelectedTags") + everyTag=true options=(hash allowAny=true disabled=formSubmitted diff --git a/app/assets/javascripts/admin/addon/templates/emojis.hbs b/app/assets/javascripts/admin/addon/templates/emojis.hbs index 30189a573d..e2e3949986 100644 --- a/app/assets/javascripts/admin/addon/templates/emojis.hbs +++ b/app/assets/javascripts/admin/addon/templates/emojis.hbs @@ -6,6 +6,7 @@ {{emoji-uploader emojiGroups=emojiGroups done=(action "emojiUploaded") + id="emoji-uploader" }}
diff --git a/app/assets/javascripts/admin/addon/templates/permalinks.hbs b/app/assets/javascripts/admin/addon/templates/permalinks.hbs index a66151140a..e23b0a6299 100644 --- a/app/assets/javascripts/admin/addon/templates/permalinks.hbs +++ b/app/assets/javascripts/admin/addon/templates/permalinks.hbs @@ -1,17 +1,21 @@ - +

{{i18n "admin.permalink.title"}}

+ - {{permalink-form action=(action "recordAdded")}} -
{{#conditional-loading-spinner condition=loading}} {{#if model.length}} +
@@ -21,7 +25,14 @@ {{#each model as |pl|}} - + `; let markdown = `Discourse Avenue\n\n**laboris**\n\n|Heading 1|Head 2|\n| --- | --- |\n|Lorem|ipsum|\n|**dolor**|*sit amet*|`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); html = `
Heading 1Head 2
Loremipsum
`; markdown = `|Heading 1|Head 2|\n| --- | --- |\n|[![Lorem\\|45x45](http://example.com/image.png)](http://example.com)|ipsum|`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("replace pipes with spaces if table format not supported", function (assert) { @@ -153,7 +153,7 @@ module("Unit | Utility | to-markdown", function () { `; let markdown = `Headi\n\nng 1 Head 2\nLorem ipsum\n[![](http://dolor.com/image.png)](http://example.com) *sit amet*`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); html = ` @@ -163,75 +163,75 @@ module("Unit | Utility | to-markdown", function () {
Heading 1
`; markdown = `Heading 1\nLorem\n*sit amet*`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); html = `
Loremsit amet
`; markdown = `Lorem **sit amet**`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("converts img tag", function (assert) { const url = "https://example.com/image.png"; const base62SHA1 = "q16M6GR110R47Z9p9Dk3PMXOJoE"; let html = ``; - assert.equal(toMarkdown(html), `![|100x50](${url})`); + assert.strictEqual(toMarkdown(html), `![|100x50](${url})`); html = ``; - assert.equal(toMarkdown(html), `![|100x50](${url} "some title")`); + assert.strictEqual(toMarkdown(html), `![|100x50](${url} "some title")`); html = ``; - assert.equal( + assert.strictEqual( toMarkdown(html), `![|100x50](upload://${base62SHA1} "some title")` ); html = `
description
`; - assert.equal(toMarkdown(html), `![description|50x100](${url})`); + assert.strictEqual(toMarkdown(html), `![description|50x100](${url})`); html = `description`; - assert.equal( + assert.strictEqual( toMarkdown(html), `[![description](${url})](http://example.com)` ); html = `description `; - assert.equal( + assert.strictEqual( toMarkdown(html), `[description ![](${url})](http://example.com)` ); html = `description`; - assert.equal(toMarkdown(html), ""); + assert.strictEqual(toMarkdown(html), ""); html = `description`; - assert.equal(toMarkdown(html), `![description](${url})`); + assert.strictEqual(toMarkdown(html), `![description](${url})`); }); test("supporting html tags by keeping them", function (assert) { let html = "Lorem ipsum dolor sit amet, consectetur"; let output = html; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = `Lorem ipsum dolor sit amet, consectetur`; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = `Lorem ipsum dolor sit.`; output = `[Lorem ipsum dolor sit](http://example.com).`; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = `Lorem ipsum dolor sit.`; - assert.equal(toMarkdown(html), html); + assert.strictEqual(toMarkdown(html), html); html = `Have you tried clicking the Help Me! button?`; - assert.equal(toMarkdown(html), html); + assert.strictEqual(toMarkdown(html), html); html = `This is highlighted!`; - assert.equal(toMarkdown(html), html); + assert.strictEqual(toMarkdown(html), html); html = `Lorem ipsum \n\n\n dolor sit.`; output = `Lorem [ipsum dolor sit.](http://example.com)`; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); }); test("converts code tags", function (assert) { @@ -244,7 +244,7 @@ helloWorld(); consectetur.`; let output = `Lorem ipsum dolor sit amet,\n\n\`\`\`\nvar helloWorld = () => {\n alert(' hello \t\t world ');\n return;\n}\nhelloWorld();\n\`\`\`\n\nconsectetur.`; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = `Lorem ipsum dolor sit amet, var helloWorld = () => { alert(' hello \t\t world '); @@ -253,23 +253,23 @@ helloWorld(); helloWorld();consectetur.`; output = `Lorem ipsum dolor sit amet, \`var helloWorld = () => {\n alert(' hello \t\t world ');\n return;\n}\nhelloWorld();\`consectetur.`; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); }); test("converts blockquote tag", function (assert) { let html = "
Lorem ipsum
"; let output = "> Lorem ipsum"; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = "
Lorem ipsum

dolor sit amet

"; output = "> Lorem ipsum\n\n> dolor sit amet"; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); html = "
\nLorem ipsum\n

dolor

sit
amet

"; output = "> Lorem ipsum\n> > dolor\n> > > sit\n> > amet"; - assert.equal(toMarkdown(html), output); + assert.strictEqual(toMarkdown(html), output); }); test("converts ol list tag", function (assert) { @@ -287,7 +287,7 @@ helloWorld();consectetur.`; `; const markdown = `Testing\n\n1. Item 1\n2. Item 2\n 100. Sub Item 1\n 101. Sub Item 2\n3. Item 3`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("converts list tag from word", function (assert) { @@ -334,7 +334,7 @@ helloWorld();consectetur.`; Item 4

List`; const markdown = `Sample\n\n* **Item 1**\n * *Item 2*\n * Item 3\n* Item 4\n\nList`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("keeps mention/hash class", function (assert) { @@ -347,7 +347,7 @@ helloWorld();consectetur.`; const markdown = `User mention: @discourse\n\nGroup mention: @discourse-group\n\nCategory link: #foo\n\nSub-category link: #foo:bar`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("keeps emoji and removes click count", function (assert) { @@ -360,7 +360,7 @@ helloWorld();consectetur.`; const markdown = `A [link](http://example.com) with click count and :boom: emoji.`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("keeps emoji syntax for custom emoji", function (assert) { @@ -372,7 +372,7 @@ helloWorld();consectetur.`; const markdown = `:custom_emoji:`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("converts image lightboxes to markdown", function (assert) { @@ -383,11 +383,11 @@ helloWorld();consectetur.`; `; let markdown = `![sherlock3_sig.jpg](https://d11a6trkgmumsb.cloudfront.net/uploads/default/original/1X/8hkjhk7692f6afed3cb99d43ab2abd4e30aa8cba.jpeg)`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); html = `sherlock3_sig`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); html = ` sherlock3_sig
@@ -396,7 +396,7 @@ helloWorld();consectetur.`; `; markdown = `![sherlock3_sig.jpg](upload://1frsimI7TOtFJyD2LLyKSHM8JWe)`; - assert.equal(toMarkdown(html), markdown); + assert.strictEqual(toMarkdown(html), markdown); }); test("converts quotes to markdown", function (assert) { @@ -423,12 +423,12 @@ this is a quote there is a quote above `; - assert.equal(toMarkdown(html), markdown.trim()); + assert.strictEqual(toMarkdown(html), markdown.trim()); }); test("strips base64 image URLs", function (assert) { const html = ''; - assert.equal(toMarkdown(html), "[image]"); + assert.strictEqual(toMarkdown(html), "[image]"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js b/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js index 5d1cf7d824..4b4f12f34f 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/upload-short-url-test.js @@ -64,25 +64,24 @@ function stubUrls(imageSrcs, attachmentSrcs, otherMediaSrcs) { response(imageSrcs.concat(attachmentSrcs.concat(otherMediaSrcs))) ); - fixture().html( + fixture().innerHTML = imageSrcs.map((src) => ``).join("") + - attachmentSrcs - .map( - (src) => - `big enterprise contract.pdf` - ) - .join("") + - `
` + - otherMediaSrcs - .map((src) => { - if (src.short_url.indexOf("mp3") > -1) { - return ``; - } else { - return ``; - } - }) - .join("") - ); + attachmentSrcs + .map( + (src) => + `big enterprise contract.pdf` + ) + .join("") + + `
` + + otherMediaSrcs + .map((src) => { + if (src.short_url.indexOf("mp3") > -1) { + return ``; + } else { + return ``; + } + }) + .join(""); } module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { @@ -97,7 +96,7 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { lookup = lookupCachedUploadUrl("upload://a.jpeg"); assert.deepEqual(lookup, {}); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); lookup = lookupCachedUploadUrl("upload://a.jpeg"); @@ -144,36 +143,36 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { test("resolveAllShortUrls - href + src replaced correctly", async function (assert) { stubUrls(); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); - let image1 = fixture().find("img").eq(0); - let image2 = fixture().find("img").eq(1); - let link = fixture().find("a"); - let audio = fixture().find("audio").eq(0); - let video = fixture().find("video").eq(0); + let image1 = fixture().querySelector("img"); + let image2 = fixture().querySelectorAll("img")[1]; + let audio = fixture().querySelector("audio"); + let video = fixture().querySelector("video"); + let link = fixture().querySelector("a"); - assert.equal(image1.attr("src"), "/images/avatar.png?a"); - assert.equal(image2.attr("src"), "/images/avatar.png?b"); - assert.equal(link.attr("href"), "/uploads/short-url/c.pdf"); - assert.equal( - video.find("source").attr("src"), + assert.strictEqual(image1.getAttribute("src"), "/images/avatar.png?a"); + assert.strictEqual(image2.getAttribute("src"), "/images/avatar.png?b"); + assert.strictEqual(link.getAttribute("href"), "/uploads/short-url/c.pdf"); + assert.strictEqual( + video.querySelector("source").getAttribute("src"), "/uploads/default/original/3X/c/b/4.mp4" ); - assert.equal( - audio.find("source").attr("src"), + assert.strictEqual( + audio.querySelector("source").getAttribute("src"), "/uploads/default/original/3X/c/b/5.mp3" ); }); test("resolveAllShortUrls - url with full origin replaced correctly", async function (assert) { stubUrls(); - await resolveAllShortUrls(ajax, { secure_media: false }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: false }, fixture()); await settled(); - let video = fixture().find("video").eq(1); + let video = fixture().querySelectorAll("video")[1]; - assert.equal( - video.find("source").attr("src"), + assert.strictEqual( + video.querySelector("source").getAttribute("src"), "http://localhost:3000/uploads/default/original/3X/c/b/6.mp4" ); }); @@ -190,12 +189,12 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { ], null ); - await resolveAllShortUrls(ajax, { secure_media: true }, fixture()[0]); + await resolveAllShortUrls(ajax, { secure_media: true }, fixture()); await settled(); - let link = fixture().find("a"); - assert.equal( - link.attr("href"), + let link = fixture().querySelector("a"); + assert.strictEqual( + link.getAttribute("href"), "/secure-media-uploads/default/original/3X/c/b/3.pdf" ); }); @@ -204,7 +203,7 @@ module("Unit | Utility | pretty-text/upload-short-url", function (hooks) { stubUrls(); let lookup; - let scopedElement = fixture()[0].querySelector(".scoped-area"); + let scopedElement = fixture().querySelector(".scoped-area"); await resolveAllShortUrls(ajax, {}, scopedElement); await settled(); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/uploads-test.js b/app/assets/javascripts/discourse/tests/unit/lib/uploads-test.js index c11fcbbc2e..68d3f4de72 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/uploads-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/uploads-test.js @@ -275,40 +275,40 @@ discourseModule("Unit | Utility | uploads", function () { } test("getUploadMarkdown", function (assert) { - assert.equal( + assert.strictEqual( testUploadMarkdown("lolcat.gif"), "![lolcat|100x200](/uploads/123/abcdef.ext)" ); - assert.equal( + assert.strictEqual( testUploadMarkdown("[foo|bar].png"), "![foobar|100x200](/uploads/123/abcdef.ext)" ); - assert.equal( + assert.strictEqual( testUploadMarkdown("file name with space.png"), "![file name with space|100x200](/uploads/123/abcdef.ext)" ); - assert.equal( + assert.strictEqual( testUploadMarkdown("image.file.name.with.dots.png"), "![image.file.name.with.dots|100x200](/uploads/123/abcdef.ext)" ); const short_url = "uploads://asdaasd.ext"; - assert.equal( + assert.strictEqual( testUploadMarkdown("important.txt", { short_url }), `[important.txt|attachment](${short_url}) (42 Bytes)` ); }); test("getUploadMarkdown - replaces GUID in image alt text on iOS", function (assert) { - assert.equal( + assert.strictEqual( testUploadMarkdown("8F2B469B-6B2C-4213-BC68-57B4876365A0.jpeg"), "![8F2B469B-6B2C-4213-BC68-57B4876365A0|100x200](/uploads/123/abcdef.ext)" ); sinon.stub(Utilities, "isAppleDevice").returns(true); - assert.equal( + assert.strictEqual( testUploadMarkdown("8F2B469B-6B2C-4213-BC68-57B4876365A0.jpeg"), "![image|100x200](/uploads/123/abcdef.ext)" ); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/uppy-checksum-plugin-test.js b/app/assets/javascripts/discourse/tests/unit/lib/uppy-checksum-plugin-test.js index 58cd1b7e27..93371bc0de 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/uppy-checksum-plugin-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/uppy-checksum-plugin-test.js @@ -49,8 +49,8 @@ module("Unit | Utility | UppyChecksum Plugin", function () { const plugin = new UppyChecksum(fakeUppy, { capabilities, }); - assert.equal(plugin.id, "uppy-checksum"); - assert.equal(plugin.capabilities, capabilities); + assert.strictEqual(plugin.id, "uppy-checksum"); + assert.strictEqual(plugin.capabilities, capabilities); }); test("it does nothing if not running in a secure context", function (assert) { @@ -67,7 +67,7 @@ module("Unit | Utility | UppyChecksum Plugin", function () { const fileId = "uppy-test/file/vv2/xvejg5w/blah/png-1d-1d-2v-1d-1e-image/jpeg-9043429-1624921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal( + assert.strictEqual( plugin.uppy.emitted.length, 1, "only the complete event was fired by the checksum plugin because it skipped the file" @@ -90,7 +90,7 @@ module("Unit | Utility | UppyChecksum Plugin", function () { const fileId = "uppy-test/file/vv2/xvejg5w/blah/png-1d-1d-2v-1d-1e-image/jpeg-9043429-1624921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal( + assert.strictEqual( plugin.uppy.emitted.length, 1, "only the complete event was fired by the checksum plugin because it skipped the file" @@ -111,7 +111,7 @@ module("Unit | Utility | UppyChecksum Plugin", function () { const fileId = "uppy-test/file/vv2/xvejg5w/blah/png-1d-1d-2v-1d-1e-image/jpeg-9043429-1624921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal( + assert.strictEqual( plugin.uppy.emitted.length, 1, "only the complete event was fired by the checksum plugin because it skipped the file" @@ -132,9 +132,12 @@ module("Unit | Utility | UppyChecksum Plugin", function () { const fileId = "uppy-test/file/mnb3/jfhrg43x/blah3/png-1d-1d-2v-1d-1e-image/jpeg-111111-1837921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-complete"); - assert.equal(plugin.uppy.getFile(fileId).meta.sha1_checksum, null); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-complete"); + assert.strictEqual( + plugin.uppy.getFile(fileId).meta.sha1_checksum, + undefined + ); done(); }); }); @@ -153,17 +156,17 @@ module("Unit | Utility | UppyChecksum Plugin", function () { "uppy-test/file/blah1/ads37x2/blah1/png-1d-1d-2v-1d-1e-image/jpeg-99999-1837921727764", ]; plugin.uppy.preprocessors[0](fileIds).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[2].event, "preprocess-complete"); - assert.equal(plugin.uppy.emitted[3].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[2].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[3].event, "preprocess-complete"); // these checksums are the actual SHA1 hashes of the test file names - assert.equal( + assert.strictEqual( plugin.uppy.getFile(fileIds[0]).meta.sha1_checksum, "d9bafe64b034b655db018ad0226c6865300ada31" ); - assert.equal( + assert.strictEqual( plugin.uppy.getFile(fileIds[1]).meta.sha1_checksum, "cb10341e3efeab45f0bc309a1c497edca4c5a744" ); @@ -191,10 +194,10 @@ module("Unit | Utility | UppyChecksum Plugin", function () { .rejects({ message: "Algorithm: Unrecognized name" }); plugin.uppy.preprocessors[0](fileIds).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[2].event, "preprocess-complete"); - assert.equal(plugin.uppy.emitted[3].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[2].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[3].event, "preprocess-complete"); assert.deepEqual(plugin.uppy.getFile(fileIds[0]).meta, {}); assert.deepEqual(plugin.uppy.getFile(fileIds[1]).meta, {}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/uppy-media-optimization-plugin-test.js b/app/assets/javascripts/discourse/tests/unit/lib/uppy-media-optimization-plugin-test.js index 55631b153b..d4c882b5d9 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/uppy-media-optimization-plugin-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/uppy-media-optimization-plugin-test.js @@ -42,9 +42,9 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { return "wow such optimized"; }, }); - assert.equal(plugin.id, "uppy-media-optimization"); - assert.equal(plugin.runParallel, true); - assert.equal(plugin.optimizeFn(), "wow such optimized"); + assert.strictEqual(plugin.id, "uppy-media-optimization"); + assert.strictEqual(plugin.runParallel, true); + assert.strictEqual(plugin.optimizeFn(), "wow such optimized"); }); test("installation uses the correct function", function (assert) { @@ -52,18 +52,21 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { const plugin = new UppyMediaOptimization(fakeUppy, { runParallel: true, }); - plugin._optimizeParallel = function () { - return "using parallel"; - }; - plugin._optimizeSerial = function () { - return "using serial"; - }; + + Object.defineProperty(plugin, "_optimizeParallel", { + value: () => "using parallel", + }); + + Object.defineProperty(plugin, "_optimizeSerial", { + value: () => "using serial", + }); + plugin.install(); - assert.equal(plugin.uppy.preprocessors[0](), "using parallel"); + assert.strictEqual(plugin.uppy.preprocessors[0](), "using parallel"); plugin.runParallel = false; plugin.uppy.preprocessors = []; plugin.install(); - assert.equal(plugin.uppy.preprocessors[0](), "using serial"); + assert.strictEqual(plugin.uppy.preprocessors[0](), "using serial"); }); test("sets the file state when successfully optimizing the file and emits events", function (assert) { @@ -80,9 +83,9 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { "uppy-test/file/vv2/xvejg5w/blah/jpg-1d-1d-2v-1d-1e-image/jpeg-9043429-1624921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-complete"); - assert.equal(plugin.uppy.getFile(fileId).data, "new file state"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.getFile(fileId).data, "new file state"); done(); }); }); @@ -103,9 +106,9 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { "uppy-test/file/vv2/xvejg5w/blah/jpg-1d-1d-2v-1d-1e-image/jpeg-9043429-1624921727764"; plugin.uppy.preprocessors[0]([fileId]).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-complete"); - assert.equal(plugin.uppy.getFile(fileId).data, "old file state"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.getFile(fileId).data, "old file state"); done(); }); }); @@ -126,12 +129,18 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { ]; plugin.uppy.preprocessors[0](fileIds).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-complete"); - assert.equal(plugin.uppy.emitted[2].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[3].event, "preprocess-complete"); - assert.equal(plugin.uppy.getFile(fileIds[0]).data, "new file state"); - assert.equal(plugin.uppy.getFile(fileIds[1]).data, "new file state"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[2].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[3].event, "preprocess-complete"); + assert.strictEqual( + plugin.uppy.getFile(fileIds[0]).data, + "new file state" + ); + assert.strictEqual( + plugin.uppy.getFile(fileIds[1]).data, + "new file state" + ); done(); }); }); @@ -152,12 +161,18 @@ module("Unit | Utility | UppyMediaOptimization Plugin", function () { ]; plugin.uppy.preprocessors[0](fileIds).then(() => { - assert.equal(plugin.uppy.emitted[0].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[1].event, "preprocess-progress"); - assert.equal(plugin.uppy.emitted[2].event, "preprocess-complete"); - assert.equal(plugin.uppy.emitted[3].event, "preprocess-complete"); - assert.equal(plugin.uppy.getFile(fileIds[0]).data, "new file state"); - assert.equal(plugin.uppy.getFile(fileIds[1]).data, "new file state"); + assert.strictEqual(plugin.uppy.emitted[0].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[1].event, "preprocess-progress"); + assert.strictEqual(plugin.uppy.emitted[2].event, "preprocess-complete"); + assert.strictEqual(plugin.uppy.emitted[3].event, "preprocess-complete"); + assert.strictEqual( + plugin.uppy.getFile(fileIds[0]).data, + "new file state" + ); + assert.strictEqual( + plugin.uppy.getFile(fileIds[1]).data, + "new file state" + ); done(); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/url-test.js b/app/assets/javascripts/discourse/tests/unit/lib/url-test.js index 5781a8383a..b9b1a35b96 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/url-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/url-test.js @@ -58,14 +58,14 @@ module("Unit | Utility | url", function () { }); test("userPath", function (assert) { - assert.equal(userPath(), "/u"); - assert.equal(userPath("eviltrout"), "/u/eviltrout"); + assert.strictEqual(userPath(), "/u"); + assert.strictEqual(userPath("eviltrout"), "/u/eviltrout"); }); test("userPath with prefix", function (assert) { setPrefix("/forum"); - assert.equal(userPath(), "/forum/u"); - assert.equal(userPath("eviltrout"), "/forum/u/eviltrout"); + assert.strictEqual(userPath(), "/forum/u"); + assert.strictEqual(userPath("eviltrout"), "/forum/u/eviltrout"); }); test("routeTo with prefix", async function (assert) { @@ -82,16 +82,19 @@ module("Unit | Utility | url", function () { }); test("prefixProtocol", async function (assert) { - assert.equal( + assert.strictEqual( prefixProtocol("mailto:mr-beaver@aol.com"), "mailto:mr-beaver@aol.com" ); - assert.equal(prefixProtocol("discourse.org"), "https://discourse.org"); - assert.equal( + assert.strictEqual( + prefixProtocol("discourse.org"), + "https://discourse.org" + ); + assert.strictEqual( prefixProtocol("www.discourse.org"), "https://www.discourse.org" ); - assert.equal( + assert.strictEqual( prefixProtocol("www.discourse.org/mailto:foo"), "https://www.discourse.org/mailto:foo" ); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/user-search-test.js b/app/assets/javascripts/discourse/tests/unit/lib/user-search-test.js index 7e63f52ce7..3310fa988a 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/user-search-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/user-search-test.js @@ -83,17 +83,17 @@ module("Unit | Utility | user-search", function (hooks) { test("it flushes cache when switching categories", async function (assert) { let results = await userSearch({ term: "hello", categoryId: 1 }); - assert.equal(results[0].username, "category_1"); - assert.equal(results.length, 1); + assert.strictEqual(results[0].username, "category_1"); + assert.strictEqual(results.length, 1); // this is cached ... so let's check the cache is good results = await userSearch({ term: "hello", categoryId: 1 }); - assert.equal(results[0].username, "category_1"); - assert.equal(results.length, 1); + assert.strictEqual(results[0].username, "category_1"); + assert.strictEqual(results.length, 1); results = await userSearch({ term: "hello", categoryId: 2 }); - assert.equal(results[0].username, "category_2"); - assert.equal(results.length, 1); + assert.strictEqual(results[0].username, "category_2"); + assert.strictEqual(results.length, 1); }); test("it returns cancel when eager completing with no results", async function (assert) { @@ -102,31 +102,31 @@ module("Unit | Utility | user-search", function (hooks) { for (let i = 0; i < 2; i++) { // No topic or category, will always cancel let result = await userSearch({ term: "" }); - assert.equal(result, CANCELLED_STATUS); + assert.strictEqual(result, CANCELLED_STATUS); } for (let i = 0; i < 2; i++) { // Unsecured category, so has no recommendations let result = await userSearch({ term: "", categoryId: 3 }); - assert.equal(result, CANCELLED_STATUS); + assert.strictEqual(result, CANCELLED_STATUS); } for (let i = 0; i < 2; i++) { // Secured category, will have 1 recommendation let results = await userSearch({ term: "", categoryId: 1 }); - assert.equal(results[0].username, "category_1"); - assert.equal(results.length, 1); + assert.strictEqual(results[0].username, "category_1"); + assert.strictEqual(results.length, 1); } }); test("it places groups unconditionally for exact match", async function (assert) { let results = await userSearch({ term: "Team" }); - assert.equal(results[results.length - 1]["name"], "team"); + assert.strictEqual(results[results.length - 1]["name"], "team"); }); test("it strips @ from the beginning", async function (assert) { let results = await userSearch({ term: "@Team" }); - assert.equal(results[results.length - 1]["name"], "team"); + assert.strictEqual(results[results.length - 1]["name"], "team"); }); test("it skips a search depending on punctuation", async function (assert) { @@ -140,7 +140,7 @@ module("Unit | Utility | user-search", function (hooks) { for (let term of skippedTerms) { results = await userSearch({ term }); - assert.equal(results.length, 0); + assert.strictEqual(results.length, 0); } let allowedTerms = [ @@ -155,23 +155,23 @@ module("Unit | Utility | user-search", function (hooks) { for (let term of allowedTerms) { results = await userSearch({ term, topicId }); - assert.equal(results.length, 6); + assert.strictEqual(results.length, 6); } results = await userSearch({ term: "sam@sam.com", allowEmails: true }); // 6 + email - assert.equal(results.length, 7); + assert.strictEqual(results.length, 7); results = await userSearch({ term: "sam+test@sam.com", allowEmails: true }); - assert.equal(results.length, 7); + assert.strictEqual(results.length, 7); results = await userSearch({ term: "sam@sam.com" }); - assert.equal(results.length, 0); + assert.strictEqual(results.length, 0); results = await userSearch({ term: "no-results@example.com", allowEmails: true, }); - assert.equal(results.length, 1); + assert.strictEqual(results.length, 1); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/utilities-test.js b/app/assets/javascripts/discourse/tests/unit/lib/utilities-test.js index c863511c12..edbc2ddab2 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/utilities-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/utilities-test.js @@ -21,15 +21,19 @@ import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; discourseModule("Unit | Utilities", function () { test("escapeExpression", function (assert) { - assert.equal(escapeExpression(">"), ">", "escapes unsafe characters"); + assert.strictEqual( + escapeExpression(">"), + ">", + "escapes unsafe characters" + ); - assert.equal( + assert.strictEqual( escapeExpression(new Handlebars.SafeString(">")), ">", "does not double-escape safe strings" ); - assert.equal( + assert.strictEqual( escapeExpression(undefined), "", "returns a falsy string when given a falsy value" @@ -48,22 +52,22 @@ discourseModule("Unit | Utilities", function () { }); test("extractDomainFromUrl", function (assert) { - assert.equal( + assert.strictEqual( extractDomainFromUrl("http://meta.discourse.org:443/random"), "meta.discourse.org", "extract domain name from url" ); - assert.equal( + assert.strictEqual( extractDomainFromUrl("meta.discourse.org:443/random"), "meta.discourse.org", "extract domain regardless of scheme presence" ); - assert.equal( + assert.strictEqual( extractDomainFromUrl("http://192.168.0.1:443/random"), "192.168.0.1", "works for IP address" ); - assert.equal( + assert.strictEqual( extractDomainFromUrl("http://localhost:443/random"), "localhost", "works for localhost" @@ -73,12 +77,12 @@ discourseModule("Unit | Utilities", function () { test("avatarUrl", function (assert) { let rawSize = getRawSize; assert.blank(avatarUrl("", "tiny"), "no template returns blank"); - assert.equal( + assert.strictEqual( avatarUrl("/fake/template/{size}.png", "tiny"), "/fake/template/" + rawSize(20) + ".png", "simple avatar url" ); - assert.equal( + assert.strictEqual( avatarUrl("/fake/template/{size}.png", "large"), "/fake/template/" + rawSize(45) + ".png", "different size" @@ -98,29 +102,29 @@ discourseModule("Unit | Utilities", function () { setDevicePixelRatio(2); let avatarTemplate = "/path/to/avatar/{size}.png"; - assert.equal( - avatarImg({ avatarTemplate: avatarTemplate, size: "tiny" }), - "", + assert.strictEqual( + avatarImg({ avatarTemplate, size: "tiny" }), + "", "it returns the avatar html" ); - assert.equal( + assert.strictEqual( avatarImg({ - avatarTemplate: avatarTemplate, + avatarTemplate, size: "tiny", title: "evilest trout", }), - "", + "", "it adds a title if supplied" ); - assert.equal( + assert.strictEqual( avatarImg({ - avatarTemplate: avatarTemplate, + avatarTemplate, size: "tiny", extraClasses: "evil fish", }), - "", + "", "it adds extra classes if supplied" ); @@ -138,7 +142,7 @@ discourseModule("Unit | Utilities", function () { meta.content = "hot"; document.body.appendChild(meta); initializeDefaultHomepage(this.siteSettings); - assert.equal( + assert.strictEqual( defaultHomepage(), "hot", "default homepage is pulled from " @@ -149,7 +153,7 @@ discourseModule("Unit | Utilities", function () { test("defaultHomepage via site settings", function (assert) { this.siteSettings.top_menu = "top|latest|hot"; initializeDefaultHomepage(this.siteSettings); - assert.equal( + assert.strictEqual( defaultHomepage(), "top", "default homepage is the first item in the top_menu site setting" @@ -158,9 +162,9 @@ discourseModule("Unit | Utilities", function () { test("setDefaultHomepage", function (assert) { initializeDefaultHomepage(this.siteSettings); - assert.equal(defaultHomepage(), "latest"); + assert.strictEqual(defaultHomepage(), "latest"); setDefaultHomepage("top"); - assert.equal(defaultHomepage(), "top"); + assert.strictEqual(defaultHomepage(), "top"); }); test("caretRowCol", function (assert) { @@ -173,12 +177,12 @@ discourseModule("Unit | Utilities", function () { setCaretPosition(textarea, setCaretPos); const result = caretRowCol(textarea); - assert.equal( + assert.strictEqual( result.rowNum, expectedRowNum, "returns the right row of the caret" ); - assert.equal( + assert.strictEqual( result.colNum, expectedColNum, "returns the right col of the caret" @@ -198,13 +202,13 @@ discourseModule("Unit | Utilities", function () { const accentedString = "Créme_Brûlée!"; const unicodeString = "談話"; - assert.equal( + assert.strictEqual( toAsciiPrintable(accentedString, "discourse"), "Creme_Brulee!", "it replaces accented characters with the appropriate ASCII equivalent" ); - assert.equal( + assert.strictEqual( toAsciiPrintable(unicodeString, "discourse"), "discourse", "it uses the fallback string when unable to convert" @@ -222,19 +226,23 @@ discourseModule("Unit | Utilities", function () { const accentedString = "Créme_Brûlée!"; const unicodeString = "談話"; - assert.equal( + assert.strictEqual( slugify(asciiString), "0-some-cool-discourse-site-0", "it properly slugifies an ASCII string" ); - assert.equal( + assert.strictEqual( slugify(accentedString), "crme-brle", "it removes accented characters" ); - assert.equal(slugify(unicodeString), "", "it removes unicode characters"); + assert.strictEqual( + slugify(unicodeString), + "", + "it removes unicode characters" + ); }); test("fillMissingDates", function (assert) { @@ -243,7 +251,7 @@ discourseModule("Unit | Utilities", function () { const data = '[{"x":"2017-11-12","y":3},{"x":"2017-11-27","y":2},{"x":"2017-12-06","y":9},{"x":"2017-12-11","y":2}]'; - assert.equal( + assert.strictEqual( fillMissingDates(JSON.parse(data), startDate, endDate).length, 31, "it returns a JSON array with 31 dates" @@ -269,7 +277,7 @@ discourseModule("Unit | Utilities", function () { texts.forEach((text) => { for (let i = 0; i < text.length; ++i) { if (text[i] === "0" || text[i] === "1") { - assert.equal(inCodeBlock(text, i), text[i] === "1"); + assert.strictEqual(inCodeBlock(text, i), text[i] === "1"); } } }); diff --git a/app/assets/javascripts/discourse/tests/unit/localization-test.js b/app/assets/javascripts/discourse/tests/unit/localization-test.js index f2bf2f3c0f..6d8f303bbe 100644 --- a/app/assets/javascripts/discourse/tests/unit/localization-test.js +++ b/app/assets/javascripts/discourse/tests/unit/localization-test.js @@ -45,12 +45,12 @@ test("translation overrides", function (assert) { }; LocalizationInitializer.initialize(getApplication()); - assert.equal( + assert.strictEqual( I18n.t("composer.reply"), "WAT", "overrides existing translation in current locale" ); - assert.equal( + assert.strictEqual( I18n.t("topic.reply.help"), "foobar", "overrides translation in default locale" @@ -64,5 +64,5 @@ test("skip translation override if parent node is not an object", function (asse }; LocalizationInitializer.initialize(getApplication()); - assert.equal(I18n.t("composer.reply.help"), "[fr.composer.reply.help]"); + assert.strictEqual(I18n.t("composer.reply.help"), "[fr.composer.reply.help]"); }); diff --git a/app/assets/javascripts/discourse/tests/unit/mixins/setting-object-test.js b/app/assets/javascripts/discourse/tests/unit/mixins/setting-object-test.js index a3cca394fc..af97d3e391 100644 --- a/app/assets/javascripts/discourse/tests/unit/mixins/setting-object-test.js +++ b/app/assets/javascripts/discourse/tests/unit/mixins/setting-object-test.js @@ -10,8 +10,8 @@ module("Unit | Mixin | setting-object", function () { valid_values: ["foo", "bar"], }); - assert.equal(fooSettingInstance.computedValueProperty, null); - assert.equal(fooSettingInstance.computedNameProperty, null); + assert.strictEqual(fooSettingInstance.computedValueProperty, null); + assert.strictEqual(fooSettingInstance.computedNameProperty, null); }); test("object", function (assert) { @@ -21,8 +21,8 @@ module("Unit | Mixin | setting-object", function () { valid_values: [{ value: "foo", name: "bar" }], }); - assert.equal(fooSettingInstance.computedValueProperty, "value"); - assert.equal(fooSettingInstance.computedNameProperty, "name"); + assert.strictEqual(fooSettingInstance.computedValueProperty, "value"); + assert.strictEqual(fooSettingInstance.computedNameProperty, "name"); }); test("no values", function (assert) { @@ -32,8 +32,8 @@ module("Unit | Mixin | setting-object", function () { valid_values: [], }); - assert.equal(fooSettingInstance.computedValueProperty, null); - assert.equal(fooSettingInstance.computedNameProperty, null); + assert.strictEqual(fooSettingInstance.computedValueProperty, null); + assert.strictEqual(fooSettingInstance.computedNameProperty, null); }); test("value/name properties defined", function (assert) { @@ -45,7 +45,7 @@ module("Unit | Mixin | setting-object", function () { valid_values: [], }); - assert.equal(fooSettingInstance.computedValueProperty, "foo"); - assert.equal(fooSettingInstance.computedNameProperty, "bar"); + assert.strictEqual(fooSettingInstance.computedValueProperty, "foo"); + assert.strictEqual(fooSettingInstance.computedNameProperty, "bar"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/mixins/singleton-test.js b/app/assets/javascripts/discourse/tests/unit/mixins/singleton-test.js index 0a2e29d0e5..12b52130a8 100644 --- a/app/assets/javascripts/discourse/tests/unit/mixins/singleton-test.js +++ b/app/assets/javascripts/discourse/tests/unit/mixins/singleton-test.js @@ -9,12 +9,12 @@ module("Unit | Mixin | singleton", function () { let current = DummyModel.current(); assert.present(current, "current returns the current instance"); - assert.equal( + assert.strictEqual( current, DummyModel.current(), "calling it again returns the same instance" ); - assert.notEqual( + assert.notStrictEqual( current, DummyModel.create({}), "we can create other instances that are not the same as current" @@ -31,7 +31,7 @@ module("Unit | Mixin | singleton", function () { "by default attributes are blank" ); current.set("evil", "trout"); - assert.equal( + assert.strictEqual( DummyModel.currentProp("evil"), "trout", "after changing the instance, the value is set" @@ -47,22 +47,22 @@ module("Unit | Mixin | singleton", function () { "by default attributes are blank" ); let result = DummyModel.currentProp("adventure", "time"); - assert.equal(result, "time", "it returns the new value"); - assert.equal( + assert.strictEqual(result, "time", "it returns the new value"); + assert.strictEqual( DummyModel.currentProp("adventure"), "time", "after calling currentProp the value is set" ); DummyModel.currentProp("count", 0); - assert.equal( + assert.strictEqual( DummyModel.currentProp("count"), 0, "we can set the value to 0" ); DummyModel.currentProp("adventure", null); - assert.equal( + assert.strictEqual( DummyModel.currentProp("adventure"), null, "we can set the value to null" @@ -77,7 +77,7 @@ module("Unit | Mixin | singleton", function () { }, }); - assert.equal( + assert.strictEqual( Shoe.currentProp("toes"), 5, "it created the class using `createCurrent`" diff --git a/app/assets/javascripts/discourse/tests/unit/models/badge-test.js b/app/assets/javascripts/discourse/tests/unit/models/badge-test.js index 794cf97231..1d5d8e4517 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/badge-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/badge-test.js @@ -20,8 +20,12 @@ module("Unit | Model | badge", function () { const badges = Badge.createFromJson(badgesJson); assert.ok(Array.isArray(badges), "returns an array"); - assert.equal(badges[0].get("name"), "Badge 1", "badge details are set"); - assert.equal( + assert.strictEqual( + badges[0].get("name"), + "Badge 1", + "badge details are set" + ); + assert.strictEqual( badges[0].get("badge_type.name"), "Silver 1", "badge_type reference is set" @@ -46,8 +50,8 @@ module("Unit | Model | badge", function () { }; const badge = Badge.create({ name: "Badge 1" }); badge.updateFromJson(badgeJson); - assert.equal(badge.get("id"), 1126, "id is set"); - assert.equal( + assert.strictEqual(badge.get("id"), 1126, "id is set"); + assert.strictEqual( badge.get("badge_type.name"), "Silver 1", "badge_type reference is set" diff --git a/app/assets/javascripts/discourse/tests/unit/models/category-test.js b/app/assets/javascripts/discourse/tests/unit/models/category-test.js index 2d5f8e0e85..18c2f7cee5 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/category-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/category-test.js @@ -8,7 +8,7 @@ module("Unit | Model | category", function () { const store = createStore(); const slugFor = function (cat, val, text) { - assert.equal(Category.slugFor(cat), val, text); + assert.strictEqual(Category.slugFor(cat), val, text); }; slugFor( @@ -39,7 +39,7 @@ module("Unit | Model | category", function () { slugFor( store.createRecord("category", { slug: "luke", - parentCategory: parentCategory, + parentCategory, }), "darth/luke", "it uses the parent slug before the child" @@ -48,7 +48,7 @@ module("Unit | Model | category", function () { slugFor( store.createRecord("category", { id: 555, - parentCategory: parentCategory, + parentCategory, }), "darth/555-category", "it uses the parent slug before the child and then uses id" @@ -58,7 +58,7 @@ module("Unit | Model | category", function () { slugFor( store.createRecord("category", { id: 555, - parentCategory: parentCategory, + parentCategory, }), "345-category/555-category", "it uses the parent before the child and uses ids for both" @@ -221,6 +221,50 @@ module("Unit | Model | category", function () { assert.deepEqual(Category.findBySlugPathWithID("foo/baz/3"), baz); }); + test("minimumRequiredTags", function (assert) { + const store = createStore(); + + let foo = store.createRecord("category", { + id: 1, + slug: "foo", + required_tag_groups: ["bar"], + min_tags_from_required_group: 2, + }); + + assert.equal(foo.minimumRequiredTags, 2); + + foo = store.createRecord("category", { + id: 2, + slug: "foo", + }); + + assert.equal(foo.minimumRequiredTags, null); + + foo = store.createRecord("category", { + id: 3, + slug: "foo", + minimum_required_tags: 0, + }); + + assert.equal(foo.minimumRequiredTags, null); + + foo = store.createRecord("category", { + id: 4, + slug: "foo", + minimum_required_tags: 2, + }); + + assert.equal(foo.minimumRequiredTags, 2); + + foo = store.createRecord("category", { + id: 5, + slug: "foo", + min_tags_from_required_group: 2, + }); + + assert.equal(foo.minimumRequiredTags, null); + }); + test("search with category name", function (assert) { const store = createStore(), category1 = store.createRecord("category", { diff --git a/app/assets/javascripts/discourse/tests/unit/models/composer-test.js b/app/assets/javascripts/discourse/tests/unit/models/composer-test.js index 415988c24f..eb3f513dd2 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/composer-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/composer-test.js @@ -31,7 +31,7 @@ discourseModule("Unit | Model | composer", function () { test("replyLength", function (assert) { const replyLength = function (val, expectedLength) { const composer = createComposer({ reply: val }); - assert.equal(composer.get("replyLength"), expectedLength); + assert.strictEqual(composer.get("replyLength"), expectedLength); }; replyLength("basic reply", 11, "basic reply length"); @@ -79,7 +79,11 @@ discourseModule("Unit | Model | composer", function () { action = CREATE_TOPIC; } const composer = createComposer({ reply: val, action }); - assert.equal(composer.get("missingReplyCharacters"), expected, message); + assert.strictEqual( + composer.get("missingReplyCharacters"), + expected, + message + ); }; missingReplyCharacters( @@ -115,7 +119,7 @@ discourseModule("Unit | Model | composer", function () { reply: link, }); - assert.equal( + assert.strictEqual( composer.get("missingReplyCharacters"), 0, "don't require any post content" @@ -128,7 +132,11 @@ discourseModule("Unit | Model | composer", function () { title: val, action: isPM ? PRIVATE_MESSAGE : REPLY, }); - assert.equal(composer.get("missingTitleCharacters"), expected, message); + assert.strictEqual( + composer.get("missingTitleCharacters"), + expected, + message + ); }; missingTitleCharacters( @@ -168,9 +176,13 @@ discourseModule("Unit | Model | composer", function () { assert.blank(composer.get("reply"), "the reply is blank by default"); composer.appendText("hello"); - assert.equal(composer.get("reply"), "hello", "it appends text to nothing"); + assert.strictEqual( + composer.get("reply"), + "hello", + "it appends text to nothing" + ); composer.appendText(" world"); - assert.equal( + assert.strictEqual( composer.get("reply"), "hello world", "it appends text to existing text" @@ -180,19 +192,19 @@ discourseModule("Unit | Model | composer", function () { composer.appendText("a\n\n\n\nb"); composer.appendText("c", 3, { block: true }); - assert.equal(composer.get("reply"), "a\n\nc\n\nb"); + assert.strictEqual(composer.get("reply"), "a\n\nc\n\nb"); composer.clearState(); composer.appendText("ab"); composer.appendText("c", 1, { block: true }); - assert.equal(composer.get("reply"), "a\n\nc\n\nb"); + assert.strictEqual(composer.get("reply"), "a\n\nc\n\nb"); composer.clearState(); composer.appendText("\nab"); composer.appendText("c", 0, { block: true }); - assert.equal(composer.get("reply"), "c\n\nab"); + assert.strictEqual(composer.get("reply"), "c\n\nab"); }); test("prependText", function (assert) { @@ -201,17 +213,21 @@ discourseModule("Unit | Model | composer", function () { assert.blank(composer.get("reply"), "the reply is blank by default"); composer.prependText("hello"); - assert.equal(composer.get("reply"), "hello", "it prepends text to nothing"); + assert.strictEqual( + composer.get("reply"), + "hello", + "it prepends text to nothing" + ); composer.prependText("world "); - assert.equal( + assert.strictEqual( composer.get("reply"), "world hello", "it prepends text to existing text" ); composer.prependText("before new line", { new_line: true }); - assert.equal( + assert.strictEqual( composer.get("reply"), "before new line\n\nworld hello", "it prepends text with new line to existing text" @@ -253,7 +269,7 @@ discourseModule("Unit | Model | composer", function () { topic: EmberObject.create({ pm_with_non_human_user: true }), }); - assert.equal(composer.get("minimumPostLength"), 1); + assert.strictEqual(composer.get("minimumPostLength"), 1); }); test("editingFirstPost", function (assert) { @@ -261,7 +277,7 @@ discourseModule("Unit | Model | composer", function () { assert.ok(!composer.get("editingFirstPost"), "it's false by default"); const post = Post.create({ id: 123, post_number: 2 }); - composer.setProperties({ post: post, action: EDIT }); + composer.setProperties({ post, action: EDIT }); assert.ok( !composer.get("editingFirstPost"), "it's false when not editing the first post" @@ -321,16 +337,16 @@ discourseModule("Unit | Model | composer", function () { action: REPLY, draftKey: "asfd", draftSequence: 1, - quote: quote, + quote, }); }; - assert.equal( + assert.strictEqual( newComposer().get("originalText"), quote, "originalText is the quote" ); - assert.equal( + assert.strictEqual( newComposer().get("replyDirty"), false, "replyDirty is initially false with a quote" @@ -347,7 +363,7 @@ discourseModule("Unit | Model | composer", function () { post_number: 2, static_doc: true, }); - composer.setProperties({ post: post, action: EDIT }); + composer.setProperties({ post, action: EDIT }); composer.set("title", "asdf"); assert.ok(composer.get("titleLengthValid"), "admins can use short titles"); @@ -367,14 +383,14 @@ discourseModule("Unit | Model | composer", function () { test("title placeholder depends on what you're doing", function (assert) { let composer = createComposer({ action: CREATE_TOPIC }); - assert.equal( + assert.strictEqual( composer.get("titlePlaceholder"), "composer.title_placeholder", "placeholder for normal topic" ); composer = createComposer({ action: PRIVATE_MESSAGE }); - assert.equal( + assert.strictEqual( composer.get("titlePlaceholder"), "composer.title_placeholder", "placeholder for private message" @@ -383,14 +399,14 @@ discourseModule("Unit | Model | composer", function () { this.siteSettings.topic_featured_link_enabled = true; composer = createComposer({ action: CREATE_TOPIC }); - assert.equal( + assert.strictEqual( composer.get("titlePlaceholder"), "composer.title_or_link_placeholder", "placeholder invites you to paste a link" ); composer = createComposer({ action: PRIVATE_MESSAGE }); - assert.equal( + assert.strictEqual( composer.get("titlePlaceholder"), "composer.title_placeholder", "placeholder for private message with topic links enabled" @@ -401,7 +417,7 @@ discourseModule("Unit | Model | composer", function () { this.siteSettings.topic_featured_link_enabled = true; this.siteSettings.allow_uncategorized_topics = false; let composer = createComposer({ action: CREATE_TOPIC }); - assert.equal( + assert.strictEqual( composer.get("titlePlaceholder"), "composer.title_or_link_placeholder", "placeholder invites you to paste a link" diff --git a/app/assets/javascripts/discourse/tests/unit/models/email-log-test.js b/app/assets/javascripts/discourse/tests/unit/models/email-log-test.js index adefc0dc13..420e036325 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/email-log-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/email-log-test.js @@ -26,7 +26,7 @@ module("Unit | Model | email-log", function () { }, }; const emailLog = EmailLog.create(attrs); - assert.equal( + assert.strictEqual( emailLog.get("post_url"), "/forum/t/some-pro-tips-for-you/41/5", "includes the subfolder in the post url" diff --git a/app/assets/javascripts/discourse/tests/unit/models/group-test.js b/app/assets/javascripts/discourse/tests/unit/models/group-test.js index 4b266f7e0c..396c824242 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/group-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/group-test.js @@ -5,7 +5,7 @@ module("Unit | Model | group", function () { test("displayName", function (assert) { const group = Group.create({ name: "test", display_name: "donkey" }); - assert.equal( + assert.strictEqual( group.get("displayName"), "donkey", "it should return the display name" @@ -13,7 +13,7 @@ module("Unit | Model | group", function () { group.set("display_name", null); - assert.equal( + assert.strictEqual( group.get("displayName"), "test", "it should return the group's name" diff --git a/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js b/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js index 409eae4d46..d1b5bab44e 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/nav-item-test.js @@ -20,7 +20,11 @@ module("Unit | Model | nav-item", function (hooks) { assert.expect(4); function href(text, opts, expected, label) { - assert.equal(NavItem.fromText(text, opts).get("href"), expected, label); + assert.strictEqual( + NavItem.fromText(text, opts).get("href"), + expected, + label + ); } href("latest", {}, "/latest", "latest"); @@ -37,7 +41,7 @@ module("Unit | Model | nav-item", function (hooks) { test("count", function (assert) { const navItem = createStore().createRecord("nav-item", { name: "new" }); - assert.equal(navItem.get("count"), 0, "it has no count by default"); + assert.strictEqual(navItem.get("count"), 0, "it has no count by default"); const tracker = navItem.get("topicTrackingState"); tracker.modifyState("t1", { @@ -47,7 +51,7 @@ module("Unit | Model | nav-item", function (hooks) { }); tracker.incrementMessageCount(); - assert.equal( + assert.strictEqual( navItem.get("count"), 1, "it updates when a new message arrives" diff --git a/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js b/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js index 7f3f216efb..7cda767a01 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js @@ -44,7 +44,11 @@ module("Unit | Model | post-stream", function () { const postStream = buildStream(4567, [1, 3, 4]); const store = postStream.store; - assert.equal(postStream.get("lastPostId"), 4, "the last post id is 4"); + assert.strictEqual( + postStream.get("lastPostId"), + 4, + "the last post id is 4" + ); assert.ok(!postStream.get("hasPosts"), "there are no posts by default"); assert.ok( @@ -52,7 +56,7 @@ module("Unit | Model | post-stream", function () { "the first post is not loaded" ); assert.ok(!postStream.get("loadedAllPosts"), "the last post is not loaded"); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 0, "it has no posts initially" @@ -65,7 +69,7 @@ module("Unit | Model | post-stream", function () { !postStream.get("firstPostPresent"), "the first post is still not loaded" ); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 1, "it has one post in the stream" @@ -79,7 +83,7 @@ module("Unit | Model | post-stream", function () { "the first post is still loaded" ); assert.ok(postStream.get("loadedAllPosts"), "the last post is now loaded"); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 2, "it has two posts in the stream" @@ -88,7 +92,7 @@ module("Unit | Model | post-stream", function () { postStream.appendPost( store.createRecord("post", { id: 4, post_number: 4 }) ); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 2, "it will not add the same post with id twice" @@ -96,13 +100,13 @@ module("Unit | Model | post-stream", function () { const stagedPost = store.createRecord("post", { raw: "incomplete post" }); postStream.appendPost(stagedPost); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 3, "it can handle posts without ids" ); postStream.appendPost(stagedPost); - assert.equal( + assert.strictEqual( postStream.get("posts.length"), 3, "it won't add the same post without an id twice" @@ -136,22 +140,22 @@ module("Unit | Model | post-stream", function () { store.createRecord("post", { id: 2, post_number: 3 }) ); - assert.equal( + assert.strictEqual( postStream.closestPostNumberFor(2), 2, "If a post is in the stream it returns its post number" ); - assert.equal( + assert.strictEqual( postStream.closestPostNumberFor(3), 3, "If a post is in the stream it returns its post number" ); - assert.equal( + assert.strictEqual( postStream.closestPostNumberFor(10), 3, "it clips to the upper bound of the stream" ); - assert.equal( + assert.strictEqual( postStream.closestPostNumberFor(0), 2, "it clips to the lower bound of the stream" @@ -166,26 +170,26 @@ module("Unit | Model | post-stream", function () { [5, 1], ]); - assert.equal(postStream.closestDaysAgoFor(1), 10); - assert.equal(postStream.closestDaysAgoFor(2), 10); - assert.equal(postStream.closestDaysAgoFor(3), 8); - assert.equal(postStream.closestDaysAgoFor(4), 8); - assert.equal(postStream.closestDaysAgoFor(5), 1); + assert.strictEqual(postStream.closestDaysAgoFor(1), 10); + assert.strictEqual(postStream.closestDaysAgoFor(2), 10); + assert.strictEqual(postStream.closestDaysAgoFor(3), 8); + assert.strictEqual(postStream.closestDaysAgoFor(4), 8); + assert.strictEqual(postStream.closestDaysAgoFor(5), 1); // Out of bounds - assert.equal(postStream.closestDaysAgoFor(-1), 10); - assert.equal(postStream.closestDaysAgoFor(0), 10); - assert.equal(postStream.closestDaysAgoFor(10), 1); + assert.strictEqual(postStream.closestDaysAgoFor(-1), 10); + assert.strictEqual(postStream.closestDaysAgoFor(0), 10); + assert.strictEqual(postStream.closestDaysAgoFor(10), 1); postStream.set("timelineLookup", []); - assert.equal(postStream.closestDaysAgoFor(1), undefined); + assert.strictEqual(postStream.closestDaysAgoFor(1), undefined); }); test("closestDaysAgoFor - empty", function (assert) { const postStream = buildStream(1231); postStream.set("timelineLookup", []); - assert.equal(postStream.closestDaysAgoFor(1), null); + assert.strictEqual(postStream.closestDaysAgoFor(1), undefined); }); test("updateFromJson", function (assert) { @@ -197,10 +201,14 @@ module("Unit | Model | post-stream", function () { extra_property: 12, }); - assert.equal(postStream.get("posts.length"), 1, "it loaded the posts"); + assert.strictEqual( + postStream.get("posts.length"), + 1, + "it loaded the posts" + ); assert.containsInstance(postStream.get("posts"), Post); - assert.equal(postStream.get("extra_property"), 12); + assert.strictEqual(postStream.get("extra_property"), 12); }); test("removePosts", function (assert) { @@ -217,10 +225,10 @@ module("Unit | Model | post-stream", function () { // Removing nothing does nothing postStream.removePosts(); - assert.equal(postStream.get("posts.length"), 3); + assert.strictEqual(postStream.get("posts.length"), 3); postStream.removePosts([p1, p3]); - assert.equal(postStream.get("posts.length"), 1); + assert.strictEqual(postStream.get("posts.length"), 1); assert.deepEqual(postStream.get("stream"), [2]); }); @@ -245,22 +253,26 @@ module("Unit | Model | post-stream", function () { const postStream = buildStream(1234, [10, 20, 30, 40, 50, 60, 70]); postStream.set("gaps", { before: { 60: [55, 58] } }); - assert.equal( + assert.strictEqual( postStream.findPostIdForPostNumber(500), - null, - "it returns null when the post cannot be found" + undefined, + "it returns undefined when the post cannot be found" ); - assert.equal( + assert.strictEqual( postStream.findPostIdForPostNumber(1), 10, "it finds the postId at the beginning" ); - assert.equal( + assert.strictEqual( postStream.findPostIdForPostNumber(5), 50, "it finds the postId in the middle" ); - assert.equal(postStream.findPostIdForPostNumber(8), 60, "it respects gaps"); + assert.strictEqual( + postStream.findPostIdForPostNumber(8), + 60, + "it respects gaps" + ); }); test("fillGapBefore", function (assert) { @@ -287,7 +299,7 @@ module("Unit | Model | post-stream", function () { const postStream = buildStream(1236); sinon.stub(postStream, "refresh").returns(Promise.resolve()); - assert.equal( + assert.strictEqual( postStream.get("userFilters.length"), 0, "by default no participants are toggled" @@ -313,21 +325,21 @@ module("Unit | Model | post-stream", function () { sinon.stub(postStream, "refresh").returns(Promise.resolve()); - assert.equal( + assert.strictEqual( postStream.get("filterRepliesToPostNumber"), false, "by default no replies are filtered" ); postStream.filterReplies(3, 2); - assert.equal( + assert.strictEqual( postStream.get("filterRepliesToPostNumber"), 3, "postNumber is in the filters" ); postStream.cancelFilter(); - assert.equal( + assert.strictEqual( postStream.get("filterRepliesToPostNumber"), false, "cancelFilter clears" @@ -344,17 +356,21 @@ module("Unit | Model | post-stream", function () { sinon.stub(postStream, "refresh").returns(Promise.resolve()); - assert.equal( + assert.strictEqual( postStream.get("filterUpwardsPostID"), false, "by default filter is false" ); postStream.filterUpwards(2); - assert.equal(postStream.get("filterUpwardsPostID"), 2, "filter is set"); + assert.strictEqual( + postStream.get("filterUpwardsPostID"), + 2, + "filter is set" + ); postStream.cancelFilter(); - assert.equal( + assert.strictEqual( postStream.get("filterUpwardsPostID"), false, "filter cleared" @@ -525,13 +541,13 @@ module("Unit | Model | post-stream", function () { "it has no highest post number yet" ); let stored = postStream.storePost(post); - assert.equal(post, stored, "it returns the post it stored"); - assert.equal( + assert.strictEqual(post, stored, "it returns the post it stored"); + assert.strictEqual( post.get("topic"), postStream.get("topic"), "it creates the topic reference properly" ); - assert.equal( + assert.strictEqual( postStream.get("topic.highest_post_number"), 100, "it set the highest post number" @@ -543,12 +559,12 @@ module("Unit | Model | post-stream", function () { raw: "updated value", }); const storedDupe = postStream.storePost(dupePost); - assert.equal( + assert.strictEqual( storedDupe, post, "it returns the previously stored post instead to avoid dupes" ); - assert.equal( + assert.strictEqual( storedDupe.get("raw"), "updated value", "it updates the previously stored post" @@ -556,7 +572,7 @@ module("Unit | Model | post-stream", function () { const postWithoutId = store.createRecord("post", { raw: "hello world" }); stored = postStream.storePost(postWithoutId); - assert.equal(stored, postWithoutId, "it returns the same post back"); + assert.strictEqual(stored, postWithoutId, "it returns the same post back"); }); test("identity map", async function (assert) { @@ -570,7 +586,7 @@ module("Unit | Model | post-stream", function () { store.createRecord("post", { id: 3, post_number: 4 }) ); - assert.equal( + assert.strictEqual( postStream.findLoadedPost(1), p1, "it can return cached posts by id" @@ -579,15 +595,19 @@ module("Unit | Model | post-stream", function () { // Find posts by ids uses the identity map const result = await postStream.findPostsByIds([1, 2, 3]); - assert.equal(result.length, 3); - assert.equal(result.objectAt(0), p1); - assert.equal(result.objectAt(1).get("post_number"), 2); - assert.equal(result.objectAt(2), p3); + assert.strictEqual(result.length, 3); + assert.strictEqual(result.objectAt(0), p1); + assert.strictEqual(result.objectAt(1).get("post_number"), 2); + assert.strictEqual(result.objectAt(2), p3); }); test("loadIntoIdentityMap with no data", async function (assert) { const result = await buildStream(1234).loadIntoIdentityMap([]); - assert.equal(result.length, 0, "requesting no posts produces no posts"); + assert.strictEqual( + result.length, + 0, + "requesting no posts produces no posts" + ); }); test("loadIntoIdentityMap with post ids", async function (assert) { @@ -616,7 +636,7 @@ module("Unit | Model | post-stream", function () { "it adds the returned post to the store" ); - assert.equal( + assert.strictEqual( postStream.get("posts").length, 6, "it adds the right posts into the stream" @@ -639,7 +659,7 @@ module("Unit | Model | post-stream", function () { "it adds the returned post to the store" ); - assert.equal( + assert.strictEqual( postStream.get("posts").length, 6, "it adds the right posts into the stream" @@ -656,7 +676,7 @@ module("Unit | Model | post-stream", function () { topic_id: 10101, }); postStream.appendPost(original); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), original, "the original post is lastAppended" @@ -680,8 +700,8 @@ module("Unit | Model | post-stream", function () { // Stage the new post in the stream const result = postStream.stagePost(stagedPost, user); - assert.equal(result, "staged", "it returns staged"); - assert.equal( + assert.strictEqual(result, "staged", "it returns staged"); + assert.strictEqual( topic.get("highest_post_number"), 2, "it updates the highest_post_number" @@ -690,26 +710,30 @@ module("Unit | Model | post-stream", function () { postStream.get("loading"), "it is loading while the post is being staged" ); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), original, "it doesn't consider staged posts as the lastAppended" ); - assert.equal(topic.get("posts_count"), 2, "it increases the post count"); + assert.strictEqual( + topic.get("posts_count"), + 2, + "it increases the post count" + ); assert.present(topic.get("last_posted_at"), "it updates last_posted_at"); - assert.equal( + assert.strictEqual( topic.get("details.last_poster"), user, "it changes the last poster" ); - assert.equal( + assert.strictEqual( stagedPost.get("topic"), topic, "it assigns the topic reference" ); - assert.equal( + assert.strictEqual( stagedPost.get("post_number"), 2, "it is assigned the probable post_number" @@ -722,19 +746,27 @@ module("Unit | Model | post-stream", function () { postStream.get("posts").includes(stagedPost), "the post is added to the stream" ); - assert.equal(stagedPost.get("id"), -1, "the post has a magical -1 id"); + assert.strictEqual( + stagedPost.get("id"), + -1, + "the post has a magical -1 id" + ); // Undoing a created post (there was an error) postStream.undoPost(stagedPost); assert.ok(!postStream.get("loading"), "it is no longer loading"); - assert.equal( + assert.strictEqual( topic.get("highest_post_number"), 1, "it reverts the highest_post_number" ); - assert.equal(topic.get("posts_count"), 1, "it reverts the post count"); - assert.equal( + assert.strictEqual( + topic.get("posts_count"), + 1, + "it reverts the post count" + ); + assert.strictEqual( postStream.get("filteredPostsCount"), 1, "it retains the filteredPostsCount" @@ -743,7 +775,7 @@ module("Unit | Model | post-stream", function () { !postStream.get("posts").includes(stagedPost), "the post is removed from the stream" ); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), original, "it doesn't consider undid post lastAppended" @@ -760,7 +792,7 @@ module("Unit | Model | post-stream", function () { topic_id: 10101, }); postStream.appendPost(original); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), original, "the original post is lastAppended" @@ -781,7 +813,7 @@ module("Unit | Model | post-stream", function () { // Stage the new post in the stream let result = postStream.stagePost(stagedPost, user); - assert.equal(result, "staged", "it returns staged"); + assert.strictEqual(result, "staged", "it returns staged"); assert.ok( postStream.get("loading"), @@ -790,12 +822,12 @@ module("Unit | Model | post-stream", function () { stagedPost.setProperties({ id: 1234, raw: "different raw value" }); result = postStream.stagePost(stagedPost, user); - assert.equal( + assert.strictEqual( result, "alreadyStaging", "you can't stage a post while it is currently staging" ); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), original, "staging a post doesn't change the lastAppended" @@ -808,7 +840,7 @@ module("Unit | Model | post-stream", function () { ); assert.ok(!postStream.get("loading"), "it is no longer loading"); - assert.equal( + assert.strictEqual( postStream.get("filteredPostsCount"), 2, "it increases the filteredPostsCount" @@ -817,12 +849,12 @@ module("Unit | Model | post-stream", function () { const found = postStream.findLoadedPost(stagedPost.get("id")); assert.present(found, "the post is in the identity map"); assert.ok(postStream.indexOf(stagedPost) > -1, "the post is in the stream"); - assert.equal( + assert.strictEqual( found.get("raw"), "different raw value", "it also updated the value in the stream" ); - assert.ok( + assert.strictEqual( postStream.get("lastAppended"), found, "committing a post changes lastAppended" @@ -857,7 +889,7 @@ module("Unit | Model | post-stream", function () { [1, 2, 3, 5].forEach((id) => { postStream.appendPost( - store.createRecord("post", { id: id, post_number: id }) + store.createRecord("post", { id, post_number: id }) ); }); @@ -869,7 +901,7 @@ module("Unit | Model | post-stream", function () { return response({ id: 4, post_number: 4 }); }); - assert.equal( + assert.strictEqual( postStream.get("postsWithPlaceholders.length"), 4, "it should return the right length" @@ -877,7 +909,7 @@ module("Unit | Model | post-stream", function () { await postStream.triggerRecoveredPost(4); - assert.equal( + assert.strictEqual( postStream.get("postsWithPlaceholders.length"), 5, "it should return the right length" @@ -901,7 +933,7 @@ module("Unit | Model | post-stream", function () { }); postStream.stagePost(stagedPost, user); - assert.equal( + assert.strictEqual( postStream.get("filteredPostsCount"), 0, "it has no filteredPostsCount yet" @@ -910,10 +942,14 @@ module("Unit | Model | post-stream", function () { sinon.stub(postStream, "appendMore"); postStream.triggerNewPostsInStream([123]); - assert.equal(postStream.get("filteredPostsCount"), 1, "it added the post"); + assert.strictEqual( + postStream.get("filteredPostsCount"), + 1, + "it added the post" + ); postStream.commitPost(stagedPost); - assert.equal( + assert.strictEqual( postStream.get("filteredPostsCount"), 1, "it does not add the same post twice" @@ -953,12 +989,12 @@ module("Unit | Model | post-stream", function () { .returns(Promise.resolve([post2])); await postStream.triggerNewPostsInStream([101]); - assert.equal( + assert.strictEqual( postStream.posts.length, 2, "it added the regular post to the posts" ); - assert.equal( + assert.strictEqual( postStream.get("stream.length"), 2, "it added the regular post to the stream" @@ -968,12 +1004,12 @@ module("Unit | Model | post-stream", function () { sinon.stub(postStream, "findPostsByIds").returns(Promise.resolve([post3])); await postStream.triggerNewPostsInStream([102]); - assert.equal( + assert.strictEqual( postStream.posts.length, 2, "it does not add the ignored post to the posts" ); - assert.equal( + assert.strictEqual( postStream.stream.length, 2, "it does not add the ignored post to the stream" @@ -997,61 +1033,61 @@ module("Unit | Model | post-stream", function () { postStream.appendPost(p3); // Test enumerable and array access - assert.equal(postsWithPlaceholders.get("length"), 3); - assert.equal(testProxy.get("length"), 3); - assert.equal(postsWithPlaceholders.nextObject(0), p1); - assert.equal(postsWithPlaceholders.objectAt(0), p1); - assert.equal(postsWithPlaceholders.nextObject(1, p1), p2); - assert.equal(postsWithPlaceholders.objectAt(1), p2); - assert.equal(postsWithPlaceholders.nextObject(2, p2), p3); - assert.equal(postsWithPlaceholders.objectAt(2), p3); + assert.strictEqual(postsWithPlaceholders.get("length"), 3); + assert.strictEqual(testProxy.get("length"), 3); + assert.strictEqual(postsWithPlaceholders.nextObject(0), p1); + assert.strictEqual(postsWithPlaceholders.objectAt(0), p1); + assert.strictEqual(postsWithPlaceholders.nextObject(1, p1), p2); + assert.strictEqual(postsWithPlaceholders.objectAt(1), p2); + assert.strictEqual(postsWithPlaceholders.nextObject(2, p2), p3); + assert.strictEqual(postsWithPlaceholders.objectAt(2), p3); const promise = postStream.appendMore(); - assert.equal( + assert.strictEqual( postsWithPlaceholders.get("length"), 8, "we immediately have a larger placeholder window" ); - assert.equal(testProxy.get("length"), 8); + assert.strictEqual(testProxy.get("length"), 8); assert.ok(!!postsWithPlaceholders.nextObject(3, p3)); assert.ok(!!postsWithPlaceholders.objectAt(4)); assert.ok(postsWithPlaceholders.objectAt(3) !== p4); assert.ok(testProxy.objectAt(3) !== p4); await promise; - assert.equal(postsWithPlaceholders.objectAt(3), p4); - assert.equal( + assert.strictEqual(postsWithPlaceholders.objectAt(3), p4); + assert.strictEqual( postsWithPlaceholders.get("length"), 8, "have a larger placeholder window when loaded" ); - assert.equal(testProxy.get("length"), 8); - assert.equal(testProxy.objectAt(3), p4); + assert.strictEqual(testProxy.get("length"), 8); + assert.strictEqual(testProxy.objectAt(3), p4); }); test("filteredPostsCount", function (assert) { const postStream = buildStream(4567, [1, 3, 4]); - assert.equal(postStream.get("filteredPostsCount"), 3); + assert.strictEqual(postStream.get("filteredPostsCount"), 3); // Megatopic postStream.set("isMegaTopic", true); postStream.set("topic.highest_post_number", 4); - assert.equal(postStream.get("filteredPostsCount"), 4); + assert.strictEqual(postStream.get("filteredPostsCount"), 4); }); test("lastPostId", function (assert) { const postStream = buildStream(4567, [1, 3, 4]); - assert.equal(postStream.get("lastPostId"), 4); + assert.strictEqual(postStream.get("lastPostId"), 4); postStream.setProperties({ isMegaTopic: true, lastId: 2, }); - assert.equal(postStream.get("lastPostId"), 2); + assert.strictEqual(postStream.get("lastPostId"), 2); }); test("progressIndexOfPostId", function (assert) { @@ -1059,10 +1095,10 @@ module("Unit | Model | post-stream", function () { const store = createStore(); const post = store.createRecord("post", { id: 1, post_number: 5 }); - assert.equal(postStream.progressIndexOfPostId(post), 1); + assert.strictEqual(postStream.progressIndexOfPostId(post), 1); postStream.set("isMegaTopic", true); - assert.equal(postStream.progressIndexOfPostId(post), 5); + assert.strictEqual(postStream.progressIndexOfPostId(post), 5); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/post-test.js b/app/assets/javascripts/discourse/tests/unit/models/post-test.js index 82b5e52805..6938b2b476 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/post-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/post-test.js @@ -54,17 +54,17 @@ module("Unit | Model | post", function () { }) ); - assert.equal(post.get("raw"), "different raw", "raw field updated"); + assert.strictEqual(post.get("raw"), "different raw", "raw field updated"); }); test("destroy by staff", async function (assert) { let user = User.create({ username: "staff", moderator: true }); - let post = buildPost({ user: user }); + let post = buildPost({ user }); await post.destroy(user); assert.present(post.get("deleted_at"), "it has a `deleted_at` field."); - assert.equal( + assert.strictEqual( post.get("deleted_by"), user, "it has the user in the `deleted_by` field" @@ -85,7 +85,7 @@ module("Unit | Model | post", function () { test("destroy by non-staff", async function (assert) { const originalCooked = "this is the original cooked value"; const user = User.create({ username: "evil trout" }); - const post = buildPost({ user: user, cooked: originalCooked }); + const post = buildPost({ user, cooked: originalCooked }); await post.destroy(user); @@ -97,6 +97,6 @@ module("Unit | Model | post", function () { post.get("cooked") !== originalCooked, "the cooked content changed" ); - assert.equal(post.get("version"), 2, "the version number increased"); + assert.strictEqual(post.get("version"), 2, "the version number increased"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/report-test.js b/app/assets/javascripts/discourse/tests/unit/models/report-test.js index 3324ec0919..3cb76c9a0d 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/report-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/report-test.js @@ -18,21 +18,21 @@ module("Unit | Model | report", function () { test("counts", function (assert) { const report = reportWithData([5, 4, 3, 2, 1, 100, 99, 98, 1000]); - assert.equal(report.get("todayCount"), 5); - assert.equal(report.get("yesterdayCount"), 4); - assert.equal( + assert.strictEqual(report.get("todayCount"), 5); + assert.strictEqual(report.get("yesterdayCount"), 4); + assert.strictEqual( report.valueFor(2, 4), 6, "adds the values for the given range of days, inclusive" ); - assert.equal( + assert.strictEqual( report.get("lastSevenDaysCount"), 307, "sums 7 days excluding today" ); report.set("type", "time_to_first_response"); - assert.equal( + assert.strictEqual( report.valueFor(2, 4), 2, "averages the values for the given range of days" @@ -42,14 +42,30 @@ module("Unit | Model | report", function () { test("percentChangeString", function (assert) { const report = reportWithData([]); - assert.equal(report.percentChangeString(5, 8), "+60%", "value increased"); - assert.equal(report.percentChangeString(8, 2), "-75%", "value decreased"); - assert.equal(report.percentChangeString(8, 8), "0%", "value unchanged"); + assert.strictEqual( + report.percentChangeString(5, 8), + "+60%", + "value increased" + ); + assert.strictEqual( + report.percentChangeString(8, 2), + "-75%", + "value decreased" + ); + assert.strictEqual( + report.percentChangeString(8, 8), + "0%", + "value unchanged" + ); assert.blank( report.percentChangeString(0, 8), "returns blank when previous value was 0" ); - assert.equal(report.percentChangeString(8, 0), "-100%", "yesterday was 0"); + assert.strictEqual( + report.percentChangeString(8, 0), + "-100%", + "yesterday was 0" + ); assert.blank( report.percentChangeString(0, 0), "returns blank when both were 0" @@ -64,7 +80,7 @@ module("Unit | Model | report", function () { test("yesterdayCountTitle when two days ago was 0", function (assert) { const title = reportWithData([6, 8, 0, 2, 1]).get("yesterdayCountTitle"); - assert.equal(title.indexOf("%"), -1); + assert.strictEqual(title.indexOf("%"), -1); assert.ok(title.match(/Was 0/)); }); @@ -451,100 +467,103 @@ module("Unit | Model | report", function () { const computedLabels = report.get("computedLabels"); const usernameLabel = computedLabels[0]; - assert.equal(usernameLabel.mainProperty, "username"); - assert.equal(usernameLabel.sortProperty, "username"); - assert.equal(usernameLabel.title, "Moderator"); - assert.equal(usernameLabel.type, "user"); + assert.strictEqual(usernameLabel.mainProperty, "username"); + assert.strictEqual(usernameLabel.sortProperty, "username"); + assert.strictEqual(usernameLabel.title, "Moderator"); + assert.strictEqual(usernameLabel.type, "user"); const computedUsernameLabel = usernameLabel.compute(row); - assert.equal( + assert.strictEqual( computedUsernameLabel.formatedValue, - "joffrey" + "joffrey" ); - assert.equal(computedUsernameLabel.value, "joffrey"); + assert.strictEqual(computedUsernameLabel.value, "joffrey"); const flagCountLabel = computedLabels[1]; - assert.equal(flagCountLabel.mainProperty, "flag_count"); - assert.equal(flagCountLabel.sortProperty, "flag_count"); - assert.equal(flagCountLabel.title, "Flag count"); - assert.equal(flagCountLabel.type, "number"); + assert.strictEqual(flagCountLabel.mainProperty, "flag_count"); + assert.strictEqual(flagCountLabel.sortProperty, "flag_count"); + assert.strictEqual(flagCountLabel.title, "Flag count"); + assert.strictEqual(flagCountLabel.type, "number"); let computedFlagCountLabel = flagCountLabel.compute(row); - assert.equal(computedFlagCountLabel.formatedValue, "1.9k"); + assert.strictEqual(computedFlagCountLabel.formatedValue, "1.9k"); assert.strictEqual(computedFlagCountLabel.value, 1876); computedFlagCountLabel = flagCountLabel.compute(row, { formatNumbers: false, }); - assert.equal(computedFlagCountLabel.formatedValue, 1876); + assert.strictEqual(computedFlagCountLabel.formatedValue, "1876"); const timeReadLabel = computedLabels[2]; - assert.equal(timeReadLabel.mainProperty, "time_read"); - assert.equal(timeReadLabel.sortProperty, "time_read"); - assert.equal(timeReadLabel.title, "Time read"); - assert.equal(timeReadLabel.type, "seconds"); + assert.strictEqual(timeReadLabel.mainProperty, "time_read"); + assert.strictEqual(timeReadLabel.sortProperty, "time_read"); + assert.strictEqual(timeReadLabel.title, "Time read"); + assert.strictEqual(timeReadLabel.type, "seconds"); const computedTimeReadLabel = timeReadLabel.compute(row); - assert.equal(computedTimeReadLabel.formatedValue, "3d"); - assert.equal(computedTimeReadLabel.value, 287362); + assert.strictEqual(computedTimeReadLabel.formatedValue, "3d"); + assert.strictEqual(computedTimeReadLabel.value, 287362); const noteLabel = computedLabels[3]; - assert.equal(noteLabel.mainProperty, "note"); - assert.equal(noteLabel.sortProperty, "note"); - assert.equal(noteLabel.title, "Note"); - assert.equal(noteLabel.type, "text"); + assert.strictEqual(noteLabel.mainProperty, "note"); + assert.strictEqual(noteLabel.sortProperty, "note"); + assert.strictEqual(noteLabel.title, "Note"); + assert.strictEqual(noteLabel.type, "text"); const computedNoteLabel = noteLabel.compute(row); - assert.equal(computedNoteLabel.formatedValue, "This is a long note"); - assert.equal(computedNoteLabel.value, "This is a long note"); + assert.strictEqual(computedNoteLabel.formatedValue, "This is a long note"); + assert.strictEqual(computedNoteLabel.value, "This is a long note"); const topicLabel = computedLabels[4]; - assert.equal(topicLabel.mainProperty, "topic_title"); - assert.equal(topicLabel.sortProperty, "topic_title"); - assert.equal(topicLabel.title, "Topic"); - assert.equal(topicLabel.type, "topic"); + assert.strictEqual(topicLabel.mainProperty, "topic_title"); + assert.strictEqual(topicLabel.sortProperty, "topic_title"); + assert.strictEqual(topicLabel.title, "Topic"); + assert.strictEqual(topicLabel.type, "topic"); const computedTopicLabel = topicLabel.compute(row); - assert.equal( + assert.strictEqual( computedTopicLabel.formatedValue, "Test topic <html>" ); - assert.equal(computedTopicLabel.value, "Test topic "); + assert.strictEqual(computedTopicLabel.value, "Test topic "); const postLabel = computedLabels[5]; - assert.equal(postLabel.mainProperty, "post_raw"); - assert.equal(postLabel.sortProperty, "post_raw"); - assert.equal(postLabel.title, "Post"); - assert.equal(postLabel.type, "post"); + assert.strictEqual(postLabel.mainProperty, "post_raw"); + assert.strictEqual(postLabel.sortProperty, "post_raw"); + assert.strictEqual(postLabel.title, "Post"); + assert.strictEqual(postLabel.type, "post"); const computedPostLabel = postLabel.compute(row); - assert.equal( + assert.strictEqual( computedPostLabel.formatedValue, "This is the beginning of <html>" ); - assert.equal(computedPostLabel.value, "This is the beginning of "); + assert.strictEqual( + computedPostLabel.value, + "This is the beginning of " + ); const filesizeLabel = computedLabels[6]; - assert.equal(filesizeLabel.mainProperty, "filesize"); - assert.equal(filesizeLabel.sortProperty, "filesize"); - assert.equal(filesizeLabel.title, "Filesize"); - assert.equal(filesizeLabel.type, "bytes"); + assert.strictEqual(filesizeLabel.mainProperty, "filesize"); + assert.strictEqual(filesizeLabel.sortProperty, "filesize"); + assert.strictEqual(filesizeLabel.title, "Filesize"); + assert.strictEqual(filesizeLabel.type, "bytes"); const computedFilesizeLabel = filesizeLabel.compute(row); - assert.equal(computedFilesizeLabel.formatedValue, "569.0 KB"); - assert.equal(computedFilesizeLabel.value, 582641); + assert.strictEqual(computedFilesizeLabel.formatedValue, "569.0 KB"); + assert.strictEqual(computedFilesizeLabel.value, 582641); // subfolder support setPrefix("/forum"); const postLink = computedLabels[5].compute(row).formatedValue; - assert.equal( + assert.strictEqual( postLink, "This is the beginning of <html>" ); const topicLink = computedLabels[4].compute(row).formatedValue; - assert.equal( + assert.strictEqual( topicLink, "Test topic <html>" ); const userLink = computedLabels[0].compute(row).formatedValue; - assert.equal( + assert.strictEqual( userLink, - "joffrey" + "joffrey" ); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/rest-model-test.js b/app/assets/javascripts/discourse/tests/unit/models/rest-model-test.js index d444d275ef..539beb88d0 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/rest-model-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/rest-model-test.js @@ -16,13 +16,13 @@ module("Unit | Model | rest-model", function () { }); let g = Grape.create({ store, percent: 0.4 }); - assert.equal(g.get("inverse"), 0.6, "it runs `munge` on `create`"); + assert.strictEqual(g.get("inverse"), 0.6, "it runs `munge` on `create`"); }); test("update", async function (assert) { const store = createStore(); const widget = await store.find("widget", 123); - assert.equal(widget.get("name"), "Trout Lure"); + assert.strictEqual(widget.get("name"), "Trout Lure"); assert.ok(!widget.get("isSaving"), "it is not saving"); const spyBeforeUpdate = sinon.spy(widget, "beforeUpdate"); @@ -34,10 +34,10 @@ module("Unit | Model | rest-model", function () { const result = await promise; assert.ok(spyAfterUpdate.calledOn(widget)); assert.ok(!widget.get("isSaving"), "it is no longer saving"); - assert.equal(widget.get("name"), "new name"); + assert.strictEqual(widget.get("name"), "new name"); assert.ok(result.target, "it has a reference to the record"); - assert.equal(result.target.name, widget.get("name")); + assert.strictEqual(result.target.name, widget.get("name")); }); test("updating simultaneously", async function (assert) { @@ -81,7 +81,7 @@ module("Unit | Model | rest-model", function () { assert.ok(!widget.get("isNew"), "it is no longer new"); assert.ok(result.target, "it has a reference to the record"); - assert.equal(result.target.name, widget.get("name")); + assert.strictEqual(result.target.name, widget.get("name")); }); test("creating simultaneously", function (assert) { @@ -127,18 +127,18 @@ module("Unit | Model | rest-model", function () { //Create const widget = store.createRecord("my-widget"); await widget.save({ name: "Evil Widget" }); - assert.equal(widget.id, 100, "it saved a new record successfully"); - assert.equal(widget.get("name"), "Evil Widget"); + assert.strictEqual(widget.id, 100, "it saved a new record successfully"); + assert.strictEqual(widget.get("name"), "Evil Widget"); // Update await widget.update({ name: "new name" }); - assert.equal(widget.get("name"), "new name"); + assert.strictEqual(widget.get("name"), "new name"); // Destroy await widget.destroyRecord(); // Lookup const foundWidget = await store.find("my-widget", 123); - assert.equal(foundWidget.name, "Trout Lure"); + assert.strictEqual(foundWidget.name, "Trout Lure"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/result-set-test.js b/app/assets/javascripts/discourse/tests/unit/models/result-set-test.js index 595e722780..c1e7660b33 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/result-set-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/result-set-test.js @@ -5,8 +5,8 @@ import createStore from "discourse/tests/helpers/create-store"; module("Unit | Model | result-set", function () { test("defaults", function (assert) { const resultSet = ResultSet.create({ content: [] }); - assert.equal(resultSet.get("length"), 0); - assert.equal(resultSet.get("totalRows"), 0); + assert.strictEqual(resultSet.get("length"), 0); + assert.strictEqual(resultSet.get("totalRows"), 0); assert.ok(!resultSet.get("loadMoreUrl")); assert.ok(!resultSet.get("loading")); assert.ok(!resultSet.get("loadingMore")); @@ -16,8 +16,8 @@ module("Unit | Model | result-set", function () { test("pagination support", async function (assert) { const store = createStore(); const resultSet = await store.findAll("widget"); - assert.equal(resultSet.get("length"), 2); - assert.equal(resultSet.get("totalRows"), 4); + assert.strictEqual(resultSet.get("length"), 2); + assert.strictEqual(resultSet.get("totalRows"), 4); assert.ok(resultSet.get("loadMoreUrl"), "has a url to load more"); assert.ok(!resultSet.get("loadingMore"), "it is not loading more"); assert.ok(resultSet.get("canLoadMore")); @@ -27,7 +27,7 @@ module("Unit | Model | result-set", function () { await promise; assert.ok(!resultSet.get("loadingMore"), "it finished loading more"); - assert.equal(resultSet.get("length"), 4); + assert.strictEqual(resultSet.get("length"), 4); assert.ok(!resultSet.get("loadMoreUrl")); assert.ok(!resultSet.get("canLoadMore")); }); @@ -35,7 +35,7 @@ module("Unit | Model | result-set", function () { test("refresh support", async function (assert) { const store = createStore(); const resultSet = await store.findAll("widget"); - assert.equal( + assert.strictEqual( resultSet.get("refreshUrl"), "/widgets?refresh=true", "it has the refresh url" diff --git a/app/assets/javascripts/discourse/tests/unit/models/site-test.js b/app/assets/javascripts/discourse/tests/unit/models/site-test.js index d5e52ada4c..e4b5be284c 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/site-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/site-test.js @@ -39,17 +39,17 @@ module("Unit | Model | site", function () { site.get("sortedCategories"); assert.present(categories, "The categories are present"); - assert.equal(categories.length, 3, "it loaded all three categories"); + assert.strictEqual(categories.length, 3, "it loaded all three categories"); const parent = categories.findBy("id", 1234); assert.present(parent, "it loaded the parent category"); assert.blank(parent.get("parentCategory"), "it has no parent category"); - assert.equal(parent.get("subcategories").length, 1); + assert.strictEqual(parent.get("subcategories").length, 1); const subcategory = categories.findBy("id", 3456); assert.present(subcategory, "it loaded the subcategory"); - assert.equal( + assert.strictEqual( subcategory.get("parentCategory"), parent, "it has associated the child with the parent" @@ -59,12 +59,12 @@ module("Unit | Model | site", function () { categories.removeObject(categories[2]); categories.removeObject(categories[1]); - assert.equal( + assert.strictEqual( categories.length, site.get("categoriesByCount").length, "categories by count should change on removal" ); - assert.equal( + assert.strictEqual( categories.length, site.get("sortedCategories").length, "sorted categories should change on removal" diff --git a/app/assets/javascripts/discourse/tests/unit/models/theme-test.js b/app/assets/javascripts/discourse/tests/unit/models/theme-test.js index 94c360d58d..1d3f2e49fc 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/theme-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/theme-test.js @@ -6,7 +6,7 @@ module("Unit | Model | theme"); test("can add an upload correctly", function (assert) { let theme = Theme.create(); - assert.equal( + assert.strictEqual( theme.get("uploads.length"), 0, "uploads should be an empty array" @@ -14,9 +14,9 @@ test("can add an upload correctly", function (assert) { theme.setField("common", "bob", "", 999, 2); let fields = theme.get("theme_fields"); - assert.equal(fields.length, 1, "expecting 1 theme field"); - assert.equal(fields[0].upload_id, 999, "expecting upload id to be set"); - assert.equal(fields[0].type_id, 2, "expecting type id to be set"); + assert.strictEqual(fields.length, 1, "expecting 1 theme field"); + assert.strictEqual(fields[0].upload_id, 999, "expecting upload id to be set"); + assert.strictEqual(fields[0].type_id, 2, "expecting type id to be set"); - assert.equal(theme.get("uploads.length"), 1, "expecting an upload"); + assert.strictEqual(theme.get("uploads.length"), 1, "expecting an upload"); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js index 5dd146e5eb..23093db921 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-details-test.js @@ -21,7 +21,7 @@ module("Unit | Model | topic-details", function () { allowed_users: [{ username: "eviltrout" }], }); - assert.equal( + assert.strictEqual( details.get("allowed_users.length"), 1, "it loaded the allowed users" diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js index 45c5f9a6ca..2d8676921c 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-test.js @@ -49,7 +49,7 @@ discourseModule("Unit | Model | topic", function () { topic.set("category", category); - assert.equal(topic.get("lastUnreadUrl"), "/t/hello/101/1"); + assert.strictEqual(topic.get("lastUnreadUrl"), "/t/hello/101/1"); }); test("has details", function (assert) { @@ -57,7 +57,7 @@ discourseModule("Unit | Model | topic", function () { const topicDetails = topic.get("details"); assert.present(topicDetails, "a topic has topicDetails after we create it"); - assert.equal( + assert.strictEqual( topicDetails.get("topic"), topic, "the topicDetails has a reference back to the topic" @@ -69,7 +69,7 @@ discourseModule("Unit | Model | topic", function () { const postStream = topic.get("postStream"); assert.present(postStream, "a topic has a postStream after we create it"); - assert.equal( + assert.strictEqual( postStream.get("topic"), topic, "the postStream has a reference back to the topic" @@ -80,7 +80,11 @@ discourseModule("Unit | Model | topic", function () { const topic = Topic.create({ suggested_topics: [{ id: 1 }, { id: 2 }] }); const suggestedTopics = topic.get("suggestedTopics"); - assert.equal(suggestedTopics.length, 2, "it loaded the suggested_topics"); + assert.strictEqual( + suggestedTopics.length, + 2, + "it loaded the suggested_topics" + ); assert.containsInstance(suggestedTopics, Topic); }); @@ -89,7 +93,7 @@ discourseModule("Unit | Model | topic", function () { const category = Category.list()[0]; const topic = Topic.create({ id: 1111, category_id: category.get("id") }); - assert.equal(topic.get("category"), category); + assert.strictEqual(topic.get("category"), category); }); test("updateFromJson", function (assert) { @@ -104,9 +108,17 @@ discourseModule("Unit | Model | topic", function () { }); assert.blank(topic.get("post_stream"), "it does not update post_stream"); - assert.equal(topic.get("details.hello"), "world", "it updates the details"); - assert.equal(topic.get("cool"), "property", "it updates other properties"); - assert.equal(topic.get("category"), category); + assert.strictEqual( + topic.get("details.hello"), + "world", + "it updates the details" + ); + assert.strictEqual( + topic.get("cool"), + "property", + "it updates other properties" + ); + assert.strictEqual(topic.get("category"), category); }); test("recover", async function (assert) { @@ -128,7 +140,7 @@ discourseModule("Unit | Model | topic", function () { fancy_title: ":smile: with all :) the emojis :pear::peach:", }); - assert.equal( + assert.strictEqual( topic.get("fancyTitle"), `smile with all slight_smile the emojis pearpeach`, "supports emojis" @@ -140,12 +152,12 @@ discourseModule("Unit | Model | topic", function () { const ltrTopic = Topic.create({ fancy_title: "This is a test" }); this.siteSettings.support_mixed_text_direction = true; - assert.equal( + assert.strictEqual( rtlTopic.get("fancyTitle"), `هذا اختبار`, "sets the dir-span to rtl" ); - assert.equal( + assert.strictEqual( ltrTopic.get("fancyTitle"), `This is a test`, "sets the dir-span to ltr" @@ -158,7 +170,7 @@ discourseModule("Unit | Model | topic", function () { pinned: true, }); - assert.equal( + assert.strictEqual( topic.get("escapedExcerpt"), `This is a test topic smile`, "supports emojis" @@ -167,15 +179,15 @@ discourseModule("Unit | Model | topic", function () { test("visible & invisible", function (assert) { const topic = Topic.create(); - assert.equal(topic.visible, undefined); - assert.equal(topic.invisible, undefined); + assert.strictEqual(topic.visible, undefined); + assert.strictEqual(topic.invisible, undefined); const visibleTopic = Topic.create({ visible: true }); - assert.equal(visibleTopic.visible, true); - assert.equal(visibleTopic.invisible, false); + assert.strictEqual(visibleTopic.visible, true); + assert.strictEqual(visibleTopic.invisible, false); const invisibleTopic = Topic.create({ visible: false }); - assert.equal(invisibleTopic.visible, false); - assert.equal(invisibleTopic.invisible, true); + assert.strictEqual(invisibleTopic.visible, false); + assert.strictEqual(invisibleTopic.invisible, true); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js index b2ffb56fb1..8b02e16689 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/topic-tracking-state-test.js @@ -71,14 +71,22 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { const tagCounts = trackingState.countTags(["baz", "pending"]); - assert.equal(tagCounts["baz"].newCount, 2, "baz tag new counts"); - assert.equal(tagCounts["baz"].unreadCount, 0, "baz tag unread counts"); - assert.equal( + assert.strictEqual(tagCounts["baz"].newCount, 2, "baz tag new counts"); + assert.strictEqual( + tagCounts["baz"].unreadCount, + 0, + "baz tag unread counts" + ); + assert.strictEqual( tagCounts["pending"].unreadCount, 2, "pending tag unread counts" ); - assert.equal(tagCounts["pending"].newCount, 0, "pending tag new counts"); + assert.strictEqual( + tagCounts["pending"].newCount, + 0, + "pending tag new counts" + ); }); test("tag counts - with total", function (assert) { @@ -151,12 +159,20 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { includeTotal: true, }); - assert.equal(states["baz"].newCount, 2, "baz tag new counts"); - assert.equal(states["baz"].unreadCount, 0, "baz tag unread counts"); - assert.equal(states["baz"].totalCount, 3, "baz tag total counts"); - assert.equal(states["pending"].unreadCount, 2, "pending tag unread counts"); - assert.equal(states["pending"].newCount, 0, "pending tag new counts"); - assert.equal(states["pending"].totalCount, 4, "pending tag total counts"); + assert.strictEqual(states["baz"].newCount, 2, "baz tag new counts"); + assert.strictEqual(states["baz"].unreadCount, 0, "baz tag unread counts"); + assert.strictEqual(states["baz"].totalCount, 3, "baz tag total counts"); + assert.strictEqual( + states["pending"].unreadCount, + 2, + "pending tag unread counts" + ); + assert.strictEqual(states["pending"].newCount, 0, "pending tag new counts"); + assert.strictEqual( + states["pending"].totalCount, + 4, + "pending tag total counts" + ); }); test("forEachTracked", function (assert) { @@ -231,10 +247,10 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { } }); - assert.equal(randomNew, 1, "random tag new"); - assert.equal(randomUnread, 0, "random tag unread"); - assert.equal(sevenNew, 0, "category seven new"); - assert.equal(sevenUnread, 2, "category seven unread"); + assert.strictEqual(randomNew, 1, "random tag new"); + assert.strictEqual(randomUnread, 0, "random tag unread"); + assert.strictEqual(sevenNew, 0, "category seven new"); + assert.strictEqual(sevenUnread, 2, "category seven unread"); }); test("sync - delayed new topics for backend list are removed", function (assert) { @@ -253,7 +269,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }; trackingState.sync(list, "new"); - assert.equal( + assert.strictEqual( list.topics.length, 0, "expect new topic to be removed as it was seen" @@ -278,12 +294,12 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }; trackingState.sync(list, "unread"); - assert.equal( + assert.strictEqual( list.topics[0].unseen, false, "expect unread topic to be marked as seen" ); - assert.equal( + assert.strictEqual( list.topics[0].prevent_sync, true, "expect unread topic to be marked as prevent_sync" @@ -354,7 +370,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { let state111 = trackingState.findState(111); let state222 = trackingState.findState(222); - assert.equal( + assert.strictEqual( state111.last_read_post_number, null, "unseen topics get last_read_post_number reset to null" @@ -364,7 +380,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { { highest_post_number: 20, tags: ["pending"], category_id: 1 }, "highest_post_number, category, and tags are set for a topic" ); - assert.equal( + assert.strictEqual( state222.last_read_post_number, 17, "last_read_post_number is highest_post_number - (unread + new)" @@ -395,14 +411,14 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }; trackingState.sync(list, "unread"); - assert.equal( + assert.strictEqual( trackingState.findState(111).last_read_post_number, 5, "last_read_post_number set to highest post number to pretend read" ); trackingState.sync(list, "new"); - assert.equal( + assert.strictEqual( trackingState.findState(222).last_read_post_number, 1, "last_read_post_number set to 1 to pretend not new" @@ -454,7 +470,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { test("message count is incremented", function (assert) { publishToMessageBus(`/unread/${currentUser.id}`, unreadTopicPayload); - assert.equal( + assert.strictEqual( trackingState.messageCount, 1, "message count incremented" @@ -481,7 +497,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }, "topic state updated" ); - assert.equal(stateCallbackCalled, true, "state change callback called"); + assert.strictEqual( + stateCallbackCalled, + true, + "state change callback called" + ); }); test("adds incoming so it is counted in topic lists", function (assert) { @@ -492,7 +512,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { [111], "unread topic is incoming" ); - assert.equal( + assert.strictEqual( trackingState.incomingCount, 1, "incoming count is increased" @@ -512,7 +532,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { [111], "unread topic is incoming" ); - assert.equal( + assert.strictEqual( trackingState.incomingCount, 1, "incoming count is increased" @@ -535,7 +555,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { message_type: "dismiss_new", payload: { topic_ids: [112] }, }); - assert.equal(trackingState.findState(112).is_seen, true); + assert.strictEqual(trackingState.findState(112).is_seen, true); }); test("marks a topic as read", function (assert) { @@ -612,9 +632,9 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { test("topics in muted categories do not get added to the state", function (assert) { trackingState.currentUser.set("muted_category_ids", [123]); publishToMessageBus("/new", newTopicPayload); - assert.equal( + assert.strictEqual( trackingState.findState(222), - null, + undefined, "the new topic is not in the state" ); }); @@ -622,9 +642,9 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { test("topics in muted tags do not get added to the state", function (assert) { trackingState.currentUser.set("muted_tag_ids", [44]); publishToMessageBus("/new", newTopicPayload); - assert.equal( + assert.strictEqual( trackingState.findState(222), - null, + undefined, "the new topic is not in the state" ); }); @@ -632,7 +652,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { test("message count is incremented", function (assert) { publishToMessageBus("/new", newTopicPayload); - assert.equal( + assert.strictEqual( trackingState.messageCount, 1, "message count incremented" @@ -658,7 +678,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }, "new topic loaded into state" ); - assert.equal(stateCallbackCalled, true, "state change callback called"); + assert.strictEqual( + stateCallbackCalled, + true, + "state change callback called" + ); }); test("adds incoming so it is counted in topic lists", function (assert) { @@ -669,7 +693,7 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { [222], "new topic is incoming" ); - assert.equal( + assert.strictEqual( trackingState.incomingCount, 1, "incoming count is increased" @@ -691,12 +715,16 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { publishToMessageBus("/delete", { topic_id: 111 }); - assert.equal( + assert.strictEqual( trackingState.findState(111).deleted, true, "marks the topic as deleted" ); - assert.equal(trackingState.messageCount, 1, "increments message count"); + assert.strictEqual( + trackingState.messageCount, + 1, + "increments message count" + ); }); test("establishChannels - /recover MessageBus channel payloads processed", function (assert) { @@ -712,12 +740,16 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { publishToMessageBus("/recover", { topic_id: 111 }); - assert.equal( + assert.strictEqual( trackingState.findState(111).deleted, false, "marks the topic as not deleted" ); - assert.equal(trackingState.messageCount, 1, "increments message count"); + assert.strictEqual( + trackingState.messageCount, + 1, + "increments message count" + ); }); test("establishChannels - /destroy MessageBus channel payloads processed", function (assert) { @@ -737,7 +769,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { publishToMessageBus("/destroy", { topic_id: 111 }); - assert.equal(trackingState.messageCount, 1, "increments message count"); + assert.strictEqual( + trackingState.messageCount, + 1, + "increments message count" + ); assert.ok( DiscourseURL.redirectTo.calledWith("/"), "redirect to / because topic is destroyed" @@ -745,20 +781,9 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { }); test("subscribe to category", function (assert) { - const store = createStore(); - const darth = store.createRecord("category", { id: 1, slug: "darth" }), - luke = store.createRecord("category", { - id: 2, - slug: "luke", - parentCategory: darth, - }), - categoryList = [darth, luke]; - - sinon.stub(Category, "list").returns(categoryList); - const trackingState = TopicTrackingState.create(); - trackingState.trackIncoming("c/darth/1/l/latest"); + trackingState.trackIncoming("c/feature/2/l/latest"); trackingState.notifyIncoming({ message_type: "new_topic", @@ -773,17 +798,17 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { trackingState.notifyIncoming({ message_type: "new_topic", topic_id: 3, - payload: { category_id: 1 }, + payload: { category_id: 26 }, }); - assert.equal( + assert.strictEqual( trackingState.get("incomingCount"), 2, "expect to properly track incoming for category" ); trackingState.resetTracking(); - trackingState.trackIncoming("c/darth/luke/2/l/latest"); + trackingState.trackIncoming("c/feature/spec/26/l/latest"); trackingState.notifyIncoming({ message_type: "new_topic", @@ -795,17 +820,39 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { topic_id: 2, payload: { category_id: 3 }, }); + + assert.strictEqual( + trackingState.get("incomingCount"), + 0, + "parent or other category doesn't affect subcategory" + ); + trackingState.notifyIncoming({ message_type: "new_topic", topic_id: 3, - payload: { category_id: 1 }, + payload: { category_id: 26 }, }); - assert.equal( + assert.strictEqual( trackingState.get("incomingCount"), 1, "expect to properly track incoming for subcategory" ); + + trackingState.resetTracking(); + trackingState.trackIncoming("c/feature/spec/26/none/l/latest"); + + trackingState.notifyIncoming({ + message_type: "new_topic", + topic_id: 3, + payload: { category_id: 26 }, + }); + + assert.strictEqual( + trackingState.get("incomingCount"), + 1, + "expect to properly track incoming for subcategory using none tags route" + ); }); test("getSubCategoryIds", function (assert) { @@ -858,9 +905,9 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { const trackingState = TopicTrackingState.create({ currentUser }); - assert.equal(trackingState.countNew(1), 0); - assert.equal(trackingState.countNew(2), 0); - assert.equal(trackingState.countNew(3), 0); + assert.strictEqual(trackingState.countNew(1), 0); + assert.strictEqual(trackingState.countNew(2), 0); + assert.strictEqual(trackingState.countNew(3), 0); trackingState.states.set("t112", { last_read_post_number: null, @@ -870,11 +917,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { created_in_new_period: true, }); - assert.equal(trackingState.countNew(1), 1); - assert.equal(trackingState.countNew(1, undefined, true), 0); - assert.equal(trackingState.countNew(1, "missing-tag"), 0); - assert.equal(trackingState.countNew(2), 1); - assert.equal(trackingState.countNew(3), 0); + assert.strictEqual(trackingState.countNew(1), 1); + assert.strictEqual(trackingState.countNew(1, undefined, true), 0); + assert.strictEqual(trackingState.countNew(1, "missing-tag"), 0); + assert.strictEqual(trackingState.countNew(2), 1); + assert.strictEqual(trackingState.countNew(3), 0); trackingState.states.set("t113", { last_read_post_number: null, @@ -885,11 +932,11 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { created_in_new_period: true, }); - assert.equal(trackingState.countNew(1), 2); - assert.equal(trackingState.countNew(2), 2); - assert.equal(trackingState.countNew(3), 1); - assert.equal(trackingState.countNew(3, "amazing"), 1); - assert.equal(trackingState.countNew(3, "missing"), 0); + assert.strictEqual(trackingState.countNew(1), 2); + assert.strictEqual(trackingState.countNew(2), 2); + assert.strictEqual(trackingState.countNew(3), 1); + assert.strictEqual(trackingState.countNew(3, "amazing"), 1); + assert.strictEqual(trackingState.countNew(3, "missing"), 0); trackingState.states.set("t111", { last_read_post_number: null, @@ -899,16 +946,16 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { created_in_new_period: true, }); - assert.equal(trackingState.countNew(1), 3); - assert.equal(trackingState.countNew(2), 2); - assert.equal(trackingState.countNew(3), 1); + assert.strictEqual(trackingState.countNew(1), 3); + assert.strictEqual(trackingState.countNew(2), 2); + assert.strictEqual(trackingState.countNew(3), 1); trackingState.states.set("t115", { last_read_post_number: null, id: 115, category_id: 4, }); - assert.equal(trackingState.countNew(4), 0); + assert.strictEqual(trackingState.countNew(4), 0); }); test("mute and unmute topic", function (assert) { @@ -923,21 +970,21 @@ discourseModule("Unit | Model | topic-tracking-state", function (hooks) { topic_id: 1, message_type: "muted", }); - assert.equal(currentUser.muted_topics[0].topicId, 1); + assert.strictEqual(currentUser.muted_topics[0].topicId, 1); trackingState.trackMutedOrUnmutedTopic({ topic_id: 2, message_type: "unmuted", }); - assert.equal(currentUser.unmuted_topics[0].topicId, 2); + assert.strictEqual(currentUser.unmuted_topics[0].topicId, 2); trackingState.pruneOldMutedAndUnmutedTopics(); - assert.equal(trackingState.isMutedTopic(1), true); - assert.equal(trackingState.isUnmutedTopic(2), true); + assert.strictEqual(trackingState.isMutedTopic(1), true); + assert.strictEqual(trackingState.isUnmutedTopic(2), true); this.clock.tick(60000); trackingState.pruneOldMutedAndUnmutedTopics(); - assert.equal(trackingState.isMutedTopic(1), false); - assert.equal(trackingState.isUnmutedTopic(2), false); + assert.strictEqual(trackingState.isMutedTopic(1), false); + assert.strictEqual(trackingState.isUnmutedTopic(2), false); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-action-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-action-test.js index 50cd70e739..f0a910796f 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-action-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-action-test.js @@ -24,8 +24,8 @@ module("Unit | Model | user-action", function () { }), ]); - assert.equal(actions.length, 2); - assert.equal(actions[0].get("children.length"), 1); - assert.equal(actions[0].get("children")[0].items.length, 2); + assert.strictEqual(actions.length, 2); + assert.strictEqual(actions[0].get("children.length"), 1); + assert.strictEqual(actions[0].get("children")[0].items.length, 2); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js index 70e30733e2..eed1f62191 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-badge-test.js @@ -8,17 +8,17 @@ module("Unit | Model | user-badge", function () { JSON.parse(JSON.stringify(badgeFixtures["/user_badges"])) ); assert.ok(!Array.isArray(userBadge), "does not return an array"); - assert.equal( + assert.strictEqual( userBadge.get("badge.name"), "Badge 2", "badge reference is set" ); - assert.equal( + assert.strictEqual( userBadge.get("badge.badge_type.name"), "Silver 2", "badge.badge_type reference is set" ); - assert.equal( + assert.strictEqual( userBadge.get("granted_by.username"), "anne3", "granted_by reference is set" @@ -30,9 +30,9 @@ module("Unit | Model | user-badge", function () { JSON.parse(JSON.stringify(badgeFixtures["/user-badges/:username"])) ); assert.ok(Array.isArray(userBadges), "returns an array"); - assert.equal( + assert.strictEqual( userBadges[0].get("granted_by"), - null, + undefined, "granted_by reference is not set when null" ); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-drafts-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-drafts-test.js index 55d7e781eb..fbb99d5cc1 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-drafts-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-drafts-test.js @@ -9,7 +9,11 @@ module("Unit | Model | user-draft", function () { const user = User.create({ id: 1, username: "eviltrout" }); const stream = user.userDraftsStream; assert.present(stream, "a user has a drafts stream by default"); - assert.equal(stream.content.length, 0, "no items are loaded by default"); + assert.strictEqual( + stream.content.length, + 0, + "no items are loaded by default" + ); assert.blank(stream.content, "no content by default"); }); @@ -24,8 +28,8 @@ module("Unit | Model | user-draft", function () { }), ]; - assert.equal(drafts.length, 2, "drafts count is right"); - assert.equal( + assert.strictEqual(drafts.length, 2, "drafts count is right"); + assert.strictEqual( drafts[1].draftType, I18n.t("drafts.new_topic"), "loads correct draftType label" diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-stream-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-stream-test.js index 496a383988..f65a52def8 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-stream-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-stream-test.js @@ -7,13 +7,13 @@ module("Unit | Model | user-stream", function () { let user = User.create({ id: 1, username: "eviltrout" }); let stream = user.get("stream"); assert.present(stream, "a user has a stream by default"); - assert.equal( + assert.strictEqual( stream.get("user"), user, "the stream points back to the user" ); - assert.equal( + assert.strictEqual( stream.get("itemsLoaded"), 0, "no items are loaded by default" @@ -29,15 +29,15 @@ module("Unit | Model | user-stream", function () { let stream = user.get("stream"); // defaults to posts/topics - assert.equal(stream.get("filterParam"), "4,5"); + assert.strictEqual(stream.get("filterParam"), "4,5"); stream.set("filter", UserAction.TYPES.topics); - assert.equal(stream.get("filterParam"), "4"); + assert.strictEqual(stream.get("filterParam"), 4); stream.set("filter", UserAction.TYPES.likes_given); - assert.equal(stream.get("filterParam"), UserAction.TYPES.likes_given); + assert.strictEqual(stream.get("filterParam"), UserAction.TYPES.likes_given); stream.set("filter", UserAction.TYPES.replies); - assert.equal(stream.get("filterParam"), "6,9"); + assert.strictEqual(stream.get("filterParam"), "6,9"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/models/user-test.js b/app/assets/javascripts/discourse/tests/unit/models/user-test.js index fb30ea6c31..fe80430aa2 100644 --- a/app/assets/javascripts/discourse/tests/unit/models/user-test.js +++ b/app/assets/javascripts/discourse/tests/unit/models/user-test.js @@ -22,7 +22,7 @@ module("Unit | Model | user", function () { assert.deepEqual( user.get("searchContext"), - { type: "user", id: "eviltrout", user: user }, + { type: "user", id: "eviltrout", user }, "has a search context" ); }); @@ -45,7 +45,7 @@ module("Unit | Model | user", function () { let user = User.create({ admin: true }); let group = Group.create({ automatic: true }); - assert.equal( + assert.strictEqual( user.canManageGroup(group), false, "automatic groups cannot be managed." @@ -54,7 +54,7 @@ module("Unit | Model | user", function () { group.set("automatic", false); group.setProperties({ can_admin_group: true }); - assert.equal( + assert.strictEqual( user.canManageGroup(group), true, "an admin should be able to manage the group" @@ -63,7 +63,7 @@ module("Unit | Model | user", function () { user.set("admin", false); group.setProperties({ is_group_owner: true }); - assert.equal( + assert.strictEqual( user.canManageGroup(group), true, "a group owner should be able to manage the group" @@ -78,7 +78,7 @@ module("Unit | Model | user", function () { sinon.stub(ajaxlib.ajax); let spy = sinon.spy(ajaxlib, "ajax"); - assert.equal( + assert.strictEqual( user.resolvedTimezone(user), tz, "if the user already has a timezone return it" @@ -88,7 +88,7 @@ module("Unit | Model | user", function () { "if the user already has a timezone do not call AJAX update" ); user = User.create({ username: "chuck", id: 111 }); - assert.equal( + assert.strictEqual( user.resolvedTimezone(user), "America/Chicago", "if the user has no timezone guess it with moment" @@ -103,9 +103,9 @@ module("Unit | Model | user", function () { ); let otherUser = User.create({ username: "howardhamlin", id: 999 }); - assert.equal( + assert.strictEqual( otherUser.resolvedTimezone(user), - null, + undefined, "if the user has no timezone and the user is not the current user, do NOT guess with moment" ); assert.not( diff --git a/app/assets/javascripts/discourse/tests/unit/services/document-title-test.js b/app/assets/javascripts/discourse/tests/unit/services/document-title-test.js index ffb7d1009f..196f4f1b14 100644 --- a/app/assets/javascripts/discourse/tests/unit/services/document-title-test.js +++ b/app/assets/javascripts/discourse/tests/unit/services/document-title-test.js @@ -25,16 +25,16 @@ discourseModule("Unit | Service | document-title", function (hooks) { test("it updates the document title", function (assert) { this.documentTitle.setTitle("Test Title"); - assert.equal(document.title, "Test Title", "title is correct"); + assert.strictEqual(document.title, "Test Title", "title is correct"); }); test("it doesn't display notification counts for anonymous users", function (assert) { this.documentTitle.setTitle("test notifications"); this.documentTitle.updateNotificationCount(5); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); this.documentTitle.setFocus(false); this.documentTitle.updateNotificationCount(6); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); }); test("it displays notification counts for logged in users", function (assert) { @@ -42,12 +42,12 @@ discourseModule("Unit | Service | document-title", function (hooks) { this.documentTitle.currentUser.dynamic_favicon = false; this.documentTitle.setTitle("test notifications"); this.documentTitle.updateNotificationCount(5); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); this.documentTitle.setFocus(false); this.documentTitle.updateNotificationCount(6); - assert.equal(document.title, "(6) test notifications"); + assert.strictEqual(document.title, "(6) test notifications"); this.documentTitle.setFocus(true); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); }); test("it doesn't display notification counts for users in do not disturb", function (assert) { @@ -60,25 +60,25 @@ discourseModule("Unit | Service | document-title", function (hooks) { this.documentTitle.currentUser.dynamic_favicon = false; this.documentTitle.setTitle("test notifications"); this.documentTitle.updateNotificationCount(5); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); this.documentTitle.setFocus(false); this.documentTitle.updateNotificationCount(6); - assert.equal(document.title, "test notifications"); + assert.strictEqual(document.title, "test notifications"); }); test("it doesn't increment background context counts when focused", function (assert) { this.documentTitle.setTitle("background context"); this.documentTitle.setFocus(true); this.documentTitle.incrementBackgroundContextCount(); - assert.equal(document.title, "background context"); + assert.strictEqual(document.title, "background context"); }); test("it increments background context counts when not focused", function (assert) { this.documentTitle.setTitle("background context"); this.documentTitle.setFocus(false); this.documentTitle.incrementBackgroundContextCount(); - assert.equal(document.title, "(1) background context"); + assert.strictEqual(document.title, "(1) background context"); this.documentTitle.setFocus(true); - assert.equal(document.title, "background context"); + assert.strictEqual(document.title, "background context"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/services/presence-test.js b/app/assets/javascripts/discourse/tests/unit/services/presence-test.js index ee8cede382..d5b0d97f7d 100644 --- a/app/assets/javascripts/discourse/tests/unit/services/presence-test.js +++ b/app/assets/javascripts/discourse/tests/unit/services/presence-test.js @@ -58,14 +58,14 @@ acceptance("Presence - Subscribing", function (needs) { test("subscribing and receiving updates", async function (assert) { let presenceService = this.container.lookup("service:presence"); let channel = presenceService.getChannel("/test/ch1"); - assert.equal(channel.name, "/test/ch1"); + assert.strictEqual(channel.name, "/test/ch1"); await channel.subscribe({ users: usersFixture(), last_message_id: 1, }); - assert.equal(channel.users.length, 3, "it starts with three users"); + assert.strictEqual(channel.users.length, 3, "it starts with three users"); publishToMessageBus( "/presence/test/ch1", @@ -76,7 +76,7 @@ acceptance("Presence - Subscribing", function (needs) { 2 ); - assert.equal(channel.users.length, 2, "one user is removed"); + assert.strictEqual(channel.users.length, 2, "one user is removed"); publishToMessageBus( "/presence/test/ch1", @@ -87,7 +87,7 @@ acceptance("Presence - Subscribing", function (needs) { 3 ); - assert.equal(channel.users.length, 3, "one user is added"); + assert.strictEqual(channel.users.length, 3, "one user is added"); }); test("fetches data when no initial state", async function (assert) { @@ -96,7 +96,7 @@ acceptance("Presence - Subscribing", function (needs) { await channel.subscribe(); - assert.equal(channel.users.length, 3, "loads initial state"); + assert.strictEqual(channel.users.length, 3, "loads initial state"); publishToMessageBus( "/presence/test/ch1", @@ -107,7 +107,7 @@ acceptance("Presence - Subscribing", function (needs) { 2 ); - assert.equal( + assert.strictEqual( channel.users.length, 2, "updates following messagebus message" @@ -124,7 +124,7 @@ acceptance("Presence - Subscribing", function (needs) { await channel._presenceState._resubscribePromise; - assert.equal( + assert.strictEqual( channel.users.length, 3, "detects missed messagebus message, fetches data from server" @@ -148,9 +148,9 @@ acceptance("Presence - Subscribing", function (needs) { await channel.subscribe(); - assert.equal(channel.count, 3, "has the correct count"); - assert.equal(channel.countOnly, true, "identifies as countOnly"); - assert.equal(channel.users, null, "has null users list"); + assert.strictEqual(channel.count, 3, "has the correct count"); + assert.strictEqual(channel.countOnly, true, "identifies as countOnly"); + assert.strictEqual(channel.users, null, "has null users list"); publishToMessageBus( "/presence/countonly/ch1", @@ -161,7 +161,7 @@ acceptance("Presence - Subscribing", function (needs) { 2 ); - assert.equal(channel.count, 4, "updates the count via messagebus"); + assert.strictEqual(channel.count, 4, "updates the count via messagebus"); publishToMessageBus( "/presence/countonly/ch1", @@ -174,7 +174,7 @@ acceptance("Presence - Subscribing", function (needs) { await channel._presenceState._resubscribePromise; - assert.equal( + assert.strictEqual( channel.count, 3, "resubscribes when receiving a non-count-only message" @@ -187,37 +187,45 @@ acceptance("Presence - Subscribing", function (needs) { let channelDup = presenceService.getChannel("/test/ch1"); await channel.subscribe(); - assert.equal(channel.subscribed, true, "channel is subscribed"); - assert.equal(channel.count, 3, "channel has the correct count"); - assert.equal(channel.users.length, 3, "channel has users"); + assert.strictEqual(channel.subscribed, true, "channel is subscribed"); + assert.strictEqual(channel.count, 3, "channel has the correct count"); + assert.strictEqual(channel.users.length, 3, "channel has users"); - assert.equal(channelDup.subscribed, false, "channelDup is not subscribed"); - assert.equal(channelDup.count, null, "channelDup has no count"); - assert.equal(channelDup.users, null, "channelDup has users"); + assert.strictEqual( + channelDup.subscribed, + false, + "channelDup is not subscribed" + ); + assert.strictEqual(channelDup.count, undefined, "channelDup has no count"); + assert.strictEqual(channelDup.users, undefined, "channelDup has users"); await channelDup.subscribe(); - assert.equal(channelDup.subscribed, true, "channelDup can subscribe"); + assert.strictEqual(channelDup.subscribed, true, "channelDup can subscribe"); assert.ok( channelDup._presenceState, "channelDup has a valid internal state" ); - assert.equal( + assert.strictEqual( channelDup._presenceState, channel._presenceState, "internal state is shared" ); await channel.unsubscribe(); - assert.equal(channel.subscribed, false, "channel can unsubscribe"); - assert.equal( + assert.strictEqual(channel.subscribed, false, "channel can unsubscribe"); + assert.strictEqual( channelDup._presenceState, channel._presenceState, "state is maintained" ); await channelDup.unsubscribe(); - assert.equal(channel.subscribed, false, "channelDup can unsubscribe"); - assert.equal(channelDup._presenceState, undefined, "state is cleared"); + assert.strictEqual(channel.subscribed, false, "channelDup can unsubscribe"); + assert.strictEqual( + channelDup._presenceState, + undefined, + "state is cleared" + ); }); }); @@ -250,7 +258,7 @@ acceptance("Presence - Entering and Leaving", function (needs) { const channel = presenceService.getChannel("/test/ch1"); await channel.enter(); - assert.equal(requests.length, 1, "updated the server for enter"); + assert.strictEqual(requests.length, 1, "updated the server for enter"); let presentChannels = requests.pop().getAll("present_channels[]"); assert.deepEqual( presentChannels, @@ -259,7 +267,7 @@ acceptance("Presence - Entering and Leaving", function (needs) { ); await channel.leave(); - assert.equal(requests.length, 1, "updated the server for leave"); + assert.strictEqual(requests.length, 1, "updated the server for leave"); const request = requests.pop(); presentChannels = request.getAll("present_channels[]"); const leaveChannels = request.getAll("leave_channels[]"); @@ -287,32 +295,32 @@ acceptance("Presence - Entering and Leaving", function (needs) { const channelDup = presenceService.getChannel("/test/ch1"); await channel.enter(); - assert.equal(channel.present, true, "channel is present"); - assert.equal(channelDup.present, false, "channelDup is absent"); + assert.strictEqual(channel.present, true, "channel is present"); + assert.strictEqual(channelDup.present, false, "channelDup is absent"); assert.ok( presenceService._presentChannels.has("/test/ch1"), "service shows present" ); await channelDup.enter(); - assert.equal(channel.present, true, "channel is present"); - assert.equal(channelDup.present, true, "channelDup is present"); + assert.strictEqual(channel.present, true, "channel is present"); + assert.strictEqual(channelDup.present, true, "channelDup is present"); assert.ok( presenceService._presentChannels.has("/test/ch1"), "service shows present" ); await channel.leave(); - assert.equal(channel.present, false, "channel is absent"); - assert.equal(channelDup.present, true, "channelDup is present"); + assert.strictEqual(channel.present, false, "channel is absent"); + assert.strictEqual(channelDup.present, true, "channelDup is present"); assert.ok( presenceService._presentChannels.has("/test/ch1"), "service shows present" ); await channelDup.leave(); - assert.equal(channel.present, false, "channel is absent"); - assert.equal(channel.present, false, "channelDup is absent"); + assert.strictEqual(channel.present, false, "channel is absent"); + assert.strictEqual(channel.present, false, "channelDup is absent"); assert.notOk( presenceService._presentChannels.has("/test/ch1"), "service shows absent" diff --git a/app/assets/javascripts/discourse/tests/unit/services/store-test.js b/app/assets/javascripts/discourse/tests/unit/services/store-test.js index 7e2e818403..639eac6134 100644 --- a/app/assets/javascripts/discourse/tests/unit/services/store-test.js +++ b/app/assets/javascripts/discourse/tests/unit/services/store-test.js @@ -7,8 +7,8 @@ module("Unit | Service | store", function () { const widget = store.createRecord("widget", { id: 111, name: "hello" }); assert.ok(!widget.get("isNew"), "it is not a new record"); - assert.equal(widget.get("name"), "hello"); - assert.equal(widget.get("id"), 111); + assert.strictEqual(widget.get("name"), "hello"); + assert.strictEqual(widget.get("id"), 111); }); test("createRecord without an `id`", function (assert) { @@ -26,9 +26,9 @@ module("Unit | Service | store", function () { const obj = { id: 1, name: "something" }; const other = store.createRecord("widget", obj); - assert.equal(widget, other, "returns the same record"); - assert.equal(widget.name, "something", "it updates the properties"); - assert.equal(obj.id, 1, "it does not remove the id from the input"); + assert.strictEqual(widget, other, "returns the same record"); + assert.strictEqual(widget.name, "something", "it updates the properties"); + assert.strictEqual(obj.id, 1, "it does not remove the id from the input"); }); test("createRecord without attributes", function (assert) { @@ -44,17 +44,17 @@ module("Unit | Service | store", function () { const widget = store.createRecord("widget", { id: 33 }); const secondWidget = store.createRecord("widget", { id: 33 }); - assert.equal(widget, secondWidget, "they should be the same"); + assert.strictEqual(widget, secondWidget, "they should be the same"); }); test("find", async function (assert) { const store = createStore(); const widget = await store.find("widget", 123); - assert.equal(widget.get("name"), "Trout Lure"); - assert.equal(widget.get("id"), 123); + assert.strictEqual(widget.get("name"), "Trout Lure"); + assert.strictEqual(widget.get("id"), 123); assert.ok(!widget.get("isNew"), "found records are not new"); - assert.equal( + assert.strictEqual( widget.get("extras.hello"), "world", "extra attributes are set" @@ -62,8 +62,8 @@ module("Unit | Service | store", function () { // A second find by id returns the same object const widget2 = await store.find("widget", 123); - assert.equal(widget, widget2); - assert.equal( + assert.strictEqual(widget, widget2); + assert.strictEqual( widget.get("extras.hello"), "world", "extra attributes are set" @@ -73,13 +73,13 @@ module("Unit | Service | store", function () { test("find with object id", async function (assert) { const store = createStore(); const widget = await store.find("widget", { id: 123 }); - assert.equal(widget.get("firstObject.name"), "Trout Lure"); + assert.strictEqual(widget.get("firstObject.name"), "Trout Lure"); }); test("find with query param", async function (assert) { const store = createStore(); const widget = await store.find("widget", { name: "Trout Lure" }); - assert.equal(widget.get("firstObject.id"), 123); + assert.strictEqual(widget.get("firstObject.id"), 123); }); test("findStale with no stale results", async function (assert) { @@ -89,7 +89,7 @@ module("Unit | Service | store", function () { assert.ok(!stale.hasResults, "there are no stale results"); assert.ok(!stale.results, "results are present"); const widget = await stale.refresh(); - assert.equal( + assert.strictEqual( widget.get("firstObject.id"), 123, "a `refresh()` method provides results for stale" @@ -106,17 +106,17 @@ module("Unit | Service | store", function () { const store = createStore(); const result = await store.update("cool-thing", 123, { name: "hello" }); assert.ok(result); - assert.equal(result.payload.name, "hello"); + assert.strictEqual(result.payload.name, "hello"); }); test("findAll", async function (assert) { const store = createStore(); const result = await store.findAll("widget"); - assert.equal(result.get("length"), 2); + assert.strictEqual(result.get("length"), 2); const widget = result.findBy("id", 124); assert.ok(!widget.get("isNew"), "found records are not new"); - assert.equal(widget.get("name"), "Evil Repellant"); + assert.strictEqual(widget.get("name"), "Evil Repellant"); }); test("destroyRecord", async function (assert) { @@ -139,9 +139,9 @@ module("Unit | Service | store", function () { assert.ok(fruit.get("farmer"), "it has the embedded object"); const fruitCols = fruit.get("colors"); - assert.equal(fruitCols.length, 2); - assert.equal(fruitCols[0].get("id"), 1); - assert.equal(fruitCols[1].get("id"), 2); + assert.strictEqual(fruitCols.length, 2); + assert.strictEqual(fruitCols[0].get("id"), 1); + assert.strictEqual(fruitCols[1].get("id"), 2); }); test("embedded records can be cleared", async function (assert) { @@ -156,7 +156,7 @@ module("Unit | Service | store", function () { test("meta types", async function (assert) { const store = createStore(); const barn = await store.find("barn", 1); - assert.equal( + assert.strictEqual( barn.get("owner.name"), "Old MacDonald", "it has the embedded farmer" @@ -166,29 +166,29 @@ module("Unit | Service | store", function () { test("findAll embedded", async function (assert) { const store = createStore(); const fruits = await store.findAll("fruit"); - assert.equal(fruits.objectAt(0).get("farmer.name"), "Old MacDonald"); - assert.equal( + assert.strictEqual(fruits.objectAt(0).get("farmer.name"), "Old MacDonald"); + assert.strictEqual( fruits.objectAt(0).get("farmer"), fruits.objectAt(1).get("farmer"), "points at the same object" ); - assert.equal( + assert.strictEqual( fruits.get("extras.hello"), "world", "it can supply extra information" ); const fruitCols = fruits.objectAt(0).get("colors"); - assert.equal(fruitCols.length, 2); - assert.equal(fruitCols[0].get("id"), 1); - assert.equal(fruitCols[1].get("id"), 2); + assert.strictEqual(fruitCols.length, 2); + assert.strictEqual(fruitCols[0].get("id"), 1); + assert.strictEqual(fruitCols[1].get("id"), 2); - assert.equal(fruits.objectAt(2).get("farmer.name"), "Luke Skywalker"); + assert.strictEqual(fruits.objectAt(2).get("farmer.name"), "Luke Skywalker"); }); test("custom primaryKey", async function (assert) { const store = createStore(); const cats = await store.findAll("cat"); - assert.equal(cats.objectAt(0).name, "souna"); + assert.strictEqual(cats.objectAt(0).name, "souna"); }); }); diff --git a/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js b/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js index 649a68893a..c0b7c960ab 100644 --- a/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js +++ b/app/assets/javascripts/discourse/tests/unit/utils/decorators-test.js @@ -42,12 +42,12 @@ discourseModule("utils:decorators", function (hooks) { async test(assert) { assert.ok(exists(document.querySelector(".foo-component"))); - assert.equal(this.baz, 1); + assert.strictEqual(this.baz, 1); await this.clearRender(); assert.ok(!exists(document.querySelector(".foo-component"))); - assert.equal(this.baz, 1); + assert.strictEqual(this.baz, 1); }, }); }); diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index 24547305e3..142e1bc669 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -6,7 +6,7 @@ I18n.defaultLocale = "en"; // Set default pluralization rule I18n.pluralizationRules = { - en: function(n) { + en(n) { return n === 0 ? ["zero", "none", "other"] : n === 1 ? "one" : "other"; } }; diff --git a/app/assets/javascripts/pretty-text/addon/allow-lister.js b/app/assets/javascripts/pretty-text/addon/allow-lister.js index 042004452e..a6378ee61c 100644 --- a/app/assets/javascripts/pretty-text/addon/allow-lister.js +++ b/app/assets/javascripts/pretty-text/addon/allow-lister.js @@ -139,6 +139,7 @@ export const DEFAULT_LIST = [ `a.inline-onebox`, `a.inline-onebox-loading`, "a[data-bbcode]", + "a[data-word]", "a[name]", "a[rel=nofollow]", "a[rel=ugc]", diff --git a/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js b/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js index 010b6a9f06..b427ce6141 100644 --- a/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js +++ b/app/assets/javascripts/pretty-text/addon/inline-oneboxer.js @@ -24,12 +24,12 @@ export function applyInlineOneboxes(inline, ajax, opts) { result["inline-oneboxes"].forEach((onebox) => { if (onebox.title) { _cache[onebox.url] = onebox; + let links = inline[onebox.url] || []; links.forEach((link) => { - $(link) - .text(onebox.title) - .addClass("inline-onebox") - .removeClass("inline-onebox-loading"); + link.innerText = onebox.title; + link.classList.add("inline-onebox"); + link.classList.remove("inline-onebox-loading"); }); } }); diff --git a/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js b/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js index c9a98165ce..6b7daa5dee 100644 --- a/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js +++ b/app/assets/javascripts/pretty-text/addon/oneboxer-cache.js @@ -24,6 +24,5 @@ export function normalize(url) { } export function lookupCache(url) { - const cached = localCache[normalize(url)]; - return cached && cached.prop("outerHTML"); + return localCache[normalize(url)]?.outerHTML; } diff --git a/app/assets/javascripts/pretty-text/addon/oneboxer.js b/app/assets/javascripts/pretty-text/addon/oneboxer.js index ada1bd4d4f..f5d0679765 100644 --- a/app/assets/javascripts/pretty-text/addon/oneboxer.js +++ b/app/assets/javascripts/pretty-text/addon/oneboxer.js @@ -1,6 +1,6 @@ import { failedCache, - localCache, + lookupCache, normalize, resetFailedCache, resetLocalCache, @@ -21,37 +21,38 @@ export function resetCache() { } function resolveSize(img) { - $(img).addClass("size-resolved"); + img.classList.add("size-resolved"); if (img.width > 0 && img.width === img.height) { - $(img).addClass("onebox-avatar"); + img.classList.add("onebox-avatar"); } } // Detect square images and apply smaller onebox-avatar class -function applySquareGenericOnebox($elem) { - if (!$elem.hasClass("allowlistedgeneric")) { +function applySquareGenericOnebox(elem) { + if (!elem.classList.contains("allowlistedgeneric")) { return; } - let $img = $elem.find(".onebox-body img.thumbnail"); - let img = $img[0]; + let img = elem.querySelector(".onebox-body img.thumbnail"); // already resolved... skip - if ($img.length !== 1 || $img.hasClass("size-resolved")) { + if (!img || img.classList.contains("size-resolved")) { return; } if (img.complete) { resolveSize(img); } else { - $img.on("load.onebox", () => { - resolveSize(img); - $img.off("load.onebox"); - }); + img.addEventListener("load", _handleLoadingOneboxImages); } } +function _handleLoadingOneboxImages() { + resolveSize(this); + this.removeEventListener("load", _handleLoadingOneboxImages); +} + function loadNext(ajax) { if (loadingQueue.length === 0) { timeout = null; @@ -60,7 +61,7 @@ function loadNext(ajax) { let timeoutMs = 150; let removeLoading = true; - const { url, refresh, $elem, categoryId, topicId } = loadingQueue.shift(); + const { url, refresh, elem, categoryId, topicId } = loadingQueue.shift(); // Retrieve the onebox return ajax("/onebox", { @@ -74,16 +75,19 @@ function loadNext(ajax) { }) .then( (html) => { - let $html = $(html); - setLocalCache(normalize(url), $html); - $elem.replaceWith($html); - applySquareGenericOnebox($html); + let template = document.createElement("template"); + template.innerHTML = html.trim(); + const node = template.content.firstChild; + + setLocalCache(normalize(url), node); + elem.replaceWith(node); + applySquareGenericOnebox(node); }, (result) => { - if (result && result.jqXHR && result.jqXHR.status === 429) { + if (result?.jqXHR?.status === 429) { timeoutMs = 2000; removeLoading = false; - loadingQueue.unshift({ url, refresh, $elem, categoryId, topicId }); + loadingQueue.unshift({ url, refresh, elem, categoryId, topicId }); } else { setFailedCache(normalize(url), true); } @@ -92,13 +96,13 @@ function loadNext(ajax) { .finally(() => { timeout = later(() => loadNext(ajax), timeoutMs); if (removeLoading) { - $elem.removeClass(LOADING_ONEBOX_CSS_CLASS); - $elem.data("onebox-loaded"); + elem.classList.remove(LOADING_ONEBOX_CSS_CLASS); + elem.dataset.oneboxLoaded = ""; } }); } -// Perform a lookup of a onebox based an anchor $element. +// Perform a lookup of a onebox based an anchor element. // It will insert a loading indicator and remove it when the loading is complete or fails. export function load({ elem, @@ -109,13 +113,13 @@ export function load({ offline = false, synchronous = false, }) { - const $elem = $(elem); - // If the onebox has loaded or is loading, return - if ($elem.data("onebox-loaded")) { + + if (elem.dataset.oneboxLoaded) { return; } - if ($elem.hasClass(LOADING_ONEBOX_CSS_CLASS)) { + + if (elem.classList.contains(LOADING_ONEBOX_CSS_CLASS)) { return; } @@ -124,9 +128,9 @@ export function load({ // Unless we're forcing a refresh... if (!refresh) { // If we have it in our cache, return it. - const cached = localCache[normalize(url)]; + const cached = lookupCache(url); if (cached) { - return cached.prop("outerHTML"); + return cached; } // If the request failed, don't do anything @@ -141,10 +145,10 @@ export function load({ } // Add the loading CSS class - $elem.addClass(LOADING_ONEBOX_CSS_CLASS); + elem.classList.add(LOADING_ONEBOX_CSS_CLASS); // Add to the loading queue - loadingQueue.push({ url, refresh, $elem, categoryId, topicId }); + loadingQueue.push({ url, refresh, elem, categoryId, topicId }); // Load next url in queue if (synchronous) { diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js index 4ac19201e6..78027d6f2f 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/anchor.js @@ -39,7 +39,11 @@ export function setup(helper) { .replace(/^-+/, "") .replace(/-+$/, ""); - slug = `${slug || "heading"}-${++headingId}`; + if (slug.match(/^[^a-z]/)) { + slug = `h-${slug}`; + } + + slug = `${slug || "h"}-${++headingId}`; linkOpen.attrSet("name", slug); linkOpen.attrSet("class", "anchor"); diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js index c9d2c714a4..c196b935dd 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js @@ -351,7 +351,7 @@ export function setup(helper) { ruler.push("code", { tag: "code", - replace: function (state, tagInfo, content) { + replace(state, tagInfo, content) { let token; token = state.push("fence", "code", 0); token.content = content; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js index 5ca653daaf..26dd393b1f 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js @@ -168,7 +168,7 @@ export function setup(helper) { ruler.push("code", { tag: "code", - replace: function (state, tagInfo, content) { + replace(state, tagInfo, content) { let token; token = state.push("code_inline", "code", 0); token.content = content; @@ -179,7 +179,7 @@ export function setup(helper) { const simpleUrlRegex = /^http[s]?:\/\//; ruler.push("url", { tag: "url", - wrap: function (startToken, endToken, tagInfo, content) { + wrap(startToken, endToken, tagInfo, content) { const url = (tagInfo.attrs["_default"] || content).trim(); if (simpleUrlRegex.test(url)) { @@ -212,7 +212,7 @@ export function setup(helper) { ruler.push("email", { tag: "email", - replace: function (state, tagInfo, content) { + replace(state, tagInfo, content) { let token; let email = tagInfo.attrs["_default"] || content; @@ -232,7 +232,7 @@ export function setup(helper) { ruler.push("image", { tag: "img", - replace: function (state, tagInfo, content) { + replace(state, tagInfo, content) { let token = state.push("image", "img", 0); token.attrs = [ ["src", content], diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js similarity index 54% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js rename to app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js index 8b34244611..6c49d5df00 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/image-controls.js @@ -1,3 +1,5 @@ +import I18n from "I18n"; + const SCALES = ["100", "75", "50"]; function isUpload(token) { @@ -65,51 +67,81 @@ function buildScaleButton(selectedScale, scale) { ); } +function buildImageAltTextButton(altText) { + return ` + + ${altText} + + + +`; +} + // We need this to load after `upload-protocol` which is priority 0 export const priority = 1; +function ruleWithImageControls(oldRule) { + return function (tokens, idx, options, env, slf) { + const token = tokens[idx]; + const scaleIndex = token.attrIndex("scale"); + const imageIndex = token.attrIndex("index-image"); + + if (scaleIndex !== -1) { + let selectedScale = token.attrs[scaleIndex][1]; + let index = token.attrs[imageIndex][1]; + + let result = ``; + + result += oldRule(tokens, idx, options, env, slf); + + result += ``; + + result += ``; + result += SCALES.map((scale) => + buildScaleButton(selectedScale, scale) + ).join(""); + result += ``; + + result += buildImageAltTextButton(token.attrs[token.attrIndex("alt")][1]); + + result += ""; + + return result; + } else { + return oldRule(tokens, idx, options, env, slf); + } + }; +} + export function setup(helper) { const opts = helper.getOptions(); if (opts.previewing) { helper.allowList([ "span.image-wrapper", "span.button-wrapper", + "span[class=scale-btn-container]", "span[class=scale-btn]", "span[class=scale-btn active]", "span.separator", "span.scale-btn[data-scale]", "span.button-wrapper[data-image-index]", + "span[aria-label]", + "span.alt-text-readonly-container", + "span.alt-text-readonly-container.alt-text", + "span.alt-text-readonly-container.alt-text-edit-btn", + "svg[class=fa d-icon d-icon-pencil svg-icon svg-string]", + "use[href=#pencil-alt]", + "input[type=text]", + "input[hidden=true]", + "input[class=alt-text-input]", ]); helper.registerPlugin((md) => { const oldRule = md.renderer.rules.image; - md.renderer.rules.image = function (tokens, idx, options, env, slf) { - const token = tokens[idx]; - const scaleIndex = token.attrIndex("scale"); - const imageIndex = token.attrIndex("index-image"); - - if (scaleIndex !== -1) { - let selectedScale = token.attrs[scaleIndex][1]; - let index = token.attrs[imageIndex][1]; - - let result = ""; - result += oldRule(tokens, idx, options, env, slf); - - result += - ""; - - result += SCALES.map((scale) => - buildScaleButton(selectedScale, scale) - ).join(""); - - result += ""; - - return result; - } else { - return oldRule(tokens, idx, options, env, slf); - } - }; + md.renderer.rules.image = ruleWithImageControls(oldRule); md.core.ruler.after("upload-protocol", "resize-controls", rule); }); diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js index a58bf5298f..f43fceec0d 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js @@ -151,7 +151,7 @@ const rule = { state.push("bbcode_open", "blockquote", 1); }, - after: function (state) { + after(state) { state.push("bbcode_close", "blockquote", -1); state.push("bbcode_close", "aside", -1); }, diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js index 5533b5fded..3a2ed4d7e1 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js @@ -11,7 +11,7 @@ export function setup(helper) { // we need a custom callback for style handling helper.allowList({ - custom: function (tag, attr, val) { + custom(tag, attr, val) { if (tag !== "th" && tag !== "td") { return false; } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js index ec7f26fa14..7c09bf1c35 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/watched-words.js @@ -31,7 +31,17 @@ function findAllMatches(text, matchers) { return matches.sort((a, b) => a.index - b.index); } +// We need this to load after mentions and hashtags which are priority 0 +export const priority = 1; + +const NONE = 0; +const MENTION = 1; +const HASHTAG_LINK = 2; +const HASHTAG_SPAN = 3; + export function setup(helper) { + const opts = helper.getOptions(); + helper.registerPlugin((md) => { const matchers = []; @@ -63,7 +73,7 @@ export function setup(helper) { return; } - const cache = {}; + const cache = new Map(); md.core.ruler.push("watched-words", (state) => { for (let j = 0, l = state.tokens.length; j < l; j++) { @@ -75,6 +85,39 @@ export function setup(helper) { let htmlLinkLevel = 0; + // We scan once to mark tokens that must be skipped because they are + // mentions or hashtags + let lastType = NONE; + for (let i = 0; i < tokens.length; ++i) { + const currentToken = tokens[i]; + + if (currentToken.type === "mention_open") { + lastType = MENTION; + } else if ( + (currentToken.type === "link_open" || + currentToken.type === "span_open") && + currentToken.attrs && + currentToken.attrs.some( + (attr) => attr[0] === "class" && attr[1] === "hashtag" + ) + ) { + lastType = + currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN; + } + + if (lastType !== NONE) { + currentToken.skipReplace = true; + } + + if ( + (lastType === MENTION && currentToken.type === "mention_close") || + (lastType === HASHTAG_LINK && currentToken.type === "link_close") || + (lastType === HASHTAG_SPAN && currentToken.type === "span_close") + ) { + lastType = NONE; + } + } + // We scan from the end, to keep position when new tags added. // Use reversed logic in links start/end match for (let i = tokens.length - 1; i >= 0; i--) { @@ -103,10 +146,21 @@ export function setup(helper) { } } + // Skip content of mentions or hashtags + if (currentToken.skipReplace) { + continue; + } + if (currentToken.type === "text") { const text = currentToken.content; - const matches = (cache[text] = - cache[text] || findAllMatches(text, matchers)); + + let matches; + if (cache.has(text)) { + matches = cache.get(text); + } else { + matches = findAllMatches(text, matchers); + cache.set(text, matches); + } // Now split string to nodes const nodes = []; @@ -119,14 +173,6 @@ export function setup(helper) { continue; } - if ( - matches[ln].index > 0 && - (text[matches[ln].index - 1] === "@" || - text[matches[ln].index - 1] === "#") - ) { - continue; - } - if (matches[ln].index > lastPos) { token = new state.Token("text", "", 0); token.content = text.slice(lastPos, matches[ln].index); @@ -139,6 +185,9 @@ export function setup(helper) { if (htmlLinkLevel === 0 && state.md.validateLink(url)) { token = new state.Token("link_open", "a", 1); token.attrs = [["href", url]]; + if (opts.discourse.previewing) { + token.attrs.push(["data-word", ""]); + } token.level = level++; token.markup = "linkify"; token.info = "auto"; diff --git a/app/assets/javascripts/select-kit/addon/components/category-chooser.js b/app/assets/javascripts/select-kit/addon/components/category-chooser.js index a81cf0cd9c..2bca205043 100644 --- a/app/assets/javascripts/select-kit/addon/components/category-chooser.js +++ b/app/assets/javascripts/select-kit/addon/components/category-chooser.js @@ -20,6 +20,7 @@ export default ComboBoxComponent.extend({ permissionType: PermissionType.FULL, excludeCategoryId: null, scopedCategoryId: null, + prioritizedCategoryId: null, }, modifyComponentForRow() { diff --git a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box.js b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box.js index 594ba938a1..1d571787ef 100644 --- a/app/assets/javascripts/select-kit/addon/components/dropdown-select-box.js +++ b/app/assets/javascripts/select-kit/addon/components/dropdown-select-box.js @@ -12,6 +12,7 @@ export default SingleSelectComponent.extend({ caretUpIcon: "caret-up", caretDownIcon: "caret-down", showCaret: false, + customStyle: null, }, modifyComponentForRow() { diff --git a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js index 59bc2a959b..0025efd206 100644 --- a/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js +++ b/app/assets/javascripts/select-kit/addon/components/mini-tag-chooser.js @@ -23,10 +23,8 @@ export default MultiSelectComponent.extend(TagsMixin, { termMatchesForbidden: false, categoryId: null, everyTag: false, - none: "tagging.choose_for_topic", closeOnChange: false, - maximum: "maximumSelectedTags", - minimum: "minimumSelectedTags", + maximum: "maxTagsPerTopic", autoInsertNoneItem: false, }, @@ -38,31 +36,20 @@ export default MultiSelectComponent.extend(TagsMixin, { return "tag-row"; }, - allowAnyTag: or("allowCreate", "site.can_create_tag"), - - maximumSelectedTags: computed(function () { - return parseInt( - this.options.limit || - this.selectKit.options.maximum || - this.maxTagsPerTopic, - 10 - ); - }), - - minimumSelectedTags: computed(function () { - if ( - this.selectKit.options.minimum || - this.selectKit.options.requiredTagGroups - ) { - const minimum = parseInt(this.selectKit.options.minimum, 10); - if (minimum > 0) { - return this.defaultItem( - null, - I18n.t("select_kit.min_content_not_reached", { count: minimum }) - ); - } + modifyNoSelection() { + if (this.selectKit.options.minimum > 0) { + return this.defaultItem( + null, + I18n.t("tagging.choose_for_topic_required", { + count: this.selectKit.options.minimum, + }) + ); + } else { + return this.defaultItem(null, I18n.t("tagging.choose_for_topic")); } - }), + }, + + allowAnyTag: or("allowCreate", "site.can_create_tag"), caretIcon: computed("value.[]", "content.[]", function () { const maximum = this.selectKit.options.maximum; diff --git a/app/assets/javascripts/select-kit/addon/components/multi-select.js b/app/assets/javascripts/select-kit/addon/components/multi-select.js index ec0e6986cd..908f75eeb3 100644 --- a/app/assets/javascripts/select-kit/addon/components/multi-select.js +++ b/app/assets/javascripts/select-kit/addon/components/multi-select.js @@ -124,35 +124,40 @@ export default SelectKitComponent.extend({ } }, - selectedContent: computed("value.[]", "content.[]", function () { - const value = makeArray(this.value).map((v) => - this.selectKit.options.castInteger && this._isNumeric(v) ? Number(v) : v - ); + selectedContent: computed( + "value.[]", + "content.[]", + "selectKit.noneItem", + function () { + const value = makeArray(this.value).map((v) => + this.selectKit.options.castInteger && this._isNumeric(v) ? Number(v) : v + ); - if (value.length) { - let content = []; + if (value.length) { + let content = []; - value.forEach((v) => { - if (this.selectKit.valueProperty) { - const c = makeArray(this.content).findBy( - this.selectKit.valueProperty, - v - ); - if (c) { - content.push(c); + value.forEach((v) => { + if (this.selectKit.valueProperty) { + const c = makeArray(this.content).findBy( + this.selectKit.valueProperty, + v + ); + if (c) { + content.push(c); + } + } else { + if (makeArray(this.content).includes(v)) { + content.push(v); + } } - } else { - if (makeArray(this.content).includes(v)) { - content.push(v); - } - } - }); + }); - return this.selectKit.modifySelection(content); + return this.selectKit.modifySelection(content); + } + + return this.selectKit.noneItem; } - - return null; - }), + ), _onKeydown(event) { if ( 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 0567b3394b..2bc430fd29 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit.js @@ -209,8 +209,12 @@ export default Component.extend( didReceiveAttrs() { this._super(...arguments); - const computedOptions = {}; Object.keys(this.selectKitOptions).forEach((key) => { + if (isPresent(this.options[key])) { + this.selectKit.options.set(key, this.options[key]); + return; + } + const value = this.selectKitOptions[key]; if ( @@ -219,9 +223,9 @@ export default Component.extend( key === "componentForCollection" ) { if (typeof value === "string") { - computedOptions[key] = () => value; + this.selectKit.options.set(key, () => value); } else { - computedOptions[key] = bind(this, value); + this.selectKit.options.set(key, bind(this, value)); } return; @@ -234,15 +238,13 @@ export default Component.extend( ) { const computedValue = get(this, value); if (typeof computedValue !== "function") { - computedOptions[key] = get(this, value); + this.selectKit.options.set(key, computedValue); return; } } - computedOptions[key] = value; + + this.selectKit.options.set(key, value); }); - this.selectKit.options.setProperties( - Object.assign(computedOptions, this.options || {}) - ); this.selectKit.setProperties({ hasSelection: !isEmpty(this.value), @@ -264,6 +266,7 @@ export default Component.extend( }, selectKitOptions: { + allowAny: false, showFullTitle: true, none: null, translatedNone: null, @@ -277,7 +280,6 @@ export default Component.extend( maximum: null, maximumLabel: null, minimum: null, - minimumLabel: null, autoInsertNoneItem: true, closeOnChange: true, limitMatches: null, diff --git a/app/assets/javascripts/select-kit/addon/templates/components/multi-select.hbs b/app/assets/javascripts/select-kit/addon/templates/components/multi-select.hbs index 445584c32e..c5ad03fa1e 100644 --- a/app/assets/javascripts/select-kit/addon/templates/components/multi-select.hbs +++ b/app/assets/javascripts/select-kit/addon/templates/components/multi-select.hbs @@ -13,7 +13,7 @@ id=(concat selectKit.uniqueID "-filter") }} - {{#if selectedContent}} + {{#if selectedContent.length}}
{{#each selectedContent as |item|}} {{component selectKit.options.selectedChoiceComponent diff --git a/app/assets/javascripts/start-discourse.js b/app/assets/javascripts/start-discourse.js index 2791d2ce13..41b452a88d 100644 --- a/app/assets/javascripts/start-discourse.js +++ b/app/assets/javascripts/start-discourse.js @@ -1,4 +1,5 @@ // discourse-skip-module (function () { + // eslint-disable-next-line no-undef Discourse.start(); })(); diff --git a/app/assets/javascripts/wizard/components/wizard-step.js b/app/assets/javascripts/wizard/components/wizard-step.js index 1d66e86cca..48e3f75864 100644 --- a/app/assets/javascripts/wizard/components/wizard-step.js +++ b/app/assets/javascripts/wizard/components/wizard-step.js @@ -5,6 +5,7 @@ import getUrl from "discourse-common/lib/get-url"; import { htmlSafe } from "@ember/template"; import { schedule } from "@ember/runloop"; +// eslint-disable-next-line no-undef jQuery.fn.wiggle = function (times, duration) { if (times > 0) { this.animate( diff --git a/app/assets/javascripts/wizard/lib/ajax.js b/app/assets/javascripts/wizard/lib/ajax.js index 670747e807..3889069f71 100644 --- a/app/assets/javascripts/wizard/lib/ajax.js +++ b/app/assets/javascripts/wizard/lib/ajax.js @@ -7,7 +7,7 @@ let token; export function getToken() { if (!token) { - token = $('meta[name="csrf-token"]').attr("content"); + token = document.querySelector('meta[name="csrf-token"]')?.content; } return token; diff --git a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js index 7f935e4fb4..ea550cf270 100644 --- a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js +++ b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js @@ -1,4 +1,4 @@ -import { currentRouteName, visit } from "@ember/test-helpers"; +import { click, currentRouteName, fillIn, visit } from "@ember/test-helpers"; import { module, test } from "qunit"; import { run } from "@ember/runloop"; import startApp from "wizard/test/helpers/start-app"; @@ -21,7 +21,7 @@ function exists(selector) { test("Wizard starts", async function (assert) { await visit("/"); assert.ok(exists(".wizard-column-contents")); - assert.equal(currentRouteName(), "step"); + assert.strictEqual(currentRouteName(), "step"); }); test("Going back and forth in steps", async function (assert) { diff --git a/app/assets/javascripts/wizard/test/components/invite-list-test.js b/app/assets/javascripts/wizard/test/components/invite-list-test.js index c3e988b318..2acfe5071c 100644 --- a/app/assets/javascripts/wizard/test/components/invite-list-test.js +++ b/app/assets/javascripts/wizard/test/components/invite-list-test.js @@ -1,5 +1,6 @@ import { componentTest } from "wizard/test/helpers/component-test"; import { moduleForComponent } from "ember-qunit"; +import { click, fillIn } from "@ember/test-helpers"; moduleForComponent("invite-list", { integration: true }); @@ -21,7 +22,7 @@ componentTest("can add users", { ); const firstVal = JSON.parse(this.get("field.value")); - assert.equal(firstVal.length, 0, "empty JSON at first"); + assert.strictEqual(firstVal.length, 0, "empty JSON at first"); assert.ok( this.get("field.warning"), @@ -45,8 +46,8 @@ componentTest("can add users", { assert.ok(document.querySelectorAll(".new-user .invalid").length === 0); const val = JSON.parse(this.get("field.value")); - assert.equal(val.length, 1); - assert.equal( + assert.strictEqual(val.length, 1); + assert.strictEqual( val[0].email, "eviltrout@example.com", "adds the email to the JSON" diff --git a/app/assets/javascripts/yarn.lock b/app/assets/javascripts/yarn.lock index 00dcf52920..af9328d4ee 100644 --- a/app/assets/javascripts/yarn.lock +++ b/app/assets/javascripts/yarn.lock @@ -1018,6 +1018,45 @@ walk-sync "^1.1.3" wrap-legacy-hbs-plugin-if-needed "^1.0.1" +"@embroider/core@0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@embroider/core/-/core-0.36.0.tgz#fbbd60d29c3fcbe02b4e3e63e6043a43de2b9ce3" + integrity sha512-J6esENP+aNt+/r070cF1RCJyCi/Rn1I6uFp37vxyLWwvGDuT0E7wGcaPU29VBkBFqxi4Z1n4F796BaGHv+kX6w== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.12.3" + "@babel/plugin-syntax-dynamic-import" "^7.8.3" + "@babel/plugin-transform-runtime" "^7.12.1" + "@babel/runtime" "^7.12.5" + "@babel/traverse" "^7.12.1" + "@babel/types" "^7.12.1" + "@embroider/macros" "0.36.0" + assert-never "^1.1.0" + babel-plugin-syntax-dynamic-import "^6.18.0" + broccoli-node-api "^1.7.0" + broccoli-persistent-filter "^3.1.2" + broccoli-plugin "^4.0.1" + broccoli-source "^3.0.0" + debug "^3.1.0" + escape-string-regexp "^4.0.0" + fast-sourcemap-concat "^1.4.0" + filesize "^4.1.2" + fs-extra "^7.0.1" + fs-tree-diff "^2.0.0" + handlebars "^4.4.2" + js-string-escape "^1.0.1" + jsdom "^16.4.0" + json-stable-stringify "^1.0.1" + lodash "^4.17.10" + pkg-up "^3.1.0" + resolve "^1.8.1" + resolve-package-path "^1.2.2" + semver "^7.3.2" + strip-bom "^3.0.0" + typescript-memoize "^1.0.0-alpha.3" + walk-sync "^1.1.3" + wrap-legacy-hbs-plugin-if-needed "^1.0.1" + "@embroider/macros@0.33.0": version "0.33.0" resolved "https://registry.yarnpkg.com/@embroider/macros/-/macros-0.33.0.tgz#d5826ea7565bb69b57ba81ed528315fe77acbf9d" @@ -1033,6 +1072,21 @@ resolve "^1.8.1" semver "^7.3.2" +"@embroider/macros@0.36.0", "@embroider/macros@^0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@embroider/macros/-/macros-0.36.0.tgz#5330f1e6f12112f0f68e34b3e4855dc7dd3c69a5" + integrity sha512-w37G4uXG+Wi3K3EHSFBSr/n6kGFXYG8nzZ9ptzDOC7LP3Oh5/MskBnVZW3+JkHXUPEqKsDGlxPxCVpPl1kQyjQ== + dependencies: + "@babel/core" "^7.12.3" + "@babel/traverse" "^7.12.1" + "@babel/types" "^7.12.1" + "@embroider/core" "0.36.0" + assert-never "^1.1.0" + ember-cli-babel "^7.23.0" + lodash "^4.17.10" + resolve "^1.8.1" + semver "^7.3.2" + "@eslint/eslintrc@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.1.tgz#442763b88cecbe3ee0ec7ca6d6dd6168550cbf14" @@ -3734,9 +3788,9 @@ can-symlink@^1.0.0: tmp "0.0.28" caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001208: - version "1.0.30001208" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz#a999014a35cebd4f98c405930a057a0d75352eb9" - integrity sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA== + version "1.0.30001280" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001280.tgz#066a506046ba4be34cde5f74a08db7a396718fb7" + integrity sha512-kFXwYvHe5rix25uwueBxC569o53J6TpnGu0BEEn+6Lhl2vsnAumRFWEBhDft1fwyo6m1r4i+RqA4+163FpeFcA== capture-exit@^2.0.0: version "2.0.0" @@ -3939,6 +3993,16 @@ cli-table3@^0.5.1: optionalDependencies: colors "^1.1.2" +cli-table3@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.0.tgz#b7b1bc65ca8e7b5cef9124e13dc2b21e2ce4faee" + integrity sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ== + dependencies: + object-assign "^4.1.0" + string-width "^4.2.0" + optionalDependencies: + colors "^1.1.2" + cli-table@^0.3.1: version "0.3.6" resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc" @@ -4414,6 +4478,13 @@ debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" +debug@^4.2.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + debug@~3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" @@ -4786,6 +4857,39 @@ ember-cli-babel@^7.0.0, ember-cli-babel@^7.11.0, ember-cli-babel@^7.11.1, ember- rimraf "^3.0.1" semver "^5.5.0" +ember-cli-babel@^7.21.0: + version "7.26.6" + resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-7.26.6.tgz#322fbbd3baad9dd99e3276ff05bc6faef5e54b39" + integrity sha512-040svtfj2RC35j/WMwdWJFusZaXmNoytLAMyBDGLMSlRvznudTxZjGlPV6UupmtTBApy58cEF8Fq4a+COWoEmQ== + dependencies: + "@babel/core" "^7.12.0" + "@babel/helper-compilation-targets" "^7.12.0" + "@babel/plugin-proposal-class-properties" "^7.13.0" + "@babel/plugin-proposal-decorators" "^7.13.5" + "@babel/plugin-transform-modules-amd" "^7.13.0" + "@babel/plugin-transform-runtime" "^7.13.9" + "@babel/plugin-transform-typescript" "^7.13.0" + "@babel/polyfill" "^7.11.5" + "@babel/preset-env" "^7.12.0" + "@babel/runtime" "7.12.18" + amd-name-resolver "^1.3.1" + babel-plugin-debug-macros "^0.3.4" + babel-plugin-ember-data-packages-polyfill "^0.1.2" + babel-plugin-ember-modules-api-polyfill "^3.5.0" + babel-plugin-module-resolver "^3.2.0" + broccoli-babel-transpiler "^7.8.0" + broccoli-debug "^0.6.4" + broccoli-funnel "^2.0.2" + broccoli-source "^2.1.2" + clone "^2.1.2" + ember-cli-babel-plugin-helpers "^1.1.1" + ember-cli-version-checker "^4.1.0" + ensure-posix-path "^1.0.2" + fixturify-project "^1.10.0" + resolve-package-path "^3.1.0" + rimraf "^3.0.1" + semver "^5.5.0" + ember-cli-dependency-checker@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/ember-cli-dependency-checker/-/ember-cli-dependency-checker-3.2.0.tgz#9202ad9e14d6fda33cffc22a11c343c2a8885330" @@ -5151,6 +5255,26 @@ ember-disable-prototype-extensions@^1.1.3: resolved "https://registry.yarnpkg.com/ember-disable-prototype-extensions/-/ember-disable-prototype-extensions-1.1.3.tgz#1969135217654b5e278f9fe2d9d4e49b5720329e" integrity sha1-GWkTUhdlS14nj5/i2dTkm1cgMp4= +ember-exam@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ember-exam/-/ember-exam-6.1.0.tgz#1ea2c0ece27ac8ad6a80d959b1c207611b7dfdd7" + integrity sha512-H9tg7eUgqkjAsr1/15UzxGyZobGLgsyTi56Ng0ySnkYGCRfvVpwtVc3xgcNOFnUaa9RExUFpxC0adjW3K87Uxw== + dependencies: + "@embroider/macros" "^0.36.0" + chalk "^4.1.0" + cli-table3 "^0.6.0" + debug "^4.2.0" + ember-auto-import "^1.10.1" + ember-cli-babel "^7.21.0" + ember-cli-version-checker "^5.1.2" + execa "^4.0.3" + fs-extra "^9.0.1" + js-yaml "^3.14.0" + npmlog "^4.1.2" + rimraf "^3.0.2" + semver "^7.3.2" + silent-error "^1.1.1" + ember-export-application-global@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/ember-export-application-global/-/ember-export-application-global-2.0.1.tgz#b120a70e322ab208defc9e2daebe8d0dfc2dcd46" @@ -5796,7 +5920,7 @@ execa@^2.0.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -execa@^4.0.0, execa@^4.1.0: +execa@^4.0.0, execa@^4.0.3, execa@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a" integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA== @@ -8850,7 +8974,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -npmlog@^4.0.0: +npmlog@^4.0.0, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== diff --git a/app/assets/stylesheets/common/admin/api.scss b/app/assets/stylesheets/common/admin/api.scss index 91d25c87a5..f664986c04 100644 --- a/app/assets/stylesheets/common/admin/api.scss +++ b/app/assets/stylesheets/common/admin/api.scss @@ -179,9 +179,6 @@ table.api-keys { width: 50%; } .scopes-title { - font-size: $font-up-2; - font-weight: bold; - text-decoration: underline; margin-top: 20px; } } diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index f7a9f7721c..dfeff7a4a7 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -244,9 +244,6 @@ .components-list { color: var(--secondary); } - .fa { - color: inherit; - } } &:not(.selected) { .broken-indicator { @@ -284,9 +281,13 @@ .others-count, .others-count:hover { - color: var(--primary-high); + color: inherit; text-decoration: underline; } + + .d-icon { + color: inherit; + } } .inner-wrapper { @@ -670,12 +671,14 @@ margin-top: 1em; color: var(--primary-medium); } + // Permalinks .permalinks { .url, .topic, .category, .external_url, + .destination, .post { @include ellipsis; max-width: 100px; @@ -689,27 +692,51 @@ } } -.permalink-search { - text-align: left; - @include breakpoint(tablet, min-width) { - text-align: right; - } -} - .permalink-form { + padding: 0.5em 1em 0 1em; + margin-top: 1em; + background: var(--primary-very-low); .select-kit { - max-width: 200px; + max-width: 260px; + } + @include breakpoint(tablet) { + label { + width: 100%; + } } -} - -.permalink-title { - margin-bottom: 10px; } .permalink-description { color: var(--primary-medium); } +.permalink-search { + margin-top: 2em; + input { + min-width: 250px; + margin-bottom: 0; + } +} + +table.permalinks { + @include breakpoint(tablet, min-width) { + td.url, + th.url { + width: 33%; + } + + th.destination, + td.destination { + width: 60%; + } + } + @include breakpoint(tablet) { + td.url .btn-flat { + display: none; + } + } +} + // embedding .embeddable-hosts { margin-bottom: 2em; diff --git a/app/assets/stylesheets/common/base/magnific-popup.scss b/app/assets/stylesheets/common/base/magnific-popup.scss index 29896ded01..e4fc9e5333 100644 --- a/app/assets/stylesheets/common/base/magnific-popup.scss +++ b/app/assets/stylesheets/common/base/magnific-popup.scss @@ -584,7 +584,9 @@ button { /* start state */ .mfp-content { opacity: 0; - transition: all 0.2s; + @media screen and (prefers-reduced-motion: no-preference) { + transition: all 0.2s; + } -webkit-transform: scale(0.8); -ms-transform: scale(0.8); transform: scale(0.8); diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index 51a27ce4eb..8b826b345c 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -12,10 +12,11 @@ } .modal-inner-container { + --modal-max-width: 47em; // set in ems to scale with user font-size box-sizing: border-box; flex: 0 1 auto; margin: 0 auto; - max-width: 700px; + max-width: var(--modal-max-width); background-color: var(--secondary); box-shadow: shadow("modal"); @@ -273,7 +274,7 @@ pre code { white-space: pre-wrap; - max-width: 700px; + max-width: var(--modal-max-width); } } @@ -364,7 +365,7 @@ } .incoming-email-content { height: 300px; - max-width: 700px; + max-width: 100%; width: 90vw; // forcing textarea wider textarea, .incoming-email-html-part { @@ -372,6 +373,7 @@ border: none; border-top: 1px solid var(--primary-low); padding-top: 10px; + width: 100%; } textarea { font-family: monospace; @@ -427,7 +429,8 @@ } .change-timestamp { - max-width: 420px; + width: 28em; // scales with user font-size + max-width: 90vw; // prevents overflow due to extra large user font-size } .change-timestamp { diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index 368c7cf9c7..76a0576af7 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -866,6 +866,14 @@ aside.onebox.xkcd .onebox-body img { justify-content: center; align-items: center; + &.image { + &:before { + opacity: 0.8; + content: svg-uri( + '' + ); + } + } &.video { &:before { opacity: 0.8; diff --git a/app/assets/stylesheets/common/base/share_link.scss b/app/assets/stylesheets/common/base/share_link.scss index 2eb6724316..e074d8e417 100644 --- a/app/assets/stylesheets/common/base/share_link.scss +++ b/app/assets/stylesheets/common/base/share_link.scss @@ -1,18 +1,15 @@ // styles that apply to the "share" modal & popup when sharing a link to a post or topic -.link-share-container { +.link-share-container, +.notify-user-input { display: flex; - button { + .btn { margin-left: 0.5em; - transition-property: background-color, color; // don't transition outline } - input { + input, + .select-kit { width: 100%; - font-size: var(--font-up-1); margin-bottom: 0; - &:focus + button { - outline: 1px solid var(--tertiary); - } } } @@ -62,7 +59,8 @@ box-shadow: shadow("card"); background-color: var(--secondary); padding: 0.5em; - width: 300px; + width: 20em; // scales with user font-size + max-width: 100vw; // prevents overflow due to extra large user font-size display: none; &.visible { display: block; @@ -114,23 +112,6 @@ } } -.notify-user-input { - display: flex; - align-items: stretch; - - .select-kit { - width: 100%; - } - - .multi-select-header { - width: 100%; - } - - .select-kit.multi-select .choices { - padding: 0; - } -} - // topic invite modal .create-invite-modal { diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index cbaee44f12..7c78b7387d 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -95,6 +95,8 @@ $quote-share-maxwidth: 150px; margin: 30px 0 10px; line-height: $line-height-medium; a.anchor { + opacity: 0; + transition: opacity 0.25s; &:before { content: svg-uri( '' @@ -103,23 +105,14 @@ $quote-share-maxwidth: 150px; margin-left: -20px; padding-right: 4px; position: absolute; - opacity: 0; - transition: opacity 0.25s; - } - &:hover { - // show when hovering where icon should be - &:before { - opacity: 1; - } } } - &:hover { - // show when hovering header - a.anchor { - &:before { - opacity: 1; - } - } + + // show when hovering where icon should be + // show when hovering header + .discourse-no-touch & a.anchor:hover, + .discourse-no-touch &:hover a.anchor { + opacity: 1; } } @@ -281,6 +274,22 @@ $quote-share-maxwidth: 150px; } } +@keyframes heartBump { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.5); + } + 100% { + transform: scale(1); + } +} + +.has-like .d-icon.heart-animation { + animation: heartBump 0.4s; +} + // we use aside to hold expandable quotes (versus, say, static blockquotes) aside.quote { margin-top: 1em; @@ -1260,7 +1269,8 @@ a.mention-group { display: flex; flex-wrap: wrap; align-items: stretch; // aligns items by making them the same height - button { + button, + details { margin-right: 0.54em; } > * { @@ -1268,7 +1278,7 @@ a.mention-group { } .topic-admin-menu-button-container { display: inline-flex; - > span { + .topic-admin-menu-button { display: flex; // to make this button match siblings behavior, all of its parents need to be flex } } @@ -1410,3 +1420,56 @@ a.mention-group { opacity: 0; } } + +.open-popup-link { + position: sticky; + left: 0.5rem; + top: 0.5rem; + opacity: 0%; + white-space: nowrap; + display: block; +} + +.fullscreen-table-wrapper { + transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + display: block; +} + +.expand-table-icon { + margin-right: 4px; +} + +.fullscreen-table-modal .modal-inner-container { + width: max-content; + max-width: 90%; + margin: 0 auto; + padding: 10px; + + .modal-body { + padding-top: 0; + } + + thead { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--secondary); + } + + tbody { + overflow-x: hidden; + } + + td { + padding: 0.5rem; + } +} + +html.discourse-no-touch .fullscreen-table-wrapper:hover { + border-radius: 5px; + box-shadow: 0 2px 5px 0 rgba(var(--always-black-rgb), 0.1), + 0 2px 10px 0 rgba(var(--always-black-rgb), 0.1); + .open-popup-link { + opacity: 100%; + } +} diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 568b347583..58e47fcbb8 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -1,3 +1,5 @@ +$topic-progress-height: 42px; + @keyframes button-jump-up { 0% { margin-bottom: -60px; @@ -49,28 +51,28 @@ top: calc(var(--header-offset, 60px) + 2em); margin-left: 1em; z-index: z("timeline"); - } - } - // progress bar - @media screen and (max-width: 924px) { - // at 924px viewport width and below the progress bar is visible (see topic-navigation.js) - grid-template-areas: "posts posts"; - .timeline-container:not(.timeline-fullscreen) { - display: none; // hiding this because sometimes the JS switch lags and causes layout issues - } - .timeline-container { - .timeline-scroller-content { - position: relative; + &.topic-progress-expanded { + z-index: z("fullscreen"); } } } -} + // progress bar + @media screen and (max-width: 924px) { + grid-template-areas: "posts"; + grid-template-columns: auto; + .topic-navigation { + grid-area: posts; + grid-row: 2; + width: auto; + } -@media screen and (max-width: 924px) { - .post-stream { - // make space for the topic progress bar to dock - padding-bottom: 2em; + .timeline-container:not(.timeline-fullscreen) { + display: none; // hiding this because sometimes the JS switch lags and causes layout issues + } + .timeline-container .timeline-scroller-content { + position: relative; + } } } @@ -91,8 +93,13 @@ } #topic-progress-wrapper { + position: fixed; + bottom: 0px; + right: 10px; + margin: 0 auto; display: flex; - right: 9px; // 8px padding on #main-outlet + 1px right border + justify-content: flex-end; + z-index: z("timeline"); .topic-admin-menu-button-container { display: flex; > span { @@ -109,11 +116,70 @@ 0 ); // iOS11 Rendering bug https://meta.discourse.org/t/wrench-menu-not-disappearing-on-ios/94297 } + + &.docked { + position: initial; + .topic-admin-popup-menu.right-side { + bottom: -150px; // Prevents menu from being too high when a topic is very short + } + } + + body:not(.footer-nav-visible) & { + bottom: env(safe-area-inset-bottom); + } + + &:not(.docked) { + @media screen and (min-width: $reply-area-max-width) { + // position to right side of composer + right: 50%; + margin-right: calc(#{$reply-area-max-width} / 2 * -1); + } + } + + &.with-transitions { + transition: bottom 0.2s, margin-bottom 0.2s; + + #topic-progress .bg { + transition: width 0.5s; + } + } } -#topic-progress-wrapper.docked { - .topic-admin-popup-menu.right-side { - bottom: -150px; // Prevents menu from being too high when a topic is very short +#topic-progress { + position: relative; + background-color: var(--secondary); + color: var(--tertiary); + border: 1px solid var(--tertiary-low); + width: 145px; + height: $topic-progress-height; + + /* as a big ol' click target, don't let text inside be selected */ + @include unselectable; + + .nums { + position: relative; + top: 12px; + width: 100%; + text-align: center; + z-index: z("base"); + } + h4 { + display: inline; + font-size: $font-up-2; + line-height: $line-height-small; + } + .d-icon { + position: absolute; + right: 8px; + bottom: 9px; + z-index: z("base"); + } + .bg { + position: absolute; + top: 0; + bottom: 0; + width: var(--progress-bg-width, 0); + background-color: var(--tertiary-low); } } diff --git a/app/assets/stylesheets/common/components/badges.scss b/app/assets/stylesheets/common/components/badges.scss index 72a26175de..c16705adfa 100644 --- a/app/assets/stylesheets/common/components/badges.scss +++ b/app/assets/stylesheets/common/components/badges.scss @@ -116,7 +116,7 @@ span.badge-category { color: var(--primary-high); - padding: 1px 3px; + padding-left: 4px; overflow: hidden; text-overflow: ellipsis; @@ -127,9 +127,9 @@ .badge-category-parent-bg, .badge-category-bg { - // Subcategories display: inline-block; - padding: 0 1px; + padding-left: 1px; + padding-right: 1px; &:before { content: "\a0"; diff --git a/app/assets/stylesheets/common/components/footer-nav.scss b/app/assets/stylesheets/common/components/footer-nav.scss index 71ed2f9af5..6d43084cd1 100644 --- a/app/assets/stylesheets/common/components/footer-nav.scss +++ b/app/assets/stylesheets/common/components/footer-nav.scss @@ -9,19 +9,18 @@ body.footer-nav-visible { padding-bottom: $footer-nav-height + 15; } - #topic-progress-wrapper, #reply-control.draft { bottom: $footer-nav-height; - } - - #reply-control.draft { margin-bottom: env(safe-area-inset-bottom); padding-bottom: 0px; } + #topic-progress-wrapper:not(.docked) { + margin-bottom: calc(#{$footer-nav-height} + env(safe-area-inset-bottom)); + } .posts-filtered-notice { transition: all linear 0.1s; - bottom: $footer-nav-height + 15; + bottom: calc(#{$footer-nav-height} + env(safe-area-inset-bottom)); } } diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss index a93e7ae3b3..48446ae7ae 100644 --- a/app/assets/stylesheets/common/d-editor.scss +++ b/app/assets/stylesheets/common/d-editor.scss @@ -177,23 +177,34 @@ opacity: 1; } } + .button-wrapper { + display: flex; + flex-wrap: wrap; + gap: 0 0.5em; + position: absolute; height: var(--resizer-height); bottom: 0; left: 0; opacity: 0; - display: flex; - align-items: center; transition: all 0.25s; z-index: 1; // needs to be higher than image width: 100%; background: var(--secondary); // for when images are wider than controls + .scale-btn-container, + .alt-text-readonly-container { + background: var(--secondary); + display: flex; + height: var(--resizer-height); + align-items: center; + } + .scale-btn { - background: var(--secondary); // for when controls are wider than image color: var(--tertiary); padding: 0 1em; + &:first-child, &:last-child { padding: 0; @@ -209,6 +220,38 @@ cursor: pointer; } } + + .alt-text-readonly-container { + max-width: 100%; + flex: 1 1; + + .alt-text { + margin-right: 0.5em; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + } + + .alt-text-edit-btn svg { + padding-right: 0.5em; + pointer-events: none; + } + + .alt-text-input { + height: var(--resizer-height); + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + margin: 0; + + &[hidden="true"] { + display: none; + } + } + } } } diff --git a/app/assets/stylesheets/common/select-kit/category-drop.scss b/app/assets/stylesheets/common/select-kit/category-drop.scss index 920277c159..29f249c545 100644 --- a/app/assets/stylesheets/common/select-kit/category-drop.scss +++ b/app/assets/stylesheets/common/select-kit/category-drop.scss @@ -19,10 +19,6 @@ } } - &.bar.has-selection .category-drop-header { - padding: 0 5px 0 10px; - } - &.box.has-selection .category-drop-header { .badge-wrapper.box { padding: 0; diff --git a/app/assets/stylesheets/common/topic-timeline.scss b/app/assets/stylesheets/common/topic-timeline.scss index 833e84222f..40bb7267c1 100644 --- a/app/assets/stylesheets/common/topic-timeline.scss +++ b/app/assets/stylesheets/common/topic-timeline.scss @@ -29,7 +29,7 @@ } .topic-timeline { .timeline-footer-controls { - display: inherit; + display: flex; } } .timeline-controls { @@ -119,17 +119,19 @@ bottom: 20px; left: 10px; - button, - .btn-group { - margin-bottom: 0; - margin-right: 15px; - vertical-align: top; + .topic-notifications-button { + margin-right: 0.5em; } - .widget-component-connector { - vertical-align: top; + .jump-to-post { + margin-bottom: 0.5em; + } + + .topic-admin-menu-button { + display: flex; } } + .timeline-scrollarea-wrapper { display: table-cell; padding-right: 1.5em; @@ -140,7 +142,7 @@ border-right-style: solid; border-right-width: 1px; max-width: 120px; - float: right; + margin-top: 2em; .timeline-scroller { position: relative; diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 4a4613d612..e61e090438 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -11,19 +11,48 @@ } } - #private-message-users { - width: 404px; - } - - .item + #private-message-users { - width: unset; - min-width: 150px; - flex: 1 1 auto; - } - .select-kit.is-expanded { z-index: z("composer", "dropdown") + 1; } + + &.private-message { + .with-tags { + .title-and-category { + flex-wrap: nowrap; // force title and tags on same line for PMs + .mini-tag-chooser { + margin-bottom: 5px; // match title input margin + flex: 0 0 auto; + margin-left: 1%; // matches margin between category and tag input + width: 39%; + } + } + } + + #private-message-users, + .users-input { + width: 100%; + flex: 0 0 auto; + &.can-warn { + // space for warning, inverse of mini-tag-chooser width + width: 60%; + } + } + + .add-warning { + flex: 1 1 auto; + overflow: hidden; + span { + // protects the space of the user input in case there's a very long translation + @include ellipsis; + } + } + + &.show-preview { + .user-selector { + width: 50%; + } + } + } } .edit-title { diff --git a/app/assets/stylesheets/desktop/modal.scss b/app/assets/stylesheets/desktop/modal.scss index 6356b420f8..3c7368df84 100644 --- a/app/assets/stylesheets/desktop/modal.scss +++ b/app/assets/stylesheets/desktop/modal.scss @@ -112,6 +112,7 @@ .create-invite-bulk-modal, .share-topic-modal { .modal-inner-container { - width: 600px; + width: 40em; // scale with user font-size + max-width: 100vw; // prevent overflow if user font-size is enourmous } } diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 6ff674301c..ab3737de0f 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -619,6 +619,7 @@ blockquote { #{$topic-avatar-width} + #{$topic-body-width} + (#{$topic-body-width-padding} * 2) ); + max-width: 100%; @media all and (max-width: 790px) { // 32px is (left + right padding * 2) from .wrap in common/base/discourse.scss max-width: calc(100vw - 32px); diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index dfcdb47278..b7033d57d6 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -58,34 +58,6 @@ } } -#topic-progress-wrapper { - position: fixed; - bottom: 0; - left: 0; - margin: 0 auto; - max-width: $reply-area-max-width; - display: flex; - justify-content: flex-end; - z-index: z("timeline"); - // max-width + bottom + left/right makes this element take up the whole width - // albeit as a transparent row, but we disable pointer-events to allow user to - // interact with visible elements at bottom of viewport - pointer-events: none; - > * { - // and then we reset for its children - pointer-events: auto; - } - &.docked { - position: absolute; - bottom: -70px; - } - html.rtl & { - justify-content: flex-start; - right: 0; - left: 2em; - } -} - #topic-progress-expanded { border: 1px solid var(--primary-low); padding: 5px; @@ -128,55 +100,6 @@ } } -#topic-progress { - position: relative; - left: 340px; - &.hidden { - display: none; - } - background-color: var(--secondary); - color: var(--tertiary); - border: 1px solid var(--tertiary-low); - border-bottom: none; - width: 145px; - height: 34px; - - /* as a big ol' click target, don't let text inside be selected */ - @include unselectable; - - &:hover { - cursor: pointer; - } - .nums { - position: relative; - top: 9px; - width: 100%; - text-align: center; - z-index: z("base"); - } - .d-icon { - position: absolute; - right: 8px; - bottom: 9px; - z-index: z("base"); - } - h4 { - display: inline; - font-size: $font-up-2; - line-height: $line-height-small; - } - .bg { - position: absolute; - top: 0; - bottom: 0; - width: 0; - max-width: 145px; - border-right: 1px solid var(--tertiary-low); - background-color: var(--tertiary-low); - transition: width 0.75s; - } -} - #topic-filter { background-color: var(--highlight-medium); padding: 8px; @@ -187,6 +110,10 @@ z-index: z("dropdown"); } +#topic-progress:hover { + cursor: pointer; +} + #topic-progress, #topic-progress-expanded { right: 0; @@ -200,10 +127,6 @@ } @media all and (max-width: 485px) { - #topic-progress-wrapper.docked { - display: none; - } - #topic-footer-main-buttons { max-width: 100%; } diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index 94274b24c8..09bdd5ed72 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -272,6 +272,11 @@ table.user-invite-list { .show-mores { position: absolute; } + + #reply-control .mini-tag-chooser { + width: 100%; + margin: 0; + } } .user-messages { diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index 943307d894..205ec8977c 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -32,13 +32,6 @@ padding-bottom: 0px; } - // iOS 15 Safari has a floating address bar that displays above the composer submit bar - // we cannot detect its preseence, so we need to add extra padding - // Apple says it's a known issue, see https://bugs.webkit.org/show_bug.cgi?id=229876 - .keyboard-visible body.ios-safari-15-hack &.open .reply-area { - padding-bottom: 45px; - } - .reply-to { margin: 5px 0; .reply-details { diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss index ebfe9441a4..adad908cff 100644 --- a/app/assets/stylesheets/mobile/topic-post.scss +++ b/app/assets/stylesheets/mobile/topic-post.scss @@ -433,16 +433,16 @@ span.highlighted { } .posts-filtered-notice { - padding-right: 10em; + padding-right: 8.5em; + padding-bottom: unquote("max(1em, env(safe-area-inset-bottom))"); flex-wrap: wrap; justify-content: flex-start; - padding-bottom: unquote("max(0.75em, env(safe-area-inset-bottom))"); margin: 1em -9px; z-index: 101; .filtered-replies-show-all { position: absolute; - right: 2em; + right: 1em; } .filtered-replies-viewing { @@ -458,3 +458,8 @@ span.highlighted { } } } + +.open-popup-link { + opacity: 100%; + margin-bottom: 1rem; +} diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss index 1ad9635e97..ec5b6202dd 100644 --- a/app/assets/stylesheets/mobile/topic.scss +++ b/app/assets/stylesheets/mobile/topic.scss @@ -31,26 +31,6 @@ top: -100%; // above parent container + some extra space } -#topic-progress-wrapper { - position: fixed; - right: 10px; // match 10px padding on .wrap - bottom: 0; - z-index: z("timeline"); - &:not(.docked) { - margin-bottom: env(safe-area-inset-bottom); - } - html.rtl & { - /** - * This should be the other way around, but it has to be "wrong" here - * because our RTL CSS is generated using the `rtlit` gem which flips - * left to right and right to left, so this will be corrected when it - * goes through rtlit. - */ - left: unset; - right: 1em; - } -} - #topic-progress-expanded { border: 1px solid var(--primary-low); padding: 5px; @@ -92,49 +72,6 @@ } } -#topic-progress { - position: relative; - &.hidden { - display: none; - } - background-color: var(--secondary); - color: var(--tertiary); - border: 1px solid var(--tertiary-low); - width: 145px; - height: 42px; - - /* as a big ol' click target, don't let text inside be selected */ - @include unselectable; - - .nums { - position: relative; - top: 12px; - width: 100%; - text-align: center; - z-index: z("base"); - } - h4 { - display: inline; - font-size: $font-up-2; - line-height: $line-height-small; - } - .d-icon { - position: absolute; - right: 8px; - bottom: 9px; - z-index: z("base"); - } - .bg { - position: absolute; - top: 0; - bottom: 0; - width: 0; - border-right: 1px solid var(--tertiary-low); - background-color: var(--tertiary-low); - transition: width 0.75s; - } -} - .topic-error { padding: 18px; width: 90%; @@ -151,14 +88,6 @@ } } -#topic-progress-wrapper.docked { - position: absolute; -} - -.topic-post:last-of-type { - padding-bottom: 40px; -} - sup sup, sub sup, sup sub, diff --git a/app/controllers/admin/api_controller.rb b/app/controllers/admin/api_controller.rb index 513aa4b480..627c4c4009 100644 --- a/app/controllers/admin/api_controller.rb +++ b/app/controllers/admin/api_controller.rb @@ -5,16 +5,22 @@ class Admin::ApiController < Admin::AdminController # If we used "api_key", then our user provider would try to use the value for authentication def index - keys = ApiKey.where(hidden: false) + offset = (params[:offset] || 0).to_i + limit = (params[:limit] || 50).to_i.clamp(1, 50) - # Put active keys first - # Sort active keys by created_at, sort revoked keys by revoked_at - keys = keys.order(<<~SQL) - CASE WHEN revoked_at IS NULL THEN 0 ELSE 1 END, - COALESCE(revoked_at, created_at) DESC - SQL + keys = ApiKey + .where(hidden: false) + .includes(:user, :api_key_scopes) + # Sort revoked keys by revoked_at and active keys by created_at + .order("revoked_at DESC NULLS FIRST, created_at DESC") + .offset(offset) + .limit(limit) - render_serialized(keys.to_a, ApiKeySerializer, root: 'keys') + render_json_dump( + keys: serialize_data(keys, ApiKeySerializer), + offset: offset, + limit: limit + ) end def show diff --git a/app/controllers/admin/backups_controller.rb b/app/controllers/admin/backups_controller.rb index f8b3300f99..0ea55fe150 100644 --- a/app/controllers/admin/backups_controller.rb +++ b/app/controllers/admin/backups_controller.rb @@ -176,13 +176,14 @@ class Admin::BackupsController < Admin::AdminController chunk_number = params.fetch(:resumableChunkNumber).to_i chunk_size = params.fetch(:resumableChunkSize).to_i current_chunk_size = params.fetch(:resumableCurrentChunkSize).to_i + previous_chunk_number = chunk_number - 1 # path to chunk file chunk = BackupRestore::LocalBackupStore.chunk_path(identifier, filename, chunk_number) # upload chunk HandleChunkUpload.upload_chunk(chunk, file: file) - uploaded_file_size = chunk_number * chunk_size + uploaded_file_size = previous_chunk_number * chunk_size # when all chunks are uploaded if uploaded_file_size + current_chunk_size >= total_size # merge all the chunks in a background thread diff --git a/app/controllers/admin/emojis_controller.rb b/app/controllers/admin/emojis_controller.rb index 72e1c30801..8320ea340e 100644 --- a/app/controllers/admin/emojis_controller.rb +++ b/app/controllers/admin/emojis_controller.rb @@ -6,6 +6,9 @@ class Admin::EmojisController < Admin::AdminController render_serialized(Emoji.custom, EmojiSerializer, root: false) end + # TODO (martin) Figure out a way that this kind of custom logic can + # be run in the ExternalUploadManager when a direct S3 upload is completed, + # related to preventDirectS3Uploads in the UppyUploadMixin. def create file = params[:file] || params[:files].first name = params[:name] || File.basename(file.original_filename, ".*") diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 95f9bbe1e1..33cfe6db3c 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -282,7 +282,7 @@ class Admin::ThemesController < Admin::AdminController private def ban_in_allowlist_mode! - raise Discourse::InvalidAccess if !GlobalSetting.allowed_theme_ids.nil? + raise Discourse::InvalidAccess if !Theme.allowed_remote_theme_ids.nil? end def ban_for_remote_theme! diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index bdb33fee0c..74eab9c71f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base include GlobalPath include Hijack include ReadOnlyHeader + include VaryHeader attr_reader :theme_id @@ -46,6 +47,7 @@ class ApplicationController < ActionController::Base after_action :perform_refresh_session after_action :dont_cache_page after_action :conditionally_allow_site_embedding + after_action :ensure_vary_header HONEYPOT_KEY ||= 'HONEYPOT_KEY' CHALLENGE_KEY ||= 'CHALLENGE_KEY' diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 4e7337eaf0..cddfb3931d 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -279,7 +279,9 @@ class CategoriesController < ApplicationController if topics_filter == :latest result.topic_list = TopicQuery.new(current_user, topic_options).list_latest elsif topics_filter == :top - result.topic_list = TopicQuery.new(nil, topic_options).list_top_for(SiteSetting.top_page_default_timeframe.to_sym) + result.topic_list = TopicQuery.new(current_user, topic_options).list_top_for( + SiteSetting.top_page_default_timeframe.to_sym + ) end render_serialized(result, CategoryAndTopicListsSerializer, root: false) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8bc89054b8..6fbb21044f 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -247,7 +247,7 @@ class GroupsController < ApplicationController dir = (params[:asc] && params[:asc].present?) ? 'ASC' : 'DESC' if params[:desc] - Discourse.deprecate(":desc is deprecated please use :asc instead", output_in_test: true) + Discourse.deprecate(":desc is deprecated please use :asc instead", output_in_test: true, drop_from: '2.9.0') dir = (params[:desc] && params[:desc].present?) ? 'DESC' : 'ASC' end order = "" diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 0ffeaf61d6..0506310049 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -776,7 +776,7 @@ class PostsController < ApplicationController result[:referrer] = request.env["HTTP_REFERER"] if recipients = result[:target_usernames] - Discourse.deprecate("`target_usernames` is deprecated, use `target_recipients` instead.", output_in_test: true) + Discourse.deprecate("`target_usernames` is deprecated, use `target_recipients` instead.", output_in_test: true, drop_from: '2.9.0') else recipients = result[:target_recipients] end diff --git a/app/controllers/presence_controller.rb b/app/controllers/presence_controller.rb index fd10fc0429..653250a8af 100644 --- a/app/controllers/presence_controller.rb +++ b/app/controllers/presence_controller.rb @@ -39,8 +39,9 @@ class PresenceController < ApplicationController client_id = params[:client_id] raise Discourse::InvalidParameters.new(:client_id) if !client_id.is_a?(String) || client_id.blank? - # JS client is designed to throttle to one request every 5 seconds - RateLimiter.new(nil, "update-presence-#{current_user.id}-#{client_id}}", 3, 10.seconds).performed! + # JS client is designed to throttle to one request per second + # When no changes are being made, it makes one request every 30 seconds + RateLimiter.new(nil, "update-presence-#{current_user.id}", 20, 10.seconds).performed! present_channels = params[:present_channels] if present_channels && !(present_channels.is_a?(Array) && present_channels.all? { |c| c.is_a? String }) diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 099cc16c75..baeef8c388 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -387,8 +387,10 @@ class TopicsController < ApplicationController success = true if changes.length > 0 + bypass_bump = should_bypass_bump?(changes) + first_post = topic.ordered_posts.first - success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false) + success = PostRevisor.new(first_post, topic).revise!(current_user, changes, validate_post: false, bypass_bump: bypass_bump) if !success && topic.errors.blank? topic.errors.add(:base, :unable_to_update) @@ -1114,6 +1116,11 @@ class TopicsController < ApplicationController Promotion.new(current_user).review if current_user.present? end + def should_bypass_bump?(changes) + (changes[:category_id].present? && SiteSetting.disable_category_edit_notifications) || + (changes[:tags].present? && SiteSetting.disable_tags_edit_notifications) + end + def slugs_do_not_match if SiteSetting.slug_generation_method != "encoded" params[:slug] && @topic_view.topic.slug != params[:slug] diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 142b6a00b8..589ccec90e 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -224,22 +224,15 @@ class UploadsController < ApplicationController ) end - metadata = parse_allowed_metadata(params[:metadata]) - - url = Discourse.store.signed_url_for_temporary_upload( - file_name, metadata: metadata - ) - key = Discourse.store.path_from_url(url) - - upload_stub = ExternalUploadStub.create!( - key: key, - created_by: current_user, - original_filename: file_name, + external_upload_data = ExternalUploadManager.create_direct_upload( + current_user: current_user, + file_name: file_name, + file_size: file_size, upload_type: type, - filesize: file_size + metadata: parse_allowed_metadata(params[:metadata]) ) - render json: { url: url, key: key, unique_identifier: upload_stub.unique_identifier } + render json: external_upload_data end def complete_external_upload @@ -262,10 +255,12 @@ class UploadsController < ApplicationController external_upload_manager = ExternalUploadManager.new(external_upload_stub, opts) hijack do begin - upload = external_upload_manager.promote_to_upload! + upload = external_upload_manager.transform! + if upload.errors.empty? + response_serialized = external_upload_stub.upload_type != "backup" ? UploadsController.serialize_upload(upload) : {} external_upload_stub.destroy! - render json: UploadsController.serialize_upload(upload), status: 200 + render json: response_serialized, status: 200 else render_json_error(upload.errors.to_hash.values.flatten, status: 422) end @@ -307,43 +302,43 @@ class UploadsController < ApplicationController file_name = params.require(:file_name) file_size = params.require(:file_size).to_i upload_type = params.require(:upload_type) - content_type = MiniMime.lookup_by_filename(file_name)&.content_type - if file_size_too_big?(file_name, file_size) - return render_json_error( - I18n.t("upload.attachments.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_attachment_size_kb.kilobytes)), - status: 422 - ) + if upload_type == "backup" + ensure_staff + return render_json_error(I18n.t("backup.backup_file_should_be_tar_gz")) unless valid_backup_extension?(file_name) + return render_json_error(I18n.t("backup.invalid_filename")) unless valid_backup_filename?(file_name) + else + if file_size_too_big?(file_name, file_size) + return render_json_error( + I18n.t("upload.attachments.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_attachment_size_kb.kilobytes)), + status: 422 + ) + end end - metadata = parse_allowed_metadata(params[:metadata]) - begin - multipart_upload = Discourse.store.create_multipart( - file_name, content_type, metadata: metadata + external_upload_data = ExternalUploadManager.create_direct_multipart_upload( + current_user: current_user, + file_name: file_name, + file_size: file_size, + upload_type: upload_type, + metadata: parse_allowed_metadata(params[:metadata]) ) rescue Aws::S3::Errors::ServiceError => err return render_json_error( debug_upload_error(err, "upload.create_mutlipart_failure", additional_detail: err.message), status: 422 ) + rescue BackupRestore::BackupStore::BackupFileExists + return render_json_error(I18n.t("backup.file_exists"), status: 422) + rescue BackupRestore::BackupStore::StorageError => err + return render_json_error( + debug_upload_error(err, "upload.create_mutlipart_failure", additional_detail: err.message), + status: 422 + ) end - upload_stub = ExternalUploadStub.create!( - key: multipart_upload[:key], - created_by: current_user, - original_filename: file_name, - upload_type: upload_type, - external_upload_identifier: multipart_upload[:upload_id], - multipart: true, - filesize: file_size - ) - - render json: { - external_upload_identifier: upload_stub.external_upload_identifier, - key: upload_stub.key, - unique_identifier: upload_stub.unique_identifier - } + render json: external_upload_data end def batch_presign_multipart_parts @@ -367,9 +362,11 @@ class UploadsController < ApplicationController return render_404 end + store = multipart_store(external_upload_stub.upload_type) + presigned_urls = {} part_numbers.each do |part_number| - presigned_urls[part_number] = Discourse.store.presign_multipart_part( + presigned_urls[part_number] = store.presign_multipart_part( upload_id: external_upload_stub.external_upload_identifier, key: external_upload_stub.key, part_number: part_number @@ -390,9 +387,12 @@ class UploadsController < ApplicationController end def multipart_upload_exists?(external_upload_stub) + store = multipart_store(external_upload_stub.upload_type) begin - Discourse.store.list_multipart_parts( - upload_id: external_upload_stub.external_upload_identifier, key: external_upload_stub.key + store.list_multipart_parts( + upload_id: external_upload_stub.external_upload_identifier, + key: external_upload_stub.key, + max_parts: 1 ) rescue Aws::S3::Errors::NoSuchUpload => err debug_upload_error(err, "upload.external_upload_not_found", { additional_detail: "path: #{external_upload_stub.key}" }) @@ -413,9 +413,10 @@ class UploadsController < ApplicationController return render json: success_json if external_upload_stub.blank? return render_404 if external_upload_stub.created_by_id != current_user.id + store = multipart_store(external_upload_stub.upload_type) begin - Discourse.store.abort_multipart( + store.abort_multipart( upload_id: external_upload_stub.external_upload_identifier, key: external_upload_stub.key ) @@ -448,6 +449,7 @@ class UploadsController < ApplicationController return render_404 end + store = multipart_store(external_upload_stub.upload_type) parts = parts.map do |part| part_number = part[:part_number] etag = part[:etag] @@ -465,7 +467,7 @@ class UploadsController < ApplicationController end begin - complete_response = Discourse.store.complete_multipart( + complete_response = store.complete_multipart( upload_id: external_upload_stub.external_upload_identifier, key: external_upload_stub.key, parts: parts @@ -482,6 +484,11 @@ class UploadsController < ApplicationController protected + def multipart_store(upload_type) + ensure_staff if upload_type == "backup" + ExternalUploadManager.store_for_upload_type(upload_type) + end + def force_download? params[:dl] == "1" end @@ -603,4 +610,12 @@ class UploadsController < ApplicationController return if metadata.blank? metadata.permit("sha1-checksum").to_h end + + def valid_backup_extension?(filename) + /\.(tar\.gz|t?gz)$/i =~ filename + end + + def valid_backup_filename?(filename) + !!(/^[a-zA-Z0-9\._\-]+$/ =~ filename) + end end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 6a9253b6f3..7850cf2c67 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -98,8 +98,14 @@ class Users::OmniauthCallbacksController < ApplicationController end def failure - error_key = params[:message].to_s.gsub(/[^\w-]/, "") || "generic" - flash[:error] = I18n.t("login.omniauth_error.#{error_key}", default: I18n.t("login.omniauth_error.generic")) + error_key = params[:message].to_s.gsub(/[^\w-]/, "") + error_key = "generic" if error_key.blank? + + flash[:error] = I18n.t( + "login.omniauth_error.#{error_key}", + default: I18n.t("login.omniauth_error.generic") + ).html_safe + render 'failure' end diff --git a/app/jobs/onceoff/vacate_legacy_prefix_backups.rb b/app/jobs/onceoff/vacate_legacy_prefix_backups.rb deleted file mode 100644 index 0791a01c0d..0000000000 --- a/app/jobs/onceoff/vacate_legacy_prefix_backups.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class VacateLegacyPrefixBackups < ::Jobs::Onceoff - def execute_onceoff(args) - args ||= {} - BackupRestore::S3BackupStore.create(s3_options: args[:s3_options]).vacate_legacy_prefix if SiteSetting.backup_location == BackupLocationSiteSetting::S3 - end - end -end diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index 4ff6d1a3ff..262e871920 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -317,7 +317,11 @@ module Jobs def add_group_names(user, user_info_array) group_names = user.groups.map { |g| g.name }.join(";") - user_info_array << escape_comma(group_names) if group_names.present? + if group_names.present? + user_info_array << escape_comma(group_names) + else + user_info_array << nil + end user_info_array end diff --git a/app/jobs/regular/pull_hotlinked_images.rb b/app/jobs/regular/pull_hotlinked_images.rb index 934941c5ba..b532366deb 100644 --- a/app/jobs/regular/pull_hotlinked_images.rb +++ b/app/jobs/regular/pull_hotlinked_images.rb @@ -241,7 +241,7 @@ module Jobs ) end - private + protected def normalize_src(src) uri = Addressable::URI.heuristic_parse(src) diff --git a/app/jobs/regular/pull_user_profile_hotlinked_images.rb b/app/jobs/regular/pull_user_profile_hotlinked_images.rb new file mode 100644 index 0000000000..ae87a9043d --- /dev/null +++ b/app/jobs/regular/pull_user_profile_hotlinked_images.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Jobs + class PullUserProfileHotlinkedImages < ::Jobs::PullHotlinkedImages + def execute(args) + @user_id = args[:user_id] + raise Discourse::InvalidParameters.new(:user_id) if @user_id.blank? + + user_profile = UserProfile.find_by(user_id: @user_id) + return if user_profile.blank? + + large_image_urls = [] + broken_image_urls = [] + downloaded_images = {} + + extract_images_from(user_profile.bio_cooked).each do |node| + download_src = original_src = node['src'] || node['href'] + download_src = "#{SiteSetting.force_https ? "https" : "http"}:#{original_src}" if original_src.start_with?("//") + normalized_src = normalize_src(download_src) + + next if !should_download_image?(download_src) + + begin + already_attempted_download = downloaded_images.include?(normalized_src) || large_image_urls.include?(normalized_src) || broken_image_urls.include?(normalized_src) + if !already_attempted_download + downloaded_images[normalized_src] = attempt_download(download_src, @user_id) + end + rescue ImageTooLargeError + large_image_urls << normalized_src + rescue ImageBrokenError + broken_image_urls << normalized_src + end + + # have we successfully downloaded that file? + if upload = downloaded_images[normalized_src] + user_profile.bio_raw = replace_in_raw(original_src: original_src, upload: upload, raw: user_profile.bio_raw) + end + rescue => e + raise e if Rails.env.test? + log(:error, "Failed to pull hotlinked image (#{download_src}) user: #{@user_id}\n" + e.message + "\n" + e.backtrace.join("\n")) + end + + user_profile.skip_pull_hotlinked_image = true + user_profile.save! + end + end +end diff --git a/app/jobs/scheduled/clean_up_uploads.rb b/app/jobs/scheduled/clean_up_uploads.rb index 770ff7144b..db666f78d2 100644 --- a/app/jobs/scheduled/clean_up_uploads.rb +++ b/app/jobs/scheduled/clean_up_uploads.rb @@ -38,6 +38,7 @@ module Jobs encoded_sha = Base62.encode(upload.sha1.hex) next if ReviewableQueuedPost.pending.where("payload->>'raw' LIKE '%#{upload.sha1}%' OR payload->>'raw' LIKE '%#{encoded_sha}%'").exists? next if Draft.where("data LIKE '%#{upload.sha1}%' OR data LIKE '%#{encoded_sha}%'").exists? + next if UserProfile.where("bio_raw LIKE '%#{upload.sha1}%' OR bio_raw LIKE '%#{encoded_sha}%'").exists? if defined?(ChatMessage) && ChatMessage.where("message LIKE ? OR message LIKE ?", "%#{upload.sha1}%", "%#{encoded_sha}%").exists? next diff --git a/app/models/api_key_scope.rb b/app/models/api_key_scope.rb index f4cb44e2f8..9016da3b68 100644 --- a/app/models/api_key_scope.rb +++ b/app/models/api_key_scope.rb @@ -17,6 +17,9 @@ class ApiKeyScope < ActiveRecord::Base return @default_mappings unless @default_mappings.nil? mappings = { + global: { + read: { methods: %i[get] } + }, topics: { write: { actions: %w[posts#create], params: %i[topic_id] }, read: { @@ -48,12 +51,7 @@ class ApiKeyScope < ActiveRecord::Base } } - mappings.each_value do |resource_actions| - resource_actions.each_value do |action_data| - action_data[:urls] = find_urls(action_data[:actions]) - end - end - + parse_resources!(mappings) @default_mappings = mappings end @@ -62,33 +60,48 @@ class ApiKeyScope < ActiveRecord::Base return default_mappings if plugin_mappings.empty? default_mappings.deep_dup.tap do |mappings| - - plugin_mappings.each do |resource| - resource.each_value do |resource_actions| - resource_actions.each_value do |action_data| - action_data[:urls] = find_urls(action_data[:actions]) - end - end - - mappings.deep_merge!(resource) + plugin_mappings.each do |plugin_mapping| + parse_resources!(plugin_mapping) + mappings.deep_merge!(plugin_mapping) end end end - def find_urls(actions) - Rails.application.routes.routes.reduce([]) do |memo, route| - defaults = route.defaults - action = "#{defaults[:controller].to_s}##{defaults[:action]}" - path = route.path.spec.to_s.gsub(/\(\.:format\)/, '') - api_supported_path = path.end_with?('.rss') || route.path.requirements[:format]&.match?('json') - excluded_paths = %w[/new-topic /new-message /exception] + def parse_resources!(mappings) + mappings.each_value do |resource_actions| + resource_actions.each_value do |action_data| + action_data[:urls] = find_urls(actions: action_data[:actions], methods: action_data[:methods]) + end + end + end - memo.tap do |m| - if actions.include?(action) && api_supported_path && !excluded_paths.include?(path) - m << "#{path} (#{route.verb})" + def find_urls(actions:, methods:) + action_urls = [] + method_urls = [] + + if actions.present? + Rails.application.routes.routes.reduce([]) do |memo, route| + defaults = route.defaults + action = "#{defaults[:controller].to_s}##{defaults[:action]}" + path = route.path.spec.to_s.gsub(/\(\.:format\)/, '') + api_supported_path = path.end_with?('.rss') || route.path.requirements[:format]&.match?('json') + excluded_paths = %w[/new-topic /new-message /exception] + + memo.tap do |m| + if actions.include?(action) && api_supported_path && !excluded_paths.include?(path) + m << "#{path} (#{route.verb})" + end end end end + + if methods.present? + methods.each do |method| + method_urls << "* (#{method})" + end + end + + action_urls + method_urls end end diff --git a/app/models/badge.rb b/app/models/badge.rb index 5e480aa2e5..af0c054d66 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -5,6 +5,7 @@ class Badge < ActiveRecord::Base self.ignored_columns = %w{image} include GlobalPath + include HasSanitizableFields # NOTE: These badge ids are not in order! They are grouped logically. # When picking an id, *search* for it. @@ -116,6 +117,7 @@ class Badge < ActiveRecord::Base scope :enabled, -> { where(enabled: true) } before_create :ensure_not_system + before_save :sanitize_description after_commit do SvgSprite.expire_cache @@ -314,6 +316,12 @@ class Badge < ActiveRecord::Base def ensure_not_system self.id = [Badge.maximum(:id) + 1, 100].max unless id end + + def sanitize_description + if description_changed? + self.description = sanitize_field(self.description) + end + end end # == Schema Information diff --git a/app/models/category.rb b/app/models/category.rb index 9140247b95..ad5a662a35 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -747,7 +747,7 @@ class Category < ActiveRecord::Base end def url_with_id - Discourse.deprecate("Category#url_with_id is deprecated. Use `Category#url` instead.", output_in_test: true) + Discourse.deprecate("Category#url_with_id is deprecated. Use `Category#url` instead.", output_in_test: true, drop_from: '2.9.0') url end diff --git a/app/models/category_list.rb b/app/models/category_list.rb index 655edfa194..e092dabf42 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -63,6 +63,13 @@ class CategoryList category_featured_topics = CategoryFeaturedTopic.select([:category_id, :topic_id]).order(:rank) @all_topics = Topic.where(id: category_featured_topics.map(&:topic_id)) + + if @guardian.authenticated? + @all_topics = @all_topics + .joins("LEFT JOIN topic_users tu ON topics.id = tu.topic_id AND tu.user_id = #{@guardian.user.id.to_i}") + .where('COALESCE(tu.notification_level,1) > :muted', muted: TopicUser.notification_levels[:muted]) + end + @all_topics = @all_topics.includes(:last_poster) if @options[:include_topics] @all_topics.each do |t| # hint for the serializer diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index bcd7d7c02f..afb897f9b4 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -346,7 +346,7 @@ class ColorScheme < ActiveRecord::Base end def is_dark? - return if colors.empty? + return if colors.to_a.empty? primary_b = brightness(colors_by_name["primary"].hex) secondary_b = brightness(colors_by_name["secondary"].hex) diff --git a/app/models/concerns/has_sanitizable_fields.rb b/app/models/concerns/has_sanitizable_fields.rb new file mode 100644 index 0000000000..b0db07de00 --- /dev/null +++ b/app/models/concerns/has_sanitizable_fields.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module HasSanitizableFields + extend ActiveSupport::Concern + + def sanitize_field(field, additional_attributes: []) + if field + sanitizer = Rails::Html::SafeListSanitizer.new + allowed_attributes = Rails::Html::SafeListSanitizer.allowed_attributes + + if additional_attributes.present? + allowed_attributes = allowed_attributes.merge(additional_attributes) + end + + field = CGI.unescape_html(sanitizer.sanitize(field, attributes: allowed_attributes)) + # Just replace the characters that our translations use for interpolation. + # Calling CGI.unescape removes characters like '+', which will corrupt the original value. + field = field.gsub('%7B', '{').gsub('%7D', '}') + end + + field + end +end diff --git a/app/models/discourse_single_sign_on.rb b/app/models/discourse_single_sign_on.rb index e268d98b67..31a4b830ad 100644 --- a/app/models/discourse_single_sign_on.rb +++ b/app/models/discourse_single_sign_on.rb @@ -50,8 +50,10 @@ class DiscourseSingleSignOn < SingleSignOn def nonce_error if Discourse.cache.read(used_nonce_key).present? "Nonce has already been used" + elsif SiteSetting.discourse_connect_csrf_protection + "Nonce is incorrect, was generated in a different browser session, or has expired" else - "Nonce has expired" + "Nonce is incorrect, or has expired" end end @@ -323,8 +325,8 @@ class DiscourseSingleSignOn < SingleSignOn if SiteSetting.auth_overrides_username? && username.present? if user.username.downcase == username.downcase user.username = username # there may be a change of case - elsif user.username != username - user.username = UserNameSuggester.suggest(username || name || email, user.username) + elsif user.username != UserNameSuggester.fix_username(username) + user.username = UserNameSuggester.suggest(username) end end diff --git a/app/models/global_setting.rb b/app/models/global_setting.rb index fba25eccdc..c83db37cfa 100644 --- a/app/models/global_setting.rb +++ b/app/models/global_setting.rb @@ -238,23 +238,6 @@ class GlobalSetting end end - # test only - def self.reset_allowed_theme_ids! - @allowed_theme_ids = nil - end - - def self.allowed_theme_ids - return nil if allowed_theme_repos.blank? - - @allowed_theme_ids ||= begin - urls = allowed_theme_repos.split(",").map(&:strip) - Theme - .joins(:remote_theme) - .where('remote_themes.remote_url in (?)', urls) - .pluck(:id) - end - end - def self.add_default(name, default) unless self.respond_to? name define_singleton_method(name) do diff --git a/app/models/group.rb b/app/models/group.rb index 374058faf7..37e08a23db 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -287,11 +287,6 @@ class Group < ActiveRecord::Base end end - def self.register_plugin_editable_group_custom_field(custom_field_name, plugin) - Discourse.deprecate("Editable group custom fields should be registered using the plugin API", since: "v2.4.0.beta4", drop_from: "v2.5.0") - DiscoursePluginRegistry.register_editable_group_custom_field(custom_field_name, plugin) - end - def downcase_incoming_email self.incoming_email = (incoming_email || "").strip.downcase.presence end @@ -979,23 +974,26 @@ class Group < ActiveRecord::Base /*where*/ SQL - builder = DB.build(sql) - builder.where(<<~SQL, id: id) - id IN ( - SELECT user_id - FROM group_users - WHERE group_id = :id - ) - SQL + [:primary_group_id, :flair_group_id].each do |column| + builder = DB.build(sql) + builder.where(<<~SQL, id: id) + id IN ( + SELECT user_id + FROM group_users + WHERE group_id = :id + ) + SQL - if primary_group - builder.set("primary_group_id = :id") - else - builder.set("primary_group_id = NULL") - builder.where("primary_group_id = :id") + if primary_group + builder.set("#{column} = :id") + builder.where("#{column} IS NULL") if column == :flair_group_id + else + builder.set("#{column} = NULL") + builder.where("#{column} = :id") + end + + builder.exec end - - builder.exec end end diff --git a/app/models/locale_site_setting.rb b/app/models/locale_site_setting.rb index f541a27f48..6843187164 100644 --- a/app/models/locale_site_setting.rb +++ b/app/models/locale_site_setting.rb @@ -23,7 +23,7 @@ class LocaleSiteSetting < EnumSiteSetting @lock.synchronize do @language_names ||= begin - names = YAML.load(File.read(File.join(Rails.root, 'config', 'locales', 'names.yml'))) + names = YAML.safe_load(File.read(File.join(Rails.root, 'config', 'locales', 'names.yml'))) DiscoursePluginRegistry.locales.each do |locale, options| if !names.key?(locale) && options[:name] && options[:nativeName] diff --git a/app/models/post.rb b/app/models/post.rb index 86bb006c7a..7120db505e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -84,9 +84,13 @@ class Post < ActiveRecord::Base register_custom_field_type(NOTICE, :json) - scope :private_posts_for_user, ->(user) { - where("posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL})", user_id: user.id) - } + scope :private_posts_for_user, ->(user) do + where( + "topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_USER}) + OR topics.id IN (#{Topic::PRIVATE_MESSAGES_SQL_GROUP})", + user_id: user.id + ) + end scope :by_newest, -> { order('created_at DESC, id DESC') } scope :by_post_number, -> { order('post_number ASC') } diff --git a/app/models/post_action.rb b/app/models/post_action.rb index c7d28f810f..a26998aa7c 100644 --- a/app/models/post_action.rb +++ b/app/models/post_action.rb @@ -103,7 +103,11 @@ class PostAction < ActiveRecord::Base end def self.act(created_by, post, post_action_type_id, opts = {}) - Discourse.deprecate("PostAction.act is deprecated. Use `PostActionCreator` instead.", output_in_test: true) + Discourse.deprecate( + "PostAction.act is deprecated. Use `PostActionCreator` instead.", + output_in_test: true, + drop_from: '2.9.0', + ) result = PostActionCreator.new( created_by, @@ -131,7 +135,8 @@ class PostAction < ActiveRecord::Base def self.remove_act(user, post, post_action_type_id) Discourse.deprecate( "PostAction.remove_act is deprecated. Use `PostActionDestroyer` instead.", - output_in_test: true + output_in_test: true, + drop_from: '2.9.0', ) PostActionDestroyer.new(user, post, post_action_type_id).perform diff --git a/app/models/report.rb b/app/models/report.rb index b0f97d7ae5..4bd185641a 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -107,7 +107,7 @@ class Report def add_filter(name, options = {}) if options[:type].blank? options[:type] = name - Discourse.deprecate("#{name} filter should define a `:type` option. Temporarily setting type to #{name}.") + Discourse.deprecate("#{name} filter should define a `:type` option. Temporarily setting type to #{name}.", drop_from: '2.9.0') end available_filters[name] = options diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index bff1c63064..08d0264419 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -402,7 +402,8 @@ class Reviewable < ActiveRecord::Base def post_options Discourse.deprecate( "Reviewable#post_options is deprecated. Please use #payload instead.", - output_in_test: true + output_in_test: true, + drop_from: '2.9.0', ) end diff --git a/app/models/site.rb b/app/models/site.rb index 4dbc2ea402..d8ba6cf083 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -32,7 +32,7 @@ class Site end def user_fields - UserField.order(:position).all + UserField.includes(:user_field_options).order(:position).all end def self.categories_cache_key @@ -138,12 +138,11 @@ class Site end def self.json_for(guardian) - if guardian.anonymous? && SiteSetting.login_required return { periods: TopTopic.periods.map(&:to_s), filters: Discourse.filters.map(&:to_s), - user_fields: UserField.all.map do |userfield| + user_fields: UserField.includes(:user_field_options).order(:position).all.map do |userfield| UserFieldSerializer.new(userfield, root: false, scope: guardian) end, auth_providers: Discourse.enabled_auth_providers.map do |provider| @@ -162,7 +161,6 @@ class Site if cached_json && seq == cached_seq.to_i && Discourse.git_version == cached_version return cached_json end - end site = Site.new(guardian) diff --git a/app/models/site_setting.rb b/app/models/site_setting.rb index 89a5e923fc..8f0b251628 100644 --- a/app/models/site_setting.rb +++ b/app/models/site_setting.rb @@ -90,7 +90,7 @@ class SiteSetting < ActiveRecord::Base end def self.queue_jobs=(val) - Discourse.deprecate("queue_jobs is deprecated. Please use Jobs.run_immediately! instead") + Discourse.deprecate("queue_jobs is deprecated. Please use Jobs.run_immediately! instead", drop_from: '2.9.0') val ? Jobs.run_later! : Jobs.run_immediately! end diff --git a/app/models/theme.rb b/app/models/theme.rb index e6c81bb4e9..3f59348d9e 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -50,6 +50,7 @@ class Theme < ActiveRecord::Base :locale_fields, :user, :color_scheme, + :theme_translation_overrides, theme_fields: :upload ) } @@ -183,6 +184,18 @@ class Theme < ActiveRecord::Base end end + def self.allowed_remote_theme_ids + return nil if GlobalSetting.allowed_theme_repos.blank? + + get_set_cache "allowed_remote_theme_ids" do + urls = GlobalSetting.allowed_theme_repos.split(",").map(&:strip) + Theme + .joins(:remote_theme) + .where('remote_themes.remote_url in (?)', urls) + .pluck(:id) + end + end + def self.components_for(theme_id) get_set_cache "theme_components_for_#{theme_id}" do ChildTheme.where(parent_theme_id: theme_id).pluck(:child_theme_id) diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 603e1f0eaf..fac8bf1b78 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -216,8 +216,12 @@ class ThemeField < ActiveRecord::Base # this would reduce the size of the payload, without affecting functionality data = {} fallback_data.each { |hash| data.merge!(hash) } - overrides = theme.translation_override_hash.deep_symbolize_keys - data.deep_merge!(overrides) if with_overrides + + if with_overrides + overrides = theme.translation_override_hash.deep_symbolize_keys + data.deep_merge!(overrides) + end + data end diff --git a/app/models/topic.rb b/app/models/topic.rb index 5f807a7dfe..3519a5e894 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -267,19 +267,25 @@ class Topic < ActiveRecord::Base # Return private message topics scope :private_messages, -> { where(archetype: Archetype.private_message) } - PRIVATE_MESSAGES_SQL = <<~SQL + PRIVATE_MESSAGES_SQL_USER = <<~SQL SELECT topic_id FROM topic_allowed_users WHERE user_id = :user_id - UNION ALL + SQL + + PRIVATE_MESSAGES_SQL_GROUP = <<~SQL SELECT tg.topic_id FROM topic_allowed_groups tg JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id SQL - scope :private_messages_for_user, ->(user) { - private_messages.where("topics.id IN (#{PRIVATE_MESSAGES_SQL})", user_id: user.id) - } + scope :private_messages_for_user, ->(user) do + private_messages.where( + "topics.id IN (#{PRIVATE_MESSAGES_SQL_USER}) + OR topics.id IN (#{PRIVATE_MESSAGES_SQL_GROUP})", + user_id: user.id + ) + end scope :listable_topics, -> { where('topics.archetype <> ?', Archetype.private_message) } @@ -505,7 +511,7 @@ class Topic < ActiveRecord::Base # Remove muted and shared draft categories remove_category_ids = CategoryUser.where(user_id: user.id, notification_level: CategoryUser.notification_levels[:muted]).pluck(:category_id) if SiteSetting.digest_suppress_categories.present? - remove_category_ids += SiteSetting.digest_suppress_categories.split("|").map(&:to_i) + topics = topics.where("topics.category_id NOT IN (?)", SiteSetting.digest_suppress_categories.split("|").map(&:to_i)) end if SiteSetting.shared_drafts_enabled? remove_category_ids << SiteSetting.shared_drafts_category @@ -971,16 +977,38 @@ class Topic < ActiveRecord::Base end def invite_group(user, group) - TopicAllowedGroup.create!(topic_id: id, group_id: group.id) - allowed_groups.reload + TopicAllowedGroup.create!(topic_id: self.id, group_id: group.id) + self.allowed_groups.reload - last_post = posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first + last_post = self.posts.order('post_number desc').where('not hidden AND posts.deleted_at IS NULL').first if last_post Jobs.enqueue(:post_alert, post_id: last_post.id) add_small_action(user, "invited_group", group.name) Jobs.enqueue(:group_pm_alert, user_id: user.id, group_id: group.id, post_id: last_post.id) end + # If the group invited includes the OP of the topic as one of is members, + # we cannot strip the topic_allowed_user record since it will be more + # complicated to recover the topic_allowed_user record for the OP if the + # group is removed. + allowed_user_where_clause = <<~SQL + users.id IN ( + SELECT topic_allowed_users.user_id + FROM topic_allowed_users + INNER JOIN group_users ON group_users.user_id = topic_allowed_users.user_id + INNER JOIN topic_allowed_groups ON topic_allowed_groups.group_id = group_users.group_id + WHERE topic_allowed_groups.group_id = :group_id AND + topic_allowed_users.topic_id = :topic_id AND + topic_allowed_users.user_id != :op_user_id + ) + SQL + User.where([ + allowed_user_where_clause, + { group_id: group.id, topic_id: self.id, op_user_id: self.user_id } + ]).find_each do |allowed_user| + remove_allowed_user(Discourse.system_user, allowed_user) + end + true end @@ -994,13 +1022,7 @@ class Topic < ActiveRecord::Base raise UserExists.new(I18n.t("topic_invite.user_exists")) end - if MutedUser - .where(user: target_user, muted_user: invited_by) - .joins(:muted_user) - .where('NOT admin AND NOT moderator') - .exists? - raise NotAllowed.new(I18n.t("topic_invite.muted_invitee")) - end + ensure_can_invite!(target_user, invited_by) if TopicUser .where(topic: self, @@ -1038,6 +1060,22 @@ class Topic < ActiveRecord::Base end end + def ensure_can_invite!(target_user, invited_by) + if MutedUser + .where(user: target_user, muted_user: invited_by) + .joins(:muted_user) + .where('NOT admin AND NOT moderator') + .exists? + raise NotAllowed + elsif IgnoredUser + .where(user: target_user, ignored_user: invited_by) + .joins(:ignored_user) + .where('NOT admin AND NOT moderator') + .exists? + raise NotAllowed + end + end + def email_already_exists_for?(invite) invite.email_already_exists && private_message? end @@ -1705,6 +1743,9 @@ class Topic < ActiveRecord::Base end def create_invite_notification!(target_user, notification_type, username) + invited_by = User.find_by_username(username) + ensure_can_invite!(target_user, invited_by) + target_user.notifications.create!( notification_type: notification_type, topic_id: self.id, @@ -1727,6 +1768,15 @@ class Topic < ActiveRecord::Base ).performed! end + def cannot_permanently_delete_reason(user) + if self.posts_count > 0 + I18n.t('post.cannot_permanently_delete.many_posts') + elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago + time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i) + I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left) + end + end + private def invite_to_private_message(invited_by, target_user, guardian) @@ -1787,15 +1837,6 @@ class Topic < ActiveRecord::Base def apply_per_day_rate_limit_for(key, method_name) RateLimiter.new(user, "#{key}-per-day", SiteSetting.get(method_name), 1.day.to_i) end - - def cannot_permanently_delete_reason(user) - if self.posts_count > 1 - I18n.t('post.cannot_permanently_delete.many_posts') - elsif self.deleted_by_id == user&.id && self.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago - time_left = RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i - Time.zone.now.to_i + self.deleted_at.to_i) - I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: time_left) - end - end end # == Schema Information diff --git a/app/models/topic_embed.rb b/app/models/topic_embed.rb index 502409d3c1..22739134d1 100644 --- a/app/models/topic_embed.rb +++ b/app/models/topic_embed.rb @@ -117,8 +117,8 @@ class TopicEmbed < ActiveRecord::Base follow_canonical: true, ) - url = fd.resolve - return if url.blank? + uri = fd.resolve + return if uri.blank? opts = { tags: %w[div p code pre h1 h2 h3 b em i strong a img ul li ol blockquote], @@ -132,7 +132,7 @@ class TopicEmbed < ActiveRecord::Base response = FetchResponse.new begin - html = open(url, allow_redirections: :safe).read + html = uri.read(allow_redirections: :safe) rescue OpenURI::HTTPError, Net::OpenTimeout return end @@ -256,10 +256,6 @@ class TopicEmbed < ActiveRecord::Base body end end - - def self.open(uri, **kwargs) - URI.open(uri, **kwargs) - end end # == Schema Information diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index 6758689c71..977de9c2ac 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -85,12 +85,18 @@ class TopicLink < ActiveRecord::Base FROM topic_links l LEFT JOIN topics t ON t.id = l.link_topic_id LEFT JOIN categories AS c ON c.id = t.category_id + /*left_join*/ /*where*/ ORDER BY reflection ASC, clicks DESC") builder.where('t.deleted_at IS NULL') builder.where("COALESCE(t.archetype, 'regular') <> :archetype", archetype: Archetype.private_message) + if guardian.authenticated? + builder.left_join("topic_users AS tu ON (t.id = tu.topic_id AND tu.user_id = #{guardian.user.id.to_i})") + builder.where('COALESCE(tu.notification_level,1) > :muted', muted: TopicUser.notification_levels[:muted]) + end + # not certain if pluck is right, cause it may interfere with caching builder.where('l.post_id in (:post_ids)', post_ids: posts.map(&:id)) builder.secure_category(guardian.secure_category_ids) diff --git a/app/models/translation_override.rb b/app/models/translation_override.rb index edd2b85084..85b09c0e74 100644 --- a/app/models/translation_override.rb +++ b/app/models/translation_override.rb @@ -39,6 +39,7 @@ class TranslationOverride < ActiveRecord::Base } } + include HasSanitizableFields include ActiveSupport::Deprecation::DeprecatedConstantAccessor deprecate_constant 'CUSTOM_INTERPOLATION_KEYS_WHITELIST', 'TranslationOverride::ALLOWED_CUSTOM_INTERPOLATION_KEYS' @@ -50,13 +51,15 @@ class TranslationOverride < ActiveRecord::Base def self.upsert!(locale, key, value) params = { locale: locale, translation_key: key } - data = { value: value } + translation_override = find_or_initialize_by(params) + sanitized_value = translation_override.sanitize_field(value, additional_attributes: ['data-auto-route']) + + data = { value: sanitized_value } if key.end_with?('_MF') _, filename = JsLocaleHelper.find_message_format_locale([locale], fallback_to_english: false) - data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, value) + data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, sanitized_value) end - translation_override = find_or_initialize_by(params) params.merge!(data) if translation_override.new_record? i18n_changed(locale, [key]) if translation_override.update(data) translation_override @@ -125,7 +128,6 @@ class TranslationOverride < ActiveRecord::Base if original_text original_interpolation_keys = I18nInterpolationKeysFinder.find(original_text) new_interpolation_keys = I18nInterpolationKeysFinder.find(value) - custom_interpolation_keys = [] ALLOWED_CUSTOM_INTERPOLATION_KEYS.select do |keys, value| diff --git a/app/models/user_action.rb b/app/models/user_action.rb index c512c1cef3..95a388afbe 100644 --- a/app/models/user_action.rb +++ b/app/models/user_action.rb @@ -170,6 +170,7 @@ class UserAction < ActiveRecord::Base action_type action_code action_code_who + action_code_path topic_closed topic_id topic_archived @@ -218,6 +219,7 @@ class UserAction < ActiveRecord::Base p.post_type, p.action_code, pc.value AS action_code_who, + pc2.value AS action_code_path, p.edit_reason, t.category_id FROM user_actions as a @@ -229,6 +231,7 @@ class UserAction < ActiveRecord::Base JOIN users au on au.id = a.user_id LEFT JOIN categories c on c.id = t.category_id LEFT JOIN post_custom_fields pc ON pc.post_id = a.target_post_id AND pc.name = 'action_code_who' + LEFT JOIN post_custom_fields pc2 ON pc2.post_id = a.target_post_id AND pc.name = 'action_code_path' /*where*/ /*order_by*/ /*offset*/ diff --git a/app/models/user_field.rb b/app/models/user_field.rb index b024574894..e5c3caf86d 100644 --- a/app/models/user_field.rb +++ b/app/models/user_field.rb @@ -3,6 +3,7 @@ class UserField < ActiveRecord::Base include AnonCacheInvalidator + include HasSanitizableFields validates_presence_of :description, :field_type validates_presence_of :name, unless: -> { field_type == "confirm" } @@ -10,6 +11,7 @@ class UserField < ActiveRecord::Base has_one :directory_column, dependent: :destroy accepts_nested_attributes_for :user_field_options + before_save :sanitize_description after_save :queue_index_search def self.max_length @@ -19,6 +21,14 @@ class UserField < ActiveRecord::Base def queue_index_search SearchIndexer.queue_users_reindex(UserCustomField.where(name: "user_field_#{self.id}").pluck(:user_id)) end + + private + + def sanitize_description + if description_changed? + self.description = sanitize_field(self.description) + end + end end # == Schema Information diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 574f295baa..4b2f4f62b6 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -12,6 +12,7 @@ class UserProfile < ActiveRecord::Base validates :user, presence: true before_save :cook after_save :trigger_badges + after_save :pull_hotlinked_image validate :website_domain_validator, if: Proc.new { |c| c.new_record? || c.website_changed? } @@ -19,6 +20,8 @@ class UserProfile < ActiveRecord::Base BAKED_VERSION = 1 + attr_accessor :skip_pull_hotlinked_image + def bio_excerpt(length = 350, opts = {}) return nil if bio_cooked.blank? excerpt = PrettyText.excerpt(bio_cooked, length, opts).sub(/
$/, '') @@ -113,6 +116,16 @@ class UserProfile < ActiveRecord::Base BadgeGranter.queue_badge_grant(Badge::Trigger::UserChange, user: self) end + def pull_hotlinked_image + if !skip_pull_hotlinked_image && saved_change_to_bio_raw? + Jobs.enqueue_in( + SiteSetting.editing_grace_period, + :pull_user_profile_hotlinked_images, + user_id: self.user_id + ) + end + end + private def cooked diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 8899ed0731..dbc1a1477a 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -79,6 +79,7 @@ class PostSerializer < BasicPostSerializer :is_auto_generated, :action_code, :action_code_who, + :action_code_path, :notice, :last_wiki_edit, :locked, @@ -284,7 +285,6 @@ class PostSerializer < BasicPostSerializer count = object.public_send(count_col) if object.respond_to?(count_col) summary = { id: id, count: count } - summary[:hidden] = true if sym == :vote if scope.post_can_act?(object, sym, opts: { taken_actions: actions }, can_see_post: can_see_post) summary[:can_act] = true @@ -444,6 +444,14 @@ class PostSerializer < BasicPostSerializer include_action_code? && action_code_who.present? end + def action_code_path + post_custom_fields["action_code_path"] + end + + def include_action_code_path? + include_action_code? && action_code_path.present? + end + def notice post_custom_fields[Post::NOTICE] end diff --git a/app/serializers/user_action_serializer.rb b/app/serializers/user_action_serializer.rb index a218ae8366..29ed526dc0 100644 --- a/app/serializers/user_action_serializer.rb +++ b/app/serializers/user_action_serializer.rb @@ -29,6 +29,7 @@ class UserActionSerializer < ApplicationSerializer :post_type, :action_code, :action_code_who, + :action_code_path, :edit_reason, :category_id, :closed, @@ -90,4 +91,12 @@ class UserActionSerializer < ApplicationSerializer object.action_code_who end + def include_action_code_path? + action_code_path.present? + end + + def action_code_path + object.action_code_path + end + end diff --git a/app/services/external_upload_manager.rb b/app/services/external_upload_manager.rb index e5c032b468..cfeca323c7 100644 --- a/app/services/external_upload_manager.rb +++ b/app/services/external_upload_manager.rb @@ -5,6 +5,8 @@ class ExternalUploadManager SIZE_MISMATCH_BAN_MINUTES = 5 BAN_USER_REDIS_PREFIX = "ban_user_from_external_uploads_" + UPLOAD_TYPES_EXCLUDED_FROM_UPLOAD_PROMOTION = ["backup"].freeze + class ChecksumMismatchError < StandardError; end class DownloadFailedError < StandardError; end class CannotPromoteError < StandardError; end @@ -20,29 +22,74 @@ class ExternalUploadManager Discourse.redis.get("#{BAN_USER_REDIS_PREFIX}#{user.id}") == "1" end + def self.create_direct_upload(current_user:, file_name:, file_size:, upload_type:, metadata: {}) + store = store_for_upload_type(upload_type) + url = store.signed_url_for_temporary_upload( + file_name, metadata: metadata + ) + key = store.s3_helper.path_from_url(url) + + upload_stub = ExternalUploadStub.create!( + key: key, + created_by: current_user, + original_filename: file_name, + upload_type: upload_type, + filesize: file_size + ) + + { url: url, key: key, unique_identifier: upload_stub.unique_identifier } + end + + def self.create_direct_multipart_upload( + current_user:, file_name:, file_size:, upload_type:, metadata: {} + ) + content_type = MiniMime.lookup_by_filename(file_name)&.content_type + store = store_for_upload_type(upload_type) + multipart_upload = store.create_multipart( + file_name, content_type, metadata: metadata + ) + + upload_stub = ExternalUploadStub.create!( + key: multipart_upload[:key], + created_by: current_user, + original_filename: file_name, + upload_type: upload_type, + external_upload_identifier: multipart_upload[:upload_id], + multipart: true, + filesize: file_size + ) + + { + external_upload_identifier: upload_stub.external_upload_identifier, + key: upload_stub.key, + unique_identifier: upload_stub.unique_identifier + } + end + + def self.store_for_upload_type(upload_type) + if upload_type == "backup" + if !SiteSetting.enable_backups? || SiteSetting.backup_location != BackupLocationSiteSetting::S3 + raise Discourse::InvalidAccess.new + end + BackupRestore::BackupStore.create + else + Discourse.store + end + end + def initialize(external_upload_stub, upload_create_opts = {}) @external_upload_stub = external_upload_stub @upload_create_opts = upload_create_opts + @store = ExternalUploadManager.store_for_upload_type(external_upload_stub.upload_type) end def can_promote? external_upload_stub.status == ExternalUploadStub.statuses[:created] end - def promote_to_upload! + def transform! raise CannotPromoteError if !can_promote? - external_upload_stub.update!(status: ExternalUploadStub.statuses[:uploaded]) - external_stub_object = Discourse.store.object_from_path(external_upload_stub.key) - external_etag = external_stub_object.etag - external_size = external_stub_object.size - external_sha1 = external_stub_object.metadata["sha1-checksum"] - - # This could be legitimately nil, if it's too big to download on the - # server, or it could have failed. To this end we set a should_download - # variable as well to check. - tempfile = nil - should_download = external_size < DOWNLOAD_LIMIT # We require that the file size is specified ahead of time, and compare # it here to make sure that people are not uploading excessively large @@ -56,6 +103,34 @@ class ExternalUploadManager raise SizeMismatchError.new("expected: #{external_upload_stub.filesize}, actual: #{external_size}") end + if UPLOAD_TYPES_EXCLUDED_FROM_UPLOAD_PROMOTION.include?(external_upload_stub.upload_type) + move_to_final_destination + else + promote_to_upload + end + rescue + if !SiteSetting.enable_upload_debug_mode + # We don't need to do anything special to abort multipart uploads here, + # because at this point (calling promote_to_upload!), the multipart + # upload would already be complete. + @store.delete_file(external_upload_stub.key) + external_upload_stub.destroy! + else + external_upload_stub.update(status: ExternalUploadStub.statuses[:failed]) + end + + raise + end + + private + + def promote_to_upload + # This could be legitimately nil, if it's too big to download on the + # server, or it could have failed. To this end we set a should_download + # variable as well to check. + tempfile = nil + should_download = external_size < DOWNLOAD_LIMIT + if should_download tempfile = download(external_upload_stub.key, external_upload_stub.upload_type) @@ -79,26 +154,38 @@ class ExternalUploadManager UploadCreator.new(tempfile, external_upload_stub.original_filename, opts).create_for( external_upload_stub.created_by_id ) - rescue - if !SiteSetting.enable_upload_debug_mode - # We don't need to do anything special to abort multipart uploads here, - # because at this point (calling promote_to_upload!), the multipart - # upload would already be complete. - Discourse.store.delete_file(external_upload_stub.key) - external_upload_stub.destroy! - else - external_upload_stub.update(status: ExternalUploadStub.statuses[:failed]) - end - - raise ensure tempfile&.close! end - private + def move_to_final_destination + content_type = MiniMime.lookup_by_filename(external_upload_stub.original_filename).content_type + @store.move_existing_stored_upload( + existing_external_upload_key: external_upload_stub.key, + original_filename: external_upload_stub.original_filename, + content_type: content_type + ) + Struct.new(:errors).new([]) + end + + def external_stub_object + @external_stub_object ||= @store.object_from_path(external_upload_stub.key) + end + + def external_etag + @external_etag ||= external_stub_object.etag + end + + def external_size + @external_size ||= external_stub_object.size + end + + def external_sha1 + @external_sha1 ||= external_stub_object.metadata["sha1-checksum"] + end def download(key, type) - url = Discourse.store.signed_url_for_path(external_upload_stub.key) + url = @store.signed_url_for_path(external_upload_stub.key) FileHelper.download( url, max_file_size: DOWNLOAD_LIMIT, diff --git a/app/services/post_action_notifier.rb b/app/services/post_action_notifier.rb index f58b784b50..71bf1e5d80 100644 --- a/app/services/post_action_notifier.rb +++ b/app/services/post_action_notifier.rb @@ -102,10 +102,7 @@ class PostActionNotifier return if post_revision.user.blank? return if post.topic.blank? return if post.topic.private_message? - return if SiteSetting.disable_system_edit_notifications && post_revision.user_id == Discourse::SYSTEM_USER_ID - if SiteSetting.disable_category_edit_notifications && post_revision.modifications&.dig("category_id").present? - return - end + return if notification_is_disabled?(post_revision) user_ids = [] @@ -160,4 +157,13 @@ class PostActionNotifier def self.add_post_revision_notifier_recipients(&block) custom_post_revision_notifier_recipients << block end + + private + + def self.notification_is_disabled?(post_revision) + modifications = post_revision.modifications + (SiteSetting.disable_system_edit_notifications && post_revision.user_id == Discourse::SYSTEM_USER_ID) || + (SiteSetting.disable_category_edit_notifications && modifications&.dig("category_id").present?) || + (SiteSetting.disable_tags_edit_notifications && modifications&.dig("tags").present?) + end end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index 081fd70c9b..7b49d0d158 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -37,6 +37,10 @@ class PostAlerter def self.push_notification(user, payload) return if user.do_not_disturb? + DiscoursePluginRegistry.push_notification_filters.each do |filter| + return unless filter.call(user, payload) + end + if user.push_subscriptions.exists? Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload) end diff --git a/app/services/push_notification_pusher.rb b/app/services/push_notification_pusher.rb index 3e0e4dc08a..85ff518a39 100644 --- a/app/services/push_notification_pusher.rb +++ b/app/services/push_notification_pusher.rb @@ -16,7 +16,7 @@ class PushNotificationPusher body: payload[:excerpt], badge: get_badge, icon: ActionController::Base.helpers.image_url("push-notifications/#{Notification.types[payload[:notification_type]]}.png"), - tag: "#{Discourse.current_hostname}-#{payload[:topic_id]}", + tag: payload[:tag] || "#{Discourse.current_hostname}-#{payload[:topic_id]}", base_url: Discourse.base_url, url: payload[:post_url], hide_when_active: true diff --git a/app/services/site_settings_task.rb b/app/services/site_settings_task.rb index 64c80486ff..356d8eccf1 100644 --- a/app/services/site_settings_task.rb +++ b/app/services/site_settings_task.rb @@ -16,7 +16,7 @@ class SiteSettingsTask counts = { updated: 0, not_found: 0, errors: 0 } log = [] - site_settings = YAML::load(yml) + site_settings = YAML::safe_load(yml) site_settings.each do |site_setting| key = site_setting[0] val = site_setting[1] diff --git a/app/views/layouts/ember_cli.html.erb b/app/views/layouts/ember_cli.html.erb index 0ccb6765cb..6657011071 100644 --- a/app/views/layouts/ember_cli.html.erb +++ b/app/views/layouts/ember_cli.html.erb @@ -17,7 +17,7 @@

Then visit the following URL to use Discourse:

-

http://localhost:4200

+

http://<%= Discourse.current_hostname %>:4200

diff --git a/app/views/qunit/index.html.erb b/app/views/qunit/index.html.erb index 00c92302e0..830e3d8bb5 100644 --- a/app/views/qunit/index.html.erb +++ b/app/views/qunit/index.html.erb @@ -9,7 +9,6 @@ <%= preload_script "discourse/tests/core_plugins_tests" %> <%= preload_script "discourse/tests/test_starter" %> <%= csrf_meta_tags %> - diff --git a/app/views/qunit/theme.html.erb b/app/views/qunit/theme.html.erb index 412dcb8c0d..53752cc9a2 100644 --- a/app/views/qunit/theme.html.erb +++ b/app/views/qunit/theme.html.erb @@ -21,7 +21,6 @@ <%= theme_js_lookup %> <%= theme_lookup("head_tag") %> <%= theme_tests %> - <%= preload_script_url ExtraLocalesController.url('admin') %> <%= tag.meta id: 'data-discourse-setup', data: client_side_setup_data %> diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index c1957ea31f..b69bacd64b 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -53,9 +53,10 @@ <% post_custom_fields = @topic_view.post_custom_fields[post.id] || {} who_username = post_custom_fields["action_code_who"] || "" + small_action_href = post_custom_fields["action_code_path"] || "" if post.action_code %> - <%= t("js.action_codes.#{post.action_code}", when: "", who: who_username).html_safe %> + <%= t("js.action_codes.#{post.action_code}", when: "", who: who_username, href: small_action_href).html_safe %> <% end %> diff --git a/bin/ember-cli b/bin/ember-cli index 7c0d5bca9b..a646b63970 100755 --- a/bin/ember-cli +++ b/bin/ember-cli @@ -5,14 +5,14 @@ require 'pathname' RAILS_ROOT = File.expand_path("../../", Pathname.new(__FILE__).realpath) PORT = ENV["UNICORN_PORT"] ||= "3000" - +HOSTNAME = ENV["DISCOURSE_HOSTNAME"] ||= "127.0.0.1" yarn_dir = File.join(RAILS_ROOT, "app/assets/javascripts/discourse") PROXY = if ARGV.include?("--try") "https://try.discourse.org" else - "http://localhost:#{PORT}" + "http://#{HOSTNAME}:#{PORT}" end command = diff --git a/config/database.yml b/config/database.yml index 456b8752f1..0644f3d527 100644 --- a/config/database.yml +++ b/config/database.yml @@ -12,7 +12,7 @@ development: ### If you change this setting you will need to ### - restart sidekiq if you change this setting ### - rebake all to posts using: `RAILS_ENV=production bundle exec rake posts:rebake` - - "localhost" + - "<%= ENV['DISCOURSE_HOSTNAME'] || 'localhost' %>" variables: <%= ENV.filter { |k,v| k.start_with? 'DISCOURSE_DB_VARIABLES_' } .transform_keys { |k| k.slice(('DISCOURSE_DB_VARIABLES_'.length)..).downcase } .to_json %> diff --git a/config/discourse_defaults.conf b/config/discourse_defaults.conf index eeb7412fb7..f0d34270d0 100644 --- a/config/discourse_defaults.conf +++ b/config/discourse_defaults.conf @@ -339,3 +339,9 @@ cluster_name = # The YAML file used to configure multisite clusters multisite_config_path = config/multisite.yml + +# If false, only short (regular) polling will be attempted +enable_long_polling = + +# Length of time to hold open a long polling connection in milliseconds +long_polling_interval = diff --git a/config/initializers/004-message_bus.rb b/config/initializers/004-message_bus.rb index fcc62b3f4e..f560ad2a32 100644 --- a/config/initializers/004-message_bus.rb +++ b/config/initializers/004-message_bus.rb @@ -119,8 +119,16 @@ else end MessageBus.reliable_pub_sub.max_backlog_size = GlobalSetting.message_bus_max_backlog_size -MessageBus.long_polling_enabled = SiteSetting.enable_long_polling -MessageBus.long_polling_interval = SiteSetting.long_polling_interval +if SiteSetting.table_exists? && SiteSetting.where(name: ['enable_long_polling', 'long_polling_interval']).exists? + Discourse.deprecate("enable_long_polling/long_polling_interval have switched from site settings to global settings. Remove the override from the Site Settings UI, and use a config file or environment variables to set the global settings.", drop_from: '2.9.0') + + MessageBus.long_polling_enabled = SiteSetting.enable_long_polling + MessageBus.long_polling_interval = SiteSetting.long_polling_interval +else + MessageBus.long_polling_enabled = GlobalSetting.enable_long_polling.nil? ? true : GlobalSetting.enable_long_polling + MessageBus.long_polling_interval = GlobalSetting.long_polling_interval || 25000 +end + MessageBus.cache_assets = !Rails.env.development? MessageBus.enable_diagnostics diff --git a/config/initializers/200-first_middlewares.rb b/config/initializers/200-first_middlewares.rb index ad383d6137..c8e4951bd1 100644 --- a/config/initializers/200-first_middlewares.rb +++ b/config/initializers/200-first_middlewares.rb @@ -20,7 +20,18 @@ if Rails.env != 'development' || ENV['TRACK_REQUESTS'] end end -if Rails.configuration.multisite +if Rails.env.test? + # In test mode we can't insert/remove middlewares + # Therefore we insert a small helper which effectively switches the multisite + # middleware on/off based on the Rails.configuration.multisite value + class TestMultisiteMiddleware < RailsMultisite::Middleware + def call(env) + return @app.call(env) if !Rails.configuration.multisite + super(env) + end + end + Rails.configuration.middleware.unshift TestMultisiteMiddleware, RailsMultisite::DiscoursePatches.config +elsif Rails.configuration.multisite assets_hostnames = GlobalSetting.cdn_hostnames if assets_hostnames.empty? diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index d2b293fd0e..f119f2b76e 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -4756,12 +4756,8 @@ ar: %{example} يوصى بشدة بوضع بادئة لأسماء الخصائص لتجنُّب تعارضها مع المكوِّنات الإضافية أو الأساسية. - head_tag: - text: "" - title: "HTML الذي سيتم إدراجه قبل وسم ‎‎" body_tag: - text: "" - title: "HTML الذي سيتم إدراجه قبل وسم ‎‎" + text: "النص الأساسي" yaml: text: "YAML" title: "تحديد إعدادات السمة بتنسيق YAML" @@ -5626,7 +5622,6 @@ ar: grant_existing_holders: منح شارات إضافة لحاملي الشارات الحاليين emoji: title: "الرمز التعبيري" - help: "إضافة رمز تعبيري جديد سيكون متاحًا للجميع. (تلميح احترافي: يمكنك سحب وإفلات عدة ملفات في الوقت نفسه)" add: "إضافة رمز تعبيري جديد" uploading: "جارٍ التحميل..." name: "الاسم" @@ -5637,7 +5632,6 @@ ar: embedding: get_started: "إذا كنت تريد تضمين Discourse في موقع آخر، فابدأ بإضافة مضيفه." confirm_delete: "هل تريد بالتأكيد حذف هذا المضيف؟" - sample: "استخدم رمز HTML التالي في موقعك لإنشاء موضوعات discourse وتضمينها. استبدل REPLACE_ME بالرابط الأساسي للصفحة التي تضمِّنه فيها." title: "التضمين" host: "المضيفون المسموح بهم" class_name: "اسم الفئة" diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index f45ae45b1e..b8653f4340 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -1563,8 +1563,8 @@ be: add: "Дадаць" header: text: "Header" - head_tag: - text: "<" + body_tag: + text: "цела" colors: title: "<......" copy_name_prefix: "капіяваць з" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index 804f0e1d70..5d3ab526b5 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -2444,12 +2444,8 @@ bg: text: "Футър" embedded_scss: text: "ембеднато CSS" - head_tag: - text: "" - title: "HTML код който ще бъде поставен преди тага" body_tag: - text: "" - title: "HTML код който ще бъде поставен преди тага" + text: "Тяло" colors: title: "Цветове" copy_name_prefix: "Копие на" @@ -2991,7 +2987,6 @@ bg: csv_has_unmatched_users: "Следните записи са в CSV файла, но те не могат да бъдат съчетани със съществуващи потребители и следователно няма да получат значката:" emoji: title: "Емотикони " - help: "Добави нови емотикони, които ще бъдат достъпни до всички. (Съвет: Може да дръпнете и пуснете много файлове наведнъж). " add: "Добави нова емотикона " uploading: "Качва се..." name: "Име " @@ -3001,7 +2996,6 @@ bg: embedding: get_started: "Ако искате да внедрите Discourse в друг сайт, започнете с добавянето на хоста му." confirm_delete: "Сигурни ли сте, че искате да изтриете този хост?" - sample: "Използвайте следния HTML код в сайта си, за да създадете и вградите discourse теми. Сменете REPLACE_ME с URL адреса на страницата която искате да вградите." title: "Ембедване" host: "Позволени хостове" edit: "редактирай" diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index bbe1933958..fd49707b0d 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -3596,12 +3596,8 @@ bs_BA: embedded_scss: text: "Ugrađeni CSS" title: "Unesite prilagođeni CSS za isporuku s ugrađenom verzijom komentara" - head_tag: - text: "" - title: "HTML koji će biti umetnut prije tag" body_tag: - text: "" - title: "HTML koji će biti umetnut prije tag" + text: "Tijelo" yaml: text: "YAML" title: "Definirajte postavke teme u YAML formatu" @@ -4303,7 +4299,6 @@ bs_BA: badge_query_examples_title: "Primjeri upita za oznaku" emoji: title: "Emoji" - help: "Dodajte novi emotikon koji će biti dostupan svima. (PROTIP: povlačenje i ispuštanje više datoteka odjednom)" add: "Dodaj novi emoji" uploading: "Učitava se..." name: "Ime" diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index 57c29c0223..027b34a6b6 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -3379,12 +3379,8 @@ ca: embedded_scss: text: "CSS incrustat" title: "Introduïu el CSS personalitzat que es lliurarà amb la versió incrustada dels comentaris" - head_tag: - text: "" - title: "HTML que s'inclourà abans de l'etiqueta " body_tag: - text: "" - title: "HTML que s'inclourà abans de l'etiqueta " + text: "Cos" yaml: text: "YAML" title: "Definiu la configuració de l'aparença en format YAML" @@ -4098,7 +4094,6 @@ ca: badge_query_examples_title: "Exemples de consulta d'insígnies" emoji: title: "Emoji" - help: "Afegeix un nou emoji que estarà disponible per a tothom. (CONSELL: arrossegueu i deixeu anar molts fitxers de cop)" add: "Afegeix emoji nou" uploading: "Carregant..." name: "Nom" @@ -4108,7 +4103,6 @@ ca: embedding: get_started: "Si voleu incrustar Discourse en un altre web, comenceu afegint-hi l'amfitrió." confirm_delete: "Esteu segur que voleu suprimir aquest amfitrió?" - sample: "Feu servir el següent codi HTML en el vostre lloc web per a crear i incrustar temes de Discourse. Reemplaceu REEMPLAÇA'M amb l'URL canònic de la pàgina en la qual ho esteu incrustant." title: "Incrustant" host: "Amfitrions permesos" class_name: "Nom de la classe" diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index 0c1ee1756b..e3d7eabb45 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -3215,12 +3215,8 @@ cs: embedded_scss: text: "Vloženo CSS" title: "Zadat vlastní CSS k doručení s vloženou verzí kometářů" - head_tag: - text: "" - title: "HTML které bude vloženo před štítkem" body_tag: - text: "" - title: "HTML, který bude vložený před štítek" + text: "Tělo" yaml: text: "YAML" title: "Definovat nastavení motivu ve formátu YAML" @@ -3845,7 +3841,6 @@ cs: with_time: %{username} at %{time} emoji: title: "Emoji" - help: "Vložte nové emoji, které bude dostupné pro všechny na fóru. (Protip: můžete přetáhnout několik souborů najednou.)" add: "Vložit nový Emoji" uploading: "Nahrává se..." name: "Název" @@ -3855,7 +3850,6 @@ cs: embedding: get_started: "Pokud chceš zabudovat Discourse do jiných stránek, začni s přidáním hostu." confirm_delete: "Opravdu chcete smazat tento host?" - sample: "Abyste vytvořili a zabudovali témata z discourse, použijte následující HTML kód na vašem webu. Nahraď REPLACE_ME celkovým URL stránky, do které je zabudováváš." title: "Zabudování" host: "Povolené hosty" class_name: "název třídy" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index ac82b7ed5a..aa42f1b40d 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -4070,12 +4070,8 @@ da: %{example} Præfiksering af egenskabsnavne anbefales stærkt for at undgå konflikter med plugins og/eller kernen. - head_tag: - text: "" - title: "HTML som vil blive sat ind før tagget " body_tag: - text: "" - title: "HTML som vil blive sat ind før tagget " + text: "Brødtekst" yaml: text: "YAML" title: "Definér tema indstillinger i YAML-format" @@ -4580,7 +4576,7 @@ da: title: "Fremskridt med sletning af indlæg" description: "Sletter indlæg ..." confirmation: - cancel: "Afbryd" + cancel: "Annuller" merge: button: "Flet" prompt: @@ -4591,7 +4587,7 @@ da:

Alle emner, indlæg, beskeder og andet indhold oprettet af @%{username} overføres.

target_username_placeholder: "Brugernavn på ny ejer" transfer_and_delete: "Overfør & Slet @%{username}" - cancel: "Afbryd" + cancel: "Annuller" progress: title: "Flet fremgang" confirmation: @@ -4604,7 +4600,7 @@ da:

For at fortsætte skriv: %{text}

text: "overfør @%{username} til @%{targetUsername}" transfer_and_delete: "Overfør & Slet @%{username}" - cancel: "Afbryd" + cancel: "Annuller" merging_user: "Fletter bruger..." merge_failed: "Der opstod en fejl under sammenfletning af brugere." delete_forbidden_because_staff: "Admins og moderatorer kan ikke slettes." @@ -4710,7 +4706,7 @@ da: save: "Gem" edit: "Ret" delete: "Slet" - cancel: "Afbryd" + cancel: "Annuller" delete_confirm: "Er du sikker på at du vil slette det brugerfelt?" options: "Indstillinger" required: @@ -4906,7 +4902,6 @@ da: grant_existing_holders: Tildel yderligere emblemer til eksisterende emblemindehavere emoji: title: "Humørikon" - help: "Tilføj en ny emoji, som vil være tilgængelig for alle. (PROTIP: drag & drop flere filer på én gang)" add: "Tilføj ny emoji" uploading: "Overfører…" name: "Navn" @@ -4917,7 +4912,6 @@ da: embedding: get_started: "Hvis du vil indlejre Discourse på et andet website, skal du starte med at tilføje dets server." confirm_delete: "Er du sikker på at du vil slette denne server?" - sample: "Tilføj denne HTML til dit site for at oprette og indlejre emner fra discourse. Erstat REPLACE_ME med den kanoniske URL for den side du indlejrer den på," title: "Indlejring" host: "Tilladte servere" class_name: "Klasse Navn" diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index 72c0ba0bfd..d9a1396586 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -1143,9 +1143,9 @@ de: tags: "Schlagwörter" warnings: "Offizielle Warnungen" read_more_in_group: "Möchtest Du mehr lesen? Entdecke andere Nachrichten in %{groupLink}." - read_more: "Möchtest Du mehr lesen? Durchsuche andere Nachrichten in persönliche Nachrichten." + read_more: "Möchtest Du mehr lesen? Durchsuche andere Nachrichten in persönliche Nachrichten." read_more_group_pm_MF: "Dort { UNREAD, plural, =0 {} one { ist # ungelesen } other { sind # ungelesen } } { NEW, plural, =0 {} one { {BOTH, select, true{und } false {ist } other{}} # neue Nachricht} other { {BOTH, select, true{und } false {sind } other{}} # neue Nachrichten} } verbleibend, oder durchsuche andere Nachrichten in {groupLink}" - read_more_personal_pm_MF: "Dort { UNREAD, plural, =0 {} one { ist # ungelesen } other { sind # ungelesen } } { NEW, plural, =0 {} one { {BOTH, select, true{und } false {ist } other{}} # neue Nachricht} other { {BOTH, select, true{und } false {sind } other{}} # neue Nachrichten} } verbleibend oder durchsuche andere persönliche Nachrichten" + read_more_personal_pm_MF: "Dort { UNREAD, plural, =0 {} one { ist # ungelesen } other { sind # ungelesen } } { NEW, plural, =0 {} one { {BOTH, select, true{und } false {ist } other{}} # neue Nachricht} other { {BOTH, select, true{und } false {sind } other{}} # neue Nachrichten} } verbleibend oder durchsuche andere persönliche Nachrichten" preferences_nav: account: "Konto" security: "Sicherheit" @@ -3622,6 +3622,8 @@ de: no_read_topics_title: "Du hast noch keine Themen gelesen" no_read_topics_body: "Sobald Du mit dem Lesen von Diskussionen beginnst, siehst Du hier eine Liste. Zum Lesen suche nach Themen, die Du in Top oder Kategorien oder suche nach dem Stichwort %{searchIcon}" no_group_messages_title: "Keine Gruppennachrichten gefunden" + fullscreen_table: + expand_btn: "Tabelle erweitern" admin_js: type_to_filter: "zum Filtern hier eingeben …" admin: @@ -4221,11 +4223,11 @@ de: Eigenschaften sollten mit einem Präfix versehen werden, um Konflikte mit Discourse und/oder Plug-ins zu vermeiden. head_tag: - text: "" - title: "HTML, das vor dem -Tag eingefügt wird" + text: "Kopf" + title: "HTML, das vor dem Head-Tag eingefügt wird" body_tag: - text: "" - title: "HTML, das vor dem -Tag eingefügt wird" + text: "Body" + title: "HTML, das vor dem Body-Tag eingefügt wird" yaml: text: "YAML" title: "Definiere Theme-Einstellungen im YAML-Format" @@ -5077,7 +5079,6 @@ de: grant_existing_holders: Gewähre weiteren Abzeichen an bestehende Abzeicheninhaber emoji: title: "Emoji" - help: "Neues Emoji hinzufügen, das für alle verfügbar sein wird. (TIPP: Per Drag-and-drop kannst du gleichzeitig mehrere Dateien hinzufügen)" add: "Neues Emoji hinzufügen" uploading: "Wird hochgeladen …" name: "Name" @@ -5088,7 +5089,6 @@ de: embedding: get_started: "Wenn du Discourse in einer anderen Website einbetten möchtest, beginne mit dem Hinzufügen des Hosts." confirm_delete: "Möchtest du wirklich diesen Host löschen?" - sample: "Verwende den folgenden HTML-Code auf deiner Website, um Discourse-Themen zu erstellen und einzubetten. Ersetze REPLACE_ME mit der URL der Seite, auf der du ihn einbettest." title: "Einbettung" host: "Erlaubte Hosts" class_name: "Klassenname" diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index 3d2c722833..325c1c775a 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -3643,12 +3643,8 @@ el: color_definitions: text: "Ορισμοί χρωμάτων" title: "Εισαγωγή προσαρμοσμένων ορισμών χρωμάτων (μόνο για προχωρημένους χρήστες)" - head_tag: - text: "" - title: "HTML το οποίο θα προστίθεται πριν το tag" body_tag: - text: "" - title: "HTML το οποίο θα προστίθεται πριν το tag" + text: "Body" yaml: text: "YAML" title: "Ορισμός ρυθμίσεων θέματος σε μορφή YAML" @@ -4409,7 +4405,6 @@ el: replace_owners: Αφαιρέστε το σήμα από προηγούμενους κατόχους emoji: title: "Emoji" - help: "Πρόσθεσε ένα νέο emoji που θα είναι διαθέσιμο σε όλους. (Συμβουλή: μπορείς να σύρεις πολλά αρχεία ταυτόχρονα)" add: "Προσθήκη νέου emoji" uploading: "Ανεβαίνει..." name: "Όνομα" @@ -4420,7 +4415,6 @@ el: embedding: get_started: "Εάν θέλεις να ενσωματώσεις το Discourse σε μια άλλη ιστοσελίδα, ξεκίνα με το να προσθέσεις το host του." confirm_delete: "Είσαι βέβαιος πως θέλεις να διαγράψεις το host;" - sample: "Χρησιμοποιήστε τον ακόλουθο HTML κώδικα στον ιστότοπό σας για να ενθέσετε θέματα. Αντικαταστήστε REPLACE_ME με το κανονικό URL της σελίδας στην οποία θα ενθέσετε το θέμα. " title: "Ενσωμάτωση" host: "Επιτρεπόμενα hosts" class_name: "Class Name" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2054b24c4d..a50b1a6e19 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1219,7 +1219,7 @@ en: tags: "Tags" warnings: "Official Warnings" read_more_in_group: "Want to read more? Browse other messages in %{groupLink}." - read_more: "Want to read more? Browse other messages in personal messages." + read_more: "Want to read more? Browse other messages in personal messages." read_more_group_pm_MF: "There { UNREAD, plural, @@ -1249,7 +1249,7 @@ en: =0 {} one { {BOTH, select, true{and } false {is } other{}} # new message} other { {BOTH, select, true{and } false {are } other{}} # new messages} - } remaining, or browse other personal messages" + } remaining, or browse other personal messages" preferences_nav: account: "Account" @@ -2234,6 +2234,9 @@ en: reload: "Reload" ignore: "Ignore" + image_alt_text: + aria_label: Alt text for image + notifications: tooltip: regular: @@ -3765,6 +3768,9 @@ en: changed: "tags changed:" tags: "Tags" choose_for_topic: "optional tags" + choose_for_topic_required: + one: "select at least %{count} tag..." + other: "select at least %{count} tags..." info: "Info" default_info: "This tag isn't restricted to any categories, and has no synonyms. To add restrictions, put this tag in a tag group." category_restricted: "This tag is restricted to categories you don't have permission to access." @@ -3938,6 +3944,9 @@ en: no_group_messages_title: "No group messages found" + fullscreen_table: + expand_btn: "Expand Table" + # This section is exported to the javascript for i18n in the admin section admin_js: type_to_filter: "type to filter..." @@ -4138,6 +4147,7 @@ en: no_description: (no description) all_api_keys: All API Keys user_mode: User Level + scope_mode: Scope impersonate_all_users: Impersonate any user single_user: "Single User" user_placeholder: Enter username @@ -4148,12 +4158,15 @@ en: delete: Permanently Delete not_shown_again: This key will not be displayed again. Make sure you take a copy before continuing. continue: Continue - use_global_key: Global Key (allows all actions) scopes: description: | When using scopes, you can restrict an API key to a specific set of endpoints. You can also define which parameters will be allowed. Use commas to separate multiple values. title: Scopes + granular: Granular + read_only: Read-only + global: Global + global_description: API key has no restriction and all endpoints are accessible. resource: Resource action: Action allowed_parameters: Allowed Parameters @@ -4161,6 +4174,8 @@ en: any_parameter: (any parameter) allowed_urls: Allowed URLs descriptions: + global: + read: Restrict API key to read-only endpoints. 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. @@ -4552,11 +4567,11 @@ en: Prefixing the property names is highly recommended to avoid conflicts with plugins and/or core. head_tag: - text: "" - title: "HTML that will be inserted before the tag" + text: "Head" + title: "HTML that will be inserted before the head tag" body_tag: - text: "" - title: "HTML that will be inserted before the tag" + text: "Body" + title: "HTML that will be inserted before the body tag" yaml: text: "YAML" title: "Define theme settings in YAML format" @@ -5434,7 +5449,7 @@ en: emoji: title: "Emoji" - help: "Add new emoji that will be available to everyone. (PROTIP: drag & drop multiple files at once)" + help: "Add new emoji that will be available to everyone. Drag and drop multiple files at once without entering a name to create emojis using their file names." add: "Add New Emoji" uploading: "Uploading..." name: "Name" @@ -5446,7 +5461,7 @@ en: embedding: get_started: "If you'd like to embed Discourse on another website, begin by adding its host." confirm_delete: "Are you sure you want to delete that host?" - sample: "Use the following HTML code into your site to create and embed discourse topics. Replace REPLACE_ME with the canonical URL of the page you are embedding it on." + sample: "Paste the following HTML code into your site to create and embed discourse topics. Replace REPLACE_ME with the canonical URL of the page you are embedding it on." title: "Embedding" host: "Allowed Hosts" class_name: "Class Name" diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index f69b66fa0a..382321e190 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -283,6 +283,7 @@ es: bookmarks: created: "Has guardado esta publicación en marcadores. %{name}" not_bookmarked: "guarda esta publicación en marcadores" + remove_reminder_keep_bookmark: "Quitar recordatorio y mantener marcador" created_with_reminder: "Has marcado esta publicación con un recordatorio %{date}. %{name}" remove: "Eliminar marcador" delete: "Eliminar marcador" @@ -1142,7 +1143,6 @@ es: tags: "Etiquetas" warnings: "Advertencias oficiales" read_more_in_group: "¿Quieres leer más? Consulta otros mensajes de %{groupLink}." - read_more: "¿Quieres leer más? Echa un vistazo a otros mensajes dentro de tus mensajes personales." preferences_nav: account: "Cuenta" security: "Seguridad" @@ -2158,6 +2158,7 @@ es: tags: "Etiquetas" in: "en" in_this_topic: "en este tema" + in_this_topic_tooltip: "cambiar a buscar en todos los temas" in_topics_posts: "en todos los temas y publicaciones" enter_hint: "o pulsa Intro" in_posts_by: "en publicaciones por %{username}" @@ -2185,7 +2186,7 @@ es: label: Publicado por aria_label: Filtrar por autor in_category: - label: Categorizado + label: Categoría in_group: label: En el grupo with_badge: @@ -3618,6 +3619,8 @@ es: no_read_topics_title: "Todavía no has leído ningún tema" no_read_topics_body: "Cuando empieces a leer temas, los verás aquí en una lista. Puedes empezar a buscar temas que te interesen en Destacados, la lista de Categorías o buscando palabras %{searchIcon}" no_group_messages_title: "No hay ningún mensaje en el grupo" + fullscreen_table: + expand_btn: "Expandir tabla" admin_js: type_to_filter: "filtrar opciones..." admin: @@ -4216,11 +4219,9 @@ es: Se recomienda poner prefijos a los nombres de propiedad para evitar conflictos con plugins o el core head_tag: - text: "" - title: "HTML a insertar antes de la etiqueta " + text: "Cabecera" body_tag: - text: "" - title: "HTML a insertar antes de la etiqueta " + text: "Cuerpo" yaml: text: "YAML" title: "Definir ajustes al tema en formato YAML" @@ -5072,7 +5073,6 @@ es: grant_existing_holders: Conceder una vez más la medalla a las cuentas que ya la tengan emoji: title: "Emoji" - help: "Añade emojis nuevos que estarán disponibles para todos. (CONSEJO: arrasta varios archivos a la vez)" add: "Añadir emoji nuevo" uploading: "Subiendo..." name: "Nombre" @@ -5083,7 +5083,6 @@ es: embedding: get_started: "Si quieres insertar Discourse en otro sitio web, empieza por añadir su host." confirm_delete: "¿Seguro que quieres eliminar este host?" - sample: "Usa el siguiente código HTML en tu sitio para crear e insertar temas. Reempalza REPLACE_ME con la URL canónica de la página donde lo quieres incrustar." title: "Incrustado" host: "Hosts permitidos" class_name: "Nombre de clase" diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 3337d90df3..3feda31cbd 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -2899,12 +2899,8 @@ et: text: "Jalus" embedded_scss: text: "Sängitatud CSS" - head_tag: - text: "" - title: "HTML, mida lisatakse enne silti" body_tag: - text: "" - title: "HTML, mida lisatakse enne silti" + text: "Sisu" yaml: text: "YAML" colors: @@ -3485,7 +3481,6 @@ et: with_time: %{username} ajal %{time} emoji: title: "Emotikon" - help: "Lisa uued emotikonid kõigile kasutamiseks. (PRO-vihje: pukseeri korraga mitu faili)" add: "Lisa uus emotikon" uploading: "Laen üles..." name: "Nimi" @@ -3495,7 +3490,6 @@ et: embedding: get_started: "Kui Sa soovid lisada Discourse teise veebisaidi sisse ehk sängitada, alusta lisades oma teenusepakkuja" confirm_delete: "Oled Sa kindel, et soovid kustutada selle teenusepakkuja?" - sample: "Kasuta järgnevat HTML koodi oma saidil, et luua ja lisada/sängitada discourse teemad oma saidile. Asenda REPLACE_ME kanoonilise lehe URL-viitega, kuhu Sa seda lisad." title: "Sängitamine" host: "Lubatud hostid" class_name: "Klassi nimi" diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index e525caa2d4..11a94f1789 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -1807,6 +1807,8 @@ fa_IR: label: "فعالسازی بالا آوردن مبحث در لیست" desc: "بدون تغییر زمان اخرین پاسخ، پاسخ دهید." ignore: "چشم پوشی" + image_alt_text: + aria_label: متن Alt برای عکس notifications: tooltip: regular: @@ -3480,12 +3482,8 @@ fa_IR: embedded_scss: text: "کد CSS جاسازی شده" title: "کد CSS سفارشی مد نظرتان را اینجا وارد کنید" - head_tag: - text: "" - title: "کد HTML که قبل از برچسب اضافه می‌شود" body_tag: - text: "" - title: "کد HTML که قبل از برچسب اضافه می‌شود" + text: "بدنه" yaml: text: "YAML" colors: @@ -4076,7 +4074,6 @@ fa_IR: with_time: %{username} در %{time} emoji: title: "شکلک" - help: "اضافه کردن شکلک های جدید که در دسترس همگان خواهد بود.(PROTIP: کشیدن و رها کردن چند فایل در یک بار)" add: "افزودن شکلک جدید" uploading: "در حال بار گذاری ..." name: "نام" @@ -4086,7 +4083,6 @@ fa_IR: embedding: get_started: "اگر مایل هستید که Discourse را بر روی یک وبسایت دیگر جاساز کنید, با اضافه کردن میزبان یا همان هاست آن وبسایت شروع کنید." confirm_delete: "آیا مطمئن هستید که میخواهید آن میزبان را حذف کنید؟" - sample: "از این کد HTML در سایت خود استفاده کنید تا بتوانید مبحث های Discourse ایجاد کنید یا جاساز کنید, این کد REPLACE_ME را به URL استاندارد صفحه ای که بر روی آن جاسازی میکنید تغییر دهید." title: "جاسازی" host: "میزبان های مجاز" class_name: "نام کلاس" diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index 1c9774c646..27f1537f70 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -4098,12 +4098,8 @@ fi: %{example} Etuliitteen lisääminen CSS-kuvauksiin on erittäin suositeltavaa, jotta vältät ristiriidat lisäosien tai ydinsovelluksen kanssa. - head_tag: - text: "" - title: "HTML, joka sijoitetaan ennen -tunnistetta" body_tag: - text: "" - title: "HTML, joka sijoitetaan ennen -tunnistetta" + text: "Leipäteksti" yaml: text: "YAML" title: "Määritä teeman asetukset YAML-muodossa" @@ -4938,7 +4934,6 @@ fi: replace_owners: Poista kunniamerkki aiemmilta omistajilta emoji: title: "Emoji" - help: "Lisää uusi emoji, joka on kaikkien käytettävissä. (Vinkki: voit vetää ja pudottaa useita tiedostoja kerralla)" add: "Lisää uusi emoji" uploading: "Ladataan..." name: "Nimi" @@ -4949,7 +4944,6 @@ fi: embedding: get_started: "Jos haluat upottaa Discoursen toiselle sivustolle, aloita lisäämällä sen isäntä." confirm_delete: "Oletko varma, että haluat poistaa tämän isännän?" - sample: "Käytä alla olevaa HTML-koodia sivustollasi, jotta voit luoda ja upottaa discourse-ketjuja. Korvaa REPLACE_ME upotettavan sen sivun kanonisella URL-osoitteella, jolle upotat." title: "Upottaminen" host: "Sallitut isännät" class_name: "Luokan nimi" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index a16ada026c..9d7163cd51 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -4096,12 +4096,8 @@ fr: %{example} Il est fortement recommandé de préfixer les noms des propriétés CSS pour éviter tout conflit avec Discourse ou ses extensions. - head_tag: - text: "" - title: "HTML qui sera inséré avant la balise " body_tag: - text: "" - title: "HTML qui sera inséré avant la balise " + text: "Corps" yaml: text: "YAML" title: "Définir les paramètres du thème au format YAML" @@ -4936,7 +4932,6 @@ fr: replace_owners: Retirer le badge des propriétaires précédents emoji: title: "Émoji" - help: "Ajoutez de nouveaux émojis qui seront disponibles pour tout le monde. (Conseil : glissez-déposez plusieurs fichiers en même temps)" add: "Ajouter un nouvel émoji" uploading: "Envoi en cours…" name: "Nom" @@ -4947,7 +4942,6 @@ fr: embedding: get_started: "Si vous souhaitez intégrer Discourse dans un autre site, commencez par ajouter son hôte." confirm_delete: "Voulez-vous vraiment supprimer cet hôte ?" - sample: "Introduire le code HTML suivant dans votre site pour créer et intégrer des sujets Discourse. Remplacer REPLACE_ME avec l'URL de la page dans laquelle vous souhaitez l'intégrer." title: "Intégration externe" host: "Hôtes autorisés" class_name: "Nom de classe" diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index 2590db6297..42c757aacc 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -3970,12 +3970,8 @@ gl: } Prefixar os nomes da propiedade resulta altamente recomendábel para evitarmos conflitos con complementos e/ou o núcleo. - head_tag: - text: "" - title: "HTML que se inserirá antes da etiqueta " body_tag: - text: "" - title: "HTML que se inserirá antes da etiqueta " + text: "Corpo" yaml: text: "YAML" title: "Definir os axustes do tema en formato YAML" @@ -4784,7 +4780,6 @@ gl: replace_owners: Retirar a insignia dos propietarios anteriores emoji: title: "Emoji" - help: "Engada un novo emoji que estará dispoñíbel para todos. (Suxestión: arrastre e solte múltiples ficheiros dunha vez)" add: "Engadir novo emoji" uploading: "Cargando..." name: "Nome" @@ -4795,7 +4790,6 @@ gl: embedding: get_started: "Se quere incorporar o Discourse noutro sitio web, comece engadindo o seu servidor." confirm_delete: "Confirma a eliminación deste servidor?" - sample: "Utilice o seguinte código HTML no seu sitio para crear e incorporar temas do Discourse. Substitúa REPLACE_ME polo URL canónico da páxina na que o está a incorporar." title: "Incorporando" host: "Servidores permitidos" class_name: "Nome da clase" diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index 0e3c66bb49..78cd0a2e7e 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -1096,15 +1096,15 @@ he: notifications: "התראות" statistics: "סטטיסטיקות" desktop_notifications: - label: "התראות" + label: "התראות חיות" not_supported: "התראות לא נתמכות בדפדפן זה. עמך הסליחה." perm_default: "הפעלת התראות" perm_denied_btn: "הרשאות נדחו" perm_denied_expl: "דחית הרשאה לקבלת התראות. יש לאפשר התראות בהגדרות הדפדפן שלך." - disable: "כבוי התראות" - enable: "אפשר התראות" + disable: "השבתת התראות" + enable: "הפעלת התראות" each_browser_note: 'הערה: עליך לשנות הגדרה זו בכל דפדפן שבו אתה משתמש. כל ההודעות יושבתו במצב "אל תפריע", ללא קשר להגדרה זו.' - consent_prompt: "האם ברצונך לקבל התראות כשאנשים מגיבים לפוסטים שלך?" + consent_prompt: "לקבל התראות חיות כשמתקבלות תגובות לפוסטים שלך?" dismiss: "דחה" dismiss_notifications: "בטלו הכל" dismiss_notifications_tooltip: "סימון כל ההתראות שלא נקראו כהתראות שנקראו" @@ -1182,7 +1182,7 @@ he: watched_first_post_tags_instructions: "אתם תיודעו לגבי הפוסט הראשון בכל נושא חדש בתגיות אלו." muted_categories: "מושתק" muted_categories_instructions: "לא תקבל הודעה בנוגע לנושאים חדשים בקטגוריות אלה, והם לא יופיעו בקטגוריות או בדפים האחרונים." - muted_categories_instructions_dont_hide: "לא תישלחנה אליך התראות על שום דבר בנוגע לנושאים בקטגוריות האלו." + muted_categories_instructions_dont_hide: "לא תישלחנה אליך התראות על שום דבר שנוגע לנושאים בקטגוריות האלו." regular_categories: "רגילות" regular_categories_instructions: "הקטגוריות האלו תופענה תחת רשימות הנושאים „אחרונים” ו־„מובילים”." no_category_access: "בתור פיקוח יש לך גישה מוגבלת לקטגוריות, שמירה מושבתת." @@ -1244,7 +1244,7 @@ he: tags: "תגיות" warnings: "אזהרות רשמיות" read_more_in_group: "מעניין אותך לקרוא עוד? אפשר לעיין בהודעות אחרות ב־%{groupLink}." - read_more: "מעניין אותך לקרוא עוד? אפשר לעיין בהודעות אחרות בהודעות הפרטיות." + read_more: "מעניין אותך לקרוא עוד? אפשר לעיין בהודעות אחרות בהודעות הפרטיות." preferences_nav: account: "חשבון" security: "אבטחה" @@ -1404,7 +1404,7 @@ he: other: "נשלח לך הודעה בדוא״ל רק אם לא הופעת ב־%{count} הדקות האחרונות." associated_accounts: title: "חשבונות מקושרים" - connect: "התחבר" + connect: "התחברות" revoke: "בטל" cancel: "ביטול" not_connected: "(לא מחובר)" @@ -1871,7 +1871,7 @@ he: awaiting_activation: "החשבון שלך ממתין להפעלה, נא להשתמש בקישור „שכחתי ססמה” כדי לשלוח הודעת הפעלה נוספת." awaiting_approval: "החשבון שלך טרם אושר על ידי חבר סגל. תישלח אליך הודעה בדוא״ל כשהוא יאושר." requires_invite: "סליחה, גישה לפורום הזה היא בהזמנה בלבד." - not_activated: "אינך יכול להתחבר עדיין. שלחנו לך דואר אלקטרוני להפעלת החשבון לכתובת: %{sentTo}. יש לעקוב אחר ההוראות בדואר כדי להפעיל את החשבון." + not_activated: "עוד אין לך אפשרות להיכנס. שלחנו אליך הודעת הפעלה בדוא״ל אל %{sentTo}. נא לעקוב אחר ההוראות שבהודעה כדי להפעיל את החשבון שלך." not_allowed_from_ip_address: "הכניסה מכתובת IP זו אסורה." admin_not_allowed_from_ip_address: "הכניסה לניהול מכתובת IP זו אסורה." resend_activation_email: "יש ללחוץ כאן לשליחת דואר אלקטרוני חוזר להפעלת החשבון." @@ -2182,6 +2182,8 @@ he: desc: "הגב מבלי לשנות את תאריך התגובה האחרונה" reload: "רענון" ignore: "התעלמות" + image_alt_text: + aria_label: כיתוב חלופי לתמונה notifications: tooltip: regular: @@ -3900,6 +3902,8 @@ he: no_topics_title: "עדיין לא פתחת אף נושא" no_read_topics_title: "טרם קראת נושאים" no_group_messages_title: "לא נמצאו הודעות קבוצתיות" + fullscreen_table: + expand_btn: "הרחבת טבלה" admin_js: type_to_filter: "הקלידו לסינון..." admin: @@ -4510,11 +4514,11 @@ he: מומלץ מאוד להוסיף קידומת לשמות המאפיינים כדי להימנע מסתירות מול תוספים ו/או הליבה. head_tag: - text: "" - title: "HTML שיוכנס לפני התג " + text: "Head" + title: "HTML שיתווסף לפני התגית ‎head" body_tag: - text: "" - title: "HTML שיוכנס לפני התג " + text: "Body" + title: "HTML שיתווסף לפני התגית body" yaml: text: "YAML" title: "עריכת הגדרות ערכת העיצוב בתצורת YAML" @@ -5375,7 +5379,7 @@ he: grant_existing_holders: הענקת עיטורים נוספים למחזיקים בעיטורים קיימים emoji: title: "אמוג׳י" - help: "הוספת אמוג׳י חדשים שיהיו זמינים לכולם. (עצה למקצוענים: עדיף לגרור לכאן כמה קבצים בבת אחת)" + help: "הוספת אמוג׳י חדש שיהיה זמין לכולם. אפשר לגרור ולשחרר לכאן מגוון קבצים בבת אחת ללא מילוי שם ליצירת אמוג׳ים לפי שמות הקבצים שלהם." add: "הוספת אמוג׳י חדש" uploading: "בהליכי העלאה..." name: "שם" @@ -5386,7 +5390,7 @@ he: embedding: get_started: "כדי להטמיע את Discourse באתר אחר, יש להתחיל בהוספת השרת שלו." confirm_delete: "האם ברצונכם להסיר את הhost הזה? " - sample: "יש להשתמש בקוד ה־HTML הבא באתר שלך כדי ליצור ולהטמיע נושאים של Discourse. עליך להחליף את REPLACE_ME בכתובת היחסית של העמוד בו תתבצע ההטמעה." + sample: "יש להדביק את קוד ה־HTML הבא באתר שלך כדי ליצור ולהטמיע נושאים של Discourse. עליך להחליף את REPLACE_ME בכתובת היחסית של העמוד בו תתבצע ההטמעה." title: "שילוב (embedding)" host: "שרתים מורשים" class_name: "שם מחלקה" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index c041b1de34..0e888afd07 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -283,6 +283,7 @@ hu: bookmarks: created: "Könyvjelzőzte ezt a bejegyzést. %{name}" not_bookmarked: "bejegyzés könyvjelzőzése" + remove_reminder_keep_bookmark: "Emlékeztető eltávolítása és könyvjelző megtartása" created_with_reminder: "Könyvjelzőzte ezt a bejegyzést, és emlékeztetőt állított be ekkorra: %{date}. %{name}" remove: "Könyvjelző eltávolítása" delete: "Könyvjelző törlése" @@ -1085,6 +1086,7 @@ hu: watched_first_post_tags_instructions: "Csak az új témakörök legelső hozzászólásáról fog értesítést kapni ezekben a címkékben." muted_categories: "Némított" muted_categories_instructions: "Semmilyen értesítést nem fog kapni a kategória témaköreiről, és a kategóriák vagy a legújabbak között sem fog megjelenni." + muted_categories_instructions_dont_hide: "Nem fog értesítést kapni az új témákról ezekben a kategóriákban." regular_categories: "Átlagos" no_category_access: "Moderátorként korlátozott kategória-hozzáférése van, a mentés tiltott." delete_account: "Saját fiók törlése" @@ -1140,9 +1142,7 @@ hu: tags: "Címkék" warnings: "Hivatalos figyelmeztetések" read_more_in_group: "Szeretne többet olvasni? Nézzen meg más témákat itt: %{groupLink}." - read_more: "Szeretne többet olvasni? Böngésszen a többi üzenet között a személyes üzenetekben." read_more_group_pm_MF: "{ UNREAD, plural, =0 {} one {# olvasatlan } other {# olvasatlan } } { NEW, plural, =0 {} one { {BOTH, select, true{és} false{} other{}} # új üzenet van hátra, vagy böngésszen } other { {BOTH, select, true{és} false{Böngésszen} other{}} # új üzenet van hátra, vagy böngésszen} } más üzeneteket ebben a csoportban: {groupLink}" - read_more_personal_pm_MF: "{ UNREAD, plural, =0 {} one {# olvasatlan } other {# olvasatlan } } { NEW, plural, =0 {} one { {BOTH, select, true{és} false{} other{}} # új üzenet van hátra, vagy böngésszen } other { {BOTH, select, true{és} false{Böngésszen} other{}} # új üzenet van hátra, vagy böngésszen} } más személyes üzeneteket" preferences_nav: account: "Fiók" security: "Biztonság" @@ -1281,6 +1281,7 @@ hu: auth_override_instructions: "Az e-mail-címet a hitelesítésszolgáltató tudja frissíteni." no_secondary: "Nincsenek másodlagos e-mail-címek" instructions: "Sosem jelenik meg nyilvánosan." + admin_note: "Megjegyzés: Ha egy rendszergazda felhasználó megváltoztatja egy nem rendszergazda felhasználó e-mail-címét, az azt jelzi, hogy a felhasználó elveszítette az eredeti e-mail-fiókját, ezért jelszó-visszaállítási e-mail lesz küldve az új címére. A felhasználó e-mail-címe addig nem változik meg, míg be nem fejezi a jelszó-visszaállítási folyamatot." ok: "E-mailt fogunk küldeni a megerősítéshez" required: "Adjon meg egy e-mail-címet" invalid: "Adjon meg egy érvényes e-mail-címet" @@ -1297,10 +1298,15 @@ hu: revoke: "Visszavonás" cancel: "Mégse" not_connected: "(nincs csatlakoztatva)" + confirm_modal_title: "%{provider}-fiók összekapcsolása" + confirm_description: + disconnect: "A meglévő „%{account_description}” %{provider}-fiókja le lesz választva." + account_specific: "Az Ön „%{account_description}” %{provider}-fiókja lesz használva a hitelesítéshez." name: title: "Név" instructions: "a teljes neve (nem kötelező)" instructions_required: "Az Ön teljes neve" + required: "Adjon meg egy nevet" too_short: "A neve túl rövid" ok: "A neve megfelelő." username: @@ -1314,6 +1320,8 @@ hu: too_long: "A felhasználóneve túl hosszú" checking: "A felhasználónév elérhetőségének ellenőrzése…" prefilled: "Az e-mail-cím megfelel ennek a regisztrált felhasználónévnek" + required: "Adjon meg egy felhasználónevet" + edit: "Felhasználónév szerkesztése" locale: title: "A felület nyelve" instructions: "A felhasználói felület nyelve. A lap frissítése után fog módosulni." @@ -1482,6 +1490,10 @@ hu: bulk_invite: none: "Nincs megjeleníthető meghívó ezen az oldalon." text: "Tömeges meghívás" + instructions: | +

Hívjon meg gyorsan felhasználókat egy listáról, hogy gyorsan összeállítsa a közösséget. Készítsen egy CSV-fájlt amely legalább sort tartalmaz a meghívandó felhasználók e-mail-címével. A következő vesszővel elválasztott információk adhatók meg, ha csoportokhoz is szeretné rendelni az embereket, vagy egy konkrét témához akarja őket küldeni az első bejelentkezésükkor.

+
email@example.com,első_csoport_neve;második_csoport_neve;témaazonosító
+

Minden a feltöltött CSV-fájlban lévő e-mail-címre egy meghívó lesz küldve, és ezeket majd később kezelheti.

progress: "%{progress}% feltöltve…" success: "A fájl sikeresen feltöltve. Értesítést fog kapni., ha a folyamat befejeződött." error: "Sajnáljuk, a fájlnak CSV formátumúnak kell lennie." @@ -1493,6 +1505,7 @@ hu: same_as_email: "A jelszava megegyezik az e-mail-címével." ok: "A jelszava megfelelőnek tűnik." instructions: "legalább %{count} karakter" + required: "Adjon meg egy jelszót" summary: title: "Összefoglaló" stats: "Statisztikák" @@ -1547,11 +1560,16 @@ hu: avatar: title: "Profilkép" header_title: "profil, üzenetek, könyvjelzők és beállítások" + name_and_description: "%{name} – %{description}" + edit: "Profilkép szerkesztése" title: title: "Cím" none: "(egyik sem)" + instructions: "a felhasználónév után jelenik meg" flair: + title: "Színesítő" none: "(egyik sem)" + instructions: "a profilkép mellett megjelenített ikon" primary_group: title: "Elsődleges csoport" none: "(egyik sem)" @@ -1584,7 +1602,9 @@ hu: fixed: "Oldal betöltése" modal: close: "bezárás" + dismiss_error: "Hiba elvetése" close: "Bezárás" + assets_changed_confirm: "Ez az oldal most kapott egy szoftverfrissítést. Beszerzi most a legújabb verziót?" logout: "Kijelentkezett." refresh: "Frissítés" home: "Kezdőlap" @@ -1592,14 +1612,27 @@ hu: enabled: "Az oldal írásvédett módban van. Nyugodtan folytathatja a böngészést, de a válasz, kedvelés és egyéb műveletek egyelőre le vannak tiltva." login_disabled: "A belépés le van tiltva, amíg az oldal írásvédett módban van." logout_disabled: "A kilépés le van tiltva, amíg az oldal írásvédett módban van." + too_few_topics_and_posts_notice_MF: >- + Kezdjük el a beszélgetést! Jelenleg {currentTopics, plural, one {# téma} other {# téma}} és {currentPosts, plural, one {# bejegyzés} other {# bejegyzés}} található. A látogatóknak több olvasnivalóra van szükségük – azt javasoljuk, hogy legalább {requiredTopics, plural, one {# téma} other {# téma}} és {requiredPosts, plural, one {# bejegyzés} other {# bejegyzés}} legyen. Csak a stáb láthatja ezt az üzenetet. + too_few_topics_notice_MF: >- + Kezdjük el a beszélgetést! Jelenleg {currentTopics, plural, one {# téma} other {# téma}} található. A látogatóknak több olvasnivalóra van szükségük – azt javasoljuk, hogy legalább {requiredTopics, plural, one {# téma} other {# téma}} legyen. Csak a stáb láthatja ezt az üzenetet. + too_few_posts_notice_MF: >- + Kezdjük el a beszélgetést! Jelenleg {currentPosts, plural, one {# bejegyzés} other {# bejegyzés}} található. A látogatóknak több olvasnivalóra van szükségük – azt javasoljuk, hogy legalább {requiredPosts, plural, one {# bejegyzés} other {# bejegyzés}} legyen. Csak a stáb láthatja ezt az üzenetet. + logs_error_rate_notice: + reached_hour_MF: "{relativeAge}{rate, plural, one {# hiba/óra} other {# hiba/óra}}, elérte az oldal beállításiban megadott {limit, plural, one {# hiba/órás} other {# hiba/órás}} korlátot." + reached_minute_MF: "{relativeAge}{rate, plural, one {# hiba/perc} other {# hiba/perc}}, elérte az oldal beállításiban megadott {limit, plural, one {# hiba/perces} other {# hiba/perces}} korlátot." + exceeded_hour_MF: "{relativeAge}{rate, plural, one {# hiba/óra} other {# hiba/óra}}, túllépte az oldal beállításiban megadott {limit, plural, one {# hiba/órás} other {# hiba/órás}} korlátot." + exceeded_minute_MF: "{relativeAge}{rate, plural, one {# hiba/perc} other {# hiba/perc}}, túllépte az oldal beállításiban megadott {limit, plural, one {# hiba/perces} other {# hiba/perces}} korlátot." learn_more: "tudjon meg többet…" first_post: Első bejegyzés mute: Némítás unmute: Némítás feloldása last_post: Közzétett + local_time: "Helyi idő" time_read: Olvasott time_read_recently: "%{time_read} mostanában" time_read_tooltip: "%{time_read} olvasással töltött idő" + time_read_recently_tooltip: "%{time_read} olvasással töltött idő (%{recent_time_read} az elmúlt 60 napban)" last_reply_lowercase: utolsó válasz replies_lowercase: one: válasz @@ -1608,6 +1641,7 @@ hu: sign_up: "Regisztráció" hide_session: "Emlékeztessen holnap" hide_forever: "nem, köszönöm" + hidden_for_session: "Rendben, holnap megkérdezzük. Bármikor használhatja a „Bejelentkezés” gombot, hogy fiókot készítsen magának." intro: "Szia! Úgy tűnik, tetszik neked a fórumunk, de még nem regisztráltál fiókot." value_prop: "Onnantól, hogy létrehozol egy fiókot, a rendszer emlékezni fog arra, hogy mit olvastál, így mindig oda térhetsz vissza, ahol korábban abbahagytad. Értesítéseket is kapsz, itt is és e-mailben is, valahányszor valaki válaszol neked. A bejegyzéseket pedig kedvelheted is. :heartpulse:" summary: @@ -1615,8 +1649,11 @@ hu: description: one: "%{count} válasz van." other: "%{count} válasz van." + description_time_MF: "{replyCount, plural, one {is # válasz} other {are # válasz}} található, a becsült olvasási idő {readingTime, plural, one {# perc} other {# perc}}." enable: "Téma összefoglalása" disable: "Összes bejegyzés megjelenítése" + short_label: "Összefoglalás" + short_title: "A téma összefoglalásának megjelenítése: a legérdekesebb bejegyzéseket a közösség határozta meg" deleted_filter: enabled_description: "Ez a téma törölt bejegyzéseket is tartalmaz, melyek el lettek rejtve." disabled_description: "A téma törölt bejegyzései is megjelennek." @@ -1644,6 +1681,7 @@ hu: disclaimer: "Csak akkor regisztráljon, ha elfogadja az adatvédelmi szabályzatot és a szolgáltatási feltételeket." title: "Regisztráció" failed: "Valami hiba történt, talán ez az e-mail cím már regisztrálva van. Próbálta már a jelszó-emlékeztetőt?" + associate: "Már van fiókja? Jelentkezzen be hogy hozzákapcsolja a(z) %{provider}-fiókját." forgot_password: title: "Jelszó-visszaállítás" action: "Elfelejtettem a jelszavamat" @@ -1673,13 +1711,23 @@ hu: logging_in_as: Bejelentkezés mint %{email} confirm_button: Bejelentkezés befejezése login: + header_title: "Üdvözlünk újra" subheader_title: "Jelentkezzen be a fiókjába" + title: "Bejelentkezés" username: "Felhasználó" password: "Jelszó" second_factor_title: "Kétfaktoros hitelesítés" second_factor_description: "Írja be a hitelesítési kódot az alkalmazásából:" + second_factor_backup: "Bejelentkezés biztonsági kóddal" + second_factor_backup_title: "Kétfaktoros biztonsági kód" second_factor_backup_description: "Írja be valamelyik biztonsági kódját:" + second_factor: "Bejelentkezés hitelesítő alkalmazással" + security_key_description: "Ha előkészítette a fizikai biztonsági kulcsot, nyomja meg a lenti „Hitelesítés biztonsági kulccsal” gombot." + security_key_alternative: "Próbálkozás más módon" + security_key_authenticate: "Hitelesítés biztonsági kulccsal" + security_key_not_allowed_error: "A biztonsági hardverkulcsos hitelesítési folyamata során időtúllépés történt, vagy megszakították." security_key_no_matching_credential_error: "A megadott biztonsági kulcsban nem található megfelelő hitelesítő adat." + security_key_support_missing_error: "Jelenlegi eszköze vagy böngészője nem támogatja a biztonsági kulcsok használatát. Használjon más módszert." email_placeholder: "E-mail/felhasználónév" caps_lock_warning: "A Caps Lock be van kapcsolva" error: "Ismeretlen hiba" @@ -1714,17 +1762,26 @@ hu: twitter: name: "Twitter" title: "Twitterrel" + sr_title: "Bejelentkezés a Twitterrel" instagram: name: "Instagram" title: "Instagrammal" + sr_title: "Bejelentkezés az Instagrammal" facebook: name: "Facebook" title: "Facebookkal" + sr_title: "Bejelentkezés a Facebookkal" github: name: "GitHub" title: "GitHubbal" + sr_title: "Bejelentkezés a GitHubbal" discord: name: "Discord" + title: "Discorddal" + sr_title: "Bejelentkezés a Discorddal" + second_factor_toggle: + totp: "Helyette hitelesítő alkalmazás használata" + backup_code: "Helyette biztonsági kód használata" invites: accept_title: "Meghívás" emoji: "boríték emodzsi" @@ -1756,18 +1813,28 @@ hu: shift: "Shift" ctrl: "Ctrl" alt: "Alt" + enter: "Enter" conditional_loading_section: loading: Betöltés… category_row: topic_count: one: "%{count} téma van ebben a kategóriában" other: "%{count} téma van ebben a kategóriában" + plus_subcategories_title: + one: "%{name} és egy alkategória" + other: "%{name} és %{count} alkategória" + plus_subcategories: + one: "+ %{count} alkategória" + other: "+ %{count} alkategória" select_kit: delete_item: "%{name} törlése" filter_by: "Szűrő: %{name}" select_to_filter: "Válaszd ki a szűrni kívánt értéket" default_header_text: Kiválasztás… no_content: Nem található egyezés + results_count: + one: "%{count} találat" + other: "%{count} találat" filter_placeholder: Keresés… filter_placeholder_with_any: Keresés vagy létrehozás… create: "Létrehozás: „%{content}”" @@ -1816,10 +1883,16 @@ hu: whisper: "suttogás" unlist: "nem listázott" add_warning: "Ez egy hivatalos figyelmeztetés." + toggle_whisper: "Suttogás be/ki" + toggle_unlisted: "Nem listázott állapot be/ki" posting_not_on_topic: "Melyik témakörre szeretnél válaszolni?" saved_local_draft_tip: "helyben mentett" similar_topics: "A témaköröd hasonlít a..." drafts_offline: "vázlatok offline" + edit_conflict: "szerkesztési ütközés" + group_mentioned_limit: + one: "Figyelem!Megemlítette a(z) %{group} csoportot, azonban ennek a csoportnak több mint %{count} tagja van, amely több, mint a rendszergazda által beállított megemlítési korlát. Senki nem lesz értesítve." + other: "Figyelem!Megemlítette a(z) %{group} csoportot, azonban ennek a csoportnak több mint %{count} tagja van, amely több, mint a rendszergazda által beállított megemlítési korlát. Senki nem lesz értesítve." group_mentioned: one: "A(z) %{group} csoport megemlítésével %{count} embert fog értesíteni – biztos benne?" other: "A(z) %{group} csoport megemlítésével %{count} embert fog értesíteni – biztos benne?" @@ -1827,12 +1900,28 @@ hu: category: "Megemlítette %{username} felhasználót, de nem lesz értesítve, mivel nincs hozzáférése ehhez a témához. Hozzá kell adnia egy olyan csoporthoz, amely hozzáfér ehhez a kategóriához." private: "Említetted %{username} de nem lesznek értesítve mivel képtelenek megnézni ezt a személyes üzenetet. Megkell hívnod őket ehez a személyes üzenethez" duplicate_link: "Úgy tűnik, hogy a %{domain} re mutató linkjét @%{username} már ban közzétette a témában, válasz: %{ago} - Biztosan újra el akarja küldeni?" + reference_topic_title: "Válasz: %{title}" error: - title_missing: "A címet kötelező megadni" - category_missing: "Ki kéne választanod egy kategóriát" + title_missing: "A cím kötelező" + title_too_short: + one: "A címnek legalább %{count} karakteresnek kell lennie" + other: "A címnek legalább %{count} karakteresnek kell lennie" + title_too_long: + one: "A cím legfeljebb %{count} karakteres lehet" + other: "A cím legfeljebb %{count} karakteres lehet" + post_missing: "A bejegyzés nem lehet üres" + post_length: + one: "A bejegyzésnek legalább %{count} karakteresnek kell lennie" + other: "A bejegyzésnek legalább %{count} karakteresnek kell lennie" + try_like: "Próbálta már a %{heart} gombot?" + category_missing: "Választania kell egy kategóriát" + tags_missing: + one: "Választania kell legalább %{count} címkét" + other: "Választania kell legalább %{count} címkét" + topic_template_not_modified: "Adjon hozzá részleteket és konkrétumokat a témájához a témasablon szerkesztésével." save_edit: "Módosítások mentése" - overwrite_edit: "Módosítások mentése" - reply_original: "Válasz az eredeti témakörre" + overwrite_edit: "Módosítások felülírása" + reply_original: "Válasz az eredeti témára" reply_here: "Válasz ide" reply: "Válasz" cancel: "Mégse" @@ -1841,13 +1930,14 @@ hu: create_whisper: "Suttogás" create_shared_draft: "Megosztott vázlat létrehozása" edit_shared_draft: "Megosztott vázlat szerkesztése" + title: "Vagy nyomjon %{modifier}Entert" users_placeholder: "Felhasználó hozzáadása" - title_placeholder: "Mi lesz a témája ennek a beszélgetésnek, röviden?" - title_or_link_placeholder: "Adj címet vagy másolj ide egy linket" - edit_reason_placeholder: "miért szerkesztesz?" - topic_featured_link_placeholder: "Írja be a link címét!" + title_placeholder: "Mi lesz a témája ennek a beszélgetésnek egy rövid mondatban?" + title_or_link_placeholder: "Írja be a címet, vagy illesszen be ide egy hivatkozást" + edit_reason_placeholder: "miért szerkeszt?" + topic_featured_link_placeholder: "Írja be a címmel megjelenített hivatkozást." remove_featured_link: "Hivatkozás eltávolítása a témából." - reply_placeholder: "Ide írhatsz. A feltöltéshez húzz- vagy illessz be képet! A formázáshoz használhatsz Markdown-, BBCode- vagy HTML kódokat is." + reply_placeholder: "Ide írjon. A formázáshoz használhat Markdownt, BBCode-ot vagy HTML-t. Idehúzhat vagy beilleszthet képeket." reply_placeholder_no_images: "Ide írj. Használhatsz Markdown-t, BBCode-ot, vagy HTML-t a formázáshoz." reply_placeholder_choose_category: "Válassz egy kategóriát, mielőtt elkezdesz írni!" view_new_post: "Nézd meg az új bejegyzésedet." @@ -1855,6 +1945,8 @@ hu: saved: "Elmentve!" saved_draft: "Vázlat közzététele folyamatban. Koppintson a folytatáshoz." uploading: "Feltöltés..." + show_preview: "előnézet megjelenítése" + hide_preview: "előnézet elrejtése" quote_post_title: "Teljes bejegyzés idézése" bold_label: "B" bold_title: "Félkövér" @@ -1866,6 +1958,7 @@ hu: link_description: "itt add meg a link leírását" link_dialog_title: "Hiperhivatkozás beszúrása" link_optional_text: "alternatív cím" + link_url_placeholder: "Illesszen be egy URL-t, vagy gépeljen a témák kereséséhez" blockquote_title: "Idézetblokk" blockquote_text: "Idézetblokk" code_title: "Előformázott szöveg" @@ -1876,35 +1969,55 @@ hu: olist_title: "Számozott lista" ulist_title: "Pontozott lista" list_item: "Listaelem" + toggle_direction: "Irány be/ki" help: "Markdown szerkesztési súgó" + collapse: "szerkesztő panel minimalizálása" + open: "szerkesztő panel megnyitása" abandon: "szerkesztő bezárása és a vázlat elvetése" + enter_fullscreen: "belépés a teljes képernyős szerkesztőben" + exit_fullscreen: "kilépés a teljes képernyős szerkesztőből" + show_toolbar: "szerkesztő eszköztár megjelenítése" + hide_toolbar: "szerkesztő eszköztár elrejtése" modal_ok: "Rendben" modal_cancel: "Mégse" cant_send_pm: "Nem tud üzenetet küldeni %{username} számára" yourself_confirm: title: "Elfelejtett címzettet hozzáadni?" body: "Jelenleg ez az üzenet csak saját magadnak lett elküldve" + slow_mode: + error: "Ez a téma lassú módban van. Nemrég már tett közzé bejegyzést; %{timeLeft} múlva teheti meg újra." admin_options_title: "A témakör opcionális szervezői beállításai" composer_actions: reply: Válasz draft: Vázlat edit: Szerkesztés reply_to_post: + label: Válasz %{postUsername} bejegyzésére desc: Válasz egy adott bejegyzésre reply_as_new_topic: label: Válasz hivatkozott témaként desc: Egy új téma létrehozása ezzel a témával kapcsolatban confirm: Új témavázlatot mentett, amely automatikusan felül lesz írva, ha kapcsolt témát hoz létre. + reply_as_new_group_message: + label: Válasz új csoportos üzenetként + desc: Új üzenet létrehozása azonos címzettekkel reply_as_private_message: label: Új üzenet desc: Új személyes üzenet létrehozása reply_to_topic: label: Válasz a témára + desc: Válasz a témára, nem konkrét bejegyzésre + toggle_whisper: + label: Suttogás be/ki + desc: A suttogások csak a stáb tagjai számára láthatók create_topic: label: "Új téma" shared_draft: label: "Megosztott vázlat" desc: "Témavázlat készítése, amely csak az engedélyezett felhasználók számára látható" + toggle_topic_bump: + desc: "Válasz a legutóbbi válasz dátumának módosítása nélkül" + reload: "Újratöltés" ignore: "Letiltás" notifications: tooltip: @@ -1914,6 +2027,9 @@ hu: message: one: "%{count} olvasatlan üzenet" other: "%{count} olvasatlan üzenet" + high_priority: + one: "%{count} olvasatlan, magas prioritású értesítés" + other: "%{count} olvasatlan, magas prioritású értesítés" title: "értesítések a @név megemlítésekről, a hozzászólásaira adott válaszokról, üzenetekről stb." none: "Jelenleg nem lehet betölteni az értesítéseket." empty: "Nem található értesítés." @@ -1939,12 +2055,18 @@ hu: invited_to_private_message: "

%{username} %{description}" invited_to_topic: "%{username} %{description}" invitee_accepted: "%{username} elfogadta a meghívásodat" + moved_post: "%{username} áthelyezte: %{description}" linked: "%{username} %{description}" granted_badge: "Új jelvény: „%{description}”" topic_reminder: "%{username} %{description}" watching_first_post: "Új téma %{description}" + membership_request_accepted: "Tagság elfogadva itt: „%{group_name}”" + membership_request_consolidated: + one: "%{count} nyitott tagsági kérés ennél: „%{group_name}”" + other: "%{count} nyitott tagsági kérés ennél: „%{group_name}”" reaction: "%{username}%{description}" reaction_2: "%{username}, %{username2} %{description}" + votes_released: "%{description} – kész" group_message_summary: one: "%{count} Üzenet a %{group_name} postaládában" other: "%{count} Üzenetek a %{group_name} postaládában" @@ -1961,21 +2083,36 @@ hu: confirm_body: "Siker! Értesítések engedélyezve." custom: "Értesítés %{username} felhasználótól itt: %{site_title}" titles: + mentioned: "megemlítve" + replied: "új válasz" + quoted: "idézve" + edited: "szerkesztve" + liked: "új kedvelés" private_message: "új személyes üzenet" invited_to_private_message: "meghívott privát üzenetre" + invitee_accepted: "meghívás elfogadva" + posted: "új bejegyzés" + moved_post: "bejegyzés áthelyezve" linked: "linkelve" bookmark_reminder: "könyvjelző emlékeztető" bookmark_reminder_with_name: "könyvjelző emlékeztető - %{name}" granted_badge: "kiadott jelvény" + invited_to_topic: "meghívva a témához" + group_mentioned: "csoport megemlítve" group_message_summary: "új csoportüzenetek" watching_first_post: "új téma" + topic_reminder: "téma emlékeztető" + liked_consolidated: "új kedvelés" post_approved: "bejegyzés jóváhagyva" + membership_request_consolidated: "új tagsági kérés" + reaction: "új reakció" upload_selector: uploading: "Feltöltés" - select_file: "File kiválasztása." - default_image_alt_text: Kép + processing: "Feltöltés feldolgozása" + select_file: "Válaszon fájlt" + default_image_alt_text: kép search: - sort_by: "Rendezés " + sort_by: "Rendezés" relevance: "Relevancia" latest_post: "Utolsó bejegyzés" latest_topic: "Legutóbbi téma" @@ -1983,78 +2120,147 @@ hu: most_liked: "Legtöbbet kedvelt" select_all: "Összes kijelölése" clear_all: "Összes törlése" - too_short: "Keresési kifejezésed túl rövid." + too_short: "A keresési kifejezés túl rövid." + open_advanced: "Speciális keresés megnyitása" + clear_search: "Keresés törlése" + sort_or_bulk_actions: "Találatok rendezése vagy tömeges kiválasztása" + result_count: + one: "%{count} találat erre:%{term}" + other: "%{count}%{plus} találat erre:%{term}" title: "Keresés" full_page_title: "Keresés" no_results: "Nincs találat." no_more_results: "Nincs több találat." post_format: "#%{post_number}, szerző: %{username}" results_page: "Találatok erre: „%{term}”" + more_results: "Több találat is van. Szűkítse a keresési feltételeket." + cant_find: "Nem találja, amit keres?" start_new_topic: "Esetleg indítson egy új témát?" or_search_google: "Vagy keressen a Google-lel:" search_google: "Inkább keressen a Google-lel:" search_google_button: "Google" search_button: "Keresés" + search_term_label: "adja meg a keresési kulcsszót" categories: "Kategóriák" tags: "Címkék" + in: "ebben:" + in_this_topic: "ebben a témában" + in_this_topic_tooltip: "váltás az összes téma keresésére" + in_topics_posts: "az összes témában és bejegyzésben" + enter_hint: "vagy nyomjon Entert" + in_posts_by: "%{username} bejegyzéseiben" type: + default: "Témák/bejegyzések" users: "Felhasználók" categories: "Kategóriák" + categories_and_tags: "Kategóriák/címkék" context: user: "Keresés @%{username} bejegyzései között" category: "Keresés a(z) #%{category} kategóriában" + tag: "A(z) #%{tag} címke keresése" topic: "Keresés ebben a témában" private_messages: "Üzenetek keresése" + tips: + category_tag: "szűrés kategória vagy címke szerint" + author: "szűrés a bejegyzés szerzője szerint" + in: "szűrés metaadat szerint (pl.: in:title, in:personal, in:pinned)" + status: "szűrés a téma állapota szerint" + full_search: "teljes oldalas keresés indítása" + full_search_key: "%{modifier} + Enter" advanced: + title: Speciális szűrők posted_by: label: 'Szerző:' + aria_label: Szűrés a bejegyzés szerzője szerint in_category: label: Kategorizált + in_group: + label: Csoportban with_badge: label: Jelvénnyel with_tags: label: Címkézett + aria_label: Szűrés címkék használatával filters: label: 'Csak akkor jelenítsen meg egy témát/választ, ha:' title: A keresett kifejezés megtalálható a címben likes: kedveltem + posted: közzétettem benne created: én hoztam létre watching: figyelem tracking: követem private: az üzeneteim között található bookmarks: könyvjelzőztem first: az a legelső bejegyzés + pinned: kitűzött seen: olvastam + unseen: nem olvastam + wiki: wiki bejegyzések + images: képeket tartalmaz all_tags: A fenti címkék mindegyike statuses: - open: nyitott - closed: zárolt + label: Ahol a témák + open: nyitottak + closed: zároltak + public: nyilvános archived: archivált noreplies: nincsenek válaszok + single_user: egyetlen felhasználót tartalmaznak post: count: label: Bejegyzések + min: + placeholder: legalább + aria_label: szűrés a bejegyzések legkisebb száma szerint + max: + placeholder: legfeljebb + aria_label: szűrés a bejegyzések legnagyobb száma szerint time: label: Közzétett + aria_label: Szűrés közzétételi dátum szerint before: előtte after: utána views: label: Megtekintések + min_views: + placeholder: legalább + aria_label: szűrés a megtekintések legkisebb száma szerint + max_views: + placeholder: legfeljebb + aria_label: szűrés a megtekintések legnagyobb száma szerint + additional_options: + label: "Szűrés hozzászólásszám és témamegtekintés szerint" + hamburger_menu: "menü" new_item: "új" go_back: "visszalépés" - not_logged_in_user: "felhasználói oldal összesítéssel a jelenleg aktivitásokról és beállításokról" + not_logged_in_user: "felhasználói oldal a jelenlegi tevékenységek és beállítások összesítésével" current_user: "ugrás a felhasználói oldalára" + view_all: "összes %{tab} megtekintése" topics: new_messages_marker: "utolsó látogatás" bulk: select_all: "Összes kiválasztása" clear_all: "Összes törlése" - unlist_topics: "Nem listázott témák" + unlist_topics: "Témák listázásnak megszüntetése" relist_topics: "Újralistázott témák" + reset_read: "Olvasott állapot visszaállítása" delete: "Témák törlése" dismiss: "Elvetés" dismiss_read: "Olvasatlan üzenetek elvetése" + dismiss_read_with_selected: + one: "%{count} olvasatlan elvetése" + other: "%{count} olvasatlan elvetése" dismiss_button: "Elvetés…" + dismiss_button_with_selected: + one: "Elvetés (%{count})…" + other: "Elvetés (%{count})…" + dismiss_tooltip: "Csak az új hozzászólások elvetése vagy a témák követésének leállítása" + also_dismiss_topics: "Ezen témák követésének leállítása, így sosem fognak olvasatlanként megjelenni" + dismiss_new: "Újak elvetése" + dismiss_new_with_selected: + one: "Újak elvetése (%{count})" + other: "Újak elvetése (%{count})" + toggle: "témák csoportos kiválasztása be/ki" actions: "Csoportos műveletek" change_category: "Kategória beállítása…" close_topics: "Témák lezárása" @@ -2066,41 +2272,61 @@ hu: selected: one: "Kiválasztott %{count} témát." other: "Kiválasztott %{count} témát." + change_tags: "Címkék cseréje" + append_tags: "Címkék hozzáfűzése" + choose_new_tags: "Válasszon új címkéket ezekhez a témákhoz:" + choose_append_tags: "Válasszon új hozzáfűzendő címkéket ezekhez a témákhoz:" + changed_tags: "Azon témák címkéi megváltoztak." + remove_tags: "Összes címke eltávolítása" confirm_remove_tags: one: "Az összes címke eltávolításra kerül erről a témáról. Biztos benne?" other: "Az összes címke eltávolításra kerül %{count} témáról. Biztos benne?" + progress: + one: "Folyamat: %{count} téma" + other: "Folyamat: %{count} téma" none: unread: "Nincsenek olvasatlan témái." + unseen: "Nincsenek nem látott témái." new: "Nincsenek új témái." read: "Még egy témát sem olvasott el." posted: "Még egy témához sem szólt hozzá." + latest: "Naprakész lett!" bookmarks: "Még nem adott hozzá témát a könyvjelzőihez." category: "Nincsenek témakörök a %{category} kategóriában." top: "Nincsenek top témák" educate: - new: '

Az új témák fognak itt megjelenni. Alapértelmezésként itt jelennek meg a friss témák, és egy ilyen jelet találsz majd azok mellett, amelyeket ráadásul az elmúlt két napban tettek közzé.

A beállításaidat ezen az oldalon tudod módosítani.

' + new: '

Az új témák fognak itt megjelenni. Alapértelmezésként itt jelennek meg a friss témák, és egy ilyen jelet talál majd azok mellett, amelyek az elmúlt két napban lettek közzétéve.

A beállításait ezen az oldalon tudja módosítani.

' bottom: latest: "Nincs több friss téma." - posted: "Nincsen több közzétett téma" + posted: "Nincsen több közzétett téma." read: "Nincs több olvasott téma." - new: "Nincs több új témakör." - unread: "Nincs több olvasatlan témakör." - category: "Nincs több %{category} téma." - tag: "Nincs több %{tag} téma." - top: "Nincsenek több top témák" - bookmarks: "Nincs több témakör a könyvjelzők között." + new: "Nincs több új téma." + unread: "Nincs több olvasatlan téma." + unseen: "Nincs több nem látott téma." + category: "Nincs több „%{category}” kategóriájú téma." + tag: "Nincs több „%{tag}” címkéjű téma." + top: "Nincsenek több népszerű téma" + bookmarks: "Nincs több könyvjelzőzött téma." topic: + filter_to: + one: "%{count} bejegyzés a témában" + other: "%{count} bejegyzés a témában" create: "Új téma" create_long: "Új téma létrehozása" open_draft: "Vázlat megnyitása" - private_message: "Üzenj " + private_message: "Üzenet indítása" archive_message: - help: "Az üzenet az archivumba adása" + help: "Üzenet áthelyezése az archívumba" title: "Archívum" move_to_inbox: title: "Áthelyezés a bejövő üzenetek közé" + help: "Üzenet áthelyezése a Beérkezett üzenetek közé" edit_message: + help: "Az üzenet első bejegyzésének szerkesztése" title: "Szerkesztés" + defer: + help: "Megjelölés olvasatlanként" + title: "Elhalasztás" list: "Témák" new: "új téma" unread: "olvasatlan" @@ -2110,34 +2336,43 @@ hu: unread_topics: one: "%{count} olvasatlan téma" other: "%{count} olvasatlan téma" - title: "Témakör" + title: "Téma" invalid_access: - title: "Privát témakör" - description: "Sajnáljuk, de nincs hozzáférésed ehhez a témához!" - login_required: "Be kell jelentkezned, hogy megtekinthesd ezt a témakört!" + title: "A téma privát" + description: "Sajnáljuk, de nincs hozzáférése ehhez a témához!" + login_required: "Be kell jelentkeznie, hogy megtekinthesse ezt a témát!" server_error: - title: "Nem sikerült betölteni a témakört" + title: "A téma betöltése sikertelen" description: "Sajnos nem tudtuk betölteni a témát, valószínűleg kapcsolódási probléma miatt. Kérjük, próbáld újra. Ha a probléma továbbra is fennáll, értesíts minket." not_found: - title: "Nem létező témakör" - description: "Sajnáljuk, de nem tudtuk megtalálni ezt a témakört. Talán egy moderátor kitörölte volna?" + title: "A téma nem található" + description: "Sajnos a téma nem található. Lehet, hogy egy moderátor törölte?" unread_posts: - one: "%{count} hozzászólást nem olvastál a témában" - other: "%{count} hozzászólást nem olvastál a témában" + one: "%{count} nem olvasott bejegyzés van ebben a témában" + other: "%{count} nem olvasott bejegyzés van ebben a témában" likes: one: "%{count} kedvelés van a témában" other: "%{count} kedvelés van a témában" - back_to_list: "Vissza a témakörök listájára" - options: "Témakör beállításai" - show_links: "linkek megjelenítése ebben a témakörben" - read_more_in_category: "Szeretnél még többet olvasni? Nézz meg más témakat itt, %{catLink} vagy itt: %{latestLink}." - read_more: "Szeretnél mégtöbbet olvasni? %{catLink} vagy %{latestLink}." - browse_all_categories: Böngéssz a kategóriák között + back_to_list: "Vissza a témalistához" + options: "Témabeállítások" + show_links: "a témában szereplő hivatkozások megjelenítése" + collapse_details: "téma részleteinek összecsukása" + expand_details: "téma részleteinek kibontása" + read_more_in_category: "Szeretne többet olvasni? Böngésszen más témákat itt: %{catLink} vagy %{latestLink}." + read_more: "Szeretne többet olvasni? %{catLink} vagy %{latestLink}." + unread_indicator: "Egyetlen tag sem olvasta még a téma utolsó bejegyzését." + read_more_MF: "{ UNREAD, plural, =0 {} one {# olvasatlan} other {# olvasatlan} } { NEW, plural, =0 {} one { {BOTH, select, true{és} false {} other{}} # új téma} other { {BOTH, select, true{és} false {} other{}} # új téma} } van hátra, vagy {CATEGORY, select, true {böngésszen más témákat itt: {catLink}} false {{latestLink}} other {}}" + bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}" + browse_all_categories: Összes kategória böngészése + browse_all_tags: Összes címke böngészése view_latest_topics: legújabb témák megtekintése - jump_reply_up: ugrás régebbi válaszhoz - jump_reply_down: ugrás újabb válaszhoz - deleted: "Ez a témakör ki lett törölve" + suggest_create_topic: Készen áll, hogy új beszélgetést kezdjen? + jump_reply_up: ugrás a korábbi válaszhoz + jump_reply_down: ugrás a későbbi válaszhoz + deleted: "A téma törölve lett" slow_mode_update: + title: "Lassú mód" + select: "A felhasználók ennyi időnként csak egyszer tehetnek közzé bejegyzést:" enable: "Engedélyezés" remove: "Letiltás" hours: "Óráig:" @@ -2946,6 +3181,8 @@ hu: no_drafts_title: "Még nem kezdett el egyetlen vázlatot sem" no_drafts_body: "Még nem áll készen a bejegyzés létrehozására? Automatikusan mentve lesz egy új vázlat, és itt fel lesz sorolva, ha egy új témát, választ vagy személyes üzenetet kezd el írni. Válassza a Mégse gombot, hogy elvesse, vagy mentse a vázlatot a későbbi folytatáshoz." no_likes_others: "Nincsenek kedvelt bejegyzések." + fullscreen_table: + expand_btn: "Táblázat kibontása" admin_js: type_to_filter: "Írj ide a szűréshez.." admin: @@ -3295,9 +3532,11 @@ hu: embedded_scss: text: "Ágyazott CSS" head_tag: - text: "" + text: "Fej" + title: "A „head” címke előtt beillesztendő HTML" body_tag: - text: "" + text: "Törzs" + title: "A „body” címke előtt beillesztendő HTML" yaml: text: "YAML" title: "Téma beállításainak megadása YAML formátumban" @@ -3829,7 +4068,6 @@ hu: badge_query_examples_title: "Jelvény lekérdezési példák" emoji: title: "Emodzsi" - help: "Adjon hozzá egy új emodzsit, amely mindenki számára elérhető lesz. (PROTIP: fogjon és vigyen több fájlt egyszerre)" add: "Új emodzsi hozzáadása" uploading: "Feltöltés…" name: "Név" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index 8e1c3a4b69..6986c89b39 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -3420,12 +3420,8 @@ hy: embedded_scss: text: "Զետեղված CSS" title: "Մուտքագրեք մասնավոր CSS՝ մեկնաբանությունների զետեղված տարբերակի վրա կիրառելու համար" - head_tag: - text: "" - title: "HTML, որը պետք է մուտք արվի մինչև թեգը" body_tag: - text: "" - title: "HTML, որը պետք է մուտք արվի մինչև թեգը" + text: "Մարմին" yaml: text: "YAML" title: "Սահմանել թեմայի կարգավորումները YAML ֆորմատով" @@ -4174,7 +4170,6 @@ hy: replace_owners: Հեռացնել նախորդ սեփականատիրոջ կրծքանշանը emoji: title: "Էմոջի" - help: "Ավելացրեք նոր էմոջի, որը հասանելի կլինի բոլորին: (ՀՈՒՇՈՒՄ՝ քաշեք և գցեք բազմակի ֆայլեր միանգամից)" add: "Ավելացնել Նոր Էմոջի" uploading: "Վերբեռնում..." name: "Անուն" @@ -4185,7 +4180,6 @@ hy: embedding: get_started: "Եթե Դուք ցանկանում եք զետեղել Discourse-ը մեկ այլ կայքում, սկսեք նրա հոսթի ավելացմամբ:" confirm_delete: "Դուք համոզվա՞ծ եք, որ ցանկանում եք ջնջել այդ հոսթը:" - sample: "Օգտագործեք հետևյալ HTML կոդը Ձեր կայքում՝ discourse-ի թեմաներ ստեղծելու և զետեղելու համար: Փոխարինեք REPLACE_ME տեքստը կանոնավոր URL-ով այն էջի վրա, որտեղ Դուք ներկառուցում եք այն:" title: "Զետեղում" host: "Թույլատրված Հոսթերը" class_name: "Class-ի Անունը" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 10f95e6bcc..eb9a3940de 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -39,6 +39,7 @@ id: long_date_without_year_with_linebreak: "MMM D
LT" long_date_with_year_with_linebreak: "MMM D, 'YY
LT" wrap_ago: "%{date} yang lalu" + wrap_on: "pada %{date}" tiny: half_a_minute: "< 1m" less_than_x_seconds: @@ -92,6 +93,8 @@ id: previous_month: "Bulan Lalu" next_month: "Bulan Depan" placeholder: tanggal + from_placeholder: "dari tanggal" + to_placeholder: "sampai saat ini" share: topic_html: 'Topik: %{topicTitle}' post: "post #%{postNumber}" @@ -156,6 +159,7 @@ id: cn_northwest_1: "Cina (Ningxia)" eu_central_1: "EU (Frankfurt)" eu_north_1: "EU (Stockholm)" + eu_south_1: "Uni Eropa (Milan)" eu_west_1: "EU (Ireland)" eu_west_2: "Eropa (London)" eu_west_3: "EU (Paris)" @@ -166,6 +170,7 @@ id: us_gov_west_1: "AWS GovCloud (US-West)" us_west_1: "US West (N. California)" us_west_2: "US West (Oregon)" + clear_input: "Hapus masukan" edit: "Ubah judul dan kategori topik ini" expand: "Perluas" not_implemented: "Maaf, fitur ini belum diimplementasikan." @@ -1770,6 +1775,8 @@ id: enable: "Aktifkan" disable: "Nonaktifkan" add: "Menambahkan" + body_tag: + text: "Konten" colors: undo: "batalkan perintah" revert: "dibalik" diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index 8a1b8d35e0..0d3b58fabe 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -1143,7 +1143,7 @@ it: tags: "Etichette" warnings: "Avvertimenti ufficiali" read_more_in_group: "Vuoi continuare a leggere? Sfoglia altri messaggi in %{groupLink}." - read_more: "Vuoi saperne di più? Sfoglia altri messaggi nei messaggi personali." + read_more: "Vuoi saperne di più? Leggi altri messaggi in messaggi personali." preferences_nav: account: "Account" security: "Sicurezza" @@ -2022,6 +2022,8 @@ it: desc: "Rispondi senza cambiare la data dell'ultima risposta" reload: "Ricarica" ignore: "Ignora" + image_alt_text: + aria_label: Testo alternativo per l'immagine notifications: tooltip: regular: @@ -3573,6 +3575,8 @@ it: no_activity_others: "Nessuna attività." no_replies_others: "Nessuna risposta." no_likes_others: "Nessun messaggio con \"Mi piace\"." + fullscreen_table: + expand_btn: "Espandi Tabella" admin_js: type_to_filter: "digita per filtrare..." admin: @@ -4170,11 +4174,11 @@ it: Si consiglia di assegnare un prefisso ai nomi delle proprietà per evitare conflitti con i plugin e/o il kernel. head_tag: - text: "" - title: "HTML che sarà inserito prima del tag " + text: "Intestazione" + title: "L'HTML sarà inserito prima dell'etichetta di intestazione" body_tag: - text: "" - title: "HTML che deve essere inserito prima del tag " + text: "Corpo" + title: "HTML che sarà inserito prima dell'etichetta del corpo" yaml: text: "YAML" title: "Definisci le impostazioni del tema in formato YAML" @@ -5016,7 +5020,7 @@ it: grant_existing_holders: Assegna distintivi aggiuntivi ai titolari di distintivi già esistenti emoji: title: "Emoji" - help: "Aggiungi nuovi emoji da mettere a disposizione per tutti. (Suggerimento: trascina e rilascia più file in una volta sola)" + help: "Aggiungi nuove emoji che saranno disponibili per tutti. Trascina e rilascia più file contemporaneamente senza inserire un nome per creare emoji utilizzando i nomi dei file." add: "Aggiungi Nuovo Emoji" uploading: "In caricamento..." name: "Nome" @@ -5027,7 +5031,7 @@ it: embedding: get_started: "Se lo desideri, puoi incorporare Discourse in un altro sito web. Comincia aggiungendo il nome dell'host." confirm_delete: "Sicuro di voler cancellare questo host?" - sample: "Utilizza il seguente codice HTML nel tuo sito per creare e incorporare gli argomenti di Discourse. Sostituisci REPLACE_ME con l'URL canonico della pagina in cui lo stai incorporando." + sample: "Incolla il seguente codice HTML nel tuo sito per creare e incorporare argomenti Discourse. Sostituire REPLACE_ME con l'URL canonico della pagina su cui si sta incorporando." title: "Integrato" host: "Host Abilitati" class_name: "Nome Classe" diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index 258d08453f..8320bc94a5 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -3947,12 +3947,8 @@ ja: %{example} プラグインやコアとの競合を避けるには、プロパティ名に接頭辞を付けることを強くお勧めします。 - head_tag: - text: "" - title: " タグの前に挿入される HTML" body_tag: - text: "" - title: " タグの前に挿入される HTML" + text: "本文" yaml: text: "YAML" title: "YAML 形式でテーマ設定を定義してください" @@ -4787,7 +4783,6 @@ ja: grant_existing_holders: 既存のバッジ保有者に追加のバッジを付与する emoji: title: "絵文字" - help: "みんなが利用できる新しい絵文字を追加します。(ヒント: 複数のファイルを一度にドラッグアンドドロップできます)" add: "新しい絵文字を追加" uploading: "アップロード中..." name: "名前" @@ -4798,7 +4793,6 @@ ja: embedding: get_started: "ほかのウェブサイトに Discourse を埋め込む場合は、まずホストを追加してください。" confirm_delete: "このホストを削除してもよろしいですか?" - sample: "次の HTML コードをサイトに使用して、Discourse のトピックを作成して埋め込みます。REPLACE_ME を埋め込み先の正規 URL に置き換えてください。" title: "埋め込み" host: "許可されたホスト" class_name: "クラス名" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index a96269a59a..f2ead6798d 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -81,7 +81,7 @@ ko: x_days: other: "%{count}일 전" x_months: - other: "%{count}달 전" + other: "%{count}개월 전" x_years: other: "%{count}년 전" later: @@ -257,6 +257,7 @@ ko: bookmarks: created: "이 글을 북마크했습니다. %{name}" not_bookmarked: "이 글을 북마크" + remove_reminder_keep_bookmark: "알림 제거 및 북마크 유지" created_with_reminder: "알림 %{date}으로 이 글을 북마크했습니다. %{name}" remove: "북마크 삭제" delete: "북마크 삭제" @@ -1085,6 +1086,7 @@ ko: failed_to_move: "선택한 메시지를 이동하지 못했습니다. (네트워크가 다운되었을 수 있음)" tags: "태그" warnings: "공식 경고" + read_more_in_group: "더 읽을거리가 필요하신가요? %{groupLink}에서 다른 메시지를 찾아보십시오." preferences_nav: account: "계정" security: "보안" @@ -1241,6 +1243,7 @@ ko: not_connected: "(연결되지 않음)" confirm_modal_title: "%{provider} 계정 연결" confirm_description: + disconnect: "기존 %{provider} 계정 '%{account_description}' 연결이 해제됩니다." account_specific: "사용자님의 %{provider} 계정 '%{account_description}'이 인증에 사용됩니다." generic: "%{provider} 계정이 인증에 사용됩니다." name: @@ -1582,6 +1585,7 @@ ko: enable: "이 글 요약" disable: "모든 글 표시" short_label: "요약하기" + short_title: "이 글의 요약: 커뮤니티의 인기 게시물" deleted_filter: enabled_description: "이 글에는 숨겨진 삭제 된 게시물이 있습니다." disabled_description: "글에 삭제 된 게시물이 표시됩니다." @@ -1852,6 +1856,7 @@ ko: create_whisper: "귓속말" create_shared_draft: "공유 초안 만들기" edit_shared_draft: "공유 초안 편집" + title: "또는 %{modifier}Enter 키를 누릅니다." users_placeholder: "사용자 추가" title_placeholder: "이야기 나누고자 하는 내용을 한문장으로 적는다면?" title_or_link_placeholder: "여기에 제목을 입력하거나 링크를 붙여 넣으세요." @@ -2059,6 +2064,7 @@ ko: categories: "카테고리" tags: "태그" in_this_topic: "이 글에서" + in_this_topic_tooltip: "모든 글 검색으로 전환" in_topics_posts: "모든 글과 댓글에서" enter_hint: "또는 Enter 키를 누르십시오." in_posts_by: "%{username}님의 글에서" @@ -2713,6 +2719,7 @@ ko: just_the_post: "아니오, 글만 삭제합니다." admin: "관리자 기능" permanently_delete: "영구적으로 삭제" + permanently_delete_confirmation: "이 게시물을 영구적으로 삭제하시겠습니까? 복구할 수 없습니다." wiki: "위키 만들기" unwiki: "위키 제거하기" convert_to_moderator: "스태프 색상 추가하기" @@ -3427,12 +3434,15 @@ ko: no_replies_title: "아직 어떤 글에도 댓글을 달지 않았습니다." no_replies_others: "답글이 없습니다." no_drafts_title: "초안을 시작하지 않았습니다." + no_drafts_body: "아직 글을 작성할 준비가 되지 않았습니까? 글, 댓글 또는 개인 메시지 작성을 시작할 때마다 새 초안을 자동으로 저장하고 여기에 나열합니다. 취소 버튼을 클릭해 삭제하거나 나중에 계속하려면 초안을 저장하세요." no_likes_title: "아직 어떤 글에도 좋아요를 클릭하지 않으셨습니다." no_likes_body: "참여와 기여를 시작하는 가장 좋은 방법은 다른 사용자의 글을 읽고 %{heartIcon} 을 클릭하는 것입니다!" no_likes_others: "좋아요를 받은 게시글이 없습니다" no_topics_title: "아직 작성한 글이 없습니다." no_read_topics_title: "아직 읽은 글이 없습니다." no_group_messages_title: "그룹 메시지가 없습니다." + fullscreen_table: + expand_btn: "테이블 확장" admin_js: type_to_filter: "필터를 입력하세요" admin: @@ -4025,11 +4035,11 @@ ko: 플러그인 및/또는 코어와의 충돌을 피하기 위해 속성 이름 앞에 붙이는 것이 좋습니다. head_tag: - text: "" - title: " 태그 전에 들어갈 HTML" + text: "헤드" + title: "헤드 태그 앞에 삽입될 HTML" body_tag: - text: "" - title: " 태그 전에 들어갈 HTML" + text: "바디" + title: "본문 태그 앞에 삽입될 HTML" yaml: text: "YAML" title: "YAML 형식으로 테마 설정 정의" @@ -4874,7 +4884,7 @@ ko: grant_existing_holders: 기존 배지 소지자에게 추가 배지 부여 emoji: title: "이모티콘" - help: "모든 사람이 사용할 수 있는 새 이모티콘을 추가합니다.(PROTIP: 한 번에 여러 파일을 드래그 앤 드롭)" + help: "모든 사람이 사용할 수 있는 새 이모티콘을 추가합니다.이름을 입력하지 않고 여러 파일을 한 번에 끌어다 놓아 파일 이름을 사용하여 이모티콘을 만들 수 있습니다." add: "새 이모티콘 추가" uploading: "업로드 중..." name: "이름" @@ -4885,7 +4895,6 @@ ko: embedding: get_started: "다른 웹사이트에 Discourse를 임베드하려면 호스트 추가부터 하세요" confirm_delete: "정말로 host를 삭제할까요?" - sample: "Discourse 토픽을 웹사이트에 삽입(embed)하기 위해 다음 HTML코드를 이용하세요. REPLACE_ME 부분을 당신이 삽입하려는 웹사이트의 정식URL로 교체 하면 됩니다." title: "삽입(Embedding)" host: "허용 Host" class_name: "클래스 이름" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index 65f78a6370..4e0745a122 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -3377,6 +3377,8 @@ lt: no_topics_title: "Dar nepradėjote jokių temų" no_read_topics_title: "Dar neskaitėte jokių temų" no_group_messages_title: "Grupės pranešimų nerasta" + fullscreen_table: + expand_btn: "Išskleisti lentelę" admin_js: type_to_filter: "įrašyk kažką dėl filtro..." admin: @@ -3874,12 +3876,8 @@ lt: text: "Įterptas CSS" color_definitions: text: "Spalvų apibrėžimai" - head_tag: - text: "" - title: "HTML, kuris bus įstatytas prieš tag'ą" body_tag: - text: "" - title: "HTML, kuris bus įstatytas prieš tag'ą" + text: "Turinys" yaml: text: "YAML" colors: @@ -4634,7 +4632,6 @@ lt: grant_existing_holders: Suteikite papildomų ženklelių esamiems ženklelių savininkams emoji: title: "Šypsenėlės" - help: "Pridėti naują šypsenėlę, kurią galės matyti visi (patarimas: kelis failus kelk tuo pačiu metu)" add: "Pridėti Naują Šypsenėlę" uploading: "Įkeliama..." name: "Vardas" @@ -4644,7 +4641,6 @@ lt: embedding: get_started: "Norėčiau pridėti Discourse prie kito puslapio. Pradėk tiesiog pridėdamas valdytoją." confirm_delete: "Ar tikrai nori ištrinti šį šeimininką?" - sample: "Use the following HTML code into your site to create and embed discourse topics. Replace REPLACE_ME with the canonical URL of the page you are embedding it on." title: "Įterpimai" host: "Šeimininkai" class_name: "Klasės pavadinimas" diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 0178bc7749..4bbf3b2bf1 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -3134,6 +3134,8 @@ lv: add: "Pievienot" scss: text: "CSS" + body_tag: + text: "Ķermenis" colors: undo: "atsaukt" revert: "atgriezt" @@ -3658,7 +3660,6 @@ lv: with_time: %{username} %{time} emoji: title: "Smaidiņi" - help: "Pievienot jaunu smaidiņu, kas būs pieejams visiem." add: "Pievienot Jaunu Smaidiņu" uploading: "Notiek ielāde..." name: "Nosaukums" @@ -3668,7 +3669,6 @@ lv: embedding: get_started: "Ja tu vēlies ieguldīt Discourse citā mājas lapā, sāc ar \"hosta\" pievienošanu.\n\nIf you'd like to embed Discourse on another website, begin by adding its host." confirm_delete: "Vai tiešām vēlies dzēst šo?" - sample: "(Use the following HTML code into your site to create and embed discourse topics. Replace REPLACE_ME with the canonical URL of the page you are embedding it on.)" title: "Ieguldīšana" host: "Atļautie lietotāji" class_name: "Klases Nosaukums" diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index e91ddcc8a2..056d6a85eb 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -3458,6 +3458,8 @@ nb_NO: no_activity_others: "Ingen aktivitet." no_replies_others: "Ingen svar." no_likes_others: "Ingen likte innlegg. " + fullscreen_table: + expand_btn: "Utvid tabell" admin_js: type_to_filter: "skriv for å filtrere…" admin: @@ -3946,12 +3948,8 @@ nb_NO: embedded_scss: text: "Innebygd CSS" title: "Skriv inn egendefinert CSS for å levere med innebygde versjoner av kommentarer" - head_tag: - text: "" - title: "HTML som har blitt smettet inn før -taggen" body_tag: - text: "" - title: "HTML som vil bli smettet inn før -taggen" + text: "Body" yaml: text: "YAML" title: "Definer draktinnstillinger i YAML-format" @@ -4640,7 +4638,6 @@ nb_NO: with_time: %{username} - %{time} emoji: title: "Emoji" - help: "Legg til en ny emoji som vil være tilgjengelig for alle. (PROFFTIPS: Dra og slipp flere filer samtidig)" add: "Legg til ny Emoji" uploading: "Laster opp…" name: "Navn" @@ -4650,7 +4647,6 @@ nb_NO: embedding: get_started: "Hvis du vil bygge inn Discourse på en annen nettside, begynn med å legge til dens vert." confirm_delete: "Er du sikker på at du vil slette den verten?" - sample: "Bruk følgende HTML-kode på siden din for å bygge inn Discourse-emner. Erstatt ERSTATT_MEG med riktig nettadresse til siden du bygger den inn i." title: "Innbygging" host: "Tillatte verter" class_name: "Klassenavn" diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index df9c6b95ec..b76d15508b 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -183,6 +183,7 @@ nl: cn_northwest_1: "China (Ningxia)" eu_central_1: "EU (Frankfurt)" eu_north_1: "EU (Stockholm)" + eu_south_1: "EU (Milaan)" eu_west_1: "EU (Ierland)" eu_west_2: "EU (Londen)" eu_west_3: "EU (Parijs)" @@ -240,6 +241,8 @@ nl: character_count: one: "%{count} teken" other: "%{count} tekens" + period_chooser: + aria_label: "Filteren op periode" related_messages: title: "Gerelateerde berichten" see_all: 'Alle berichten van @%{username}bekijken...' @@ -267,6 +270,7 @@ nl: contact_info: "Neem in het geval van een kritieke kwestie of dringende vraagstukken in verband met deze website contact met ons op via %{contact_info}." bookmarked: title: "Bladwijzer maken" + edit_bookmark: "Bladwijzer bewerken" clear_bookmarks: "Bladwijzers wissen" help: bookmark: "Klik om een bladwijzer voor het eerste bericht van dit topic te maken" @@ -300,6 +304,7 @@ nl: copied: "gekopieerd!" drafts: label: "Concepten" + label_with_count: "Concepten (%{count})" resume: "Hervatten" remove: "Verwijderen" remove_confirmation: "Weet u zeker dat u dit concept wilt verwijderen?" @@ -362,6 +367,7 @@ nl: placeholder: "typ hier de titel, URL of ID van het bericht" review: order_by: "Sorteren op" + date_filter: "Geplaatst tussen" in_reply_to: "in reactie op" explain: why: "leg uit waarom dit item in de wachtrij is beland" @@ -623,6 +629,7 @@ nl: title: "Beheren" name: "Naam" full_name: "Volledige naam" + add_members: "Gebruikers toevoegen" invite_members: "Uitnodigen" delete_member_confirm: "'%{username}' uit de groep '%{group}' verwijderen?" profile: @@ -634,7 +641,16 @@ nl: email: title: "E-mailadres" status: "%{old_emails} / %{total_emails} e-mails via IMAP gesynchroniseerd." + test_settings: "Test Instellingen" + save_settings: "Instellingen opslaan" + last_updated: "Laatst bijgewerkt:" last_updated_by: "door" + smtp_settings_valid: "SMTP instellingen geldig." + smtp_title: "SMTP" + imap_title: "IMAP" + imap_additional_settings: "Extra instellingen" + prefill: + gmail: "GMail" credentials: title: "Referenties" smtp_server: "SMTP-server" @@ -977,6 +993,7 @@ nl: description: "Onboarding-tips en badges voor nieuwe gebruikers overslaan" not_first_time: "Niet uw eerste keer?" skip_link: "Deze tips overslaan" + read_later: "Ik zal het later lezen." theme_default_on_all_devices: "Dit het standaardthema maken op al mijn apparaten" color_scheme_default_on_all_devices: "Standaard kleurenschema(’s) op al mijn apparaten instellen" color_scheme: "Kleurenschema" @@ -1073,6 +1090,7 @@ nl: rejected_posts: "geweigerde berichten" messages: inbox: "Postvak IN" + personal: "Persoonlijk" latest: "Nieuwste" sent: "Verzonden" unread: "Ongelezen" @@ -1089,6 +1107,7 @@ nl: move_to_archive: "Archiveren" failed_to_move: "Het verplaatsen van geselecteerde berichten is niet gelukt (misschien is uw netwerkverbinding verbroken)" tags: "Tags" + warnings: "Officiële waarschuwingen" preferences_nav: account: "Account" security: "Beveiliging" @@ -1696,21 +1715,27 @@ nl: google_oauth2: name: "Google" title: "met Google" + sr_title: "Inloggen met Google" twitter: name: "Twitter" title: "met Twitter" + sr_title: "Inloggen met Twitter" instagram: name: "Instagram" title: "met Instagram" + sr_title: "Inloggen met Instagram" facebook: name: "Facebook" title: "met Facebook" + sr_title: "Inloggen met Facebook" github: name: "GitHub" title: "met GitHub" + sr_title: "Inloggen met GitHub" discord: name: "Discord" title: "met Discord" + sr_title: "Inloggen met Discord" second_factor_toggle: totp: "Een authenticator-app gebruiken" backup_code: "Een back-upcode gebruiken" @@ -1765,6 +1790,9 @@ nl: select_to_filter: "Een waarde selecteren om te filteren" default_header_text: Selecteren... no_content: Geen overeenkomsten gevonden + results_count: + one: "%{count} resultaat" + other: "%{count} resultaten" filter_placeholder: Zoeken... filter_placeholder_with_any: Zoeken of aanmaken... create: "Aanmaken: '%{content}'" @@ -2055,6 +2083,7 @@ nl: select_all: "Alles selecteren" clear_all: "Alles wissen" too_short: "Uw zoekterm is te kort." + clear_search: "Zoekopdracht wissen" result_count: one: "%{count} resultaat voor%{term}" other: "%{count}%{plus} resultaten voor%{term}" @@ -2073,15 +2102,23 @@ nl: search_button: "Zoeken" categories: "Categorieën" tags: "Tags" + in: "in" + in_this_topic: "in dit onderwerp" + enter_hint: "of druk op Enter" + in_posts_by: "In berichten van @%{username}" type: + default: "Onderwerpen/berichten" users: "Gebruikers" categories: "Categorieën" + categories_and_tags: "Categorieën/tags" context: user: "Berichten van @%{username} doorzoeken" category: "De categorie #%{category} doorzoeken" tag: "De tag #%{tag} doorzoeken" topic: "Dit topic doorzoeken" private_messages: "Berichten doorzoeken" + tips: + category_tag: "filtert op categorie of tag" advanced: posted_by: label: Geplaatst door @@ -2133,8 +2170,13 @@ nl: label: Weergaven min_views: placeholder: minimaal + aria_label: filteren op minimale weergaven max_views: placeholder: maximaal + aria_label: filteren op maximale weergaven + additional_options: + label: "Filteren op aantal berichten en onderwerpweergaven" + hamburger_menu: "menu" new_item: "nieuw" go_back: "terug" not_logged_in_user: "gebruikerspagina met samenvatting van huidige activiteit en voorkeuren" @@ -2151,6 +2193,9 @@ nl: delete: "Topics verwijderen" dismiss: "Negeren" dismiss_read: "Alle ongelezen negeren" + dismiss_read_with_selected: + one: "%{count} ongelezen onderwerp negeren" + other: "%{count} ongelezen onderwerpen negeren" dismiss_button: "Negeren..." dismiss_tooltip: "Alleen nieuwe berichten negeren of het volgen van topics stoppen" also_dismiss_topics: "Het volgen van deze topics stoppen, zodat ze nooit meer als ongelezen verschijnen." @@ -2800,6 +2845,7 @@ nl: bookmarks: create: "Bladwijzer maken" edit: "Bladwijzer bewerken" + edit_for_topic: "Bewerk bladwijzer voor onderwerp" created: "Gemaakt" updated: "Bijgewerkt" name: "Naam" @@ -4019,12 +4065,8 @@ nl: %{example} Het toevoegen van voorvoegsels aan de eigenschapsnamen wordt sterk aanbevolen om conflicten met plug-ins en/of core te voorkomen. - head_tag: - text: "" - title: "HTML die voor de -tag wordt ingevoegd" body_tag: - text: "" - title: "HTML die voor de -tag wordt ingevoegd" + text: "Inhoud" yaml: text: "YAML" title: "Thema-instellingen definiëren in YAML-indeling" @@ -4840,9 +4882,9 @@ nl: upload_csv: Een CSV met e-mailadressen of gebruikersnamen uploaden aborted: Upload een CSV dat e-mailadressen of gebruikersnamen bevat replace_owners: De badge van vorige eigenaren verwijderen + grant_existing_holders: Extra badges toekennen aan bestaande badgehouders emoji: title: "Emoji" - help: "Nieuwe emoji toevoegen die voor iedereen beschikbaar zal zijn. (PROTIP: versleep meerdere bestanden tegelijk)" add: "Nieuwe emoji toevoegen" uploading: "Uploaden..." name: "Naam" @@ -4853,7 +4895,6 @@ nl: embedding: get_started: "Als u Discourse in een andere website wilt inbedden, begin dan door de host ervan toe te voegen." confirm_delete: "Weet u zeker dat u die host wilt verwijderen?" - sample: "Gebruik de volgende HTML-code in uw website om Discourse-topics te maken en in te bedden. Vervang REPLACE_ME door de canonieke URL van de pagina waarin u het topic wilt inbedden." title: "Inbedding" host: "Toegestane hosts" class_name: "Klassenaam" diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index aaf8bd3459..b49352f15a 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -1247,7 +1247,7 @@ pl_PL: tags: "Tagi" warnings: "Oficjalne ostrzeżenia" read_more_in_group: "Chcesz przeczytać więcej? Przeglądaj inne wiadomości w %{groupLink}." - read_more: "Chcesz przeczytać więcej? Przeglądaj inne wiadomości w wiadomościach prywatnych." + read_more: "Chcesz przeczytać więcej? Przeglądaj inne wiadomości w wiadomościach osobistych." preferences_nav: account: "Konto" security: "Bezpieczeństwo" @@ -4538,12 +4538,8 @@ pl_PL: %{example} Zdecydowanie zalecamy dodawanie przedrostków do nazw zmiennych, aby uniknąć konfliktów z wtyczkami i/lub silnikiem forum. - head_tag: - text: "" - title: "HTML, który zostanie umieszczony przed tagiem " body_tag: - text: "" - title: "HTML, który zostanie umieszczony przed tagiem " + text: "Body" yaml: text: "YAML" title: "Zdefiniuj ustawienia motywu w formacie YAML" @@ -5407,7 +5403,6 @@ pl_PL: grant_existing_holders: Przyznaj dodatkowe odznaki obecnym posiadaczom odznak emoji: title: "Emoji" - help: "Dodaj nowe emoji. (PROTIP: przeciągnij i upuść kilka plików na raz)" add: "Dodaj nowe Emoji" uploading: "Przesyłanie…" name: "Nazwa" @@ -5418,7 +5413,6 @@ pl_PL: embedding: get_started: "Jeśli chcesz osadzić Discourse na innej stronie, rozpocznij podając jej host." confirm_delete: "Czy na pewno chcesz usunąć ten host?" - sample: "Użyj poniższego kodu HTML na swojej stronie, aby osadzić tematy z Discourse. Zastąp REPLACE_ME domyślnym adresem URL strony na której osadzasz." title: "Osadzanie" host: "Dozwolone hosty" class_name: "Nazwa klasy" diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index a11723ef14..65e5686daf 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -3882,12 +3882,8 @@ pt: %{example} Prefixar os nomes das propriedades é altamente recomendado para evitar conflitos com plugins e/ou o core. - head_tag: - text: "" - title: "HTML que será introduzido antes da tag " body_tag: - text: "" - title: "HTML que será introduzido antes da tag " + text: "Corpo" yaml: text: "YAML" title: "Defina as configurações do tema no formato YAML" @@ -4516,7 +4512,6 @@ pt: badge_query_examples_title: "Exemplos de queries de crachás" emoji: title: "Emoji" - help: "Adicionar novo emoji que irá estar disponível para todos. (PROTIP: arraste múltiplos ficheiros de uma só vez)" add: "Adicionar Novo Emoji" uploading: "A carregar…" name: "Nome" @@ -4526,7 +4521,6 @@ pt: embedding: get_started: "Se deseja incorporar o Discourse noutro sítio, comece por adicionar o seu servidor." confirm_delete: "Tem certeza que deseja eliminar este servidor?" - sample: "Utilize o seguinte código HTML no seu sítio para criar e incorporar tópicos do discourse. Substitua REPLACE_ME pelo URL canónico da página onde está a incorporá-los." title: "Incorporação" host: "Servidores Permitidos" edit: "editar" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index bb39d4eb20..ad71fc0079 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -272,6 +272,7 @@ pt_BR: help: bookmark: "Clique para adicionar a primeira postagem deste tópico aos favoritos" edit_bookmark: "Clique para editar o favorito neste tópico" + edit_bookmark_for_topic: "Clique para editar o favorito para este tópico" unbookmark: "Clique para remover todos os favoritos neste tópico" unbookmark_with_reminder: "Clique para remover todos os favoritos e lembretes neste tópico." bookmarks: @@ -2122,16 +2123,25 @@ pt_BR: search_button: "Pesquisar" categories: "Categorias" tags: "Etiquetas" + in: "em" + in_this_topic: "neste tópico" + enter_hint: "ou pressione Enter" + in_posts_by: "em postagens por %{username}" type: + default: "Tópicos/postagens" users: "Usuários" categories: "Categorias" + categories_and_tags: "Categorias/etiquetas" context: user: "Pesquisar postagens de @%{username}" category: "Pesquisar a categoria #%{category}" tag: "Pesquisar a etiqueta #%{tag}" topic: "Pesquisar este tópico" private_messages: "Pesquisar mensagens" + tips: + full_search_key: "%{modifier} + Enter" advanced: + title: Filtros avançados posted_by: label: Postado por in_category: @@ -2691,7 +2701,9 @@ pt_BR: deleted_by_author_simple: "(tópico excluído pelo(a) autor(a))" post: quote_reply: "Citação" + quote_reply_shortcut: "Ou pressione q" quote_edit: "Editar" + quote_edit_shortcut: "Ou pressione e" quote_share: "Compartilhar" edit_reason: "Motivo:" post_number: "postagem %{number}" @@ -2870,7 +2882,9 @@ pt_BR: button: "HTML" bookmarks: create: "Criar favorito" + create_for_topic: "Criar favorito para o tópico" edit: "Editar favorito" + edit_for_topic: "Editar favorito para o tópico" created: "Criado" updated: "Atualizado" name: "Nome" @@ -4115,12 +4129,8 @@ pt_BR: %{example} É altamente recomendável adicionar os nomes das propriedades ao prefixo para evitar conflitos com plug-ins e/ou núcleo. - head_tag: - text: "" - title: "O HTML que será inserido antes da etiqueta" body_tag: - text: "" - title: "O HTML que será inserido antes da etiqueta" + text: "Corpo" yaml: text: "YAML" title: "Definir configurações de tema no formato YAML" @@ -4961,7 +4971,6 @@ pt_BR: grant_existing_holders: Conceder emblemas adicionais a titulares de emblema existentes emoji: title: "Emoji" - help: "Adicionar novo emoji que estará disponível para todos(as). (DICA: arraste e solte diversos arquivos de uma vez)" add: "Adicionar novo emoji" uploading: "Enviando..." name: "Nome" @@ -4972,7 +4981,6 @@ pt_BR: embedding: get_started: "Se você deseja incorporar Discourse em outro site, começe adicionando seu host." confirm_delete: "Você tem certeza que deseja excluir este host?" - sample: "Use o seguinte código HTML no seu site para criar e incorporar tópicos do Discourse. Substitua REPLACE_ME pela URL canônica da página na qual você está incorporando." title: "Incorporar" host: "Hosts permitidos" class_name: "Nome da classe" diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index fc0b99da62..124d063742 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -3109,12 +3109,8 @@ ro: text: "Subsol" embedded_scss: text: "CSS încorporat" - head_tag: - text: "1" - title: "HTML care va fi inserat înainte de 1 tag" body_tag: - text: "1" - title: "HTML care va fi inserat înaintea de tag-ul " + text: "Corp" colors: title: "Culori" copy_name_prefix: "Copie a" @@ -3704,7 +3700,6 @@ ro: with_time: %{username} la %{time} emoji: title: "Emoji" - help: "Adaugă un nou \"emoji\" care va fi disponibil pentru toţi. (PROTIP: trage şi adaugă mai multe fişiere odată)" add: "Adaugă un emoji nou" uploading: "Se încarcă..." name: "Nume" @@ -3714,7 +3709,6 @@ ro: embedding: get_started: "Dacă dorești să încorporezi Discourse pe un alt website, începe prin a-i adăuga gazda." confirm_delete: "Ești sigur că vrei să ștergi acest host?" - sample: "Folosește următorul cod HTML în site-ul tău pentru a crea și pentru a încorpora subiecte Discourse. Înlocuiește ÎNLOCUIEȘTE_MĂ cu URL-ul canonic al paginii pe care dorești să o încorporezi." title: "Embedding" host: "Host-uri permise" class_name: "Numele clasei" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index 35ce5bddf9..6cfe2bd42e 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -2194,6 +2194,8 @@ ru: desc: "Ответить без изменения даты последнего ответа" reload: "Обновить" ignore: "Игнорировать" + image_alt_text: + aria_label: Альт-текст для изображения notifications: tooltip: regular: @@ -2353,7 +2355,7 @@ ru: topic: "Искать в этой теме" private_messages: "Искать в личных сообщениях" tips: - category_tag: "фильтры по категориям или тегам" + category_tag: "фильтры по разделам или тегам" author: "фильтры по автору сообщения" in: "фильтры по метаданным (например, in:title, in:personal, in:pinned)" status: "фильтры по статусу темы" @@ -3934,6 +3936,8 @@ ru: no_read_topics_title: "Вы пока ещё не прочли ни одной темы" no_read_topics_body: "Когда вы начнете читать обсуждения, список читаемых тем появится здесь. Чтобы начать читать, поищите интересующие вас темы в разделеОбсуждаемые, в других разделах форума, либо выполните поиск по ключевому слову %{searchIcon}" no_group_messages_title: "Групповые сообщения не найдены" + fullscreen_table: + expand_btn: "Развернуть таблицу" admin_js: type_to_filter: "Фильтрация настроек..." admin: @@ -4537,11 +4541,11 @@ ru: title: "Укажите собственные цвета (только для опытных пользователей)" placeholder: "\nИспользуйте эту таблицу стилей для добавления цветов в список пользовательских свойств CSS.\n\nПример: \n\n%{example}\n\nНастоятельно рекомендуется добавлять префиксы к именам свойств, чтобы избежать конфликтов с плагинами и / или движком форума." head_tag: - text: "" - title: "HTML для размещения перед тегом " + text: "Head" + title: "HTML, который будет вставлен перед тегом 'head'" body_tag: - text: "" - title: "HTML для размещения перед тегом " + text: "Body" + title: "HTML, который будет вставлен перед тегом 'body'" yaml: text: "YAML" title: "Определить настройки темы в формате YAML" @@ -5405,7 +5409,7 @@ ru: grant_existing_holders: Предоставление награждённым дополнительных наград emoji: title: "Эмодзи" - help: "Добавить новые эмодзи, которые будут доступны всем. (Подсказка: можно перетаскивать несколько файлов за раз)" + help: "Добавьте новые эмодзи, которые будут доступны всем. Перетащите сразу несколько файлов без указания имени, чтобы создать эмодзи, используя их имена файлов." add: "Добавить новую иконку" uploading: "Загрузка..." name: "Название" @@ -5416,7 +5420,7 @@ ru: embedding: get_started: "Для встраивания на другой сайт необходимо добавить соответствующий хост." confirm_delete: "Вы действительно хотите удалить это поле?" - sample: "Используйте следующий HTML-код на своём сайте, для возможности создания связанных тем. Замените REPLACE_ME канонической ссылкой страницы, куда производится встраивание." + sample: "Используйте следующий HTML-код на своём сайте для создания связанных тем. Замените REPLACE_ME канонической ссылкой страницы, в которую производится встраивание." title: "Встраивание" host: "Разрешённые хосты" class_name: "Имя класса" diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 022f3ec224..47f5da78be 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -2564,12 +2564,8 @@ sk: text: "Päta" embedded_scss: text: "Vnorené CSS" - head_tag: - text: "" - title: "HTML, ktoré bude vložené pred tag" body_tag: - text: "" - title: "HTML, ktoré bude vložené pred tag" + text: "Telo" colors: title: "Farby" copy_name_prefix: "Kópia" @@ -3094,7 +3090,6 @@ sk: with_time: %{username} v čase %{time} emoji: title: "Emoji" - help: "Pridaj nové emoji, ktoré bude dostupné pre všetkých (TIP: môžete pretiahnuť viac súborov naraz)" add: "Pridaj nové Emoji" uploading: "Upload prebieha..." name: "Meno" @@ -3104,7 +3099,6 @@ sk: embedding: get_started: "Pokiaľ chcete vložiť Discourse na inú stránku, začnite pridaním jej hostiteľa." confirm_delete: "Ste si istý, že chcete zmazať tohoto hostiteľa?" - sample: "Použite nasledovný HTML kód vo Vašej stránke pre vytvorenie vloženej témy Discourse. Nahraďte REPLACE_ME kanonickou URL adresou stránky, do ktorej to vkladáte." title: "Vkladám" host: "Povolení hostitelia" edit: "uprav" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index 9ab205b141..079982a91f 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -3721,12 +3721,6 @@ sl: text: "CSS" header: text: "Glava" - head_tag: - text: "" - title: "HTML, ki bo vstavljen pred oznako" - body_tag: - text: "" - title: "HTML, ki bo vstavljen pred oznako" colors: title: "Barve" copy_name_prefix: "Kopija od" @@ -4275,7 +4269,6 @@ sl: badge_query_examples_title: "Primeri poizvedb SQL za značke" emoji: title: "Emoji" - help: "Dodaj nov emoji, ki bo na voljo vsem. (lahko prestavite in spustite več datotek naenkrat)" add: "Dodaj nov emoji" uploading: "Nalagam..." name: "Ime" diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index e0f92304d5..dbcc6c97e2 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -2180,12 +2180,8 @@ sq: text: "Fundi i faqes" embedded_scss: text: "CSS e ngjitur" - head_tag: - text: "" - title: "HTML që do të vendoset para mbylles së tagut " body_tag: - text: "" - title: "HTML që do të vendoset para mbylljes së tagut " + text: "Trupi" colors: title: "Ngjyrat" copy_name_prefix: "Kopje e" @@ -2704,7 +2700,6 @@ sq: with_time: %{username} at %{time} emoji: title: "Emoji" - help: "Add new emoji that will be available to everyone. (PROTIP: drag & drop multiple files at once)" add: "Add New Emoji" uploading: "Duke ngarkuar..." name: "Emri" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index 719475cd37..716f42a664 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -1988,12 +1988,6 @@ sr: text: "Header" footer: text: "Footer" - head_tag: - text: "" - title: "HTML koji će biti umetnut pre oznake" - body_tag: - text: "" - title: "HTML koji će biti umetnut pre %{username} u %{time} emoji: title: "Emoji" - help: "Dodajte nove Emoji-e koji će biti dostupni svima. (Savet: povuci i ispusti više datoteka odjednom)" add: "Dodaj Novi Emoji" uploading: "Učitavanje..." name: "Ime" diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index e719e612b9..575e04ac58 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -1142,9 +1142,9 @@ sv: tags: "Taggar" warnings: "Officiella varningar" read_more_in_group: "Vill du läsa mer? Bläddra bland andra meddelanden i %{groupLink}." - read_more: "Vill du läsa mer? Bläddra genom andra meddelanden i personliga meddelanden." + read_more: "Vill du läsa mer? Bläddra bland andra meddelanden i personliga meddelanden." read_more_group_pm_MF: "Det { UNREAD, plural, =0 {} one { finns # oläst } other { finns # olästa } } { NEW, plural, =0 {} one { {BOTH, select, true{och } false {finns } other{}} # nytt meddelande} other { {BOTH, select, true{och } false {finns } other{}} # nya meddelanden} } kvar, eller bläddra bland andra meddelanden i {groupLink}" - read_more_personal_pm_MF: "Där { UNREAD, plural, =0 {} one { är # oläst } other { är # olästa } } { NEW, plural, =0 {} one { {BOTH, select, true{och } false {är } other{}} # nytt meddelande} other { {BOTH, select, true{och } false {är } other{}} # nya meddelanden} } kvar, eller bläddra genom andra personliga meddelanden" + read_more_personal_pm_MF: "Det { UNREAD, plural, =0 {} one { finns # oläst } other { finns # olästa } } { NEW, plural, =0 {} one { {BOTH, select, true{och } false {finns } other{}} # nytt meddelande} other { {BOTH, select, true{och } false {finns } other{}} # nya meddelanden} } kvar, eller bläddra bland andra personliga meddelanden" preferences_nav: account: "Konto" security: "Säkerhet" @@ -2034,6 +2034,8 @@ sv: desc: "Svara utan att ändra senaste svarsdatum" reload: "Ladda om" ignore: "Ignorera" + image_alt_text: + aria_label: Alternativ text för bild notifications: tooltip: regular: @@ -3622,6 +3624,8 @@ sv: no_read_topics_title: "Du har inte läst några ämnen än" no_read_topics_body: "När du börjar läsa diskussioner ser du en lista här. Börja läsa genom att leta efter ämnen som du tycker är intressanta i Topp eller Kategorier eller sök efter sökord %{searchIcon}" no_group_messages_title: "Inga gruppmeddelanden hittades" + fullscreen_table: + expand_btn: "Expandera tabell" admin_js: type_to_filter: "skriv för att filtrera..." admin: @@ -4221,11 +4225,11 @@ sv: Användning av prefix för egenskapsnamnen rekommenderas starkt för att undvika konflikter mellan tillägg och/eller grundläggande program. head_tag: - text: "" - title: "HTML som kommer att sättas in före taggen" + text: "Huvud" + title: "HTML som kommer att infogas före huvudtaggen" body_tag: - text: "" - title: "HTML som kommer att infogas in före taggen " + text: "Huvuddel" + title: "HTML som kommer att infogas innan kroppstaggen" yaml: text: "YAML" title: "Definiera temainställningar i YAML-format" @@ -5077,7 +5081,7 @@ sv: grant_existing_holders: Utfärda ytterligare utmärkelser till befintliga utmärkelseinnehavare emoji: title: "Emoji" - help: "Lägg till en ny emoji för andra att använda. (TIPS: dra och släpp flera filer på en och samma gång)" + help: "Lägg till nya emoji som kommer att vara tillgängliga för alla. Dra och släpp flera filer samtidigt utan att ange ett namn för att skapa emojis med deras filnamn." add: "Lägg till ny emoji" uploading: "Laddar upp..." name: "Namn" @@ -5088,7 +5092,7 @@ sv: embedding: get_started: "Börja genom att lägga till värden, om du vill bädda in Discourse på en annan hemsida. " confirm_delete: "Är du säker på att du vill ta bort värden?" - sample: "Använd följande HTML-kod på din webbplats för att skapa och bädda in discourse-ämnen. Byt ut REPLACE_ME mot den kanoniska URL-en för sidan som du bäddar in den i." + sample: "Klistra in följande HTML-kod på din webbplats för att skapa och bädda in discourse-ämnen. Byt ut REPLACE_ME mot den vägledande URL-en för sidan som du bäddar in den i." title: "Inbäddning" host: "Tillåtna värdar" class_name: "Klassnamn" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index 9928975ad6..511182703f 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -2632,12 +2632,8 @@ sw: title: "Ruhusu HTLM kuonekana kwenye ukurasa wa kijachini" embedded_scss: text: "CSS Iliyoambatanishwa" - head_tag: - text: "" - title: "HTML itakayowekwa kabla ya lebo " body_tag: - text: "" - title: "HTML itakayowekwa kabla ya lebo " + text: "Mwili" yaml: text: "YAML" title: "Tambulisha mipangilio ya mpango kwenye umbiza wa YAML" @@ -3242,7 +3238,6 @@ sw: with_time: %{username} kwenye %{time} emoji: title: "Emoji" - help: "Ongeza emoji mpya ambayo itapatikana kwa kila mtu. (USHAURI: unaweza kuweka zaidi ya faili moja mara moja)" add: "Ongeza Emoji Mpya" uploading: "Inaongezwa" name: "Jina" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index d6a40530c9..1280899a99 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -1561,12 +1561,8 @@ te: text: "హెడర్" footer: text: "ఫుటరు" - head_tag: - text: "" - title: " కొస ముందు ఉంచే హెచ్ టీ యం యల్" body_tag: - text: "" - title: " కొస ముందు ఉంచే హెచ్ టీ యం యల్" + text: "శరీరం" colors: title: "రంగులు" copy_name_prefix: "దీనికి నకలు" diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index b5c04a6749..132e058ab1 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -2665,10 +2665,6 @@ th: text: "Footer" embedded_scss: text: "Embedded CSS" - head_tag: - text: "" - body_tag: - text: "" colors: title: "สี" undo: "เลิกทำ" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index eab53f25aa..e24427ba29 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -1140,6 +1140,8 @@ tr_TR: tags: "Etiketler" warnings: "Resmi Uyarılar" read_more_in_group: "Daha fazla okumak ister misin? %{groupLink} bağlantısındaki diğer iletilere göz atın." + read_more: "Daha fazla okumak isterseniz kişisel mesajlar altında diğer mesajlara göz atın." + read_more_personal_pm_MF: "{ UNREAD, plural, =0 {} one {# adet okunmamış} other {# adet okunmamış }} mesaj { NEW, plural, =0 {} one { {BOTH, select, true{ve } false {var } other{}} # adet yeni mesaj} other { {BOTH, select, true{ve } false {var } other{}} # adet yeni mesaj} } var, ya dakişisel mesajlara göz atın" preferences_nav: account: "Hesap" security: "Güvenlik" @@ -2124,7 +2126,7 @@ tr_TR: sort_by: "Sırala" relevance: "Uygunluk" latest_post: "Son Gönderi" - latest_topic: "En son konu" + latest_topic: "En Son Konu" most_viewed: "En Çok Görüntülenen" most_liked: "En Çok Beğenilen" select_all: "Tümünü Seç" @@ -2156,6 +2158,7 @@ tr_TR: in_this_topic: "bu konuda" in_this_topic_tooltip: "tüm konuları aramaya geç" in_topics_posts: "tüm konularda ve gönderilerde" + enter_hint: "veya Enter tuşuna basın" in_posts_by: "@%{username} kullancısına ait gönderilerde" type: default: "Konular/yazılar" @@ -2370,7 +2373,7 @@ tr_TR: read_more_in_category: "Daha fazlası için %{catLink} kategorisine göz atabilir ya da %{latestLink}yebilirsin." read_more: "Daha fazla okumak mı istiyorsun? %{catLink} ya da %{latestLink}." unread_indicator: "Bu konunun son mesajını henüz hiç üye okumamış." - read_more_MF: "{ UNREAD, plural, =0 {0 okunmamış mesaj} one {1 okunmamış mesaj } other { # okunmamış mesaj } }ve{ NEW, plural, =0 {0 yeni mesaj} one { {BOTH, select, true{} false {} other{}}1 yeni mesaj} other { {BOTH, select, true{} false {} other{}} # yeni mesaj}} var. {CATEGORY, select, true {{catLink} kategorisine göz atabilirsin.} false {{latestLink}} other {}}" + read_more_MF: "{ UNREAD, plural, =0 {0 okunmamış mesaj} one { 1 okunmamış mesaj } other { # okunmamış mesaj } } { NEW, plural, =0 {0 yeni mesaj} one { {BOTH, select, true{ve } false {} other{}} 1 yeni mesaj} other { {BOTH, select, true{ve } false {} other{}} # yeni mesaj}} var. Ya da {CATEGORY, select, true {{catLink} kategorisine göz atabilirsin} false {{latestLink}} other {}}." bumped_at_title_MF: "{FIRST_POST}: {CREATED_AT}\n{LAST_POST}: {BUMPED_AT}" browse_all_categories: Bütün kategorilere göz at browse_all_tags: Tüm etiketlere göz atın @@ -3608,6 +3611,8 @@ tr_TR: no_topics_title: "Henüz herhangi bir konu başlatmadın" no_read_topics_title: "Henüz herhangi bir konu okumadın" no_group_messages_title: "Grup mesajı bulunamadı" + fullscreen_table: + expand_btn: "Tabloyu Genişlet" admin_js: type_to_filter: "filtrelemek için yaz..." admin: @@ -4206,11 +4211,11 @@ tr_TR: Eklentiler ve/veya ana kod ile çakışmaları önlemek için özellik adlarının önüne önek eklenmesi şiddetle tavsiye edilir. head_tag: - text: "" - title: " etiketinden önce eklenecek HTML" + text: "Head" + title: "Head etiketinden önce eklenecek HTML" body_tag: - text: "" - title: " etiketinden önce eklenecek HTML" + text: "İçerik" + title: "Body etiketinden önce eklenecek HTML" yaml: text: "YAML" title: "Tema ayarlarını YAML formatında tanımla" @@ -5062,7 +5067,6 @@ tr_TR: grant_existing_holders: Mevcut rozet sahiplerine ek rozetler ver emoji: title: "Emoji" - help: "Herkese açık yeni bir emoji ekle. (PROTIP: birden çok dosyayı tek seferde sürükleyip bırakabilirsin)" add: "Yeni Emoji Ekle" uploading: "Yükleniyor..." name: "İsim" @@ -5073,7 +5077,6 @@ tr_TR: embedding: get_started: "Eğer Discourse'u başka bir web sitesine yerleştirmek istiyorsan bu sitenin sunucusunu ekleyerek başla." confirm_delete: "Bu sunucuyu silmek istediğine emin misin?" - sample: "Discourse konuları oluşturmak ve yerleştirmek için aşağıdaki HTML kodunu sitende kullan. REPLACE_ME'yi Discourse'u yerleştirdiğin sayfanın tam URL'i ile değiştir." title: "Yerleştirme" host: "İzin Verilen Sunucular" class_name: "Sınıf adı" diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index e44c842294..bc9a9642a5 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -335,6 +335,7 @@ uk: bookmarks: created: "Ви додали повідомлення в закладки. %{name}" not_bookmarked: "додати цей допис до закладок" + remove_reminder_keep_bookmark: "Вилучити нагадування, але зберегти закладку" created_with_reminder: "Ви додали цей допис в закладку із нагадуванням %{date}. %{name}" remove: "Вилучити закладку" delete: "Видалити закладку" @@ -751,6 +752,7 @@ uk: settings_required: "Усі налаштування є обов’язковими. Будь ласка, заповніть усі поля перед підтвердженням." smtp_settings_valid: "Настройки SMTP дійсні." smtp_title: "SMTP" + smtp_instructions: "Коли ви ввімкнете SMTP для групи, всі вихідні повідомлення електронної пошти, надіслані з групи, буде надіслано через вказані тут параметри SMTP, замість стандартного поштового сервера, налаштованого для інших листів." imap_title: "IMAP" imap_additional_settings: "Додаткові налаштування" imap_alpha_warning: "Попередження. Це функція на альфа-тестуванні. Офіційно підтримується лише Gmail. Використовуйте на свій страх і ризик!" @@ -1241,7 +1243,6 @@ uk: warnings: "Офіційні попередження" read_more_in_group: "Бажаєте дізнатися більше? Перегляньте інші повідомлення в %{groupLink}." read_more_group_pm_MF: "У вас { UNREAD, plural, =0 {} one { # непрочитане } few { # непрочитаних } other { # непрочитаних } } { NEW, plural, =0 {} one { {BOTH, select, true{и } false { } other{}} # нове повідомлення} few { {BOTH, select, true{і } false { } other{}} # нових повідомлень} other { {BOTH, select, true{і } false { } other{}} # нових повідомлень} }, ви також можете продивитись інші повідомлення в групі {groupLink}" - read_more_personal_pm_MF: "У вас { UNREAD, plural, =0 {} one { # непрочитане } few { # непрочитаних } other { # непрочитаних } } { NEW, plural, =0 {} one { {BOTH, select, true{і } false { } other{}} # нове приватне повідомлення} few { {BOTH, select, true{і } false { } other{}} # нових приватних повідомлень} other { {BOTH, select, true{і } false { } other{}} # нових приватних повідомлень} }, ви також можете продивитись інші приватні повідомлення" preferences_nav: account: "Обліковий запис" security: "Безпека" @@ -4530,11 +4531,10 @@ uk: Рекомендується вводити префікси до назв властивостей, щоб уникнути конфліктів з плагінами та / або ядром. head_tag: - text: "" - title: "HTML-код, який буде вставлений перед міткою " + title: "HTML-код, який буде вставлений перед тегом " body_tag: - text: "" - title: "HTML-код, який буде вставлений перед міткою " + text: "Тіло" + title: "HTML-код, який буде вставлений перед тегом " yaml: text: "YAML" title: "Визначити налаштування теми в форматі YAML" @@ -4869,6 +4869,7 @@ uk: clear_all: Скинути все clear_all_confirm: "Ви впевнені, що хочете видалити всі контрольні слова на %{action}?" invalid_regex: 'Контрольоване слово "%{word}" є недійсним регулярним виразом.' + regex_warning: 'Контролюємі слова є регулярні вирази і вони автоматично не включають межі слів. Якщо ви хочете, щоб регулярний вираз відповідав цілим словам, додайте \b на початку і в кінці вашого регулярного виразу.' actions: block: "Заблокувати" censor: "Цензура" @@ -5397,7 +5398,7 @@ uk: grant_existing_holders: Надати додаткові нагороди існуючим власникам нагород emoji: title: "Іконки" - help: "Додати нові смайли-emoji, які будуть доступні всім. (Підказка: можна перетягувати кілька файлів за раз)" + help: "Додайте нові смайли, які будуть доступні для всіх. Перетягуйте декілька файлів одночасно, не вводячи назву, щоб створити емодзі, використовуючи їхні імена файлів." add: "Додати нову іконку" uploading: "Завантаження…" name: "Ім'я" @@ -5408,7 +5409,7 @@ uk: embedding: get_started: "Для вбудовування на Інший сайт необхідно додати відповідний хост." confirm_delete: "Ви впевнені, що хочете видалити цей хост?" - sample: "Використовуйте наступний HTML-код на своєму сайті, для можливості створення пов’язаних тем. Замініть REPLACE_ME канонічної посиланням сторінки, куди проводиться вбудовування." + sample: "Використовуйте наступний HTML-код на своєму сайті, для створення пов’язаних тем. Замініть REPLACE_ME канонічним посиланням сторінки, в яку проводиться вбудовування." title: "Взаємодія" host: "Дозволені Хости" class_name: "Назва класу" diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index 48fccc326b..ceaec164a6 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -3556,12 +3556,8 @@ ur: embedded_scss: text: "اَیمبَیڈ کیا ہوا CSS" title: "تبصرے کے اَیمبَیڈ شدہ ورژن کے ساتھ فراہم کیے جانے والا اپنی مرضی کا CSS درج کریں" - head_tag: - text: "" - title: "HTML جو ٹیگ سے پہلے داخل کی جائے گی" body_tag: - text: "" - title: "HTML جو ٹیگ سے پہلے داخل کی جائے گی" + text: "متن" yaml: text: "YAML" title: "تھیم ترتیبات کی وضاحت YAML فارمَیٹ میں کریں" @@ -4270,7 +4266,6 @@ ur: badge_query_examples_title: "بَیج قُوَیری کی مثالیں" emoji: title: "اِیمَوجی" - help: "نئی اِیمَوجی شامل کریں جو سب کے لئے دستیاب ہوگی۔ (پیشہ وَرَانہ ٹِپ: ایک بار میں ایک سے زیادہ فائلوں کو ڈرَیگ & ڈراپ کریں)" add: "نئی اِیمَوجی شامل کریں" uploading: "اَپ لوڈ کیا جا رہا ہے..." name: "نام" @@ -4280,7 +4275,6 @@ ur: embedding: get_started: "اگر آپ ڈِسکورس کو ایک اور ویب سائٹ پر اَیمبَیڈ کرنا چاہتے ہیں، تو ہوسٹ شامل کر کے شروع کریں۔" confirm_delete: "کیا آپ واقعی اس ہَوسٹ کو حذف کرنا چاہتے ہیں؟" - sample: "ڈِسکورس ٹاپک بنانے اور اَیمبَیڈ کرنے کیلئے اپنی ویب سائٹ میں درج ذیل HTML کوڈ استعمال کریں۔ جس پیج پر آپ اسے اَیمبَیڈ کر رہے ہیں اُس کا canonical URL REPLACE_ME کی جگہ اِستعمال کریں۔" title: "اَیمبَیڈ کرنا" host: "اجازت یافتہ ہَوسٹ" class_name: "کلاس کا نام" diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index f05c8935dc..6646132989 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -3784,12 +3784,8 @@ vi: color_definitions: text: "Màu sắc" title: "Nhập màu tùy chỉnh (chỉ dành cho người dùng nâng cao)" - head_tag: - text: "" - title: "HTML sẻ thêm trước thẻ " body_tag: - text: "" - title: "HTML sẽ thêm trước thẻ " + text: "Thân" yaml: text: "YAML" title: "Xác định cài đặt chủ đề ở định dạng YAML" @@ -4546,7 +4542,6 @@ vi: replace_owners: Xóa huy hiệu khỏi chủ sở hữu trước đó emoji: title: "Emoji" - help: "Thêm emoji mới có sẵn cho tất cả mọi người. (MẸO: kéo & thả nhiều file cùng lúc)" add: "Thêm emoji mới" uploading: "Đang tải lên..." name: "Tên" @@ -4556,7 +4551,6 @@ vi: embedding: get_started: "Nếu bạn muốn nhúng Discourse trên một website khác, bắt đầu bằng cách thêm host." confirm_delete: "Bạn muốn xóa host này?" - sample: "Sử dụng mã HTML sau vào website để tạo và nhúng các chủ đề. Thay thế REPLACE_ME với Canonical URL của trang bạn muốn nhúng." title: "Nhúng" host: "Cho phép Host" class_name: "Tên lớp" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index eaf3008079..6653ef002c 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -1089,7 +1089,6 @@ zh_CN: tags: "标签" warnings: "官方警告" read_more_in_group: "想阅读更多?浏览%{groupLink}或中的其他话题。" - read_more: "想阅读更多? 请在 个人消息中浏览其他消息。" preferences_nav: account: "帐户" security: "安全性" @@ -4016,12 +4015,8 @@ zh_CN: %{example} 强烈建议为属性名称添加前缀以避免与插件和/或核心冲突。 - head_tag: - text: "" - title: "将在 标记前插入的 HTML" body_tag: - text: "" - title: "将在 标记前插入的 HTML" + text: "正文" yaml: text: "YAML" title: "使用 YAML 格式定义主题设置" @@ -4867,7 +4862,6 @@ zh_CN: grant_existing_holders: 向现有徽章持有者授予额外的徽章 emoji: title: "表情符号" - help: "添加可供所有人使用的新表情符号。(高级提示:一次拖放多个文件)" add: "添加新表情符号" uploading: "正在上传…" name: "名称" @@ -4878,7 +4872,6 @@ zh_CN: embedding: get_started: "如果您想将 Discourse 嵌入另一个网站,请先添加其主机。" confirm_delete: "确定要删除该主机吗?" - sample: "在您的站点中使用以下 HTML 代码来创建和嵌入 Discourse 话题。将 REPLACE_ME 替换为您嵌入它的页面的规范 URL。" title: "嵌入" host: "允许的主机" class_name: "类名" diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index dcb2465329..8ea4c75ca4 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -970,7 +970,7 @@ zh_TW: move_to_archive: "封存" failed_to_move: "移動所選郵件失敗(請檢查網路連線)" tags: "標籤" - read_more: "想看更多?在個人訊息瀏覽其他訊息。" + read_more: "想看更多?到個人訊息瀏覽其他訊息。" preferences_nav: account: "帳號" security: "安全性" @@ -3231,12 +3231,8 @@ zh_TW: embedded_scss: text: "嵌入的 CSS" title: "輸入用於嵌入回應的自定義 CSS 樣式" - head_tag: - text: "" - title: "會在標籤前被插入的 HTML" body_tag: - text: "" - title: "會在標籤前被插入的 HTML" + text: "內容" yaml: text: "YAML" title: "用 YAML 格式定義佈景主題的設定" @@ -3918,7 +3914,6 @@ zh_TW: badge_query_examples_title: "查詢徽章範例" emoji: title: "Emoji" - help: "新增新的emoji供所有人使用。(提示:一次拖放多個檔案)" add: "新增emoji" uploading: "正在上傳..." name: "名稱" @@ -3928,7 +3923,6 @@ zh_TW: embedding: get_started: "如果你想要將 Discourse 嵌入至其他網站,添加他們的主機地址。" confirm_delete: "你確定要刪除此主機?" - sample: "使用下列 HTML 代碼至你的站點開啟和嵌入 Discourse 話題。把REPLACE_ME 替換成你將嵌入至的網址。" title: "嵌入" host: "允許的主機" class_name: "階級名稱" diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index 93a3585055..e61ce82a11 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -285,7 +285,6 @@ ar: topic_invite: failed_to_invite: "لا يمكن دعوة المستخدم إلى هذا الموضوع دون عضوية في إحدى المجموعات التالية: %{group_names}." user_exists: "عذرًا، لقد تمت دعوة هذا المستخدم بالفعل. لا يمكنك دعوة مستخدام إلى موضوع أكثر من مرة." - muted_invitee: "عذرًا، لقد كتمك هذا المستخدم." muted_topic: "عذرًا، لقد كتم هذا المستخدم ذلك الموضوع." receiver_does_not_allow_pm: "عذرًا، لا يسمح لك هذا المستخدم بإرسال رسائل خاصة إليه." sender_does_not_allow_pm: "عذرًا، أنت لا تسمح لهذا المستخدم بإرسال رسائل خاصة إليك." @@ -1658,7 +1657,6 @@ ar: summary_likes_required: "الحد الأدنى من عدد الإعجابات في الموضوع قبل تفعيل \"تلخيص هذا الموضوع\". سيتم تطبيق التغييرات على هذا الإعداد بأثر رجعي في غضون أسبوع." summary_percent_filter: "يظهر أعلى % من المنشورات عندما يضغط المستخدم على \"تلخيص هذا الموضوع\"" summary_max_results: "الحد الأقصى لعدد المنشورات التي تم إرجاعها بواسطة \"تلخيص هذا الموضوع\"" - enable_personal_messages: "السماح للمستخدمين من مستوى الثقة 1 (قابل للإعداد عبر الحد الأدنى لمستوى الثقة لإرسال الرسائل) بإنشاء رسائل والرد على الرسائل. لاحظ أن فريق العمل يمكنه دائمًا إرسال الرسائل بغض النظر عن أي شيء." enable_system_message_replies: "يسمح للمستخدمين بالرد على رسائل النظام، حتى إذا كانت الرسائل الشخصية متوقفة." enable_long_polling: "يمكن لناقل الرسائل المُستخدَم في الإشعارات استخدام الاستقصاء الطويل" enable_chunked_encoding: "تفعيل استجابات الترميز المقسَّمة بواسطة الخادم. تعمل هذه الميزة على معظم الإعدادات، ولكن قد يتم تخزين بعض الخوادم الوكيلة مؤقتًا، مما يتسبب في تأخير الاستجابات" @@ -1988,7 +1986,6 @@ ar: topic_view_duration_hours: "عد مرة عرض الموضوع الجديد مرة واحدة لكل IP/مستخدم كل N ساعة" user_profile_view_duration_hours: "عد عرض الملف الشخصي للمستخدم الجديد مرة واحدة لكل IP/مستخدم كل N ساعة" levenshtein_distance_spammer_emails: "عدد الأحرف المختلفة الذي سيسمح بمطابقة جزئية عند مطابقة الرسائل الإلكترونية غير المرغوب فيها" - max_new_accounts_per_registration_ip: "التوقُّف عن قبول عمليات الاشتراك الجديدة من عنوان IP هذا إذا كان هناك بالفعل (n) حساب من مستوى الثقة 0 منه (ولم يكن لفريق العمل أو من المستوى الثقة 2 أو أعلى)" min_ban_entries_for_roll_up: "عند النقر على الزر \"تجميع\"، سيتم إنشاء إدخال حظر جديد في الشبكة الفرعية إذا كان هناك (N) من الإدخالات على الأقل." max_age_unmatched_emails: "حذف إدخالات البريد الإلكتروني الخاضعة للمراقبة غير المتطابقة بعد (N) يوم" max_age_unmatched_ips: "حذف إدخالات عناوين IP الخاضعة للمراقبة غير المتطابقة بعد (N) يوم" @@ -4421,10 +4418,8 @@ ar: label: "اسم مجتمعك" placeholder: "استراحة جين" site_description: - label: "صِف مجتمعك في جملة واحدة قصيرة" placeholder: "مكان لجين وأصدقائها لمناقشة أشياء رائعة" short_site_description: - label: "صِف مجتمعك في بضع كلمات" placeholder: "أفضل مجتمع على الإطلاق" introduction: title: "مقدمة" diff --git a/config/locales/server.be.yml b/config/locales/server.be.yml index 7ae833e9d5..db6d898ed4 100644 --- a/config/locales/server.be.yml +++ b/config/locales/server.be.yml @@ -933,7 +933,6 @@ be: summary_score_threshold: "Мінімальная колькасць балаў, неабходныя для пасады, якія будуць уключаны ў «Сумаваць гэтую тэму»" summary_percent_filter: "Калі карыстальнік націскае «Сумаваць гэтую тэму», паказваюць верхнюю% паведамленняў" summary_max_results: "Максімум паведамлення якiя вяртаюцца «Абагульніць Гэтую тэму»" - enable_personal_messages: "Дазволіць траставы ўзровень 1 (наладжваецца праз мін ўзровень даверу для адпраўкі паведамленняў) карыстальнікам ствараць паведамленні і адказваць на паведамленні. Звярніце ўвагу, што супрацоўнікі заўсёды могуць адпраўляць паведамленні незалежна ад таго, што." enable_system_message_replies: "Дазваляе карыстальнікам адказваць на сістэмныя паведамленні, нават калі асабістыя паведамленні адключаныя" enable_long_polling: "Шына паведамленняў выкарыстоўваюцца для апавяшчэння можа выкарыстоўваць доўгі апытанне" long_polling_base_url: "Базавы URL выкарыстоўваецца для апытання (калі CDN абслугоўвае дынамічны кантэнт, не забудзьцеся ўсталяваць гэта паходжанне цягнуць), напрыклад: HTTP:" @@ -1146,7 +1145,6 @@ be: topic_view_duration_hours: "Граф новага віду тэмы адзін раз у IP" user_profile_view_duration_hours: "Граф новы від профіляў карыстальнікаў адзін раз у IP" levenshtein_distance_spammer_emails: "Пры супастаўленні спамерскіх лістоў, колькасць знакаў розніцы, што будзе па-ранейшаму дазваляе невыразны матч." - max_new_accounts_per_registration_ip: "Калі ўжо ёсць (п) узровень даверу 0 рахункаў з гэтага IP (і ніхто не з'яўляецца супрацоўнікам або TL2 або вышэй), спыніць прыём новых падпісак з гэтага IP." min_ban_entries_for_roll_up: "Пры націску на кнопку Roll Up, створыць новую запіс аб забароне падсеткі, калі ёсць па меншай меры (N) запісаў." max_age_unmatched_emails: "Выдаліць няпарнага Экранаваныя запісу па электроннай пошце пасля (N) дзён." max_age_unmatched_ips: "Выдаліць няпарнага Экранаваныя запісу IP пасля (N) дзён." @@ -2403,10 +2401,8 @@ be: label: "Імя вашага супольнасці" placeholder: "Hangout Джэйн" site_description: - label: "Апішыце сваю супольнасць у адным кароткім сказе" placeholder: "Месца для Джэйн і яе сяброў, каб абмеркаваць цікавыя рэчы" short_site_description: - label: "Апішыце сваю супольнасць у некалькіх словах" placeholder: "Лепшае супольнасць калі-небудзь" introduction: title: "ўвядзенне" diff --git a/config/locales/server.bg.yml b/config/locales/server.bg.yml index f4d2f97fdf..4e35aae1f1 100644 --- a/config/locales/server.bg.yml +++ b/config/locales/server.bg.yml @@ -803,7 +803,6 @@ bg: privacy_policy_url: "Ако имате документ \"Декларация за поверителност\", който се хоства някъде другаде и желаете да го използвате, въведете пълния път до URL адреса тук. " allowed_spam_host_domains: "Списък с домейните изключени от спам хост теста. Новите потребители никога няма да бъдат ограничавани в създаването на публикации с линкове към тези домейни." levenshtein_distance_spammer_emails: "Колко символа разлика в съвпадението може да има, когато се проверява имейла за спам, за да има приблизително съвпадение." - max_new_accounts_per_registration_ip: "Ако вече има (n) в ниво на доверие 0 акаунта от това IP (и никой не е от екипа или от по-високо ниво на доверие 2), спри да приемаш нови регистрации от този IP адрес." min_ban_entries_for_roll_up: "Когато щракнете на бутона Сливане, ще създадете нов бан на подмрежа, ако в същата има повече от (N) записа." max_age_unmatched_emails: "Изтриване на несъвпадащите пресяти имейл записи след (N) дни." max_age_unmatched_ips: "Изтрий несъчетаните пресяти IP вписвания след (N) дни." diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml index 346d7eea66..310ae161ef 100644 --- a/config/locales/server.ca.yml +++ b/config/locales/server.ca.yml @@ -1251,7 +1251,6 @@ ca: summary_likes_required: "Nombre mínim de 'M'agrada' en un tema abans que sigui activat 'Resumeix aquest tema'. Els canvis en aquesta configuració s'aplicaran retroactivament dins d'una setmana." summary_percent_filter: "Quan un usuari fa clic en 'Resumeix aquest tema', mostra el % superior de les publicacions" summary_max_results: "Nombre màxim de publicacions retornades per \"Resumeix aquest tema\"" - enable_personal_messages: "Permet que els usuaris amb nivell de confiança 1 puguin crear missatges i respondre-hi (configurable amb nivell de confiança mínim per a enviar missatges). Observeu que l'equip responsable sempre pot enviar tota mena de missatges." enable_system_message_replies: "Permet als usuaris respondre als missatges del sistema, fins i tot si els missatges personals estan desactivats" enable_long_polling: "El bus de missatges emprat per a notificar pot utilitzar 'long polling'" long_polling_base_url: "URL base emprat per a 'long polling' (quan una xarxa de CDN serveix contingut dinàmic, assegureu-vos que ho heu configurat com a 'origin pull'), p. ex. http://origin.site.com" @@ -1505,7 +1504,6 @@ ca: topic_view_duration_hours: "Compta una nova vista de tema una vegada per IP/usuari cada N hores" user_profile_view_duration_hours: "Compta una nova vista de perfil d'usuari una vegada per IP/usuari cada N hores." levenshtein_distance_spammer_emails: "En cercar coincidències de correus brossa, diferència de nombre de caràcters que encara permetrà una coincidència difusa (fuzzy match)." - max_new_accounts_per_registration_ip: "Si ja hi ha (n) comptes amb nivell de confiança o d'aquesta IP (i cap no és membre de l'equip responsable o té nivell 2 o superior), deixa d'acceptar registres des d'aquesta IP." min_ban_entries_for_roll_up: "En clicar el botó Agrupa es crearà una nova entrada de prohibició de subxarxa si hi ha almenys (N) entrades." max_age_unmatched_emails: "Suprimeix entrades no coincidents de correus sota supervisió després de (N) dies." max_age_unmatched_ips: "Suprimeix entrades no coincidents d'entrades IP sota supervisió després de (N) dies." @@ -3006,10 +3004,8 @@ ca: label: "El nom de la vostra comunitat" placeholder: "Cau de la Jane" site_description: - label: "Descriviu la vostra comunitat amb una frase curta" placeholder: "Un lloc on la Jane i les seves amistats poden conversar sobre temes interessants" short_site_description: - label: "Descriviu la vostra comunitat en poques paraules" placeholder: "La millor comunitat de tots els temps" introduction: title: "Introducció" diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml index 6acd0a86c4..b81a320d42 100644 --- a/config/locales/server.da.yml +++ b/config/locales/server.da.yml @@ -241,7 +241,6 @@ da: topic_invite: failed_to_invite: "Brugeren kan ikke inviteres til dette emne uden et gruppemedlemskab i en af følgende grupper: %{group_names}." user_exists: "Beklager, men brugeren er allerede inviteret. Du kan kun invitere en bruger til et emne én gang." - muted_invitee: "Beklager, denne bruger har slukket for dig." muted_topic: "Beklager, denne bruger ønsker tavshed i dette emne." receiver_does_not_allow_pm: "Beklager, denne bruger tillader dig ikke at sende private beskeder til sig." sender_does_not_allow_pm: "Beklager, du tillader ikke denne bruger sender dig private beskeder." @@ -1374,7 +1373,6 @@ da: summary_likes_required: "Minimum antal 'Synes godt om' i et emne, før 'Opsummer dette emne' er aktiveret. Ændringer af denne indstilling anvendes med tilbagevirkende kraft inden for en uge." summary_percent_filter: "Når en bruger kllikker 'Opsummer dette Emne' vises top % af indlæg" summary_max_results: "Maksimum antal indlæg returneret af 'Opsummer dette emne'" - enable_personal_messages: "Tillad Trust Level 1 (kan konfigureres minimum trust level til at sende beskeder), brugere til at sende og svare på beskeder. Bemærk at moderatorer beskeder, uanset hvad." enable_system_message_replies: "Tillader brugere at svare på systemmeddelelser, også selvom personlige meddelelser er deaktiveret" enable_long_polling: "Message bus til underretninger kan bruge long polling" long_polling_base_url: "URL anvendt for afsteminger (når CDN leverer dynamisk indhold, så sæt dette til oprindelig / orginal) f.eks: http://origin.site.com" @@ -1584,7 +1582,6 @@ da: tos_url: "Hvis du hoster dine forretningsbetingelser et andet sted kan du indtaste den fulde URL her." privacy_policy_url: "Hvis du hoster din privatlivspolitik et andet sted kan du indtaste den fulde URL her." staff_like_weight: "Hvor meget vægt der gives for personalets 'Synes godt om' (ikke-personale har en vægt på 1.)" - max_new_accounts_per_registration_ip: "Hvis der allerede er (n) kontoer med tillidsniveau 0 fra en IP (og ingen er en del af hjælperteamet eller på TL2 eller højere), så accepteres nye kontooprettelser fra denne IP ikke." num_flaggers_to_close_topic: "Minimum antal anmeldelser fra unikke brugere, der skal til for automatisk at sætte et emne på pause for ingriben" auto_respond_to_flag_actions: "Aktivér automatisk besvarelse, når en anmeldelse fjernes." high_trust_flaggers_auto_hide_posts: "Nye brugerindlæg skjules automatisk efter at være blevet anmeldt som spam af en TL3+ bruger" @@ -3601,10 +3598,8 @@ da: label: "Dit fællesskabs navn" placeholder: "Janes Sted" site_description: - label: "Beskriv dit fællesskab med en kort sætning" placeholder: "Et sted hvor Jane og hendes venner kan diskutere fede emner" short_site_description: - label: "Beskriv dit fællesskab med nogle få ord" placeholder: "Bedste fællesskab nogensinde" introduction: title: "Introduktion" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 07d968275c..d1f2c30bdd 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -250,7 +250,6 @@ de: topic_invite: failed_to_invite: "Der Benutzer kann nicht in dieses Thema eingeladen werden, ohne Mitglied in einer der folgenden Gruppen zu sein: %{group_names}" user_exists: "Entschuldige, dieser Benutzer ist bereits eingeladen worden. Du kannst einen Benutzer nur einmal zu einem Thema einladen." - muted_invitee: "Entschuldige, dieser Benutzer hat dich stummgeschaltet." muted_topic: "Entschuldige, dieser Benutzer hat dieses Thema stummgeschaltet." receiver_does_not_allow_pm: "Entschuldige, dieser Benutzer erlaubt es dir nicht, ihm private Nachrichten zu senden." sender_does_not_allow_pm: "Entschuldige, du erlaubst diesem Benutzer nicht, dir private Nachrichten zu senden." @@ -1790,7 +1789,7 @@ de: topic_view_duration_hours: "Zähle einen neuen Themenaufruf einmal pro IP/Benutzer alle N Stunden" user_profile_view_duration_hours: "Zähle einen neuen Profilaufruf einmal pro IP/Benutzer alle N Stunden" levenshtein_distance_spammer_emails: "E-Mail-Adressen, die sich um so viele Zeichen unterscheiden, werden beim Abgleich mit Adressen der Spammer dennoch als identisch betrachtet." - max_new_accounts_per_registration_ip: "Keine neuen Registrierungen von einer IP-Adresse annehmen, wenn bereits (n) Benutzerkonten mit Vertrauensstufe 0 zugeordnet sind (und keines davon ein Team-Mitglied ist oder Vertrauensstufe 2 oder höher hat)." + max_new_accounts_per_registration_ip: "Keine neuen Registrierungen von einer IP-Adresse annehmen, wenn bereits (n) Benutzerkonten mit Vertrauensstufe 0 zugeordnet sind (und keines davon ein Team-Mitglied ist oder Vertrauensstufe 2 oder höher hat). 0 setzen um die Begrenzung zu deaktivieren." min_ban_entries_for_roll_up: "Wenn du auf die Schaltfläche „Zusammenfassen“ klickst, wird ein neuer Subnetz-Block-Eintrag erstellt, wenn es mindestens (N) Einträge gibt." max_age_unmatched_emails: "Gefilterte E-Mail-Adressen nach (N) Tagen ohne Treffer löschen." max_age_unmatched_ips: "Gefilterte IP-Adressen nach (N) Tagen ohne Treffer löschen." @@ -1926,7 +1925,8 @@ de: permalink_normalizations: "Diesen regulären Ausdruck anwenden, bevor Permalinks verarbeitet werden; Beispiel: /(topic.*)\\?.*/\\1 wird Query-Strings von Themen-Routen entfernen. Format: regulärer Ausdruck + String, benutze \\1 usw., um Teilausdrücke zu verwenden" global_notice: "Zeigt allen Besuchern eine DRINGENDE NOTFALL-Meldung in Form eines nicht ausblendbaren, global sichtbaren Banners an. Leere den Inhalt, um sie wieder auszublenden (HTML ist erlaubt)." disable_system_edit_notifications: "Unterdrückt Bearbeitungsbenachrichtigungen durch den System-Benutzer, wenn die „download_remote_images_to_local“-Einstellung aktiviert ist." - disable_category_edit_notifications: "Deaktiviere Benachrichtigungen zum Bearbeiten von Kategorien für Themen." + disable_category_edit_notifications: "Deaktiviere Benachrichtigungen über Kategorie-Anpassungen von Themen." + disable_tags_edit_notifications: "Deaktiviere Benachrichtigungen über Schlagwort-Anpassungen von Themen." notification_consolidation_threshold: "Anzahl von „Gefällt mir“- oder Mitgliedschaftsanfragen-Benachrichtigungen, bevor die Benachrichtigungen zu einer einzelnen zusammengefasst werden. Zum Deaktivieren auf 0 setzen." likes_notification_consolidation_window_mins: "Zeitfenster in Minuten, in dem mehrere „Gefällt mir“-Benachrichtigungen zu einer einzelnen Benachrichtigung zusammengefasst werden, sobald der Schwellenwert erreicht wird. Der Schwellenwert kann mittels `SiteSetting.notification_consolidation_threshold` eingestellt werden." automatically_unpin_topics: "Themen automatisch loslösen, wenn ein Benutzer das Ende erreicht." @@ -4367,10 +4367,8 @@ de: label: "Name deiner Community" placeholder: "Erikas Stammtisch" site_description: - label: "Beschreibe deine Community in einem kurzen Satz" placeholder: "Ein Ort für Erika und ihre Bekannten, um coole Sachen zu besprechen" short_site_description: - label: "Beschreibe deine Community in wenigen Worten" placeholder: "Beste Community aller Zeiten" introduction: title: "Einführung" diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 77297228a0..d5c837eb4e 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -950,7 +950,6 @@ el: force_https: "Αναγκάζει το site σας να χρησιμοποιεί μόνο HTTPS. ΠΡΟΣΟΧΗ: Μην το ενεργοποιήσετε, μέχρι να επαληθεύσετε ότι το HTTPS είναι πλήρως εγκατεστημένο και λειτουργεί απολύτως παντού! Ελέγξατε το CDN σας, όλες τις κοινωνικές συνδέσεις και τα εξωτερικά σας λογότυπα / εξαρτήσεις για να βεβαιωθείτε ότι είναι όλα συμβατά με HTTPS;" summary_score_threshold: "Η ελάχιστη βαθμολογία που απαιτείται από μια ανάρτηση για να συμπεριληφθεί στο «Συνοψίστε αυτό το θέμα»" summary_percent_filter: "Όταν ο χρήστης επιλέξει «Συνοψίστε αυτό το θέμα», δείξε το κορυφαίο % των αναρτήσεων" - enable_personal_messages: "Επίτρεψε στους χρήστες επιπέδου εμπιστοσύνης 1 (ρυθμιζόμενο μέσω min trust level to send messages) να δημιουργήσουν μηνύματα και να απαντήσουν σε μηνύματα. Σημειώστε ότι οι συνεργάτες μπορούν πάντα να στέλνουν μηνύματα." enable_long_polling: "Η αρτηρία μηνυμάτων που χρησιμοποιείτε για ειδοποιήσεις μπορεί να χρησιμοποιήσει μακρυά μέθοδο εξέτασης." long_polling_base_url: "Base URL που χρησιμοποιείτε για μακρύ ψήφισμα (όταν ένα CDN εξυπηρετεί δυναμικό περιεχόμενο, βεβαιωθείτε ότι το ορίσατε σε έλξη προέλευσης ) π.χ.: http://origin.site.com" long_polling_interval: "Χρονικό διάστημα που ο διακομιστής θα πρέπει να περιμένει πριν απαντήσει στους πελάτες όταν δεν υπάρχουν δεδομένα για την αποστολή (μόνο συνδεδεμένοι χρήστες )" @@ -1130,7 +1129,6 @@ el: topic_view_duration_hours: "Μετρήστε μια νέα προβολή νήματος μία φορά ανά IP/User κάθε N ώρες" user_profile_view_duration_hours: "Μετρήστε μια νέα προβολή προφίλ χρήστη μία φορά ανά IP/User κάθε N ώρες" levenshtein_distance_spammer_emails: "Στην αντιστοίχιση διευθύνσεων email των σπάμερ, η διαφορά στον αριθμό των χαρακτήρων η οποία θα επιτρέπει μια ασαφή αντιστοίχιση." - max_new_accounts_per_registration_ip: "Αν ήδη υπάρχουν (n) λογαριασμοί επιπέδου εμπιστοσύνης 0 από αυτή την IP διεύθυνση (και κανείς από αυτούς δεν είναι συνεργάτης ή τουλάχιστον στο ΕΕ2), σταμάτα να αποδέχεσαι νέες εγγραφές από αυτή την IP διεύθυνση. " min_ban_entries_for_roll_up: "Το πάτημα του πλήκτρου Κύλιση προς τα πάνω, θα δημιουργήσει μία απαγόρευση εισαγωγής νέου υποδικτύου αν υπάρχουν τουλάχιστον (Ν) εισαγωγές. " max_age_unmatched_emails: "Διαγράψτε τις αταίριαστες ελεγχόμενες εγγραφές email μετά από (N) μέρες." max_age_unmatched_ips: "Διαγράψτε τις αταίριαστες ελεγχόμενες εγγραφές IP μετά από (N) μέρες." @@ -2546,7 +2544,6 @@ el: label: "Το όνομα της κοινότητάς σου" placeholder: "Το μέρος που συχνάζει η Jane" site_description: - label: "Περίγραψε την κοινότητά σου σε μια σύντομη πρόταση" placeholder: "Ένα μέρος για την Jane και τις φίλες της να συζητήσουν ωραία θέματα" introduction: title: "Εισαγωγή" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index da3b53354a..62492771d6 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -270,7 +270,6 @@ en: topic_invite: failed_to_invite: "The user cannot be invited into this topic without a group membership in either one of the following groups: %{group_names}." user_exists: "Sorry, that user has already been invited. You may only invite a user to a topic once." - muted_invitee: "Sorry, that user muted you." muted_topic: "Sorry, that user muted this topic." receiver_does_not_allow_pm: "Sorry, that user does not allow you to send them private messages." sender_does_not_allow_pm: "Sorry, you do not allow that user to send you private messages." @@ -1548,7 +1547,7 @@ en: summary_max_results: "Maximum posts returned by 'Summarize This Topic'" summary_timeline_button: "Show a 'Summarize' button in the timeline" - enable_personal_messages: "Allow trust level 1 (configurable via min trust level to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what." + enable_personal_messages: "Allow trust level 1 (configurable via min trust to send messages) users to create messages and reply to messages. Note that staff can always send messages no matter what." enable_system_message_replies: "Allows users to reply to system messages, even if personal messages are disabled" enable_long_polling: "Message bus used for notification can use long polling" enable_chunked_encoding: "Enable chunked encoding responses by the server. This feature works on most setups however some proxies may buffer, causing responses to be delayed" @@ -1950,7 +1949,7 @@ en: user_profile_view_duration_hours: "Count a new user profile view once per IP/User every N hours" levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match." - max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP." + max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP. Set to 0 to disable the limit." min_ban_entries_for_roll_up: "When clicking the Roll up button, will create a new subnet ban entry if there are at least (N) entries." max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days." @@ -2131,6 +2130,8 @@ en: disable_category_edit_notifications: "Disable category edit notifications on topics." + disable_tags_edit_notifications: "Disable tags edit notifications on topics." + notification_consolidation_threshold: "Number of liked or membership request notifications received before the notifications are consolidated into a single one. Set to 0 to disable." likes_notification_consolidation_window_mins: "Duration in minutes where liked notifications are consolidated into a single notification once the threshold has been reached. The threshold can be configured via `SiteSetting.notification_consolidation_threshold`." @@ -2164,7 +2165,7 @@ en: warn_reviving_old_topic_age: "When someone starts replying to a topic where the last reply is older than this many days, a warning will be displayed. Disable by setting to 0." autohighlight_all_code: "Force apply code highlighting to all preformatted code blocks even when they didn't explicitly specify the language." highlighted_languages: "Included syntax highlighting rules. (Warning: including too many languages may impact performance) see: https://highlightjs.org/static/demo for a demo" - show_copy_button_on_codeblocks: "Add a button to codeblocks to copy the block contents to the user's clipboard. This feature is not supported on Internet Explorer." + show_copy_button_on_codeblocks: "Add a button to codeblocks to copy the block contents to the user's clipboard." embed_any_origin: "Allow embeddable content regardless of origin. This is required for mobile apps with static HTML." embed_topics_list: "Support HTML embedding of topics lists" @@ -4810,10 +4811,10 @@ en: label: "Your community’s name" placeholder: "Jane’s Hangout" site_description: - label: "Describe your community in one short sentence" + label: "Describe your community in one short sentence (used in search results and social media)" placeholder: "A place for Jane and her friends to discuss cool stuff" short_site_description: - label: "Describe your community in few words" + label: "Describe your community in few words (used for the homepage title)" placeholder: "Best community ever" introduction: diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 177b5441f3..3ea356d725 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -250,7 +250,6 @@ es: topic_invite: failed_to_invite: "No se puede invitar al usuario a este tema sin ser miembro de un grupo en cualquiera de los siguientes grupos: %{group_names}." user_exists: "Lo sentimos, ese usuario ya fue invitado. Solo se puede invitar a un usuario a un tema una vez." - muted_invitee: "Lo sentimos, el usuario te ha silenciado." muted_topic: "Lo sentimos, el usuario ha silenciado este tema." receiver_does_not_allow_pm: "Lo sentimos, el usuario no permite que le envíes mensajes privados." sender_does_not_allow_pm: "Lo sentimos, no permites que el usuario te mande mensajes privados." @@ -1458,7 +1457,6 @@ es: summary_percent_filter: "Cuando un usuario hace clic en «resumen de este tema», mostrar el % de las publicaciones destacadas" summary_max_results: "Cantidad máxima de publicaciones devueltas en «resumen de este tema»" summary_timeline_button: "Mostrar un botón para «Resumir» en la línea de tiempo" - enable_personal_messages: "Permitir a los usuarios con nivel de confianza 1 (configurable a través del mínimo nivel de confianza para enviar mensajes) crear mensajes y responder a ellos. Ten en cuenta que el personal siempre puede enviar mensajes pase lo que pase." enable_system_message_replies: "Permite a los usuarios responder a los mensajes del sistema, incluso si los mensajes personales están desactivados." enable_long_polling: "Los mensajes usados para notificaciones pueden usar el long polling" enable_chunked_encoding: "Activar respuestas en lotes del servidor. Esta funcionalidad debería funcionar en casi todos los entornos, pero algunos proxies pueden causar que las respuestas tarden" @@ -1789,7 +1787,6 @@ es: topic_view_duration_hours: "Contar una visita a un nuevo tema por IP/Usuario cada N horas" user_profile_view_duration_hours: "Contar una nueva visita de perfil por IP/Usuario cada N horas" levenshtein_distance_spammer_emails: "Al revisar coincidencias por correos electrónicos de spammers, cantidad de caracteres diferentes que permiten una coincidencia parcial." - max_new_accounts_per_registration_ip: "Si ya hay (n) cuentas con nivel de confianza 0 desde esta IP (y ninguna es de un miembro del equipo o de nivel de confianza 2 o superior), prohibir registros nuevos desde esa IP." min_ban_entries_for_roll_up: "Al hacer clic en el botón agrupar, se creará un nuevo rango de entradas prohibidas si hay al menos (N) entradas." max_age_unmatched_emails: "Eliminar entradas de correos electrónicos prohibidos que no coincidan después de (N) días." max_age_unmatched_ips: "Eliminar entradas de IP prohibidos que no coincidan después de (N) días." @@ -1925,6 +1922,7 @@ es: permalink_normalizations: "Aplicar la siguiente expresión regular antes de hacer coincidir los permalinks, por ejemplo: /(topic.*)\\?.*/\\1 despojará las cadenas de consulta de las rutas de los temas. El formato es regex+string usa \\1 etc. para acceder a capturas" global_notice: "Mostrar un banner global de URGENCIA o EMERGENCIA que no se pueda ocultar para todos los visitantes. Deja este campo en blanco para ocultarlo (se permite HTML)." disable_system_edit_notifications: "Desactivar editar notificaciones por el usuario del sistema cuando «download_remote_images_to_local» este activo." + disable_category_edit_notifications: "Desactivar que se manden notificaciones al cambiar la categoría de los temas." notification_consolidation_threshold: "Cantidad de me gusta o notificaciones de solicitud de membresía recibidas antes de que las notificaciones se consoliden en una sola. Establece el valor en 0 para desactivar." likes_notification_consolidation_window_mins: "Minutos durante los que las notificaciones se consolidan en una sola notificación una vez que se haya alcanzado el limite. El límite se puede configurar en «SiteSetting.notification_consolidation_threshold»." automatically_unpin_topics: "Desanclar automáticamente cuando el usuario llega al final del tema." @@ -4366,10 +4364,8 @@ es: label: "El nombre de tu comunidad" placeholder: "Reunión de Juana" site_description: - label: "Describe tu comunidad en una frase corta" placeholder: "Un sitio para que Juana y sus amigos discutan cosas interesantes" short_site_description: - label: "Describe tu comunidad en pocas palabras" placeholder: "La mejor comunidad del mundo" introduction: title: "Introducción" diff --git a/config/locales/server.et.yml b/config/locales/server.et.yml index 4a4d5c182a..866fd68aed 100644 --- a/config/locales/server.et.yml +++ b/config/locales/server.et.yml @@ -1024,7 +1024,6 @@ et: label: "Sinu kogukonna nimi" placeholder: "Jane ajaviitenurgake" site_description: - label: "Kirjelda oma kogukonda ühe lühikese lausega" placeholder: "Koht Janele ja tema sõpradele lahedate arutelude jaoks" introduction: title: "Sissejuhatus" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index 2d68606323..e849ccd769 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -907,7 +907,6 @@ fa_IR: force_https: "اجبار به استفاده از HTTPS ، تا زمانی که HTTPS را کامل تنظیم نکرده‌اید این گزینه را فعال نکنید. آیا تمام CDN ها و شبکه‌های اجتماعی جهت ورود را بررسی کردید و تمام لوگو‌ها و وابستگی‌ها بدون مشکل با HTTPS کار می‌کنند؟" summary_score_threshold: "حداقل امتیاز برای یک نوشته که بتواند شامل \" خلاصه این موضوع\" شود" summary_percent_filter: "وقتی کاربر بر روی ' خلاصه این موضوع' کلیک کرد٬‌ % بهترین نوشته‌ها را نشان بده" - enable_personal_messages: "اجازه ارسال پیام به کاربران سطح اعتماد 1 (قابل تنظیم با حداقل سطح اعتماد برای ارسال پیام). توجه کنید که همکاران در هر شرایطی می‌توانند پیام ارسال کنند." enable_long_polling: "message bus استفاده شده برای آگاه سازی می تواند برای رای گیری طولانی استفاده شود. " long_polling_base_url: " URL پایه استفاده شده برای رای گیری طولانی (وقتی CDN خدمت محتوای پویا می دهد٬ از تنظیم بودن منشا این کشش مطمئن شوید) برای نمونه : http://origin.site.com" long_polling_interval: "مدت زمانی که سرور قبل پاسخ دادن به مشتری‌ها باید صبر کند، وقتی در آن‌جا داده ای برای ارسال نیست (فقط کاربران وارد شده)" @@ -1080,7 +1079,6 @@ fa_IR: topic_view_duration_hours: "بازدید‌های موضوعات را به ازای هر آیپی/کاربر در N ساعت محاسبه کن" user_profile_view_duration_hours: "بازدید‌های پروفایل کاربران را به ازای هر آیپی/کاربر در N ساعت محاسبه کن" levenshtein_distance_spammer_emails: "هنگامی که تطبیق ایمیل هرزنامه باشد٬ تعداد نویسه‌های متفاوت که هنوز هم به یک تطبق مبهم اجازه خواهد داد." - max_new_accounts_per_registration_ip: "اگر در حال حاضر (n) حساب کاربری با سطح اعتماد 0 از این IP وجود دارد(و هیچ یک از کارمندان عضو یا با سطح اعتماد 2 یا بالاتر نیستند) ثبت نام را از این ‌IP را متوقف کن. " min_ban_entries_for_roll_up: "وقتی بر روی کلید جمع کردن کلیک کنید٬ یک ممنوعیت زیرشبکه ورودی جدید ساخته می شود اگر در آنجا حداقل (N) ورودی باشد. " max_age_unmatched_emails: "ایمیل های ورودی همسان نشده نمایش داده شده بعد از (N) روز پاک شوند." max_age_unmatched_ips: "IP ورودی همسان نشده نمایش داده شده بعد از (N) روز پاک شوند. " @@ -2231,7 +2229,6 @@ fa_IR: label: "نام انجمن شما" placeholder: "انجمن آزموده" site_description: - label: "انجمن خود را در یک جمله کوتاه توضیح دهید" placeholder: "محلی برای آموزش انواع زبان‌های برنامه نویسی" introduction: title: "مقدمه" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 2879a738ee..dc35ee9f45 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -249,7 +249,6 @@ fi: topic_invite: failed_to_invite: "Käyttäjää ei voi kutsua ketjuun, jollei hän ole jonkun näistä ryhmistä jäsen: %{group_names}." user_exists: "Pahoittelut, tämä käyttäjä on jo kutsuttu. Voit kutsua toisen käyttäjän ketjuun vain yhden kerran." - muted_invitee: "Valitettavasti tämä käyttäjä on vaimentanut sinut." muted_topic: "Valitettavasti käyttäjä on vaimentanut tämän ketjun." receiver_does_not_allow_pm: "Tämä käyttäjä ei salli sinun lähettää hänelle yksityisviestejä." sender_does_not_allow_pm: "Et salli tämän käyttäjän lähettää sinulle yksityisviestejä." @@ -1446,7 +1445,6 @@ fi: summary_likes_required: "Vähimmäismäärä tykkäyksiä ketjussa ennen kuin Näytä ketjun tiivistelmä otetaan käyttöön. Muutokset tähän asetukseen otetaan käyttöön taannehtivasti viikon kuluessa." summary_percent_filter: "Kun käyttäjä klikkaa 'Näytä ketjun tiivistelmä', näytä paras % viesteistä" summary_max_results: "Maksimimäärä viestejä, jotka näytetään ketjun tiivistelmässä" - enable_personal_messages: "Salli luottamustason 1 saavuttaneiden käyttäjien (määritettävissä viestien lähettämiseen vaadittavassa vähimmäisluottamustasossa) lähettää yksityisviestejä ja vastata niihin. Huomioi, että henkilökunta voi aina lähettää yksityisviestejä." enable_system_message_replies: "Sallii käyttäjien vastata järjestelmän viesteihin, vaikka yksityisviestit eivät olisikaan käytössä" enable_long_polling: "Ilmoitusten käyttämä viestiväylä voi käyttää long pollingia" enable_chunked_encoding: "Ota käyttöön palvelimen joukkokoodausvastaukset. Tämä ominaisuus toimii useimmissa määrityksissä, mutta jotkin välityspalvelimet saattavat puskuroida, mikä aiheuttaa vastausten viivästymisen" @@ -1769,7 +1767,6 @@ fi: topic_view_duration_hours: "Laske uusi ketjun katselu kerran per IP/käyttäjä joka N:s tunti" user_profile_view_duration_hours: "Laske uusi profiilin katselu kerran per IP/käyttäjä joka N:s tunti" levenshtein_distance_spammer_emails: "Verrattaessa sähköpostiosoitteita tunnettuihin roskapostittajiin, näin monen merkin ero saa vielä aikaan sumean osuman." - max_new_accounts_per_registration_ip: "Jos samasta IP-osoitteesta on jo (n) luottamustason 0 käyttäjätiliä (eikä yhtään henkilökunnan tai vähintään LT2), lakkaa hyväksymästä uusia rekisteröitymisiä tästä IP:stä." min_ban_entries_for_roll_up: "Kun Kokoa-painiketta painetaan, luodaan IP-porttikielloista aliverkon kattavia, kieltoja jos kieltoja on asettu vähintään (N) määrä." max_age_unmatched_emails: "Poista osumattomat seulotut sähköpostiosoitteet (N) päivän jälkeen." max_age_unmatched_ips: "Poista osumattomat seulotut IP-osoitteet (N) päivän jälkeen." @@ -4262,10 +4259,8 @@ fi: label: "Yhteisön nimi" placeholder: "Jennin mesta" site_description: - label: "Kuvaile yhteisöä yhdellä lyhyellä lauseella" placeholder: "Paikka, jossa Jenni ja hänen ystävänsä voivat keskustella kivoista jutuista" short_site_description: - label: "Kuvaile yhteisöä muutamalla sanalla lyhyesti" placeholder: "Paras yhteisö ikinä missään" introduction: title: "Johdanto" diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index daeca741ed..54239c5f93 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -249,7 +249,6 @@ fr: topic_invite: failed_to_invite: "L'utilisateur ne peut pas être invité dans ce sujet sans être membre d'un des groupes suivants : %{group_names}." user_exists: "Nous sommes désolés, cet utilisateur a déjà été invité. Vous ne pouvez inviter un utilisateur qu'une seule fois par sujet." - muted_invitee: "Nous sommes désolés, cet utilisateur vous a mis(e) en sourdine." muted_topic: "Nous sommes désolés, cet utilisateur a mis ce sujet en sourdine." receiver_does_not_allow_pm: "Nous sommes désolés, cet utilisateur ne vous autorise pas à lui envoyer des messages directs." sender_does_not_allow_pm: "Nous sommes désolés, vous n'autorisez pas cet utilisateur à vous envoyer des messages directs." @@ -1446,7 +1445,6 @@ fr: summary_likes_required: "Nombre minimal de « J'aime » dans un sujet avant que la fonctionnalité « Résumer ce sujet » soit activée. Les modifications de ce paramètre sont appliquées rétroactivement sur une semaine." summary_percent_filter: "Quand un utilisateur clique sur « Résumer ce sujet », montrer le top % des messages" summary_max_results: "Nombre maximal de messages inclus dans le résultat de « Résumer ce sujet »" - enable_personal_messages: "Autoriser les utilisateurs de niveau de confiance 1 à créer des messages directs et à y répondre (configurable via le niveau de confiance minimal pour envoyer des messages directs). Notez que les responsables peuvent toujours envoyer des messages directs." enable_system_message_replies: "Permettre aux utilisateurs de répondre aux messages système, même si les messages directs sont désactivés." enable_long_polling: "Utiliser les requêtes longues pour le flux de notifications." enable_chunked_encoding: "Activer les réponses d'encodage par bloc par le serveur. Cette fonctionnalité fonctionne dans la plupart des configurations, mais certains proxys peuvent mettre les réponses en mémoire tampon et risquent donc de les retarder" @@ -1769,7 +1767,6 @@ fr: topic_view_duration_hours: "Compter la vue d'un sujet une seule fois par IP ou par utilisateur toutes les N heures" user_profile_view_duration_hours: "Compter la vue d'un profil d'utilisateur une seule fois par IP ou par utilisateur qui visite toutes les N heures" levenshtein_distance_spammer_emails: "Une adresse courriel sera attribuée à un spammeur connu même si elle diffère par ce nombre de caractères." - max_new_accounts_per_registration_ip: "S'il y a déjà (n) comptes avec un niveau de confiance 0 en provenance de cette adresse IP (et aucun n'est un responsable ou avec un niveau de confiance 2 et plus), ne plus accepter de nouvelles inscriptions depuis cette adresse IP." min_ban_entries_for_roll_up: "En cliquant sur le bouton Consolider, une liste d'au moins (N) adresses interdites sera remplacée par une plage de sous réseau." max_age_unmatched_emails: "Effacer les adresses courriel sous surveillance sans correspondance après (N) jours" max_age_unmatched_ips: "Effacer les adresses IP sous surveillance sans correspondance après (N) jours" @@ -4273,10 +4270,8 @@ fr: label: "Nom de votre communauté" placeholder: "Le repaire de Jeanne" site_description: - label: "Décrivez votre communauté en une courte phrase" placeholder: "Un endroit de discussion pour Jeanne et ses amis" short_site_description: - label: "Décrivez votre communauté en quelques mots" placeholder: "Meilleure communauté de tous les temps" introduction: title: "Introduction" diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml index 3ea5ad9ec8..356eb108cf 100644 --- a/config/locales/server.gl.yml +++ b/config/locales/server.gl.yml @@ -235,7 +235,6 @@ gl: topic_invite: failed_to_invite: "O usuario non pode ser invitado a este tema por non ser membro de ningún destes grupos: %{group_names}." user_exists: "Este usuario xa foi invitado. Só se pode invitar a un usuario a un tema unha única vez." - muted_invitee: "Sentímolo, ese usuario silenciouno." muted_topic: "Sentímolo, ese usuario silenciou este tema." receiver_does_not_allow_pm: "Sentímolo, ese usuario non lle permite enviarlles mensaxes privadas." sender_does_not_allow_pm: "Sentímolo, vostede non permite que ese usuario lle envíe mensaxes privadas." @@ -1399,7 +1398,6 @@ gl: summary_likes_required: "Número mínimo de gústame nun tema antes de que «Resumir este tema» estea activado. Os cambios neste axuste aplicaranse retroactivamente despois dunha semana." summary_percent_filter: "Cando un usuario preme en «Resumir este tema» mostrar a porcentaxe de publicacións destacadas" summary_max_results: "Número máximo de publicacións que devolve a opción «Resumir este tema»" - enable_personal_messages: "Permitirlles aos usuarios con nivel de confianza 1 (configurable a través de nivel mínimo de confianza para enviar mensaxes) crear mensaxes e respondelas. Repare en que o equipo sempre pode enviar mensaxes pasar o que pasar." enable_system_message_replies: "Permitirlles aos usuarios responder mensaxes do sistema, mesmo aínda que as mensaxes persoais estean desactivadas" enable_long_polling: "O bus de mensaxes utilizado para a notificación pode utilizar «long polling»" enable_chunked_encoding: "Active as respostas mediante codificación fragmentaria do servidor. Esta funcionalidade serve na maioría das configuracións, pero algúns servidores intermedios poden retelas, o que provocaría o atraso nas respostas" @@ -1710,7 +1708,6 @@ gl: topic_view_duration_hours: "Contar unha visita a un novo tema por IP/usuario cada N horas" user_profile_view_duration_hours: "Contar unha visita de perfil por IP/usuario cada N horas" levenshtein_distance_spammer_emails: "Ao revisar coincidencias en correos electrónicos de remitentes non desexados, número de caracteres diferentes que permiten una coincidencia parcial." - max_new_accounts_per_registration_ip: "Se hai xa (n) contas con nivel de confianza 0 desde este IP (e ningunha é dun membro do equipo ou de nivel de confianza 2 ou superior), prohibir rexistros novos desde ese IP." min_ban_entries_for_roll_up: "Ao premer no botón Pregar, crearase unha nova entrada de veto de subrede se hai polo menos (N) entradas." max_age_unmatched_emails: "Eliminar entradas de correos electrónicos prohibidos que non coincidan despois de (N) días." max_age_unmatched_ips: "Eliminar entradas de IP prohibidas que non coincidan despois de (N) días." @@ -4094,10 +4091,8 @@ gl: label: "O nome da súa comunidade" placeholder: "Reunión de Helena" site_description: - label: "Describa a súa comunidade cunha frase curta" placeholder: "Un lugar para que Helena e as súas amizades discutan sobre cousas interesantes" short_site_description: - label: "Describa a súa comunidade nunhas poucas palabras" placeholder: "A mellor comunidade do mundo" introduction: title: "Introdución" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 70bdedae21..71e5968e73 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -120,7 +120,7 @@ he: silenced_user_error: "מתרחש כאשר השולח הושתק." bad_destination_address: "מתרחש כאשר אף אחת מהכתובות בשדות אל או עותק אינה תואמת לאף כתובת דוא״ל נכנסת מוגדרת." strangers_not_allowed_error: "מתרחש כאשר משתמשים מנסים ליצור נושא חדש בקטגוריה בה הם אינם חברים." - insufficient_trust_level_error: "מתרחש כאשר משתמשים מנסים ליצור נושא חדש בקטגוריה בה אין להם את רמת האמון/הרשאה הנדרשת." + insufficient_trust_level_error: "קורה כאשר משתמשים מנסים ליצור נושא חדש בקטגוריה בה אין להם את דרגת האמון/הרשאה הנדרשת." reply_user_not_matching_error: "מתרחש כאשר תגובה מגיעה מכתובת דוא״ל שונה מזו שאליה נשלחה ההתראה." topic_not_found_error: "מתרחש כאשר הגיעה תגובה אבל הנושא התואם נמחק." topic_closed_error: "מתרחש כאשר הגיעה תגובה אבל הנושא התואם נסגר." @@ -267,7 +267,6 @@ he: topic_invite: failed_to_invite: "לא ניתן להזמין את המשתמש לנושא הזה בהעדר חברות באחת הקבוצות הבאות: %{group_names}" user_exists: "כבר נשלחה הזמנה למשתמש זה. ניתן להזמין משתמש לנושא פעם אחת בלבד, עמך הסליחה." - muted_invitee: "הושתקת על ידי המשתמש הזה, עמך הסליחה." muted_topic: "המשתמש הזה השתיק את הנושא הזה, עמך הסליחה." receiver_does_not_allow_pm: "משתמש זה לא מאשר לך לשלוח אליו הודעות פרטיות, עמך הסליחה." sender_does_not_allow_pm: "אסרת על משתמש זה לשלוח אליך הודעות פרטיות, עמך הסליחה." @@ -687,7 +686,7 @@ he: trust_levels: admin: "ניהול" staff: "סגל" - change_failed_explanation: "ניסיתם להוריד ברמה את %{user_name} ל- '%{new_trust_level}'. אולם רמת האמון שלהם היא כבר '%{current_trust_level}'. %{user_name} ישאר/תישאר ב-'%{current_trust_level}' - אם ברצונכם להוריד את המשתמש/ת נעלו קודם את רמת האמון" + change_failed_explanation: "ניסית להוריד את דרגת האמון של %{user_name} לכדי ‚%{new_trust_level}’. למרות שדרגת האמון של המשתמש היא כבר ‚%{current_trust_level}’. דרגת האמון של %{user_name} תישאר ‚%{current_trust_level}’ - כדי להוריד משתמש בדרגה יש לנעול את דרגת האמון תחילה" post: image_placeholder: broken: "תמונה זו פגומה" @@ -1046,7 +1045,7 @@ he: unwatch_category: "הפסיקו לצפות בכל הנושאים בקטגוריה %{category}" mailing_list_mode: "כבו את מצב ״רשימת תפוצה״" all: "לא לשלוח לי הודעות דוא״ל מאת %{sitename}" - different_user_description: "התחברת כמשתמש אחר מזה שאליו נשלחה הודעת דוא״ל. נא להתנתק או לעבור למצב אלמוני ולנסות שוב." + different_user_description: "נכנסת כמשתמש אחר מזה שאליו נשלחה הודעת דוא״ל. נא להתנתק או לעבור למצב אלמוני ולנסות שוב." not_found_description: "לא הצלחנו למצוא את ביטול המינוי הזה עמך הסליחה. יכול להיות שהקישור בהודעה שקיבלת ישן מדי או שתוקפו פג?" log_out: "התנתקות" submit: "שמירת העדפות" @@ -1073,7 +1072,7 @@ he: confirm_title: להמשיך אל %{site_name} logging_in_as: כניסה למערכת בתור %{username} confirm_button: סיום הכניסה - no_trust_level: "מצטערים, אין לכם את רמת האמון הנדרשת כדי לגשת ל API של המשתמשים" + no_trust_level: "אין לך את דרגת האמון הנדרשת כדי לגשת ל־API של המשתמשים, סליחה" generic_error: "לא הצלחנו לייצר את מפתחות ה־API של המשתמש, ייתכן שיכולת זו הושבתה על ידי הנהלת האתר, עמך הסליחה" scopes: message_bus: "עדכונים חיים" @@ -1530,14 +1529,14 @@ he: email_subject: "תבנית נושא בהתאמה אישית להודעות דוא״ל תקניות. יש לעיין ב־https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "מספק פרטים נוספים למשתמשים בנוגע לסיבה שבגינה אין להם גישה לנושא מסוים. לתשומת לבך: מדובר בתצורה לא מאובטחת כיוון שמשתמשים ידעו אם כתובת מקשרת לנושא תקף." enforce_second_factor: "מאלץ משתמשים להפעיל אימות דו־שלבי. יש לבחור ב‚הכול’ כדי לאלץ את כל המשתמשים. יש לבחור ב‚סגל’ כדי לאכוף על חברי סגל בלבד." - force_https: "הכריחו את אתרכם להשתמש אך ורק ב HTTPS. אזהרה: אל תאפשרו זאת עד שתוודאו ש HTTPS מותקן ועובד ממש בכל המקרים! וידאתם את הגדרות ה CDN שלכם, כל שירותי ההתחברות, וכל הלוגואים / תלויות החיצוניים - כדי לוודא שכולם עובדים גם כן עם HTTPS?" + force_https: "אילוץ האתר שלך להשתמש ב־HTTPS בלבד. אזהרה: אין להפעיל זאת עד שווידאת ש־HTTPS מוגדר ועובד לחלוטין בכל מקום! בדקת שה־CDN, הכניסה דרך רשתות חברתיות ולוגואים / תלויות חיצוניות גם כן תומכים ב־HTTPS?" summary_score_threshold: "הניקוד המינימלי הנדרש כדי שפוסט ייכלל ב\"סיכום נושא זה\"" summary_posts_required: "כמות פוסטים מזערית בנושא בטרם מופעל ‚תקציר לנושא זה’. שינויים בהגדרה זו יחולו רטרואקטיבית תוך שבוע." summary_likes_required: "כמות לייקים מזערית בנושא בטרם מופעל ‚תקציר לנושא זה’. שינויים בהגדרה זו יחולו רטרואקטיבית תוך שבוע." summary_percent_filter: "כאשר משתמש/ת מקליקים על \"סיכום נושא זה\", הציגו את % הפוסטים הראשונים" summary_max_results: "מספר הפוסטים המרבי שיוחזר על ידי ‚סיכום הנושא הזה’" summary_timeline_button: "הצגת כפתור ‚סיכום’ בציר הזמן" - enable_personal_messages: "לאפשר למשתמשים בדרגת אמון 1 (ניתן להגדרה דרך דרגת אמון מזערית לשליחת הודעות) ליצור הודעות ולענות להודעות. נא לשים לב שהסגל תמיד יכול לשלוח הודעות, ללא קשר." + enable_personal_messages: "לאפשר למשתמשים בדרגת אמון 1 (ניתן להגדרה דרך אמון מזערי לשליחת הודעות) ליצור הודעות ולענות להודעות. נא לשים לב שהסגל תמיד יכול לשלוח הודעות, ללא קשר." enable_system_message_replies: "מאפשר למשתמשים להגיב להודעות מערכת אפילו כשהודעות אישיות מושבתות" enable_long_polling: "באס הודעות שמשמש להתראות יכול להשתמש בתשאול ארוך (long polling)" enable_chunked_encoding: "הפעלת תגובות קידוד מחולקות מצד השרת. תכונה זו עובדת ברוב התצורות אך חלק מהמתווכים עשויים לכלוא כצעד ביניים, מה שעלול לגרום להאטה" @@ -1553,7 +1552,7 @@ he: max_topics_in_first_day: "הכמות המקסימלית של נושאים שמשתמשים מורשים ליצור ב 24 השעות הראשונות לאחר הפוסט הראשון שלהם" max_replies_in_first_day: "הכמות המקסימלית של תגובות שמשתמשים מורשים ליצור ב 24 השעות הראשונות אחרי יצירת הפוסט הראשון שלהם" tl2_additional_likes_per_day_multiplier: "להגדיל את כמות הלייקים האפשרית ביום עבור tl2 (משתמש) באמצעות הכפלה במספר זה. " - tl3_additional_likes_per_day_multiplier: "להגדיל את כמות הלייקים האפשרית ביום עבור רמת-אמון 3 (רגיל) באמצעות הכפלה במספר זה." + tl3_additional_likes_per_day_multiplier: "הכפלה במספר הזה מגדילה את מגבלת הלייקים ליום עבור דרגת אמון 3 (רגיל)" tl4_additional_likes_per_day_multiplier: "להגדיל את כמות הלייקים האפשרית ביום עבור tl4 (מנהיג) באמצעות הכפלה במספר זה. " tl2_additional_edits_per_day_multiplier: "להגדיל את כמות העריכות האפשרית ביום עבור דרגת אמון 2 (חברים) באמצעות הכפלה במספר זה" tl3_additional_edits_per_day_multiplier: "להגדיל את כמות העריכות האפשרית ביום עבור דרגת אמון 3 (רגיל) באמצעות הכפלה במספר זה" @@ -1722,8 +1721,8 @@ he: max_personal_messages_per_day: "מספר ההודעות האישיות החדשות המרבי שמשתמשים יכולים ליצור ביום." max_invites_per_day: "מספר מקסימלי של הזמנות שיכולים משתמשים לשלוח ביום." max_topic_invitations_per_day: "מספר מירבי של הזמנות לנושא שמשתמשים יכולים לשלוח ביום. " - max_logins_per_ip_per_hour: "מספר מקסימלי של התחברויות מורשות לכל כתובת IP בשעה" - max_logins_per_ip_per_minute: "מספר מקסימלי של התחברויות מורשות לכתובת IP לדקה" + max_logins_per_ip_per_hour: "מספר כניסות מרבי מורשה לכל כתובת IP בשעה" + max_logins_per_ip_per_minute: "מספר כניסות מרבי מורשה לכל כתובת IP בדקה" max_post_deletions_per_minute: "מספר הפוסטים המרבי שמשתמש יכול למחוק בדקה אחת. 0 ישבית מחיקת פוסטים." max_post_deletions_per_day: "מספר הפוסטים המרבי שמשתמש יכול למחוק ביום אחד. 0 ישבית מחיקת פוסטים." invite_link_max_redemptions_limit: "כמות הניצולים המרבית שמורשית לקישורים הזמנה לא יכולה לעבור את הערך הזה." @@ -1768,34 +1767,34 @@ he: composer_media_optimization_image_encode_quality: "איכות קידוד JPEG המשמשת בתהליך הקידוד מחדש." min_ratio_to_crop: "יחס לחיתוך תמונות גבוהות. יש להקליד את התוצאה ברוחב / גובה." simultaneous_uploads: "מספר הקבצים המרבי שניתן לגרור ולהשליך אל מחבר ההודעות" - default_invitee_trust_level: "ברירת מחדל של רמת אמון (0-4) של משתמשים מוזמנים." - default_trust_level: "רמת אמון (0-4) לכל המשתמשים החדשים. אזהרה! שינוי של משתנה זה שם אתכם בסיכון רציני לספאם." - tl1_requires_topics_entered: "כמה נושאים משתמשים חדשים צריכים להתחיל עד שישודרגו לרמת אמון 1." - tl1_requires_read_posts: "כמה פוסטים משתמשים צריכים לקרוא לפני שישודרגו לרמת אמון 1." - tl1_requires_time_spent_mins: "כמה דקות משתמשים חדשים צריכים לקרוא פוסטים לפני שישודרגו לרמת אמון 1." - tl2_requires_topics_entered: "כמה נושאים משתמשים חדשים צריכים להתחיל עד שישודרגו לרמת אמון 2." - tl2_requires_read_posts: "כמה פוסטים משתמשים צריכים לקרוא לפני שישודרגו לרמת אמון 2." - tl2_requires_time_spent_mins: "כמה דקות משתמשים חדשים צריכים לקרוא פוסטים לפני שישודרגו לרמת אמון 2." - tl2_requires_days_visited: "כמה ימים שונים משתמשים צריכים לבקר באתר לפני שישודרגו לרמת אמון 2." - tl2_requires_likes_received: "כמה \"לייקים\" משתמשים צריכים לקבל לפני שישודרגו לרמת אמון 2." - tl2_requires_likes_given: "כמה לייקים משתמשים צריכים לתת לפני שישודרגו לרמת אמון 2." - tl2_requires_topic_reply_count: "על כמה נושאים משתמשים צריכים לענות לפני שישודרגו לרמת אמון 2." - tl3_time_period: "דרישות פרק זמן של רמת אמון 3 (בימים)" - tl3_requires_days_visited: "מספר מינימלי של ימים שמשתמש צריך לבקר באתר ב (תקופת זמן רמת-אמון-3) הימים האחרונים כדי להיות מועמד לקידום לרמת אמון 3. קבעו ליותר מתקופת זמן של רמת-אמון-3 כדי לנטרל קידום ל רמת-אמון-3. (0 או יותר)." - tl3_requires_topics_replied_to: "מספר מינימלי של נושאים שמשתמש צריך להגיב עליהם ב (תקופת זמן רמת-אמון-3) הימים האחרונים כדי להיות מועמד לקידום לרמת אמון 3. (0 או יותר)" - tl3_requires_topics_viewed: "אחוז הנושאים שנוצרו במהלך (תקופת זמן רמת-אמון-3) הימים האחרונים שמשתמש צריך לצפות בהם כדי שיוכל להיות מועמד לקידום לרמת אמון 3. (0 עד 100)" - tl3_requires_topics_viewed_cap: "המספר המקסימלי הנדרש של נושאים לצפייה ב (תקופת זמן רמת-אמון-3) הימים האחרונים." - tl3_requires_posts_read: "אחוז הנושאים שנוצרו ב (תקופת זמן רמת-אמון-3) הימים האחרונים שעל משתמש לצפות בהם כדי להיות מועמד לרמת אמון 3. (0 עד 100)" - tl3_requires_posts_read_cap: "המספר המקסימלי הנדרש של פוסטים לקריאה ב (תקופת זמן רמת-אמון-3) הימים האחרונים." - tl3_requires_topics_viewed_all_time: "מספר מינימלי של נושאים שמשתמשים צריכים לעיין בהם על מנת שיתאפשר להם להיות משודרגים לרמת אמון 3." - tl3_requires_posts_read_all_time: "מספר מינימלי של פוסטים שמשתמשים קראו על מנת שיוכלו להיות משודרגים לרמת אמון 3." - tl3_requires_max_flagged: "אסור שלמשתמש ידוגלו למעלה מ x פוסטים על ידי x משתמשים שונים ב (תקופת זמן רמת-אמון-3) הימים האחרונים כדי להיות מועמד לקידום לרמת אמון 3, כאשר x הוא ערך ההגדרה. (0 או יותר)" - tl3_promotion_min_duration: "מספר הימים המינימלי ששדרוג לרמת אמון 3 אורך לפני שניתן להוריד משתמשים בדרגה בחזרה לרמת אמון 2." - tl3_requires_likes_given: "המספר המינימלי של לייקים שצריכים להנתן ב (תקופת זמן רמת-אמון-3) הימים האחרונים כדי להיות מועמד לקידום לרמת אמון 3." - tl3_requires_likes_received: "המספר המינימלי של לייקים שחייבים להתקבל ב (תקופת זמן רמת-אמון-3) הימים האחרונים כדי להיות מועמדים לקידום לרמת אמון 3." - tl3_links_no_follow: "מניעת הסרת rel=nofollow מקישורים שמפורסמים על ידי משתמשים ברמת אמון 3." + default_invitee_trust_level: "דרגת האמון כבררת המחדל למשתמשים שהוזמנו (0‏-4)" + default_trust_level: "דרגת האמון כבררת המחדל (0‏-4) לכל המשתמשים החדשים. אזהרה! שינוי הערך הזה חושף אותך בפני סכנה ממשית לספאם." + tl1_requires_topics_entered: "לכמה נושאים על משתמש חדש להיכנס בטרם קידומו לדרגת אמון 1." + tl1_requires_read_posts: "כמה פוסטים על משתמש חדש לקרוא בטרם קידומו לדרגת אמון 1." + tl1_requires_time_spent_mins: "כמה דקות קריאה של פוסטים על משתמש חדש לצבור בטרם קידומו לדרגת אמון 1." + tl2_requires_topics_entered: "לכמה נושאים על משתמש להיכנס בטרם קידומו לדרגת אמון 2." + tl2_requires_read_posts: "כמה פוסטים על משתמש לקרוא בטרם קידומו לדרגת אמון 2." + tl2_requires_time_spent_mins: "כמה דקות קריאה של פוסטים על משתמש לצבור בטרם קידומו לדרגת אמון 2." + tl2_requires_days_visited: "כמה ימים לא רצופים על משתמש לבקר באתר בטרם קידומות לדרגת אמון 2." + tl2_requires_likes_received: "כמה לייקים על משתמש לקבל בטרם קידומו לדרגת אמון 2." + tl2_requires_likes_given: "כמה לייקים על משתמש להעניק בטרם קידומו לדרגת אמון 2." + tl2_requires_topic_reply_count: "על כמה נושאים על משתמש לענות בטרם קידומו לדרגת אמון 2." + tl3_time_period: "דרישות ותק (בימים) לדרגת אמון 3" + tl3_requires_days_visited: "מספר הימים המזערי שעל משתמש לבקר באתר במשך (תקופת זמן עם דרגת אמון 3) ימים כדי לעמוד בתנאי הקידום לדרגת אמון 3. אפשר להגדיר לתקופת זמן ממושכת יותר מאשר דרגת אמון 3 כדי להשבית את הקידומים לדרגת אמון 3. (0 ומעלה)" + tl3_requires_topics_replied_to: "מספר הנושאים המזערי שעל משתמש להגיב בהם במשך (תקופת זמן עם דרגת אמון 3) ימים כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 ומעלה)" + tl3_requires_topics_viewed: "אחוז הנושאים שנוצרו במהלך (תקופה עם דרגת אמון 3) ימים שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 עד 100)" + tl3_requires_topics_viewed_cap: "המספר המרבי הדרוש של צפיות בנושאים במשך (תקופה בדרגת אמון 3) ימים." + tl3_requires_posts_read: "אחוז הפוסטים שנוצרו במהלך (תקופה עם דרגת אמון 3) ימים שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3. (0 עד 100)" + tl3_requires_posts_read_cap: "המספר המרבי הדרוש של פוסטים שנקראו במשך (תקופה בדרגת אמון 3) ימים." + tl3_requires_topics_viewed_all_time: "מספר הנושאים הכולל המזערי שעל משתמש לצפות בהם כדי לעמוד בתנאי הקידום לדרגת אמון 3." + tl3_requires_posts_read_all_time: "מספר הפוסטים הכולל המזערי שעל משתמש לקרוא כדי לעמוד בתנאי הקידום לדרגת אמון 3." + tl3_requires_max_flagged: "אסור שלמשתמש יהיו יותר מ־x פוסטים שמסומנים בדגל על ידי x משתמשים שונים במשך (משך זמן דרגת אמון 3) הימים האחרונים כדי לעמוד בתנאי הקידום לדרגת אמון 3, כאשר x הוא ערך ההגדרה הזאת. (0 ומעלה)" + tl3_promotion_min_duration: "מספר הימים המזערי שקידום לרמת אמון 3 נמשך לפני שניתן להוריד משתמשים בחזרה לדרגת אמון 2." + tl3_requires_likes_given: "מספר הלייקים המזערי שיש להעניק במשך (פרק זמן בדרגת אמון 3) הימים האחרונים כדי לעמוד בתנאי הקידום לדרגת אמון 3." + tl3_requires_likes_received: "מספר הלייקים המזערי שיש לקבל במשך (פרק זמן בדרגת אמון 3) הימים האחרונים כדי לעמוד בתנאי הקידום לדרגת אמון 3." + tl3_links_no_follow: "מניעת הסרת rel=nofollow מקישורים שמפורסמים על ידי משתמשים בדרגת אמון 3." trusted_users_can_edit_others: "לאפשר למשתמשים בדרגת אמון גבוהה לערוך תוכן של משתמשים אחרים" - min_trust_to_create_topic: "רמת האמון המינימלית הנדרשת כדי לייצר נושא חדש." + min_trust_to_create_topic: "דרגת האמון המזערית הנדרשת ליצירת נושא חדש." allow_flagging_staff: "אם אפשרות זו פעילה, משתמשים יכולים לסמן בדגל פוסטים שפורסמו על ידי חשבונות סגל." min_trust_to_edit_wiki_post: "דרגת האמון המזערית הנדרשת לעריכת פוסט המסומן כוויקי." min_trust_to_edit_post: "דרגת האמון המזערית הנדרשת לעריכת פוסטים." @@ -1868,7 +1867,7 @@ he: topic_view_duration_hours: "ספרו צפיות חדשות בנושא פעם אחת לכל IP/משתמש לכל N שעות" user_profile_view_duration_hours: "ספרו צפיות בפרופיל משתמש פעם אחת לכל IP/משתמש בכל N שעות" levenshtein_distance_spammer_emails: "כאשר מתאימים דוא\"ל של ספאמרים, מספר ההבדלים בתווים שעדיין מאפשרים התאמה מטושטשת." - max_new_accounts_per_registration_ip: "אם ישנם כבר (n) חשבונות עם רמת אמון 0 מכתובת IP זו (ואף אחד מהם אינו חבר צוות, או בעל/ת רמת אמון 2 ומעלה), הפסיקו קבלת הרשמות מכתובת IP זו." + max_new_accounts_per_registration_ip: "אם יש כבר (n) חשבונות בדרגת אמון 0 מכתובת IP זו (ואף אחד מהם אינו חבר צוות, או בדרגת אמון 2 ומעלה), לא יתקבלו עוד הרשמות מכתובת IP זו. הגדרה ל־0 תשבית את המגבלה." min_ban_entries_for_roll_up: "בעת לחיצה על לחצן הגלילה למעלה, ייוצר איסור כניסת משנה (subnet ban entry) חדשה אם יש לפחות (N) ערכים." max_age_unmatched_emails: "למחוק רשומות דוא״ל במעקב שלא קיבלו התאמה לאחר (N) ימים." max_age_unmatched_ips: "מחק ערכי IP לא תואמים שמוצגים לאחר (N) ימים." @@ -1932,7 +1931,7 @@ he: pop3_polling_delete_from_server: "למחוק הודעות דוא״ל מהשרת. לתשומת לבך: השבתת אפשרות זו מאלצת אותך לפנות את תיבת הדוא״ל באופן ידני" log_mail_processing_failures: "לתעד את כל שגיאות עיבוד הדוא״ל אל ‎/logs" email_in: 'לאפשר למשתמשים לפרסם נושאים חדשים באמצעות דוא״ל (נדרש תשאול ידני או דרך pop3). יש להגדיר את הכתובות בלשונית ה„הגדרות” שבכל קטגוריה.' - email_in_min_trust: "רמת האמון המינימלית הנדרשת למשתמשים כדי שיוכלו להעלות נושאים חדשים באמצעות הדוא\"ל." + email_in_min_trust: "דרגת האמון המזערית הנדרשת למשתמש כדי לפרסם נושאים חדשים דרך הדוא״ל." email_in_authserv_id: "מזהה השירות מבצע בדיקות אימות על הודעות דוא״ל נכנסות. יש לעיין ב־https://meta.discourse.org/t/134358 לקבלת הנחיות כיצד להגדיר זאת." email_in_spam_header: "כותרת הודעת הדוא״ל לאיתור ספאם." enable_imap: "הפעלת IMAP לסנכרון הודעות קבוצתיות." @@ -1977,7 +1976,7 @@ he: enable_category_group_moderation: "לאפשר לקבוצות לסקור תוכן בקטגוריות מסוימות" group_in_subject: "ניתן להגדיר %%{optional_pm} בנושא הודעת הדוא״ל לשם הקבוצה הראשונה בהודעה הפרטית (PM), למידע נוסף: התאמת תבנית הנושא להודעות דוא״ל תקניות" allow_anonymous_posting: "לאפשר למשתמשים לעבור למצב אלמוני" - anonymous_posting_min_trust_level: "רמת האמון המזערית הנדרשת כדי לאפשר פרסום אלמוני" + anonymous_posting_min_trust_level: "דרגת האמון המזערית הנדרשת כדי לאפשר פרסום אלמוני" anonymous_account_duration_minutes: "כדי להגן על האלמוניות יש ליצור חשבון אלמוני חדש כל N דקות עבור כל משתמש. למשל: אם ההגדרה היא 600, בחלוף 600 דקות מהפוסט האחרון וגם אם המשתמש עבר למצב אלמוני, ייווצר חשבון אלמוני חדש." hide_user_profiles_from_public: "להשבית כרטיסי משתמשים, פרופילי משתמשים וספריית משתמשים עבור משתמשים אלמוניים." allow_users_to_hide_profile: "לאפשר למשתמשים להחביא את הפרופיל והנוכחות שלהם" @@ -2004,6 +2003,7 @@ he: global_notice: "הצגת מודעה גלובלית דחופה בגדר חירום לכל המבקרים, יש להחליף בתוכן ריק כדי להסתיר אותה (מותר HTML)." disable_system_edit_notifications: "ביטול התראות עריכה על ידי משתמש המערכת כאשר 'download_remote_images_to_local' פעיל." disable_category_edit_notifications: "השבתת התראות על עריכת קטגוריות בנושאים." + disable_tags_edit_notifications: "השבתת התראות על עריכת תגיות בנושאים." notification_consolidation_threshold: "מספר ההתראות שסומנו בלייק או בקשות שהתקבלו לפני שההתראות קובצו להתראה אחת. יש להגדיר ל־0 כדי להשבית." likes_notification_consolidation_window_mins: "משך הזמן בשניות בו התראות מקובצות להתראה אחת לאחר שהגיעו לסף הזה. ניתן להגדיר את הסף דרך `SiteSetting.notification_consolidation_threshold` (סף קיבוץ התראות)." automatically_unpin_topics: "הסרת נעיצה אוטומטית של נושאים כאשר המשתמשים מגיעים לתחתית." @@ -2054,8 +2054,8 @@ he: emoji_autocomplete_min_chars: "מספר התווים המזערי שנדרש להקפצת חלונית השלמה אוטומטית של אמוג׳י" enable_inline_emoji_translation: "הפעלת תרגום לאמוג׳י כחלק מהשורה (ללא רווחים או סימני פיסוק לפני)." approve_post_count: "מספר הפוסטים ממשתמשים חדשים או בסיסיים שחייבים לאשר אותם" - approve_unless_trust_level: "פוסטים של משתמשים מתחת לרמת אמון זו חייבים לעבור אישור" - approve_new_topics_unless_trust_level: "נושאים חדשים עבור משתמשים מתחת לרמת אמון זו חייבים להיות מאושרים" + 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 לניטרול)" @@ -2111,12 +2111,12 @@ he: revoke_api_keys_days: "מספר הימים בטרם שלילה אוטומטית של מפתח API שלא היה בשימוש (0 - לעולם לא)." allow_user_api_keys: "לאפשר למשתמשים ליצור מפתחות API" allow_user_api_key_scopes: "רשימת אזורים מותרים למפתחות API של משתמשים" - min_trust_level_for_user_api_key: "רמת אמון נדרשת לייצור של מפתחות API של משתמש" + min_trust_level_for_user_api_key: "דרגת האמון הנדרשת ליצירת מפתחות API למשתמש" allowed_user_api_auth_redirects: "כתובת מורשית להפניית אימות למפתחות API של משתמש. בסימן התו־כל * ניתן להשתמש כדי ללכוד חלק ממנה (למשל: www.example.com/*‎)." allowed_user_api_push_urls: "URLים מורשים לדחיפת שרת ל API של משתמשים." expire_user_api_keys_days: "מספר הימים בטרם פקיעת תוקף מפתח ה־API של המשתמש אוטומטית (0 - לעולם לא)." tagging_enabled: "לאפשר תגיות על נושאים?" - min_trust_to_create_tag: "רמת האמון המינימלית שנדרשת כדי ליצור תג." + min_trust_to_create_tag: "דרגת האמון המזערית שנדרשת כדי ליצור תגית." max_tags_per_topic: "כמות התגיות המרבית שניתן להקצות לנושא." max_tag_length: "אורך התג המקסימלי (מספר תווים)." max_tag_search_results: "בעת חיפוש אחר תגיות, כמה תוצאות תופענה לכל היותר." @@ -2125,7 +2125,7 @@ he: tags_listed_by_group: "הצגת תגיות לפי קבוצת תגיות בעמוד התגיות." tag_style: "סגנון ויזואלי לתג עיטורים." allow_staff_to_tag_pms: "לאפשר לחברי הסגל לתייג כל הודעה אישית" - min_trust_level_to_tag_topics: "רמת אמון מינימלית שדרושה כדי לתייג נושאים" + min_trust_level_to_tag_topics: "דרגת האמון המזערית שדרושה כדי לתייג נושאים" suppress_overlapping_tags_in_list: "אם תגיות תואמות מילים בכותרות נושאים באופן מדויק, לא להציג את התגית" remove_muted_tags_from_latest: "לא להציג נושאים שמתויגים רק בתגיות מושתקות ברשימת הנושאים האחרונים." force_lowercase_tags: "לאלץ את כל התגיות החדשות להיות באותיות קטנות בלבד." @@ -2348,7 +2348,7 @@ he: security_key_no_matching_credential_error: "לא ניתן למצוא פרטי גישה במפתח האבטחה שסופק." security_key_support_missing_error: "המכשיר או הדפדפן הנוכחי שלך לא תומך בשימוש במפתחות אבטחה, נא להשתמש בשיטה אחרת." security_key_invalid: "אירעה שגיאה באימות מפתח האבטחה." - not_approved: "חשבונך טרם אושר. יישלח אליך דואר אלקטרוני כשהוא יהיה מוכן להתחברות." + not_approved: "חשבונך טרם אושר. תישלח אליך הודעה בדוא״ל כשיהיה מוכן לכניסה." incorrect_username_email_or_password: "שם משתמש, דואר אלקטרוני או סיסמה לא נכונים" incorrect_password: "ססמה שגויה" wait_approval: "תודה על שנרשמת. אנחנו ניידע אותך כשהחשבון שלך יאושר." @@ -2358,7 +2358,7 @@ he: not_allowed_from_ip_address: "אין לך אפשרות להיכנס בתור %{username} מכתובת IP זו." admin_not_allowed_from_ip_address: "אין לך אפשרות להיכנס כהנהלת המערכת מכתובת IP זו." reset_not_allowed_from_ip_address: "אי אפשר לבקש איפוס ססמה מכתובת ה־IP הזאת." - suspended: "אינך יכול להתחבר עד %{date}." + suspended: "אין לך אפשרות להיכנס עד %{date}." suspended_with_reason: "חשבון הושעה עד %{date}: %{reason}" suspended_with_reason_forever: "חשבון מושעה: %{reason}" errors: "%{errors}" @@ -2369,7 +2369,7 @@ he: csrf_detected: "זמן ההמתנה לאישור הסתיים או שהחלפת דפדפנים. נא לנסות שוב." request_error: "אירעה שגיאה בעת התחלת האישור. נא לנסות שוב." invalid_iat: "לא ניתן לאמת את אסימון האישור עקב הפרשים בשעון השרת. נא לנסות שוב." - omniauth_error_unknown: "משהו השתבש במהלך עיבוד ההתחברות שלך, אנא נסו שנית." + omniauth_error_unknown: "משהו השתבש במהלך עיבוד הכניסה שלך, נא לנסות שוב." omniauth_confirm_title: "כניסה עם %{provider}" omniauth_confirm_button: "המשך" authenticator_error_no_valid_email: "אף אחת מהכתובת שמשויכות אל %{account} אינה מורשית. ייתכן שיהיה עליך להגדיר את החשבון שלך עם כתובת דוא״ל אחרת." @@ -2872,8 +2872,8 @@ he: subject_template: "ייצוא הנתונים נכשל" text_body_template: "אנו מתנצלים אך ייצוא הנתונים שלך נכשל. נא לעיין ביומנים או [ליצור קשר עם חבר סגל](%{base_url}/about)." email_reject_insufficient_trust_level: - title: "מייל נדחה, רמת אמון לא מספיקה" - subject_template: "[%{email_prefix}] בעיית מייל -- רמת אמון לא מספיקה" + title: "דחיית דוא״ל עקב דרגת אמון בלתי מספקת" + subject_template: "[%{email_prefix}] בעיית דוא״ל -- דרגת אמון לא מספיקה" text_body_template: | ההודעה שניסית לשלוח בדוא״ל אל %{destination} (עם הכותרת %{former_title}) לא נשלחה, עמך הסליחה. @@ -3533,15 +3533,15 @@ he: title: "קביעת סיסמה" subject_template: "[%{email_prefix}] קביעת סיסמה" text_body_template: | - מישהו ביקש להוסיף סיסמה לחשבון שלכם ב-[%{site_name}](%{base_url}). לחילופין, אתם יכולים להתחבר באמצעות כל שירות מקוון נתמך (גוגל, פייסבוק, וכד׳) שמקושר עם כתובת מייל זו. + מישהו ביקש להוסיף סיסמה לחשבון שלך ב־[%{site_name}](%{base_url}). לחלופין, אפשר להיכנס דרך כל שירות מקוון נתמך (Google, פייסבוק, וכד׳) שמקושר עם כתובת דוא״ל זו. - אם לא אתם ביקשתם זאת, אתם יכולים פשוט להתעלם ממייל זה. + אם לא יזמת בקשה שכזאת, ניתן פשוט להתעלם מהודעה זו. - לחצו על הקישור הבא כדי לבחור סיסמה: + לחיצה על הקישור הבא תאפשר לך לבחור סיסמה: %{base_url}/u/password-reset/%{email_token} admin_login: title: "כניסת הנהלה" - subject_template: "[%{email_prefix}] התחברות" + subject_template: "[%{email_prefix}] כניסה" text_body_template: | התקבלה בקשה להיכנס לחשבון שלך אצל [%{site_name}](%{base_url}). @@ -4096,17 +4096,17 @@ he: name: בסיסיים description: הוענקו כל תכונות הקהילה החיוניות long_description: | - עיטור זה מוענק עם הגיעך לרמת אמון 1. תודה לך על הבעת העניין בקהילה תוך קריאת מגוון נושאים כדי להבין את מהות הקהילה שלנו לעומק. המגבלות שחלות על משתמשים חדשים אינן חלות עליך עוד, קיבלת יכולות קהילה חיוניות כגון הודעות אישיות, סימון בדגל, עריכת ויקי והיכולת לפרסם מגוון תמונות וקישורים. + עיטור זה מוענק עם הגיעך לדרגת אמון 1. תודה לך על הבעת העניין בקהילה תוך קריאת מגוון נושאים כדי להבין את מהות הקהילה שלנו לעומק. המגבלות שחלות על משתמשים חדשים אינן חלות עליך עוד, קיבלת יכולות קהילה חיוניות כגון הודעות אישיות, סימון בדגל, עריכת ויקי והיכולת לפרסם מגוון תמונות וקישורים. member: name: חברים description: הוענקו הזמנות, שיחות קבוצתיות, עוד לייקים long_description: | - עיטור זה מוענק עם הגיעך לרמת אמון 2. תודה על השתתפותך בקהילה במשך מספר שבועות שמראים מעורבות של ממש בקהילה. מהיום יתאפשר לך לשלוח הזמנות דרך עמוד המשתמש שלך או דרך נושאים פרטניים, ליצור הודעות קבוצתיות אישיות ולקבל יותר לייקים כל יום. + עיטור זה מוענק עם הגיעך לדרגת אמון 2. תודה על השתתפותך בקהילה במשך מספר שבועות שמראה מעורבות של ממש בקהילה. מהיום יתאפשר לך לשלוח הזמנות דרך עמוד המשתמש שלך או דרך נושאים פרטניים, ליצור הודעות קבוצתיות אישיות ושיהיו לך יותר לייקים כל יום. regular: name: רגילים description: הוענקו העברה בין קטגוריות, שינוי שם, קישורים עם מעקב, ויקי, עוד לייקים long_description: | - עיטור זה מוענק עם הגיעך לרמת אמון 3. תודה על נטילת חלק בקהילה דרך קבע לאורך תקופה של מספר חודשים. נכון להיום מידת המעורבות שלך הן בקריאה מרובה והן בתרומה לקהילה הן אלו שמחזקות את הקהילה שלנו והופכות אותה לנהדרת. מעתה יתאפשר לך להעביר בין קטגוריות ולשנות שמות של נושאים, להשתמש בדגלי זבל משפיעים יותר, לגשת לאזור הטרקלין הפרטי ויתאפשר לך לקבל הרבה יותר לייקים כל יום. + עיטור זה מוענק עם הגיעך לדרגת אמון 3. תודה על נטילת חלק בקהילה דרך קבע לאורך תקופה של מספר חודשים. נכון להיום מידת המעורבות שלך הן בקריאה מרובה והן בתרומה לקהילה הן אלו שמחזקות את הקהילה שלנו והופכות אותה לנהדרת. מעתה יתאפשר לך להעביר בין קטגוריות ולשנות שמות של נושאים, להשתמש בדגלי זבל משפיעים יותר, לגשת לאזור הטרקלין הפרטי ויהיו לך הרבה יותר לייקים כל יום. leader: name: מובילים description: הוענקו עריכה, הצמדה, סגירה, העברה לארכיון, פיצול ומיזוג, עוד לייקים באופן גלובלי @@ -4402,10 +4402,10 @@ he: label: "שם הקהילה שלכם" placeholder: "המקום של ג׳יין" site_description: - label: "נא לתאר את הקהילה שלך במשפט קצר אחד" + label: "נא לתאר את הקהילה שלך במשפט קצר אחד (ישמש תוצאות חיפוש ורשתות חברתיות)" placeholder: "מקום לג׳יין וחבריה לשוחח על דברים מגניבים" short_site_description: - label: "נא לתאר את הקהילה שלך במספר מילים" + label: "נא לתאר את הקהילה שלך במספר מילים (משמש לכתובת דף הבית)" placeholder: "הקהילה הכי טובה בעולם" introduction: title: "מבוא" @@ -4582,6 +4582,7 @@ he: contains_media: "פוסט זה כולל מדיה מוטמעת. מידע נוסף תחת %{link}." queued_by_staff: "אחד מחברי הסגל חושב שהפוסט הזה דורש סקירה. עד אז הוא יישאר מוסתר." links: + watched_word: רשימת מילים במעקב category: הגדרות קטגוריה actions: agree: diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml index 43b2274dcc..d3c1d0cca8 100644 --- a/config/locales/server.hy.yml +++ b/config/locales/server.hy.yml @@ -1115,7 +1115,6 @@ hy: summary_score_threshold: "'Ամփոփել Այս Թեման'-ի մեջ ներառման համար անհրաժեշտ գրառման նվազագույն միավորը" summary_percent_filter: "Երբ օգտատերը սեղմում է 'Ամփոփել Այս Թեման' , ցուցադրել գրառումների թոփ %-ը" summary_max_results: "'Ամփոփել Այս Թեման' -ի կողմից վերադարձված առավելագույն գրառումները" - enable_personal_messages: "Թույլատրել վստահության 1-ին մակարդակի (կարգավորվում է հաղորդագրություններ ուղարկելու համար նվազագույն վստահության մակարդակի միջոցով) օգտատերերին ստեղծել և պատասխանել հաղորդագրություններին: Նկատի ունեցեք, որ անձնակազմը միշտ կարող է ուղարկել հաղորդագրություն, կապ չունի թե ինչպիսի:" enable_system_message_replies: "Թույլատրում է օգտատերերին պատասխանել համակարգային հաղորդագրությունների, անգամ եթե անձնական հաղորդագրություններն անջատված են" enable_long_polling: "Ծանուցման համար օգտագործվող նամակների ավտոբուսը (Message bus) կարող է օգտագործել long polling:" long_polling_base_url: "long polling-ի համար օգտագործվող հիմնական URL (երբ CDN-ը տալիս է դինամիկ բովանդակություն, համոզվեք, որ սա սահմանված է origin pull) , օրինակ՝ http://origin.site.com" @@ -1351,7 +1350,6 @@ hy: topic_view_duration_hours: "Հաշվարկել թեմայի նոր դիտում՝ ըստ յուրաքանչյուր IP-ի/Օգտատիրոջ յուրաքանչյուր N ժամը մեկ" user_profile_view_duration_hours: "Հաշվարկել օգտատիրոջ պրոֆիլի նոր դիտում՝ ըստ յուրաքանչյուր IP-ի/Օգտատիրոջ յուրաքանչյուր N ժամը մեկ" levenshtein_distance_spammer_emails: "Սպամմերի էլ. նամակների համապատասխանեցման ժամանակ սիմվոլների տարբերության քանակը, որը դեռևս թույլ կտա անորոշ համընկնում:" - max_new_accounts_per_registration_ip: "Եթե արդեն իսկ կա վստահության 0 մակարդակ ունեցող (n) հաշիվ այս IP -ից (և ոչ մեկը անձնակազմի անդամ չէ կամ չունի ՎՄ2 կամ ավելի բարձր), դադարել ընդունել նոր մուտքեր այդ IP-ից:" min_ban_entries_for_roll_up: "Խմբավորել կոճակը սեղմելիս կստեղծվի նոր սուբնեթի արգելքի մուտք, եթե կա առնվազն (N) մուտք:" max_age_unmatched_emails: "Ջնջել չհամընկած ցուցադրված էլ. մուտքերը (N) օր հետո:" max_age_unmatched_ips: "Ջնջել չհամընկնող ցուադրված IP մուտքերը (N) օր անց:" @@ -3008,10 +3006,8 @@ hy: label: "Ձեր համայնքի անվանումը" placeholder: "Jane’s Hangout" site_description: - label: "Նկարագրեք Ձեր համայնքը մեկ կարճ նախադասությանբ" placeholder: "Ջեյնի և իր ընկերների համար ընտիր թեմաներ քննարկելու վայր" short_site_description: - label: "Նկարագրեք Ձեր համայնքը մի քանի բառով" placeholder: "Բոլոր ժամանակների ամենալավ համայնքը" introduction: title: "Ներածություն" diff --git a/config/locales/server.id.yml b/config/locales/server.id.yml index 63564ad6f2..2ad5654032 100644 --- a/config/locales/server.id.yml +++ b/config/locales/server.id.yml @@ -32,6 +32,10 @@ id: - November - Desember <<: *datetime_formats + time: + am: "am" + pm: "pm" + <<: *datetime_formats title: "Discourse" topics: "Topik" posts: "post" @@ -704,8 +708,6 @@ id: reject: title: "Tolak" fallback_username: "pengguna" - time: - <<: *datetime_formats activemodel: errors: <<: *errors diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index 100f5ed4a9..fbcf394308 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -250,7 +250,6 @@ it: topic_invite: failed_to_invite: "L'utente non può essere invitato in questo argomento senza essere membro di uno dei seguenti gruppi: %{group_names}." user_exists: "Spiacenti, l'utente è stato già invitato. Puoi invitare un utente ad uno stesso argomento solo una volta." - muted_invitee: "Spiacenti, quell'utente ti ha silenziato." muted_topic: "Spiacenti, quell'utente ha silenziato questo argomento." receiver_does_not_allow_pm: "Spiacenti, quell'utente non ti permette di inviargli messaggi privati." sender_does_not_allow_pm: "Spiacenti, non permetti a quell'utente di inviarti messaggi privati." @@ -1784,7 +1783,7 @@ it: topic_view_duration_hours: "Conteggia una nuova visita dell'argomento una volta per IP/Utente ogni N ore" user_profile_view_duration_hours: "Conteggia una nuova visita del profilo utente una volta per IP/Utente ogni N ore" levenshtein_distance_spammer_emails: "Quanti caratteri di differenza faranno comunque scattare una corrispondenza approssimativa nell'analisi delle email di spam. " - max_new_accounts_per_registration_ip: "Se vi sono già (n) account con livello di attendibilità 0 da questo IP (e nessuno è membro dello staff o a livello TL2 o superiore), non accettare nuove iscrizioni utente da questo IP." + max_new_accounts_per_registration_ip: "Se vi sono già (n) account con livello di attendibilità 0 da questo IP (e nessuno è membro dello staff o a livello 2 o superiore), non accettare nuove iscrizioni da questo IP. Impostare a 0 per disabilitare il limite." min_ban_entries_for_roll_up: "Quando si clicca il pulsante Roll up, verrà creato un nuovo elenco di IP interdetti se sono presenti almeno (N) elementi." max_age_unmatched_emails: "Elimina le voci di email scansionate senza corrispondenza dopo (N) giorni." max_age_unmatched_ips: "Elimina le voci di IP scansionati senza corrispondenza dopo (N) giorni." @@ -1921,6 +1920,7 @@ it: global_notice: "Mostra un banner di avviso globale URGENTE, EMERGENZA, non eliminabile a tutti i visitatori, impostare l'opzione vuota per nasconderlo (HTML consentito)." disable_system_edit_notifications: "Disabilita le notifiche di modifica dall'utente system quando 'download_remote_images_to_local' è attivo." disable_category_edit_notifications: "Disabilita le notifiche di modifica delle categorie negli argomenti." + disable_tags_edit_notifications: "Disabilita le notifiche di modifica delle etichette sugli argomenti." notification_consolidation_threshold: "Il numero di notifiche per richieste di adesione o di \"Mi piace\" ricevuti prima che le notifiche vengano raccolte in una sola. Impostare il valore a 0 per disabilitare l'opzione." likes_notification_consolidation_window_mins: "Durata in minuti del periodo in cui le notifiche dei \"mi piace\" sono raccolte in un'unica notifica una volta raggiunta la soglia definita. La soglia può essere configurata tramite l'impostazione `SiteSetting.notification_consolidation_threshold`." automatically_unpin_topics: "Spunta automaticamente gli argomenti quando l'utente arriva in fondo." @@ -3912,10 +3912,8 @@ it: label: "Il nome della tua comunità" placeholder: "Il Ritrovo di Jane" site_description: - label: "Descrivi la tua comunità in una breve frase" placeholder: "Un posto per Jane e per i suoi amici dove discutere di cose forti" short_site_description: - label: "Descrivi la tua comunità in poche parole" placeholder: "La miglior comunità di sempre" introduction: title: "Introduzione" diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index e05d05cb3e..b83e0908fb 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -240,7 +240,6 @@ ja: topic_invite: failed_to_invite: "次のいずれかのグループのグループメンバーシップがない場合、ユーザーをこのトピックに招待できません: %{group_names}。" user_exists: "そのユーザーはすでに招待されています。ユーザーを一度しかトピックに招待できません。" - muted_invitee: "そのユーザーはあなたをミュートしています。" muted_topic: "そのユーザーはこのトピックをミュートしています。" receiver_does_not_allow_pm: "そのユーザーは、あなたがプライベートメッセージを送信することを許可していません。" sender_does_not_allow_pm: "そのユーザーがプライベートメッセージを送信することを許可していません。" @@ -1396,7 +1395,6 @@ ja: summary_likes_required: "'このトピックを要約' を有効にするために最低限必要なトピック内の「いいね!」数。この設定を変更すると、1 週間以内で遡って適用されます。" summary_percent_filter: "「このトピックを要約」をクリックしたとき表示される上位投稿の割合%" summary_max_results: "'このトピックを要約' が返す最大投稿数" - enable_personal_messages: "信頼レベル 1 ユーザーによるメッセージとメッセージへの返信の作成を許可する (メッセージを送信する最低信頼レベルで構成可能)。スタッフは設定に関係なく必ずメッセージを作成できます。" enable_system_message_replies: "個人メッセージが無効である場合でも、ユーザーによるシステムメッセージへの返信を許可する" enable_long_polling: "通知用のメッセージバスによるロングポーリングの利用を許可する" enable_chunked_encoding: "サーバーによるチャンク形式エンコーディング応答を有効にする。この機能はほとんどのセットアップで動作しますが、一部のプロキシではバッファリングが生じ、応答が遅延する可能性があります。" @@ -1726,7 +1724,6 @@ ja: topic_view_duration_hours: "N 時間ごとに IP/ユーザーあたりの新規トピックビューを 1 回カウントする" user_profile_view_duration_hours: "N 時間ごとに IP/ユーザーあたりの新規ユーザープロフィールビューを 1 回カウントする" levenshtein_distance_spammer_emails: "迷惑メールのアドレスを照合する場合、あいまい一致を許可する文字数の差。" - max_new_accounts_per_registration_ip: "この IP でアクセスする信頼レベル 0 のアカウントが (n) 個存在する場合 (さらにこれらがスタッフメンバーや TL2 以上のメンバーでない場合)、この IP から新たに登録できないようにします。" min_ban_entries_for_roll_up: "ロールアップボタンをクリックする際に少なくとも (N) 個のエントリーがある場合、新しいサブネット禁止エントリーを作成します。" max_age_unmatched_emails: "(N) 日間一致しなかったスクリーン対象メールアドレスを削除します。" max_age_unmatched_ips: "(N) 日間一致しなかったスクリーン対象 IP アドレスを削除します。" @@ -4177,10 +4174,8 @@ ja: label: "コミュニティの名前" placeholder: "純子のたまり場" site_description: - label: "コミュニティについて簡単な一文で説明してください" placeholder: "純子と純子の仲間がワイワイする場所" short_site_description: - label: "コミュニティについて短い言葉で説明してください" placeholder: "史上最高のコミュニティ" introduction: title: "紹介" diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 07fc37d38b..ecda50d200 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -239,7 +239,6 @@ ko: topic_invite: failed_to_invite: "%{group_names} 그룹 중 하나에 그룹 구성원이 없으면이 주제에 사용자를 초대 할 수 없습니다." user_exists: "해당 사용자는 이미 초대를 받았습니다. 토픽 초대는 딱 한번만 할 수 있습니다." - muted_invitee: "죄송합니다. 해당 사용자는 사용자님을 관심없음으로 설정했습니다." muted_topic: "죄송합니다. 해당 사용자는 이 글을 관심없음으로 설정했습니다." receiver_does_not_allow_pm: "죄송합니다. 해당 사용자는 비공개 메시지를 보낼 수 있도록 허용하지 않습니다." sender_does_not_allow_pm: "죄송합니다. 해당 사용자는 비공개 메시지를 보낼 수 있도록 허용하지 않습니다." @@ -1405,7 +1404,7 @@ ko: summary_percent_filter: "요약본 보기를 클릭시, 글 중에 몇 %의 상위 글을 보여줄 것인가?" summary_max_results: "'이 주제 요약'에서 반환 한 최대 게시물" summary_timeline_button: "타임라인에 '요약' 버튼 표시" - enable_personal_messages: "신뢰도 1 사용자(메시지 전송을 위한 최소 신뢰도로 설정가능)가 메시지를 작성하고 답장을 쓸 수 있도록 허용합니다. 운영진은 언제나 메시지를 보낼 수 있음을 참고하세요." + enable_personal_messages: "회원레벨1 사용자가 메시지를 작성하고 메시지에 답장할 수 있도록 허용합니다. 관리자는 언제든지 메시지를 보낼 수 있습니다." enable_system_message_replies: "개인 메시지가 비활성화 된 경우에도 사용자가 시스템 메시지에 회신 할 수 있습니다" enable_long_polling: "Message bus used for notification can use long polling" enable_chunked_encoding: "서버에서 청크 분할 인코딩 응답을 활성화합니다. 이 기능은 대부분의 설정에서 작동하지만 일부 프록시는 버퍼링되어 응답이 지연될 수 있습니다." @@ -1732,7 +1731,6 @@ ko: topic_view_duration_hours: "N 시간마다 IP/User 별로 새 토픽 조회수를 셉니다." user_profile_view_duration_hours: "N 시간마다 IP/User 별로 새 프로필 조회수를 셉니다." levenshtein_distance_spammer_emails: "스패머 메일을 체크할 때, 허용할 다른 글자 개수(fuzzy match)" - max_new_accounts_per_registration_ip: "(n) 회원등급의 0개의 계정(스태프도 tl2 이상도 아닌 계정만)이 이 IP에 있으면, 새로운 회원가입 방지." min_ban_entries_for_roll_up: "Roll up 버튼을 눌렀을 때, 적어도 (N)개의 엔트리가 있다면 새 subnet ban 엔트리를 만듭니다." max_age_unmatched_emails: "(N)일 뒤에 안 맞는 막힌 이메일 접근을 지웁니다." max_age_unmatched_ips: "(N)일 뒤에 안 맞는 막힌 IP 접근을 지웁니다." @@ -1865,6 +1863,7 @@ ko: permalink_normalizations: "퍼마링크를 매칭하기 전에 다음 정규표현식을 적용. 예: /(topic.*)\\?.*/\\1 를 적용하면 토픽 루트에서 퀴리 스트링을 뽑아냅니다. 캡쳐된 값에 접근하려면 정규표현식+스트링 형식에 \\1 등을 사용하면 됩니다." global_notice: "모든 방문자에게 긴급, 긴급, 무시할 수없는 글로벌 배너 통지를 표시하고 숨기려면 공백으로 변경하십시오 (HTML 허용)." disable_system_edit_notifications: "'download_remote_images_to_local'가 활성화되있으면 시스템 사용자에 의한 수정 알림을 비활성화합니다." + disable_category_edit_notifications: "글의 카테고리 편집 알림을 비활성화합니다." notification_consolidation_threshold: "알림이 단일 알림으로 통합되기 전에 수신 된 좋아요 또는 회원 요청 알림 수 비활성화하려면 0으로 설정하십시오." likes_notification_consolidation_window_mins: "임계 값에 도달하면 선호 알림이 단일 알림으로 통합되는 시간 (분)입니다. 임계 값은`SiteSetting.notification_consolidation_threshold`를 통해 구성 할 수 있습니다." automatically_unpin_topics: "사용자가 하단에 도달하면 토픽 고정을 자동 해제." @@ -2413,6 +2412,17 @@ ko: new_version_mailer: title: "새 버전 메일러" subject_template: "[%{email_prefix}] 새로운 담화 버전, 업데이트 가능" + text_body_template: | + [Discourse](https://www.discourse.org)의 새로운 버전이 출시되었습니다! + + 사용자의 현재 버전: %{installed_version} + 새 버전: **%{new_version}** + + - 간편한 **[원클릭 브라우저 업그레이드](%{base_url}/admin/upgrade)** + + - [릴리스 노트]( https://meta.discourse.org/tag/release-notes) 또는 [GitHub 변경 로그](https://github.com/discourse/discourse/commits/main) 보기 + + - [meta.discourse.org](https:// meta.discourse.org)를 방문해 뉴스, 토론 및 Discourse 지원 확인 new_version_mailer_with_notes: title: "노트가있는 새로운 버전 메일러" subject_template: "[%{email_prefix}] 업데이트 가능" @@ -4096,10 +4106,8 @@ ko: label: "커뮤니티 이름" placeholder: "빠오 소굴" site_description: - label: "당신의 커뮤니티를 짧은 문장으로 축약하여 설명해보세요" placeholder: "빠오가 친구와 함께 재밌는 일들에 대하여 이야기나누는 곳" short_site_description: - label: "몇 마디로 커뮤니티를 설명하십시오" placeholder: "최고의 커뮤니티" introduction: title: "소개" @@ -4261,9 +4269,11 @@ ko: post_count: "모든 사용자의 처음 몇 개의 게시물은 관리자의 승인을 받아야 합니다. 다음을 참조하십시오. %{link}" auto_silence_regex: "%{link}의 설정과 일치하는 새 사용자입니다." watched_word: "이 게시물에는 감시 단어가 포함되어 있습니다. 다음 링크를 참조하십시오. %{link}" + category: "이 카테고리의 게시물은 관리자의 수동 승인이 필요합니다. 다음을 참조하십시오. %{link}" must_approve_users: "모든 신규 사용자는 관리자의 승인을 받아야 합니다. 다음을 참조하십시오. %{link}" invite_only: "모든 신규 사용자를 초대해야 합니다. 다음 링크를 참조하십시오. %{link}" email_auth_res_enqueue: "이 이메일은 DMARC 확인에 실패했으며, 발신자가 아닌 것 같습니다. 자세한 내용은 원시 이메일 헤더를 확인하십시오." + suspect_user: "이 새로운 사용자는 글이나 댓글을 읽지 않고 프로필 정보를 입력했으며, 이는 스팸 발송자일 가능성이 큽니다. 다음을 참조하십시오. %{link}" contains_media: "이 게시물에는 미디어가 포함되어 있습니다. 다음을 참조하십시오. %{link}" queued_by_staff: "관리자가 게시물의 검토가 필요하다고 판단했습니다. 그때까지는 숨겨져 있습니다." links: diff --git a/config/locales/server.lt.yml b/config/locales/server.lt.yml index 0dd0f37e55..fd3bad17c8 100644 --- a/config/locales/server.lt.yml +++ b/config/locales/server.lt.yml @@ -177,7 +177,6 @@ lt: topic_invite: failed_to_invite: "Vartotojas negali būti pakviestas į šią temą be grupės narystės vienoje iš šių grupių: %{group_names}." user_exists: "Atsiprašome, šis vartotojas jau buvo pakviestas. Galite pakviesti vartotoją į temą tik vieną kartą." - muted_invitee: "Atsiprašome, vartotojas jus nutildė." muted_topic: "Atsiprašome, kad vartotojas nutildė šią temą." receiver_does_not_allow_pm: "Atsiprašome, vartotojas neleidžia jums siųsti asmeninių pranešimų." sender_does_not_allow_pm: "Atsiprašome, jūs neleidžiate šiam vartotojui siųsti jums asmeninių pranešimų." @@ -1350,6 +1349,13 @@ lt: not_allowed: "neleidžiama iš šio el. pašto teikėjo. Prašome naudoti kitą el. pašto adresą." blocked: "negalimas." does_not_exist: "N/A" + ip_address: + blocked: "Naujos registracijos neleidžiamos iš jūsų IP adreso." + max_new_accounts_per_registration_ip: "Naujos registracijos neleidžiamos iš jūsų IP adreso (pasiekta maksimali riba). Susisiekite su administracija." + website: + domain_not_allowed: "Svetainė netinkama. Leidžiami domenai yra: %{domains}" + destroy_reasons: + inactive_user: "Neaktyvus vartotojas" invite_forum_mailer: text_body_template: | %{inviter_name} pakvietė jus prisijungti @@ -2064,6 +2070,8 @@ lt: Šis ženklelis suteikiamas, kai jūsų tema sulaukia 10 teigiamų įvertinimų. Pradėjote įdomų pokalbį, kuris patiko visuomenei. good_topic: name: Gera tema + long_description: | + Šis ženklelis suteikiamas, kai jūsų tema sulaukia 25 teigiamų įvertinimų. Pradėjote įdomų pokalbį, kuris patiko visuomenei. great_topic: name: Puiki tema long_description: | @@ -2234,10 +2242,8 @@ lt: title: label: "Jūsų bendruomenės pavadinimas" site_description: - label: "Apibūdinkite savo bendruomenę vienu trumpu sakiniu" placeholder: "Džeinei ir jos draugams skirta vieta aptarti įdomius dalykus" short_site_description: - label: "Apibūdinkite savo bendruomenę keliais žodžiais" placeholder: "Pati geriausia bendruomenė" introduction: title: "Prisistatymas" diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index fca5e8e9c3..775e9e1480 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -227,7 +227,6 @@ nl: topic_invite: failed_to_invite: "De gebruiker kan niet voor dit topic worden uitgenodigd zonder een groepslidmaatschap in een van de volgende groepen: %{group_names}." user_exists: "Sorry, die gebruiker is al uitgenodigd. U kunt een gebruiker maar één keer voor een topic uitnodigen." - muted_invitee: "Sorry, die gebruiker heeft u gedempt." backup: operation_already_running: "Er wordt al een bewerking uitgevoerd. Er kan nu geen nieuwe taak worden gestart." backup_file_should_be_tar_gz: "Het back-upbestand dient een .tar.gz-archief te zijn." @@ -1349,7 +1348,6 @@ nl: summary_likes_required: "Het minimale aantal likes in een topic voordat 'Dit topic samenvatten' wordt ingeschakeld. Wijzigingen in deze instelling worden binnen een week met terugwerkende kracht toegepast." summary_percent_filter: "Wanneer een gebruiker op 'Dit topic samenvatten' klikt, de top % van berichten tonen" summary_max_results: "Maximale aantal weergegeven berichten door 'Dit topic samenvatten'" - enable_personal_messages: "Gebruikers met vertrouwensniveau 1 (instelbaar via het minimale niveau om berichten te verzenden) toestaan om berichten aan te maken en op berichten te antwoorden. Houd er rekening mee dat stafleden altijd berichten kunnen verzenden." enable_system_message_replies: "Toestaan dat gebruikers op systeemberichten kunnen antwoorden, zelfs als persoonlijke berichten zijn uitgeschakeld" enable_long_polling: "Gebruikte 'message bus' voor melding kan 'long polling' gebruiken" enable_chunked_encoding: "Gesegmenteerde coderingsantwoorden van de server inschakelen. Deze functie werkt in de meeste configuraties, hoewel sommige proxy's kunnen bufferen, waardoor reacties worden vertraagd" @@ -1624,7 +1622,6 @@ nl: topic_view_duration_hours: "Elke N uur één keer per IP/Gebruiker een nieuw-topicweergave tellen." user_profile_view_duration_hours: "Elke N uur één keer per IP/Gebruiker een nieuw-gebruikersprofielweergave tellen." levenshtein_distance_spammer_emails: "Bij het vergelijken van spam-e-mails, het aantal verschillende tekens waarbij nog steeds een wazige overeenkomst kan bestaan." - max_new_accounts_per_registration_ip: "Als er al (n) accounts met vertrouwensniveau 0 zijn van een bepaald IP-adres (en geen daarvan is staflid of heeft een vertrouwensniveau 2 of hoger), het accepteren van nieuwe registraties vanaf dat IP-adres stoppen." min_ban_entries_for_roll_up: "Bij het klikken op de knop Samenvoegen, een nieuwe subnet-banvermelding maken als er minstens (N) vermeldingen zijn." max_age_unmatched_emails: "Niet-overeenkomende gecontroleerde e-mailadresvermeldingen na (N) dagen verwijderen." max_age_unmatched_ips: "Niet-overeenkomende gecontroleerde IP-adresvermeldingen na (N) dagen verwijderen." diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index d0f367cbe2..16be762e05 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -268,7 +268,6 @@ pl_PL: topic_invite: failed_to_invite: "Nie można zaprosić użytkownika do tego tematu, ponieważ nie jest członkiem choć jednej z następujących grup: %{group_names}." user_exists: "Przepraszamy, ten użytkownik został już zaproszony. Możesz zaprosić użytkownika do tematu tylko raz." - muted_invitee: "Przepraszamy, ten użytkownik Cię wyciszył." muted_topic: "Przepraszamy, ten użytkownik wyciszył ten temat." receiver_does_not_allow_pm: "Przepraszamy, ten użytkownik nie pozwala na wysyłanie mu prywatnych wiadomości." sender_does_not_allow_pm: "Przepraszamy, nie pozwalasz temu użytkownikowi na wysyłanie Ci prywatnych wiadomości." @@ -1564,7 +1563,6 @@ 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: "Zezwalaj użytkownikom o poziomie zaufania 1 (możliwe do zmiany przez min trust level to send messages) na tworzenie wiadomości i odpowiadanie na nie. Zwróć uwagę, że administracja 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_long_polling: "Message bus used for notification can use long polling" 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." @@ -1895,7 +1893,6 @@ pl_PL: topic_view_duration_hours: "Licz wyświetlanie nowego tematu na IP/Użytkownika co N godzin" user_profile_view_duration_hours: "Licz nowe wyświetlenia profilu użytkownika na IP/Użytkownika co N godzin" levenshtein_distance_spammer_emails: "Przy dopasowywaniu emaili spamowych, różnica liczby znaków, która w dalszym ciągu pozwoli na przybliżone dopasowanie." - max_new_accounts_per_registration_ip: "Jeśli istnieję już (n) kont o poziomie zaufania 0 z tego adresu IP (i żaden nie jest z obsługi lub na TL2 lub wyżej), zatrzymaj akceptowanie nowych użytkowników z tego IP." min_ban_entries_for_roll_up: "Podczas naciskania na przycisk Przewiń do góry, utwórz nowy wpis blokady podsieci jeśli jest co najmniej (N) wpisów." max_age_unmatched_emails: "Usuń niedopasowany ekranowany wpis email po (N) dniach." max_age_unmatched_ips: "Usuń niedopasowany ekranowany wpis IP po (N) dniach." @@ -4522,10 +4519,8 @@ pl_PL: label: "Twoja pełna nazwa" placeholder: "Hangout Jane" site_description: - label: "Opisz swoja społeczność w jednym krótkim zdaniu." placeholder: "Miejsce dla Jane i jej przyjaciół na dyskusje o fajnych sprawach." short_site_description: - label: "Opisz swoją społeczność w paru słowach" placeholder: "Najlepsza społeczność kiedykolwiek" introduction: title: "Wprowadzenie" diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index 6bec0244a7..40b422278b 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -237,7 +237,6 @@ pt: topic_invite: failed_to_invite: "O usuário não pode ser convidado para este tópico sem uma associação de grupo em um dos seguintes grupos: %{group_names}." user_exists: "Pedimos desculpa, esse utilizador já foi convidado. Pode convidar um utilizador para um tópico apenas uma vez." - muted_invitee: "Desculpe, esse usuário silenciou-te." muted_topic: "Desculpe, esse usuário silenciou este tópico." receiver_does_not_allow_pm: "Desculpe, esse usuário não permite que você lhe envie mensagens privadas." sender_does_not_allow_pm: "Desculpe, você não permite que esse usuário lhe envie mensagens privadas." @@ -1009,7 +1008,6 @@ pt: force_https: "Forçar o site a usar apenas HTTPS. ALERTA: NÃO active esta opção enquanto não verificar que o HTTPS está completamente configurado e funcional absolutamente em todo o lado! Verificou a sua CDN, todos os logins por rede social, e quaisquer logos ou outras dependências externas para garantir que também são compatíveis com HTTPS?" summary_score_threshold: "Pontuação mínima necessária para que uma mensagem seja incluída em 'Resumir Este Tópico'" summary_percent_filter: "Quando um utilizador clica em 'Resumir Este Tópico', mostrar as melhores % de mensagens" - enable_personal_messages: "Permitir que utilizadores de nível de confiança 1 (configurável através do nível de confiança mínimo para enviar mensagens) criem mensagens e respostas a mensagens. Note que a equipa de apoio pode mandar mensagens de qualquer maneira." enable_long_polling: "O sistema de mensagens usado para notificações pode fazer solicitações longas" long_polling_base_url: "URL base utilizado para sondar o servidor (quando um CDN serve conteúdo dinâmico, certifique-se de definir isto para a \"pull\" original) por exemplo: http://origem.site.com" long_polling_interval: "O tempo que um servidor deverá aguardar antes de responder aos clientes quando não existirem dados para serem enviados (apenas utilizadores autenticados)" @@ -1171,7 +1169,6 @@ pt: topic_view_duration_hours: "Contar uma nova visualização do tópico uma vez por IP/Utilizador a cada N horas" user_profile_view_duration_hours: "Contar visualização de novo perfil de utilizador uma vez por IP/utilizador a cada N horas" levenshtein_distance_spammer_emails: "Ao fazer a correspondência de e-mails de spam, o número de caracteres de diferença que ainda permitirá uma correspondência difusa." - max_new_accounts_per_registration_ip: "Se já há (n) contas com nível de confiança 0 a partir deste IP (e nenhum é um membro do pessoal ou em nível de confiança 2 ou superior), parar de aceitar novos registos a partir desse IP." min_ban_entries_for_roll_up: "Ao clicar no botão Agrupar, irá criar uma nova entrada de sub-rede banida se houver pelo menos (N) entradas." max_age_unmatched_emails: "Eliminar entradas de email não encontradas após (N) dias." max_age_unmatched_ips: "Eliminar entradas IP não encontradas após (N) dias." @@ -2200,7 +2197,6 @@ pt: label: "O nome da sua comunidade" placeholder: "Reduto da Joana" site_description: - label: "Descreva a sua comunidade numa frase curta" placeholder: "Um lugar para a Jona e os seus amigos falarem de coisas giras" introduction: title: "Introdução" diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index c1f943e5f5..79dd862f02 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -249,7 +249,6 @@ pt_BR: topic_invite: failed_to_invite: "O(a) usuário(a) não pode ser convidado(a) para este tópico sem uma associação de grupo em um dos seguintes grupos: %{group_names}." user_exists: "Desculpe, este(a) usuário(a) já foi convidado(a). Você pode convidar um(a) usuário(a) para um tópico apenas uma vez." - muted_invitee: "Desculpe, você foi silenciado(a) por este(a) usuário(a)." muted_topic: "Desculpe, este(a) usuário(a) silenciou este tópico." receiver_does_not_allow_pm: "Desculpe, esse usuário não permite que você envie mensagens privadas." sender_does_not_allow_pm: "Desculpe, você não permite que o(a) usuário(a) envie mensagens privadas." @@ -1450,7 +1449,6 @@ pt_BR: summary_likes_required: "Quantidade mínima de curtidas em um tópico antes de ativar \"Resumir este tópico\". As alterações desta configuração serão aplicadas de forma retroativa dentro de uma semana." summary_percent_filter: "Quando um(a) usuário(a) clicar em \"Resuma este tópico\", exiba as % melhores postagens" summary_max_results: "Quantidade máxima de postagens retornadas por \"Resumir este tópico\"" - enable_personal_messages: "Permita que usuários(as) do nível de confiança 1 (configurável por meio do nível mínimo de confiança para enviar mensagens) criem e respondam a mensagens. Nove que a equipe sempre pode enviar mensagens." enable_system_message_replies: "Permite que os(as) usuários(as) respondam às mensagens do sistema, mesmo se as mensagens pessoais estiverem desativadas" enable_long_polling: "O sistema de mensagens das notificações pode fazer sondagens longas." enable_chunked_encoding: "Ative respostas de codificação em bloco no servidor. Esse recurso funciona na maioria das configurações, mas alguns proxies podem ser armazenados em buffer, atrasando as respostas" @@ -1780,7 +1778,6 @@ pt_BR: topic_view_duration_hours: "Conte uma nova visualização de tópico uma vez por IP/usuário(a) a cada N horas" user_profile_view_duration_hours: "Conte uma nova visualização de perfil de usuário(a) uma vez por IP/usuário(a) a cada N horas" levenshtein_distance_spammer_emails: "Ao fazer correspondência de e-mails de remetentes de spam, o tamanho da diferença de caracteres que ainda causará uma correspondência aproximada." - max_new_accounts_per_registration_ip: "Se já houver (n) contas com o nível de confiança 0 deste IP (e nenhum for membro da equipe ou tiver NC2 ou mais alto), pare de aceitar novas assinaturas desse IP." min_ban_entries_for_roll_up: "Ao clicar no botão Combinar, uma entrada de banimento de subrede será criada se houver ao menos (N) entradas." max_age_unmatched_emails: "Excluir entradas de e-mails filtrados sem correspondência após (N) dias." max_age_unmatched_ips: "Excluir entradas de IPs filtrados sem correspondência após (N) dias." @@ -4297,10 +4294,8 @@ pt_BR: label: "Nome de sua comunidade" placeholder: "Hangout de Jane" site_description: - label: "Descreva sua comunidade com uma frase curta." placeholder: "Um lugar para Jane e seus amigos discutirem coisas legais." short_site_description: - label: "Descreva sua comunidade em poucas palavras" placeholder: "Melhor comunidade de todos os tempos" introduction: title: "Introdução" diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index 3fb10f374d..00f8522651 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -834,7 +834,6 @@ ro: force_https: "Forțează site-ul să folosească exclusiv HTTPS. ATENȚIE: NU activa această opțiune până nu ai verificat dacă HTTPS este configurat în întregime și funcționează absolut peste tot. Ai verificat, de asemenea, dacă și CND-ul, toate autentificările cu cont pe rețele de socializaer și toate logo-urile și dependențele externe sunt compatibile cu HTTPS?" summary_score_threshold: "Scorul minim necesar pentru ca o postare să fie inclusă în 'Rezumă acest subiect'" summary_percent_filter: "Când un utilizator face click pe 'Rezumatul acestui subiect', arată primele % de postări" - enable_personal_messages: "Acordă nivelul de încredere 1 (configurabil via nivel minim de încredere pentru trimiterea de mesaje) utilizatorilor, pentru a le permite să creeze și să răspundă la mesaje. Atenție: echipa poate oricând să trimită mesaje, indiferent de setări." enable_long_polling: "Bus-ul de mesaje folosit pentru notificări poate utiliza long polling." long_polling_base_url: "URL de bază folosit pentru long polling (atunci când un CDN servește conținut dinamic, asigură-te că setezi asta pe origin pull) ex: http://origin.site.com" long_polling_interval: "Durata cât serverul va trebui să aștepte înainte de a răspunde clienților atunci când nu există date de trimis (exclusiv utilizatori autentificați)" @@ -995,7 +994,6 @@ ro: topic_view_duration_hours: "Contorizează încă o vizualizare a unui subiect nou o singură dată per IP/Utilizator la fiecare N ore." user_profile_view_duration_hours: "Contorizează câte vizualizare de profil utilizator o singură dată per IP/Utilizator la fiecare N ore" levenshtein_distance_spammer_emails: "Când se face detectarea spam pe bază de potrivire cu un set de criterii, care este diferența de numere de caractere care încă mai permite o potrivire aproximativă (fuzzy match)." - max_new_accounts_per_registration_ip: "Dacă există deja (n) conturi cu nivelul de încredere 0 de la acest IP (și nici unul nu este un membru al echipei sau la NÎ2 sau mai mare), blochează acceptarea de noi înregistrări de la acest IP." min_ban_entries_for_roll_up: "Când apeși pe butonul Consolidare, dacă există cel puțin (N) înregistrări, se va crea o nouă înregistrare de subrețea blocată" max_age_unmatched_emails: "șterge înregistrările emailurilor verificate, care nu corespund, după (N) zile." max_age_unmatched_ips: "șterge adresele de IP verificate, care nu corespund, după (N) zile." @@ -1949,7 +1947,6 @@ ro: label: "Numele comunității tale" placeholder: "Bârlogul Mariei" site_description: - label: "Descrie-ți comunitatea într-o singură frază scurtă" placeholder: "Un loc în care Maria și prietenii stau la taifas despre chestii mișto" introduction: title: "Introducere" diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index 4b29b68443..06d1d4417a 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -268,7 +268,6 @@ ru: topic_invite: failed_to_invite: "Пользователь не может быть приглашён в эту тему без членства в одной из следующих групп: %{group_names}." user_exists: "К сожалению, этот пользователь уже был приглашён. Вы можете пригласить пользователя в тему только один раз." - muted_invitee: "Извините, но этот пользователь отключил возможность получать от вас уведомления." muted_topic: "Извините, но этот пользователь отключил возможность получать уведомления для этой темы." receiver_does_not_allow_pm: "Извините, но этот пользователь не позволяет вам отправлять ему личные сообщения." sender_does_not_allow_pm: "Извините, но вы не позволяете этому пользователю отправлять вам личные сообщения." @@ -1592,7 +1591,7 @@ ru: notify_mods_when_user_silenced: "Отправлять сообщение всем модераторам, если пользователь автоматически заблокирован." flag_sockpuppets: "Если новый пользователь отвечает на тему с того же IP-адреса, что и пользователь, создавший тему, пометить обе его публикации как потенциальный спам." traditional_markdown_linebreaks: "Использовать стандартный способ переноса строки в Markdown: строка должна заканчиваться двумя пробелами." - enable_markdown_typographer: "Использовать правила типографики для улучшения читабельности текста: заменять прямые кавычки «фигурными кавычками»; (c), (tm) - соответствующими символами и т. д." + enable_markdown_typographer: "Использовать правила типографики для улучшения читаемости текста: заменять прямые кавычки «фигурными кавычками»; (c), (tm) - соответствующими символами и т. д." enable_markdown_linkify: "Автоматически отображать текст, который выглядит как ссылка, в виде ссылки: www.example.com и https://example.com будут автоматически отображены в виде ссылок" markdown_linkify_tlds: "Список доменов верхнего уровня, которые автоматически обрабатываются как ссылки" markdown_typographer_quotation_marks: "Список пар замены двойных и одинарных кавычек" @@ -1852,9 +1851,9 @@ ru: email_posts_context: "Количество предыдущих ответов, которое необходимо включать в почтовые уведомления в качестве контекста." flush_timings_secs: "Частота, с которой оправляются метки времени на сервер, в секундах" title_max_word_length: "Максимально допустимая длина слов в заголовке темы." - title_min_entropy: "Минимальная энтропия, требуемая для названия темы. Энтропия - количество уникальных символов, причём некоторые русские буквы могут считаться за 2 символа, а не за 1, как английские)." + title_min_entropy: "Минимальная энтропия, требуемая для названия темы. (Энтропия - количество уникальных символов, причём некоторые русские буквы могут считаться за 2 символа, а не за 1, как английские)." body_min_entropy: "Минимальная энтропия, требуемая для текста новой темы. Энтропия - количество уникальных символов, причём некоторые русские буквы могут считаться за 2 символа, а не за 1, как английские)." - allow_uppercase_posts: "Разрешать создавать названия тем или сообщения заглавными буквами." + allow_uppercase_posts: "Разрешать создавать названия тем или сообщений заглавными буквами." max_consecutive_replies: "Максимальное количество сообщений, которые пользователь может создать ПОДРЯД в теме, после чего у него не будет возможности добавить ещё один ответ." enable_filtered_replies_view: 'Кнопка ответов отображает только текущее сообщение и ответы на него, свернув все остальные сообщения.' title_fancy_entities: "В заголовках тем преобразовывать обычные символы ASCII и пунктуацию SmartyPants в объекты HTML" @@ -1896,7 +1895,7 @@ ru: topic_view_duration_hours: "Считать все просмотры темы как один просмотр, если просмотры происходят с одного IP-адреса в течение указанного здесь количества часов." user_profile_view_duration_hours: "Считать все просмотры профиля пользователя как один просмотр, если просмотры происходят с одного IP-адреса в течение указанного здесь количества часов." levenshtein_distance_spammer_emails: "Количество символов, на которое могут различаться сообщения, если проводится нечёткое сравнение текста при проверке писем на спам." - max_new_accounts_per_registration_ip: "Если обнаружено указанное здесь количество аккаунтов с уровнем доверия 0, использующих общий IP-адрес (и ни один аккаунт не принадлежит сотрудникам или пользователям с уровнем доверия 2 и выше), прекратить регистрацию новых аккаунтов с этого IP-адреса." + max_new_accounts_per_registration_ip: "Если обнаружено указанное здесь количество аккаунтов с уровнем доверия 0, использующих общий IP-адрес (и ни один аккаунт не принадлежит сотрудникам или пользователям с уровнем доверия 2 и выше), прекратить регистрацию новых аккаунтов с этого IP-адреса. Для отключения параметра установите значение в 0." min_ban_entries_for_roll_up: "Создавать новую запись запрета подсети, если в списке адресов есть указанное здесь количество записей." max_age_unmatched_emails: "Удалять отфильтрованные письма после указанного здесь количества дней." max_age_unmatched_ips: "Удалять отфильтрованные IP-адреса после указанного здесь количества дней." @@ -2033,6 +2032,7 @@ ru: global_notice: "Показывать глобальное постоянно отображаемое объявление со СРОЧНЫМИ / АВАРИЙНЫМИ сообщениями всем посетителям. Для скрытия объявления - удалите его содержание (разрешено использование HTML)." disable_system_edit_notifications: "Отключить уведомления системы, если включена настройка 'download_remote_images_to_local'." disable_category_edit_notifications: "Не уведомлять при перемещении тем в другой раздел." + disable_tags_edit_notifications: "Не уведомлять при изменении тегов темы." notification_consolidation_threshold: "Максимальное количество уведомлений о полученных симпатиях или запросах на вступление в группу, после которого уведомления будут объединяться в одно. Для отключения параметра установите это значение в 0." likes_notification_consolidation_window_mins: "Количество минут, по прошествии которых уведомления о полученных симпатиях будут объединяться в одно уведомление, если достигнуто пороговое значение, которое настраивается в параметре `site_setting.notification_consolidation_threshold`." automatically_unpin_topics: "Автоматически откреплять полностью прочтённые темы." @@ -4431,10 +4431,10 @@ ru: label: "Название сообщества" placeholder: "Женина тусовка" site_description: - label: "Опишите ваше сообщество одним коротким предложением" + label: "Опишите ваше сообщество в одном коротком предложении (используется в результатах поиска и социальных сетях)" placeholder: "Место, где Женя и её друзья обсуждают интересные новости" short_site_description: - label: "Опишите свое сообщество в нескольких словах" + label: "Опишите ваше сообщество несколькими словами (используется для заголовка домашней страницы)" placeholder: "Лучшее сообщество, когда-либо существовавшее в мире" introduction: title: "Введение" diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml index de8af27724..c22b8537fc 100644 --- a/config/locales/server.sk.yml +++ b/config/locales/server.sk.yml @@ -936,7 +936,6 @@ sk: topic_view_duration_hours: "Započítaj nové pozretie témy raz za IP/používateľa každých N hodín." user_profile_view_duration_hours: "Započítaj nové pozretie používateľského profilu raz za IP/používateľa každých N hodín." levenshtein_distance_spammer_emails: "Počet rozdielnych znakov, ktoré stále umožnia fuzzy zhodu, keď sa hľadájú emaily od spammerov." - max_new_accounts_per_registration_ip: "Neakceptovať ďalšie prihlásenia z IP adresy v prípade, ak už existuje (n) účtov s úrovňou dôvery 0 z danej IP adresy (a žiadny nie je zamestnancec alebo s úrovňou dôvery 2 a vyššou)." min_ban_entries_for_roll_up: "Ak existuje minimálne (N) položiek a kliknete na Zrolovať, vytvorí sa nový obmedzujúci záznam podsiete." max_age_unmatched_emails: "Zmazať nevyskytujúce sa kontrolované emailové adresy po (N) dňoch." max_age_unmatched_ips: "Zmazať nevyskytujúce sa kontrolované IP adresy po (N) dňoch." diff --git a/config/locales/server.sl.yml b/config/locales/server.sl.yml index 64f49c0429..dfa3528ae9 100644 --- a/config/locales/server.sl.yml +++ b/config/locales/server.sl.yml @@ -2019,10 +2019,8 @@ sl: label: "Ime skupnosti" placeholder: "Mikijev klub" site_description: - label: "Opiši vašo skupnost v enem kratkem stavku" placeholder: "Prostor kjer Miki debatira s svojimi prijatelji o zanimivih zadevah " short_site_description: - label: "Opišite vašo skupnost v nekaj besedah" placeholder: "Najboljši klub" privacy: title: "Dostopnost" diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml index ce81f88692..527a127e2b 100644 --- a/config/locales/server.sq.yml +++ b/config/locales/server.sq.yml @@ -747,7 +747,6 @@ sq: privacy_policy_url: "If you have a Privacy Policy document hosted elsewhere that you want to use, provide the full URL here." allowed_spam_host_domains: "A list of domains excluded from spam host testing. New users will never be restricted from creating posts with links to these domains." levenshtein_distance_spammer_emails: "When matching spammer emails, number of characters difference that will still allow a fuzzy match." - max_new_accounts_per_registration_ip: "If there are already (n) trust level 0 accounts from this IP (and none is a staff member or at TL2 or higher), stop accepting new signups from that IP." min_ban_entries_for_roll_up: "When clicking the Roll up button, will create a new subnet ban entry if there are at least (N) entries." max_age_unmatched_emails: "Delete unmatched screened email entries after (N) days." max_age_unmatched_ips: "Delete unmatched screened IP entries after (N) days." diff --git a/config/locales/server.sr.yml b/config/locales/server.sr.yml index 23276cda97..6ac81e9905 100644 --- a/config/locales/server.sr.yml +++ b/config/locales/server.sr.yml @@ -487,7 +487,6 @@ sr: label: "Ime vaše zajednice" placeholder: "Milenino mesto za bleju" site_description: - label: "Opišite vašu zajednicu u jednoj kratkoj rečenici" placeholder: "Mesto gde Milena i njene prijateljice ćaskaju o kul stvarima" introduction: title: "Uvod" diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index d1a420f734..3821f558c2 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -250,7 +250,6 @@ sv: topic_invite: failed_to_invite: "Användaren kan inte bjudas in till detta ämne utan gruppmedlemskap i någon av de följande grupperna: %{group_names}." user_exists: "Tyvärr har den användaren redan bjudits in. Du kan endast bjuda in en användare till ett ämne en gång." - muted_invitee: "Tyvärr tystade den användaren dig." muted_topic: "Tyvärr tystade den användaren detta ämne." receiver_does_not_allow_pm: "Tyvärr tillåter inte den användaren att du skickar privata meddelanden till dem." sender_does_not_allow_pm: "Tyvärr tillåter du inte den användaren att skicka privata meddelanden till dig." @@ -1439,7 +1438,7 @@ sv: summary_percent_filter: "Visa högsta % av inläggen när en användare bockar i 'Sammanfatta det här ämnet'" summary_max_results: "Maximalt antal inlägg som returneras av 'Sammanfatta detta ämne'" summary_timeline_button: "Visa en \"Sammanfatta\"-knapp på tidslinjen" - enable_personal_messages: "Tillåt användare med förtroendenivå 1 (konfigurera via minsta förtroendenivå för att skicka meddelanden) att skapa meddelanden och svara på meddelanden. Notera att personalen alltid kan skicka meddelanden oavsett." + enable_personal_messages: "Tillåt användare med förtroendenivå 1 (konfigurera genom minsta förtroendenivå för att skicka meddelanden) att skapa meddelanden och svara på meddelanden. Notera att personalen alltid kan skicka meddelanden oavsett." enable_system_message_replies: "Tillåter användare att svara på systemmeddelanden, även om personliga meddelanden har inaktiverats" enable_long_polling: "Meddelande-buss som används för avisering kan använda long polling" enable_chunked_encoding: "Aktivera packade kodningssvar (chunked) från servern. Den här funktionen fungerar på de flesta inställningar, men vissa proxyservrar kan buffra, vilket gör att svaren fördröjs" @@ -1770,7 +1769,7 @@ sv: topic_view_duration_hours: "Räkna en ny ämnesgranskning en gång per IP/Användare per N timmar" user_profile_view_duration_hours: "Räkna en ny användarprofilgranskning en gång per IP/Användare per N timmar" levenshtein_distance_spammer_emails: "Skillnad i antal tecken vid matchning av e-postadresser som används för skräppostutskick som fortfarande tillåter en suddig matchning." - max_new_accounts_per_registration_ip: "Sluta acceptera nya registreringar från en IP-adress om det redan finns (n) användare med förtroendenivå 0 från den här IP-adressen (och ingen av dem är en medlem i personalen eller en användare med förtroendenivå 2 eller högre)." + max_new_accounts_per_registration_ip: "Sluta acceptera nya registreringar från en IP-adress om det redan finns (n) användare med förtroendenivå 0 från den här IP-adressen (och ingen av dem är en medlem i personalen eller en användare med förtroendenivå 2 eller högre). Ange värde 0 för att inaktivera begränsningen." min_ban_entries_for_roll_up: "När upprullningsknappen klickas skapas en ny delnätspost om det finns minst (N) poster." max_age_unmatched_emails: "Ta bort omatchade undersökta e-postinlägg efter (N) dagar." max_age_unmatched_ips: "Ta bort omatchade undersökta IP-poster efter (N) dagar." @@ -1907,6 +1906,7 @@ sv: global_notice: "Visa ett BRÅDSKANDE, icke-stängningsbart globalt banderollmeddelande för alla besökare, ändra till blankt för att dölja det (HTML tillåts)." disable_system_edit_notifications: "Inaktivera redigering av aviseringar av systemanvändaren när 'download_remote_images_to_local' har aktiverats." disable_category_edit_notifications: "Inaktivera kategoriredigeringsaviseringar för ämnen." + disable_tags_edit_notifications: "Inaktivera notifieringar om att taggar har redigerats för ämnen." notification_consolidation_threshold: "Antal meddelanden om gillad eller medlemskapsbegäran innan meddelandena konsolideras till en enda. Ställ in till 0 för att inaktivera." likes_notification_consolidation_window_mins: "Varaktighet i minuter efter vilken gillad-aviseringar konsolideras till en enda avisering då tröskeln har uppnåtts. Tröskeln kan konfigureras via `SiteSetting.notification_consolidation_threshold`." automatically_unpin_topics: "Ta automatiskt ned ämnen när användaren når botten." @@ -2467,7 +2467,7 @@ sv: new_version_mailer: title: "E-post om ny version" subject_template: "[%{email_prefix}] Ny version av Discourse, uppdatering tillgänglig" - text_body_template: "Hurra, det finns en ny version av [Discourse](http://www.discourse.org) tillgänglig!\n\nDin version: %{installed_version}\nNy version: **%{new_version}**\n\n- Uppgradera genom att använda den enkla **[uppgraderingsknappen](%{base_url}/admin/upgrade)** \n\n- Se vad som är nytt i [uppdateringsnoteringarna](https://meta.discourse.org/tag/release-notes) eller läs i [GitHubs råa ändringslogg](https://github.com/discourse/discourse/commits/main)\n\n- Besök [meta.discourse.org](http://meta.discourse.org) för att läsa de senaste nyheterna, diskutera eller få hjälp angående Discourse\n" + text_body_template: "Hurra, det finns en ny version av [Discourse](http://www.discourse.org) tillgänglig!\n\nDin version: %{installed_version}\nNy version: **%{new_version}**\n\n- Uppgradera genom att använda den enkla **[uppgraderingsknappen](%{base_url}/admin/upgrade)** \n\n- Se vad som är nytt i [uppdateringsnoteringarna](https://meta.discourse.org/tag/release-notes) eller läs i [GitHubs obearbetade ändringslogg](https://github.com/discourse/discourse/commits/main)\n\n- Besök [meta.discourse.org](http://meta.discourse.org) för att läsa de senaste nyheterna, diskutera eller få hjälp angående Discourse\n" new_version_mailer_with_notes: title: "E-post om ny version med anteckningar" subject_template: "[%{email_prefix}] Uppdatering tillgänglig" @@ -2479,7 +2479,7 @@ sv: - Uppgradera genom att använda den enkla **[uppgraderingsknappen](%{base_url}/admin/upgrade)** - - Se vad som är nytt i [uppdateringsnoteringarna](https://meta.discourse.org/tag/release-notes) eller läs i [GitHubs råa ändringslogg](https://github.com/discourse/discourse/commits/main) + - Se vad som är nytt i [uppdateringsnoteringarna](https://meta.discourse.org/tag/release-notes) eller läs i [GitHubs obearbetade ändringslogg](https://github.com/discourse/discourse/commits/main) - Besök [meta.discourse.org](http://meta.discourse.org) för att läsa de senaste nyheterna, diskutera eller få hjälp angående Discourse @@ -3991,10 +3991,10 @@ sv: label: "Ditt forums namn" placeholder: "Här hänger Jane!" site_description: - label: "Beskriv ditt forum med en kort mening" + label: "Beskriv din gemenskap i en kort mening (används i sökresultat och sociala medier)" placeholder: "Ett ställe där Jane och hennes vänner kan diskutera coola grejor" short_site_description: - label: "Beskriv ditt forum med ett par ord" + label: "Beskriv din gemenskap med några få ord (används för hemsidans titel)" placeholder: "Bästa forumet någonsin" introduction: title: "Introduktion" diff --git a/config/locales/server.sw.yml b/config/locales/server.sw.yml index e356e2d8a3..bd24784c51 100644 --- a/config/locales/server.sw.yml +++ b/config/locales/server.sw.yml @@ -789,7 +789,6 @@ sw: allowed_inline_onebox_domains: "Orodha za anwani za mtandao ambazo zitawekwa kwenye boxi kama zikiunganishwa bila kichwa cha habari" summary_score_threshold: "Alama ya chini ambayo mada inahitaji kabla ya kuwekwa ndani ya 'Fupisha Hii Mada' " summary_percent_filter: "Mtumiaji akibonyeza 'Fupisha Hii Mada', onyesha % machapisho ya juu" - enable_personal_messages: "Ruhusu watumiaji wenye kiwango cha 1 cha uaminifu (inasanidiwa kwa kupitia kiwango cha chini cha uaminifu kutuma ujumbe) kutuma ujumbe wa barua pepe. Wasaidizi wataweza kutuma ujumbe mda kila wakati." enable_system_message_replies: "Waruhusu watumiaji wajibu ujumbe wa mfumo,hata kama ujumbe binafsi umezuiliwa." notify_mods_when_user_silenced: "Kama mtumiaji akinyamazishwa, tuma ujumbe kwa wasimamizi wote." markdown_linkify_tlds: "Orodha ya vikoa vya hali ya juu ambavyo otomatikali ni viungo." @@ -889,7 +888,6 @@ sw: allowed_spam_host_domains: "Orodha ya vikoa vilivyotengwa kutoka kwenye majaribio ya komputa mwenyeji ya barua taka. Watumiaji wapya hawatazuiliwa kutengeneza machapisho yenye viungo kwenda kwenye vikoa hivi." topic_view_duration_hours: "Hesabu utembezi wa mada mpya mara moja kuendana na anwani ya mtandao/Mtumiaji kila baada ya masaa N" user_profile_view_duration_hours: "Hesabu utembezi wa mada mpya mara moja kuendana na anwani ya mtandao/Mtumiaji kila baada ya masaa N" - max_new_accounts_per_registration_ip: "Kama kuna akaunti 0 zenye kiwango (n) cha uaminifu kutoka kwenye anwani hii ya mtandao (na hakuna hata mmoja ambaye ni msaidizi or kwenye kiwango cha 2 cha uaminifu au zaidi), kataa usajili kutoka kwenye anwani hiyo." num_hours_to_close_topic: "Mda wa masaa kusitisha mada ili kuingilia." auto_silence_fast_typers_max_trust_level: "Kiwango cha juu cha uaminifu kunyamazisha wanaochapa haraka sana otomatikali" reply_by_email_enabled: "Ruhusu majibu ya mada kupitia barua pepe." @@ -2027,7 +2025,6 @@ sw: label: "Jina la Jumuia" placeholder: "Sehemu ya Jeni ya Kuburudika" site_description: - label: "Elezea jamii yako ndani ya sentensi moja" placeholder: "Sehemu ya Jeni na marafiki zake kujadili vitu vizuri" introduction: title: "Utambulisho" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 534e63f25c..24753ec717 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -250,7 +250,6 @@ tr_TR: topic_invite: failed_to_invite: "Kullanıcı, aşağıdaki gruplardan birinde grup üyeliği olmadan bu konuya davet edilemez: %{group_names}." user_exists: "Üzgünüz, bu kullanıcı zaten davet edildi. Konuya yalnızca bir kullanıcı davet edebilirsiniz." - muted_invitee: "Üzgünüz, o kullanıcı sizi sessize aldı." muted_topic: "Üzgünüz, o kullanıcı bu konuyu sessize aldı." receiver_does_not_allow_pm: "Üzgünüz, bu kullanıcı onlara özel mesaj göndermenize izin vermiyor." sender_does_not_allow_pm: "Üzgünüz, o kullanıcının size özel mesaj göndermesine izin vermiyorsunuz." @@ -266,6 +265,7 @@ tr_TR: not_found: "İstenilen URL ya da kaynak bulunamadı." invalid_access: "İstenilen kaynağı görüntüleyebilmeniz için izniniz yok." authenticator_not_found: "Kimlik doğrulama yöntemi mevcut değil veya devre dışı bırakıldı." + authenticator_no_connect: "Bu kimlik doğrulama sağlayıcısı varolan bir forum hesabına bağlanmaya izin vermez." invalid_api_credentials: "İstenilen kaynağı görüntülemenize izin verilmiyor. API kullanıcı adı veya parolası geçersiz." provider_not_enabled: "İstenilen kaynağı görüntülemenize izin verilmiyor. Kimlik doğrulama kontrolü etkin değil." provider_not_found: "İstenilen kaynağı görüntülemenize izin verilmiyor. Kimlik doğrulama sağlayıcısı mevcut değil." @@ -367,7 +367,7 @@ tr_TR: read_full_topic: "Konunun tamamını okuyun" private_message_abbrev: "İlt" rss_description: - latest: "En son konular" + latest: "En Son Konular" top: "En iyi konular" top_all: "Tüm zamanlarda en iyi konular" top_yearly: "Yıllık en iyi konular" @@ -477,6 +477,7 @@ tr_TR: Herkesin benzersiz bir profil resmi olduğunda, tartışmaları takip etmek ve konuşmalarda ilginç insanlar bulmak daha kolay! sequential_replies: "### Aynı anda birkaç gönderiye yanıt vermeyi denediniz \nBunun yerine yerine, lütfen önceki gönderilerden alıntılar veya @name referansları içeren tek bir yanıt veriniz.\nMetni vurgulayıp görünen alıntı düğmesini seçerek önceki yanıtınızı düzenleyebilirsiniz. Böylece diğer kullanıcıların, yanıtlarınızı okuması daha kolay olacaktır.\n" dominating_topic: Birden fazla gönderdiniz %{percent}Burada cevapların% else orada herkesin duymak isteriz edilir? + get_a_room: '@%{reply_username} %{count} kez yanıt verdiniz, bunun yerine ona kişisel bir mesaj gönderebileceğinizi biliyor muydunuz?' too_many_replies: | ### Bu konu için cevap limitinizi doldurdunuz @@ -654,6 +655,9 @@ tr_TR: has_likes: one: "%{count} Beğeni" other: "%{count} Beğeni" + cannot_permanently_delete: + many_posts: "Başka gönderiler olduğu için bu konuyu kalıcı olarak silemezsiniz." + wait_or_different_admin: "Bu gönderiyi kalıcı olarak silmeden önce %{time_left} beklemelisiniz veya farklı bir yönetici bunu yapmalıdır." rate_limiter: slow_down: "Bu işlemi çok fazla yaptınız, lütfen daha sonra tekrar deneyin." too_many_requests: "Bu işlemi çok defa yaptınız. Lütfen tekrar denemeden önce %{time_left} bekleyin." @@ -1330,8 +1334,12 @@ tr_TR: queue_size_warning: "Kuyruğa eklenmiş işlerin sayısı fazla: %{queue_size}. Bu Sidekiq işlem(ler)indeki bir sorunu işaret ediyor olabilir, ya da daha fazla Sidekiq işçisi eklemeniz gerekiyor olabilir." memory_warning: "Sunucunuz toplam 1GB'tan az bellek ile çalışıyor. En az 1GB bellek tavsiye edilmektedir." google_oauth2_config_warning: 'Sunucu, Google OAuth2 (enable_google_oauth2_logins) ile kaydolmaya ve oturum açmaya izin verecek şekilde yapılandırılmıştır, ancak istemci kimliği ve istemci gizli değerleri ayarlanmamıştır. Site Ayarları ve ayarları güncelleyin. Daha fazlasını öğrenmek için bu kılavuza bakın.' + facebook_config_warning: 'Sunucu Facebook (enable_facebook_logins) ile üyelik oluşturulmasına ve giriş yapılmasına izin veriyor, fakat uygulama ID''si ve gizli uygulama değerleri henüz ayarlanmamış. Site Ayarlarına gidin ve ayarları güncelleyin. Daha fazla bilgi için bu kılavuza bakın.' + twitter_config_warning: 'Sunucu Twitter (enable_twitter_logins) ile üyelik oluşturulması ve giriş yapılmasına izin veriyor, fakat anahtar ve gizli değerler henüz ayarlanmamış. Site Ayarlarınagidin ve ayarları güncelleyin. Daha fazla bilgi için bu kılavuza bakın.' + github_config_warning: 'Sunucu GitHub (enable_github_logins) ile üyelik oluşturulması ve giriş yapılmasına izin veriyor, fakat iclient ID ve client secret değerleri henüz ayarlanmamış. Site Ayarlarına gidin ve ayarları güncelleyin. Daha fazla bilgi için bu kılavuza bakın.' s3_config_warning: 'Sunucu s3''e dosya yüklenebilmesi için yapılandırılmış, fakat şunlardan en az biri henüz ayarlanmamış: s3_access_key_id, s3_secret_access_key or s3_upload_bucket. Site Ayarlarına gidin ve ayarları güncelleyin. Daha fazla bilgi için "S3''e resim yüklemeleri nasıl ayarlanır?" konulu gönderiye bakın.' s3_backup_config_warning: 'Sunucu s3''e yedeklerin yüklenebilmesi için yapılandırılmış, fakat şunlardan en az biri henüz ayarlanmamış: s3_access_key_id, s3_secret_access_key or s3_upload_bucket. Site Ayarlarına gidin ve ayarları güncelleyin. Daha fazla bilgi için "S3''e resim yüklemeleri nasıl ayarlanır?" konulu gönderiye bakın.' + s3_cdn_warning: 'Sunucu, dosyaları S3''e yükleyecek şekilde yapılandırılmış ancak yapılandırılmış bir S3 CDN''si yok. Bu, pahalı S3 maliyetlerine ve daha yavaş site performansına yol açabilir. Daha fazla bilgi için bkz. "Yüklemeler için Nesne Depolamayı Kullanma".' image_magick_warning: 'Sunucu büyük resimlerin küçük boylarının oluşturulması için yapılandırılmış, fakat ImageMagick henüz kurulmamış. Favori paket yöneticinizi kullanarak ImageMagick kurun veya son sürümünü indirin.' failing_emails_warning: 'Başarısızlıkla sonuçlanmış %{num_failed_jobs} e-posta işlemi bulunuyor. app.yml dosyanızı kontrol edin ve e-posta sunucu ayarlarınızın doğru olduğundan emin olun. Sidekiq''deki başarısız işlemlere göz atın.' subfolder_ends_in_slash: "Alt dizin kurulumunuz hatalı, DISCOURSE_RELATIVE_URL_ROOT sonunda yan çizgi bulunmalı." @@ -1345,6 +1353,7 @@ tr_TR: force_https_warning: "Web siteniz SSL kullanıyor. Ancak site ayarlarınızda ` force_https` henüz etkin değil." out_of_date_themes: "Aşağıdaki temalar için güncellemeler mevcuttur:" unreachable_themes: "Aşağıdaki temalar için güncellemeleri kontrol edemedik:" + watched_word_regexp_error: "İzlenen %{action} kelimeler için normal ifadeye geçersiz, Lütfen İzlenen Kelime ayarlarınızı kontrol edin veya 'izlenen kelimeler normal ifadeler' site ayarını devre dışı bırakın." site_settings: disabled: "devredışı" display_local_time_in_user_card: "Kullanıcı kartı açıkken yerel saati kullanıcının saat dilimine göre görüntüle." @@ -1352,6 +1361,7 @@ tr_TR: delete_old_hidden_posts: "30 günden fazla süreyle gizli kalan gizlenmiş gönderileri otomatik olarak sil." default_locale: "Bu Söylem örneğinin varsayılan dili. Özelleştir / Metinile sistem tarafından oluşturulan kategorilerin ve konuların metnini değiştirebilirsiniz." allow_user_locale: "Kullanıcıların arayüz için kendi istedikleri dili seçmesine izin ver" + set_locale_from_accept_language_header: "anonim kullanıcılar için web tarayıcılarının dil başlıklarından arayüz dilini ayarlayın" support_mixed_text_direction: "Soldan sağa ve sağdan sola karışık metin yönlerini destekleyin." min_post_length: "Gönderide olması gereken en az karakter sayısı" min_first_post_length: "İlk gönderi için (konu içi) izin verilen en az karakter sayısı" @@ -1359,6 +1369,7 @@ tr_TR: max_post_length: "Gönderide izin verilen en fazla karakter sayısı" topic_featured_link_enabled: "Konuları olan bir bağlantı yayınlamayı etkinleştir." show_topic_featured_link_in_digest: "Özetlenmiş e-postada konu özellikli bağlantıyı gösterin." + min_topic_views_for_delete_confirm: "Bir konunun silindiğinde bir onay açılır penceresinin görünmesi için sahip olması gereken minimum görüntüleme sayısı" min_topic_title_length: "Konuda olması gereken en az karakter sayısı" max_topic_title_length: "Konu başlığında izin verilen en fazla karakter sayısı" min_personal_message_title_length: "İleti başlıkları için izin verilen en az karakter sayısı" @@ -1375,11 +1386,14 @@ tr_TR: category_search_priority_high_weight: "Yüksek kategori öncelikli arama için sıralamada uygulanan ağırlık." allow_uncategorized_topics: "Konuların kategori seçmeden oluşturulmasına izin ver. DİKKAT: Bu özelliği kapamadan önce kategorisiz tüm konuları kategorize etmeniz lazım." allow_duplicate_topic_titles: "Aynı başlık ile birden çok konu açılmasına izin ver." + allow_duplicate_topic_titles_category: "Farklı kategorilerde aynı başlıkla konu açılmasına izin ver. allow_duplicate_topic_titles devre dışı bırakılmalıdır." unique_posts_mins: "Kullanıcının aynı içerikle yeni bir gönderi oluşturmadan önce geçmesi gereken dakika" educate_until_posts: "Kullanıcılar, ilk (n) gönderilerini yazmaya başladıklarında, pop-up yeni kullanıcı eğitim paneli metin düzenleyecisinin üstünde çıksın." title: "Sayfa başlığı etiketinde kullanılacak, bu sitenin ismi." site_description: "Meta açıklama etiketinde kullanılacak, bu sitenin bir cümlelik açıklaması." short_site_description: "Ana sayfadaki başlık etiketinde kullanılan kısa açıklama." + contact_email: "Bu site için yöneticini e-posta adresi. /about iletişim formu içerisinde, sadece acil durumlar ve kritik bildirimler için kullanılacak." + contact_url: "Bu site için iletişim URL'sidir. Acil konular için / about iletişim formunda kullanılır." crawl_images: "Doğru genişlik ve yükseklik boyutlarını girmek için uzak URL'lerdeki resimlerin birer kopyasını alınsın." download_remote_images_to_local: "Uzaktaki resimler yerel resimlere çevirmek için indirilsin; bu ayar resim bağlantılarının kırılmasını önleyecektir" download_remote_images_threshold: "Uzaktaki resimlerin yerele indirilmesi için gereken en az disk alanı (yüzdesel)" @@ -1406,6 +1420,7 @@ tr_TR: post_onebox_maxlength: "Kutulanmış bir Discourse gönderisinin en fazla karakter uzunluğu" blocked_onebox_domains: "Asla yayınlanmayacak alanların listesi." allowed_inline_onebox_domains: "Başlıksız bağlanırsa minyatür formda yayınlanacak alanların listesi" + enable_inline_onebox_on_all_domains: "İnline_onebox_domain_whitelist site ayarını yok say ve tüm alan adlarında inline onebox'a izin ver." force_custom_user_agent_hosts: "Tüm isteklerde özel onebox kullanıcı aracısını kullanacak ana makineler. (Özellikle kullanıcı aracısıyla erişimi sınırlayan ana bilgisayarlar için kullanışlıdır)." max_oneboxes_per_post: "Gönderide olabilecek en fazla kutulama sayısı" facebook_app_access_token: "Facebook uygulama kimliğinizden ve gizli dizenizden bir token oluşturuldu. Instagram onebox'larını oluşturmak için kullanılır." @@ -1418,6 +1433,7 @@ tr_TR: mobile_logo_dark: "'Mobil logo' ayarı için koyu şema alternatifi." large_icon: "Diğer meta veri simgeleri için temel olarak kullanılan resim. İdeal olarak 512 x 512 den büyük olmalıdır. Boş bırakılırsa logo_small kullanılacaktır." manifest_icon: "Android de logo/splash görüntüsü olarak kullanılan resim. Otomatik şekilde 512 × 512 olarak yeniden boyutlandırılır. Boş bırakılırsa, large_icon kullanılır." + manifest_screenshots: "Uygulama yükleme (PWA) sayfasında örnek özelliklerinizi ve işlevselliğinizi gösteren ekran görüntüleri. Tüm resimler yerel yüklemeler olmalı ve aynı boyutlarda olmalıdır." favicon: "Siteniz için bir simge, bkz. Https://en.wikipedia.org/wiki/Favicon . Bir CDN üzerinde düzgün çalışmak için bir png olması gerekir. 32x32 olarak yeniden boyutlandırılacak. Boş bırakılırsa, large_icon kullanılır." apple_touch_icon: "Apple dokunmatik cihazlar için kullanılan simge. Otomatik olarak 180x180 boyutuna getirilecek. Boş bırakılırsa, large_icon kullanılır." opengraph_image: "Sayfada başka uygun görüntü yoksa varsayılan açık grafik görüntüsü. Boş bırakılırsa, large_icon kullanılır" @@ -1426,14 +1442,19 @@ tr_TR: email_custom_headers: "Sınırlandırılmış özel e-posta başlıkları listesi" email_subject: "Standart e-postalar için özelleştirilebilir konu biçimi. Bkz. https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" detailed_404: "Kullanıcılara belirli bir konuya neden erişemedikleri hakkında daha fazla ayrıntı sağlar. Not: Bu daha az güvenlidir çünkü kullanıcılar bir URL'nin geçerli bir konuya bağlantı verip vermediğini bilecektir." + enforce_second_factor: "Kullanıcıları ikinci faktör kimlik doğrulamasını etkinleştirmeye zorlar. Tüm kullanıcılara uygulamak için \"tümü\"; seçeneğini seçin. Yalnızca yetkili kullanıcılara uygulamak için \"yetkili\" seçin." force_https: "Sitenizi HTTPS kullanmaya zorlayın. DİKKAT: bu seçeneği HTTPS'nin her yerde doğru bir şekilde çalıştığından emin olmadan SEÇMEYİN. Sitenizde bulunan paylaşım siteleri bağlantılarını, CDN' adresinizi, dışsal bağlantısı olan görsellerinizi de kontrol ettiniz mi?" + same_site_cookies: "Aynı site çerezlerini kullanın, desteklenen tüm tarayıcılardaki Siteler Arası İstek Sahteciliği'ni (Lax veya Strict) ortadan kaldırırlar. Uyarı: Katı kurallı oturum, yalnızca giriş yapmaya ve TOA kullanmaya zorlayan sitelerde çalışır." summary_score_threshold: "Bir gönderinin 'Bu Konuyu Özetle' içinde yer alması için gereken en az skor." + summary_posts_required: "'Bu Konuyu Özetle' etkinleştirilmeden önce bir konudaki minimum gönderi sayısı. Bu ayarda yapılan değişiklikler bir hafta içinde geriye dönük olarak uygulanacaktır." + summary_likes_required: "'Bu Konuyu Özetle' etkinleştirilmeden önce bir konudaki minimum gönderi sayısı. Bu ayarda yapılan değişiklikler bir hafta içinde geriye dönük olarak uygulanacaktır." summary_percent_filter: "Kullanıcı 'Bu Konuyu Özetle'ye tıkladığında, gönderinin ilk % kısmını göster" summary_max_results: "'Bu Konuyu Özetle' den dönen en fazla gönderi sayısı" summary_timeline_button: "Zaman çizelgesinde 'Özetle' düğmesi göster" - enable_personal_messages: "Güven seviyesi 1'e(özel ileti göndermek için en az seviye ayarıyla belirlenebilir) sahip kullanıcıların ileti oluşturup cevaplamasına izin ver. Görevliler her durumda ileti gönderebilir." + enable_personal_messages: "Güven düzeyi 1 (mesaj göndermek için minimum güven seviyesi ile yapılandırılabilir) kullanıcıların mesaj oluşturmasına ve mesajları yanıtlamasına izin verin. Personelin ne olursa olsun her zaman mesaj gönderebileceğini unutmayın." enable_system_message_replies: "Kişisel mesajlar devre dışı bırakılsa bile kullanıcıların sistem mesajlarını yanıtlamasına izin verir" enable_long_polling: "Bildiri için kullanılan ileti yolu uzun sorgular yapabilir" + enable_chunked_encoding: "Sunucu tarafından yığınlanmış kodlama yanıtlarını etkinleştirin. Bu özellik çoğu kurulumda çalışır, ancak bazı proxy'ler arabelleğe alabilir ve yanıtların gecikmesine neden olabilir" long_polling_base_url: "Uzun sorgular için kullanılan baz URL (CDN dinamik içerik sunuyorsa, bunu origin olarak ayarladığına emin ol) ör: http://origin.site.com" long_polling_interval: "Gönderilecek bilgi olmadığı zaman sunucunun kullanıcılara geri dönmeden önce beklemesi gereken zaman (sadece giriş yapmış kullanıcın için)" polling_interval: "Uzun sorgular yapılmadığı zaman, kaç mili saniyede bir giriş yapmış kullanıcılar poll yapmalı" @@ -1727,7 +1748,6 @@ tr_TR: topic_view_duration_hours: "Her N saatte IP/Kullanıcı başına bir kez yeni konu görüntülemesi say" user_profile_view_duration_hours: "Her N saatte IP/Kullanıcı başına bir kez yeni profil görüntülemesi say" levenshtein_distance_spammer_emails: "İstenmeyen e-postaları eşleştirilirken, bulanık eşleşme için tahammül edilecek karakter sayısı farklılığı." - max_new_accounts_per_registration_ip: "Eğer bu IP'den güven seviyesi 0 olan halihazırda (n) hesap varsa (hiçbiri görevli, GS2 ya da daha yüksek seviyede biri değilse), bu IP'den yeni kayıt kabul etme." min_ban_entries_for_roll_up: "Topla düğmesine tıklandığında, (N) adetten fazla giriş varsa yeni bir subnet engelleme girişi yaratılacak." max_age_unmatched_emails: "Taranmış e-posta kayıtlarından karşılığı olmayanları (N) gün sonunda sil." max_age_unmatched_ips: "Taranmış IP girişlerinden karşılığı olmayanları (N) gün sonunda sil." @@ -2835,7 +2855,7 @@ tr_TR: Tedbir olarak hesabınız sessize alındı. Bir görevli hesabınızı kontrol edene kadar konulara cevap yazamayacak veya yeni konu açamayacaksınız. Verdiğimiz rahatsızlıktan dolayı özür dileriz. - Ek bilgi için, başvurunuz [topluluk yönergelerine](%{base_url}/guidelines). + Ek bilgi için [topluluk yönergelerine](%{base_url}/guidelines) bakınız. too_many_tl3_flags: title: "Çok Fazla GS3 Raporu" subject_template: "Yeni hesap askıda" @@ -2846,7 +2866,7 @@ tr_TR: Tedbir olarak hesabınız sessize alındı. Bir görevli hesabınızı kontrol edene kadar konulara cevap yazamayacak veya yeni konu açamayacaksınız. Verdiğimiz rahatsızlıktan dolayı özür dileriz. - Ek bilgi için, başvurunuz [topluluk yönergelerine](%{base_url}/guidelines). + Ek bilgi için [topluluk yönergelerine](%{base_url}/guidelines) bakınız. silenced_by_staff: title: "Görevli tarafından sessize alındı." subject_template: "Hesap geçiçi olarak askıda" @@ -2857,7 +2877,7 @@ tr_TR: Lütfen gezmeye devam edin fakat bir [görevli](%{base_url}/about) son gönderilerinizi kontrol edene kadar konulara cevap yazamayacak veya konu açamayacaksınız. Verdiğimiz rahatsızlıktan dolayı özür dileriz. - Ek bilgi için, başvurunuz [topluluk yönergelerine](%{base_url}/guidelines). + Ek bilgi için [topluluk yönergelerine](%{base_url}/guidelines) bakınız. user_automatically_silenced: title: "Kullanıcı otomatik olarak sessize alındı" subject_template: "Topluluk raporlarından dolayı yeni kullanıcı %{username} sessize alındı" @@ -3793,10 +3813,8 @@ tr_TR: label: "Topluluğunuzun ismi" placeholder: "Ali'nin Yeri" site_description: - label: "Topluluğunuzu kısa bir cümle ile tanımlayın" placeholder: "Ali ve arkadaşlarının ilginç şeyleri tartışabilecekleri bir yer" short_site_description: - label: "Topluluğunuzu birkaç kelimeyle açıklayın" placeholder: "Şimdiye kadarki en iyi topluluk" introduction: title: "Tanıtım" diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index 96ea10014c..11e2620856 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -268,7 +268,6 @@ uk: topic_invite: failed_to_invite: "Користувача не можна запросити в цю тему без членства в одній із наступних груп: %{group_names}." user_exists: "На жаль, цього користувача вже запрошено. Ви можете запросити користувача до теми лише один раз." - muted_invitee: "На жаль, цей користувач вас ігнорує." muted_topic: "На жаль, цей користувач ігнорує цю тему." receiver_does_not_allow_pm: "На жаль, цей користувач не дозволяє вам надсилати йому приватні повідомлення." sender_does_not_allow_pm: "На жаль, ви не дозволяєте цьому користувачеві надсилати вам приватні повідомлення." @@ -536,6 +535,7 @@ uk: Для більшості людей набагато простіше читати теми, у яких довгі відповіді і їх мало, ніж коли багато коротеньких відповідей. dominating_topic: Ви розмістили тут більше %{percent}% відповідей, можливо є ще хтось, кого варто було би почути? + get_a_room: Ви відповіли користувачу @%{reply_username} %{count} разів, ви знали, що можете надіслати їм особисте повідомлення? too_many_replies: "### Ви досягли межі відповідей в цю тему. \nНа жаль, нові користувачі тимчасово обмежені %{newuser_max_replies_per_topic} відповідей в цій темі. \nЗамість того, щоб додавати ще одну відповідь, будь ласка, подумайте про редагування попередніх відповідей або відвідайте інші теми.\n" reviving_old_topic: | ### Відновити обговорення в цій темі? @@ -712,12 +712,27 @@ uk: few: "%{count} Симпатії" many: "%{count} Симпатій" other: "%{count} Симпатій" + cannot_permanently_delete: + many_posts: "Ви не можете остаточно видалити цю тему, тому що вона має інші повідомлення." + wait_or_different_admin: "Ви повинні зачекати %{time_left} , перш ніж остаточно видалити цей пост, або інший адміністратор може зробити це." rate_limiter: slow_down: "Ви виконали цю дію занадто багато разів, спробуйте ще раз пізніше." too_many_requests: "Ви виконуєте цю дію занадто часто. Зачекайте будь-ласка %{time_left} перш ніж спробувати знову." by_type: + first_day_replies_per_day: "Ми цінуємо ваш ентузіазм, так тримати! Зважаючи на безпеку нашої спільноти, ви досягли максимальної кількості відповідей, які за перший день може створювати новий користувач. Зачекайте, будь ласка, %{time_left} , і ви зможете створювати більше відповідей." + first_day_topics_per_day: "Ми цінуємо ваш ентузіазм! Тим не менш, для безпеки нашої спільноти ви досягли максимальної кількості тем, які новий користувач може створити за перший день. Будь ласка, зачекайте %{time_left} , і ви зможете створювати нові теми." + create_topic: "Ви створюєте теми занадто швидко. Будь ласка зачекайте %{time_left}, потім спробуйте ще раз" + create_post: "Ви відповідаєте занадто швидко. Будь ласка, зачекайте %{time_left} перед наступною спробою." + delete_post: "Ви видаляєте повідомлення занадто швидко. Будь ласка зачекайте %{time_left} перед наступною спробою." + public_group_membership: "Ви приєднуєтеся / залишаєте групи занадто часто. Будь ласка зачекайте %{time_left} перед наступною спробою." + topics_per_day: "Ви досягли максимальної кількості нових тем, дозволених за день. Ви можете створити нові теми через %{time_left}." + pms_per_day: "Ви досягли максимальної кількості нових тем, дозволених за день. Ви можете створити нові теми через %{time_left}." + create_like: "Ого! Ви ділилися великою любов'ю! Ви досягли максимуму лайків за сьогодні, але, коли ви отримаєте вищий рівень довіри, то зможете додавати більше лайків. Ви зможете знову вподобати допис через %{time_left}." + create_bookmark: "Ви досягли максимальної кількості щоденних закладок. Ви можете створити більше закладок через %{time_left}." + edit_post: "Ви досягли максимальної кількості щоденних правок. Ви зможете знову вносити зміни через %{time_left}." live_post_counts: "Ви занадто швидко переглядаєте пости. Будь ласка зачекайте %{time_left} перед наступною спробою." unsubscribe_via_email: "Ви досягли максимальної кількості відписок через email на сьогодні. Будь ласка зачекайте %{time_left} перед наступною спробою." + topic_invitations_per_day: "Ви досягли максимальної кількості запрошень в тему за день. Ви знову зможете надсилати запрошення через %{time_left}." hours: one: "%{count} година" few: "%{count} години" @@ -978,6 +993,18 @@ uk: others: "Закладок немає." no_drafts: self: "У вас немає чернеток; почніть складати відповідь в будь-якій темі, і вона буде автоматично збережена як нова чернетка." + email_settings: + pop3_authentication_error: "Виникла проблема з обліковими даними POP3, перевірте ім'я користувача та пароль і повторіть спробу." + imap_authentication_error: "Виникла проблема з обліковими даними IMAP, перевірте ім'я користувача та пароль і повторіть спробу." + imap_no_response_error: "Сталася помилка під час з’єднання з сервером IMAP. %{message}" + smtp_authentication_error: "Виникла проблема з обліковими даними SMTP, перевірте ім'я користувача та пароль і повторіть спробу." + authentication_error_gmail_app_password: 'Потрібен спеціальний пароль. Дізнайтеся більше у цій статті довідки Google' + smtp_server_busy_error: "Сервер SMTP зараз зайнятий, повторіть спробу пізніше." + smtp_unhandled_error: "Сталася необроблена помилка під час підключення до сервера SMTP. %{message}" + imap_unhandled_error: "Під час з’єднання з сервером IMAP сталася невідома помилка. %{message}" + connection_error: "Виникла проблема з'єднання з сервером, перевірте ім'я сервера і порт і спробуйте знову." + timeout_error: "Час очікування підключення до сервера вичерпано, перевірте ім'я сервера і порт і спробуйте знову." + unhandled_error: "Необроблена помилка під час перевірки параметрів електронної пошти. %{message}" webauthn: validation: invalid_type_error: "Наданий тип webauthn був недійсним. Дійсні типи є webauthn.get та webauthn.create." @@ -1026,6 +1053,7 @@ uk: remove: "Ця тема тепер не є оголошенням і більше не буде доступна широкому загалу вгорі на всіх сторінках." unsubscribed: title: "Налаштування електронних листів змінені!" + description: "Параметри електронної пошти для %{email} було оновлено. Щоб змінити параметри електронної пошти відвідайте налаштування користувача." topic_description: "Щоб повторно підписатися на %{link}, використовуйте кнопку сповіщень внизу або праворуч від теми." private_topic_description: "Щоб повторно підписатися, використовуйте елемент сповіщення внизу або праворуч від теми." uploads: @@ -1432,6 +1460,7 @@ uk: force_https_warning: "Ваш веб-сайт використовує SSL. Але `force_https` ще не ввімкнено в налаштуваннях вашого сайту." out_of_date_themes: "Оновлення доступні для таких тем:" unreachable_themes: "Нам не вдалося перевірити наявність оновлень за наступними темами:" + watched_word_regexp_error: "Неприпустимий регулярний вираз для '%{action}' контрольованих слів. Будь ласка, перевірте ваші Налаштування контрольованих слів, або вимкніть параметр 'контрольовані слова представлені регулярними виразами'." site_settings: disabled: "вимкнуто" display_local_time_in_user_card: "Відображення локального часу на основі часового поясу користувача під час відкриття картки користувача." @@ -1464,11 +1493,13 @@ uk: category_search_priority_high_weight: "Вага, застосована до ранжирування для високого пріоритету пошуку категорії." allow_uncategorized_topics: "Дозволити створення тем без категорії. УВАГА: Якщо є які-небудь теми без категорії, ви повинні перекласифікувати їх, перш ніж відключити." allow_duplicate_topic_titles: "Дозволити теми з однаковими, дублюючими назвами." + allow_duplicate_topic_titles_category: "Дозволити теми з ідентичними, повторюваними заголовками, якщо категорія інша. Параметр allow_duplicate_topic_titles повинен бути вимкненим." unique_posts_mins: "Кількість хвилин, перш ніж користувач зможе знову написати допис з тим самим вмістом" educate_until_posts: "Кількість перших повідомлень нових користувачів, для яких потрібно показувати нескладну підказку з порадами для новачків." title: "Назва цього сайту. Буде додано в HTML-тег title." site_description: "Опишіть сайт одним реченням, для використання опису в мета-тег description." short_site_description: "Короткий опис, що використовується в тезі заголовка на головній сторінці" + contact_email: "Адреса електронної пошти ключового контакту, відповідального за цей сайт. Використовується для критичних повідомлень, а також відображається на /about для невідкладних питань." contact_url: "Контактний URL цього сайту. Вказано на сторінці /about для термінових питань." crawl_images: "Отримувати зображення з віддалених адрес, щоб встановити правильні розміри ширини та висоти." download_remote_images_to_local: "Завантажувати картинки, вставлені в повідомлення посиланнями на інші сайти, і зберігати їх локально, щоб запобігти їх зміни або втрату." @@ -1520,13 +1551,14 @@ uk: detailed_404: "Надає користувачам більше інформації про те, чому він не може отримати доступ до певної теми. Примітка: Це не дуже безпечно, оскільки користувачі дізнаються, чи URL-адреса посилається на дійсну тему." enforce_second_factor: "Примусова активація двофакторної аутентифікації. Виберіть \"Всі\" для примусового увімкнення всім користувачам. Виберіть \"Персонал\" для використання тільки для співробітників користувачів." force_https: "Примусово використовувати лише HTTPS для вашого сайту. УВАГА: можуть зникнути зображення до моменту, коли HTTPS повністю налаштовано і працюватиме всюди! Ви перевірили всі свої CDN, соціальні логіни і будь-які зовнішні логотипи і залежності, щоб переконатися, що вони всі сумісні з HTTPS, також?" + same_site_cookies: "Використовувати файли cookie, зі знешкодженням при перехресному запиті CSRF для підтримуючих це браузерів (Lax або Strict). Попередження: Strict будуть працювати тільки на сайтах, які використовують SSO." summary_score_threshold: "Мінімальна оцінка повідомлення, необхідна для його включення в зведення по темі" summary_posts_required: "Мінімальна кількість дописів у темі перед увімкненням \"Підсумок теми\". Зміни цього налаштування застосовуватимуться до тем минулого тижня." summary_likes_required: "Мінімальна кількість вподобань у темі перед увімкненням \"Підсумок теми\". Зміни цього налаштування застосовуватимуться до тем минулого тижня." summary_percent_filter: "При натисканні на кнопку \"Зведення по темі\", показувати кращі % дописів" summary_max_results: "Максимальна кількість повідомлень, що показуються в 'Зведенні по темі'" summary_timeline_button: "Відображати кнопку «Підсумок» на шкалі часу" - enable_personal_messages: "Дозволити користувачам рівня довіри 1 (який можна налаштувати через мінімальний рівень довіри для надсилання повідомлень) створювати повідомлення та відповідати на повідомлення. Зверніть увагу, що співробітники завжди можуть надсилати повідомлення, незважаючи ні на що." + enable_personal_messages: "Дозволити користувачам рівня довіри 1 (який можна налаштувати через мінімальний рівень довіри для надсилання повідомлень) створювати повідомлення та відповідати на повідомлення. Зверніть увагу, що співробітники завжди можуть надсилати повідомлення, незалежно від того." enable_system_message_replies: "Дозволяє користувачам відповідати на системні повідомлення, навіть якщо приватні повідомлення відключені" enable_long_polling: "Використовувати механізм long polling для повідомлень про події" enable_chunked_encoding: "Увімкнути дозвіл на фрагментацію повідомлень (chunked encoding) на сервері. Ця функція працює на більшості випадків, однак деякі проксі-сервери можуть буферизувати контент, що спричиняє затримку відповідей" @@ -1544,6 +1576,9 @@ uk: tl2_additional_likes_per_day_multiplier: "Збільшити ліміт лайків на день для tl2 (member) до" tl3_additional_likes_per_day_multiplier: "Збільшити ліміт лайків на день для tl3 (member) до" tl4_additional_likes_per_day_multiplier: "Збільшити ліміт лайків на день для tl4 (leader) до" + tl2_additional_edits_per_day_multiplier: "Збільшити ліміт редагувань на день для tl2 (учасника) шляхом множення на це число" + tl3_additional_edits_per_day_multiplier: "Збільшити ліміт редагувань на день для tl3 (активний користувач) шляхом множення на це число" + tl4_additional_edits_per_day_multiplier: "Збільшити ліміт редагувань на день для tl4 (лідера) шляхом множення на це число" num_users_to_silence_new_user: "Якщо повідомлення нового користувача отримують більше прапорів спаму ніж num_spam_flags_to_silence_new_user від багатьох різних користувачів, приховайте всі його публікації та запобігти подальшим публікаціям. 0 відключити." num_tl3_flags_to_silence_new_user: "Якщо повідомлення нового користувача отримують більше прапорів від користувачів з 3 рівнем довіри ніж num_tl3_users_to_silence_new_user, приховати усі його повідомлення та запобігти подальшим публікаціям. 0 відключити" num_tl3_users_to_silence_new_user: "Якщо повідомлення нового користувача отримують прапорів бульше num_tl3_flags_to_silence_new_user від багатьох різних користувачів з 3 рівнем довіри, приховати всі його дописи та запобігти подальшим публікаціям. 0 відключити." @@ -1558,6 +1593,8 @@ uk: must_approve_users: "Персонал повинен схвалити всі нові облікові записи користувачів, перш ніж їм буде надано доступ до сайту" invite_code: "Користувач повинен ввести цей код для дозволу реєстрації облікового запису, ігнорується, коли пусто (нечутлива до регістру)" approve_suspect_users: "Додавати підозрілих користувачів до черги огляду. Підозрілі користувачі можуть входити до профілю, але не можуть читати повідомлення." + review_every_post: "Всі дописи повинні бути переглянуті. ПОПЕРЕДЖЕННЯ! НЕ РЕКОМЕНДУЄТЬСЯ ДЛЯ ПЕРЕВАНТАЖЕНИХ САЙТІВ." + pending_users_reminder_delay_minutes: "Повідомляти модераторів, якщо нові користувачі чекають схвалення довше ніж стільки хвилин. Встановіть -1 для вимкнення сповіщення." persistent_sessions: "Користувачі залишатимуться авторизовані при закритті веб-браузера" maximum_session_age: "Користувач залишиться в системі протягом n годин з моменту останнього відвідування" ga_version: "Версія Google Universal Analytics для використання: v3 (analytics.js), v4 (gtag)" @@ -1581,6 +1618,7 @@ uk: content_security_policy: "Увімкнути політику безпеки вмісту" content_security_policy_report_only: "Увімкнути тільки звіт про політику безпеки вмісту" content_security_policy_collect_reports: "Увімкнути збір звітів про порушення CSP на /csp_reports" + content_security_policy_frame_ancestors: "Обмежити за допомогою CSP, хто може вбудувати цей сайт в iframes. Керування дозволеними хостами на Вбудовування" content_security_policy_script_src: "Додаткові джерела сценаріїв з дозволом. Поточний хост і CDN включені за замовчуванням. Див. Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Облікові записи адміністраторів, які не відвідували сайт протягом цієї кількості днів, потрібно буде повторно підтвердити свою електронну адресу пошти перед входом у систему. Встановіть 0, щоб відключити." top_menu: "Визначає, які елементи відображаються в навігації на головній сторінці та в якому порядку. Наприклад latest|new|unread|categories|top|read|posted|bookmarks" @@ -1629,8 +1667,21 @@ uk: min_admin_password_length: "Мінімальна довжина пароля для адміністратора." password_unique_characters: "Мінімальна довжина пароля для адміністратора." block_common_passwords: "Не дозволяти використовувати паролі зі списку 10 000 самих часто використовуваних паролів." + auth_skip_create_confirm: Під час реєстрації через зовнішню автентифікацію пропускати спливаюче вікно створення облікового запису. Найкраще використовувати разом із sso_overrides_email, sso_overrides_username та sso_overrides_name. auth_immediately: "Автоматично перенаправляти на зовнішню систему входу без взаємодії з користувачем. Це набуває чинності лише тоді, коли login_required має значення true, і існує лише один зовнішній метод автентифікації" + enable_discourse_connect: "Увімкнути вхід через DiscourseConnect (раніше 'Discourse SSO') (ПОПЕРЕДЖЕННЯ: EMAIL КОРИСТУВАЧІВ *ПОВИННІ* ПЕРЕВІРЯТИСЯ ЗОВНІШНІМ САЙТОМ!)" + verbose_discourse_connect_logging: "Записувати всі дані діагностики, пов'язані з DiscourseConnect у файл /logs" + enable_discourse_connect_provider: "Використовувати DiscourseConnect (раніше Discourse SSO) протокол єдиного входу /session/sso_provider. Потребує встановлення параметра sso_provider_secrets" + discourse_connect_url: "Адреса кінцевої точки DiscourseConnect (має містити http:// або https://)" + discourse_connect_secret: "Секретний набір символів, який використовується DiscourseConnect для перевірки автентичності зашифрованого входу, переконайтеся, що це 10 або більше символів" + discourse_connect_provider_secrets: "Список секретних пар доменів, що використовують DiscourseConnect. Переконайтеся, що DiscourseConnect секрет має 10 символів або більше. Символ підстановки * може бути використаний для відповідності будь-якому домену або лише його частині (наприклад, *.example.com)." discourse_connect_overrides_bio: "Перезаписує інформацію в профіль користувача і не дозволяє користувачеві його змінювати" + discourse_connect_overrides_groups: "Синхронізуйте всі групи вручну з групами, вказаними в атрибуті груп (ПОПЕРЕДЖЕННЯ: якщо ви не вказали групи, то весь список груп користувача буде очищено)" + auth_overrides_email: "Переписує локальну адресу електронної пошти, поштою зовнішнього сайту при кожному вході та забороняє її зміні. Застосовується для всіх провайдерів автентифікації. (ПОПЕРЕДЖЕННЯ: через це можуть статися розбіжності)" + auth_overrides_username: "Замінює локальне ім’я користувача на зовнішнє ім’я користувача при використанні SSO при кожному вході та забороняє зміни. Застосовується до всіх постачальників аутентифікації. (ПОПЕРЕДЖЕННЯ: розбіжності можуть виникати через різницю в довжині імені користувача/вимогах)" + auth_overrides_name: "Переписує повне ім'я на повне ім'я зовнішнього сайту під час кожного входу та запобігає локальним змінам. Застосовується до всіх постачальників автентифікації." + discourse_connect_overrides_avatar: "Переписує аватар користувача значенням з DiscourseConnect. Якщо цей параметр увімкнено, користувачі не зможуть завантажувати аватари на Disсourse." + discourse_connect_overrides_location: "Змінює місце знаходження користувача зовнішнім його розташуванням при використанні SSO та запобігає локальним змінам." enable_local_logins_via_email: "Дозволити користувачам запитувати посилання для входу в один клік та надсилати їм електронною поштою цього посилання." allow_new_registrations: "Дозволити реєстрацію нових користувачів. Вимкніть, щоб заборонити відвідувачам створювати нові облікові записи." enable_signup_cta: "Покажіть повідомлення анонімним користувачам, які повернулися, з пропозицією зареєструвати обліковий запис." @@ -1807,7 +1858,6 @@ uk: topic_view_duration_hours: "Кількість нових переглядів теми один раз на IP/Користувача кожні N годин" user_profile_view_duration_hours: "Кількість нових переглядів профілю користувача один раз на IP/Користувача кожні N годин" levenshtein_distance_spammer_emails: "При перевірці листа на спам, яка кількість символів має бути різною" - max_new_accounts_per_registration_ip: "Якщо з цієї IP-адреси вже є (n) облікових записів рівня довіри 0 (і жоден не є співробітником або рівня TL2 або вище), припиняти приймати нові реєстрації з цієї IP." min_ban_entries_for_roll_up: "При натисканні кнопки \"Згорнути\" буде створено новий запис блокування підмережі, якщо є принаймні (N) записів." max_age_unmatched_emails: "Видаляти невідповідні записи електронної пошти через (N) днів." max_age_unmatched_ips: "Видаляти невідповідні IP-записи через (N) днів." @@ -1864,7 +1914,9 @@ uk: log_mail_processing_failures: "Журнал усіх помилок обробки електронної пошти в /logs" email_in_min_trust: "Мінімальний рівень довіри, який потрібен користувачу для дозволу створювати нові теми через email. " email_in_spam_header: "Заголовок електронної пошти для виявлення спаму." + imap_batch_import_email: "Мінімальна кількість нових листів, які запускають режим імпорту (вимикає поштові сповіщення)." email_prefix: "[Label], який використовується в темі електронних листів. За замовчуванням встановлено значення 'title', якщо воно явно не встановлено." + email_site_title: "Назва сайту використовується як відправник електронних листів з сайту. Типове значення «title», якщо не встановлено. Якщо заголовок містить символи, які заборонено в рядках відправника електронної пошти, то буде використано цей параметр." minimum_topics_similar: "Скільки тем має існувати, перш ніж подібні теми будуть показані під час створення нових тем." relative_date_duration: "Кількість днів після публікації, коли дати публікації будуть відображені як відносні (7d), а не абсолютні (20 лютого)." delete_user_max_post_age: "Не дозволяти видаляти користувачів, чия перша публікація старша (x) днів." @@ -3114,10 +3166,10 @@ uk: label: "Назва спільноти" placeholder: "Женіна тусовка" site_description: - label: "Опишіть Ваше співтовариство одним коротким реченням" + label: "Опишіть свою спільноту в одному короткому реченні (використовується в результатах пошуку та соціальних мережах)" placeholder: "Місце для Джейн та її друзів для обговорення класних речей" short_site_description: - label: "Опишіть свою спільноту в декількох словах" + label: "Опишіть свою спільноту кількома словами (використовується для заголовка домашньої сторінки)" placeholder: "Краще співтовариство" introduction: title: "Вступ" diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index 35b9c3164d..a759a64690 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -1208,7 +1208,6 @@ ur: summary_score_threshold: "'اِس ٹاپک کا خلاصہ کریں' میں شامل ہونے کیلئے ایک پوسٹ کا کم از کم سکور" summary_percent_filter: "جب صارف 'اِس ٹاپک کا خلاصہ کریں' پر کلک کرتا ہے، تو سب سے اوپر % پوسٹس دکھائیں" summary_max_results: "'اِس ٹاپک کا خلاصہ کریں' کی طرف سے لوٹائی جانے والی زیادہ سے زیادہ پوسٹس" - enable_personal_messages: "ٹرسٹ لَیول 1 (پیغامات بھیجنے کیلئے کم از کم ٹرسٹ لَیول کے ذریعہ ترتیب دے سکتے ہیں) والے صارفین کو پیغامات بنانے اور پیغامات کا جواب دینے کی اجازت دیں۔ نوٹ کریں کہ جوبھی ہو، اسٹاف ہمیشہ پیغامات بھیج سکتا ہے۔" enable_system_message_replies: "صارفین کو سِسٹم پیغامات کا جواب دینے کی اجازت دیں، یہاں تک کہ اگر ذاتی پیغامات بھی غیر فعال ہوں" enable_long_polling: "نوٹیفکیشن کیلئے مَیسج بس استعمال ہو رہا ہے، لانگ پولِنگ کا استعمال کیا جا سکتا ہے" long_polling_base_url: "لانگ پولِنگ کیلئے استعمال ہونے والا بَیس URL (جب ایک CDN متحرک مواد فراہم کر رہا ہو، تو اِس کو اوریِجِن پُل پر سَیٹ کرنا یقینی بنائیں) مثال: http://origin.site.com" @@ -1459,7 +1458,6 @@ ur: topic_view_duration_hours: "ہر ن گھنٹوں پر فی IP/صارف ایک نیا ٹاپک وِیو شمار کریں" user_profile_view_duration_hours: "ہر ن گھنٹوں پر فی IP/صارف ایک نیا صارف پروفائل وِیو شمار کریں" levenshtein_distance_spammer_emails: "سپَیمر ای میل کو مَیچ کرتے وقت، فرق حروف کی تعداد جس پر بھی ایک فزِّی میچ ہو سکے گا۔" - max_new_accounts_per_registration_ip: "اگر اِس IP کی طرف سے پہلے سے ہی (ن) ٹرسٹ لَیول 0 اکاؤنٹس موجود ہیں (اور کوئی بھی سٹاف کا رکن یا ٹ.ل.2 یا اُس سے زیادہ نہیں ہے)، اُس IP سے نئے سائن اَپ کو قبول کرنا روک دیں۔" min_ban_entries_for_roll_up: "رول اَپ بٹن پر کلک کرتے وقت اگر کم از کم (ن) اندراج موجود ہوں، تو ایک نیا ذیلی نیٹ بَین اندراج تخلیق ہو جائے گا۔" max_age_unmatched_emails: "غیر مَیچ شدہ سکرین کردہ ای میل اندراجات (ن) دنوں کے بعد حذف کر دیں۔" max_age_unmatched_ips: "غیر مَیچ شدہ سکرین کردہ IP اندراجات (ن) دنوں کے بعد حذف کر دیں۔" @@ -3284,10 +3282,8 @@ ur: label: "آپ کی کمیونٹی کا نام" placeholder: "جمیلہ کے ٹھہرنے کی جگہ" site_description: - label: "اپنی کمیونٹی کو ایک مختصر جملہ میں بیان کریں" placeholder: "جمیلہ اور اس کی دوستوں کیلئے دلچسپ چیزوں پر بحث کرنے کی ایک جگہ" short_site_description: - label: "اپنی کمیونٹی کو چند الفاظ میں بیان کریں" placeholder: "ہمیشہ کی سب سے بہترین کمیونٹی" introduction: title: "تعارف" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index 2c944c1c1f..6aa84b65ef 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -1067,7 +1067,6 @@ vi: topic_view_duration_hours: "Đếm lượt xem chủ đề mới một lần cho mỗi IP/User trong N giờ" user_profile_view_duration_hours: "Đếm lượt xem hồ sơ mới một lần cho mỗi IP/User trong N giờ" levenshtein_distance_spammer_emails: "Khi phù hợp với thư rác, số ký tự khác biệt vẫn sẽ cho phép khả năng chính xác mờ." - max_new_accounts_per_registration_ip: "Nếu đã có (n) tài khoản với độ tin cậy mức 0 từ IP này (không phải là BQT, mức TL2 hoặc cao hơn), ngừng chấp nhận đăng ký mới từ IP này." min_ban_entries_for_roll_up: "Khi click vào nút cuộn lên, sẽ tạo ra một entry subnet cấm mới nếu có ít nhất (N) entry." max_age_unmatched_emails: "Xóa các email hiển thị không khớp sau (N) ngày." max_age_unmatched_ips: "Xóa các IP hiển thị không khớp sau (N) ngày." diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index bbd129cb98..2f26dae6b2 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -241,7 +241,6 @@ zh_CN: topic_invite: failed_to_invite: "如果用户不是以下任一群组的成员,则无法被邀请加入此话题:%{group_names}。" user_exists: "抱歉,该用户已被邀请。您一次只可以邀请一个用户加入话题。" - muted_invitee: "抱歉,该用户已将您设为免打扰。" muted_topic: "抱歉,该用户已将此话题设为免打扰。" receiver_does_not_allow_pm: "抱歉,该用户不允许您向他们发送私信。" sender_does_not_allow_pm: "抱歉,您不允许该用户向您发送私信。" @@ -1403,7 +1402,6 @@ zh_CN: summary_percent_filter: "当用户点击“总结此话题”时,显示前百分之几的帖子" summary_max_results: "“总结此话题”返回的最大帖子数量" summary_timeline_button: "在时间线中显示“总结”按钮" - enable_personal_messages: "允许信任级别 1(可通过最小信任级别配置来发送消息)用户创建消息和回复消息。注意:管理人员不受限制。" enable_system_message_replies: "即使禁用了个人消息,也允许用户回复系统消息" enable_long_polling: "用于通知的消息总线可以使用长轮询" enable_chunked_encoding: "启用服务器的分块编码响应。此功能适用于大多数设置,但某些代理可能会缓冲,导致响应延迟" @@ -1734,7 +1732,6 @@ zh_CN: topic_view_duration_hours: "每个 IP/用户每 N 小时计算一次新话题浏览" user_profile_view_duration_hours: "每个 IP/用户每 N 小时计算一次新用户个人资料浏览" levenshtein_distance_spammer_emails: "在匹配垃圾信息发布者电子邮件时,仍然允许模糊匹配的字符数差异。" - max_new_accounts_per_registration_ip: "如果已经有 (n) 个来自此 IP 的信任级别 0 帐户(并且没有一个是管理人员或者是 TL2 或更高级别),则停止接受来自该 IP 的新注册。" min_ban_entries_for_roll_up: "点击“汇总”按钮时,如果至少有 (N) 个条目,将创建一个子网禁止条目" max_age_unmatched_emails: "在 (N) 天后删除不匹配的已屏蔽电子邮件条目。" max_age_unmatched_ips: "在 (N) 天后删除不匹配的已屏蔽 IP 条目。" @@ -4272,10 +4269,8 @@ zh_CN: label: "您社区的名称" placeholder: "朱桦的环聊" site_description: - label: "用一句话描述您的社区" placeholder: "朱桦和她的朋友讨论新奇事物的地方" short_site_description: - label: "用几个词描述您的社区" placeholder: "有史以来最好的社区" introduction: title: "介绍" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index f903a5e3eb..0d05adf6d8 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -1142,7 +1142,6 @@ zh_TW: summary_score_threshold: "將一個貼文包含在“概括主題”中所需的最少分數" summary_percent_filter: "當使用者點擊 \"此話題的摘要\",顯示前面多少 % 的貼文" summary_max_results: "“概括主題”將顯示最大貼文數量" - enable_personal_messages: "允許信任等級1(可以另外選擇發送消息的信任等級)的使用者建立訊息和回覆訊息。注意:管理人員不受限制。" enable_system_message_replies: "即使個人訊息功能未開啟,允許使用者回覆系統傳送的訊息。" enable_long_polling: "啟用消息匯流排使通知功能可以使用長輪詢(long polling)" long_polling_base_url: "長輪詢的基本 URL(當用 CDN 分發動態內容,請設置此至原始拉取地址)例如:http://origin.site.com" @@ -1383,7 +1382,6 @@ zh_TW: topic_view_duration_hours: "按照每 IP/使用者每 N 小時來記錄一次新的主題訪問" user_profile_view_duration_hours: "按照每 IP/使用者每 N 小時來記錄使用者資料訪問數" levenshtein_distance_spammer_emails: "當比對廣告Email時,數字與文字將仍使用模糊比對" - max_new_accounts_per_registration_ip: "如果已經有了從這個 IP 創建的 (n) 個信任等級0的賬戶(並且沒有一個是職員或者是信任等級2以上的使用者),不再允許來自該 IP 地址的註冊請求。" min_ban_entries_for_roll_up: "當點擊折疊按鈕且不少於 (N) 條記錄時,將會創建一個子網禁止記錄。" max_age_unmatched_emails: "在 (N) 天後刪除不匹配的電郵地址。" max_age_unmatched_ips: "在 (N) 天後刪除不匹配的 IP 記錄。" @@ -3050,10 +3048,8 @@ zh_TW: label: "你社群的名字" placeholder: "小明的論壇" site_description: - label: "用一句簡短的話描述你的社群" placeholder: "小明和他的朋友討論酷東西的論壇" short_site_description: - label: "用幾句話描述你的社群" placeholder: "最佳社群" introduction: title: "介紹" diff --git a/config/site_settings.yml b/config/site_settings.yml index 107c4b91d4..39835d5a88 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -267,11 +267,11 @@ basic: client: true default: true hidden: true - enable_experimental_image_uploader: + enable_experimental_composer_uploader: client: true default: false hidden: true - enable_experimental_composer_uploader: + enable_experimental_backup_uploader: client: true default: false hidden: true @@ -453,12 +453,17 @@ login: client: true auth_immediately: default: true + auth_overrides_email: + default: false + validator: "SsoOverridesEmailValidator" + client: true + auth_overrides_username: false + auth_overrides_name: false enable_discourse_connect: client: true default: false validator: "EnableSsoValidator" discourse_connect_allows_all_return_paths: false - enable_discourse_connect_provider: false verbose_discourse_connect_logging: false verbose_upload_logging: hidden: true @@ -475,22 +480,8 @@ login: discourse_connect_secret: default: "" secret: true - discourse_connect_provider_secrets: - default: "" - type: list - list_type: secret - secret: true - placeholder: - key: "sso_provider.key_placeholder" - value: "sso_provider.value_placeholder" discourse_connect_overrides_groups: false discourse_connect_overrides_bio: false - auth_overrides_email: - default: false - validator: "SsoOverridesEmailValidator" - client: true - auth_overrides_username: false - auth_overrides_name: false discourse_connect_overrides_avatar: default: false client: true @@ -502,6 +493,15 @@ login: discourse_connect_csrf_protection: default: true hidden: true + enable_discourse_connect_provider: false + discourse_connect_provider_secrets: + default: "" + type: list + list_type: secret + secret: true + placeholder: + key: "sso_provider.key_placeholder" + value: "sso_provider.value_placeholder" blocked_email_domains: default: "mailinator.com" type: list @@ -1637,7 +1637,7 @@ security: allow_any: false choices: "['*'] + Onebox::Engine.all_iframe_origins" allowed_iframes: - default: "https://www.google.com/maps/embed?|https://www.openstreetmap.org/export/embed.html?|https://calendar.google.com/calendar/embed?|https://codepen.io/" + default: "https://www.google.com/maps/embed?|https://www.openstreetmap.org/export/embed.html?|https://calendar.google.com/calendar/embed?|https://codepen.io/|https://www.instagram.com" type: list list_type: simple client: true @@ -1680,6 +1680,7 @@ security: hidden: true can_permanently_delete: default: false + client: true hidden: true onebox: @@ -2214,6 +2215,9 @@ uncategorized: disable_category_edit_notifications: default: false + disable_tags_edit_notifications: + default: false + notification_consolidation_threshold: default: 3 min: 0 diff --git a/docs/INSTALL-cloud.md b/docs/INSTALL-cloud.md index 792e068b49..d2504711f6 100644 --- a/docs/INSTALL-cloud.md +++ b/docs/INSTALL-cloud.md @@ -1,12 +1,26 @@ -**Set up Discourse in the cloud in under 30 minutes** with zero knowledge of Rails or Linux shell. One example is [DigitalOcean][do], but these steps will work on any **Docker-compatible** cloud provider or local server. +**Set up Discourse in the cloud in under 30 minutes** with zero knowledge of Rails or Linux shell. One example +is [DigitalOcean][do], but these steps will work on any **Docker-compatible** cloud provider or local server. This +walkthrough will go through these in detail: -> 🔔 Don't have 30 minutes to set this up? For a flat one-time fee of $150, the community can install Discourse in the cloud for you. [Click here to purchase a self-supported community install](https://www.literatecomputing.com/product/discourse-install/). +1. [Create new cloud server](#1-create-new-cloud-server) +2. [Access new cloud server](#2-access-your-cloud-server) +3. [Install Discourse](#3-install-discourse) +4. [Setting up email](#4-setting-up-email) +5. [Customize domain name](#5-customize-domain-name) +6. [Edit Discourse configuration](#6-edit-discourse-configuration) +7. [Start Discourse](#7-start-discourse) +8. [Register new account and become admin](#8-register-new-account-and-become-admin) +9. [Post-install maintenance](#9-post-install-maintenance) +10. [(Optional) Add more Discourse features](#10-optional-add-more-discourse-features) -### Create New Cloud Server +> 🔔 Don't have 30 minutes to set this up? For a flat one-time fee of $150, the community can install Discourse in the cloud for you. [Click here to purchase a self-supported community install](https://www.literatecomputing.com/product/discourse-install/). + +### 1. Create New Cloud Server Create your new cloud server, for example [on DigitalOcean][do]: -- The default of **the current supported LTS release of Ubuntu Server** works fine. At minimum, a 64-bit Linux OS with a modern kernel version is required. +- The default of **the current supported LTS release of Ubuntu Server** works fine. At minimum, a 64-bit Linux OS with a + modern kernel version is required. - The default of **1 GB** RAM works fine for small Discourse communities. We recommend 2 GB RAM for larger communities. @@ -16,7 +30,7 @@ Create your new cloud server, for example [on DigitalOcean][do]: Create your new Droplet. You may receive an email with the root password, however, [you should set up SSH keys](https://www.google.com/search?q=digitalocean+ssh+keys), as they are more secure. -### Access Your Cloud Server +### 2. Access Your Cloud Server Connect to your server via its IP address using SSH, or [Putty][put] on Windows: @@ -24,8 +38,7 @@ Connect to your server via its IP address using SSH, or [Putty][put] on Windows: Either use the root password from the email DigitalOcean sent you when the server was set up, or have a valid SSH key configured on your local machine. - -### Install Discourse +### 3. Install Discourse Clone the [Official Discourse Docker Image][dd] into `/var/discourse`. @@ -35,7 +48,7 @@ Clone the [Official Discourse Docker Image][dd] into `/var/discourse`. You will need to be root through the rest of the setup and bootstrap process. -### Email +### 4. Setting Up Email > ⚠️ **Email is CRITICAL for account creation and notifications in Discourse.** If you do not properly configure email before bootstrapping YOU WILL HAVE A BROKEN SITE! @@ -49,7 +62,7 @@ You will need to be root through the rest of the setup and bootstrap process. - If you're having trouble getting emails to work, follow our [Email Troubleshooting Guide](https://meta.discourse.org/t/troubleshooting-email-on-a-new-discourse-install/16326) -### Domain Name +### 5. Customize Domain Name > 🔔 Discourse will not work from an IP address, you must own a domain name such as `example.com` to proceed. @@ -59,7 +72,7 @@ You will need to be root through the rest of the setup and bootstrap process. - Your DNS controls should be accessible from the place where you purchased your domain name. Create a DNS [`A` record](https://support.dnsimple.com/articles/a-record/) for the `discourse.example.com` hostname in your DNS control panel, pointing to the IP address of your cloud instance where you are installing Discourse. -### Edit Discourse Configuration +### 6. Edit Discourse Configuration Launch the setup tool at @@ -82,13 +95,13 @@ Let's Encrypt account setup is to give you a free HTTPS certificate for your sit This will generate an `app.yml` configuration file on your behalf, and then kicks off bootstrap. Bootstrapping takes between **2-8 minutes** to set up your Discourse. If you need to change these settings after bootstrapping, you can run `./discourse-setup` again (it will re-use your previous values from the file) or edit `/containers/app.yml` manually with `nano` and then `./launcher rebuild app`, otherwise your changes will not take effect. -### Start Discourse +### 7. Start Discourse Once bootstrapping is complete, your Discourse should be accessible in your web browser via the domain name `discourse.example.com` you entered earlier. -### Register New Account and Become Admin +### 8. Register New Account and Become Admin Register a new admin account using one of the email addresses you entered before bootstrapping. @@ -106,7 +119,7 @@ After completing the setup wizard, you should see Staff topics and **READ ME FIR -### Post-Install Maintenance +### 9. Post-Install Maintenance - We strongly suggest you turn on automatic security updates for your OS. In Ubuntu use the `dpkg-reconfigure -plow unattended-upgrades` command. In CentOS/RHEL, use the [`yum-cron`](https://www.redhat.com/sysadmin/using-yum-cron) package. - If you are using a password and not a SSH key, be sure to enforce a strong root password. In Ubuntu use the `apt install libpam-cracklib` package. We also recommend `fail2ban` which blocks any IP addresses for 10 minutes that attempt more than 3 password retries. @@ -146,7 +159,7 @@ Options: --docker-args Extra arguments to pass when running docker ``` -### Add More Discourse Features +### 10. (Optional) Add More Discourse Features Do you want... diff --git a/lefthook.yml b/lefthook.yml index b5e3a77397..534e33e87b 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -5,14 +5,10 @@ pre-commit: glob: "*.rb" run: bundle exec rubocop --parallel {staged_files} prettier: - glob: "*.{js,es6}" + glob: "*.js" include: "app/assets/javascripts|test/javascripts" run: yarn pprettier --list-different {staged_files} - eslint-es6: - glob: "*.es6" - include: "app/assets/javascripts|test/javascripts" - run: yarn eslint --ext .es6 -f compact {staged_files} - eslint-js: + eslint: glob: "*.js" include: "app/assets/javascripts|test/javascripts" run: yarn eslint -f compact {staged_files} @@ -52,23 +48,17 @@ lints: rubocop: run: bundle exec rubocop --parallel prettier: - glob: "*.{js,es6}" + glob: "*.js" include: "app/assets/javascripts|test/javascripts" run: yarn pprettier --list-different {all_files} - eslint-assets-es6: - run: yarn eslint --ext .es6 app/assets/javascripts eslint-assets-js: run: yarn eslint app/assets/javascripts - eslint-test-es6: - run: yarn eslint --ext .es6 test/javascripts eslint-test-js: run: yarn eslint test/javascripts eslint-plugins-assets: - run: yarn eslint --global I18n --ext .es6 plugins/**/assets/javascripts + run: yarn eslint plugins/**/assets/javascripts eslint-plugins-test: - run: yarn eslint --global I18n --ext .es6 plugins/**/test/javascripts - eslint-assets-tests: - run: yarn eslint app/assets/javascripts test/javascripts + run: yarn eslint plugins/**/test/javascripts i18n-lint: glob: "**/{client,server}.en.yml" run: bundle exec ruby script/i18n_lint.rb {all_files} diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index 8780aced0c..5303077182 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -37,7 +37,7 @@ class AdminUserIndexQuery end def custom_direction - Discourse.deprecate(":ascending is deprecated please use :asc instead", output_in_test: true) if params[:ascending] + Discourse.deprecate(":ascending is deprecated please use :asc instead", output_in_test: true, drop_from: '2.9.0') if params[:ascending] asc = params[:asc] || params[:ascending] asc.present? && asc ? "ASC" : "DESC" end diff --git a/lib/auth/auth_provider.rb b/lib/auth/auth_provider.rb index 50bb76c7e8..09f0f5f39a 100644 --- a/lib/auth/auth_provider.rb +++ b/lib/auth/auth_provider.rb @@ -16,24 +16,24 @@ class Auth::AuthProvider attr_accessor(*auth_attributes) def enabled_setting=(val) - Discourse.deprecate("(#{authenticator.name}) enabled_setting is deprecated. Please define authenticator.enabled? instead") + Discourse.deprecate("(#{authenticator.name}) enabled_setting is deprecated. Please define authenticator.enabled? instead", drop_from: '2.9.0') @enabled_setting = val end def background_color=(val) - Discourse.deprecate("(#{authenticator.name}) background_color is no longer functional. Please use CSS instead") + Discourse.deprecate("(#{authenticator.name}) background_color is no longer functional. Please use CSS instead", drop_from: '2.9.0') end def full_screen_login=(val) - Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login parameter can be removed from the auth_provider.") + Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login parameter can be removed from the auth_provider.", drop_from: '2.9.0') end def full_screen_login_setting=(val) - Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login_setting parameter can be removed from the auth_provider.") + Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login_setting parameter can be removed from the auth_provider.", drop_from: '2.9.0') end def message=(val) - Discourse.deprecate("(#{authenticator.name}) message is no longer used because all logins are full screen. It should be removed from the auth_provider") + Discourse.deprecate("(#{authenticator.name}) message is no longer used because all logins are full screen. It should be removed from the auth_provider", drop_from: '2.9.0') end def name diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb index 8c16392b3a..a35d40adda 100644 --- a/lib/auth/default_current_user_provider.rb +++ b/lib/auth/default_current_user_provider.rb @@ -137,8 +137,9 @@ class Auth::DefaultCurrentUserProvider # user api key handling if user_api_key - limiter_min = RateLimiter.new(nil, "user_api_min_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_minute, 60) - limiter_day = RateLimiter.new(nil, "user_api_day_#{user_api_key}", GlobalSetting.max_user_api_reqs_per_day, 86400) + hashed_user_api_key = ApiKey.hash_key(user_api_key) + limiter_min = RateLimiter.new(nil, "user_api_min_#{hashed_user_api_key}", GlobalSetting.max_user_api_reqs_per_minute, 60) + limiter_day = RateLimiter.new(nil, "user_api_day_#{hashed_user_api_key}", GlobalSetting.max_user_api_reqs_per_day, 86400) unless limiter_day.can_perform? limiter_day.performed! @@ -382,7 +383,7 @@ class Auth::DefaultCurrentUserProvider limit = GlobalSetting.max_admin_api_reqs_per_minute.to_i if GlobalSetting.respond_to?(:max_admin_api_reqs_per_key_per_minute) - Discourse.deprecate("DISCOURSE_MAX_ADMIN_API_REQS_PER_KEY_PER_MINUTE is deprecated. Please use DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE") + Discourse.deprecate("DISCOURSE_MAX_ADMIN_API_REQS_PER_KEY_PER_MINUTE is deprecated. Please use DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE", drop_from: '2.9.0') limit = [ GlobalSetting.max_admin_api_reqs_per_key_per_minute.to_i, limit].max end diff --git a/lib/auth/result.rb b/lib/auth/result.rb index 08a12203d2..5f68606d2a 100644 --- a/lib/auth/result.rb +++ b/lib/auth/result.rb @@ -77,8 +77,8 @@ class Auth::Result def apply_user_attributes! change_made = false - if SiteSetting.auth_overrides_username? && username.present? && username != user.username - user.username = UserNameSuggester.suggest(username_suggester_attributes, user.username) + if SiteSetting.auth_overrides_username? && username.present? && UserNameSuggester.fix_username(username) != user.username + user.username = UserNameSuggester.suggest(username) change_made = true end diff --git a/lib/backup_restore/s3_backup_store.rb b/lib/backup_restore/s3_backup_store.rb index f991c05829..40326f7019 100644 --- a/lib/backup_restore/s3_backup_store.rb +++ b/lib/backup_restore/s3_backup_store.rb @@ -2,12 +2,18 @@ module BackupRestore class S3BackupStore < BackupStore - UPLOAD_URL_EXPIRES_AFTER_SECONDS ||= 21_600 # 6 hours + UPLOAD_URL_EXPIRES_AFTER_SECONDS ||= 6.hours.to_i + + delegate :abort_multipart, :presign_multipart_part, :list_multipart_parts, + :complete_multipart, to: :s3_helper def initialize(opts = {}) @s3_options = S3Helper.s3_options(SiteSetting) @s3_options.merge!(opts[:s3_options]) if opts[:s3_options] - @s3_helper = S3Helper.new(s3_bucket_name_with_prefix, '', @s3_options.clone) + end + + def s3_helper + @s3_helper ||= S3Helper.new(s3_bucket_name_with_prefix, '', @s3_options.clone) end def remote? @@ -15,12 +21,12 @@ module BackupRestore end def file(filename, include_download_source: false) - obj = @s3_helper.object(filename) + obj = s3_helper.object(filename) create_file_from_object(obj, include_download_source) if obj.exists? end def delete_file(filename) - obj = @s3_helper.object(filename) + obj = s3_helper.object(filename) if obj.exists? obj.delete @@ -29,11 +35,11 @@ module BackupRestore end def download_file(filename, destination_path, failure_message = nil) - @s3_helper.download_file(filename, destination_path, failure_message) + s3_helper.download_file(filename, destination_path, failure_message) end def upload_file(filename, source_path, content_type) - obj = @s3_helper.object(filename) + obj = s3_helper.object(filename) raise BackupFileExists.new if obj.exists? obj.upload_file(source_path, content_type: content_type) @@ -41,30 +47,71 @@ module BackupRestore end def generate_upload_url(filename) - obj = @s3_helper.object(filename) + obj = s3_helper.object(filename) raise BackupFileExists.new if obj.exists? - ensure_cors! + # TODO (martin) We can remove this at a later date when we move this + # ensure CORS for backups and direct uploads to a post-site-setting + # change event, so the rake task doesn't have to be run manually. + @s3_helper.ensure_cors!([S3CorsRulesets::BACKUP_DIRECT_UPLOAD]) + presigned_url(obj, :put, UPLOAD_URL_EXPIRES_AFTER_SECONDS) rescue Aws::Errors::ServiceError => e Rails.logger.warn("Failed to generate upload URL for S3: #{e.message.presence || e.class.name}") raise StorageError.new(e.message.presence || e.class.name) end - def vacate_legacy_prefix - legacy_s3_helper = S3Helper.new(s3_bucket_name_with_legacy_prefix, '', @s3_options.clone) - bucket, prefix = s3_bucket_name_with_prefix.split('/', 2) - legacy_keys = legacy_s3_helper.list - .reject { |o| o.key.starts_with? prefix } - .map { |o| o.key } - legacy_keys.each do |legacy_key| - @s3_helper.s3_client.copy_object({ - copy_source: File.join(bucket, legacy_key), - bucket: bucket, - key: File.join(prefix, legacy_key.split('/').last) - }) - legacy_s3_helper.delete_object(legacy_key) + def signed_url_for_temporary_upload(file_name, expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, metadata: {}) + obj = object_from_path(file_name) + raise BackupFileExists.new if obj.exists? + key = temporary_upload_path(file_name) + s3_helper.presigned_url( + key, + method: :put_object, + expires_in: expires_in, + opts: { + metadata: metadata, + acl: "private" + } + ) + end + + def temporary_upload_path(file_name) + FileStore::BaseStore.temporary_upload_path(file_name, folder_prefix: temporary_folder_prefix) + end + + def temporary_folder_prefix + folder_prefix = s3_helper.s3_bucket_folder_path.nil? ? "" : s3_helper.s3_bucket_folder_path + + if Rails.env.test? + folder_prefix = File.join(folder_prefix, "test_#{ENV['TEST_ENV_NUMBER'].presence || '0'}") end + + folder_prefix + end + + def create_multipart(file_name, content_type, metadata: {}) + obj = object_from_path(file_name) + raise BackupFileExists.new if obj.exists? + key = temporary_upload_path(file_name) + s3_helper.create_multipart(key, content_type, metadata: metadata) + end + + def move_existing_stored_upload( + existing_external_upload_key:, + original_filename: nil, + content_type: nil + ) + s3_helper.copy( + existing_external_upload_key, + File.join(s3_helper.s3_bucket_folder_path, original_filename), + options: { acl: "private", apply_metadata_to_destination: true } + ) + s3_helper.delete_object(existing_external_upload_key) + end + + def object_from_path(path) + s3_helper.object(path) end private @@ -72,7 +119,7 @@ module BackupRestore def unsorted_files objects = [] - @s3_helper.list.each do |obj| + s3_helper.list.each do |obj| if obj.key.match?(file_regex) objects << create_file_from_object(obj) end @@ -98,17 +145,6 @@ module BackupRestore obj.presigned_url(method, expires_in: expires_in_seconds) end - def ensure_cors! - rule = { - allowed_headers: ["*"], - allowed_methods: ["PUT"], - allowed_origins: [Discourse.base_url_no_prefix], - max_age_seconds: 3000 - } - - @s3_helper.ensure_cors!([rule]) - end - def cleanup_allowed? !SiteSetting.s3_disable_cleanup end @@ -117,17 +153,9 @@ module BackupRestore File.join(SiteSetting.s3_backup_bucket, RailsMultisite::ConnectionManagement.current_db) end - def s3_bucket_name_with_legacy_prefix - if Rails.configuration.multisite - File.join(SiteSetting.s3_backup_bucket, "backups", RailsMultisite::ConnectionManagement.current_db) - else - SiteSetting.s3_backup_bucket - end - end - def file_regex @file_regex ||= begin - path = @s3_helper.s3_bucket_folder_path || "" + path = s3_helper.s3_bucket_folder_path || "" if path.present? path = "#{path}/" unless path.end_with?("/") diff --git a/lib/cache.rb b/lib/cache.rb index 7d69361915..ca814c9e2b 100644 --- a/lib/cache.rb +++ b/lib/cache.rb @@ -86,7 +86,7 @@ class Cache if raw begin - Marshal.load(raw) + Marshal.load(raw) # rubocop:disable Security/MarshalLoad rescue => e log_first_exception(e) end @@ -113,7 +113,7 @@ class Cache def read_entry(key) if data = redis.get(key) - Marshal.load(data) + Marshal.load(data) # rubocop:disable Security/MarshalLoad end rescue => e # corrupt cache, this can happen if Marshal version diff --git a/lib/discourse_plugin_registry.rb b/lib/discourse_plugin_registry.rb index 8106809144..53a05aa4b4 100644 --- a/lib/discourse_plugin_registry.rb +++ b/lib/discourse_plugin_registry.rb @@ -91,6 +91,8 @@ class DiscoursePluginRegistry define_filtered_register :presence_channel_prefixes + define_filtered_register :push_notification_filters + def self.register_auth_provider(auth_provider) self.auth_providers << auth_provider end diff --git a/lib/discourse_updates.rb b/lib/discourse_updates.rb index fbc81aac9f..c7450feb5a 100644 --- a/lib/discourse_updates.rb +++ b/lib/discourse_updates.rb @@ -61,18 +61,34 @@ module DiscourseUpdates Discourse.redis.get last_installed_version_key end + def last_installed_version=(arg) + Discourse.redis.set(last_installed_version_key, arg) + end + def latest_version Discourse.redis.get latest_version_key end + def latest_version=(arg) + Discourse.redis.set(latest_version_key, arg) + end + def missing_versions_count Discourse.redis.get(missing_versions_count_key).try(:to_i) end + def missing_versions_count=(arg) + Discourse.redis.set(missing_versions_count_key, arg) + end + def critical_updates_available? (Discourse.redis.get(critical_updates_available_key) || false) == 'true' end + def critical_updates_available=(arg) + Discourse.redis.set(critical_updates_available_key, arg) + end + def updated_at t = Discourse.redis.get(updated_at_key) t ? Time.zone.parse(t) : nil @@ -82,12 +98,6 @@ module DiscourseUpdates Discourse.redis.set updated_at_key, time_with_zone.as_json end - ['last_installed_version', 'latest_version', 'missing_versions_count', 'critical_updates_available'].each do |name| - eval "define_method :#{name}= do |arg| - Discourse.redis.set #{name}_key, arg - end" - end - def missing_versions=(versions) # delete previous list from redis prev_keys = Discourse.redis.lrange(missing_versions_list_key, 0, 4) diff --git a/lib/file_store/base_store.rb b/lib/file_store/base_store.rb index f3d948f45a..5d21449bcd 100644 --- a/lib/file_store/base_store.rb +++ b/lib/file_store/base_store.rb @@ -41,7 +41,7 @@ module FileStore File.join(path, "test_#{ENV['TEST_ENV_NUMBER'].presence || '0'}") end - def temporary_upload_path(file_name, folder_prefix: "") + def self.temporary_upload_path(file_name, folder_prefix: "") # We don't want to use the original file name as it can contain special # characters, which can interfere with external providers operations and # introduce other unexpected behaviour. @@ -49,7 +49,6 @@ module FileStore File.join( TEMPORARY_UPLOAD_PREFIX, folder_prefix, - upload_path, SecureRandom.hex, file_name_random ) diff --git a/lib/file_store/local_store.rb b/lib/file_store/local_store.rb index e945ac1c32..e71e3a6ba7 100644 --- a/lib/file_store/local_store.rb +++ b/lib/file_store/local_store.rb @@ -39,6 +39,10 @@ module FileStore File.join(Discourse.base_path, upload_path) end + def temporary_upload_path(filename) + FileStore::BaseStore.temporary_upload_path(filename, folder_prefix: relative_base_url) + end + def external? false end diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 2e63681821..fb06605112 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -11,6 +11,9 @@ module FileStore class S3Store < BaseStore TOMBSTONE_PREFIX ||= "tombstone/" + delegate :abort_multipart, :presign_multipart_part, :list_multipart_parts, + :complete_multipart, to: :s3_helper + def initialize(s3_helper = nil) @s3_helper = s3_helper end @@ -35,7 +38,11 @@ module FileStore url end - def move_existing_stored_upload(existing_external_upload_key, upload, content_type = nil) + def move_existing_stored_upload( + existing_external_upload_key:, + upload: nil, + content_type: nil + ) upload.url = nil path = get_path_for_upload(upload) url, upload.etag = store_file( @@ -210,10 +217,6 @@ module FileStore upload.url end - def path_from_url(url) - URI.parse(url).path.delete_prefix("/") - end - def cdn_url(url) return url if SiteSetting.Upload.s3_cdn_url.blank? schema = url[/^(https?:)?\/\//, 1] @@ -228,7 +231,7 @@ module FileStore def signed_url_for_temporary_upload(file_name, expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, metadata: {}) key = temporary_upload_path(file_name) - presigned_url( + s3_helper.presigned_url( key, method: :put_object, expires_in: expires_in, @@ -240,7 +243,10 @@ module FileStore end def temporary_upload_path(file_name) - s3_bucket_folder_path.nil? ? super(file_name) : super(file_name, folder_prefix: s3_bucket_folder_path) + folder_prefix = s3_bucket_folder_path.nil? ? upload_path : File.join(s3_bucket_folder_path, upload_path) + FileStore::BaseStore.temporary_upload_path( + file_name, folder_prefix: folder_prefix + ) end def object_from_path(path) @@ -315,76 +321,13 @@ module FileStore FileUtils.mv(old_upload_path, public_upload_path) if old_upload_path end - def abort_multipart(key:, upload_id:) - s3_helper.s3_client.abort_multipart_upload( - bucket: s3_bucket_name, - key: key, - upload_id: upload_id - ) - end - def create_multipart(file_name, content_type, metadata: {}) key = temporary_upload_path(file_name) - response = s3_helper.s3_client.create_multipart_upload( - acl: "private", - bucket: s3_bucket_name, - key: key, - content_type: content_type, - metadata: metadata - ) - { upload_id: response.upload_id, key: key } - end - - def presign_multipart_part(upload_id:, key:, part_number:) - presigned_url( - key, - method: :upload_part, - expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, - opts: { - part_number: part_number, - upload_id: upload_id - } - ) - end - - def list_multipart_parts(upload_id:, key:) - s3_helper.s3_client.list_parts( - bucket: s3_bucket_name, - key: key, - upload_id: upload_id - ) - end - - def complete_multipart(upload_id:, key:, parts:) - s3_helper.s3_client.complete_multipart_upload( - bucket: s3_bucket_name, - key: key, - upload_id: upload_id, - multipart_upload: { - parts: parts - } - ) + s3_helper.create_multipart(key, content_type, metadata: metadata) end private - def presigned_url( - key, - method:, - expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, - opts: {} - ) - signer = Aws::S3::Presigner.new(client: s3_helper.s3_client) - signer.presigned_url( - method, - { - bucket: s3_bucket_name, - key: key, - expires_in: expires_in, - }.merge(opts) - ) - end - def presigned_get_url( url, force_download: false, diff --git a/lib/final_destination.rb b/lib/final_destination.rb index e3805479c8..09f4d0c58e 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -225,7 +225,7 @@ class FinalDestination end if @follow_canonical - next_url = uri(fetch_canonical_url(response.body)) + next_url = fetch_canonical_url(response.body) if next_url.to_s.present? && next_url != @uri @follow_canonical = false @@ -239,7 +239,7 @@ class FinalDestination @content_type = response.headers['Content-Type'] if response.headers.has_key?('Content-Type') @status = :resolved return @uri - when 400, 405, 406, 409, 500, 501 + when 103, 400, 405, 406, 409, 500, 501 response_status, small_headers = small_get(request_headers) if response_status == 200 @@ -481,10 +481,17 @@ class FinalDestination def fetch_canonical_url(body) return if body.blank? - canonical_link = Nokogiri::HTML5(body).at("link[rel='canonical']") - return if canonical_link.nil? + canonical_element = Nokogiri::HTML5(body).at("link[rel='canonical']") + return if canonical_element.nil? + canonical_uri = uri(canonical_element['href']) + return if canonical_uri.blank? - canonical_link['href'] + return canonical_uri if canonical_uri.host.present? + parts = [@uri.host, canonical_uri.to_s] + complete_url = canonical_uri.to_s.starts_with?('/') ? parts.join('') : parts.join('/') + complete_url = "#{@uri.scheme}://#{complete_url}" if @uri.scheme + + uri(complete_url) end end diff --git a/lib/flag_query.rb b/lib/flag_query.rb index 5c6c0bce9b..beb69d7897 100644 --- a/lib/flag_query.rb +++ b/lib/flag_query.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true -require 'ostruct' - module FlagQuery - def self.plugin_post_custom_fields @plugin_post_custom_fields ||= {} end @@ -12,212 +9,4 @@ module FlagQuery def self.register_plugin_post_custom_field(field, plugin) plugin_post_custom_fields[field] = plugin end - - def self.flagged_posts_report(current_user, opts = nil) - Discourse.deprecate("FlagQuery is deprecated, use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4") - - opts ||= {} - offset = opts[:offset] || 0 - per_page = opts[:per_page] || 25 - - reviewables = ReviewableFlaggedPost.default_visible.viewable_by(current_user, order: 'created_at DESC') - reviewables = reviewables.where(topic_id: opts[:topic_id]) if opts[:topic_id] - reviewables = reviewables.where(target_created_by_id: opts[:user_id]) if opts[:user_id] - reviewables = reviewables.limit(per_page).offset(offset) - - if opts[:filter] == 'old' - reviewables = reviewables.where("status <> ?", Reviewable.statuses[:pending]) - else - reviewables = reviewables.pending - end - - total_rows = reviewables.count - - post_ids = reviewables.map(&:target_id).uniq - - posts = DB.query(<<~SQL, post_ids: post_ids) - SELECT p.id, - p.cooked as excerpt, - p.raw, - p.user_id, - p.topic_id, - p.post_number, - p.reply_count, - p.hidden, - p.deleted_at, - p.user_deleted, - NULL as post_action_ids, - (SELECT created_at FROM post_revisions WHERE post_id = p.id AND user_id = p.user_id ORDER BY created_at DESC LIMIT 1) AS last_revised_at, - (SELECT COUNT(*) FROM post_actions WHERE (disagreed_at IS NOT NULL OR agreed_at IS NOT NULL OR deferred_at IS NOT NULL) AND post_id = p.id)::int AS previous_flags_count - FROM posts p - WHERE p.id in (:post_ids) - SQL - - post_lookup = {} - user_ids = Set.new - topic_ids = Set.new - - posts.each do |p| - user_ids << p.user_id - topic_ids << p.topic_id - p.excerpt = Post.excerpt(p.excerpt) - post_lookup[p.id] = p - end - - all_post_actions = [] - reviewables.each do |r| - post = post_lookup[r.target_id] - post.post_action_ids ||= [] - - r.reviewable_scores.order('created_at desc').each do |rs| - action = { - id: rs.id, - post_id: post.id, - user_id: rs.user_id, - post_action_type_id: rs.reviewable_score_type, - created_at: rs.created_at, - disposed_by_id: rs.reviewed_by_id, - disposed_at: rs.reviewed_at, - disposition: ReviewableScore.statuses[rs.status], - targets_topic: r.payload['targets_topic'], - staff_took_action: rs.took_action? - } - action[:name_key] = PostActionType.types.key(rs.reviewable_score_type) - - if rs.meta_topic.present? - meta_posts = rs.meta_topic.ordered_posts - - conversation = {} - if response = meta_posts[0] - action[:related_post_id] = response.id - - conversation[:response] = { - excerpt: excerpt(response.cooked), - user_id: response.user_id - } - user_ids << response.user_id - if reply = meta_posts[1] - conversation[:reply] = { - excerpt: excerpt(reply.cooked), - user_id: reply.user_id - } - user_ids << reply.user_id - conversation[:has_more] = rs.meta_topic.posts_count > 2 - end - end - - action.merge!(permalink: rs.meta_topic.relative_url, conversation: conversation) - end - - post.post_action_ids << action[:id] - all_post_actions << action - user_ids << action[:user_id] - user_ids << rs.reviewed_by_id if rs.reviewed_by_id - end - end - - post_custom_field_names = [] - plugin_post_custom_fields.each do |field, plugin| - post_custom_field_names << field if plugin.enabled? - end - - post_custom_fields = Post.custom_fields_for_ids(post_ids, post_custom_field_names) - - # maintain order - posts = post_ids.map { |id| post_lookup[id] } - - # TODO: add serializer so we can skip this - posts.map! do |post| - result = post.to_h - if cfs = post_custom_fields[post.id] - result[:custom_fields] = cfs - end - result - end - - guardian = Guardian.new(current_user) - users = User.includes(:user_stat).where(id: user_ids.to_a).to_a - User.preload_custom_fields(users, User.allowed_user_custom_fields(guardian)) - - [ - posts, - Topic.with_deleted.where(id: topic_ids.to_a).to_a, - users, - all_post_actions, - total_rows - ] - end - - def self.flagged_post_actions(opts = nil) - Discourse.deprecate("FlagQuery is deprecated, please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4") - - opts ||= {} - - scores = ReviewableScore.includes(:reviewable).where('reviewables.type' => 'ReviewableFlaggedPost') - scores = scores.where('reviewables.topic_id' => opts[:topic_id]) if opts[:topic_id] - scores = scores.where('reviewables.target_created_by_id' => opts[:user_id]) if opts[:user_id] - - if opts[:filter] == 'without_custom' - return scores.where(reviewable_score_type: PostActionType.flag_types_without_custom.values) - end - - if opts[:filter] == "old" - scores = scores.where('reviewables.status <> ?', Reviewable.statuses[:pending]) - else - scores = scores.where('reviewables.status' => Reviewable.statuses[:pending]) - end - - scores - end - - def self.flagged_topics - Discourse.deprecate("FlagQuery has been deprecated. Please use the Reviewable API instead.", since: "2.3.0beta5", drop_from: "2.4") - - params = { - pending: Reviewable.statuses[:pending], - min_score: Reviewable.min_score_for_priority - } - - results = DB.query(<<~SQL, params) - SELECT rs.reviewable_score_type, - p.id AS post_id, - r.topic_id, - rs.created_at, - p.user_id - FROM reviewables AS r - INNER JOIN reviewable_scores AS rs ON rs.reviewable_id = r.id - INNER JOIN posts AS p ON p.id = r.target_id - WHERE r.type = 'ReviewableFlaggedPost' - AND r.status = :pending - AND r.score >= :min_score - ORDER BY rs.created_at DESC - SQL - - ft_by_id = {} - user_ids = Set.new - - results.each do |r| - ft = ft_by_id[r.topic_id] ||= OpenStruct.new( - topic_id: r.topic_id, - flag_counts: {}, - user_ids: Set.new, - last_flag_at: r.created_at, - ) - - ft.flag_counts[r.reviewable_score_type] ||= 0 - ft.flag_counts[r.reviewable_score_type] += 1 - - ft.user_ids << r.user_id - user_ids << r.user_id - end - - all_topics = Topic.where(id: ft_by_id.keys).to_a - all_topics.each { |t| ft_by_id[t.id].topic = t } - - Topic.preload_custom_fields(all_topics, TopicList.preloaded_custom_fields) - { - flagged_topics: ft_by_id.values, - users: User.where(id: user_ids) - } - end end diff --git a/lib/freedom_patches/active_record_base.rb b/lib/freedom_patches/active_record_base.rb index 0a7a54e763..f4909ae1f8 100644 --- a/lib/freedom_patches/active_record_base.rb +++ b/lib/freedom_patches/active_record_base.rb @@ -26,7 +26,7 @@ class ActiveRecord::Base # Execute SQL manually def self.exec_sql(*args) - Discourse.deprecate("exec_sql should not be used anymore, please use DB.exec or DB.query instead!") + Discourse.deprecate("exec_sql should not be used anymore, please use DB.exec or DB.query instead!", drop_from: '2.9.0') conn = ActiveRecord::Base.connection sql = ActiveRecord::Base.public_send(:sanitize_sql_array, args) diff --git a/lib/guardian.rb b/lib/guardian.rb index 693222846f..a4af71e2a5 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -397,7 +397,7 @@ class Guardian end def can_bulk_invite_to_forum?(user) - user.admin? && !SiteSetting.enable_discourse_connect + user.admin? end def can_resend_all_invites?(user) @@ -494,7 +494,7 @@ class Guardian def allow_themes?(theme_ids, include_preview: false) return true if theme_ids.blank? - if allowed_theme_ids = GlobalSetting.allowed_theme_ids + if allowed_theme_ids = Theme.allowed_remote_theme_ids if (theme_ids - allowed_theme_ids).present? return false end diff --git a/lib/guardian/topic_guardian.rb b/lib/guardian/topic_guardian.rb index 1d33bfdb24..ef619bd112 100644 --- a/lib/guardian/topic_guardian.rb +++ b/lib/guardian/topic_guardian.rb @@ -156,7 +156,7 @@ module TopicGuardian def can_permanently_delete_topic?(topic) return false if !SiteSetting.can_permanently_delete return false if !topic - return false if topic.posts_count > 1 + return false if topic.posts_count > 0 return false if !is_admin? || !can_see_topic?(topic) return false if !topic.deleted_at return false if topic.deleted_by_id == @user.id && topic.deleted_at >= Post::PERMANENT_DELETE_TIMER.ago diff --git a/lib/i18n/locale_file_checker.rb b/lib/i18n/locale_file_checker.rb index e818823fd8..757e9b969a 100644 --- a/lib/i18n/locale_file_checker.rb +++ b/lib/i18n/locale_file_checker.rb @@ -165,7 +165,7 @@ class LocaleFileChecker def plural_keys @plural_keys ||= begin - eval(File.read("#{Rails.root}/#{PLURALS_FILE}")).map do |locale, value| + eval(File.read("#{Rails.root}/#{PLURALS_FILE}")).map do |locale, value| # rubocop:disable Security/Eval [locale.to_s, value[:i18n][:plural][:keys].map(&:to_s)] end.to_h end diff --git a/lib/imap/sync.rb b/lib/imap/sync.rb index 5f034270dc..040910cbc8 100644 --- a/lib/imap/sync.rb +++ b/lib/imap/sync.rb @@ -141,7 +141,7 @@ module Imap message_id: Email.message_id_clean(email['ENVELOPE'].message_id), imap_uid: nil, imap_uid_validity: nil - ).where("to_addresses LIKE '%#{@group.email_username}%'").first + ).where("to_addresses LIKE ?", "%#{@group.email_username}%").first if incoming_email incoming_email.update( diff --git a/lib/import_export/base_exporter.rb b/lib/import_export/base_exporter.rb index 509266652c..5a7f4a6195 100644 --- a/lib/import_export/base_exporter.rb +++ b/lib/import_export/base_exporter.rb @@ -14,7 +14,7 @@ module ImportExport :public_admission, :membership_request_template, :messageable_level, :mentionable_level, :members_visibility_level, :publish_read_state] - USER_ATTRS = [:id, :email, :username, :name, :created_at, :trust_level, :active, :last_emailed_at] + USER_ATTRS = [:id, :email, :username, :name, :created_at, :trust_level, :active, :last_emailed_at, :custom_fields] TOPIC_ATTRS = [:id, :title, :created_at, :views, :category_id, :closed, :archived, :archetype] diff --git a/lib/middleware/anonymous_cache.rb b/lib/middleware/anonymous_cache.rb index a62fb01836..cb06d8793f 100644 --- a/lib/middleware/anonymous_cache.rb +++ b/lib/middleware/anonymous_cache.rb @@ -29,7 +29,7 @@ module Middleware method << "|#{k}=#\{h.#{v}}" end method << "\"\nend" - eval(method) + eval(method) # rubocop:disable Security/Eval @@compiled = true end @@ -315,7 +315,7 @@ module Middleware if PAYLOAD_INVALID_REQUEST_METHODS.include?(env[Rack::REQUEST_METHOD]) && env[Rack::RACK_INPUT].size > 0 - return [413, {}, []] + return [413, { "Cache-Control" => "private, max-age=0, must-revalidate" }, []] end helper = Helper.new(env) diff --git a/lib/middleware/discourse_public_exceptions.rb b/lib/middleware/discourse_public_exceptions.rb index 8b9cbc827d..fcc2b5ed26 100644 --- a/lib/middleware/discourse_public_exceptions.rb +++ b/lib/middleware/discourse_public_exceptions.rb @@ -35,7 +35,7 @@ module Middleware begin request.format rescue Mime::Type::InvalidMimeType - return [400, {}, ["Invalid MIME type"]] + return [400, { "Cache-Control" => "private, max-age=0, must-revalidate" }, ["Invalid MIME type"]] end if ApplicationController.rescue_with_handler(exception, object: fake_controller) diff --git a/lib/new_post_result.rb b/lib/new_post_result.rb index 25d61f497a..4fa50b2486 100644 --- a/lib/new_post_result.rb +++ b/lib/new_post_result.rb @@ -36,7 +36,8 @@ class NewPostResult def queued_post Discourse.deprecate( "NewPostManager#queued_post is deprecated. Please use #reviewable instead.", - output_in_test: true + output_in_test: true, + drop_from: '2.9.0', ) reviewable diff --git a/lib/onebox/engine/github_actions_onebox.rb b/lib/onebox/engine/github_actions_onebox.rb index cdb7e16378..182fff49d5 100644 --- a/lib/onebox/engine/github_actions_onebox.rb +++ b/lib/onebox/engine/github_actions_onebox.rb @@ -71,7 +71,7 @@ module Onebox raw["head_commit"]["message"].lines.first elsif type == :pr_run pr_url = "https://api.github.com/repos/#{match[:org]}/#{match[:repo]}/pulls/#{match[:pr_id]}" - ::MultiJson.load(URI.open(pr_url, read_timeout: timeout))["title"] + ::MultiJson.load(URI.parse(pr_url).open(read_timeout: timeout))["title"] end { diff --git a/lib/onebox/engine/instagram_onebox.rb b/lib/onebox/engine/instagram_onebox.rb index 21a8ae6c6f..7cc96ad3d6 100644 --- a/lib/onebox/engine/instagram_onebox.rb +++ b/lib/onebox/engine/instagram_onebox.rb @@ -9,22 +9,41 @@ module Onebox matches_regexp(/^https?:\/\/(?:www\.)?(?:instagram\.com|instagr\.am)\/?(?:.*)\/(?:p|tv)\/[a-zA-Z\d_-]+/) always_https + requires_iframe_origins "https://www.instagram.com" def clean_url url.scan(/^https?:\/\/(?:www\.)?(?:instagram\.com|instagr\.am)\/?(?:.*)\/(?:p|tv)\/[a-zA-Z\d_-]+/).flatten.first end def data - oembed = get_oembed - raise "No oEmbed data found. Ensure 'facebook_app_access_token' is valid" if oembed.data.empty? + @data ||= begin + oembed = get_oembed + raise "No oEmbed data found. Ensure 'facebook_app_access_token' is valid" if oembed.data.empty? - { - link: clean_url.gsub("/#{oembed.author_name}/", "/"), - title: "@#{oembed.author_name}", - image: oembed.thumbnail_url, - description: Onebox::Helpers.truncate(oembed.title, 250), - } + { + link: clean_url.gsub("/#{oembed.author_name}/", "/") + '/embed', + title: "@#{oembed.author_name}", + image: oembed.thumbnail_url, + image_width: oembed.data[:thumbnail_width], + image_height: oembed.data[:thumbnail_height], + description: Onebox::Helpers.truncate(oembed.title, 250), + } + end + end + def placeholder_html + ::Onebox::Helpers.image_placeholder_html + end + + def to_html + <<-HTML + + HTML end protected diff --git a/lib/onebox/engine/json.rb b/lib/onebox/engine/json.rb index 261dc0309c..204b09c720 100644 --- a/lib/onebox/engine/json.rb +++ b/lib/onebox/engine/json.rb @@ -6,7 +6,7 @@ module Onebox private def raw - @raw ||= ::MultiJson.load(URI.open(url, read_timeout: timeout)) + @raw ||= ::MultiJson.load(URI.parse(url).open(read_timeout: timeout)) end end end diff --git a/lib/onebox/engine/pubmed_onebox.rb b/lib/onebox/engine/pubmed_onebox.rb index 1cf8a0ac9b..366d5d5029 100644 --- a/lib/onebox/engine/pubmed_onebox.rb +++ b/lib/onebox/engine/pubmed_onebox.rb @@ -12,7 +12,7 @@ module Onebox def xml return @xml if defined?(@xml) - doc = Nokogiri::XML(URI.open(URI.join(@url, "?report=xml&format=text"))) + doc = Nokogiri::XML(URI.join(@url, "?report=xml&format=text").open) pre = doc.xpath("//pre") @xml = Nokogiri::XML("" + pre.text + "") end diff --git a/lib/onebox/helpers.rb b/lib/onebox/helpers.rb index 94138de1ad..4d54ac8d2e 100644 --- a/lib/onebox/helpers.rb +++ b/lib/onebox/helpers.rb @@ -234,6 +234,10 @@ module Onebox Addressable::URI.unencode(url) end + def self.image_placeholder_html + "
" + end + def self.video_placeholder_html "
" end diff --git a/lib/onebox/mixins/git_blob_onebox.rb b/lib/onebox/mixins/git_blob_onebox.rb index cac7b2a764..0c9b2bda12 100644 --- a/lib/onebox/mixins/git_blob_onebox.rb +++ b/lib/onebox/mixins/git_blob_onebox.rb @@ -167,7 +167,7 @@ module Onebox @model_file = @lang.dup @raw = "https://render.githubusercontent.com/view/solid?url=" + self.raw_template(m) else - contents = URI.open(self.raw_template(m), read_timeout: timeout).read + contents = URI.parse(self.raw_template(m)).open(read_timeout: timeout).read contents_lines = contents.lines #get contents lines contents_lines_size = contents_lines.size #get number of lines diff --git a/lib/onebox/status_check.rb b/lib/onebox/status_check.rb index 73b3bca24e..0980b8d135 100644 --- a/lib/onebox/status_check.rb +++ b/lib/onebox/status_check.rb @@ -35,7 +35,7 @@ module Onebox private def check - res = URI.open(@url, read_timeout: (@options.timeout || Onebox.options.timeout)) + res = URI.parse(@url).open(read_timeout: (@options.timeout || Onebox.options.timeout)) @status = res.status.first.to_i rescue OpenURI::HTTPError => e @status = e.io.status.first.to_i diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index f7e9305e34..e59a6fbc05 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -326,8 +326,8 @@ class Plugin::Instance # Add a post_custom_fields_allowlister block to the TopicView, respecting if the plugin is enabled def topic_view_post_custom_fields_allowlister(&block) reloadable_patch do |plugin| - ::TopicView.add_post_custom_fields_allowlister do |user| - plugin.enabled? ? block.call(user) : [] + ::TopicView.add_post_custom_fields_allowlister do |user, topic| + plugin.enabled? ? block.call(user, topic) : [] end end end @@ -744,9 +744,9 @@ class Plugin::Instance provider.authenticator.enabled? rescue NotImplementedError provider.authenticator.define_singleton_method(:enabled?) do - Discourse.deprecate("#{provider.authenticator.class.name} should define an `enabled?` function. Patching for now.") + Discourse.deprecate("#{provider.authenticator.class.name} should define an `enabled?` function. Patching for now.", drop_from: '2.9.0') return SiteSetting.get(provider.enabled_setting) if provider.enabled_setting - Discourse.deprecate("#{provider.authenticator.class.name} has not defined an enabled_setting. Defaulting to true.") + Discourse.deprecate("#{provider.authenticator.class.name} has not defined an enabled_setting. Defaulting to true.", drop_from: '2.9.0') true end end @@ -950,6 +950,12 @@ class Plugin::Instance DiscoursePluginRegistry.register_presence_channel_prefix([prefix, block], self) end + # Registers a new push notification filter. User and notification payload are passed into block, and if all + # filters return `true`, the push notification will be sent. + def register_push_notification_filter(&block) + DiscoursePluginRegistry.register_push_notification_filter(block, self) + end + # Register a ReviewableScore setting_name associated with a reason. # We'll use this to build a site setting link and add it to the reason's translation. # diff --git a/lib/post_action_creator.rb b/lib/post_action_creator.rb index e1f42eef74..31a037844b 100644 --- a/lib/post_action_creator.rb +++ b/lib/post_action_creator.rb @@ -156,13 +156,15 @@ private end def notify_subscribers - if self.class.notify_types.include?(@post_action_name) + if @post_action_name == :like + @post.publish_change_to_clients! :liked, { likes_count: @post.like_count + 1 } + elsif self.class.notify_types.include?(@post_action_name) @post.publish_change_to_clients! :acted end end def self.notify_types - @notify_types ||= ([:like] + PostActionType.notify_flag_types.keys) + @notify_types ||= PostActionType.notify_flag_types.keys end def enforce_rules diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index f87a191918..9d09c7ee32 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -205,7 +205,8 @@ class PostDestroyer @post.revise(@user, { raw: I18n.t(key) }, force_new_version: true, - deleting_post: true + deleting_post: true, + skip_validations: true ) Post.transaction do diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index 42b70d0038..f3b8aa7333 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -531,9 +531,9 @@ class PostRevisor modifications.each_key do |field| if revision.modifications.has_key?(field) - old_value = revision.modifications[field][0].to_s - new_value = modifications[field][1].to_s - if old_value != new_value + old_value = revision.modifications[field][0] + new_value = modifications[field][1] + if old_value.to_s != new_value.to_s revision.modifications[field] = [old_value, new_value] else revision.modifications.delete(field) @@ -545,6 +545,7 @@ class PostRevisor # should probably do this before saving the post! if revision.modifications.empty? revision.destroy + @post.last_editor_id = PostRevision.where(post_id: @post.id).order(number: :desc).pluck_first(:user_id) || @post.user_id @post.version -= 1 @post.public_version -= 1 @post.save diff --git a/lib/presence_channel.rb b/lib/presence_channel.rb index 531e861973..f2e8031c4c 100644 --- a/lib/presence_channel.rb +++ b/lib/presence_channel.rb @@ -36,19 +36,21 @@ class PresenceChannel # count_only: boolean. If true, user identities are never revealed to clients. (default []) class Config NOT_FOUND ||= "notfound" - attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only - def initialize(public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false) + attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout + + def initialize(public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false, timeout: nil) @public = public @allowed_user_ids = allowed_user_ids @allowed_group_ids = allowed_group_ids @count_only = count_only + @timeout = timeout end def self.from_json(json) data = JSON.parse(json, symbolize_names: true) data = {} if !data.is_a? Hash - new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only)) + new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout)) end def to_json @@ -56,12 +58,13 @@ class PresenceChannel data[:allowed_user_ids] = allowed_user_ids if allowed_user_ids data[:allowed_group_ids] = allowed_group_ids if allowed_group_ids data[:count_only] = count_only if count_only + data[:timeout] = timeout if timeout data.to_json end end DEFAULT_TIMEOUT ||= 60 - CONFIG_CACHE_SECONDS ||= 120 + CONFIG_CACHE_SECONDS ||= 10 GC_SECONDS ||= 24.hours.to_i MUTEX_TIMEOUT_SECONDS ||= 10 MUTEX_LOCKED_ERROR ||= "PresenceChannel mutex is locked" @@ -72,7 +75,6 @@ class PresenceChannel def initialize(name, raise_not_found: true, use_cache: true) @name = name - @timeout = DEFAULT_TIMEOUT @message_bus_channel_name = "/presence#{name}" begin @@ -81,6 +83,8 @@ class PresenceChannel raise if raise_not_found @config = Config.new end + + @timeout = config.timeout || DEFAULT_TIMEOUT end # Is this user allowed to view this channel? @@ -281,7 +285,7 @@ class PresenceChannel # should not exist, the block should return `nil`. If the channel should exist, # the block should return a PresenceChannel::Config object. # - # Return values may be cached for up to 2 minutes. + # Return values may be cached for up to 10 seconds. # # Plugins should use the {Plugin::Instance.register_presence_channel_prefix} API instead def self.register_prefix(prefix, &block) diff --git a/lib/s3_cors_rulesets.rb b/lib/s3_cors_rulesets.rb new file mode 100644 index 0000000000..7178e1a45f --- /dev/null +++ b/lib/s3_cors_rulesets.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_dependency "s3_helper" + +class S3CorsRulesets + ASSETS = { + allowed_headers: ["Authorization"], + allowed_methods: ["GET", "HEAD"], + allowed_origins: ["*"], + max_age_seconds: 3000 + }.freeze + + BACKUP_DIRECT_UPLOAD = { + allowed_headers: ["*"], + expose_headers: ["ETag"], + allowed_methods: ["GET", "HEAD", "PUT"], + allowed_origins: ["*"], + max_age_seconds: 3000 + }.freeze + + DIRECT_UPLOAD = { + allowed_headers: ["Authorization", "Content-Disposition", "Content-Type"], + expose_headers: ["ETag"], + allowed_methods: ["GET", "HEAD", "PUT"], + allowed_origins: ["*"], + max_age_seconds: 3000 + }.freeze + + RULE_STATUS_SKIPPED = "rules_skipped_from_settings" + RULE_STATUS_EXISTED = "rules_already_existed" + RULE_STATUS_APPLIED = "rules_applied" + + ## + # Used by the s3:ensure_cors_rules rake task to make sure the + # relevant CORS rules are applied to allow for direct uploads to + # S3, and in the case of assets rules so there are fonts and other + # public assets for the site loaded correctly. + # + # The use_db_s3_config param comes from ENV, and if the S3 client + # is not provided it is initialized by the S3Helper. + def self.sync(use_db_s3_config:, s3_client: nil) + return if !SiteSetting.s3_install_cors_rule + return if !(GlobalSetting.use_s3? || SiteSetting.enable_s3_uploads) + + assets_rules_status = RULE_STATUS_SKIPPED + backup_rules_status = RULE_STATUS_SKIPPED + direct_upload_rules_status = RULE_STATUS_SKIPPED + + s3_helper = S3Helper.build_from_config( + s3_client: s3_client, use_db_s3_config: use_db_s3_config + ) + puts "Attempting to apply ASSETS S3 CORS ruleset in bucket #{s3_helper.s3_bucket_name}." + assets_rules_status = s3_helper.ensure_cors!([S3CorsRulesets::ASSETS]) ? RULE_STATUS_APPLIED : RULE_STATUS_EXISTED + + if SiteSetting.enable_backups? && SiteSetting.backup_location == BackupLocationSiteSetting::S3 + backup_s3_helper = S3Helper.build_from_config( + s3_client: s3_client, use_db_s3_config: use_db_s3_config, for_backup: true + ) + puts "Attempting to apply BACKUP_DIRECT_UPLOAD S3 CORS ruleset in bucket #{backup_s3_helper.s3_bucket_name}." + backup_rules_status = backup_s3_helper.ensure_cors!([S3CorsRulesets::BACKUP_DIRECT_UPLOAD]) ? RULE_STATUS_APPLIED : RULE_STATUS_EXISTED + end + + if SiteSetting.enable_direct_s3_uploads + puts "Attempting to apply DIRECT_UPLOAD S3 CORS ruleset in bucket #{s3_helper.s3_bucket_name}." + direct_upload_rules_status = s3_helper.ensure_cors!([S3CorsRulesets::DIRECT_UPLOAD]) ? RULE_STATUS_APPLIED : RULE_STATUS_EXISTED + end + + { + assets_rules_status: assets_rules_status, + backup_rules_status: backup_rules_status, + direct_upload_rules_status: direct_upload_rules_status + } + end +end diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index f5a5773295..12bcd22a3a 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -40,6 +40,21 @@ class S3Helper end end + def self.build_from_config(use_db_s3_config: false, for_backup: false, s3_client: nil) + setting_klass = use_db_s3_config ? SiteSetting : GlobalSetting + options = S3Helper.s3_options(setting_klass) + options[:client] = s3_client if s3_client.present? + + bucket = + if for_backup + setting_klass.s3_backup_bucket + else + use_db_s3_config ? SiteSetting.s3_upload_bucket : GlobalSetting.s3_bucket + end + + S3Helper.new(bucket.downcase, '', options) + end + def self.get_bucket_and_folder_path(s3_bucket_name) s3_bucket_name.downcase.split("/", 2) end @@ -63,6 +78,10 @@ class S3Helper [path, etag.gsub('"', '')] end + def path_from_url(url) + URI.parse(url).path.delete_prefix("/") + end + def remove(s3_filename, copy_to_tombstone = false) s3_filename = s3_filename.dup @@ -124,36 +143,36 @@ class S3Helper [destination, response.copy_object_result.etag.gsub('"', '')] end - # make sure we have a cors config for assets - # otherwise we will have no fonts + # Several places in the application need certain CORS rules to exist + # inside an S3 bucket so requests to the bucket can be made + # directly from the browser. The s3:ensure_cors_rules rake task + # is used to ensure these rules exist for assets, S3 backups, and + # direct S3 uploads, depending on configuration. def ensure_cors!(rules = nil) return unless SiteSetting.s3_install_cors_rule + rules = [rules] if !rules.is_a?(Array) + existing_rules = fetch_bucket_cors_rules - rule = nil + new_rules = rules - existing_rules + return false if new_rules.empty? + + final_rules = existing_rules + new_rules begin - rule = s3_resource.client.get_bucket_cors( - bucket: @s3_bucket_name - ).cors_rules&.first - rescue Aws::S3::Errors::NoSuchCORSConfiguration - # no rule - end - - unless rule - rules = [{ - allowed_headers: ["Authorization"], - allowed_methods: ["GET", "HEAD"], - allowed_origins: ["*"], - max_age_seconds: 3000 - }] if rules.nil? - s3_resource.client.put_bucket_cors( bucket: @s3_bucket_name, cors_configuration: { - cors_rules: rules + 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}") + return false end + + true end def update_lifecycle(id, days, prefix: nil, tag: nil) @@ -267,8 +286,105 @@ class S3Helper get_path_for_s3_upload(path) end + def abort_multipart(key:, upload_id:) + s3_client.abort_multipart_upload( + bucket: s3_bucket_name, + key: key, + upload_id: upload_id + ) + end + + def create_multipart(key, content_type, metadata: {}) + response = s3_client.create_multipart_upload( + acl: "private", + bucket: s3_bucket_name, + key: key, + content_type: content_type, + metadata: metadata + ) + { upload_id: response.upload_id, key: key } + end + + def presign_multipart_part(upload_id:, key:, part_number:) + presigned_url( + key, + method: :upload_part, + expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, + opts: { + part_number: part_number, + upload_id: upload_id + } + ) + end + + # Important note from the S3 documentation: + # + # This request returns a default and maximum of 1000 parts. + # You can restrict the number of parts returned by specifying the + # max_parts argument. If your multipart upload consists of more than 1,000 + # parts, the response returns an IsTruncated field with the value of true, + # and a NextPartNumberMarker element. + # + # In subsequent ListParts requests you can include the part_number_marker arg + # using the NextPartNumberMarker the field value from the previous response to + # get more parts. + # + # See https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#list_parts-instance_method + def list_multipart_parts(upload_id:, key:, max_parts: 1000, start_from_part_number: nil) + options = { + bucket: s3_bucket_name, + key: key, + upload_id: upload_id, + max_parts: max_parts + } + + if start_from_part_number.present? + options[:part_number_marker] = start_from_part_number + end + + s3_client.list_parts(options) + end + + def complete_multipart(upload_id:, key:, parts:) + s3_client.complete_multipart_upload( + bucket: s3_bucket_name, + key: key, + upload_id: upload_id, + multipart_upload: { + parts: parts + } + ) + end + + def presigned_url( + key, + method:, + expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, + opts: {} + ) + Aws::S3::Presigner.new(client: s3_client).presigned_url( + method, + { + bucket: s3_bucket_name, + key: key, + expires_in: expires_in, + }.merge(opts) + ) + end + private + def fetch_bucket_cors_rules + begin + s3_resource.client.get_bucket_cors( + bucket: @s3_bucket_name + ).cors_rules&.map(&:to_h) || [] + rescue Aws::S3::Errors::NoSuchCORSConfiguration + # no rule + [] + end + end + def default_s3_options if SiteSetting.enable_s3_uploads? options = self.class.s3_options(SiteSetting) diff --git a/lib/search.rb b/lib/search.rb index dd5581d886..def3be0fd7 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -69,19 +69,17 @@ class Search SiteSetting.search_tokenize_chinese_japanese_korean end - def self.prepare_data(search_data, purpose = :query) - purpose ||= :query - + def self.prepare_data(search_data, purpose = nil) data = search_data.dup data.force_encoding("UTF-8") + if purpose != :topic # TODO cppjieba_rb is designed for chinese, we need something else for Japanese # Korean appears to be safe cause words are already space separated # For Japanese we should investigate using kakasi if segment_cjk? require 'cppjieba_rb' unless defined? CppjiebaRb - mode = (purpose == :query ? :query : :mix) - data = CppjiebaRb.segment(search_data, mode: mode) + data = CppjiebaRb.segment(search_data, mode: :mix) # TODO: we still want to tokenize here but the current stopword list is too wide # in cppjieba leading to words such as volume to be skipped. PG already has an English @@ -93,7 +91,6 @@ class Search end data = data.join(' ') - else data.squish! end @@ -896,12 +893,15 @@ class Search def posts_query(limit, type_filter: nil, aggregate_search: false) posts = Post.where(post_type: Topic.visible_post_types(@guardian.user)) .joins(:post_search_data, :topic) - .joins("LEFT JOIN categories ON categories.id = topics.category_id") + + if type_filter != "private_messages" + posts = posts.joins("LEFT JOIN categories ON categories.id = topics.category_id") + end is_topic_search = @search_context.present? && @search_context.is_a?(Topic) posts = posts.where("topics.visible") unless is_topic_search - if type_filter === "private_messages" || (is_topic_search && @search_context.private_message?) + if type_filter == "private_messages" || (is_topic_search && @search_context.private_message?) posts = posts .where( "topics.archetype = ? AND post_search_data.private_message", @@ -911,7 +911,7 @@ class Search unless @guardian.is_admin? posts = posts.private_posts_for_user(@guardian.user) end - elsif type_filter === "all_topics" + elsif type_filter == "all_topics" private_posts = posts .where( "topics.archetype = ? AND post_search_data.private_message", @@ -974,7 +974,7 @@ class Search posts = if @search_context.present? if @search_context.is_a?(User) - if type_filter === "private_messages" + if type_filter == "private_messages" if @guardian.is_admin? && !@search_all_pms posts.private_posts_for_user(@search_context) else @@ -990,7 +990,7 @@ class Search .push(@search_context.id) posts.where("topics.category_id in (?)", category_ids) - elsif @search_context.is_a?(Topic) + elsif is_topic_search posts.where("topics.id = #{@search_context.id}") .order("posts.post_number #{@order == :latest ? "DESC" : ""}") elsif @search_context.is_a?(Tag) @@ -1028,7 +1028,7 @@ class Search else posts = posts.order("posts.like_count DESC") end - else + elsif !is_topic_search rank = <<~SQL TS_RANK_CD( post_search_data.search_data, @@ -1037,57 +1037,61 @@ class Search ) SQL - category_search_priority = <<~SQL - ( - CASE categories.search_priority - WHEN #{Searchable::PRIORITIES[:very_high]} - THEN 3 - WHEN #{Searchable::PRIORITIES[:very_low]} - THEN 1 - ELSE 2 - END - ) - SQL - - category_priority_weights = <<~SQL - ( - CASE categories.search_priority - WHEN #{Searchable::PRIORITIES[:low]} - THEN #{SiteSetting.category_search_priority_low_weight} - WHEN #{Searchable::PRIORITIES[:high]} - THEN #{SiteSetting.category_search_priority_high_weight} - ELSE - CASE WHEN topics.closed - THEN 0.9 - ELSE 1 + if type_filter != "private_messages" + category_search_priority = <<~SQL + ( + CASE categories.search_priority + WHEN #{Searchable::PRIORITIES[:very_high]} + THEN 3 + WHEN #{Searchable::PRIORITIES[:very_low]} + THEN 1 + ELSE 2 END - END - ) - SQL + ) + SQL - data_ranking = - if @term.blank? - "(#{category_priority_weights})" - else - "(#{rank} * #{category_priority_weights})" - end + category_priority_weights = <<~SQL + ( + CASE categories.search_priority + WHEN #{Searchable::PRIORITIES[:low]} + THEN #{SiteSetting.category_search_priority_low_weight} + WHEN #{Searchable::PRIORITIES[:high]} + THEN #{SiteSetting.category_search_priority_high_weight} + ELSE + CASE WHEN topics.closed + THEN 0.9 + ELSE 1 + END + END + ) + SQL - posts = - if aggregate_search - posts.order("MAX(#{category_search_priority}) DESC", "MAX(#{data_ranking}) DESC") - else - posts.order("#{category_search_priority} DESC", "#{data_ranking} DESC") - end + data_ranking = + if @term.blank? + "(#{category_priority_weights})" + else + "(#{rank} * #{category_priority_weights})" + end + + posts = + if aggregate_search + posts.order("MAX(#{category_search_priority}) DESC", "MAX(#{data_ranking}) DESC") + else + posts.order("#{category_search_priority} DESC", "#{data_ranking} DESC") + end + end posts = posts.order("topics.bumped_at DESC") end - posts = - if secure_category_ids.present? - posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted) OR (categories.id IN (?))", secure_category_ids).references(:categories) - else - posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted)").references(:categories) - end + if type_filter != "private_messages" + posts = + if secure_category_ids.present? + posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted) OR (categories.id IN (?))", secure_category_ids).references(:categories) + else + posts.where("(categories.id IS NULL) OR (NOT categories.read_restricted)").references(:categories) + end + end if @order advanced_order = Search.advanced_orders&.fetch(@order, nil) diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index dd529e8e71..c86c67ff24 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -2,6 +2,7 @@ module SiteSettingExtension include SiteSettings::DeprecatedSettings + include HasSanitizableFields # support default_locale being set via global settings # this also adds support for testing the extension and global settings @@ -362,8 +363,12 @@ module SiteSettingExtension def add_override!(name, val) old_val = current[name] val, type = type_supervisor.to_db_value(name, val) - provider.save(name, val, type) - current[name] = type_supervisor.to_rb_value(name, val) + + sanitize_override = val.is_a?(String) && client_settings.include?(name) + + sanitized_val = sanitize_override ? sanitize_field(val) : val + provider.save(name, sanitized_val, type) + current[name] = type_supervisor.to_rb_value(name, sanitized_val) clear_uploads_cache(name) notify_clients!(name) if client_settings.include? name clear_cache! diff --git a/lib/site_settings/type_supervisor.rb b/lib/site_settings/type_supervisor.rb index 99170c3dc0..428aadfe4c 100644 --- a/lib/site_settings/type_supervisor.rb +++ b/lib/site_settings/type_supervisor.rb @@ -92,7 +92,7 @@ class SiteSettings::TypeSupervisor end if (new_choices = opts[:choices]) - new_choices = eval(new_choices) if new_choices.is_a?(String) + new_choices = eval(new_choices) if new_choices.is_a?(String) # rubocop:disable Security/Eval if @choices.has_key?(name) @choices[name].concat(new_choices) diff --git a/lib/sql_builder.rb b/lib/sql_builder.rb index 669cf17716..1517cc812e 100644 --- a/lib/sql_builder.rb +++ b/lib/sql_builder.rb @@ -4,7 +4,7 @@ class SqlBuilder def initialize(template, klass = nil) - Discourse.deprecate("SqlBuilder is deprecated and will be removed, please use DB.build instead!") + Discourse.deprecate("SqlBuilder is deprecated and will be removed, please use DB.build instead!", drop_from: '2.9.0') @args = {} @sql = template diff --git a/lib/stylesheet/functions.rb b/lib/stylesheet/functions.rb index 4abc98a51a..072998ab76 100644 --- a/lib/stylesheet/functions.rb +++ b/lib/stylesheet/functions.rb @@ -3,11 +3,11 @@ module Stylesheet module ScssFunctions def asset_url(path) - Discourse.deprecate("The `asset-url` SCSS function is deprecated. Use `absolute-image-url` instead.") + Discourse.deprecate("The `asset-url` SCSS function is deprecated. Use `absolute-image-url` instead.", drop_from: '2.9.0') SassC::Script::Value::String.new("url('#{ActionController::Base.helpers.asset_url(path.value)}')") end def image_url(path) - Discourse.deprecate("The `image-url` SCSS function is deprecated. Use `absolute-image-url` instead.") + Discourse.deprecate("The `image-url` SCSS function is deprecated. Use `absolute-image-url` instead.", drop_from: '2.9.0') SassC::Script::Value::String.new("url('#{ActionController::Base.helpers.image_url(path.value)}')") end end diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index f4b1294ea5..0b1c3b757e 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -132,7 +132,6 @@ module SvgSprite "globe-americas", "hand-point-right", "hands-helping", - "heading", "heart", "history", "home", @@ -176,7 +175,6 @@ module SvgSprite "search", "share", "shield-alt", - "shower", "sign-in-alt", "sign-out-alt", "signal", @@ -184,7 +182,6 @@ module SvgSprite "star", "step-backward", "step-forward", - "stopwatch", "stream", "sync-alt", "sync", @@ -201,7 +198,6 @@ module SvgSprite "toggle-off", "toggle-on", "trash-alt", - "tv", "undo", "unlink", "unlock", diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake index a3d5895892..88430a7951 100644 --- a/lib/tasks/categories.rake +++ b/lib/tasks/categories.rake @@ -14,7 +14,12 @@ task "categories:move_topics", [:from_category, :to_category] => [:environment] if from_category.present? && to_category.present? puts "Moving topics from #{from_category.slug} to #{to_category.slug}..." - Topic.where(category_id: from_category.id).update_all(category_id: to_category.id) + + Topic + .where(category_id: from_category.id) + .where.not(id: from_category.topic_id) + .update_all(category_id: to_category.id) + from_category.update_attribute(:topic_count, 0) puts "Updating category stats..." diff --git a/lib/tasks/cdn.rake b/lib/tasks/cdn.rake index 438e1020fc..b9c6c27501 100644 --- a/lib/tasks/cdn.rake +++ b/lib/tasks/cdn.rake @@ -20,7 +20,7 @@ task 'assets:prestage' => :environment do |t| puts "pre staging: #{assets.join(' ')}" # makes testing simpler leaving this here - config = YAML::load(File.open("#{Rails.root}/config/cdn.yml")) + config = YAML::safe_load(File.open("#{Rails.root}/config/cdn.yml")) start = Time.now diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 3230357df3..8d0c37013e 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -114,7 +114,7 @@ task 'multisite:migrate' => ['db:load_config', 'environment', 'set_locale'] do | raise "Multisite migrate is only supported in production" end - DistributedMutex.synchronize('db_migration', redis: Discourse.redis.without_namespace, validity: 300) do + DistributedMutex.synchronize('db_migration', redis: Discourse.redis.without_namespace, validity: 1200) do # TODO: Switch to processes for concurrent migrations because Rails migration # is not thread safe by default. concurrency = 1 diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake index 5d66206055..1e712e53b9 100644 --- a/lib/tasks/docker.rake +++ b/lib/tasks/docker.rake @@ -19,6 +19,7 @@ # => JS_TIMEOUT set timeout for qunit tests in ms # => WARMUP_TMP_FOLDER runs a single spec to warmup the tmp folder and obtain accurate results when profiling specs. # => EMBER_CLI set to 1 to run JS tests using the Ember CLI +# => EMBER_CLI_BROWSERS comma separated list of browsers to test against. Options are Chrome, Firefox, and Headless Firefox. # # Other useful environment variables (not specific to this rake task) # => COMMIT_HASH used by the discourse_test docker image to load a specific commit of discourse @@ -86,7 +87,7 @@ task 'docker:test' do unless ENV["SKIP_CORE"] puts "Listing prettier offenses in core:" - @good &&= run_or_fail('yarn prettier --list-different "app/assets/stylesheets/**/*.scss" "app/assets/javascripts/**/*.{js,es6}"') + @good &&= run_or_fail('yarn prettier --list-different "app/assets/stylesheets/**/*.scss" "app/assets/javascripts/**/*.js"') end unless ENV["SKIP_PLUGINS"] @@ -225,8 +226,9 @@ task 'docker:test' do if ENV["EMBER_CLI"] Dir.chdir("#{Rails.root}/app/assets/javascripts/discourse") do # rubocop:disable Discourse/NoChdir + browsers = ENV["EMBER_CLI_BROWSERS"] || 'Chrome' @good &&= run_or_fail("yarn install") - @good &&= run_or_fail("yarn ember test") + @good &&= run_or_fail("yarn ember test --launch #{browsers}") end end end diff --git a/lib/tasks/emoji.rake b/lib/tasks/emoji.rake index 18ec5fc034..3cb7f5ff3a 100644 --- a/lib/tasks/emoji.rake +++ b/lib/tasks/emoji.rake @@ -284,7 +284,7 @@ desc "update emoji images" task "emoji:update" do copy_emoji_db - json_db = open(File.join(GENERATED_PATH, "db.json")).read + json_db = File.read(File.join(GENERATED_PATH, "db.json")) db = JSON.parse(json_db) write_db_json(db["emojis"], db["translations"]) @@ -352,7 +352,7 @@ end def generate_emoji_groups(keywords, sections) puts "Generating groups..." - list = open(EMOJI_ORDERING_URL).read + list = URI.parse(EMOJI_ORDERING_URL).read doc = Nokogiri::HTML5(list) table = doc.css("table")[0] diff --git a/lib/tasks/integration.rake b/lib/tasks/integration.rake index 3b01602bcb..9f46772c2e 100644 --- a/lib/tasks/integration.rake +++ b/lib/tasks/integration.rake @@ -19,7 +19,7 @@ task 'integration:create_fixtures' => :environment do filename = "#{Rails.root}/test/javascripts/fixtures/#{type}_fixtures.js" - content = "/*jshint maxlen:10000000 */\nexport default {\n" + content = "export default {\n" urls.each do |url| http_result = fake_xhr("http://localhost:3000#{url}") diff --git a/lib/tasks/posts.rake b/lib/tasks/posts.rake index 955fa97d8b..4b7b35e8f7 100644 --- a/lib/tasks/posts.rake +++ b/lib/tasks/posts.rake @@ -54,7 +54,7 @@ end desc 'Rebake all posts with a quote using a letter_avatar' task 'posts:fix_letter_avatars' => :environment do - return unless SiteSetting.external_system_avatars_enabled + next unless SiteSetting.external_system_avatars_enabled search = Post.where("user_id <> -1") .where("raw LIKE '%/letter\_avatar/%' OR cooked LIKE '%/letter\_avatar/%'") diff --git a/lib/tasks/redis.rake b/lib/tasks/redis.rake index 4e5605b4df..ad824bb503 100644 --- a/lib/tasks/redis.rake +++ b/lib/tasks/redis.rake @@ -1,7 +1,7 @@ # frozen_string_literal: true task 'redis:clean_up' => ['environment'] do - return unless Rails.configuration.multisite + next unless Rails.configuration.multisite dbs = RailsMultisite::ConnectionManagement.all_dbs dbs << Discourse::SIDEKIQ_NAMESPACE diff --git a/lib/tasks/s3.rake b/lib/tasks/s3.rake index e775e5c260..7b7258d6d4 100644 --- a/lib/tasks/s3.rake +++ b/lib/tasks/s3.rake @@ -46,22 +46,7 @@ def use_db_s3_config end def helper - @helper ||= begin - bucket, options = - if use_db_s3_config - [ - SiteSetting.s3_upload_bucket.downcase, - S3Helper.s3_options(SiteSetting) - ] - else - [ - GlobalSetting.s3_bucket.downcase, - S3Helper.s3_options(GlobalSetting) - ] - end - - S3Helper.new(bucket, '', options) - end + @helper ||= S3Helper.build_from_config(use_db_s3_config: use_db_s3_config) end def assets @@ -186,12 +171,23 @@ task 's3:correct_cachecontrol' => :environment do end -task 's3:upload_assets' => :environment do +task 's3:ensure_cors_rules' => :environment do ensure_s3_configured! - puts "installing CORS rule" - helper.ensure_cors! + puts "Installing CORS rules..." + result = S3CorsRulesets.sync(use_db_s3_config: use_db_s3_config) + if !result + puts "skipping" + next + end + + puts "Assets rules status: #{result[:assets_rules_status]}." + puts "Backup rules status: #{result[:backup_rules_status]}." + puts "Direct upload rules status: #{result[:direct_upload_rules_status]}." +end + +task 's3:upload_assets' => [:environment, 's3:ensure_cors_rules'] do assets.each do |asset| upload(*asset) end diff --git a/lib/tasks/themes.rake b/lib/tasks/themes.rake index 1e2fdfacc0..cd9eda143f 100644 --- a/lib/tasks/themes.rake +++ b/lib/tasks/themes.rake @@ -30,7 +30,7 @@ task "themes:install" => :environment do |task, args| use_json = theme_args == '' theme_args = begin - use_json ? JSON.parse(ARGV.last.gsub('--', '')) : YAML::load(theme_args) + use_json ? JSON.parse(ARGV.last.gsub('--', '')) : YAML::safe_load(theme_args) rescue puts use_json ? "Invalid JSON input. \n#{ARGV.last}" : "Invalid YML: \n#{theme_args}" exit 1 diff --git a/lib/tasks/topics.rake b/lib/tasks/topics.rake index a47a0b0c07..f979e625c7 100644 --- a/lib/tasks/topics.rake +++ b/lib/tasks/topics.rake @@ -84,7 +84,7 @@ end task "topics:update_fancy_titles" => :environment do if !SiteSetting.title_fancy_entities? puts "fancy topic titles are disabled" - return + next end DB.exec("UPDATE topics SET fancy_title = NULL") diff --git a/lib/topic_creator.rb b/lib/topic_creator.rb index a250ef69fb..1211c84fe7 100644 --- a/lib/topic_creator.rb +++ b/lib/topic_creator.rb @@ -26,11 +26,13 @@ class TopicCreator category = find_category if category.present? && guardian.can_tag?(topic) - tags = @opts[:tags].present? ? Tag.where(name: @opts[:tags]) : (@opts[:tags] || []) + tags = @opts[:tags].presence || [] + existing_tags = tags.present? ? Tag.where(name: tags) : [] + valid_tags = guardian.can_create_tag? ? tags : existing_tags # both add to topic.errors - DiscourseTagging.validate_min_required_tags_for_category(guardian, topic, category, tags) - DiscourseTagging.validate_required_tags_from_group(guardian, topic, category, tags) + DiscourseTagging.validate_min_required_tags_for_category(guardian, topic, category, valid_tags) + DiscourseTagging.validate_required_tags_from_group(guardian, topic, category, existing_tags) end DiscourseEvent.trigger(:after_validate_topic, topic, self) diff --git a/lib/topic_view.rb b/lib/topic_view.rb index ec21530826..184b11372e 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -54,7 +54,7 @@ class TopicView end def self.default_post_custom_fields - @default_post_custom_fields ||= [Post::NOTICE, "action_code_who"] + @default_post_custom_fields ||= [Post::NOTICE, "action_code_who", "action_code_path"] end def self.post_custom_fields_allowlisters @@ -65,8 +65,8 @@ class TopicView post_custom_fields_allowlisters << block end - def self.allowed_post_custom_fields(user) - wpcf = default_post_custom_fields + post_custom_fields_allowlisters.map { |w| w.call(user) } + def self.allowed_post_custom_fields(user, topic) + wpcf = default_post_custom_fields + post_custom_fields_allowlisters.map { |w| w.call(user, topic) } wpcf.flatten.uniq end @@ -116,7 +116,7 @@ class TopicView @user_custom_fields = User.custom_fields_for_ids(@posts.pluck(:user_id), added_fields) end - if (allowed_fields = TopicView.allowed_post_custom_fields(@user)).present? + if (allowed_fields = TopicView.allowed_post_custom_fields(@user, @topic)).present? @post_custom_fields = Post.custom_fields_for_ids(@posts.pluck(:id), allowed_fields) end end @@ -388,7 +388,7 @@ class TopicView def bookmarks @bookmarks ||= @topic.bookmarks.where(user: @user).joins(:topic).select( - :id, :post_id, :for_topic, :reminder_at, :name, :auto_delete_preference + :id, :post_id, "topics.id AS topic_id", :for_topic, :reminder_at, :name, :auto_delete_preference ) end diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index 3f2d23b41a..522aa47c69 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -200,7 +200,10 @@ class UploadCreator if should_move # move the file in the store instead of reuploading - url = Discourse.store.move_existing_stored_upload(@opts[:existing_external_upload_key], @upload) + url = Discourse.store.move_existing_stored_upload( + existing_external_upload_key: @opts[:existing_external_upload_key], + upload: @upload + ) else # store the file and update its url File.open(@file.path) do |f| diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb index 3a294c0c36..dcb7877339 100644 --- a/lib/user_name_suggester.rb +++ b/lib/user_name_suggester.rb @@ -4,9 +4,9 @@ module UserNameSuggester GENERIC_NAMES = ['i', 'me', 'info', 'support', 'admin', 'webmaster', 'hello', 'mail', 'office', 'contact', 'team'] LAST_RESORT_USERNAME = "user" - def self.suggest(name_or_email, allowed_username = nil) + def self.suggest(name_or_email) name = parse_name_from_email(name_or_email) - find_available_username_based_on(name, allowed_username) + find_available_username_based_on(name) end def self.parse_name_from_email(name_or_email) @@ -20,22 +20,13 @@ module UserNameSuggester name end - def self.find_available_username_based_on(name, allowed_username = nil) + def self.find_available_username_based_on(name) name = fix_username(name) offset = nil i = 1 attempt = name - normalized_attempt = User.normalize_username(attempt) - - original_allowed_username = allowed_username - allowed_username = User.normalize_username(allowed_username) if allowed_username - - until ( - normalized_attempt == allowed_username || - User.username_available?(attempt) || - i > 100 - ) + until User.username_available?(attempt) || i > 100 if offset.nil? normalized = User.normalize_username(name) @@ -51,8 +42,7 @@ module UserNameSuggester params = { count: count + 10, - name: normalized, - allowed_normalized: allowed_username || '' + name: normalized } # increasing the search space a bit to allow for some extra noise @@ -60,11 +50,7 @@ module UserNameSuggester WITH numbers AS (SELECT generate_series(1, :count) AS n) SELECT n FROM numbers - LEFT JOIN users ON ( - username_lower = :name || n::varchar - ) AND ( - username_lower <> :allowed_normalized - ) + LEFT JOIN users ON (username_lower = :name || n::varchar) WHERE users.id IS NULL ORDER by n ASC LIMIT 1 @@ -82,22 +68,15 @@ module UserNameSuggester max_length = User.username_length.end - suffix.length attempt = "#{truncate(name, max_length)}#{suffix}" - normalized_attempt = User.normalize_username(attempt) i += 1 end - until normalized_attempt == allowed_username || User.username_available?(attempt) || i > 200 + until User.username_available?(attempt) || i > 200 attempt = SecureRandom.hex[1..SiteSetting.max_username_length] - normalized_attempt = User.normalize_username(attempt) i += 1 end - if allowed_username == normalized_attempt - original_allowed_username - else - attempt - end - + attempt end def self.fix_username(name) diff --git a/lib/vary_header.rb b/lib/vary_header.rb new file mode 100644 index 0000000000..c80eb7a9b8 --- /dev/null +++ b/lib/vary_header.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module VaryHeader + def ensure_vary_header + response.headers['Vary'] ||= 'Accept' if !params[:format] + end +end diff --git a/lib/version.rb b/lib/version.rb index 80ba316ece..e44370aacc 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -10,7 +10,7 @@ module Discourse MAJOR = 2 MINOR = 8 TINY = 0 - PRE = 'beta7' + PRE = 'beta8' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/package.json b/package.json index b2b05c8dc1..f97e6bbf7f 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dependencies": { "@discourse/itsatrap": "^2.0.10", "@fortawesome/fontawesome-free": "5.11.2", - "@highlightjs/cdn-assets": "^10.6.0", + "@highlightjs/cdn-assets": "^10.7.0", "@json-editor/json-editor": "^2.5.2", "@popperjs/core": "v2.9.3", "@uppy/aws-s3": "^2.0.4", @@ -23,7 +23,7 @@ "chart.js": "3.5.1", "chartjs-plugin-datalabels": "^2.0.0", "diffhtml": "^1.0.0-beta.20", - "eslint-config-discourse": "^1.1.8", + "eslint-config-discourse": "^1.1.9", "handlebars": "^4.7.7", "jquery": "3.5.1", "jquery-color": "3.0.0-alpha.1", diff --git a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js similarity index 100% rename from plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 rename to plugins/discourse-details/assets/javascripts/initializers/apply-details.js diff --git a/plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js.es6 b/plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js similarity index 90% rename from plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js.es6 rename to plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js index debaacc72b..00bd6b670a 100644 --- a/plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js.es6 +++ b/plugins/discourse-details/assets/javascripts/lib/discourse-markdown/details.js @@ -1,6 +1,6 @@ const rule = { tag: "details", - before: function (state, tagInfo) { + before(state, tagInfo) { const attrs = tagInfo.attrs; state.push("bbcode_open", "details", 1); state.push("bbcode_open", "summary", 1); @@ -11,7 +11,7 @@ const rule = { state.push("bbcode_close", "summary", -1); }, - after: function (state) { + after(state) { state.push("bbcode_close", "details", -1); }, }; diff --git a/plugins/discourse-details/plugin.rb b/plugins/discourse-details/plugin.rb index 130bd58a32..9903bb861d 100644 --- a/plugins/discourse-details/plugin.rb +++ b/plugins/discourse-details/plugin.rb @@ -5,6 +5,7 @@ # version: 0.4 # authors: Régis Hanol # url: https://github.com/discourse/discourse/tree/main/plugins/discourse-details +# transpile_js: true enabled_site_setting :details_enabled hide_plugin if self.respond_to?(:hide_plugin) diff --git a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js similarity index 91% rename from plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 rename to plugins/discourse-details/test/javascripts/acceptance/details-button-test.js index 5c475de7c1..1f09262104 100644 --- a/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js.es6 +++ b/plugins/discourse-details/test/javascripts/acceptance/details-button-test.js @@ -7,6 +7,7 @@ import I18n from "I18n"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import selectKit from "discourse/tests/helpers/select-kit-helper"; import { test } from "qunit"; +import { click, fillIn, visit } from "@ember/test-helpers"; acceptance("Details Button", function (needs) { needs.user(); @@ -21,7 +22,7 @@ acceptance("Details Button", function (needs) { await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); - assert.equal( + assert.strictEqual( queryAll(".d-editor-input").val(), `\n[details="${I18n.t("composer.details_title")}"]\n${I18n.t( "composer.details_text" @@ -38,7 +39,7 @@ acceptance("Details Button", function (needs) { await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); - assert.equal( + assert.strictEqual( queryAll(".d-editor-input").val(), `\n[details="${I18n.t( "composer.details_title" @@ -46,12 +47,12 @@ acceptance("Details Button", function (needs) { "it should contain the right selected output" ); - assert.equal( + assert.strictEqual( textarea.selectionStart, 21, "it should start highlighting at the right position" ); - assert.equal( + assert.strictEqual( textarea.selectionEnd, 37, "it should end highlighting at the right position" @@ -65,7 +66,7 @@ acceptance("Details Button", function (needs) { await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); - assert.equal( + assert.strictEqual( queryAll(".d-editor-input").val(), `Before \n[details="${I18n.t( "composer.details_title" @@ -73,12 +74,12 @@ acceptance("Details Button", function (needs) { "it should contain the right output" ); - assert.equal( + assert.strictEqual( textarea.selectionStart, 28, "it should start highlighting at the right position" ); - assert.equal( + assert.strictEqual( textarea.selectionEnd, 48, "it should end highlighting at the right position" @@ -92,7 +93,7 @@ acceptance("Details Button", function (needs) { await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); - assert.equal( + assert.strictEqual( queryAll(".d-editor-input").val(), `Before \n\n[details="${I18n.t( "composer.details_title" @@ -100,12 +101,12 @@ acceptance("Details Button", function (needs) { "it should contain the right output" ); - assert.equal( + assert.strictEqual( textarea.selectionStart, 29, "it should start highlighting at the right position" ); - assert.equal( + assert.strictEqual( textarea.selectionEnd, 49, "it should end highlighting at the right position" @@ -127,7 +128,7 @@ acceptance("Details Button", function (needs) { await popupMenu.expand(); await popupMenu.selectRowByValue("insertDetails"); - assert.equal( + assert.strictEqual( queryAll(".d-editor-input").val(), `\n[details="${I18n.t( "composer.details_title" diff --git a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js similarity index 97% rename from plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 rename to plugins/discourse-details/test/javascripts/lib/details-cooked-test.js index 28df6289fe..d8a9ff3687 100644 --- a/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js.es6 +++ b/plugins/discourse-details/test/javascripts/lib/details-cooked-test.js @@ -16,7 +16,7 @@ const defaultOpts = buildOptions({ test("details", function (assert) { const cooked = (input, expected, text) => { - assert.equal( + assert.strictEqual( new PrettyText(defaultOpts).cook(input), expected.replace(/\/>/g, ">"), text diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js.es6 b/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js similarity index 98% rename from plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js index ad7c073e2f..f1a95cfaa3 100644 --- a/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/discourse/components/discourse-local-dates-create-form.js @@ -12,6 +12,7 @@ import loadScript from "discourse/lib/load-script"; import { notEmpty } from "@ember/object/computed"; import { propertyNotEqual } from "discourse/lib/computed"; import { schedule } from "@ember/runloop"; +import { getOwner } from "discourse-common/lib/get-owner"; export default Component.extend({ timeFormat: "HH:mm:ss", @@ -211,7 +212,7 @@ export default Component.extend({ previewedFormats(formats) { return formats.map((format) => { return { - format: format, + format, preview: moment().format(format), }; }); @@ -435,7 +436,7 @@ export default Component.extend({ }, _closeModal() { - const composer = Discourse.__container__.lookup("controller:composer"); + const composer = getOwner(this).lookup("controller:composer"); composer.send("closeModal"); }, }); diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse/controllers/discourse-local-dates-create-modal.js.es6 b/plugins/discourse-local-dates/assets/javascripts/discourse/controllers/discourse-local-dates-create-modal.js similarity index 100% rename from plugins/discourse-local-dates/assets/javascripts/discourse/controllers/discourse-local-dates-create-modal.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/discourse/controllers/discourse-local-dates-create-modal.js diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js similarity index 100% rename from plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/date-with-zone-helper.js.es6 b/plugins/discourse-local-dates/assets/javascripts/lib/date-with-zone-helper.js similarity index 100% rename from plugins/discourse-local-dates/assets/javascripts/lib/date-with-zone-helper.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/lib/date-with-zone-helper.js diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js similarity index 100% rename from plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/discourse-local-dates.js diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/local-date-builder.js.es6 b/plugins/discourse-local-dates/assets/javascripts/lib/local-date-builder.js similarity index 100% rename from plugins/discourse-local-dates/assets/javascripts/lib/local-date-builder.js.es6 rename to plugins/discourse-local-dates/assets/javascripts/lib/local-date-builder.js diff --git a/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss b/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss index 4f9760ca7c..9f2543d22c 100644 --- a/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss +++ b/plugins/discourse-local-dates/assets/stylesheets/common/discourse-local-dates.scss @@ -311,7 +311,8 @@ html:not(.mobile-view) { .fixed-modal .discourse-local-dates-create-modal.modal-body { - width: 600px; + width: 40em; // using ems to scale with user font size + max-width: 100vw; // avoids overflow if someone has extreme font-sizes impacting width max-height: 400px !important; } } diff --git a/plugins/discourse-local-dates/config/locales/client.nb_NO.yml b/plugins/discourse-local-dates/config/locales/client.nb_NO.yml index 02088ecd7a..8baf50f7a7 100644 --- a/plugins/discourse-local-dates/config/locales/client.nb_NO.yml +++ b/plugins/discourse-local-dates/config/locales/client.nb_NO.yml @@ -40,3 +40,4 @@ nb_NO: every_three_months: "Hver tredje måned" every_six_months: "Hver sjette måned" every_year: "Hvert år" + default_title: "%{site_name} Hendelse" diff --git a/plugins/discourse-local-dates/config/locales/client.tr_TR.yml b/plugins/discourse-local-dates/config/locales/client.tr_TR.yml index 558a057126..a03042cc0f 100644 --- a/plugins/discourse-local-dates/config/locales/client.tr_TR.yml +++ b/plugins/discourse-local-dates/config/locales/client.tr_TR.yml @@ -40,3 +40,4 @@ tr_TR: every_three_months: "Üç ayda bir" every_six_months: "Altı ayda bir" every_year: "Her yıl" + default_title: "%{site_name} Etkinliği" diff --git a/plugins/discourse-local-dates/config/locales/client.uk.yml b/plugins/discourse-local-dates/config/locales/client.uk.yml index d167b2a805..fb5e4dd45d 100644 --- a/plugins/discourse-local-dates/config/locales/client.uk.yml +++ b/plugins/discourse-local-dates/config/locales/client.uk.yml @@ -40,3 +40,4 @@ uk: every_three_months: "Кожні три місяці" every_six_months: "Кожні півроку" every_year: "Щороку" + default_title: "Подія на сайті %{site_name}" diff --git a/plugins/discourse-local-dates/config/locales/server.id.yml b/plugins/discourse-local-dates/config/locales/server.id.yml index 596e36b2e1..f786f4348c 100644 --- a/plugins/discourse-local-dates/config/locales/server.id.yml +++ b/plugins/discourse-local-dates/config/locales/server.id.yml @@ -5,3 +5,6 @@ # https://translate.discourse.org/ id: + site_settings: + discourse_local_dates_default_timezones: "Daftar zona waktu default, harus berupa TZ yang valid" + discourse_local_dates_email_format: "Format yang digunakan untuk menampilkan tanggal dalam email." diff --git a/plugins/discourse-local-dates/plugin.rb b/plugins/discourse-local-dates/plugin.rb index b4fdae639f..32961189f7 100644 --- a/plugins/discourse-local-dates/plugin.rb +++ b/plugins/discourse-local-dates/plugin.rb @@ -4,6 +4,8 @@ # about: Display a date in your local timezone # version: 0.1 # author: Joffrey Jaffeux +# transpile_js: true + hide_plugin if self.respond_to?(:hide_plugin) register_asset 'stylesheets/common/discourse-local-dates.scss' diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js similarity index 99% rename from plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 rename to plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js index 4d776bbf89..76e8177563 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/download-calendar-test.js @@ -30,7 +30,7 @@ acceptance( await click(".discourse-local-date"); await click(document.querySelector(".download-calendar")); - assert.equal( + assert.strictEqual( query("#discourse-modal-title").textContent.trim(), I18n.t("download_calendar.title"), "it should display modal to select calendar" diff --git a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js similarity index 72% rename from plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 rename to plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js index 1ca18d534c..b45d5e440f 100644 --- a/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/acceptance/local-dates-composer-test.js @@ -1,5 +1,6 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; +import { click, fillIn, visit } from "@ember/test-helpers"; acceptance("Local Dates - composer", function (needs) { needs.user(); @@ -20,25 +21,29 @@ acceptance("Local Dates - composer", function (needs) { '[date=2017-10-23 time=01:30:00 displayedTimezone="America/Chicago" format="LLLL" calendar="off" recurring="1.weeks" timezone=" Asia/Calcutta" timezones="Europe/Paris|America/Los_Angeles"]' ); - assert.equal(getAttr("date"), "2017-10-23", "it has the correct date"); - assert.equal(getAttr("time"), "01:30:00", "it has the correct time"); - assert.equal( + assert.strictEqual( + getAttr("date"), + "2017-10-23", + "it has the correct date" + ); + assert.strictEqual(getAttr("time"), "01:30:00", "it has the correct time"); + assert.strictEqual( getAttr("displayed-timezone"), "America/Chicago", "it has the correct displayed timezone" ); - assert.equal(getAttr("format"), "LLLL", "it has the correct format"); - assert.equal( + assert.strictEqual(getAttr("format"), "LLLL", "it has the correct format"); + assert.strictEqual( getAttr("timezones"), "Europe/Paris|America/Los_Angeles", "it has the correct timezones" ); - assert.equal( + assert.strictEqual( getAttr("recurring"), "1.weeks", "it has the correct recurring" ); - assert.equal( + assert.strictEqual( getAttr("timezone"), "Asia/Calcutta", "it has the correct timezone" @@ -49,7 +54,11 @@ acceptance("Local Dates - composer", function (needs) { '[date=2017-10-24 format="LL" timezone="Asia/Calcutta" timezones="Europe/Paris|America/Los_Angeles"]' ); - assert.equal(getAttr("date"), "2017-10-24", "it has the correct date"); + assert.strictEqual( + getAttr("date"), + "2017-10-24", + "it has the correct date" + ); assert.notOk(getAttr("time"), "it doesn’t have time"); }); }); diff --git a/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js similarity index 93% rename from plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 rename to plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js index 71848a1cfd..7257cd8862 100644 --- a/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/lib/date-with-zone-helper-test.js @@ -26,7 +26,7 @@ test("#format", function (assert) { minute: 36, timezone: PARIS, }); - assert.equal(date.format(), "2020-03-15T15:36:00.000+01:00"); + assert.strictEqual(date.format(), "2020-03-15T15:36:00.000+01:00"); }); test("#unitRepetitionsBetweenDates", function (assert) { @@ -39,7 +39,7 @@ test("#unitRepetitionsBetweenDates", function (assert) { minute: 36, timezone: PARIS, }); - assert.equal( + assert.strictEqual( date.unitRepetitionsBetweenDates( "1.hour", moment.tz("2020-02-15 15:36", SYDNEY) @@ -55,7 +55,7 @@ test("#unitRepetitionsBetweenDates", function (assert) { minute: 36, timezone: PARIS, }); - assert.equal( + assert.strictEqual( date.unitRepetitionsBetweenDates( "1.minute", moment.tz("2020-02-15 15:36", PARIS) @@ -71,7 +71,7 @@ test("#unitRepetitionsBetweenDates", function (assert) { minute: 36, timezone: PARIS, }); - assert.equal( + assert.strictEqual( date.unitRepetitionsBetweenDates( "1.minute", moment.tz("2020-02-15 15:37", PARIS) @@ -87,7 +87,7 @@ test("#unitRepetitionsBetweenDates", function (assert) { minute: 36, timezone: PARIS, }); - assert.equal( + assert.strictEqual( date.unitRepetitionsBetweenDates( "2.minutes", moment.tz("2020-02-15 15:41", PARIS) @@ -112,7 +112,7 @@ test("#add", function (assert) { assert.notOk(date.isDST()); futureLocalDate = date.add(8, "months"); assert.notOk(futureLocalDate.isDST()); - assert.equal( + assert.strictEqual( futureLocalDate.format(), "2020-11-19T15:36:00.000+01:00", "it correctly adds from a !isDST date to a !isDST date" @@ -128,7 +128,7 @@ test("#add", function (assert) { assert.ok(date.isDST()); futureLocalDate = date.add(1, "year"); assert.ok(futureLocalDate.isDST()); - assert.equal( + assert.strictEqual( futureLocalDate.format(), "2021-04-25T15:36:00.000+02:00", "it correctly adds from a isDST date to a isDST date" @@ -144,7 +144,7 @@ test("#add", function (assert) { assert.notOk(date.isDST()); futureLocalDate = date.add(1, "week"); assert.ok(futureLocalDate.isDST()); - assert.equal( + assert.strictEqual( futureLocalDate.format(), "2020-04-01T15:36:00.000+02:00", "it correctly adds from a !isDST date to a isDST date" @@ -161,7 +161,7 @@ test("#add", function (assert) { assert.ok(date.isDST()); futureLocalDate = date.add(8, "months"); assert.notOk(futureLocalDate.isDST()); - assert.equal( + assert.strictEqual( futureLocalDate.format(), "2020-12-01T15:36:00.000+01:00", "it correctly adds from a isDST date to a !isDST date" diff --git a/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 b/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js similarity index 99% rename from plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 rename to plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js index f01c289d1d..a419beee5e 100644 --- a/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js.es6 +++ b/plugins/discourse-local-dates/test/javascripts/lib/local-date-builder-test.js @@ -1,7 +1,7 @@ import I18n from "I18n"; import LocalDateBuilder from "./local-date-builder"; import sinon from "sinon"; -import { module, test } from "qunit"; +import QUnit, { module, test } from "qunit"; const UTC = "Etc/UTC"; const SYDNEY = "Australia/Sydney"; @@ -47,7 +47,7 @@ QUnit.assert.buildsCorrectDate = function (options, expected, message) { ); if (expected.formated) { - this.test.assert.equal( + this.test.assert.strictEqual( localDateBuilder.build().formated, expected.formated, message || "it formates the date correctly" diff --git a/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js similarity index 100% rename from plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 rename to plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js diff --git a/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml b/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml index 92f8b549f2..aa33db00e2 100644 --- a/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml +++ b/plugins/discourse-narrative-bot/config/locales/client.nb_NO.yml @@ -9,4 +9,4 @@ nb_NO: discourse_narrative_bot: welcome_post_type: new_user_track: "Start den nye brukerveiledningen for alle nye brukere" - welcome_message: "Send alle nye brukere en velkomstmelding med en rask startguide" + welcome_message: "Send alle nye brukere en velkomstmelding med en rask startguide for bruk av forumet." diff --git a/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml b/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml index 9ec4cf7670..e281276a74 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.tr_TR.yml @@ -151,13 +151,13 @@ tr_TR: hello: title: "Selamlar!" message: |- - %{title} başlığına katıldığınız için teşekkür ederiz! + %{title} topluluğuna katıldığın için teşekkür ederiz! - - Ben sadece bir robotum, ama [cana yakın personelimiz](%{base_uri}/about) bir kişiye ulaşmanız gerektiğinde size yardımcı olmak için burada. + - Ben sadece bir robotum, ama gerçek birine ulaşmak istersen [cana yakın personelimiz](%{base_uri}/about) sana yardımcı olmak için her zaman burada. - - Güvenlik nedeniyle, yeni kullanıcıların yapabileceklerini geçici olarak sınırlandırıyoruz. Sizi tanıdıkça [yeni yetenekler] (https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) (ve [badges](%{base_uri}/badges)) kazanacaksınız. + - Güvenlik nedeniyle, yeni kullanıcıların yapabileceklerini geçici olarak sınırlandırıyoruz. Seni tanıdıkça [yeni yetenekler](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) (ve [rozetler](%{base_uri}/badges)) edinmeni sağlayacağız. - - Her zaman [medeni topluluk davranışına] (%{base_uri}/guidelines) inanıyoruz. + - Her ne olursa olsun doğru yöntemin [medeni topluluk davranışları](%{base_uri}/guidelines) sergilemek olduğuna inanıyoruz. onebox: instructions: |- Sonrasında bu bağlantılardan birini benimle paylaşabilir misiniz? **herhangi bir bağlantıyı tek başına** yanıtladığınızda, kutucuk açıklama ekleyebilmeniz için otomatik olarak genişler. @@ -231,7 +231,7 @@ tr_TR: Yazımdaki herhangi bir metni seçtiğinizde **Alıntı** butonu görünür veya seçilen herhangi bir metinle **Yanıtla** tuşuna da basabilirsiniz! Tekrar dener misin? bookmark: instructions: |- - Daha fazla bilgi edinmek istiyorsanız aşağıdan seç ve ** bu özel ileti yi yer im lerine ekle **. Bunu yaparsanız, gelecekte bir :gift: kazanabilirsiniz! + Daha fazla bilgi edinmek istiyorsanız aşağıdan ikonuna tıklayın ve **bu özel iletiyi yer imlerine ekleyin**. Bunu yaparsanız, gelecekte bir :gift: kazanabilirsiniz! reply: |- Mükemmel! Artık istediğiniz zaman doğrudan [profilinizdeki yer imleri sekmesinden](%{bookmark_url}) özel sohbetimize geri dönüş yolunu kolayca bulabilirsiniz. Sağ üstteki ↗ profil resminizi seçmeniz yeterli not_found: |- diff --git a/plugins/discourse-narrative-bot/plugin.rb b/plugins/discourse-narrative-bot/plugin.rb index 5bda9a65ea..2748dc9bc5 100644 --- a/plugins/discourse-narrative-bot/plugin.rb +++ b/plugins/discourse-narrative-bot/plugin.rb @@ -5,6 +5,7 @@ # version: 1.0 # authors: Nick Sahler, Alan Tan # url: https://github.com/discourse/discourse/tree/main/plugins/discourse-narrative-bot +# transpile_js: true enabled_site_setting :discourse_narrative_bot_enabled hide_plugin if self.respond_to?(:hide_plugin) diff --git a/plugins/discourse-presence/README.md b/plugins/discourse-presence/README.md index 4e41c6c62e..64be78e1ca 100644 --- a/plugins/discourse-presence/README.md +++ b/plugins/discourse-presence/README.md @@ -1,14 +1,2 @@ # Discourse Presence plugin This plugin shows which users are currently writing a reply at the same time as you. - -## Installation - -Follow the directions at [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) using https://github.com/discourse/discourse-presence.git as the repository URL. - -## Authors - -André Pereira, David Taylor - -## License - -GNU GPL v2 diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js new file mode 100644 index 0000000000..4430cf35ae --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js @@ -0,0 +1,113 @@ +import discourseComputed, { + observes, + on, +} from "discourse-common/utils/decorators"; +import { equal, gt, readOnly, union } from "@ember/object/computed"; +import Component from "@ember/component"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + presence: service(), + composerPresenceManager: service(), + + @discourseComputed( + "model.replyingToTopic", + "model.editingPost", + "model.whisper", + "model.composerOpened" + ) + state(replyingToTopic, editingPost, whisper, composerOpen) { + if (!composerOpen) { + return; + } else if (editingPost) { + return "edit"; + } else if (whisper) { + return "whisper"; + } else if (replyingToTopic) { + return "reply"; + } + }, + + isReply: equal("state", "reply"), + isEdit: equal("state", "edit"), + isWhisper: equal("state", "whisper"), + + @discourseComputed("model.topic.id", "isReply", "isWhisper") + replyChannelName(topicId, isReply, isWhisper) { + if (topicId && (isReply || isWhisper)) { + return `/discourse-presence/reply/${topicId}`; + } + }, + + @discourseComputed("model.topic.id", "isReply", "isWhisper") + whisperChannelName(topicId, isReply, isWhisper) { + if (topicId && this.currentUser.staff && (isReply || isWhisper)) { + return `/discourse-presence/whisper/${topicId}`; + } + }, + + @discourseComputed("isEdit", "model.post.id") + editChannelName(isEdit, postId) { + if (isEdit) { + return `/discourse-presence/edit/${postId}`; + } + }, + + _setupChannel(channelKey, name) { + if (this[channelKey]?.name !== name) { + this[channelKey]?.unsubscribe(); + if (name) { + this.set(channelKey, this.presence.getChannel(name)); + this[channelKey].subscribe(); + } else if (this[channelKey]) { + this.set(channelKey, null); + } + } + }, + + @observes("replyChannelName", "whisperChannelName", "editChannelName") + _setupChannels() { + this._setupChannel("replyChannel", this.replyChannelName); + this._setupChannel("whisperChannel", this.whisperChannelName); + this._setupChannel("editChannel", this.editChannelName); + }, + + _cleanupChannels() { + this._setupChannel("replyChannel", null); + this._setupChannel("whisperChannel", null); + this._setupChannel("editChannel", null); + }, + + replyingUsers: union("replyChannel.users", "whisperChannel.users"), + editingUsers: readOnly("editChannel.users"), + + @discourseComputed("isReply", "replyingUsers.[]", "editingUsers.[]") + presenceUsers(isReply, replyingUsers, editingUsers) { + const users = isReply ? replyingUsers : editingUsers; + return users + ?.filter((u) => u.id !== this.currentUser.id) + ?.slice(0, this.siteSettings.presence_max_users_shown); + }, + + shouldDisplay: gt("presenceUsers.length", 0), + + @on("didInsertElement") + subscribe() { + this._setupChannels(); + }, + + @observes("model.reply", "state", "model.post.id", "model.topic.id") + _contentChanged() { + if (this.model.reply === "") { + return; + } + const entity = this.state === "edit" ? this.model?.post : this.model?.topic; + this.composerPresenceManager.notifyState(this.state, entity?.id); + }, + + @on("willDestroyElement") + closeComposer() { + this._cleanupChannels(); + this.composerPresenceManager.leave(); + }, +}); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 deleted file mode 100644 index 19a82d0309..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 +++ /dev/null @@ -1,117 +0,0 @@ -import { - CLOSED, - COMPOSER_TYPE, - EDITING, - KEEP_ALIVE_DURATION_SECONDS, - REPLYING, -} from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import { cancel, throttle } from "@ember/runloop"; -import discourseComputed, { - observes, - on, -} from "discourse-common/utils/decorators"; -import { gt, readOnly } from "@ember/object/computed"; -import Component from "@ember/component"; -import { inject as service } from "@ember/service"; - -export default Component.extend({ - // Passed in variables - presenceManager: service(), - - @discourseComputed("model.topic.id") - users(topicId) { - return this.presenceManager.users(topicId); - }, - - @discourseComputed("model.topic.id") - editingUsers(topicId) { - return this.presenceManager.editingUsers(topicId); - }, - - isReply: readOnly("model.replyingToTopic"), - isEdit: readOnly("model.editingPost"), - - @on("didInsertElement") - subscribe() { - this.presenceManager.subscribe(this.get("model.topic.id"), COMPOSER_TYPE); - }, - - @discourseComputed( - "model.post.id", - "editingUsers.@each.last_seen", - "users.@each.last_seen", - "isReply", - "isEdit" - ) - presenceUsers(postId, editingUsers, users, isReply, isEdit) { - if (isEdit) { - return editingUsers.filterBy("post_id", postId); - } else if (isReply) { - return users; - } - return []; - }, - - shouldDisplay: gt("presenceUsers.length", 0), - - @observes("model.reply", "model.title") - typing() { - throttle(this, this._typing, KEEP_ALIVE_DURATION_SECONDS * 1000); - }, - - _typing() { - if ((!this.isReply && !this.isEdit) || !this.get("model.composerOpened")) { - return; - } - - let data = { - topicId: this.get("model.topic.id"), - state: this.isEdit ? EDITING : REPLYING, - whisper: this.get("model.whisper"), - postId: this.get("model.post.id"), - presenceStaffOnly: this.get("model._presenceStaffOnly"), - }; - - this._prevPublishData = data; - - this._throttle = this.presenceManager.publish( - data.topicId, - data.state, - data.whisper, - data.postId, - data.presenceStaffOnly - ); - }, - - @observes("model.whisper") - cancelThrottle() { - this._cancelThrottle(); - }, - - @observes("model.action", "model.topic.id") - composerState() { - if (this._prevPublishData) { - this.presenceManager.publish( - this._prevPublishData.topicId, - CLOSED, - this._prevPublishData.whisper, - this._prevPublishData.postId - ); - this._prevPublishData = null; - } - }, - - @on("willDestroyElement") - closeComposer() { - this._cancelThrottle(); - this._prevPublishData = null; - this.presenceManager.cleanUpPresence(COMPOSER_TYPE); - }, - - _cancelThrottle() { - if (this._throttle) { - cancel(this._throttle); - this._throttle = null; - } - }, -}); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js new file mode 100644 index 0000000000..42e504cee7 --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js @@ -0,0 +1,63 @@ +import discourseComputed, { on } from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { gt, union } from "@ember/object/computed"; +import { inject as service } from "@ember/service"; + +export default Component.extend({ + topic: null, + presence: service(), + replyChannel: null, + whisperChannel: null, + + @discourseComputed("replyChannel.users.[]") + replyUsers(users) { + return users?.filter((u) => u.id !== this.currentUser.id); + }, + + @discourseComputed("whisperChannel.users.[]") + whisperUsers(users) { + return users?.filter((u) => u.id !== this.currentUser.id); + }, + + users: union("replyUsers", "whisperUsers"), + + @discourseComputed("topic.id") + replyChannelName(id) { + return `/discourse-presence/reply/${id}`; + }, + + @discourseComputed("topic.id") + whisperChannelName(id) { + return `/discourse-presence/whisper/${id}`; + }, + + shouldDisplay: gt("users.length", 0), + + didReceiveAttrs() { + this._super(...arguments); + + if (this.replyChannel?.name !== this.replyChannelName) { + this.replyChannel?.unsubscribe(); + this.set("replyChannel", this.presence.getChannel(this.replyChannelName)); + this.replyChannel.subscribe(); + } + + if ( + this.currentUser.staff && + this.whisperChannel?.name !== this.whisperChannelName + ) { + this.whisperChannel?.unsubscribe(); + this.set( + "whisperChannel", + this.presence.getChannel(this.whisperChannelName) + ); + this.whisperChannel.subscribe(); + } + }, + + @on("willDestroyElement") + _destroyed() { + this.replyChannel?.unsubscribe(); + this.whisperChannel?.unsubscribe(); + }, +}); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 deleted file mode 100644 index f38ac5c582..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/components/topic-presence-display.js.es6 +++ /dev/null @@ -1,37 +0,0 @@ -import discourseComputed, { on } from "discourse-common/utils/decorators"; -import Component from "@ember/component"; -import { TOPIC_TYPE } from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import { gt } from "@ember/object/computed"; -import { inject as service } from "@ember/service"; - -export default Component.extend({ - topic: null, - topicId: null, - presenceManager: service(), - - @discourseComputed("topic.id") - users(topicId) { - return this.presenceManager.users(topicId); - }, - - shouldDisplay: gt("users.length", 0), - - didReceiveAttrs() { - this._super(...arguments); - if (this.topicId) { - this.presenceManager.unsubscribe(this.topicId, TOPIC_TYPE); - } - this.set("topicId", this.get("topic.id")); - }, - - @on("didInsertElement") - subscribe() { - this.set("topicId", this.get("topic.id")); - this.presenceManager.subscribe(this.get("topic.id"), TOPIC_TYPE); - }, - - @on("willDestroyElement") - _destroyed() { - this.presenceManager.unsubscribe(this.get("topic.id"), TOPIC_TYPE); - }, -}); diff --git a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 deleted file mode 100644 index 7db5048f67..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/lib/presence.js.es6 +++ /dev/null @@ -1,229 +0,0 @@ -import { cancel, later } from "@ember/runloop"; -import EmberObject from "@ember/object"; -import { ajax } from "discourse/lib/ajax"; -import discourseComputed from "discourse-common/utils/decorators"; - -// The durations chosen here determines the accuracy of the presence feature and -// is tied closely with the server side implementation. Decreasing the duration -// to increase the accuracy will come at the expense of having to more network -// calls to publish the client's state. -// -// Logic walk through of our heuristic implementation: -// - When client A is typing, a message is published every KEEP_ALIVE_DURATION_SECONDS. -// - Client B receives the message and stores each user in an array and marks -// the user with a client-side timestamp of when the user was seen. -// - If client A continues to type, client B will continue to receive messages to -// update the client-side timestamp of when client A was last seen. -// - If client A disconnects or becomes inactive, the state of client A will be -// cleaned up on client B by a scheduler that runs every TIMER_INTERVAL_MILLISECONDS -export const KEEP_ALIVE_DURATION_SECONDS = 10; -const BUFFER_DURATION_SECONDS = KEEP_ALIVE_DURATION_SECONDS + 2; - -const MESSAGE_BUS_LAST_ID = 0; -const TIMER_INTERVAL_MILLISECONDS = 2000; - -export const REPLYING = "replying"; -export const EDITING = "editing"; -export const CLOSED = "closed"; - -export const TOPIC_TYPE = "topic"; -export const COMPOSER_TYPE = "composer"; - -const Presence = EmberObject.extend({ - users: null, - editingUsers: null, - subscribers: null, - topicId: null, - currentUser: null, - messageBus: null, - siteSettings: null, - - init() { - this._super(...arguments); - - this.setProperties({ - users: [], - editingUsers: [], - subscribers: new Set(), - }); - }, - - subscribe(type) { - if (this.subscribers.size === 0) { - this.messageBus.subscribe( - this.channel, - (message) => { - const { user, state } = message; - if (this.get("currentUser.id") === user.id) { - return; - } - - switch (state) { - case REPLYING: - this._appendUser(this.users, user); - break; - case EDITING: - this._appendUser(this.editingUsers, user, { - post_id: parseInt(message.post_id, 10), - }); - break; - case CLOSED: - this._removeUser(user); - break; - } - }, - MESSAGE_BUS_LAST_ID - ); - } - - this.subscribers.add(type); - }, - - unsubscribe(type) { - this.subscribers.delete(type); - const noSubscribers = this.subscribers.size === 0; - - if (noSubscribers) { - this.messageBus.unsubscribe(this.channel); - this._stopTimer(); - - this.setProperties({ - users: [], - editingUsers: [], - }); - } - - return noSubscribers; - }, - - @discourseComputed("topicId") - channel(topicId) { - return `/presence-plugin/${topicId}`; - }, - - publish(state, whisper, postId, staffOnly) { - // NOTE: `user_option` is the correct place to get this value from, but - // it may not have been set yet. It will always have been set directly - // on the currentUser, via the preloaded_json payload. - // TODO: Remove this when preloaded_json is refactored. - let hiddenProfile = this.get( - "currentUser.user_option.hide_profile_and_presence" - ); - if (hiddenProfile === undefined) { - hiddenProfile = this.get("currentUser.hide_profile_and_presence"); - } - - if (hiddenProfile && this.get("siteSettings.allow_users_to_hide_profile")) { - return; - } - - const data = { - state, - topic_id: this.topicId, - }; - - if (whisper) { - data.is_whisper = true; - } - - if (postId && state === EDITING) { - data.post_id = postId; - } - - if (staffOnly) { - data.staff_only = true; - } - - return ajax("/presence-plugin/publish", { - type: "POST", - data, - }); - }, - - _removeUser(user) { - [this.users, this.editingUsers].forEach((users) => { - const existingUser = users.findBy("id", user.id); - if (existingUser) { - users.removeObject(existingUser); - } - }); - }, - - _cleanUpUsers() { - [this.users, this.editingUsers].forEach((users) => { - const staleUsers = []; - - users.forEach((user) => { - if (user.last_seen <= Date.now() - BUFFER_DURATION_SECONDS * 1000) { - staleUsers.push(user); - } - }); - - users.removeObjects(staleUsers); - }); - - return this.users.length === 0 && this.editingUsers.length === 0; - }, - - _appendUser(users, user, attrs) { - let existingUser; - let usersLength = 0; - - users.forEach((u) => { - if (u.id === user.id) { - existingUser = u; - } - - if (attrs && attrs.post_id) { - if (u.post_id === attrs.post_id) { - usersLength++; - } - } else { - usersLength++; - } - }); - - const props = attrs || {}; - props.last_seen = Date.now(); - - if (existingUser) { - existingUser.setProperties(props); - } else { - const limit = this.get("siteSettings.presence_max_users_shown"); - - if (usersLength < limit) { - users.pushObject(EmberObject.create(Object.assign(user, props))); - } - } - - this._startTimer(() => { - this._cleanUpUsers(); - }); - }, - - _scheduleTimer(callback) { - return later( - this, - () => { - const stop = callback(); - - if (!stop) { - this.set("_timer", this._scheduleTimer(callback)); - } - }, - TIMER_INTERVAL_MILLISECONDS - ); - }, - - _stopTimer() { - cancel(this._timer); - }, - - _startTimer(callback) { - if (!this._timer) { - this.set("_timer", this._scheduleTimer(callback)); - } - }, -}); - -export default Presence; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js b/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js new file mode 100644 index 0000000000..e302a3a585 --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/services/composer-presence-manager.js @@ -0,0 +1,64 @@ +import Service, { inject as service } from "@ember/service"; +import { cancel, debounce } from "@ember/runloop"; +import { isTesting } from "discourse-common/config/environment"; + +const PRESENCE_CHANNEL_PREFIX = "/discourse-presence"; +const KEEP_ALIVE_DURATION_SECONDS = 10; + +export default class ComposerPresenceManager extends Service { + @service presence; + + notifyState(intent, id) { + if ( + this.siteSettings.allow_users_to_hide_profile && + this.currentUser.hide_profile_and_presence + ) { + return; + } + + if (intent === undefined) { + return this.leave(); + } + + if (!["reply", "whisper", "edit"].includes(intent)) { + throw `Unknown intent ${intent}`; + } + + const state = `${intent}/${id}`; + + if (this._state !== state) { + this._enter(intent, id); + this._state = state; + } + + if (!isTesting()) { + this._autoLeaveTimer = debounce( + this, + this.leave, + KEEP_ALIVE_DURATION_SECONDS * 1000 + ); + } + } + + leave() { + this._presentChannel?.leave(); + this._presentChannel = null; + this._state = null; + if (this._autoLeaveTimer) { + cancel(this._autoLeaveTimer); + this._autoLeaveTimer = null; + } + } + + _enter(intent, id) { + this.leave(); + + let channelName = `${PRESENCE_CHANNEL_PREFIX}/${intent}/${id}`; + this._presentChannel = this.presence.getChannel(channelName); + this._presentChannel.enter(); + } + + willDestroy() { + this.leave(); + } +} diff --git a/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 deleted file mode 100644 index ae24b63073..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/services/presence-manager.js.es6 +++ /dev/null @@ -1,82 +0,0 @@ -import Presence, { - CLOSED, -} from "discourse/plugins/discourse-presence/discourse/lib/presence"; -import Service from "@ember/service"; - -const PresenceManager = Service.extend({ - presences: null, - - init() { - this._super(...arguments); - - this.setProperties({ - presences: {}, - }); - }, - - subscribe(topicId, type) { - if (!topicId) { - return; - } - this._getPresence(topicId).subscribe(type); - }, - - unsubscribe(topicId, type) { - if (!topicId) { - return; - } - const presence = this._getPresence(topicId); - - if (presence.unsubscribe(type)) { - delete this.presences[topicId]; - } - }, - - users(topicId) { - if (!topicId) { - return []; - } - return this._getPresence(topicId).users; - }, - - editingUsers(topicId) { - if (!topicId) { - return []; - } - return this._getPresence(topicId).editingUsers; - }, - - publish(topicId, state, whisper, postId, staffOnly) { - if (!topicId) { - return; - } - return this._getPresence(topicId).publish( - state, - whisper, - postId, - staffOnly - ); - }, - - cleanUpPresence(type) { - Object.keys(this.presences).forEach((key) => { - this.publish(key, CLOSED); - this.unsubscribe(key, type); - }); - }, - - _getPresence(topicId) { - if (!this.presences[topicId]) { - this.presences[topicId] = Presence.create({ - messageBus: this.messageBus, - siteSettings: this.siteSettings, - currentUser: this.currentUser, - topicId, - }); - } - - return this.presences[topicId]; - }, -}); - -export default PresenceManager; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 deleted file mode 100644 index 75ca86b4a4..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default { - shouldRender(_, component) { - return component.siteSettings.presence_enabled; - }, -}; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs index c8514c7edc..5b76786960 100644 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs +++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.hbs @@ -1 +1,2 @@ +{{!-- Note: the topic-above-footer-buttons outlet is only rendered for logged-in users --}} {{topic-presence-display topic=model}} diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 deleted file mode 100644 index 75ca86b4a4..0000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/topic-above-footer-buttons/presence.js.es6 +++ /dev/null @@ -1,5 +0,0 @@ -export default { - shouldRender(_, component) { - return component.siteSettings.presence_enabled; - }, -}; diff --git a/plugins/discourse-presence/config/locales/server.id.yml b/plugins/discourse-presence/config/locales/server.id.yml index 596e36b2e1..6b2f60edcd 100644 --- a/plugins/discourse-presence/config/locales/server.id.yml +++ b/plugins/discourse-presence/config/locales/server.id.yml @@ -5,3 +5,6 @@ # https://translate.discourse.org/ id: + site_settings: + presence_enabled: "Tampilkan pengguna yang sedang membalas topik saat ini, atau mengedit posting saat ini?" + presence_max_users_shown: "Jumlah maksimum pengguna yang ditampilkan." diff --git a/plugins/discourse-presence/plugin.rb b/plugins/discourse-presence/plugin.rb index d20f4a2b1d..b1e356d747 100644 --- a/plugins/discourse-presence/plugin.rb +++ b/plugins/discourse-presence/plugin.rb @@ -1,178 +1,72 @@ # frozen_string_literal: true # name: discourse-presence -# about: Show which users are writing a reply to a topic +# about: Show which users are replying to a topic, or editing a post # version: 2.0 # authors: André Pereira, David Taylor, tgxworld # url: https://github.com/discourse/discourse/tree/main/plugins/discourse-presence +# transpile_js: true enabled_site_setting :presence_enabled hide_plugin if self.respond_to?(:hide_plugin) register_asset 'stylesheets/presence.scss' -PLUGIN_NAME ||= -"discourse-presence" - after_initialize do - MessageBus.register_client_message_filter('/presence-plugin/') do |message| - published_at = message.data["published_at"] + register_presence_channel_prefix("discourse-presence") do |channel_name| + if topic_id = channel_name[/\/discourse-presence\/reply\/(\d+)/, 1] + topic = Topic.find(topic_id) + config = PresenceChannel::Config.new - if published_at - (Time.zone.now.to_i - published_at) <= ::Presence::MAX_BACKLOG_AGE_SECONDS - else - false - end - end - - module ::Presence - MAX_BACKLOG_AGE_SECONDS = 10 - - class Engine < ::Rails::Engine - engine_name PLUGIN_NAME - isolate_namespace Presence - end - end - - require_dependency "application_controller" - - class Presence::PresencesController < ::ApplicationController - requires_plugin PLUGIN_NAME - before_action :ensure_logged_in - before_action :ensure_presence_enabled - - EDITING_STATE = 'editing' - REPLYING_STATE = 'replying' - CLOSED_STATE = 'closed' - - def handle_message - [:state, :topic_id].each do |key| - raise ActionController::ParameterMissing.new(key) unless params.key?(key) - end - - topic_id = permitted_params[:topic_id] - topic = Topic.find_by(id: topic_id) - - raise Discourse::InvalidParameters.new(:topic_id) unless topic - guardian.ensure_can_see!(topic) - - post = nil - - if (permitted_params[:post_id]) - if (permitted_params[:state] != EDITING_STATE) - raise Discourse::InvalidParameters.new(:state) - end - - post = Post.find_by(id: permitted_params[:post_id]) - raise Discourse::InvalidParameters.new(:topic_id) unless post - - guardian.ensure_can_edit!(post) - end - - opts = { - max_backlog_age: Presence::MAX_BACKLOG_AGE_SECONDS - } - - if permitted_params[:staff_only] - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] + if topic.private_message? + config.allowed_user_ids = topic.allowed_users.pluck(:id) + config.allowed_group_ids = topic.allowed_groups.pluck(:group_id) + [::Group::AUTO_GROUPS[:staff]] + elsif secure_group_ids = topic.secure_group_ids + config.allowed_group_ids = secure_group_ids + [::Group::AUTO_GROUPS[:admins]] else - case permitted_params[:state] - when EDITING_STATE - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] - - if !post.locked? && !permitted_params[:is_whisper] - opts[:user_ids] = [post.user_id] - - if topic.private_message? - if post.wiki - opts[:user_ids] = opts[:user_ids].concat( - topic.allowed_users.where( - "trust_level >= ? AND NOT admin OR moderator", - SiteSetting.min_trust_to_edit_wiki_post - ).pluck(:id) - ) - - opts[:user_ids].uniq! - - # Ignore trust level and just publish to all allowed groups since - # trying to figure out which users in the allowed groups have - # the necessary trust levels can lead to a large array of user ids - # if the groups are big. - opts[:group_ids] = opts[:group_ids].concat( - topic.allowed_groups.pluck(:id) - ) - end - else - if post.wiki - opts[:group_ids] << Group::AUTO_GROUPS[:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"] - elsif SiteSetting.trusted_users_can_edit_others? - opts[:group_ids] << Group::AUTO_GROUPS[:trust_level_4] - end - end - end - when REPLYING_STATE - if permitted_params[:is_whisper] - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]] - elsif topic.private_message? - opts[:user_ids] = topic.allowed_users.pluck(:id) - - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]].concat( - topic.allowed_groups.pluck(:id) - ) - else - opts[:group_ids] = topic.secure_group_ids - end - when CLOSED_STATE - if topic.private_message? - opts[:user_ids] = topic.allowed_users.pluck(:id) - - opts[:group_ids] = [Group::AUTO_GROUPS[:staff]].concat( - topic.allowed_groups.pluck(:id) - ) - else - opts[:group_ids] = topic.secure_group_ids - end - end + # config.public=true would make data available to anon, so use the tl0 group instead + config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:trust_level_0] ] end - payload = { - user: BasicUserSerializer.new(current_user, root: false).as_json, - state: permitted_params[:state], - is_whisper: permitted_params[:is_whisper].present?, - published_at: Time.zone.now.to_i - } + config + elsif topic_id = channel_name[/\/discourse-presence\/whisper\/(\d+)/, 1] + Topic.find(topic_id) # Just ensure it exists + PresenceChannel::Config.new(allowed_group_ids: [::Group::AUTO_GROUPS[:staff]]) + elsif post_id = channel_name[/\/discourse-presence\/edit\/(\d+)/, 1] + post = Post.find(post_id) + topic = Topic.find(post.topic_id) - if (post_id = permitted_params[:post_id]).present? - payload[:post_id] = post_id + config = PresenceChannel::Config.new + config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:staff] ] + + # Locked and whisper posts are staff only + next config if post.locked? || post.whisper? + + config.allowed_user_ids = [ post.user_id ] + + if topic.private_message? && post.wiki + # Ignore trust level and just publish to all allowed groups since + # trying to figure out which users in the allowed groups have + # the necessary trust levels can lead to a large array of user ids + # if the groups are big. + config.allowed_user_ids += topic.allowed_users.pluck(:id) + config.allowed_group_ids += topic.allowed_groups.pluck(:id) + elsif post.wiki + config.allowed_group_ids << Group::AUTO_GROUPS[:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"] end - MessageBus.publish("/presence-plugin/#{topic_id}", payload, opts) - - render json: success_json - end - - private - - def ensure_presence_enabled - if !SiteSetting.presence_enabled || - (SiteSetting.allow_users_to_hide_profile && - current_user.user_option.hide_profile_and_presence?) - - raise Discourse::NotFound + if !topic.private_message? && SiteSetting.trusted_users_can_edit_others? + config.allowed_group_ids << Group::AUTO_GROUPS[:trust_level_4] end + + if SiteSetting.enable_category_group_moderation? && group_id = topic.category&.reviewable_by_group_id + config.allowed_group_ids << group_id + end + + config end - - def permitted_params - params.permit(:state, :topic_id, :post_id, :is_whisper, :staff_only) - end + rescue ActiveRecord::RecordNotFound + nil end - - Presence::Engine.routes.draw do - post '/publish' => 'presences#handle_message' - end - - Discourse::Application.routes.append do - mount ::Presence::Engine, at: '/presence-plugin' - end - end diff --git a/plugins/discourse-presence/spec/integration/presence_spec.rb b/plugins/discourse-presence/spec/integration/presence_spec.rb new file mode 100644 index 0000000000..a59c43b837 --- /dev/null +++ b/plugins/discourse-presence/spec/integration/presence_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe "discourse-presence" do + describe 'PresenceChannel configuration' do + fab!(:user) { Fabricate(:user) } + fab!(:user2) { Fabricate(:user) } + fab!(:admin) { Fabricate(:admin) } + + fab!(:group) do + group = Fabricate(:group) + group.add(user) + group + end + + fab!(:category) { Fabricate(:private_category, group: group) } + fab!(:private_topic) { Fabricate(:topic, category: category) } + fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) } + + fab!(:private_message) do + Fabricate(:private_message_topic, + allowed_groups: [group] + ) + end + + before { PresenceChannel.clear_all! } + + it 'handles invalid topic IDs' do + expect do + PresenceChannel.new('/discourse-presence/reply/-999').config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new('/discourse-presence/reply/blah').config + end.to raise_error(PresenceChannel::NotFound) + end + + it 'handles deleted topics' do + public_topic.trash! + + expect do + PresenceChannel.new("/discourse-presence/reply/#{public_topic.id}").config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new("/discourse-presence/whisper/#{public_topic.id}").config + end.to raise_error(PresenceChannel::NotFound) + + expect do + PresenceChannel.new("/discourse-presence/edit/#{public_topic.first_post.id}").config + end.to raise_error(PresenceChannel::NotFound) + end + + it 'handles secure category permissions for reply' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}") + expect(c.can_view?(user_id: user.id)).to eq(true) + expect(c.can_enter?(user_id: user.id)).to eq(true) + + group.remove(user) + + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}", use_cache: false) + expect(c.can_view?(user_id: user.id)).to eq(false) + expect(c.can_enter?(user_id: user.id)).to eq(false) + end + + it 'handles secure category permissions for edit' do + p = Fabricate(:post, topic: private_topic, user: private_topic.user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.can_view?(user_id: user.id)).to eq(false) + expect(c.can_view?(user_id: private_topic.user.id)).to eq(true) + end + + it 'handles category moderators for edit' do + SiteSetting.trusted_users_can_edit_others = false + p = Fabricate(:post, topic: private_topic, user: private_topic.user) + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + + SiteSetting.enable_category_group_moderation = true + category.update(reviewable_by_group_id: group.id) + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}", use_cache: false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff], group.id) + end + + it 'handles permissions for a public topic' do + c = PresenceChannel.new("/discourse-presence/reply/#{public_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(::Group::AUTO_GROUPS[:trust_level_0]) + end + + it 'handles permissions for secure category topics' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(group.id, Group::AUTO_GROUPS[:admins]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'handles permissions for private messsages' do + c = PresenceChannel.new("/discourse-presence/reply/#{private_message.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(group.id, Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to contain_exactly( + *private_message.topic_allowed_users.pluck(:user_id) + ) + end + + it "handles permissions for whispers" do + c = PresenceChannel.new("/discourse-presence/whisper/#{public_topic.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'only allows staff when editing whispers' do + p = Fabricate(:whisper, topic: public_topic, user: admin) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it 'only allows staff when editing a locked post' do + p = Fabricate(:post, topic: public_topic, user: admin, locked_by_id: Discourse.system_user.id) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) + expect(c.config.allowed_user_ids).to eq(nil) + end + + it "allows author, staff, TL4 when editing a public post" do + p = Fabricate(:post, topic: public_topic, user: user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:trust_level_4], + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "allows only author and staff when editing a public post with tl4 editing disabled" do + SiteSetting.trusted_users_can_edit_others = false + + p = Fabricate(:post, topic: public_topic, user: user) + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "follows the wiki edit trust level site setting" do + p = Fabricate(:post, topic: public_topic, user: user, wiki: true) + SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] + SiteSetting.trusted_users_can_edit_others = false + + c = PresenceChannel.new("/discourse-presence/edit/#{p.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff], + Group::AUTO_GROUPS[:trust_level_1] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "allows author and staff when editing a private message" do + post = Fabricate(:post, topic: private_message, user: user) + + c = PresenceChannel.new("/discourse-presence/edit/#{post.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff] + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id) + end + + it "includes all message participants for PM wiki" do + post = Fabricate(:post, topic: private_message, user: user, wiki: true) + + c = PresenceChannel.new("/discourse-presence/edit/#{post.id}") + expect(c.config.public).to eq(false) + expect(c.config.allowed_group_ids).to contain_exactly( + Group::AUTO_GROUPS[:staff], + *private_message.allowed_groups.pluck(:id) + ) + expect(c.config.allowed_user_ids).to contain_exactly(user.id, *private_message.allowed_users.pluck(:id)) + end + end +end diff --git a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb b/plugins/discourse-presence/spec/requests/presence_controller_spec.rb deleted file mode 100644 index adededeeb3..0000000000 --- a/plugins/discourse-presence/spec/requests/presence_controller_spec.rb +++ /dev/null @@ -1,472 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe ::Presence::PresencesController do - describe '#handle_message' do - context 'when not logged in' do - it 'should raise the right error' do - post '/presence-plugin/publish.json' - - expect(response.status).to eq(403) - end - end - - context 'when logged in' do - fab!(:user) { Fabricate(:user) } - fab!(:user2) { Fabricate(:user) } - fab!(:admin) { Fabricate(:admin) } - - fab!(:group) do - group = Fabricate(:group) - group.add(user) - group - end - - fab!(:category) { Fabricate(:private_category, group: group) } - fab!(:private_topic) { Fabricate(:topic, category: category) } - fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) } - - fab!(:private_message) do - Fabricate(:private_message_topic, - allowed_groups: [group] - ) - end - - before do - sign_in(user) - end - - it 'returns the right response when user disables the presence feature' do - user.user_option.update_column(:hide_profile_and_presence, true) - - post '/presence-plugin/publish.json' - - expect(response.status).to eq(404) - end - - it 'returns the right response when user disables the presence feature and allow_users_to_hide_profile is disabled' do - user.user_option.update_column(:hide_profile_and_presence, true) - SiteSetting.allow_users_to_hide_profile = false - - post '/presence-plugin/publish.json', params: { topic_id: public_topic.id, state: 'replying' } - - expect(response.status).to eq(200) - end - - it 'returns the right response when the presence site settings is disabled' do - SiteSetting.presence_enabled = false - - post '/presence-plugin/publish.json' - - expect(response.status).to eq(404) - end - - it 'returns the right response if required params are missing' do - post '/presence-plugin/publish.json' - - expect(response.status).to eq(400) - end - - it 'returns the right response if topic_id is invalid' do - post '/presence-plugin/publish.json', params: { topic_id: -999, state: 'replying' } - - expect(response.status).to eq(400) - end - - it 'returns the right response when user does not have access to the topic' do - group.remove(user) - - post '/presence-plugin/publish.json', params: { topic_id: private_topic.id, state: 'replying' } - - expect(response.status).to eq(403) - end - - it 'returns the right response when an invalid state is provided with a post_id' do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: public_topic.first_post.id, - state: 'some state' - } - - expect(response.status).to eq(400) - end - - it 'returns the right response when user can not edit a post' do - Fabricate(:post, topic: private_topic, user: private_topic.user) - - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - post_id: private_topic.first_post.id, - state: 'editing' - } - - expect(response.status).to eq(403) - end - - it 'returns the right response when an invalid post_id is given' do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: -9, - state: 'editing' - } - - expect(response.status).to eq(400) - end - - it 'publishes the right message for a public topic' do - freeze_time - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { topic_id: public_topic.id, state: 'replying' } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.channel).to eq("/presence-plugin/#{public_topic.id}") - expect(message.data.dig(:user, :id)).to eq(user.id) - expect(message.data[:published_at]).to eq(Time.zone.now.to_i) - expect(message.group_ids).to eq(nil) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message for a restricted topic' do - freeze_time - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - state: 'replying' - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.channel).to eq("/presence-plugin/#{private_topic.id}") - expect(message.data.dig(:user, :id)).to eq(user.id) - expect(message.data[:published_at]).to eq(Time.zone.now.to_i) - expect(message.group_ids).to contain_exactly(group.id) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message for a private message' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - state: 'replying' - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - group.id, - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly( - *private_message.topic_allowed_users.pluck(:user_id) - ) - end - - it 'publishes the message to staff group when user is whispering' do - SiteSetting.enable_whispers = true - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: 'replying', - is_whisper: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when staff_only param override is present' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: 'replying', - staff_only: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when a staff is editing a whisper' do - SiteSetting.enable_whispers = true - sign_in(admin) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: public_topic.first_post.id, - state: 'editing', - is_whisper: true - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to staff group when a staff is editing a locked post' do - SiteSetting.enable_whispers = true - sign_in(admin) - locked_post = Fabricate(:post, topic: public_topic, locked_by_id: admin.id) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: locked_post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the message to author, staff group and TL4 group when editing a public post' do - post = Fabricate(:post, topic: public_topic, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:trust_level_4], - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to author and staff group when editing a public post ' \ - 'if SiteSettings.trusted_users_can_edit_others is set to false' do - - post = Fabricate(:post, topic: public_topic, user: user) - SiteSetting.trusted_users_can_edit_others = false - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff]) - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to SiteSetting.min_trust_to_edit_wiki_post group ' \ - 'and staff group when editing a wiki in a public topic' do - - post = Fabricate(:post, topic: public_topic, user: user, wiki: true) - SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:trust_level_1], - Group::AUTO_GROUPS[:staff] - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to author and staff group when editing a private message' do - post = Fabricate(:post, topic: private_message, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - ) - - expect(message.user_ids).to contain_exactly(user.id) - end - - it 'publishes the message to users with trust levels of SiteSetting.min_trust_to_edit_wiki_post ' \ - 'and staff group when editing a wiki in a private message' do - - post = Fabricate(:post, - topic: private_message, - user: private_message.user, - wiki: true - ) - - user2.update!(trust_level: TrustLevel.levels[:newuser]) - group.add(user2) - - SiteSetting.min_trust_to_edit_wiki_post = TrustLevel.levels[:basic] - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - post_id: post.id, - state: 'editing', - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - group.id - ) - - expect(message.user_ids).to contain_exactly( - *private_message.allowed_users.pluck(:id) - ) - end - - it 'publishes the right message when closing composer in public topic' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: public_topic.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to eq(nil) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message when closing composer in private topic' do - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_topic.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly(group.id) - expect(message.user_ids).to eq(nil) - end - - it 'publishes the right message when closing composer in private message' do - post = Fabricate(:post, topic: private_message, user: user) - - messages = MessageBus.track_publish do - post '/presence-plugin/publish.json', params: { - topic_id: private_message.id, - state: described_class::CLOSED_STATE, - } - - expect(response.status).to eq(200) - end - - expect(messages.length).to eq(1) - - message = messages.first - - expect(message.group_ids).to contain_exactly( - Group::AUTO_GROUPS[:staff], - group.id - ) - - expect(message.user_ids).to contain_exactly( - *private_message.allowed_users.pluck(:id) - ) - end - end - end -end diff --git a/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js new file mode 100644 index 0000000000..3500f13a21 --- /dev/null +++ b/plugins/discourse-presence/test/javascripts/acceptance/discourse-presence-test.js @@ -0,0 +1,232 @@ +import { + acceptance, + count, + exists, + queryAll, +} from "discourse/tests/helpers/qunit-helpers"; +import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { + joinChannel, + leaveChannel, + presentUserIds, +} from "discourse/tests/helpers/presence-pretender"; +import User from "discourse/models/user"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +acceptance("Discourse Presence Plugin", function (needs) { + needs.user(); + needs.settings({ enable_whispers: true }); + + test("Doesn't break topic creation", async function (assert) { + await visit("/"); + await click("#create-topic"); + await fillIn("#reply-title", "Internationalization Localization"); + await fillIn( + ".d-editor-input", + "this is the *content* of a new topic post" + ); + await click("#reply-control button.create"); + + assert.strictEqual( + currentURL(), + "/t/internationalization-localization/280", + "it transitions to the newly created topic URL" + ); + }); + + test("Publishes own reply presence", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "does not publish presence for open composer" + ); + + await fillIn(".d-editor-input", "this is the content of my reply"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [User.current().id], + "publishes presence when typing" + ); + + await click("#reply-control button.create"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "leaves channel when composer closes" + ); + }); + + test("Uses whisper channel for whispers", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + await fillIn(".d-editor-input", "this is the content of my reply"); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [User.current().id], + "publishes reply presence when typing" + ); + + const menu = selectKit(".toolbar-popup-menu-options"); + await menu.expand(); + await menu.selectRowByValue("toggleWhisper"); + + assert.strictEqual( + count(".composer-actions svg.d-icon-far-eye-slash"), + 1, + "it sets the post type to whisper" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "removes reply presence" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [User.current().id], + "adds whisper presence" + ); + + await click("#reply-control button.create"); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [], + "leaves whisper channel when composer closes" + ); + }); + + test("Uses the edit channel for editing", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await click(".topic-post:nth-of-type(1) button.show-more-actions"); + await click(".topic-post:nth-of-type(1) button.edit"); + + assert.strictEqual( + queryAll(".d-editor-input").val(), + queryAll(".topic-post:nth-of-type(1) .cooked > p").text(), + "composer has contents of post to be edited" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/edit/398"), + [], + "is not present when composer first opened" + ); + + await fillIn(".d-editor-input", "some edited content"); + + assert.deepEqual( + presentUserIds("/discourse-presence/edit/398"), + [User.current().id], + "becomes present in the edit channel" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/reply/280"), + [], + "is not made present in the reply channel" + ); + + assert.deepEqual( + presentUserIds("/discourse-presence/whisper/280"), + [], + "is not made present in the whisper channel" + ); + }); + + test("Displays replying and whispering presence at bottom of topic", async function (assert) { + await visit("/t/internationalization-localization/280"); + + const avatarSelector = + ".topic-above-footer-buttons-outlet.presence .presence-avatars .avatar"; + assert.ok( + exists(".topic-above-footer-buttons-outlet.presence"), + "includes the presence component" + ); + assert.strictEqual(count(avatarSelector), 0, "no avatars displayed"); + + await joinChannel("/discourse-presence/reply/280", { + id: 123, + avatar_template: "/a/b/c.jpg", + username: "myusername", + }); + + assert.strictEqual(count(avatarSelector), 1, "avatar displayed"); + + await joinChannel("/discourse-presence/whisper/280", { + id: 124, + avatar_template: "/a/b/c.jpg", + username: "myusername2", + }); + + assert.strictEqual(count(avatarSelector), 2, "whisper avatar displayed"); + + await leaveChannel("/discourse-presence/reply/280", { + id: 123, + }); + + assert.strictEqual(count(avatarSelector), 1, "reply avatar removed"); + + await leaveChannel("/discourse-presence/whisper/280", { + id: 124, + }); + + assert.strictEqual(count(avatarSelector), 0, "whisper avatar removed"); + }); + + test("Displays replying and whispering presence in composer", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click("#topic-footer-buttons .btn.create"); + assert.ok(exists(".d-editor-input"), "the composer input is visible"); + + const avatarSelector = + ".composer-fields-outlet.presence .presence-avatars .avatar"; + assert.ok( + exists(".composer-fields-outlet.presence"), + "includes the presence component" + ); + assert.strictEqual(count(avatarSelector), 0, "no avatars displayed"); + + await joinChannel("/discourse-presence/reply/280", { + id: 123, + avatar_template: "/a/b/c.jpg", + username: "myusername", + }); + + assert.strictEqual(count(avatarSelector), 1, "avatar displayed"); + + await joinChannel("/discourse-presence/whisper/280", { + id: 124, + avatar_template: "/a/b/c.jpg", + username: "myusername2", + }); + + assert.strictEqual(count(avatarSelector), 2, "whisper avatar displayed"); + + await leaveChannel("/discourse-presence/reply/280", { + id: 123, + }); + + assert.strictEqual(count(avatarSelector), 1, "reply avatar removed"); + + await leaveChannel("/discourse-presence/whisper/280", { + id: 124, + }); + + assert.strictEqual(count(avatarSelector), 0, "whisper avatar removed"); + }); +}); diff --git a/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js b/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js index b6f3ddf669..643bfc7ade 100644 --- a/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js +++ b/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js @@ -162,10 +162,10 @@ export default function initLazyYt($) { $.fn.lazyYT = function (newSettings) { let defaultSettings = { default_ratio: "16:9", - callback: null, // ToDO execute callback if given + callback: null, // TODO: execute callback if given container_class: "lazyYT-container", }; - let settings = $.extend(defaultSettings, newSettings); + let settings = Object.assign(defaultSettings, newSettings); return this.each(function () { let $el = $(this).addClass(settings.container_class); diff --git a/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 b/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js similarity index 100% rename from plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 rename to plugins/poll/assets/javascripts/components/poll-breakdown-chart.js diff --git a/plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 b/plugins/poll/assets/javascripts/components/poll-breakdown-option.js similarity index 100% rename from plugins/poll/assets/javascripts/components/poll-breakdown-option.js.es6 rename to plugins/poll/assets/javascripts/components/poll-breakdown-option.js diff --git a/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js similarity index 98% rename from plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 rename to plugins/poll/assets/javascripts/controllers/poll-breakdown.js index 794efa0de3..77d1c1e7a6 100644 --- a/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js @@ -8,6 +8,7 @@ import discourseComputed from "discourse-common/utils/decorators"; import { htmlSafe } from "@ember/template"; import loadScript from "discourse/lib/load-script"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import bootbox from "bootbox"; export default Controller.extend(ModalFunctionality, { model: null, diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js similarity index 100% rename from plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 rename to plugins/poll/assets/javascripts/controllers/poll-ui-builder.js diff --git a/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js similarity index 100% rename from plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 rename to plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js similarity index 100% rename from plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 rename to plugins/poll/assets/javascripts/initializers/extend-for-poll.js diff --git a/plugins/poll/assets/javascripts/lib/chart-colors.js.es6 b/plugins/poll/assets/javascripts/lib/chart-colors.js similarity index 100% rename from plugins/poll/assets/javascripts/lib/chart-colors.js.es6 rename to plugins/poll/assets/javascripts/lib/chart-colors.js diff --git a/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 b/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js similarity index 99% rename from plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 rename to plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js index 061ccceaf9..543a8e67d9 100644 --- a/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 +++ b/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js @@ -107,14 +107,14 @@ function getTitle(tokens, startToken) { const rule = { tag: "poll", - before: function (state, tagInfo, raw) { + before(state, tagInfo, raw) { let token = state.push("text", "", 0); token.content = raw; token.bbcode_attrs = tagInfo.attrs; token.bbcode_type = "poll_open"; }, - after: function (state, openToken, raw) { + after(state, openToken, raw) { const titleTokens = getTitle(state.tokens, openToken); let items = getListItems(state.tokens, openToken); diff --git a/plugins/poll/assets/javascripts/lib/even-round.js.es6 b/plugins/poll/assets/javascripts/lib/even-round.js similarity index 100% rename from plugins/poll/assets/javascripts/lib/even-round.js.es6 rename to plugins/poll/assets/javascripts/lib/even-round.js diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js similarity index 99% rename from plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 rename to plugins/poll/assets/javascripts/widgets/discourse-poll.js index 6b476dea10..3b1dfa5bd3 100644 --- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 +++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js @@ -13,6 +13,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { relativeAge } from "discourse/lib/formatter"; import round from "discourse/lib/round"; import showModal from "discourse/lib/show-modal"; +import bootbox from "bootbox"; const FETCH_VOTERS_COUNT = 25; @@ -567,7 +568,7 @@ function pieChartConfig(data, labels, opts = {}) { plugins: { legend: { labels: { - generateLabels: function () { + generateLabels() { return labels.map((text, index) => { return { fillStyle: getColors(data.length)[index], @@ -791,7 +792,7 @@ export default createWidget("discourse-poll", { (attrs.post.get("topic.archived") && !staffOnly) || (this.isClosed() && !staffOnly); - const newAttrs = jQuery.extend({}, attrs, { + const newAttrs = Object.assign({}, attrs, { canCastVotes: this.canCastVotes(), hasVoted: this.hasVoted(), isAutomaticallyClosed: this.isAutomaticallyClosed(), diff --git a/plugins/poll/assets/stylesheets/desktop/poll-ui-builder.scss b/plugins/poll/assets/stylesheets/desktop/poll-ui-builder.scss index caff3a9eec..1bfac2e179 100644 --- a/plugins/poll/assets/stylesheets/desktop/poll-ui-builder.scss +++ b/plugins/poll/assets/stylesheets/desktop/poll-ui-builder.scss @@ -1,6 +1,7 @@ .poll-ui-builder-modal { .modal-inner-container { - width: 600px; + width: 40em; // scale with user font-size + max-width: 100vw; // prevent overflow if user font-size is enourmous } .modal-body { diff --git a/plugins/poll/config/locales/client.uk.yml b/plugins/poll/config/locales/client.uk.yml index 4f308dde33..20d55f2baa 100644 --- a/plugins/poll/config/locales/client.uk.yml +++ b/plugins/poll/config/locales/client.uk.yml @@ -53,6 +53,9 @@ uk: show-results: title: "Показати результати опитування" label: "Показати результати" + remove-vote: + title: "Видалити свій голос" + label: "Вилучити голос" hide-results: title: "Назад до своїх голосів" label: "Показати голосування" diff --git a/plugins/poll/config/locales/server.id.yml b/plugins/poll/config/locales/server.id.yml index ea2c870ba5..f67c0ef460 100644 --- a/plugins/poll/config/locales/server.id.yml +++ b/plugins/poll/config/locales/server.id.yml @@ -29,3 +29,5 @@ id: only_staff_or_op_can_toggle_status: "Hanya anggota staf atau penerbit asli yang dapat merubah status polling." email: link_to_poll: "Klik untuk menampilkan polling." + user_field: + no_data: "Tidak ada Data" diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index de3c8a18ec..078d40c0ec 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -5,6 +5,7 @@ # version: 1.0 # authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip) # url: https://github.com/discourse/discourse/tree/main/plugins/poll +# transpile_js: true register_asset "stylesheets/common/poll.scss" register_asset "stylesheets/desktop/poll.scss", :desktop diff --git a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js similarity index 95% rename from plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js index b7320a1e64..750a053078 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-breakdown-test.js @@ -6,7 +6,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Poll breakdown", function (needs) { needs.user(); @@ -76,7 +76,7 @@ acceptance("Poll breakdown", function (needs) { assert.ok(exists(".poll-breakdown-total-votes"), "displays the vote count"); - assert.equal( + assert.strictEqual( count(".poll-breakdown-chart-container"), 2, "renders a chart for each of the groups in group_results response" @@ -92,7 +92,7 @@ acceptance("Poll breakdown", function (needs) { await visit("/t/-/topic_with_pie_chart_poll"); await click(".poll-show-breakdown"); - assert.equal( + assert.strictEqual( query(".poll-breakdown-option-count").textContent.trim(), "40.0%", "displays the correct vote percentage" @@ -100,7 +100,7 @@ acceptance("Poll breakdown", function (needs) { await click(".modal-tabs .count"); - assert.equal( + assert.strictEqual( query(".poll-breakdown-option-count").textContent.trim(), "2", "displays the correct vote count" @@ -108,7 +108,7 @@ acceptance("Poll breakdown", function (needs) { await click(".modal-tabs .percentage"); - assert.equal( + assert.strictEqual( query(".poll-breakdown-option-count").textContent.trim(), "40.0%", "displays the percentage again" diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js similarity index 100% rename from plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-builder-disabled-test.js diff --git a/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js similarity index 100% rename from plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-builder-enabled-test.js diff --git a/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js similarity index 99% rename from plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js index 7399ee3aac..7e8e011e35 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-in-reply-history-test.js @@ -1,7 +1,7 @@ import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Poll in a post reply history", function (needs) { needs.user(); diff --git a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js similarity index 90% rename from plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js index 4d720b2009..c36042d49e 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-pie-chart-test.js @@ -18,25 +18,25 @@ acceptance("Rendering polls with pie charts", function (needs) { const poll = query(".poll"); - assert.equal( + assert.strictEqual( query(".info-number", poll).innerHTML, "2", "it should display the right number of voters" ); - assert.equal( + assert.strictEqual( queryAll(".info-number", poll)[1].innerHTML, "5", "it should display the right number of votes" ); - assert.equal( + assert.strictEqual( poll.classList.contains("pie"), true, "pie class is present on poll div" ); - assert.equal( + assert.strictEqual( queryAll(".poll-results-chart", poll).length, 1, "Renders the chart div instead of bar container" diff --git a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js similarity index 98% rename from plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-quote-test.js index d15664bd55..491aa00ecd 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-quote-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-quote-test.js @@ -1,7 +1,7 @@ import { acceptance, count } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Poll quote", function (needs) { needs.user(); @@ -432,7 +432,7 @@ acceptance("Poll quote", function (needs) { test("renders and extends", async function (assert) { await visit("/t/-/topic_with_two_quoted_polls"); await click(".quote-controls"); - assert.equal(count(".poll"), 2, "polls are rendered"); - assert.equal(count(".poll-buttons"), 2, "polls are extended"); + assert.strictEqual(count(".poll"), 2, "polls are rendered"); + assert.strictEqual(count(".poll-buttons"), 2, "polls are extended"); }); }); diff --git a/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 b/plugins/poll/test/javascripts/acceptance/poll-results-test.js similarity index 97% rename from plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 rename to plugins/poll/test/javascripts/acceptance/poll-results-test.js index ef547c8460..5a7e52da2a 100644 --- a/plugins/poll/test/javascripts/acceptance/poll-results-test.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/poll-results-test.js @@ -6,7 +6,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Poll results", function (needs) { needs.user(); @@ -567,12 +567,12 @@ acceptance("Poll results", function (needs) { test("can load more voters", async function (assert) { await visit("/t/-/load-more-poll-voters"); - assert.equal( - find(".poll-container .results li:nth-child(1) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(1) .poll-voters li"), 1 ); - assert.equal( - find(".poll-container .results li:nth-child(2) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(2) .poll-voters li"), 0 ); @@ -626,24 +626,24 @@ acceptance("Poll results", function (needs) { }); await visit("/t/-/load-more-poll-voters"); - assert.equal( - find(".poll-container .results li:nth-child(1) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(1) .poll-voters li"), 1 ); - assert.equal( - find(".poll-container .results li:nth-child(2) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(2) .poll-voters li"), 1 ); await click(".poll-voters-toggle-expand a"); await visit("/t/-/load-more-poll-voters"); - assert.equal( - find(".poll-container .results li:nth-child(1) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(1) .poll-voters li"), 2 ); - assert.equal( - find(".poll-container .results li:nth-child(2) .poll-voters li").length, + assert.strictEqual( + count(".poll-container .results li:nth-child(2) .poll-voters li"), 0 ); }); @@ -652,13 +652,13 @@ acceptance("Poll results", function (needs) { await visit("/t/-/load-more-poll-voters"); await click(".toggle-results"); - assert.equal(count(".poll-container .d-icon-circle"), 1); - assert.equal(count(".poll-container .d-icon-far-circle"), 1); + assert.strictEqual(count(".poll-container .d-icon-circle"), 1); + assert.strictEqual(count(".poll-container .d-icon-far-circle"), 1); await click(".remove-vote"); - assert.equal(count(".poll-container .d-icon-circle"), 0); - assert.equal(count(".poll-container .d-icon-far-circle"), 2); + assert.strictEqual(count(".poll-container .d-icon-circle"), 0); + assert.strictEqual(count(".poll-container .d-icon-far-circle"), 2); }); }); diff --git a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js similarity index 86% rename from plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 rename to plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js index 12147b270e..36363f9acf 100644 --- a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-desktop.js @@ -1,7 +1,7 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Rendering polls with bar charts - desktop", function (needs) { needs.user(); @@ -46,15 +46,15 @@ acceptance("Rendering polls with bar charts - desktop", function (needs) { const polls = queryAll(".poll"); - assert.equal(polls.length, 2, "it should render the polls correctly"); + assert.strictEqual(polls.length, 2, "it should render the polls correctly"); - assert.equal( + assert.strictEqual( queryAll(".info-number", polls[0]).text(), "2", "it should display the right number of votes" ); - assert.equal( + assert.strictEqual( queryAll(".info-number", polls[1]).text(), "3", "it should display the right number of votes" @@ -65,11 +65,11 @@ acceptance("Rendering polls with bar charts - desktop", function (needs) { await visit("/t/-/14"); const polls = queryAll(".poll"); - assert.equal(polls.length, 1, "it should render the poll correctly"); + assert.strictEqual(polls.length, 1, "it should render the poll correctly"); await click("button.toggle-results"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 25, "it should display the right number of voters" @@ -77,7 +77,7 @@ acceptance("Rendering polls with bar charts - desktop", function (needs) { await click(".poll-voters-toggle-expand:nth-of-type(1) a"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 26, "it should display the right number of voters" @@ -88,11 +88,11 @@ acceptance("Rendering polls with bar charts - desktop", function (needs) { await visit("/t/-/13"); const polls = queryAll(".poll"); - assert.equal(polls.length, 1, "it should render the poll correctly"); + assert.strictEqual(polls.length, 1, "it should render the poll correctly"); await click("button.toggle-results"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 25, "it should display the right number of voters" @@ -105,7 +105,7 @@ acceptance("Rendering polls with bar charts - desktop", function (needs) { await click(".poll-voters-toggle-expand:nth-of-type(1) a"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 30, "it should display the right number of voters" diff --git a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js similarity index 88% rename from plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 rename to plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js index 654241d26c..44c74c2685 100644 --- a/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js.es6 +++ b/plugins/poll/test/javascripts/acceptance/polls-bar-chart-test-mobile.js @@ -1,7 +1,7 @@ import { acceptance, queryAll } from "discourse/tests/helpers/qunit-helpers"; import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { test } from "qunit"; -import { visit } from "@ember/test-helpers"; +import { click, visit } from "@ember/test-helpers"; acceptance("Rendering polls with bar charts - mobile", function (needs) { needs.user(); @@ -27,11 +27,11 @@ acceptance("Rendering polls with bar charts - mobile", function (needs) { await visit("/t/-/13"); const polls = queryAll(".poll"); - assert.equal(polls.length, 1, "it should render the poll correctly"); + assert.strictEqual(polls.length, 1, "it should render the poll correctly"); await click("button.toggle-results"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 25, "it should display the right number of voters" @@ -44,7 +44,7 @@ acceptance("Rendering polls with bar charts - mobile", function (needs) { await click(".poll-voters-toggle-expand:nth-of-type(1) a"); - assert.equal( + assert.strictEqual( queryAll(".poll-voters:nth-of-type(1) li").length, 35, "it should display the right number of voters" diff --git a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js similarity index 81% rename from plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 rename to plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js index 6f4a420f5c..2993cb42b9 100644 --- a/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js.es6 +++ b/plugins/poll/test/javascripts/controllers/poll-ui-builder-test.js @@ -23,14 +23,14 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { pollOptions: [{ value: "a" }], }); - assert.equal(controller.isMultiple, true, "it should be true"); + assert.strictEqual(controller.isMultiple, true, "it should be true"); controller.setProperties({ pollType: "random", pollOptions: [{ value: "b" }], }); - assert.equal(controller.isMultiple, false, "it should be false"); + assert.strictEqual(controller.isMultiple, false, "it should be false"); }); test("isNumber", function (assert) { @@ -38,11 +38,11 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("pollType", REGULAR_POLL_TYPE); - assert.equal(controller.isNumber, false, "it should be false"); + assert.strictEqual(controller.isNumber, false, "it should be false"); controller.set("pollType", NUMBER_POLL_TYPE); - assert.equal(controller.isNumber, true, "it should be true"); + assert.strictEqual(controller.isNumber, true, "it should be true"); }); test("pollOptionsCount", function (assert) { @@ -50,47 +50,47 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("pollOptions", [{ value: "1" }, { value: "2" }]); - assert.equal(controller.pollOptionsCount, 2, "it should equal 2"); + assert.strictEqual(controller.pollOptionsCount, 2, "it should equal 2"); controller.set("pollOptions", []); - assert.equal(controller.pollOptionsCount, 0, "it should equal 0"); + assert.strictEqual(controller.pollOptionsCount, 0, "it should equal 0"); }); test("disableInsert", function (assert) { const controller = setupController(this); controller.siteSettings.poll_maximum_options = 20; - assert.equal(controller.disableInsert, true, "it should be true"); + assert.strictEqual(controller.disableInsert, true, "it should be true"); controller.set("pollOptions", [{ value: "a" }, { value: "b" }]); - assert.equal(controller.disableInsert, false, "it should be false"); + assert.strictEqual(controller.disableInsert, false, "it should be false"); controller.set("pollType", NUMBER_POLL_TYPE); - assert.equal(controller.disableInsert, false, "it should be false"); + assert.strictEqual(controller.disableInsert, false, "it should be false"); controller.setProperties({ pollType: REGULAR_POLL_TYPE, pollOptions: [{ value: "a" }, { value: "b" }, { value: "c" }], }); - assert.equal(controller.disableInsert, false, "it should be false"); + assert.strictEqual(controller.disableInsert, false, "it should be false"); controller.setProperties({ pollType: REGULAR_POLL_TYPE, pollOptions: [], }); - assert.equal(controller.disableInsert, true, "it should be true"); + assert.strictEqual(controller.disableInsert, true, "it should be true"); controller.setProperties({ pollType: REGULAR_POLL_TYPE, pollOptions: [{ value: "w" }], }); - assert.equal(controller.disableInsert, false, "it should be false"); + assert.strictEqual(controller.disableInsert, false, "it should be false"); }); test("number pollOutput", async function (assert) { @@ -103,7 +103,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { }); await settled(); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=number results=always min=1 max=20 step=1]\n[/poll]\n", "it should return the right output" @@ -111,7 +111,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("pollStep", 2); await settled(); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=number results=always min=1 max=20 step=2]\n[/poll]\n", "it should return the right output" @@ -119,7 +119,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("publicPoll", true); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=number results=always min=1 max=20 step=2 public=true]\n[/poll]\n", "it should return the right output" @@ -127,7 +127,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("pollStep", 0); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=number results=always min=1 max=20 step=1 public=true]\n[/poll]\n", "it should return the right output" @@ -143,7 +143,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { pollType: REGULAR_POLL_TYPE, }); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=regular results=always chartType=bar]\n* 1\n* 2\n[/poll]\n", "it should return the right output" @@ -151,7 +151,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("publicPoll", "true"); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=regular results=always public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", "it should return the right output" @@ -159,7 +159,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("pollGroups", "test"); - assert.equal( + assert.strictEqual( controller.get("pollOutput"), "[poll type=regular results=always public=true chartType=bar groups=test]\n* 1\n* 2\n[/poll]\n", "it should return the right output" @@ -176,7 +176,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { pollOptions: [{ value: "1" }, { value: "2" }], }); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=multiple results=always min=1 max=2 chartType=bar]\n* 1\n* 2\n[/poll]\n", "it should return the right output" @@ -184,7 +184,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { controller.set("publicPoll", "true"); - assert.equal( + assert.strictEqual( controller.pollOutput, "[poll type=multiple results=always min=1 max=2 public=true chartType=bar]\n* 1\n* 2\n[/poll]\n", "it should return the right output" @@ -204,7 +204,7 @@ discourseModule("Unit | Controller | poll-ui-builder", function () { test("poll result is always by default", function (assert) { const controller = setupController(this); - assert.equal(controller.pollResult, "always"); + assert.strictEqual(controller.pollResult, "always"); }); test("staff_only option is present for staff", async function (assert) { diff --git a/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 b/plugins/poll/test/javascripts/helpers/display-poll-builder-button.js similarity index 100% rename from plugins/poll/test/javascripts/helpers/display-poll-builder-button.js.es6 rename to plugins/poll/test/javascripts/helpers/display-poll-builder-button.js diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js similarity index 100% rename from plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 rename to plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js similarity index 74% rename from plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 rename to plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js index b7d219b88c..59dff66e23 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js @@ -31,8 +31,8 @@ discourseModule( }, test(assert) { - assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); - assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); + assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%"); + assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%"); }, }); @@ -50,8 +50,8 @@ discourseModule( }, test(assert) { - assert.equal(queryAll(".option .percentage")[0].innerText, "56%"); - assert.equal(queryAll(".option .percentage")[1].innerText, "44%"); + assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%"); + assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%"); }, }); @@ -78,17 +78,17 @@ discourseModule( test(assert) { let percentages = queryAll(".option .percentage"); - assert.equal(percentages[0].innerText, "41%"); - assert.equal(percentages[1].innerText, "33%"); - assert.equal(percentages[2].innerText, "16%"); - assert.equal(percentages[3].innerText, "8%"); + assert.strictEqual(percentages[0].innerText, "41%"); + assert.strictEqual(percentages[1].innerText, "33%"); + assert.strictEqual(percentages[2].innerText, "16%"); + assert.strictEqual(percentages[3].innerText, "8%"); - assert.equal( + assert.strictEqual( queryAll(".option")[3].querySelectorAll("span")[1].innerText, "a" ); - assert.equal(percentages[4].innerText, "8%"); - assert.equal( + assert.strictEqual(percentages[4].innerText, "8%"); + assert.strictEqual( queryAll(".option")[4].querySelectorAll("span")[1].innerText, "b" ); diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js similarity index 93% rename from plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 rename to plugins/poll/test/javascripts/widgets/discourse-poll-test.js index 90da62b6b2..431c50de33 100644 --- a/plugins/poll/test/javascripts/widgets/discourse-poll-test.js.es6 +++ b/plugins/poll/test/javascripts/widgets/discourse-poll-test.js @@ -11,6 +11,7 @@ import EmberObject from "@ember/object"; import I18n from "I18n"; import pretender from "discourse/tests/helpers/create-pretender"; import hbs from "htmlbars-inline-precompile"; +import { click } from "@ember/test-helpers"; let requests = 0; @@ -123,12 +124,12 @@ discourseModule( await click( "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']" ); - assert.equal(requests, 1); - assert.equal(count(".chosen"), 1); - assert.equal(queryAll(".chosen").text(), "100%yes"); + assert.strictEqual(requests, 1); + assert.strictEqual(count(".chosen"), 1); + assert.strictEqual(queryAll(".chosen").text(), "100%yes"); await click(".toggle-results"); - assert.equal( + assert.strictEqual( queryAll("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']") .length, 1 @@ -171,11 +172,11 @@ discourseModule( await click( "li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']" ); - assert.equal( + assert.strictEqual( queryAll(".poll-container .alert").text(), I18n.t("poll.results.groups.title", { groups: "foo" }) ); - assert.equal(requests, 0); + assert.strictEqual(requests, 0); assert.ok(!exists(".chosen")); }, }); diff --git a/plugins/styleguide/assets/javascripts/discourse/components/color-example.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/color-example.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/color-example.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/color-example.js diff --git a/plugins/styleguide/assets/javascripts/discourse/components/styleguide-example.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/styleguide-example.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/styleguide-example.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/styleguide-example.js diff --git a/plugins/styleguide/assets/javascripts/discourse/components/styleguide-icons.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/styleguide-icons.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/styleguide-icons.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/styleguide-icons.js diff --git a/plugins/styleguide/assets/javascripts/discourse/components/styleguide-link.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/styleguide-link.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/styleguide-link.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/styleguide-link.js diff --git a/plugins/styleguide/assets/javascripts/discourse/components/styleguide-markdown.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/styleguide-markdown.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/styleguide-markdown.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/styleguide-markdown.js diff --git a/plugins/styleguide/assets/javascripts/discourse/components/styleguide-section.js.es6 b/plugins/styleguide/assets/javascripts/discourse/components/styleguide-section.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/components/styleguide-section.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/components/styleguide-section.js diff --git a/plugins/styleguide/assets/javascripts/discourse/controllers/styleguide-show.js.es6 b/plugins/styleguide/assets/javascripts/discourse/controllers/styleguide-show.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/controllers/styleguide-show.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/controllers/styleguide-show.js diff --git a/plugins/styleguide/assets/javascripts/discourse/controllers/styleguide.js.es6 b/plugins/styleguide/assets/javascripts/discourse/controllers/styleguide.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/controllers/styleguide.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/controllers/styleguide.js diff --git a/plugins/styleguide/assets/javascripts/discourse/helpers/section-title.js.es6 b/plugins/styleguide/assets/javascripts/discourse/helpers/section-title.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/helpers/section-title.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/helpers/section-title.js diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js.es6 b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js similarity index 99% rename from plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js index 0e01c5fc44..df2f90450e 100644 --- a/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js.es6 +++ b/plugins/styleguide/assets/javascripts/discourse/lib/dummy-data.js @@ -89,7 +89,7 @@ export function createData(store) { topicId++; return store.createRecord( "topic", - $.extend( + Object.assign( { id: topicId, title: `Example Topic Title ${topicId}`, diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js.es6 b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js diff --git a/plugins/styleguide/assets/javascripts/discourse/routes/styleguide-show.js.es6 b/plugins/styleguide/assets/javascripts/discourse/routes/styleguide-show.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/routes/styleguide-show.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/routes/styleguide-show.js diff --git a/plugins/styleguide/assets/javascripts/discourse/routes/styleguide.js.es6 b/plugins/styleguide/assets/javascripts/discourse/routes/styleguide.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/routes/styleguide.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/routes/styleguide.js diff --git a/plugins/styleguide/assets/javascripts/discourse/styleguide-route-map.js.es6 b/plugins/styleguide/assets/javascripts/discourse/styleguide-route-map.js similarity index 100% rename from plugins/styleguide/assets/javascripts/discourse/styleguide-route-map.js.es6 rename to plugins/styleguide/assets/javascripts/discourse/styleguide-route-map.js diff --git a/plugins/styleguide/config/locales/client.id.yml b/plugins/styleguide/config/locales/client.id.yml index 5190ea6536..436ae7ff79 100644 --- a/plugins/styleguide/config/locales/client.id.yml +++ b/plugins/styleguide/config/locales/client.id.yml @@ -7,7 +7,26 @@ id: js: styleguide: + welcome: "Untuk memulai, pilih bagian dari menu di sebelah kiri." + categories: + atoms: Atom + molecules: Molekul + organisms: Organisme sections: + typography: + title: "Tipografi" + example: "Selamat datang di Discourse" + paragraph: "Lorem ipsum dolor duduk amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut tenaga kerja et dolore magna aliqua. Ut Enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea komodo consequat. Duis aute irure dolor di reprehenderit di voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt anim id est laborum." + date_time_inputs: + title: "Masukan Tanggal/Waktu" + font_scale: + title: "Sistem Font" + colors: + title: "Warna" + icons: + title: "Ikon" + buttons: + title: "Tombol" categories: title: "Kategori" navigation: diff --git a/plugins/styleguide/plugin.rb b/plugins/styleguide/plugin.rb index 4c3244b1f6..f90169de17 100644 --- a/plugins/styleguide/plugin.rb +++ b/plugins/styleguide/plugin.rb @@ -4,6 +4,7 @@ # about: Preview how Widgets are Styled in Discourse # version: 0.2 # author: Robin Ward +# transpile_js: true register_asset "stylesheets/styleguide.scss" enabled_site_setting :styleguide_enabled diff --git a/public/javascripts/media-optimization-worker.js b/public/javascripts/media-optimization-worker.js index e57e362723..b3fd8f0127 100644 --- a/public/javascripts/media-optimization-worker.js +++ b/public/javascripts/media-optimization-worker.js @@ -2,10 +2,10 @@ function resizeWithAspect( input_width, input_height, target_width, - target_height, + target_height ) { if (!target_width && !target_height) { - throw Error('Need to specify at least width or height when resizing'); + throw Error("Need to specify at least width or height when resizing"); } if (target_width && target_height) { @@ -33,9 +33,6 @@ function logIfDebug(message) { } async function optimize(imageData, fileName, width, height, settings) { - - await loadLibs(settings); - const mozJpegDefaultOptions = { quality: settings.encode_quality, baseline: false, @@ -63,7 +60,11 @@ async function optimize(imageData, fileName, width, height, settings) { // resize if (width > settings.resize_threshold) { try { - const target_dimensions = resizeWithAspect(width, height, settings.resize_target); + const target_dimensions = resizeWithAspect( + width, + height, + settings.resize_target + ); const resizeResult = self.codecs.resize( new Uint8ClampedArray(imageData), width, //in @@ -75,12 +76,12 @@ async function optimize(imageData, fileName, width, height, settings) { settings.resize_linear_rgb ); if (resizeResult[3] !== 255) { - throw "Image corrupted during resize. Falling back to the original for encode" + throw "Image corrupted during resize. Falling back to the original for encode"; } maybeResized = new ImageData( resizeResult, target_dimensions.width, - target_dimensions.height, + target_dimensions.height ).data; width = target_dimensions.width; height = target_dimensions.height; @@ -102,12 +103,12 @@ async function optimize(imageData, fileName, width, height, settings) { mozJpegDefaultOptions ); - const finalSize = result.byteLength + const finalSize = result.byteLength; logIfDebug(`Worker post reencode file: ${finalSize}`); logIfDebug(`Reduction: ${(initialSize / finalSize).toFixed(1)}x speedup`); if (finalSize < 20000) { - throw "Final size suspciously small, discarding optimizations" + throw "Final size suspciously small, discarding optimizations"; } let transferrable = Uint8Array.from(result).buffer; // decoded was allocated inside WASM so it **cannot** be transfered to another context, need to copy by value @@ -132,7 +133,7 @@ onmessage = async function (e) { type: "file", file: optimized, fileName: e.data.fileName, - fileId: e.data.fileId + fileId: e.data.fileId, }, [optimized] ); @@ -142,31 +143,27 @@ onmessage = async function (e) { type: "error", file: e.data.file, fileName: e.data.fileName, - fileId: e.data.fileId + fileId: e.data.fileId, }); } break; + case "install": + await loadLibs(e.data.settings); + postMessage({ type: "installed" }); + break; default: logIfDebug(`Sorry, we are out of ${e}.`); } }; -async function loadLibs(settings){ - +async function loadLibs(settings) { if (self.codecs) return; - if (!self.loadedMozJpeg) { - importScripts(settings.mozjpeg_script); - self.loadedMozJpeg = true; - } - - if (!self.loadedResizeScript) { - importScripts(settings.resize_script); - self.loadedResizeScript = true; - } + importScripts(settings.mozjpeg_script); + importScripts(settings.resize_script); let encoderModuleOverrides = { - locateFile: function(path, prefix) { + locateFile: function (path, prefix) { // if it's a mem init file, use a custom dir if (path.endsWith(".wasm")) return settings.mozjpeg_wasm; // otherwise, use the default, the prefix (JS file's dir) + the path @@ -181,5 +178,5 @@ async function loadLibs(settings){ const { resize } = wasm_bindgen; await wasm_bindgen(settings.resize_wasm); - self.codecs = {mozjpeg_enc: mozjpeg_enc_module, resize: resize}; + self.codecs = { mozjpeg_enc: mozjpeg_enc_module, resize: resize }; } diff --git a/script/bench.rb b/script/bench.rb index ce88f408b4..d1ab78d791 100644 --- a/script/bench.rb +++ b/script/bench.rb @@ -297,7 +297,7 @@ begin run("RAILS_ENV=profile bundle exec rake assets:clean") def get_mem(pid) - YAML.load `ruby script/memstats.rb #{pid} --yaml` + YAML.safe_load `ruby script/memstats.rb #{pid} --yaml` end mem = get_mem(pid) diff --git a/script/benchmarks/cache/bench.rb b/script/benchmarks/cache/bench.rb index ff6092e859..fad1b73607 100644 --- a/script/benchmarks/cache/bench.rb +++ b/script/benchmarks/cache/bench.rb @@ -46,7 +46,7 @@ Benchmark.ips do |x| x.report("redis get string marshal") do |times| while times > 0 - Marshal.load(Discourse.redis.get("test_keym")) + Marshal.load(Discourse.redis.get("test_keym")) # rubocop:disable Security/MarshalLoad times -= 1 end end diff --git a/script/bulk_import/vanilla.rb b/script/bulk_import/vanilla.rb index 92ddf3fd17..cb85b853f9 100644 --- a/script/bulk_import/vanilla.rb +++ b/script/bulk_import/vanilla.rb @@ -5,6 +5,8 @@ require "mysql2" require "rake" require "htmlentities" +# NOTE: this importer expects a MySQL DB to directly connect to + class BulkImport::Vanilla < BulkImport::Base VANILLA_DB = "dbname" diff --git a/script/discourse b/script/discourse index 1d4e4ff702..bca7197c30 100755 --- a/script/discourse +++ b/script/discourse @@ -4,6 +4,10 @@ require "thor" class DiscourseCLI < Thor + def self.exit_on_failure? + true + end + desc "remap [--global,--regex] FROM TO", "Remap a string sequence across all tables" long_desc <<-LONGDESC Replace a string sequence FROM with TO across all tables. diff --git a/script/import_scripts/jforum.rb b/script/import_scripts/jforum.rb index e5d56718b6..1c1136f208 100644 --- a/script/import_scripts/jforum.rb +++ b/script/import_scripts/jforum.rb @@ -10,7 +10,7 @@ class ImportScripts::JForum < ImportScripts::Base def initialize super - @settings = YAML.load(File.read(ARGV.first), symbolize_names: true) + @settings = YAML.safe_load(File.read(ARGV.first), symbolize_names: true) @database_client = Mysql2::Client.new( host: @settings[:database][:host], diff --git a/script/import_scripts/mbox/support/indexer.rb b/script/import_scripts/mbox/support/indexer.rb index 47ce3ed133..c8164534d7 100644 --- a/script/import_scripts/mbox/support/indexer.rb +++ b/script/import_scripts/mbox/support/indexer.rb @@ -48,7 +48,7 @@ module ImportScripts::Mbox if File.exist?(metadata_file) # workaround for YML files that contain classname in file header yaml = File.read(metadata_file).sub(/^--- !.*$/, '---') - metadata = YAML.load(yaml) + metadata = YAML.safe_load(yaml) else metadata = {} end diff --git a/script/import_scripts/nodebb/nodebb.rb b/script/import_scripts/nodebb/nodebb.rb index 45fe69c7c8..5fed857301 100644 --- a/script/import_scripts/nodebb/nodebb.rb +++ b/script/import_scripts/nodebb/nodebb.rb @@ -180,7 +180,7 @@ class ImportScripts::NodeBB < ImportScripts::Base if is_external # download external image begin - string_io = open(picture, read_timeout: 5) + string_io = uri.open(read_timeout: 5) rescue Net::ReadTimeout puts "timeout downloading avatar for user #{imported_user.id}" return nil @@ -246,7 +246,7 @@ class ImportScripts::NodeBB < ImportScripts::Base if is_external begin - string_io = open(picture, read_timeout: 5) + string_io = uri.open(read_timeout: 5) rescue Net::ReadTimeout return nil end diff --git a/script/import_scripts/phorum.rb b/script/import_scripts/phorum.rb index 3dc4e80b04..dc2639933e 100644 --- a/script/import_scripts/phorum.rb +++ b/script/import_scripts/phorum.rb @@ -25,6 +25,7 @@ class ImportScripts::Phorum < ImportScripts::Base import_users import_categories import_posts + import_attachments end def import_users @@ -34,7 +35,7 @@ class ImportScripts::Phorum < ImportScripts::Base batches(BATCH_SIZE) do |offset| results = mysql_query( - "SELECT user_id id, username, email, real_name name, date_added created_at, + "SELECT user_id id, username, TRIM(email) AS email, username name, date_added created_at, date_last_active last_seen_at, admin FROM #{TABLE_PREFIX}users WHERE #{TABLE_PREFIX}users.active = 1 @@ -209,12 +210,79 @@ class ImportScripts::Phorum < ImportScripts::Base s.gsub!(/\[hr\]/i, "
") + # remove trailing
+ s = s.chomp("
") + s end def mysql_query(sql) @client.query(sql, cache_rows: false) end + + def import_attachments + puts '', 'importing attachments...' + + uploads = mysql_query <<-SQL + SELECT message_id, filename, FROM_BASE64(file_data) AS file_data, file_id + FROM #{TABLE_PREFIX}files + where message_id > 0 + order by file_id + SQL + + current_count = 0 + total_count = uploads.count + + uploads.each do |upload| + + # puts "*** processing file #{upload['file_id']}" + + post_id = post_id_from_imported_post_id(upload['message_id']) + + if post_id.nil? + puts "Post #{upload['message_id']} for attachment #{upload['file_id']} not found" + next + end + + post = Post.find(post_id) + + real_filename = upload['filename'] + real_filename.prepend SecureRandom.hex if real_filename[0] == '.' + + tmpfile = 'attach_' + upload['file_id'].to_s + filename = File.join('/tmp/', tmpfile) + File.open(filename, 'wb') { |f| + f.write(upload['file_data']) + } + + upl_obj = create_upload(post.user.id, filename, real_filename) + + # puts "discourse post #{post['id']} and upload #{upl_obj['id']}" + + if upl_obj&.persisted? + html = html_for_upload(upl_obj, real_filename) + if !post.raw[html] + post.raw += "\n\n#{html}\n\n" + post.save! + if PostUpload.where(post: post, upload: upl_obj).exists? + puts "skipping creating uploaded for previously uploaded file #{upload['file_id']}" + else + PostUpload.create!(post: post, upload: upl_obj) + end + # PostUpload.create!(post: post, upload: upl_obj) unless PostUpload.where(post: post, upload: upl_obj).exists? + else + puts "Skipping attachment #{upload['file_id']}" + end + else + puts "Failed to upload attachment #{upload['file_id']}" + exit + end + + current_count += 1 + print_status(current_count, total_count) + end + end + end ImportScripts::Phorum.new.perform diff --git a/script/import_scripts/smf2.rb b/script/import_scripts/smf2.rb index 74b63189cd..9885f1edc3 100644 --- a/script/import_scripts/smf2.rb +++ b/script/import_scripts/smf2.rb @@ -558,7 +558,7 @@ class ImportScripts::Smf2 < ImportScripts::Base def read_smf_settings settings = File.join(self.smfroot, 'Settings.php') - IO.readlines(settings).each do |line| + File.readlines(settings).each do |line| next unless m = /\$([a-z_]+)\s*=\s*['"](.+?)['"]\s*;\s*((#|\/\/).*)?$/.match(line) case m[1] when 'db_server' then self.host ||= m[2] diff --git a/script/import_scripts/vanilla.rb b/script/import_scripts/vanilla.rb index 08f8ada44b..1d67cd045a 100644 --- a/script/import_scripts/vanilla.rb +++ b/script/import_scripts/vanilla.rb @@ -3,6 +3,9 @@ require "csv" require File.expand_path(File.dirname(__FILE__) + "/base.rb") +# NOTE: this importer expects a text file obtained through Vanilla Porter +# user documentation: https://meta.discourse.org/t/how-to-migrate-import-from-vanilla-to-discourse/27273 + class ImportScripts::Vanilla < ImportScripts::Base def initialize @@ -199,7 +202,9 @@ class ImportScripts::Vanilla < ImportScripts::Base user_emails_in_conversation = @users.select { |u| user_ids_in_conversation.include?(u[:user_id]) } .map { |u| u[:email] } # retrieve their usernames from the database - target_usernames = User.where("email IN (?)", user_emails_in_conversation).pluck(:username).to_a + target_usernames = User.joins(:user_emails) + .where(user_emails: { email: user_emails_in_conversation }) + .pluck(:username) next if target_usernames.blank? @@ -207,7 +212,6 @@ class ImportScripts::Vanilla < ImportScripts::Base first_message = @conversation_messages.select { |cm| cm[:message_id] == conversation[:first_message_id] }.first { - archetype: Archetype.private_message, id: "conversation#" + conversation[:conversation_id], user_id: user.id, title: "Private message from #{user.username}", diff --git a/script/import_scripts/zendesk_api.rb b/script/import_scripts/zendesk_api.rb index 4b5a2b5680..9237a76442 100644 --- a/script/import_scripts/zendesk_api.rb +++ b/script/import_scripts/zendesk_api.rb @@ -333,7 +333,7 @@ class ImportScripts::ZendeskApi < ImportScripts::Base attempts = 0 begin - open("#{$1}") do |image| + URI.parse(image_url).open do |image| # IMAGE_DOWNLOAD_PATH is whatever image, it will be replaced with the downloaded image File.open(IMAGE_DOWNLOAD_PATH, "wb") do |file| file.write(image.read) diff --git a/script/memstats.rb b/script/memstats.rb index f387a88337..998d8d525f 100755 --- a/script/memstats.rb +++ b/script/memstats.rb @@ -131,7 +131,7 @@ def format_number(n) end def get_commandline(pid) - commandline = IO.read("/proc/#{pid}/cmdline").split("\0") + commandline = File.read("/proc/#{pid}/cmdline").split("\0") if commandline.first =~ /java$/ then loop { break if commandline.shift == "-jar" } return "[java] #{commandline.shift}" diff --git a/spec/components/auth/default_current_user_provider_spec.rb b/spec/components/auth/default_current_user_provider_spec.rb index b8da06fc58..502e289f86 100644 --- a/spec/components/auth/default_current_user_provider_spec.rb +++ b/spec/components/auth/default_current_user_provider_spec.rb @@ -621,8 +621,8 @@ describe Auth::DefaultCurrentUserProvider do end it "rate limits api usage" do - limiter1 = RateLimiter.new(nil, "user_api_day_#{api_key.key}", 10, 60) - limiter2 = RateLimiter.new(nil, "user_api_min_#{api_key.key}", 10, 60) + limiter1 = RateLimiter.new(nil, "user_api_day_#{ApiKey.hash_key(api_key.key)}", 10, 60) + limiter2 = RateLimiter.new(nil, "user_api_min_#{ApiKey.hash_key(api_key.key)}", 10, 60) limiter1.clear! limiter2.clear! diff --git a/spec/components/discourse_spec.rb b/spec/components/discourse_spec.rb index 701cc39950..aa2542e04b 100644 --- a/spec/components/discourse_spec.rb +++ b/spec/components/discourse_spec.rb @@ -104,6 +104,7 @@ describe Discourse do after do Discourse.plugins.delete plugin1 Discourse.plugins.delete plugin2 + DiscoursePluginRegistry.reset! end before do diff --git a/spec/components/discourse_updates_spec.rb b/spec/components/discourse_updates_spec.rb index 6bb8fd2740..dbe653f085 100644 --- a/spec/components/discourse_updates_spec.rb +++ b/spec/components/discourse_updates_spec.rb @@ -3,23 +3,18 @@ require 'rails_helper' describe DiscourseUpdates do - def stub_data(latest, missing, critical, updated_at) - DiscourseUpdates.stubs(:latest_version).returns(latest) - DiscourseUpdates.stubs(:missing_versions_count).returns(missing) - DiscourseUpdates.stubs(:critical_updates_available?).returns(critical) - DiscourseUpdates.stubs(:updated_at).returns(updated_at) - end - - before do - Jobs::VersionCheck.any_instance.stubs(:execute).returns(true) + DiscourseUpdates.latest_version = latest + DiscourseUpdates.missing_versions_count = missing + DiscourseUpdates.critical_updates_available = critical + DiscourseUpdates.updated_at = updated_at end subject { DiscourseUpdates.check_version } context 'version check was done at the current installed version' do before do - DiscourseUpdates.stubs(:last_installed_version).returns(Discourse::VERSION::STRING) + DiscourseUpdates.last_installed_version = Discourse::VERSION::STRING end context 'a good version check request happened recently' do @@ -36,7 +31,7 @@ describe DiscourseUpdates do end it 'returns the timestamp of the last version check' do - expect(subject.updated_at).to eq_time(time) + expect(subject.updated_at).to be_within_one_second_of(time) end end @@ -52,7 +47,7 @@ describe DiscourseUpdates do end it 'returns the timestamp of the last version check' do - expect(subject.updated_at).to eq_time(time) + expect(subject.updated_at).to be_within_one_second_of(time) end end end @@ -115,7 +110,7 @@ describe DiscourseUpdates do context 'version check was done at a different installed version' do before do - DiscourseUpdates.stubs(:last_installed_version).returns('0.9.1') + DiscourseUpdates.last_installed_version = '0.9.1' end shared_examples "when last_installed_version is old" do @@ -211,7 +206,7 @@ describe DiscourseUpdates do ] Discourse.redis.set('new_features', MultiJson.dump(features_with_versions)) - DiscourseUpdates.stubs(:last_installed_version).returns("2.7.0.beta2") + DiscourseUpdates.last_installed_version = "2.7.0.beta2" result = DiscourseUpdates.new_features expect(result.length).to eq(3) diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb index 472e61cd83..82563ef97b 100644 --- a/spec/components/file_store/s3_store_spec.rb +++ b/spec/components/file_store/s3_store_spec.rb @@ -163,7 +163,11 @@ describe FileStore::S3Store do s3_helper.expects(:copy).with(external_upload_stub.key, kind_of(String), options: upload_opts).returns(["path", "etag"]) s3_helper.expects(:delete_object).with(external_upload_stub.key) upload = Fabricate(:upload, extension: "png", sha1: upload_sha1, original_filename: original_filename) - store.move_existing_stored_upload(external_upload_stub.key, upload, "image/png") + store.move_existing_stored_upload( + existing_external_upload_key: external_upload_stub.key, + upload: upload, + content_type: "image/png" + ) end context "when the file is a PDF" do @@ -175,7 +179,11 @@ describe FileStore::S3Store do disp_opts = { content_disposition: "attachment; filename=\"#{original_filename}\"; filename*=UTF-8''#{original_filename}", content_type: "application/pdf" } s3_helper.expects(:copy).with(external_upload_stub.key, kind_of(String), options: upload_opts.merge(disp_opts)).returns(["path", "etag"]) upload = Fabricate(:upload, extension: "png", sha1: upload_sha1, original_filename: original_filename) - store.move_existing_stored_upload(external_upload_stub.key, upload, "application/pdf") + store.move_existing_stored_upload( + existing_external_upload_key: external_upload_stub.key, + upload: upload, + content_type: "application/pdf" + ) end end end diff --git a/spec/components/final_destination_spec.rb b/spec/components/final_destination_spec.rb index 8b781324bd..e9548ead0b 100644 --- a/spec/components/final_destination_spec.rb +++ b/spec/components/final_destination_spec.rb @@ -194,6 +194,31 @@ describe FinalDestination do expect(final.status).to eq(:resolved) end + it 'resolves the canonical link when the URL is relative' do + host = "https://codinghorror.com" + + canonical_follow("#{host}/blog", "/blog/canonical") + stub_request(:head, "#{host}/blog/canonical").to_return(doc_response) + + final = FinalDestination.new("#{host}/blog", opts.merge(follow_canonical: true)) + + expect(final.resolve.to_s).to eq("#{host}/blog/canonical") + expect(final.redirected?).to eq(false) + expect(final.status).to eq(:resolved) + end + + it 'resolves the canonical link when the URL is relative and does not start with the / symbol' do + host = "https://codinghorror.com" + canonical_follow("#{host}/blog", "blog/canonical") + stub_request(:head, "#{host}/blog/canonical").to_return(doc_response) + + final = FinalDestination.new("#{host}/blog", opts.merge(follow_canonical: true)) + + expect(final.resolve.to_s).to eq("#{host}/blog/canonical") + expect(final.redirected?).to eq(false) + expect(final.status).to eq(:resolved) + end + it "does not follow the canonical link if it's the same as the current URL" do canonical_follow("https://eviltrout.com", "https://eviltrout.com") diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 0185b32217..3ff0420bb2 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -3196,14 +3196,9 @@ describe Guardian do context "allowlist mode" do before do - GlobalSetting.reset_allowed_theme_ids! global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git" end - after do - GlobalSetting.reset_allowed_theme_ids! - end - it "should respect theme allowlisting" do r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") theme.update!(remote_theme_id: r.id) diff --git a/spec/components/imap/sync_spec.rb b/spec/components/imap/sync_spec.rb index 04ee4eb3cd..3c494904bb 100644 --- a/spec/components/imap/sync_spec.rb +++ b/spec/components/imap/sync_spec.rb @@ -2,7 +2,6 @@ require 'rails_helper' require 'imap/sync' -require_relative 'imap_helper' describe Imap::Sync do diff --git a/spec/components/middleware/anonymous_cache_spec.rb b/spec/components/middleware/anonymous_cache_spec.rb index e0b23c8a54..9cf0e05866 100644 --- a/spec/components/middleware/anonymous_cache_spec.rb +++ b/spec/components/middleware/anonymous_cache_spec.rb @@ -240,11 +240,12 @@ describe Middleware::AnonymousCache do context 'invalid request payload' do it 'returns 413 for GET request with payload' do - status, _, _ = middleware.call(env.tap do |environment| + status, headers, _ = middleware.call(env.tap do |environment| environment[Rack::RACK_INPUT].write("test") end) expect(status).to eq(413) + expect(headers["Cache-Control"]).to eq("private, max-age=0, must-revalidate") end end diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index 527f0545d2..21afb3abc9 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -308,7 +308,7 @@ describe Oneboxer do end end - context 'facebook_app_access_token' do + context 'instagram' do it 'providing a token should attempt to use new endpoint' do url = "https://www.instagram.com/p/CHLkBERAiLa" access_token = 'abc123' @@ -318,7 +318,7 @@ describe Oneboxer do stub_request(:head, url) stub_request(:get, "https://graph.facebook.com/v9.0/instagram_oembed?url=#{url}&access_token=#{access_token}").to_return(body: response("instagram_new")) - expect(Oneboxer.preview(url, invalidate_oneboxes: true)).not_to include('instagram-description') + expect(Oneboxer.preview(url, invalidate_oneboxes: true)).to include('placeholder-icon image') end it 'unconfigured token should attempt to use old endpoint' do @@ -326,7 +326,15 @@ describe Oneboxer do stub_request(:head, url) stub_request(:get, "https://api.instagram.com/oembed/?url=#{url}").to_return(body: response("instagram_old")) - expect(Oneboxer.preview(url, invalidate_oneboxes: true)).to include('instagram-description') + expect(Oneboxer.preview(url, invalidate_oneboxes: true)).to include('placeholder-icon image') + end + + it 'renders result using an iframe' do + url = "https://www.instagram.com/p/CHLkBERAiLa" + stub_request(:head, url) + stub_request(:get, "https://api.instagram.com/oembed/?url=#{url}").to_return(body: response("instagram_old")) + + expect(Oneboxer.onebox(url, invalidate_oneboxes: true)).to include('iframe') end end diff --git a/spec/components/pbkdf2_spec.rb b/spec/components/pbkdf2_spec.rb index 0de2f5e635..bbc07c4be0 100644 --- a/spec/components/pbkdf2_spec.rb +++ b/spec/components/pbkdf2_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'rails_helper' require 'pbkdf2' describe Pbkdf2 do diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb index a1e3b989f3..530df2385c 100644 --- a/spec/components/post_destroyer_spec.rb +++ b/spec/components/post_destroyer_spec.rb @@ -473,6 +473,18 @@ describe PostDestroyer do expect_job_enqueued(job: :sync_topic_user_bookmarked, args: { topic_id: post2.topic_id }) end + it "skips post revise validations when post is marked for deletion by the author" do + SiteSetting.min_first_post_length = 100 + post = create_post(raw: "this is a long post what passes the min_first_post_length validation " * 3) + PostDestroyer.new(post.user, post).destroy + post.reload + expect(post.errors).to be_blank + expect(post.revisions.count).to eq(1) + expect(post.raw).to eq(I18n.t("js.topic.deleted_by_author_simple")) + expect(post.user_deleted).to eq(true) + expect(post.topic.closed).to eq(true) + end + context "as a moderator" do it "deletes the post" do author = post.user diff --git a/spec/components/post_revisor_spec.rb b/spec/components/post_revisor_spec.rb index 207f217783..d0c318a2c1 100644 --- a/spec/components/post_revisor_spec.rb +++ b/spec/components/post_revisor_spec.rb @@ -148,6 +148,22 @@ describe PostRevisor do subject { PostRevisor.new(post) } + it 'destroys last revision if edit is undone' do + old_raw = post.raw + + subject.revise!(admin, raw: 'new post body', tags: ['new-tag']) + expect(post.topic.reload.tags.map(&:name)).to contain_exactly('new-tag') + expect(post.post_revisions.reload.size).to eq(1) + + subject.revise!(admin, raw: old_raw, tags: []) + expect(post.topic.reload.tags.map(&:name)).to be_empty + expect(post.post_revisions.reload.size).to eq(0) + + subject.revise!(admin, raw: 'next post body', tags: ['new-tag']) + expect(post.topic.reload.tags.map(&:name)).to contain_exactly('new-tag') + expect(post.post_revisions.reload.size).to eq(1) + end + describe 'with the same body' do it "doesn't change version" do expect { @@ -703,6 +719,17 @@ describe PostRevisor do expect(post.revisions.first.modifications["archetype"][1]).to eq(new_archetype) end + it "revises and tracks changes of topic tags" do + subject.revise!(admin, tags: ['new-tag']) + expect(post.post_revisions.last.modifications).to eq('tags' => [[], ['new-tag']]) + + subject.revise!(admin, tags: ['new-tag', 'new-tag-2']) + expect(post.post_revisions.last.modifications).to eq('tags' => [[], ['new-tag', 'new-tag-2']]) + + subject.revise!(admin, tags: ['new-tag-3']) + expect(post.post_revisions.last.modifications).to eq('tags' => [[], ['new-tag-3']]) + end + context "#publish_changes" do let!(:post) { Fabricate(:post, topic: topic) } diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 7acb5e0f3b..d4b7267291 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -1479,6 +1479,22 @@ HTML HTML end + it "does not replace hashtags and mentions when watched words are regular expressions" do + SiteSetting.watched_words_regular_expressions = true + + Fabricate(:user, username: "test") + category = Fabricate(:category, slug: "test") + Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "es", replacement: "discourse") + + expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML) +

+ @test + #test + tdiscourset +

+ HTML + end + it "supports overlapping words" do Fabricate(:watched_word, action: WatchedWord.actions[:link], word: "meta", replacement: "https://meta.discourse.org") Fabricate(:watched_word, action: WatchedWord.actions[:replace], word: "iz", replacement: "is") diff --git a/spec/components/s3_helper_spec.rb b/spec/components/s3_helper_spec.rb index daf229fc6f..d70164728d 100644 --- a/spec/components/s3_helper_spec.rb +++ b/spec/components/s3_helper_spec.rb @@ -129,4 +129,59 @@ describe "S3Helper" do expect(response.second).to eq("etag") end end + + describe "#ensure_cors" do + let(:s3_helper) { S3Helper.new("test-bucket", "", client: client) } + + it "does nothing if !s3_install_cors_rule" do + SiteSetting.s3_install_cors_rule = false + s3_helper.expects(:s3_resource).never + s3_helper.ensure_cors! + end + + it "creates the assets rule if no rule exists" do + s3_helper.s3_client.stub_responses(:get_bucket_cors, Aws::S3::Errors::NoSuchCORSConfiguration.new("", {})) + s3_helper.s3_client.expects(:put_bucket_cors).with( + bucket: s3_helper.s3_bucket_name, + cors_configuration: { + cors_rules: [S3CorsRulesets::ASSETS] + } + ) + s3_helper.ensure_cors!([S3CorsRulesets::ASSETS]) + end + + it "does nothing if a rule already exists" do + s3_helper.s3_client.stub_responses(:get_bucket_cors, { + cors_rules: [S3CorsRulesets::ASSETS] + }) + s3_helper.s3_client.expects(:put_bucket_cors).never + s3_helper.ensure_cors!([S3CorsRulesets::ASSETS]) + end + + it "applies the passed in rule if a different rule already exists" do + s3_helper.s3_client.stub_responses(:get_bucket_cors, { + cors_rules: [S3CorsRulesets::ASSETS] + }) + s3_helper.s3_client.expects(:put_bucket_cors).with( + bucket: s3_helper.s3_bucket_name, + cors_configuration: { + cors_rules: [S3CorsRulesets::ASSETS, S3CorsRulesets::BACKUP_DIRECT_UPLOAD] + } + ) + s3_helper.ensure_cors!([S3CorsRulesets::BACKUP_DIRECT_UPLOAD]) + end + + it "returns false if the CORS rules do not get applied from an error" do + s3_helper.s3_client.stub_responses(:get_bucket_cors, { + cors_rules: [S3CorsRulesets::ASSETS] + }) + s3_helper.s3_client.expects(:put_bucket_cors).with( + bucket: s3_helper.s3_bucket_name, + cors_configuration: { + cors_rules: [S3CorsRulesets::ASSETS, S3CorsRulesets::BACKUP_DIRECT_UPLOAD] + } + ).raises(Aws::S3::Errors::AccessDenied.new("test", "test", {})) + expect(s3_helper.ensure_cors!([S3CorsRulesets::BACKUP_DIRECT_UPLOAD])).to eq(false) + end + end end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index c8f2a5c952..d3c61aacbb 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -1107,7 +1107,7 @@ describe Search do it 'splits English / Chinese and filter out stop words' do SiteSetting.default_locale = 'zh_CN' data = Search.prepare_data(sentence).split(' ') - expect(data).to eq(["Discourse", "中国", "基础", "设施", "基础设施", "网络", "正在", "组装"]) + expect(data).to eq(["Discourse", "中国", "基础设施", "网络", "正在", "组装"]) end it 'splits for indexing and filter out stop words' do @@ -1119,12 +1119,6 @@ describe Search do it 'splits English / Traditional Chinese and filter out stop words' do SiteSetting.default_locale = 'zh_TW' data = Search.prepare_data(sentence_t).split(' ') - expect(data).to eq(["Discourse", "太平", "平山", "太平山", "森林", "遊樂區"]) - end - - it 'splits for indexing and filter out stop words' do - SiteSetting.default_locale = 'zh_TW' - data = Search.prepare_data(sentence_t, :index).split(' ') expect(data).to eq(["Discourse", "太平山", "森林", "遊樂區"]) end diff --git a/spec/components/svg_sprite/svg_sprite_spec.rb b/spec/components/svg_sprite/svg_sprite_spec.rb index 15bccf6b1b..e3d1ccd3bf 100644 --- a/spec/components/svg_sprite/svg_sprite_spec.rb +++ b/spec/components/svg_sprite/svg_sprite_spec.rb @@ -243,6 +243,7 @@ describe SvgSprite do after do Discourse.plugins.delete plugin1 + DiscoursePluginRegistry.reset! end it "includes custom icons from plugins" do diff --git a/spec/components/topic_creator_spec.rb b/spec/components/topic_creator_spec.rb index a624e7d5ef..433462a4c2 100644 --- a/spec/components/topic_creator_spec.rb +++ b/spec/components/topic_creator_spec.rb @@ -140,6 +140,12 @@ describe TopicCreator do expect(topic.tags.length).to eq(2) end + it "minimum_required_tags is satisfying for new tags if user can create" do + topic = TopicCreator.create(user, Guardian.new(user), valid_attrs.merge(tags: ["new tag", "another tag"], category: category.id)) + expect(topic).to be_valid + expect(topic.tags.length).to eq(2) + end + it "lets new user create a topic if they don't have sufficient trust level to tag topics" do SiteSetting.min_trust_level_to_tag_topics = 1 new_user = Fabricate(:newuser) diff --git a/spec/components/user_name_suggester_spec.rb b/spec/components/user_name_suggester_spec.rb index 3adc48a606..21a44b2c10 100644 --- a/spec/components/user_name_suggester_spec.rb +++ b/spec/components/user_name_suggester_spec.rb @@ -160,14 +160,6 @@ describe UserNameSuggester do expect(UserNameSuggester.suggest('য়া')).to eq('য়া11') end - it "does not skip ove allowed names" do - Fabricate(:user, username: 'sam') - Fabricate(:user, username: 'saM1') - Fabricate(:user, username: 'sam2') - - expect(UserNameSuggester.suggest('SaM', 'Sam1')).to eq('Sam1') - end - it "normalizes usernames" do actual = 'Löwe' # NFD, "Lo\u0308we" expected = 'Löwe' # NFC, "L\u00F6we" diff --git a/spec/fabricators/external_upload_stub_fabricator.rb b/spec/fabricators/external_upload_stub_fabricator.rb index ce7b1ec616..d05ba626b3 100644 --- a/spec/fabricators/external_upload_stub_fabricator.rb +++ b/spec/fabricators/external_upload_stub_fabricator.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true Fabricator(:external_upload_stub) do + transient :folder_prefix + created_by { Fabricate(:user) } original_filename "test.txt" - key { Discourse.store.temporary_upload_path("test.txt") } + key { |attrs| FileStore::BaseStore.temporary_upload_path("test.txt", folder_prefix: attrs[:folder_prefix] || "") } upload_type "card_background" filesize 1024 status 1 @@ -12,11 +14,11 @@ end Fabricator(:image_external_upload_stub, from: :external_upload_stub) do original_filename "logo.png" filesize 1024 - key { Discourse.store.temporary_upload_path("logo.png") } + key { |attrs| FileStore::BaseStore.temporary_upload_path("logo.png", folder_prefix: attrs[:folder_prefix] || "") } end Fabricator(:attachment_external_upload_stub, from: :external_upload_stub) do original_filename "file.pdf" filesize 1024 - key { Discourse.store.temporary_upload_path("file.pdf") } + key { |attrs| FileStore::BaseStore.temporary_upload_path("file.pdf", folder_prefix: attrs[:folder_prefix] || "") } end diff --git a/spec/fixtures/onebox/instagram_old_onebox.response b/spec/fixtures/onebox/instagram_old_onebox.response deleted file mode 100644 index b0c0a9a5c4..0000000000 --- a/spec/fixtures/onebox/instagram_old_onebox.response +++ /dev/null @@ -1,17 +0,0 @@ - -{ -"version": "1.0", -"title": "Photo by Pete McBride @pedromcbride | For the first time in three decades, inhabitants of northern India are able to see the Himalaya\u2014thanks to reduced air pollution over the last few weeks. Considering that India experiences some of the worst pollution in the world, this is a literal breath of fresh air. When I was there, the air was so thick you could taste the smoke and fumes.\n\nThe coronavirus pandemic that has led to India's temporary reduction in pollutants has also put the country on the world's largest lockdown, and it's too soon to tell what impact that has had on curbing the disease\u2014as well as what the long-term effects will be on attitudes toward fresh air once the population returns to business as usual. For more on India and the environment, follow @pedromcbride. #india #himalaya #covid19 #pollution", -"author_name": "natgeo", -"author_url": "https://www.instagram.com/natgeo", -"author_id": 787132, "media_id": "2310750110684704208_787132", -"provider_name": "Instagram", -"provider_url": "https://www.instagram.com", -"type": "rich", -"width": 658, -"height": null, -"html": "\u003cblockquote class=\"instagram-media\" data-instgrm-captioned data-instgrm-permalink=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" data-instgrm-version=\"13\" style=\" background:#FFF; border:0; border-radius:3px; box-shadow:0 0 1px 0 rgba(0,0,0,0.5),0 1px 10px 0 rgba(0,0,0,0.15); margin: 1px; max-width:658px; min-width:326px; padding:0; width:99.375%; width:-webkit-calc(100% - 2px); width:calc(100% - 2px);\"\u003e\u003cdiv style=\"padding:16px;\"\u003e \u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" background:#FFFFFF; line-height:0; padding:0 0; text-align:center; text-decoration:none; width:100%;\" target=\"_blank\"\u003e \u003cdiv style=\" display: flex; flex-direction: row; align-items: center;\"\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 40px; margin-right: 14px; width: 40px;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 100px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 60px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 19% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display:block; height:50px; margin:0 auto 12px; width:50px;\"\u003e\u003csvg width=\"50px\" height=\"50px\" viewBox=\"0 0 60 60\" version=\"1.1\" xmlns=\"https://www.w3.org/2000/svg\" xmlns:xlink=\"https://www.w3.org/1999/xlink\"\u003e\u003cg stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\"\u003e\u003cg transform=\"translate(-511.000000, -20.000000)\" fill=\"#000000\"\u003e\u003cg\u003e\u003cpath d=\"M556.869,30.41 C554.814,30.41 553.148,32.076 553.148,34.131 C553.148,36.186 554.814,37.852 556.869,37.852 C558.924,37.852 560.59,36.186 560.59,34.131 C560.59,32.076 558.924,30.41 556.869,30.41 M541,60.657 C535.114,60.657 530.342,55.887 530.342,50 C530.342,44.114 535.114,39.342 541,39.342 C546.887,39.342 551.658,44.114 551.658,50 C551.658,55.887 546.887,60.657 541,60.657 M541,33.886 C532.1,33.886 524.886,41.1 524.886,50 C524.886,58.899 532.1,66.113 541,66.113 C549.9,66.113 557.115,58.899 557.115,50 C557.115,41.1 549.9,33.886 541,33.886 M565.378,62.101 C565.244,65.022 564.756,66.606 564.346,67.663 C563.803,69.06 563.154,70.057 562.106,71.106 C561.058,72.155 560.06,72.803 558.662,73.347 C557.607,73.757 556.021,74.244 553.102,74.378 C549.944,74.521 548.997,74.552 541,74.552 C533.003,74.552 532.056,74.521 528.898,74.378 C525.979,74.244 524.393,73.757 523.338,73.347 C521.94,72.803 520.942,72.155 519.894,71.106 C518.846,70.057 518.197,69.06 517.654,67.663 C517.244,66.606 516.755,65.022 516.623,62.101 C516.479,58.943 516.448,57.996 516.448,50 C516.448,42.003 516.479,41.056 516.623,37.899 C516.755,34.978 517.244,33.391 517.654,32.338 C518.197,30.938 518.846,29.942 519.894,28.894 C520.942,27.846 521.94,27.196 523.338,26.654 C524.393,26.244 525.979,25.756 528.898,25.623 C532.057,25.479 533.004,25.448 541,25.448 C548.997,25.448 549.943,25.479 553.102,25.623 C556.021,25.756 557.607,26.244 558.662,26.654 C560.06,27.196 561.058,27.846 562.106,28.894 C563.154,29.942 563.803,30.938 564.346,32.338 C564.756,33.391 565.244,34.978 565.378,37.899 C565.522,41.056 565.552,42.003 565.552,50 C565.552,57.996 565.522,58.943 565.378,62.101 M570.82,37.631 C570.674,34.438 570.167,32.258 569.425,30.349 C568.659,28.377 567.633,26.702 565.965,25.035 C564.297,23.368 562.623,22.342 560.652,21.575 C558.743,20.834 556.562,20.326 553.369,20.18 C550.169,20.033 549.148,20 541,20 C532.853,20 531.831,20.033 528.631,20.18 C525.438,20.326 523.257,20.834 521.349,21.575 C519.376,22.342 517.703,23.368 516.035,25.035 C514.368,26.702 513.342,28.377 512.574,30.349 C511.834,32.258 511.326,34.438 511.181,37.631 C511.035,40.831 511,41.851 511,50 C511,58.147 511.035,59.17 511.181,62.369 C511.326,65.562 511.834,67.743 512.574,69.651 C513.342,71.625 514.368,73.296 516.035,74.965 C517.703,76.634 519.376,77.658 521.349,78.425 C523.257,79.167 525.438,79.673 528.631,79.82 C531.831,79.965 532.853,80.001 541,80.001 C549.148,80.001 550.169,79.965 553.369,79.82 C556.562,79.673 558.743,79.167 560.652,78.425 C562.623,77.658 564.297,76.634 565.965,74.965 C567.633,73.296 568.659,71.625 569.425,69.651 C570.167,67.743 570.674,65.562 570.82,62.369 C570.966,59.17 571,58.147 571,50 C571,41.851 570.966,40.831 570.82,37.631\"\u003e\u003c/path\u003e\u003c/g\u003e\u003c/g\u003e\u003c/g\u003e\u003c/svg\u003e\u003c/div\u003e\u003cdiv style=\"padding-top: 8px;\"\u003e \u003cdiv style=\" color:#3897f0; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:550; line-height:18px;\"\u003e View this post on Instagram\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"padding: 12.5% 0;\"\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: row; margin-bottom: 14px; align-items: center;\"\u003e\u003cdiv\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(0px) translateY(7px);\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; height: 12.5px; transform: rotate(-45deg) translateX(3px) translateY(1px); width: 12.5px; flex-grow: 0; margin-right: 14px; margin-left: 2px;\"\u003e\u003c/div\u003e \u003cdiv style=\"background-color: #F4F4F4; border-radius: 50%; height: 12.5px; width: 12.5px; transform: translateX(9px) translateY(-18px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: 8px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 50%; flex-grow: 0; height: 20px; width: 20px;\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 2px solid transparent; border-left: 6px solid #f4f4f4; border-bottom: 2px solid transparent; transform: translateX(16px) translateY(-4px) rotate(30deg)\"\u003e\u003c/div\u003e\u003c/div\u003e\u003cdiv style=\"margin-left: auto;\"\u003e \u003cdiv style=\" width: 0px; border-top: 8px solid #F4F4F4; border-right: 8px solid transparent; transform: translateY(16px);\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; flex-grow: 0; height: 12px; width: 16px; transform: translateY(-4px);\"\u003e\u003c/div\u003e \u003cdiv style=\" width: 0; height: 0; border-top: 8px solid #F4F4F4; border-left: 8px solid transparent; transform: translateY(-4px) translateX(8px);\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/div\u003e \u003cdiv style=\"display: flex; flex-direction: column; flex-grow: 1; justify-content: center; margin-bottom: 24px;\"\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; margin-bottom: 6px; width: 224px;\"\u003e\u003c/div\u003e \u003cdiv style=\" background-color: #F4F4F4; border-radius: 4px; flex-grow: 0; height: 14px; width: 144px;\"\u003e\u003c/div\u003e\u003c/div\u003e\u003c/a\u003e\u003cp style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; line-height:17px; margin-bottom:0; margin-top:8px; overflow:hidden; padding:8px 0 7px; text-align:center; text-overflow:ellipsis; white-space:nowrap;\"\u003e\u003ca href=\"https://www.instagram.com/p/CARbvuYDm3Q/?utm_source=ig_embed\u0026amp;utm_campaign=loading\" style=\" color:#c9c8cd; font-family:Arial,sans-serif; font-size:14px; font-style:normal; font-weight:normal; line-height:17px; text-decoration:none;\" target=\"_blank\"\u003eA post shared by National Geographic (@natgeo)\u003c/a\u003e\u003c/p\u003e\u003c/div\u003e\u003c/blockquote\u003e\n\u003cscript async src=\"//www.instagram.com/embed.js\"\u003e\u003c/script\u003e", -"thumbnail_url": "https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-15/sh0.08/e35/s640x640/97565241_163250548553285_9172168193050746487_n.jpg?_nc_ht=scontent-yyz1-1.cdninstagram.com\u0026_nc_cat=105\u0026_nc_ohc=dnXCQ6urT_gAX99AO01\u0026_nc_tp=24\u0026oh=32b676a618164ab0248e2726767dae14\u0026oe=5FDD8836", -"thumbnail_width": 640, -"thumbnail_height": 427 -} diff --git a/spec/integration/multisite_cookies_spec.rb b/spec/integration/multisite_cookies_spec.rb new file mode 100644 index 0000000000..6256eef4aa --- /dev/null +++ b/spec/integration/multisite_cookies_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'multisite', type: [:multisite, :request] do + it "works" do + get "http://test.localhost/session/csrf.json" + expect(response.status).to eq(200) + cookie = response.cookies["_forum_session"] + id1 = session["session_id"] + + get "http://test.localhost/session/csrf.json", headers: { "Cookie" => "_forum_session=#{cookie};" } + expect(response.status).to eq(200) + id2 = session["session_id"] + + expect(id1).to eq(id2) + + get "http://test2.localhost/session/csrf.json", headers: { "Cookie" => "_forum_session=#{cookie};" } + expect(response.status).to eq(200) + id3 = session["session_id"] + + # Session cookie was rejected and rotated + expect(id2).not_to eq(id3) + end +end diff --git a/spec/integration/multisite_spec.rb b/spec/integration/multisite_spec.rb index d369617850..d1026bd980 100644 --- a/spec/integration/multisite_spec.rb +++ b/spec/integration/multisite_spec.rb @@ -2,62 +2,33 @@ require 'rails_helper' -describe 'multisite', type: :multisite do - class DBNameMiddleware - def initialize(app, config = {}) - @app = app - end - - def call(env) - # note current_db is already being ruined on boot cause its not multisite - [200, {}, [RailsMultisite::ConnectionManagement.current_hostname]] - end - end - - let :session do - stack = ActionDispatch::MiddlewareStack.new - stack.use RailsMultisite::Middleware, RailsMultisite::DiscoursePatches.config - stack.use DBNameMiddleware - - routes = ActionDispatch::Routing::RouteSet.new - stack.build(routes) - end - +describe 'multisite', type: [:multisite, :request] do it "should always allow /srv/status through" do - headers = { - "HTTP_HOST" => "unknown.com", - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/srv/status", - "rack.input" => StringIO.new - } - - code, _, body = session.call(headers) - expect(code).to eq(200) - expect(body.join).to eq("test.localhost") + get "http://unknown.com/srv/status" + expect(response.status).to eq(200) + expect(request.env["HTTP_HOST"]).to eq("test.localhost") # Rewritten by EnforceHostname middleware end - it "should 404 on unknown routes" do - headers = { - "HTTP_HOST" => "unknown.com", - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/topics", - "rack.input" => StringIO.new - } - - code, _ = session.call(headers) - expect(code).to eq(404) + it "should 404 for unknown domains" do + get "http://unknown.com/about.json" + expect(response.status).to eq(404) end - it "should hit correct site elsewise" do - headers = { - "HTTP_HOST" => "test2.localhost", - "REQUEST_METHOD" => "GET", - "PATH_INFO" => "/topics", - "rack.input" => StringIO.new - } + it "should hit correct site otherwise" do + site_1_url = Fabricate(:topic, title: "Site 1 Topic Title", user: Discourse.system_user).relative_url - code, _, body = session.call(headers) - expect(code).to eq(200) - expect(body.join).to eq("test2.localhost") + test_multisite_connection('second') do + site_2_url = Fabricate(:topic, title: "Site 2 Topic Title", user: Discourse.system_user).relative_url + + get "http://test.localhost/#{site_1_url}.json" + expect(request.env["RAILS_MULTISITE_HOST"]).to eq("test.localhost") + expect(response.status).to eq(200) + expect(response.parsed_body["title"]).to eq("Site 1 Topic Title") + + get "http://test2.localhost/#{site_2_url}.json" + expect(response.status).to eq(200) + expect(request.env["RAILS_MULTISITE_HOST"]).to eq("test2.localhost") + expect(response.parsed_body["title"]).to eq("Site 2 Topic Title") + end end end diff --git a/spec/jobs/pull_user_profile_hotlinked_images_spec.rb b/spec/jobs/pull_user_profile_hotlinked_images_spec.rb new file mode 100644 index 0000000000..3f5a41f90f --- /dev/null +++ b/spec/jobs/pull_user_profile_hotlinked_images_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Jobs::PullUserProfileHotlinkedImages do + fab!(:user) { Fabricate(:user) } + + let(:image_url) { "http://wiki.mozilla.org/images/2/2e/Longcat1.png" } + let(:png) { Base64.decode64("R0lGODlhAQABALMAAAAAAIAAAACAAICAAAAAgIAAgACAgMDAwICAgP8AAAD/AP//AAAA//8A/wD//wBiZCH5BAEAAA8ALAAAAAABAAEAAAQC8EUAOw==") } + + before do + stub_request(:get, image_url).to_return(body: png, headers: { "Content-Type" => "image/png" }) + SiteSetting.download_remote_images_to_local = true + end + + describe '#execute' do + before do + stub_image_size + end + + it 'replaces images' do + user.user_profile.update!(bio_raw: "![](#{image_url})") + expect { Jobs::PullUserProfileHotlinkedImages.new.execute(user_id: user.id) }.to change { Upload.count }.by(1) + expect(user.user_profile.reload.bio_cooked).to include(Upload.last.url) + end + end +end diff --git a/spec/jobs/vacate_legacy_prefix_backups_spec.rb b/spec/jobs/vacate_legacy_prefix_backups_spec.rb deleted file mode 100644 index 45eb0df9ef..0000000000 --- a/spec/jobs/vacate_legacy_prefix_backups_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -require "s3_helper" -require "rails_helper" - -describe Jobs::VacateLegacyPrefixBackups, type: :multisite do - let(:bucket_name) { "backupbucket" } - - before do - @s3_client = Aws::S3::Client.new(stub_responses: true) - @s3_options = { client: @s3_client } - @objects = [] - create_backups - - @s3_client.stub_responses(:list_objects_v2, -> (context) do - { contents: objects_with_prefix(context) } - end) - - setup_s3 - SiteSetting.s3_backup_bucket = bucket_name - SiteSetting.backup_location = BackupLocationSiteSetting::S3 - end - - it "copies the backups from legacy path to new path" do - @objects.each do |object| - legacy_key = object[:key] - legacy_object = @s3_client.get_object(bucket: bucket_name, key: legacy_key) - - @s3_client.expects(:copy_object).with({ - copy_source: File.join(bucket_name, legacy_key), - bucket: bucket_name, - key: legacy_key.sub(/^backups\//, "") - }) - - @s3_client.expects(:delete_object).with(bucket: bucket_name, key: legacy_key).returns(legacy_object) - end - - described_class.new.execute_onceoff(s3_options: @s3_options) - end - - def objects_with_prefix(context) - prefix = context.params[:prefix] - @objects.select { |obj| obj[:key].start_with?(prefix) } - end - - def create_backups - @objects.clear - - @objects << { key: "backups/default/b.tar.gz", size: 17, last_modified: Time.parse("2018-09-13T15:10:00Z") } - @objects << { key: "backups/default/filename.tar.gz", size: 17, last_modified: Time.parse("2019-10-18T17:20:00Z") } - end -end diff --git a/spec/lib/backup_restore/uploads_restorer_spec.rb b/spec/lib/backup_restore/uploads_restorer_spec.rb index 2963e381e0..cd3079e1fa 100644 --- a/spec/lib/backup_restore/uploads_restorer_spec.rb +++ b/spec/lib/backup_restore/uploads_restorer_spec.rb @@ -99,7 +99,7 @@ describe BackupRestore::UploadsRestorer do let!(:multisite) { { name: "multisite", value: true } } let!(:no_multisite) { { name: "multisite", value: false } } let!(:source_db_name) { { name: "db_name", value: "foo" } } - let!(:base_url) { { name: "base_url", value: "https://www.example.com/forum" } } + let!(:base_url) { { name: "base_url", value: "https://test.localhost/forum" } } let!(:no_cdn_url) { { name: "cdn_url", value: nil } } let!(:cdn_url) { { name: "cdn_url", value: "https://some-cdn.example.com" } } let(:target_site_name) { target_site_type == multisite ? "second" : "default" } @@ -187,7 +187,7 @@ describe BackupRestore::UploadsRestorer do expect_remap( target_site_name: target_site_name, metadata: [source_site_type, base_url], - from: "https://www.example.com/forum", + from: "https://test.localhost/forum", to: "http://localhost" ) end diff --git a/spec/lib/content_security_policy_spec.rb b/spec/lib/content_security_policy_spec.rb index a2d85c538c..c8fbcb45c7 100644 --- a/spec/lib/content_security_policy_spec.rb +++ b/spec/lib/content_security_policy_spec.rb @@ -234,6 +234,7 @@ describe ContentSecurityPolicy do expect(parse(policy)['manifest-src']).to_not include('https://manifest-src.com') Discourse.plugins.delete plugin + DiscoursePluginRegistry.reset! end it 'can extend frame_ancestors' do @@ -251,6 +252,7 @@ describe ContentSecurityPolicy do expect(parse(policy)['frame-ancestors']).to_not include('https://frame-ancestors-plugin.ext') Discourse.plugins.delete plugin + DiscoursePluginRegistry.reset! end end diff --git a/spec/lib/onebox/engine/instagram_onebox_spec.rb b/spec/lib/onebox/engine/instagram_onebox_spec.rb index 9924e7064a..f0d5c672b5 100644 --- a/spec/lib/onebox/engine/instagram_onebox_spec.rb +++ b/spec/lib/onebox/engine/instagram_onebox_spec.rb @@ -5,22 +5,23 @@ require "rails_helper" describe Onebox::Engine::InstagramOnebox do let(:access_token) { 'abc123' } let(:link) { "https://www.instagram.com/p/CARbvuYDm3Q" } + let(:onebox_options) { { allowed_iframe_regexes: Onebox::Engine.origins_to_regexes(["https://www.instagram.com"]) } } it 'oneboxes links that include the username' do link_with_profile = 'https://www.instagram.com/bennyblood24/p/CARbvuYDm3Q/' - onebox_klass = Onebox::Matcher.new(link_with_profile).oneboxed + onebox_klass = Onebox::Matcher.new(link_with_profile, onebox_options).oneboxed expect(onebox_klass.name).to eq(described_class.name) end it 'oneboxes photo links' do photo_link = 'https://www.instagram.com/p/CARbvuYDm3Q/' - onebox_klass = Onebox::Matcher.new(photo_link).oneboxed + onebox_klass = Onebox::Matcher.new(photo_link, onebox_options).oneboxed expect(onebox_klass.name).to eq(described_class.name) end it 'oneboxes tv links' do tv_link = "https://www.instagram.com/tv/CIlM7UzMgXO/?hl=en" - onebox_klass = Onebox::Matcher.new(tv_link).oneboxed + onebox_klass = Onebox::Matcher.new(tv_link, onebox_options).oneboxed expect(onebox_klass.name).to eq(described_class.name) end @@ -28,6 +29,7 @@ describe Onebox::Engine::InstagramOnebox do let(:api_link) { "https://graph.facebook.com/v9.0/instagram_oembed?url=#{link}&access_token=#{access_token}" } before do + stub_request(:head, link) stub_request(:get, api_link).to_return(status: 200, body: onebox_response("instagram")) stub_request(:get, "https://api.instagram.com/oembed/?url=https://www.instagram.com/p/CARbvuYDm3Q") .to_return(status: 200, body: onebox_response("instagram")) @@ -39,18 +41,15 @@ describe Onebox::Engine::InstagramOnebox do Onebox.options = @previous_options end - it "includes title" do - onebox = described_class.new(link) - html = onebox.to_html - - expect(html).to include('@natgeo') + it "renders preview with a placeholder" do + expect(Oneboxer.preview(link, invalidate_oneboxes: true)).to include('placeholder-icon image') end - it "includes image" do + it "renders html using an iframe" do onebox = described_class.new(link) html = onebox.to_html - expect(html).to include("https://scontent.cdninstagram.com/v/t51.2885-15/sh0.08/e35/s640x640/97565241_163250548553285_9172168193050746487_n.jpg") + expect(html).to include('@natgeo') + it "renders preview with a placeholder" do + expect(Oneboxer.preview(link, invalidate_oneboxes: true)).to include('placeholder-icon image') end - it "includes image" do - expect(html).to include("https://scontent-yyz1-1.cdninstagram.com/v/t51.2885-15/sh0.08/e35/s640x640/97565241_163250548553285_9172168193050746487_n.jpg") + it "renders html using an iframe" do + expect(html).to include('click me!alert('TEST');") + end + describe '#manually_grantable?' do fab!(:badge) { Fabricate(:badge, name: 'Test Badge') } subject { badge.manually_grantable? } diff --git a/spec/models/category_list_spec.rb b/spec/models/category_list_spec.rb index 81a0fe3310..5e8b8adcd9 100644 --- a/spec/models/category_list_spec.rb +++ b/spec/models/category_list_spec.rb @@ -53,6 +53,19 @@ describe CategoryList do expect(CategoryList.new(Guardian.new(nil), include_topics: true).categories.find { |x| x.name == private_cat.name }).to eq(nil) end + it "doesn't show muted topics" do + cat = Fabricate(:category_with_definition) # public category + topic = Fabricate(:topic, category: cat) + + CategoryFeaturedTopic.feature_topics + + expect(CategoryList.new(Guardian.new(user), include_topics: true).categories.find { |x| x.name == cat.name }.displayable_topics.count).to eq(1) + + TopicUser.change(user.id, topic.id, notification_level: TopicUser.notification_levels[:muted]) + + expect(CategoryList.new(Guardian.new(user), include_topics: true).categories.find { |x| x.name == cat.name }.displayable_topics.count).to eq(0) + end + end context "when mute_all_categories_by_default enabled" do diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb index b9fdba6fb4..fdd34c579e 100644 --- a/spec/models/discourse_single_sign_on_spec.rb +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -265,27 +265,6 @@ describe DiscourseSingleSignOn do expect(add_group4.usernames).to eq(user.username) end - it 'can override username properly when only the case changes' do - SiteSetting.auth_overrides_username = true - - sso = new_discourse_sso - sso.username = "testuser" - sso.name = "test user" - sso.email = "test@test.com" - sso.external_id = "100" - sso.bio = "This **is** the bio" - sso.suppress_welcome_message = true - - # create the original user - user = sso.lookup_or_create_user(ip_address) - expect(user.username).to eq "testuser" - - # change the username case - sso.username = "TestUser" - user = sso.lookup_or_create_user(ip_address) - expect(user.username).to eq "TestUser" - end - it 'behaves properly when auth_overrides_username is set but username is missing or blank' do SiteSetting.auth_overrides_username = true @@ -347,6 +326,46 @@ describe DiscourseSingleSignOn do expect(admin.name).to eq "Louis C.K." end + it 'can override username properly when only the case changes' do + SiteSetting.auth_overrides_username = true + + sso = new_discourse_sso + sso.username = "testuser" + sso.name = "test user" + sso.email = "test@test.com" + sso.external_id = "100" + sso.bio = "This **is** the bio" + sso.suppress_welcome_message = true + + # create the original user + user = sso.lookup_or_create_user(ip_address) + expect(user.username).to eq "testuser" + + # change the username case + sso.username = "TestUser" + user = sso.lookup_or_create_user(ip_address) + expect(user.username).to eq "TestUser" + end + + it 'do not override username when a new username after fixing is the same' do + SiteSetting.auth_overrides_username = true + + sso = new_discourse_sso + sso.username = "testuser" + sso.name = "test user" + sso.email = "test@test.com" + sso.external_id = "100" + + # create the original user + user = sso.lookup_or_create_user(ip_address) + expect(user.username).to eq "testuser" + + # change the username case + sso.username = "testuserგამარჯობა" + user = sso.lookup_or_create_user(ip_address) + expect(user.username).to eq "testuser" + end + it "doesn't use email as a source for username suggestions by default" do sso = new_discourse_sso sso.external_id = "100" @@ -525,7 +544,18 @@ describe DiscourseSingleSignOn do expect(sso.nonce_valid?).to eq true Discourse.cache.delete(sso.used_nonce_key) - expect(sso.nonce_error).to eq("Nonce has expired") + expect(sso.nonce_error).to eq("Nonce is incorrect, was generated in a different browser session, or has expired") + end + + it "generates correct error message when nonce is expired, and csrf protection disabled" do + SiteSetting.discourse_connect_csrf_protection = false + _ , payload = DiscourseSingleSignOn.generate_url(secure_session: secure_session).split("?") + + sso = DiscourseSingleSignOn.parse(payload, secure_session: secure_session) + expect(sso.nonce_valid?).to eq true + + Discourse.cache.delete(sso.used_nonce_key) + expect(sso.nonce_error).to eq("Nonce is incorrect, or has expired") end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index c72f6f869d..0bd4eff451 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'rails_helper' -require_relative '../components/imap/imap_helper' describe Group do let(:admin) { Fabricate(:admin) } @@ -198,12 +197,19 @@ describe Group do end describe '#primary_group=' do - it "updates all members' #primary_group" do + before do group.add(user) + end + it "updates all members' #primary_group" do expect { group.update(primary_group: true) }.to change { user.reload.primary_group }.from(nil).to(group) expect { group.update(primary_group: false) }.to change { user.reload.primary_group }.from(group).to(nil) end + + it "updates all members' #flair_group" do + expect { group.update(primary_group: true) }.to change { user.reload.flair_group }.from(nil).to(group) + expect { group.update(primary_group: false) }.to change { user.reload.flair_group }.from(group).to(nil) + end end describe '#title=' do diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index 879eca29e1..ccd9e20481 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -1755,4 +1755,22 @@ describe Post do post.publish_change_to_clients!(:created) end end + + describe "#cannot_permanently_delete_reason" do + fab!(:post) { Fabricate(:post) } + fab!(:admin) { Fabricate(:admin) } + + before do + freeze_time + PostDestroyer.new(admin, post).destroy + end + + it 'returns error message if same admin and time did not pass' do + expect(post.cannot_permanently_delete_reason(admin)).to eq(I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i))) + end + + it 'returns nothing if different admin' do + expect(post.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + end end diff --git a/spec/models/site_setting_spec.rb b/spec/models/site_setting_spec.rb index 7a81584878..7d858a951d 100644 --- a/spec/models/site_setting_spec.rb +++ b/spec/models/site_setting_spec.rb @@ -204,4 +204,22 @@ describe SiteSetting do expect(SiteSetting.blocked_attachment_filenames_regex).to eq(/foo|bar/) end end + + it 'sanitizes the client settings when they are overridden' do + xss = "click me!" + + SiteSetting.global_notice = xss + + expect(SiteSetting.global_notice).to eq("click me!alert('TEST');") + end + + it "doesn't corrupt site settings with special characters" do + value = 'OX5y3Oljb+Qt9Bu809vsBQ==<>!%{}*&!@#$%..._-A' + settings = new_settings(SiteSettings::LocalProcessProvider.new) + settings.setting(:test_setting, '', client: true) + + settings.test_setting = value + + expect(settings.test_setting).to eq(value) + end end diff --git a/spec/models/topic_embed_spec.rb b/spec/models/topic_embed_spec.rb index 8659eb7d56..4ce37dc37a 100644 --- a/spec/models/topic_embed_spec.rb +++ b/spec/models/topic_embed_spec.rb @@ -191,19 +191,13 @@ describe TopicEmbed do describe '.find_remote' do fab!(:embeddable_host) { Fabricate(:embeddable_host) } - let(:file) { StringIO.new } - - before do - TopicEmbed.stubs(:open).returns file - end context ".title_scrub" do let(:url) { 'http://eviltrout.com/123' } let(:contents) { "Through the Looking Glass - Classic Bookssome content here" } before do - file.stubs(:read).returns contents - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) end it "doesn't scrub the title by default" do @@ -225,8 +219,7 @@ describe TopicEmbed do before do SiteSetting.allowed_embed_classnames = 'emoji, foo' - file.stubs(:read).returns contents - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) @response = TopicEmbed.find_remote(url) end @@ -257,9 +250,7 @@ describe TopicEmbed do let(:contents) { 'rich and morty' } before(:each) do - file.stubs(:read).returns contents - TopicEmbed.stubs(:open).returns file - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) end it "has no author tag" do @@ -276,8 +267,7 @@ describe TopicEmbed do before(:each) do SiteSetting.allowed_embed_classnames = '' - file.stubs(:read).returns contents - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) @response = TopicEmbed.find_remote(url) end @@ -303,8 +293,7 @@ describe TopicEmbed do let(:contents) { "سلاماین یک پاراگراف آزمون است." } before do - stub_request(:get, url) - file.stubs(:read).returns contents + stub_request(:get, url).to_return(status: 200, body: contents) end it "doesn't throw an error" do @@ -318,8 +307,7 @@ describe TopicEmbed do let(:contents) { "Hello World!" } before do - stub_request(:get, url) - file.stubs(:read).returns contents + stub_request(:get, url).to_return(status: 200, body: contents) end it "doesn't throw an error" do @@ -341,8 +329,7 @@ describe TopicEmbed do let(:contents) { '

URL encoded @ symbol

normal mailto link

' } before do - file.stubs(:read).returns contents - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) end it "handles mailto links" do @@ -358,8 +345,7 @@ describe TopicEmbed do let(:contents) { '

Baz

' } before do - file.stubs(:read).returns contents - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: contents) end it "doesn’t raise an exception" do @@ -374,9 +360,9 @@ describe TopicEmbed do let(:canonical_content) { "Canonical" } before do - file.stubs(:read).returns canonical_content - stub_request(:get, url) + stub_request(:get, url).to_return(status: 200, body: content) stub_request(:head, canonical_url) + stub_request(:get, canonical_url).to_return(status: 200, body: canonical_content) end it 'a' do diff --git a/spec/models/topic_link_spec.rb b/spec/models/topic_link_spec.rb index 099aa5a587..2b2ee26f7d 100644 --- a/spec/models/topic_link_spec.rb +++ b/spec/models/topic_link_spec.rb @@ -472,6 +472,22 @@ describe TopicLink do expect(TopicLink.topic_map(Guardian.new, post.topic_id).count).to eq(0) end + + it 'secures internal links correctly' do + other_topic = Fabricate(:topic) + other_user = Fabricate(:user) + + url = "http://#{test_uri.host}/t/topic-slug/#{other_topic.id}" + post = Fabricate(:post, raw: "hello test topic #{url}") + TopicLink.extract_from(post) + TopicLinkClick.create!(topic_link: post.topic.topic_links.first, ip_address: '192.168.1.1') + + expect(TopicLink.counts_for(Guardian.new(other_user), post.topic, [post]).length).to eq(1) + + TopicUser.change(other_user.id, other_topic.id, notification_level: TopicUser.notification_levels[:muted]) + + expect(TopicLink.counts_for(Guardian.new(other_user), post.topic, [post]).length).to eq(0) + end end describe ".duplicate_lookup" do diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 64d599f019..de3086ed46 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -711,12 +711,23 @@ describe Topic do end context "from a muted user" do - before { MutedUser.create!(user: another_user, muted_user: user) } + before { Fabricate(:muted_user, user: another_user, muted_user: user) } - it 'fails with an error message' do + it 'fails with an error' do + expect { topic.invite(user, another_user.username) } + .to raise_error(Topic::NotAllowed) + expect(topic.allowed_users).to_not include(another_user) + expect(Post.last).to be_blank + expect(Notification.last).to be_blank + end + end + + context "from a ignored user" do + before { Fabricate(:ignored_user, user: another_user, ignored_user: user) } + + it 'fails with an error' do expect { topic.invite(user, another_user.username) } .to raise_error(Topic::NotAllowed) - .with_message(I18n.t("topic_invite.muted_invitee")) expect(topic.allowed_users).to_not include(another_user) expect(Post.last).to be_blank expect(Notification.last).to be_blank @@ -1021,6 +1032,51 @@ describe Topic do .to eq(Notification.types[:group_message_summary]) end + + it "removes users in topic_allowed_users who are part of the added group" do + admins = Group[:admins] + admins.update!(messageable_level: Group::ALIAS_LEVELS[:everyone]) + + # clear up the state so we can be more explicit with the test + TopicAllowedUser.where(topic: topic).delete_all + user0 = topic.user + user1 = Fabricate(:user) + user2 = Fabricate(:user) + user3 = Fabricate(:user) + Fabricate(:topic_allowed_user, topic: topic, user: user0) + Fabricate(:topic_allowed_user, topic: topic, user: user1) + Fabricate(:topic_allowed_user, topic: topic, user: user2) + Fabricate(:topic_allowed_user, topic: topic, user: user3) + + admins.add(user1) + admins.add(user2) + + other_topic = Fabricate(:topic) + Fabricate(:topic_allowed_user, user: user1, topic: other_topic) + + expect(topic.invite_group(topic.user, admins)).to eq(true) + expect(topic.posts.last.action_code).to eq("removed_user") + expect(topic.allowed_users).to match_array([user0, user3, Discourse.system_user]) + expect(other_topic.allowed_users).to match_array([user1]) + end + + it "does not remove the OP from topic_allowed_users if they are part of an added group" do + admins = Group[:admins] + admins.update!(messageable_level: Group::ALIAS_LEVELS[:everyone]) + + # clear up the state so we can be more explicit with the test + TopicAllowedUser.where(topic: topic).delete_all + user0 = topic.user + user1 = Fabricate(:user) + Fabricate(:topic_allowed_user, topic: topic, user: user0) + Fabricate(:topic_allowed_user, topic: topic, user: user1) + + admins.add(topic.user) + admins.add(user1) + + expect(topic.invite_group(topic.user, admins)).to eq(true) + expect(topic.allowed_users).to match_array([topic.user, Discourse.system_user]) + end end end end @@ -2015,11 +2071,15 @@ describe Topic do it "doesn't return topics from suppressed categories" do user = Fabricate(:user) category = Fabricate(:category_with_definition, created_at: 2.minutes.ago) - Fabricate(:topic, category: category, created_at: 1.minute.ago) + topic = Fabricate(:topic, category: category, created_at: 1.minute.ago) SiteSetting.digest_suppress_categories = "#{category.id}" expect(Topic.for_digest(user, 1.year.ago, top_order: true)).to be_blank + + Fabricate(:topic_user, user: user, topic: topic, notification_level: TopicUser.notification_levels[:regular]) + + expect(Topic.for_digest(user, 1.year.ago, top_order: true)).to be_blank end it "doesn't return topics from TL0 users" do @@ -2891,4 +2951,34 @@ describe Topic do end end end + + describe "#cannot_permanently_delete_reason" do + fab!(:post) { Fabricate(:post) } + let!(:topic) { post.topic } + fab!(:admin) { Fabricate(:admin) } + + before do + freeze_time + end + + it 'returns error message if topic has more posts' do + post_2 = PostCreator.create!(Fabricate(:user), topic_id: topic.id, raw: 'some post content') + + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(I18n.t('post.cannot_permanently_delete.many_posts')) + + PostDestroyer.new(admin, post_2).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + + it 'returns error message if same admin and time did not pass' do + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(admin)).to eq(I18n.t('post.cannot_permanently_delete.wait_or_different_admin', time_left: RateLimiter.time_left(Post::PERMANENT_DELETE_TIMER.to_i))) + end + + it 'returns nothing if different admin' do + PostDestroyer.new(admin, post).destroy + expect(topic.reload.cannot_permanently_delete_reason(Fabricate(:admin))).to eq(nil) + end + end end diff --git a/spec/models/translation_override_spec.rb b/spec/models/translation_override_spec.rb index dddb037ba1..9a05f30c28 100644 --- a/spec/models/translation_override_spec.rb +++ b/spec/models/translation_override_spec.rb @@ -115,6 +115,16 @@ describe TranslationOverride do expect(ovr.value).to eq('some value') end + it 'sanitizes values before upsert' do + xss = "setup wizard" + + TranslationOverride.upsert!('en', 'js.wizard_required', xss) + + ovr = TranslationOverride.where(locale: 'en', translation_key: 'js.wizard_required').first + expect(ovr).to be_present + expect(ovr.value).to eq("setup wizard ✨alert('TEST');") + end + it "stores js for a message format key" do TranslationOverride.upsert!('ru', 'some.key_MF', '{NUM_RESULTS, plural, one {1 result} other {many} }') diff --git a/spec/models/user_field_spec.rb b/spec/models/user_field_spec.rb index 7f545b3706..e738591146 100644 --- a/spec/models/user_field_spec.rb +++ b/spec/models/user_field_spec.rb @@ -12,4 +12,13 @@ describe UserField do subject { described_class.new(field_type: 'dropdown') } it { is_expected.to validate_presence_of :name } end + + it 'sanitizes the description' do + xss = "click me!" + user_field = Fabricate(:user_field) + + user_field.update!(description: xss) + + expect(user_field.description).to eq("click me!alert('TEST');") + end end diff --git a/spec/multisite/s3_store_spec.rb b/spec/multisite/s3_store_spec.rb index 17eb47f555..67a79df7e2 100644 --- a/spec/multisite/s3_store_spec.rb +++ b/spec/multisite/s3_store_spec.rb @@ -183,16 +183,16 @@ RSpec.describe 'Multisite s3 uploads', type: :multisite do it "returns signed URL with correct path" do test_multisite_connection('default') do upload = Fabricate(:upload, original_filename: "small.pdf", extension: "pdf", secure: true) + path = Discourse.store.get_path_for_upload(upload) s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once - s3_bucket.expects(:object).with("#{upload_path}/original/1X/#{upload.sha1}.pdf").returns(s3_object).at_least_once + s3_bucket.expects(:object).with("#{upload_path}/#{path}").returns(s3_object).at_least_once s3_object.expects(:presigned_url).with(:get, expires_in: S3Helper::DOWNLOAD_URL_EXPIRES_AFTER_SECONDS) upload.url = store.store_upload(uploaded_file, upload) expect(upload.url).to eq( - "//some-really-cool-bucket.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/original/1X/#{upload.sha1}.pdf" + "//some-really-cool-bucket.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/#{path}" ) - expect(store.url_for(upload)).not_to eq(upload.url) end end @@ -311,7 +311,7 @@ RSpec.describe 'Multisite s3 uploads', type: :multisite do it "returns a presigned url with the correct params and the key for the temporary file" do url = store.signed_url_for_temporary_upload("test.png") - key = store.path_from_url(url) + key = store.s3_helper.path_from_url(url) expect(url).to match(/Amz-Expires/) expect(key).to match(/temp\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/) end @@ -327,7 +327,7 @@ RSpec.describe 'Multisite s3 uploads', type: :multisite do it "returns a presigned url with the correct params and the key for the temporary file" do url = store.signed_url_for_temporary_upload("test.png") - key = store.path_from_url(url) + key = store.s3_helper.path_from_url(url) expect(url).to match(/Amz-Expires/) expect(key).to match(/temp\/site\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/) end @@ -339,7 +339,7 @@ RSpec.describe 'Multisite s3 uploads', type: :multisite do it "returns a presigned url with the correct params and the key for the temporary file" do test_multisite_connection('second') do url = store.signed_url_for_temporary_upload("test.png") - key = store.path_from_url(url) + key = store.s3_helper.path_from_url(url) expect(url).to match(/Amz-Expires/) expect(key).to match(/temp\/standard99\/uploads\/second\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/) end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 61523bc2d7..18e32104f2 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -44,7 +44,11 @@ class RspecErrorTracker def call(env) begin @app.call(env) - rescue => e + + # This is a little repetitive, but since WebMock::NetConnectNotAllowedError + # and also Mocha::ExpectationError inherit from Exception instead of StandardError + # they do not get captured by the rescue => e shorthand :( + rescue WebMock::NetConnectNotAllowedError, Mocha::ExpectationError, StandardError => e RspecErrorTracker.last_exception = e raise e end @@ -287,6 +291,10 @@ RSpec.configure do |config| DB.test_transaction = ActiveRecord::Base.connection.current_transaction end + # Match the request hostname to the value in `database.yml` + config.before(:all, type: [:request, :multisite]) { host! "test.localhost" } + config.before(:each, type: [:request, :multisite]) { host! "test.localhost" } + config.before(:each, type: :multisite) do Rails.configuration.multisite = true # rubocop:disable Discourse/NoDirectMultisiteManipulation diff --git a/spec/requests/admin/api_controller_spec.rb b/spec/requests/admin/api_controller_spec.rb index c52b343a55..e1f2d4d4e2 100644 --- a/spec/requests/admin/api_controller_spec.rb +++ b/spec/requests/admin/api_controller_spec.rb @@ -12,6 +12,7 @@ describe Admin::ApiController do fab!(:key1, refind: false) { Fabricate(:api_key, description: "my key") } fab!(:key2, refind: false) { Fabricate(:api_key, user: admin) } + fab!(:key3, refind: false) { Fabricate(:api_key, user: admin) } context "as an admin" do before do @@ -22,7 +23,21 @@ describe Admin::ApiController do it "succeeds" do get "/admin/api/keys.json" expect(response.status).to eq(200) - expect(response.parsed_body["keys"].length).to eq(2) + expect(response.parsed_body["keys"].length).to eq(3) + end + + it "can paginate results" do + get "/admin/api/keys.json?offset=0&limit=2" + expect(response.status).to eq(200) + expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key3.id, key2.id) + + get "/admin/api/keys.json?offset=1&limit=2" + expect(response.status).to eq(200) + expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key2.id, key1.id) + + get "/admin/api/keys.json?offset=2&limit=2" + expect(response.status).to eq(200) + expect(response.parsed_body["keys"].map { |x| x["id"] }).to contain_exactly(key1.id) end end @@ -222,7 +237,7 @@ describe Admin::ApiController do scopes = response.parsed_body['scopes'] - expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts') + expect(scopes.keys).to contain_exactly('topics', 'users', 'email', 'posts', 'global') end end end @@ -246,7 +261,7 @@ describe Admin::ApiController do } expect(response.status).to eq(404) - expect(ApiKey.count).to eq(2) + expect(ApiKey.count).to eq(3) end end end diff --git a/spec/requests/admin/backups_controller_spec.rb b/spec/requests/admin/backups_controller_spec.rb index e750b6dc51..fa72233a59 100644 --- a/spec/requests/admin/backups_controller_spec.rb +++ b/spec/requests/admin/backups_controller_spec.rb @@ -234,10 +234,10 @@ RSpec.describe Admin::BackupsController do describe "when filename is valid" do it "should upload the file successfully" do + freeze_time described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) filename = 'test_Site-0123456789.tar.gz' - @paths = [backup_path(File.join('tmp', 'test', "#{filename}.part1"))] post "/admin/backups/upload.json", params: { resumableFilename: filename, @@ -248,11 +248,102 @@ RSpec.describe Admin::BackupsController do resumableCurrentChunkSize: '1', file: fixture_file_upload(Tempfile.new) } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 1 + }, at: 5.seconds.from_now) expect(response.status).to eq(200) expect(response.body).to eq("") end end + + describe "completing an upload by enqueuing backup_chunks_merger" do + let(:filename) { 'test_Site-0123456789.tar.gz' } + + it "works with a single chunk" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).returns(true) + + # 2MB file, 2MB chunks = 1x 2MB chunk + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '2097152', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 1 + }, at: 5.seconds.from_now) + end + + it "works with multiple chunks when the final chunk is chunk_size + remainder" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).twice.returns(true) + + # 5MB file, 2MB chunks = 1x 2MB chunk + 1x 3MB chunk with resumable.js + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '2', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '3145728', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 2 + }, at: 5.seconds.from_now) + end + + it "works with multiple chunks when the final chunk is just the remaninder" do + freeze_time + described_class.any_instance.expects(:has_enough_space_on_disk?).times(3).returns(true) + + # 5MB file, 2MB chunks = 2x 2MB chunk + 1x 1MB chunk with uppy.js + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '1', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '2', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '2097152', + file: fixture_file_upload(Tempfile.new) + } + post "/admin/backups/upload.json", params: { + resumableFilename: filename, + resumableTotalSize: '5242880', + resumableIdentifier: 'test', + resumableChunkNumber: '3', + resumableChunkSize: '2097152', + resumableCurrentChunkSize: '1048576', + file: fixture_file_upload(Tempfile.new) + } + expect_job_enqueued(job: :backup_chunks_merger, args: { + filename: filename, identifier: 'test', chunks: 3 + }, at: 5.seconds.from_now) + end + end end describe "#check_backup_chunk" do diff --git a/spec/requests/admin/themes_controller_spec.rb b/spec/requests/admin/themes_controller_spec.rb index 472bc03981..92b7020e4f 100644 --- a/spec/requests/admin/themes_controller_spec.rb +++ b/spec/requests/admin/themes_controller_spec.rb @@ -102,26 +102,23 @@ describe Admin::ThemesController do context 'when theme allowlist mode is enabled' do before do - GlobalSetting.reset_allowed_theme_ids! - global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header" - end - - after do - GlobalSetting.reset_allowed_theme_ids! + global_setting :allowed_theme_repos, "https://github.com/discourse/discourse-brand-header.git" end it "allows allowlisted imports" do - RemoteTheme.stubs(:import_theme) + expect(Theme.allowed_remote_theme_ids.length).to eq(0) + post "/admin/themes/import.json", params: { - remote: ' https://github.com/discourse/discourse-brand-header ' + remote: ' https://github.com/discourse/discourse-brand-header.git ' } + expect(Theme.allowed_remote_theme_ids.length).to eq(1) expect(response.status).to eq(201) end it "prevents adding disallowed themes" do RemoteTheme.stubs(:import_theme) - remote = ' https://bad.com/discourse/discourse-brand-header ' + remote = ' https://bad.com/discourse/discourse-brand-header.git ' post "/admin/themes/import.json", params: { remote: remote } @@ -138,7 +135,7 @@ describe Admin::ThemesController do it 'can import a theme from Git' do RemoteTheme.stubs(:import_theme) post "/admin/themes/import.json", params: { - remote: ' https://github.com/discourse/discourse-brand-header ' + remote: ' https://github.com/discourse/discourse-brand-header.git ' } expect(response.status).to eq(201) @@ -311,14 +308,9 @@ describe Admin::ThemesController do context 'when theme allowlist mode is enabled' do before do - GlobalSetting.reset_allowed_theme_ids! global_setting :allowed_theme_repos, " https://magic.com/repo.git, https://x.com/git" end - after do - GlobalSetting.reset_allowed_theme_ids! - end - it 'unconditionally bans theme_fields from updating' do r = RemoteTheme.create!(remote_url: "https://magic.com/repo.git") theme.update!(remote_theme_id: r.id) diff --git a/spec/requests/api/schemas/json/topic_create_request.json b/spec/requests/api/schemas/json/topic_create_request.json index c9a822e458..70b1a4489e 100644 --- a/spec/requests/api/schemas/json/topic_create_request.json +++ b/spec/requests/api/schemas/json/topic_create_request.json @@ -33,10 +33,13 @@ }, "created_at": { "type": "string" + }, + "embed_url": { + "type": "string", + "description": "Provide a URL from a remote system to associate a forum topic with that URL, typically for using Discourse as a comments system for an external blog." } }, "required": [ "raw" ] } - diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb index bff3fdcaa9..610cebccbd 100644 --- a/spec/requests/application_controller_spec.rb +++ b/spec/requests/application_controller_spec.rb @@ -831,4 +831,24 @@ RSpec.describe ApplicationController do end end end + + describe 'vary header' do + it 'includes Vary:Accept on all requests where format is not explicit' do + # Rails default behaviour - include Vary:Accept when Accept is supplied + get "/latest", headers: { "Accept" => "application/json" } + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq("Accept") + + # Discourse additional behaviour (see lib/vary_header.rb) + # Include Vary:Accept even when Accept is not supplied + get "/latest" + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq("Accept") + + # Not needed, because the path 'format' parameter overrides the Accept header + get "/latest.json" + expect(response.status).to eq(200) + expect(response.headers["Vary"]).to eq(nil) + end + end end diff --git a/spec/requests/categories_controller_spec.rb b/spec/requests/categories_controller_spec.rb index 2067279cf1..b9e09cf3bf 100644 --- a/spec/requests/categories_controller_spec.rb +++ b/spec/requests/categories_controller_spec.rb @@ -633,5 +633,23 @@ describe CategoriesController do get "/categories_and_latest.json" expect(response.parsed_body["category_list"]["categories"].map { |x| x['id'] }).not_to include(uncategorized.id) end + + describe 'Showing top topics from private categories' do + it 'returns the top topic from the private category when the user is a member' do + restricted_group = Fabricate(:group) + private_cat = Fabricate(:private_category, group: restricted_group) + private_topic = Fabricate(:topic, category: private_cat, like_count: 1000, posts_count: 100) + TopTopic.refresh! + restricted_group.add(user) + sign_in(user) + + get "/categories_and_top.json" + parsed_topic = response.parsed_body.dig('topic_list', 'topics').detect do |t| + t.dig('id') == private_topic.id + end + + expect(parsed_topic).to be_present + end + end end end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 852893954d..eea1a2d362 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -904,6 +904,16 @@ describe InvitesController do expect(Jobs::BulkInvite.jobs.size).to eq(1) end + it 'allows admin to bulk invite when DiscourseConnect enabled' do + SiteSetting.discourse_connect_url = "https://example.com" + SiteSetting.enable_discourse_connect = true + + sign_in(admin) + post '/invites/upload_csv.json', params: { file: file, name: 'discourse.csv' } + expect(response.status).to eq(200) + expect(Jobs::BulkInvite.jobs.size).to eq(1) + end + it 'sends limited invites at a time' do SiteSetting.max_bulk_invites = 3 sign_in(admin) diff --git a/spec/requests/qunit_controller_spec.rb b/spec/requests/qunit_controller_spec.rb index aa7773127e..bf06a012a8 100644 --- a/spec/requests/qunit_controller_spec.rb +++ b/spec/requests/qunit_controller_spec.rb @@ -108,7 +108,6 @@ describe QunitController do expect(response.body).to match(/\/theme-javascripts\/\h{40}\.js/) expect(response.body).to include("/theme-javascripts/tests/#{theme.id}-") expect(response.body).to include("/assets/discourse/tests/test_starter.js") - expect(response.body).to include("/extra-locales/admin") end end end diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index 2aa3b858b2..7a76aa043a 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -1358,6 +1358,53 @@ RSpec.describe TopicsController do expect(response.status).to eq(200) end + context 'when using SiteSetting.disable_category_edit_notifications or SiteSetting.disable_tags_edit_notifications' do + shared_examples 'a topic bump suppressor' do + it "doesn't bump the topic if the setting is enabled" do + enable_setting + last_bumped_at = topic.bumped_at + expect(last_bumped_at).not_to be_nil + + expect do + put "/t/#{topic.slug}/#{topic.id}.json", params: params + end.to change { topic.reload.send(attribute_to_change) }.to(expected_new_value) + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).to eq_time(last_bumped_at) + end + + it "bumps the topic if the setting is disabled" do + disable_setting + last_bumped_at = topic.bumped_at + expect(last_bumped_at).not_to be_nil + + expect do + put "/t/#{topic.slug}/#{topic.id}.json", params: params + end.to change { topic.reload.send(attribute_to_change) }.to(expected_new_value) + + expect(response.status).to eq(200) + expect(topic.reload.bumped_at).not_to eq_time(last_bumped_at) + end + end + + it_behaves_like 'a topic bump suppressor' do + let(:attribute_to_change) { :category_id } + let(:expected_new_value) { category.id } + let(:params) { { category_id: category.id } } + let(:enable_setting) { SiteSetting.disable_category_edit_notifications = true } + let(:disable_setting) { SiteSetting.disable_category_edit_notifications = false } + end + + it_behaves_like 'a topic bump suppressor' do + let(:tags) { [Fabricate(:tag), Fabricate(:tag)] } + let(:attribute_to_change) { :tags } + let(:expected_new_value) { tags } + let(:params) { { tags: tags.map(&:name) } } + let(:enable_setting) { SiteSetting.disable_tags_edit_notifications = true } + let(:disable_setting) { SiteSetting.disable_tags_edit_notifications = false } + end + end + describe "when first post is locked" do it "blocks non-staff from editing even if 'trusted_users_can_edit_others' is true" do SiteSetting.trusted_users_can_edit_others = true diff --git a/spec/requests/uploads_controller_spec.rb b/spec/requests/uploads_controller_spec.rb index b142eed0f6..85d7df04da 100644 --- a/spec/requests/uploads_controller_spec.rb +++ b/spec/requests/uploads_controller_spec.rb @@ -787,14 +787,14 @@ describe UploadsController do describe "#create_multipart" do context "when the store is external" do let(:mock_multipart_upload_id) { "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" } + let(:test_bucket_prefix) { "test_#{ENV['TEST_ENV_NUMBER'].presence || '0'}" } before do sign_in(user) SiteSetting.enable_direct_s3_uploads = true setup_s3 - FileStore::S3Store.any_instance.stubs(:temporary_upload_path).returns( - "uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png" - ) + SiteSetting.s3_backup_bucket = "s3-backup-bucket" + SiteSetting.backup_location = BackupLocationSiteSetting::S3 end it "errors if the correct params are not provided" do @@ -830,17 +830,20 @@ describe UploadsController do end def stub_create_multipart_request + FileStore::S3Store.any_instance.stubs(:temporary_upload_path).returns( + "uploads/default/#{test_bucket_prefix}/temp/28fccf8259bbe75b873a2bd2564b778c/test.png" + ) create_multipart_result = <<~BODY \n s3-upload-bucket - uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png + uploads/default/#{test_bucket_prefix}/temp/28fccf8259bbe75b873a2bd2564b778c/test.png #{mock_multipart_upload_id} BODY stub_request( :post, - "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png?uploads" + "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/uploads/default/#{test_bucket_prefix}/temp/28fccf8259bbe75b873a2bd2564b778c/test.png?uploads" ).to_return({ status: 200, body: create_multipart_result }) end @@ -915,6 +918,88 @@ describe UploadsController do expect(response.status).to eq(429) end end + + context "when the upload_type is backup" do + let(:upload_type) { "backup" } + let(:backup_file_exists_response) { { status: 404 } } + + before do + stub_request(:head, "https://s3-backup-bucket.s3.us-west-1.amazonaws.com/").to_return(status: 200, body: "", headers: {}) + stub_request(:head, "https://s3-backup-bucket.s3.us-west-1.amazonaws.com/default/test.tar.gz").to_return( + backup_file_exists_response + ) + end + + context "when the user is not admin" do + it "errors with invalid access error" do + post "/uploads/create-multipart.json", params: { + file_name: "test.tar.gz", + upload_type: upload_type, + file_size: 4098 + } + expect(response.status).to eq(403) + end + end + + context "when the user is admin" do + before do + user.update(admin: true) + end + + def stub_create_multipart_backup_request + BackupRestore::S3BackupStore.any_instance.stubs(:temporary_upload_path).returns( + "temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz" + ) + create_multipart_result = <<~BODY + \n + + s3-backup-bucket + temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz + #{mock_multipart_upload_id} + + BODY + stub_request(:post, "https://s3-backup-bucket.s3.us-west-1.amazonaws.com/temp/default/#{test_bucket_prefix}/28fccf8259bbe75b873a2bd2564b778c/2u98j832nx93272x947823.gz?uploads"). + to_return(status: 200, body: create_multipart_result) + end + + it "creates the multipart upload" do + stub_create_multipart_backup_request + post "/uploads/create-multipart.json", params: { + file_name: "test.tar.gz", + upload_type: upload_type, + file_size: 4098 + } + expect(response.status).to eq(200) + result = response.parsed_body + + external_upload_stub = ExternalUploadStub.where( + unique_identifier: result["unique_identifier"], + original_filename: "test.tar.gz", + created_by: user, + upload_type: upload_type, + key: result["key"], + multipart: true + ) + expect(external_upload_stub.exists?).to eq(true) + end + + context "when backup of same filename already exists" do + let(:backup_file_exists_response) { { status: 200, body: "" } } + + it "throws an error" do + post "/uploads/create-multipart.json", params: { + file_name: "test.tar.gz", + upload_type: upload_type, + file_size: 4098 + } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"]).to include( + I18n.t("backup.file_exists") + ) + end + end + end + end end context "when the store is not external" do @@ -974,7 +1059,7 @@ describe UploadsController do STANDARD BODY - stub_request(:get, "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/#{external_upload_stub.key}?uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) + stub_request(:get, "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/#{external_upload_stub.key}?max-parts=1&uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) end it "errors if the correct params are not provided" do @@ -1118,7 +1203,7 @@ describe UploadsController do STANDARD BODY - stub_request(:get, "#{upload_base_url}/#{external_upload_stub.key}?uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) + stub_request(:get, "#{upload_base_url}/#{external_upload_stub.key}?max-parts=1&uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) end it "errors if the correct params are not provided" do @@ -1201,7 +1286,7 @@ describe UploadsController do :post, "#{temp_location}?uploadId=#{external_upload_stub.external_upload_identifier}" ).with( - body: "\n \n test1\n 1\n \n \n test2\n 2\n \n\n" + body: "test11test22" ).to_return(status: 200, body: <<~XML) @@ -1215,7 +1300,7 @@ describe UploadsController do # all the functionality for ExternalUploadManager is already tested along # with stubs to S3 in its own test, we can just stub the response here upload = Fabricate(:upload) - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).returns(upload) + ExternalUploadManager.any_instance.stubs(:transform!).returns(upload) post "/uploads/complete-multipart.json", params: { unique_identifier: external_upload_stub.unique_identifier, @@ -1383,39 +1468,39 @@ describe UploadsController do end it "handles ChecksumMismatchError" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::ChecksumMismatchError) + ExternalUploadManager.any_instance.stubs(:transform!).raises(ExternalUploadManager::ChecksumMismatchError) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) end it "handles SizeMismatchError" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::SizeMismatchError.new("expected: 10, actual: 1000")) + ExternalUploadManager.any_instance.stubs(:transform!).raises(ExternalUploadManager::SizeMismatchError.new("expected: 10, actual: 1000")) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) end it "handles CannotPromoteError" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::CannotPromoteError) + ExternalUploadManager.any_instance.stubs(:transform!).raises(ExternalUploadManager::CannotPromoteError) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) end it "handles DownloadFailedError and Aws::S3::Errors::NotFound" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::DownloadFailedError) + ExternalUploadManager.any_instance.stubs(:transform!).raises(ExternalUploadManager::DownloadFailedError) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(Aws::S3::Errors::NotFound.new("error", "not found")) + ExternalUploadManager.any_instance.stubs(:transform!).raises(Aws::S3::Errors::NotFound.new("error", "not found")) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) end it "handles a generic upload failure" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(StandardError) + ExternalUploadManager.any_instance.stubs(:transform!).raises(StandardError) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) @@ -1423,14 +1508,14 @@ describe UploadsController do it "handles validation errors on the upload" do upload.errors.add(:base, "test error") - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).returns(upload) + ExternalUploadManager.any_instance.stubs(:transform!).returns(upload) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(response.status).to eq(422) expect(response.parsed_body["errors"]).to eq(["test error"]) end it "deletes the stub and returns the serialized upload when complete" do - ExternalUploadManager.any_instance.stubs(:promote_to_upload!).returns(upload) + ExternalUploadManager.any_instance.stubs(:transform!).returns(upload) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) expect(response.status).to eq(200) diff --git a/spec/serializers/upload_serializer_spec.rb b/spec/serializers/upload_serializer_spec.rb index c2a1cf6e04..01be44b552 100644 --- a/spec/serializers/upload_serializer_spec.rb +++ b/spec/serializers/upload_serializer_spec.rb @@ -7,7 +7,7 @@ RSpec.describe UploadSerializer do let(:subject) { UploadSerializer.new(upload, root: false) } it 'should render without errors' do - json_data = JSON.load(subject.to_json) + json_data = JSON.parse(subject.to_json) expect(json_data['id']).to eql upload.id expect(json_data['width']).to eql upload.width diff --git a/spec/services/external_upload_manager_spec.rb b/spec/services/external_upload_manager_spec.rb index 1cfd032a9e..f7abdd8b13 100644 --- a/spec/services/external_upload_manager_spec.rb +++ b/spec/services/external_upload_manager_spec.rb @@ -25,6 +25,10 @@ RSpec.describe ExternalUploadManager do SiteSetting.max_attachment_size_kb = 210.megabytes / 1000 setup_s3 + + SiteSetting.s3_backup_bucket = "s3-backup-bucket" + SiteSetting.backup_location = BackupLocationSiteSetting::S3 + stub_head_object stub_download_object_filehelper stub_copy_object @@ -47,7 +51,7 @@ RSpec.describe ExternalUploadManager do end end - describe "#promote_to_upload!" do + describe "#transform!" do context "when stubbed upload is < DOWNLOAD_LIMIT (small enough to download + generate sha)" do let!(:external_upload_stub) { Fabricate(:image_external_upload_stub, created_by: user, filesize: object_size) } let(:object_size) { 1.megabyte } @@ -59,7 +63,7 @@ RSpec.describe ExternalUploadManager do end it "raises an error" do - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::DownloadFailedError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::DownloadFailedError) end end @@ -68,16 +72,16 @@ RSpec.describe ExternalUploadManager do external_upload_stub.update!(status: ExternalUploadStub.statuses[:uploaded]) end it "raises an error" do - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::CannotPromoteError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::CannotPromoteError) end end context "when the upload does not get changed in UploadCreator (resized etc.)" do it "copies the stubbed upload on S3 to its new destination and deletes it" do - upload = subject.promote_to_upload! + upload = subject.transform! expect(WebMock).to have_requested( :put, - "#{upload_base_url}/original/1X/#{upload.sha1}.png", + "#{upload_base_url}/#{Discourse.store.get_path_for_upload(upload)}", ).with(headers: { 'X-Amz-Copy-Source' => "#{SiteSetting.s3_upload_bucket}/#{external_upload_stub.key}" }) expect(WebMock).to have_requested( :delete, @@ -87,7 +91,7 @@ RSpec.describe ExternalUploadManager do it "errors if the image upload is too big" do SiteSetting.max_image_size_kb = 1 - upload = subject.promote_to_upload! + upload = subject.transform! expect(upload.errors.full_messages).to include( "Filesize " + I18n.t("upload.images.too_large_humanized", max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_image_size_kb.kilobytes)) ) @@ -95,7 +99,7 @@ RSpec.describe ExternalUploadManager do it "errors if the extension is not supported" do SiteSetting.authorized_extensions = "" - upload = subject.promote_to_upload! + upload = subject.transform! expect(upload.errors.full_messages).to include( "Original filename " + I18n.t("upload.unauthorized", authorized_extensions: "") ) @@ -106,10 +110,10 @@ RSpec.describe ExternalUploadManager do let(:file) { file_from_fixtures("should_be_jpeg.heic", "images") } it "creates a new upload in s3 (not copy) and deletes the original stubbed upload" do - upload = subject.promote_to_upload! + upload = subject.transform! expect(WebMock).to have_requested( :put, - "#{upload_base_url}/original/1X/#{upload.sha1}.png" + "#{upload_base_url}/#{Discourse.store.get_path_for_upload(upload)}", ) expect(WebMock).to have_requested( :delete, "#{upload_base_url}/#{external_upload_stub.key}" @@ -124,13 +128,13 @@ RSpec.describe ExternalUploadManager do let(:client_sha1) { "blahblah" } it "raises an error, deletes the stub" do - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::ChecksumMismatchError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::ChecksumMismatchError) expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) end it "does not delete the stub if enable_upload_debug_mode" do SiteSetting.enable_upload_debug_mode = true - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::ChecksumMismatchError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::ChecksumMismatchError) external_stub = ExternalUploadStub.find(external_upload_stub.id) expect(external_stub.status).to eq(ExternalUploadStub.statuses[:failed]) end @@ -145,7 +149,7 @@ RSpec.describe ExternalUploadManager do after { Discourse.redis.flushdb } it "raises an error, deletes the file immediately, and prevents the user from uploading external files for a few minutes" do - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::SizeMismatchError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::SizeMismatchError) expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) expect(Discourse.redis.get("#{ExternalUploadManager::BAN_USER_REDIS_PREFIX}#{external_upload_stub.created_by_id}")).to eq("1") expect(WebMock).to have_requested( @@ -156,7 +160,7 @@ RSpec.describe ExternalUploadManager do it "does not delete the stub if enable_upload_debug_mode" do SiteSetting.enable_upload_debug_mode = true - expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::SizeMismatchError) + expect { subject.transform! }.to raise_error(ExternalUploadManager::SizeMismatchError) external_stub = ExternalUploadStub.find(external_upload_stub.id) expect(external_stub.status).to eq(ExternalUploadStub.statuses[:failed]) end @@ -174,32 +178,93 @@ RSpec.describe ExternalUploadManager do it "does not try and download the file" do FileHelper.expects(:download).never - subject.promote_to_upload! + subject.transform! end it "generates a fake sha for the upload record" do - upload = subject.promote_to_upload! + upload = subject.transform! expect(upload.sha1).not_to eq(sha1) expect(upload.original_sha1).to eq(nil) expect(upload.filesize).to eq(object_size) end it "marks the stub as uploaded" do - subject.promote_to_upload! + subject.transform! expect(external_upload_stub.reload.status).to eq(ExternalUploadStub.statuses[:uploaded]) end it "copies the stubbed upload on S3 to its new destination and deletes it" do - upload = subject.promote_to_upload! + upload = subject.transform! expect(WebMock).to have_requested( :put, - "#{upload_base_url}/original/1X/#{upload.sha1}.pdf" + "#{upload_base_url}/#{Discourse.store.get_path_for_upload(upload)}", ).with(headers: { 'X-Amz-Copy-Source' => "#{SiteSetting.s3_upload_bucket}/#{external_upload_stub.key}" }) expect(WebMock).to have_requested( :delete, "#{upload_base_url}/#{external_upload_stub.key}" ) end end + + context "when the upload type is backup" do + let(:upload_base_url) { "https://#{SiteSetting.s3_backup_bucket}.s3.#{SiteSetting.s3_region}.amazonaws.com" } + let(:object_size) { 200.megabytes } + let(:object_file) { file_from_fixtures("backup_since_v1.6.tar.gz", "backups") } + let!(:external_upload_stub) do + Fabricate( + :attachment_external_upload_stub, + created_by: user, + filesize: object_size, + upload_type: "backup", + original_filename: "backup_since_v1.6.tar.gz", + folder_prefix: RailsMultisite::ConnectionManagement.current_db + ) + end + + before do + stub_request(:head, "https://#{SiteSetting.s3_backup_bucket}.s3.#{SiteSetting.s3_region}.amazonaws.com/") + + # stub copy and delete object for backup, which copies the original filename to the root, + # and also uses current_db in the bucket name always + stub_request( + :put, + "#{upload_base_url}/#{RailsMultisite::ConnectionManagement.current_db}/backup_since_v1.6.tar.gz" + ).to_return( + status: 200, + headers: { "ETag" => etag }, + body: copy_object_result + ) + end + + it "does not try and download the file" do + FileHelper.expects(:download).never + subject.transform! + end + + it "raises an error when backups are disabled" do + SiteSetting.enable_backups = false + expect { subject.transform! }.to raise_error(Discourse::InvalidAccess) + end + + it "raises an error when backups are local, not s3" do + SiteSetting.backup_location = BackupLocationSiteSetting::LOCAL + expect { subject.transform! }.to raise_error(Discourse::InvalidAccess) + end + + it "does not create an upload record" do + expect { subject.transform! }.not_to change { Upload.count } + end + + it "copies the stubbed upload on S3 to its new destination and deletes it" do + upload = subject.transform! + expect(WebMock).to have_requested( + :put, + "#{upload_base_url}/#{RailsMultisite::ConnectionManagement.current_db}/backup_since_v1.6.tar.gz", + ).with(headers: { 'X-Amz-Copy-Source' => "#{SiteSetting.s3_backup_bucket}/#{external_upload_stub.key}" }) + expect(WebMock).to have_requested( + :delete, "#{upload_base_url}/#{external_upload_stub.key}" + ) + end + end end def stub_head_object @@ -226,8 +291,8 @@ RSpec.describe ExternalUploadManager do ) end - def stub_copy_object - copy_object_result = <<~BODY + def copy_object_result + <<~BODY \n @@ -235,17 +300,28 @@ RSpec.describe ExternalUploadManager do "#{etag}" BODY + end + + def stub_copy_object + upload_pdf = Fabricate(:upload, sha1: "testbc60eb18e8f974cbfae8bb0f069c3a311024", original_filename: "test.pdf", extension: "pdf") + upload_path = Discourse.store.get_path_for_upload(upload_pdf) + upload_pdf.destroy! + stub_request( :put, - "#{upload_base_url}/original/1X/testbc60eb18e8f974cbfae8bb0f069c3a311024.pdf" + "#{upload_base_url}/#{upload_path}" ).to_return( status: 200, headers: { "ETag" => etag }, body: copy_object_result ) + + upload_png = Fabricate(:upload, sha1: "bc975735dfc6409c1c2aa5ebf2239949bcbdbd65", original_filename: "test.png", extension: "png") + upload_path = Discourse.store.get_path_for_upload(upload_png) + upload_png.destroy! stub_request( :put, - "#{upload_base_url}/original/1X/bc975735dfc6409c1c2aa5ebf2239949bcbdbd65.png" + "#{upload_base_url}/#{upload_path}" ).to_return( status: 200, headers: { "ETag" => etag }, diff --git a/spec/services/post_action_notifier_spec.rb b/spec/services/post_action_notifier_spec.rb index fbdbd674e1..c78be23dc5 100644 --- a/spec/services/post_action_notifier_spec.rb +++ b/spec/services/post_action_notifier_spec.rb @@ -172,6 +172,25 @@ describe PostActionNotifier do end + context "tags edit notifications are disabled" do + it 'notifies a user of the revision made by another user' do + SiteSetting.disable_tags_edit_notifications = false + + expect { + post.revise(evil_trout, tags: [Fabricate(:tag).name]) + }.to change(post.user.notifications, :count).by(1) + end + + it 'does not notify a user of the revision made by the system user' do + SiteSetting.disable_tags_edit_notifications = true + + expect { + post.revise(evil_trout, tags: [Fabricate(:tag).name]) + }.not_to change(post.user.notifications, :count) + end + + end + context 'when using plugin API to add custom recipients' do let(:lurker) { Fabricate(:user) } diff --git a/spec/services/post_alerter_spec.rb b/spec/services/post_alerter_spec.rb index c9da51111d..56338868e6 100644 --- a/spec/services/post_alerter_spec.rb +++ b/spec/services/post_alerter_spec.rb @@ -721,12 +721,8 @@ describe PostAlerter do describe "push_notification" do let(:mention_post) { create_post_with_alerts(user: user, raw: 'Hello @eviltrout :heart:') } let(:topic) { mention_post.topic } - - it "pushes nothing to suspended users" do + before do SiteSetting.allowed_user_api_push_urls = "https://site.com/push|https://site2.com/push" - - evil_trout.update_columns(suspended_till: 1.year.from_now) - 2.times do |i| UserApiKey.create!(user_id: evil_trout.id, client_id: "xxx#{i}", @@ -734,20 +730,33 @@ describe PostAlerter do scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) }, push_url: "https://site2.com/push") end + end + describe "DiscoursePluginRegistry#push_notification_filters" do + it "sends push notifications when all filters pass" do + Plugin::Instance.new.register_push_notification_filter do |user, payload| + true + end + + expect { mention_post }.to change { Jobs::PushNotification.jobs.count }.by(1) + DiscoursePluginRegistry.reset! + end + + it "does not send push notifications when a filters returns false" do + Plugin::Instance.new.register_push_notification_filter do |user, payload| + false + end + expect { mention_post }.not_to change { Jobs::PushNotification.jobs.count } + DiscoursePluginRegistry.reset! + end + end + + it "pushes nothing to suspended users" do + evil_trout.update_columns(suspended_till: 1.year.from_now) expect { mention_post }.to_not change { Jobs::PushNotification.jobs.count } end it "pushes nothing when the user is in 'do not disturb'" do - SiteSetting.allowed_user_api_push_urls = "https://site.com/push|https://site2.com/push" - 2.times do |i| - UserApiKey.create!(user_id: evil_trout.id, - client_id: "xxx#{i}", - application_name: "iPhone#{i}", - scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) }, - push_url: "https://site2.com/push") - end - Fabricate(:do_not_disturb_timing, user: evil_trout, starts_at: Time.zone.now, ends_at: 1.day.from_now) expect { mention_post }.to_not change { Jobs::PushNotification.jobs.count } @@ -755,16 +764,6 @@ describe PostAlerter do it "correctly pushes notifications if configured correctly" do Jobs.run_immediately! - SiteSetting.allowed_user_api_push_urls = "https://site.com/push|https://site2.com/push" - - 2.times do |i| - UserApiKey.create!(user_id: evil_trout.id, - client_id: "xxx#{i}", - application_name: "iPhone#{i}", - scopes: ['notifications'].map { |name| UserApiKeyScope.new(name: name) }, - push_url: "https://site2.com/push") - end - body = nil headers = nil diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index 65d0802b13..3b56863f53 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -367,7 +367,7 @@ describe UsernameChanger do

quoted post

@@ -375,7 +375,7 @@ describe UsernameChanger do