diff --git a/.rubocop.yml b/.rubocop.yml index 55f8d548a8..eebd794151 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,16 @@ +require: + - rubocop-discourse + AllCops: TargetRubyVersion: 2.4 DisabledByDefault: true Exclude: - - 'db/schema.rb' - - 'bundle/**/*' - - 'vendor/**/*' - - 'node_modules/**/*' - - 'public/**/*' - - 'plugins/**/gems/**/*' + - "db/schema.rb" + - "bundle/**/*" + - "vendor/**/*" + - "node_modules/**/*" + - "public/**/*" + - "plugins/**/gems/**/*" # Prefer &&/|| over and/or. Style/AndOr: @@ -57,7 +60,7 @@ Layout/SpaceAroundOperators: Enabled: true Layout/SpaceBeforeFirstArg: - Enabled: true + Enabled: true # Defining a method with parameters needs parentheses. Style/MethodDefParentheses: @@ -126,6 +129,15 @@ Style/Semicolon: Enabled: true AllowAsExpressionSeparator: true +Style/RedundantReturn: + Enabled: true + +DiscourseCops/NoChdir: + Enabled: true + Exclude: + - 'spec/**/*' # Specs are run sequentially, so chdir can be used + - 'plugins/*/spec/**/*' + Style/GlobalVars: Enabled: true Severity: warning diff --git a/Gemfile b/Gemfile index f728eef550..5a77669c3c 100644 --- a/Gemfile +++ b/Gemfile @@ -16,13 +16,13 @@ if rails_master? else # until rubygems gives us optional dependencies we are stuck with this # bundle update actionmailer actionpack actionview activemodel activerecord activesupport railties - gem 'actionmailer', '6.0.0' - gem 'actionpack', '6.0.0' - gem 'actionview', '6.0.0' - gem 'activemodel', '6.0.0' - gem 'activerecord', '6.0.0' - gem 'activesupport', '6.0.0' - gem 'railties', '6.0.0' + gem 'actionmailer', '6.0.1' + gem 'actionpack', '6.0.1' + gem 'actionview', '6.0.1' + gem 'activemodel', '6.0.1' + gem 'activerecord', '6.0.1' + gem 'activesupport', '6.0.1' + gem 'railties', '6.0.1' gem 'sprockets-rails' end @@ -41,7 +41,7 @@ gem 'redis-namespace' gem 'active_model_serializers', '~> 0.8.3' -gem 'onebox', '1.9.21' +gem 'onebox', '1.9.24' gem 'http_accept_language', '~>2.0.5', require: false @@ -143,6 +143,7 @@ group :test, :development do gem 'pry-nav' gem 'byebug', require: ENV['RM_INFO'].nil? gem 'rubocop', require: false + gem "rubocop-discourse", require: false gem 'parallel_tests' end diff --git a/Gemfile.lock b/Gemfile.lock index 4176a30f6e..213187d108 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,21 +1,21 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (6.0.0) - actionpack (= 6.0.0) - actionview (= 6.0.0) - activejob (= 6.0.0) + actionmailer (6.0.1) + actionpack (= 6.0.1) + actionview (= 6.0.1) + activejob (= 6.0.1) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.0.0) - actionview (= 6.0.0) - activesupport (= 6.0.0) + actionpack (6.0.1) + actionview (= 6.0.1) + activesupport (= 6.0.1) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (6.0.0) - activesupport (= 6.0.0) + actionview (6.0.1) + activesupport (= 6.0.1) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -24,20 +24,20 @@ GEM actionview (>= 6.0.a) active_model_serializers (0.8.4) activemodel (>= 3.0) - activejob (6.0.0) - activesupport (= 6.0.0) + activejob (6.0.1) + activesupport (= 6.0.1) globalid (>= 0.3.6) - activemodel (6.0.0) - activesupport (= 6.0.0) - activerecord (6.0.0) - activemodel (= 6.0.0) - activesupport (= 6.0.0) - activesupport (6.0.0) + activemodel (6.0.1) + activesupport (= 6.0.1) + activerecord (6.0.1) + activemodel (= 6.0.1) + activesupport (= 6.0.1) + activesupport (6.0.1) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - zeitwerk (~> 2.1, >= 2.1.8) + zeitwerk (~> 2.2) addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) annotate (2.7.5) @@ -72,7 +72,7 @@ GEM rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) - bootsnap (1.4.4) + bootsnap (1.4.5) msgpack (~> 1.0) builder (3.2.3) bullet (6.0.0) @@ -91,7 +91,7 @@ GEM cppjieba_rb (0.3.3) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.4) + crass (1.0.5) css_parser (1.7.0) addressable debug_inspector (0.0.3) @@ -119,7 +119,7 @@ GEM jquery-rails (>= 1.0.17) railties (>= 3.1) ember-source (2.18.2) - erubi (1.8.0) + erubi (1.9.0) excon (0.64.0) execjs (2.7.0) exifr (1.3.6) @@ -146,7 +146,7 @@ GEM hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.0.5) - i18n (1.6.0) + i18n (1.7.0) concurrent-ruby (~> 1.0) image_size (1.5.0) in_threads (1.5.1) @@ -173,7 +173,7 @@ GEM logstash-logger (0.26.1) logstash-event (~> 1.2) logster (2.4.1) - loofah (2.2.3) + loofah (2.3.1) crass (~> 1.0.2) nokogiri (>= 1.5.9) lru_redux (1.1.0) @@ -188,23 +188,23 @@ GEM method_source (0.9.2) mini_mime (1.0.2) mini_portile2 (2.4.0) - mini_racer (0.2.6) + mini_racer (0.2.8) libv8 (>= 6.9.411) mini_scheduler (0.12.2) sidekiq mini_sql (0.2.2) mini_suffix (0.3.0) ffi (~> 1.9) - minitest (5.11.3) + minitest (5.13.0) mocha (1.8.0) metaclass (~> 0.0.1) mock_redis (0.19.0) - msgpack (1.2.10) + msgpack (1.3.1) multi_json (1.13.1) multi_xml (0.6.0) multipart-post (2.1.1) mustache (1.1.0) - nokogiri (1.10.4) + nokogiri (1.10.5) mini_portile2 (~> 2.4.0) nokogumbo (2.0.1) nokogiri (~> 1.8, >= 1.8.4) @@ -243,7 +243,7 @@ GEM omniauth-twitter (1.4.0) omniauth-oauth (~> 1.1) rack - onebox (1.9.21) + onebox (1.9.24) htmlentities (~> 4.3) multi_json (~> 1.11) mustache @@ -283,14 +283,14 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.2.0) - loofah (~> 2.2, >= 2.2.2) + rails-html-sanitizer (1.3.0) + loofah (~> 2.3) rails_multisite (2.0.7) activerecord (> 4.2, < 7) railties (> 4.2, < 7) - railties (6.0.0) - actionpack (= 6.0.0) - activesupport (= 6.0.0) + railties (6.0.1) + actionpack (= 6.0.1) + activesupport (= 6.0.1) method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) @@ -348,6 +348,8 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) + rubocop-discourse (1.0.1) + rubocop (>= 0.69.0) ruby-openid (2.7.0) ruby-prof (0.17.0) ruby-progressbar (1.10.0) @@ -418,20 +420,20 @@ GEM hkdf (~> 0.2) jwt (~> 2.0) yaml-lint (0.0.10) - zeitwerk (2.1.10) + zeitwerk (2.2.1) PLATFORMS ruby DEPENDENCIES - actionmailer (= 6.0.0) - actionpack (= 6.0.0) - actionview (= 6.0.0) + actionmailer (= 6.0.1) + actionpack (= 6.0.1) + actionview (= 6.0.1) actionview_precompiler active_model_serializers (~> 0.8.3) - activemodel (= 6.0.0) - activerecord (= 6.0.0) - activesupport (= 6.0.0) + activemodel (= 6.0.1) + activerecord (= 6.0.1) + activesupport (= 6.0.1) annotate aws-sdk-s3 aws-sdk-sns @@ -497,7 +499,7 @@ DEPENDENCIES omniauth-oauth2 omniauth-openid omniauth-twitter - onebox (= 1.9.21) + onebox (= 1.9.24) openid-redis-store parallel_tests pg @@ -508,7 +510,7 @@ DEPENDENCIES rack-mini-profiler rack-protection rails_multisite - railties (= 6.0.0) + railties (= 6.0.1) rake rb-fsevent rb-inotify (~> 0.9) @@ -524,6 +526,7 @@ DEPENDENCIES rspec-rails (= 4.0.0.beta2) rtlit rubocop + rubocop-discourse ruby-prof ruby-readability rubyzip diff --git a/app/assets/javascripts/admin/components/ace-editor.js.es6 b/app/assets/javascripts/admin/components/ace-editor.js.es6 index 60498a0bdb..4fc881dae4 100644 --- a/app/assets/javascripts/admin/components/ace-editor.js.es6 +++ b/app/assets/javascripts/admin/components/ace-editor.js.es6 @@ -1,6 +1,6 @@ import Component from "@ember/component"; import loadScript from "discourse/lib/load-script"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { on } from "@ember/object/evented"; export default Component.extend({ diff --git a/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 b/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 index ccacb2e705..316597e0f7 100644 --- a/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 +++ b/app/assets/javascripts/admin/components/admin-backups-logs.js.es6 @@ -1,10 +1,10 @@ import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { renderSpinner } from "discourse/helpers/loading-spinner"; import { escapeExpression } from "discourse/lib/utilities"; import { bufferedRender } from "discourse-common/lib/buffered-render"; -import { observes, on } from "ember-addons/ember-computed-decorators"; +import { observes, on } from "discourse-common/utils/decorators"; export default Component.extend( bufferedRender({ @@ -35,7 +35,7 @@ export default Component.extend( @on("init") @observes("logs.[]") - _updateFormattedLogs: debounce(function() { + _updateFormattedLogs: discourseDebounce(function() { const logs = this.logs; if (logs.length === 0) return; diff --git a/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 index f110293a74..093f29826b 100644 --- a/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 +++ b/app/assets/javascripts/admin/components/admin-directory-toggle.js.es6 @@ -1,36 +1,30 @@ import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -export default Component.extend( - bufferedRender({ - tagName: "th", - classNames: ["sortable"], - rerenderTriggers: ["order", "ascending"], - - buildBuffer(buffer) { - const icon = this.icon; - - if (icon) { - buffer.push(iconHTML(icon)); - } - - buffer.push(I18n.t(this.i18nKey)); - - if (this.field === this.order) { - buffer.push(iconHTML(this.ascending ? "chevron-up" : "chevron-down")); - } - }, - - click() { - const currentOrder = this.order; - const field = this.field; - - if (currentOrder === field) { - this.set("ascending", this.ascending ? null : true); - } else { - this.setProperties({ order: field, ascending: null }); - } +export default Component.extend({ + tagName: "th", + classNames: ["sortable"], + chevronIcon: null, + toggleProperties() { + if (this.order === this.field) { + this.set("ascending", this.ascending ? null : true); + } else { + this.setProperties({ order: this.field, ascending: null }); } - }) -); + }, + toggleChevron() { + if (this.order === this.field) { + let chevron = iconHTML(this.ascending ? "chevron-up" : "chevron-down"); + this.set("chevronIcon", `${chevron}`.htmlSafe()); + } else { + this.set("chevronIcon", null); + } + }, + click() { + this.toggleProperties(); + }, + didReceiveAttrs() { + this._super(...arguments); + this.toggleChevron(); + } +}); diff --git a/app/assets/javascripts/admin/components/admin-report-storage-stats.js.es6 b/app/assets/javascripts/admin/components/admin-report-storage-stats.js.es6 index 948ecf1e87..61629c626e 100644 --- a/app/assets/javascripts/admin/components/admin-report-storage-stats.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-storage-stats.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; import { setting } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["admin-report-storage-stats"], @@ -10,32 +10,32 @@ export default Component.extend({ backupStats: alias("model.data.backups"), uploadStats: alias("model.data.uploads"), - @computed("backupStats") + @discourseComputed("backupStats") showBackupStats(stats) { return stats && this.currentUser.admin; }, - @computed("backupLocation") + @discourseComputed("backupLocation") backupLocationName(backupLocation) { return I18n.t(`admin.backups.location.${backupLocation}`); }, - @computed("backupStats.used_bytes") + @discourseComputed("backupStats.used_bytes") usedBackupSpace(bytes) { return I18n.toHumanSize(bytes); }, - @computed("backupStats.free_bytes") + @discourseComputed("backupStats.free_bytes") freeBackupSpace(bytes) { return I18n.toHumanSize(bytes); }, - @computed("uploadStats.used_bytes") + @discourseComputed("uploadStats.used_bytes") usedUploadSpace(bytes) { return I18n.toHumanSize(bytes); }, - @computed("uploadStats.free_bytes") + @discourseComputed("uploadStats.free_bytes") freeUploadSpace(bytes) { return I18n.toHumanSize(bytes); } diff --git a/app/assets/javascripts/admin/components/admin-report-table-cell.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-cell.js.es6 index f83a33dbfb..e7bf688f2f 100644 --- a/app/assets/javascripts/admin/components/admin-report-table-cell.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-table-cell.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "td", @@ -8,7 +8,7 @@ export default Component.extend({ classNameBindings: ["type", "property"], options: null, - @computed("label", "data", "options") + @discourseComputed("label", "data", "options") computedLabel(label, data, options) { return label.compute(data, options || {}); }, diff --git a/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 index 9317ef1f66..bc5633b21d 100644 --- a/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "th", @@ -7,12 +7,12 @@ export default Component.extend({ classNameBindings: ["label.mainProperty", "label.type", "isCurrentSort"], attributeBindings: ["label.title:title"], - @computed("currentSortLabel.sortProperty", "label.sortProperty") + @discourseComputed("currentSortLabel.sortProperty", "label.sortProperty") isCurrentSort(currentSortField, labelSortField) { return currentSortField === labelSortField; }, - @computed("currentSortDirection") + @discourseComputed("currentSortDirection") sortIcon(currentSortDirection) { return currentSortDirection === 1 ? "caret-up" : "caret-down"; } diff --git a/app/assets/javascripts/admin/components/admin-report-table.js.es6 b/app/assets/javascripts/admin/components/admin-report-table.js.es6 index 38e00c8ab8..aa636224d4 100644 --- a/app/assets/javascripts/admin/components/admin-report-table.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-table.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; const PAGES_LIMIT = 8; @@ -13,12 +13,16 @@ export default Component.extend({ perPage: alias("options.perPage"), page: 0, - @computed("model.computedLabels.length") + @discourseComputed("model.computedLabels.length") twoColumns(labelsLength) { return labelsLength === 2; }, - @computed("totalsForSample", "options.total", "model.dates_filtering") + @discourseComputed( + "totalsForSample", + "options.total", + "model.dates_filtering" + ) showTotalForSample(totalsForSample, total, datesFiltering) { // check if we have at least one cell which contains a value const sum = totalsForSample @@ -29,12 +33,16 @@ export default Component.extend({ return sum >= 1 && total && datesFiltering; }, - @computed("model.total", "options.total", "twoColumns") + @discourseComputed("model.total", "options.total", "twoColumns") showTotal(reportTotal, total, twoColumns) { return reportTotal && total && twoColumns; }, - @computed("model.{average,data}", "totalsForSample.1.value", "twoColumns") + @discourseComputed( + "model.{average,data}", + "totalsForSample.1.value", + "twoColumns" + ) showAverage(model, sampleTotalValue, hasTwoColumns) { return ( model.average && @@ -44,17 +52,17 @@ export default Component.extend({ ); }, - @computed("totalsForSample.1.value", "model.data.length") + @discourseComputed("totalsForSample.1.value", "model.data.length") averageForSample(totals, count) { return (totals / count).toFixed(0); }, - @computed("model.data.length") + @discourseComputed("model.data.length") showSortingUI(dataLength) { return dataLength >= 5; }, - @computed("totalsForSampleRow", "model.computedLabels") + @discourseComputed("totalsForSampleRow", "model.computedLabels") totalsForSample(row, labels) { return labels.map(label => { const computedLabel = label.compute(row); @@ -64,7 +72,7 @@ export default Component.extend({ }); }, - @computed("model.data", "model.computedLabels") + @discourseComputed("model.data", "model.computedLabels") totalsForSampleRow(rows, labels) { if (!rows || !rows.length) return {}; @@ -90,7 +98,7 @@ export default Component.extend({ return totalsRow; }, - @computed("sortLabel", "sortDirection", "model.data.[]") + @discourseComputed("sortLabel", "sortDirection", "model.data.[]") sortedData(sortLabel, sortDirection, data) { data = makeArray(data); @@ -110,7 +118,7 @@ export default Component.extend({ return data; }, - @computed("sortedData.[]", "perPage", "page") + @discourseComputed("sortedData.[]", "perPage", "page") paginatedData(data, perPage, page) { if (perPage < data.length) { const start = perPage * page; @@ -120,7 +128,7 @@ export default Component.extend({ return data; }, - @computed("model.data", "perPage", "page") + @discourseComputed("model.data", "perPage", "page") pages(data, perPage, page) { if (!data || data.length <= perPage) return []; diff --git a/app/assets/javascripts/admin/components/admin-report.js.es6 b/app/assets/javascripts/admin/components/admin-report.js.es6 index db06feecfc..cb70fc07ff 100644 --- a/app/assets/javascripts/admin/components/admin-report.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; import { alias, or, and, reads, equal, notEmpty } from "@ember/object/computed"; import EmberObject from "@ember/object"; @@ -8,7 +9,7 @@ import { exportEntity } from "discourse/lib/export-csv"; import { outputExportResult } from "discourse/lib/export-result"; import { isNumeric } from "discourse/lib/utilities"; import { SCHEMA_VERSION, default as Report } from "admin/models/report"; -import computed from "ember-addons/ember-computed-decorators"; +import ENV from "discourse-common/config/environment"; const TABLE_OPTIONS = { perPage: 8, @@ -89,23 +90,23 @@ export default Component.extend({ hasData: notEmpty("model.data"), - @computed("dataSourceName", "model.type") + @discourseComputed("dataSourceName", "model.type") dasherizedDataSourceName(dataSourceName, type) { return (dataSourceName || type || "undefined").replace(/_/g, "-"); }, - @computed("dataSourceName", "model.type") + @discourseComputed("dataSourceName", "model.type") dataSource(dataSourceName, type) { dataSourceName = dataSourceName || type; return `/admin/reports/${dataSourceName}`; }, - @computed("displayedModes.length") + @discourseComputed("displayedModes.length") showModes(displayedModesLength) { return displayedModesLength > 1; }, - @computed("currentMode", "model.modes", "forcedModes") + @discourseComputed("currentMode", "model.modes", "forcedModes") displayedModes(currentMode, reportModes, forcedModes) { const modes = forcedModes ? forcedModes.split(",") : reportModes; @@ -121,12 +122,12 @@ export default Component.extend({ }); }, - @computed("currentMode") + @discourseComputed("currentMode") modeComponent(currentMode) { return `admin-report-${currentMode}`; }, - @computed("startDate") + @discourseComputed("startDate") normalizedStartDate(startDate) { return startDate && typeof startDate.isValid === "function" ? moment @@ -138,7 +139,7 @@ export default Component.extend({ .format("YYYYMMDD"); }, - @computed("endDate") + @discourseComputed("endDate") normalizedEndDate(endDate) { return endDate && typeof endDate.isValid === "function" ? moment @@ -150,7 +151,7 @@ export default Component.extend({ .format("YYYYMMDD"); }, - @computed( + @discourseComputed( "dataSourceName", "normalizedStartDate", "normalizedEndDate", @@ -162,8 +163,8 @@ export default Component.extend({ let reportKey = "reports:"; reportKey += [ dataSourceName, - Ember.testing ? "start" : startDate.replace(/-/g, ""), - Ember.testing ? "end" : endDate.replace(/-/g, ""), + ENV.environment === "test" ? "start" : startDate.replace(/-/g, ""), + ENV.environment === "test" ? "end" : endDate.replace(/-/g, ""), "[:prev_period]", this.get("reportOptions.table.limit"), customFilters diff --git a/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 b/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 index 1dec9fce37..381ac0ca47 100644 --- a/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 +++ b/app/assets/javascripts/admin/components/admin-theme-editor.js.es6 @@ -1,10 +1,10 @@ import { next } from "@ember/runloop"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; export default Component.extend({ - @computed("theme.targets", "onlyOverridden", "showAdvanced") + @discourseComputed("theme.targets", "onlyOverridden", "showAdvanced") visibleTargets(targets, onlyOverridden, showAdvanced) { return targets.filter(target => { if (target.advanced && !showAdvanced) { @@ -17,7 +17,7 @@ export default Component.extend({ }); }, - @computed("currentTargetName", "onlyOverridden", "theme.fields") + @discourseComputed("currentTargetName", "onlyOverridden", "theme.fields") visibleFields(targetName, onlyOverridden, fields) { fields = fields[targetName]; if (onlyOverridden) { @@ -26,14 +26,14 @@ export default Component.extend({ return fields; }, - @computed("currentTargetName", "fieldName") + @discourseComputed("currentTargetName", "fieldName") activeSectionMode(targetName, fieldName) { if (["settings", "translations"].includes(targetName)) return "yaml"; if (["extra_scss"].includes(targetName)) return "scss"; return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html"; }, - @computed("fieldName", "currentTargetName", "theme") + @discourseComputed("fieldName", "currentTargetName", "theme") activeSection: { get(fieldName, target, model) { return model.getField(target, fieldName); @@ -46,17 +46,21 @@ export default Component.extend({ editorId: fmt("fieldName", "currentTargetName", "%@|%@"), - @computed("maximized") + @discourseComputed("maximized") maximizeIcon(maximized) { return maximized ? "discourse-compress" : "discourse-expand"; }, - @computed("currentTargetName", "theme.targets") + @discourseComputed("currentTargetName", "theme.targets") showAddField(currentTargetName, targets) { return targets.find(t => t.name === currentTargetName).customNames; }, - @computed("currentTargetName", "fieldName", "theme.theme_fields.@each.error") + @discourseComputed( + "currentTargetName", + "fieldName", + "theme.theme_fields.@each.error" + ) error(target, fieldName) { return this.theme.getError(target, fieldName); }, diff --git a/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 b/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 index 23bfac19b4..a392196f8a 100644 --- a/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 +++ b/app/assets/javascripts/admin/components/admin-user-field-item.js.es6 @@ -8,10 +8,10 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import { propertyEqual } from "discourse/lib/computed"; import { i18n } from "discourse/lib/computed"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend(bufferedProperty("userField"), { editing: empty("userField.id"), @@ -22,7 +22,7 @@ export default Component.extend(bufferedProperty("userField"), { userFieldsDescription: i18n("admin.user_fields.description"), - @computed("buffered.field_type") + @discourseComputed("buffered.field_type") bufferedFieldType(fieldType) { return UserField.fieldTypeById(fieldType); }, @@ -39,12 +39,12 @@ export default Component.extend(bufferedProperty("userField"), { $(".user-field-name").select(); }, - @computed("userField.field_type") + @discourseComputed("userField.field_type") fieldName(fieldType) { return UserField.fieldTypeById(fieldType).get("name"); }, - @computed( + @discourseComputed( "userField.editable", "userField.required", "userField.show_on_profile", diff --git a/app/assets/javascripts/admin/components/admin-watched-word.js.es6 b/app/assets/javascripts/admin/components/admin-watched-word.js.es6 index 8a408cf166..d4f5108c0f 100644 --- a/app/assets/javascripts/admin/components/admin-watched-word.js.es6 +++ b/app/assets/javascripts/admin/components/admin-watched-word.js.es6 @@ -1,30 +1,28 @@ import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -import { escapeExpression } from "discourse/lib/utilities"; -export default Component.extend( - bufferedRender({ - classNames: ["watched-word"], +export default Component.extend({ + classNames: ["watched-word"], + watchedWord: null, + xIcon: iconHTML("times").htmlSafe(), - buildBuffer(buffer) { - buffer.push(iconHTML("times")); - buffer.push(` ${escapeExpression(this.get("word.word"))}`); - }, + init() { + this._super(...arguments); + this.set("watchedWord", this.get("word.word")); + }, - click() { - this.word - .destroy() - .then(() => { - this.action(this.word); - }) - .catch(e => { - bootbox.alert( - I18n.t("generic_error_with_reason", { - error: `http: ${e.status} - ${e.body}` - }) - ); - }); - } - }) -); + click() { + this.word + .destroy() + .then(() => { + this.action(this.word); + }) + .catch(e => { + bootbox.alert( + I18n.t("generic_error_with_reason", { + error: `http: ${e.status} - ${e.body}` + }) + ); + }); + } +}); diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 index 1c7f6f05a0..a38695c735 100644 --- a/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 +++ b/app/assets/javascripts/admin/components/admin-web-hook-event-chooser.js.es6 @@ -1,27 +1,27 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["hook-event"], typeName: alias("type.name"), - @computed("typeName") + @discourseComputed("typeName") name(typeName) { return I18n.t(`admin.web_hooks.${typeName}_event.name`); }, - @computed("typeName") + @discourseComputed("typeName") details(typeName) { return I18n.t(`admin.web_hooks.${typeName}_event.details`); }, - @computed("model.[]", "typeName") + @discourseComputed("model.[]", "typeName") eventTypeExists(eventTypes, typeName) { return eventTypes.any(event => event.name === typeName); }, - @computed("eventTypeExists") + @discourseComputed("eventTypeExists") enabled: { get(eventTypeExists) { return eventTypeExists; diff --git a/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 index 693e6502ff..365e22aa67 100644 --- a/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 +++ b/app/assets/javascripts/admin/components/admin-web-hook-event.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { ensureJSON, plainJSON, prettyJSON } from "discourse/lib/formatter"; @@ -10,7 +10,7 @@ export default Component.extend({ expandDetailsRequestKey: "request", expandDetailsResponseKey: "response", - @computed("model.status") + @discourseComputed("model.status") statusColorClasses(status) { if (!status) return ""; @@ -21,25 +21,25 @@ export default Component.extend({ } }, - @computed("model.created_at") + @discourseComputed("model.created_at") createdAt(createdAt) { return moment(createdAt).format("YYYY-MM-DD HH:mm:ss"); }, - @computed("model.duration") + @discourseComputed("model.duration") completion(duration) { const seconds = Math.floor(duration / 10.0) / 100.0; return I18n.t("admin.web_hooks.events.completed_in", { count: seconds }); }, - @computed("expandDetails") + @discourseComputed("expandDetails") expandRequestIcon(expandDetails) { return expandDetails === this.expandDetailsRequestKey ? "ellipsis-h" : "ellipsis-v"; }, - @computed("expandDetails") + @discourseComputed("expandDetails") expandResponseIcon(expandDetails) { return expandDetails === this.expandDetailsResponseKey ? "ellipsis-h" diff --git a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 index 0d8e80cc81..0c24edc9d6 100644 --- a/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 +++ b/app/assets/javascripts/admin/components/admin-web-hook-status.js.es6 @@ -1,33 +1,37 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -export default Component.extend( - bufferedRender({ - classes: ["text-muted", "text-danger", "text-successful", "text-muted"], - icons: ["far-circle", "times-circle", "circle", "circle"], +export default Component.extend({ + classes: ["text-muted", "text-danger", "text-successful", "text-muted"], + icons: ["far-circle", "times-circle", "circle", "circle"], + circleIcon: null, + deliveryStatus: null, - @computed("deliveryStatuses", "model.last_delivery_status") - status(deliveryStatuses, lastDeliveryStatus) { - return deliveryStatuses.find(s => s.id === lastDeliveryStatus); - }, + @discourseComputed("deliveryStatuses", "model.last_delivery_status") + status(deliveryStatuses, lastDeliveryStatus) { + return deliveryStatuses.find(s => s.id === lastDeliveryStatus); + }, - @computed("status.id", "icons") - icon(statusId, icons) { - return icons[statusId - 1]; - }, + @discourseComputed("status.id", "icons") + icon(statusId, icons) { + return icons[statusId - 1]; + }, - @computed("status.id", "classes") - class(statusId, classes) { - return classes[statusId - 1]; - }, + @discourseComputed("status.id", "classes") + class(statusId, classes) { + return classes[statusId - 1]; + }, - buildBuffer(buffer) { - buffer.push(iconHTML(this.icon, { class: this.class })); - buffer.push( - I18n.t(`admin.web_hooks.delivery_status.${this.get("status.name")}`) - ); - } - }) -); + didReceiveAttrs() { + this._super(...arguments); + this.set( + "circleIcon", + iconHTML(this.icon, { class: this.class }).htmlSafe() + ); + this.set( + "deliveryStatus", + I18n.t(`admin.web_hooks.delivery_status.${this.get("status.name")}`) + ); + } +}); diff --git a/app/assets/javascripts/admin/components/email-styles-editor.js.es6 b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 index e465c04cba..ef5cdb077e 100644 --- a/app/assets/javascripts/admin/components/email-styles-editor.js.es6 +++ b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 @@ -1,16 +1,16 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { reads } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ editorId: reads("fieldName"), - @computed("fieldName") + @discourseComputed("fieldName") currentEditorMode(fieldName) { return fieldName === "css" ? "scss" : fieldName; }, - @computed("fieldName", "styles.html", "styles.css") + @discourseComputed("fieldName", "styles.html", "styles.css") resetDisabled(fieldName) { return ( this.get(`styles.${fieldName}`) === @@ -18,7 +18,7 @@ export default Component.extend({ ); }, - @computed("styles", "fieldName") + @discourseComputed("styles", "fieldName") editorContents: { get(styles, fieldName) { return styles[fieldName]; diff --git a/app/assets/javascripts/admin/components/embeddable-host.js.es6 b/app/assets/javascripts/admin/components/embeddable-host.js.es6 index 57829b45fb..1d853b8986 100644 --- a/app/assets/javascripts/admin/components/embeddable-host.js.es6 +++ b/app/assets/javascripts/admin/components/embeddable-host.js.es6 @@ -1,11 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { or } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { bufferedProperty } from "discourse/mixins/buffered-content"; -import computed from "ember-addons/ember-computed-decorators"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import Category from "discourse/models/category"; export default Component.extend(bufferedProperty("host"), { editToggled: false, @@ -22,7 +23,7 @@ export default Component.extend(bufferedProperty("host"), { }); }, - @computed("buffered.host", "host.isSaving") + @discourseComputed("buffered.host", "host.isSaving") cantSave(host, isSaving) { return isSaving || isEmpty(host); }, @@ -50,7 +51,7 @@ export default Component.extend(bufferedProperty("host"), { host .save(props) .then(() => { - host.set("category", Discourse.Category.findById(this.categoryId)); + host.set("category", Category.findById(this.categoryId)); this.set("editToggled", false); }) .catch(popupAjaxError); diff --git a/app/assets/javascripts/admin/components/embedding-setting.js.es6 b/app/assets/javascripts/admin/components/embedding-setting.js.es6 index da7f9abb7f..517e37f4f9 100644 --- a/app/assets/javascripts/admin/components/embedding-setting.js.es6 +++ b/app/assets/javascripts/admin/components/embedding-setting.js.es6 @@ -1,25 +1,25 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["embed-setting"], - @computed("field") + @discourseComputed("field") inputId(field) { return field.dasherize(); }, - @computed("field") + @discourseComputed("field") translationKey(field) { return `admin.embedding.${field}`; }, - @computed("type") + @discourseComputed("type") isCheckbox(type) { return type === "checkbox"; }, - @computed("value") + @discourseComputed("value") checked: { get(value) { return !!value; diff --git a/app/assets/javascripts/admin/components/highlighted-code.js.es6 b/app/assets/javascripts/admin/components/highlighted-code.js.es6 index d182d7e2a1..9159bb574a 100644 --- a/app/assets/javascripts/admin/components/highlighted-code.js.es6 +++ b/app/assets/javascripts/admin/components/highlighted-code.js.es6 @@ -1,5 +1,5 @@ import Component from "@ember/component"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import highlightSyntax from "discourse/lib/highlight-syntax"; export default Component.extend({ diff --git a/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 index e88c2bc3b7..ff3cb98ffc 100644 --- a/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 +++ b/app/assets/javascripts/admin/components/inline-edit-checkbox.js.es6 @@ -1,8 +1,8 @@ import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["inline-edit"], @@ -21,12 +21,12 @@ export default Component.extend({ this.set("checkedInternal", this.checked); }, - @computed("labelKey") + @discourseComputed("labelKey") label(key) { return I18n.t(key); }, - @computed("checked", "checkedInternal") + @discourseComputed("checked", "checkedInternal") changed(checked, checkedInternal) { return !!checked !== !!checkedInternal; }, diff --git a/app/assets/javascripts/admin/components/ip-lookup.js.es6 b/app/assets/javascripts/admin/components/ip-lookup.js.es6 index 8438a4ee5c..06421e2e90 100644 --- a/app/assets/javascripts/admin/components/ip-lookup.js.es6 +++ b/app/assets/javascripts/admin/components/ip-lookup.js.es6 @@ -1,7 +1,7 @@ import EmberObject from "@ember/object"; import { later } from "@ember/runloop"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import AdminUser from "admin/models/admin-user"; import copyText from "discourse/lib/copy-text"; @@ -9,7 +9,7 @@ import copyText from "discourse/lib/copy-text"; export default Component.extend({ classNames: ["ip-lookup"], - @computed("other_accounts.length", "totalOthersWithSameIP") + @discourseComputed("other_accounts.length", "totalOthersWithSameIP") otherAccountsToDelete(otherAccountsLength, totalOthersWithSameIP) { // can only delete up to 50 accounts at a time const total = Math.min(50, totalOthersWithSameIP || 0); diff --git a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 index 8af1f83b16..6a703105fb 100644 --- a/app/assets/javascripts/admin/components/penalty-post-action.js.es6 +++ b/app/assets/javascripts/admin/components/penalty-post-action.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { equal } from "@ember/object/computed"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; const ACTIONS = ["delete", "delete_replies", "edit", "none"]; @@ -10,7 +10,7 @@ export default Component.extend({ postAction: null, postEdit: null, - @computed + @discourseComputed penaltyActions() { return ACTIONS.map(id => { return { id, name: I18n.t(`admin.user.penalty_post_${id}`) }; diff --git a/app/assets/javascripts/admin/components/permalink-form.js.es6 b/app/assets/javascripts/admin/components/permalink-form.js.es6 index b1f82ea295..5a90dd9913 100644 --- a/app/assets/javascripts/admin/components/permalink-form.js.es6 +++ b/app/assets/javascripts/admin/components/permalink-form.js.es6 @@ -1,6 +1,6 @@ import { schedule } from "@ember/runloop"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; import Permalink from "admin/models/permalink"; @@ -10,7 +10,7 @@ export default Component.extend({ permalinkType: "topic_id", permalinkTypePlaceholder: fmt("permalinkType", "admin.permalink.%@"), - @computed + @discourseComputed permalinkTypes() { return [ { id: "topic_id", name: I18n.t("admin.permalink.topic_id") }, diff --git a/app/assets/javascripts/admin/components/report-filters/category.js.es6 b/app/assets/javascripts/admin/components/report-filters/category.js.es6 index 7efdd4a465..60fb61be9b 100644 --- a/app/assets/javascripts/admin/components/report-filters/category.js.es6 +++ b/app/assets/javascripts/admin/components/report-filters/category.js.es6 @@ -1,5 +1,5 @@ import Category from "discourse/models/category"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import FilterComponent from "admin/components/report-filters/filter"; export default FilterComponent.extend({ @@ -7,7 +7,7 @@ export default FilterComponent.extend({ layoutName: "admin/templates/components/report-filters/category", - @computed("filter.default") + @discourseComputed("filter.default") category(categoryId) { return Category.findById(categoryId); }, diff --git a/app/assets/javascripts/admin/components/report-filters/group.js.es6 b/app/assets/javascripts/admin/components/report-filters/group.js.es6 index 54523f9446..0821cb084e 100644 --- a/app/assets/javascripts/admin/components/report-filters/group.js.es6 +++ b/app/assets/javascripts/admin/components/report-filters/group.js.es6 @@ -1,19 +1,19 @@ import FilterComponent from "admin/components/report-filters/filter"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default FilterComponent.extend({ classNames: ["group-filter"], layoutName: "admin/templates/components/report-filters/group", - @computed() + @discourseComputed() groupOptions() { return (this.site.groups || []).map(group => { return { name: group["name"], value: group["id"] }; }); }, - @computed("filter.default") + @discourseComputed("filter.default") groupId(filterDefault) { return filterDefault ? parseInt(filterDefault, 10) : null; } diff --git a/app/assets/javascripts/admin/components/resumable-upload.js.es6 b/app/assets/javascripts/admin/components/resumable-upload.js.es6 index d63a1a651d..925bdf0de9 100644 --- a/app/assets/javascripts/admin/components/resumable-upload.js.es6 +++ b/app/assets/javascripts/admin/components/resumable-upload.js.es6 @@ -4,9 +4,9 @@ import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; import { bufferedRender } from "discourse-common/lib/buffered-render"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; /*global Resumable:true */ @@ -91,12 +91,12 @@ export default Component.extend( } }, - @computed("title", "text") + @discourseComputed("title", "text") translatedTitle(title, text) { return title ? I18n.t(title) : text; }, - @computed("isUploading", "progress") + @discourseComputed("isUploading", "progress") text(isUploading, progress) { if (isUploading) { return progress + " %"; diff --git a/app/assets/javascripts/admin/components/save-controls.js.es6 b/app/assets/javascripts/admin/components/save-controls.js.es6 index b039b0158c..9da4e49fe2 100644 --- a/app/assets/javascripts/admin/components/save-controls.js.es6 +++ b/app/assets/javascripts/admin/components/save-controls.js.es6 @@ -1,13 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { or } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["controls"], buttonDisabled: or("model.isSaving", "saveDisabled"), - @computed("model.isSaving") + @discourseComputed("model.isSaving") savingText(saving) { return saving ? "saving" : "save"; } diff --git a/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 index 8b6db57764..48b92641b4 100644 --- a/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 +++ b/app/assets/javascripts/admin/components/screened-ip-address-form.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; /** @@ -12,20 +13,19 @@ import Component from "@ember/component"; **/ import ScreenedIpAddress from "admin/models/screened-ip-address"; -import computed from "ember-addons/ember-computed-decorators"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["screened-ip-address-form"], formSubmitted: false, actionName: "block", - @computed + @discourseComputed adminWhitelistEnabled() { return Discourse.SiteSettings.use_admin_ip_whitelist; }, - @computed("adminWhitelistEnabled") + @discourseComputed("adminWhitelistEnabled") actionNames(adminWhitelistEnabled) { if (adminWhitelistEnabled) { return [ diff --git a/app/assets/javascripts/admin/components/secret-value-list.js.es6 b/app/assets/javascripts/admin/components/secret-value-list.js.es6 index 4327f62f80..ea4ecf792c 100644 --- a/app/assets/javascripts/admin/components/secret-value-list.js.es6 +++ b/app/assets/javascripts/admin/components/secret-value-list.js.es6 @@ -1,6 +1,6 @@ import { isEmpty } from "@ember/utils"; import Component from "@ember/component"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import { set } from "@ember/object"; export default Component.extend({ diff --git a/app/assets/javascripts/admin/components/site-settings/bool.js.es6 b/app/assets/javascripts/admin/components/site-settings/bool.js.es6 index 2b2fdaca8e..88f4387601 100644 --- a/app/assets/javascripts/admin/components/site-settings/bool.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/bool.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ - @computed("value") + @discourseComputed("value") enabled: { get(value) { if (isEmpty(value)) { diff --git a/app/assets/javascripts/admin/components/site-settings/category-list.js.es6 b/app/assets/javascripts/admin/components/site-settings/category-list.js.es6 index d4476ddf13..ff83e13e52 100644 --- a/app/assets/javascripts/admin/components/site-settings/category-list.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/category-list.js.es6 @@ -1,11 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import Category from "discourse/models/category"; export default Component.extend({ - @computed("value") + @discourseComputed("value") selectedCategories: { get(value) { - return Discourse.Category.findByIds(value.split("|")); + return Category.findByIds(value.split("|")); }, set(value) { this.set("value", value.mapBy("id").join("|")); diff --git a/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 b/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 index 21af030269..97736c36ca 100644 --- a/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/group-list.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ - @computed() + @discourseComputed() groupChoices() { return this.site.get("groups").map(g => { return { name: g.name, id: g.id.toString() }; diff --git a/app/assets/javascripts/admin/components/site-settings/tag-list.js.es6 b/app/assets/javascripts/admin/components/site-settings/tag-list.js.es6 index 417ad622cb..c8a8e0a06f 100644 --- a/app/assets/javascripts/admin/components/site-settings/tag-list.js.es6 +++ b/app/assets/javascripts/admin/components/site-settings/tag-list.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ - @computed("value") + @discourseComputed("value") selectedTags: { get(value) { return value.split("|"); diff --git a/app/assets/javascripts/admin/components/site-text-summary.js.es6 b/app/assets/javascripts/admin/components/site-text-summary.js.es6 index 4467a092d1..11c6bc45eb 100644 --- a/app/assets/javascripts/admin/components/site-text-summary.js.es6 +++ b/app/assets/javascripts/admin/components/site-text-summary.js.es6 @@ -1,5 +1,5 @@ import Component from "@ember/component"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["site-text"], diff --git a/app/assets/javascripts/admin/components/theme-setting-relatives-selector.js.es6 b/app/assets/javascripts/admin/components/theme-setting-relatives-selector.js.es6 new file mode 100644 index 0000000000..8ba638076c --- /dev/null +++ b/app/assets/javascripts/admin/components/theme-setting-relatives-selector.js.es6 @@ -0,0 +1,26 @@ +import Component from "@ember/component"; +import BufferedContent from "discourse/mixins/buffered-content"; +import SettingComponent from "admin/mixins/setting-component"; + +export default Component.extend(BufferedContent, SettingComponent, { + layoutName: "admin/templates/components/site-setting", + + _save() { + return this.model + .save({ [this.setting.setting]: this.convertNamesToIds() }) + .then(() => this.store.findAll("theme")); + }, + + convertNamesToIds() { + return this.get("buffered.value") + .split("|") + .filter(Boolean) + .map(themeName => { + if (themeName !== "") { + return this.setting.allThemes.find(theme => theme.name === themeName) + .id; + } + return themeName; + }); + } +}); diff --git a/app/assets/javascripts/admin/components/themes-list-item.js.es6 b/app/assets/javascripts/admin/components/themes-list-item.js.es6 index af30e5a398..cfc1b63a36 100644 --- a/app/assets/javascripts/admin/components/themes-list-item.js.es6 +++ b/app/assets/javascripts/admin/components/themes-list-item.js.es6 @@ -2,11 +2,12 @@ import { gt, and } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; import { escape } from "pretty-text/sanitizer"; +import ENV from "discourse-common/config/environment"; const MAX_COMPONENTS = 4; @@ -43,7 +44,7 @@ export default Component.extend({ animate(isInitial) { const $container = $(this.element); const $list = $(this.element.querySelector(".components-list")); - if ($list.length === 0 || Ember.testing) { + if ($list.length === 0 || ENV.environment === "test") { return; } const duration = 300; @@ -54,7 +55,7 @@ export default Component.extend({ } }, - @computed( + @discourseComputed( "theme.component", "theme.childThemes.@each.name", "theme.childThemes.length", @@ -75,12 +76,12 @@ export default Component.extend({ }); }, - @computed("children") + @discourseComputed("children") childrenString(children) { return children.join(", "); }, - @computed( + @discourseComputed( "theme.childThemes.length", "theme.component", "childrenExpanded", diff --git a/app/assets/javascripts/admin/components/themes-list.js.es6 b/app/assets/javascripts/admin/components/themes-list.js.es6 index 357e4f756c..ae883a115d 100644 --- a/app/assets/javascripts/admin/components/themes-list.js.es6 +++ b/app/assets/javascripts/admin/components/themes-list.js.es6 @@ -1,7 +1,8 @@ import { gt, equal } from "@ember/object/computed"; import Component from "@ember/component"; import { THEMES, COMPONENTS } from "admin/models/theme"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; +import { getOwner } from "@ember/application"; export default Component.extend({ THEMES: THEMES, @@ -16,7 +17,7 @@ export default Component.extend({ themesTabActive: equal("currentTab", THEMES), componentsTabActive: equal("currentTab", COMPONENTS), - @computed("themes", "components", "currentTab") + @discourseComputed("themes", "components", "currentTab") themesList(themes, components) { if (this.themesTabActive) { return themes; @@ -25,7 +26,7 @@ export default Component.extend({ } }, - @computed( + @discourseComputed( "themesList", "currentTab", "themesList.@each.user_selectable", @@ -40,7 +41,7 @@ export default Component.extend({ ); }, - @computed( + @discourseComputed( "themesList", "currentTab", "themesList.@each.user_selectable", @@ -70,7 +71,7 @@ export default Component.extend({ } }, navigateToTheme(theme) { - Ember.getOwner(this) + getOwner(this) .lookup("router:main") .transitionTo("adminCustomizeThemes.show", theme); } diff --git a/app/assets/javascripts/admin/components/value-list.js.es6 b/app/assets/javascripts/admin/components/value-list.js.es6 index 31ef93932e..6582f20e7f 100644 --- a/app/assets/javascripts/admin/components/value-list.js.es6 +++ b/app/assets/javascripts/admin/components/value-list.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; import { empty, alias } from "@ember/object/computed"; import Component from "@ember/component"; -import { on } from "ember-addons/ember-computed-decorators"; -import computed from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default Component.extend({ classNameBindings: [":value-list"], @@ -30,7 +30,7 @@ export default Component.extend({ ); }, - @computed("choices.[]", "collection.[]") + @discourseComputed("choices.[]", "collection.[]") filteredChoices(choices, collection) { return makeArray(choices).filter(i => collection.indexOf(i) < 0); }, diff --git a/app/assets/javascripts/admin/components/watched-word-form.js.es6 b/app/assets/javascripts/admin/components/watched-word-form.js.es6 index 629c5a2b6a..2359320ad6 100644 --- a/app/assets/javascripts/admin/components/watched-word-form.js.es6 +++ b/app/assets/javascripts/admin/components/watched-word-form.js.es6 @@ -3,10 +3,10 @@ import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import WatchedWord from "admin/models/watched-word"; import { - default as computed, + default as discourseComputed, on, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["watched-word-form"], @@ -14,7 +14,7 @@ export default Component.extend({ actionKey: null, showMessage: false, - @computed("regularExpressions") + @discourseComputed("regularExpressions") placeholderKey(regularExpressions) { return ( "admin.watched_words.form.placeholder" + @@ -29,7 +29,7 @@ export default Component.extend({ } }, - @computed("word") + @discourseComputed("word") isUniqueWord(word) { const words = this.filteredContent || []; const filtered = words.filter(content => content.action === this.actionKey); diff --git a/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 index 417f3d5bbf..05dc41c207 100644 --- a/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 +++ b/app/assets/javascripts/admin/components/watched-word-uploader.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; export default Component.extend(UploadMixin, { @@ -13,7 +13,7 @@ export default Component.extend(UploadMixin, { return { skipValidation: true }; }, - @computed("actionKey") + @discourseComputed("actionKey") data(actionKey) { return { action_key: actionKey }; }, diff --git a/app/assets/javascripts/admin/controllers/admin-api-keys-new.js.es6 b/app/assets/javascripts/admin/controllers/admin-api-keys-new.js.es6 index f4d56c0a05..c04e6abec9 100644 --- a/app/assets/javascripts/admin/controllers/admin-api-keys-new.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-api-keys-new.js.es6 @@ -1,4 +1,4 @@ -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Ember.Controller.extend({ @@ -7,12 +7,12 @@ export default Ember.Controller.extend({ { id: "single", name: I18n.t("admin.api.single_user") } ], - @computed("userMode") + @discourseComputed("userMode") showUserSelector(mode) { return mode === "single"; }, - @computed("model.description", "model.username", "userMode") + @discourseComputed("model.description", "model.username", "userMode") saveDisabled(description, username, userMode) { if (Ember.isBlank(description)) return true; if (userMode === "single" && Ember.isBlank(username)) return true; diff --git a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 index 6b69eaea72..e4e7c24d43 100644 --- a/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-backups-index.js.es6 @@ -2,7 +2,7 @@ import { alias, equal } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { setting, i18n } from "discourse/lib/computed"; export default Controller.extend({ @@ -12,7 +12,7 @@ export default Controller.extend({ backupLocation: setting("backup_location"), localBackupStorage: equal("backupLocation", "local"), - @computed("status.allowRestore", "status.isOperationRunning") + @discourseComputed("status.allowRestore", "status.isOperationRunning") restoreTitle(allowRestore, isOperationRunning) { if (!allowRestore) { return "admin.backups.operations.restore.is_disabled"; diff --git a/app/assets/javascripts/admin/controllers/admin-badges-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-badges-show.js.es6 index 733f4589fe..2511dcead1 100644 --- a/app/assets/javascripts/admin/controllers/admin-badges-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-badges-show.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import { propertyNotEqual } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(bufferedProperty("model"), { adminBadges: inject(), @@ -19,7 +19,7 @@ export default Controller.extend(bufferedProperty("model"), { readOnly: alias("buffered.system"), showDisplayName: propertyNotEqual("name", "displayName"), - @computed("model.query", "buffered.query") + @discourseComputed("model.query", "buffered.query") hasQuery(modelQuery, bufferedQuery) { if (bufferedQuery) { return bufferedQuery.trim().length > 0; diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 index ce99c2cfea..9cdca3f098 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors-show.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { later } from "@ember/runloop"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ - @computed("model.colors", "onlyOverridden") + @discourseComputed("model.colors", "onlyOverridden") colors(allColors, onlyOverridden) { if (onlyOverridden) { return allColors.filter(color => color.get("overridden")); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 index 21c628be24..4fc2cf34f0 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-colors.js.es6 @@ -1,20 +1,20 @@ import EmberObject from "@ember/object"; import Controller from "@ember/controller"; import showModal from "discourse/lib/show-modal"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Controller.extend({ - @computed("model.@each.id") + @discourseComputed("model.@each.id") baseColorScheme() { return this.model.findBy("is_base", true); }, - @computed("model.@each.id") + @discourseComputed("model.@each.id") baseColorSchemes() { return this.model.filterBy("is_base", true); }, - @computed("baseColorScheme") + @discourseComputed("baseColorScheme") baseColors(baseColorScheme) { const baseColorsHash = EmberObject.create({}); baseColorScheme.get("colors").forEach(color => { diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 index f48f46eff0..d534792b00 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 @@ -1,13 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ - @computed("model.isSaving") + @discourseComputed("model.isSaving") saveButtonText(isSaving) { return isSaving ? I18n.t("saving") : I18n.t("admin.customize.save"); }, - @computed("model.changed", "model.isSaving") + @discourseComputed("model.changed", "model.isSaving") saveDisabled(changed, isSaving) { return !changed || isSaving; }, diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 index 5ec405594a..3617edca75 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-templates-edit.js.es6 @@ -1,19 +1,19 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { bufferedProperty } from "discourse/mixins/buffered-content"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(bufferedProperty("emailTemplate"), { saved: false, - @computed("buffered.body", "buffered.subject") + @discourseComputed("buffered.body", "buffered.subject") saveDisabled(body, subject) { return ( this.emailTemplate.body === body && this.emailTemplate.subject === subject ); }, - @computed("buffered") + @discourseComputed("buffered") hasMultipleSubjects(buffered) { if (buffered.getProperties("subject")["subject"]) { return false; diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 index a5a286bf80..75d7a486f2 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-edit.js.es6 @@ -1,6 +1,6 @@ import Controller from "@ember/controller"; import { url } from "discourse/lib/computed"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Controller.extend({ section: null, @@ -16,7 +16,7 @@ export default Controller.extend({ this.set("currentTarget", target && target.id); }, - @computed("currentTarget") + @discourseComputed("currentTarget") currentTargetName(id) { const target = this.get("model.targets").find( t => t.id === parseInt(id, 10) @@ -24,12 +24,12 @@ export default Controller.extend({ return target && target.name; }, - @computed("model.isSaving") + @discourseComputed("model.isSaving") saveButtonText(isSaving) { return isSaving ? I18n.t("saving") : I18n.t("admin.customize.save"); }, - @computed("model.changed", "model.isSaving") + @discourseComputed("model.changed", "model.isSaving") saveDisabled(changed, isSaving) { return !changed || isSaving; }, diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 index d4622e21d8..b9712761e5 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes-show.js.es6 @@ -1,7 +1,13 @@ import { makeArray } from "discourse-common/lib/helpers"; -import { empty, notEmpty, match } from "@ember/object/computed"; +import { + empty, + filterBy, + match, + mapBy, + notEmpty +} from "@ember/object/computed"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { url } from "discourse/lib/computed"; import { popupAjaxError } from "discourse/lib/ajax-error"; import showModal from "discourse/lib/show-modal"; @@ -15,8 +21,17 @@ export default Controller.extend({ previewUrl: url("model.id", "/admin/themes/%@/preview"), addButtonDisabled: empty("selectedChildThemeId"), editRouteName: "adminCustomizeThemes.edit", + parentThemesNames: mapBy("model.parentThemes", "name"), + availableParentThemes: filterBy("allThemes", "component", false), + availableActiveParentThemes: filterBy("availableParentThemes", "isActive"), + availableThemesNames: mapBy("availableParentThemes", "name"), + availableActiveThemesNames: mapBy("availableActiveParentThemes", "name"), + availableActiveChildThemes: filterBy("availableChildThemes", "hasParents"), + availableComponentsNames: mapBy("availableChildThemes", "name"), + availableActiveComponentsNames: mapBy("availableActiveChildThemes", "name"), + childThemesNames: mapBy("model.childThemes", "name"), - @computed("model.editedFields") + @discourseComputed("model.editedFields") editedFieldsFormatted() { const descriptions = []; ["common", "desktop", "mobile"].forEach(target => { @@ -34,13 +49,13 @@ export default Controller.extend({ return descriptions; }, - @computed("colorSchemeId", "model.color_scheme_id") + @discourseComputed("colorSchemeId", "model.color_scheme_id") colorSchemeChanged(colorSchemeId, existingId) { - colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId); + colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId, 10); return colorSchemeId !== existingId; }, - @computed("availableChildThemes", "model.childThemes.[]", "model") + @discourseComputed("availableChildThemes", "model.childThemes.[]", "model") selectableChildThemes(available, childThemes) { if (available) { const themes = !childThemes @@ -50,7 +65,43 @@ export default Controller.extend({ } }, - @computed("allThemes", "model.component", "model") + @discourseComputed("model.parentThemes.[]") + relativesSelectorSettingsForComponent() { + return Ember.Object.create({ + list_type: "compact", + type: "list", + preview: null, + anyValue: false, + setting: "parent_theme_ids", + label: I18n.t("admin.customize.theme.component_on_themes"), + choices: this.availableThemesNames, + default: this.parentThemesNames.join("|"), + value: this.parentThemesNames.join("|"), + defaultValues: this.availableActiveThemesNames.join("|"), + allThemes: this.allThemes, + setDefaultValuesLabel: I18n.t("admin.customize.theme.add_all_themes") + }); + }, + + @discourseComputed("model.parentThemes.[]") + relativesSelectorSettingsForTheme() { + return Ember.Object.create({ + list_type: "compact", + type: "list", + preview: null, + anyValue: false, + setting: "child_theme_ids", + label: I18n.t("admin.customize.theme.included_components"), + choices: this.availableComponentsNames, + default: this.childThemesNames.join("|"), + value: this.childThemesNames.join("|"), + defaultValues: this.availableActiveComponentsNames.join("|"), + allThemes: this.allThemes, + setDefaultValuesLabel: I18n.t("admin.customize.theme.add_all") + }); + }, + + @discourseComputed("allThemes", "model.component", "model") availableChildThemes(allThemes) { if (!this.get("model.component")) { const themeId = this.get("model.id"); @@ -60,38 +111,38 @@ export default Controller.extend({ } }, - @computed("model.component") + @discourseComputed("model.component") convertKey(component) { const type = component ? "component" : "theme"; return `admin.customize.theme.convert_${type}`; }, - @computed("model.component") + @discourseComputed("model.component") convertIcon(component) { return component ? "cube" : ""; }, - @computed("model.component") + @discourseComputed("model.component") convertTooltip(component) { const type = component ? "component" : "theme"; return `admin.customize.theme.convert_${type}_tooltip`; }, - @computed("model.settings") + @discourseComputed("model.settings") settings(settings) { return settings.map(setting => ThemeSettings.create(setting)); }, hasSettings: notEmpty("settings"), - @computed("model.translations") + @discourseComputed("model.translations") translations(translations) { return translations.map(setting => ThemeSettings.create(setting)); }, hasTranslations: notEmpty("translations"), - @computed("model.remoteError", "updatingRemote") + @discourseComputed("model.remoteError", "updatingRemote") showRemoteError(errorMessage, updating) { return errorMessage && !updating; }, @@ -189,7 +240,7 @@ export default Controller.extend({ let schemeId = this.colorSchemeId; this.set( "model.color_scheme_id", - schemeId === null ? null : parseInt(schemeId) + schemeId === null ? null : parseInt(schemeId, 10) ); this.model.saveChanges("color_scheme_id"); }, @@ -239,9 +290,9 @@ export default Controller.extend({ }, addChildTheme() { - let themeId = parseInt(this.selectedChildThemeId); + let themeId = parseInt(this.selectedChildThemeId, 10); let theme = this.allThemes.findBy("id", themeId); - this.model.addChildTheme(theme); + this.model.addChildTheme(theme).then(() => this.store.findAll("theme")); }, removeUpload(upload) { @@ -258,7 +309,9 @@ export default Controller.extend({ }, removeChildTheme(theme) { - this.model.removeChildTheme(theme); + this.model + .removeChildTheme(theme) + .then(() => this.store.findAll("theme")); }, destroy() { diff --git a/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 index 6727df97f3..f1d1f0d715 100644 --- a/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-customize-themes.js.es6 @@ -1,21 +1,21 @@ import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { THEMES } from "admin/models/theme"; export default Controller.extend({ currentTab: THEMES, - @computed("model", "model.@each.component") + @discourseComputed("model", "model.@each.component") fullThemes(themes) { return themes.filter(t => !t.get("component")); }, - @computed("model", "model.@each.component") + @discourseComputed("model", "model.@each.component") childThemes(themes) { return themes.filter(t => t.get("component")); }, - @computed("model", "model.@each.component") + @discourseComputed("model", "model.@each.component") installedThemes(themes) { return themes.map(t => t.name); } diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 index 24884b0ba6..b77e3e0288 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-general.js.es6 @@ -1,14 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { setting } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; import AdminDashboard from "admin/models/admin-dashboard"; import Report from "admin/models/report"; import PeriodComputationMixin from "admin/mixins/period-computation"; +import { computed } from "@ember/object"; function staticReport(reportType) { - return Ember.computed("reports.[]", function() { + return computed("reports.[]", function() { return makeArray(this.reports).find(report => report.type === reportType); }); } @@ -20,12 +21,12 @@ export default Controller.extend(PeriodComputationMixin, { logSearchQueriesEnabled: setting("log_search_queries"), basePath: Discourse.BaseUri, - @computed("siteSettings.dashboard_general_tab_activity_metrics") + @discourseComputed("siteSettings.dashboard_general_tab_activity_metrics") activityMetrics(metrics) { return (metrics || "").split("|").filter(m => m); }, - @computed + @discourseComputed activityMetricsFilters() { return { startDate: this.lastMonth, @@ -33,14 +34,14 @@ export default Controller.extend(PeriodComputationMixin, { }; }, - @computed + @discourseComputed topReferredTopicsOptions() { return { table: { total: false, limit: 8 } }; }, - @computed + @discourseComputed topReferredTopicsFilters() { return { startDate: moment() @@ -50,7 +51,7 @@ export default Controller.extend(PeriodComputationMixin, { }; }, - @computed + @discourseComputed trendingSearchFilters() { return { startDate: moment() @@ -60,14 +61,14 @@ export default Controller.extend(PeriodComputationMixin, { }; }, - @computed + @discourseComputed trendingSearchOptions() { return { table: { total: false, limit: 8 } }; }, - @computed + @discourseComputed trendingSearchDisabledLabel() { return I18n.t("admin.dashboard.reports.trending_search.disabled", { basePath: Discourse.BaseUri @@ -107,7 +108,7 @@ export default Controller.extend(PeriodComputationMixin, { } }, - @computed("startDate", "endDate") + @discourseComputed("startDate", "endDate") filters(startDate, endDate) { return { startDate, endDate }; }, diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-moderation.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-moderation.js.es6 index e5d8dae25a..8925825fba 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-moderation.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-moderation.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import PeriodComputationMixin from "admin/mixins/period-computation"; export default Controller.extend(PeriodComputationMixin, { - @computed + @discourseComputed flagsStatusOptions() { return { table: { @@ -13,7 +13,7 @@ export default Controller.extend(PeriodComputationMixin, { }; }, - @computed + @discourseComputed userFlaggingRatioOptions() { return { table: { @@ -23,12 +23,12 @@ export default Controller.extend(PeriodComputationMixin, { }; }, - @computed("startDate", "endDate") + @discourseComputed("startDate", "endDate") filters(startDate, endDate) { return { startDate, endDate }; }, - @computed("lastWeek", "endDate") + @discourseComputed("lastWeek", "endDate") lastWeekfilters(startDate, endDate) { return { startDate, endDate }; }, diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-reports.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-reports.js.es6 index b582f733aa..9a57b9cf32 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-reports.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-reports.js.es6 @@ -1,12 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { debounce } from "@ember/runloop"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; + const { get } = Ember; export default Controller.extend({ filter: null, - @computed("model.[]", "filter") + @discourseComputed("model.[]", "filter") filterReports(reports, filter) { if (filter) { filter = filter.toLowerCase(); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 index 0f214d6f2d..bd8561abc1 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { setting } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; import AdminDashboard from "admin/models/admin-dashboard"; import VersionCheck from "admin/models/version-check"; @@ -13,7 +13,7 @@ export default Controller.extend({ exceptionController: inject("exception"), showVersionChecks: setting("version_checks"), - @computed("problems.length") + @discourseComputed("problems.length") foundProblems(problemsLength) { return this.currentUser.get("admin") && (problemsLength || 0) > 0; }, @@ -77,7 +77,7 @@ export default Controller.extend({ .finally(() => this.set("loadingProblems", false)); }, - @computed("problemsFetchedAt") + @discourseComputed("problemsFetchedAt") problemsTimestamp(problemsFetchedAt) { return moment(problemsFetchedAt) .locale("en") diff --git a/app/assets/javascripts/admin/controllers/admin-email-bounced.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-bounced.js.es6 index 535fa4bca1..17cb8ed04f 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-bounced.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-bounced.js.es6 @@ -1,8 +1,8 @@ import AdminEmailLogsController from "admin/controllers/admin-email-logs"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default AdminEmailLogsController.extend({ - filterEmailLogs: debounce(function() { + filterEmailLogs: discourseDebounce(function() { this.loadLogs(); }, 250).observes("filter.{status,user,address,type}") }); diff --git a/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 index 7659e61edd..7dc733e00c 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-received.js.es6 @@ -1,9 +1,9 @@ import AdminEmailLogsController from "admin/controllers/admin-email-logs"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import IncomingEmail from "admin/models/incoming-email"; export default AdminEmailLogsController.extend({ - filterIncomingEmails: debounce(function() { + filterIncomingEmails: discourseDebounce(function() { this.loadLogs(IncomingEmail); }, 250).observes("filter.{status,from,to,subject}"), diff --git a/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 index 602bb052ce..d70efb23ce 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-rejected.js.es6 @@ -1,9 +1,9 @@ import AdminEmailLogsController from "admin/controllers/admin-email-logs"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import IncomingEmail from "admin/models/incoming-email"; export default AdminEmailLogsController.extend({ - filterIncomingEmails: debounce(function() { + filterIncomingEmails: discourseDebounce(function() { this.loadLogs(IncomingEmail); }, 250).observes("filter.{status,from,to,subject,error}"), diff --git a/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 index 83f52d3510..c7ddaa0043 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-sent.js.es6 @@ -1,8 +1,8 @@ import AdminEmailLogsController from "admin/controllers/admin-email-logs"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default AdminEmailLogsController.extend({ - filterEmailLogs: debounce(function() { + filterEmailLogs: discourseDebounce(function() { this.loadLogs(); }, 250).observes("filter.{status,user,address,type,reply_key}") }); diff --git a/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 b/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 index 535fa4bca1..17cb8ed04f 100644 --- a/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-email-skipped.js.es6 @@ -1,8 +1,8 @@ import AdminEmailLogsController from "admin/controllers/admin-email-logs"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default AdminEmailLogsController.extend({ - filterEmailLogs: debounce(function() { + filterEmailLogs: discourseDebounce(function() { this.loadLogs(); }, 250).observes("filter.{status,user,address,type}") }); diff --git a/app/assets/javascripts/admin/controllers/admin-embedding.js.es6 b/app/assets/javascripts/admin/controllers/admin-embedding.js.es6 index 7e185e9d12..b71c173e36 100644 --- a/app/assets/javascripts/admin/controllers/admin-embedding.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-embedding.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend({ @@ -7,13 +7,13 @@ export default Controller.extend({ embedding: null, // show settings if we have at least one created host - @computed("embedding.embeddable_hosts.@each.isCreated") + @discourseComputed("embedding.embeddable_hosts.@each.isCreated") showSecondary() { const hosts = this.get("embedding.embeddable_hosts"); return hosts.length && hosts.findBy("isCreated"); }, - @computed("embedding.base_url") + @discourseComputed("embedding.base_url") embeddingCode(baseUrl) { const html = `
diff --git a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 index 2e404eb29d..e5da638f9a 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-screened-ip-addresses.js.es6 @@ -1,5 +1,5 @@ import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { outputExportResult } from "discourse/lib/export-result"; import { exportEntity } from "discourse/lib/export-csv"; import ScreenedIpAddress from "admin/models/screened-ip-address"; @@ -9,7 +9,7 @@ export default Controller.extend({ filter: null, savedIpAddress: null, - show: debounce(function() { + show: discourseDebounce(function() { this.set("loading", true); ScreenedIpAddress.findAll(this.filter).then(result => { this.setProperties({ model: result, loading: false }); diff --git a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 index a375379fc0..e559bc846f 100644 --- a/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-logs-staff-action-logs.js.es6 @@ -5,9 +5,9 @@ import Controller from "@ember/controller"; import { exportEntity } from "discourse/lib/export-csv"; import { outputExportResult } from "discourse/lib/export-result"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ model: null, @@ -15,7 +15,7 @@ export default Controller.extend({ filtersExists: gt("filterCount", 0), userHistoryActions: null, - @computed("filters.action_name") + @discourseComputed("filters.action_name") actionFilter(name) { return name ? I18n.t("admin.logs.staff_actions.actions." + name) : null; }, diff --git a/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6 b/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6 index d024c83051..29c076c30c 100644 --- a/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6 @@ -1,12 +1,12 @@ import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import Permalink from "admin/models/permalink"; export default Controller.extend({ loading: false, filter: null, - show: debounce(function() { + show: discourseDebounce(function() { Permalink.findAll(this.filter).then(result => { this.set("model", result); this.set("loading", false); diff --git a/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 b/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 index c0322317e5..f9b34e70a4 100644 --- a/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-plugins.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ - @computed + @discourseComputed adminRoutes: function() { return this.model .map(p => { diff --git a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 index 359be15f1d..6d302204ce 100644 --- a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ queryParams: ["start_date", "end_date", "filters"], @@ -7,7 +7,7 @@ export default Controller.extend({ end_date: null, filters: null, - @computed("model.type") + @discourseComputed("model.type") reportOptions(type) { let options = { table: { perPage: 50, limit: 50, formatNumbers: false } }; diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 index 3fd10f15d1..bfd727e6ea 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings-category.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ categoryNameKey: null, adminSiteSettings: inject(), - @computed("adminSiteSettings.visibleSiteSettings", "categoryNameKey") + @discourseComputed("adminSiteSettings.visibleSiteSettings", "categoryNameKey") category(categories, nameKey) { return (categories || []).findBy("nameKey", nameKey); }, - @computed("category") + @discourseComputed("category") filteredContent(category) { return category ? category.siteSettings : []; } diff --git a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 index fe35885617..052f7c567d 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -1,7 +1,7 @@ import { isEmpty } from "@ember/utils"; import { alias } from "@ember/object/computed"; import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default Controller.extend({ filter: null, @@ -76,7 +76,7 @@ export default Controller.extend({ ); }, - filterContent: debounce(function() { + filterContent: discourseDebounce(function() { if (this._skipBounce) { this.set("_skipBounce", false); } else { diff --git a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 index cd815b9ae8..d24a172910 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-text-edit.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { bufferedProperty } from "discourse/mixins/buffered-content"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(bufferedProperty("siteText"), { saved: false, - @computed("buffered.value") + @discourseComputed("buffered.value") saveDisabled(value) { return this.siteText.value === value; }, diff --git a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 index fac3436c6e..3bedafb45c 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-badges.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, sort } from "@ember/object/computed"; import { next } from "@ember/runloop"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import GrantBadgeController from "discourse/mixins/grant-badge-controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(GrantBadgeController, { adminUser: inject(), @@ -19,7 +19,7 @@ export default Controller.extend(GrantBadgeController, { this.badgeSortOrder = ["granted_at:desc"]; }, - @computed("model", "model.[]", "model.expandedBadges.[]") + @discourseComputed("model", "model.[]", "model.expandedBadges.[]") groupedBadges() { const allBadges = this.model; diff --git a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 index de2e37668f..765e8d3686 100644 --- a/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-user-index.js.es6 @@ -6,7 +6,7 @@ import CanCheckEmails from "discourse/mixins/can-check-emails"; import { propertyNotEqual, setting } from "discourse/lib/computed"; import { userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; import { htmlSafe } from "@ember/template"; @@ -30,12 +30,12 @@ export default Controller.extend(CanCheckEmails, { "model.can_disable_second_factor" ), - @computed("model.customGroups") + @discourseComputed("model.customGroups") customGroupIds(customGroups) { return customGroups.mapBy("id"); }, - @computed("customGroupIdsBuffer", "customGroupIds") + @discourseComputed("customGroupIdsBuffer", "customGroupIds") customGroupsDirty(buffer, original) { if (buffer === null) return false; @@ -44,7 +44,7 @@ export default Controller.extend(CanCheckEmails, { : true; }, - @computed("model.automaticGroups") + @discourseComputed("model.automaticGroups") automaticGroups(automaticGroups) { return automaticGroups .map(group => { @@ -54,26 +54,30 @@ export default Controller.extend(CanCheckEmails, { .join(", "); }, - @computed("model.associated_accounts") + @discourseComputed("model.associated_accounts") associatedAccountsLoaded(associatedAccounts) { return typeof associatedAccounts !== "undefined"; }, - @computed("model.associated_accounts") + @discourseComputed("model.associated_accounts") associatedAccounts(associatedAccounts) { return associatedAccounts .map(provider => `${provider.name} (${provider.description})`) .join(", "); }, - @computed("model.user_fields.[]") + @discourseComputed("model.user_fields.[]") userFields(userFields) { return this.site.collectUserFields(userFields); }, preferencesPath: fmt("model.username_lower", userPath("%@/preferences")), - @computed("model.can_delete_all_posts", "model.staff", "model.post_count") + @discourseComputed( + "model.can_delete_all_posts", + "model.staff", + "model.post_count" + ) deleteAllPostsExplanation(canDeleteAllPosts, staff, postCount) { if (canDeleteAllPosts) { return null; @@ -93,7 +97,7 @@ export default Controller.extend(CanCheckEmails, { } }, - @computed("model.canBeDeleted", "model.staff") + @discourseComputed("model.canBeDeleted", "model.staff") deleteExplanation(canBeDeleted, staff) { if (canBeDeleted) { return null; diff --git a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 index 944b0d9485..12d4724862 100644 --- a/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-users-list-show.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { i18n } from "discourse/lib/computed"; import AdminUser from "admin/models/admin-user"; import CanCheckEmails from "discourse/mixins/can-check-emails"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(CanCheckEmails, { model: null, @@ -24,12 +24,12 @@ export default Controller.extend(CanCheckEmails, { this._canLoadMore = true; }, - @computed("query") + @discourseComputed("query") title(query) { return I18n.t("admin.users.titles." + query); }, - _filterUsers: debounce(function() { + _filterUsers: discourseDebounce(function() { this.resetFilters(); }, 250).observes("listFilter"), diff --git a/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 b/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 index 1726446d8c..d6f52c13fd 100644 --- a/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-watched-words-action.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { or } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import WatchedWord from "admin/models/watched-word"; import { ajax } from "discourse/lib/ajax"; import { fmt } from "discourse/lib/computed"; @@ -27,22 +27,22 @@ export default Controller.extend({ ); }, - @computed("actionNameKey", "adminWatchedWords.model") + @discourseComputed("actionNameKey", "adminWatchedWords.model") currentAction(actionName) { return this.findAction(actionName); }, - @computed("currentAction.words.[]", "adminWatchedWords.model") + @discourseComputed("currentAction.words.[]", "adminWatchedWords.model") filteredContent(words) { return words || []; }, - @computed("actionNameKey") + @discourseComputed("actionNameKey") actionDescription(actionNameKey) { return I18n.t("admin.watched_words.action_descriptions." + actionNameKey); }, - @computed("currentAction.count") + @discourseComputed("currentAction.count") wordCount(count) { return count || 0; }, diff --git a/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 b/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 index 397c5b030e..84a7ac7939 100644 --- a/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-watched-words.js.es6 @@ -2,7 +2,7 @@ import { isEmpty } from "@ember/utils"; import { alias } from "@ember/object/computed"; import EmberObject from "@ember/object"; import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default Controller.extend({ filter: null, @@ -43,7 +43,7 @@ export default Controller.extend({ this.set("model", matchesByAction); }, - filterContent: debounce(function() { + filterContent: discourseDebounce(function() { this.filterContentNow(); this.set("filtered", !isEmpty(this.filter)); }, 250).observes("filter"), diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 index e550a79069..6cd94efb68 100644 --- a/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show-events.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ pingDisabled: false, @@ -14,7 +14,7 @@ export default Controller.extend({ this.incomingEventIds = []; }, - @computed("incomingCount") + @discourseComputed("incomingCount") hasIncoming(incomingCount) { return incomingCount > 0; }, diff --git a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 index 83de79e1f0..4ba34034f3 100644 --- a/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-web-hooks-show.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { extractDomainFromUrl } from "discourse/lib/utilities"; -import computed from "ember-addons/ember-computed-decorators"; -import InputValidation from "discourse/models/input-validation"; +import EmberObject from "@ember/object"; export default Controller.extend({ adminWebHooks: inject(), @@ -13,12 +13,12 @@ export default Controller.extend({ defaultEventTypes: alias("adminWebHooks.defaultEventTypes"), contentTypes: alias("adminWebHooks.contentTypes"), - @computed + @discourseComputed showTagsFilter() { return this.siteSettings.tagging_enabled; }, - @computed("model.isSaving", "saved", "saveButtonDisabled") + @discourseComputed("model.isSaving", "saved", "saveButtonDisabled") savingStatus(isSaving, saved, saveButtonDisabled) { if (isSaving) { return I18n.t("saving"); @@ -30,25 +30,25 @@ export default Controller.extend({ return ""; }, - @computed("model.isNew") + @discourseComputed("model.isNew") saveButtonText(isNew) { return isNew ? I18n.t("admin.web_hooks.create") : I18n.t("admin.web_hooks.save"); }, - @computed("model.secret") + @discourseComputed("model.secret") secretValidation(secret) { if (!isEmpty(secret)) { if (secret.indexOf(" ") !== -1) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("admin.web_hooks.secret_invalid") }); } if (secret.length < 12) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("admin.web_hooks.secret_too_short") }); @@ -56,17 +56,17 @@ export default Controller.extend({ } }, - @computed("model.wildcard_web_hook", "model.web_hook_event_types.[]") + @discourseComputed("model.wildcard_web_hook", "model.web_hook_event_types.[]") eventTypeValidation(isWildcard, eventTypes) { if (!isWildcard && isEmpty(eventTypes)) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("admin.web_hooks.event_type_missing") }); } }, - @computed( + @discourseComputed( "model.isSaving", "secretValidation", "eventTypeValidation", diff --git a/app/assets/javascripts/admin/controllers/admin.js.es6 b/app/assets/javascripts/admin/controllers/admin.js.es6 index f01a898b0c..641643d573 100644 --- a/app/assets/javascripts/admin/controllers/admin.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin.js.es6 @@ -1,22 +1,22 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { dasherize } from "@ember/string"; export default Controller.extend({ router: service(), - @computed("siteSettings.enable_group_directory") + @discourseComputed("siteSettings.enable_group_directory") showGroups(enableGroupDirectory) { return !enableGroupDirectory; }, - @computed("siteSettings.enable_badges") + @discourseComputed("siteSettings.enable_badges") showBadges(enableBadges) { return this.currentUser.get("admin") && enableBadges; }, - @computed("router._router.currentPath") + @discourseComputed("router._router.currentPath") adminContentsClassName(currentPath) { let cssClasses = currentPath .split(".") diff --git a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 index d53278c856..bd246d9371 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-add-upload.js.es6 @@ -5,9 +5,9 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { ajax } from "discourse/lib/ajax"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; const THEME_FIELD_VARIABLE_TYPE_IDS = [2, 3, 4]; @@ -69,7 +69,7 @@ export default Controller.extend(ModalFunctionality, { enabled: and("nameValid", "fileSelected"), disabled: not("enabled"), - @computed("name", "adminCustomizeThemesShow.model.theme_fields") + @discourseComputed("name", "adminCustomizeThemesShow.model.theme_fields") errorMessage(name, themeFields) { if (name) { if (!name.match(/^[a-z_][a-z0-9_-]*$/i)) { @@ -94,7 +94,7 @@ export default Controller.extend(ModalFunctionality, { return null; }, - @computed("errorMessage") + @discourseComputed("errorMessage") nameValid(errorMessage) { return null === errorMessage; }, diff --git a/app/assets/javascripts/admin/controllers/modals/admin-badge-preview.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-badge-preview.js.es6 index b6419c8e04..67450978d7 100644 --- a/app/assets/javascripts/admin/controllers/modals/admin-badge-preview.js.es6 +++ b/app/assets/javascripts/admin/controllers/modals/admin-badge-preview.js.es6 @@ -1,6 +1,6 @@ import { alias, map } from "@ember/object/computed"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { escapeExpression } from "discourse/lib/utilities"; export default Controller.extend({ @@ -8,7 +8,7 @@ export default Controller.extend({ errors: alias("model.errors"), count: alias("model.grant_count"), - @computed("count", "sample.length") + @discourseComputed("count", "sample.length") countWarning(count, sampleLength) { if (count <= 10) { return sampleLength !== count; @@ -17,12 +17,12 @@ export default Controller.extend({ } }, - @computed("model.query_plan") + @discourseComputed("model.query_plan") hasQueryPlan(queryPlan) { return !!queryPlan; }, - @computed("model.query_plan") + @discourseComputed("model.query_plan") queryPlanHtml(queryPlan) { let output = `
`;
 
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-edit-badge-groupings.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-edit-badge-groupings.js.es6
index 4d0c66143c..1629aab70b 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-edit-badge-groupings.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-edit-badge-groupings.js.es6
@@ -1,7 +1,7 @@
 import Controller from "@ember/controller";
 import { ajax } from "discourse/lib/ajax";
 import ModalFunctionality from "discourse/mixins/modal-functionality";
-import { observes } from "ember-addons/ember-computed-decorators";
+import { observes } from "discourse-common/utils/decorators";
 
 export default Controller.extend(ModalFunctionality, {
   @observes("model")
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6
index 210d664cda..cca2cc54bf 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-incoming-email.js.es6
@@ -1,12 +1,12 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import Controller from "@ember/controller";
 import ModalFunctionality from "discourse/mixins/modal-functionality";
 import IncomingEmail from "admin/models/incoming-email";
-import computed from "ember-addons/ember-computed-decorators";
 import { longDate } from "discourse/lib/formatter";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 
 export default Controller.extend(ModalFunctionality, {
-  @computed("model.date")
+  @discourseComputed("model.date")
   date(d) {
     return longDate(d);
   },
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-install-theme.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-install-theme.js.es6
index 81055d733d..9aee44f72e 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-install-theme.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-install-theme.js.es6
@@ -5,9 +5,9 @@ import ModalFunctionality from "discourse/mixins/modal-functionality";
 import { ajax } from "discourse/lib/ajax";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import {
-  default as computed,
+  default as discourseComputed,
   observes
-} from "ember-addons/ember-computed-decorators";
+} from "discourse-common/utils/decorators";
 import { THEMES, COMPONENTS } from "admin/models/theme";
 import { POPULAR_THEMES } from "discourse-common/helpers/popular-themes";
 import { set } from "@ember/object";
@@ -43,7 +43,7 @@ export default Controller.extend(ModalFunctionality, {
     ];
   },
 
-  @computed("themesController.installedThemes")
+  @discourseComputed("themesController.installedThemes")
   themes(installedThemes) {
     return POPULAR_THEMES.map(t => {
       if (installedThemes.includes(t.name)) {
@@ -53,7 +53,7 @@ export default Controller.extend(ModalFunctionality, {
     });
   },
 
-  @computed(
+  @discourseComputed(
     "loading",
     "remote",
     "uploadUrl",
@@ -102,12 +102,12 @@ export default Controller.extend(ModalFunctionality, {
     }
   },
 
-  @computed("name")
+  @discourseComputed("name")
   nameTooShort(name) {
     return !name || name.length < MIN_NAME_LENGTH;
   },
 
-  @computed("component")
+  @discourseComputed("component")
   placeholder(component) {
     if (component) {
       return I18n.t("admin.customize.theme.component_name");
@@ -116,14 +116,14 @@ export default Controller.extend(ModalFunctionality, {
     }
   },
 
-  @computed("selection")
+  @discourseComputed("selection")
   submitLabel(selection) {
     return `admin.customize.theme.${
       selection === "create" ? "create" : "install"
     }`;
   },
 
-  @computed("privateChecked", "checkPrivate", "publicKey")
+  @discourseComputed("privateChecked", "checkPrivate", "publicKey")
   showPublicKey(privateChecked, checkPrivate, publicKey) {
     return privateChecked && checkPrivate && publicKey;
   },
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6
index 5c04066941..d15264f46b 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-silence-user.js.es6
@@ -1,6 +1,6 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { isEmpty } from "@ember/utils";
 import Controller from "@ember/controller";
-import computed from "ember-addons/ember-computed-decorators";
 import PenaltyController from "admin/mixins/penalty-controller";
 
 export default Controller.extend(PenaltyController, {
@@ -12,7 +12,7 @@ export default Controller.extend(PenaltyController, {
     this.setProperties({ silenceUntil: null, silencing: false });
   },
 
-  @computed("silenceUntil", "reason", "silencing")
+  @discourseComputed("silenceUntil", "reason", "silencing")
   submitDisabled(silenceUntil, reason, silencing) {
     return silencing || isEmpty(silenceUntil) || !reason || reason.length < 1;
   },
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6
index c5afea9d88..03fa9fbc83 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-suspend-user.js.es6
@@ -1,6 +1,6 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { isEmpty } from "@ember/utils";
 import Controller from "@ember/controller";
-import computed from "ember-addons/ember-computed-decorators";
 import PenaltyController from "admin/mixins/penalty-controller";
 
 export default Controller.extend(PenaltyController, {
@@ -12,7 +12,7 @@ export default Controller.extend(PenaltyController, {
     this.setProperties({ suspendUntil: null, suspending: false });
   },
 
-  @computed("suspendUntil", "reason", "suspending")
+  @discourseComputed("suspendUntil", "reason", "suspending")
   submitDisabled(suspendUntil, reason, suspending) {
     return suspending || isEmpty(suspendUntil) || !reason || reason.length < 1;
   },
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6
index a5ac891c21..08e1e178e4 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-uploaded-image-list.js.es6
@@ -1,5 +1,5 @@
 import Controller from "@ember/controller";
-import { on, observes } from "ember-addons/ember-computed-decorators";
+import { on, observes } from "discourse-common/utils/decorators";
 import ModalFunctionality from "discourse/mixins/modal-functionality";
 
 export default Controller.extend(ModalFunctionality, {
diff --git a/app/assets/javascripts/admin/controllers/modals/admin-watched-word-test.js.es6 b/app/assets/javascripts/admin/controllers/modals/admin-watched-word-test.js.es6
index 10f90ee615..9d9b73072d 100644
--- a/app/assets/javascripts/admin/controllers/modals/admin-watched-word-test.js.es6
+++ b/app/assets/javascripts/admin/controllers/modals/admin-watched-word-test.js.es6
@@ -1,9 +1,9 @@
 import Controller from "@ember/controller";
-import { default as computed } from "ember-addons/ember-computed-decorators";
+import { default as discourseComputed } from "discourse-common/utils/decorators";
 import ModalFunctionality from "discourse/mixins/modal-functionality";
 
 export default Controller.extend(ModalFunctionality, {
-  @computed("value", "model.compiledRegularExpression")
+  @discourseComputed("value", "model.compiledRegularExpression")
   matches(value, regexpString) {
     if (!value || !regexpString) return;
     let censorRegexp = new RegExp(regexpString, "ig");
diff --git a/app/assets/javascripts/admin/mixins/period-computation.js.es6 b/app/assets/javascripts/admin/mixins/period-computation.js.es6
index 354fd0ad85..c7af0e4cb3 100644
--- a/app/assets/javascripts/admin/mixins/period-computation.js.es6
+++ b/app/assets/javascripts/admin/mixins/period-computation.js.es6
@@ -1,5 +1,5 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import DiscourseURL from "discourse/lib/url";
-import computed from "ember-addons/ember-computed-decorators";
 import Mixin from "@ember/object/mixin";
 
 export default Mixin.create({
@@ -12,7 +12,7 @@ export default Mixin.create({
     this.availablePeriods = ["yearly", "quarterly", "monthly", "weekly"];
   },
 
-  @computed("period")
+  @discourseComputed("period")
   startDate(period) {
     let fullDay = moment()
       .locale("en")
@@ -37,7 +37,7 @@ export default Mixin.create({
     }
   },
 
-  @computed()
+  @discourseComputed()
   lastWeek() {
     return moment()
       .locale("en")
@@ -46,7 +46,7 @@ export default Mixin.create({
       .subtract(1, "week");
   },
 
-  @computed()
+  @discourseComputed()
   lastMonth() {
     return moment()
       .locale("en")
@@ -55,7 +55,7 @@ export default Mixin.create({
       .subtract(1, "month");
   },
 
-  @computed()
+  @discourseComputed()
   endDate() {
     return moment()
       .locale("en")
@@ -64,7 +64,7 @@ export default Mixin.create({
       .endOf("day");
   },
 
-  @computed()
+  @discourseComputed()
   today() {
     return moment()
       .locale("en")
diff --git a/app/assets/javascripts/admin/mixins/setting-component.js.es6 b/app/assets/javascripts/admin/mixins/setting-component.js.es6
index 762d5cfa2b..4d7ea03c34 100644
--- a/app/assets/javascripts/admin/mixins/setting-component.js.es6
+++ b/app/assets/javascripts/admin/mixins/setting-component.js.es6
@@ -1,11 +1,11 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { alias, oneWay } from "@ember/object/computed";
-import computed from "ember-addons/ember-computed-decorators";
 import { categoryLinkHTML } from "discourse/helpers/category-link";
 import { on } from "@ember/object/evented";
 import Mixin from "@ember/object/mixin";
 import showModal from "discourse/lib/show-modal";
-import AboutRoute from "discourse/routes/about";
 import { Promise } from "rsvp";
+import { ajax } from "discourse/lib/ajax";
 
 const CUSTOM_TYPES = [
   "bool",
@@ -26,13 +26,21 @@ const CUSTOM_TYPES = [
 
 const AUTO_REFRESH_ON_SAVE = ["logo", "logo_small", "large_icon"];
 
+function splitPipes(str) {
+  if (typeof str === "string") {
+    return str.split("|").filter(Boolean);
+  } else {
+    return [];
+  }
+}
+
 export default Mixin.create({
   classNameBindings: [":row", ":setting", "overridden", "typeClass"],
   content: alias("setting"),
   validationMessage: null,
   isSecret: oneWay("setting.secret"),
 
-  @computed("buffered.value", "setting.value")
+  @discourseComputed("buffered.value", "setting.value")
   dirty(bufferVal, settingVal) {
     if (bufferVal === null || bufferVal === undefined) bufferVal = "";
     if (settingVal === null || settingVal === undefined) settingVal = "";
@@ -40,7 +48,7 @@ export default Mixin.create({
     return bufferVal.toString() !== settingVal.toString();
   },
 
-  @computed("setting", "buffered.value")
+  @discourseComputed("setting", "buffered.value")
   preview(setting, value) {
     // A bit hacky, but allows us to use helpers
     if (setting.get("setting") === "category_style") {
@@ -51,7 +59,6 @@ export default Mixin.create({
         });
       }
     }
-
     let preview = setting.get("preview");
     if (preview) {
       return new Handlebars.SafeString(
@@ -62,22 +69,22 @@ export default Mixin.create({
     }
   },
 
-  @computed("componentType")
+  @discourseComputed("componentType")
   typeClass(componentType) {
     return componentType.replace(/\_/g, "-");
   },
 
-  @computed("setting.setting")
-  settingName(setting) {
-    return setting.replace(/\_/g, " ");
+  @discourseComputed("setting.setting", "setting.label")
+  settingName(setting, label) {
+    return label || setting.replace(/\_/g, " ");
   },
 
-  @computed("type")
+  @discourseComputed("type")
   componentType(type) {
     return CUSTOM_TYPES.indexOf(type) !== -1 ? type : "string";
   },
 
-  @computed("setting")
+  @discourseComputed("setting")
   type(setting) {
     if (setting.type === "list" && setting.list_type) {
       return `${setting.list_type}_list`;
@@ -86,16 +93,36 @@ export default Mixin.create({
     return setting.type;
   },
 
-  @computed("typeClass")
+  @discourseComputed("typeClass")
   componentName(typeClass) {
     return "site-settings/" + typeClass;
   },
 
-  @computed("setting.default", "buffered.value")
+  @discourseComputed("setting.anyValue")
+  allowAny(anyValue) {
+    return anyValue !== false;
+  },
+
+  @discourseComputed("setting.default", "buffered.value")
   overridden(settingDefault, bufferedValue) {
     return settingDefault !== bufferedValue;
   },
 
+  @discourseComputed("buffered.value")
+  bufferedValues: splitPipes,
+
+  @discourseComputed("setting.defaultValues")
+  defaultValues: splitPipes,
+
+  @discourseComputed("defaultValues", "bufferedValues")
+  defaultIsAvailable(defaultValues, bufferedValues) {
+    return (
+      defaultValues &&
+      defaultValues.length > 0 &&
+      !defaultValues.every(value => bufferedValues.includes(value))
+    );
+  },
+
   _watchEnterKey: on("didInsertElement", function() {
     $(this.element).on("keydown.setting-enter", ".input-setting-string", e => {
       if (e.keyCode === 13) {
@@ -125,7 +152,6 @@ export default Mixin.create({
         "default_email_messages_level",
         "default_email_mailing_list_mode",
         "default_email_mailing_list_mode_frequency",
-        "disable_mailing_list_mode",
         "default_email_previous_replies",
         "default_email_in_reply_to",
         "default_other_new_topic_duration_minutes",
@@ -151,12 +177,19 @@ export default Mixin.create({
       const key = this.buffered.get("setting");
 
       if (defaultUserPreferences.includes(key)) {
-        AboutRoute.create()
-          .model()
-          .then(result => {
+        const data = {};
+        data[key] = this.buffered.get("value");
+
+        ajax(`/admin/site_settings/${key}/user_count.json`, {
+          type: "PUT",
+          data
+        }).then(result => {
+          const count = result.user_count;
+
+          if (count > 0) {
             const controller = showModal("site-setting-default-categories", {
               model: {
-                count: result.stats.user_count,
+                count: result.user_count,
                 key: key.replace(/_/g, " ")
               },
               admin: true
@@ -166,7 +199,10 @@ export default Mixin.create({
               this.updateExistingUsers = controller.updateExistingUsers;
               this.send("save");
             });
-          });
+          } else {
+            this.send("save");
+          }
+        });
       } else {
         this.send("save");
       }
@@ -200,6 +236,16 @@ export default Mixin.create({
 
     toggleSecret() {
       this.toggleProperty("isSecret");
+    },
+
+    setDefaultValues() {
+      this.set(
+        "buffered.value",
+        this.bufferedValues
+          .concat(this.defaultValues)
+          .uniq()
+          .join("|")
+      );
     }
   }
 });
diff --git a/app/assets/javascripts/admin/mixins/setting-object.js.es6 b/app/assets/javascripts/admin/mixins/setting-object.js.es6
index c02004cea8..f0296e6c55 100644
--- a/app/assets/javascripts/admin/mixins/setting-object.js.es6
+++ b/app/assets/javascripts/admin/mixins/setting-object.js.es6
@@ -1,8 +1,8 @@
-import computed from "ember-addons/ember-computed-decorators";
+import discourseComputed from "discourse-common/utils/decorators";
 import Mixin from "@ember/object/mixin";
 
 export default Mixin.create({
-  @computed("value", "default")
+  @discourseComputed("value", "default")
   overridden(val, defaultVal) {
     if (val === null) val = "";
     if (defaultVal === null) defaultVal = "";
@@ -10,7 +10,7 @@ export default Mixin.create({
     return val.toString() !== defaultVal.toString();
   },
 
-  @computed("valid_values")
+  @discourseComputed("valid_values")
   validValues(validValues) {
     const vals = [],
       translateNames = this.translate_names;
@@ -25,7 +25,7 @@ export default Mixin.create({
     return vals;
   },
 
-  @computed("valid_values")
+  @discourseComputed("valid_values")
   allowsNone(validValues) {
     if (validValues && validValues.indexOf("") >= 0) {
       return "admin.settings.none";
diff --git a/app/assets/javascripts/admin/models/admin-dashboard.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard.js.es6
index 20cdbe2772..756de2cc09 100644
--- a/app/assets/javascripts/admin/models/admin-dashboard.js.es6
+++ b/app/assets/javascripts/admin/models/admin-dashboard.js.es6
@@ -1,4 +1,5 @@
 import { ajax } from "discourse/lib/ajax";
+import EmberObject from "@ember/object";
 
 const GENERAL_ATTRIBUTES = [
   "updated_at",
@@ -6,7 +7,7 @@ const GENERAL_ATTRIBUTES = [
   "release_notes_link"
 ];
 
-const AdminDashboard = Discourse.Model.extend({});
+const AdminDashboard = EmberObject.extend({});
 
 AdminDashboard.reopenClass({
   fetch() {
diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6
index 880cb44c39..25a822f388 100644
--- a/app/assets/javascripts/admin/models/admin-user.js.es6
+++ b/app/assets/javascripts/admin/models/admin-user.js.es6
@@ -1,23 +1,24 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { filter, or, gt, lt, not } from "@ember/object/computed";
 import { iconHTML } from "discourse-common/lib/icon-library";
 import { ajax } from "discourse/lib/ajax";
-import computed from "ember-addons/ember-computed-decorators";
 import { propertyNotEqual } from "discourse/lib/computed";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import Group from "discourse/models/group";
 import { userPath } from "discourse/lib/url";
 import { Promise } from "rsvp";
+import User from "discourse/models/user";
 
 const wrapAdmin = user => (user ? AdminUser.create(user) : null);
 
-const AdminUser = Discourse.User.extend({
+const AdminUser = User.extend({
   adminUserView: true,
   customGroups: filter("groups", g => !g.automatic && Group.create(g)),
   automaticGroups: filter("groups", g => g.automatic && Group.create(g)),
 
   canViewProfile: or("active", "staged"),
 
-  @computed("bounce_score", "reset_bounce_score_after")
+  @discourseComputed("bounce_score", "reset_bounce_score_after")
   bounceScore(bounce_score, reset_bounce_score_after) {
     if (bounce_score > 0) {
       return `${bounce_score} - ${moment(reset_bounce_score_after).format(
@@ -28,7 +29,7 @@ const AdminUser = Discourse.User.extend({
     }
   },
 
-  @computed("bounce_score")
+  @discourseComputed("bounce_score")
   bounceScoreExplanation(bounce_score) {
     if (bounce_score === 0) {
       return I18n.t("admin.user.bounce_score_explanation.none");
@@ -39,7 +40,7 @@ const AdminUser = Discourse.User.extend({
     }
   },
 
-  @computed
+  @discourseComputed
   bounceLink() {
     return Discourse.getURL("/admin/email/bounced");
   },
@@ -278,7 +279,7 @@ const AdminUser = Discourse.User.extend({
 
   canSuspend: not("staff"),
 
-  @computed("suspended_till", "suspended_at")
+  @discourseComputed("suspended_till", "suspended_at")
   suspendDuration(suspendedTill, suspendedAt) {
     suspendedAt = moment(suspendedAt);
     suspendedTill = moment(suspendedTill);
@@ -513,20 +514,20 @@ const AdminUser = Discourse.User.extend({
     });
   },
 
-  @computed("tl3_requirements")
+  @discourseComputed("tl3_requirements")
   tl3Requirements(requirements) {
     if (requirements) {
       return this.store.createRecord("tl3Requirements", requirements);
     }
   },
 
-  @computed("suspended_by")
+  @discourseComputed("suspended_by")
   suspendedBy: wrapAdmin,
 
-  @computed("silenced_by")
+  @discourseComputed("silenced_by")
   silencedBy: wrapAdmin,
 
-  @computed("approved_by")
+  @discourseComputed("approved_by")
   approvedBy: wrapAdmin,
 
   _formatError(event) {
diff --git a/app/assets/javascripts/admin/models/api-key.js.es6 b/app/assets/javascripts/admin/models/api-key.js.es6
index 95d8e1914c..7a20c288ae 100644
--- a/app/assets/javascripts/admin/models/api-key.js.es6
+++ b/app/assets/javascripts/admin/models/api-key.js.es6
@@ -1,10 +1,11 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import AdminUser from "admin/models/admin-user";
 import RestModel from "discourse/models/rest";
 import { ajax } from "discourse/lib/ajax";
-import computed from "ember-addons/ember-computed-decorators";
+import { computed } from "@ember/object";
 
 const ApiKey = RestModel.extend({
-  user: Ember.computed("_user", {
+  user: computed("_user", {
     get() {
       return this._user;
     },
@@ -18,12 +19,12 @@ const ApiKey = RestModel.extend({
     }
   }),
 
-  @computed("key")
+  @discourseComputed("key")
   shortKey(key) {
     return `${key.substring(0, 4)}...`;
   },
 
-  @computed("description")
+  @discourseComputed("description")
   shortDescription(description) {
     if (!description || description.length < 40) return description;
     return `${description.substring(0, 40)}...`;
@@ -45,7 +46,7 @@ const ApiKey = RestModel.extend({
     return this.getProperties("description", "username");
   },
 
-  @computed()
+  @discourseComputed()
   basePath() {
     return this.store
       .adapterFor("api-key")
diff --git a/app/assets/javascripts/admin/models/backup-status.js.es6 b/app/assets/javascripts/admin/models/backup-status.js.es6
index b7deec1c10..62c360b532 100644
--- a/app/assets/javascripts/admin/models/backup-status.js.es6
+++ b/app/assets/javascripts/admin/models/backup-status.js.es6
@@ -1,10 +1,11 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { not } from "@ember/object/computed";
-import computed from "ember-addons/ember-computed-decorators";
+import EmberObject from "@ember/object";
 
-export default Discourse.Model.extend({
+export default EmberObject.extend({
   restoreDisabled: not("restoreEnabled"),
 
-  @computed("allowRestore", "isOperationRunning")
+  @discourseComputed("allowRestore", "isOperationRunning")
   restoreEnabled(allowRestore, isOperationRunning) {
     return allowRestore && !isOperationRunning;
   }
diff --git a/app/assets/javascripts/admin/models/backup.js.es6 b/app/assets/javascripts/admin/models/backup.js.es6
index 7cd151378a..882173300a 100644
--- a/app/assets/javascripts/admin/models/backup.js.es6
+++ b/app/assets/javascripts/admin/models/backup.js.es6
@@ -1,7 +1,8 @@
 import { ajax } from "discourse/lib/ajax";
 import { extractError } from "discourse/lib/ajax-error";
+import EmberObject from "@ember/object";
 
-const Backup = Discourse.Model.extend({
+const Backup = EmberObject.extend({
   destroy() {
     return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
   },
diff --git a/app/assets/javascripts/admin/models/color-scheme-color.js.es6 b/app/assets/javascripts/admin/models/color-scheme-color.js.es6
index a3b22d23bc..2e4907ef9f 100644
--- a/app/assets/javascripts/admin/models/color-scheme-color.js.es6
+++ b/app/assets/javascripts/admin/models/color-scheme-color.js.es6
@@ -1,11 +1,12 @@
 import {
-  default as computed,
+  default as discourseComputed,
   observes,
   on
-} from "ember-addons/ember-computed-decorators";
+} from "discourse-common/utils/decorators";
 import { propertyNotEqual } from "discourse/lib/computed";
+import EmberObject from "@ember/object";
 
-const ColorSchemeColor = Discourse.Model.extend({
+const ColorSchemeColor = EmberObject.extend({
   @on("init")
   startTrackingChanges() {
     this.set("originals", { hex: this.hex || "FFFFFF" });
@@ -15,7 +16,7 @@ const ColorSchemeColor = Discourse.Model.extend({
   },
 
   // Whether value has changed since it was last saved.
-  @computed("hex")
+  @discourseComputed("hex")
   changed(hex) {
     if (!this.originals) return false;
     if (hex !== this.originals.hex) return true;
@@ -27,7 +28,7 @@ const ColorSchemeColor = Discourse.Model.extend({
   overridden: propertyNotEqual("hex", "default_hex"),
 
   // Whether the saved value is different than Discourse's default color scheme.
-  @computed("default_hex", "hex")
+  @discourseComputed("default_hex", "hex")
   savedIsOverriden(defaultHex) {
     return this.originals.hex !== defaultHex;
   },
@@ -42,7 +43,7 @@ const ColorSchemeColor = Discourse.Model.extend({
     }
   },
 
-  @computed("name")
+  @discourseComputed("name")
   translatedName(name) {
     if (!this.is_advanced) {
       return I18n.t(`admin.customize.colors.${name}.name`);
@@ -51,7 +52,7 @@ const ColorSchemeColor = Discourse.Model.extend({
     }
   },
 
-  @computed("name")
+  @discourseComputed("name")
   description(name) {
     if (!this.is_advanced) {
       return I18n.t(`admin.customize.colors.${name}.description`);
@@ -66,7 +67,7 @@ const ColorSchemeColor = Discourse.Model.extend({
 
     @property brightness
   **/
-  @computed("hex")
+  @discourseComputed("hex")
   brightness(hex) {
     if (hex.length === 6 || hex.length === 3) {
       if (hex.length === 3) {
@@ -79,9 +80,9 @@ const ColorSchemeColor = Discourse.Model.extend({
           hex.substr(2, 1);
       }
       return Math.round(
-        (parseInt("0x" + hex.substr(0, 2)) * 299 +
-          parseInt("0x" + hex.substr(2, 2)) * 587 +
-          parseInt("0x" + hex.substr(4, 2)) * 114) /
+        (parseInt(hex.substr(0, 2), 16) * 299 +
+          parseInt(hex.substr(2, 2), 16) * 587 +
+          parseInt(hex.substr(4, 2), 16) * 114) /
           1000
       );
     }
@@ -94,7 +95,7 @@ const ColorSchemeColor = Discourse.Model.extend({
     }
   },
 
-  @computed("hex")
+  @discourseComputed("hex")
   valid(hex) {
     return hex.match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null;
   }
diff --git a/app/assets/javascripts/admin/models/color-scheme.js.es6 b/app/assets/javascripts/admin/models/color-scheme.js.es6
index 8875bf2e5e..8486002386 100644
--- a/app/assets/javascripts/admin/models/color-scheme.js.es6
+++ b/app/assets/javascripts/admin/models/color-scheme.js.es6
@@ -1,16 +1,17 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { not } from "@ember/object/computed";
 import { ajax } from "discourse/lib/ajax";
 import ColorSchemeColor from "admin/models/color-scheme-color";
-import computed from "ember-addons/ember-computed-decorators";
+import EmberObject from "@ember/object";
 
-const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
+const ColorScheme = EmberObject.extend(Ember.Copyable, {
   init() {
     this._super(...arguments);
 
     this.startTrackingChanges();
   },
 
-  @computed
+  @discourseComputed
   description() {
     return "" + this.name;
   },
@@ -42,7 +43,7 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
     return newScheme;
   },
 
-  @computed("name", "colors.@each.changed", "saving")
+  @discourseComputed("name", "colors.@each.changed", "saving")
   changed(name) {
     if (!this.originals) return false;
     if (this.originals.name !== name) return true;
@@ -51,7 +52,7 @@ const ColorScheme = Discourse.Model.extend(Ember.Copyable, {
     return false;
   },
 
-  @computed("changed")
+  @discourseComputed("changed")
   disableSave(changed) {
     if (this.theme_id) {
       return false;
diff --git a/app/assets/javascripts/admin/models/email-log.js.es6 b/app/assets/javascripts/admin/models/email-log.js.es6
index f1ac52ac8d..c2eaaa26e6 100644
--- a/app/assets/javascripts/admin/models/email-log.js.es6
+++ b/app/assets/javascripts/admin/models/email-log.js.es6
@@ -1,7 +1,8 @@
 import { ajax } from "discourse/lib/ajax";
 import AdminUser from "admin/models/admin-user";
+import EmberObject from "@ember/object";
 
-const EmailLog = Discourse.Model.extend({});
+const EmailLog = EmberObject.extend({});
 
 EmailLog.reopenClass({
   create(attrs) {
diff --git a/app/assets/javascripts/admin/models/email-preview.js.es6 b/app/assets/javascripts/admin/models/email-preview.js.es6
index b8585d9080..42b7ab5878 100644
--- a/app/assets/javascripts/admin/models/email-preview.js.es6
+++ b/app/assets/javascripts/admin/models/email-preview.js.es6
@@ -1,5 +1,7 @@
 import { ajax } from "discourse/lib/ajax";
-const EmailPreview = Discourse.Model.extend({});
+import EmberObject from "@ember/object";
+
+const EmailPreview = EmberObject.extend({});
 
 export function oneWeekAgo() {
   return moment()
diff --git a/app/assets/javascripts/admin/models/email-settings.js.es6 b/app/assets/javascripts/admin/models/email-settings.js.es6
index e1d838463e..1730aae7c9 100644
--- a/app/assets/javascripts/admin/models/email-settings.js.es6
+++ b/app/assets/javascripts/admin/models/email-settings.js.es6
@@ -1,5 +1,7 @@
 import { ajax } from "discourse/lib/ajax";
-const EmailSettings = Discourse.Model.extend({});
+import EmberObject from "@ember/object";
+
+const EmailSettings = EmberObject.extend({});
 
 EmailSettings.reopenClass({
   find: function() {
diff --git a/app/assets/javascripts/admin/models/flag-type.js.es6 b/app/assets/javascripts/admin/models/flag-type.js.es6
index b1bf1ca828..93fb2eacc9 100644
--- a/app/assets/javascripts/admin/models/flag-type.js.es6
+++ b/app/assets/javascripts/admin/models/flag-type.js.es6
@@ -1,8 +1,8 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import RestModel from "discourse/models/rest";
-import computed from "ember-addons/ember-computed-decorators";
 
 export default RestModel.extend({
-  @computed("id")
+  @discourseComputed("id")
   name(id) {
     return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 });
   }
diff --git a/app/assets/javascripts/admin/models/incoming-email.js.es6 b/app/assets/javascripts/admin/models/incoming-email.js.es6
index fd9d68730b..8d46429a1d 100644
--- a/app/assets/javascripts/admin/models/incoming-email.js.es6
+++ b/app/assets/javascripts/admin/models/incoming-email.js.es6
@@ -1,7 +1,8 @@
 import { ajax } from "discourse/lib/ajax";
 import AdminUser from "admin/models/admin-user";
+import EmberObject from "@ember/object";
 
-const IncomingEmail = Discourse.Model.extend({});
+const IncomingEmail = EmberObject.extend({});
 
 IncomingEmail.reopenClass({
   create(attrs) {
diff --git a/app/assets/javascripts/admin/models/permalink.js.es6 b/app/assets/javascripts/admin/models/permalink.js.es6
index 9019bdbc30..b86e931692 100644
--- a/app/assets/javascripts/admin/models/permalink.js.es6
+++ b/app/assets/javascripts/admin/models/permalink.js.es6
@@ -1,5 +1,7 @@
 import { ajax } from "discourse/lib/ajax";
-const Permalink = Discourse.Model.extend({
+import EmberObject from "@ember/object";
+
+const Permalink = EmberObject.extend({
   save: function() {
     return ajax("/admin/permalinks.json", {
       type: "POST",
diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6
index 37c495e06f..91a5f54002 100644
--- a/app/assets/javascripts/admin/models/report.js.es6
+++ b/app/assets/javascripts/admin/models/report.js.es6
@@ -1,3 +1,4 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { makeArray } from "discourse-common/lib/helpers";
 import { isEmpty } from "@ember/utils";
 import EmberObject from "@ember/object";
@@ -9,7 +10,6 @@ import {
   formatUsername,
   toNumber
 } from "discourse/lib/utilities";
-import computed from "ember-addons/ember-computed-decorators";
 import { number, durationTiny } from "discourse/lib/formatter";
 import { renderAvatar } from "discourse/helpers/user-avatar";
 
@@ -17,17 +17,17 @@ import { renderAvatar } from "discourse/helpers/user-avatar";
 // and you want to ensure cache is reset
 export const SCHEMA_VERSION = 4;
 
-const Report = Discourse.Model.extend({
+const Report = EmberObject.extend({
   average: false,
   percent: false,
   higher_is_better: true,
 
-  @computed("modes")
+  @discourseComputed("modes")
   isTable(modes) {
     return modes.some(mode => mode === "table");
   },
 
-  @computed("type", "start_date", "end_date")
+  @discourseComputed("type", "start_date", "end_date")
   reportUrl(type, start_date, end_date) {
     start_date = moment
       .utc(start_date)
@@ -83,32 +83,32 @@ const Report = Discourse.Model.extend({
     }
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   todayCount() {
     return this.valueAt(0);
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   yesterdayCount() {
     return this.valueAt(1);
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   sevenDaysAgoCount() {
     return this.valueAt(7);
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   thirtyDaysAgoCount() {
     return this.valueAt(30);
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   lastSevenDaysCount() {
     return this.averageCount(7, this.valueFor(1, 7));
   },
 
-  @computed("data", "average")
+  @discourseComputed("data", "average")
   lastThirtyDaysCount() {
     return this.averageCount(30, this.valueFor(1, 30));
   },
@@ -117,12 +117,12 @@ const Report = Discourse.Model.extend({
     return this.average ? value / count : value;
   },
 
-  @computed("yesterdayCount", "higher_is_better")
+  @discourseComputed("yesterdayCount", "higher_is_better")
   yesterdayTrend(yesterdayCount, higherIsBetter) {
     return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
   },
 
-  @computed("lastSevenDaysCount", "higher_is_better")
+  @discourseComputed("lastSevenDaysCount", "higher_is_better")
   sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
     return this._computeTrend(
       this.valueFor(8, 14),
@@ -131,50 +131,55 @@ const Report = Discourse.Model.extend({
     );
   },
 
-  @computed("data")
+  @discourseComputed("data")
   currentTotal(data) {
     return data.reduce((cur, pair) => cur + pair.y, 0);
   },
 
-  @computed("data", "currentTotal")
+  @discourseComputed("data", "currentTotal")
   currentAverage(data, total) {
     return makeArray(data).length === 0
       ? 0
       : parseFloat((total / parseFloat(data.length)).toFixed(1));
   },
 
-  @computed("trend", "higher_is_better")
+  @discourseComputed("trend", "higher_is_better")
   trendIcon(trend, higherIsBetter) {
     return this._iconForTrend(trend, higherIsBetter);
   },
 
-  @computed("sevenDaysTrend", "higher_is_better")
+  @discourseComputed("sevenDaysTrend", "higher_is_better")
   sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
     return this._iconForTrend(sevenDaysTrend, higherIsBetter);
   },
 
-  @computed("thirtyDaysTrend", "higher_is_better")
+  @discourseComputed("thirtyDaysTrend", "higher_is_better")
   thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
     return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
   },
 
-  @computed("yesterdayTrend", "higher_is_better")
+  @discourseComputed("yesterdayTrend", "higher_is_better")
   yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
     return this._iconForTrend(yesterdayTrend, higherIsBetter);
   },
 
-  @computed("prev_period", "currentTotal", "currentAverage", "higher_is_better")
+  @discourseComputed(
+    "prev_period",
+    "currentTotal",
+    "currentAverage",
+    "higher_is_better"
+  )
   trend(prev, currentTotal, currentAverage, higherIsBetter) {
     const total = this.average ? currentAverage : currentTotal;
     return this._computeTrend(prev, total, higherIsBetter);
   },
 
-  @computed("prev30Days", "lastThirtyDaysCount", "higher_is_better")
+  @discourseComputed("prev30Days", "lastThirtyDaysCount", "higher_is_better")
   thirtyDaysTrend(prev30Days, lastThirtyDaysCount, higherIsBetter) {
     return this._computeTrend(prev30Days, lastThirtyDaysCount, higherIsBetter);
   },
 
-  @computed("type")
+  @discourseComputed("type")
   method(type) {
     if (type === "time_to_first_response") {
       return "average";
@@ -195,7 +200,7 @@ const Report = Discourse.Model.extend({
     }
   },
 
-  @computed("prev_period", "currentTotal", "currentAverage")
+  @discourseComputed("prev_period", "currentTotal", "currentAverage")
   trendTitle(prev, currentTotal, currentAverage) {
     let current = this.average ? currentAverage : currentTotal;
     let percent = this.percentChangeString(prev, current);
@@ -228,12 +233,12 @@ const Report = Discourse.Model.extend({
     return title;
   },
 
-  @computed("yesterdayCount")
+  @discourseComputed("yesterdayCount")
   yesterdayCountTitle(yesterdayCount) {
     return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
   },
 
-  @computed("lastSevenDaysCount")
+  @discourseComputed("lastSevenDaysCount")
   sevenDaysCountTitle(lastSevenDaysCount) {
     return this.changeTitle(
       this.valueFor(8, 14),
@@ -242,7 +247,7 @@ const Report = Discourse.Model.extend({
     );
   },
 
-  @computed("prev30Days", "lastThirtyDaysCount")
+  @discourseComputed("prev30Days", "lastThirtyDaysCount")
   thirtyDaysCountTitle(prev30Days, lastThirtyDaysCount) {
     return this.changeTitle(
       prev30Days,
@@ -251,18 +256,18 @@ const Report = Discourse.Model.extend({
     );
   },
 
-  @computed("data")
+  @discourseComputed("data")
   sortedData(data) {
     return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
   },
 
-  @computed("data")
+  @discourseComputed("data")
   xAxisIsDate() {
     if (!this.data[0]) return false;
     return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
   },
 
-  @computed("labels")
+  @discourseComputed("labels")
   computedLabels(labels) {
     return labels.map(label => {
       const type = label.type || "string";
diff --git a/app/assets/javascripts/admin/models/screened-email.js.es6 b/app/assets/javascripts/admin/models/screened-email.js.es6
index 6eb014c484..ea72510551 100644
--- a/app/assets/javascripts/admin/models/screened-email.js.es6
+++ b/app/assets/javascripts/admin/models/screened-email.js.es6
@@ -1,8 +1,9 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { ajax } from "discourse/lib/ajax";
-import computed from "ember-addons/ember-computed-decorators";
+import EmberObject from "@ember/object";
 
-const ScreenedEmail = Discourse.Model.extend({
-  @computed("action")
+const ScreenedEmail = EmberObject.extend({
+  @discourseComputed("action")
   actionName(action) {
     return I18n.t("admin.logs.screened_actions." + action);
   },
diff --git a/app/assets/javascripts/admin/models/screened-ip-address.js.es6 b/app/assets/javascripts/admin/models/screened-ip-address.js.es6
index 0449a666f3..bfac17d86c 100644
--- a/app/assets/javascripts/admin/models/screened-ip-address.js.es6
+++ b/app/assets/javascripts/admin/models/screened-ip-address.js.es6
@@ -1,16 +1,17 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { equal } from "@ember/object/computed";
 import { ajax } from "discourse/lib/ajax";
-import computed from "ember-addons/ember-computed-decorators";
+import EmberObject from "@ember/object";
 
-const ScreenedIpAddress = Discourse.Model.extend({
-  @computed("action_name")
+const ScreenedIpAddress = EmberObject.extend({
+  @discourseComputed("action_name")
   actionName(actionName) {
     return I18n.t(`admin.logs.screened_ips.actions.${actionName}`);
   },
 
   isBlocked: equal("action_name", "block"),
 
-  @computed("ip_address")
+  @discourseComputed("ip_address")
   isRange(ipAddress) {
     return ipAddress.indexOf("/") > 0;
   },
diff --git a/app/assets/javascripts/admin/models/screened-url.js.es6 b/app/assets/javascripts/admin/models/screened-url.js.es6
index b899c61962..31ea850778 100644
--- a/app/assets/javascripts/admin/models/screened-url.js.es6
+++ b/app/assets/javascripts/admin/models/screened-url.js.es6
@@ -1,8 +1,9 @@
+import discourseComputed from "discourse-common/utils/decorators";
 import { ajax } from "discourse/lib/ajax";
-import computed from "ember-addons/ember-computed-decorators";
+import EmberObject from "@ember/object";
 
-const ScreenedUrl = Discourse.Model.extend({
-  @computed("action")
+const ScreenedUrl = EmberObject.extend({
+  @discourseComputed("action")
   actionName(action) {
     return I18n.t("admin.logs.screened_actions." + action);
   }
diff --git a/app/assets/javascripts/admin/models/site-setting.js.es6 b/app/assets/javascripts/admin/models/site-setting.js.es6
index 7760a61114..4edc89a1b9 100644
--- a/app/assets/javascripts/admin/models/site-setting.js.es6
+++ b/app/assets/javascripts/admin/models/site-setting.js.es6
@@ -1,7 +1,8 @@
 import { ajax } from "discourse/lib/ajax";
 import Setting from "admin/mixins/setting-object";
+import EmberObject from "@ember/object";
 
-const SiteSetting = Discourse.Model.extend(Setting, {});
+const SiteSetting = EmberObject.extend(Setting, {});
 
 SiteSetting.reopenClass({
   findAll() {
diff --git a/app/assets/javascripts/admin/models/staff-action-log.js.es6 b/app/assets/javascripts/admin/models/staff-action-log.js.es6
index 2d63019dda..45330b13fc 100644
--- a/app/assets/javascripts/admin/models/staff-action-log.js.es6
+++ b/app/assets/javascripts/admin/models/staff-action-log.js.es6
@@ -1,4 +1,4 @@
-import computed from "ember-addons/ember-computed-decorators";
+import discourseComputed from "discourse-common/utils/decorators";
 import { ajax } from "discourse/lib/ajax";
 import AdminUser from "admin/models/admin-user";
 import { escapeExpression } from "discourse/lib/utilities";
@@ -13,12 +13,12 @@ function format(label, value, escape = true) {
 const StaffActionLog = RestModel.extend({
   showFullDetails: false,
 
-  @computed("action_name")
+  @discourseComputed("action_name")
   actionName(actionName) {
     return I18n.t(`admin.logs.staff_actions.actions.${actionName}`);
   },
 
-  @computed(
+  @discourseComputed(
     "email",
     "ip_address",
     "topic_id",
@@ -69,12 +69,12 @@ const StaffActionLog = RestModel.extend({
     return formatted.length > 0 ? formatted + "
" : ""; }, - @computed("details") + @discourseComputed("details") useModalForDetails(details) { return details && details.length > 100; }, - @computed("action_name") + @discourseComputed("action_name") useCustomModalForDetails(actionName) { return ["change_theme", "delete_theme"].includes(actionName); } diff --git a/app/assets/javascripts/admin/models/theme-settings.js.es6 b/app/assets/javascripts/admin/models/theme-settings.js.es6 index ab9e5bf9ce..a823592ad2 100644 --- a/app/assets/javascripts/admin/models/theme-settings.js.es6 +++ b/app/assets/javascripts/admin/models/theme-settings.js.es6 @@ -1,3 +1,4 @@ import Setting from "admin/mixins/setting-object"; +import EmberObject from "@ember/object"; -export default Discourse.Model.extend(Setting, {}); +export default EmberObject.extend(Setting, {}); diff --git a/app/assets/javascripts/admin/models/theme.js.es6 b/app/assets/javascripts/admin/models/theme.js.es6 index 4ea3d3e216..d1528bcd06 100644 --- a/app/assets/javascripts/admin/models/theme.js.es6 +++ b/app/assets/javascripts/admin/models/theme.js.es6 @@ -2,7 +2,7 @@ import { get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { or, gt } from "@ember/object/computed"; import RestModel from "discourse/models/rest"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { ajax } from "discourse/lib/ajax"; import { escapeExpression } from "discourse/lib/utilities"; @@ -19,8 +19,9 @@ const Theme = RestModel.extend({ isActive: or("default", "user_selectable"), isPendingUpdates: gt("remote_theme.commits_behind", 0), hasEditedFields: gt("editedFields.length", 0), + hasParents: gt("parent_themes.length", 0), - @computed("theme_fields.[]") + @discourseComputed("theme_fields.[]") targets() { return [ { id: 0, name: "common" }, @@ -48,7 +49,7 @@ const Theme = RestModel.extend({ }); }, - @computed("theme_fields.[]") + @discourseComputed("theme_fields.[]") fieldNames() { const common = [ "scss", @@ -82,7 +83,11 @@ const Theme = RestModel.extend({ }; }, - @computed("fieldNames", "theme_fields.[]", "theme_fields.@each.error") + @discourseComputed( + "fieldNames", + "theme_fields.[]", + "theme_fields.@each.error" + ) fields(fieldNames) { const hash = {}; Object.keys(fieldNames).forEach(target => { @@ -112,7 +117,7 @@ const Theme = RestModel.extend({ return hash; }, - @computed("theme_fields") + @discourseComputed("theme_fields") themeFields(fields) { if (!fields) { this.set("theme_fields", []); @@ -128,7 +133,7 @@ const Theme = RestModel.extend({ return hash; }, - @computed("theme_fields", "theme_fields.[]") + @discourseComputed("theme_fields", "theme_fields.[]") uploads(fields) { if (!fields) { return []; @@ -138,19 +143,19 @@ const Theme = RestModel.extend({ ); }, - @computed("theme_fields", "theme_fields.@each.error") + @discourseComputed("theme_fields", "theme_fields.@each.error") isBroken(fields) { return fields && fields.any(field => field.error && field.error.length > 0); }, - @computed("theme_fields.[]") + @discourseComputed("theme_fields.[]") editedFields(fields) { return fields.filter( field => !Ember.isBlank(field.value) && field.type_id !== SETTINGS_TYPE_ID ); }, - @computed("remote_theme.last_error_text") + @discourseComputed("remote_theme.last_error_text") remoteError(errorText) { if (errorText && errorText.length > 0) { return errorText; @@ -241,7 +246,7 @@ const Theme = RestModel.extend({ } }, - @computed("childThemes.[]") + @discourseComputed("childThemes.[]") child_theme_ids(childThemes) { if (childThemes) { return childThemes.map(theme => get(theme, "id")); @@ -265,7 +270,16 @@ const Theme = RestModel.extend({ return this.saveChanges("child_theme_ids"); }, - @computed("name", "default") + addParentTheme(theme) { + let parentThemes = this.parentThemes; + if (!parentThemes) { + parentThemes = []; + this.set("parentThemes", parentThemes); + } + parentThemes.addObject(theme); + }, + + @discourseComputed("name", "default") description: function(name, isDefault) { if (isDefault) { return I18n.t("admin.customize.theme.default_name", { name: name }); diff --git a/app/assets/javascripts/admin/models/tl3-requirements.js.es6 b/app/assets/javascripts/admin/models/tl3-requirements.js.es6 index 222c8077d7..424aea4f58 100644 --- a/app/assets/javascripts/admin/models/tl3-requirements.js.es6 +++ b/app/assets/javascripts/admin/models/tl3-requirements.js.es6 @@ -1,17 +1,18 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; +import EmberObject from "@ember/object"; -export default Discourse.Model.extend({ - @computed("days_visited", "time_period") +export default EmberObject.extend({ + @discourseComputed("days_visited", "time_period") days_visited_percent(daysVisited, timePeriod) { return Math.round((daysVisited * 100) / timePeriod); }, - @computed("min_days_visited", "time_period") + @discourseComputed("min_days_visited", "time_period") min_days_visited_percent(minDaysVisited, timePeriod) { return Math.round((minDaysVisited * 100) / timePeriod); }, - @computed( + @discourseComputed( "days_visited", "min_days_visited", "num_topics_replied_to", diff --git a/app/assets/javascripts/admin/models/version-check.js.es6 b/app/assets/javascripts/admin/models/version-check.js.es6 index 2012d0ff08..cc888b2588 100644 --- a/app/assets/javascripts/admin/models/version-check.js.es6 +++ b/app/assets/javascripts/admin/models/version-check.js.es6 @@ -1,30 +1,31 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; +import EmberObject from "@ember/object"; -const VersionCheck = Discourse.Model.extend({ - @computed("updated_at") +const VersionCheck = EmberObject.extend({ + @discourseComputed("updated_at") noCheckPerformed(updatedAt) { return updatedAt === null; }, - @computed("missing_versions_count") + @discourseComputed("missing_versions_count") upToDate(missingVersionsCount) { return missingVersionsCount === 0 || missingVersionsCount === null; }, - @computed("missing_versions_count") + @discourseComputed("missing_versions_count") behindByOneVersion(missingVersionsCount) { return missingVersionsCount === 1; }, - @computed("installed_sha") + @discourseComputed("installed_sha") gitLink(installedSHA) { if (installedSHA) { return `https://github.com/discourse/discourse/commits/${installedSHA}`; } }, - @computed("installed_sha") + @discourseComputed("installed_sha") shortSha(installedSHA) { if (installedSHA) { return installedSHA.substr(0, 10); diff --git a/app/assets/javascripts/admin/models/watched-word.js.es6 b/app/assets/javascripts/admin/models/watched-word.js.es6 index b9ef7380b6..dac78affe1 100644 --- a/app/assets/javascripts/admin/models/watched-word.js.es6 +++ b/app/assets/javascripts/admin/models/watched-word.js.es6 @@ -1,7 +1,7 @@ import { ajax } from "discourse/lib/ajax"; import EmberObject from "@ember/object"; -const WatchedWord = Discourse.Model.extend({ +const WatchedWord = EmberObject.extend({ save() { return ajax( "/admin/logs/watched_words" + (this.id ? "/" + this.id : "") + ".json", diff --git a/app/assets/javascripts/admin/models/web-hook.js.es6 b/app/assets/javascripts/admin/models/web-hook.js.es6 index 84111591fa..74fd93a5f3 100644 --- a/app/assets/javascripts/admin/models/web-hook.js.es6 +++ b/app/assets/javascripts/admin/models/web-hook.js.es6 @@ -3,9 +3,10 @@ import RestModel from "discourse/models/rest"; import Category from "discourse/models/category"; import Group from "discourse/models/group"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; +import Site from "discourse/models/site"; export default RestModel.extend({ content_type: 1, // json @@ -16,7 +17,7 @@ export default RestModel.extend({ web_hook_event_types: null, groupsFilterInName: null, - @computed("wildcard_web_hook") + @discourseComputed("wildcard_web_hook") webHookType: { get(wildcard) { return wildcard ? "wildcard" : "individual"; @@ -26,7 +27,7 @@ export default RestModel.extend({ } }, - @computed("category_ids") + @discourseComputed("category_ids") categories(categoryIds) { return Category.findByIds(categoryIds); }, @@ -36,7 +37,7 @@ export default RestModel.extend({ const groupIds = this.group_ids; this.set( "groupsFilterInName", - Discourse.Site.currentProp("groups").reduce((groupNames, g) => { + Site.currentProp("groups").reduce((groupNames, g) => { if (groupIds.includes(g.id)) { groupNames.push(g.name); } @@ -49,7 +50,7 @@ export default RestModel.extend({ return Group.findAll({ term: term, ignore_automatic: false }); }, - @computed("wildcard_web_hook", "web_hook_event_types.[]") + @discourseComputed("wildcard_web_hook", "web_hook_event_types.[]") description(isWildcardWebHook, types) { let desc = ""; @@ -87,7 +88,7 @@ export default RestModel.extend({ group_ids: isEmpty(groupNames) || isEmpty(groupNames[0]) ? [null] - : Discourse.Site.currentProp("groups").reduce((groupIds, g) => { + : Site.currentProp("groups").reduce((groupIds, g) => { if (groupNames.includes(g.name)) { groupIds.push(g.id); } diff --git a/app/assets/javascripts/admin/routes/admin-backups-index.js.es6 b/app/assets/javascripts/admin/routes/admin-backups-index.js.es6 index d20da4ac50..d463819a2a 100644 --- a/app/assets/javascripts/admin/routes/admin-backups-index.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups-index.js.es6 @@ -4,7 +4,10 @@ import Backup from "admin/models/backup"; export default Route.extend({ activate() { this.messageBus.subscribe("/admin/backups", backups => - this.controller.set("model", backups.map(backup => Backup.create(backup))) + this.controller.set( + "model", + backups.map(backup => Backup.create(backup)) + ) ); }, diff --git a/app/assets/javascripts/admin/routes/admin-backups.js.es6 b/app/assets/javascripts/admin/routes/admin-backups.js.es6 index 5025f41536..6bc42d725f 100644 --- a/app/assets/javascripts/admin/routes/admin-backups.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-backups.js.es6 @@ -5,6 +5,7 @@ import showModal from "discourse/lib/show-modal"; import BackupStatus from "admin/models/backup-status"; import Backup from "admin/models/backup"; import PreloadStore from "preload-store"; +import User from "discourse/models/user"; const LOG_CHANNEL = "/admin/backups/logs"; @@ -12,7 +13,7 @@ export default DiscourseRoute.extend({ activate() { this.messageBus.subscribe(LOG_CHANNEL, log => { if (log.message === "[STARTED]") { - Discourse.User.currentProp("hideReadOnlyAlert", true); + User.currentProp("hideReadOnlyAlert", true); this.controllerFor("adminBackups").set( "model.isOperationRunning", true @@ -31,7 +32,7 @@ export default DiscourseRoute.extend({ }) ); } else if (log.message === "[SUCCESS]") { - Discourse.User.currentProp("hideReadOnlyAlert", false); + User.currentProp("hideReadOnlyAlert", false); this.controllerFor("adminBackups").set( "model.isOperationRunning", false diff --git a/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 b/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 index 36043f6096..67acdcba58 100644 --- a/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-badges-show.js.es6 @@ -15,7 +15,10 @@ export default Route.extend({ name: I18n.t("admin.badges.new_badge") }); } - return this.modelFor("adminBadges").findBy("id", parseInt(params.badge_id)); + return this.modelFor("adminBadges").findBy( + "id", + parseInt(params.badge_id, 10) + ); }, actions: { @@ -51,7 +54,8 @@ export default Route.extend({ }) .catch(function(error) { badge.set("preview_loading", false); - Ember.Logger.error(error); + // eslint-disable-next-line no-console + console.error(error); bootbox.alert("Network error"); }); } diff --git a/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 index 146a3a61a9..8807df2c56 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-colors-show.js.es6 @@ -2,7 +2,7 @@ import Route from "@ember/routing/route"; export default Route.extend({ model(params) { const all = this.modelFor("adminCustomize.colors"); - const model = all.findBy("id", parseInt(params.scheme_id)); + const model = all.findBy("id", parseInt(params.scheme_id, 10)); return model ? model : this.replaceWith("adminCustomize.colors.index"); }, diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 index 335e9fd578..62a70f0d7c 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-edit.js.es6 @@ -2,7 +2,7 @@ import Route from "@ember/routing/route"; export default Route.extend({ model(params) { const all = this.modelFor("adminCustomizeThemes"); - const model = all.findBy("id", parseInt(params.theme_id)); + const model = all.findBy("id", parseInt(params.theme_id, 10)); return model ? { model, diff --git a/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 index c9573fd8ac..408917b57a 100644 --- a/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-customize-themes-show.js.es6 @@ -9,7 +9,7 @@ export default Route.extend({ model(params) { const all = this.modelFor("adminCustomizeThemes"); - const model = all.findBy("id", parseInt(params.theme_id)); + const model = all.findBy("id", parseInt(params.theme_id, 10)); return model ? model : this.replaceWith("adminCustomizeTheme.index"); }, diff --git a/app/assets/javascripts/admin/templates/api-keys-show.hbs b/app/assets/javascripts/admin/templates/api-keys-show.hbs index a742cf994a..725c987a22 100644 --- a/app/assets/javascripts/admin/templates/api-keys-show.hbs +++ b/app/assets/javascripts/admin/templates/api-keys-show.hbs @@ -6,7 +6,7 @@
{{#admin-form-row label="admin.api.key"}} {{#if model.revoked_at}}{{d-icon 'times-circle'}}{{/if}} - {{model.key}} +
{{model.key}}
{{/admin-form-row}} {{#admin-form-row label="admin.api.description"}} @@ -45,8 +45,8 @@ {{/admin-form-row}} {{#admin-form-row label="admin.api.last_used"}} - {{#if k.last_used_at}} - {{format-date k.last_used_at leaveAgo="true"}} + {{#if model.last_used_at}} + {{format-date model.last_used_at leaveAgo="true"}} {{else}} {{i18n "admin.api.never_used"}} {{/if}} diff --git a/app/assets/javascripts/admin/templates/backups-index.hbs b/app/assets/javascripts/admin/templates/backups-index.hbs index ee7abc5f3a..7f0675e5a5 100644 --- a/app/assets/javascripts/admin/templates/backups-index.hbs +++ b/app/assets/javascripts/admin/templates/backups-index.hbs @@ -28,6 +28,11 @@ title="admin.backups.read_only.enable.title" label="admin.backups.read_only.enable.label"}} {{/if}} +
diff --git a/app/assets/javascripts/admin/templates/components/admin-directory-toggle.hbs b/app/assets/javascripts/admin/templates/components/admin-directory-toggle.hbs new file mode 100644 index 0000000000..7ea697404f --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-directory-toggle.hbs @@ -0,0 +1 @@ +{{i18n this.i18nKey}}{{chevronIcon}} diff --git a/app/assets/javascripts/admin/templates/components/admin-watched-word.hbs b/app/assets/javascripts/admin/templates/components/admin-watched-word.hbs new file mode 100644 index 0000000000..c9e4fd9b04 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-watched-word.hbs @@ -0,0 +1 @@ +{{xIcon}}{{watchedWord}} diff --git a/app/assets/javascripts/admin/templates/components/admin-web-hook-status.hbs b/app/assets/javascripts/admin/templates/components/admin-web-hook-status.hbs new file mode 100644 index 0000000000..7aa2d1455f --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-web-hook-status.hbs @@ -0,0 +1 @@ +{{circleIcon}} {{deliveryStatus}} diff --git a/app/assets/javascripts/admin/templates/components/site-setting.hbs b/app/assets/javascripts/admin/templates/components/site-setting.hbs index 980c30986c..e67af55751 100644 --- a/app/assets/javascripts/admin/templates/components/site-setting.hbs +++ b/app/assets/javascripts/admin/templates/components/site-setting.hbs @@ -1,8 +1,11 @@

{{unbound settingName}}

+ {{#if defaultIsAvailable}} + {{setting.setDefaultValuesLabel}} + {{/if}}
- {{component componentName setting=setting value=buffered.value validationMessage=validationMessage preview=preview isSecret=isSecret}} + {{component componentName setting=setting value=buffered.value validationMessage=validationMessage preview=preview isSecret=isSecret allowAny=allowAny}}
{{#if dirty}}
diff --git a/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs b/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs index e741bea5ed..551ca3d45d 100644 --- a/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs +++ b/app/assets/javascripts/admin/templates/components/site-settings/compact-list.hbs @@ -1,3 +1,3 @@ -{{list-setting settingValue=value choices=setting.choices settingName=setting.setting}} +{{list-setting settingValue=value choices=setting.choices settingName=setting.setting allowAny=allowAny}} {{setting-validation-message message=validationMessage}}
{{{unbound setting.description}}}
diff --git a/app/assets/javascripts/admin/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/templates/customize-themes-show.hbs index 4b28949ad2..b203985056 100644 --- a/app/assets/javascripts/admin/templates/customize-themes-show.hbs +++ b/app/assets/javascripts/admin/templates/customize-themes-show.hbs @@ -59,16 +59,16 @@ {{#if model.remote_theme.remote_url}} {{#if sourceIsHttp}} - {{i18n "admin.customize.theme.source_url"}} {{d-icon "link"}} + {{i18n "admin.customize.theme.source_url"}}{{d-icon "link"}} {{else}}
{{model.remote_theme.remote_url}}
{{/if}} {{/if}} {{#if model.remote_theme.about_url}} - {{i18n "admin.customize.theme.about_theme"}} {{d-icon "link"}} + {{i18n "admin.customize.theme.about_theme"}}{{d-icon "link"}} {{/if}} {{#if model.remote_theme.license_url}} - {{i18n "admin.customize.theme.license"}} {{d-icon "link"}} + {{i18n "admin.customize.theme.license"}}{{d-icon "link"}} {{/if}} {{#if model.description}} @@ -99,23 +99,23 @@ {{/if}} - {{#if updatingRemote}} - {{i18n 'admin.customize.theme.updating'}} - {{else}} - {{#if model.remote_theme.commits_behind}} - {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} - {{#if model.remote_theme.github_diff_link}} - - {{i18n 'admin.customize.theme.compare_commits'}} - - {{/if}} - {{else}} - {{#unless showRemoteError}} - {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} - {{/unless}} + {{#if updatingRemote}} + {{i18n 'admin.customize.theme.updating'}} + {{else}} + {{#if model.remote_theme.commits_behind}} + {{i18n 'admin.customize.theme.commits_behind' count=model.remote_theme.commits_behind}} + {{#if model.remote_theme.github_diff_link}} + + {{i18n 'admin.customize.theme.compare_commits'}} + {{/if}} + {{else}} + {{#unless showRemoteError}} + {{i18n 'admin.customize.theme.up_to_date'}} {{format-date model.remote_theme.updated_at leaveAgo="true"}} + {{/unless}} {{/if}} - + {{/if}} + {{else}} {{d-icon "info-circle"}} {{i18n "admin.customize.theme.imported_from_archive"}} @@ -125,24 +125,29 @@ {{/if}} {{#unless model.component}} -
-
{{i18n "admin.customize.theme.color_scheme"}}
-
{{i18n "admin.customize.theme.color_scheme_select"}}
-
- {{color-palettes - content=colorSchemes - filterable=true - forceEscape=true - value=colorSchemeId - icon="paint-brush"}} + {{#d-section class="form-horizontal theme settings"}} +
+
+ {{i18n "admin.customize.theme.color_scheme"}} +
+
+ {{color-palettes + content=colorSchemes + filterable=true + forceEscape=true + value=colorSchemeId + icon="paint-brush"}} - {{#if colorSchemeChanged}} - {{d-button action=(action "changeScheme") class="btn-primary submit-edit" icon="check"}} - {{d-button action=(action "cancelChangeScheme") class="btn-default cancel-edit" icon="times"}} - {{/if}} +
{{i18n "admin.customize.theme.color_scheme_select"}}
+
+
+ {{#if colorSchemeChanged}} + {{d-button action=(action "changeScheme") class="ok submit-edit" icon="check"}} + {{d-button action=(action "cancelChangeScheme") class="cancel cancel-edit" icon="times"}} + {{/if}} +
- {{#link-to 'adminCustomize.colors' class="btn btn-default edit"}}{{i18n 'admin.customize.colors.edit'}}{{/link-to}} -
+ {{/d-section}} {{/unless}} {{#if parentThemes}} @@ -156,6 +161,20 @@
{{/if}} + {{#if model.component}} + {{#d-section class="form-horizontal theme settings"}} +
+ {{theme-setting-relatives-selector setting=relativesSelectorSettingsForComponent model=model class="theme-setting"}} +
+ {{/d-section}} + {{else}} + {{#d-section class="form-horizontal theme settings"}} +
+ {{theme-setting-relatives-selector setting=relativesSelectorSettingsForTheme model=model class="theme-setting"}} +
+ {{/d-section}} + {{/if}} +
{{i18n "admin.customize.theme.css_html"}}
{{#if model.hasEditedFields}} @@ -182,12 +201,12 @@ {{#if model.uploads}}
    {{#each model.uploads as |upload|}} -
  • - ${{upload.name}}: {{upload.filename}} - - {{d-button action=(action "removeUpload") actionParam=upload class="second btn-default btn-default cancel-edit" icon="times"}} - -
  • +
  • + ${{upload.name}}: {{upload.filename}} + + {{d-button action=(action "removeUpload") actionParam=upload class="second btn-default btn-default cancel-edit" icon="times"}} + +
  • {{/each}}
{{else}} @@ -218,34 +237,6 @@
{{/if}} - {{#if availableChildThemes}} -
-
- {{d-icon "puzzle-piece"}} - {{i18n "admin.customize.theme.theme_components"}} -
- {{#if model.childThemes.length}} -
    - {{#each model.childThemes as |child|}} -
  • - {{#link-to 'adminCustomizeThemes.show' child replace=true class='col child-link'}} - {{child.name}} - {{/link-to}} - - {{d-button action=(action "removeChildTheme") actionParam=child class="btn-default cancel-edit col" icon="times"}} -
  • - {{/each}} -
- {{/if}} - {{#if selectableChildThemes}} -
- {{combo-box forceEscape=true filterable=true content=selectableChildThemes value=selectedChildThemeId none="admin.customize.theme.select_component"}} - {{#d-button action=(action "addChildTheme") icon="plus" disabled=addButtonDisabled class="btn-default add-component-button"}}{{i18n "admin.customize.theme.add"}}{{/d-button}} -
- {{/if}} -
- {{/if}} - {{d-icon 'desktop'}}{{i18n 'admin.customize.theme.preview'}} {{d-icon "download"}} {{i18n 'admin.export_json.button_text'}} @@ -264,8 +255,8 @@ action=(action "enableComponent") icon="check" label="admin.customize.theme.enable"}} + {{/if}} {{/if}} - {{/if}} - {{d-button action=(action "destroy") label="admin.customize.delete" icon="trash-alt" class="btn-danger"}} + {{d-button action=(action "destroy") label="admin.customize.delete" icon="trash-alt" class="btn-danger"}}
diff --git a/app/assets/javascripts/admin/templates/email-index.hbs b/app/assets/javascripts/admin/templates/email-index.hbs index 42109ddaa9..e817cb4222 100644 --- a/app/assets/javascripts/admin/templates/email-index.hbs +++ b/app/assets/javascripts/admin/templates/email-index.hbs @@ -12,20 +12,23 @@ {{/each}}
-
- {{#if sendingEmail}} -
{{i18n 'admin.email.sending_test'}}
- {{else}} -
- {{text-field value=testEmailAddress placeholderKey="admin.email.test_email_address"}} +
+
+ {{#if sendingEmail}} +
{{i18n 'admin.email.sending_test'}}
+ {{else}} +
+ {{text-field value=testEmailAddress placeholderKey="admin.email.test_email_address"}} +
+
+ {{d-button + class="btn-primary" + action=(action "sendTestEmail") + disabled=sendTestEmailDisabled + label="admin.email.send_test" + type="submit"}} + {{#if sentTestEmailMessage}}{{sentTestEmailMessage}}{{/if}} +
+ {{/if}}
-
- {{d-button - class="btn-primary" - action=(action "sendTestEmail") - disabled=sendTestEmailDisabled - label="admin.email.send_test"}} - {{#if sentTestEmailMessage}}{{sentTestEmailMessage}}{{/if}} -
- {{/if}} -
+ diff --git a/app/assets/javascripts/admin/templates/embedding.hbs b/app/assets/javascripts/admin/templates/embedding.hbs index 45f86da9f7..f4ada49e1b 100644 --- a/app/assets/javascripts/admin/templates/embedding.hbs +++ b/app/assets/javascripts/admin/templates/embedding.hbs @@ -40,16 +40,6 @@ {{embedding-setting field="embed_truncate" value=embedding.embed_truncate type="checkbox"}}
-
-

{{i18n "admin.embedding.feed_settings"}}

-

{{i18n "admin.embedding.feed_description"}}

- - {{embedding-setting field="feed_polling_enabled" value=embedding.feed_polling_enabled type="checkbox"}} - {{embedding-setting field="feed_polling_url" value=embedding.feed_polling_url}} - {{embedding-setting field="feed_polling_frequency_mins" value=embedding.feed_polling_frequency_mins}} - {{embedding-setting field="embed_username_key_from_feed" value=embedding.embed_username_key_from_feed}} -
-

{{i18n "admin.embedding.crawling_settings"}}

{{i18n "admin.embedding.crawling_description"}}

diff --git a/app/assets/javascripts/admin/templates/users-list-show.hbs b/app/assets/javascripts/admin/templates/users-list-show.hbs index 065d2ed93d..d31a38c1f6 100644 --- a/app/assets/javascripts/admin/templates/users-list-show.hbs +++ b/app/assets/javascripts/admin/templates/users-list-show.hbs @@ -91,6 +91,7 @@ {{#if user.second_factor_enabled}} {{d-icon "lock" title="admin.user.second_factor_enabled" }} {{/if}} + {{plugin-outlet name="admin-users-list-icon" tagName="" args=(hash user=user query=query)}} {{/each}} diff --git a/app/assets/javascripts/admin/templates/web-hooks-show.hbs b/app/assets/javascripts/admin/templates/web-hooks-show.hbs index 3cd6db8c0d..cc49f3ac58 100644 --- a/app/assets/javascripts/admin/templates/web-hooks-show.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks-show.hbs @@ -54,7 +54,7 @@ {{#if showTagsFilter}}
- {{tag-chooser tags=model.tag_names everyTag=true}} + {{tag-chooser tags=model.tag_names everyTag=true excludeSynonyms=true}}
{{i18n 'admin.web_hooks.tags_filter_instructions'}}
{{/if}} diff --git a/app/assets/javascripts/admin/templates/web-hooks.hbs b/app/assets/javascripts/admin/templates/web-hooks.hbs index 389f7d4815..e63dac0828 100644 --- a/app/assets/javascripts/admin/templates/web-hooks.hbs +++ b/app/assets/javascripts/admin/templates/web-hooks.hbs @@ -1,4 +1,3 @@ -

{{i18n 'admin.web_hooks.instruction'}}

diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 033e043d43..35cc29acce 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,10 +1,9 @@ //= require_tree ./ember-addons/utils +//= require_tree ./discourse-common //= require ./ember-addons/decorator-alias //= require ./ember-addons/macro-alias -//= require ./ember-addons/ember-computed-decorators //= require ./ember-addons/fmt //= require ./polyfills -//= require_tree ./discourse-common //= require_tree ./select-kit //= require ./discourse //= require ./deprecated @@ -14,6 +13,9 @@ //= require ./discourse/lib/utilities //= require ./discourse/lib/page-visible //= require ./discourse/lib/logout +//= require ./discourse/mixins/singleton +//= require ./discourse/models/rest +//= require ./discourse/models/session //= require ./discourse/lib/ajax //= require ./discourse/lib/text //= require ./discourse/lib/hash @@ -24,7 +26,6 @@ //= require ./discourse/lib/lock-on //= require ./discourse/lib/url //= require ./discourse/lib/debounce -//= require ./discourse/lib/throttle //= require ./discourse/lib/quote //= require ./discourse/lib/key-value-store //= require ./discourse/lib/computed @@ -34,8 +35,6 @@ //= require ./discourse/lib/show-modal //= require ./discourse/mixins/scrolling //= require ./discourse/lib/ajax-error -//= require ./discourse/models/model -//= require ./discourse/models/rest //= require ./discourse/models/result-set //= require ./discourse/models/store //= require ./discourse/models/action-summary @@ -48,7 +47,7 @@ //= require ./discourse/models/badge //= require ./discourse/models/permission-type //= require ./discourse/models/user-action-group -//= require ./discourse/models/input-validation +//= require ./discourse/models/trust-level //= require ./discourse/lib/search //= require ./discourse/lib/user-search //= require ./discourse/lib/export-csv diff --git a/app/assets/javascripts/discourse-common/config/environment.js.es6 b/app/assets/javascripts/discourse-common/config/environment.js.es6 new file mode 100644 index 0000000000..3bfa2c38c5 --- /dev/null +++ b/app/assets/javascripts/discourse-common/config/environment.js.es6 @@ -0,0 +1 @@ +export default { environment: Ember.testing ? "test" : "development" }; diff --git a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 index 726b49cfde..f0188fb2c5 100644 --- a/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/icon-library.js.es6 @@ -36,7 +36,8 @@ const REPLACEMENTS = { "notification.watching_first_post": "far-dot-circle", "notification.group_message_summary": "users", "notification.post_approved": "check", - "notification.membership_request_accepted": "user-plus" + "notification.membership_request_accepted": "user-plus", + "notification.membership_request_consolidated": "users" }; // TODO: use lib/svg_sprite/fa4-renames.json here diff --git a/app/assets/javascripts/discourse-common/lib/raw-handlebars.js.es6 b/app/assets/javascripts/discourse-common/lib/raw-handlebars.js.es6 index eed8ec09a0..ca08cfd476 100644 --- a/app/assets/javascripts/discourse-common/lib/raw-handlebars.js.es6 +++ b/app/assets/javascripts/discourse-common/lib/raw-handlebars.js.es6 @@ -1,6 +1,6 @@ // This is a mechanism for quickly rendering templates which is Ember aware // templates are highly compatible with Ember so you don't need to worry about calling "get" -// and computed properties function, additionally it uses stringParams like Ember does +// and discourseComputed properties function, additionally it uses stringParams like Ember does const RawHandlebars = Handlebars.create(); diff --git a/app/assets/javascripts/discourse-common/resolver.js.es6 b/app/assets/javascripts/discourse-common/resolver.js.es6 index e1507406bb..ba272c6bf1 100644 --- a/app/assets/javascripts/discourse-common/resolver.js.es6 +++ b/app/assets/javascripts/discourse-common/resolver.js.es6 @@ -139,6 +139,11 @@ export function buildResolver(baseName) { }, resolveRoute(parsedName) { + if (parsedName.fullNameWithoutType === "basic") { + return requirejs("discourse/routes/discourse", null, null, true) + .default; + } + return this.customResolve(parsedName) || this._super(parsedName); }, diff --git a/app/assets/javascripts/ember-addons/ember-computed-decorators.js.es6 b/app/assets/javascripts/discourse-common/utils/decorators.js.es6 similarity index 82% rename from app/assets/javascripts/ember-addons/ember-computed-decorators.js.es6 rename to app/assets/javascripts/discourse-common/utils/decorators.js.es6 index eaa78d5063..053eefe341 100644 --- a/app/assets/javascripts/ember-addons/ember-computed-decorators.js.es6 +++ b/app/assets/javascripts/discourse-common/utils/decorators.js.es6 @@ -1,9 +1,9 @@ -import handleDescriptor from "./utils/handle-descriptor"; -import isDescriptor from "./utils/is-descriptor"; -import extractValue from "./utils/extract-value"; +import handleDescriptor from "ember-addons/utils/handle-descriptor"; +import isDescriptor from "ember-addons/utils/is-descriptor"; +import extractValue from "ember-addons/utils/extract-value"; -export default function computedDecorator(...params) { - // determine if user called as @computed('blah', 'blah') or @computed +export default function discourseComputedDecorator(...params) { + // determine if user called as @discourseComputed('blah', 'blah') or @discourseComputed if (isDescriptor(params[params.length - 1])) { return handleDescriptor(...arguments); } else { @@ -25,7 +25,7 @@ export function readOnly(target, name, desc) { }; } -import decoratorAlias from "./decorator-alias"; +import decoratorAlias from "ember-addons/decorator-alias"; export var on = decoratorAlias(Ember.on, "Can not `on` without event names"); export var observes = decoratorAlias( @@ -33,7 +33,7 @@ export var observes = decoratorAlias( "Can not `observe` without property names" ); -import macroAlias from "./macro-alias"; +import macroAlias from "ember-addons/macro-alias"; export var alias = macroAlias(Ember.computed.alias); export var and = macroAlias(Ember.computed.and); diff --git a/app/assets/javascripts/discourse-loader.js b/app/assets/javascripts/discourse-loader.js index 5052771e91..0cf66dd923 100644 --- a/app/assets/javascripts/discourse-loader.js +++ b/app/assets/javascripts/discourse-loader.js @@ -3,6 +3,10 @@ var define, requirejs; (function() { // In future versions of ember we don't need this var EMBER_MODULES = {}; + var ALIASES = { + "ember-addons/ember-computed-decorators": + "discourse-common/utils/decorators" + }; if (typeof Ember !== "undefined") { EMBER_MODULES = { jquery: { default: $ }, @@ -16,10 +20,11 @@ var define, requirejs; get: Ember.get, getProperties: Ember.getProperties, set: Ember.set, - setProperties: Ember.setProperties + setProperties: Ember.setProperties, + computed: Ember.computed, + defineProperty: Ember.defineProperty }, "@ember/object/computed": { - default: Ember.computed, alias: Ember.computed.alias, and: Ember.computed.and, bool: Ember.computed.bool, @@ -74,9 +79,12 @@ var define, requirejs; inject: Ember.inject.service }, "@ember/utils": { - isEmpty: Ember.isEmpty + isEmpty: Ember.isEmpty, + isNone: Ember.isNone }, - "rsvp": { + rsvp: { + default: Ember.RSVP, + EventTarget: Ember.RSVP.EventTarget, Promise: Ember.RSVP.Promise, hash: Ember.RSVP.hash, all: Ember.RSVP.all @@ -96,6 +104,9 @@ var define, requirejs; }, "@ember/component/helper": { default: Ember.Helper + }, + "@ember/error": { + default: Ember.error } }; } @@ -131,6 +142,15 @@ var define, requirejs; ); } + function deprecatedModule(depricated, useInstead) { + var warning = "[DEPRECATION] `" + depricated + "` is deprecated."; + if (useInstead) { + warning += " Please use `" + useInstead + "` instead."; + } + // eslint-disable-next-line no-console + console.warn(warning); + } + var defaultDeps = ["require", "exports", "module"]; function Module(name, deps, callback, exports) { @@ -144,7 +164,7 @@ var define, requirejs; } Module.prototype.makeRequire = function() { - var name = this.name; + var name = transformForAliases(this.name); return ( this._require || @@ -210,6 +230,16 @@ var define, requirejs; } function requireFrom(name, origin) { + name = transformForAliases(name); + + if (name === "discourse/models/input-validation") { + // eslint-disable-next-line no-console + console.log( + "input-validation has been removed and should be replaced with `@ember/object`" + ); + name = "@ember/object"; + } + var mod = EMBER_MODULES[name] || registry[name]; if (!mod) { throw new Error( @@ -223,7 +253,16 @@ var define, requirejs; throw new Error("Could not find module " + name); } + function transformForAliases(name) { + var alias = ALIASES[name]; + if (!alias) return name; + + deprecatedModule(name, alias); + return alias; + } + requirejs = require = function(name) { + name = transformForAliases(name); if (EMBER_MODULES[name]) { return EMBER_MODULES[name]; } diff --git a/app/assets/javascripts/discourse.js.es6 b/app/assets/javascripts/discourse.js.es6 index d993e3b983..38ea5dd7bb 100644 --- a/app/assets/javascripts/discourse.js.es6 +++ b/app/assets/javascripts/discourse.js.es6 @@ -1,10 +1,13 @@ /*global Mousetrap:true*/ import { buildResolver } from "discourse-common/resolver"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; +import { computed } from "@ember/object"; import FocusEvent from "discourse-common/mixins/focus-event"; +import EmberObject from "@ember/object"; +import deprecated from "discourse-common/lib/deprecated"; const _pluginCallbacks = []; @@ -58,25 +61,26 @@ const Discourse = Ember.Application.extend(FocusEvent, { $("title").text(title); } - var displayCount = this.displayCount; - if (displayCount > 0 && !Discourse.User.currentProp("dynamic_favicon")) { + let displayCount = this.displayCount; + let dynamicFavicon = this.currentUser && this.currentUser.dynamic_favicon; + if (displayCount > 0 && !dynamicFavicon) { title = `(${displayCount}) ${title}`; } document.title = title; }, - @computed("contextCount", "notificationCount") + @discourseComputed("contextCount", "notificationCount") displayCount() { - return Discourse.User.current() && - Discourse.User.currentProp("title_count_mode") === "notifications" + return this.currentUser && + this.currentUser.get("title_count_mode") === "notifications" ? this.notificationCount : this.contextCount; }, @observes("contextCount", "notificationCount") faviconChanged() { - if (Discourse.User.currentProp("dynamic_favicon")) { + if (this.currentUser && this.currentUser.get("dynamic_favicon")) { let url = Discourse.SiteSettings.site_favicon_url; // Since the favicon is cached on the browser for a really long time, we @@ -179,7 +183,7 @@ const Discourse = Ember.Application.extend(FocusEvent, { }); }, - @computed("currentAssetVersion", "desiredAssetVersion") + @discourseComputed("currentAssetVersion", "desiredAssetVersion") requiresRefresh(currentAssetVersion, desiredAssetVersion) { return desiredAssetVersion && currentAssetVersion !== desiredAssetVersion; }, @@ -188,7 +192,7 @@ const Discourse = Ember.Application.extend(FocusEvent, { _pluginCallbacks.push({ version, code }); }, - assetVersion: Ember.computed({ + assetVersion: computed({ get() { return this.currentAssetVersion; }, @@ -205,4 +209,14 @@ const Discourse = Ember.Application.extend(FocusEvent, { }) }).create(); +Object.defineProperty(Discourse, "Model", { + get() { + deprecated("Use an `@ember/object` instead of Discourse.Model", { + since: "2.4.0", + dropFrom: "2.5.0" + }); + return EmberObject; + } +}); + export default Discourse; diff --git a/app/assets/javascripts/discourse/adapters/tag-info.js.es6 b/app/assets/javascripts/discourse/adapters/tag-info.js.es6 new file mode 100644 index 0000000000..ca04b6d212 --- /dev/null +++ b/app/assets/javascripts/discourse/adapters/tag-info.js.es6 @@ -0,0 +1,7 @@ +import RESTAdapter from "discourse/adapters/rest"; + +export default RESTAdapter.extend({ + pathFor(store, type, id) { + return "/tags/" + id + "/info"; + } +}); diff --git a/app/assets/javascripts/discourse/components/about-page-users.js.es6 b/app/assets/javascripts/discourse/components/about-page-users.js.es6 index 7d4ff773ba..59db7aee75 100644 --- a/app/assets/javascripts/discourse/components/about-page-users.js.es6 +++ b/app/assets/javascripts/discourse/components/about-page-users.js.es6 @@ -3,9 +3,10 @@ import { userPath } from "discourse/lib/url"; import { formatUsername, escapeExpression } from "discourse/lib/utilities"; import { normalize } from "discourse/components/user-info"; import { renderAvatar } from "discourse/helpers/user-avatar"; +import { computed } from "@ember/object"; export default Component.extend({ - usersTemplates: Ember.computed("users.[]", function() { + usersTemplates: computed("users.[]", function() { return (this.users || []).map(user => { let name = ""; if (user.name && normalize(user.username) !== normalize(user.name)) { diff --git a/app/assets/javascripts/discourse/components/add-category-tag-classes.js.es6 b/app/assets/javascripts/discourse/components/add-category-tag-classes.js.es6 index d4a75e6ebe..50d93f3a95 100644 --- a/app/assets/javascripts/discourse/components/add-category-tag-classes.js.es6 +++ b/app/assets/javascripts/discourse/components/add-category-tag-classes.js.es6 @@ -1,6 +1,6 @@ import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; export default Component.extend({ _slug: null, diff --git a/app/assets/javascripts/discourse/components/avatar-flair.js.es6 b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 index 4d0bd2e131..f25d596ba7 100644 --- a/app/assets/javascripts/discourse/components/avatar-flair.js.es6 +++ b/app/assets/javascripts/discourse/components/avatar-flair.js.es6 @@ -1,4 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import MountWidget from "discourse/components/mount-widget"; export default MountWidget.extend({ diff --git a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 index 3350ddff31..f9144abe59 100644 --- a/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/avatar-uploader.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; export default Component.extend(UploadMixin, { @@ -21,7 +21,7 @@ export default Component.extend(UploadMixin, { this.done(); }, - @computed("user_id") + @discourseComputed("user_id") data(user_id) { return { user_id }; } diff --git a/app/assets/javascripts/discourse/components/backup-codes.js.es6 b/app/assets/javascripts/discourse/components/backup-codes.js.es6 index 5e80cc3e3b..1109c4cde6 100644 --- a/app/assets/javascripts/discourse/components/backup-codes.js.es6 +++ b/app/assets/javascripts/discourse/components/backup-codes.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; // https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding function b64EncodeUnicode(str) { @@ -32,10 +32,10 @@ export default Component.extend({ } }, - @computed("formattedBackupCodes") + @discourseComputed("formattedBackupCodes") base64BackupCode: b64EncodeUnicode, - @computed("backupCodes") + @discourseComputed("backupCodes") formattedBackupCodes(backupCodes) { if (!backupCodes) return null; diff --git a/app/assets/javascripts/discourse/components/backup-uploader.js.es6 b/app/assets/javascripts/discourse/components/backup-uploader.js.es6 index e75470439d..331684ee25 100644 --- a/app/assets/javascripts/discourse/components/backup-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/backup-uploader.js.es6 @@ -1,14 +1,14 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; import { on } from "@ember/object/evented"; export default Component.extend(UploadMixin, { tagName: "span", - @computed("uploading", "uploadProgress") + @discourseComputed("uploading", "uploadProgress") uploadButtonText(uploading, progress) { return uploading ? I18n.t("admin.backups.upload.uploading_progress", { progress }) diff --git a/app/assets/javascripts/discourse/components/badge-button.js.es6 b/app/assets/javascripts/discourse/components/badge-button.js.es6 index dde5bfb804..9204a1cf7b 100644 --- a/app/assets/javascripts/discourse/components/badge-button.js.es6 +++ b/app/assets/javascripts/discourse/components/badge-button.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "span", @@ -10,7 +10,7 @@ export default Component.extend({ "badge.enabled::disabled" ], - @computed("badge.description") + @discourseComputed("badge.description") title(badgeDescription) { return $("
" + badgeDescription + "
").text(); }, diff --git a/app/assets/javascripts/discourse/components/badge-card.js.es6 b/app/assets/javascripts/discourse/components/badge-card.js.es6 index f9380b11eb..e0b192895b 100644 --- a/app/assets/javascripts/discourse/components/badge-card.js.es6 +++ b/app/assets/javascripts/discourse/components/badge-card.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { sanitize, emojiUnescape } from "discourse/lib/text"; export default Component.extend({ size: "medium", classNameBindings: [":badge-card", "size", "badge.slug"], - @computed("badge.url", "filterUser", "username") + @discourseComputed("badge.url", "filterUser", "username") url(badgeUrl, filterUser, username) { return filterUser ? `${badgeUrl}?username=${username}` : badgeUrl; }, - @computed("count", "badge.grant_count") + @discourseComputed("count", "badge.grant_count") displayCount(count, grantCount) { if (count == null) { return grantCount; @@ -21,7 +21,7 @@ export default Component.extend({ } }, - @computed("size") + @discourseComputed("size") summary(size) { if (size === "large") { const longDescription = this.get("badge.long_description"); diff --git a/app/assets/javascripts/discourse/components/badge-selector.js.es6 b/app/assets/javascripts/discourse/components/badge-selector.js.es6 index 94c08c95ca..3e02b0740e 100644 --- a/app/assets/javascripts/discourse/components/badge-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/badge-selector.js.es6 @@ -2,13 +2,13 @@ import Component from "@ember/component"; import { on, observes, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; import { findRawTemplate } from "discourse/lib/raw-templates"; const { makeArray } = Ember; export default Component.extend({ - @computed("placeholderKey") + @discourseComputed("placeholderKey") placeholder(placeholderKey) { return placeholderKey ? I18n.t(placeholderKey) : ""; }, diff --git a/app/assets/javascripts/discourse/components/badge-title.js.es6 b/app/assets/javascripts/discourse/components/badge-title.js.es6 index d7fd08c318..ad80c39ddc 100644 --- a/app/assets/javascripts/discourse/components/badge-title.js.es6 +++ b/app/assets/javascripts/discourse/components/badge-title.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["badge-title"], @@ -8,9 +8,9 @@ export default Component.extend({ saved: false, saving: false, - @computed("selectableUserBadges", "selectedUserBadgeId") + @discourseComputed("selectableUserBadges", "selectedUserBadgeId") selectedUserBadge(selectableUserBadges, selectedUserBadgeId) { - return selectableUserBadges.findBy("id", parseInt(selectedUserBadgeId)); + return selectableUserBadges.findBy("id", parseInt(selectedUserBadgeId, 10)); }, actions: { diff --git a/app/assets/javascripts/discourse/components/basic-topic-list.js.es6 b/app/assets/javascripts/discourse/components/basic-topic-list.js.es6 index 64942198ed..be4ec964c2 100644 --- a/app/assets/javascripts/discourse/components/basic-topic-list.js.es6 +++ b/app/assets/javascripts/discourse/components/basic-topic-list.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, not } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ loadingMore: alias("topicList.loadingMore"), loading: not("loaded"), - @computed("topicList.loaded") + @discourseComputed("topicList.loaded") loaded() { var topicList = this.topicList; if (topicList) { @@ -73,7 +73,7 @@ export default Component.extend({ }); }, - @computed("topics") + @discourseComputed("topics") showUnreadIndicator(topics) { return topics.some( topic => typeof topic.unread_by_group_member !== "undefined" @@ -105,7 +105,7 @@ export default Component.extend({ } } - const topic = this.topics.findBy("id", parseInt(topicId)); + const topic = this.topics.findBy("id", parseInt(topicId, 10)); this.appEvents.trigger("topic-entrance:show", { topic, position: target.offset() diff --git a/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 index e420fcce7c..9d00a25272 100644 --- a/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 +++ b/app/assets/javascripts/discourse/components/bread-crumbs.js.es6 @@ -1,15 +1,64 @@ -import { alias, filter, or } from "@ember/object/computed"; +import { filter } from "@ember/object/computed"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; +import deprecated from "discourse-common/lib/deprecated"; // A breadcrumb including category drop downs export default Component.extend({ classNameBindings: ["hidden:hidden", ":category-breadcrumb"], tagName: "ol", - parentCategory: alias("category.parentCategory"), + @discourseComputed("categories") + filteredCategories(categories) { + return categories.filter( + category => + this.siteSettings.allow_uncategorized_topics || + category.id !== this.site.uncategorized_category_id + ); + }, + + @discourseComputed( + "category.ancestors", + "filteredCategories", + "noSubcategories" + ) + categoryBreadcrumbs(categoryAncestors, filteredCategories, noSubcategories) { + categoryAncestors = categoryAncestors || []; + const parentCategories = [undefined, ...categoryAncestors]; + const categories = [...categoryAncestors, undefined]; + const zipped = parentCategories.map((x, i) => [x, categories[i]]); + + return zipped.map(record => { + const [parentCategory, category] = record; + + const options = filteredCategories.filter( + c => + c.get("parentCategory.id") === (parentCategory && parentCategory.id) + ); + + return { + category, + parentCategory, + options, + isSubcategory: !!parentCategory, + noSubcategories: !category && noSubcategories, + hasOptions: options.length !== 0 + }; + }); + }, + + @discourseComputed("category") + parentCategory(category) { + deprecated( + "The parentCategory property of the bread-crumbs component is deprecated" + ); + return category && category.parentCategory; + }, parentCategories: filter("categories", function(c) { + deprecated( + "The parentCategories property of the bread-crumbs component is deprecated" + ); if ( c.id === this.site.get("uncategorized_category_id") && !this.siteSettings.allow_uncategorized_topics @@ -21,8 +70,11 @@ export default Component.extend({ return !c.get("parentCategory"); }), - @computed("parentCategories") + @discourseComputed("parentCategories") parentCategoriesSorted(parentCategories) { + deprecated( + "The parentCategoriesSorted property of the bread-crumbs component is deprecated" + ); if (this.siteSettings.fixed_category_positions) { return parentCategories; } @@ -30,21 +82,32 @@ export default Component.extend({ return parentCategories.sortBy("totalTopicCount").reverse(); }, - @computed("category") + @discourseComputed("category") hidden(category) { return this.site.mobileView && !category; }, - firstCategory: or("{parentCategory,category}"), - - @computed("category", "parentCategory") - secondCategory(category, parentCategory) { - if (parentCategory) return category; - return null; + @discourseComputed("category", "parentCategory") + firstCategory(category, parentCategory) { + deprecated( + "The firstCategory property of the bread-crumbs component is deprecated" + ); + return parentCategory || category; }, - @computed("firstCategory", "hideSubcategories") + @discourseComputed("category", "parentCategory") + secondCategory(category, parentCategory) { + deprecated( + "The secondCategory property of the bread-crumbs component is deprecated" + ); + return parentCategory && category; + }, + + @discourseComputed("firstCategory", "hideSubcategories") childCategories(firstCategory, hideSubcategories) { + deprecated( + "The childCategories property of the bread-crumbs component is deprecated" + ); if (hideSubcategories) { return []; } diff --git a/app/assets/javascripts/discourse/components/categories-boxes-topic.js.es6 b/app/assets/javascripts/discourse/components/categories-boxes-topic.js.es6 index b5536923ec..ed2af66cad 100644 --- a/app/assets/javascripts/discourse/components/categories-boxes-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/categories-boxes-topic.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "li", - @computed("topic.pinned", "topic.closed", "topic.archived") + @discourseComputed("topic.pinned", "topic.closed", "topic.archived") topicStatusIcon(pinned, closed, archived) { if (pinned) { return "thumbtack"; diff --git a/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 b/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 index 40b16d03fa..ffd3a77ca8 100644 --- a/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 +++ b/app/assets/javascripts/discourse/components/categories-boxes-with-topics.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "section", @@ -9,7 +9,7 @@ export default Component.extend({ "anyLogos:with-logos:no-logos" ], - @computed("categories.[].uploaded_logo.url") + @discourseComputed("categories.[].uploaded_logo.url") anyLogos() { return this.categories.any(c => { return !isEmpty(c.get("uploaded_logo.url")); diff --git a/app/assets/javascripts/discourse/components/categories-boxes.js.es6 b/app/assets/javascripts/discourse/components/categories-boxes.js.es6 index 11b76ed1d4..b4dcfde7e8 100644 --- a/app/assets/javascripts/discourse/components/categories-boxes.js.es6 +++ b/app/assets/javascripts/discourse/components/categories-boxes.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import DiscourseURL from "discourse/lib/url"; export default Component.extend({ @@ -11,12 +11,12 @@ export default Component.extend({ "hasSubcategories:with-subcategories" ], - @computed("categories.[].uploaded_logo.url") + @discourseComputed("categories.[].uploaded_logo.url") anyLogos() { return this.categories.any(c => !isEmpty(c.get("uploaded_logo.url"))); }, - @computed("categories.[].subcategories") + @discourseComputed("categories.[].subcategories") hasSubcategories() { return this.categories.any(c => !isEmpty(c.get("subcategories"))); }, diff --git a/app/assets/javascripts/discourse/components/cdn-img.js.es6 b/app/assets/javascripts/discourse/components/cdn-img.js.es6 index 338c71a1ee..a5c7a2642a 100644 --- a/app/assets/javascripts/discourse/components/cdn-img.js.es6 +++ b/app/assets/javascripts/discourse/components/cdn-img.js.es6 @@ -1,16 +1,16 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { htmlSafe } from "@ember/template"; export default Component.extend({ tagName: "", - @computed("src") + @discourseComputed("src") cdnSrc(src) { return Discourse.getURLWithCDN(src); }, - @computed("width", "height") + @discourseComputed("width", "height") style(width, height) { if (width && height) { return htmlSafe(`--aspect-ratio: ${width / height};`); diff --git a/app/assets/javascripts/discourse/components/choose-message.js.es6 b/app/assets/javascripts/discourse/components/choose-message.js.es6 index cb6c251355..c5680dfaf9 100644 --- a/app/assets/javascripts/discourse/components/choose-message.js.es6 +++ b/app/assets/javascripts/discourse/components/choose-message.js.es6 @@ -2,9 +2,9 @@ import { get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { next } from "@ember/runloop"; import Component from "@ember/component"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { searchForTerm } from "discourse/lib/search"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; export default Component.extend({ loading: null, @@ -30,7 +30,7 @@ export default Component.extend({ this.set("loading", false); }, - search: debounce(function(title) { + search: discourseDebounce(function(title) { const currentTopicId = this.currentTopicId; if (isEmpty(title)) { diff --git a/app/assets/javascripts/discourse/components/choose-topic.js.es6 b/app/assets/javascripts/discourse/components/choose-topic.js.es6 index f2d4792414..51955db565 100644 --- a/app/assets/javascripts/discourse/components/choose-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/choose-topic.js.es6 @@ -1,9 +1,9 @@ import { isEmpty } from "@ember/utils"; import { next } from "@ember/runloop"; import Component from "@ember/component"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { searchForTerm } from "discourse/lib/search"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; export default Component.extend({ loading: null, @@ -33,7 +33,7 @@ export default Component.extend({ this.set("loading", false); }, - search: debounce(function(title) { + search: discourseDebounce(function(title) { if (!this.element || this.isDestroying || this.isDestroyed) { return; } diff --git a/app/assets/javascripts/discourse/components/color-picker-choice.js.es6 b/app/assets/javascripts/discourse/components/color-picker-choice.js.es6 index a1933193d2..a384ca1920 100644 --- a/app/assets/javascripts/discourse/components/color-picker-choice.js.es6 +++ b/app/assets/javascripts/discourse/components/color-picker-choice.js.es6 @@ -1,22 +1,22 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "button", attributeBindings: ["style", "title"], classNameBindings: [":colorpicker", "isUsed:used-color:unused-color"], - @computed("color", "usedColors") + @discourseComputed("color", "usedColors") isUsed(color, usedColors) { return (usedColors || []).indexOf(color.toUpperCase()) >= 0; }, - @computed("isUsed") + @discourseComputed("isUsed") title(isUsed) { return isUsed ? I18n.t("category.already_used") : null; }, - @computed("color") + @discourseComputed("color") style(color) { return `background-color: #${color};`.htmlSafe(); }, diff --git a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 index 8e4fd6c9f7..996b409f6b 100644 --- a/app/assets/javascripts/discourse/components/composer-action-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-action-title.js.es6 @@ -1,6 +1,6 @@ import { alias, equal } from "@ember/object/computed"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { PRIVATE_MESSAGE, CREATE_TOPIC, @@ -24,7 +24,7 @@ export default Component.extend({ action: alias("model.action"), isEditing: equal("action", EDIT), - @computed("options", "action") + @discourseComputed("options", "action") actionTitle(opts, action) { if (TITLES[action]) { return I18n.t(TITLES[action]); diff --git a/app/assets/javascripts/discourse/components/composer-body.js.es6 b/app/assets/javascripts/discourse/components/composer-body.js.es6 index f265917279..f2a6ae3bc8 100644 --- a/app/assets/javascripts/discourse/components/composer-body.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-body.js.es6 @@ -5,9 +5,9 @@ import { scheduleOnce } from "@ember/runloop"; import { later } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import Composer from "discourse/models/composer"; import afterTransition from "discourse/lib/after-transition"; import positioningWorkaround from "discourse/lib/safari-hacks"; @@ -42,12 +42,12 @@ export default Component.extend(KeyEnterEscape, { "currentUserPrimaryGroupClass" ], - @computed("currentUser.primary_group_name") + @discourseComputed("currentUser.primary_group_name") currentUserPrimaryGroupClass(primaryGroupName) { return primaryGroupName && `group-${primaryGroupName}`; }, - @computed("composer.composeState") + @discourseComputed("composer.composeState") composeState(composeState) { return composeState || Composer.CLOSED; }, diff --git a/app/assets/javascripts/discourse/components/composer-editor.js.es6 b/app/assets/javascripts/discourse/components/composer-editor.js.es6 index 876851b658..ce8634bfcf 100644 --- a/app/assets/javascripts/discourse/components/composer-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-editor.js.es6 @@ -6,10 +6,10 @@ import { later } from "@ember/runloop"; import Component from "@ember/component"; import userSearch from "discourse/lib/user-search"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { linkSeenMentions, fetchUnseenMentions @@ -26,28 +26,31 @@ import Composer from "discourse/models/composer"; import { load, LOADING_ONEBOX_CSS_CLASS } from "pretty-text/oneboxer"; import { applyInlineOneboxes } from "pretty-text/inline-oneboxer"; import { ajax } from "discourse/lib/ajax"; -import InputValidation from "discourse/models/input-validation"; +import EmberObject from "@ember/object"; import { findRawTemplate } from "discourse/lib/raw-templates"; import { iconHTML } from "discourse-common/lib/icon-library"; import { tinyAvatar, - displayErrorForUpload, - getUploadMarkdown, - validateUploadedFiles, - authorizesOneOrMoreImageExtensions, formatUsername, clipboardData, safariHacksDisabled } from "discourse/lib/utilities"; +import { + validateUploadedFiles, + authorizesOneOrMoreImageExtensions, + getUploadMarkdown, + displayErrorForUpload +} from "discourse/lib/uploads"; + import { cacheShortUploadUrl, resolveAllShortUrls } from "pretty-text/upload-short-url"; - import { INLINE_ONEBOX_LOADING_CSS_CLASS, INLINE_ONEBOX_CSS_CLASS } from "pretty-text/context/inline-onebox-css-classes"; +import ENV from "discourse-common/config/environment"; const REBUILD_SCROLL_MAP_EVENTS = ["composer:resized", "composer:typed-reply"]; @@ -68,7 +71,7 @@ export default Component.extend({ scrollMap: null, uploadFilenamePlaceholder: null, - @computed("uploadFilenamePlaceholder") + @discourseComputed("uploadFilenamePlaceholder") uploadPlaceholder(uploadFilenamePlaceholder) { const clipboard = I18n.t("clipboard"); const filename = uploadFilenamePlaceholder @@ -77,26 +80,26 @@ export default Component.extend({ return `[${I18n.t("uploading_filename", { filename })}]() `; }, - @computed("composer.requiredCategoryMissing") + @discourseComputed("composer.requiredCategoryMissing") replyPlaceholder(requiredCategoryMissing) { if (requiredCategoryMissing) { return "composer.reply_placeholder_choose_category"; } else { - const key = authorizesOneOrMoreImageExtensions() + const key = authorizesOneOrMoreImageExtensions(this.currentUser.staff) ? "reply_placeholder" : "reply_placeholder_no_images"; return `composer.${key}`; } }, - @computed + @discourseComputed showLink() { return ( this.currentUser && this.currentUser.get("link_posting_access") !== "none" ); }, - @computed("composer.requiredCategoryMissing", "composer.replyLength") + @discourseComputed("composer.requiredCategoryMissing", "composer.replyLength") disableTextarea(requiredCategoryMissing, replyLength) { return requiredCategoryMissing && replyLength === 0; }, @@ -122,7 +125,7 @@ export default Component.extend({ } }, - @computed + @discourseComputed markdownOptions() { return { previewing: true, @@ -213,7 +216,7 @@ export default Component.extend({ this.appEvents.trigger("composer:will-open"); }, - @computed( + @discourseComputed( "composer.reply", "composer.replyLength", "composer.missingReplyCharacters", @@ -246,7 +249,7 @@ export default Component.extend({ } if (reason) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason, lastShownAt: lastValidatedAt @@ -273,7 +276,7 @@ export default Component.extend({ const lastMatch = matchingPlaceholder[matchingPlaceholder.length - 1]; const regex = new RegExp(regexString); const orderNr = regex.exec(lastMatch)[1] - ? parseInt(regex.exec(lastMatch)[1]) + 1 + ? parseInt(regex.exec(lastMatch)[1], 10) + 1 : 1; data.orderNr = orderNr; const filenameWithOrderNr = `${filename}(${orderNr})`; @@ -700,6 +703,7 @@ export default Component.extend({ if (this._pasted) data.formData.pasted = true; const opts = { + user: this.currentUser, isPrivateMessage, allowStaffToUploadAnyFileInPm: this.siteSettings .allow_staff_to_upload_any_file_in_pm @@ -770,59 +774,26 @@ export default Component.extend({ } }, - _appendImageScaleButtons($images, imageScaleRegex) { - const buttonScales = [100, 75, 50]; - const imageWrapperTemplate = `
`; - const buttonWrapperTemplate = `
`; - const scaleButtonTemplate = ``; + _registerImageScaleButtonClick($preview) { + // original string `![image|690x220, 50%](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` + // group 1 `image` + // group 2 `690x220` + // group 3 `, 50%` + // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png' + // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title"' - $images.each((i, e) => { - const $e = $(e); + // Notes: + // Group 3 is optional. group 4 can match images with or without a markdown title. + // All matches are whitespace tolerant as long it's still valid markdown. + // If the image is inside a code block, we'll ignore it `(?!(.*`))`. + const imageScaleRegex = /!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?\]\((upload:\/\/.*?)\)(?!(.*`))/g; - const matches = this.get("composer.reply").match(imageScaleRegex); - - // ignore previewed upload markdown in codeblock - if (!matches || $e.hasClass("codeblock-image")) return; - - if (!$e.parent().hasClass("image-wrapper")) { - const match = matches[i]; - const matchingPlaceholder = imageScaleRegex.exec(match); - - if (!matchingPlaceholder) return; - - const currentScale = matchingPlaceholder[2] || 100; - - $e.data("index", i).wrap(imageWrapperTemplate); - $e.parent().append( - $(buttonWrapperTemplate).attr("data-image-index", i) - ); - - buttonScales.forEach((buttonScale, buttonIndex) => { - const activeClass = - parseInt(currentScale, 10) === buttonScale ? "active" : ""; - - const $scaleButton = $(scaleButtonTemplate) - .addClass(activeClass) - .attr("data-scale", buttonScale) - .text(`${buttonScale}%`); - - const $buttonWrapper = $e.parent().find(".button-wrapper"); - $buttonWrapper.append($scaleButton); - - if (buttonIndex !== buttonScales.length - 1) { - $buttonWrapper.append(``); - } - }); - } - }); - }, - - _registerImageScaleButtonClick($preview, imageScaleRegex) { $preview.off("click", ".scale-btn").on("click", ".scale-btn", e => { const index = parseInt( $(e.target) .parent() - .attr("data-image-index") + .attr("data-image-index"), + 10 ); const scale = e.target.attributes["data-scale"].value; @@ -851,45 +822,6 @@ export default Component.extend({ }); }, - _placeImageScaleButtons($preview) { - // regex matches only upload placeholders with size defined, - // which is required for resizing - - // original string `![image|690x220, 50%](upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title")` - // group 1 `image` - // group 2 `690x220` - // group 3 `, 50%` - // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png' - // group 4 'upload://1TjaobgKObzpU7xRMw2HuUc87vO.png "image title"' - - // Notes: - // Group 3 is optional. group 4 can match images with or without a markdown title. - // All matches are whitespace tolerant as long it's still valid markdown - - const imageScaleRegex = /!\[(.*?)\|(\d{1,4}x\d{1,4})(,\s*\d{1,3}%)?\]\((upload:\/\/.*?)\)/g; - - // wraps previewed upload markdown in a codeblock in its own class to keep a track - // of indexes later on to replace the correct upload placeholder in the composer - if ($preview.find(".codeblock-image").length === 0) { - $(this.element) - .find(".d-editor-preview *") - .contents() - .each(function() { - if (this.nodeType !== 3) return; // TEXT_NODE - const $this = $(this); - - if ($this.text().match(imageScaleRegex)) { - $this.wrap(""); - } - }); - } - - const $images = $preview.find("img.resizable, span.codeblock-image"); - - this._appendImageScaleButtons($images, imageScaleRegex); - this._registerImageScaleButtonClick($preview, imageScaleRegex); - }, - @on("willDestroyElement") _unbindUploadTarget() { this._validUploads = 0; @@ -911,7 +843,7 @@ export default Component.extend({ // need to wait a bit for the "slide down" transition of the composer later( () => this.appEvents.trigger("composer:closed"), - Ember.testing ? 0 : 400 + ENV.environment === "test" ? 0 : 400 ); }); @@ -1078,7 +1010,7 @@ export default Component.extend({ ); } - this._placeImageScaleButtons($preview); + this._registerImageScaleButtonClick($preview); this.trigger("previewRefreshed", $preview); this.afterRefresh($preview); diff --git a/app/assets/javascripts/discourse/components/composer-message.js.es6 b/app/assets/javascripts/discourse/components/composer-message.js.es6 index 6cb98b8018..ce42d201c9 100644 --- a/app/assets/javascripts/discourse/components/composer-message.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-message.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { getOwner } from "discourse-common/lib/get-owner"; export default Component.extend({ classNameBindings: [":composer-popup", ":hidden", "message.extraClass"], - @computed("message.templateName") + @discourseComputed("message.templateName") layout(templateName) { return getOwner(this).lookup(`template:composer/${templateName}`); }, diff --git a/app/assets/javascripts/discourse/components/composer-title.js.es6 b/app/assets/javascripts/discourse/components/composer-title.js.es6 index 556b24bda8..2eb06cb071 100644 --- a/app/assets/javascripts/discourse/components/composer-title.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-title.js.es6 @@ -4,14 +4,15 @@ import { debounce } from "@ember/runloop"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; -import InputValidation from "discourse/models/input-validation"; +} from "discourse-common/utils/decorators"; import { load } from "pretty-text/oneboxer"; import { lookupCache } from "pretty-text/oneboxer-cache"; import { ajax } from "discourse/lib/ajax"; import afterTransition from "discourse/lib/after-transition"; +import ENV from "discourse-common/config/environment"; +import EmberObject from "@ember/object"; export default Component.extend({ classNames: ["title-input"], @@ -33,7 +34,7 @@ export default Component.extend({ } }, - @computed( + @discourseComputed( "composer.titleLength", "composer.missingTitleCharacters", "composer.minimumTitleLength", @@ -59,7 +60,7 @@ export default Component.extend({ } if (reason) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason, lastShownAt: lastValidatedAt @@ -67,7 +68,7 @@ export default Component.extend({ } }, - @computed("watchForLink") + @discourseComputed("watchForLink") titleMaxLength() { // maxLength gets in the way of pasting long links, so don't use it if featured links are allowed. // Validation will display a message if titles are too long. @@ -83,7 +84,7 @@ export default Component.extend({ return; } - if (Ember.testing) { + if (ENV.environment === "test") { next(() => // not ideal but we don't want to run this in current // runloop to avoid an error in console @@ -181,7 +182,7 @@ export default Component.extend({ } }, - @computed("composer.title", "composer.titleLength") + @discourseComputed("composer.title", "composer.titleLength") isAbsoluteUrl(title, titleLength) { return ( titleLength > 0 && diff --git a/app/assets/javascripts/discourse/components/composer-toggles.js.es6 b/app/assets/javascripts/discourse/components/composer-toggles.js.es6 index 9f0547d641..e08e50fa3f 100644 --- a/app/assets/javascripts/discourse/components/composer-toggles.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-toggles.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", - @computed("composeState") + @discourseComputed("composeState") toggleTitle(composeState) { return composeState === "draft" || composeState === "saving" ? "composer.abandon" : "composer.collapse"; }, - @computed("composeState") + @discourseComputed("composeState") fullscreenTitle(composeState) { return composeState === "draft" ? "composer.open" @@ -20,14 +20,14 @@ export default Component.extend({ : "composer.enter_fullscreen"; }, - @computed("composeState") + @discourseComputed("composeState") toggleIcon(composeState) { return composeState === "draft" || composeState === "saving" ? "times" : "chevron-down"; }, - @computed("composeState") + @discourseComputed("composeState") fullscreenIcon(composeState) { return composeState === "draft" ? "chevron-up" diff --git a/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 b/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 index 3e9aa03784..14ca7e3556 100644 --- a/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/composer-user-selector.js.es6 @@ -1,9 +1,9 @@ import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend({ showSelector: true, @@ -58,17 +58,17 @@ export default Component.extend({ } }, - @computed("usernames") + @discourseComputed("usernames") splitUsernames(usernames) { return usernames.split(","); }, - @computed("splitUsernames", "defaultUsernameCount") + @discourseComputed("splitUsernames", "defaultUsernameCount") limitedUsernames(splitUsernames, count) { return splitUsernames.slice(0, count).join(", "); }, - @computed("splitUsernames", "defaultUsernameCount") + @discourseComputed("splitUsernames", "defaultUsernameCount") hiddenUsersCount(splitUsernames, count) { return `${splitUsernames.length - count} ${I18n.t("more")}`; }, diff --git a/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 b/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 index 1c6e37ec03..c98075275f 100644 --- a/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 +++ b/app/assets/javascripts/discourse/components/conditional-loading-spinner.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNameBindings: [ @@ -8,7 +8,7 @@ export default Component.extend({ "condition:visible" ], - @computed("size") + @discourseComputed("size") containerClass(size) { return size === "small" ? "inline-spinner" : undefined; } diff --git a/app/assets/javascripts/discourse/components/count-i18n.js.es6 b/app/assets/javascripts/discourse/components/count-i18n.js.es6 index 9bdb715dc5..d0fd9bdccb 100644 --- a/app/assets/javascripts/discourse/components/count-i18n.js.es6 +++ b/app/assets/javascripts/discourse/components/count-i18n.js.es6 @@ -1,17 +1,15 @@ import Component from "@ember/component"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -export default Component.extend( - bufferedRender({ - tagName: "span", - rerenderTriggers: ["count", "suffix"], +export default Component.extend({ + tagName: "span", + rerenderTriggers: ["count", "suffix"], + i18nCount: null, - buildBuffer(buffer) { - buffer.push( - I18n.t(this.key + (this.suffix || ""), { - count: this.count - }) - ); - } - }) -); + didReceiveAttrs() { + this._super(...arguments); + this.set( + "i18nCount", + I18n.t(this.key + (this.suffix || ""), { count: this.count }).htmlSafe() + ); + } +}); diff --git a/app/assets/javascripts/discourse/components/create-topics-notice.js.es6 b/app/assets/javascripts/discourse/components/create-topics-notice.js.es6 index 96c29f9081..5622cfab94 100644 --- a/app/assets/javascripts/discourse/components/create-topics-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/create-topics-notice.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import LivePostCounts from "discourse/models/live-post-counts"; export default Component.extend({ @@ -36,7 +36,7 @@ export default Component.extend({ } }, - @computed() + @discourseComputed() shouldSee() { const user = this.currentUser; return ( @@ -47,7 +47,12 @@ export default Component.extend({ ); }, - @computed("enabled", "shouldSee", "publicTopicCount", "publicPostCount") + @discourseComputed( + "enabled", + "shouldSee", + "publicTopicCount", + "publicPostCount" + ) hidden() { return ( !this.enabled || @@ -57,7 +62,7 @@ export default Component.extend({ ); }, - @computed( + @discourseComputed( "publicTopicCount", "publicPostCount", "topicTrackingState.incomingCount" diff --git a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 index 6fa3792d36..b3bb4e8d1f 100644 --- a/app/assets/javascripts/discourse/components/csv-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/csv-uploader.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; import { on } from "@ember/object/evented"; @@ -12,14 +12,14 @@ export default Component.extend(UploadMixin, { return { csvOnly: true }; }, - @computed("uploading") + @discourseComputed("uploading") uploadButtonText(uploading) { return uploading ? I18n.t("uploading") : I18n.t("user.invited.bulk_invite.text"); }, - @computed("uploading") + @discourseComputed("uploading") uploadButtonDisabled(uploading) { // https://github.com/emberjs/ember.js/issues/10976#issuecomment-132417731 return uploading ? true : null; diff --git a/app/assets/javascripts/discourse/components/d-button.js.es6 b/app/assets/javascripts/discourse/components/d-button.js.es6 index 359d058d24..1d0c9d04f8 100644 --- a/app/assets/javascripts/discourse/components/d-button.js.es6 +++ b/app/assets/javascripts/discourse/components/d-button.js.es6 @@ -1,6 +1,6 @@ -import { notEmpty, empty } from "@ember/object/computed"; +import { notEmpty, empty, equal } from "@ember/object/computed"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import DiscourseURL from "discourse/lib/url"; export default Component.extend({ @@ -24,9 +24,9 @@ export default Component.extend({ btnIcon: notEmpty("icon"), - btnLink: Ember.computed.equal("display", "link"), + btnLink: equal("display", "link"), - @computed("icon", "translatedLabel") + @discourseComputed("icon", "translatedLabel") btnType(icon, translatedLabel) { if (icon) { return translatedLabel ? "btn-icon-text" : "btn-icon"; @@ -37,7 +37,7 @@ export default Component.extend({ noText: empty("translatedLabel"), - @computed("title") + @discourseComputed("title") translatedTitle: { get() { if (this._translatedTitle) return this._translatedTitle; @@ -48,7 +48,7 @@ export default Component.extend({ } }, - @computed("label") + @discourseComputed("label") translatedLabel: { get() { if (this._translatedLabel) return this._translatedLabel; diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index 3d926b9783..f223071d73 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -7,10 +7,10 @@ import { inject as service } from "@ember/service"; import Component from "@ember/component"; /*global Mousetrap:true */ import { - default as computed, + default as discourseComputed, on, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { categoryHashtagTriggerRule } from "discourse/lib/category-hashtags"; import { search as searchCategoryTag } from "discourse/lib/category-tag-search"; import { cookAsync } from "discourse/lib/text"; @@ -30,6 +30,7 @@ import { emojiSearch, isSkinTonableEmoji } from "pretty-text/emoji"; import { emojiUrlFor } from "discourse/lib/text"; import showModal from "discourse/lib/show-modal"; import { Promise } from "rsvp"; +import ENV from "discourse-common/config/environment"; // Our head can be a static string or a function that returns a string // based on input (like for numbered lists). @@ -98,6 +99,7 @@ class Toolbar { id: "link", group: "insertions", shortcut: "K", + trimLeading: true, sendAction: event => this.context.send("showLinkModal", event) }); } @@ -137,7 +139,7 @@ class Toolbar { shortcut: "Shift+7", title: "composer.olist_title", perform: e => - e.applyList(i => (!i ? "1. " : `${parseInt(i) + 1}. `), "list_item") + e.applyList(i => (!i ? "1. " : `${parseInt(i, 10) + 1}. `), "list_item") }); if (siteSettings.support_mixed_text_direction) { @@ -228,7 +230,7 @@ export default Component.extend({ emojiPickerIsActive: false, emojiStore: service("emoji-store"), - @computed("placeholder") + @discourseComputed("placeholder") placeholderTranslated(placeholder) { if (placeholder) return I18n.t(placeholder); return null; @@ -326,7 +328,7 @@ export default Component.extend({ $(this.element.querySelector(".d-editor-preview")).off("click.preview"); }, - @computed + @discourseComputed toolbar() { const toolbar = new Toolbar( this.getProperties("site", "siteSettings", "showLink") @@ -375,7 +377,7 @@ export default Component.extend({ } // Debouncing in test mode is complicated - if (Ember.testing) { + if (ENV.environment === "test") { this._updatePreview(); } else { debounce(this, this._updatePreview, 30); @@ -910,7 +912,11 @@ export default Component.extend({ const captures = selected.pre.match(/\B:(\w*)$/); if (_.isEmpty(captures)) { - this._addText(selected, `:${code}:`); + if (selected.pre.match(/\S$/)) { + this._addText(selected, ` :${code}:`); + } else { + this._addText(selected, `:${code}:`); + } } else { let numOfRemovedChars = selected.pre.length - captures[1].length; selected.pre = selected.pre.slice( @@ -955,15 +961,14 @@ export default Component.extend({ } let linkText = ""; - this._lastSel = this._getSelected(); + this._lastSel = toolbarEvent.selected; if (this._lastSel) { - linkText = this._lastSel.value.trim(); + linkText = this._lastSel.value; } showModal("insert-hyperlink").setProperties({ - linkText: linkText, - _lastSel: this._lastSel, + linkText, toolbarEvent }); }, diff --git a/app/assets/javascripts/discourse/components/d-modal.js.es6 b/app/assets/javascripts/discourse/components/d-modal.js.es6 index 19d4d2264c..300a3e585c 100644 --- a/app/assets/javascripts/discourse/components/d-modal.js.es6 +++ b/app/assets/javascripts/discourse/components/d-modal.js.es6 @@ -1,5 +1,5 @@ import { next } from "@ember/runloop"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import Component from "@ember/component"; export default Component.extend({ diff --git a/app/assets/javascripts/discourse/components/d-navigation.js.es6 b/app/assets/javascripts/discourse/components/d-navigation.js.es6 index b44ad95efd..041e21e4a8 100644 --- a/app/assets/javascripts/discourse/components/d-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/d-navigation.js.es6 @@ -1,38 +1,35 @@ +import discourseComputed from "discourse-common/utils/decorators"; +import NavItem from "discourse/models/nav-item"; import { inject as service } from "@ember/service"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -export default Component.extend({ +export default Component.extend(FilterModeMixin, { router: service(), persistedQueryParams: null, tagName: "", - @computed("category") + @discourseComputed("category") showCategoryNotifications(category) { return category && this.currentUser; }, - @computed() + @discourseComputed() categories() { return this.site.get("categoriesList"); }, - @computed("hasDraft") + @discourseComputed("hasDraft") createTopicLabel(hasDraft) { return hasDraft ? "topic.open_draft" : "topic.create"; }, - @computed("category.can_edit") + @discourseComputed("category.can_edit") showCategoryEdit: canEdit => canEdit, - @computed("filterMode", "category", "noSubcategories") - navItems(filterMode, category, noSubcategories) { - // we don't want to show the period in the navigation bar since it's in a dropdown - if (filterMode.indexOf("top/") === 0) { - filterMode = filterMode.replace("top/", ""); - } - + @discourseComputed("filterType", "category", "noSubcategories") + navItems(filterType, category, noSubcategories) { let params; const currentRouteQueryParams = this.get("router.currentRoute.queryParams"); if (this.persistedQueryParams && currentRouteQueryParams) { @@ -47,8 +44,8 @@ export default Component.extend({ }, {}); } - return Discourse.NavItem.buildList(category, { - filterMode, + return NavItem.buildList(category, { + filterType, noSubcategories, persistedQueryParams: params }); diff --git a/app/assets/javascripts/discourse/components/date-input.js.es6 b/app/assets/javascripts/discourse/components/date-input.js.es6 index 927be37881..7d56b964a2 100644 --- a/app/assets/javascripts/discourse/components/date-input.js.es6 +++ b/app/assets/javascripts/discourse/components/date-input.js.es6 @@ -3,16 +3,16 @@ import Component from "@ember/component"; /* global Pikaday:true */ import loadScript from "discourse/lib/load-script"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["d-date-input"], date: null, _picker: null, - @computed("site.mobileView") + @discourseComputed("site.mobileView") inputType(mobileView) { return mobileView ? "date" : "text"; }, @@ -26,6 +26,10 @@ export default Component.extend({ } else { this._loadPikadayPicker(container); } + + if (this.date && this._picker) { + this._picker.setDate(this.date, true); + } }, didUpdateAttrs() { @@ -71,6 +75,9 @@ export default Component.extend({ picker.destroy = () => { /* do nothing for native */ }; + picker.setDate = date => { + picker.value = date; + }; this._picker = picker; }, @@ -92,7 +99,7 @@ export default Component.extend({ this._picker = null; }, - @computed() + @discourseComputed() placeholder() { return I18n.t("dates.placeholder"); }, diff --git a/app/assets/javascripts/discourse/components/date-picker.js.es6 b/app/assets/javascripts/discourse/components/date-picker.js.es6 index e9403326bb..8ca644e952 100644 --- a/app/assets/javascripts/discourse/components/date-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker.js.es6 @@ -3,9 +3,9 @@ import Component from "@ember/component"; /* global Pikaday:true */ import loadScript from "discourse/lib/load-script"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; const DATE_FORMAT = "YYYY-MM-DD"; @@ -14,7 +14,7 @@ export default Component.extend({ _picker: null, value: null, - @computed("site.mobileView") + @discourseComputed("site.mobileView") inputType(mobileView) { return mobileView ? "date" : "text"; }, @@ -83,7 +83,7 @@ export default Component.extend({ } }, - @computed() + @discourseComputed() placeholder() { return I18n.t("dates.placeholder"); }, diff --git a/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 index ec048ec730..52438ffd94 100644 --- a/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 +++ b/app/assets/javascripts/discourse/components/date-time-input-range.js.es6 @@ -16,7 +16,7 @@ export default Component.extend({ toPanelActive: equal("currentPanel", "to"), _valid(state) { - if (state.to < state.from) { + if (state.to && state.from && state.to < state.from) { return I18n.t("date_time_picker.errors.to_before_from"); } diff --git a/app/assets/javascripts/discourse/components/date-time-input.js.es6 b/app/assets/javascripts/discourse/components/date-time-input.js.es6 index 41b41a8d32..392dc5dac0 100644 --- a/app/assets/javascripts/discourse/components/date-time-input.js.es6 +++ b/app/assets/javascripts/discourse/components/date-time-input.js.es6 @@ -1,15 +1,17 @@ import Component from "@ember/component"; +import { computed } from "@ember/object"; + export default Component.extend({ classNames: ["d-date-time-input"], date: null, showTime: true, - _hours: Ember.computed("date", function() { - return this.date ? this.date.getHours() : null; + _hours: computed("date", function() { + return this.date && this.showTime ? this.date.getHours() : null; }), - _minutes: Ember.computed("date", function() { - return this.date ? this.date.getMinutes() : null; + _minutes: computed("date", function() { + return this.date && this.showTime ? this.date.getMinutes() : null; }), actions: { diff --git a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 index 112723ba12..ecd11c2a6e 100644 --- a/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 +++ b/app/assets/javascripts/discourse/components/desktop-notification-config.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { or } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import KeyValueStore from "discourse/lib/key-value-store"; import { context, @@ -19,12 +19,12 @@ const keyValueStore = new KeyValueStore(context); export default Component.extend({ classNames: ["controls"], - @computed("isNotSupported") + @discourseComputed("isNotSupported") notificationsPermission(isNotSupported) { return isNotSupported ? "" : Notification.permission; }, - @computed + @discourseComputed notificationsDisabled: { set(value) { keyValueStore.setItem("notifications-disabled", value); @@ -35,27 +35,27 @@ export default Component.extend({ } }, - @computed + @discourseComputed isNotSupported() { return typeof window.Notification === "undefined"; }, - @computed("isNotSupported", "notificationsPermission") + @discourseComputed("isNotSupported", "notificationsPermission") isDeniedPermission(isNotSupported, notificationsPermission) { return isNotSupported ? false : notificationsPermission === "denied"; }, - @computed("isNotSupported", "notificationsPermission") + @discourseComputed("isNotSupported", "notificationsPermission") isGrantedPermission(isNotSupported, notificationsPermission) { return isNotSupported ? false : notificationsPermission === "granted"; }, - @computed("isGrantedPermission", "notificationsDisabled") + @discourseComputed("isGrantedPermission", "notificationsDisabled") isEnabledDesktop(isGrantedPermission, notificationsDisabled) { return isGrantedPermission ? !notificationsDisabled : false; }, - @computed + @discourseComputed isEnabledPush: { set(value) { const user = this.currentUser; diff --git a/app/assets/javascripts/discourse/components/directory-toggle.js.es6 b/app/assets/javascripts/discourse/components/directory-toggle.js.es6 index d45e9ce9e7..a2dcec81b5 100644 --- a/app/assets/javascripts/discourse/components/directory-toggle.js.es6 +++ b/app/assets/javascripts/discourse/components/directory-toggle.js.es6 @@ -1,48 +1,51 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -import computed from "ember-addons/ember-computed-decorators"; -export default Component.extend( - bufferedRender({ - tagName: "th", - classNames: ["sortable"], - attributeBindings: ["title"], - rerenderTriggers: ["order", "asc"], - labelKey: null, +export default Component.extend({ + tagName: "th", + classNames: ["sortable"], + attributeBindings: ["title"], + labelKey: null, + chevronIcon: null, + columnIcon: null, - @computed("field", "labelKey") - title(field, labelKey) { - if (!labelKey) { - labelKey = `directory.${this.field}`; - } - - return I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) }); - }, - - buildBuffer(buffer) { - const icon = this.icon; - if (icon) { - buffer.push(iconHTML(icon)); - } - - const field = this.field; - buffer.push(I18n.t(this.labelKey || `directory.${field}`)); - - if (field === this.order) { - buffer.push(iconHTML(this.asc ? "chevron-up" : "chevron-down")); - } - }, - - click() { - const currentOrder = this.order, - field = this.field; - - if (currentOrder === field) { - this.set("asc", this.asc ? null : true); - } else { - this.setProperties({ order: field, asc: null }); - } + @discourseComputed("field", "labelKey") + title(field, labelKey) { + if (!labelKey) { + labelKey = `directory.${this.field}`; } - }) -); + + return I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) }); + }, + + toggleProperties() { + if (this.order === this.field) { + this.set("asc", this.asc ? null : true); + } else { + this.setProperties({ order: this.field, asc: null }); + } + }, + toggleChevron() { + if (this.order === this.field) { + let chevron = iconHTML(this.asc ? "chevron-up" : "chevron-down"); + this.set("chevronIcon", `${chevron}`.htmlSafe()); + } else { + this.set("chevronIcon", null); + } + }, + click() { + this.toggleProperties(); + }, + didReceiveAttrs() { + this._super(...arguments); + this.toggleChevron(); + }, + init() { + this._super(...arguments); + if (this.icon) { + let columnIcon = iconHTML(this.icon); + this.set("columnIcon", `${columnIcon}`.htmlSafe()); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/discourse-banner.js.es6 b/app/assets/javascripts/discourse/components/discourse-banner.js.es6 index 6c8b18ea1e..9182998d76 100644 --- a/app/assets/javascripts/discourse/components/discourse-banner.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-banner.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ - @computed("user.dismissed_banner_key", "banner.key", "hide") + @discourseComputed("user.dismissed_banner_key", "banner.key", "hide") visible(dismissedBannerKey, bannerKey, hide) { dismissedBannerKey = dismissedBannerKey || this.keyValueStore.get("dismissed_banner_key"); diff --git a/app/assets/javascripts/discourse/components/discourse-linked-text.js.es6 b/app/assets/javascripts/discourse/components/discourse-linked-text.js.es6 index fe4e7fd9c8..85d6fd3cb9 100644 --- a/app/assets/javascripts/discourse/components/discourse-linked-text.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-linked-text.js.es6 @@ -1,10 +1,10 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Component.extend({ tagName: "span", - @computed("text") + @discourseComputed("text") translatedText(text) { if (text) return I18n.t(text); }, diff --git a/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 b/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 index ea07e0d5fe..57e5c1841a 100644 --- a/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-tag-bound.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "a", classNameBindings: [":discourse-tag", "style", "tagClass"], attributeBindings: ["href"], - @computed("tagRecord.id") + @discourseComputed("tagRecord.id") tagClass(tagRecordId) { return "tag-" + tagRecordId; }, - @computed("tagRecord.id") + @discourseComputed("tagRecord.id") href(tagRecordId) { return Discourse.getURL("/tags/" + tagRecordId); } diff --git a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 index c9484f6264..83e7bf6fa1 100644 --- a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/discourse-topic.js.es6 @@ -9,7 +9,7 @@ import AddArchetypeClass from "discourse/mixins/add-archetype-class"; import ClickTrack from "discourse/lib/click-track"; import Scrolling from "discourse/mixins/scrolling"; import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300; diff --git a/app/assets/javascripts/discourse/components/discovery-categories.js.es6 b/app/assets/javascripts/discourse/components/discovery-categories.js.es6 index d5c70bbf9a..bd4f34a2e7 100644 --- a/app/assets/javascripts/discourse/components/discovery-categories.js.es6 +++ b/app/assets/javascripts/discourse/components/discovery-categories.js.es6 @@ -1,6 +1,6 @@ import Component from "@ember/component"; import UrlRefresh from "discourse/mixins/url-refresh"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; const CATEGORIES_LIST_BODY_CLASS = "categories-list"; diff --git a/app/assets/javascripts/discourse/components/discovery-topics-list.js.es6 b/app/assets/javascripts/discourse/components/discovery-topics-list.js.es6 index 6706cacd4a..f4329ba2f2 100644 --- a/app/assets/javascripts/discourse/components/discovery-topics-list.js.es6 +++ b/app/assets/javascripts/discourse/components/discovery-topics-list.js.es6 @@ -1,7 +1,7 @@ import { schedule } from "@ember/runloop"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import LoadMore from "discourse/mixins/load-more"; import UrlRefresh from "discourse/mixins/url-refresh"; diff --git a/app/assets/javascripts/discourse/components/edit-category-general.js.es6 b/app/assets/javascripts/discourse/components/edit-category-general.js.es6 index e94e2a77d2..9d3e548a20 100644 --- a/app/assets/javascripts/discourse/components/edit-category-general.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-general.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { not } from "@ember/object/computed"; import { buildCategoryPanel } from "discourse/components/edit-category-panel"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; import Category from "discourse/models/category"; -import computed from "ember-addons/ember-computed-decorators"; export default buildCategoryPanel("general", { init() { @@ -21,7 +21,7 @@ export default buildCategoryPanel("general", { ), // background colors are available as a pipe-separated string - @computed + @discourseComputed backgroundColors() { const categories = this.site.get("categoriesList"); return this.siteSettings.category_colors @@ -37,12 +37,12 @@ export default buildCategoryPanel("general", { .uniq(); }, - @computed + @discourseComputed noCategoryStyle() { return this.siteSettings.category_style === "none"; }, - @computed("category.id", "category.color") + @discourseComputed("category.id", "category.color") usedBackgroundColors(categoryId, categoryColor) { const categories = this.site.get("categoriesList"); @@ -57,14 +57,14 @@ export default buildCategoryPanel("general", { .compact(); }, - @computed + @discourseComputed parentCategories() { return this.site .get("categoriesList") - .filter(c => !c.get("parentCategory")); + .filter(c => c.level + 1 < Discourse.SiteSettings.max_category_nesting); }, - @computed( + @discourseComputed( "category.parent_category_id", "category.categoryName", "category.color", @@ -76,14 +76,14 @@ export default buildCategoryPanel("general", { name, color, text_color: textColor, - parent_category_id: parseInt(parentCategoryId), + parent_category_id: parseInt(parentCategoryId, 10), read_restricted: category.get("read_restricted") }); return categoryBadgeHTML(c, { link: false }); }, // We can change the parent if there are no children - @computed("category.id") + @discourseComputed("category.id") subCategories(categoryId) { if (isEmpty(categoryId)) { return null; @@ -91,7 +91,7 @@ export default buildCategoryPanel("general", { return Category.list().filterBy("parent_category_id", categoryId); }, - @computed("category.isUncategorizedCategory", "category.id") + @discourseComputed("category.isUncategorizedCategory", "category.id") showDescription(isUncategorizedCategory, categoryId) { return !isUncategorizedCategory && categoryId; }, diff --git a/app/assets/javascripts/discourse/components/edit-category-images.js.es6 b/app/assets/javascripts/discourse/components/edit-category-images.js.es6 index 2e12268885..94956edf36 100644 --- a/app/assets/javascripts/discourse/components/edit-category-images.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-images.js.es6 @@ -1,14 +1,14 @@ import EmberObject from "@ember/object"; import { buildCategoryPanel } from "discourse/components/edit-category-panel"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default buildCategoryPanel("images").extend({ - @computed("category.uploaded_background.url") + @discourseComputed("category.uploaded_background.url") backgroundImageUrl(uploadedBackgroundUrl) { return uploadedBackgroundUrl || ""; }, - @computed("category.uploaded_logo.url") + @discourseComputed("category.uploaded_logo.url") logoImageUrl(uploadedLogoUrl) { return uploadedLogoUrl || ""; }, diff --git a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 index be41b0a09c..00ad068467 100644 --- a/app/assets/javascripts/discourse/components/edit-category-security.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-security.js.es6 @@ -1,6 +1,6 @@ import { buildCategoryPanel } from "discourse/components/edit-category-panel"; import PermissionType from "discourse/models/permission-type"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default buildCategoryPanel("security", { editingPermissions: false, @@ -38,7 +38,7 @@ export default buildCategoryPanel("security", { if (!this.get("category.is_special")) { this.category.addPermission({ group_name: group + "", - permission: PermissionType.create({ id: parseInt(id) }) + permission: PermissionType.create({ id: parseInt(id, 10) }) }); } diff --git a/app/assets/javascripts/discourse/components/edit-category-settings.js.es6 b/app/assets/javascripts/discourse/components/edit-category-settings.js.es6 index 5d692cc6eb..29556d2c9a 100644 --- a/app/assets/javascripts/discourse/components/edit-category-settings.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-settings.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { empty, and } from "@ember/object/computed"; import { setting } from "discourse/lib/computed"; import { buildCategoryPanel } from "discourse/components/edit-category-panel"; -import computed from "ember-addons/ember-computed-decorators"; import { searchPriorities } from "discourse/components/concerns/category-search-priorities"; import Group from "discourse/models/group"; @@ -20,7 +20,7 @@ export default buildCategoryPanel("settings", { ), isDefaultSortOrder: empty("category.sort_order"), - @computed + @discourseComputed availableSubcategoryListStyles() { return [ { name: I18n.t("category.subcategory_list_styles.rows"), value: "rows" }, @@ -47,7 +47,7 @@ export default buildCategoryPanel("settings", { return Group.findAll({ term, ignore_automatic: true }); }, - @computed + @discourseComputed availableViews() { return [ { name: I18n.t("filters.latest.title"), value: "latest" }, @@ -55,7 +55,7 @@ export default buildCategoryPanel("settings", { ]; }, - @computed + @discourseComputed availableTopPeriods() { return ["all", "yearly", "quarterly", "monthly", "weekly", "daily"].map( p => { @@ -64,7 +64,7 @@ export default buildCategoryPanel("settings", { ); }, - @computed + @discourseComputed searchPrioritiesOptions() { const options = []; @@ -80,7 +80,7 @@ export default buildCategoryPanel("settings", { return options; }, - @computed + @discourseComputed availableSorts() { return [ "likes", @@ -97,7 +97,7 @@ export default buildCategoryPanel("settings", { .sort((a, b) => a.name.localeCompare(b.name)); }, - @computed + @discourseComputed sortAscendingOptions() { return [ { name: I18n.t("category.sort_ascending"), value: "true" }, diff --git a/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 b/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 index 42be7233af..9fa7984533 100644 --- a/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-category-tab.js.es6 @@ -1,20 +1,20 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import { propertyEqual } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "li", classNameBindings: ["active", "tabClassName"], - @computed("tab") + @discourseComputed("tab") tabClassName(tab) { return "edit-category-" + tab; }, active: propertyEqual("selectedTab", "tab"), - @computed("tab") + @discourseComputed("tab") title(tab) { return I18n.t("category." + tab.replace("-", "_")); }, diff --git a/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 b/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 index 5b774ca667..00fa6b2bad 100644 --- a/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 +++ b/app/assets/javascripts/discourse/components/edit-topic-timer-form.js.es6 @@ -3,11 +3,10 @@ import { alias, equal, or } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; - +} from "discourse-common/utils/decorators"; import { PUBLISH_TO_CATEGORY_STATUS_TYPE, OPEN_STATUS_TYPE, @@ -27,7 +26,7 @@ export default Component.extend({ reminder: equal("selection", REMINDER_TYPE), showTimeOnly: or("autoOpen", "autoDelete", "reminder", "autoBump"), - @computed( + @discourseComputed( "topicTimer.updateTime", "loading", "publishToCategory", @@ -41,7 +40,7 @@ export default Component.extend({ ); }, - @computed("topic.visible") + @discourseComputed("topic.visible") excludeCategoryId(visible) { if (visible) return this.get("topic.category_id"); }, diff --git a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 index 52f9e8add4..fccf3c597f 100644 --- a/app/assets/javascripts/discourse/components/emoji-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/emoji-picker.js.es6 @@ -1,15 +1,16 @@ import { inject as service } from "@ember/service"; import Component from "@ember/component"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import { findRawTemplate } from "discourse/lib/raw-templates"; import { emojiUrlFor } from "discourse/lib/text"; - import { extendedEmojiList, isSkinTonableEmoji, emojiSearch } from "pretty-text/emoji"; import { safariHacksDisabled } from "discourse/lib/utilities"; +import ENV from "discourse-common/config/environment"; + const { run } = Ember; const PER_ROW = 11; @@ -441,7 +442,10 @@ export default Component.extend({ ); $diversityScales.on("click", event => { const $selectedDiversity = $(event.currentTarget); - this.set("selectedDiversity", parseInt($selectedDiversity.data("level"))); + this.set( + "selectedDiversity", + parseInt($selectedDiversity.data("level"), 10) + ); return false; }); }, @@ -509,7 +513,7 @@ export default Component.extend({ this.$picker.css(_.merge(attributes, options)); }; - if (Ember.testing || !this.automaticPositioning) { + if (ENV.environment === "test" || !this.automaticPositioning) { desktopPositioning(); return; } diff --git a/app/assets/javascripts/discourse/components/emoji-uploader.js.es6 b/app/assets/javascripts/discourse/components/emoji-uploader.js.es6 index 37dfd5ea10..fa7ee8fd10 100644 --- a/app/assets/javascripts/discourse/components/emoji-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/emoji-uploader.js.es6 @@ -1,6 +1,6 @@ import { notEmpty, not } from "@ember/object/computed"; import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import UploadMixin from "discourse/mixins/upload"; export default Component.extend(UploadMixin, { @@ -15,7 +15,7 @@ export default Component.extend(UploadMixin, { }; }, - @computed("hasName", "name") + @discourseComputed("hasName", "name") data(hasName, name) { return hasName ? { name } : {}; }, diff --git a/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 b/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 index d2b980a8f2..4c3432f277 100644 --- a/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 +++ b/app/assets/javascripts/discourse/components/expanding-text-area.js.es6 @@ -1,5 +1,5 @@ import { scheduleOnce } from "@ember/runloop"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import autosize from "discourse/lib/autosize"; export default Ember.TextArea.extend({ diff --git a/app/assets/javascripts/discourse/components/flag-action-type.js.es6 b/app/assets/javascripts/discourse/components/flag-action-type.js.es6 index c42818914d..a88356c9a9 100644 --- a/app/assets/javascripts/discourse/components/flag-action-type.js.es6 +++ b/app/assets/javascripts/discourse/components/flag-action-type.js.es6 @@ -1,17 +1,22 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { and, not, equal } from "@ember/object/computed"; import Component from "@ember/component"; import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["flag-action-type"], - @computed("flag.name_key") + @discourseComputed("flag.name_key") customPlaceholder(nameKey) { return I18n.t("flagging.custom_placeholder_" + nameKey); }, - @computed("flag.name", "flag.name_key", "flag.is_custom_flag", "username") + @discourseComputed( + "flag.name", + "flag.name_key", + "flag.is_custom_flag", + "username" + ) formattedName(name, nameKey, isCustomFlag, username) { if (isCustomFlag) { return name.replace("{{username}}", username); @@ -20,7 +25,7 @@ export default Component.extend({ } }, - @computed("flag", "selectedFlag") + @discourseComputed("flag", "selectedFlag") selected(flag, selectedFlag) { return flag === selectedFlag; }, @@ -29,12 +34,12 @@ export default Component.extend({ showDescription: not("showMessageInput"), isNotifyUser: equal("flag.name_key", "notify_user"), - @computed("flag.description", "flag.short_description") + @discourseComputed("flag.description", "flag.short_description") description(long_description, short_description) { return this.site.mobileView ? short_description : long_description; }, - @computed("message.length") + @discourseComputed("message.length") customMessageLengthClasses(messageLength) { return messageLength < Discourse.SiteSettings.min_personal_message_post_length @@ -42,7 +47,7 @@ export default Component.extend({ : "ok"; }, - @computed("message.length") + @discourseComputed("message.length") customMessageLength(messageLength) { const len = messageLength || 0; const minLen = Discourse.SiteSettings.min_personal_message_post_length; diff --git a/app/assets/javascripts/discourse/components/flag-selection.js.es6 b/app/assets/javascripts/discourse/components/flag-selection.js.es6 index b52f544907..39548b045f 100644 --- a/app/assets/javascripts/discourse/components/flag-selection.js.es6 +++ b/app/assets/javascripts/discourse/components/flag-selection.js.es6 @@ -1,6 +1,6 @@ import { next } from "@ember/runloop"; import Component from "@ember/component"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; // Mostly hacks because `flag.hbs` didn't use `radio-button` export default Component.extend({ diff --git a/app/assets/javascripts/discourse/components/flat-button.js.es6 b/app/assets/javascripts/discourse/components/flat-button.js.es6 index 1348836907..af44422025 100644 --- a/app/assets/javascripts/discourse/components/flat-button.js.es6 +++ b/app/assets/javascripts/discourse/components/flat-button.js.es6 @@ -1,12 +1,12 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Component.extend({ tagName: "button", classNames: ["btn-flat"], attributeBindings: ["disabled", "translatedTitle:title"], - @computed("title") + @discourseComputed("title") translatedTitle(title) { if (title) return I18n.t(title); }, diff --git a/app/assets/javascripts/discourse/components/footer-nav.js.es6 b/app/assets/javascripts/discourse/components/footer-nav.js.es6 index d94910364e..c4d412346e 100644 --- a/app/assets/javascripts/discourse/components/footer-nav.js.es6 +++ b/app/assets/javascripts/discourse/components/footer-nav.js.es6 @@ -2,7 +2,7 @@ import { throttle } from "@ember/runloop"; import MountWidget from "discourse/components/mount-widget"; import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction"; import Scrolling from "discourse/mixins/scrolling"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { isAppWebview, postRNWebviewMessage } from "discourse/lib/utilities"; const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 150; diff --git a/app/assets/javascripts/discourse/components/future-date-input.js.es6 b/app/assets/javascripts/discourse/components/future-date-input.js.es6 index 8746cfb0ef..05313f8ea0 100644 --- a/app/assets/javascripts/discourse/components/future-date-input.js.es6 +++ b/app/assets/javascripts/discourse/components/future-date-input.js.es6 @@ -2,9 +2,9 @@ import { isEmpty } from "@ember/utils"; import { equal, and, empty } from "@ember/object/computed"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { FORMAT } from "select-kit/components/future-date-input-selector"; import { PUBLISH_TO_CATEGORY_STATUS_TYPE } from "discourse/controllers/edit-topic-timer"; @@ -59,7 +59,7 @@ export default Component.extend({ this.set("basedOnLastPost", this.isBasedOnLastPost); }, - @computed("input", "isBasedOnLastPost") + @discourseComputed("input", "isBasedOnLastPost") duration(input, isBasedOnLastPost) { const now = moment(); @@ -70,7 +70,7 @@ export default Component.extend({ } }, - @computed("input", "isBasedOnLastPost") + @discourseComputed("input", "isBasedOnLastPost") executeAt(input, isBasedOnLastPost) { if (isBasedOnLastPost) { return moment() @@ -87,7 +87,7 @@ export default Component.extend({ if (this.label) this.set("displayLabel", I18n.t(this.label)); }, - @computed( + @discourseComputed( "statusType", "input", "isCustom", @@ -118,7 +118,7 @@ export default Component.extend({ } }, - @computed("isBasedOnLastPost", "input", "lastPostedAt") + @discourseComputed("isBasedOnLastPost", "input", "lastPostedAt") willCloseImmediately(isBasedOnLastPost, input, lastPostedAt) { if (isBasedOnLastPost && input) { let closeDate = moment(lastPostedAt); @@ -127,7 +127,7 @@ export default Component.extend({ } }, - @computed("isBasedOnLastPost", "lastPostedAt") + @discourseComputed("isBasedOnLastPost", "lastPostedAt") willCloseI18n(isBasedOnLastPost, lastPostedAt) { if (isBasedOnLastPost) { const diff = Math.round( diff --git a/app/assets/javascripts/discourse/components/global-notice.js.es6 b/app/assets/javascripts/discourse/components/global-notice.js.es6 index e16243f207..7b80c97715 100644 --- a/app/assets/javascripts/discourse/components/global-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/global-notice.js.es6 @@ -1,6 +1,6 @@ import { bind } from "@ember/runloop"; import Component from "@ember/component"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; import LogsNotice from "discourse/services/logs-notice"; import { bufferedRender } from "discourse-common/lib/buffered-render"; @@ -75,9 +75,7 @@ export default Component.extend( buffer.push( notices .map(n => { - var html = `
`; + var html = `
`; if (n[2]) html += n[2]; html += `${n[0]}
`; return html; diff --git a/app/assets/javascripts/discourse/components/google-search.js.es6 b/app/assets/javascripts/discourse/components/google-search.js.es6 index 246e7ab2a3..43db648e81 100644 --- a/app/assets/javascripts/discourse/components/google-search.js.es6 +++ b/app/assets/javascripts/discourse/components/google-search.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["google-search-form"], @@ -8,7 +8,7 @@ export default Component.extend({ hidden: alias("siteSettings.login_required"), - @computed + @discourseComputed siteUrl() { return `${location.protocol}//${location.host}${Discourse.getURL("/")}`; } diff --git a/app/assets/javascripts/discourse/components/group-card-contents.js.es6 b/app/assets/javascripts/discourse/components/group-card-contents.js.es6 index bd6d029e49..1838b32352 100644 --- a/app/assets/javascripts/discourse/components/group-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/group-card-contents.js.es6 @@ -1,10 +1,11 @@ import { alias, match, gt, or } from "@ember/object/computed"; import Component from "@ember/component"; import { setting } from "discourse/lib/computed"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import CardContentsBase from "discourse/mixins/card-contents-base"; import CleansUp from "discourse/mixins/cleans-up"; import { groupPath } from "discourse/lib/url"; +import { Promise } from "rsvp"; const maxMembersToDisplay = 10; @@ -34,14 +35,14 @@ export default Component.extend(CardContentsBase, CleansUp, { group: null, - @computed("group.user_count", "group.members.length") + @discourseComputed("group.user_count", "group.members.length") moreMembersCount: (memberCount, maxMemberDisplay) => memberCount - maxMemberDisplay, - @computed("group.name") + @discourseComputed("group.name") groupClass: name => (name ? `group-card-${name}` : ""), - @computed("group") + @discourseComputed("group") groupPath(group) { return groupPath(group.name); }, @@ -55,8 +56,9 @@ export default Component.extend(CardContentsBase, CleansUp, { if (!group.flair_url && !group.flair_bg_color) { group.set("flair_url", "fa-users"); } - group.set("limit", maxMembersToDisplay); - return group.findMembers(); + return group.members.length < maxMembersToDisplay + ? group.findMembers({ limit: maxMembersToDisplay }, true) + : Promise.resolve(); }) .catch(() => this._close()) .finally(() => this.set("loading", null)); diff --git a/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 index 7b2b7110a0..81b17bcb8d 100644 --- a/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 +++ b/app/assets/javascripts/discourse/components/group-flair-inputs.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { debounce } from "@ember/runloop"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { escapeExpression } from "discourse/lib/utilities"; import { convertIconClass } from "discourse-common/lib/icon-library"; import { ajax } from "discourse/lib/ajax"; @@ -10,17 +10,17 @@ import { htmlSafe } from "@ember/template"; export default Component.extend({ classNames: ["group-flair-inputs"], - @computed + @discourseComputed demoAvatarUrl() { return Discourse.getURL("/images/avatar.png"); }, - @computed("model.flair_url") + @discourseComputed("model.flair_url") flairPreviewIcon(flairURL) { return flairURL && /fa(r|b?)-/.test(flairURL); }, - @computed("model.flair_url", "flairPreviewIcon") + @discourseComputed("model.flair_url", "flairPreviewIcon") flairPreviewIconUrl(flairURL, flairPreviewIcon) { return flairPreviewIcon ? convertIconClass(flairURL) : ""; }, @@ -49,12 +49,12 @@ export default Component.extend({ } }, - @computed("model.flair_url", "flairPreviewIcon") + @discourseComputed("model.flair_url", "flairPreviewIcon") flairPreviewImage(flairURL, flairPreviewIcon) { return flairURL && !flairPreviewIcon; }, - @computed( + @discourseComputed( "model.flair_url", "flairPreviewImage", "model.flairBackgroundHexColor", @@ -81,12 +81,12 @@ export default Component.extend({ return htmlSafe(style); }, - @computed("model.flairBackgroundHexColor") + @discourseComputed("model.flairBackgroundHexColor") flairPreviewClasses(flairBackgroundHexColor) { if (flairBackgroundHexColor) return "rounded"; }, - @computed("flairPreviewImage") + @discourseComputed("flairPreviewImage") flairPreviewLabel(flairPreviewImage) { const key = flairPreviewImage ? "image" : "icon"; return I18n.t(`groups.flair_preview_${key}`); diff --git a/app/assets/javascripts/discourse/components/group-index-toggle.js.es6 b/app/assets/javascripts/discourse/components/group-index-toggle.js.es6 index 247b7e1829..f84b9b3d21 100644 --- a/app/assets/javascripts/discourse/components/group-index-toggle.js.es6 +++ b/app/assets/javascripts/discourse/components/group-index-toggle.js.es6 @@ -1,29 +1,30 @@ import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; -export default Component.extend( - bufferedRender({ - tagName: "th", - classNames: ["sortable"], - rerenderTriggers: ["order", "desc"], - - buildBuffer(buffer) { - buffer.push(""); - buffer.push(I18n.t(this.i18nKey)); - - if (this.field === this.order) { - buffer.push(iconHTML(this.desc ? "chevron-down" : "chevron-up")); - } - buffer.push(""); - }, - - click() { - if (this.order === this.field) { - this.set("desc", this.desc ? null : true); - } else { - this.setProperties({ order: this.field, desc: null }); - } +export default Component.extend({ + tagName: "th", + classNames: ["sortable"], + chevronIcon: null, + toggleProperties() { + if (this.order === this.field) { + this.set("desc", this.desc ? null : true); + } else { + this.setProperties({ order: this.field, desc: null }); } - }) -); + }, + toggleChevron() { + if (this.order === this.field) { + let chevron = iconHTML(this.desc ? "chevron-down" : "chevron-up"); + this.set("chevronIcon", `${chevron}`.htmlSafe()); + } else { + this.set("chevronIcon", null); + } + }, + click() { + this.toggleProperties(); + }, + didReceiveAttrs() { + this._super(...arguments); + this.toggleChevron(); + } +}); diff --git a/app/assets/javascripts/discourse/components/group-manage-logs-filter.js.es6 b/app/assets/javascripts/discourse/components/group-manage-logs-filter.js.es6 index a6712bf498..98e0c16cdd 100644 --- a/app/assets/javascripts/discourse/components/group-manage-logs-filter.js.es6 +++ b/app/assets/javascripts/discourse/components/group-manage-logs-filter.js.es6 @@ -1,15 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", - @computed("type") + @discourseComputed("type") label(type) { return I18n.t(`groups.manage.logs.${type}`); }, - @computed("value", "type") + @discourseComputed("value", "type") filterText(value, type) { return type === "action" ? I18n.t(`group_histories.actions.${value}`) diff --git a/app/assets/javascripts/discourse/components/group-manage-save-button.js.es6 b/app/assets/javascripts/discourse/components/group-manage-save-button.js.es6 index 726bfede36..9adcf9ea82 100644 --- a/app/assets/javascripts/discourse/components/group-manage-save-button.js.es6 +++ b/app/assets/javascripts/discourse/components/group-manage-save-button.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ saving: null, - @computed("saving") + @discourseComputed("saving") savingText(saving) { if (saving) return I18n.t("saving"); return saving ? I18n.t("saving") : I18n.t("save"); diff --git a/app/assets/javascripts/discourse/components/group-member-dropdown.js.es6 b/app/assets/javascripts/discourse/components/group-member-dropdown.js.es6 index 5cb7099058..7a860c29de 100644 --- a/app/assets/javascripts/discourse/components/group-member-dropdown.js.es6 +++ b/app/assets/javascripts/discourse/components/group-member-dropdown.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; export default DropdownSelectBoxComponent.extend({ @@ -16,7 +16,7 @@ export default DropdownSelectBoxComponent.extend({ autoHighlight() {}, - @computed("member.owner") + @discourseComputed("member.owner") content(isOwner) { const items = [ { diff --git a/app/assets/javascripts/discourse/components/group-members-input.js.es6 b/app/assets/javascripts/discourse/components/group-members-input.js.es6 deleted file mode 100644 index e1f701f96c..0000000000 --- a/app/assets/javascripts/discourse/components/group-members-input.js.es6 +++ /dev/null @@ -1,91 +0,0 @@ -import { isEmpty } from "@ember/utils"; -import { lte } from "@ember/object/computed"; -import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import { propertyEqual } from "discourse/lib/computed"; - -export default Component.extend({ - classNames: ["group-members-input"], - addButton: true, - - @computed("model.limit", "model.offset", "model.user_count") - currentPage(limit, offset, userCount) { - if (userCount === 0) { - return 0; - } - - return Math.floor(offset / limit) + 1; - }, - - @computed("model.limit", "model.user_count") - totalPages(limit, userCount) { - if (userCount === 0) { - return 0; - } - return Math.ceil(userCount / limit); - }, - - @computed("model.usernames") - disableAddButton(usernames) { - return !usernames || !(usernames.length > 0); - }, - - showingFirst: lte("currentPage", 1), - showingLast: propertyEqual("currentPage", "totalPages"), - - actions: { - next() { - if (this.showingLast) { - return; - } - - const group = this.model; - const offset = Math.min( - group.get("offset") + group.get("limit"), - group.get("user_count") - ); - group.set("offset", offset); - - return group.findMembers(); - }, - - previous() { - if (this.showingFirst) { - return; - } - - const group = this.model; - const offset = Math.max(group.get("offset") - group.get("limit"), 0); - group.set("offset", offset); - - return group.findMembers(); - }, - - addMembers() { - if (isEmpty(this.get("model.usernames"))) { - return; - } - this.model.addMembers(this.get("model.usernames")).catch(popupAjaxError); - this.set("model.usernames", null); - }, - - removeMember(member) { - const message = I18n.t("groups.manage.delete_member_confirm", { - username: member.get("username"), - group: this.get("model.name") - }); - - return bootbox.confirm( - message, - I18n.t("no_value"), - I18n.t("yes_value"), - confirm => { - if (confirm) { - this.model.removeMember(member); - } - } - ); - } - } -}); diff --git a/app/assets/javascripts/discourse/components/group-membership-button.js.es6 b/app/assets/javascripts/discourse/components/group-membership-button.js.es6 index 6b58c5370d..7c4abeda18 100644 --- a/app/assets/javascripts/discourse/components/group-membership-button.js.es6 +++ b/app/assets/javascripts/discourse/components/group-membership-button.js.es6 @@ -1,27 +1,27 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; import showModal from "discourse/lib/show-modal"; export default Component.extend({ classNames: ["group-membership-button"], - @computed("model.public_admission", "userIsGroupUser") + @discourseComputed("model.public_admission", "userIsGroupUser") canJoinGroup(publicAdmission, userIsGroupUser) { return publicAdmission && !userIsGroupUser; }, - @computed("model.public_exit", "userIsGroupUser") + @discourseComputed("model.public_exit", "userIsGroupUser") canLeaveGroup(publicExit, userIsGroupUser) { return publicExit && userIsGroupUser; }, - @computed("model.allow_membership_requests", "userIsGroupUser") + @discourseComputed("model.allow_membership_requests", "userIsGroupUser") canRequestMembership(allowMembershipRequests, userIsGroupUser) { return allowMembershipRequests && !userIsGroupUser; }, - @computed("model.is_group_user") + @discourseComputed("model.is_group_user") userIsGroupUser(isGroupUser) { return !!isGroupUser; }, diff --git a/app/assets/javascripts/discourse/components/group-post.js.es6 b/app/assets/javascripts/discourse/components/group-post.js.es6 index 5a3a096f49..34c83529ef 100644 --- a/app/assets/javascripts/discourse/components/group-post.js.es6 +++ b/app/assets/javascripts/discourse/components/group-post.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ - @computed("post.url") + @discourseComputed("post.url") postUrl: Discourse.getURL }); diff --git a/app/assets/javascripts/discourse/components/group-selector.js.es6 b/app/assets/javascripts/discourse/components/group-selector.js.es6 index 4d4b431888..54b789664b 100644 --- a/app/assets/javascripts/discourse/components/group-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/group-selector.js.es6 @@ -3,12 +3,12 @@ import Component from "@ember/component"; import { on, observes, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; import { findRawTemplate } from "discourse/lib/raw-templates"; export default Component.extend({ - @computed("placeholderKey") + @discourseComputed("placeholderKey") placeholder(placeholderKey) { return placeholderKey ? I18n.t(placeholderKey) : ""; }, diff --git a/app/assets/javascripts/discourse/components/groups-form-interaction-fields.js.es6 b/app/assets/javascripts/discourse/components/groups-form-interaction-fields.js.es6 index e4d1e6bb79..f3ba2d92a4 100644 --- a/app/assets/javascripts/discourse/components/groups-form-interaction-fields.js.es6 +++ b/app/assets/javascripts/discourse/components/groups-form-interaction-fields.js.es6 @@ -1,5 +1,5 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Component.extend({ init() { @@ -46,7 +46,11 @@ export default Component.extend({ ]; }, - @computed("siteSettings.email_in", "model.automatic", "currentUser.admin") + @discourseComputed( + "siteSettings.email_in", + "model.automatic", + "currentUser.admin" + ) showEmailSettings(emailIn, automatic, isAdmin) { return emailIn && isAdmin && !automatic; } diff --git a/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6 b/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6 index 1115786b08..b85eef4fa5 100644 --- a/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6 +++ b/app/assets/javascripts/discourse/components/groups-form-membership-fields.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ init() { @@ -17,15 +17,18 @@ export default Component.extend({ ]; }, - @computed("model.visibility_level", "model.public_admission") + @discourseComputed("model.visibility_level", "model.public_admission") disableMembershipRequestSetting(visibility_level, publicAdmission) { - visibility_level = parseInt(visibility_level); + visibility_level = parseInt(visibility_level, 10); return publicAdmission || visibility_level > 1; }, - @computed("model.visibility_level", "model.allow_membership_requests") + @discourseComputed( + "model.visibility_level", + "model.allow_membership_requests" + ) disablePublicSetting(visibility_level, allowMembershipRequests) { - visibility_level = parseInt(visibility_level); + visibility_level = parseInt(visibility_level, 10); return allowMembershipRequests || visibility_level > 1; } }); diff --git a/app/assets/javascripts/discourse/components/groups-form-profile-fields.js.es6 b/app/assets/javascripts/discourse/components/groups-form-profile-fields.js.es6 index 98eabcea3f..fda6dd8ec6 100644 --- a/app/assets/javascripts/discourse/components/groups-form-profile-fields.js.es6 +++ b/app/assets/javascripts/discourse/components/groups-form-profile-fields.js.es6 @@ -2,12 +2,13 @@ import { isEmpty } from "@ember/utils"; import { not } from "@ember/object/computed"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import Group from "discourse/models/group"; -import InputValidation from "discourse/models/input-validation"; -import debounce from "discourse/lib/debounce"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseDebounce from "discourse/lib/debounce"; +import EmberObject from "@ember/object"; export default Component.extend({ disableSave: null, @@ -26,7 +27,7 @@ export default Component.extend({ canEdit: not("model.automatic"), - @computed("basicNameValidation", "uniqueNameValidation") + @discourseComputed("basicNameValidation", "uniqueNameValidation") nameValidation(basicNameValidation, uniqueNameValidation) { return uniqueNameValidation ? uniqueNameValidation : basicNameValidation; }, @@ -64,36 +65,38 @@ export default Component.extend({ ); }, - checkGroupName: debounce(function() { + checkGroupName: discourseDebounce(function() { name = this.nameInput; if (isEmpty(name)) return; - Group.checkName(name).then(response => { - const validationName = "uniqueNameValidation"; + Group.checkName(name) + .then(response => { + const validationName = "uniqueNameValidation"; - if (response.available) { - this.set( - validationName, - InputValidation.create({ - ok: true, - reason: I18n.t("admin.groups.new.name.available") - }) - ); + if (response.available) { + this.set( + validationName, + EmberObject.create({ + ok: true, + reason: I18n.t("admin.groups.new.name.available") + }) + ); - this.set("disableSave", false); - this.set("model.name", this.nameInput); - } else { - let reason; - - if (response.errors) { - reason = response.errors.join(" "); + this.set("disableSave", false); + this.set("model.name", this.nameInput); } else { - reason = I18n.t("admin.groups.new.name.not_available"); - } + let reason; - this.set(validationName, this._failedInputValidation(reason)); - } - }); + if (response.errors) { + reason = response.errors.join(" "); + } else { + reason = I18n.t("admin.groups.new.name.not_available"); + } + + this.set(validationName, this._failedInputValidation(reason)); + } + }) + .catch(popupAjaxError); }, 500), _failedInputValidation(reason) { @@ -101,6 +104,6 @@ export default Component.extend({ const options = { failed: true }; if (reason) options.reason = reason; - this.set("basicNameValidation", InputValidation.create(options)); + this.set("basicNameValidation", EmberObject.create(options)); } }); diff --git a/app/assets/javascripts/discourse/components/groups-info.js.es6 b/app/assets/javascripts/discourse/components/groups-info.js.es6 index cf439ef7d0..18f86c829d 100644 --- a/app/assets/javascripts/discourse/components/groups-info.js.es6 +++ b/app/assets/javascripts/discourse/components/groups-info.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "span", classNames: ["group-info-details"], - @computed("group.full_name", "group.title") - showFullName(fullName, title) { - return fullName && fullName.length && fullName !== title; + @discourseComputed("group.full_name") + showFullName(fullName) { + return fullName && fullName.length; } }); diff --git a/app/assets/javascripts/discourse/components/honeypot-input.js.es6 b/app/assets/javascripts/discourse/components/honeypot-input.js.es6 index 06895b0586..8231303da5 100644 --- a/app/assets/javascripts/discourse/components/honeypot-input.js.es6 +++ b/app/assets/javascripts/discourse/components/honeypot-input.js.es6 @@ -1,4 +1,4 @@ -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default Ember.TextField.extend({ @on("init") diff --git a/app/assets/javascripts/discourse/components/image-uploader.js.es6 b/app/assets/javascripts/discourse/components/image-uploader.js.es6 index 514a985223..6db47602ad 100644 --- a/app/assets/javascripts/discourse/components/image-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/image-uploader.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { next } from "@ember/runloop"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; import lightbox from "discourse/lib/lightbox"; import { ajax } from "discourse/lib/ajax"; @@ -24,12 +24,12 @@ export default Component.extend(UploadMixin, { } }, - @computed("imageUrl", "placeholderUrl") + @discourseComputed("imageUrl", "placeholderUrl") showingPlaceholder(imageUrl, placeholderUrl) { return !imageUrl && placeholderUrl; }, - @computed("placeholderUrl") + @discourseComputed("placeholderUrl") placeholderStyle(url) { if (isEmpty(url)) { return "".htmlSafe(); @@ -37,7 +37,7 @@ export default Component.extend(UploadMixin, { return `background-image: url(${url})`.htmlSafe(); }, - @computed("imageUrl") + @discourseComputed("imageUrl") imageCDNURL(url) { if (isEmpty(url)) { return "".htmlSafe(); @@ -46,12 +46,12 @@ export default Component.extend(UploadMixin, { return Discourse.getURLWithCDN(url); }, - @computed("imageCDNURL") + @discourseComputed("imageCDNURL") backgroundStyle(url) { return `background-image: url(${url})`.htmlSafe(); }, - @computed("imageUrl") + @discourseComputed("imageUrl") imageBaseName(imageUrl) { if (isEmpty(imageUrl)) return; return imageUrl.split("/").slice(-1)[0]; diff --git a/app/assets/javascripts/discourse/components/images-uploader.js.es6 b/app/assets/javascripts/discourse/components/images-uploader.js.es6 index d3a9d6c3b7..4e68324b0c 100644 --- a/app/assets/javascripts/discourse/components/images-uploader.js.es6 +++ b/app/assets/javascripts/discourse/components/images-uploader.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import UploadMixin from "discourse/mixins/upload"; export default Component.extend(UploadMixin, { type: "avatar", tagName: "span", - @computed("uploading") + @discourseComputed("uploading") uploadButtonText(uploading) { return uploading ? I18n.t("uploading") : I18n.t("upload"); }, diff --git a/app/assets/javascripts/discourse/components/input-tip.js.es6 b/app/assets/javascripts/discourse/components/input-tip.js.es6 index 28e73eb0a2..1a12d8ebc0 100644 --- a/app/assets/javascripts/discourse/components/input-tip.js.es6 +++ b/app/assets/javascripts/discourse/components/input-tip.js.es6 @@ -1,21 +1,30 @@ import { alias, not } from "@ember/object/computed"; import Component from "@ember/component"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; import { iconHTML } from "discourse-common/lib/icon-library"; -export default Component.extend( - bufferedRender({ - classNameBindings: [":tip", "good", "bad"], - rerenderTriggers: ["validation"], +export default Component.extend({ + classNameBindings: [":tip", "good", "bad"], + rerenderTriggers: ["validation"], + tipIcon: null, + tipReason: null, - bad: alias("validation.failed"), - good: not("bad"), + bad: alias("validation.failed"), + good: not("bad"), - buildBuffer(buffer) { - const reason = this.get("validation.reason"); - if (reason) { - buffer.push(iconHTML(this.good ? "check" : "times") + " " + reason); - } + tipIconHTML() { + let icon = iconHTML(this.good ? "check" : "times"); + return `${icon}`.htmlSafe(); + }, + + didReceiveAttrs() { + this._super(...arguments); + let reason = this.get("validation.reason"); + if (reason) { + this.set("tipIcon", this.tipIconHTML()); + this.set("tipReason", reason); + } else { + this.set("tipIcon", null); + this.set("tipReason", null); } - }) -); + } +}); diff --git a/app/assets/javascripts/discourse/components/invite-panel.js.es6 b/app/assets/javascripts/discourse/components/invite-panel.js.es6 index b1743d4f99..8056a92dcd 100644 --- a/app/assets/javascripts/discourse/components/invite-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/invite-panel.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias, and, equal } from "@ember/object/computed"; import EmberObject from "@ember/object"; import Component from "@ember/component"; import { emailValid } from "discourse/lib/utilities"; -import computed from "ember-addons/ember-computed-decorators"; import Group from "discourse/models/group"; import Invite from "discourse/models/invite"; import { i18n } from "discourse/lib/computed"; @@ -30,7 +30,7 @@ export default Component.extend({ this.reset(); }, - @computed( + @discourseComputed( "isAdmin", "emailOrUsername", "invitingToTopic", @@ -73,7 +73,7 @@ export default Component.extend({ return false; }, - @computed( + @discourseComputed( "isAdmin", "emailOrUsername", "inviteModel.saving", @@ -113,24 +113,24 @@ export default Component.extend({ return false; }, - @computed("inviteModel.saving") + @discourseComputed("inviteModel.saving") buttonTitle(saving) { return saving ? "topic.inviting" : "topic.invite_reply.action"; }, // We are inviting to a topic if the topic isn't the current user. // The current user would mean we are inviting to the forum in general. - @computed("inviteModel") + @discourseComputed("inviteModel") invitingToTopic(inviteModel) { return inviteModel !== this.currentUser; }, - @computed("inviteModel", "inviteModel.details.can_invite_via_email") + @discourseComputed("inviteModel", "inviteModel.details.can_invite_via_email") canInviteViaEmail(inviteModel, canInviteViaEmail) { return this.inviteModel === this.currentUser ? true : canInviteViaEmail; }, - @computed("isPM", "canInviteViaEmail") + @discourseComputed("isPM", "canInviteViaEmail") showCopyInviteButton(isPM, canInviteViaEmail) { return canInviteViaEmail && !isPM; }, @@ -148,7 +148,7 @@ export default Component.extend({ // scope to allowed usernames allowExistingMembers: alias("invitingToTopic"), - @computed("isAdmin", "inviteModel.group_users") + @discourseComputed("isAdmin", "inviteModel.group_users") isGroupOwnerOrAdmin(isAdmin, groupUsers) { return ( isAdmin || (groupUsers && groupUsers.some(groupUser => groupUser.owner)) @@ -156,7 +156,7 @@ export default Component.extend({ }, // Show Groups? (add invited user to private group) - @computed( + @discourseComputed( "isGroupOwnerOrAdmin", "emailOrUsername", "isPrivateTopic", @@ -180,13 +180,13 @@ export default Component.extend({ ); }, - @computed("emailOrUsername") + @discourseComputed("emailOrUsername") showCustomMessage(emailOrUsername) { return this.inviteModel === this.currentUser || emailValid(emailOrUsername); }, // Instructional text for the modal. - @computed( + @discourseComputed( "isPM", "invitingToTopic", "emailOrUsername", @@ -231,7 +231,7 @@ export default Component.extend({ } }, - @computed("isPrivateTopic") + @discourseComputed("isPrivateTopic") showGroupsClass(isPrivateTopic) { return isPrivateTopic ? "required" : "optional"; }, @@ -240,7 +240,7 @@ export default Component.extend({ return Group.findAll({ term, ignore_automatic: true }); }, - @computed("isPM", "emailOrUsername", "invitingExistingUserToTopic") + @discourseComputed("isPM", "emailOrUsername", "invitingExistingUserToTopic") successMessage(isPM, emailOrUsername, invitingExistingUserToTopic) { if (this.hasGroups) { return I18n.t("topic.invite_private.success_group"); @@ -257,14 +257,14 @@ export default Component.extend({ } }, - @computed("isPM") + @discourseComputed("isPM") errorMessage(isPM) { return isPM ? I18n.t("topic.invite_private.error") : I18n.t("topic.invite_reply.error"); }, - @computed("canInviteViaEmail") + @discourseComputed("canInviteViaEmail") placeholderKey(canInviteViaEmail) { return canInviteViaEmail ? "topic.invite_private.email_or_username_placeholder" diff --git a/app/assets/javascripts/discourse/components/login-buttons.js.es6 b/app/assets/javascripts/discourse/components/login-buttons.js.es6 index a5a0967411..1073dd3ec0 100644 --- a/app/assets/javascripts/discourse/components/login-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/login-buttons.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { findAll } from "discourse/models/login-method"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ elementId: "login-buttons", classNameBindings: ["hidden"], - @computed("buttons.length", "showLoginWithEmailLink") + @discourseComputed("buttons.length", "showLoginWithEmailLink") hidden(buttonsCount, showLoginWithEmailLink) { return buttonsCount === 0 && !showLoginWithEmailLink; }, - @computed + @discourseComputed buttons() { return findAll(); }, diff --git a/app/assets/javascripts/discourse/components/mobile-nav.js.es6 b/app/assets/javascripts/discourse/components/mobile-nav.js.es6 index a47c09c5c4..02d35f53d3 100644 --- a/app/assets/javascripts/discourse/components/mobile-nav.js.es6 +++ b/app/assets/javascripts/discourse/components/mobile-nav.js.es6 @@ -1,6 +1,6 @@ import { next } from "@ember/runloop"; import Component from "@ember/component"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; export default Component.extend({ @on("init") diff --git a/app/assets/javascripts/discourse/components/nav-item.js.es6 b/app/assets/javascripts/discourse/components/nav-item.js.es6 index d648e892d3..7358e2ded6 100644 --- a/app/assets/javascripts/discourse/components/nav-item.js.es6 +++ b/app/assets/javascripts/discourse/components/nav-item.js.es6 @@ -1,15 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; import Component from "@ember/component"; /* You might be looking for navigation-item. */ import { iconHTML } from "discourse-common/lib/icon-library"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "li", classNameBindings: ["active"], router: service(), - @computed("label", "i18nLabel", "icon") + @discourseComputed("label", "i18nLabel", "icon") contents(label, i18nLabel, icon) { let text = i18nLabel || I18n.t(label); if (icon) { @@ -18,7 +18,7 @@ export default Component.extend({ return text; }, - @computed("route", "router.currentRoute") + @discourseComputed("route", "router.currentRoute") active(route, currentRoute) { if (!route) { return; diff --git a/app/assets/javascripts/discourse/components/navigation-bar.js.es6 b/app/assets/javascripts/discourse/components/navigation-bar.js.es6 index eb8cb454b3..6051726395 100644 --- a/app/assets/javascripts/discourse/components/navigation-bar.js.es6 +++ b/app/assets/javascripts/discourse/components/navigation-bar.js.es6 @@ -1,13 +1,14 @@ import { next } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import DiscourseURL from "discourse/lib/url"; import { renderedConnectorsFor } from "discourse/lib/plugin-connectors"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -export default Component.extend({ +export default Component.extend(FilterModeMixin, { tagName: "ul", classNameBindings: [":nav", ":nav-pills"], elementId: "navigation-bar", @@ -17,15 +18,11 @@ export default Component.extend({ this.set("connectors", renderedConnectorsFor("extra-nav-item", null, this)); }, - @computed("filterMode", "navItems") - selectedNavItem(filterMode, navItems) { - if (filterMode.indexOf("top/") === 0) { - filterMode = "top"; - } + @discourseComputed("filterType", "navItems") + selectedNavItem(filterType, navItems) { let item = navItems.find(i => i.active === true); - item = - item || navItems.find(i => i.get("filterMode").indexOf(filterMode) === 0); + item = item || navItems.find(i => i.get("filterType") === filterType); if (!item) { let connectors = this.connectors; @@ -38,7 +35,7 @@ export default Component.extend({ typeof (c.connectorClass.displayName === "function") ) { let path = c.connectorClass.path(category); - if (path.indexOf(filterMode) > 0) { + if (path.indexOf(filterType) > 0) { item = { displayName: c.connectorClass.displayName() }; diff --git a/app/assets/javascripts/discourse/components/navigation-item.js.es6 b/app/assets/javascripts/discourse/components/navigation-item.js.es6 index 6d55112a94..90c56da1c9 100644 --- a/app/assets/javascripts/discourse/components/navigation-item.js.es6 +++ b/app/assets/javascripts/discourse/components/navigation-item.js.es6 @@ -1,8 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { bufferedRender } from "discourse-common/lib/buffered-render"; +import FilterModeMixin from "discourse/mixins/filter-mode"; export default Component.extend( + FilterModeMixin, bufferedRender({ tagName: "li", classNameBindings: [ @@ -15,15 +17,12 @@ export default Component.extend( hidden: false, rerenderTriggers: ["content.count"], - @computed("content.filterMode", "filterMode", "content.active") - active(contentFilterMode, filterMode, active) { + @discourseComputed("content.filterType", "filterType", "content.active") + active(contentFilterType, filterType, active) { if (active !== undefined) { return active; } - return ( - contentFilterMode === filterMode || - filterMode.indexOf(contentFilterMode) === 0 - ); + return contentFilterType === filterType; }, buildBuffer(buffer) { @@ -34,7 +33,7 @@ export default Component.extend( // Include the category id if the option is present if (content.get("includeCategoryId")) { - let categoryId = this.get("category.id"); + let categoryId = this.get("content.category.id"); if (categoryId) { queryParams.push(`category_id=${categoryId}`); } diff --git a/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 index 8d20705f04..1ffb99c0c2 100644 --- a/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 +++ b/app/assets/javascripts/discourse/components/notification-consent-banner.js.es6 @@ -1,13 +1,11 @@ -import { default as computed } from "ember-addons/ember-computed-decorators"; - +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { keyValueStore as pushNotificationKeyValueStore } from "discourse/lib/push-notifications"; - import { default as DesktopNotificationConfig } from "discourse/components/desktop-notification-config"; const userDismissedPromptKey = "dismissed-prompt"; export default DesktopNotificationConfig.extend({ - @computed + @discourseComputed bannerDismissed: { set(value) { pushNotificationKeyValueStore.setItem(userDismissedPromptKey, value); @@ -18,7 +16,7 @@ export default DesktopNotificationConfig.extend({ } }, - @computed( + @discourseComputed( "isNotSupported", "isEnabled", "bannerDismissed", diff --git a/app/assets/javascripts/discourse/components/number-field.js.es6 b/app/assets/javascripts/discourse/components/number-field.js.es6 index 1399de713e..ca2db4d124 100644 --- a/app/assets/javascripts/discourse/components/number-field.js.es6 +++ b/app/assets/javascripts/discourse/components/number-field.js.es6 @@ -1,15 +1,15 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Ember.TextField.extend({ classNameBindings: ["invalid"], - @computed("number") + @discourseComputed("number") value: { get(number) { - return parseInt(number); + return parseInt(number, 10); }, set(value) { - const num = parseInt(value); + const num = parseInt(value, 10); if (isNaN(num)) { this.set("invalid", true); return value; @@ -21,7 +21,7 @@ export default Ember.TextField.extend({ } }, - @computed("placeholderKey") + @discourseComputed("placeholderKey") placeholder(key) { return key ? I18n.t(key) : ""; } diff --git a/app/assets/javascripts/discourse/components/plugin-connector.js.es6 b/app/assets/javascripts/discourse/components/plugin-connector.js.es6 index 715cb62965..6f6bbe825f 100644 --- a/app/assets/javascripts/discourse/components/plugin-connector.js.es6 +++ b/app/assets/javascripts/discourse/components/plugin-connector.js.es6 @@ -1,5 +1,7 @@ import Component from "@ember/component"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { defineProperty, computed } from "@ember/object"; +import deprecated from "discourse-common/lib/deprecated"; +import { buildArgsWithDeprecations } from "discourse/lib/plugin-connectors"; export default Component.extend({ init() { @@ -9,10 +11,32 @@ export default Component.extend({ this.set("layoutName", connector.templateName); const args = this.args || {}; - Object.keys(args).forEach(key => this.set(key, args[key])); + Object.keys(args).forEach(key => { + defineProperty( + this, + key, + computed("args", () => (this.args || {})[key]) + ); + }); + + const deprecatedArgs = this.deprecatedArgs || {}; + Object.keys(deprecatedArgs).forEach(key => { + defineProperty( + this, + key, + computed("deprecatedArgs", () => { + deprecated( + `The ${key} property is deprecated, but is being used in ${this.layoutName}` + ); + + return (this.deprecatedArgs || {})[key]; + }) + ); + }); const connectorClass = this.get("connector.connectorClass"); - connectorClass.setupComponent.call(this, args, this); + const merged = buildArgsWithDeprecations(args, deprecatedArgs); + connectorClass.setupComponent.call(this, merged, this); this.set("actions", connectorClass.actions); }, @@ -24,12 +48,6 @@ export default Component.extend({ connectorClass.teardownComponent.call(this, this); }, - @observes("args") - _argsChanged() { - const args = this.args || {}; - Object.keys(args).forEach(key => this.set(key, args[key])); - }, - send(name, ...args) { const connectorClass = this.get("connector.connectorClass"); const action = connectorClass.actions[name]; diff --git a/app/assets/javascripts/discourse/components/plugin-outlet.js.es6 b/app/assets/javascripts/discourse/components/plugin-outlet.js.es6 index 14e3805964..f5dc399b2f 100644 --- a/app/assets/javascripts/discourse/components/plugin-outlet.js.es6 +++ b/app/assets/javascripts/discourse/components/plugin-outlet.js.es6 @@ -30,7 +30,10 @@ import Component from "@ember/component"; The list of disabled plugins is returned via the `Site` singleton. **/ -import { renderedConnectorsFor } from "discourse/lib/plugin-connectors"; +import { + renderedConnectorsFor, + buildArgsWithDeprecations +} from "discourse/lib/plugin-connectors"; export default Component.extend({ tagName: "span", @@ -46,7 +49,10 @@ export default Component.extend({ this._super(...arguments); const name = this.name; if (name) { - const args = this.args; + const args = buildArgsWithDeprecations( + this.args || {}, + this.deprecatedArgs || {} + ); this.set("connectors", renderedConnectorsFor(name, args, this)); } } diff --git a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 index 38930d57e4..6254fe2f1e 100644 --- a/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 +++ b/app/assets/javascripts/discourse/components/popup-input-tip.js.es6 @@ -2,73 +2,70 @@ import { alias, not } from "@ember/object/computed"; import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; -import { bufferedRender } from "discourse-common/lib/buffered-render"; +} from "discourse-common/utils/decorators"; -export default Component.extend( - bufferedRender({ - classNameBindings: [":popup-tip", "good", "bad", "lastShownAt::hide"], - animateAttribute: null, - bouncePixels: 6, - bounceDelay: 100, - rerenderTriggers: ["validation.reason"], +export default Component.extend({ + classNameBindings: [":popup-tip", "good", "bad", "lastShownAt::hide"], + animateAttribute: null, + bouncePixels: 6, + bounceDelay: 100, + rerenderTriggers: ["validation.reason"], + closeIcon: `${iconHTML("times-circle")}`.htmlSafe(), + tipReason: null, - click() { - this.set("shownAt", null); - this.set("validation.lastShownAt", null); - }, + click() { + this.set("shownAt", null); + this.set("validation.lastShownAt", null); + }, - bad: alias("validation.failed"), - good: not("bad"), + bad: alias("validation.failed"), + good: not("bad"), - @computed("shownAt", "validation.lastShownAt") - lastShownAt(shownAt, lastShownAt) { - return shownAt || lastShownAt; - }, + @discourseComputed("shownAt", "validation.lastShownAt") + lastShownAt(shownAt, lastShownAt) { + return shownAt || lastShownAt; + }, - @observes("lastShownAt") - bounce() { - if (this.lastShownAt) { - var $elem = $(this.element); - if (!this.animateAttribute) { - this.animateAttribute = - $elem.css("left") === "auto" ? "right" : "left"; - } - if (this.animateAttribute === "left") { - this.bounceLeft($elem); - } else { - this.bounceRight($elem); - } + @observes("lastShownAt") + bounce() { + if (this.lastShownAt) { + var $elem = $(this.element); + if (!this.animateAttribute) { + this.animateAttribute = $elem.css("left") === "auto" ? "right" : "left"; } - }, - - buildBuffer(buffer) { - const reason = this.get("validation.reason"); - if (!reason) { - return; - } - - buffer.push( - `${iconHTML("times-circle")}${reason}` - ); - }, - - bounceLeft($elem) { - for (var i = 0; i < 5; i++) { - $elem - .animate({ left: "+=" + this.bouncePixels }, this.bounceDelay) - .animate({ left: "-=" + this.bouncePixels }, this.bounceDelay); - } - }, - - bounceRight($elem) { - for (var i = 0; i < 5; i++) { - $elem - .animate({ right: "-=" + this.bouncePixels }, this.bounceDelay) - .animate({ right: "+=" + this.bouncePixels }, this.bounceDelay); + if (this.animateAttribute === "left") { + this.bounceLeft($elem); + } else { + this.bounceRight($elem); } } - }) -); + }, + + didReceiveAttrs() { + this._super(...arguments); + let reason = this.get("validation.reason"); + if (reason) { + this.set("tipReason", `${reason}`.htmlSafe()); + } else { + this.set("tipReason", null); + } + }, + + bounceLeft($elem) { + for (var i = 0; i < 5; i++) { + $elem + .animate({ left: "+=" + this.bouncePixels }, this.bounceDelay) + .animate({ left: "-=" + this.bouncePixels }, this.bounceDelay); + } + }, + + bounceRight($elem) { + for (var i = 0; i < 5; i++) { + $elem + .animate({ right: "-=" + this.bouncePixels }, this.bounceDelay) + .animate({ right: "+=" + this.bouncePixels }, this.bounceDelay); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/preference-checkbox.js.es6 b/app/assets/javascripts/discourse/components/preference-checkbox.js.es6 index 7ae299f5de..83ad27853f 100644 --- a/app/assets/javascripts/discourse/components/preference-checkbox.js.es6 +++ b/app/assets/javascripts/discourse/components/preference-checkbox.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["controls"], - @computed("labelKey") + @discourseComputed("labelKey") label(labelKey) { return I18n.t(labelKey); }, diff --git a/app/assets/javascripts/discourse/components/pwa-install-banner.js.es6 b/app/assets/javascripts/discourse/components/pwa-install-banner.js.es6 index e2e60f3d88..cb11ef38b7 100644 --- a/app/assets/javascripts/discourse/components/pwa-install-banner.js.es6 +++ b/app/assets/javascripts/discourse/components/pwa-install-banner.js.es6 @@ -1,9 +1,9 @@ import { bind } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; const USER_DISMISSED_PROMPT_KEY = "dismissed-pwa-install-banner"; @@ -28,7 +28,7 @@ export default Component.extend({ window.removeEventListener("beforeinstallprompt", this._promptEventHandler); }, - @computed + @discourseComputed bannerDismissed: { set(value) { this.keyValueStore.set({ key: USER_DISMISSED_PROMPT_KEY, value }); @@ -39,7 +39,7 @@ export default Component.extend({ } }, - @computed("deferredInstallPromptEvent", "bannerDismissed") + @discourseComputed("deferredInstallPromptEvent", "bannerDismissed") showPWAInstallBanner() { const launchedFromDiscourseHub = window.location.search.indexOf("discourse_app=1") !== -1; diff --git a/app/assets/javascripts/discourse/components/quote-button.js.es6 b/app/assets/javascripts/discourse/components/quote-button.js.es6 index ec8b8c47e8..718f68c98a 100644 --- a/app/assets/javascripts/discourse/components/quote-button.js.es6 +++ b/app/assets/javascripts/discourse/components/quote-button.js.es6 @@ -1,6 +1,6 @@ import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { selectedText } from "discourse/lib/utilities"; export default Component.extend({ @@ -128,7 +128,10 @@ export default Component.extend({ didInsertElement() { const { isWinphone, isAndroid } = this.capabilities; const wait = isWinphone || isAndroid ? 250 : 25; - const onSelectionChanged = debounce(() => this._selectionChanged(), wait); + const onSelectionChanged = discourseDebounce( + () => this._selectionChanged(), + wait + ); $(document) .on("mousedown.quote-button", e => { diff --git a/app/assets/javascripts/discourse/components/radio-button.js.es6 b/app/assets/javascripts/discourse/components/radio-button.js.es6 index b6d44b4a99..1094aca2c7 100644 --- a/app/assets/javascripts/discourse/components/radio-button.js.es6 +++ b/app/assets/javascripts/discourse/components/radio-button.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "input", @@ -25,7 +25,7 @@ export default Component.extend({ } }, - @computed("value", "selection") + @discourseComputed("value", "selection") checked(value, selection) { return value === selection; } diff --git a/app/assets/javascripts/discourse/components/related-messages.js.es6 b/app/assets/javascripts/discourse/components/related-messages.js.es6 index bf95279941..5fec6f28ba 100644 --- a/app/assets/javascripts/discourse/components/related-messages.js.es6 +++ b/app/assets/javascripts/discourse/components/related-messages.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; export default Component.extend({ elementId: "related-messages", classNames: ["suggested-topics"], - @computed("topic") + @discourseComputed("topic") targetUser(topic) { if (!topic || !topic.isPrivateMessage) { return; @@ -23,14 +23,14 @@ export default Component.extend({ } }, - @computed + @discourseComputed searchLink() { return Discourse.getURL( `/search?expanded=true&q=%40${this.targetUser.username}%20in%3Apersonal-direct` ); }, - @computed("topic") + @discourseComputed("topic") relatedTitle(topic) { const href = this.currentUser && this.currentUser.pmPath(topic); return href diff --git a/app/assets/javascripts/discourse/components/reviewable-claimed-topic.js.es6 b/app/assets/javascripts/discourse/components/reviewable-claimed-topic.js.es6 index 0583a4605f..273b51afa7 100644 --- a/app/assets/javascripts/discourse/components/reviewable-claimed-topic.js.es6 +++ b/app/assets/javascripts/discourse/components/reviewable-claimed-topic.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { ajax } from "discourse/lib/ajax"; export default Component.extend({ tagName: "", - @computed + @discourseComputed enabled() { return this.siteSettings.reviewable_claiming !== "disabled"; }, diff --git a/app/assets/javascripts/discourse/components/reviewable-flagged-post.js.es6 b/app/assets/javascripts/discourse/components/reviewable-flagged-post.js.es6 index 58c48d1bbf..d432beece9 100644 --- a/app/assets/javascripts/discourse/components/reviewable-flagged-post.js.es6 +++ b/app/assets/javascripts/discourse/components/reviewable-flagged-post.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { gt } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { longDate } from "discourse/lib/formatter"; import { historyHeat } from "discourse/widgets/post-edits-indicator"; import showModal from "discourse/lib/show-modal"; @@ -8,12 +8,12 @@ import showModal from "discourse/lib/show-modal"; export default Component.extend({ hasEdits: gt("reviewable.post_version", 1), - @computed("reviewable.post_updated_at") + @discourseComputed("reviewable.post_updated_at") historyClass(updatedAt) { return historyHeat(this.siteSettings, new Date(updatedAt)); }, - @computed("reviewable.post_updated_at") + @discourseComputed("reviewable.post_updated_at") editedDate(updatedAt) { return longDate(updatedAt); }, diff --git a/app/assets/javascripts/discourse/components/reviewable-item.js.es6 b/app/assets/javascripts/discourse/components/reviewable-item.js.es6 index c5aaba8b7b..ef6626ebec 100644 --- a/app/assets/javascripts/discourse/components/reviewable-item.js.es6 +++ b/app/assets/javascripts/discourse/components/reviewable-item.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; import Category from "discourse/models/category"; import optionalService from "discourse/lib/optional-service"; import showModal from "discourse/lib/show-modal"; @@ -17,17 +17,17 @@ export default Component.extend({ editing: false, _updates: null, - @computed("reviewable.type") + @discourseComputed("reviewable.type") customClass(type) { return type.dasherize(); }, - @computed("siteSettings.reviewable_claiming", "reviewable.topic") + @discourseComputed("siteSettings.reviewable_claiming", "reviewable.topic") claimEnabled(claimMode, topic) { return claimMode !== "disabled" && !!topic; }, - @computed( + @discourseComputed( "claimEnabled", "siteSettings.reviewable_claiming", "reviewable.claimed_by" @@ -44,7 +44,10 @@ export default Component.extend({ return claimMode !== "required"; }, - @computed("siteSettings.reviewable_claiming", "reviewable.claimed_by") + @discourseComputed( + "siteSettings.reviewable_claiming", + "reviewable.claimed_by" + ) claimHelp(claimMode, claimedBy) { if (claimedBy) { return claimedBy.id === this.currentUser.id @@ -61,7 +64,7 @@ export default Component.extend({ // Find a component to render, if one exists. For example: // `ReviewableUser` will return `reviewable-user` - @computed("reviewable.type") + @discourseComputed("reviewable.type") reviewableComponent(type) { if (_components[type] !== undefined) { return _components[type]; diff --git a/app/assets/javascripts/discourse/components/reviewable-user.js.es6 b/app/assets/javascripts/discourse/components/reviewable-user.js.es6 index ec065a0bde..3dd3043371 100644 --- a/app/assets/javascripts/discourse/components/reviewable-user.js.es6 +++ b/app/assets/javascripts/discourse/components/reviewable-user.js.es6 @@ -1,8 +1,8 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Component.extend({ - @computed("reviewable.user_fields") + @discourseComputed("reviewable.user_fields") userFields(fields) { return this.site.collectUserFields(fields); } diff --git a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 index 8f1779487d..f0a3d817c1 100644 --- a/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 +++ b/app/assets/javascripts/discourse/components/search-advanced-options.js.es6 @@ -1,10 +1,11 @@ import { debounce } from "@ember/runloop"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { escapeExpression } from "discourse/lib/utilities"; import Group from "discourse/models/group"; import Badge from "discourse/models/badge"; +import Category from "discourse/models/category"; const REGEXP_BLOCKS = /(([^" \t\n\x0B\f\r]+)?(("[^"]+")?))/g; @@ -224,7 +225,7 @@ export default Component.extend({ .replace(REGEXP_CATEGORY_PREFIX, "") .split(":"); if (subcategories.length > 1) { - const userInput = Discourse.Category.findBySlug( + const userInput = Category.findBySlug( subcategories[1], subcategories[0] ); @@ -234,14 +235,14 @@ export default Component.extend({ ) this.set("searchedTerms.category", userInput); } else if (isNaN(subcategories)) { - const userInput = Discourse.Category.findSingleBySlug(subcategories[0]); + const userInput = Category.findSingleBySlug(subcategories[0]); if ( (!existingInput && userInput) || (existingInput && userInput && existingInput.id !== userInput.id) ) this.set("searchedTerms.category", userInput); } else { - const userInput = Discourse.Category.findById(subcategories[0]); + const userInput = Category.findById(subcategories[0]); if ( (!existingInput && userInput) || (existingInput && userInput && existingInput.id !== userInput.id) diff --git a/app/assets/javascripts/discourse/components/search-text-field.js.es6 b/app/assets/javascripts/discourse/components/search-text-field.js.es6 index 349918e22e..fc6ca86c08 100644 --- a/app/assets/javascripts/discourse/components/search-text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/search-text-field.js.es6 @@ -1,12 +1,12 @@ -import computed from "ember-addons/ember-computed-decorators"; -import { on } from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; +import { on } from "discourse-common/utils/decorators"; import TextField from "discourse/components/text-field"; import { applySearchAutocomplete } from "discourse/lib/search"; export default TextField.extend({ autocomplete: "discourse", - @computed("searchService.searchContextEnabled") + @discourseComputed("searchService.searchContextEnabled") placeholder(searchContextEnabled) { return searchContextEnabled ? "" : I18n.t("search.full_page_title"); }, diff --git a/app/assets/javascripts/discourse/components/second-factor-form.js.es6 b/app/assets/javascripts/discourse/components/second-factor-form.js.es6 index ce332c23b1..47bd68a487 100644 --- a/app/assets/javascripts/discourse/components/second-factor-form.js.es6 +++ b/app/assets/javascripts/discourse/components/second-factor-form.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; export default Component.extend({ - @computed("secondFactorMethod") + @discourseComputed("secondFactorMethod") secondFactorTitle(secondFactorMethod) { switch (secondFactorMethod) { case SECOND_FACTOR_METHODS.TOTP: @@ -15,7 +15,7 @@ export default Component.extend({ } }, - @computed("secondFactorMethod") + @discourseComputed("secondFactorMethod") secondFactorDescription(secondFactorMethod) { switch (secondFactorMethod) { case SECOND_FACTOR_METHODS.TOTP: @@ -27,7 +27,7 @@ export default Component.extend({ } }, - @computed("secondFactorMethod", "isLogin") + @discourseComputed("secondFactorMethod", "isLogin") linkText(secondFactorMethod, isLogin) { if (isLogin) { return secondFactorMethod === SECOND_FACTOR_METHODS.TOTP @@ -40,7 +40,7 @@ export default Component.extend({ } }, - @computed("backupEnabled", "secondFactorMethod") + @discourseComputed("backupEnabled", "secondFactorMethod") showToggleMethodLink(backupEnabled, secondFactorMethod) { return ( backupEnabled && secondFactorMethod !== SECOND_FACTOR_METHODS.SECURITY_KEY diff --git a/app/assets/javascripts/discourse/components/second-factor-input.js.es6 b/app/assets/javascripts/discourse/components/second-factor-input.js.es6 index 97ce1a4fa7..45ed1f4b9e 100644 --- a/app/assets/javascripts/discourse/components/second-factor-input.js.es6 +++ b/app/assets/javascripts/discourse/components/second-factor-input.js.es6 @@ -1,22 +1,22 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; export default Component.extend({ - @computed("secondFactorMethod") + @discourseComputed("secondFactorMethod") type(secondFactorMethod) { if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "tel"; if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "text"; }, - @computed("secondFactorMethod") + @discourseComputed("secondFactorMethod") pattern(secondFactorMethod) { if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "[0-9]{6}"; if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "[a-z0-9]{16}"; }, - @computed("secondFactorMethod") + @discourseComputed("secondFactorMethod") maxlength(secondFactorMethod) { if (secondFactorMethod === SECOND_FACTOR_METHODS.TOTP) return "6"; if (secondFactorMethod === SECOND_FACTOR_METHODS.BACKUP_CODE) return "16"; diff --git a/app/assets/javascripts/discourse/components/share-panel.js.es6 b/app/assets/javascripts/discourse/components/share-panel.js.es6 index 3a7b94b645..97e09c225e 100644 --- a/app/assets/javascripts/discourse/components/share-panel.js.es6 +++ b/app/assets/javascripts/discourse/components/share-panel.js.es6 @@ -3,7 +3,7 @@ import { alias } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { escapeExpression } from "discourse/lib/utilities"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Sharing from "discourse/lib/sharing"; export default Component.extend({ @@ -13,18 +13,18 @@ export default Component.extend({ topic: alias("panel.model.topic"), - @computed + @discourseComputed sources() { return Sharing.activeSources(this.siteSettings.share_links); }, - @computed("type", "topic.title") + @discourseComputed("type", "topic.title") shareTitle(type, topicTitle) { topicTitle = escapeExpression(topicTitle); return I18n.t("share.topic_html", { topicTitle }); }, - @computed("panel.model.shareUrl", "topic.shareUrl") + @discourseComputed("panel.model.shareUrl", "topic.shareUrl") shareUrl(forcedShareUrl, shareUrl) { shareUrl = forcedShareUrl || shareUrl; diff --git a/app/assets/javascripts/discourse/components/share-popup.js.es6 b/app/assets/javascripts/discourse/components/share-popup.js.es6 index 4f9d0b9792..c2174068bd 100644 --- a/app/assets/javascripts/discourse/components/share-popup.js.es6 +++ b/app/assets/javascripts/discourse/components/share-popup.js.es6 @@ -5,9 +5,9 @@ import Component from "@ember/component"; import { wantsNewWindow } from "discourse/lib/intercept-click"; import { longDateNoYear } from "discourse/lib/formatter"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import Sharing from "discourse/lib/sharing"; import { nativeShare } from "discourse/lib/pwa-utils"; @@ -17,12 +17,12 @@ export default Component.extend({ link: null, visible: null, - @computed + @discourseComputed sources() { return Sharing.activeSources(this.siteSettings.share_links); }, - @computed("type", "postNumber") + @discourseComputed("type", "postNumber") shareTitle(type, postNumber) { if (type === "topic") { return I18n.t("share.topic"); @@ -33,7 +33,7 @@ export default Component.extend({ return I18n.t("share.topic"); }, - @computed("date") + @discourseComputed("date") displayDate(date) { return longDateNoYear(new Date(date)); }, diff --git a/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 b/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 index b408ee87a1..0b988e75cf 100644 --- a/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 +++ b/app/assets/javascripts/discourse/components/shared-draft-controls.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", publishing: false, - @computed("topic.destination_category_id") + @discourseComputed("topic.destination_category_id") validCategory(destCatId) { return destCatId && destCatId !== this.site.shared_drafts_category_id; }, diff --git a/app/assets/javascripts/discourse/components/site-header.js.es6 b/app/assets/javascripts/discourse/components/site-header.js.es6 index b7b82071c1..38d42d9ea0 100644 --- a/app/assets/javascripts/discourse/components/site-header.js.es6 +++ b/app/assets/javascripts/discourse/components/site-header.js.es6 @@ -2,7 +2,7 @@ import { cancel } from "@ember/runloop"; import { schedule } from "@ember/runloop"; import { later } from "@ember/runloop"; import MountWidget from "discourse/components/mount-widget"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import Docking from "discourse/mixins/docking"; import PanEvents, { SWIPE_VELOCITY, @@ -65,7 +65,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { _handlePanDone(offset, event) { const $window = $(window); - const windowWidth = parseInt($window.width()); + const windowWidth = $window.width(); const $menuPanels = $(".menu-panel"); const menuOrigin = this._panMenuOrigin; this._shouldMenuClose(event, menuOrigin) @@ -246,16 +246,16 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { } const $window = $(window); - const windowWidth = parseInt($window.width()); + const windowWidth = $window.width(); const headerWidth = $("#main-outlet .container").width() || 1100; - const remaining = parseInt((windowWidth - headerWidth) / 2); + const remaining = (windowWidth - headerWidth) / 2; const viewMode = remaining < 50 ? "slide-in" : "drop-down"; $menuPanels.each((idx, panel) => { const $panel = $(panel); const $headerCloak = $(".header-cloak"); - let width = parseInt($panel.attr("data-max-width") || 300); + let width = parseInt($panel.attr("data-max-width"), 10) || 300; if (windowWidth - width < 50) { width = windowWidth - 50; } @@ -280,8 +280,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { const $panelBody = $(".panel-body", $panel); // 2 pixel fudge allows for firefox subpixel sizing stuff causing scrollbar - let contentHeight = - parseInt($(".panel-body-contents", $panel).height()) + 2; + let contentHeight = $(".panel-body-contents", $panel).height() + 2; // We use a mutationObserver to check for style changes, so it's important // we don't set it if it doesn't change. Same goes for the $panelBody! @@ -300,7 +299,7 @@ const SiteHeaderComponent = MountWidget.extend(Docking, PanEvents, { } // adjust panel height - const fullHeight = parseInt($window.height()); + const fullHeight = $window.height(); const offsetTop = $panel.offset().top; const scrollTop = $window.scrollTop(); @@ -373,14 +372,12 @@ export function headerHeight() { const headerOffset = $header.offset(); const headerOffsetTop = headerOffset ? headerOffset.top : 0; - return parseInt( - $header.outerHeight() + headerOffsetTop - $(window).scrollTop() - ); + return $header.outerHeight() + headerOffsetTop - $(window).scrollTop(); } export function headerTop() { const $header = $("header.d-header"); const headerOffset = $header.offset(); const headerOffsetTop = headerOffset ? headerOffset.top : 0; - return parseInt(headerOffsetTop - $(window).scrollTop()); + return headerOffsetTop - $(window).scrollTop(); } diff --git a/app/assets/javascripts/discourse/components/suggested-topics.js.es6 b/app/assets/javascripts/discourse/components/suggested-topics.js.es6 index 11b6cbf20d..c8ea62e827 100644 --- a/app/assets/javascripts/discourse/components/suggested-topics.js.es6 +++ b/app/assets/javascripts/discourse/components/suggested-topics.js.es6 @@ -1,14 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { get } from "@ember/object"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; import { iconHTML } from "discourse-common/lib/icon-library"; +import Site from "discourse/models/site"; export default Component.extend({ elementId: "suggested-topics", classNames: ["suggested-topics"], - @computed("topic") + @discourseComputed("topic") suggestedTitle(topic) { const href = this.currentUser && this.currentUser.pmPath(topic); return topic.get("isPrivateMessage") && href @@ -20,7 +21,7 @@ export default Component.extend({ : I18n.t("suggested_topics.title"); }, - @computed("topic", "topicTrackingState.messageCount") + @discourseComputed("topic", "topicTrackingState.messageCount") browseMoreMessage(topic) { // TODO decide what to show for pms if (topic.get("isPrivateMessage")) { @@ -36,8 +37,7 @@ export default Component.extend({ if ( category && - get(category, "id") === - Discourse.Site.currentProp("uncategorized_category_id") + get(category, "id") === Site.currentProp("uncategorized_category_id") ) { category = null; } diff --git a/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 b/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 index 73d20d7922..e8cfada41a 100644 --- a/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 +++ b/app/assets/javascripts/discourse/components/tag-drop-link.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import DiscourseURL from "discourse/lib/url"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "a", @@ -12,7 +12,7 @@ export default Component.extend({ ], attributeBindings: ["href"], - @computed("tagId", "category") + @discourseComputed("tagId", "category") href(tagId, category) { var url = "/tags"; if (category) { @@ -21,7 +21,7 @@ export default Component.extend({ return url + "/" + tagId; }, - @computed("tagId") + @discourseComputed("tagId") tagClass(tagId) { return "tag-" + tagId; }, diff --git a/app/assets/javascripts/discourse/components/tag-groups-form.js.es6 b/app/assets/javascripts/discourse/components/tag-groups-form.js.es6 index 2dddb086e8..b77220cd08 100644 --- a/app/assets/javascripts/discourse/components/tag-groups-form.js.es6 +++ b/app/assets/javascripts/discourse/components/tag-groups-form.js.es6 @@ -1,13 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import PermissionType from "discourse/models/permission-type"; export default Component.extend(bufferedProperty("model"), { tagName: "", - @computed("buffered.isSaving", "buffered.name", "buffered.tag_names") + @discourseComputed("buffered.isSaving", "buffered.name", "buffered.tag_names") savingDisabled(isSaving, name, tagNames) { return isSaving || isEmpty(name) || isEmpty(tagNames); }, diff --git a/app/assets/javascripts/discourse/components/tag-info.js.es6 b/app/assets/javascripts/discourse/components/tag-info.js.es6 new file mode 100644 index 0000000000..bb143c5b0c --- /dev/null +++ b/app/assets/javascripts/discourse/components/tag-info.js.es6 @@ -0,0 +1,133 @@ +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import showModal from "discourse/lib/show-modal"; +import { + default as discourseComputed, + observes +} from "discourse-common/utils/decorators"; +import Component from "@ember/component"; +import { reads, and } from "@ember/object/computed"; +import { isEmpty } from "@ember/utils"; +import Category from "discourse/models/category"; + +export default Component.extend({ + tagName: "", + loading: false, + tagInfo: null, + newSynonyms: null, + showEditControls: false, + canAdminTag: reads("currentUser.staff"), + editSynonymsMode: and("canAdminTag", "showEditControls"), + + @discourseComputed("tagInfo.tag_group_names") + tagGroupsInfo(tagGroupNames) { + return I18n.t("tagging.tag_groups_info", { + count: tagGroupNames.length, + tag_groups: tagGroupNames.join(", ") + }); + }, + + @discourseComputed("tagInfo.categories") + categoriesInfo(categories) { + return I18n.t("tagging.category_restrictions", { + count: categories.length + }); + }, + + @discourseComputed( + "tagInfo.tag_group_names", + "tagInfo.categories", + "tagInfo.synonyms" + ) + nothingToShow(tagGroupNames, categories, synonyms) { + return isEmpty(tagGroupNames) && isEmpty(categories) && isEmpty(synonyms); + }, + + @observes("expanded") + toggleExpanded() { + if (this.expanded && !this.tagInfo) { + this.loadTagInfo(); + } + }, + + loadTagInfo() { + if (this.loading) { + return; + } + this.set("loading", true); + return this.store + .find("tag-info", this.tag.id) + .then(result => { + this.set("tagInfo", result); + this.set( + "tagInfo.synonyms", + result.synonyms.map(s => this.store.createRecord("tag", s)) + ); + this.set( + "tagInfo.categories", + result.category_ids.map(id => Category.findById(id)) + ); + }) + .finally(() => this.set("loading", false)); + }, + + actions: { + toggleEditControls() { + this.toggleProperty("showEditControls"); + }, + + renameTag() { + showModal("rename-tag", { model: this.tag }); + }, + + deleteTag() { + this.sendAction("deleteAction", this.tagInfo); + }, + + unlinkSynonym(tag) { + ajax(`/tags/${this.tagInfo.name}/synonyms/${tag.id}`, { + type: "DELETE" + }) + .then(() => this.tagInfo.synonyms.removeObject(tag)) + .catch(() => bootbox.alert(I18n.t("generic_error"))); + }, + + deleteSynonym(tag) { + bootbox.confirm( + I18n.t("tagging.delete_synonym_confirm", { tag_name: tag.text }), + result => { + if (!result) return; + + tag + .destroyRecord() + .then(() => this.tagInfo.synonyms.removeObject(tag)) + .catch(() => bootbox.alert(I18n.t("generic_error"))); + } + ); + }, + + addSynonyms() { + ajax(`/tags/${this.tagInfo.name}/synonyms`, { + type: "POST", + data: { + synonyms: this.newSynonyms + } + }) + .then(result => { + if (result.success) { + this.set("newSynonyms", null); + this.loadTagInfo(); + } else if (result.failed_tags) { + bootbox.alert( + I18n.t("tagging.add_synonyms_failed", { + tag_names: Object.keys(result.failed_tags).join(", ") + }) + ); + } else { + bootbox.alert(I18n.t("generic_error")); + } + }) + .catch(popupAjaxError); + } + } +}); diff --git a/app/assets/javascripts/discourse/components/tag-list.js.es6 b/app/assets/javascripts/discourse/components/tag-list.js.es6 index 551b70c93d..a6472a1c6e 100644 --- a/app/assets/javascripts/discourse/components/tag-list.js.es6 +++ b/app/assets/javascripts/discourse/components/tag-list.js.es6 @@ -1,6 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { sort } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import Category from "discourse/models/category"; export default Component.extend({ classNameBindings: [":tag-list", "categoryClass", "tagGroupNameClass"], @@ -8,22 +9,22 @@ export default Component.extend({ isPrivateMessage: false, sortedTags: sort("tags", "sortProperties"), - @computed("titleKey") + @discourseComputed("titleKey") title(titleKey) { return titleKey && I18n.t(titleKey); }, - @computed("categoryId") + @discourseComputed("categoryId") category(categoryId) { - return categoryId && Discourse.Category.findById(categoryId); + return categoryId && Category.findById(categoryId); }, - @computed("category.fullSlug") + @discourseComputed("category.fullSlug") categoryClass(slug) { return slug && `tag-list-${slug}`; }, - @computed("tagGroupName") + @discourseComputed("tagGroupName") tagGroupNameClass(groupName) { if (groupName) { groupName = groupName diff --git a/app/assets/javascripts/discourse/components/text-field.js.es6 b/app/assets/javascripts/discourse/components/text-field.js.es6 index eca66af1d3..1978cd0ab2 100644 --- a/app/assets/javascripts/discourse/components/text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/text-field.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { siteDir, isRTL, isLTR } from "discourse/lib/text-direction"; export default Ember.TextField.extend({ @@ -10,7 +10,7 @@ export default Ember.TextField.extend({ "dir" ], - @computed + @discourseComputed dir() { if (this.siteSettings.support_mixed_text_direction) { let val = this.value; @@ -37,7 +37,7 @@ export default Ember.TextField.extend({ } }, - @computed("placeholderKey") + @discourseComputed("placeholderKey") placeholder: { get() { if (this._placeholder) return this._placeholder; diff --git a/app/assets/javascripts/discourse/components/top-period-buttons.js.es6 b/app/assets/javascripts/discourse/components/top-period-buttons.js.es6 index 9c01cf1a1d..0eaf69a992 100644 --- a/app/assets/javascripts/discourse/components/top-period-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/top-period-buttons.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNames: ["top-title-buttons"], - @computed("period") + @discourseComputed("period") periods(period) { return this.site.get("periods").filter(p => p !== period); }, diff --git a/app/assets/javascripts/discourse/components/topic-entrance.js.es6 b/app/assets/javascripts/discourse/components/topic-entrance.js.es6 index ead6913e82..b27a7fe15d 100644 --- a/app/assets/javascripts/discourse/components/topic-entrance.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-entrance.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import DiscourseURL from "discourse/lib/url"; import CleansUp from "discourse/mixins/cleans-up"; -import computed from "ember-addons/ember-computed-decorators"; function entranceDate(dt, showTime) { const today = new Date(); @@ -34,23 +34,23 @@ export default Component.extend(CleansUp, { topic: null, visible: null, - @computed("topic.created_at") + @discourseComputed("topic.created_at") createdDate: createdAt => new Date(createdAt), - @computed("topic.bumped_at") + @discourseComputed("topic.bumped_at") bumpedDate: bumpedAt => new Date(bumpedAt), - @computed("createdDate", "bumpedDate") + @discourseComputed("createdDate", "bumpedDate") showTime(createdDate, bumpedDate) { return ( bumpedDate.getTime() - createdDate.getTime() < 1000 * 60 * 60 * 24 * 2 ); }, - @computed("createdDate", "showTime") + @discourseComputed("createdDate", "showTime") topDate: (createdDate, showTime) => entranceDate(createdDate, showTime), - @computed("bumpedDate", "showTime") + @discourseComputed("bumpedDate", "showTime") bottomDate: (bumpedDate, showTime) => entranceDate(bumpedDate, showTime), didInsertElement() { @@ -63,8 +63,8 @@ export default Component.extend(CleansUp, { const $self = $(this.element); const width = $self.width(); const height = $self.height(); - pos.left = parseInt(pos.left) - width / 2; - pos.top = parseInt(pos.top) - height / 2; + pos.left = parseInt(pos.left, 10) - width / 2; + pos.top = parseInt(pos.top, 10) - height / 2; const windowWidth = $(window).width(); if (pos.left + width > windowWidth) { diff --git a/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6 b/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6 index 7c13728bff..5f4592aeed 100644 --- a/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-footer-buttons.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, or, and } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button"; export default Component.extend({ @@ -9,25 +9,25 @@ export default Component.extend({ // Allow us to extend it layoutName: "components/topic-footer-buttons", - @computed("topic.isPrivateMessage") + @discourseComputed("topic.isPrivateMessage") canArchive(isPM) { return this.siteSettings.enable_personal_messages && isPM; }, buttons: getTopicFooterButtons(), - @computed("buttons.[]") + @discourseComputed("buttons.[]") inlineButtons(buttons) { return buttons.filter(button => !button.dropdown); }, // topic.assigned_to_user is for backward plugin support - @computed("buttons.[]", "topic.assigned_to_user") + @discourseComputed("buttons.[]", "topic.assigned_to_user") dropdownButtons(buttons) { return buttons.filter(button => button.dropdown); }, - @computed("topic.isPrivateMessage") + @discourseComputed("topic.isPrivateMessage") showNotificationsButton(isPM) { return !isPM || this.siteSettings.enable_personal_messages; }, @@ -38,7 +38,7 @@ export default Component.extend({ inviteDisabled: or("topic.archived", "topic.closed", "topic.deleted"), - @computed + @discourseComputed showAdminButton() { return ( !this.site.mobileView && @@ -49,14 +49,14 @@ export default Component.extend({ showEditOnFooter: and("topic.isPrivateMessage", "site.can_tag_pms"), - @computed("topic.message_archived") + @discourseComputed("topic.message_archived") archiveIcon: archived => (archived ? "envelope" : "folder"), - @computed("topic.message_archived") + @discourseComputed("topic.message_archived") archiveTitle: archived => archived ? "topic.move_to_inbox.help" : "topic.archive_message.help", - @computed("topic.message_archived") + @discourseComputed("topic.message_archived") archiveLabel: archived => archived ? "topic.move_to_inbox.title" : "topic.archive_message.title" }); diff --git a/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 b/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 index 114e02aa04..4a55fb8886 100644 --- a/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-join-group-notice.js.es6 @@ -1,17 +1,17 @@ import Component from "@ember/component"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["topic-notice"], - @computed("model.group.{full_name,name,allow_membership_requests}") + @discourseComputed("model.group.{full_name,name,allow_membership_requests}") accessViaGroupText(group) { const name = group.full_name || group.name; const suffix = group.allow_membership_requests ? "request" : "join"; return I18n.t(`topic.group_${suffix}`, { name }); }, - @computed("model.group.allow_membership_requests") + @discourseComputed("model.group.allow_membership_requests") accessViaGroupButtonText(allowRequest) { return `groups.${allowRequest ? "request" : "join"}`; } diff --git a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 index 10161d7b51..670d45b64c 100644 --- a/app/assets/javascripts/discourse/components/topic-list-item.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list-item.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; import DiscourseURL from "discourse/lib/url"; -import computed from "ember-addons/ember-computed-decorators"; import { bufferedRender } from "discourse-common/lib/buffered-render"; import { findRawTemplate } from "discourse/lib/raw-templates"; import { wantsNewWindow } from "discourse/lib/intercept-click"; @@ -64,29 +64,29 @@ export const ListItemDefaults = { } }, - @computed("topic.id") + @discourseComputed("topic.id") unreadIndicatorChannel(topicId) { return `/private-messages/unread-indicator/${topicId}`; }, - @computed("topic.unread_by_group_member") + @discourseComputed("topic.unread_by_group_member") unreadClass(unreadByGroupMember) { return unreadByGroupMember ? "" : "read"; }, - @computed("topic.unread_by_group_member") + @discourseComputed("topic.unread_by_group_member") includeUnreadIndicator(unreadByGroupMember) { return typeof unreadByGroupMember !== "undefined"; }, - @computed + @discourseComputed newDotText() { return this.currentUser && this.currentUser.trust_level > 0 ? "" : I18n.t("filters.new.lower_title"); }, - @computed("topic", "lastVisitedTopic") + @discourseComputed("topic", "lastVisitedTopic") unboundClassNames(topic, lastVisitedTopic) { let classes = []; @@ -131,7 +131,7 @@ export const ListItemDefaults = { return this.get("topic.op_like_count") > 0; }, - @computed + @discourseComputed expandPinned: function() { const pinned = this.get("topic.pinned"); if (!pinned) { diff --git a/app/assets/javascripts/discourse/components/topic-list.js.es6 b/app/assets/javascripts/discourse/components/topic-list.js.es6 index 6d3ea337e2..d744b28a70 100644 --- a/app/assets/javascripts/discourse/components/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-list.js.es6 @@ -2,9 +2,9 @@ import { alias, reads } from "@ember/object/computed"; import { schedule } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import LoadMore from "discourse/mixins/load-more"; import { on } from "@ember/object/evented"; @@ -24,24 +24,24 @@ export default Component.extend(LoadMore, { this.refreshLastVisited(); }), - @computed("bulkSelectEnabled") + @discourseComputed("bulkSelectEnabled") toggleInTitle(bulkSelectEnabled) { return !bulkSelectEnabled && this.canBulkSelect; }, - @computed + @discourseComputed sortable() { return !!this.changeSort; }, skipHeader: reads("site.mobileView"), - @computed("order") + @discourseComputed("order") showLikes(order) { return order === "likes"; }, - @computed("order") + @discourseComputed("order") showOpLikes(order) { return order === "op_likes"; }, diff --git a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 index b2115e755d..3324814bf6 100644 --- a/app/assets/javascripts/discourse/components/topic-navigation.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-navigation.js.es6 @@ -2,7 +2,7 @@ import EmberObject from "@ember/object"; import { scheduleOnce } from "@ember/runloop"; import { later } from "@ember/runloop"; import Component from "@ember/component"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import showModal from "discourse/lib/show-modal"; import PanEvents, { SWIPE_VELOCITY, diff --git a/app/assets/javascripts/discourse/components/topic-progress.js.es6 b/app/assets/javascripts/discourse/components/topic-progress.js.es6 index 9d5cb2103f..3e055c96ed 100644 --- a/app/assets/javascripts/discourse/components/topic-progress.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-progress.js.es6 @@ -2,9 +2,9 @@ import { alias } from "@ember/object/computed"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Component.extend({ elementId: "topic-progress-wrapper", @@ -14,12 +14,12 @@ export default Component.extend({ postStream: alias("topic.postStream"), _streamPercentage: null, - @computed("progressPosition") + @discourseComputed("progressPosition") jumpTopDisabled(progressPosition) { return progressPosition <= 3; }, - @computed( + @discourseComputed( "postStream.filteredPostsCount", "topic.highest_post_number", "progressPosition" @@ -31,7 +31,7 @@ export default Component.extend({ ); }, - @computed( + @discourseComputed( "postStream.loaded", "topic.currentPost", "postStream.filteredPostsCount" @@ -44,14 +44,14 @@ export default Component.extend({ ); }, - @computed("postStream.filteredPostsCount") + @discourseComputed("postStream.filteredPostsCount") hugeNumberOfPosts(filteredPostsCount) { return ( filteredPostsCount >= this.siteSettings.short_progress_text_threshold ); }, - @computed("hugeNumberOfPosts", "topic.highest_post_number") + @discourseComputed("hugeNumberOfPosts", "topic.highest_post_number") jumpToBottomTitle(hugeNumberOfPosts, highestPostNumber) { if (hugeNumberOfPosts) { return I18n.t("topic.progress.jump_bottom_with_number", { @@ -62,7 +62,7 @@ export default Component.extend({ } }, - @computed("progressPosition", "topic.last_read_post_id") + @discourseComputed("progressPosition", "topic.last_read_post_id") showBackButton(position, lastReadId) { if (!lastReadId) { return; diff --git a/app/assets/javascripts/discourse/components/topic-status.js.es6 b/app/assets/javascripts/discourse/components/topic-status.js.es6 index dc3e0d14f6..dd1f6c5066 100644 --- a/app/assets/javascripts/discourse/components/topic-status.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-status.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; import { bufferedRender } from "discourse-common/lib/buffered-render"; import { escapeExpression } from "discourse/lib/utilities"; import TopicStatusIcons from "discourse/helpers/topic-status-icons"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend( bufferedRender({ @@ -28,7 +28,7 @@ export default Component.extend( return false; }, - @computed("disableActions") + @discourseComputed("disableActions") canAct(disableActions) { return this.currentUser && !disableActions; }, diff --git a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 index 9aa6541065..64f0dbc2d8 100644 --- a/app/assets/javascripts/discourse/components/topic-timeline.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-timeline.js.es6 @@ -1,11 +1,11 @@ import { next } from "@ember/runloop"; import MountWidget from "discourse/components/mount-widget"; import Docking from "discourse/mixins/docking"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import optionalService from "discourse/lib/optional-service"; const headerPadding = () => { - let topPadding = parseInt($("#main-outlet").css("padding-top")) + 3; + let topPadding = parseInt($("#main-outlet").css("padding-top"), 10) + 3; const iPadNavHeight = $(".footer-nav-ipad .footer-nav").height(); if (iPadNavHeight) { topPadding += iPadNavHeight; diff --git a/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 b/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 index bb26e0caba..487307f624 100644 --- a/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 +++ b/app/assets/javascripts/discourse/components/topic-timer-info.js.es6 @@ -1,11 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { cancel } from "@ember/runloop"; import { later } from "@ember/runloop"; import Component from "@ember/component"; import { iconHTML } from "discourse-common/lib/icon-library"; import { bufferedRender } from "discourse-common/lib/buffered-render"; import Category from "discourse/models/category"; -import computed from "ember-addons/ember-computed-decorators"; import { REMINDER_TYPE } from "discourse/controllers/edit-topic-timer"; +import ENV from "discourse-common/config/environment"; export default Component.extend( bufferedRender({ @@ -21,7 +22,7 @@ export default Component.extend( "categoryId" ], - @computed("statusType") + @discourseComputed("statusType") canRemoveTimer(type) { if (type === REMINDER_TYPE) return true; return this.currentUser && this.currentUser.get("canManageTopic"); @@ -86,7 +87,7 @@ export default Component.extend( buffer.push(""); // TODO Sam: concerned this can cause a heavy rerender loop - if (!Ember.testing) { + if (ENV.environment !== "test") { this._delayedRerender = later(this, this.rerender, rerenderDelay); } } diff --git a/app/assets/javascripts/discourse/components/user-badge.js.es6 b/app/assets/javascripts/discourse/components/user-badge.js.es6 index 85de291634..6f9c4850b5 100644 --- a/app/assets/javascripts/discourse/components/user-badge.js.es6 +++ b/app/assets/javascripts/discourse/components/user-badge.js.es6 @@ -1,15 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "span", - @computed("count") + @discourseComputed("count") showGrantCount(count) { return count && count > 1; }, - @computed("badge", "user") + @discourseComputed("badge", "user") badgeUrl() { // NOTE: I tried using a link-to helper here but the queryParams mean it fails var username = this.get("user.username_lower") || ""; diff --git a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 index d7c6eac785..255e494f21 100644 --- a/app/assets/javascripts/discourse/components/user-card-contents.js.es6 +++ b/app/assets/javascripts/discourse/components/user-card-contents.js.es6 @@ -3,9 +3,9 @@ import { alias, gte, and, gt, not, or } from "@ember/object/computed"; import EmberObject from "@ember/object"; import Component from "@ember/component"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import User from "discourse/models/user"; import { propertyNotEqual, setting } from "discourse/lib/computed"; import { durationTiny } from "discourse/lib/formatter"; @@ -14,6 +14,7 @@ import CardContentsBase from "discourse/mixins/card-contents-base"; import CleansUp from "discourse/mixins/cleans-up"; import { prioritizeNameInUx } from "discourse/lib/settings"; import { set } from "@ember/object"; +import { getOwner } from "@ember/application"; export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { elementId: "user-card", @@ -49,26 +50,26 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { // If inside a topic topicPostCount: null, - @computed("user.staff") + @discourseComputed("user.staff") staff: isStaff => (isStaff ? "staff" : ""), - @computed("user.trust_level") + @discourseComputed("user.trust_level") newUser: trustLevel => (trustLevel === 0 ? "new-user" : ""), - @computed("user.name") + @discourseComputed("user.name") nameFirst(name) { return prioritizeNameInUx(name, this.siteSettings); }, - @computed("username") + @discourseComputed("username") usernameClass: username => (username ? `user-card-${username}` : ""), - @computed("username", "topicPostCount") + @discourseComputed("username", "topicPostCount") togglePostsLabel(username, count) { return I18n.t("topic.filter_to", { username, count }); }, - @computed("user.user_fields.@each.value") + @discourseComputed("user.user_fields.@each.value") publicUserFields() { const siteUserFields = this.site.get("user_fields"); if (!isEmpty(siteUserFields)) { @@ -85,25 +86,25 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { } }, - @computed("user.trust_level") + @discourseComputed("user.trust_level") removeNoFollow(trustLevel) { return trustLevel > 2 && !this.siteSettings.tl3_links_no_follow; }, - @computed("user.badge_count", "user.featured_user_badges.length") + @discourseComputed("user.badge_count", "user.featured_user_badges.length") moreBadgesCount: (badgeCount, badgeLength) => badgeCount - badgeLength, - @computed("user.time_read", "user.recent_time_read") + @discourseComputed("user.time_read", "user.recent_time_read") showRecentTimeRead(timeRead, recentTimeRead) { return timeRead !== recentTimeRead && recentTimeRead !== 0; }, - @computed("user.recent_time_read") + @discourseComputed("user.recent_time_read") recentTimeRead(recentTimeReadSeconds) { return durationTiny(recentTimeReadSeconds); }, - @computed("showRecentTimeRead", "user.time_read", "recentTimeRead") + @discourseComputed("showRecentTimeRead", "user.time_read", "recentTimeRead") timeReadTooltip(showRecent, timeRead, recentTimeRead) { if (showRecent) { return I18n.t("time_read_recently_tooltip", { @@ -174,7 +175,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, { composePM(user, post) { this._close(); - Ember.getOwner(this) + getOwner(this) .lookup("router:main") .send("composePrivateMessage", user, post); }, diff --git a/app/assets/javascripts/discourse/components/user-field.js.es6 b/app/assets/javascripts/discourse/components/user-field.js.es6 index b54cbafd0a..f8e2554ab0 100644 --- a/app/assets/javascripts/discourse/components/user-field.js.es6 +++ b/app/assets/javascripts/discourse/components/user-field.js.es6 @@ -1,17 +1,17 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; import { fmt } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ classNameBindings: [":user-field", "field.field_type", "customFieldClass"], layoutName: fmt("field.field_type", "components/user-fields/%@"), - @computed + @discourseComputed noneLabel() { return "user_fields.none"; }, - @computed("field.name") + @discourseComputed("field.name") customFieldClass(fieldName) { if (fieldName) { fieldName = fieldName diff --git a/app/assets/javascripts/discourse/components/user-flag-percentage.js.es6 b/app/assets/javascripts/discourse/components/user-flag-percentage.js.es6 index 5c24fb70af..60e3c4ccef 100644 --- a/app/assets/javascripts/discourse/components/user-flag-percentage.js.es6 +++ b/app/assets/javascripts/discourse/components/user-flag-percentage.js.es6 @@ -1,16 +1,16 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", - @computed("percentage") + @discourseComputed("percentage") showPercentage(percentage) { return percentage.total >= 3; }, // We do a little logic to choose which icon to display and which text - @computed("agreed", "disagreed", "ignored") + @discourseComputed("agreed", "disagreed", "ignored") percentage(agreed, disagreed, ignored) { let total = agreed + disagreed + ignored; let result = { total }; diff --git a/app/assets/javascripts/discourse/components/user-info.js.es6 b/app/assets/javascripts/discourse/components/user-info.js.es6 index 5873829a25..2de553d13d 100644 --- a/app/assets/javascripts/discourse/components/user-info.js.es6 +++ b/app/assets/javascripts/discourse/components/user-info.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; import { userPath } from "discourse/lib/url"; export function normalize(name) { @@ -12,7 +12,7 @@ export default Component.extend({ attributeBindings: ["data-username"], size: "small", - @computed("user.username") + @discourseComputed("user.username") userPath(username) { return userPath(username); }, @@ -22,7 +22,7 @@ export default Component.extend({ // TODO: In later ember releases `hasBlock` works without this hasBlock: alias("template"), - @computed("user.name", "user.username") + @discourseComputed("user.name", "user.username") name(name, username) { if (name && normalize(username) !== normalize(name)) { return name; diff --git a/app/assets/javascripts/discourse/components/user-notifications-large.js.es6 b/app/assets/javascripts/discourse/components/user-notifications-large.js.es6 index de804fb75e..3d2a2112b5 100644 --- a/app/assets/javascripts/discourse/components/user-notifications-large.js.es6 +++ b/app/assets/javascripts/discourse/components/user-notifications-large.js.es6 @@ -1,5 +1,5 @@ import MountWidget from "discourse/components/mount-widget"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; export default MountWidget.extend({ widget: "user-notifications-large", diff --git a/app/assets/javascripts/discourse/components/user-selector.js.es6 b/app/assets/javascripts/discourse/components/user-selector.js.es6 index 772777543c..85e5c61dd7 100644 --- a/app/assets/javascripts/discourse/components/user-selector.js.es6 +++ b/app/assets/javascripts/discourse/components/user-selector.js.es6 @@ -1,5 +1,5 @@ import { isEmpty } from "@ember/utils"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import TextField from "discourse/components/text-field"; import userSearch from "discourse/lib/user-search"; import { findRawTemplate } from "discourse/lib/raw-templates"; @@ -9,6 +9,25 @@ export default TextField.extend({ autocapitalize: false, name: "user-selector", + init() { + this._super(); + this._paste = e => { + let pastedText = ""; + if (window.clipboardData && window.clipboardData.getData) { + // IE + pastedText = window.clipboardData.getData("Text"); + } else if (e.clipboardData && e.clipboardData.getData) { + pastedText = e.clipboardData.getData("text/plain"); + } + + if (pastedText.length > 0) { + this.importText(pastedText); + e.preventDefault(); + return false; + } + }; + }, + @observes("usernames") _update() { if (this.canReceiveUpdates === "true") { @@ -19,6 +38,7 @@ export default TextField.extend({ @on("willDestroyElement") _destroyAutocompleteInstance() { $(this.element).autocomplete("destroy"); + this.element.addEventListener("paste", this._paste); }, @on("didInsertElement") @@ -52,6 +72,8 @@ export default TextField.extend({ return usernames; }; + this.element.addEventListener("paste", this._paste); + const userSelectorComponent = this; $(this.element) @@ -128,6 +150,24 @@ export default TextField.extend({ }); }, + importText(text) { + let usernames = []; + if ((this.usernames || "").length > 0) { + usernames = this.usernames.split(","); + } + + (text || "").split(/[, \n]+/).forEach(val => { + val = val.replace(/^@+/, "").trim(); + if (val.length > 0) { + usernames.push(val); + } + }); + this.set("usernames", usernames.uniq().join(",")); + if (this.canReceiveUpdates !== "true") { + this._createAutocompleteInstance({ updateData: true }); + } + }, + // THIS IS A HUGE HACK TO SUPPORT CLEARING THE INPUT @observes("usernames") _clearInput() { diff --git a/app/assets/javascripts/discourse/components/user-summary-category-search.js.es6 b/app/assets/javascripts/discourse/components/user-summary-category-search.js.es6 index 5206a83988..202ee1eb58 100644 --- a/app/assets/javascripts/discourse/components/user-summary-category-search.js.es6 +++ b/app/assets/javascripts/discourse/components/user-summary-category-search.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", - @computed("user", "category") + @discourseComputed("user", "category") searchParams() { return `@${this.get("user.username")} #${this.get("category.slug")}`; } diff --git a/app/assets/javascripts/discourse/components/user-summary-topics-list.js.es6 b/app/assets/javascripts/discourse/components/user-summary-topics-list.js.es6 index 39970994da..5cee1e00e9 100644 --- a/app/assets/javascripts/discourse/components/user-summary-topics-list.js.es6 +++ b/app/assets/javascripts/discourse/components/user-summary-topics-list.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; // should be kept in sync with 'UserSummary::MAX_SUMMARY_RESULTS' const MAX_SUMMARY_RESULTS = 6; @@ -7,7 +7,7 @@ const MAX_SUMMARY_RESULTS = 6; export default Component.extend({ tagName: "", - @computed("items.length") + @discourseComputed("items.length") hasMore(length) { return length >= MAX_SUMMARY_RESULTS; } diff --git a/app/assets/javascripts/discourse/controllers/about.js.es6 b/app/assets/javascripts/discourse/controllers/about.js.es6 index 85f26757ae..9225421995 100644 --- a/app/assets/javascripts/discourse/controllers/about.js.es6 +++ b/app/assets/javascripts/discourse/controllers/about.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { gt } from "@ember/object/computed"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ faqOverriden: gt("siteSettings.faq_url.length", 0), - @computed + @discourseComputed contactInfo() { if (this.siteSettings.contact_url) { return I18n.t("about.contact_info", { diff --git a/app/assets/javascripts/discourse/controllers/account-created-edit-email.js.es6 b/app/assets/javascripts/discourse/controllers/account-created-edit-email.js.es6 index 1002a90752..dd3314110b 100644 --- a/app/assets/javascripts/discourse/controllers/account-created-edit-email.js.es6 +++ b/app/assets/javascripts/discourse/controllers/account-created-edit-email.js.es6 @@ -1,13 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import { changeEmail } from "discourse/lib/user-activation"; -import computed from "ember-addons/ember-computed-decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend({ accountCreated: null, newEmail: null, - @computed("newEmail", "accountCreated.email") + @discourseComputed("newEmail", "accountCreated.email") submitDisabled(newEmail, currentEmail) { return newEmail === currentEmail; }, diff --git a/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 index 8fc90f6cf0..010878f720 100644 --- a/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 +++ b/app/assets/javascripts/discourse/controllers/activation-edit.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { extractError } from "discourse/lib/ajax-error"; import { changeEmail } from "discourse/lib/user-activation"; @@ -12,7 +12,7 @@ export default Controller.extend(ModalFunctionality, { newEmail: null, password: null, - @computed("newEmail", "currentEmail") + @discourseComputed("newEmail", "currentEmail") submitDisabled(newEmail, currentEmail) { return newEmail === currentEmail; }, diff --git a/app/assets/javascripts/discourse/controllers/add-post-notice.js.es6 b/app/assets/javascripts/discourse/controllers/add-post-notice.js.es6 index 83883ca5aa..b0c92b3a81 100644 --- a/app/assets/javascripts/discourse/controllers/add-post-notice.js.es6 +++ b/app/assets/javascripts/discourse/controllers/add-post-notice.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import computed from "ember-addons/ember-computed-decorators"; import { cookAsync } from "discourse/lib/text"; export default Controller.extend(ModalFunctionality, { @@ -12,7 +12,7 @@ export default Controller.extend(ModalFunctionality, { notice: null, saving: false, - @computed("saving", "notice") + @discourseComputed("saving", "notice") disabled(saving, notice) { return saving || isEmpty(notice); }, diff --git a/app/assets/javascripts/discourse/controllers/application.js.es6 b/app/assets/javascripts/discourse/controllers/application.js.es6 index 610e745e64..c96c10c6e0 100644 --- a/app/assets/javascripts/discourse/controllers/application.js.es6 +++ b/app/assets/javascripts/discourse/controllers/application.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject as service } from "@ember/service"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { isAppWebview, isiOSPWA } from "discourse/lib/utilities"; export default Controller.extend({ @@ -8,7 +8,7 @@ export default Controller.extend({ showFooter: false, router: service(), - @computed + @discourseComputed canSignUp() { return ( !Discourse.SiteSettings.invite_only && @@ -17,12 +17,12 @@ export default Controller.extend({ ); }, - @computed + @discourseComputed loginRequired() { return Discourse.SiteSettings.login_required && !this.currentUser; }, - @computed + @discourseComputed showFooterNav() { return isAppWebview() || isiOSPWA(); } diff --git a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 index 1a23033580..959b296cec 100644 --- a/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 +++ b/app/assets/javascripts/discourse/controllers/avatar-selector.js.es6 @@ -1,12 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { ajax } from "discourse/lib/ajax"; -import { allowsImages } from "discourse/lib/utilities"; +import { allowsImages } from "discourse/lib/uploads"; import { popupAjaxError } from "discourse/lib/ajax-error"; export default Controller.extend(ModalFunctionality, { - @computed( + @discourseComputed( "selected", "user.system_avatar_upload_id", "user.gravatar_avatar_upload_id", @@ -23,7 +23,7 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed( + @discourseComputed( "selected", "user.system_avatar_template", "user.gravatar_avatar_template", @@ -40,9 +40,12 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed() + @discourseComputed() allowAvatarUpload() { - return this.siteSettings.allow_uploaded_avatars && allowsImages(); + return ( + this.siteSettings.allow_uploaded_avatars && + allowsImages(this.currentUser.staff) + ); }, actions: { diff --git a/app/assets/javascripts/discourse/controllers/badges/index.js.es6 b/app/assets/javascripts/discourse/controllers/badges/index.js.es6 index c1c4d8db27..e056401703 100644 --- a/app/assets/javascripts/discourse/controllers/badges/index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/badges/index.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ - @computed("model") + @discourseComputed("model") badgeGroups(model) { var sorted = _.sortBy(model, function(badge) { var pos = badge.get("badge_grouping.position"); diff --git a/app/assets/javascripts/discourse/controllers/badges/show.js.es6 b/app/assets/javascripts/discourse/controllers/badges/show.js.es6 index d1093c13bc..807ea2b596 100644 --- a/app/assets/javascripts/discourse/controllers/badges/show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/badges/show.js.es6 @@ -4,9 +4,9 @@ import Controller from "@ember/controller"; import Badge from "discourse/models/badge"; import UserBadge from "discourse/models/user-badge"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ queryParams: ["username"], @@ -15,12 +15,12 @@ export default Controller.extend({ application: inject(), hiddenSetTitle: true, - @computed("userBadgesAll") + @discourseComputed("userBadgesAll") filteredList(userBadgesAll) { return userBadgesAll.filterBy("badge.allow_title", true); }, - @computed("filteredList") + @discourseComputed("filteredList") selectableUserBadges(filteredList) { return [ EmberObject.create({ @@ -30,24 +30,24 @@ export default Controller.extend({ ]; }, - @computed("username") + @discourseComputed("username") user(username) { if (username) { return this.userBadges[0].get("user"); } }, - @computed("username", "model.grant_count", "userBadges.grant_count") + @discourseComputed("username", "model.grant_count", "userBadges.grant_count") grantCount(username, modelCount, userCount) { return username ? userCount : modelCount; }, - @computed("model.grant_count", "userBadges.grant_count") + @discourseComputed("model.grant_count", "userBadges.grant_count") othersCount(modelCount, userCount) { return modelCount - userCount; }, - @computed("model.allow_title", "model.has_badge", "model") + @discourseComputed("model.allow_title", "model.has_badge", "model") canSelectTitle(hasTitleBadges, hasBadge) { return this.siteSettings.enable_badges && hasTitleBadges && hasBadge; }, @@ -81,7 +81,7 @@ export default Controller.extend({ } }, - @computed("noMoreBadges", "grantCount", "userBadges.length") + @discourseComputed("noMoreBadges", "grantCount", "userBadges.length") canLoadMore(noMoreBadges, grantCount, userBadgeLength) { if (noMoreBadges) { return false; @@ -89,7 +89,7 @@ export default Controller.extend({ return grantCount > (userBadgeLength || 0); }, - @computed("user", "model.grant_count") + @discourseComputed("user", "model.grant_count") canShowOthers(user, grantCount) { return !!user && grantCount > 1; }, diff --git a/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 b/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 index 0d8ac58ca2..d9627ee47f 100644 --- a/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 +++ b/app/assets/javascripts/discourse/controllers/bulk-notification-level.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { empty } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { topicLevels } from "discourse/lib/notification-levels"; // Support for changing the notification level of various topics @@ -9,7 +9,7 @@ export default Controller.extend({ topicBulkActions: inject(), notificationLevelId: null, - @computed + @discourseComputed notificationLevels() { return topicLevels.map(level => { return { diff --git a/app/assets/javascripts/discourse/controllers/change-owner.js.es6 b/app/assets/javascripts/discourse/controllers/change-owner.js.es6 index 10ed05c04f..0380faf86c 100644 --- a/app/assets/javascripts/discourse/controllers/change-owner.js.es6 +++ b/app/assets/javascripts/discourse/controllers/change-owner.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias } from "@ember/object/computed"; import { next } from "@ember/runloop"; @@ -5,7 +6,7 @@ import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import DiscourseURL from "discourse/lib/url"; -import computed from "ember-addons/ember-computed-decorators"; +import Topic from "discourse/models/topic"; export default Controller.extend(ModalFunctionality, { topicController: inject("topic"), @@ -16,7 +17,7 @@ export default Controller.extend(ModalFunctionality, { selectedPostsCount: alias("topicController.selectedPostsCount"), selectedPostsUsername: alias("topicController.selectedPostsUsername"), - @computed("saving", "new_user") + @discourseComputed("saving", "new_user") buttonDisabled(saving, newUser) { return saving || isEmpty(newUser); }, @@ -37,10 +38,7 @@ export default Controller.extend(ModalFunctionality, { username: this.new_user }; - Discourse.Topic.changeOwners( - this.get("topicController.model.id"), - options - ).then( + Topic.changeOwners(this.get("topicController.model.id"), options).then( () => { this.send("closeModal"); this.topicController.send("deselectAll"); diff --git a/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 index 9a29c747d7..5cb6bdba53 100644 --- a/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 +++ b/app/assets/javascripts/discourse/controllers/change-timestamp.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { next } from "@ember/runloop"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import computed from "ember-addons/ember-computed-decorators"; import DiscourseURL from "discourse/lib/url"; import Topic from "discourse/models/topic"; @@ -14,22 +14,22 @@ export default Controller.extend(ModalFunctionality, { date: "", time: "", - @computed("saving") + @discourseComputed("saving") buttonTitle(saving) { return saving ? I18n.t("saving") : I18n.t("topic.change_timestamp.action"); }, - @computed("date", "time") + @discourseComputed("date", "time") createdAt(date, time) { return moment(`${date} ${time}`, "YYYY-MM-DD HH:mm:ss"); }, - @computed("createdAt") + @discourseComputed("createdAt") validTimestamp(createdAt) { return moment().diff(createdAt, "minutes") < 0; }, - @computed("saving", "date", "validTimestamp") + @discourseComputed("saving", "date", "validTimestamp") buttonDisabled(saving, date, validTimestamp) { if (saving || validTimestamp) return true; return isEmpty(date); diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index df26812280..a379d8f7e1 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -9,22 +9,22 @@ import Quote from "discourse/lib/quote"; import Draft from "discourse/models/draft"; import Composer from "discourse/models/composer"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; -import InputValidation from "discourse/models/input-validation"; +} from "discourse-common/utils/decorators"; import { getOwner } from "discourse-common/lib/get-owner"; +import { escapeExpression, safariHacksDisabled } from "discourse/lib/utilities"; import { - escapeExpression, - uploadIcon, authorizesOneOrMoreExtensions, - safariHacksDisabled -} from "discourse/lib/utilities"; + uploadIcon +} from "discourse/lib/uploads"; import { emojiUnescape } from "discourse/lib/text"; import { shortDate } from "discourse/lib/formatter"; import { SAVE_LABELS, SAVE_ICONS } from "discourse/models/composer"; import { Promise } from "rsvp"; +import ENV from "discourse-common/config/environment"; +import EmberObject, { computed } from "@ember/object"; function loadDraft(store, opts) { opts = opts || {}; @@ -67,7 +67,7 @@ function loadDraft(store, opts) { const _popupMenuOptionsCallbacks = []; -let _checkDraftPopup = !Ember.testing; +let _checkDraftPopup = !ENV.environment === "test"; export function toggleCheckDraftPopup(enabled) { _checkDraftPopup = enabled; @@ -109,7 +109,7 @@ export default Controller.extend({ this.set("showPreview", val === "true"); }, - @computed("showPreview") + @discourseComputed("showPreview") toggleText(showPreview) { return showPreview ? I18n.t("composer.hide_preview") @@ -126,7 +126,7 @@ export default Controller.extend({ } }, - @computed( + @discourseComputed( "model.replyingToTopic", "model.creatingPrivateMessage", "model.targetUsernames", @@ -157,7 +157,7 @@ export default Controller.extend({ return "title"; }, - showToolbar: Ember.computed({ + showToolbar: computed({ get() { const keyValueStore = getOwner(this).lookup("key-value-store:main"); const storedVal = keyValueStore.get("toolbar-enabled"); @@ -183,7 +183,7 @@ export default Controller.extend({ topicModel: alias("topicController.model"), - @computed("model.canEditTitle", "model.creatingPrivateMessage") + @discourseComputed("model.canEditTitle", "model.creatingPrivateMessage") canEditTags(canEditTitle, creatingPrivateMessage) { return ( this.site.can_tag_topics && @@ -193,12 +193,12 @@ export default Controller.extend({ ); }, - @computed("model.editingPost", "model.topic.details.can_edit") + @discourseComputed("model.editingPost", "model.topic.details.can_edit") disableCategoryChooser(editingPost, canEditTopic) { return editingPost && !canEditTopic; }, - @computed("model.editingPost", "model.topic.canEditTags") + @discourseComputed("model.editingPost", "model.topic.canEditTags") disableTagsChooser(editingPost, canEditTags) { return editingPost && !canEditTags; }, @@ -207,12 +207,12 @@ export default Controller.extend({ canUnlistTopic: and("model.creatingTopic", "isStaffUser"), - @computed("canWhisper", "replyingToWhisper") + @discourseComputed("canWhisper", "replyingToWhisper") showWhisperToggle(canWhisper, replyingToWhisper) { return canWhisper && !replyingToWhisper; }, - @computed("model.post") + @discourseComputed("model.post") replyingToWhisper(repliedToPost) { return ( repliedToPost && repliedToPost.post_type === this.site.post_types.whisper @@ -221,14 +221,14 @@ export default Controller.extend({ isWhispering: or("replyingToWhisper", "model.whisper"), - @computed("model.action", "isWhispering") + @discourseComputed("model.action", "isWhispering") saveIcon(action, isWhispering) { if (isWhispering) return "far-eye-slash"; return SAVE_ICONS[action]; }, - @computed("model.action", "isWhispering", "model.editConflict") + @discourseComputed("model.action", "isWhispering", "model.editConflict") saveLabel(action, isWhispering, editConflict) { if (editConflict) return "composer.overwrite_edit"; else if (isWhispering) return "composer.create_whisper"; @@ -236,7 +236,7 @@ export default Controller.extend({ return SAVE_LABELS[action]; }, - @computed("isStaffUser", "model.action") + @discourseComputed("isStaffUser", "model.action") canWhisper(isStaffUser, action) { return ( this.siteSettings.enable_whispers && @@ -259,7 +259,7 @@ export default Controller.extend({ return option; }, - @computed("model.composeState", "model.creatingTopic", "model.post") + @discourseComputed("model.composeState", "model.creatingTopic", "model.post") popupMenuOptions(composeState) { if (composeState === "open" || composeState === "fullscreen") { const options = []; @@ -294,7 +294,7 @@ export default Controller.extend({ } }, - @computed("model.creatingPrivateMessage", "model.targetUsernames") + @discourseComputed("model.creatingPrivateMessage", "model.targetUsernames") showWarning(creatingPrivateMessage, usernames) { if (!this.get("currentUser.staff")) { return false; @@ -314,18 +314,20 @@ export default Controller.extend({ return creatingPrivateMessage; }, - @computed("model.topic.title") + @discourseComputed("model.topic.title") draftTitle(topicTitle) { return emojiUnescape(escapeExpression(topicTitle)); }, - @computed + @discourseComputed allowUpload() { - return authorizesOneOrMoreExtensions(); + return authorizesOneOrMoreExtensions(this.currentUser.staff); }, - @computed() - uploadIcon: () => uploadIcon(), + @discourseComputed() + uploadIcon() { + return uploadIcon(this.currentUser.staff); + }, actions: { togglePreview() { @@ -695,7 +697,7 @@ export default Controller.extend({ if (this.get("model.editingPost")) { this.appEvents.trigger("post-stream:refresh", { - id: parseInt(result.responseJson.id) + id: parseInt(result.responseJson.id, 10) }); if (result.responseJson.post.post_number === 1) { this.appEvents.trigger("header:update-topic", composer.topic); @@ -707,6 +709,17 @@ export default Controller.extend({ if (result.responseJson.action === "create_post") { this.appEvents.trigger("post:highlight", result.payload.post_number); } + + if (result.responseJson.route_to) { + this.destroyDraft(); + if (result.responseJson.message) { + return bootbox.alert(result.responseJson.message, () => { + DiscourseURL.routeTo(result.responseJson.route_to); + }); + } + return DiscourseURL.routeTo(result.responseJson.route_to); + } + this.close(); const currentUser = this.currentUser; @@ -757,8 +770,8 @@ export default Controller.extend({ @method open @param {Object} opts Options for creating a post @param {String} opts.action The action we're performing: edit, reply or createTopic - @param {Discourse.Post} [opts.post] The post we're replying to - @param {Discourse.Topic} [opts.topic] The topic we're replying to + @param {Post} [opts.post] The post we're replying to + @param {Topic} [opts.topic] The topic we're replying to @param {String} [opts.quote] If we're opening a reply from a quote, the quote we're making **/ open(opts) { @@ -1053,10 +1066,10 @@ export default Controller.extend({ debounce(this, this._saveDraft, 2000); }, - @computed("model.categoryId", "lastValidatedAt") + @discourseComputed("model.categoryId", "lastValidatedAt") categoryValidation(categoryId, lastValidatedAt) { if (!this.siteSettings.allow_uncategorized_topics && !categoryId) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("composer.error.category_missing"), lastShownAt: lastValidatedAt @@ -1064,7 +1077,7 @@ export default Controller.extend({ } }, - @computed("model.category", "model.tags", "lastValidatedAt") + @discourseComputed("model.category", "model.tags", "lastValidatedAt") tagValidation(category, tags, lastValidatedAt) { const tagsArray = tags || []; if ( @@ -1072,7 +1085,7 @@ export default Controller.extend({ category && category.minimum_required_tags > tagsArray.length ) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("composer.error.tags_missing", { count: category.minimum_required_tags @@ -1111,12 +1124,12 @@ export default Controller.extend({ $(".d-editor-input").autocomplete({ cancel: true }); }, - @computed("model.action") + @discourseComputed("model.action") canEdit(action) { return action === "edit" && this.currentUser.can_edit; }, - @computed("model.composeState") + @discourseComputed("model.composeState") visible(state) { return state && state !== "closed"; } diff --git a/app/assets/javascripts/discourse/controllers/create-account.js.es6 b/app/assets/javascripts/discourse/controllers/create-account.js.es6 index beb87d2d3a..5bfe1ec139 100644 --- a/app/assets/javascripts/discourse/controllers/create-account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/create-account.js.es6 @@ -6,17 +6,18 @@ import { ajax } from "discourse/lib/ajax"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { setting } from "discourse/lib/computed"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { emailValid } from "discourse/lib/utilities"; -import InputValidation from "discourse/models/input-validation"; import PasswordValidation from "discourse/mixins/password-validation"; import UsernameValidation from "discourse/mixins/username-validation"; import NameValidation from "discourse/mixins/name-validation"; import UserFieldsValidation from "discourse/mixins/user-fields-validation"; import { userPath } from "discourse/lib/url"; import { findAll } from "discourse/models/login-method"; +import EmberObject from "@ember/object"; +import User from "discourse/models/user"; export default Controller.extend( ModalFunctionality, @@ -58,7 +59,7 @@ export default Controller.extend( this._createUserFields(); }, - @computed( + @discourseComputed( "passwordRequired", "nameValidation.failed", "emailValidation.failed", @@ -82,7 +83,7 @@ export default Controller.extend( usernameRequired: not("authOptions.omit_username"), - @computed + @discourseComputed fullnameRequired() { return ( this.get("siteSettings.full_name_required") || @@ -90,12 +91,12 @@ export default Controller.extend( ); }, - @computed("authOptions.auth_provider") + @discourseComputed("authOptions.auth_provider") passwordRequired(authProvider) { return isEmpty(authProvider); }, - @computed + @discourseComputed disclaimerHtml() { return I18n.t("create_account.disclaimer", { tos_link: this.get("siteSettings.tos_url") || Discourse.getURL("/tos"), @@ -106,17 +107,17 @@ export default Controller.extend( }, // Check the email address - @computed("accountEmail", "rejectedEmails.[]") + @discourseComputed("accountEmail", "rejectedEmails.[]") emailValidation(email, rejectedEmails) { // If blank, fail without a reason if (isEmpty(email)) { - return InputValidation.create({ + return EmberObject.create({ failed: true }); } if (rejectedEmails.includes(email)) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.email.invalid") }); @@ -126,7 +127,7 @@ export default Controller.extend( this.get("authOptions.email") === email && this.get("authOptions.email_valid") ) { - return InputValidation.create({ + return EmberObject.create({ ok: true, reason: I18n.t("user.email.authenticated", { provider: this.authProviderDisplayName( @@ -137,19 +138,23 @@ export default Controller.extend( } if (emailValid(email)) { - return InputValidation.create({ + return EmberObject.create({ ok: true, reason: I18n.t("user.email.ok") }); } - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.email.invalid") }); }, - @computed("accountEmail", "authOptions.email", "authOptions.email_valid") + @discourseComputed( + "accountEmail", + "authOptions.email", + "authOptions.email_valid" + ) emailValidated() { return ( this.get("authOptions.email") === this.accountEmail && @@ -187,7 +192,7 @@ export default Controller.extend( }.observes("emailValidation", "accountEmail"), // Determines whether at least one login button is enabled - @computed + @discourseComputed hasAtLeastOneLoginButton() { return findAll().length > 0; }, @@ -240,7 +245,7 @@ export default Controller.extend( } this.set("formSubmitted", true); - return Discourse.User.createAccount(attrs).then( + return User.createAccount(attrs).then( result => { this.set("isDeveloper", false); if (result.success) { diff --git a/app/assets/javascripts/discourse/controllers/discovery.js.es6 b/app/assets/javascripts/discourse/controllers/discovery.js.es6 index 6345bb8aa2..93237fa22b 100644 --- a/app/assets/javascripts/discourse/controllers/discovery.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery.js.es6 @@ -2,6 +2,7 @@ import { alias, not } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import DiscourseURL from "discourse/lib/url"; +import Category from "discourse/models/category"; export default Controller.extend({ discoveryTopics: inject("discovery/topics"), @@ -22,13 +23,13 @@ export default Controller.extend({ showMoreUrl(period) { let url = "", category = this.category; + if (category) { - url = - "/c/" + - Discourse.Category.slugFor(category) + - (this.noSubcategories ? "/none" : "") + - "/l"; + url = `/c/${Category.slugFor(category)}/${category.id}${ + this.noSubcategories ? "/none" : "" + }/l`; } + url += "/top/" + period; return url; }, diff --git a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 index 81542214ef..69e59db2a9 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/categories.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { reads } from "@ember/object/computed"; import { inject } from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import DiscoveryController from "discourse/controllers/discovery"; import { dasherize } from "@ember/string"; @@ -19,7 +19,7 @@ export default DiscoveryController.extend({ canEdit: reads("currentUser.staff"), - @computed("model.categories.[].featuredTopics.length") + @discourseComputed("model.categories.[].featuredTopics.length") latestTopicOnly() { return ( this.get("model.categories").find( @@ -28,7 +28,7 @@ export default DiscoveryController.extend({ ); }, - @computed("model.parentCategory") + @discourseComputed("model.parentCategory") categoryPageStyle(parentCategory) { let style = this.site.mobileView ? "categories_with_featured_topics" diff --git a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 index 447e2ead37..14a6c597f8 100644 --- a/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 +++ b/app/assets/javascripts/discourse/controllers/discovery/topics.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, not, gt, empty, notEmpty, equal } from "@ember/object/computed"; import { inject } from "@ember/controller"; import DiscoveryController from "discourse/controllers/discovery"; @@ -7,7 +8,7 @@ import { endWith } from "discourse/lib/computed"; import showModal from "discourse/lib/show-modal"; import { userPath } from "discourse/lib/url"; import TopicList from "discourse/models/topic-list"; -import computed from "ember-addons/ember-computed-decorators"; +import Topic from "discourse/models/topic"; const controllerOpts = { discovery: inject(), @@ -83,7 +84,9 @@ const controllerOpts = { resetNew() { this.topicTrackingState.resetNew(); - Discourse.Topic.resetNew().then(() => this.send("refresh")); + Topic.resetNew(this.category, !this.noSubcategories).then(() => + this.send("refresh") + ); }, dismissReadPosts() { @@ -98,17 +101,17 @@ const controllerOpts = { return filter.match(new RegExp(filterType + "$", "gi")) ? true : false; }, - @computed("model.filter", "model.topics.length") + @discourseComputed("model.filter", "model.topics.length") showDismissRead(filter, topicsLength) { return this.isFilterPage(filter, "unread") && topicsLength > 0; }, - @computed("model.filter", "model.topics.length") + @discourseComputed("model.filter", "model.topics.length") showResetNew(filter, topicsLength) { - return filter === "new" && topicsLength > 0; + return this.isFilterPage(filter, "new") && topicsLength > 0; }, - @computed("model.filter", "model.topics.length") + @discourseComputed("model.filter", "model.topics.length") showDismissAtTop(filter, topicsLength) { return ( (this.isFilterPage(filter, "new") || @@ -128,7 +131,7 @@ const controllerOpts = { weekly: equal("period", "weekly"), daily: equal("period", "daily"), - @computed("allLoaded", "model.topics.length") + @discourseComputed("allLoaded", "model.topics.length") footerMessage(allLoaded, topicsLength) { if (!allLoaded) return; @@ -151,7 +154,7 @@ const controllerOpts = { } }, - @computed("allLoaded", "model.topics.length") + @discourseComputed("allLoaded", "model.topics.length") footerEducation(allLoaded, topicsLength) { if (!allLoaded || topicsLength > 0 || !this.currentUser) { return; diff --git a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 index 87c1378ffe..36c961c7e1 100644 --- a/app/assets/javascripts/discourse/controllers/edit-category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-category.js.es6 @@ -4,10 +4,11 @@ import ModalFunctionality from "discourse/mixins/modal-functionality"; import DiscourseURL from "discourse/lib/url"; import { extractError } from "discourse/lib/ajax-error"; import { - default as computed, + default as discourseComputed, on, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; +import Category from "discourse/models/category"; export default Controller.extend(ModalFunctionality, { selectedTab: null, @@ -39,7 +40,7 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed("model.{id,name}") + @discourseComputed("model.{id,name}") title(model) { if (model.id) { return I18n.t("category.edit_dialog_title", { @@ -54,7 +55,7 @@ export default Controller.extend(ModalFunctionality, { this.set("modal.title", this.title); }, - @computed("saving", "model.name", "model.color", "deleting") + @discourseComputed("saving", "model.name", "model.color", "deleting") disabled(saving, name, color, deleting) { if (saving || deleting) return true; if (!name) return true; @@ -62,18 +63,18 @@ export default Controller.extend(ModalFunctionality, { return false; }, - @computed("saving", "deleting") + @discourseComputed("saving", "deleting") deleteDisabled(saving, deleting) { return deleting || saving || false; }, - @computed("name") + @discourseComputed("name") categoryName(name) { name = name || ""; return name.trim().length > 0 ? name : I18n.t("preview"); }, - @computed("saving", "model.id") + @discourseComputed("saving", "model.id") saveLabel(saving, id) { if (saving) return "saving"; return id ? "category.save" : "category.create"; @@ -106,7 +107,7 @@ export default Controller.extend(ModalFunctionality, { slug: result.category.slug, id: result.category.id }); - DiscourseURL.redirectTo("/c/" + Discourse.Category.slugFor(model)); + DiscourseURL.redirectTo(`/c/${Category.slugFor(model)}/${model.id}`); }) .catch(error => { this.flash(extractError(error), "error"); diff --git a/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 b/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 index 7dc26c632c..c1473509f2 100644 --- a/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/edit-topic-timer.js.es6 @@ -1,6 +1,6 @@ import EmberObject from "@ember/object"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import TopicTimer from "discourse/models/topic-timer"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -17,7 +17,7 @@ export default Controller.extend(ModalFunctionality, { loading: false, isPublic: "true", - @computed("model.closed") + @discourseComputed("model.closed") publicTimerTypes(closed) { let types = [ { @@ -50,17 +50,21 @@ export default Controller.extend(ModalFunctionality, { return types; }, - @computed() + @discourseComputed() privateTimerTypes() { return [{ id: REMINDER_TYPE, name: I18n.t("topic.reminder.title") }]; }, - @computed("isPublic", "publicTimerTypes", "privateTimerTypes") + @discourseComputed("isPublic", "publicTimerTypes", "privateTimerTypes") selections(isPublic, publicTimerTypes, privateTimerTypes) { return "true" === isPublic ? publicTimerTypes : privateTimerTypes; }, - @computed("isPublic", "model.topic_timer", "model.private_topic_timer") + @discourseComputed( + "isPublic", + "model.topic_timer", + "model.private_topic_timer" + ) topicTimer(isPublic, publicTopicTimer, privateTopicTimer) { return "true" === isPublic ? publicTopicTimer : privateTopicTimer; }, diff --git a/app/assets/javascripts/discourse/controllers/email-login.js.es6 b/app/assets/javascripts/discourse/controllers/email-login.js.es6 index 78ab74f239..01612c766f 100644 --- a/app/assets/javascripts/discourse/controllers/email-login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/email-login.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import { ajax } from "discourse/lib/ajax"; import DiscourseURL from "discourse/lib/url"; @@ -9,12 +9,12 @@ import { getWebauthnCredential } from "discourse/lib/webauthn"; export default Controller.extend({ lockImageUrl: Discourse.getURL("/images/lock.svg"), - @computed("model") + @discourseComputed("model") secondFactorRequired(model) { return model.security_key_required || model.second_factor_required; }, - @computed("model") + @discourseComputed("model") secondFactorMethod(model) { return model.security_key_required ? SECOND_FACTOR_METHODS.SECURITY_KEY diff --git a/app/assets/javascripts/discourse/controllers/exception.js.es6 b/app/assets/javascripts/discourse/controllers/exception.js.es6 index 7967f2d24a..f53eff91fd 100644 --- a/app/assets/javascripts/discourse/controllers/exception.js.es6 +++ b/app/assets/javascripts/discourse/controllers/exception.js.es6 @@ -3,8 +3,8 @@ import { schedule } from "@ember/runloop"; import Controller from "@ember/controller"; import { on, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; const ButtonBackBright = { classes: "btn-primary", @@ -33,7 +33,7 @@ export default Controller.extend({ thrown: null, lastTransition: null, - @computed + @discourseComputed isNetwork() { // never made it on the wire if (this.get("thrown.readyState") === 0) return true; @@ -60,7 +60,7 @@ export default Controller.extend({ this.set("loading", false); }, - @computed("isNetwork", "isServer", "isUnknown") + @discourseComputed("isNetwork", "isServer", "isUnknown") reason() { if (this.isNetwork) { return I18n.t("errors.reasons.network"); @@ -78,7 +78,7 @@ export default Controller.extend({ requestUrl: alias("thrown.requestedUrl"), - @computed("networkFixed", "isNetwork", "isServer", "isUnknown") + @discourseComputed("networkFixed", "isNetwork", "isServer", "isUnknown") desc() { if (this.networkFixed) { return I18n.t("errors.desc.network_fixed"); @@ -96,7 +96,7 @@ export default Controller.extend({ } }, - @computed("networkFixed", "isNetwork", "isServer", "isUnknown") + @discourseComputed("networkFixed", "isNetwork", "isServer", "isUnknown") enabledButtons() { if (this.networkFixed) { return [ButtonLoadPage]; diff --git a/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 b/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 index 48ca403722..8fb459a145 100644 --- a/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/feature-topic.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { categoryLinkHTML } from "discourse/helpers/category-link"; -import computed from "ember-addons/ember-computed-decorators"; -import InputValidation from "discourse/models/input-validation"; +import EmberObject from "@ember/object"; export default Controller.extend(ModalFunctionality, { topicController: inject("topic"), @@ -23,12 +23,16 @@ export default Controller.extend(ModalFunctionality, { }); }, - @computed("model.category") + @discourseComputed("model.category") categoryLink(category) { return categoryLinkHTML(category, { allowUncategorized: true }); }, - @computed("categoryLink", "model.pinned_globally", "model.pinned_until") + @discourseComputed( + "categoryLink", + "model.pinned_globally", + "model.pinned_until" + ) unPinMessage(categoryLink, pinnedGlobally, pinnedUntil) { let name = "topic.feature_topic.unpin"; if (pinnedGlobally) name += "_globally"; @@ -38,12 +42,12 @@ export default Controller.extend(ModalFunctionality, { return I18n.t(name, { categoryLink, until }); }, - @computed("categoryLink") + @discourseComputed("categoryLink") pinMessage(categoryLink) { return I18n.t("topic.feature_topic.pin", { categoryLink }); }, - @computed("categoryLink", "pinnedInCategoryCount") + @discourseComputed("categoryLink", "pinnedInCategoryCount") alreadyPinnedMessage(categoryLink, count) { const key = count === 0 @@ -52,40 +56,40 @@ export default Controller.extend(ModalFunctionality, { return I18n.t(key, { categoryLink, count }); }, - @computed("parsedPinnedInCategoryUntil") + @discourseComputed("parsedPinnedInCategoryUntil") pinDisabled(parsedPinnedInCategoryUntil) { return !this._isDateValid(parsedPinnedInCategoryUntil); }, - @computed("parsedPinnedGloballyUntil") + @discourseComputed("parsedPinnedGloballyUntil") pinGloballyDisabled(parsedPinnedGloballyUntil) { return !this._isDateValid(parsedPinnedGloballyUntil); }, - @computed("model.pinnedInCategoryUntil") + @discourseComputed("model.pinnedInCategoryUntil") parsedPinnedInCategoryUntil(pinnedInCategoryUntil) { return this._parseDate(pinnedInCategoryUntil); }, - @computed("model.pinnedGloballyUntil") + @discourseComputed("model.pinnedGloballyUntil") parsedPinnedGloballyUntil(pinnedGloballyUntil) { return this._parseDate(pinnedGloballyUntil); }, - @computed("pinDisabled") + @discourseComputed("pinDisabled") pinInCategoryValidation(pinDisabled) { if (pinDisabled) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("topic.feature_topic.pin_validation") }); } }, - @computed("pinGloballyDisabled") + @discourseComputed("pinGloballyDisabled") pinGloballyValidation(pinGloballyDisabled) { if (pinGloballyDisabled) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("topic.feature_topic.pin_validation") }); diff --git a/app/assets/javascripts/discourse/controllers/flag.js.es6 b/app/assets/javascripts/discourse/controllers/flag.js.es6 index 8f3c49ebc5..9eb3ae711d 100644 --- a/app/assets/javascripts/discourse/controllers/flag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/flag.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { not } from "@ember/object/computed"; import EmberObject from "@ember/object"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import ActionSummary from "discourse/models/action-summary"; import { MAX_MESSAGE_LENGTH } from "discourse/models/post-action-type"; -import computed from "ember-addons/ember-computed-decorators"; import optionalService from "discourse/lib/optional-service"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -32,17 +32,17 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed("spammerDetails.canDelete", "selected.name_key") + @discourseComputed("spammerDetails.canDelete", "selected.name_key") showDeleteSpammer(canDeleteSpammer, nameKey) { return canDeleteSpammer && nameKey === "spam"; }, - @computed("flagTopic") + @discourseComputed("flagTopic") title(flagTopic) { return flagTopic ? "flagging_topic.title" : "flagging.title"; }, - @computed("post", "flagTopic", "model.actions_summary.@each.can_act") + @discourseComputed("post", "flagTopic", "model.actions_summary.@each.can_act") flagsAvailable() { if (!this.flagTopic) { // flagging post @@ -77,7 +77,7 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed("post", "flagTopic", "model.actions_summary.@each.can_act") + @discourseComputed("post", "flagTopic", "model.actions_summary.@each.can_act") staffFlagsAvailable() { return ( this.get("model.flagsAvailable") && @@ -85,7 +85,7 @@ export default Controller.extend(ModalFunctionality, { ); }, - @computed("selected.is_custom_flag", "message.length") + @discourseComputed("selected.is_custom_flag", "message.length") submitEnabled() { const selected = this.selected; if (!selected) return false; @@ -103,17 +103,17 @@ export default Controller.extend(ModalFunctionality, { submitDisabled: not("submitEnabled"), // Staff accounts can "take action" - @computed("flagTopic", "selected.is_custom_flag") + @discourseComputed("flagTopic", "selected.is_custom_flag") canTakeAction(flagTopic, isCustomFlag) { return !flagTopic && !isCustomFlag && this.currentUser.get("staff"); }, - @computed("selected.is_custom_flag") + @discourseComputed("selected.is_custom_flag") submitIcon(isCustomFlag) { return isCustomFlag ? "envelope" : "flag"; }, - @computed("selected.is_custom_flag", "flagTopic") + @discourseComputed("selected.is_custom_flag", "flagTopic") submitLabel(isCustomFlag, flagTopic) { if (isCustomFlag) { return flagTopic @@ -193,7 +193,7 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed("flagTopic", "selected.name_key") + @discourseComputed("flagTopic", "selected.name_key") canSendWarning(flagTopic, nameKey) { return ( !flagTopic && this.currentUser.get("staff") && nameKey === "notify_user" diff --git a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 index ae5d53679d..0f667683ad 100644 --- a/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 +++ b/app/assets/javascripts/discourse/controllers/forgot-password.js.es6 @@ -1,16 +1,16 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { escapeExpression } from "discourse/lib/utilities"; import { extractError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(ModalFunctionality, { offerHelp: null, helpSeen: false, - @computed("accountEmailOrUsername", "disabled") + @discourseComputed("accountEmailOrUsername", "disabled") submitDisabled(accountEmailOrUsername, disabled) { return isEmpty((accountEmailOrUsername || "").trim()) || disabled; }, diff --git a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 index 0b37bead27..d62a2a8177 100644 --- a/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 +++ b/app/assets/javascripts/discourse/controllers/full-page-search.js.es6 @@ -10,9 +10,9 @@ import { isValidSearchTerm } from "discourse/lib/search"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; import { escapeExpression } from "discourse/lib/utilities"; import { setTransient } from "discourse/lib/page-tracker"; @@ -47,17 +47,17 @@ export default Controller.extend({ page: 1, resultCount: null, - @computed("resultCount") + @discourseComputed("resultCount") hasResults(resultCount) { return (resultCount || 0) > 0; }, - @computed("q") + @discourseComputed("q") hasAutofocus(q) { return isEmpty(q); }, - @computed("q") + @discourseComputed("q") highlightQuery(q) { if (!q) { return; @@ -66,7 +66,7 @@ export default Controller.extend({ return _.reject(q.split(/\s+/), t => t === "l").join(" "); }, - @computed("skip_context", "context") + @discourseComputed("skip_context", "context") searchContextEnabled: { get(skip, context) { return (!skip && context) || skip === "false"; @@ -76,7 +76,7 @@ export default Controller.extend({ } }, - @computed("context", "context_id") + @discourseComputed("context", "context_id") searchContextDescription(context, id) { var name = id; if (context === "category") { @@ -90,18 +90,18 @@ export default Controller.extend({ return searchContextDescription(context, name); }, - @computed("q") + @discourseComputed("q") searchActive(q) { return isValidSearchTerm(q); }, - @computed("q") + @discourseComputed("q") noSortQ(q) { q = this.cleanTerm(q); return escapeExpression(q); }, - @computed("canCreateTopic", "siteSettings.login_required") + @discourseComputed("canCreateTopic", "siteSettings.login_required") showSuggestion(canCreateTopic, loginRequired) { return canCreateTopic || !loginRequired; }, @@ -146,7 +146,7 @@ export default Controller.extend({ } }, - @computed("q") + @discourseComputed("q") showLikeCount(q) { return q && q.indexOf("order:likes") > -1; }, @@ -160,7 +160,7 @@ export default Controller.extend({ } }, - @computed("q") + @discourseComputed("q") isPrivateMessage(q) { return ( q && @@ -177,7 +177,7 @@ export default Controller.extend({ this.set("application.showFooter", !this.loading); }, - @computed("resultCount", "noSortQ") + @discourseComputed("resultCount", "noSortQ") resultCountLabel(count, term) { const plus = count % 50 === 0 ? "+" : ""; return I18n.t("search.result_count", { count, plus, term }); @@ -188,17 +188,17 @@ export default Controller.extend({ this.set("resultCount", this.get("model.posts.length")); }, - @computed("hasResults") + @discourseComputed("hasResults") canBulkSelect(hasResults) { return this.currentUser && this.currentUser.staff && hasResults; }, - @computed("model.grouped_search_result.can_create_topic") + @discourseComputed("model.grouped_search_result.can_create_topic") canCreateTopic(userCanCreateTopic) { return this.currentUser && userCanCreateTopic; }, - @computed("page") + @discourseComputed("page") isLastPage(page) { return page === PAGE_LIMIT; }, diff --git a/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 b/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 index 287b6a6994..a3cd4e11e8 100644 --- a/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 +++ b/app/assets/javascripts/discourse/controllers/grant-badge.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { extractError } from "discourse/lib/ajax-error"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import GrantBadgeController from "discourse/mixins/grant-badge-controller"; @@ -21,12 +21,12 @@ export default Controller.extend(ModalFunctionality, GrantBadgeController, { this.userBadges = []; }, - @computed("topicController.selectedPosts") + @discourseComputed("topicController.selectedPosts") post() { return this.get("topicController.selectedPosts")[0]; }, - @computed("post") + @discourseComputed("post") badgeReason(post) { const url = post.get("url"); const protocolAndHost = @@ -35,7 +35,7 @@ export default Controller.extend(ModalFunctionality, GrantBadgeController, { return url.indexOf("/") === 0 ? protocolAndHost + url : url; }, - @computed("saving", "selectedBadgeGrantable") + @discourseComputed("saving", "selectedBadgeGrantable") buttonDisabled(saving, selectedBadgeGrantable) { return saving || !selectedBadgeGrantable; }, diff --git a/app/assets/javascripts/discourse/controllers/group-activity-posts.js.es6 b/app/assets/javascripts/discourse/controllers/group-activity-posts.js.es6 index ed648a3dec..d73bf08d2f 100644 --- a/app/assets/javascripts/discourse/controllers/group-activity-posts.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-activity-posts.js.es6 @@ -1,6 +1,6 @@ import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { fmt } from "discourse/lib/computed"; export default Controller.extend({ diff --git a/app/assets/javascripts/discourse/controllers/group-add-members.js.es6 b/app/assets/javascripts/discourse/controllers/group-add-members.js.es6 index 53c1327c42..9b1f888113 100644 --- a/app/assets/javascripts/discourse/controllers/group-add-members.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-add-members.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { extractError } from "discourse/lib/ajax-error"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -8,7 +8,7 @@ export default Controller.extend(ModalFunctionality, { loading: false, setAsOwner: false, - @computed("model.usernames", "loading") + @discourseComputed("model.usernames", "loading") disableAddButton(usernames, loading) { return loading || !usernames || !(usernames.length > 0); }, diff --git a/app/assets/javascripts/discourse/controllers/group-bulk-add.js.es6 b/app/assets/javascripts/discourse/controllers/group-bulk-add.js.es6 index bef9d535f4..0310d4e309 100644 --- a/app/assets/javascripts/discourse/controllers/group-bulk-add.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-bulk-add.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { extractError } from "discourse/lib/ajax-error"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { ajax } from "discourse/lib/ajax"; @@ -8,7 +8,7 @@ import { ajax } from "discourse/lib/ajax"; export default Controller.extend(ModalFunctionality, { loading: false, - @computed("input", "loading", "result") + @discourseComputed("input", "loading", "result") disableAddButton(input, loading, result) { return loading || isEmpty(input) || input.length <= 0 || result; }, diff --git a/app/assets/javascripts/discourse/controllers/group-index.js.es6 b/app/assets/javascripts/discourse/controllers/group-index.js.es6 index 2cc94f9f4e..887d5dbfcf 100644 --- a/app/assets/javascripts/discourse/controllers/group-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-index.js.es6 @@ -1,64 +1,77 @@ +import Controller, { inject } from "@ember/controller"; import { alias } from "@ember/object/computed"; -import { inject } from "@ember/controller"; -import Controller from "@ember/controller"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import Group from "discourse/models/group"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; -import debounce from "discourse/lib/debounce"; +} from "discourse-common/utils/decorators"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseDebounce from "discourse/lib/debounce"; export default Controller.extend({ - queryParams: ["order", "desc", "filter"], - order: "", - desc: null, - loading: false, - limit: null, - offset: null, - isOwner: alias("model.is_group_owner"), - showActions: false, - filter: null, - filterInput: null, application: inject(), + queryParams: ["order", "desc", "filter"], + + order: "", + desc: null, + filter: null, + filterInput: null, + + loading: false, + isOwner: alias("model.is_group_owner"), + showActions: false, + @observes("filterInput") - _setFilter: debounce(function() { + _setFilter: discourseDebounce(function() { this.set("filter", this.filterInput); }, 500), @observes("order", "desc", "filter") - refreshMembers() { - this.set("loading", true); - const model = this.model; - - if (model && model.can_see_members) { - model.findMembers(this.memberParams).finally(() => { - this.set( - "application.showFooter", - model.members.length >= model.user_count - ); - this.set("loading", false); - }); - } + _filtersChanged() { + this.findMembers(true); }, - @computed("order", "desc", "filter") + findMembers(refresh) { + if (this.loading) { + return; + } + + const model = this.model; + if (!model) { + return; + } + + if (!refresh && model.members.length >= model.user_count) { + this.set("application.showFooter", true); + return; + } + + this.set("loading", true); + model.findMembers(this.memberParams, refresh).finally(() => { + this.set( + "application.showFooter", + model.members.length >= model.user_count + ); + this.set("loading", false); + }); + }, + + @discourseComputed("order", "desc", "filter") memberParams(order, desc, filter) { return { order, desc, filter }; }, - @computed("model.members") + @discourseComputed("model.members.[]") hasMembers(members) { return members && members.length > 0; }, - @computed("model") + @discourseComputed("model") canManageGroup(model) { return this.currentUser && this.currentUser.canManageGroup(model); }, - @computed + @discourseComputed filterPlaceholder() { if (this.currentUser && this.currentUser.admin) { return "groups.members.filter_placeholder_admin"; @@ -68,6 +81,10 @@ export default Controller.extend({ }, actions: { + loadMore() { + this.findMembers(); + }, + toggleActions() { this.toggleProperty("showActions"); }, @@ -92,38 +109,6 @@ export default Controller.extend({ .then(() => this.set("usernames", [])) .catch(popupAjaxError); } - }, - - loadMore() { - if (this.loading) { - return; - } - if (this.get("model.members.length") >= this.get("model.user_count")) { - this.set("application.showFooter", true); - return; - } - - this.set("loading", true); - - Group.loadMembers( - this.get("model.name"), - this.get("model.members.length"), - this.limit, - { order: this.order, desc: this.desc } - ).then(result => { - this.get("model.members").addObjects( - result.members.map(member => Discourse.User.create(member)) - ); - this.setProperties({ - loading: false, - user_count: result.meta.total, - limit: result.meta.limit, - offset: Math.min( - result.meta.offset + result.meta.limit, - result.meta.total - ) - }); - }); } } }); diff --git a/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 b/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 index f5c3ba48f3..3aaf3fdb87 100644 --- a/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-manage-logs.js.es6 @@ -2,9 +2,9 @@ import { inject } from "@ember/controller"; import EmberObject from "@ember/object"; import Controller from "@ember/controller"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ group: inject(), @@ -17,7 +17,7 @@ export default Controller.extend({ this.set("filters", EmberObject.create()); }, - @computed( + @discourseComputed( "filters.action", "filters.acting_user", "filters.target_user", diff --git a/app/assets/javascripts/discourse/controllers/group-manage.js.es6 b/app/assets/javascripts/discourse/controllers/group-manage.js.es6 index dab05b6dbc..b99f8a70f9 100644 --- a/app/assets/javascripts/discourse/controllers/group-manage.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-manage.js.es6 @@ -1,11 +1,11 @@ import { inject as service } from "@ember/service"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default Controller.extend({ router: service(), - @computed("model.automatic") + @discourseComputed("model.automatic") tabs(automatic) { const defaultTabs = [ { route: "group.manage.profile", title: "groups.manage.profile.title" }, diff --git a/app/assets/javascripts/discourse/controllers/group-requests.js.es6 b/app/assets/javascripts/discourse/controllers/group-requests.js.es6 index 246fd0cf9a..013c44f6d8 100644 --- a/app/assets/javascripts/discourse/controllers/group-requests.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group-requests.js.es6 @@ -1,81 +1,70 @@ -import { inject } from "@ember/controller"; -import Controller from "@ember/controller"; +import Controller, { inject } from "@ember/controller"; +import { + default as discourseComputed, + observes +} from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import Group from "discourse/models/group"; -import { - default as computed, - observes -} from "ember-addons/ember-computed-decorators"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default Controller.extend({ - queryParams: ["order", "desc", "filter"], - order: "", - desc: null, - loading: false, - limit: null, - offset: null, - filter: null, - filterInput: null, application: inject(), + queryParams: ["order", "desc", "filter"], + + order: "", + desc: null, + filter: null, + filterInput: null, + + loading: false, + @observes("filterInput") - _setFilter: debounce(function() { + _setFilter: discourseDebounce(function() { this.set("filter", this.filterInput); }, 500), @observes("order", "desc", "filter") - refreshRequesters(force) { - if (this.loading || !this.model) { + _filtersChanged() { + this.findRequesters(true); + }, + + findRequesters(refresh) { + if (this.loading) { return; } - if ( - !force && - this.count && - this.get("model.requesters.length") >= this.count - ) { + const model = this.model; + if (!model) { + return; + } + + if (!refresh && model.members.length >= model.user_count) { this.set("application.showFooter", true); return; } this.set("loading", true); - this.set("application.showFooter", false); - - Group.loadMembers( - this.get("model.name"), - force ? 0 : this.get("model.requesters.length"), - this.limit, - { - order: this.order, - desc: this.desc, - filter: this.filter, - requesters: true - } - ).then(result => { - const requesters = (!force && this.get("model.requesters")) || []; - requesters.addObjects(result.members.map(m => Discourse.User.create(m))); - this.set("model.requesters", requesters); - - this.setProperties({ - loading: false, - count: result.meta.total, - limit: result.meta.limit, - offset: Math.min( - result.meta.offset + result.meta.limit, - result.meta.total - ) - }); + model.findRequesters(this.memberParams, refresh).finally(() => { + this.set( + "application.showFooter", + model.requesters.length >= model.user_count + ); + this.set("loading", false); }); }, - @computed("model.requesters") + @discourseComputed("order", "desc", "filter") + memberParams(order, desc, filter) { + return { order, desc, filter }; + }, + + @discourseComputed("model.requesters.[]") hasRequesters(requesters) { return requesters && requesters.length > 0; }, - @computed + @discourseComputed filterPlaceholder() { if (this.currentUser && this.currentUser.admin) { return "groups.members.filter_placeholder_admin"; @@ -93,7 +82,7 @@ export default Controller.extend({ actions: { loadMore() { - this.refreshRequesters(); + this.findRequesters(); }, acceptRequest(user) { diff --git a/app/assets/javascripts/discourse/controllers/group.js.es6 b/app/assets/javascripts/discourse/controllers/group.js.es6 index 322e7ab12c..b6e9a7ab8f 100644 --- a/app/assets/javascripts/discourse/controllers/group.js.es6 +++ b/app/assets/javascripts/discourse/controllers/group.js.es6 @@ -1,7 +1,7 @@ import EmberObject from "@ember/object"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; const Tab = EmberObject.extend({ init() { @@ -18,13 +18,20 @@ export default Controller.extend({ showing: "members", destroying: null, - @computed( + @discourseComputed( "showMessages", "model.user_count", + "model.request_count", "canManageGroup", "model.allow_membership_requests" ) - tabs(showMessages, userCount, canManageGroup, allowMembershipRequests) { + tabs( + showMessages, + userCount, + requestCount, + canManageGroup, + allowMembershipRequests + ) { const membersTab = Tab.create({ name: "members", route: "group.index", @@ -41,7 +48,8 @@ export default Controller.extend({ Tab.create({ name: "requests", i18nKey: "requests.title", - icon: "user-plus" + icon: "user-plus", + count: requestCount }) ); } @@ -68,7 +76,7 @@ export default Controller.extend({ return defaultTabs; }, - @computed("model.is_group_user") + @discourseComputed("model.is_group_user") showMessages(isGroupUser) { if (!this.siteSettings.enable_personal_messages) { return false; @@ -77,17 +85,17 @@ export default Controller.extend({ return isGroupUser || (this.currentUser && this.currentUser.admin); }, - @computed("model.is_group_owner", "model.automatic") + @discourseComputed("model.is_group_owner", "model.automatic") canEditGroup(isGroupOwner, automatic) { return !automatic && isGroupOwner; }, - @computed("model.displayName", "model.full_name") + @discourseComputed("model.displayName", "model.full_name") groupName(displayName, fullName) { return (fullName || displayName).capitalize(); }, - @computed( + @discourseComputed( "model.name", "model.flair_url", "model.flair_bg_color", @@ -102,12 +110,12 @@ export default Controller.extend({ }; }, - @computed("model.messageable") + @discourseComputed("model.messageable") displayGroupMessageButton(messageable) { return this.currentUser && messageable; }, - @computed("model", "model.automatic") + @discourseComputed("model", "model.automatic") canManageGroup(model, automatic) { return ( this.currentUser && diff --git a/app/assets/javascripts/discourse/controllers/groups-index.js.es6 b/app/assets/javascripts/discourse/controllers/groups-index.js.es6 index a466572b26..0380bdffd8 100644 --- a/app/assets/javascripts/discourse/controllers/groups-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/groups-index.js.es6 @@ -1,10 +1,10 @@ import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ application: inject(), @@ -14,7 +14,7 @@ export default Controller.extend({ filter: "", type: null, - @computed("model.extras.type_filters") + @discourseComputed("model.extras.type_filters") types(typeFilters) { const types = []; @@ -28,7 +28,7 @@ export default Controller.extend({ }, @observes("filterInput") - _setFilter: debounce(function() { + _setFilter: discourseDebounce(function() { this.set("filter", this.filterInput); }, 500), diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6 index 69ea502d41..5a34f292cf 100644 --- a/app/assets/javascripts/discourse/controllers/history.js.es6 +++ b/app/assets/javascripts/discourse/controllers/history.js.es6 @@ -1,15 +1,18 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, gt, not, or, equal } from "@ember/object/computed"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; -import computed from "ember-addons/ember-computed-decorators"; import { propertyGreaterThan, propertyLessThan } from "discourse/lib/computed"; -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; import { sanitizeAsync } from "discourse/lib/text"; import { iconHTML } from "discourse-common/lib/icon-library"; +import Post from "discourse/models/post"; +import Category from "discourse/models/category"; +import { computed } from "@ember/object"; function customTagArray(fieldName) { - return Ember.computed(fieldName, function() { + return computed(fieldName, function() { var val = this.get(fieldName); if (!val) { return val; @@ -39,7 +42,11 @@ export default Controller.extend(ModalFunctionality, { previousTagChanges: customTagArray("model.tags_changes.previous"), currentTagChanges: customTagArray("model.tags_changes.current"), - @computed("previousVersion", "model.current_version", "model.version_count") + @discourseComputed( + "previousVersion", + "model.current_version", + "model.version_count" + ) revisionsText(previous, current, total) { return I18n.t( "post.revisions.controls.comparing_previous_to_current_out_of_total", @@ -55,19 +62,19 @@ export default Controller.extend(ModalFunctionality, { refresh(postId, postVersion) { this.set("loading", true); - Discourse.Post.loadRevision(postId, postVersion).then(result => { + Post.loadRevision(postId, postVersion).then(result => { this.setProperties({ loading: false, model: result }); }); }, hide(postId, postVersion) { - Discourse.Post.hideRevision(postId, postVersion).then(() => + Post.hideRevision(postId, postVersion).then(() => this.refresh(postId, postVersion) ); }, show(postId, postVersion) { - Discourse.Post.showRevision(postId, postVersion).then(() => + Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion) ); }, @@ -83,10 +90,7 @@ export default Controller.extend(ModalFunctionality, { post.set("topic.fancy_title", result.topic.fancy_title); } if (result.category_id) { - post.set( - "topic.category", - Discourse.Category.findById(result.category_id) - ); + post.set("topic.category", Category.findById(result.category_id)); } this.send("closeModal"); }) @@ -101,17 +105,17 @@ export default Controller.extend(ModalFunctionality, { }); }, - @computed("model.created_at") + @discourseComputed("model.created_at") createdAtDate(createdAt) { return moment(createdAt).format("LLLL"); }, - @computed("model.current_version") + @discourseComputed("model.current_version") previousVersion(current) { return current - 1; }, - @computed("model.current_revision", "model.previous_revision") + @discourseComputed("model.current_revision", "model.previous_revision") displayGoToPrevious(current, prev) { return prev && current > prev; }, @@ -140,17 +144,17 @@ export default Controller.extend(ModalFunctionality, { loadNextDisabled: or("loading", "hideGoToNext"), loadLastDisabled: or("loading", "hideGoToLast"), - @computed("model.previous_hidden") + @discourseComputed("model.previous_hidden") displayShow(prevHidden) { return prevHidden && this.currentUser && this.currentUser.get("staff"); }, - @computed("model.previous_hidden") + @discourseComputed("model.previous_hidden") displayHide(prevHidden) { return !prevHidden && this.currentUser && this.currentUser.get("staff"); }, - @computed( + @discourseComputed( "model.last_revision", "model.current_revision", "model.can_edit", @@ -160,19 +164,23 @@ export default Controller.extend(ModalFunctionality, { return !!(canEdit && topicController && lastRevision === currentRevision); }, - @computed("model.wiki") + @discourseComputed("model.wiki") editButtonLabel(wiki) { return `post.revisions.controls.${wiki ? "edit_wiki" : "edit_post"}`; }, - @computed() + @discourseComputed() displayRevert() { return this.currentUser && this.currentUser.get("staff"); }, isEitherRevisionHidden: or("model.previous_hidden", "model.current_hidden"), - @computed("model.previous_hidden", "model.current_hidden", "displayingInline") + @discourseComputed( + "model.previous_hidden", + "model.current_hidden", + "displayingInline" + ) hiddenClasses(prevHidden, currentHidden, displayingInline) { if (displayingInline) { return this.isEitherRevisionHidden ? "hidden-revision-either" : null; @@ -192,43 +200,43 @@ export default Controller.extend(ModalFunctionality, { displayingSideBySide: equal("viewMode", "side_by_side"), displayingSideBySideMarkdown: equal("viewMode", "side_by_side_markdown"), - @computed("displayingInline") + @discourseComputed("displayingInline") inlineClass(displayingInline) { return displayingInline ? "btn-danger" : "btn-flat"; }, - @computed("displayingSideBySide") + @discourseComputed("displayingSideBySide") sideBySideClass(displayingSideBySide) { return displayingSideBySide ? "btn-danger" : "btn-flat"; }, - @computed("displayingSideBySideMarkdown") + @discourseComputed("displayingSideBySideMarkdown") sideBySideMarkdownClass(displayingSideBySideMarkdown) { return displayingSideBySideMarkdown ? "btn-danger" : "btn-flat"; }, - @computed("model.category_id_changes") + @discourseComputed("model.category_id_changes") previousCategory(changes) { if (changes) { - var category = Discourse.Category.findById(changes["previous"]); + var category = Category.findById(changes["previous"]); return categoryBadgeHTML(category, { allowUncategorized: true }); } }, - @computed("model.category_id_changes") + @discourseComputed("model.category_id_changes") currentCategory(changes) { if (changes) { - var category = Discourse.Category.findById(changes["current"]); + var category = Category.findById(changes["current"]); return categoryBadgeHTML(category, { allowUncategorized: true }); } }, - @computed("model.wiki_changes") + @discourseComputed("model.wiki_changes") wikiDisabled(changes) { return changes && !changes["current"]; }, - @computed("model.post_type_changes") + @discourseComputed("model.post_type_changes") postTypeDisabled(changes) { return ( changes && @@ -236,7 +244,7 @@ export default Controller.extend(ModalFunctionality, { ); }, - @computed("viewMode", "model.title_changes") + @discourseComputed("viewMode", "model.title_changes") titleDiff(viewMode) { if (viewMode === "side_by_side_markdown") { viewMode = "side_by_side"; diff --git a/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 b/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 index b04499456b..21ea0603f8 100644 --- a/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 +++ b/app/assets/javascripts/discourse/controllers/insert-hyperlink.js.es6 @@ -147,7 +147,7 @@ export default Controller.extend(ModalFunctionality, { const origLink = this.linkUrl; const linkUrl = origLink.indexOf("://") === -1 ? `http://${origLink}` : origLink; - const sel = this._lastSel; + const sel = this.toolbarEvent.selected; if (isEmpty(linkUrl)) { return; diff --git a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 index 1b1c99de7d..9be1de123e 100644 --- a/app/assets/javascripts/discourse/controllers/invites-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/invites-show.js.es6 @@ -1,7 +1,7 @@ import { isEmpty } from "@ember/utils"; import { alias, notEmpty } from "@ember/object/computed"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import getUrl from "discourse-common/lib/get-url"; import DiscourseURL from "discourse/lib/url"; import { ajax } from "discourse/lib/ajax"; @@ -26,24 +26,24 @@ export default Controller.extend( userFields: null, inviteImageUrl: getUrl("/images/envelope.svg"), - @computed + @discourseComputed welcomeTitle() { return I18n.t("invites.welcome_to", { site_name: this.siteSettings.title }); }, - @computed("email") + @discourseComputed("email") yourEmailMessage(email) { return I18n.t("invites.your_email", { email: email }); }, - @computed + @discourseComputed externalAuthsEnabled() { return findLoginMethods().length > 0; }, - @computed( + @discourseComputed( "usernameValidation.failed", "passwordValidation.failed", "nameValidation.failed", @@ -58,7 +58,7 @@ export default Controller.extend( return usernameFailed || passwordFailed || nameFailed || userFieldsFailed; }, - @computed + @discourseComputed fullnameRequired() { return ( this.siteSettings.full_name_required || this.siteSettings.enable_names @@ -82,7 +82,8 @@ export default Controller.extend( username: this.accountUsername, name: this.accountName, password: this.accountPassword, - user_custom_fields: userCustomFields + user_custom_fields: userCustomFields, + timezone: moment.tz.guess() } }) .then(result => { diff --git a/app/assets/javascripts/discourse/controllers/jump-to-post.js.es6 b/app/assets/javascripts/discourse/controllers/jump-to-post.js.es6 index 35981f39a7..cd3b95936f 100644 --- a/app/assets/javascripts/discourse/controllers/jump-to-post.js.es6 +++ b/app/assets/javascripts/discourse/controllers/jump-to-post.js.es6 @@ -24,7 +24,7 @@ export default Controller.extend(ModalFunctionality, { }, _jumpToIndex(postsCounts, postNumber) { - const where = Math.min(postsCounts, Math.max(1, parseInt(postNumber))); + const where = Math.min(postsCounts, Math.max(1, parseInt(postNumber, 10))); this.jumpToIndex(where); this._close(); }, diff --git a/app/assets/javascripts/discourse/controllers/login.js.es6 b/app/assets/javascripts/discourse/controllers/login.js.es6 index 19fbc21fb7..7fd028cced 100644 --- a/app/assets/javascripts/discourse/controllers/login.js.es6 +++ b/app/assets/javascripts/discourse/controllers/login.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias, or, readOnly } from "@ember/object/computed"; import EmberObject from "@ember/object"; @@ -13,7 +14,6 @@ import { findAll } from "discourse/models/login-method"; import { escape } from "pretty-text/sanitizer"; import { escapeExpression, areCookiesEnabled } from "discourse/lib/utilities"; import { extractError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import { getWebauthnCredential } from "discourse/lib/webauthn"; @@ -55,17 +55,17 @@ export default Controller.extend(ModalFunctionality, { }); }, - @computed("showSecondFactor", "showSecurityKey") + @discourseComputed("showSecondFactor", "showSecurityKey") credentialsClass(showSecondFactor, showSecurityKey) { return showSecondFactor || showSecurityKey ? "hidden" : ""; }, - @computed("showSecondFactor", "showSecurityKey") + @discourseComputed("showSecondFactor", "showSecurityKey") secondFactorClass(showSecondFactor, showSecurityKey) { return showSecondFactor || showSecurityKey ? "" : "hidden"; }, - @computed("awaitingApproval", "hasAtLeastOneLoginButton") + @discourseComputed("awaitingApproval", "hasAtLeastOneLoginButton") modalBodyClasses(awaitingApproval, hasAtLeastOneLoginButton) { const classes = ["login-modal"]; if (awaitingApproval) classes.push("awaiting-approval"); @@ -73,31 +73,31 @@ export default Controller.extend(ModalFunctionality, { return classes.join(" "); }, - @computed("showSecondFactor", "showSecurityKey") + @discourseComputed("showSecondFactor", "showSecurityKey") disableLoginFields(showSecondFactor, showSecurityKey) { return showSecondFactor || showSecurityKey; }, - @computed("canLoginLocalWithEmail") + @discourseComputed("canLoginLocalWithEmail") hasAtLeastOneLoginButton(canLoginLocalWithEmail) { return findAll().length > 0 || canLoginLocalWithEmail; }, - @computed("loggingIn") + @discourseComputed("loggingIn") loginButtonLabel(loggingIn) { return loggingIn ? "login.logging_in" : "login.title"; }, loginDisabled: or("loggingIn", "loggedIn"), - @computed("loggingIn", "application.canSignUp") + @discourseComputed("loggingIn", "application.canSignUp") showSignupLink(loggingIn, canSignUp) { return canSignUp && !loggingIn; }, showSpinner: readOnly("loggingIn"), - @computed("canLoginLocalWithEmail", "processingEmailLink") + @discourseComputed("canLoginLocalWithEmail", "processingEmailLink") showLoginWithEmailLink(canLoginLocalWithEmail, processingEmailLink) { return canLoginLocalWithEmail && !processingEmailLink; }, @@ -122,7 +122,8 @@ export default Controller.extend(ModalFunctionality, { password: this.loginPassword, second_factor_token: this.secondFactorToken, second_factor_method: this.secondFactorMethod, - security_key_credential: this.securityKeyCredential + security_key_credential: this.securityKeyCredential, + timezone: moment.tz.guess() } }).then( result => { diff --git a/app/assets/javascripts/discourse/controllers/move-to-topic.js.es6 b/app/assets/javascripts/discourse/controllers/move-to-topic.js.es6 index c8934801cb..f80b659baa 100644 --- a/app/assets/javascripts/discourse/controllers/move-to-topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/move-to-topic.js.es6 @@ -6,7 +6,7 @@ import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { movePosts, mergeTopic } from "discourse/models/topic"; import DiscourseURL from "discourse/lib/url"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { extractError } from "discourse/lib/ajax-error"; export default Controller.extend(ModalFunctionality, { @@ -46,12 +46,12 @@ export default Controller.extend(ModalFunctionality, { selectedAllPosts: alias("topicController.selectedAllPosts"), selectedPosts: alias("topicController.selectedPosts"), - @computed("saving", "selectedTopicId", "topicName") + @discourseComputed("saving", "selectedTopicId", "topicName") buttonDisabled(saving, selectedTopicId, topicName) { return saving || (isEmpty(selectedTopicId) && isEmpty(topicName)); }, - @computed( + @discourseComputed( "saving", "newTopic", "existingTopic", @@ -95,7 +95,7 @@ export default Controller.extend(ModalFunctionality, { } }, - @computed("selectedAllPosts", "selectedPosts", "selectedPosts.[]") + @discourseComputed("selectedAllPosts", "selectedPosts", "selectedPosts.[]") canSplitTopic(selectedAllPosts, selectedPosts) { return ( !selectedAllPosts && @@ -105,9 +105,9 @@ export default Controller.extend(ModalFunctionality, { ); }, - @computed("canSplitTopic") + @discourseComputed("canSplitTopic") canSplitToPM(canSplitTopic) { - return canSplitTopic && (this.currentUser && this.currentUser.admin); + return canSplitTopic && this.currentUser && this.currentUser.admin; }, actions: { diff --git a/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 b/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 index 216ca0448f..db7ac2bae9 100644 --- a/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/navigation/categories.js.es6 @@ -1,11 +1,14 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import NavigationDefaultController from "discourse/controllers/navigation/default"; -import computed from "ember-addons/ember-computed-decorators"; export default NavigationDefaultController.extend({ discoveryCategories: inject("discovery/categories"), - @computed("discoveryCategories.model", "discoveryCategories.model.draft") + @discourseComputed( + "discoveryCategories.model", + "discoveryCategories.model.draft" + ) draft() { return this.get("discoveryCategories.model.draft"); } diff --git a/app/assets/javascripts/discourse/controllers/navigation/category.js.es6 b/app/assets/javascripts/discourse/controllers/navigation/category.js.es6 index 1b13265576..23da3c88df 100644 --- a/app/assets/javascripts/discourse/controllers/navigation/category.js.es6 +++ b/app/assets/javascripts/discourse/controllers/navigation/category.js.es6 @@ -1,7 +1,8 @@ import { none, and } from "@ember/object/computed"; import NavigationDefaultController from "discourse/controllers/navigation/default"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -export default NavigationDefaultController.extend({ +export default NavigationDefaultController.extend(FilterModeMixin, { showingParentCategory: none("category.parentCategory"), showingSubcategoryList: and( "category.show_subcategory_list", diff --git a/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 b/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 index 9d4d051974..02dd26acde 100644 --- a/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 +++ b/app/assets/javascripts/discourse/controllers/navigation/default.js.es6 @@ -1,12 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -export default Controller.extend({ +export default Controller.extend(FilterModeMixin, { discovery: inject(), discoveryTopics: inject("discovery/topics"), - @computed("discoveryTopics.model", "discoveryTopics.model.draft") + @discourseComputed("discoveryTopics.model", "discoveryTopics.model.draft") draft: function() { return this.get("discoveryTopics.model.draft"); } diff --git a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 index b7a4545aed..7f3718482e 100644 --- a/app/assets/javascripts/discourse/controllers/password-reset.js.es6 +++ b/app/assets/javascripts/discourse/controllers/password-reset.js.es6 @@ -1,6 +1,6 @@ import { alias, or } from "@ember/object/computed"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import DiscourseURL from "discourse/lib/url"; import { ajax } from "discourse/lib/ajax"; import PasswordValidation from "discourse/mixins/password-validation"; @@ -18,7 +18,7 @@ export default Controller.extend(PasswordValidation, { "model.second_factor_required", "model.security_key_required" ), - @computed("model.security_key_required") + @discourseComputed("model.security_key_required") secondFactorMethod(security_key_required) { return security_key_required ? SECOND_FACTOR_METHODS.SECURITY_KEY @@ -30,14 +30,14 @@ export default Controller.extend(PasswordValidation, { requiresApproval: false, redirected: false, - @computed() + @discourseComputed() continueButtonText() { return I18n.t("password_reset.continue", { site_name: this.siteSettings.title }); }, - @computed("redirectTo") + @discourseComputed("redirectTo") redirectHref(redirectTo) { return Discourse.getURL(redirectTo || "/"); }, diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index e30386afdf..f09f94ae89 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -1,9 +1,8 @@ -import { isEmpty } from "@ember/utils"; import { not, or, gt } from "@ember/object/computed"; import Controller from "@ember/controller"; import { iconHTML } from "discourse-common/lib/icon-library"; import CanCheckEmails from "discourse/mixins/can-check-emails"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { propertyNotEqual, setting } from "discourse/lib/computed"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -11,6 +10,7 @@ import showModal from "discourse/lib/show-modal"; import { findAll } from "discourse/models/login-method"; import { ajax } from "discourse/lib/ajax"; import { userPath } from "discourse/lib/url"; +import logout from "discourse/lib/logout"; // Number of tokens shown by default. const DEFAULT_AUTH_TOKENS_COUNT = 2; @@ -43,7 +43,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { this.set("passwordProgress", null); }, - @computed() + @discourseComputed() nameInstructions() { return I18n.t( this.siteSettings.full_name_required @@ -54,7 +54,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { canSelectTitle: gt("model.availableTitles.length", 0), - @computed("model.filteredGroups") + @discourseComputed("model.filteredGroups") canSelectPrimaryGroup(primaryGroupOptions) { return ( primaryGroupOptions.length > 0 && @@ -62,7 +62,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { ); }, - @computed("model.is_anonymous") + @discourseComputed("model.is_anonymous") canChangePassword(isAnonymous) { if (isAnonymous) { return false; @@ -73,12 +73,12 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { } }, - @computed("model.associated_accounts") + @discourseComputed("model.associated_accounts") associatedAccountsLoaded(associatedAccounts) { return typeof associatedAccounts !== "undefined"; }, - @computed("model.associated_accounts.[]") + @discourseComputed("model.associated_accounts.[]") authProviders(accounts) { const allMethods = findAll(); @@ -94,7 +94,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { disableConnectButtons: propertyNotEqual("model.id", "currentUser.id"), - @computed( + @discourseComputed( "model.second_factor_enabled", "canCheckEmails", "model.is_anonymous" @@ -110,7 +110,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { return findAll().length > 0; }, - @computed("showAllAuthTokens", "model.user_auth_tokens") + @discourseComputed("showAllAuthTokens", "model.user_auth_tokens") authTokens(showAllAuthTokens, tokens) { tokens.sort((a, b) => { if (a.is_active) { @@ -240,14 +240,7 @@ export default Controller.extend(CanCheckEmails, PreferencesTabController, { } ) .then(() => { - if (!token) { - const redirect = this.siteSettings.logout_redirect; - if (isEmpty(redirect)) { - window.location = Discourse.getURL("/"); - } else { - window.location.href = redirect; - } - } + if (!token) logout(); // All sessions revoked }) .catch(popupAjaxError); }, diff --git a/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 index 8f86974e38..4628234524 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/categories.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { or } from "@ember/object/computed"; import Controller from "@ember/controller"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(PreferencesTabController, { init() { @@ -16,7 +16,7 @@ export default Controller.extend(PreferencesTabController, { ]; }, - @computed( + @discourseComputed( "model.watchedCategories", "model.watchedFirstPostCategories", "model.trackedCategories", @@ -26,12 +26,12 @@ export default Controller.extend(PreferencesTabController, { return [].concat(watched, watchedFirst, tracked, muted).filter(t => t); }, - @computed + @discourseComputed canSee() { return this.get("currentUser.id") === this.get("model.id"); }, - @computed("siteSettings.remove_muted_tags_from_latest") + @discourseComputed("siteSettings.remove_muted_tags_from_latest") hideMutedTags() { return this.siteSettings.remove_muted_tags_from_latest !== "never"; }, diff --git a/app/assets/javascripts/discourse/controllers/preferences/email.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/email.js.es6 index c25db36a43..413638e0db 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/email.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/email.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { empty, or } from "@ember/object/computed"; import Controller from "@ember/controller"; import { propertyEqual } from "discourse/lib/computed"; -import InputValidation from "discourse/models/input-validation"; +import EmberObject from "@ember/object"; import { emailValid } from "discourse/lib/utilities"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ taken: false, @@ -24,26 +24,26 @@ export default Controller.extend({ unchanged: propertyEqual("newEmailLower", "currentUser.email"), - @computed("newEmail") + @discourseComputed("newEmail") newEmailLower(newEmail) { return newEmail.toLowerCase().trim(); }, - @computed("saving") + @discourseComputed("saving") saveButtonText(saving) { if (saving) return I18n.t("saving"); return I18n.t("user.change"); }, - @computed("newEmail") + @discourseComputed("newEmail") invalidEmail(newEmail) { return !emailValid(newEmail); }, - @computed("invalidEmail") + @discourseComputed("invalidEmail") emailValidation(invalidEmail) { if (invalidEmail) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.email.invalid") }); diff --git a/app/assets/javascripts/discourse/controllers/preferences/emails.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/emails.js.es6 index a2001457e5..008ed9c8cd 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/emails.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/emails.js.es6 @@ -1,7 +1,7 @@ import { equal } from "@ember/object/computed"; import Controller from "@ember/controller"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; const EMAIL_LEVELS = { @@ -60,7 +60,7 @@ export default Controller.extend(PreferencesTabController, { ]; }, - @computed() + @discourseComputed() frequencyEstimate() { var estimate = this.get("model.mailing_list_posts_per_day"); if (!estimate || estimate < 2) { @@ -72,7 +72,7 @@ export default Controller.extend(PreferencesTabController, { } }, - @computed() + @discourseComputed() mailingListModeOptions() { return [ { name: this.frequencyEstimate, value: 1 }, @@ -80,7 +80,7 @@ export default Controller.extend(PreferencesTabController, { ]; }, - @computed() + @discourseComputed() emailFrequencyInstructions() { if (this.siteSettings.email_time_window_mins) { return I18n.t("user.email.frequency", { diff --git a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 index 9ef7280fc2..370d5b9a4d 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/interface.js.es6 @@ -3,9 +3,9 @@ import Controller from "@ember/controller"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { setDefaultHomepage } from "discourse/lib/utilities"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { listThemes, previewTheme, @@ -30,7 +30,7 @@ const TEXT_SIZES = ["smaller", "normal", "larger", "largest"]; const TITLE_COUNT_MODES = ["notifications", "contextual"]; export default Controller.extend(PreferencesTabController, { - @computed("makeThemeDefault") + @discourseComputed("makeThemeDefault") saveAttrNames(makeDefault) { let attrs = [ "locale", @@ -55,43 +55,43 @@ export default Controller.extend(PreferencesTabController, { preferencesController: inject("preferences"), - @computed() + @discourseComputed() isiPad() { // TODO: remove this preference checkbox when iOS adoption > 90% // (currently only applies to iOS 12 and below) return isiPad() && !iOSWithVisualViewport(); }, - @computed() + @discourseComputed() disableSafariHacks() { return safariHacksDisabled(); }, - @computed() + @discourseComputed() availableLocales() { return JSON.parse(this.siteSettings.available_locales); }, - @computed + @discourseComputed textSizes() { return TEXT_SIZES.map(value => { return { name: I18n.t(`user.text_size.${value}`), value }; }); }, - @computed + @discourseComputed titleCountModes() { return TITLE_COUNT_MODES.map(value => { return { name: I18n.t(`user.title_count_mode.${value}`), value }; }); }, - @computed + @discourseComputed userSelectableThemes() { return listThemes(this.site); }, - @computed("userSelectableThemes") + @discourseComputed("userSelectableThemes") showThemeSelector(themes) { return themes && themes.length > 1; }, @@ -102,12 +102,12 @@ export default Controller.extend(PreferencesTabController, { previewTheme([id]); }, - @computed("model.user_option.theme_ids", "themeId") + @discourseComputed("model.user_option.theme_ids", "themeId") showThemeSetDefault(userOptionThemes, selectedTheme) { return !userOptionThemes || userOptionThemes[0] !== selectedTheme; }, - @computed("model.user_option.text_size", "textSize") + @discourseComputed("model.user_option.text_size", "textSize") showTextSetDefault(userOptionTextSize, selectedTextSize) { return userOptionTextSize !== selectedTextSize; }, @@ -119,7 +119,7 @@ export default Controller.extend(PreferencesTabController, { setDefaultHomepage(userHome || siteHome); }, - @computed() + @discourseComputed() userSelectableHome() { let homeValues = {}; Object.keys(USER_HOMES).forEach(newValue => { diff --git a/app/assets/javascripts/discourse/controllers/preferences/profile.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/profile.js.es6 index b4e9a46fac..764dbb29dc 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/profile.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/profile.js.es6 @@ -1,7 +1,7 @@ import { isEmpty } from "@ember/utils"; import EmberObject from "@ember/object"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { cookAsync } from "discourse/lib/text"; @@ -18,11 +18,12 @@ export default Controller.extend(PreferencesTabController, { "user_fields", "profile_background_upload_url", "card_background_upload_url", - "date_of_birth" + "date_of_birth", + "timezone" ]; }, - @computed("model.user_fields.@each.value") + @discourseComputed("model.user_fields.@each.value") userFields() { let siteUserFields = this.site.get("user_fields"); if (!isEmpty(siteUserFields)) { @@ -41,7 +42,7 @@ export default Controller.extend(PreferencesTabController, { } }, - @computed("model.can_change_bio") + @discourseComputed("model.can_change_bio") canChangeBio(canChangeBio) { return canChangeBio; }, diff --git a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 index 233ed03390..21e9a55f38 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/second-factor.js.es6 @@ -1,6 +1,6 @@ import { alias, and } from "@ember/object/computed"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import CanCheckEmails from "discourse/mixins/can-check-emails"; import { default as DiscourseURL, userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -27,12 +27,12 @@ export default Controller.extend(CanCheckEmails, { this.set("totps", []); }, - @computed + @discourseComputed displayOAuthWarning() { return findAll().length > 0; }, - @computed("currentUser") + @discourseComputed("currentUser") showEnforcedNotice(user) { return user && user.enforcedSecondFactor; }, diff --git a/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 index 85d1241ad3..1aede6792d 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/tags.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import PreferencesTabController from "discourse/mixins/preferences-tab-controller"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(PreferencesTabController, { init() { @@ -15,7 +15,7 @@ export default Controller.extend(PreferencesTabController, { ]; }, - @computed( + @discourseComputed( "model.watched_tags.[]", "model.watching_first_post_tags.[]", "model.tracked_tags.[]", diff --git a/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 index 954e8c739e..530d1a616e 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/username.js.es6 @@ -2,13 +2,14 @@ import { isEmpty } from "@ember/utils"; import { empty, or } from "@ember/object/computed"; import Controller from "@ember/controller"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { setting, propertyEqual } from "discourse/lib/computed"; import DiscourseURL from "discourse/lib/url"; import { userPath } from "discourse/lib/url"; import { popupAjaxError } from "discourse/lib/ajax-error"; +import User from "discourse/models/user"; export default Controller.extend({ taken: false, @@ -41,21 +42,19 @@ export default Controller.extend({ if (isEmpty(this.newUsername)) return; if (this.unchanged) return; - Discourse.User.checkUsername( - newUsername, - undefined, - this.get("model.id") - ).then(result => { - if (result.errors) { - this.set("errorMessage", result.errors.join(" ")); - } else if (result.available === false) { - this.set("taken", true); + User.checkUsername(newUsername, undefined, this.get("model.id")).then( + result => { + if (result.errors) { + this.set("errorMessage", result.errors.join(" ")); + } else if (result.available === false) { + this.set("taken", true); + } } - }); + ); } }, - @computed("saving") + @discourseComputed("saving") saveButtonText(saving) { if (saving) return I18n.t("saving"); return I18n.t("user.change"); diff --git a/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 b/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 index b08bc2d646..4bb35a34ca 100644 --- a/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 +++ b/app/assets/javascripts/discourse/controllers/rename-tag.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import computed from "ember-addons/ember-computed-decorators"; import BufferedContent from "discourse/mixins/buffered-content"; import { extractError } from "discourse/lib/ajax-error"; export default Controller.extend(ModalFunctionality, BufferedContent, { - @computed("buffered.id", "id") + @discourseComputed("buffered.id", "id") renameDisabled(inputTagName, currentTagName) { const filterRegexp = new RegExp(this.site.tags_filter_regexp, "g"); const newTagName = inputTagName diff --git a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 index 06d5f30cca..baaddc1825 100644 --- a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 +++ b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 @@ -7,8 +7,8 @@ const BufferedProxy = window.BufferedProxy; // import BufferedProxy from 'ember- import { popupAjaxError } from "discourse/lib/ajax-error"; import { on, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; import Ember from "ember"; export default Controller.extend(ModalFunctionality, Ember.Evented, { @@ -23,7 +23,7 @@ export default Controller.extend(ModalFunctionality, Ember.Evented, { this.fixIndices(); }, - @computed("site.categories") + @discourseComputed("site.categories") categoriesBuffered(categories) { const bufProxy = EmberObjectProxy.extend(BufferedProxy); return categories.map(c => bufProxy.create({ content: c })); @@ -31,7 +31,7 @@ export default Controller.extend(ModalFunctionality, Ember.Evented, { categoriesOrdered: sort("categoriesBuffered", "categoriesSorting"), - @computed("categoriesBuffered.@each.hasBufferedChanges") + @discourseComputed("categoriesBuffered.@each.hasBufferedChanges") showApplyAll() { let anyChanged = false; this.categoriesBuffered.forEach(bc => { @@ -115,7 +115,7 @@ export default Controller.extend(ModalFunctionality, Ember.Evented, { actions: { change(cat, e) { - let position = parseInt($(e.target).val()); + let position = parseInt($(e.target).val(), 10); let amount = Math.min( Math.max(position, 0), this.categoriesOrdered.length - 1 diff --git a/app/assets/javascripts/discourse/controllers/request-group-membership-form.js.es6 b/app/assets/javascripts/discourse/controllers/request-group-membership-form.js.es6 index 0f316873ce..bbb54d03dd 100644 --- a/app/assets/javascripts/discourse/controllers/request-group-membership-form.js.es6 +++ b/app/assets/javascripts/discourse/controllers/request-group-membership-form.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias } from "@ember/object/computed"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { popupAjaxError } from "discourse/lib/ajax-error"; import DiscourseURL from "discourse/lib/url"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -10,12 +10,12 @@ export default Controller.extend(ModalFunctionality, { loading: false, reason: alias("model.membership_request_template"), - @computed("model.name") + @discourseComputed("model.name") title(groupName) { return I18n.t("groups.membership_request.title", { group_name: groupName }); }, - @computed("loading", "reason") + @discourseComputed("loading", "reason") disableSubmit(loading, reason) { return loading || isEmpty(reason); }, diff --git a/app/assets/javascripts/discourse/controllers/review-index.js.es6 b/app/assets/javascripts/discourse/controllers/review-index.js.es6 index 75ab25b4f1..aeb61d7d61 100644 --- a/app/assets/javascripts/discourse/controllers/review-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/review-index.js.es6 @@ -1,5 +1,5 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend({ queryParams: [ @@ -9,7 +9,10 @@ export default Controller.extend({ "category_id", "topic_id", "username", - "sort_order" + "from_date", + "to_date", + "sort_order", + "additional_filters" ], type: null, status: "pending", @@ -19,7 +22,10 @@ export default Controller.extend({ topic_id: null, filtersExpanded: false, username: "", + from_date: null, + to_date: null, sort_order: "priority", + additional_filters: null, init(...args) { this._super(...args); @@ -27,7 +33,7 @@ export default Controller.extend({ this.set("filtersExpanded", !this.site.mobileView); }, - @computed("reviewableTypes") + @discourseComputed("reviewableTypes") allTypes() { return (this.reviewableTypes || []).map(type => { return { @@ -37,7 +43,7 @@ export default Controller.extend({ }); }, - @computed + @discourseComputed priorities() { return ["low", "medium", "high"].map(priority => { return { @@ -47,7 +53,7 @@ export default Controller.extend({ }); }, - @computed + @discourseComputed sortOrders() { return ["priority", "priority_asc", "created_at", "created_at_asc"].map( order => { @@ -59,7 +65,7 @@ export default Controller.extend({ ); }, - @computed + @discourseComputed statuses() { return [ "pending", @@ -74,11 +80,20 @@ export default Controller.extend({ }); }, - @computed("filtersExpanded") + @discourseComputed("filtersExpanded") toggleFiltersIcon(filtersExpanded) { return filtersExpanded ? "chevron-up" : "chevron-down"; }, + setRange(range) { + if (range.from) { + this.set("from", new Date(range.from).toISOString().split("T")[0]); + } + if (range.to) { + this.set("to", new Date(range.to).toISOString().split("T")[0]); + } + }, + actions: { remove(ids) { if (!ids) { @@ -103,8 +118,12 @@ export default Controller.extend({ status: this.filterStatus, category_id: this.filterCategoryId, username: this.filterUsername, - sort_order: this.filterSortOrder + from_date: this.filterFromDate, + to_date: this.filterToDate, + sort_order: this.filterSortOrder, + additional_filters: JSON.stringify(this.additionalFilters) }); + this.send("refreshRoute"); }, diff --git a/app/assets/javascripts/discourse/controllers/search-help.js.es6 b/app/assets/javascripts/discourse/controllers/search-help.js.es6 index 654722d2c7..1475373480 100644 --- a/app/assets/javascripts/discourse/controllers/search-help.js.es6 +++ b/app/assets/javascripts/discourse/controllers/search-help.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; -import computed from "ember-addons/ember-computed-decorators"; export default Controller.extend(ModalFunctionality, { - @computed + @discourseComputed showGoogleSearch() { return !Discourse.SiteSettings.login_required; } diff --git a/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 b/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 index 2801056c71..35b24b07df 100644 --- a/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 +++ b/app/assets/javascripts/discourse/controllers/second-factor-backup-edit.js.es6 @@ -1,7 +1,7 @@ import { alias } from "@ember/object/computed"; import { later } from "@ember/runloop"; import Controller from "@ember/controller"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { SECOND_FACTOR_METHODS } from "discourse/models/user"; import ModalFunctionality from "discourse/mixins/modal-functionality"; @@ -14,7 +14,7 @@ export default Controller.extend(ModalFunctionality, { backupCodes: null, secondFactorMethod: SECOND_FACTOR_METHODS.TOTP, - @computed("backupEnabled") + @discourseComputed("backupEnabled") generateBackupCodeBtnLabel(backupEnabled) { return backupEnabled ? "user.second_factor_backup.regenerate" diff --git a/app/assets/javascripts/discourse/controllers/static.js.es6 b/app/assets/javascripts/discourse/controllers/static.js.es6 index 587e14da81..ad4f953105 100644 --- a/app/assets/javascripts/discourse/controllers/static.js.es6 +++ b/app/assets/javascripts/discourse/controllers/static.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { equal } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; import { userPath } from "discourse/lib/url"; export default Controller.extend({ @@ -10,10 +10,10 @@ export default Controller.extend({ showLoginButton: equal("model.path", "login"), - @computed("model.path") + @discourseComputed("model.path") bodyClass: path => `static-${path}`, - @computed("model.path") + @discourseComputed("model.path") showSignupButton() { return ( this.get("model.path") === "login" && this.get("application.canSignUp") diff --git a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 index c2ec12fda6..14473df9a5 100644 --- a/app/assets/javascripts/discourse/controllers/tags-index.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-index.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, notEmpty } from "@ember/object/computed"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import showModal from "discourse/lib/show-modal"; import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; @@ -19,7 +19,7 @@ export default Controller.extend({ this.sortProperties = ["totalCount:desc", "id"]; }, - @computed("groupedByCategory", "groupedByTagGroup") + @discourseComputed("groupedByCategory", "groupedByTagGroup") otherTagsTitleKey(groupedByCategory, groupedByTagGroup) { if (!groupedByCategory && !groupedByTagGroup) { return "tagging.all_tags"; @@ -28,7 +28,7 @@ export default Controller.extend({ } }, - @computed + @discourseComputed actionsMapping() { return { manageGroups: () => this.send("showTagGroups"), diff --git a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 index 9d787fe8a4..a34a368a1a 100644 --- a/app/assets/javascripts/discourse/controllers/tags-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/tags-show.js.es6 @@ -2,63 +2,20 @@ import { alias } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import BulkTopicSelection from "discourse/mixins/bulk-topic-selection"; -import { - default as NavItem, - extraNavItemProperties, - customNavItemHref -} from "discourse/models/nav-item"; +import { default as NavItem } from "discourse/models/nav-item"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -if (extraNavItemProperties) { - extraNavItemProperties(function(text, opts) { - if (opts && opts.tagId) { - return { tagId: opts.tagId }; - } else { - return {}; - } - }); -} - -if (customNavItemHref) { - customNavItemHref(function(navItem) { - if (navItem.get("tagId")) { - const name = navItem.get("name"); - - if (!Discourse.Site.currentProp("filters").includes(name)) { - return null; - } - - let path = "/tags/"; - const category = navItem.get("category"); - - if (category) { - path += "c/"; - path += Discourse.Category.slugFor(category); - if (navItem.get("noSubcategories")) { - path += "/none"; - } - path += "/"; - } - - path += `${navItem.get("tagId")}/l/`; - return `${path}${name.replace(" ", "-")}`; - } else { - return null; - } - }); -} - -export default Controller.extend(BulkTopicSelection, { +export default Controller.extend(BulkTopicSelection, FilterModeMixin, { application: inject(), tag: null, additionalTags: null, list: null, canAdminTag: alias("currentUser.staff"), - filterMode: null, navMode: "latest", loading: false, canCreateTopic: false, @@ -69,15 +26,16 @@ export default Controller.extend(BulkTopicSelection, { search: null, max_posts: null, q: null, + showInfo: false, categories: alias("site.categoriesList"), - @computed("list", "list.draft") + @discourseComputed("list", "list.draft") createTopicLabel(list, listDraft) { return listDraft ? "topic.open_draft" : "topic.create"; }, - @computed( + @discourseComputed( "canCreateTopic", "category", "canCreateTopicOnCategory", @@ -108,22 +66,23 @@ export default Controller.extend(BulkTopicSelection, { "q" ], - @computed("category", "tag.id", "filterMode") - navItems(category, tagId, filterMode) { + @discourseComputed("category", "tag.id", "filterType", "noSubcategories") + navItems(category, tagId, filterType, noSubcategories) { return NavItem.buildList(category, { tagId, - filterMode + filterType, + noSubcategories }); }, - @computed("category") + @discourseComputed("category") showTagFilter() { return Discourse.SiteSettings.show_filter_by_tag; }, - @computed("additionalTags", "canAdminTag", "category") - showAdminControls(additionalTags, canAdminTag, category) { - return !additionalTags && canAdminTag && !category; + @discourseComputed("additionalTags", "category", "tag.id") + showToggleInfo(additionalTags, category, tagId) { + return !additionalTags && !category && tagId !== "none"; }, loadMoreTopics() { @@ -135,7 +94,7 @@ export default Controller.extend(BulkTopicSelection, { this.set("application.showFooter", !this.get("list.canLoadMore")); }, - @computed("navMode", "list.topics.length", "loading") + @discourseComputed("navMode", "list.topics.length", "loading") footerMessage(navMode, listTopicsLength, loading) { if (loading || listTopicsLength !== 0) { return; @@ -163,6 +122,10 @@ export default Controller.extend(BulkTopicSelection, { this.send("invalidateModel"); }, + toggleInfo() { + this.toggleProperty("showInfo"); + }, + refresh() { // TODO: this probably doesn't work anymore return this.store @@ -173,15 +136,23 @@ export default Controller.extend(BulkTopicSelection, { }); }, - deleteTag() { + deleteTag(tagInfo) { const numTopics = this.get("list.topic_list.tags.firstObject.topic_count") || 0; - const confirmText = + let confirmText = numTopics === 0 ? I18n.t("tagging.delete_confirm_no_topics") : I18n.t("tagging.delete_confirm", { count: numTopics }); + if (tagInfo.synonyms.length > 0) { + confirmText += + " " + + I18n.t("tagging.delete_confirm_synonyms", { + count: tagInfo.synonyms.length + }); + } + bootbox.confirm(confirmText, result => { if (!result) return; diff --git a/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 b/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 index d2a809fc6e..723e7fee54 100644 --- a/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic-bulk-actions.js.es6 @@ -1,6 +1,8 @@ import { empty, alias } from "@ember/object/computed"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; +import Topic from "discourse/models/topic"; +import Category from "discourse/models/category"; const _buttons = []; @@ -79,7 +81,10 @@ export default Controller.extend(ModalFunctionality, { const topics = this.get("model.topics"); // const relistButtonIndex = _buttons.findIndex(b => b.action === 'relistTopics'); - this.set("buttons", _buttons.filter(b => b.buttonVisible(topics))); + this.set( + "buttons", + _buttons.filter(b => b.buttonVisible(topics)) + ); this.set("modal.modalClass", "topic-bulk-actions-modal small"); this.send("changeBulkTemplate", "modal/bulk-actions-buttons"); }, @@ -88,7 +93,7 @@ export default Controller.extend(ModalFunctionality, { this.set("loading", true); const topics = this.get("model.topics"); - return Discourse.Topic.bulkOperation(topics, operation) + return Topic.bulkOperation(topics, operation) .then(result => { this.set("loading", false); if (result && result.topic_ids) { @@ -174,7 +179,7 @@ export default Controller.extend(ModalFunctionality, { changeCategory() { const categoryId = parseInt(this.newCategoryId, 10) || 0; - const category = Discourse.Category.findById(categoryId); + const category = Category.findById(categoryId); this.perform({ type: "change_category", category_id: categoryId }).then( topics => { diff --git a/app/assets/javascripts/discourse/controllers/topic.js.es6 b/app/assets/javascripts/discourse/controllers/topic.js.es6 index af63f140be..d273e51153 100644 --- a/app/assets/javascripts/discourse/controllers/topic.js.es6 +++ b/app/assets/javascripts/discourse/controllers/topic.js.es6 @@ -12,13 +12,13 @@ import Post from "discourse/models/post"; import Quote from "discourse/lib/quote"; import QuoteState from "discourse/lib/quote-state"; import Topic from "discourse/models/topic"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import isElementInViewport from "discourse/lib/is-element-in-viewport"; import { ajax } from "discourse/lib/ajax"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { extractLinkMeta } from "discourse/lib/render-topic-featured-link"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { spinnerHTML } from "discourse/helpers/loading-spinner"; @@ -80,7 +80,7 @@ export default Controller.extend(bufferedProperty("model"), { } }, - @computed("model.details.can_create_post") + @discourseComputed("model.details.can_create_post") embedQuoteButton(canCreatePost) { return ( canCreatePost && @@ -89,28 +89,31 @@ export default Controller.extend(bufferedProperty("model"), { ); }, - @computed("model.postStream.loaded", "model.category_id") + @discourseComputed("model.postStream.loaded", "model.category_id") showSharedDraftControls(loaded, categoryId) { let draftCat = this.site.shared_drafts_category_id; return loaded && draftCat && categoryId && draftCat === categoryId; }, - @computed("site.mobileView", "model.posts_count") + @discourseComputed("site.mobileView", "model.posts_count") showSelectedPostsAtBottom(mobileView, postsCount) { return mobileView && postsCount > 3; }, - @computed("model.postStream.posts", "model.postStream.postsWithPlaceholders") + @discourseComputed( + "model.postStream.posts", + "model.postStream.postsWithPlaceholders" + ) postsToRender(posts, postsWithPlaceholders) { return this.capabilities.isAndroid ? posts : postsWithPlaceholders; }, - @computed("model.postStream.loadingFilter") + @discourseComputed("model.postStream.loadingFilter") androidLoading(loading) { return this.capabilities.isAndroid && loading; }, - @computed("model") + @discourseComputed("model") pmPath(topic) { return this.currentUser && this.currentUser.pmPath(topic); }, @@ -153,12 +156,12 @@ export default Controller.extend(bufferedProperty("model"), { DiscourseURL.routeTo(url); }, - @computed + @discourseComputed selectedQuery() { return post => this.postSelected(post); }, - @computed("model.isPrivateMessage", "model.category.id") + @discourseComputed("model.isPrivateMessage", "model.category.id") canEditTopicFeaturedLink(isPrivateMessage, categoryId) { if (!this.siteSettings.topic_featured_link_enabled || isPrivateMessage) { return false; @@ -174,12 +177,12 @@ export default Controller.extend(bufferedProperty("model"), { ); }, - @computed("model") + @discourseComputed("model") featuredLinkDomain(topic) { return extractLinkMeta(topic).domain; }, - @computed("model.isPrivateMessage") + @discourseComputed("model.isPrivateMessage") canEditTags(isPrivateMessage) { return ( this.site.get("can_tag_topics") && @@ -444,9 +447,7 @@ export default Controller.extend(bufferedProperty("model"), { : "/"; ajax("/t/" + topic.get("id") + "/timings.json?last=1", { type: "DELETE" }) .then(() => { - const highestSeenByTopic = Discourse.Session.currentProp( - "highestSeenByTopic" - ); + const highestSeenByTopic = this.session.get("highestSeenByTopic"); highestSeenByTopic[topic.get("id")] = null; DiscourseURL.routeTo(goToPath); }) @@ -1157,7 +1158,7 @@ export default Controller.extend(bufferedProperty("model"), { selectedPostsCount: alias("selectedPostIds.length"), - @computed( + @discourseComputed( "selectedPostIds", "model.postStream.posts", "selectedPostIds.[]", @@ -1169,7 +1170,7 @@ export default Controller.extend(bufferedProperty("model"), { .filter(post => post !== undefined); }, - @computed("selectedPostsCount", "selectedPosts", "selectedPosts.[]") + @discourseComputed("selectedPostsCount", "selectedPosts", "selectedPosts.[]") selectedPostsUsername(selectedPostsCount, selectedPosts) { if (selectedPosts.length < 1 || selectedPostsCount > selectedPosts.length) { return undefined; @@ -1180,7 +1181,7 @@ export default Controller.extend(bufferedProperty("model"), { : undefined; }, - @computed( + @discourseComputed( "selectedPostsCount", "model.postStream.isMegaTopic", "model.postStream.stream.length", @@ -1199,14 +1200,14 @@ export default Controller.extend(bufferedProperty("model"), { } }, - @computed("selectedAllPosts", "model.postStream.isMegaTopic") + @discourseComputed("selectedAllPosts", "model.postStream.isMegaTopic") canSelectAll(selectedAllPosts, isMegaTopic) { return isMegaTopic ? false : !selectedAllPosts; }, canDeselectAll: alias("selectedAllPosts"), - @computed( + @discourseComputed( "currentUser.staff", "selectedPostsCount", "selectedAllPosts", @@ -1225,19 +1226,23 @@ export default Controller.extend(bufferedProperty("model"), { ); }, - @computed("model.details.can_move_posts", "selectedPostsCount") + @discourseComputed("model.details.can_move_posts", "selectedPostsCount") canMergeTopic(canMovePosts, selectedPostsCount) { return canMovePosts && selectedPostsCount > 0; }, - @computed("currentUser.admin", "selectedPostsCount", "selectedPostsUsername") + @discourseComputed( + "currentUser.admin", + "selectedPostsCount", + "selectedPostsUsername" + ) canChangeOwner(isAdmin, selectedPostsCount, selectedPostsUsername) { return ( isAdmin && selectedPostsCount > 0 && selectedPostsUsername !== undefined ); }, - @computed( + @discourseComputed( "selectedPostsCount", "selectedPostsUsername", "selectedPosts", @@ -1260,7 +1265,7 @@ export default Controller.extend(bufferedProperty("model"), { return this.selectedAllPost || this.selectedPostIds.includes(post.id); }, - @computed + @discourseComputed loadingHTML() { return spinnerHTML; }, @@ -1360,7 +1365,8 @@ export default Controller.extend(bufferedProperty("model"), { if (callback) { callback(this, data); } else { - Ember.Logger.warn("unknown topic bus message type", data); + // eslint-disable-next-line no-console + console.warn("unknown topic bus message type", data); } } } @@ -1393,7 +1399,7 @@ export default Controller.extend(bufferedProperty("model"), { ); }, - _scrollToPost: debounce(function(postNumber) { + _scrollToPost: discourseDebounce(function(postNumber) { const $post = $(`.topic-post article#post_${postNumber}`); if ($post.length === 0 || isElementInViewport($post)) return; diff --git a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 index 87c73494a0..64a433ecd9 100644 --- a/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 +++ b/app/assets/javascripts/discourse/controllers/upload-selector.js.es6 @@ -2,18 +2,18 @@ import { equal } from "@ember/object/computed"; import Controller from "@ember/controller"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { allowsAttachments, - authorizesAllExtensions, authorizedExtensions, + authorizesAllExtensions, uploadIcon -} from "discourse/lib/utilities"; +} from "discourse/lib/uploads"; -function uploadTranslate(key) { - if (allowsAttachments()) { +function uploadTranslate(key, user) { + if (allowsAttachments(user.staff)) { key += "_with_attachments"; } return `upload_selector.${key}`; @@ -27,18 +27,24 @@ export default Controller.extend(ModalFunctionality, { remote: equal("selection", "remote"), selection: "local", - @computed() - uploadIcon: () => uploadIcon(), + @discourseComputed() + uploadIcon() { + return uploadIcon(this.currentUser.staff); + }, - @computed() - title: () => uploadTranslate("title"), + @discourseComputed() + title() { + return uploadTranslate("title", this.currentUser); + }, - @computed("selection") + @discourseComputed("selection") tip(selection) { - const authorized_extensions = authorizesAllExtensions() + const authorized_extensions = authorizesAllExtensions( + this.currentUser.staff + ) ? "" - : `(${authorizedExtensions()})`; - return I18n.t(uploadTranslate(`${selection}_tip`), { + : `(${authorizedExtensions(this.currentUser.staff)})`; + return I18n.t(uploadTranslate(`${selection}_tip`, this.currentUser), { authorized_extensions }); }, diff --git a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 index 8a8c102b0e..1fd6ec0bdf 100644 --- a/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-invited-show.js.es6 @@ -1,12 +1,12 @@ import { equal, reads, gte } from "@ember/object/computed"; import Controller from "@ember/controller"; import Invite from "discourse/models/invite"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ user: null, @@ -27,15 +27,17 @@ export default Controller.extend({ }, @observes("searchTerm") - _searchTermChanged: debounce(function() { - Invite.findInvitedBy(this.user, this.filter, this.searchTerm).then( - invites => this.set("model", invites) - ); + _searchTermChanged: discourseDebounce(function() { + Invite.findInvitedBy( + this.user, + this.filter, + this.searchTerm + ).then(invites => this.set("model", invites)); }, 250), inviteRedeemed: equal("filter", "redeemed"), - @computed("filter") + @discourseComputed("filter") showBulkActionButtons(filter) { return ( filter === "pending" && @@ -50,7 +52,7 @@ export default Controller.extend({ showSearch: gte("totalInvites", 10), - @computed("invitesCount.total", "invitesCount.pending") + @discourseComputed("invitesCount.total", "invitesCount.pending") pendingLabel(invitesCountTotal, invitesCountPending) { if (invitesCountTotal > 50) { return I18n.t("user.invited.pending_tab_with_count", { @@ -61,7 +63,7 @@ export default Controller.extend({ } }, - @computed("invitesCount.total", "invitesCount.redeemed") + @discourseComputed("invitesCount.total", "invitesCount.redeemed") redeemedLabel(invitesCountTotal, invitesCountRedeemed) { if (invitesCountTotal > 50) { return I18n.t("user.invited.redeemed_tab_with_count", { diff --git a/app/assets/javascripts/discourse/controllers/user-notifications.js.es6 b/app/assets/javascripts/discourse/controllers/user-notifications.js.es6 index ea5507cc5c..24e61aa73a 100644 --- a/app/assets/javascripts/discourse/controllers/user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-notifications.js.es6 @@ -2,9 +2,9 @@ import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import { ajax } from "discourse/lib/ajax"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default Controller.extend({ application: inject(), @@ -14,12 +14,12 @@ export default Controller.extend({ this.set("application.showFooter", !this.get("model.canLoadMore")); }, - @computed("model.content.length") + @discourseComputed("model.content.length") hasNotifications(length) { return length > 0; }, - @computed("model.content.@each.read") + @discourseComputed("model.content.@each.read") allNotificationsRead() { return !this.get("model.content").some( notification => !notification.get("read") diff --git a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 index c6252978c9..724d8b9909 100644 --- a/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-private-messages.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias, equal, and } from "@ember/object/computed"; import { inject as service } from "@ember/service"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import Topic from "discourse/models/topic"; export default Controller.extend({ @@ -22,17 +22,17 @@ export default Controller.extend({ showNewPM: and("user.viewingSelf", "currentUser.can_send_private_messages"), - @computed("selected.[]", "bulkSelectEnabled") + @discourseComputed("selected.[]", "bulkSelectEnabled") hasSelection(selected, bulkSelectEnabled) { return bulkSelectEnabled && selected && selected.length > 0; }, - @computed("hasSelection", "pmView", "archive") + @discourseComputed("hasSelection", "pmView", "archive") canMoveToInbox(hasSelection, pmView, archive) { return hasSelection && (pmView === "archive" || archive); }, - @computed("hasSelection", "pmView", "archive") + @discourseComputed("hasSelection", "pmView", "archive") canArchive(hasSelection, pmView, archive) { return hasSelection && pmView !== "archive" && !archive; }, diff --git a/app/assets/javascripts/discourse/controllers/user-summary.js.es6 b/app/assets/javascripts/discourse/controllers/user-summary.js.es6 index 7383cfa336..0a1a37572e 100644 --- a/app/assets/javascripts/discourse/controllers/user-summary.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-summary.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { alias } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; import { durationTiny } from "discourse/lib/formatter"; // should be kept in sync with 'UserSummary::MAX_BADGES' @@ -11,22 +11,22 @@ export default Controller.extend({ userController: inject("user"), user: alias("userController.model"), - @computed("model.badges.length") + @discourseComputed("model.badges.length") moreBadges(badgesLength) { return badgesLength >= MAX_BADGES; }, - @computed("model.time_read") + @discourseComputed("model.time_read") timeRead(timeReadSeconds) { return durationTiny(timeReadSeconds); }, - @computed("model.time_read", "model.recent_time_read") + @discourseComputed("model.time_read", "model.recent_time_read") showRecentTimeRead(timeRead, recentTimeRead) { return timeRead !== recentTimeRead && recentTimeRead !== 0; }, - @computed("model.recent_time_read") + @discourseComputed("model.recent_time_read") recentTimeRead(recentTimeReadSeconds) { return recentTimeReadSeconds > 0 ? durationTiny(recentTimeReadSeconds) diff --git a/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 b/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 index 5539500165..6c09d2e36b 100644 --- a/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user-topics-list.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; // Lists of topics on a user's page. export default Controller.extend({ @@ -26,7 +26,7 @@ export default Controller.extend({ this.set("application.showFooter", !this.get("model.canLoadMore")); }.observes("model.canLoadMore"), - @computed("incomingCount") + @discourseComputed("incomingCount") hasIncoming(incomingCount) { return incomingCount > 0; }, diff --git a/app/assets/javascripts/discourse/controllers/user.js.es6 b/app/assets/javascripts/discourse/controllers/user.js.es6 index e24bf67a68..507ca24690 100644 --- a/app/assets/javascripts/discourse/controllers/user.js.es6 +++ b/app/assets/javascripts/discourse/controllers/user.js.es6 @@ -1,3 +1,4 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { isEmpty } from "@ember/utils"; import { alias, or, gt, not, and } from "@ember/object/computed"; import EmberObject from "@ember/object"; @@ -5,7 +6,6 @@ import { inject as service } from "@ember/service"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; import CanCheckEmails from "discourse/mixins/can-check-emails"; -import computed from "ember-addons/ember-computed-decorators"; import User from "discourse/models/user"; import optionalService from "discourse/lib/optional-service"; import { prioritizeNameInUx } from "discourse/lib/settings"; @@ -18,23 +18,28 @@ export default Controller.extend(CanCheckEmails, { currentPath: alias("router._router.currentPath"), adminTools: optionalService(), - @computed("model.username") + @discourseComputed("model.username") viewingSelf(username) { let currentUser = this.currentUser; return currentUser && username === currentUser.get("username"); }, - @computed("viewingSelf", "model.profile_hidden") + @discourseComputed("viewingSelf", "model.profile_hidden") canExpandProfile(viewingSelf, profileHidden) { return !profileHidden && viewingSelf; }, - @computed("model.profileBackgroundUrl") + @discourseComputed("model.profileBackgroundUrl") hasProfileBackgroundUrl(background) { return !isEmpty(background.toString()); }, - @computed("model.profile_hidden", "indexStream", "viewingSelf", "forceExpand") + @discourseComputed( + "model.profile_hidden", + "indexStream", + "viewingSelf", + "forceExpand" + ) collapsedInfo(profileHidden, indexStream, viewingSelf, forceExpand) { if (profileHidden) { return true; @@ -56,58 +61,58 @@ export default Controller.extend(CanCheckEmails, { "hasReceivedWarnings" ), - @computed("model.suspended", "currentUser.staff") + @discourseComputed("model.suspended", "currentUser.staff") isNotSuspendedOrIsStaff(suspended, isStaff) { return !suspended || isStaff; }, linkWebsite: not("model.isBasic"), - @computed("model.trust_level") + @discourseComputed("model.trust_level") removeNoFollow(trustLevel) { return trustLevel > 2 && !this.siteSettings.tl3_links_no_follow; }, - @computed("viewingSelf", "currentUser.admin") + @discourseComputed("viewingSelf", "currentUser.admin") showBookmarks(viewingSelf, isAdmin) { return viewingSelf || isAdmin; }, - @computed("viewingSelf") + @discourseComputed("viewingSelf") showDrafts(viewingSelf) { return viewingSelf; }, - @computed("viewingSelf", "currentUser.admin") + @discourseComputed("viewingSelf", "currentUser.admin") showPrivateMessages(viewingSelf, isAdmin) { return ( this.siteSettings.enable_personal_messages && (viewingSelf || isAdmin) ); }, - @computed("viewingSelf", "currentUser.staff") + @discourseComputed("viewingSelf", "currentUser.staff") showNotificationsTab(viewingSelf, staff) { return viewingSelf || staff; }, - @computed("model.name") + @discourseComputed("model.name") nameFirst(name) { return prioritizeNameInUx(name, this.siteSettings); }, - @computed("model.badge_count") + @discourseComputed("model.badge_count") showBadges(badgeCount) { return Discourse.SiteSettings.enable_badges && badgeCount > 0; }, - @computed() + @discourseComputed() canInviteToForum() { return User.currentProp("can_invite_to_forum"); }, canDeleteUser: and("model.can_be_deleted", "model.can_delete_all_posts"), - @computed("model.user_fields.@each.value") + @discourseComputed("model.user_fields.@each.value") publicUserFields() { const siteUserFields = this.site.get("user_fields"); if (!isEmpty(siteUserFields)) { diff --git a/app/assets/javascripts/discourse/controllers/users.js.es6 b/app/assets/javascripts/discourse/controllers/users.js.es6 index 36e3236ffd..8fd3b3e157 100644 --- a/app/assets/javascripts/discourse/controllers/users.js.es6 +++ b/app/assets/javascripts/discourse/controllers/users.js.es6 @@ -1,7 +1,7 @@ import { equal } from "@ember/object/computed"; import { inject } from "@ember/controller"; import Controller from "@ember/controller"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default Controller.extend({ application: inject(), @@ -15,7 +15,7 @@ export default Controller.extend({ showTimeRead: equal("period", "all"), - _setName: debounce(function() { + _setName: discourseDebounce(function() { this.set("name", this.nameInput); }, 500).observes("nameInput"), diff --git a/app/assets/javascripts/discourse/helpers/application.js.es6 b/app/assets/javascripts/discourse/helpers/application.js.es6 index 0710d8b939..e6a88ded97 100644 --- a/app/assets/javascripts/discourse/helpers/application.js.es6 +++ b/app/assets/javascripts/discourse/helpers/application.js.es6 @@ -22,7 +22,10 @@ registerUnbound("number", (orig, params) => { let title = I18n.toNumber(orig, { precision: 0 }); if (params.numberKey) { - title = I18n.t(params.numberKey, { number: title, count: parseInt(orig) }); + title = I18n.t(params.numberKey, { + number: title, + count: parseInt(orig, 10) + }); } let classNames = "number"; diff --git a/app/assets/javascripts/discourse/helpers/category-link.js.es6 b/app/assets/javascripts/discourse/helpers/category-link.js.es6 index c76382c8df..b8a6198797 100644 --- a/app/assets/javascripts/discourse/helpers/category-link.js.es6 +++ b/app/assets/javascripts/discourse/helpers/category-link.js.es6 @@ -2,6 +2,8 @@ import { get } from "@ember/object"; import { registerUnbound } from "discourse-common/lib/helpers"; import { isRTL } from "discourse/lib/text-direction"; import { iconHTML } from "discourse-common/lib/icon-library"; +import Category from "discourse/models/category"; +import Site from "discourse/models/site"; let escapeExpression = Handlebars.Utils.escapeExpression; let _renderer = defaultCategoryLinkRenderer; @@ -31,8 +33,7 @@ export function categoryBadgeHTML(category, opts) { if ( !category || (!opts.allowUncategorized && - get(category, "id") === - Discourse.Site.currentProp("uncategorized_category_id") && + get(category, "id") === Site.currentProp("uncategorized_category_id") && Discourse.SiteSettings.suppress_uncategorized_badge) ) return ""; @@ -78,7 +79,7 @@ function defaultCategoryLinkRenderer(category, opts) { let restricted = get(category, "read_restricted"); let url = opts.url ? opts.url - : Discourse.getURL("/c/") + Discourse.Category.slugFor(category); + : Discourse.getURL(`/c/${Category.slugFor(category)}/${category.id}`); let href = opts.link === false ? "" : url; let tagName = opts.link === false || opts.link === "false" ? "span" : "a"; let extraClasses = opts.extraClasses ? " " + opts.extraClasses : ""; @@ -88,9 +89,7 @@ function defaultCategoryLinkRenderer(category, opts) { let categoryDir = ""; if (!opts.hideParent) { - parentCat = Discourse.Category.findById( - get(category, "parent_category_id") - ); + parentCat = Category.findById(get(category, "parent_category_id")); } const categoryStyle = diff --git a/app/assets/javascripts/discourse/helpers/reviewable-status.js.es6 b/app/assets/javascripts/discourse/helpers/reviewable-status.js.es6 index 5807cb0e64..b024500a0c 100644 --- a/app/assets/javascripts/discourse/helpers/reviewable-status.js.es6 +++ b/app/assets/javascripts/discourse/helpers/reviewable-status.js.es6 @@ -1,6 +1,5 @@ import { htmlHelper } from "discourse-common/lib/helpers"; import { iconHTML } from "discourse-common/lib/icon-library"; - import { PENDING, APPROVED, diff --git a/app/assets/javascripts/discourse/initializers/live-development.js.es6 b/app/assets/javascripts/discourse/initializers/live-development.js.es6 index 8104bdfd0a..38afdbc9b7 100644 --- a/app/assets/javascripts/discourse/initializers/live-development.js.es6 +++ b/app/assets/javascripts/discourse/initializers/live-development.js.es6 @@ -1,5 +1,6 @@ import DiscourseURL from "discourse/lib/url"; import { currentThemeIds, refreshCSS } from "discourse/lib/theme-selector"; +import ENV from "discourse-common/config/environment"; // Use the message bus for live reloading of components for faster development. export default { @@ -12,7 +13,10 @@ export default { window.location.search.indexOf("?preview_theme_id=") === 0 ) { // force preview theme id to always be carried along - const themeId = parseInt(window.location.search.slice(18).split("&")[0]); + const themeId = parseInt( + window.location.search.slice(18).split("&")[0], + 10 + ); if (!isNaN(themeId)) { const patchState = function(f) { const patched = window.history[f]; @@ -43,7 +47,7 @@ export default { }); // Useful to export this for debugging purposes - if (Discourse.Environment === "development" && !Ember.testing) { + if (Discourse.Environment === "development" && ENV.environment !== "test") { window.DiscourseURL = DiscourseURL; } diff --git a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 index a9e1cc24d0..35504de3bb 100644 --- a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 +++ b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 @@ -26,7 +26,10 @@ export default { const players = $("audio", $elem); if (players.length) { players.on("play", () => { - const postId = parseInt($elem.closest("article").data("post-id")); + const postId = parseInt( + $elem.closest("article").data("post-id"), + 10 + ); if (postId) { api.preventCloak(postId); } diff --git a/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 b/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 index b619969ac5..d3ca4fde2a 100644 --- a/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 +++ b/app/assets/javascripts/discourse/initializers/sharing-sources.js.es6 @@ -22,7 +22,7 @@ export default { Sharing.addSource({ id: "facebook", - icon: "fab-facebook-square", + icon: "fab-facebook", title: I18n.t("share.facebook"), generateUrl: function(link, title) { return ( diff --git a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 index c79a94ceaf..8efb204c36 100644 --- a/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 +++ b/app/assets/javascripts/discourse/initializers/subscribe-user-notifications.js.es6 @@ -12,6 +12,7 @@ import { isPushNotificationsEnabled } from "discourse/lib/push-notifications"; import { set } from "@ember/object"; +import ENV from "discourse-common/config/environment"; export default { name: "subscribe-user-notifications", @@ -127,7 +128,7 @@ export default { Discourse.set("assetVersion", data) ); - if (!Ember.testing) { + if (ENV.environment !== "test") { bus.subscribe(alertChannel(user), data => onNotification(data, user)); initDesktopNotifications(bus, appEvents); diff --git a/app/assets/javascripts/discourse/lib/ajax.js.es6 b/app/assets/javascripts/discourse/lib/ajax.js.es6 index 40fd85d686..0f995e8f11 100644 --- a/app/assets/javascripts/discourse/lib/ajax.js.es6 +++ b/app/assets/javascripts/discourse/lib/ajax.js.es6 @@ -1,7 +1,9 @@ import { run } from "@ember/runloop"; import pageVisible from "discourse/lib/page-visible"; import logout from "discourse/lib/logout"; +import Session from "discourse/models/session"; import { Promise } from "rsvp"; +import Site from "discourse/models/site"; let _trackView = false; let _transientHeader = null; @@ -44,7 +46,7 @@ function handleRedirect(data) { export function updateCsrfToken() { return ajax("/session/csrf").then(result => { - Discourse.Session.currentProp("csrfToken", result.csrf); + Session.currentProp("csrfToken", result.csrf); }); } @@ -99,7 +101,7 @@ export function ajax() { handleLogoff(xhr); run(() => { - Discourse.Site.currentProp( + Site.currentProp( "isReadOnly", !!xhr.getResponseHeader("Discourse-Readonly") ); @@ -120,7 +122,7 @@ export function ajax() { // note: for bad CSRF we don't loop an extra request right away. // this allows us to eliminate the possibility of having a loop. if (xhr.status === 403 && xhr.responseText === '["BAD CSRF"]') { - Discourse.Session.current().set("csrfToken", null); + Session.current().set("csrfToken", null); } // If it's a parsererror, don't reject @@ -162,7 +164,7 @@ export function ajax() { args.type && args.type.toUpperCase() !== "GET" && url !== Discourse.getURL("/clicks/track") && - !Discourse.Session.currentProp("csrfToken") + !Session.currentProp("csrfToken") ) { promise = new Promise((resolve, reject) => { ajaxObj = updateCsrfToken().then(() => { diff --git a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 index 0bcb3331c8..aeb7b836a2 100644 --- a/app/assets/javascripts/discourse/lib/autocomplete.js.es6 +++ b/app/assets/javascripts/discourse/lib/autocomplete.js.es6 @@ -1,14 +1,16 @@ import { cancel } from "@ember/runloop"; import { later } from "@ember/runloop"; +import { iconHTML } from "discourse-common/lib/icon-library"; +import { setCaretPosition, caretPosition } from "discourse/lib/utilities"; +import Site from "discourse/models/site"; + /** This is a jQuery plugin to support autocompleting values in our text fields. @module $.fn.autocomplete **/ -import { iconHTML } from "discourse-common/lib/icon-library"; -export const CANCELLED_STATUS = "__CANCELLED"; -import { setCaretPosition, caretPosition } from "discourse/lib/utilities"; +export const CANCELLED_STATUS = "__CANCELLED"; const allowedLettersRegex = /[\s\t\[\{\(\/]/; const keys = { @@ -210,11 +212,9 @@ export default function(options) { } if (options.single && !options.width) { - this.css("width", "100%"); + this.attr("class", `${this.attr("class")} fullwidth-input`); } else if (options.width) { this.css("width", options.width); - } else { - this.width(150); } this.attr( @@ -319,7 +319,7 @@ export default function(options) { vOffset = BELOW; } - if (Discourse.Site.currentProp("mobileView")) { + if (Site.currentProp("mobileView")) { if (me.height() / 2 >= pos.top) { vOffset = BELOW; } diff --git a/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 b/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 index 0b46e05b73..ca1ddcc85e 100644 --- a/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/category-tag-search.js.es6 @@ -1,4 +1,4 @@ -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import Category from "discourse/models/category"; import { TAG_HASHTAG_POSTFIX } from "discourse/lib/tag-hashtags"; @@ -21,7 +21,7 @@ function searchTags(term, categories, limit) { resolve(CANCELLED_STATUS); }, 5000); - const debouncedSearch = debounce((q, cats, resultFunc) => { + const debouncedSearch = discourseDebounce((q, cats, resultFunc) => { oldSearch = $.ajax(Discourse.getURL("/tags/filter/search"), { type: "GET", cache: true, diff --git a/app/assets/javascripts/discourse/lib/click-track.js.es6 b/app/assets/javascripts/discourse/lib/click-track.js.es6 index 12abc76152..c3186937b0 100644 --- a/app/assets/javascripts/discourse/lib/click-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/click-track.js.es6 @@ -4,6 +4,8 @@ import DiscourseURL from "discourse/lib/url"; import { wantsNewWindow } from "discourse/lib/intercept-click"; import { selectedText } from "discourse/lib/utilities"; import { Promise } from "rsvp"; +import ENV from "discourse-common/config/environment"; +import User from "discourse/models/user"; export function isValidLink($link) { // Do not track: @@ -63,7 +65,7 @@ export default { // Warn the user if they cannot download the file. if ( Discourse.SiteSettings.prevent_anons_from_downloading_files && - !Discourse.User.current() + !User.current() ) { bootbox.alert(I18n.t("post.errors.attachment_download_requires_login")); } else if (wantsNewWindow(e)) { @@ -82,7 +84,7 @@ export default { const postId = $article.data("post-id"); const topicId = $("#topic").data("topic-id") || $article.data("topic-id"); const userId = $link.data("user-id") || $article.data("user-id"); - const ownLink = userId && userId === Discourse.User.currentProp("id"); + const ownLink = userId && userId === User.currentProp("id"); // Update badge clicks unless it's our own. if (tracking && !ownLink) { @@ -99,7 +101,7 @@ export default { let trackPromise = Promise.resolve(); if (tracking) { - if (!Ember.testing && navigator.sendBeacon) { + if (ENV.environment !== "test" && navigator.sendBeacon) { const data = new FormData(); data.append("url", href); data.append("post_id", postId); @@ -118,9 +120,7 @@ export default { } const isInternal = DiscourseURL.isInternal(href); - const openExternalInNewTab = Discourse.User.currentProp( - "external_links_in_new_tab" - ); + const openExternalInNewTab = User.currentProp("external_links_in_new_tab"); if (!wantsNewWindow(e)) { if (!isInternal && openExternalInNewTab) { diff --git a/app/assets/javascripts/discourse/lib/computed.js.es6 b/app/assets/javascripts/discourse/lib/computed.js.es6 index e6a440eb83..992029d293 100644 --- a/app/assets/javascripts/discourse/lib/computed.js.es6 +++ b/app/assets/javascripts/discourse/lib/computed.js.es6 @@ -6,7 +6,7 @@ import addonFmt from "ember-addons/fmt"; @method propertyEqual @params {String} p1 the first property @params {String} p2 the second property - @return {Function} computedProperty function + @return {Function} discourseComputedProperty function **/ export function propertyEqual(p1, p2) { @@ -21,7 +21,7 @@ export function propertyEqual(p1, p2) { @method propertyNotEqual @params {String} p1 the first property @params {String} p2 the second property - @return {Function} computedProperty function + @return {Function} discourseComputedProperty function **/ export function propertyNotEqual(p1, p2) { return Ember.computed(p1, p2, function() { @@ -47,7 +47,7 @@ export function propertyLessThan(p1, p2) { @method i18n @params {String} properties* to format @params {String} format the i18n format string - @return {Function} computedProperty function + @return {Function} discourseComputedProperty function **/ export function i18n(...args) { const format = args.pop(); @@ -63,7 +63,7 @@ export function i18n(...args) { @method fmt @params {String} properties* to format @params {String} format the format string - @return {Function} computedProperty function + @return {Function} discourseComputedProperty function **/ export function fmt(...args) { const format = args.pop(); @@ -79,7 +79,7 @@ export function fmt(...args) { @method url @params {String} properties* to format @params {String} format the format string for the URL - @return {Function} computedProperty function returning a URL + @return {Function} discourseComputedProperty function returning a URL **/ export function url(...args) { const format = args.pop(); @@ -94,7 +94,7 @@ export function url(...args) { @method endWith @params {String} properties* to check @params {String} substring the substring - @return {Function} computedProperty function + @return {Function} discourseComputedProperty function **/ export function endWith() { const args = Array.prototype.slice.call(arguments, 0); diff --git a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 index 52c0449285..394d9a6dd3 100644 --- a/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/desktop-notifications.js.es6 @@ -3,6 +3,8 @@ import DiscourseURL from "discourse/lib/url"; import KeyValueStore from "discourse/lib/key-value-store"; import { formatUsername } from "discourse/lib/utilities"; import { Promise } from "rsvp"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; let primaryTab = false; let liveEnabled = false; @@ -21,21 +23,23 @@ function init(messageBus, appEvents) { liveEnabled = false; mbClientId = messageBus.clientId; - if (!Discourse.User.current()) { + if (!User.current()) { return; } try { keyValueStore.getItem(focusTrackerKey); } catch (e) { - Ember.Logger.info( + // eslint-disable-next-line no-console + console.info( "Discourse desktop notifications are disabled - localStorage denied." ); return; } if (!("Notification" in window)) { - Ember.Logger.info( + // eslint-disable-next-line no-console + console.info( "Discourse desktop notifications are disabled - not supported by browser" ); return; @@ -49,7 +53,8 @@ function init(messageBus, appEvents) { return; } } catch (e) { - Ember.Logger.warn( + // eslint-disable-next-line no-console + console.warn( "Unexpected error, Notification is defined on window but not a responding correctly " + e ); @@ -60,7 +65,8 @@ function init(messageBus, appEvents) { // Preliminary checks passed, continue with setup setupNotifications(appEvents); } catch (e) { - Ember.Logger.error(e); + // eslint-disable-next-line no-console + console.error(e); } } @@ -209,7 +215,7 @@ function requestPermission() { function i18nKey(notification_type) { return ( "notifications.popup." + - Discourse.Site.current().get("notificationLookup")[notification_type] + Site.current().get("notificationLookup")[notification_type] ); } diff --git a/app/assets/javascripts/discourse/lib/eyeline.js.es6 b/app/assets/javascripts/discourse/lib/eyeline.js.es6 index f49801f099..bebbfa1623 100644 --- a/app/assets/javascripts/discourse/lib/eyeline.js.es6 +++ b/app/assets/javascripts/discourse/lib/eyeline.js.es6 @@ -1,18 +1,42 @@ -// Track visible elemnts on the screen. +import ENV from "discourse-common/config/environment"; +import { EventTarget } from "rsvp"; + +let _skipUpdate; +let _rootElement; + +export function configureEyeline(opts) { + if (opts) { + _skipUpdate = opts.skipUpdate; + _rootElement = opts.rootElement; + } else { + _skipUpdate = ENV.environment === "test"; + _rootElement = null; + } +} + +configureEyeline(); + +// Track visible elements on the screen. const Eyeline = function Eyeline(selector) { this.selector = selector; }; Eyeline.prototype.update = function() { - if (Ember.testing) { + if (_skipUpdate) { return; } - const docViewTop = $(window).scrollTop(), - windowHeight = $(window).height(), - docViewBottom = docViewTop + windowHeight, - $elements = $(this.selector), - bottomOffset = $elements.last().offset(); + const docViewTop = _rootElement + ? $(_rootElement).scrollTop() + : $(window).scrollTop(); + const windowHeight = _rootElement + ? $(_rootElement).height() + : $(window).height(); + const docViewBottom = docViewTop + windowHeight; + const $elements = $(this.selector); + const bottomOffset = _rootElement + ? $elements.last().position() + : $elements.last().offset(); let atBottom = false; if (bottomOffset) { @@ -22,7 +46,7 @@ Eyeline.prototype.update = function() { return $elements.each((i, elem) => { const $elem = $(elem), - elemTop = $elem.offset().top, + elemTop = _rootElement ? $elem.position().top : $elem.offset().top, elemBottom = elemTop + $elem.height(); let markSeen = false; @@ -61,13 +85,13 @@ Eyeline.prototype.update = function() { // Call this when we know aren't loading any more elements. Mark the rest as seen Eyeline.prototype.flushRest = function() { - if (Ember.testing) { + if (ENV.environment === "test") { return; } $(this.selector).each((i, elem) => this.trigger("saw", { detail: $(elem) })); }; -RSVP.EventTarget.mixin(Eyeline.prototype); +EventTarget.mixin(Eyeline.prototype); export default Eyeline; diff --git a/app/assets/javascripts/discourse/lib/formatter.js.es6 b/app/assets/javascripts/discourse/lib/formatter.js.es6 index 2f71c83658..28bdb6cef2 100644 --- a/app/assets/javascripts/discourse/lib/formatter.js.es6 +++ b/app/assets/javascripts/discourse/lib/formatter.js.es6 @@ -195,11 +195,11 @@ export function durationTiny(distance, ageOpts) { const numYears = distanceInMinutes / 525600.0; const remainder = numYears % 1; if (remainder < 0.25) { - formatted = t("about_x_years", { count: parseInt(numYears) }); + formatted = t("about_x_years", { count: Math.floor(numYears) }); } else if (remainder < 0.75) { - formatted = t("over_x_years", { count: parseInt(numYears) }); + formatted = t("over_x_years", { count: Math.floor(numYears) }); } else { - formatted = t("almost_x_years", { count: parseInt(numYears) + 1 }); + formatted = t("almost_x_years", { count: Math.floor(numYears) + 1 }); } break; diff --git a/app/assets/javascripts/discourse/lib/key-value-store.js.es6 b/app/assets/javascripts/discourse/lib/key-value-store.js.es6 index c45501cf05..a0a0a5dcaa 100644 --- a/app/assets/javascripts/discourse/lib/key-value-store.js.es6 +++ b/app/assets/javascripts/discourse/lib/key-value-store.js.es6 @@ -67,7 +67,7 @@ KeyValueStore.prototype = { if (!safeLocalStorage) { return def; } - const result = parseInt(this.get(key)); + const result = parseInt(this.get(key), 10); if (!isFinite(result)) { return def; } diff --git a/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 index 12f78fc3f5..284c5a3be4 100644 --- a/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 +++ b/app/assets/javascripts/discourse/lib/lazy-load-images.js.es6 @@ -56,10 +56,16 @@ function show(image) { copyImg.className = imageData.className; let inOnebox = false; + let inQuote = false; for (let element = image; element; element = element.parentElement) { + if (element.tagName === "ARTICLE" && element.dataset.postId) { + break; + } if (element.classList.contains("onebox")) { inOnebox = true; - break; + } + if (element.tagName === "BLOCKQUOTE") { + inQuote = true; } } @@ -68,6 +74,18 @@ function show(image) { copyImg.style.height = `${imageData.height}px`; } + if (inQuote && imageData.width && imageData.height) { + const computedStyle = window.getComputedStyle(image); + const width = parseInt(computedStyle.width, 10); + const height = width * (imageData.height / imageData.width); + + image.width = width; + image.height = height; + + copyImg.style.width = `${width}px`; + copyImg.style.height = `${height}px`; + } + image.parentNode.insertBefore(copyImg, image); } else { image.classList.remove("d-lazyload-hidden"); diff --git a/app/assets/javascripts/discourse/lib/lightbox.js.es6 b/app/assets/javascripts/discourse/lib/lightbox.js.es6 index c84d878d6a..34330c44c8 100644 --- a/app/assets/javascripts/discourse/lib/lightbox.js.es6 +++ b/app/assets/javascripts/discourse/lib/lightbox.js.es6 @@ -3,6 +3,7 @@ import { escapeExpression } from "discourse/lib/utilities"; import { renderIcon } from "discourse-common/lib/icon-library"; import { isAppWebview, postRNWebviewMessage } from "discourse/lib/utilities"; import { spinnerHTML } from "discourse/helpers/loading-spinner"; +import User from "discourse/models/user"; export default function($elem) { if (!$elem) { @@ -77,7 +78,7 @@ export default function($elem) { ]; if ( !Discourse.SiteSettings.prevent_anons_from_downloading_files || - Discourse.User.current() + User.current() ) { src.push( ' { + Object.defineProperty(output, key, { value: args[key] }); + }); + + Object.keys(deprecatedArgs).forEach(key => { + Object.defineProperty(output, key, { + get() { + deprecated(`${key} is deprecated`); + + return deprecatedArgs[key]; + } + }); + }); + + return output; +} diff --git a/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 index d1b2092445..3bf8c2457c 100644 --- a/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 +++ b/app/assets/javascripts/discourse/lib/posts-with-placeholders.js.es6 @@ -1,5 +1,5 @@ import EmberObject from "@ember/object"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export function Placeholder(viewName) { this.viewName = viewName; @@ -13,7 +13,7 @@ export default EmberObject.extend(Ember.Array, { this._appendingIds = {}; }, - @computed + @discourseComputed length() { return ( this.get("posts.length") + Object.keys(this._appendingIds || {}).length diff --git a/app/assets/javascripts/discourse/lib/push-notifications.js.es6 b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 index 00f15d0fab..20040a8b11 100644 --- a/app/assets/javascripts/discourse/lib/push-notifications.js.es6 +++ b/app/assets/javascripts/discourse/lib/push-notifications.js.es6 @@ -22,7 +22,7 @@ function userAgentVersionChecker(agent, version, mobileView) { new RegExp(`${agent}\/(\\d+)\\.\\d`) ); if (uaMatch && mobileView) return false; - if (!uaMatch || parseInt(uaMatch[1]) < version) return false; + if (!uaMatch || parseInt(uaMatch[1], 10) < version) return false; return true; } @@ -49,10 +49,10 @@ export function isPushNotificationsSupported(mobileView) { if ( !( "serviceWorker" in navigator && - (ServiceWorkerRegistration && - typeof Notification !== "undefined" && - "showNotification" in ServiceWorkerRegistration.prototype && - "PushManager" in window) + ServiceWorkerRegistration && + typeof Notification !== "undefined" && + "showNotification" in ServiceWorkerRegistration.prototype && + "PushManager" in window ) ) { return false; diff --git a/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 b/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 index b6e5c5c5e9..c2dd9eb96c 100644 --- a/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 +++ b/app/assets/javascripts/discourse/lib/register-topic-footer-button.js.es6 @@ -1,8 +1,11 @@ +import error from "@ember/error"; +import { computed } from "@ember/object"; + let _topicFooterButtons = {}; export function registerTopicFooterButton(button) { if (!button.id) { - Ember.error(`Attempted to register a topic button: ${button} with no id.`); + error(`Attempted to register a topic button: ${button} with no id.`); return; } @@ -31,7 +34,7 @@ export function registerTopicFooterButton(button) { // css class appended to the button classNames: [], - // computed properties which should force a button state refresh + // discourseComputed properties which should force a button state refresh // eg: ["topic.bookmarked", "topic.category_id"] dependentKeys: [], @@ -52,7 +55,7 @@ export function registerTopicFooterButton(button) { !normalizedButton.title && !normalizedButton.translatedTitle ) { - Ember.error( + error( `Attempted to register a topic button: ${button.id} with no icon or title.` ); return; @@ -68,7 +71,7 @@ export function getTopicFooterButtons() { .filter(x => x) ); - return Ember.computed(...dependentKeys, { + return computed(...dependentKeys, { get() { const _isFunction = descriptor => descriptor && typeof descriptor === "function"; @@ -86,37 +89,37 @@ export function getTopicFooterButtons() { return Object.values(_topicFooterButtons) .filter(button => _compute(button, "displayed")) .map(button => { - const computedButon = {}; + const discourseComputedButon = {}; - computedButon.id = button.id; + discourseComputedButon.id = button.id; const label = _compute(button, "label"); - computedButon.label = label + discourseComputedButon.label = label ? I18n.t(label) : _compute(button, "translatedLabel"); const title = _compute(button, "title"); - computedButon.title = title + discourseComputedButon.title = title ? I18n.t(title) : _compute(button, "translatedTitle"); - computedButon.classNames = ( + discourseComputedButon.classNames = ( _compute(button, "classNames") || [] ).join(" "); - computedButon.icon = _compute(button, "icon"); - computedButon.disabled = _compute(button, "disabled"); - computedButon.dropdown = _compute(button, "dropdown"); - computedButon.priority = _compute(button, "priority"); + discourseComputedButon.icon = _compute(button, "icon"); + discourseComputedButon.disabled = _compute(button, "disabled"); + discourseComputedButon.dropdown = _compute(button, "dropdown"); + discourseComputedButon.priority = _compute(button, "priority"); if (_isFunction(button.action)) { - computedButon.action = () => button.action.apply(this); + discourseComputedButon.action = () => button.action.apply(this); } else { const actionName = button.action; - computedButon.action = () => this[actionName](); + discourseComputedButon.action = () => this[actionName](); } - return computedButon; + return discourseComputedButon; }) .sortBy("priority") .reverse(); diff --git a/app/assets/javascripts/discourse/lib/render-tag.js.es6 b/app/assets/javascripts/discourse/lib/render-tag.js.es6 index 4e0597d4a4..5206e2c900 100644 --- a/app/assets/javascripts/discourse/lib/render-tag.js.es6 +++ b/app/assets/javascripts/discourse/lib/render-tag.js.es6 @@ -1,3 +1,5 @@ +import User from "discourse/models/user"; + let _renderer = defaultRenderTag; export function replaceTagRenderer(fn) { @@ -12,10 +14,10 @@ function defaultRenderTag(tag, params) { const tagName = params.tagName || "a"; let path; if (tagName === "a" && !params.noHref) { - if (params.isPrivateMessage && Discourse.User.current()) { + if ((params.isPrivateMessage || params.pmOnly) && User.current()) { const username = params.tagsForUser ? params.tagsForUser - : Discourse.User.current().username; + : User.current().username; path = `/u/${username}/messages/tags/${tag}`; } else { path = `/tags/${tag}`; @@ -26,6 +28,9 @@ function defaultRenderTag(tag, params) { if (Discourse.SiteSettings.tag_style || params.style) { classes.push(params.style || Discourse.SiteSettings.tag_style); } + if (params.size) { + classes.push(params.size); + } let val = "<" + diff --git a/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 index bcf13b115a..b4751954aa 100644 --- a/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 +++ b/app/assets/javascripts/discourse/lib/render-topic-featured-link.js.es6 @@ -1,5 +1,6 @@ import { h } from "virtual-dom"; import { renderIcon } from "discourse-common/lib/icon-library"; +import User from "discourse/models/user"; const _decorators = []; @@ -9,9 +10,7 @@ export function addFeaturedLinkMetaDecorator(decorator) { export function extractLinkMeta(topic) { const href = topic.get("featured_link"); - const target = Discourse.User.currentProp("external_links_in_new_tab") - ? "_blank" - : ""; + const target = User.currentProp("external_links_in_new_tab") ? "_blank" : ""; if (!href) { return; diff --git a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 index afb55f7f8b..9241cbbed7 100644 --- a/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 +++ b/app/assets/javascripts/discourse/lib/safari-hacks.js.es6 @@ -1,5 +1,5 @@ import { later } from "@ember/runloop"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { safariHacksDisabled, iOSWithVisualViewport @@ -132,7 +132,7 @@ function positioningWorkaround($fixedElement) { positioningWorkaround.blur(evt); }; - var blurred = debounce(blurredNow, 250); + var blurred = discourseDebounce(blurredNow, 250); var positioningHack = function(evt) { // we need this, otherwise changing focus means we never clear @@ -217,7 +217,7 @@ function positioningWorkaround($fixedElement) { } } - const checkForInputs = debounce(function() { + const checkForInputs = discourseDebounce(function() { attachTouchStart(fixedElement, lastTouched); $fixedElement.find("input[type=text],textarea").each(function() { diff --git a/app/assets/javascripts/discourse/lib/screen-track.js.es6 b/app/assets/javascripts/discourse/lib/screen-track.js.es6 index 314e1fac0a..91e0d42a83 100644 --- a/app/assets/javascripts/discourse/lib/screen-track.js.es6 +++ b/app/assets/javascripts/discourse/lib/screen-track.js.es6 @@ -187,7 +187,7 @@ export default class { // Save unique topic IDs up to a max let topicIds = storage.get("anon-topic-ids"); if (topicIds) { - topicIds = topicIds.split(",").map(e => parseInt(e)); + topicIds = topicIds.split(",").map(e => parseInt(e, 10)); } else { topicIds = []; } diff --git a/app/assets/javascripts/discourse/lib/search.js.es6 b/app/assets/javascripts/discourse/lib/search.js.es6 index 2824268e66..426a630483 100644 --- a/app/assets/javascripts/discourse/lib/search.js.es6 +++ b/app/assets/javascripts/discourse/lib/search.js.es6 @@ -145,7 +145,8 @@ export function searchForTerm(term, opts) { if (opts.searchContext) { data.search_context = { type: opts.searchContext.type, - id: opts.searchContext.id + id: opts.searchContext.id, + name: opts.searchContext.name }; } @@ -167,6 +168,8 @@ export function searchContextDescription(type, name) { return I18n.t("search.context.user", { username: name }); case "category": return I18n.t("search.context.category", { category: name }); + case "tag": + return I18n.t("search.context.tag", { tag: name }); case "private_messages": return I18n.t("search.context.private_messages"); } diff --git a/app/assets/javascripts/discourse/lib/text.js.es6 b/app/assets/javascripts/discourse/lib/text.js.es6 index 2767426c09..7862ce53e0 100644 --- a/app/assets/javascripts/discourse/lib/text.js.es6 +++ b/app/assets/javascripts/discourse/lib/text.js.es6 @@ -68,7 +68,8 @@ function emojiOptions() { return { getURL: Discourse.getURLWithCDN, emojiSet: Discourse.SiteSettings.emoji_set, - enableEmojiShortcuts: Discourse.SiteSettings.enable_emoji_shortcuts + enableEmojiShortcuts: Discourse.SiteSettings.enable_emoji_shortcuts, + inlineEmoji: Discourse.SiteSettings.enable_inline_emoji_translation }; } diff --git a/app/assets/javascripts/discourse/lib/throttle.js.es6 b/app/assets/javascripts/discourse/lib/throttle.js.es6 deleted file mode 100644 index 05daa36c5a..0000000000 --- a/app/assets/javascripts/discourse/lib/throttle.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -import { throttle } from "@ember/runloop"; -/** - Throttle a Javascript function. This means if it's called many times in a time limit it - should only be executed one time at most during this time limit - Original function will be called with the context and arguments from the last call made. -**/ -export default function(func, spacing, immediate) { - let self, args; - const later = function() { - func.apply(self, args); - }; - - return function() { - self = this; - args = arguments; - - throttle(null, later, spacing, immediate); - }; -} diff --git a/app/assets/javascripts/discourse/lib/to-markdown.js.es6 b/app/assets/javascripts/discourse/lib/to-markdown.js.es6 index 4a3c486251..c7ba652984 100644 --- a/app/assets/javascripts/discourse/lib/to-markdown.js.es6 +++ b/app/assets/javascripts/discourse/lib/to-markdown.js.es6 @@ -321,7 +321,8 @@ export class Tag { if (msoListClasses.includes(attrs.class)) { try { const level = parseInt( - attrs.style.match(/level./)[0].replace("level", "") + attrs.style.match(/level./)[0].replace("level", ""), + 10 ); indent = Array(level).join("\t") + indent; } finally { @@ -448,7 +449,7 @@ export class Tag { const bullet = text.match(/\n\t*\*/)[0]; for ( - let i = parseInt(this.element.attributes.start || 1); + let i = parseInt(this.element.attributes.start || 1, 10); text.includes(bullet); i++ ) { @@ -488,6 +489,8 @@ function tags() { Tag.keep("small"), Tag.keep("big"), Tag.keep("kbd"), + Tag.keep("ruby"), + Tag.keep("rt"), Tag.li(), Tag.link(), Tag.image(), diff --git a/app/assets/javascripts/discourse/lib/transform-post.js.es6 b/app/assets/javascripts/discourse/lib/transform-post.js.es6 index e6583cf8f6..a018934521 100644 --- a/app/assets/javascripts/discourse/lib/transform-post.js.es6 +++ b/app/assets/javascripts/discourse/lib/transform-post.js.es6 @@ -239,7 +239,8 @@ export default function transformPost( postAtts.showFlagDelete = !postAtts.canDelete && postAtts.yours && - (currentUser && !currentUser.staff); + currentUser && + !currentUser.staff; } else { postAtts.canRecover = postAtts.isDeleted && postAtts.canRecover; postAtts.canDelete = diff --git a/app/assets/javascripts/discourse/lib/uploads.js.es6 b/app/assets/javascripts/discourse/lib/uploads.js.es6 new file mode 100644 index 0000000000..ed0271e8f3 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/uploads.js.es6 @@ -0,0 +1,284 @@ +import { isAppleDevice } from "discourse/lib/utilities"; + +function isGUID(value) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value + ); +} + +function imageNameFromFileName(fileName) { + const split = fileName.split("."); + let name = split[split.length - 2]; + + if (isAppleDevice() && isGUID(name)) { + name = I18n.t("upload_selector.default_image_alt_text"); + } + + return name.replace(/\[|\]|\|/g, ""); +} + +export function validateUploadedFiles(files, opts) { + if (!files || files.length === 0) { + return false; + } + + if (files.length > 1) { + bootbox.alert(I18n.t("post.errors.too_many_uploads")); + return false; + } + + const upload = files[0]; + + // CHROME ONLY: if the image was pasted, sets its name to a default one + if (typeof Blob !== "undefined" && typeof File !== "undefined") { + if ( + upload instanceof Blob && + !(upload instanceof File) && + upload.type === "image/png" + ) { + upload.name = "image.png"; + } + } + + opts = opts || {}; + opts.type = uploadTypeFromFileName(upload.name); + + return validateUploadedFile(upload, opts); +} + +function validateUploadedFile(file, opts) { + if (opts.skipValidation) return true; + + opts = opts || {}; + let user = opts.user; + let staff = user && user.staff; + + if (!authorizesOneOrMoreExtensions(staff)) return false; + + const name = file && file.name; + + if (!name) { + return false; + } + + // check that the uploaded file is authorized + if (opts.allowStaffToUploadAnyFileInPm && opts.isPrivateMessage) { + if (staff) { + return true; + } + } + + if (opts.imagesOnly) { + if (!isAnImage(name) && !isAuthorizedImage(name, staff)) { + bootbox.alert( + I18n.t("post.errors.upload_not_authorized", { + authorized_extensions: authorizedImagesExtensions(staff) + }) + ); + return false; + } + } else if (opts.csvOnly) { + if (!/\.csv$/i.test(name)) { + bootbox.alert(I18n.t("user.invited.bulk_invite.error")); + return false; + } + } else { + if (!authorizesAllExtensions(staff) && !isAuthorizedFile(name, staff)) { + bootbox.alert( + I18n.t("post.errors.upload_not_authorized", { + authorized_extensions: authorizedExtensions(staff) + }) + ); + return false; + } + } + + if (!opts.bypassNewUserRestriction) { + // ensures that new users can upload a file + if (user && !user.isAllowedToUploadAFile(opts.type)) { + bootbox.alert( + I18n.t(`post.errors.${opts.type}_upload_not_allowed_for_new_user`) + ); + return false; + } + } + + // everything went fine + return true; +} + +const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|svg|ico)/i; + +function extensionsToArray(exts) { + return exts + .toLowerCase() + .replace(/[\s\.]+/g, "") + .split("|") + .filter(ext => ext.indexOf("*") === -1); +} + +function extensions() { + return extensionsToArray(Discourse.SiteSettings.authorized_extensions); +} + +function staffExtensions() { + return extensionsToArray( + Discourse.SiteSettings.authorized_extensions_for_staff + ); +} + +function imagesExtensions(staff) { + let exts = extensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext)); + if (staff) { + const staffExts = staffExtensions().filter(ext => + IMAGES_EXTENSIONS_REGEX.test(ext) + ); + exts = _.union(exts, staffExts); + } + return exts; +} + +function extensionsRegex() { + return new RegExp("\\.(" + extensions().join("|") + ")$", "i"); +} + +function imagesExtensionsRegex(staff) { + return new RegExp("\\.(" + imagesExtensions(staff).join("|") + ")$", "i"); +} + +function staffExtensionsRegex() { + return new RegExp("\\.(" + staffExtensions().join("|") + ")$", "i"); +} + +function isAuthorizedFile(fileName, staff) { + if (staff && staffExtensionsRegex().test(fileName)) { + return true; + } + return extensionsRegex().test(fileName); +} + +function isAuthorizedImage(fileName, staff) { + return imagesExtensionsRegex(staff).test(fileName); +} + +export function authorizedExtensions(staff) { + const exts = staff ? [...extensions(), ...staffExtensions()] : extensions(); + return exts.filter(ext => ext.length > 0).join(", "); +} + +function authorizedImagesExtensions(staff) { + return authorizesAllExtensions(staff) + ? "png, jpg, jpeg, gif, svg, ico" + : imagesExtensions(staff).join(", "); +} + +export function authorizesAllExtensions(staff) { + return ( + Discourse.SiteSettings.authorized_extensions.indexOf("*") >= 0 || + (Discourse.SiteSettings.authorized_extensions_for_staff.indexOf("*") >= 0 && + staff) + ); +} + +export function authorizesOneOrMoreExtensions(staff) { + if (authorizesAllExtensions(staff)) return true; + + return ( + Discourse.SiteSettings.authorized_extensions.split("|").filter(ext => ext) + .length > 0 + ); +} + +export function authorizesOneOrMoreImageExtensions(staff) { + if (authorizesAllExtensions(staff)) return true; + return imagesExtensions(staff).length > 0; +} + +export function isAnImage(path) { + return /\.(png|jpe?g|gif|svg|ico)$/i.test(path); +} + +function uploadTypeFromFileName(fileName) { + return isAnImage(fileName) ? "image" : "attachment"; +} + +export function allowsImages(staff) { + return ( + authorizesAllExtensions(staff) || + IMAGES_EXTENSIONS_REGEX.test(authorizedExtensions(staff)) + ); +} + +export function allowsAttachments(staff) { + return ( + authorizesAllExtensions(staff) || + authorizedExtensions(staff).split(", ").length > + imagesExtensions(staff).length + ); +} + +export function uploadIcon(staff) { + return allowsAttachments(staff) ? "upload" : "far-image"; +} + +function uploadLocation(url) { + if (Discourse.CDN) { + url = Discourse.getURLWithCDN(url); + return /^\/\//.test(url) ? "http:" + url : url; + } else if (Discourse.S3BaseUrl) { + return "https:" + url; + } else { + var protocol = window.location.protocol + "//", + hostname = window.location.hostname, + port = window.location.port ? ":" + window.location.port : ""; + return protocol + hostname + port + url; + } +} + +export function getUploadMarkdown(upload) { + if (isAnImage(upload.original_filename)) { + const name = imageNameFromFileName(upload.original_filename); + return `![${name}|${upload.thumbnail_width}x${ + upload.thumbnail_height + }](${upload.short_url || upload.url})`; + } else if ( + /\.(mov|mp4|webm|ogv|mp3|ogg|wav|m4a)$/i.test(upload.original_filename) + ) { + return uploadLocation(upload.url); + } else { + return `[${upload.original_filename}|attachment](${ + upload.short_url + }) (${I18n.toHumanSize(upload.filesize)})`; + } +} + +export function displayErrorForUpload(data) { + if (data.jqXHR) { + switch (data.jqXHR.status) { + // cancelled by the user + case 0: + return; + + // entity too large, usually returned from the web server + case 413: + const type = uploadTypeFromFileName(data.files[0].name); + const max_size_kb = Discourse.SiteSettings[`max_${type}_size_kb`]; + bootbox.alert(I18n.t("post.errors.file_too_large", { max_size_kb })); + return; + + // the error message is provided by the server + case 422: + if (data.jqXHR.responseJSON.message) { + bootbox.alert(data.jqXHR.responseJSON.message); + } else { + bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")); + } + return; + } + } else if (data.errors && data.errors.length > 0) { + bootbox.alert(data.errors.join("\n")); + return; + } + // otherwise, display a generic error message + bootbox.alert(I18n.t("post.errors.upload")); +} diff --git a/app/assets/javascripts/discourse/lib/url.js.es6 b/app/assets/javascripts/discourse/lib/url.js.es6 index 71986d4d29..ae252facf8 100644 --- a/app/assets/javascripts/discourse/lib/url.js.es6 +++ b/app/assets/javascripts/discourse/lib/url.js.es6 @@ -5,6 +5,7 @@ import { schedule } from "@ember/runloop"; import offsetCalculator from "discourse/lib/offset-calculator"; import LockOn from "discourse/lib/lock-on"; import { defaultHomepage } from "discourse/lib/utilities"; +import User from "discourse/models/user"; const rewrites = []; const TOPIC_REGEXP = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/; @@ -230,7 +231,7 @@ const DiscourseURL = EmberObject.extend({ // Rewrite /my/* urls let myPath = `${baseUri}/my/`; if (path.indexOf(myPath) === 0) { - const currentUser = Discourse.User.current(); + const currentUser = User.current(); if (currentUser) { path = path.replace( myPath, diff --git a/app/assets/javascripts/discourse/lib/user-search.js.es6 b/app/assets/javascripts/discourse/lib/user-search.js.es6 index 63fbb2da71..2a5890aa3f 100644 --- a/app/assets/javascripts/discourse/lib/user-search.js.es6 +++ b/app/assets/javascripts/discourse/lib/user-search.js.es6 @@ -1,4 +1,4 @@ -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import { userPath } from "discourse/lib/url"; import { emailValid } from "discourse/lib/utilities"; @@ -79,7 +79,7 @@ function performSearch( }); } -var debouncedSearch = debounce(performSearch, 300); +var debouncedSearch = discourseDebounce(performSearch, 300); function organizeResults(r, options) { if (r === CANCELLED_STATUS) { @@ -136,7 +136,7 @@ function organizeResults(r, options) { // will not find me, which is a reasonable compromise // // we also ignore if we notice a double space or a string that is only a space -const ignoreRegex = /([\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,\/:;<=>?\[\]^`{|}~])|\s\s|^\s$/; +const ignoreRegex = /([\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*,\/:;<=>?\[\]^`{|}~])|\s\s|^\s$|^[^+]*\+[^@]*$/; function skipSearch(term, allowEmails) { if (term.indexOf("@") > -1 && !allowEmails) { diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index 599c3a4965..145674f3a5 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -195,292 +195,6 @@ export function setCaretPosition(ctrl, pos) { } } -export function validateUploadedFiles(files, opts) { - if (!files || files.length === 0) { - return false; - } - - if (files.length > 1) { - bootbox.alert(I18n.t("post.errors.too_many_uploads")); - return false; - } - - const upload = files[0]; - - // CHROME ONLY: if the image was pasted, sets its name to a default one - if (typeof Blob !== "undefined" && typeof File !== "undefined") { - if ( - upload instanceof Blob && - !(upload instanceof File) && - upload.type === "image/png" - ) { - upload.name = "image.png"; - } - } - - opts = opts || {}; - opts.type = uploadTypeFromFileName(upload.name); - - return validateUploadedFile(upload, opts); -} - -export function validateUploadedFile(file, opts) { - if (opts.skipValidation) return true; - if (!authorizesOneOrMoreExtensions()) return false; - - opts = opts || {}; - - const name = file && file.name; - - if (!name) { - return false; - } - - // check that the uploaded file is authorized - if (opts.allowStaffToUploadAnyFileInPm && opts.isPrivateMessage) { - if (Discourse.User.currentProp("staff")) { - return true; - } - } - - if (opts.imagesOnly) { - if (!isAnImage(name) && !isAuthorizedImage(name)) { - bootbox.alert( - I18n.t("post.errors.upload_not_authorized", { - authorized_extensions: authorizedImagesExtensions() - }) - ); - return false; - } - } else if (opts.csvOnly) { - if (!/\.csv$/i.test(name)) { - bootbox.alert(I18n.t("user.invited.bulk_invite.error")); - return false; - } - } else { - if (!authorizesAllExtensions() && !isAuthorizedFile(name)) { - bootbox.alert( - I18n.t("post.errors.upload_not_authorized", { - authorized_extensions: authorizedExtensions() - }) - ); - return false; - } - } - - if (!opts.bypassNewUserRestriction) { - // ensures that new users can upload a file - if (!Discourse.User.current().isAllowedToUploadAFile(opts.type)) { - bootbox.alert( - I18n.t(`post.errors.${opts.type}_upload_not_allowed_for_new_user`) - ); - return false; - } - } - - // everything went fine - return true; -} - -const IMAGES_EXTENSIONS_REGEX = /(png|jpe?g|gif|svg|ico)/i; - -function extensionsToArray(exts) { - return exts - .toLowerCase() - .replace(/[\s\.]+/g, "") - .split("|") - .filter(ext => ext.indexOf("*") === -1); -} - -function extensions() { - return extensionsToArray(Discourse.SiteSettings.authorized_extensions); -} - -function staffExtensions() { - return extensionsToArray( - Discourse.SiteSettings.authorized_extensions_for_staff - ); -} - -function imagesExtensions() { - let exts = extensions().filter(ext => IMAGES_EXTENSIONS_REGEX.test(ext)); - if (Discourse.User.currentProp("staff")) { - const staffExts = staffExtensions().filter(ext => - IMAGES_EXTENSIONS_REGEX.test(ext) - ); - exts = _.union(exts, staffExts); - } - return exts; -} - -function extensionsRegex() { - return new RegExp("\\.(" + extensions().join("|") + ")$", "i"); -} - -function imagesExtensionsRegex() { - return new RegExp("\\.(" + imagesExtensions().join("|") + ")$", "i"); -} - -function staffExtensionsRegex() { - return new RegExp("\\.(" + staffExtensions().join("|") + ")$", "i"); -} - -function isAuthorizedFile(fileName) { - if ( - Discourse.User.currentProp("staff") && - staffExtensionsRegex().test(fileName) - ) { - return true; - } - return extensionsRegex().test(fileName); -} - -function isAuthorizedImage(fileName) { - return imagesExtensionsRegex().test(fileName); -} - -export function authorizedExtensions() { - const exts = Discourse.User.currentProp("staff") - ? [...extensions(), ...staffExtensions()] - : extensions(); - return exts.filter(ext => ext.length > 0).join(", "); -} - -export function authorizedImagesExtensions() { - return authorizesAllExtensions() - ? "png, jpg, jpeg, gif, svg, ico" - : imagesExtensions().join(", "); -} - -export function authorizesAllExtensions() { - return ( - Discourse.SiteSettings.authorized_extensions.indexOf("*") >= 0 || - (Discourse.SiteSettings.authorized_extensions_for_staff.indexOf("*") >= 0 && - Discourse.User.currentProp("staff")) - ); -} - -export function authorizesOneOrMoreExtensions() { - if (authorizesAllExtensions()) return true; - - return ( - Discourse.SiteSettings.authorized_extensions.split("|").filter(ext => ext) - .length > 0 - ); -} - -export function authorizesOneOrMoreImageExtensions() { - if (authorizesAllExtensions()) return true; - - return imagesExtensions().length > 0; -} - -export function isAnImage(path) { - return /\.(png|jpe?g|gif|svg|ico)$/i.test(path); -} - -function uploadTypeFromFileName(fileName) { - return isAnImage(fileName) ? "image" : "attachment"; -} - -function isGUID(value) { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - value - ); -} - -function imageNameFromFileName(fileName) { - const split = fileName.split("."); - let name = split[split.length - 2]; - - if (exports.isAppleDevice() && isGUID(name)) { - name = I18n.t("upload_selector.default_image_alt_text"); - } - - return encodeURIComponent(name); -} - -export function allowsImages() { - return ( - authorizesAllExtensions() || - IMAGES_EXTENSIONS_REGEX.test(authorizedExtensions()) - ); -} - -export function allowsAttachments() { - return ( - authorizesAllExtensions() || - authorizedExtensions().split(", ").length > imagesExtensions().length - ); -} - -export function uploadIcon() { - return allowsAttachments() ? "upload" : "far-image"; -} - -export function uploadLocation(url) { - if (Discourse.CDN) { - url = Discourse.getURLWithCDN(url); - return /^\/\//.test(url) ? "http:" + url : url; - } else if (Discourse.S3BaseUrl) { - return "https:" + url; - } else { - var protocol = window.location.protocol + "//", - hostname = window.location.hostname, - port = window.location.port ? ":" + window.location.port : ""; - return protocol + hostname + port + url; - } -} - -export function getUploadMarkdown(upload) { - if (isAnImage(upload.original_filename)) { - const name = imageNameFromFileName(upload.original_filename); - return `![${name}|${upload.thumbnail_width}x${ - upload.thumbnail_height - }](${upload.short_url || upload.url})`; - } else if ( - !Discourse.SiteSettings.prevent_anons_from_downloading_files && - /\.(mov|mp4|webm|ogv|mp3|ogg|wav|m4a)$/i.test(upload.original_filename) - ) { - return uploadLocation(upload.url); - } else { - return `[${upload.original_filename}|attachment](${ - upload.short_url - }) (${I18n.toHumanSize(upload.filesize)})`; - } -} - -export function displayErrorForUpload(data) { - if (data.jqXHR) { - switch (data.jqXHR.status) { - // cancelled by the user - case 0: - return; - - // entity too large, usually returned from the web server - case 413: - const type = uploadTypeFromFileName(data.files[0].name); - const max_size_kb = Discourse.SiteSettings[`max_${type}_size_kb`]; - bootbox.alert(I18n.t("post.errors.file_too_large", { max_size_kb })); - return; - - // the error message is provided by the server - case 422: - if (data.jqXHR.responseJSON.message) { - bootbox.alert(data.jqXHR.responseJSON.message); - } else { - bootbox.alert(data.jqXHR.responseJSON.errors.join("\n")); - } - return; - } - } else if (data.errors && data.errors.length > 0) { - bootbox.alert(data.errors.join("\n")); - return; - } - // otherwise, display a generic error message - bootbox.alert(I18n.t("post.errors.upload")); -} - export function defaultHomepage() { let homepage = null; let elem = _.first($(homepageSelector)); diff --git a/app/assets/javascripts/discourse/mapping-router.js.es6 b/app/assets/javascripts/discourse/mapping-router.js.es6 index 52d25f5479..c50eb9112c 100644 --- a/app/assets/javascripts/discourse/mapping-router.js.es6 +++ b/app/assets/javascripts/discourse/mapping-router.js.es6 @@ -1,11 +1,13 @@ import { defaultHomepage } from "discourse/lib/utilities"; import { rewritePath } from "discourse/lib/url"; +import ENV from "discourse-common/config/environment"; +import Site from "discourse/models/site"; const rootURL = Discourse.BaseUri; const BareRouter = Ember.Router.extend({ rootURL, - location: Ember.testing ? "none" : "discourse-location", + location: ENV.environment === "test" ? "none" : "discourse-location", handleURL(url) { url = rewritePath(url); @@ -32,7 +34,7 @@ class RouteNode { this.children = []; this.childrenByName = {}; this.paths = {}; - this.site = Discourse.Site.current(); + this.site = Site.current(); if (!opts.path) { opts.path = name; diff --git a/app/assets/javascripts/discourse/mixins/add-archetype-class.js.es6 b/app/assets/javascripts/discourse/mixins/add-archetype-class.js.es6 index a8a2c3c528..1436f0def6 100644 --- a/app/assets/javascripts/discourse/mixins/add-archetype-class.js.es6 +++ b/app/assets/javascripts/discourse/mixins/add-archetype-class.js.es6 @@ -1,4 +1,4 @@ -import { on, observes } from "ember-addons/ember-computed-decorators"; +import { on, observes } from "discourse-common/utils/decorators"; // Mix this in to a view that has a `archetype` property to automatically // add it to the body as the view is entered / left / model is changed. diff --git a/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 index 08723e1486..beb145a3ec 100644 --- a/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 +++ b/app/assets/javascripts/discourse/mixins/buffered-content.js.es6 @@ -1,10 +1,11 @@ import EmberObjectProxy from "@ember/object/proxy"; import Mixin from "@ember/object/mixin"; +import { computed } from "@ember/object"; /* global BufferedProxy: true */ export function bufferedProperty(property) { const mixin = { - buffered: Ember.computed(property, function() { + buffered: computed(property, function() { return EmberObjectProxy.extend(BufferedProxy).create({ content: this.get(property) }); diff --git a/app/assets/javascripts/discourse/mixins/bulk-topic-selection.js.es6 b/app/assets/javascripts/discourse/mixins/bulk-topic-selection.js.es6 index 311e47661d..cc2dfc1d55 100644 --- a/app/assets/javascripts/discourse/mixins/bulk-topic-selection.js.es6 +++ b/app/assets/javascripts/discourse/mixins/bulk-topic-selection.js.es6 @@ -1,7 +1,8 @@ import { alias } from "@ember/object/computed"; import { NotificationLevels } from "discourse/lib/notification-levels"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; +import Topic from "discourse/models/topic"; export default Mixin.create({ bulkSelectEnabled: false, @@ -33,9 +34,9 @@ export default Mixin.create({ let promise; if (this.selected.length > 0) { - promise = Discourse.Topic.bulkOperation(this.selected, operation); + promise = Topic.bulkOperation(this.selected, operation); } else { - promise = Discourse.Topic.bulkOperationByFilter( + promise = Topic.bulkOperationByFilter( "unread", operation, this.get("category.id"), diff --git a/app/assets/javascripts/discourse/mixins/filter-mode.js.es6 b/app/assets/javascripts/discourse/mixins/filter-mode.js.es6 new file mode 100644 index 0000000000..4287080762 --- /dev/null +++ b/app/assets/javascripts/discourse/mixins/filter-mode.js.es6 @@ -0,0 +1,50 @@ +import Mixin from "@ember/object/mixin"; +import { computed } from "@ember/object"; +import Category from "discourse/models/category"; + +export default Mixin.create({ + filterModeInternal: computed( + "rawFilterMode", + "filterType", + "category", + "noSubcategories", + function() { + const rawFilterMode = this.rawFilterMode; + if (rawFilterMode) { + return rawFilterMode; + } else { + const category = this.category; + const filterType = this.filterType; + + if (category) { + const noSubcategories = this.noSubcategories; + + return `c/${Category.slugFor(category)}${ + noSubcategories ? "/none" : "" + }/l/${filterType}`; + } else { + return filterType; + } + } + } + ), + + filterMode: computed("filterModeInternal", { + get() { + return this.filterModeInternal; + }, + + set(key, value) { + this.set("rawFilterMode", value); + const parts = value.split("/"); + + if (parts.length >= 2 && parts[parts.length - 2] === "top") { + this.set("filterType", "top"); + } else { + this.set("filterType", parts.pop()); + } + + return value; + } + }) +}); diff --git a/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 b/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 index 37502e3749..153ba29c01 100644 --- a/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 +++ b/app/assets/javascripts/discourse/mixins/grant-badge-controller.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { empty } from "@ember/object/computed"; -import computed from "ember-addons/ember-computed-decorators"; import UserBadge from "discourse/models/user-badge"; import { convertIconClass } from "discourse-common/lib/icon-library"; import Mixin from "@ember/object/mixin"; export default Mixin.create({ - @computed("allBadges.[]", "userBadges.[]") + @discourseComputed("allBadges.[]", "userBadges.[]") grantableBadges(allBadges, userBadges) { const granted = userBadges.reduce((map, badge) => { map[badge.get("badge_id")] = true; @@ -31,7 +31,7 @@ export default Mixin.create({ noGrantableBadges: empty("grantableBadges"), - @computed("selectedBadgeId", "grantableBadges") + @discourseComputed("selectedBadgeId", "grantableBadges") selectedBadgeGrantable(selectedBadgeId, grantableBadges) { return ( grantableBadges && diff --git a/app/assets/javascripts/discourse/mixins/load-more.js.es6 b/app/assets/javascripts/discourse/mixins/load-more.js.es6 index a7761e1399..911e261e47 100644 --- a/app/assets/javascripts/discourse/mixins/load-more.js.es6 +++ b/app/assets/javascripts/discourse/mixins/load-more.js.es6 @@ -1,6 +1,6 @@ import Eyeline from "discourse/lib/eyeline"; import Scrolling from "discourse/mixins/scrolling"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; // Provides the ability to load more items for a view which is scrolled to the bottom. @@ -21,6 +21,7 @@ export default Mixin.create(Scrolling, { const eyeline = new Eyeline(this.eyelineSelector + ":last"); this.set("eyeline", eyeline); eyeline.on("sawBottom", () => this.send("loadMore")); + eyeline.update(); // update once to consider current position this.bindScrolling(); }, diff --git a/app/assets/javascripts/discourse/mixins/name-validation.js.es6 b/app/assets/javascripts/discourse/mixins/name-validation.js.es6 index 46f1b4dfae..b2594c97fe 100644 --- a/app/assets/javascripts/discourse/mixins/name-validation.js.es6 +++ b/app/assets/javascripts/discourse/mixins/name-validation.js.es6 @@ -1,10 +1,10 @@ import { isEmpty } from "@ember/utils"; -import InputValidation from "discourse/models/input-validation"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; +import EmberObject from "@ember/object"; export default Mixin.create({ - @computed() + @discourseComputed() nameInstructions() { return I18n.t( this.siteSettings.full_name_required @@ -14,12 +14,12 @@ export default Mixin.create({ }, // Validate the name. - @computed("accountName") + @discourseComputed("accountName") nameValidation() { if (this.siteSettings.full_name_required && isEmpty(this.accountName)) { - return InputValidation.create({ failed: true }); + return EmberObject.create({ failed: true }); } - return InputValidation.create({ ok: true }); + return EmberObject.create({ ok: true }); } }); diff --git a/app/assets/javascripts/discourse/mixins/password-validation.js.es6 b/app/assets/javascripts/discourse/mixins/password-validation.js.es6 index 3ddf9131f0..b313368f52 100644 --- a/app/assets/javascripts/discourse/mixins/password-validation.js.es6 +++ b/app/assets/javascripts/discourse/mixins/password-validation.js.es6 @@ -1,7 +1,7 @@ import { isEmpty } from "@ember/utils"; -import InputValidation from "discourse/models/input-validation"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; +import EmberObject from "@ember/object"; export default Mixin.create({ rejectedPasswords: null, @@ -12,21 +12,21 @@ export default Mixin.create({ this.set("rejectedPasswordsMessages", new Map()); }, - @computed("passwordMinLength") + @discourseComputed("passwordMinLength") passwordInstructions() { return I18n.t("user.password.instructions", { count: this.passwordMinLength }); }, - @computed("isDeveloper", "admin") + @discourseComputed("isDeveloper", "admin") passwordMinLength(isDeveloper, admin) { return isDeveloper || admin ? this.siteSettings.min_admin_password_length : this.siteSettings.min_password_length; }, - @computed( + @discourseComputed( "accountPassword", "passwordRequired", "rejectedPasswords.[]", @@ -43,11 +43,11 @@ export default Mixin.create({ passwordMinLength ) { if (!passwordRequired) { - return InputValidation.create({ ok: true }); + return EmberObject.create({ ok: true }); } if (rejectedPasswords.includes(password)) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: this.rejectedPasswordsMessages.get(password) || @@ -57,33 +57,33 @@ export default Mixin.create({ // If blank, fail without a reason if (isEmpty(password)) { - return InputValidation.create({ failed: true }); + return EmberObject.create({ failed: true }); } // If too short if (password.length < passwordMinLength) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.password.too_short") }); } if (!isEmpty(accountUsername) && password === accountUsername) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.password.same_as_username") }); } if (!isEmpty(accountEmail) && password === accountEmail) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.password.same_as_email") }); } // Looks good! - return InputValidation.create({ + return EmberObject.create({ ok: true, reason: I18n.t("user.password.ok") }); diff --git a/app/assets/javascripts/discourse/mixins/preferences-tab-controller.js.es6 b/app/assets/javascripts/discourse/mixins/preferences-tab-controller.js.es6 index 7e49326f41..2e8763097c 100644 --- a/app/assets/javascripts/discourse/mixins/preferences-tab-controller.js.es6 +++ b/app/assets/javascripts/discourse/mixins/preferences-tab-controller.js.es6 @@ -1,10 +1,10 @@ -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; export default Mixin.create({ saved: false, - @computed("model.isSaving") + @discourseComputed("model.isSaving") saveButtonText(isSaving) { return isSaving ? I18n.t("saving") : I18n.t("save"); } diff --git a/app/assets/javascripts/discourse/mixins/scroll-top.js.es6 b/app/assets/javascripts/discourse/mixins/scroll-top.js.es6 index 82ae1c0479..d636c2333f 100644 --- a/app/assets/javascripts/discourse/mixins/scroll-top.js.es6 +++ b/app/assets/javascripts/discourse/mixins/scroll-top.js.es6 @@ -2,10 +2,11 @@ import { scheduleOnce } from "@ember/runloop"; import DiscourseURL from "discourse/lib/url"; import { deprecated } from "discourse/mixins/scroll-top"; import Mixin from "@ember/object/mixin"; +import ENV from "discourse-common/config/environment"; const context = { _scrollTop() { - if (Ember.testing) { + if (ENV.environment === "test") { return; } $(document).scrollTop(0); diff --git a/app/assets/javascripts/discourse/mixins/scrolling.js.es6 b/app/assets/javascripts/discourse/mixins/scrolling.js.es6 index b690c77de4..2a15df47eb 100644 --- a/app/assets/javascripts/discourse/mixins/scrolling.js.es6 +++ b/app/assets/javascripts/discourse/mixins/scrolling.js.es6 @@ -1,5 +1,5 @@ import { scheduleOnce } from "@ember/runloop"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import Mixin from "@ember/object/mixin"; /** @@ -43,7 +43,7 @@ const Scrolling = Mixin.create({ }; if (opts.debounce) { - onScrollMethod = debounce(onScrollMethod, opts.debounce); + onScrollMethod = discourseDebounce(onScrollMethod, opts.debounce); } ScrollingDOMMethods.bindOnScroll(onScrollMethod, opts.name); diff --git a/app/assets/javascripts/discourse/mixins/upload.js.es6 b/app/assets/javascripts/discourse/mixins/upload.js.es6 index a3b0d8626a..820e2e912a 100644 --- a/app/assets/javascripts/discourse/mixins/upload.js.es6 +++ b/app/assets/javascripts/discourse/mixins/upload.js.es6 @@ -1,8 +1,7 @@ import { displayErrorForUpload, validateUploadedFiles -} from "discourse/lib/utilities"; - +} from "discourse/lib/uploads"; import getUrl from "discourse-common/lib/get-url"; import { on } from "@ember/object/evented"; import Mixin from "@ember/object/mixin"; @@ -79,7 +78,7 @@ export default Mixin.create({ $upload.on("fileuploadsubmit", (e, data) => { const opts = _.merge( - { bypassNewUserRestriction: true }, + { bypassNewUserRestriction: true, user: this.currentUser }, this.validateUploadedFilesOptions() ); const isValid = validateUploadedFiles(data.files, opts); diff --git a/app/assets/javascripts/discourse/mixins/user-fields-validation.js.es6 b/app/assets/javascripts/discourse/mixins/user-fields-validation.js.es6 index ec007c993d..782ceca03d 100644 --- a/app/assets/javascripts/discourse/mixins/user-fields-validation.js.es6 +++ b/app/assets/javascripts/discourse/mixins/user-fields-validation.js.es6 @@ -1,10 +1,9 @@ import { isEmpty } from "@ember/utils"; import EmberObject from "@ember/object"; -import InputValidation from "discourse/models/input-validation"; import { on, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; export default Mixin.create({ @@ -24,7 +23,7 @@ export default Mixin.create({ }, // Validate required fields - @computed("userFields.@each.value") + @discourseComputed("userFields.@each.value") userFieldsValidation() { let userFields = this.userFields; if (userFields) { @@ -36,9 +35,9 @@ export default Mixin.create({ return !val || isEmpty(val); }); if (anyEmpty) { - return InputValidation.create({ failed: true }); + return EmberObject.create({ failed: true }); } } - return InputValidation.create({ ok: true }); + return EmberObject.create({ ok: true }); } }); diff --git a/app/assets/javascripts/discourse/mixins/username-validation.js.es6 b/app/assets/javascripts/discourse/mixins/username-validation.js.es6 index 66e86221a9..3e18f815c5 100644 --- a/app/assets/javascripts/discourse/mixins/username-validation.js.es6 +++ b/app/assets/javascripts/discourse/mixins/username-validation.js.es6 @@ -1,9 +1,10 @@ import { isEmpty } from "@ember/utils"; -import InputValidation from "discourse/models/input-validation"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; import { setting } from "discourse/lib/computed"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; +import EmberObject from "@ember/object"; +import User from "discourse/models/user"; export default Mixin.create({ uniqueUsernameValidation: null, @@ -12,8 +13,8 @@ export default Mixin.create({ minUsernameLength: setting("min_username_length"), - fetchExistingUsername: debounce(function() { - Discourse.User.checkUsername(null, this.accountEmail).then(result => { + fetchExistingUsername: discourseDebounce(function() { + User.checkUsername(null, this.accountEmail).then(result => { if ( result.suggestion && (isEmpty(this.accountUsername) || @@ -27,12 +28,12 @@ export default Mixin.create({ }); }, 500), - @computed("accountUsername") + @discourseComputed("accountUsername") basicUsernameValidation(accountUsername) { this.set("uniqueUsernameValidation", null); if (accountUsername && accountUsername === this.prefilledUsername) { - return InputValidation.create({ + return EmberObject.create({ ok: true, reason: I18n.t("user.username.prefilled") }); @@ -40,12 +41,12 @@ export default Mixin.create({ // If blank, fail without a reason if (isEmpty(accountUsername)) { - return InputValidation.create({ failed: true }); + return EmberObject.create({ failed: true }); } // If too short if (accountUsername.length < this.siteSettings.min_username_length) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.username.too_short") }); @@ -53,7 +54,7 @@ export default Mixin.create({ // If too long if (accountUsername.length > this.maxUsernameLength) { - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.username.too_long") }); @@ -61,7 +62,7 @@ export default Mixin.create({ this.checkUsernameAvailability(); // Let's check it out asynchronously - return InputValidation.create({ + return EmberObject.create({ failed: true, reason: I18n.t("user.username.checking") }); @@ -74,51 +75,50 @@ export default Mixin.create({ ); }, - checkUsernameAvailability: debounce(function() { + checkUsernameAvailability: discourseDebounce(function() { if (this.shouldCheckUsernameAvailability()) { - return Discourse.User.checkUsername( - this.accountUsername, - this.accountEmail - ).then(result => { - this.set("isDeveloper", false); - if (result.available) { - if (result.is_developer) { - this.set("isDeveloper", true); - } - return this.set( - "uniqueUsernameValidation", - InputValidation.create({ - ok: true, - reason: I18n.t("user.username.available") - }) - ); - } else { - if (result.suggestion) { + return User.checkUsername(this.accountUsername, this.accountEmail).then( + result => { + this.set("isDeveloper", false); + if (result.available) { + if (result.is_developer) { + this.set("isDeveloper", true); + } return this.set( "uniqueUsernameValidation", - InputValidation.create({ - failed: true, - reason: I18n.t("user.username.not_available", result) + EmberObject.create({ + ok: true, + reason: I18n.t("user.username.available") }) ); } else { - return this.set( - "uniqueUsernameValidation", - InputValidation.create({ - failed: true, - reason: result.errors - ? result.errors.join(" ") - : I18n.t("user.username.not_available_no_suggestion") - }) - ); + if (result.suggestion) { + return this.set( + "uniqueUsernameValidation", + EmberObject.create({ + failed: true, + reason: I18n.t("user.username.not_available", result) + }) + ); + } else { + return this.set( + "uniqueUsernameValidation", + EmberObject.create({ + failed: true, + reason: result.errors + ? result.errors.join(" ") + : I18n.t("user.username.not_available_no_suggestion") + }) + ); + } } } - }); + ); } }, 500), // Actually wait for the async name check before we're 100% sure we're good to go - @computed("uniqueUsernameValidation", "basicUsernameValidation") + @discourseComputed("uniqueUsernameValidation", "basicUsernameValidation") usernameValidation() { const basicValidation = this.basicUsernameValidation; const uniqueUsername = this.uniqueUsernameValidation; diff --git a/app/assets/javascripts/discourse/models/action-summary.js.es6 b/app/assets/javascripts/discourse/models/action-summary.js.es6 index ce8259b9c0..aad8b48b8a 100644 --- a/app/assets/javascripts/discourse/models/action-summary.js.es6 +++ b/app/assets/javascripts/discourse/models/action-summary.js.es6 @@ -60,10 +60,12 @@ export default RestModel.extend({ post.updateActionsSummary(data.result); } const remaining = parseInt( - data.xhr.getResponseHeader("Discourse-Actions-Remaining") || 0 + data.xhr.getResponseHeader("Discourse-Actions-Remaining") || 0, + 10 ); const max = parseInt( - data.xhr.getResponseHeader("Discourse-Actions-Max") || 0 + data.xhr.getResponseHeader("Discourse-Actions-Max") || 0, + 10 ); return { acted: true, remaining, max }; }) diff --git a/app/assets/javascripts/discourse/models/badge-grouping.js.es6 b/app/assets/javascripts/discourse/models/badge-grouping.js.es6 index 573dff223d..6dbaa5c7c6 100644 --- a/app/assets/javascripts/discourse/models/badge-grouping.js.es6 +++ b/app/assets/javascripts/discourse/models/badge-grouping.js.es6 @@ -1,13 +1,13 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; export default RestModel.extend({ - @computed("name") + @discourseComputed("name") i18nNameKey() { return this.name.toLowerCase().replace(/\s/g, "_"); }, - @computed("name") + @discourseComputed("name") displayName() { const i18nKey = `badges.badge_grouping.${this.i18nNameKey}.name`; return I18n.t(i18nKey, { defaultValue: this.name }); diff --git a/app/assets/javascripts/discourse/models/badge.js.es6 b/app/assets/javascripts/discourse/models/badge.js.es6 index 88203b5084..5b9e46cef9 100644 --- a/app/assets/javascripts/discourse/models/badge.js.es6 +++ b/app/assets/javascripts/discourse/models/badge.js.es6 @@ -1,15 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { none } from "@ember/object/computed"; import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import BadgeGrouping from "discourse/models/badge-grouping"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; import { Promise } from "rsvp"; const Badge = RestModel.extend({ newBadge: none("id"), - @computed + @discourseComputed url() { return Discourse.getURL(`/badges/${this.id}/${this.slug}`); }, @@ -27,7 +27,7 @@ const Badge = RestModel.extend({ } }, - @computed("badge_type.name") + @discourseComputed("badge_type.name") badgeTypeClassName(type) { type = type || ""; return `badge-type-${type.toLowerCase()}`; diff --git a/app/assets/javascripts/discourse/models/category-list.js.es6 b/app/assets/javascripts/discourse/models/category-list.js.es6 index aff72e7fa3..b8437f706d 100644 --- a/app/assets/javascripts/discourse/models/category-list.js.es6 +++ b/app/assets/javascripts/discourse/models/category-list.js.es6 @@ -1,5 +1,8 @@ import PreloadStore from "preload-store"; import { ajax } from "discourse/lib/ajax"; +import Topic from "discourse/models/topic"; +import Category from "discourse/models/category"; +import Site from "discourse/models/site"; const CategoryList = Ember.ArrayProxy.extend({ init() { @@ -11,7 +14,7 @@ const CategoryList = Ember.ArrayProxy.extend({ CategoryList.reopenClass({ categoriesFrom(store, result) { const categories = CategoryList.create(); - const list = Discourse.Category.list(); + const list = Category.list(); let statPeriod = "all"; const minCategories = result.category_list.categories.length * 0.66; @@ -39,7 +42,7 @@ CategoryList.reopenClass({ if (c.topics) { c.topics = c.topics.map(t => { - const topic = Discourse.Topic.create(t); + const topic = Topic.create(t); topic.set("category", c); return topic; }); @@ -74,7 +77,9 @@ CategoryList.reopenClass({ break; } - categories.pushObject(store.createRecord("category", c)); + const record = Site.current().updateCategory(c); + record.setupGroupsAndPermissions(); + categories.pushObject(record); }); return categories; }, diff --git a/app/assets/javascripts/discourse/models/category.js.es6 b/app/assets/javascripts/discourse/models/category.js.es6 index 4248f6a8f8..9bb56c276e 100644 --- a/app/assets/javascripts/discourse/models/category.js.es6 +++ b/app/assets/javascripts/discourse/models/category.js.es6 @@ -1,10 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { get } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import PermissionType from "discourse/models/permission-type"; import { NotificationLevels } from "discourse/lib/notification-levels"; +import deprecated from "discourse-common/lib/deprecated"; +import Site from "discourse/models/site"; const Category = RestModel.extend({ permissions: null, @@ -39,7 +41,7 @@ const Category = RestModel.extend({ } }, - @computed + @discourseComputed availablePermissions() { return [ PermissionType.create({ id: PermissionType.FULL }), @@ -48,52 +50,62 @@ const Category = RestModel.extend({ ]; }, - @computed("id") + @discourseComputed("id") searchContext(id) { return { type: "category", id, category: this }; }, - @computed("notification_level") + @discourseComputed("parentCategory.ancestors") + ancestors(parentAncestors) { + return [...(parentAncestors || []), this]; + }, + + @discourseComputed("parentCategory.level") + level(parentLevel) { + return (parentLevel || -1) + 1; + }, + + @discourseComputed("notification_level") isMuted(notificationLevel) { return notificationLevel === NotificationLevels.MUTED; }, - @computed("name") + @discourseComputed("name") url() { - return Discourse.getURL("/c/") + Category.slugFor(this); + return Discourse.getURL(`/c/${Category.slugFor(this)}/${this.id}`); }, - @computed + @discourseComputed fullSlug() { return Category.slugFor(this).replace(/\//g, "-"); }, - @computed("name") + @discourseComputed("name") nameLower(name) { return name.toLowerCase(); }, - @computed("url") + @discourseComputed("url") unreadUrl(url) { return `${url}/l/unread`; }, - @computed("url") + @discourseComputed("url") newUrl(url) { return `${url}/l/new`; }, - @computed("color", "text_color") + @discourseComputed("color", "text_color") style(color, textColor) { return `background-color: #${color}; color: #${textColor}`; }, - @computed("topic_count") + @discourseComputed("topic_count") moreTopics(topicCount) { return topicCount > (this.num_featured_topics || 2); }, - @computed("topic_count", "subcategories") + @discourseComputed("topic_count", "subcategories") totalTopicCount(topicCount, subcats) { let count = topicCount; if (subcats) { @@ -130,7 +142,6 @@ const Category = RestModel.extend({ allow_badges: this.allow_badges, custom_fields: this.custom_fields, topic_template: this.topic_template, - suppress_from_latest: this.suppress_from_latest, all_topics_wiki: this.all_topics_wiki, allowed_tags: this.allowed_tags, allowed_tag_groups: this.allowed_tag_groups, @@ -181,26 +192,26 @@ const Category = RestModel.extend({ this.availableGroups.addObject(permission.group_name); }, - @computed("topics") + @discourseComputed("topics") latestTopic(topics) { if (topics && topics.length) { return topics[0]; } }, - @computed("topics") + @discourseComputed("topics") featuredTopics(topics) { if (topics && topics.length) { return topics.slice(0, this.num_featured_topics || 2); } }, - @computed("id", "topicTrackingState.messageCount") + @discourseComputed("id", "topicTrackingState.messageCount") unreadTopics(id) { return this.topicTrackingState.countUnread(id); }, - @computed("id", "topicTrackingState.messageCount") + @discourseComputed("id", "topicTrackingState.messageCount") newTopics(id) { return this.topicTrackingState.countNew(id); }, @@ -211,9 +222,9 @@ const Category = RestModel.extend({ return ajax(url, { data: { notification_level }, type: "POST" }); }, - @computed("id") + @discourseComputed("id") isUncategorizedCategory(id) { - return id === Discourse.Site.currentProp("uncategorized_category_id"); + return id === Site.currentProp("uncategorized_category_id"); } }); @@ -225,7 +236,7 @@ Category.reopenClass({ _uncategorized || Category.list().findBy( "id", - Discourse.Site.currentProp("uncategorized_category_id") + Site.currentProp("uncategorized_category_id") ); return _uncategorized; }, @@ -249,15 +260,15 @@ Category.reopenClass({ }, list() { - return Discourse.Site.currentProp("categoriesList"); + return Site.currentProp("categoriesList"); }, listByActivity() { - return Discourse.Site.currentProp("sortedCategories"); + return Site.currentProp("sortedCategories"); }, - idMap() { - return Discourse.Site.currentProp("categoriesById"); + _idMap() { + return Site.currentProp("categoriesById"); }, findSingleBySlug(slug) { @@ -272,7 +283,7 @@ Category.reopenClass({ if (!id) { return; } - return Category.idMap()[id]; + return Category._idMap()[id]; }, findByIds(ids = []) { @@ -286,6 +297,58 @@ Category.reopenClass({ return categories; }, + findBySlugAndParent(slug, parentCategory) { + return Category.list().find(category => { + if (Discourse.SiteSettings.slug_generation_method === "encoded") { + slug = encodeURI(slug); + } + + return ( + category.slug === slug && + (category.parentCategory || null) === parentCategory + ); + }); + }, + + findBySlugPath(slugPath) { + let category = null; + + for (const slug of slugPath) { + category = this.findBySlugAndParent(slug, category); + + if (!category) { + return null; + } + } + + return category; + }, + + findBySlugPathWithID(slugPathWithID) { + const parts = slugPathWithID.split("/"); + let category = null; + + if (parts.length > 0 && parts[parts.length - 1].match(/^\d+$/)) { + const id = parseInt(parts.pop(), 10); + + category = Category.findById(id); + } else { + category = Category.findBySlugPath(parts); + + if ( + !category && + parts.length > 0 && + parts[parts.length - 1].match(/^\d+-/) + ) { + const id = parseInt(parts.pop(), 10); + + category = Category.findById(id); + } + } + + return category; + }, + findBySlug(slug, parentSlug) { const categories = Category.list(); let category; @@ -407,4 +470,14 @@ Category.reopenClass({ } }); +Object.defineProperty(Discourse, "Category", { + get() { + deprecated( + "Import the Category class instead of using Discourse.Category", + { since: "2.4.0", dropFrom: "2.5.0" } + ); + return Category; + } +}); + export default Category; diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 8e3a1af259..a630f521c4 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -10,15 +10,17 @@ import { throwAjaxError } from "discourse/lib/ajax-error"; import Quote from "discourse/lib/quote"; import Draft from "discourse/models/draft"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { escapeExpression, tinyAvatar } from "discourse/lib/utilities"; import { propertyNotEqual } from "discourse/lib/computed"; -import throttle from "discourse/lib/throttle"; +import { throttle } from "@ember/runloop"; import { Promise } from "rsvp"; import { set } from "@ember/object"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; // The actions the composer can take export const CREATE_TOPIC = "createTopic", @@ -55,7 +57,8 @@ const CLOSED = "closed", tags: "tags", featured_link: "featuredLink", shared_draft: "sharedDraft", - no_bump: "noBump" + no_bump: "noBump", + draft_key: "draftKey" }, _edit_topic_serializer = { title: "topic.title", @@ -108,7 +111,7 @@ const Composer = RestModel.extend({ sharedDraft: equal("action", CREATE_SHARED_DRAFT), - @computed + @discourseComputed categoryId: { get() { return this._categoryId; @@ -132,12 +135,12 @@ const Composer = RestModel.extend({ } }, - @computed("categoryId") + @discourseComputed("categoryId") category(categoryId) { return categoryId ? this.site.categories.findBy("id", categoryId) : null; }, - @computed("category") + @discourseComputed("category") minimumRequiredTags(category) { return category && category.minimum_required_tags > 0 ? category.minimum_required_tags @@ -150,18 +153,18 @@ const Composer = RestModel.extend({ notCreatingPrivateMessage: not("creatingPrivateMessage"), notPrivateMessage: not("privateMessage"), - @computed("editingPost", "topic.details.can_edit") + @discourseComputed("editingPost", "topic.details.can_edit") disableTitleInput(editingPost, canEditTopic) { return editingPost && !canEditTopic; }, - @computed("privateMessage", "archetype.hasOptions") + @discourseComputed("privateMessage", "archetype.hasOptions") showCategoryChooser(isPrivateMessage, hasOptions) { const manyCategories = this.site.categories.length > 1; return !isPrivateMessage && (hasOptions || manyCategories); }, - @computed("creatingPrivateMessage", "topic") + @discourseComputed("creatingPrivateMessage", "topic") privateMessage(creatingPrivateMessage, topic) { return ( creatingPrivateMessage || (topic && topic.archetype === "private_message") @@ -170,7 +173,7 @@ const Composer = RestModel.extend({ topicFirstPost: or("creatingTopic", "editingFirstPost"), - @computed("action") + @discourseComputed("action") editingPost: isEdit, replyingToTopic: equal("action", REPLY), @@ -202,7 +205,7 @@ const Composer = RestModel.extend({ } }, - @computed + @discourseComputed composerTime: { get() { let total = this.composerTotalOpened || 0; @@ -216,7 +219,7 @@ const Composer = RestModel.extend({ } }, - @computed("archetypeId") + @discourseComputed("archetypeId") archetype(archetypeId) { return this.archetypes.findBy("id", archetypeId); }, @@ -226,15 +229,18 @@ const Composer = RestModel.extend({ return this.set("metaData", EmberObject.create()); }, - // view detected user is typing - typing: throttle( - function() { - const typingTime = this.typingTime || 0; - this.set("typingTime", typingTime + 100); - }, - 100, - false - ), + // called whenever the user types to update the typing time + typing() { + throttle( + this, + function() { + const typingTime = this.typingTime || 0; + this.set("typingTime", typingTime + 100); + }, + 100, + false + ); + }, editingFirstPost: and("editingPost", "post.firstPost"), @@ -251,7 +257,7 @@ const Composer = RestModel.extend({ "notPrivateMessage" ), - @computed("canEditTitle", "creatingPrivateMessage", "categoryId") + @discourseComputed("canEditTitle", "creatingPrivateMessage", "categoryId") canEditTopicFeaturedLink(canEditTitle, creatingPrivateMessage, categoryId) { if ( !this.siteSettings.topic_featured_link_enabled || @@ -277,14 +283,14 @@ const Composer = RestModel.extend({ ); }, - @computed("canEditTopicFeaturedLink") + @discourseComputed("canEditTopicFeaturedLink") titlePlaceholder(canEditTopicFeaturedLink) { return canEditTopicFeaturedLink ? "composer.title_or_link_placeholder" : "composer.title_placeholder"; }, - @computed("action", "post", "topic", "topic.title") + @discourseComputed("action", "post", "topic", "topic.title") replyOptions(action, post, topic, topicTitle) { const options = { userLink: null, @@ -334,7 +340,7 @@ const Composer = RestModel.extend({ return options; }, - @computed( + @discourseComputed( "loading", "canEditTitle", "titleLength", @@ -405,7 +411,7 @@ const Composer = RestModel.extend({ } }, - @computed("canCategorize", "categoryId") + @discourseComputed("canCategorize", "categoryId") requiredCategoryMissing(canCategorize, categoryId) { return ( canCategorize && @@ -414,14 +420,14 @@ const Composer = RestModel.extend({ ); }, - @computed("minimumTitleLength", "titleLength", "post.static_doc") + @discourseComputed("minimumTitleLength", "titleLength", "post.static_doc") titleLengthValid(minTitleLength, titleLength, staticDoc) { if (this.user.admin && staticDoc && titleLength > 0) return true; if (titleLength < minTitleLength) return false; return titleLength <= this.siteSettings.max_topic_title_length; }, - @computed("metaData") + @discourseComputed("metaData") hasMetaData(metaData) { return metaData ? isEmpty(Ember.keys(metaData)) : false; }, @@ -430,12 +436,12 @@ const Composer = RestModel.extend({ titleDirty: propertyNotEqual("title", "originalTitle"), - @computed("minimumTitleLength", "titleLength") + @discourseComputed("minimumTitleLength", "titleLength") missingTitleCharacters(minimumTitleLength, titleLength) { return minimumTitleLength - titleLength; }, - @computed("privateMessage") + @discourseComputed("privateMessage") minimumTitleLength(privateMessage) { if (privateMessage) { return this.siteSettings.min_personal_message_title_length; @@ -444,7 +450,11 @@ const Composer = RestModel.extend({ } }, - @computed("minimumPostLength", "replyLength", "canEditTopicFeaturedLink") + @discourseComputed( + "minimumPostLength", + "replyLength", + "canEditTopicFeaturedLink" + ) missingReplyCharacters( minimumPostLength, replyLength, @@ -459,7 +469,11 @@ const Composer = RestModel.extend({ return minimumPostLength - replyLength; }, - @computed("privateMessage", "topicFirstPost", "topic.pm_with_non_human_user") + @discourseComputed( + "privateMessage", + "topicFirstPost", + "topic.pm_with_non_human_user" + ) minimumPostLength(privateMessage, topicFirstPost, pmWithNonHumanUser) { if (pmWithNonHumanUser) { return 1; @@ -473,13 +487,13 @@ const Composer = RestModel.extend({ } }, - @computed("title") + @discourseComputed("title") titleLength(title) { title = title || ""; return title.replace(/\s+/gim, " ").trim().length; }, - @computed("reply") + @discourseComputed("reply") replyLength(reply) { reply = reply || ""; @@ -639,10 +653,7 @@ const Composer = RestModel.extend({ const replyBlank = isEmpty(this.reply); const composer = this; - if ( - !replyBlank && - ((opts.reply || isEdit(opts.action)) && this.replyDirty) - ) { + if (!replyBlank && (opts.reply || isEdit(opts.action)) && this.replyDirty) { return; } @@ -1134,8 +1145,8 @@ Composer.reopenClass({ // TODO: Replace with injection create(args) { args = args || {}; - args.user = args.user || Discourse.User.current(); - args.site = args.site || Discourse.Site.current(); + args.user = args.user || User.current(); + args.site = args.site || Site.current(); args.siteSettings = args.siteSettings || Discourse.SiteSettings; return this._super(args); }, diff --git a/app/assets/javascripts/discourse/models/draft.js.es6 b/app/assets/javascripts/discourse/models/draft.js.es6 index 62fefef8c7..178c38f94a 100644 --- a/app/assets/javascripts/discourse/models/draft.js.es6 +++ b/app/assets/javascripts/discourse/models/draft.js.es6 @@ -1,5 +1,7 @@ import { ajax } from "discourse/lib/ajax"; -const Draft = Discourse.Model.extend(); +import EmberObject from "@ember/object"; + +const Draft = EmberObject.extend(); Draft.reopenClass({ clear(key, sequence) { diff --git a/app/assets/javascripts/discourse/models/group-history.js.es6 b/app/assets/javascripts/discourse/models/group-history.js.es6 index 946741ae5d..8c22e9af5f 100644 --- a/app/assets/javascripts/discourse/models/group-history.js.es6 +++ b/app/assets/javascripts/discourse/models/group-history.js.es6 @@ -1,8 +1,8 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; export default RestModel.extend({ - @computed("action") + @discourseComputed("action") actionTitle(action) { return I18n.t(`group_histories.actions.${action}`); } diff --git a/app/assets/javascripts/discourse/models/group.js.es6 b/app/assets/javascripts/discourse/models/group.js.es6 index b25746471e..284719579a 100644 --- a/app/assets/javascripts/discourse/models/group.js.es6 +++ b/app/assets/javascripts/discourse/models/group.js.es6 @@ -1,85 +1,112 @@ +import EmberObject from "@ember/object"; +import { equal } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; -import { notEmpty, equal } from "@ember/object/computed"; -import { ajax } from "discourse/lib/ajax"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; +import { ajax } from "discourse/lib/ajax"; +import Category from "discourse/models/category"; import GroupHistory from "discourse/models/group-history"; import RestModel from "discourse/models/rest"; -import Category from "discourse/models/category"; -import User from "discourse/models/user"; import Topic from "discourse/models/topic"; -import { popupAjaxError } from "discourse/lib/ajax-error"; -import EmberObject from "@ember/object"; +import User from "discourse/models/user"; +import { Promise } from "rsvp"; const Group = RestModel.extend({ - limit: 50, - offset: 0, user_count: 0, + limit: null, + offset: null, + + request_count: 0, + requestersLimit: null, + requestersOffset: null, init() { this._super(...arguments); - - this.set("owners", []); + this.setProperties({ members: [], requesters: [] }); }, - hasOwners: notEmpty("owners"), - - @computed("automatic_membership_email_domains") + @discourseComputed("automatic_membership_email_domains") emailDomains(value) { return isEmpty(value) ? "" : value; }, - @computed("automatic") + @discourseComputed("automatic") type(automatic) { return automatic ? "automatic" : "custom"; }, - @computed("user_count") - userCountDisplay(userCount) { - // don't display zero its ugly - if (userCount > 0) { - return userCount; + findMembers(params, refresh) { + if (isEmpty(this.name) || !this.can_see_members) { + return Promise.reject(); } + + if (refresh) { + this.setProperties({ limit: null, offset: null }); + } + + params = Object.assign( + { offset: (this.offset || 0) + (this.limit || 0) }, + params + ); + + return Group.loadMembers(this.name, params).then(result => { + const ownerIds = new Set(); + result.owners.forEach(owner => ownerIds.add(owner.id)); + + const members = refresh ? [] : this.members; + members.pushObjects( + result.members.map(member => { + member.owner = ownerIds.has(member.id); + return User.create(member); + }) + ); + + this.setProperties({ + members, + user_count: result.meta.total, + limit: result.meta.limit, + offset: result.meta.offset + }); + }); }, - findMembers(params) { + findRequesters(params, refresh) { if (isEmpty(this.name) || !this.can_see_members) { - return; + return Promise.reject(); } - const offset = Math.min(this.user_count, Math.max(this.offset, 0)); + if (refresh) { + this.setProperties({ requestersOffset: null, requestersLimit: null }); + } - return Group.loadMembers(this.name, offset, this.limit, params).then( - result => { - const ownerIds = {}; - result.owners.forEach(owner => (ownerIds[owner.id] = true)); - - this.setProperties({ - user_count: result.meta.total, - limit: result.meta.limit, - offset: result.meta.offset, - members: result.members.map(member => { - if (ownerIds[member.id]) { - member.owner = true; - } - return User.create(member); - }), - owners: result.owners.map(owner => User.create(owner)) - }); - } + params = Object.assign( + { + offset: (this.requestersOffset || 0) + (this.requestersLimit || 0), + requesters: true + }, + params ); + + return Group.loadMembers(this.name, params).then(result => { + const requesters = refresh ? [] : this.requesters; + requesters.pushObjects(result.members.map(m => User.create(m))); + + this.setProperties({ + requesters, + request_count: result.meta.total, + requestersLimit: result.meta.limit, + requestersOffset: result.meta.offset + }); + }); }, removeOwner(member) { return ajax(`/admin/groups/${this.id}/owners.json`, { type: "DELETE", data: { user_id: member.id } - }).then(() => { - // reload member list - this.findMembers(); - }); + }).then(() => this.findMembers()); }, removeMember(member, params) { @@ -119,19 +146,19 @@ const Group = RestModel.extend({ return this.findMembers({ filter: response.usernames.join(",") }); }, - @computed("display_name", "name") + @discourseComputed("display_name", "name") displayName(groupDisplayName, name) { return groupDisplayName || name; }, - @computed("flair_bg_color") + @discourseComputed("flair_bg_color") flairBackgroundHexColor(flairBgColor) { return flairBgColor ? flairBgColor.replace(new RegExp("[^0-9a-fA-F]", "g"), "") : null; }, - @computed("flair_color") + @discourseComputed("flair_color") flairHexColor(flairColor) { return flairColor ? flairColor.replace(new RegExp("[^0-9a-fA-F]", "g"), "") @@ -140,7 +167,7 @@ const Group = RestModel.extend({ canEveryoneMention: equal("mentionable_level", 99), - @computed("visibility_level") + @discourseComputed("visibility_level") isPrivate(visibilityLevel) { return visibilityLevel > 1; }, @@ -236,7 +263,7 @@ const Group = RestModel.extend({ } if (opts.categoryId) { - data.category_id = parseInt(opts.categoryId); + data.category_id = parseInt(opts.categoryId, 10); } return ajax(`/groups/${this.name}/${type}.json`, { data }).then(posts => { @@ -272,16 +299,8 @@ Group.reopenClass({ ); }, - loadMembers(name, offset, limit, params) { - return ajax(`/groups/${name}/members.json`, { - data: Object.assign( - { - limit: limit || 50, - offset: offset || 0 - }, - params || {} - ) - }); + loadMembers(name, opts) { + return ajax(`/groups/${name}/members.json`, { data: opts }); }, mentionable(name) { @@ -293,9 +312,7 @@ Group.reopenClass({ }, checkName(name) { - return ajax("/groups/check-name", { - data: { group_name: name } - }).catch(popupAjaxError); + return ajax("/groups/check-name", { data: { group_name: name } }); } }); diff --git a/app/assets/javascripts/discourse/models/input-validation.js.es6 b/app/assets/javascripts/discourse/models/input-validation.js.es6 deleted file mode 100644 index fcbd0a1536..0000000000 --- a/app/assets/javascripts/discourse/models/input-validation.js.es6 +++ /dev/null @@ -1,4 +0,0 @@ -import Model from "discourse/models/model"; - -// A trivial model we use to handle input validation -export default Model.extend(); diff --git a/app/assets/javascripts/discourse/models/invite.js.es6 b/app/assets/javascripts/discourse/models/invite.js.es6 index 54baf013f5..7eb142c0ab 100644 --- a/app/assets/javascripts/discourse/models/invite.js.es6 +++ b/app/assets/javascripts/discourse/models/invite.js.es6 @@ -3,8 +3,10 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { userPath } from "discourse/lib/url"; import { Promise } from "rsvp"; +import { isNone } from "@ember/utils"; +import User from "discourse/models/user"; -const Invite = Discourse.Model.extend({ +const Invite = EmberObject.extend({ rescind() { ajax("/invites", { type: "DELETE", @@ -27,7 +29,7 @@ Invite.reopenClass({ create() { const result = this._super.apply(this, arguments); if (result.user) { - result.user = Discourse.User.create(result.user); + result.user = User.create(result.user); } return result; }, @@ -36,8 +38,8 @@ Invite.reopenClass({ if (!user) Promise.resolve(); const data = {}; - if (!Ember.isNone(filter)) data.filter = filter; - if (!Ember.isNone(search)) data.search = search; + if (!isNone(filter)) data.filter = filter; + if (!isNone(search)) data.search = search; data.offset = offset || 0; return ajax(userPath(`${user.username_lower}/invited.json`), { @@ -51,9 +53,9 @@ Invite.reopenClass({ findInvitedCount(user) { if (!user) Promise.resolve(); - return ajax(userPath(`${user.username_lower}/invited_count.json`)).then( - result => EmberObject.create(result.counts) - ); + return ajax( + userPath(`${user.username_lower}/invited_count.json`) + ).then(result => EmberObject.create(result.counts)); }, reinviteAll() { diff --git a/app/assets/javascripts/discourse/models/live-post-counts.es6 b/app/assets/javascripts/discourse/models/live-post-counts.es6 index b28c525f8d..ac5f14d76f 100644 --- a/app/assets/javascripts/discourse/models/live-post-counts.es6 +++ b/app/assets/javascripts/discourse/models/live-post-counts.es6 @@ -1,5 +1,7 @@ import { ajax } from "discourse/lib/ajax"; -const LivePostCounts = Discourse.Model.extend({}); +import EmberObject from "@ember/object"; + +const LivePostCounts = EmberObject.extend({}); LivePostCounts.reopenClass({ find() { diff --git a/app/assets/javascripts/discourse/models/login-method.js.es6 b/app/assets/javascripts/discourse/models/login-method.js.es6 index 0bfddf3951..84b4cf4a52 100644 --- a/app/assets/javascripts/discourse/models/login-method.js.es6 +++ b/app/assets/javascripts/discourse/models/login-method.js.es6 @@ -1,20 +1,22 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; import { updateCsrfToken } from "discourse/lib/ajax"; import { Promise } from "rsvp"; +import Session from "discourse/models/session"; +import Site from "discourse/models/site"; const LoginMethod = EmberObject.extend({ - @computed + @discourseComputed title() { return this.title_override || I18n.t(`login.${this.name}.title`); }, - @computed + @discourseComputed prettyName() { return this.pretty_name_override || I18n.t(`login.${this.name}.name`); }, - @computed + @discourseComputed message() { return this.message_override || I18n.t(`login.${this.name}.message`); }, @@ -52,7 +54,7 @@ LoginMethod.reopenClass({ const input = document.createElement("input"); input.setAttribute("name", "authenticity_token"); - input.setAttribute("value", Discourse.Session.currentProp("csrfToken")); + input.setAttribute("value", Session.currentProp("csrfToken")); form.appendChild(input); document.body.appendChild(form); @@ -69,7 +71,7 @@ export function findAll() { methods = []; - Discourse.Site.currentProp("auth_providers").forEach(provider => + Site.currentProp("auth_providers").forEach(provider => methods.pushObject(LoginMethod.create(provider)) ); diff --git a/app/assets/javascripts/discourse/models/model.js.es6 b/app/assets/javascripts/discourse/models/model.js.es6 deleted file mode 100644 index 5ce0eb2eaf..0000000000 --- a/app/assets/javascripts/discourse/models/model.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -import { isEmpty } from "@ember/utils"; -import EmberObject from "@ember/object"; -const Model = EmberObject.extend(); - -Model.reopenClass({ - extractByKey(collection, klass) { - const retval = {}; - if (isEmpty(collection)) { - return retval; - } - - collection.forEach(function(item) { - retval[item.id] = klass.create(item); - }); - return retval; - } -}); - -export default Model; diff --git a/app/assets/javascripts/discourse/models/nav-item.js.es6 b/app/assets/javascripts/discourse/models/nav-item.js.es6 index 5b36d3b4e4..83330778e0 100644 --- a/app/assets/javascripts/discourse/models/nav-item.js.es6 +++ b/app/assets/javascripts/discourse/models/nav-item.js.es6 @@ -1,27 +1,27 @@ -import { toTitleCase } from "discourse/lib/formatter"; +import discourseComputed from "discourse-common/utils/decorators"; import { emojiUnescape } from "discourse/lib/text"; -import computed from "ember-addons/ember-computed-decorators"; +import Category from "discourse/models/category"; +import EmberObject from "@ember/object"; +import { reads } from "@ember/object/computed"; +import deprecated from "discourse-common/lib/deprecated"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; -const NavItem = Discourse.Model.extend({ - @computed("categoryName", "name") - title(categoryName, name) { +const NavItem = EmberObject.extend({ + @discourseComputed("name") + title(name) { const extra = {}; - if (categoryName) { - name = "category"; - extra.categoryName = categoryName; - } - return I18n.t("filters." + name.replace("/", ".") + ".help", extra); }, - @computed("categoryName", "name", "count") - displayName(categoryName, name, count) { + @discourseComputed("name", "count") + displayName(name, count) { count = count || 0; if ( name === "latest" && - (!Discourse.Site.currentProp("mobileView") || this.tagId !== undefined) + (!Site.currentProp("mobileView") || this.tagId !== undefined) ) { count = 0; } @@ -29,37 +29,13 @@ const NavItem = Discourse.Model.extend({ let extra = { count: count }; const titleKey = count === 0 ? ".title" : ".title_with_count"; - if (categoryName) { - name = "category"; - extra.categoryName = toTitleCase(categoryName); - } - return emojiUnescape( I18n.t(`filters.${name.replace("/", ".") + titleKey}`, extra) ); }, - @computed("name") - categoryName(name) { - const split = name.split("/"); - return split[0] === "category" ? split[1] : null; - }, - - @computed("name") - categorySlug(name) { - const split = name.split("/"); - if (split[0] === "category" && split[1]) { - const cat = Discourse.Site.current().categories.findBy( - "nameLower", - split[1].toLowerCase() - ); - return cat ? Discourse.Category.slugFor(cat) : null; - } - return null; - }, - - @computed("filterMode") - href(filterMode) { + @discourseComputed("filterType", "category", "noSubcategories", "tagId") + href(filterType, category, noSubcategories, tagId) { let customHref = null; NavItem.customNavItemHrefs.forEach(function(cb) { @@ -73,28 +49,27 @@ const NavItem = Discourse.Model.extend({ return customHref; } - return Discourse.getURL("/") + filterMode; + const context = { category, noSubcategories, tagId }; + return NavItem.pathFor(filterType, context); }, - @computed("name", "category", "categorySlug", "noSubcategories") - filterMode(name, category, categorySlug, noSubcategories) { - if (name.split("/")[0] === "category") { - return "c/" + categorySlug; - } else { - let mode = ""; - if (category) { - mode += "c/"; - mode += Discourse.Category.slugFor(category); - if (noSubcategories) { - mode += "/none"; - } - mode += "/l/"; + filterType: reads("name"), + + @discourseComputed("name", "category", "noSubcategories") + filterMode(name, category, noSubcategories) { + let mode = ""; + if (category) { + mode += "c/"; + mode += Category.slugFor(category); + if (noSubcategories) { + mode += "/none"; } - return mode + name.replace(" ", "-"); + mode += "/l/"; } + return mode + name.replace(" ", "-"); }, - @computed("name", "category", "topicTrackingState.messageCount") + @discourseComputed("name", "category", "topicTrackingState.messageCount") count(name, category) { const state = this.topicTrackingState; if (state) { @@ -104,7 +79,7 @@ const NavItem = Discourse.Model.extend({ }); const ExtraNavItem = NavItem.extend({ - href: computed("href", { + href: discourseComputed("href", { get() { if (this._href) { return this._href; @@ -126,43 +101,81 @@ const ExtraNavItem = NavItem.extend({ NavItem.reopenClass({ extraArgsCallbacks: [], customNavItemHrefs: [], - extraNavItems: [], + extraNavItemDescriptors: [], - // create a nav item from the text, will return null if there is not valid nav item for this particular text - fromText(text, opts) { - var split = text.split(","), - name = split[0], - testName = name.split("/")[0], - anonymous = !Discourse.User.current(); + pathFor(filterType, context) { + let path = Discourse.getURL(""); + let includesCategoryContext = false; + let includesTagContext = false; + + if (filterType === "categories") { + path += "/categories"; + return path; + } + + if (context.tagId && Site.currentProp("filters").includes(filterType)) { + includesTagContext = true; + path += "/tags"; + } + + if (context.category) { + includesCategoryContext = true; + path += `/c/${Category.slugFor(context.category)}/${context.category.id}`; + + if (context.noSubcategories) { + path += "/none"; + } + } + + if (includesTagContext) { + path += `/${context.tagId}`; + } + + if (includesTagContext || includesCategoryContext) { + path += "/l"; + } + + path += `/${filterType}`; + + // In the case of top, the nav item doesn't include a period because the + // period has its own selector just below + + return path; + }, + + // Create a nav item given a filterType. It returns null if there is not + // valid nav item. The name is a historical artifact. + fromText(filterType, opts) { + const anonymous = !User.current(); opts = opts || {}; - if ( - anonymous && - !Discourse.Site.currentProp("anonymous_top_menu_items").includes(testName) - ) - return null; + if (anonymous) { + const topMenuItems = Site.currentProp("anonymous_top_menu_items"); + if (!topMenuItems || !topMenuItems.includes(filterType)) { + return null; + } + } - if (!Discourse.Category.list() && testName === "categories") return null; - if (!Discourse.Site.currentProp("top_menu_items").includes(testName)) - return null; + if (!Category.list() && filterType === "categories") return null; + if (!Site.currentProp("top_menu_items").includes(filterType)) return null; - var args = { name: name, hasIcon: name === "unread" }, - extra = null, - self = this; + var args = { name: filterType, hasIcon: filterType === "unread" }; if (opts.category) { args.category = opts.category; } + if (opts.tagId) { + args.tagId = opts.tagId; + } if (opts.persistedQueryParams) { args.persistedQueryParams = opts.persistedQueryParams; } if (opts.noSubcategories) { args.noSubcategories = true; } - NavItem.extraArgsCallbacks.forEach(cb => { - extra = cb.call(self, text, opts); - _.merge(args, extra); - }); + NavItem.extraArgsCallbacks.forEach(cb => + _.merge(args, cb.call(this, filterType, opts)) + ); const store = Discourse.__container__.lookup("service:store"); return store.createRecord("nav-item", args); @@ -177,29 +190,36 @@ NavItem.reopenClass({ let items = Discourse.SiteSettings.top_menu.split("|"); - if ( - args.filterMode && - !items.some(i => i.indexOf(args.filterMode) !== -1) - ) { - items.push(args.filterMode); + const filterType = (args.filterMode || "").split("/").pop(); + + if (!items.some(i => filterType === i)) { + items.push(filterType); } items = items - .map(i => Discourse.NavItem.fromText(i, args)) + .map(i => NavItem.fromText(i, args)) .filter( i => i !== null && !(category && i.get("name").indexOf("categor") === 0) ); - const extraItems = NavItem.extraNavItems.filter(item => { - if (!item.customFilter) return true; - return item.customFilter.call(this, category, args); - }); + const context = { + category: args.category, + tagId: args.tagId, + noSubcategories: args.noSubcategories + }; + + const extraItems = NavItem.extraNavItemDescriptors + .map(descriptor => ExtraNavItem.create(_.merge({}, context, descriptor))) + .filter(item => { + if (!item.customFilter) return true; + return item.customFilter(category, args); + }); let forceActive = false; extraItems.forEach(item => { if (item.init) { - item.init.call(this, item, category, args); + item.init(item, category, args); } const before = item.before; @@ -215,11 +235,11 @@ NavItem.reopenClass({ items.push(item); } - if (!item.customHref) return; + if (item.customHref) { + item.set("href", item.customHref(category, args)); + } - item.set("href", item.customHref.call(this, category, args)); - - if (item.forceActive && item.forceActive.call(this, category, args)) { + if (item.forceActive && item.forceActive(category, args)) { item.active = true; forceActive = true; } else { @@ -249,7 +269,15 @@ export function customNavItemHref(cb) { } export function addNavItem(item) { - const navItem = ExtraNavItem.create(item); - NavItem.extraNavItems.push(navItem); - return navItem; + NavItem.extraNavItemDescriptors.push(item); } + +Object.defineProperty(Discourse, "NavItem", { + get() { + deprecated("Import the NavItem class instead of using Discourse.NavItem", { + since: "2.4.0", + dropFrom: "2.5.0" + }); + return NavItem; + } +}); diff --git a/app/assets/javascripts/discourse/models/permission-type.js.es6 b/app/assets/javascripts/discourse/models/permission-type.js.es6 index 858be5722f..f6ad56c928 100644 --- a/app/assets/javascripts/discourse/models/permission-type.js.es6 +++ b/app/assets/javascripts/discourse/models/permission-type.js.es6 @@ -1,7 +1,8 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; +import EmberObject from "@ember/object"; -const PermissionType = Discourse.Model.extend({ - @computed("id") +const PermissionType = EmberObject.extend({ + @discourseComputed("id") description(id) { var key = ""; diff --git a/app/assets/javascripts/discourse/models/post-stream.js.es6 b/app/assets/javascripts/discourse/models/post-stream.js.es6 index 6baafb909b..614db16332 100644 --- a/app/assets/javascripts/discourse/models/post-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/post-stream.js.es6 @@ -5,9 +5,10 @@ import { ajax } from "discourse/lib/ajax"; import DiscourseURL from "discourse/lib/url"; import RestModel from "discourse/models/rest"; import PostsWithPlaceholders from "discourse/lib/posts-with-placeholders"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import { loadTopicView } from "discourse/models/topic"; import { Promise } from "rsvp"; +import User from "discourse/models/user"; export default RestModel.extend({ _identityMap: null, @@ -50,17 +51,21 @@ export default RestModel.extend({ loading: or("loadingAbove", "loadingBelow", "loadingFilter", "stagingPost"), notLoading: not("loading"), - @computed("isMegaTopic", "stream.length", "topic.highest_post_number") + @discourseComputed( + "isMegaTopic", + "stream.length", + "topic.highest_post_number" + ) filteredPostsCount(isMegaTopic, streamLength, topicHighestPostNumber) { return isMegaTopic ? topicHighestPostNumber : streamLength; }, - @computed("posts.[]") + @discourseComputed("posts.[]") hasPosts() { return this.get("posts.length") > 0; }, - @computed("hasPosts", "filteredPostsCount") + @discourseComputed("hasPosts", "filteredPostsCount") hasLoadedData(hasPosts, filteredPostsCount) { return hasPosts && filteredPostsCount > 0; }, @@ -68,7 +73,7 @@ export default RestModel.extend({ canAppendMore: and("notLoading", "hasPosts", "lastPostNotLoaded"), canPrependMore: and("notLoading", "hasPosts", "firstPostNotLoaded"), - @computed("hasLoadedData", "firstPostId", "posts.[]") + @discourseComputed("hasLoadedData", "firstPostId", "posts.[]") firstPostPresent(hasLoadedData, firstPostId) { if (!hasLoadedData) { return false; @@ -81,17 +86,17 @@ export default RestModel.extend({ firstId: null, lastId: null, - @computed("isMegaTopic", "stream.firstObject", "firstId") + @discourseComputed("isMegaTopic", "stream.firstObject", "firstId") firstPostId(isMegaTopic, streamFirstId, firstId) { return isMegaTopic ? firstId : streamFirstId; }, - @computed("isMegaTopic", "stream.lastObject", "lastId") + @discourseComputed("isMegaTopic", "stream.lastObject", "lastId") lastPostId(isMegaTopic, streamLastId, lastId) { return isMegaTopic ? lastId : streamLastId; }, - @computed("hasLoadedData", "lastPostId", "posts.@each.id") + @discourseComputed("hasLoadedData", "lastPostId", "posts.@each.id") loadedAllPosts(hasLoadedData, lastPostId) { if (!hasLoadedData) { return false; @@ -109,7 +114,7 @@ export default RestModel.extend({ Returns a JS Object of current stream filter options. It should match the query params for the stream. **/ - @computed("summary", "userFilters.[]") + @discourseComputed("summary", "userFilters.[]") streamFilters(summary) { const result = {}; if (summary) { @@ -124,7 +129,7 @@ export default RestModel.extend({ return result; }, - @computed("streamFilters.[]", "topic.posts_count", "posts.length") + @discourseComputed("streamFilters.[]", "topic.posts_count", "posts.length") hasNoFilters() { const streamFilters = this.streamFilters; return !( @@ -137,7 +142,7 @@ export default RestModel.extend({ Returns the window of posts above the current set in the stream, bound to the top of the stream. This is the collection we'll ask for when scrolling upwards. **/ - @computed("posts.[]", "stream.[]") + @discourseComputed("posts.[]", "stream.[]") previousWindow() { // If we can't find the last post loaded, bail const firstPost = _.first(this.posts); @@ -163,7 +168,7 @@ export default RestModel.extend({ Returns the window of posts below the current set in the stream, bound by the bottom of the stream. This is the collection we use when scrolling downwards. **/ - @computed("posts.lastObject", "stream.[]") + @discourseComputed("posts.lastObject", "stream.[]") nextWindow(lastLoadedPost) { // If we can't find the last post loaded, bail if (!lastLoadedPost) { @@ -601,8 +606,7 @@ export default RestModel.extend({ return this.findPostsByIds([postId]) .then(posts => { const ignoredUsers = - Discourse.User.current() && - Discourse.User.current().get("ignored_users"); + User.current() && User.current().get("ignored_users"); posts.forEach(p => { if (ignoredUsers && ignoredUsers.includes(p.username)) { this.stream.removeObject(postId); diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6 index 4fbfcdeea0..5bb020e444 100644 --- a/app/assets/javascripts/discourse/models/post.js.es6 +++ b/app/assets/javascripts/discourse/models/post.js.es6 @@ -1,4 +1,5 @@ -import { get } from "@ember/object"; +import discourseComputed from "discourse-common/utils/decorators"; +import { computed, get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { equal, and, or, not } from "@ember/object/computed"; import EmberObject from "@ember/object"; @@ -8,16 +9,17 @@ import { popupAjaxError } from "discourse/lib/ajax-error"; import ActionSummary from "discourse/models/action-summary"; import { propertyEqual } from "discourse/lib/computed"; import Quote from "discourse/lib/quote"; -import computed from "ember-addons/ember-computed-decorators"; import { postUrl } from "discourse/lib/utilities"; import { cookAsync } from "discourse/lib/text"; import { userPath } from "discourse/lib/url"; import Composer from "discourse/models/composer"; import { Promise } from "rsvp"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; const Post = RestModel.extend({ // TODO: Remove this once one instantiate all `Discourse.Post` models via the store. - siteSettings: Ember.computed({ + siteSettings: computed({ get() { return Discourse.SiteSettings; }, @@ -28,9 +30,9 @@ const Post = RestModel.extend({ } }), - @computed("url") + @discourseComputed("url") shareUrl(url) { - const user = Discourse.User.current(); + const user = User.current(); const userSuffix = user ? `?u=${user.username_lower}` : ""; if (this.firstPost) { @@ -48,24 +50,24 @@ const Post = RestModel.extend({ deleted: or("deleted_at", "deletedViaTopic"), notDeleted: not("deleted"), - @computed("name", "username") + @discourseComputed("name", "username") showName(name, username) { return ( name && name !== username && Discourse.SiteSettings.display_name_on_posts ); }, - @computed("firstPost", "deleted_by", "topic.deleted_by") + @discourseComputed("firstPost", "deleted_by", "topic.deleted_by") postDeletedBy(firstPost, deletedBy, topicDeletedBy) { return firstPost ? topicDeletedBy : deletedBy; }, - @computed("firstPost", "deleted_at", "topic.deleted_at") + @discourseComputed("firstPost", "deleted_at", "topic.deleted_at") postDeletedAt(firstPost, deletedAt, topicDeletedAt) { return firstPost ? topicDeletedAt : deletedAt; }, - @computed("post_number", "topic_id", "topic.slug") + @discourseComputed("post_number", "topic_id", "topic.slug") url(post_number, topic_id, topicSlug) { return postUrl( topicSlug || this.topic_slug, @@ -75,12 +77,12 @@ const Post = RestModel.extend({ }, // Don't drop the /1 - @computed("post_number", "url") + @discourseComputed("post_number", "url") urlWithNumber(postNumber, baseUrl) { return postNumber === 1 ? `${baseUrl}/1` : baseUrl; }, - @computed("username") + @discourseComputed("username") usernameUrl: userPath, topicOwner: propertyEqual("topic.details.created_by.id", "user_id"), @@ -94,14 +96,14 @@ const Post = RestModel.extend({ .catch(popupAjaxError); }, - @computed("link_counts.@each.internal") + @discourseComputed("link_counts.@each.internal") internalLinks() { if (isEmpty(this.link_counts)) return null; return this.link_counts.filterBy("internal").filterBy("title"); }, - @computed("actions_summary.@each.can_act") + @discourseComputed("actions_summary.@each.can_act") flagsAvailable() { // TODO: Investigate why `this.site` is sometimes null when running // Search - Search with context @@ -320,7 +322,7 @@ const Post = RestModel.extend({ // need to wait to hear back from server (stuff may not be loaded) - return Discourse.Post.updateBookmark(this.id, this.bookmarked) + return Post.updateBookmark(this.id, this.bookmarked) .then(result => { this.set("topic.bookmarked", result.topic_bookmarked); this.appEvents.trigger("page:bookmark-post-toggled", this); @@ -355,7 +357,7 @@ Post.reopenClass({ // this area should be optimized, it is creating way too many objects per post json.actions_summary = json.actions_summary.map(a => { - a.actionType = Discourse.Site.current().postActionTypeById(a.id); + a.actionType = Site.current().postActionTypeById(a.id); a.count = a.count || 0; const actionSummary = ActionSummary.create(a); lookup[a.actionType.name_key] = actionSummary; @@ -370,7 +372,7 @@ Post.reopenClass({ } if (json && json.reply_to_user) { - json.reply_to_user = Discourse.User.create(json.reply_to_user); + json.reply_to_user = User.create(json.reply_to_user); } return json; @@ -417,7 +419,7 @@ Post.reopenClass({ loadQuote(postId) { return ajax(`/posts/${postId}.json`).then(result => { - const post = Discourse.Post.create(result); + const post = Post.create(result); return Quote.build(post, post.raw, { raw: true, full: true }); }); }, diff --git a/app/assets/javascripts/discourse/models/result-set.js.es6 b/app/assets/javascripts/discourse/models/result-set.js.es6 index 436abfd2de..56e2c164b5 100644 --- a/app/assets/javascripts/discourse/models/result-set.js.es6 +++ b/app/assets/javascripts/discourse/models/result-set.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { Promise } from "rsvp"; export default Ember.ArrayProxy.extend({ @@ -15,7 +15,7 @@ export default Ember.ArrayProxy.extend({ __type: null, resultSetMeta: null, - @computed("totalRows", "length") + @discourseComputed("totalRows", "length") canLoadMore(totalRows, length) { return length < totalRows; }, diff --git a/app/assets/javascripts/discourse/models/reviewable.js.es6 b/app/assets/javascripts/discourse/models/reviewable.js.es6 index 892822c59e..fd6ad39603 100644 --- a/app/assets/javascripts/discourse/models/reviewable.js.es6 +++ b/app/assets/javascripts/discourse/models/reviewable.js.es6 @@ -1,6 +1,6 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; import Category from "discourse/models/category"; import { Promise } from "rsvp"; @@ -11,7 +11,7 @@ export const IGNORED = 3; export const DELETED = 4; export default RestModel.extend({ - @computed("type", "topic") + @discourseComputed("type", "topic") humanType(type, topic) { // Display "Queued Topic" if the post will create a topic if (type === "ReviewableQueuedPost" && !topic) { diff --git a/app/assets/javascripts/discourse/models/session.js.es6 b/app/assets/javascripts/discourse/models/session.js.es6 index f323540640..9f05080ea9 100644 --- a/app/assets/javascripts/discourse/models/session.js.es6 +++ b/app/assets/javascripts/discourse/models/session.js.es6 @@ -1,5 +1,6 @@ import RestModel from "discourse/models/rest"; import Singleton from "discourse/mixins/singleton"; +import deprecated from "discourse-common/lib/deprecated"; // A data model representing current session data. You can put transient // data here you might want later. It is not stored or serialized anywhere. @@ -10,4 +11,14 @@ const Session = RestModel.extend({ }); Session.reopenClass(Singleton); + +Object.defineProperty(Discourse, "Session", { + get() { + deprecated("Import the Session object instead of using Discourse.Session", { + since: "2.4.0", + dropFrom: "2.5.0" + }); + return Session; + } +}); export default Session; diff --git a/app/assets/javascripts/discourse/models/site.js.es6 b/app/assets/javascripts/discourse/models/site.js.es6 index 07dc4d72b3..5f9065d3d8 100644 --- a/app/assets/javascripts/discourse/models/site.js.es6 +++ b/app/assets/javascripts/discourse/models/site.js.es6 @@ -1,13 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { alias, sort } from "@ember/object/computed"; import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; import Archetype from "discourse/models/archetype"; import PostActionType from "discourse/models/post-action-type"; import Singleton from "discourse/mixins/singleton"; import RestModel from "discourse/models/rest"; +import TrustLevel from "discourse/models/trust-level"; import PreloadStore from "preload-store"; +import deprecated from "discourse-common/lib/deprecated"; const Site = RestModel.extend({ isReadOnly: alias("is_readonly"), @@ -18,7 +20,7 @@ const Site = RestModel.extend({ this.topicCountDesc = ["topic_count:desc"]; }, - @computed("notification_types") + @discourseComputed("notification_types") notificationLookup(notificationTypes) { const result = []; Object.keys(notificationTypes).forEach( @@ -27,7 +29,7 @@ const Site = RestModel.extend({ return result; }, - @computed("post_action_types.[]") + @discourseComputed("post_action_types.[]") flagTypes() { const postActionTypes = this.post_action_types; if (!postActionTypes) return []; @@ -52,7 +54,7 @@ const Site = RestModel.extend({ }, // Sort subcategories under parents - @computed("categoriesByCount", "categories.[]") + @discourseComputed("categoriesByCount", "categories.[]") sortedCategories(cats) { const result = [], remaining = {}; @@ -79,13 +81,13 @@ const Site = RestModel.extend({ return result; }, - @computed + @discourseComputed baseUri() { return Discourse.baseUri; }, // Returns it in the correct order, by setting - @computed + @discourseComputed("categories.[]") categoriesList() { return this.siteSettings.fixed_category_positions ? this.categories @@ -121,11 +123,13 @@ const Site = RestModel.extend({ if (existingCategory) { existingCategory.setProperties(newCategory); + return existingCategory; } else { // TODO insert in right order? newCategory = this.store.createRecord("category", newCategory); categories.pushObject(newCategory); this.categoriesById[categoryId] = newCategory; + return newCategory; } } }); @@ -176,9 +180,7 @@ Site.reopenClass(Singleton, { } if (result.trust_levels) { - result.trustLevels = result.trust_levels.map(tl => - Discourse.TrustLevel.create(tl) - ); + result.trustLevels = result.trust_levels.map(tl => TrustLevel.create(tl)); delete result.trust_levels; } @@ -215,4 +217,18 @@ Site.reopenClass(Singleton, { } }); +let warned = false; +Object.defineProperty(Discourse, "Site", { + get() { + if (!warned) { + deprecated("Import the Site class instead of using Discourse.Site", { + since: "2.4.0", + dropFrom: "2.6.0" + }); + warned = true; + } + return Site; + } +}); + export default Site; diff --git a/app/assets/javascripts/discourse/models/static-page.js.es6 b/app/assets/javascripts/discourse/models/static-page.js.es6 index 57bb3cc2df..7ec19c23b7 100644 --- a/app/assets/javascripts/discourse/models/static-page.js.es6 +++ b/app/assets/javascripts/discourse/models/static-page.js.es6 @@ -1,5 +1,7 @@ import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; +import { Promise } from "rsvp"; + const StaticPage = EmberObject.extend(); StaticPage.reopenClass({ diff --git a/app/assets/javascripts/discourse/models/store.js.es6 b/app/assets/javascripts/discourse/models/store.js.es6 index 39a5ce1434..68b2b76342 100644 --- a/app/assets/javascripts/discourse/models/store.js.es6 +++ b/app/assets/javascripts/discourse/models/store.js.es6 @@ -5,6 +5,8 @@ import ResultSet from "discourse/models/result-set"; import { getRegister } from "discourse-common/lib/get-owner"; import { underscore } from "@ember/string"; import { set } from "@ember/object"; +import Category from "discourse/models/category"; +import { Promise } from "rsvp"; let _identityMap; @@ -218,6 +220,13 @@ export default EmberObject.extend({ _resultSet(type, result, findArgs) { const adapter = this.adapterFor(type); const typeName = underscore(this.pluralize(adapter.apiNameFor(type))); + + if (!result[typeName]) { + // eslint-disable-next-line no-console + console.error(`JSON response is missing \`${typeName}\` key`, result); + return; + } + const content = result[typeName].map(obj => this._hydrate(type, obj, result) ); @@ -272,7 +281,7 @@ export default EmberObject.extend({ // to category. That should either respect this or be // removed. if (subType === "category" && type !== "topic") { - return Discourse.Category.findById(id); + return Category.findById(id); } if (root.meta && root.meta.types) { diff --git a/app/assets/javascripts/discourse/models/tag-group.js.es6 b/app/assets/javascripts/discourse/models/tag-group.js.es6 index e42ccee9dd..18afc52fe7 100644 --- a/app/assets/javascripts/discourse/models/tag-group.js.es6 +++ b/app/assets/javascripts/discourse/models/tag-group.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; import PermissionType from "discourse/models/permission-type"; export default RestModel.extend({ - @computed("permissions") + @discourseComputed("permissions") permissionName(permissions) { if (!permissions) return "public"; diff --git a/app/assets/javascripts/discourse/models/tag.js.es6 b/app/assets/javascripts/discourse/models/tag.js.es6 index c9665111ac..0985bb3dfb 100644 --- a/app/assets/javascripts/discourse/models/tag.js.es6 +++ b/app/assets/javascripts/discourse/models/tag.js.es6 @@ -1,14 +1,19 @@ +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; export default RestModel.extend({ - @computed("count", "pm_count") + @discourseComputed("count", "pm_count") totalCount(count, pmCount) { return count + pmCount; }, - @computed("count", "pm_count") + @discourseComputed("count", "pm_count") pmOnly(count, pmCount) { return count === 0 && pmCount > 0; + }, + + @discourseComputed("id") + searchContext(id) { + return { type: "tag", id, tag: this, name: id }; } }); diff --git a/app/assets/javascripts/discourse/models/topic-details.js.es6 b/app/assets/javascripts/discourse/models/topic-details.js.es6 index 0622cacb69..849b072bd5 100644 --- a/app/assets/javascripts/discourse/models/topic-details.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-details.js.es6 @@ -1,7 +1,7 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; - +import User from "discourse/models/user"; /** A model representing a Topic's details that aren't always present, such as a list of participants. When showing topics in lists and such this information should not be required. @@ -17,7 +17,7 @@ const TopicDetails = RestModel.extend({ if (details.allowed_users) { details.allowed_users = details.allowed_users.map(function(u) { - return Discourse.User.create(u); + return User.create(u); }); } @@ -32,7 +32,7 @@ const TopicDetails = RestModel.extend({ this.set("loaded", true); }, - @computed("notification_level", "notifications_reason_id") + @discourseComputed("notification_level", "notifications_reason_id") notificationReasonText(level, reason) { if (typeof level !== "number") { level = 1; @@ -48,13 +48,13 @@ const TopicDetails = RestModel.extend({ } if ( - Discourse.User.currentProp("mailing_list_mode") && + User.currentProp("mailing_list_mode") && level > NotificationLevels.MUTED ) { return I18n.t("topic.notifications.reasons.mailing_list_mode"); } else { return I18n.t(localeString, { - username: Discourse.User.currentProp("username_lower"), + username: User.currentProp("username_lower"), basePath: Discourse.BaseUri }); } diff --git a/app/assets/javascripts/discourse/models/topic-list.js.es6 b/app/assets/javascripts/discourse/models/topic-list.js.es6 index da01a6899d..489c926de3 100644 --- a/app/assets/javascripts/discourse/models/topic-list.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-list.js.es6 @@ -2,9 +2,24 @@ import { notEmpty } from "@ember/object/computed"; import EmberObject from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import RestModel from "discourse/models/rest"; -import Model from "discourse/models/model"; import { getOwner } from "discourse-common/lib/get-owner"; import { Promise } from "rsvp"; +import Category from "discourse/models/category"; +import Session from "discourse/models/session"; +import { isEmpty } from "@ember/utils"; +import User from "discourse/models/user"; + +function extractByKey(collection, klass) { + const retval = {}; + if (isEmpty(collection)) { + return retval; + } + + collection.forEach(function(item) { + retval[item.id] = klass.create(item); + }); + return retval; +} // Whether to show the category badge in topic lists function displayCategoryInList(site, category) { @@ -92,7 +107,7 @@ const TopicList = RestModel.extend({ more_topics_url: result.topic_list.more_topics_url }); - Discourse.Session.currentProp("topicList", this); + Session.currentProp("topicList", this); return this.more_topics_url; } }); @@ -122,7 +137,7 @@ const TopicList = RestModel.extend({ i++; }); - if (storeInSession) Discourse.Session.currentProp("topicList", this); + if (storeInSession) Session.currentProp("topicList", this); }); } }); @@ -136,9 +151,9 @@ TopicList.reopenClass({ // Stitch together our side loaded data - const categories = Discourse.Category.list(), - users = Model.extractByKey(result.users, Discourse.User), - groups = Model.extractByKey(result.primary_groups, EmberObject); + const categories = Category.list(), + users = extractByKey(result.users, User), + groups = extractByKey(result.primary_groups, EmberObject); return result.topic_list[listKey].map(t => { t.category = categories.findBy("id", t.category_id); diff --git a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 index 89eab6119a..3d6ff774db 100644 --- a/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 +++ b/app/assets/javascripts/discourse/models/topic-tracking-state.js.es6 @@ -2,17 +2,20 @@ import { get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { NotificationLevels } from "discourse/lib/notification-levels"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; -import { defaultHomepage } from "discourse/lib/utilities"; +} from "discourse-common/utils/decorators"; import PreloadStore from "preload-store"; +import Category from "discourse/models/category"; +import EmberObject from "@ember/object"; +import User from "discourse/models/user"; function isNew(topic) { return ( topic.last_read_post_number === null && ((topic.notification_level !== 0 && !topic.notification_level) || - topic.notification_level >= NotificationLevels.TRACKING) + topic.notification_level >= NotificationLevels.TRACKING) && + isUnseen(topic) ); } @@ -24,7 +27,11 @@ function isUnread(topic) { ); } -const TopicTrackingState = Discourse.Model.extend({ +function isUnseen(topic) { + return !topic.is_seen; +} + +const TopicTrackingState = EmberObject.extend({ messageCount: 0, @on("init") @@ -44,9 +51,7 @@ const TopicTrackingState = Discourse.Model.extend({ } if (["new_topic", "latest"].includes(data.message_type)) { - const muted_category_ids = Discourse.User.currentProp( - "muted_category_ids" - ); + const muted_category_ids = User.currentProp("muted_category_ids"); if ( muted_category_ids && muted_category_ids.includes(data.payload.category_id) @@ -57,7 +62,7 @@ const TopicTrackingState = Discourse.Model.extend({ // fill parent_category_id we need it for counting new/unread if (data.payload && data.payload.category_id) { - var category = Discourse.Category.findById(data.payload.category_id); + var category = Category.findById(data.payload.category_id); if (category && category.parent_category_id) { data.payload.parent_category_id = category.parent_category_id; @@ -68,6 +73,22 @@ const TopicTrackingState = Discourse.Model.extend({ tracker.notify(data); } + if (data.message_type === "dismiss_new") { + Object.keys(tracker.states).forEach(k => { + const topic = tracker.states[k]; + if ( + !data.payload.category_id || + topic.category_id === parseInt(data.payload.category_id, 0) + ) { + tracker.states[k] = Object.assign({}, topic, { + is_seen: true + }); + } + }); + tracker.notifyPropertyChange("states"); + tracker.incrementMessageCount(); + } + if (["new_topic", "unread", "read"].includes(data.message_type)) { tracker.notify(data); const old = tracker.states["t" + data.topic_id]; @@ -133,7 +154,7 @@ const TopicTrackingState = Discourse.Model.extend({ const categoryId = data.payload && data.payload.category_id; if (filterCategory && filterCategory.get("id") !== categoryId) { - const category = categoryId && Discourse.Category.findById(categoryId); + const category = categoryId && Category.findById(categoryId); if ( !category || category.get("parentCategory.id") !== filterCategory.get("id") @@ -142,18 +163,6 @@ const TopicTrackingState = Discourse.Model.extend({ } } - if (filter === defaultHomepage()) { - const suppressed_from_latest_category_ids = Discourse.Site.currentProp( - "suppressed_from_latest_category_ids" - ); - if ( - suppressed_from_latest_category_ids && - suppressed_from_latest_category_ids.includes(data.payload.category_id) - ) { - return; - } - } - if ( ["all", "latest", "new"].includes(filter) && data.message_type === "new_topic" @@ -194,7 +203,7 @@ const TopicTrackingState = Discourse.Model.extend({ if (split.length >= 4) { filter = split[split.length - 1]; // c/cat/subcat/l/latest - var category = Discourse.Category.findSingleBySlug( + var category = Category.findSingleBySlug( split.splice(1, split.length - 3).join("/") ); this.set("filterCategory", category); @@ -206,7 +215,7 @@ const TopicTrackingState = Discourse.Model.extend({ this.set("incomingCount", 0); }, - @computed("incomingCount") + @discourseComputed("incomingCount") hasIncoming(incomingCount) { return incomingCount && incomingCount > 0; }, @@ -227,7 +236,11 @@ const TopicTrackingState = Discourse.Model.extend({ if (state) { const lastRead = t.get("last_read_post_number"); - if (lastRead !== state.last_read_post_number) { + const isSeen = t.get("is_seen"); + if ( + lastRead !== state.last_read_post_number || + isSeen !== state.is_seen + ) { const postsCount = t.get("posts_count"); let newPosts = postsCount - state.highest_post_number, unread = postsCount - state.last_read_post_number; @@ -247,7 +260,8 @@ const TopicTrackingState = Discourse.Model.extend({ last_read_post_number: state.last_read_post_number, new_posts: newPosts, unread: unread, - unseen: !state.last_read_post_number + is_seen: state.is_seen, + unseen: !state.last_read_post_number && isUnseen(state) }); } } @@ -408,12 +422,11 @@ const TopicTrackingState = Discourse.Model.extend({ loadStates(data) { const states = this.states; - const idMap = Discourse.Category.idMap(); // I am taking some shortcuts here to avoid 500 gets for a large list if (data) { data.forEach(topic => { - var category = idMap[topic.category_id]; + let category = Category.findById(topic.category_id); if (category && category.parent_category_id) { topic.parent_category_id = category.parent_category_id; } diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index f4bf6c6533..d95f8334b8 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -14,10 +14,15 @@ import { emojiUnescape } from "discourse/lib/text"; import PreloadStore from "preload-store"; import { userPath } from "discourse/lib/url"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; +import Category from "discourse/models/category"; +import Session from "discourse/models/session"; +import { Promise } from "rsvp"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; export function loadTopicView(topic, args) { const data = _.merge({}, args); @@ -42,18 +47,18 @@ const Topic = RestModel.extend({ message: null, errorLoading: false, - @computed("last_read_post_number", "highest_post_number") + @discourseComputed("last_read_post_number", "highest_post_number") visited(lastReadPostNumber, highestPostNumber) { // >= to handle case where there are deleted posts at the end of the topic return lastReadPostNumber >= highestPostNumber; }, - @computed("posters.firstObject") + @discourseComputed("posters.firstObject") creator(poster) { return poster && poster.user; }, - @computed("posters.[]") + @discourseComputed("posters.[]") lastPoster(posters) { let user; if (posters && posters.length > 0) { @@ -65,8 +70,8 @@ const Topic = RestModel.extend({ return user || this.creator; }, - @computed("posters.[]", "participants.[]") - featuredUsers(posters, participants) { + @discourseComputed("posters.[]", "participants.[]", "allowed_user_count") + featuredUsers(posters, participants, allowedUserCount) { let users = posters; const maxUserCount = 5; const posterCount = users.length; @@ -92,14 +97,21 @@ const Topic = RestModel.extend({ }); } + if (this.isPrivateMessage && allowedUserCount > maxUserCount) { + users.splice(maxUserCount - 2, 1); // remove second-last avatar + users.push({ + moreCount: `+${allowedUserCount - maxUserCount + 1}` + }); + } + return users; }, - @computed("fancy_title") + @discourseComputed("fancy_title") fancyTitle(title) { let fancyTitle = censor( - emojiUnescape(title || ""), - Discourse.Site.currentProp("censored_regexp") + emojiUnescape(title) || "", + Site.currentProp("censored_regexp") ); if (Discourse.SiteSettings.support_mixed_text_direction) { @@ -110,7 +122,7 @@ const Topic = RestModel.extend({ }, // returns createdAt if there's no bumped date - @computed("bumped_at", "createdAt") + @discourseComputed("bumped_at", "createdAt") bumpedAt(bumped_at, createdAt) { if (bumped_at) { return new Date(bumped_at); @@ -119,7 +131,7 @@ const Topic = RestModel.extend({ } }, - @computed("bumpedAt", "createdAt") + @discourseComputed("bumpedAt", "createdAt") bumpedAtTitle(bumpedAt, createdAt) { const firstPost = I18n.t("first_post"); const lastPost = I18n.t("last_post"); @@ -129,12 +141,12 @@ const Topic = RestModel.extend({ return `${firstPost}: ${createdAtDate}\n${lastPost}: ${bumpedAtDate}`; }, - @computed("created_at") + @discourseComputed("created_at") createdAt(created_at) { return new Date(created_at); }, - @computed + @discourseComputed postStream() { return this.store.createRecord("postStream", { id: this.id, @@ -142,7 +154,7 @@ const Topic = RestModel.extend({ }); }, - @computed("tags") + @discourseComputed("tags") visibleListTags(tags) { if (!tags || !Discourse.SiteSettings.suppress_overlapping_tags_in_list) { return tags; @@ -160,7 +172,7 @@ const Topic = RestModel.extend({ return newTags; }, - @computed("related_messages") + @discourseComputed("related_messages") relatedMessages(relatedMessages) { if (relatedMessages) { const store = this.store; @@ -172,7 +184,7 @@ const Topic = RestModel.extend({ } }, - @computed("suggested_topics") + @discourseComputed("suggested_topics") suggestedTopics(suggestedTopics) { if (suggestedTopics) { const store = this.store; @@ -184,12 +196,12 @@ const Topic = RestModel.extend({ } }, - @computed("posts_count") + @discourseComputed("posts_count") replyCount(postsCount) { return postsCount - 1; }, - @computed + @discourseComputed details() { return this.store.createRecord("topicDetails", { id: this.id, @@ -200,7 +212,7 @@ const Topic = RestModel.extend({ invisible: not("visible"), deleted: notEmpty("deleted_at"), - @computed("id") + @discourseComputed("id") searchContext(id) { return { type: "topic", id }; }, @@ -208,7 +220,7 @@ const Topic = RestModel.extend({ @on("init") @observes("category_id") _categoryIdChanged() { - this.set("category", Discourse.Category.findById(this.category_id)); + this.set("category", Category.findById(this.category_id)); }, @observes("categoryName") @@ -223,21 +235,21 @@ const Topic = RestModel.extend({ categoryClass: fmt("category.fullSlug", "category-%@"), - @computed("tags") + @discourseComputed("tags") tagClasses(tags) { return tags && tags.map(t => `tag-${t}`).join(" "); }, - @computed("url") + @discourseComputed("url") shareUrl(url) { - const user = Discourse.User.current(); + const user = User.current(); const userQueryString = user ? `?u=${user.get("username_lower")}` : ""; return `${url}${userQueryString}`; }, printUrl: fmt("url", "%@/print"), - @computed("id", "slug") + @discourseComputed("id", "slug") url(id, slug) { slug = slug || ""; if (slug.trim().length === 0) { @@ -255,18 +267,18 @@ const Topic = RestModel.extend({ return url; }, - @computed("new_posts", "unread") + @discourseComputed("new_posts", "unread") totalUnread(newPosts, unread) { const count = (unread || 0) + (newPosts || 0); return count > 0 ? count : null; }, - @computed("last_read_post_number", "url") + @discourseComputed("last_read_post_number", "url") lastReadUrl(lastReadPostNumber) { return this.urlForPostNumber(lastReadPostNumber); }, - @computed("last_read_post_number", "highest_post_number", "url") + @discourseComputed("last_read_post_number", "highest_post_number", "url") lastUnreadUrl(lastReadPostNumber, highestPostNumber) { if (highestPostNumber <= lastReadPostNumber) { if (this.get("category.navigate_to_first_post_after_read")) { @@ -279,23 +291,23 @@ const Topic = RestModel.extend({ } }, - @computed("highest_post_number", "url") + @discourseComputed("highest_post_number", "url") lastPostUrl(highestPostNumber) { return this.urlForPostNumber(highestPostNumber); }, - @computed("url") + @discourseComputed("url") firstPostUrl() { return this.urlForPostNumber(1); }, - @computed("url") + @discourseComputed("url") summaryUrl() { const summaryQueryString = this.has_summary ? "?filter=summary" : ""; return `${this.urlForPostNumber(1)}${summaryQueryString}`; }, - @computed("last_poster.username") + @discourseComputed("last_poster.username") lastPosterUrl(username) { return userPath(username); }, @@ -303,9 +315,9 @@ const Topic = RestModel.extend({ // The amount of new posts to display. It might be different than what the server // tells us if we are still asynchronously flushing our "recently read" data. // So take what the browser has seen into consideration. - @computed("new_posts", "id") + @discourseComputed("new_posts", "id") displayNewPosts(newPosts, id) { - const highestSeen = Discourse.Session.currentProp("highestSeenByTopic")[id]; + const highestSeen = Session.currentProp("highestSeenByTopic")[id]; if (highestSeen) { const delta = highestSeen - this.last_read_post_number; if (delta > 0) { @@ -319,7 +331,7 @@ const Topic = RestModel.extend({ return newPosts; }, - @computed("views") + @discourseComputed("views") viewsHeat(v) { if (v >= Discourse.SiteSettings.topic_views_heat_high) { return "heatmap-high"; @@ -333,9 +345,9 @@ const Topic = RestModel.extend({ return null; }, - @computed("archetype") + @discourseComputed("archetype") archetypeObject(archetype) { - return Discourse.Site.currentProp("archetypes").findBy("id", archetype); + return Site.currentProp("archetypes").findBy("id", archetype); }, isPrivateMessage: equal("archetype", "private_message"), @@ -532,14 +544,14 @@ const Topic = RestModel.extend({ }); }, - @computed("excerpt") + @discourseComputed("excerpt") escapedExcerpt(excerpt) { return emojiUnescape(excerpt); }, hasExcerpt: notEmpty("excerpt"), - @computed("excerpt") + @discourseComputed("excerpt") excerptTruncated(excerpt) { return excerpt && excerpt.substr(excerpt.length - 8, 8) === "…"; }, @@ -638,7 +650,7 @@ Topic.reopenClass({ const lookup = EmberObject.create(); result.actions_summary = result.actions_summary.map(a => { a.post = result; - a.actionType = Discourse.Site.current().postActionTypeById(a.id); + a.actionType = Site.current().postActionTypeById(a.id); const actionSummary = ActionSummary.create(a); lookup.set(a.actionType.get("name_key"), actionSummary); return actionSummary; @@ -760,8 +772,11 @@ Topic.reopenClass({ }); }, - resetNew() { - return ajax("/topics/reset-new", { type: "PUT" }); + resetNew(category, include_subcategories) { + const data = category + ? { category_id: category.id, include_subcategories } + : {}; + return ajax("/topics/reset-new", { type: "PUT", data }); }, idForSlug(slug) { diff --git a/app/assets/javascripts/discourse/models/user-action-group.js.es6 b/app/assets/javascripts/discourse/models/user-action-group.js.es6 index b0706cd324..c432cd694c 100644 --- a/app/assets/javascripts/discourse/models/user-action-group.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action-group.js.es6 @@ -1,8 +1,7 @@ -/** - A data model representing a group of UserActions -**/ -export default Discourse.Model.extend({ - push: function(item) { +import EmberObject from "@ember/object"; + +export default EmberObject.extend({ + push(item) { if (!this.items) { this.items = []; } diff --git a/app/assets/javascripts/discourse/models/user-action-stat.js.es6 b/app/assets/javascripts/discourse/models/user-action-stat.js.es6 index fd531cde9a..0791edd34d 100644 --- a/app/assets/javascripts/discourse/models/user-action-stat.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action-stat.js.es6 @@ -1,10 +1,10 @@ +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; import UserAction from "discourse/models/user-action"; import { i18n } from "discourse/lib/computed"; -import computed from "ember-addons/ember-computed-decorators"; export default RestModel.extend({ - @computed("action_type") + @discourseComputed("action_type") isPM(actionType) { return ( actionType === UserAction.TYPES.messages_sent || @@ -14,7 +14,7 @@ export default RestModel.extend({ description: i18n("action_type", "user_action_groups.%@"), - @computed("action_type") + @discourseComputed("action_type") isResponse(actionType) { return ( actionType === UserAction.TYPES.replies || diff --git a/app/assets/javascripts/discourse/models/user-action.js.es6 b/app/assets/javascripts/discourse/models/user-action.js.es6 index 5ca07e7025..eea26a7ab4 100644 --- a/app/assets/javascripts/discourse/models/user-action.js.es6 +++ b/app/assets/javascripts/discourse/models/user-action.js.es6 @@ -1,10 +1,12 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { or, equal, and } from "@ember/object/computed"; import RestModel from "discourse/models/rest"; -import { on } from "ember-addons/ember-computed-decorators"; -import computed from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import UserActionGroup from "discourse/models/user-action-group"; import { postUrl } from "discourse/lib/utilities"; import { userPath } from "discourse/lib/url"; +import Category from "discourse/models/category"; +import User from "discourse/models/user"; const UserActionTypes = { likes_given: 1, @@ -31,11 +33,11 @@ const UserAction = RestModel.extend({ _attachCategory() { const categoryId = this.category_id; if (categoryId) { - this.set("category", Discourse.Category.findById(categoryId)); + this.set("category", Category.findById(categoryId)); } }, - @computed("action_type") + @discourseComputed("action_type") descriptionKey(action) { if (action === null || UserAction.TO_SHOW.indexOf(action) >= 0) { if (this.isPM) { @@ -66,41 +68,41 @@ const UserAction = RestModel.extend({ } }, - @computed("username") + @discourseComputed("username") sameUser(username) { - return username === Discourse.User.currentProp("username"); + return username === User.currentProp("username"); }, - @computed("target_username") + @discourseComputed("target_username") targetUser(targetUsername) { - return targetUsername === Discourse.User.currentProp("username"); + return targetUsername === User.currentProp("username"); }, presentName: or("name", "username"), targetDisplayName: or("target_name", "target_username"), actingDisplayName: or("acting_name", "acting_username"), - @computed("target_username") + @discourseComputed("target_username") targetUserUrl(username) { return userPath(username); }, - @computed("username") + @discourseComputed("username") usernameLower(username) { return username.toLowerCase(); }, - @computed("usernameLower") + @discourseComputed("usernameLower") userUrl(usernameLower) { return userPath(usernameLower); }, - @computed() + @discourseComputed() postUrl() { return postUrl(this.slug, this.topic_id, this.post_number); }, - @computed() + @discourseComputed() replyUrl() { return postUrl(this.slug, this.topic_id, this.reply_to_post_number); }, @@ -145,7 +147,7 @@ const UserAction = RestModel.extend({ } }, - @computed( + @discourseComputed( "childGroups", "childGroups.likes.items", "childGroups.likes.items.[]", diff --git a/app/assets/javascripts/discourse/models/user-badge.js.es6 b/app/assets/javascripts/discourse/models/user-badge.js.es6 index 69e88caf2e..67b3133e15 100644 --- a/app/assets/javascripts/discourse/models/user-badge.js.es6 +++ b/app/assets/javascripts/discourse/models/user-badge.js.es6 @@ -1,10 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import Badge from "discourse/models/badge"; -import computed from "ember-addons/ember-computed-decorators"; import { Promise } from "rsvp"; +import Topic from "discourse/models/topic"; +import EmberObject from "@ember/object"; +import User from "discourse/models/user"; -const UserBadge = Discourse.Model.extend({ - @computed +const UserBadge = EmberObject.extend({ + @discourseComputed postUrl: function() { if (this.topic_title) { return "/t/-/" + this.topic_id + "/" + this.post_number; @@ -26,7 +29,7 @@ UserBadge.reopenClass({ } var users = {}; json.users.forEach(function(userJson) { - users[userJson.id] = Discourse.User.create(userJson); + users[userJson.id] = User.create(userJson); }); // Create Topic objects. @@ -35,7 +38,7 @@ UserBadge.reopenClass({ } var topics = {}; json.topics.forEach(function(topicJson) { - topics[topicJson.id] = Discourse.Topic.create(topicJson); + topics[topicJson.id] = Topic.create(topicJson); }); // Create the badges. diff --git a/app/assets/javascripts/discourse/models/user-draft.js.es6 b/app/assets/javascripts/discourse/models/user-draft.js.es6 index 2ab353e92c..6e31ae3b2f 100644 --- a/app/assets/javascripts/discourse/models/user-draft.js.es6 +++ b/app/assets/javascripts/discourse/models/user-draft.js.es6 @@ -1,33 +1,32 @@ +import discourseComputed from "discourse-common/utils/decorators"; import RestModel from "discourse/models/rest"; -import computed from "ember-addons/ember-computed-decorators"; import { postUrl } from "discourse/lib/utilities"; import { userPath } from "discourse/lib/url"; import User from "discourse/models/user"; - import { NEW_TOPIC_KEY, NEW_PRIVATE_MESSAGE_KEY } from "discourse/models/composer"; export default RestModel.extend({ - @computed("draft_username") + @discourseComputed("draft_username") editableDraft(draftUsername) { return draftUsername === User.currentProp("username"); }, - @computed("username_lower") + @discourseComputed("username_lower") userUrl(usernameLower) { return userPath(usernameLower); }, - @computed("topic_id") + @discourseComputed("topic_id") postUrl(topicId) { if (!topicId) return; return postUrl(this.slug, this.topic_id, this.post_number); }, - @computed("draft_key") + @discourseComputed("draft_key") draftType(draftKey) { switch (draftKey) { case NEW_TOPIC_KEY: diff --git a/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 b/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 index ee1d3cccad..7472cc8e67 100644 --- a/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-drafts-stream.js.es6 @@ -1,9 +1,9 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import { url } from "discourse/lib/computed"; import RestModel from "discourse/models/rest"; import UserDraft from "discourse/models/user-draft"; import { emojiUnescape } from "discourse/lib/text"; -import computed from "ember-addons/ember-computed-decorators"; import { Promise } from "rsvp"; import { NEW_TOPIC_KEY, @@ -38,7 +38,7 @@ export default RestModel.extend({ return this.findItems(); }, - @computed("content.length", "loaded") + @discourseComputed("content.length", "loaded") noContent(contentLength, loaded) { return loaded && contentLength === 0; }, diff --git a/app/assets/javascripts/discourse/models/user-posts-stream.js.es6 b/app/assets/javascripts/discourse/models/user-posts-stream.js.es6 index 74cadcb455..5a71149fb5 100644 --- a/app/assets/javascripts/discourse/models/user-posts-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-posts-stream.js.es6 @@ -1,10 +1,11 @@ -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import { ajax } from "discourse/lib/ajax"; import { url } from "discourse/lib/computed"; import UserAction from "discourse/models/user-action"; import { Promise } from "rsvp"; +import EmberObject from "@ember/object"; -export default Discourse.Model.extend({ +export default EmberObject.extend({ loaded: false, @on("init") diff --git a/app/assets/javascripts/discourse/models/user-stream.js.es6 b/app/assets/javascripts/discourse/models/user-stream.js.es6 index 1a90ebd940..19e3d2ee62 100644 --- a/app/assets/javascripts/discourse/models/user-stream.js.es6 +++ b/app/assets/javascripts/discourse/models/user-stream.js.es6 @@ -5,9 +5,9 @@ import UserAction from "discourse/models/user-action"; import { emojiUnescape } from "discourse/lib/text"; import { Promise } from "rsvp"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default RestModel.extend({ loaded: false, @@ -17,9 +17,9 @@ export default RestModel.extend({ this.setProperties({ itemsLoaded: 0, content: [] }); }, - @computed("filter") + @discourseComputed("filter") filterParam(filter) { - if (filter === Discourse.UserAction.TYPES.replies) { + if (filter === UserAction.TYPES.replies) { return [UserAction.TYPES.replies, UserAction.TYPES.quotes].join(","); } @@ -51,7 +51,7 @@ export default RestModel.extend({ return this.findItems(); }, - @computed("loaded", "content.[]") + @discourseComputed("loaded", "content.[]") noContent(loaded, content) { return loaded && content.length === 0; }, diff --git a/app/assets/javascripts/discourse/models/user.js.es6 b/app/assets/javascripts/discourse/models/user.js.es6 index af2a0dd2fc..9b92f4bd03 100644 --- a/app/assets/javascripts/discourse/models/user.js.es6 +++ b/app/assets/javascripts/discourse/models/user.js.es6 @@ -1,6 +1,6 @@ import { isEmpty } from "@ember/utils"; import { gt, equal, or } from "@ember/object/computed"; -import EmberObject from "@ember/object"; +import EmberObject, { computed } from "@ember/object"; import { ajax } from "discourse/lib/ajax"; import { url } from "discourse/lib/computed"; import RestModel from "discourse/models/rest"; @@ -9,9 +9,9 @@ import UserPostsStream from "discourse/models/user-posts-stream"; import Singleton from "discourse/mixins/singleton"; import { longDate } from "discourse/lib/formatter"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import Badge from "discourse/models/badge"; import UserBadge from "discourse/models/user-badge"; import UserActionStat from "discourse/models/user-action-stat"; @@ -25,6 +25,8 @@ import { userPath } from "discourse/lib/url"; import Category from "discourse/models/category"; import { Promise } from "rsvp"; import { getProperties } from "@ember/object"; +import deprecated from "discourse-common/lib/deprecated"; +import Site from "discourse/models/site"; export const SECOND_FACTOR_METHODS = { TOTP: 1, @@ -43,27 +45,27 @@ const User = RestModel.extend({ reason: null }, - @computed("can_be_deleted", "post_count") + @discourseComputed("can_be_deleted", "post_count") canBeDeleted(canBeDeleted, postCount) { return canBeDeleted && postCount <= 5; }, - @computed() + @discourseComputed() stream() { return UserStream.create({ user: this }); }, - @computed() + @discourseComputed() postsStream() { return UserPostsStream.create({ user: this }); }, - @computed() + @discourseComputed() userDraftsStream() { return UserDraftsStream.create({ user: this }); }, - staff: Ember.computed("admin", "moderator", { + staff: computed("admin", "moderator", { get() { return this.admin || this.moderator; }, @@ -78,7 +80,7 @@ const User = RestModel.extend({ return ajax(`/session/${this.username}`, { type: "DELETE" }); }, - @computed("username_lower") + @discourseComputed("username_lower") searchContext(username) { return { type: "user", @@ -87,7 +89,7 @@ const User = RestModel.extend({ }; }, - @computed("username", "name") + @discourseComputed("username", "name") displayName(username, name) { if (Discourse.SiteSettings.enable_names && !isEmpty(name)) { return name; @@ -95,7 +97,7 @@ const User = RestModel.extend({ return username; }, - @computed("profile_background_upload_url") + @discourseComputed("profile_background_upload_url") profileBackgroundUrl(bgUrl) { if (isEmpty(bgUrl) || !Discourse.SiteSettings.allow_profile_backgrounds) { return "".htmlSafe(); @@ -107,13 +109,13 @@ const User = RestModel.extend({ ).htmlSafe(); }, - @computed() + @discourseComputed() path() { // no need to observe, requires a hard refresh to update return userPath(this.username_lower); }, - @computed() + @discourseComputed() userApiKeys() { const keys = this.user_api_keys; if (keys) { @@ -171,35 +173,35 @@ const User = RestModel.extend({ adminPath: url("id", "username_lower", "/admin/users/%@1/%@2"), - @computed() + @discourseComputed() mutedTopicsPath() { return defaultHomepage() === "latest" ? Discourse.getURL("/?state=muted") : Discourse.getURL("/latest?state=muted"); }, - @computed() + @discourseComputed() watchingTopicsPath() { return defaultHomepage() === "latest" ? Discourse.getURL("/?state=watching") : Discourse.getURL("/latest?state=watching"); }, - @computed() + @discourseComputed() trackingTopicsPath() { return defaultHomepage() === "latest" ? Discourse.getURL("/?state=tracking") : Discourse.getURL("/latest?state=tracking"); }, - @computed("username") + @discourseComputed("username") username_lower(username) { return username.toLowerCase(); }, - @computed("trust_level") + @discourseComputed("trust_level") trustLevel(trustLevel) { - return Discourse.Site.currentProp("trustLevels").findBy( + return Site.currentProp("trustLevels").findBy( "id", parseInt(trustLevel, 10) ); @@ -210,26 +212,26 @@ const User = RestModel.extend({ isElder: equal("trust_level", 4), canManageTopic: or("staff", "isElder"), - @computed("previous_visit_at") + @discourseComputed("previous_visit_at") previousVisitAt(previous_visit_at) { return new Date(previous_visit_at); }, - @computed("suspended_till") + @discourseComputed("suspended_till") suspended(suspendedTill) { return suspendedTill && moment(suspendedTill).isAfter(); }, - @computed("suspended_till") + @discourseComputed("suspended_till") suspendedForever: isForever, - @computed("silenced_till") + @discourseComputed("silenced_till") silencedForever: isForever, - @computed("suspended_till") + @discourseComputed("suspended_till") suspendedTillDate: longDate, - @computed("silenced_till") + @discourseComputed("silenced_till") silencedTillDate: longDate, changeUsername(new_username) { @@ -247,7 +249,7 @@ const User = RestModel.extend({ }, copy() { - return Discourse.User.create(this.getProperties(Object.keys(this))); + return User.create(this.getProperties(Object.keys(this))); }, save(fields) { @@ -300,7 +302,8 @@ const User = RestModel.extend({ "homepage_id", "hide_profile_and_presence", "text_size", - "title_count_mode" + "title_count_mode", + "timezone" ]; if (fields) { @@ -359,7 +362,7 @@ const User = RestModel.extend({ "external_links_in_new_tab", "dynamic_favicon" ); - Discourse.User.current().setProperties(userProps); + User.current().setProperties(userProps); this.setProperties(updatedState); }) .finally(() => { @@ -492,7 +495,7 @@ const User = RestModel.extend({ numGroupsToDisplay: 2, - @computed("groups.[]") + @discourseComputed("groups.[]") filteredGroups() { const groups = this.groups || []; @@ -501,19 +504,19 @@ const User = RestModel.extend({ }); }, - @computed("filteredGroups", "numGroupsToDisplay") + @discourseComputed("filteredGroups", "numGroupsToDisplay") displayGroups(filteredGroups, numGroupsToDisplay) { const groups = filteredGroups.slice(0, numGroupsToDisplay); return groups.length === 0 ? null : groups; }, - @computed("filteredGroups", "numGroupsToDisplay") + @discourseComputed("filteredGroups", "numGroupsToDisplay") showMoreGroupsLink(filteredGroups, numGroupsToDisplay) { return filteredGroups.length > numGroupsToDisplay; }, // The user's stat count, excluding PMs. - @computed("statsExcludingPms.@each.count") + @discourseComputed("statsExcludingPms.@each.count") statsCountNonPM() { if (isEmpty(this.statsExcludingPms)) return 0; let count = 0; @@ -526,7 +529,7 @@ const User = RestModel.extend({ }, // The user's stats, excluding PMs. - @computed("stats.@each.isPM") + @discourseComputed("stats.@each.isPM") statsExcludingPms() { if (isEmpty(this.stats)) return []; return this.stats.rejectBy("isPM"); @@ -539,7 +542,7 @@ const User = RestModel.extend({ return ajax(userPath(`${user.get("username")}.json`), { data: options }); }).then(json => { if (!isEmpty(json.user.stats)) { - json.user.stats = Discourse.User.groupStats( + json.user.stats = User.groupStats( json.user.stats.map(s => { if (s.count) s.count = parseInt(s.count, 10); return UserActionStat.create(s); @@ -560,7 +563,7 @@ const User = RestModel.extend({ } if (json.user.invited_by) { - json.user.invited_by = Discourse.User.create(json.user.invited_by); + json.user.invited_by = User.create(json.user.invited_by); } if (!isEmpty(json.user.featured_user_badge_ids)) { @@ -583,7 +586,7 @@ const User = RestModel.extend({ }, findStaffInfo() { - if (!Discourse.User.currentProp("staff")) { + if (!User.currentProp("staff")) { return Promise.resolve(null); } return ajax(userPath(`${this.username_lower}/staff-info.json`)).then( @@ -631,17 +634,14 @@ const User = RestModel.extend({ @observes("muted_category_ids") updateMutedCategories() { - this.set( - "mutedCategories", - Discourse.Category.findByIds(this.muted_category_ids) - ); + this.set("mutedCategories", Category.findByIds(this.muted_category_ids)); }, @observes("tracked_category_ids") updateTrackedCategories() { this.set( "trackedCategories", - Discourse.Category.findByIds(this.tracked_category_ids) + Category.findByIds(this.tracked_category_ids) ); }, @@ -649,7 +649,7 @@ const User = RestModel.extend({ updateWatchedCategories() { this.set( "watchedCategories", - Discourse.Category.findByIds(this.watched_category_ids) + Category.findByIds(this.watched_category_ids) ); }, @@ -657,11 +657,11 @@ const User = RestModel.extend({ updateWatchedFirstPostCategories() { this.set( "watchedFirstPostCategories", - Discourse.Category.findByIds(this.watched_first_post_category_ids) + Category.findByIds(this.watched_first_post_category_ids) ); }, - @computed("can_delete_account") + @discourseComputed("can_delete_account") canDeleteAccount(canDeleteAccount) { return !Discourse.SiteSettings.enable_sso && canDeleteAccount; }, @@ -682,7 +682,7 @@ const User = RestModel.extend({ type: "PUT", data: { notification_level: level, expiring_at: expiringAt } }).then(() => { - const currentUser = Discourse.User.current(); + const currentUser = User.current(); if (currentUser) { if (level === "normal" || level === "mute") { currentUser.ignored_users.removeObject(this.username); @@ -768,7 +768,7 @@ const User = RestModel.extend({ : this.admin || group.get("is_group_owner"); }, - @computed("groups.@each.title", "badges.[]") + @discourseComputed("groups.@each.title", "badges.[]") availableTitles() { let titles = []; @@ -794,7 +794,7 @@ const User = RestModel.extend({ }); }, - @computed("user_option.text_size_seq", "user_option.text_size") + @discourseComputed("user_option.text_size_seq", "user_option.text_size") currentTextSize(serverSeq, serverSize) { if ($.cookie("text_size")) { const [cookieSize, cookieSeq] = $.cookie("text_size").split("|"); @@ -817,7 +817,7 @@ const User = RestModel.extend({ } }, - @computed("second_factor_enabled", "staff") + @discourseComputed("second_factor_enabled", "staff") enforcedSecondFactor(secondFactorEnabled, staff) { const enforce = Discourse.SiteSettings.enforce_second_factor; return ( @@ -828,7 +828,7 @@ const User = RestModel.extend({ }); User.reopenClass(Singleton, { - // Find a `Discourse.User` for a given username. + // Find a `User` for a given username. findByUsername(username, options) { const user = User.create({ username: username }); return user.findDetails(options); @@ -854,6 +854,11 @@ User.reopenClass(Singleton, { return null; }, + resetCurrent(user) { + this._super(user); + Discourse.currentUser = user; + }, + checkUsername(username, email, for_user_id) { return ajax(userPath("check_username"), { data: { username, email, for_user_id } @@ -897,11 +902,26 @@ User.reopenClass(Singleton, { username: attrs.accountUsername, password_confirmation: attrs.accountPasswordConfirm, challenge: attrs.accountChallenge, - user_fields: attrs.userFields + user_fields: attrs.userFields, + timezone: moment.tz.guess() }, type: "POST" }); } }); +let warned = false; +Object.defineProperty(Discourse, "User", { + get() { + if (!warned) { + deprecated("Import the User class instead of using User", { + since: "2.4.0", + dropFrom: "2.6.0" + }); + warned = true; + } + return User; + } +}); + export default User; diff --git a/app/assets/javascripts/discourse/pre-initializers/dynamic-route-builders.js.es6 b/app/assets/javascripts/discourse/pre-initializers/dynamic-route-builders.js.es6 index 55f4fee1d9..26ac79cd02 100644 --- a/app/assets/javascripts/discourse/pre-initializers/dynamic-route-builders.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/dynamic-route-builders.js.es6 @@ -2,6 +2,8 @@ import buildCategoryRoute from "discourse/routes/build-category-route"; import buildTopicRoute from "discourse/routes/build-topic-route"; import DiscoverySortableController from "discourse/controllers/discovery-sortable"; import TagsShowRoute from "discourse/routes/tags-show"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; export default { after: "inject-discourse-objects", @@ -18,8 +20,9 @@ export default { app.DiscoveryCategoryNoneRoute = buildCategoryRoute("default", { no_subcategories: true }); + app.DiscoveryCategoryWithIDRoute = buildCategoryRoute("default"); - const site = Discourse.Site.current(); + const site = Site.current(); site.get("filters").forEach(filter => { const filterCapitalized = filter.capitalize(); app[ @@ -54,8 +57,8 @@ export default { Discourse.DiscoveryTopRoute = buildTopicRoute("top", { actions: { willTransition() { - Discourse.User.currentProp("should_be_redirected_to_top", false); - Discourse.User.currentProp("redirected_to_top.reason", null); + User.currentProp("should_be_redirected_to_top", false); + User.currentProp("redirected_to_top.reason", null); return this._super(...arguments); } } diff --git a/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 b/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 index 2555cf6d12..eab9eb988c 100644 --- a/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 +++ b/app/assets/javascripts/discourse/pre-initializers/inject-discourse-objects.js.es6 @@ -2,12 +2,15 @@ import Session from "discourse/models/session"; import KeyValueStore from "discourse/lib/key-value-store"; import Store from "discourse/models/store"; import DiscourseLocation from "discourse/lib/discourse-location"; +import Discourse from "discourse"; import SearchService from "discourse/services/search"; import { startTracking, default as TopicTrackingState } from "discourse/models/topic-tracking-state"; import ScreenTrack from "discourse/lib/screen-track"; +import Site from "discourse/models/site"; +import User from "discourse/models/user"; const ALL_TARGETS = ["controller", "component", "route", "model", "adapter"]; @@ -29,8 +32,9 @@ export default { app.register("message-bus:main", messageBus, { instantiate: false }); ALL_TARGETS.forEach(t => app.inject(t, "messageBus", "message-bus:main")); - const currentUser = Discourse.User.current(); + const currentUser = User.current(); app.register("current-user:main", currentUser, { instantiate: false }); + Discourse.currentUser = currentUser; const topicTrackingState = TopicTrackingState.create({ messageBus, @@ -49,7 +53,7 @@ export default { app.inject(t, "siteSettings", "site-settings:main") ); - const site = Discourse.Site.current(); + const site = Site.current(); app.register("site:main", site, { instantiate: false }); ALL_TARGETS.forEach(t => app.inject(t, "site", "site:main")); diff --git a/app/assets/javascripts/discourse/raw-views/list/post-count-or-badges.js.es6 b/app/assets/javascripts/discourse/raw-views/list/post-count-or-badges.js.es6 index d7c5f0aa06..20b15a3ebd 100644 --- a/app/assets/javascripts/discourse/raw-views/list/post-count-or-badges.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/list/post-count-or-badges.js.es6 @@ -1,12 +1,12 @@ import { or, and } from "@ember/object/computed"; import EmberObject from "@ember/object"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default EmberObject.extend({ postCountsPresent: or("topic.unread", "topic.displayNewPosts"), showBadges: and("postBadgesEnabled", "postCountsPresent"), - @computed + @discourseComputed newDotText() { return this.currentUser && this.currentUser.trust_level > 0 ? "" diff --git a/app/assets/javascripts/discourse/raw-views/list/posts-count-column.js.es6 b/app/assets/javascripts/discourse/raw-views/list/posts-count-column.js.es6 index 2c7ca4c9b9..12a27f3a22 100644 --- a/app/assets/javascripts/discourse/raw-views/list/posts-count-column.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/list/posts-count-column.js.es6 @@ -1,11 +1,11 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; import { fmt } from "discourse/lib/computed"; export default EmberObject.extend({ tagName: "td", - @computed("topic.like_count", "topic.posts_count") + @discourseComputed("topic.like_count", "topic.posts_count") ratio(likeCount, postCount) { const likes = parseFloat(likeCount); const posts = parseFloat(postCount); @@ -17,12 +17,12 @@ export default EmberObject.extend({ return (likes || 0) / posts; }, - @computed("topic.replyCount", "ratioText") + @discourseComputed("topic.replyCount", "ratioText") title(count, ratio) { return I18n.messageFormat("posts_likes_MF", { count, ratio }).trim(); }, - @computed("ratio") + @discourseComputed("ratio") ratioText(ratio) { const settings = this.siteSettings; if (ratio > settings.topic_post_like_heat_high) { diff --git a/app/assets/javascripts/discourse/raw-views/list/visited-line.js.es6 b/app/assets/javascripts/discourse/raw-views/list/visited-line.js.es6 index b2aac57550..2428c81012 100644 --- a/app/assets/javascripts/discourse/raw-views/list/visited-line.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/list/visited-line.js.es6 @@ -1,8 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; export default EmberObject.extend({ - @computed + @discourseComputed isLastVisited: function() { return this.lastVisitedTopic === this.topic; } diff --git a/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 b/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 index 0ac85216b7..e5b44af68e 100644 --- a/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/topic-list-header-column.js.es6 @@ -1,8 +1,8 @@ import EmberObject from "@ember/object"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default EmberObject.extend({ - @computed + @discourseComputed localizedName() { if (this.forceName) { return this.forceName; @@ -11,18 +11,18 @@ export default EmberObject.extend({ return this.name ? I18n.t(this.name) : ""; }, - @computed + @discourseComputed sortIcon() { const asc = this.parent.ascending ? "up" : "down"; return `chevron-${asc}`; }, - @computed + @discourseComputed isSorting() { return this.sortable && this.parent.order === this.order; }, - @computed + @discourseComputed className() { const name = []; diff --git a/app/assets/javascripts/discourse/raw-views/topic-status.js.es6 b/app/assets/javascripts/discourse/raw-views/topic-status.js.es6 index 8e7269bbca..d6b2c74999 100644 --- a/app/assets/javascripts/discourse/raw-views/topic-status.js.es6 +++ b/app/assets/javascripts/discourse/raw-views/topic-status.js.es6 @@ -1,15 +1,15 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; export default EmberObject.extend({ showDefault: null, - @computed("defaultIcon") + @discourseComputed("defaultIcon") renderDiv(defaultIcon) { return (defaultIcon || this.statuses.length > 0) && !this.noDiv; }, - @computed + @discourseComputed statuses() { const topic = this.topic; const results = []; diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 1da84b4047..3dc0d5787a 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -1,3 +1,5 @@ +import Site from "discourse/models/site"; + export default function() { // Error page this.route("exception", { path: "/exception" }); @@ -20,44 +22,55 @@ export default function() { this.route("topicBySlugOrId", { path: "/t/:slugOrId", resetNamespace: true }); this.route("discovery", { path: "/", resetNamespace: true }, function() { + // legacy route + this.route("topParentCategory", { path: "/c/:slug/l/top" }); + // top this.route("top"); - this.route("topParentCategory", { path: "/c/:slug/l/top" }); - this.route("topCategoryNone", { path: "/c/:slug/none/l/top" }); - this.route("topCategory", { path: "/c/:parentSlug/:slug/l/top" }); + this.route("topCategoryNone", { + path: "/c/*category_slug_path_with_id/none/l/top" + }); + this.route("topCategory", { path: "/c/*category_slug_path_with_id/l/top" }); // top by periods - Discourse.Site.currentProp("periods").forEach(period => { + Site.currentProp("periods").forEach(period => { const top = "top" + period.capitalize(); - this.route(top, { path: "/top/" + period }); + + // legacy route this.route(top + "ParentCategory", { path: "/c/:slug/l/top/" + period }); + + this.route(top, { path: "/top/" + period }); this.route(top + "CategoryNone", { - path: "/c/:slug/none/l/top/" + period + path: "/c/*category_slug_path_with_id/none/l/top/" + period }); this.route(top + "Category", { - path: "/c/:parentSlug/:slug/l/top/" + period + path: "/c/*category_slug_path_with_id/l/top/" + period }); }); // filters - Discourse.Site.currentProp("filters").forEach(filter => { - this.route(filter, { path: "/" + filter }); + Site.currentProp("filters").forEach(filter => { + // legacy route this.route(filter + "ParentCategory", { path: "/c/:slug/l/" + filter }); + + this.route(filter, { path: "/" + filter }); this.route(filter + "CategoryNone", { - path: "/c/:slug/none/l/" + filter + path: "/c/*category_slug_path_with_id/none/l/" + filter }); this.route(filter + "Category", { - path: "/c/:parentSlug/:slug/l/" + filter + path: "/c/*category_slug_path_with_id/l/" + filter }); }); this.route("categories"); - // default filter for a category + // legacy routes this.route("parentCategory", { path: "/c/:slug" }); - this.route("categoryNone", { path: "/c/:slug/none" }); - this.route("category", { path: "/c/:parentSlug/:slug" }); this.route("categoryWithID", { path: "/c/:parentSlug/:slug/:id" }); + + // default filter for a category + this.route("categoryNone", { path: "/c/*category_slug_path_with_id/none" }); + this.route("category", { path: "/c/*category_slug_path_with_id" }); }); this.route("groups", { resetNamespace: true, path: "/g" }, function() { @@ -198,24 +211,22 @@ export default function() { this.route("tags", { resetNamespace: true }, function() { this.route("show", { path: "/:tag_id" }); - this.route("showCategory", { path: "/c/:category/:tag_id" }); - this.route("showCategoryNone", { path: "/c/:category/none/:tag_id" }); - this.route("showParentCategory", { - path: "/c/:parent_category/:category/:tag_id" + this.route("showCategory", { + path: "/c/*category_slug_path_with_id/:tag_id" + }); + this.route("showCategoryNone", { + path: "/c/*category_slug_path_with_id/none/:tag_id" }); - Discourse.Site.currentProp("filters").forEach(filter => { + Site.currentProp("filters").forEach(filter => { this.route("show" + filter.capitalize(), { path: "/:tag_id/l/" + filter }); this.route("showCategory" + filter.capitalize(), { - path: "/c/:category/:tag_id/l/" + filter + path: "/c/*category_slug_path_with_id/:tag_id/l/" + filter }); this.route("showCategoryNone" + filter.capitalize(), { - path: "/c/:category/:tag_id/l/" + filter - }); - this.route("showParentCategory" + filter.capitalize(), { - path: "/c/:parent_category/:category/:tag_id/l/" + filter + path: "/c/*category_slug_path_with_id/none/:tag_id/l/" + filter }); }); this.route("intersection", { diff --git a/app/assets/javascripts/discourse/routes/application.js.es6 b/app/assets/javascripts/discourse/routes/application.js.es6 index cb1e5f0406..9a3fafba36 100644 --- a/app/assets/javascripts/discourse/routes/application.js.es6 +++ b/app/assets/javascripts/discourse/routes/application.js.es6 @@ -12,6 +12,7 @@ import { findAll } from "discourse/models/login-method"; import { getOwner } from "discourse-common/lib/get-owner"; import { userPath } from "discourse/lib/url"; import Composer from "discourse/models/composer"; +import { EventTarget } from "rsvp"; function unlessReadOnly(method, message) { return function() { @@ -288,5 +289,5 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, { } }); -RSVP.EventTarget.mixin(ApplicationRoute); +EventTarget.mixin(ApplicationRoute); export default ApplicationRoute; diff --git a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 index 06259b920c..86f18c5ddd 100644 --- a/app/assets/javascripts/discourse/routes/build-category-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-category-route.js.es6 @@ -9,39 +9,51 @@ import PermissionType from "discourse/models/permission-type"; import CategoryList from "discourse/models/category-list"; import Category from "discourse/models/category"; import { Promise, all } from "rsvp"; +import { isNone } from "@ember/utils"; // A helper function to create a category route with parameters export default (filterArg, params) => { return DiscourseRoute.extend({ queryParams, - model(modelParams) { - const category = Category.findBySlug( - modelParams.slug, - modelParams.parentSlug - ); - if (!category) { - return Category.reloadBySlug( - modelParams.slug, - modelParams.parentSlug - ).then(atts => { - if (modelParams.parentSlug) { - atts.category.parentCategory = Category.findBySlug( - modelParams.parentSlug - ); - } - const record = this.store.createRecord("category", atts.category); - record.setupGroupsAndPermissions(); - this.site.updateCategory(record); - return { - category: Category.findBySlug( - modelParams.slug, - modelParams.parentSlug - ) - }; - }); + serialize(modelParams) { + if (!modelParams.category_slug_path_with_id) { + if (modelParams.id === "none") { + const category_slug_path_with_id = [ + modelParams.parentSlug, + modelParams.slug + ].join("/"); + const category = Category.findBySlugPathWithID( + category_slug_path_with_id + ); + this.replaceWith("discovery.categoryNone", { + category, + category_slug_path_with_id + }); + } else { + modelParams.category_slug_path_with_id = [ + modelParams.parentSlug, + modelParams.slug, + modelParams.id + ] + .filter(x => x) + .join("/"); + } + } + + return modelParams; + }, + + model(modelParams) { + modelParams = this.serialize(modelParams); + + const category = Category.findBySlugPathWithID( + modelParams.category_slug_path_with_id + ); + + if (category) { + return { category }; } - return { category }; }, afterModel(model, transition) { @@ -65,21 +77,19 @@ export default (filterArg, params) => { _setupNavigation(category) { const noSubcategories = params && !!params.no_subcategories, - filterMode = `c/${Discourse.Category.slugFor(category)}${ - noSubcategories ? "/none" : "" - }/l/${this.filter(category)}`; + filterType = this.filter(category).split("/")[0]; this.controllerFor("navigation/category").setProperties({ category, - filterMode: filterMode, - noSubcategories: params && params.no_subcategories + filterType, + noSubcategories }); }, _createSubcategoryList(category) { this._categoryList = null; if ( - Ember.isNone(category.get("parentCategory")) && + isNone(category.get("parentCategory")) && category.get("show_subcategory_list") ) { return CategoryList.listForParent(this.store, category).then( @@ -92,9 +102,9 @@ export default (filterArg, params) => { }, _retrieveTopicList(category, transition) { - const listFilter = `c/${Discourse.Category.slugFor( - category - )}/l/${this.filter(category)}`, + const listFilter = `c/${Category.slugFor(category)}/${ + category.id + }/l/${this.filter(category)}`, findOpts = filterQueryParams(transition.to.queryParams, params), extras = { cached: this.isPoppedState(transition) }; diff --git a/app/assets/javascripts/discourse/routes/build-private-messages-route.js.es6 b/app/assets/javascripts/discourse/routes/build-private-messages-route.js.es6 index 7331705f8f..abae09a255 100644 --- a/app/assets/javascripts/discourse/routes/build-private-messages-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-private-messages-route.js.es6 @@ -1,10 +1,11 @@ import UserTopicListRoute from "discourse/routes/user-topic-list"; import { findOrResetCachedTopicList } from "discourse/lib/cached-topic-list"; +import UserAction from "discourse/models/user-action"; // A helper to build a user topic list route export default (viewName, path, channel) => { return UserTopicListRoute.extend({ - userActionType: Discourse.UserAction.TYPES.messages_received, + userActionType: UserAction.TYPES.messages_received, titleToken() { const key = viewName === "index" ? "inbox" : viewName; diff --git a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 index 5d0a576005..e7a8d97ef3 100644 --- a/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 +++ b/app/assets/javascripts/discourse/routes/build-topic-route.js.es6 @@ -1,6 +1,9 @@ import DiscourseRoute from "discourse/routes/discourse"; import { queryParams } from "discourse/controllers/discovery-sortable"; import { defaultHomepage } from "discourse/lib/utilities"; +import Session from "discourse/models/session"; +import { Promise } from "rsvp"; +import Site from "discourse/models/site"; // A helper to build a topic route for a filter function filterQueryParams(params, defaultParams) { @@ -19,7 +22,7 @@ function filterQueryParams(params, defaultParams) { function findTopicList(store, tracking, filter, filterParams, extras) { extras = extras || {}; return new Promise(function(resolve) { - const session = Discourse.Session.current(); + const session = Session.current(); if (extras.cached) { const cachedList = session.get("topicList"); @@ -62,9 +65,9 @@ function findTopicList(store, tracking, filter, filterParams, extras) { tracking.sync(list, list.filter); tracking.trackIncoming(list.filter); } - Discourse.Session.currentProp("topicList", list); + Session.currentProp("topicList", list); if (list.topic_list && list.topic_list.top_tags) { - Discourse.Site.currentProp("top_tags", list.topic_list.top_tags); + Site.currentProp("top_tags", list.topic_list.top_tags); } return list; }); @@ -77,7 +80,10 @@ export default function(filter, extras) { queryParams, beforeModel() { - this.controllerFor("navigation/default").set("filterMode", filter); + this.controllerFor("navigation/default").set( + "filterType", + filter.split("/")[0] + ); }, model(data, transition) { diff --git a/app/assets/javascripts/discourse/routes/discourse.js.es6 b/app/assets/javascripts/discourse/routes/discourse.js.es6 index 2329168593..980e986cd9 100644 --- a/app/assets/javascripts/discourse/routes/discourse.js.es6 +++ b/app/assets/javascripts/discourse/routes/discourse.js.es6 @@ -2,6 +2,7 @@ import { once } from "@ember/runloop"; import Composer from "discourse/models/composer"; import { getOwner } from "discourse-common/lib/get-owner"; import Route from "@ember/routing/route"; +import deprecated from "discourse-common/lib/deprecated"; const DiscourseRoute = Route.extend({ showFooter: false, @@ -107,4 +108,14 @@ const DiscourseRoute = Route.extend({ } }); +Object.defineProperty(Discourse, "Route", { + get() { + deprecated("Import the Route class instead of using Discourse.Route", { + since: "2.4.0", + dropFrom: "2.5.0" + }); + return Route; + } +}); + export default DiscourseRoute; diff --git a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 index f5bafdf12b..35b124aa5b 100644 --- a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 @@ -21,10 +21,7 @@ const DiscoveryCategoriesRoute = DiscourseRoute.extend(OpenComposer, { let style = !this.site.mobileView && this.siteSettings.desktop_category_page_style; - let parentCategory = this.get("model.parentCategory"); - if (parentCategory) { - return CategoryList.listForParent(this.store, parentCategory); - } else if (style === "categories_and_latest_topics") { + if (style === "categories_and_latest_topics") { return this._findCategoriesAndTopics("latest"); } else if (style === "categories_and_top_topics") { return this._findCategoriesAndTopics("top"); diff --git a/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 b/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 deleted file mode 100644 index e5ed07790e..0000000000 --- a/app/assets/javascripts/discourse/routes/discovery-category-with-id.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -import Category from "discourse/models/category"; - -export default Discourse.DiscoveryCategoryRoute.extend({ - model(params) { - return { category: Category.findById(params.id) }; - } -}); diff --git a/app/assets/javascripts/discourse/routes/discovery.js.es6 b/app/assets/javascripts/discourse/routes/discovery.js.es6 index 68796918ef..d812950666 100644 --- a/app/assets/javascripts/discourse/routes/discovery.js.es6 +++ b/app/assets/javascripts/discourse/routes/discovery.js.es6 @@ -5,6 +5,7 @@ import DiscourseRoute from "discourse/routes/discourse"; import OpenComposer from "discourse/mixins/open-composer"; import { scrollTop } from "discourse/mixins/scroll-top"; +import User from "discourse/models/user"; export default DiscourseRoute.extend(OpenComposer, { redirect() { @@ -12,7 +13,7 @@ export default DiscourseRoute.extend(OpenComposer, { }, beforeModel(transition) { - const user = Discourse.User; + const user = User; const url = transition.intent.url; if ( diff --git a/app/assets/javascripts/discourse/routes/group-index.js.es6 b/app/assets/javascripts/discourse/routes/group-index.js.es6 index a8f630df0f..a3aed42816 100644 --- a/app/assets/javascripts/discourse/routes/group-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-index.js.es6 @@ -19,7 +19,7 @@ export default DiscourseRoute.extend({ filterInput: this._params.filter }); - controller.refreshMembers(); + controller.findMembers(true); }, actions: { diff --git a/app/assets/javascripts/discourse/routes/group-requests.js.es6 b/app/assets/javascripts/discourse/routes/group-requests.js.es6 index b299bb01bf..548529cab0 100644 --- a/app/assets/javascripts/discourse/routes/group-requests.js.es6 +++ b/app/assets/javascripts/discourse/routes/group-requests.js.es6 @@ -18,6 +18,6 @@ export default DiscourseRoute.extend({ filterInput: this._params.filter }); - controller.refreshRequesters(true); + controller.findRequesters(true); } }); diff --git a/app/assets/javascripts/discourse/routes/preferences-account.js.es6 b/app/assets/javascripts/discourse/routes/preferences-account.js.es6 index 6ea1e95b95..f55a568997 100644 --- a/app/assets/javascripts/discourse/routes/preferences-account.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-account.js.es6 @@ -8,7 +8,10 @@ export default RestrictedUserRoute.extend({ const user = this.modelFor("user"); if (this.siteSettings.enable_badges) { return UserBadge.findByUsername(user.get("username")).then(userBadges => { - user.set("badges", userBadges.map(ub => ub.badge)); + user.set( + "badges", + userBadges.map(ub => ub.badge) + ); return user; }); } else { diff --git a/app/assets/javascripts/discourse/routes/preferences-profile.js.es6 b/app/assets/javascripts/discourse/routes/preferences-profile.js.es6 index 713d79e420..7b794ef681 100644 --- a/app/assets/javascripts/discourse/routes/preferences-profile.js.es6 +++ b/app/assets/javascripts/discourse/routes/preferences-profile.js.es6 @@ -1,5 +1,10 @@ import RestrictedUserRoute from "discourse/routes/restricted-user"; export default RestrictedUserRoute.extend({ - showFooter: true + showFooter: true, + setupController(controller, model) { + model.user_option.timezone = + model.user_option.timezone || moment.tz.guess(); + controller.set("model", model); + } }); diff --git a/app/assets/javascripts/discourse/routes/review-index.js.es6 b/app/assets/javascripts/discourse/routes/review-index.js.es6 index d5b2fad370..3da7513e6d 100644 --- a/app/assets/javascripts/discourse/routes/review-index.js.es6 +++ b/app/assets/javascripts/discourse/routes/review-index.js.es6 @@ -23,7 +23,10 @@ export default DiscourseRoute.extend({ filterPriority: meta.priority, reviewableTypes: meta.reviewable_types, filterUsername: meta.username, - filterSortOrder: meta.sort_order + filterFromDate: meta.from_date, + filterToDate: meta.to_date, + filterSortOrder: meta.sort_order, + additionalFilters: meta.additional_filters || {} }); }, diff --git a/app/assets/javascripts/discourse/routes/tags-show.js.es6 b/app/assets/javascripts/discourse/routes/tags-show.js.es6 index c718cc75ff..44b6bc6e1d 100644 --- a/app/assets/javascripts/discourse/routes/tags-show.js.es6 +++ b/app/assets/javascripts/discourse/routes/tags-show.js.es6 @@ -7,8 +7,10 @@ import { } from "discourse/routes/build-topic-route"; import { queryParams } from "discourse/controllers/discovery-sortable"; import PermissionType from "discourse/models/permission-type"; +import Category from "discourse/models/category"; +import FilterModeMixin from "discourse/mixins/filter-mode"; -export default DiscourseRoute.extend({ +export default DiscourseRoute.extend(FilterModeMixin, { navMode: "latest", queryParams, @@ -22,8 +24,6 @@ export default DiscourseRoute.extend({ const tag = this.store.createRecord("tag", { id: Handlebars.Utils.escapeExpression(params.tag_id) }); - let f = ""; - if (params.additional_tags) { this.set( "additionalTags", @@ -37,22 +37,9 @@ export default DiscourseRoute.extend({ this.set("additionalTags", null); } - if (params.category) { - f = "c/"; - if (params.parent_category) { - f += `${params.parent_category}/`; - } - f += `${params.category}/l/`; - } - f += this.navMode; - this.set("filterMode", f); + this.set("filterType", this.navMode.split("/")[0]); - if (params.category) { - this.set("categorySlug", params.category); - } - if (params.parent_category) { - this.set("parentCategorySlug", params.parent_category); - } + this.set("categorySlugPathWithID", params.category_slug_path_with_id); if (tag && tag.get("id") !== "none" && this.currentUser) { // If logged in, we should get the tag's user settings @@ -69,37 +56,35 @@ export default DiscourseRoute.extend({ afterModel(tag, transition) { const controller = this.controllerFor("tags.show"); - controller.set("loading", true); + controller.setProperties({ + loading: true, + showInfo: false + }); const params = filterQueryParams(transition.to.queryParams, {}); - const categorySlug = this.categorySlug; - const parentCategorySlug = this.parentCategorySlug; + const category = this.categorySlugPathWithID + ? Category.findBySlugPathWithID(this.categorySlugPathWithID) + : null; const topicFilter = this.navMode; const tagId = tag ? tag.id.toLowerCase() : "none"; let filter; - if (categorySlug) { - const category = Discourse.Category.findBySlug( - categorySlug, - parentCategorySlug - ); - if (parentCategorySlug) { - filter = `tags/c/${parentCategorySlug}/${categorySlug}/${tagId}/l/${topicFilter}`; - } else if (this.noSubcategories) { - filter = `tags/c/${categorySlug}/none/${tagId}/l/${topicFilter}`; - } else { - filter = `tags/c/${categorySlug}/${tagId}/l/${topicFilter}`; - } - if (category) { - category.setupGroupsAndPermissions(); - this.set("category", category); + if (category) { + category.setupGroupsAndPermissions(); + this.set("category", category); + filter = `tags/c/${Category.slugFor(category)}/${category.id}`; + + if (this.noSubcategories) { + filter += "/none"; } + + filter += `/${tagId}/l/${topicFilter}`; } else if (this.additionalTags) { + this.set("category", null); filter = `tags/intersection/${tagId}/${this.additionalTags.join("/")}`; - this.set("category", null); } else { - filter = `tags/${tagId}/l/${topicFilter}`; this.set("category", null); + filter = `tags/${tagId}/l/${topicFilter}`; } return findTopicList(this.store, this.topicTrackingState, filter, params, { @@ -162,11 +147,17 @@ export default DiscourseRoute.extend({ tag: model, additionalTags: this.additionalTags, category: this.category, - filterMode: this.filterMode, + filterType: this.filterType, navMode: this.navMode, tagNotification: this.tagNotification, noSubcategories: this.noSubcategories }); + this.searchService.set("searchContext", model.get("searchContext")); + }, + + deactivate() { + this._super(...arguments); + this.searchService.set("searchContext", null); }, actions: { diff --git a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 index 08c4671236..dd2215520f 100644 --- a/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic-from-params.js.es6 @@ -3,6 +3,7 @@ import { scheduleOnce } from "@ember/runloop"; import DiscourseRoute from "discourse/routes/discourse"; import DiscourseURL from "discourse/lib/url"; import Draft from "discourse/models/draft"; +import ENV from "discourse-common/config/environment"; // This route is used for retrieving a topic based on params export default DiscourseRoute.extend({ @@ -80,7 +81,7 @@ export default DiscourseRoute.extend({ } }) .catch(e => { - if (!Ember.testing) { + if (ENV.environment !== "test") { // eslint-disable-next-line no-console console.log("Could not view topic", e); } diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6 index a6f3eba69a..d4c22f9b3e 100644 --- a/app/assets/javascripts/discourse/routes/topic.js.es6 +++ b/app/assets/javascripts/discourse/routes/topic.js.es6 @@ -6,6 +6,7 @@ import { later } from "@ember/runloop"; import DiscourseRoute from "discourse/routes/discourse"; import DiscourseURL from "discourse/lib/url"; import { ID_CONSTRAINT } from "discourse/models/topic"; +import { EventTarget } from "rsvp"; let isTransitioning = false, scheduledReplace = null, @@ -313,5 +314,5 @@ const TopicRoute = DiscourseRoute.extend({ } }); -RSVP.EventTarget.mixin(TopicRoute); +EventTarget.mixin(TopicRoute); export default TopicRoute; diff --git a/app/assets/javascripts/discourse/routes/user.js.es6 b/app/assets/javascripts/discourse/routes/user.js.es6 index af8d85ccf0..85248c9038 100644 --- a/app/assets/javascripts/discourse/routes/user.js.es6 +++ b/app/assets/javascripts/discourse/routes/user.js.es6 @@ -1,4 +1,5 @@ import DiscourseRoute from "discourse/routes/discourse"; +import User from "discourse/models/user"; export default DiscourseRoute.extend({ titleToken() { @@ -41,7 +42,7 @@ export default DiscourseRoute.extend({ return this.currentUser; } - return Discourse.User.create({ + return User.create({ username: encodeURIComponent(params.username) }); }, diff --git a/app/assets/javascripts/discourse/services/logs-notice.js.es6 b/app/assets/javascripts/discourse/services/logs-notice.js.es6 index 73961054a1..9d124edf0b 100644 --- a/app/assets/javascripts/discourse/services/logs-notice.js.es6 +++ b/app/assets/javascripts/discourse/services/logs-notice.js.es6 @@ -1,10 +1,10 @@ import { isEmpty } from "@ember/utils"; import EmberObject from "@ember/object"; import { - default as computed, + default as discourseComputed, on, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; const LOGS_NOTICE_KEY = "logs-notice-text"; @@ -47,22 +47,22 @@ const LogsNotice = EmberObject.extend({ }); }, - @computed("text") + @discourseComputed("text") isEmpty(text) { return isEmpty(text); }, - @computed("text") + @discourseComputed("text") message(text) { return new Handlebars.SafeString(text); }, - @computed("currentUser") + @discourseComputed("currentUser") isAdmin(currentUser) { return currentUser && currentUser.admin; }, - @computed("isEmpty", "isAdmin") + @discourseComputed("isEmpty", "isAdmin") hidden(thisIsEmpty, isAdmin) { return !isAdmin || thisIsEmpty; }, @@ -72,7 +72,7 @@ const LogsNotice = EmberObject.extend({ this.keyValueStore.setItem(LOGS_NOTICE_KEY, this.text); }, - @computed( + @discourseComputed( "siteSettings.alert_admins_if_errors_per_hour", "siteSettings.alert_admins_if_errors_per_minute" ) diff --git a/app/assets/javascripts/discourse/services/search.js.es6 b/app/assets/javascripts/discourse/services/search.js.es6 index 641e35e482..c9e51f43c8 100644 --- a/app/assets/javascripts/discourse/services/search.js.es6 +++ b/app/assets/javascripts/discourse/services/search.js.es6 @@ -1,9 +1,9 @@ import { get } from "@ember/object"; import EmberObject from "@ember/object"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; export default EmberObject.extend({ searchContextEnabled: false, // checkbox to scope search @@ -16,7 +16,7 @@ export default EmberObject.extend({ this.set("highlightTerm", this.term); }, - @computed("searchContext") + @discourseComputed("searchContext") contextType: { get(searchContext) { if (searchContext) { diff --git a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs index 9f41a5d160..c7bfe57aaf 100644 --- a/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs +++ b/app/assets/javascripts/discourse/templates/components/bread-crumbs.hbs @@ -1,21 +1,17 @@ -{{category-drop - category=firstCategory - categories=parentCategoriesSorted - countSubcategories=true}} - -{{#if childCategories}} - {{category-drop - category=secondCategory - parentCategory=firstCategory - categories=childCategories - subCategory=true - noSubcategories=noSubcategories}} -{{/if}} +{{#each categoryBreadcrumbs as |breadcrumb|}} + {{#if breadcrumb.hasOptions}} + {{category-drop + category=breadcrumb.category + parentCategory=breadcrumb.parentCategory + categories=breadcrumb.options + subCategory=breadcrumb.isSubcategory + noSubcategories=breadcrumb.noSubcategories}} + {{/if}} +{{/each}} {{#if siteSettings.tagging_enabled}} {{tag-drop - firstCategory=firstCategory - secondCategory=secondCategory + currentCategory=category tagId=tagId}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/composer-user-selector.hbs b/app/assets/javascripts/discourse/templates/components/composer-user-selector.hbs index 9a9b1cfcf8..21c69bda7a 100644 --- a/app/assets/javascripts/discourse/templates/components/composer-user-selector.hbs +++ b/app/assets/javascripts/discourse/templates/components/composer-user-selector.hbs @@ -1,6 +1,6 @@ {{#if showSelector}} {{user-selector topicId=topicId - onChangeCallback=(action "triggerResize") + onChangeCallback=(action "triggerResize") id="private-message-users" includeMessageableGroups='true' placeholderKey="composer.users_placeholder" diff --git a/app/assets/javascripts/discourse/templates/components/count-i18n.hbs b/app/assets/javascripts/discourse/templates/components/count-i18n.hbs new file mode 100644 index 0000000000..f7a5927a07 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/count-i18n.hbs @@ -0,0 +1 @@ +{{i18nCount}} diff --git a/app/assets/javascripts/discourse/templates/components/d-navigation.hbs b/app/assets/javascripts/discourse/templates/components/d-navigation.hbs index 5cf53f5a20..a97af68cbc 100644 --- a/app/assets/javascripts/discourse/templates/components/d-navigation.hbs +++ b/app/assets/javascripts/discourse/templates/components/d-navigation.hbs @@ -12,6 +12,13 @@ {{category-notifications-button value=category.notification_level category=category}} {{/if}} +{{plugin-outlet name="before-create-topic-button" + args=(hash + canCreateTopic=canCreateTopic + createTopicDisabled=createTopicDisabled + createTopicLabel=createTopicLabel) +}} + {{create-topic-button canCreateTopic=canCreateTopic action=createTopic diff --git a/app/assets/javascripts/discourse/templates/components/directory-toggle.hbs b/app/assets/javascripts/discourse/templates/components/directory-toggle.hbs new file mode 100644 index 0000000000..91785f8530 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/directory-toggle.hbs @@ -0,0 +1 @@ +{{columnIcon}}{{title}}{{chevronIcon}} diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-general.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-general.hbs index 5f0720e838..1c30b0c762 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-general.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-general.hbs @@ -22,7 +22,7 @@ value=category.parent_category_id excludeCategoryId=category.id categories=parentCategories - allowSubCategories=false + allowSubCategories=true allowUncategorized=false}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs index 8c1f5472d3..1f65507a76 100644 --- a/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs +++ b/app/assets/javascripts/discourse/templates/components/edit-category-settings.hbs @@ -58,13 +58,6 @@ {{/if}} -
- -
-
+
diff --git a/app/assets/javascripts/discourse/templates/components/group-index-toggle.hbs b/app/assets/javascripts/discourse/templates/components/group-index-toggle.hbs new file mode 100644 index 0000000000..7ea697404f --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/group-index-toggle.hbs @@ -0,0 +1 @@ +{{i18n this.i18nKey}}{{chevronIcon}} diff --git a/app/assets/javascripts/discourse/templates/components/group-members-input.hbs b/app/assets/javascripts/discourse/templates/components/group-members-input.hbs deleted file mode 100644 index 73c5820a85..0000000000 --- a/app/assets/javascripts/discourse/templates/components/group-members-input.hbs +++ /dev/null @@ -1,30 +0,0 @@ - - -{{#if model.members}} -
- - {{currentPage}}/{{totalPages}} - -
-
- {{#each model.members as |member|}} - {{group-member member=member automatic=model.automatic removeAction=(action "removeMember")}} - {{/each}} -
-{{/if}} - -{{#unless model.automatic}} -
- {{user-selector usernames=model.usernames - placeholderKey="groups.selector_placeholder" - id="member-selector"}} - - {{#if addButton}} - {{d-button action=(action "addMembers") - class="add" - icon="plus" - disabled=disableAddButton - label="groups.manage.add_members"}} - {{/if}} -
-{{/unless}} diff --git a/app/assets/javascripts/discourse/templates/components/input-tip.hbs b/app/assets/javascripts/discourse/templates/components/input-tip.hbs new file mode 100644 index 0000000000..ade54357b5 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/input-tip.hbs @@ -0,0 +1 @@ +{{tipIcon}} {{tipReason}} diff --git a/app/assets/javascripts/discourse/templates/components/plugin-outlet.hbs b/app/assets/javascripts/discourse/templates/components/plugin-outlet.hbs index 73126a2572..8dbfeec09d 100644 --- a/app/assets/javascripts/discourse/templates/components/plugin-outlet.hbs +++ b/app/assets/javascripts/discourse/templates/components/plugin-outlet.hbs @@ -1,3 +1,3 @@ {{#each connectors as |c|}} - {{plugin-connector connector=c args=args class=c.classNames tagName=connectorTagName}} + {{plugin-connector connector=c args=args deprecatedArgs=deprecatedArgs class=c.classNames tagName=connectorTagName}} {{/each}} diff --git a/app/assets/javascripts/discourse/templates/components/popup-input-tip.hbs b/app/assets/javascripts/discourse/templates/components/popup-input-tip.hbs new file mode 100644 index 0000000000..ded803482f --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/popup-input-tip.hbs @@ -0,0 +1 @@ +{{closeIcon}}{{tipReason}} diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs b/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs index 22b5ceb385..e6bb87508a 100644 --- a/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs +++ b/app/assets/javascripts/discourse/templates/components/reviewable-flagged-post.hbs @@ -20,6 +20,7 @@ {{{reviewable.cooked}}} {{/if}}
+ {{plugin-outlet name="after-reviewable-flagged-post-body" args=(hash model=reviewable)}} {{yield}}
diff --git a/app/assets/javascripts/discourse/templates/components/reviewable-score.js.es6 b/app/assets/javascripts/discourse/templates/components/reviewable-score.js.es6 index 291db6c238..ee36959c61 100644 --- a/app/assets/javascripts/discourse/templates/components/reviewable-score.js.es6 +++ b/app/assets/javascripts/discourse/templates/components/reviewable-score.js.es6 @@ -1,13 +1,13 @@ +import discourseComputed from "discourse-common/utils/decorators"; import { gt } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; export default Component.extend({ tagName: "", showStatus: gt("rs.status", 0), - @computed("rs.score_type.title", "reviewable.target_created_by") + @discourseComputed("rs.score_type.title", "reviewable.target_created_by") title(title, targetCreatedBy) { if (title && targetCreatedBy) { return title.replace("{{username}}", targetCreatedBy.username); diff --git a/app/assets/javascripts/discourse/templates/components/tag-groups-form.hbs b/app/assets/javascripts/discourse/templates/components/tag-groups-form.hbs index 7436a829f6..512f9c0df5 100644 --- a/app/assets/javascripts/discourse/templates/components/tag-groups-form.hbs +++ b/app/assets/javascripts/discourse/templates/components/tag-groups-form.hbs @@ -9,7 +9,8 @@ everyTag=true allowCreate=true filterPlaceholder="tagging.groups.tags_placeholder" - unlimitedTagCount=true}} + unlimitedTagCount=true + excludeSynonyms=true}}
@@ -19,6 +20,7 @@ everyTag=true maximum=1 allowCreate=true + excludeSynonyms=true filterPlaceholder="tagging.groups.parent_tag_placeholder"}} {{i18n 'tagging.groups.parent_tag_description'}}
diff --git a/app/assets/javascripts/discourse/templates/components/tag-info.hbs b/app/assets/javascripts/discourse/templates/components/tag-info.hbs new file mode 100644 index 0000000000..0fe9b38596 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/tag-info.hbs @@ -0,0 +1,73 @@ +{{#if expanded}} +
+ {{#if tagInfo}} +
+ {{discourse-tag tagInfo.name tagName="div" size="large"}} + {{#if canAdminTag}} + {{d-button class="btn-default" action=(action "renameTag") icon="pencil-alt" label="tagging.rename_tag" id="rename-tag"}} + {{d-button class="btn-default" action=(action "toggleEditControls") icon="cog" label="tagging.edit_synonyms" id="edit-synonyms"}} + {{#if deleteAction}} + {{d-button class="btn-danger delete-tag" action=(action "deleteTag") icon="far-trash-alt" label="tagging.delete_tag" id="delete-tag"}} + {{/if}} + {{/if}} +
+
+ {{#if tagInfo.tag_group_names}} + {{tagGroupsInfo}} + {{/if}} + {{#if tagInfo.categories}} + {{categoriesInfo}} +
+ {{#each tagInfo.categories as |category|}} + {{category-link category}} + {{/each}} + {{/if}} + {{#if nothingToShow}} + {{i18n "tagging.default_info"}} + {{/if}} +
+ {{#if tagInfo.synonyms}} +
+

{{i18n "tagging.synonyms"}}

+
{{{i18n "tagging.synonyms_description" base_tag_name=tagInfo.name}}}
+
+ {{#each tagInfo.synonyms as |tag|}} +
+ {{discourse-tag tag.id pmOnly=tag.pmOnly tagName="div"}} + {{#if editSynonymsMode}} + + {{d-icon "unlink" title="tagging.remove_synonym"}} + + + {{d-icon "far-trash-alt" title="tagging.delete_tag"}} + + {{/if}} +
+ {{/each}} +
+
+
+ {{/if}} + {{#if editSynonymsMode}} +
+ + {{tag-chooser + id="add-synonyms" + tags=newSynonyms + everyTag=true + excludeSynonyms=true + excludeHasSynonyms=true + unlimitedTagCount=true}} +
+ {{d-button + class="btn-default" + action=(action "addSynonyms") + disabled=addSynonymsDisabled + label="tagging.add_synonyms"}} + {{/if}} + {{/if}} + {{#if loading}} +
{{i18n 'loading'}}
+ {{/if}} +
+{{/if}} diff --git a/app/assets/javascripts/discourse/templates/components/tag-list.hbs b/app/assets/javascripts/discourse/templates/components/tag-list.hbs index 24adf8aa0e..96ef2cd78c 100644 --- a/app/assets/javascripts/discourse/templates/components/tag-list.hbs +++ b/app/assets/javascripts/discourse/templates/components/tag-list.hbs @@ -9,8 +9,7 @@ {{/if}} {{#each sortedTags as |tag|}}
- {{discourse-tag tag.id isPrivateMessage=isPrivateMessage tagsForUser=tagsForUser}} {{#if tag.pmOnly}}{{d-icon "far-envelope"}}{{/if}}{{#if tag.totalCount}} x {{tag.totalCount}}{{/if}} + {{discourse-tag tag.id isPrivateMessage=isPrivateMessage pmOnly=tag.pmOnly tagsForUser=tagsForUser}} {{#if tag.pmOnly}}{{d-icon "far-envelope"}}{{/if}}{{#if tag.totalCount}} x {{tag.totalCount}}{{/if}}
{{/each}}
-
diff --git a/app/assets/javascripts/discourse/templates/full-page-search.hbs b/app/assets/javascripts/discourse/templates/full-page-search.hbs index c2c15bb389..9d5e739cab 100644 --- a/app/assets/javascripts/discourse/templates/full-page-search.hbs +++ b/app/assets/javascripts/discourse/templates/full-page-search.hbs @@ -17,6 +17,7 @@ {{/if}}
+ {{!-- context is only provided when searching from mobile view --}}
{{#if context}}
diff --git a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs index 6b1009f91c..38d50d468b 100644 --- a/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/posters-column.raw.hbs @@ -1,5 +1,9 @@ {{#each posters as |poster|}} -{{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" namePath="user.name" imageSize="small"}} + {{#if poster.moreCount}} + {{poster.moreCount}} + {{else}} + {{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" namePath="user.name" imageSize="small"}} + {{/if}} {{/each}} diff --git a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs index 473fbd9e38..6ccead6258 100644 --- a/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs +++ b/app/assets/javascripts/discourse/templates/modal/forgot-password.hbs @@ -1,4 +1,4 @@ -
+ {{#d-modal-body class="forgot-password-modal"}} {{#unless offerHelp}} @@ -12,7 +12,8 @@ {{d-button action=(action "resetPassword") label="forgot_password.reset" disabled=submitDisabled - class="btn-primary forgot-password-reset"}} + class="btn-primary forgot-password-reset" + type='submit'}} {{else}} {{d-button class="btn-large btn-primary" label="forgot_password.button_ok" diff --git a/app/assets/javascripts/discourse/templates/navigation/categories.hbs b/app/assets/javascripts/discourse/templates/navigation/categories.hbs index 4dba68da5c..b0f90e30a9 100644 --- a/app/assets/javascripts/discourse/templates/navigation/categories.hbs +++ b/app/assets/javascripts/discourse/templates/navigation/categories.hbs @@ -1,6 +1,6 @@ {{#d-section bodyClass="navigation-categories" class="navigation-container"}} {{d-navigation - filterMode="categories" + filterType="categories" showCategoryAdmin=showCategoryAdmin createCategory=(route-action "createCategory") reorderCategories=(route-action "reorderCategories") diff --git a/app/assets/javascripts/discourse/templates/password-reset.hbs b/app/assets/javascripts/discourse/templates/password-reset.hbs index 5599e20864..c7c83156c5 100644 --- a/app/assets/javascripts/discourse/templates/password-reset.hbs +++ b/app/assets/javascripts/discourse/templates/password-reset.hbs @@ -41,7 +41,7 @@ {{/second-factor-form}} {{/if}} {{#unless securityKeyRequired }} - {{d-button action=(action "submit") class='btn-primary' label='submit'}} + {{d-button action=(action "submit") class='btn-primary' label='submit' type='submit'}} {{/unless}} {{else}}

{{i18n 'user.change_password.choose'}}

@@ -56,7 +56,7 @@ {{d-icon "exclamation-triangle"}} {{i18n 'login.caps_lock_warning'}}
- {{d-button action=(action "submit") class='btn-primary' label='user.change_password.set_password'}} + {{d-button action=(action "submit") class='btn-primary' label='user.change_password.set_password' type='submit'}} {{/if}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs index 675e005c39..5b0da4457e 100644 --- a/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs +++ b/app/assets/javascripts/discourse/templates/preferences-second-factor.hbs @@ -1,6 +1,6 @@
{{#conditional-loading-spinner condition=loading}} -
+ {{#if showEnforcedNotice}}
diff --git a/app/assets/javascripts/discourse/templates/preferences/categories.hbs b/app/assets/javascripts/discourse/templates/preferences/categories.hbs index e264ef1137..671242a16a 100644 --- a/app/assets/javascripts/discourse/templates/preferences/categories.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/categories.hbs @@ -27,14 +27,16 @@
{{i18n 'user.watched_first_post_categories_instructions'}}
-
- - {{#if canSee}} - {{i18n 'user.tracked_topics_link'}} - {{/if}} - {{category-selector categories=model.mutedCategories blacklist=selectedCategories}} -
-
{{i18n (if hideMutedTags 'user.muted_categories_instructions' 'user.muted_categories_instructions_dont_hide')}}
+ {{#unless siteSettings.mute_all_categories_by_default}} +
+ + {{#if canSee}} + {{i18n 'user.tracked_topics_link'}} + {{/if}} + {{category-selector categories=model.mutedCategories blacklist=selectedCategories}} +
+
{{i18n (if hideMutedTags 'user.muted_categories_instructions' 'user.muted_categories_instructions_dont_hide')}}
+ {{/unless}}
{{plugin-outlet name="user-preferences-categories" args=(hash model=model save=(action "save"))}} diff --git a/app/assets/javascripts/discourse/templates/preferences/profile.hbs b/app/assets/javascripts/discourse/templates/preferences/profile.hbs index 3d5a2bcc75..7bd63f0930 100644 --- a/app/assets/javascripts/discourse/templates/preferences/profile.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/profile.hbs @@ -7,6 +7,11 @@
{{/if}} +
+ + {{timezone-input value=model.user_option.timezone onSelect=(action (mut model.user_option.timezone)) class="input-xxlarge"}} +
+
diff --git a/app/assets/javascripts/discourse/templates/review-index.hbs b/app/assets/javascripts/discourse/templates/review-index.hbs index 86799229f1..277c7723f8 100644 --- a/app/assets/javascripts/discourse/templates/review-index.hbs +++ b/app/assets/javascripts/discourse/templates/review-index.hbs @@ -24,7 +24,7 @@ {{#if filtersExpanded}} - {{plugin-outlet name="above-review-filters" args=(hash model=model)}} + {{plugin-outlet name="above-review-filters" args=(hash model=model additionalFilters=additionalFilters)}}
@@ -59,6 +59,10 @@
{{/if}} +
+ {{date-time-input-range showFromTime=false showToTime=false from=filterFromDate to=filterToDate onChange=setRange}} +
+
{{i18n "review.order_by"}} {{combo-box value=filterSortOrder content=sortOrders}} diff --git a/app/assets/javascripts/discourse/templates/tags/show.hbs b/app/assets/javascripts/discourse/templates/tags/show.hbs index d63e7b7cb6..d7f98ea87b 100644 --- a/app/assets/javascripts/discourse/templates/tags/show.hbs +++ b/app/assets/javascripts/discourse/templates/tags/show.hbs @@ -39,14 +39,17 @@ label=createTopicLabel action=(route-action "createTopic")}} - {{#if showAdminControls}} - {{d-button action=(route-action "renameTag") actionParam=tag icon="pencil-alt" class="admin-tag"}} - {{d-button action=(action "deleteTag") icon="far-trash-alt" class="admin-tag btn-danger"}} - {{/if}} + {{#if showToggleInfo}} + {{d-button icon="tag" label="tagging.info" action=(action "toggleInfo") id="show-tag-info"}} + {{/if}}
+{{#if showToggleInfo}} + {{tag-info tag=tag expanded=showInfo list=list deleteAction=(action "deleteTag")}} +{{/if}} + {{plugin-outlet name="discovery-list-container-top"}}
diff --git a/app/assets/javascripts/discourse/templates/user-invited-show.hbs b/app/assets/javascripts/discourse/templates/user-invited-show.hbs index 66a9954511..e6cbd79473 100644 --- a/app/assets/javascripts/discourse/templates/user-invited-show.hbs +++ b/app/assets/javascripts/discourse/templates/user-invited-show.hbs @@ -80,7 +80,7 @@ {{/if}} {{else}} {{unbound invite.email}} - {{format-date invite.created_at}} + {{format-date invite.updated_at}} {{#if invite.expired}}
{{i18n 'user.invited.expired'}}
diff --git a/app/assets/javascripts/discourse/widgets/connector.js.es6 b/app/assets/javascripts/discourse/widgets/connector.js.es6 index e3c0d9b628..9701f4c80a 100644 --- a/app/assets/javascripts/discourse/widgets/connector.js.es6 +++ b/app/assets/javascripts/discourse/widgets/connector.js.es6 @@ -22,9 +22,7 @@ export default class Connector { ); } - const container = Ember.getOwner - ? Ember.getOwner(mounted) - : mounted.container; + const container = getOwner ? getOwner(mounted) : mounted.container; let view; diff --git a/app/assets/javascripts/discourse/widgets/glue.js.es6 b/app/assets/javascripts/discourse/widgets/glue.js.es6 index c3a6af4b80..7d8a2a4561 100644 --- a/app/assets/javascripts/discourse/widgets/glue.js.es6 +++ b/app/assets/javascripts/discourse/widgets/glue.js.es6 @@ -3,6 +3,7 @@ import { scheduleOnce } from "@ember/runloop"; import { diff, patch } from "virtual-dom"; import { queryRegistry } from "discourse/widgets/widget"; import DirtyKeys from "discourse/lib/dirty-keys"; +import ENV from "discourse-common/config/environment"; export default class WidgetGlue { constructor(name, register, attrs) { @@ -34,7 +35,7 @@ export default class WidgetGlue { cancel(this._timeout); // in test mode return early if store cannot be found - if (Ember.testing) { + if (ENV.environment === "test") { try { this.register.lookup("service:store"); } catch (e) { @@ -53,6 +54,18 @@ export default class WidgetGlue { } cleanUp() { + const widgets = []; + const findWidgets = widget => { + widget.vnode.children.forEach(child => { + if (child.constructor.name === "CustomWidget") { + widgets.push(child); + findWidgets(child, widgets); + } + }); + }; + findWidgets(this._tree, widgets); + widgets.reverse().forEach(widget => widget.destroy()); + cancel(this._timeout); } } diff --git a/app/assets/javascripts/discourse/widgets/hamburger-categories.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-categories.js.es6 index 2112dd4f1d..3a51939f33 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-categories.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-categories.js.es6 @@ -1,6 +1,7 @@ import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; import { number } from "discourse/lib/formatter"; +import Category from "discourse/models/category"; createWidget("hamburger-category", { tagName: "li.category-link", @@ -10,7 +11,7 @@ createWidget("hamburger-category", { this.tagName += ".subcategory"; } - this.tagName += ".category-" + Discourse.Category.slugFor(c, "-"); + this.tagName += ".category-" + Category.slugFor(c, "-"); const results = [ this.attach("category-link", { category: c, allowUncategorized: true }) diff --git a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 index b11689796a..912594351e 100644 --- a/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hamburger-menu.js.es6 @@ -173,7 +173,7 @@ export default createWidget("hamburger-menu", { listCategories() { const maxCategoriesToDisplay = this.siteSettings .header_dropdown_category_count; - let categories = this.site.get("categoriesByCount"); + let categories = []; if (this.currentUser) { const allCategories = this.site @@ -203,6 +203,10 @@ export default createWidget("hamburger-menu", { .filter(c => !categories.includes(c)) .sort((a, b) => b.topic_count - a.topic_count) ); + } else { + categories = this.site + .get("categoriesByCount") + .filter(c => c.notification_level !== NotificationLevels.MUTED); } if (!this.siteSettings.allow_uncategorized_topics) { @@ -305,7 +309,7 @@ export default createWidget("hamburger-menu", { if (this.settings.showCategories) { results.push(this.listCategories()); - results.push(h("hr")); + results.push(h("hr.categories-separator")); } results.push( @@ -335,7 +339,7 @@ export default createWidget("hamburger-menu", { this.sendWidgetAction("toggleHamburger"); } else { const $window = $(window); - const windowWidth = parseInt($window.width(), 10); + const windowWidth = $window.width(); const $panel = $(".menu-panel"); $panel.addClass("animate"); const panelOffsetDirection = this.site.mobileView ? "left" : "right"; diff --git a/app/assets/javascripts/discourse/widgets/header.js.es6 b/app/assets/javascripts/discourse/widgets/header.js.es6 index c86931260b..e08bab8741 100644 --- a/app/assets/javascripts/discourse/widgets/header.js.es6 +++ b/app/assets/javascripts/discourse/widgets/header.js.es6 @@ -267,7 +267,7 @@ createWidget("header-cloak", { scheduleRerender() {} }); -const forceContextEnabled = ["category", "user", "private_messages"]; +const forceContextEnabled = ["category", "user", "private_messages", "tag"]; let additionalPanels = []; export function attachAdditionalPanel(name, toggle, transformAttrs) { diff --git a/app/assets/javascripts/discourse/widgets/hooks.js.es6 b/app/assets/javascripts/discourse/widgets/hooks.js.es6 index d4796d5bb9..979c45a82d 100644 --- a/app/assets/javascripts/discourse/widgets/hooks.js.es6 +++ b/app/assets/javascripts/discourse/widgets/hooks.js.es6 @@ -7,6 +7,8 @@ const MOUSE_DOWN_OUTSIDE_ATTRIBUTE_NAME = const KEY_UP_ATTRIBUTE_NAME = "_discourse_key_up_widget"; const KEY_DOWN_ATTRIBUTE_NAME = "_discourse_key_down_widget"; const DRAG_ATTRIBUTE_NAME = "_discourse_drag_widget"; +const INPUT_ATTRIBUTE_NAME = "_discourse_input_widget"; +const CHANGE_ATTRIBUTE_NAME = "_discourse_change_widget"; function buildHook(attributeName, setAttr) { return class { @@ -42,11 +44,19 @@ export const WidgetMouseDownOutsideHook = buildHook( export const WidgetKeyUpHook = buildHook(KEY_UP_ATTRIBUTE_NAME); export const WidgetKeyDownHook = buildHook(KEY_DOWN_ATTRIBUTE_NAME); export const WidgetDragHook = buildHook(DRAG_ATTRIBUTE_NAME); +export const WidgetInputHook = buildHook(INPUT_ATTRIBUTE_NAME); +export const WidgetChangeHook = buildHook(CHANGE_ATTRIBUTE_NAME); -function nodeCallback(node, attrName, cb) { +function nodeCallback(node, attrName, cb, options = { rerender: true }) { + const { rerender } = options; const widget = findWidget(node, attrName); + if (widget) { - widget.rerenderResult(() => cb(widget)); + if (rerender) { + widget.rerenderResult(() => cb(widget)); + } else { + cb(widget); + } } } @@ -168,5 +178,17 @@ WidgetClickHook.setupDocumentCallback = function() { nodeCallback(e.target, KEY_DOWN_ATTRIBUTE_NAME, w => w.keyDown(e)); }); + $(document).on("input.discourse-widget", e => { + nodeCallback(e.target, INPUT_ATTRIBUTE_NAME, w => w.input(e), { + rerender: false + }); + }); + + $(document).on("change.discourse-widget", e => { + nodeCallback(e.target, CHANGE_ATTRIBUTE_NAME, w => w.change(e), { + rerender: false + }); + }); + _watchingDocument = true; }; diff --git a/app/assets/javascripts/discourse/widgets/liked-consolidated-notification-item.js.es6 b/app/assets/javascripts/discourse/widgets/liked-consolidated-notification-item.js.es6 index 96a3604255..bc4ce3f43f 100644 --- a/app/assets/javascripts/discourse/widgets/liked-consolidated-notification-item.js.es6 +++ b/app/assets/javascripts/discourse/widgets/liked-consolidated-notification-item.js.es6 @@ -22,7 +22,7 @@ createWidgetFrom( const description = I18n.t( "notifications.liked_consolidated_description", { - count: parseInt(data.count) + count: parseInt(data.count, 10) } ); diff --git a/app/assets/javascripts/discourse/widgets/link.js.es6 b/app/assets/javascripts/discourse/widgets/link.js.es6 index 841fda069f..1194e1c8ae 100644 --- a/app/assets/javascripts/discourse/widgets/link.js.es6 +++ b/app/assets/javascripts/discourse/widgets/link.js.es6 @@ -79,7 +79,7 @@ export default createWidget("link", { const currentUser = this.currentUser; if (currentUser && attrs.badgeCount) { - const val = parseInt(currentUser.get(attrs.badgeCount)); + const val = parseInt(currentUser.get(attrs.badgeCount), 10); if (val > 0) { const title = attrs.badgeTitle ? I18n.t(attrs.badgeTitle) : ""; result.push(" "); diff --git a/app/assets/javascripts/discourse/widgets/membership-request-consolidated-notification-item.js.es6 b/app/assets/javascripts/discourse/widgets/membership-request-consolidated-notification-item.js.es6 new file mode 100644 index 0000000000..fafa3656f6 --- /dev/null +++ b/app/assets/javascripts/discourse/widgets/membership-request-consolidated-notification-item.js.es6 @@ -0,0 +1,22 @@ +import { createWidgetFrom } from "discourse/widgets/widget"; +import { DefaultNotificationItem } from "discourse/widgets/default-notification-item"; +import { userPath } from "discourse/lib/url"; + +createWidgetFrom( + DefaultNotificationItem, + "membership-request-consolidated-notification-item", + { + url() { + return userPath( + `${this.attrs.username || this.currentUser.username}/messages` + ); + }, + + text(notificationName, data) { + return I18n.t("notifications.membership_request_consolidated", { + group_name: data.group_name, + count: parseInt(data.count, 10) + }); + } + } +); diff --git a/app/assets/javascripts/discourse/widgets/menu-panel.js.es6 b/app/assets/javascripts/discourse/widgets/menu-panel.js.es6 index 69ccea7626..cc8218e8b6 100644 --- a/app/assets/javascripts/discourse/widgets/menu-panel.js.es6 +++ b/app/assets/javascripts/discourse/widgets/menu-panel.js.es6 @@ -19,7 +19,10 @@ createWidget("menu-links", { const result = []; result.push( - h("ul.menu-links.columned", links.map(l => h("li", liOpts, l))) + h( + "ul.menu-links.columned", + links.map(l => h("li", liOpts, l)) + ) ); result.push(h("div.clearfix")); diff --git a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 index 780cb20e1f..2039ed446f 100644 --- a/app/assets/javascripts/discourse/widgets/post-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-menu.js.es6 @@ -5,11 +5,12 @@ import { avatarAtts } from "discourse/widgets/actions-summary"; import { h } from "virtual-dom"; import showModal from "discourse/lib/show-modal"; import { Promise } from "rsvp"; +import ENV from "discourse-common/config/environment"; const LIKE_ACTION = 2; function animateHeart($elem, start, end, complete) { - if (Ember.testing) { + if (ENV.environment === "test") { return run(this, complete); } diff --git a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 index ea45f0ed7c..709d70153a 100644 --- a/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post-small-action.js.es6 @@ -5,6 +5,7 @@ import { h } from "virtual-dom"; import { avatarFor } from "discourse/widgets/post"; import { userPath } from "discourse/lib/url"; import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; +import { computed } from "@ember/object"; export function actionDescriptionHtml(actionCode, createdAt, username) { const dt = new Date(createdAt); @@ -22,7 +23,7 @@ export function actionDescriptionHtml(actionCode, createdAt, username) { } export function actionDescription(actionCode, createdAt, username) { - return Ember.computed(actionCode, createdAt, function() { + return computed(actionCode, createdAt, function() { const ac = this.get(actionCode); if (ac) { return actionDescriptionHtml(ac, this.get(createdAt), this.get(username)); diff --git a/app/assets/javascripts/discourse/widgets/post.js.es6 b/app/assets/javascripts/discourse/widgets/post.js.es6 index 8eec4318a8..5107da42f0 100644 --- a/app/assets/javascripts/discourse/widgets/post.js.es6 +++ b/app/assets/javascripts/discourse/widgets/post.js.es6 @@ -16,6 +16,7 @@ import { import hbs from "discourse/widgets/hbs-compiler"; import { relativeAgeMediumSpan } from "discourse/lib/formatter"; import { prioritizeNameInUx } from "discourse/lib/settings"; +import { Promise } from "rsvp"; function transformWithCallbacks(post) { let transformed = transformBasicPost(post); @@ -709,7 +710,7 @@ export default createWidget("post", { // only warn once per day const yesterday = new Date().getTime() - 1000 * 60 * 60 * 24; - if (lastWarnedLikes && parseInt(lastWarnedLikes) > yesterday) { + if (lastWarnedLikes && parseInt(lastWarnedLikes, 10) > yesterday) { return; } diff --git a/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 index a37200e595..46b209d1af 100644 --- a/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 +++ b/app/assets/javascripts/discourse/widgets/quick-access-item.js.es6 @@ -33,11 +33,21 @@ createWidget("quick-access-item", { return result; }, - html({ icon, href }) { + html({ href, icon }) { + let content = this._contentHtml(); + + if (href) { + let topicId = href.match(/\/t\/.*?\/(\d+)/); + if (topicId && topicId[1]) { + topicId = escapeExpression(topicId[1]); + content = `${content}`; + } + } + return h("a", { attributes: { href } }, [ iconNode(icon), new RawHtml({ - html: `
${this._usernameHtml()}${this._contentHtml()}
` + html: `
${this._usernameHtml()}${content}
` }) ]); }, diff --git a/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 index 904629b818..6b64ee241a 100644 --- a/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 +++ b/app/assets/javascripts/discourse/widgets/quick-access-panel.js.es6 @@ -2,6 +2,7 @@ import Session from "discourse/models/session"; import { createWidget } from "discourse/widgets/widget"; import { h } from "virtual-dom"; import { headerHeight } from "discourse/components/site-header"; +import { Promise } from "rsvp"; const AVERAGE_ITEM_HEIGHT = 55; diff --git a/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 index f2bfc7ffb7..0d77177a25 100644 --- a/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 +++ b/app/assets/javascripts/discourse/widgets/quick-access-profile.js.es6 @@ -1,5 +1,6 @@ import QuickAccessPanel from "discourse/widgets/quick-access-panel"; import { createWidgetFrom } from "discourse/widgets/widget"; +import { Promise } from "rsvp"; createWidgetFrom(QuickAccessPanel, "quick-access-profile", { buildKey: () => "quick-access-profile", diff --git a/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 b/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 index de2ea2ac42..93312553bd 100644 --- a/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 +++ b/app/assets/javascripts/discourse/widgets/search-menu-controls.js.es6 @@ -54,7 +54,9 @@ createWidget("search-context", { if (ctx) { const description = searchContextDescription( get(ctx, "type"), - get(ctx, "user.username") || get(ctx, "category.name") + get(ctx, "user.username") || + get(ctx, "category.name") || + get(ctx, "tag.id") ); result.push( h("label", [ diff --git a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 index 813a390ec5..eedb6cf06e 100644 --- a/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 +++ b/app/assets/javascripts/discourse/widgets/topic-timeline.js.es6 @@ -188,11 +188,7 @@ createWidget("timeline-scrollarea", { if (this.state.position !== result.scrollPosition) { this.state.position = result.scrollPosition; - this.sendWidgetAction( - "updatePosition", - result.position, - result.scrollPosition - ); + this.sendWidgetAction("updatePosition", current); } return result; @@ -215,7 +211,8 @@ createWidget("timeline-scrollarea", { position.lastRead > 3 && Math.abs(position.lastRead - position.current) > 3 && Math.abs(position.lastRead - position.total) > 1 && - (position.lastRead && position.lastRead !== position.total); + position.lastRead && + position.lastRead !== position.total; if (hasBackPosition) { const lastReadTop = Math.round( @@ -266,7 +263,7 @@ createWidget("timeline-scrollarea", { const position = this.position(); this.state.scrolledPost = position.current; - if (position.current === position.scrollPosition) { + if (position.current === position.scrollPosition || this.site.mobileView) { this.sendWidgetAction("jumpToIndex", position.current); } else { this.sendWidgetAction("jumpEnd"); @@ -391,7 +388,7 @@ export default createWidget("topic-timeline", { return { position: null, excerpt: null }; }, - updatePosition(postIdx, scrollPosition) { + updatePosition(scrollPosition) { if (!this.attrs.fullScreen) { return; } @@ -407,8 +404,7 @@ export default createWidget("topic-timeline", { } // we have an off by one, stream is zero based, - // postIdx is 1 based - stream.excerpt(postIdx - 1).then(info => { + stream.excerpt(scrollPosition - 1).then(info => { if (info && this.state.position === scrollPosition) { let excerpt = ""; diff --git a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 index cf669d1edc..6f4b5c323c 100644 --- a/app/assets/javascripts/discourse/widgets/user-menu.js.es6 +++ b/app/assets/javascripts/discourse/widgets/user-menu.js.es6 @@ -109,13 +109,16 @@ createWidget("user-menu-links", { glyphs.push(this.notificationsGlyph()); glyphs.push(this.bookmarksGlyph()); - if (this.siteSettings.enable_personal_messages) { + if (this.siteSettings.enable_personal_messages || this.currentUser.staff) { glyphs.push(this.messagesGlyph()); } return h("ul.menu-links-row", [ links.map(l => h("li.user", this.linkHtml(l))), - h("li.glyphs", glyphs.map(l => this.glyphHtml(l))) + h( + "li.glyphs", + glyphs.map(l => this.glyphHtml(l)) + ) ]); }, @@ -221,7 +224,7 @@ export default createWidget("user-menu", { this.sendWidgetAction("toggleUserMenu"); } else { const $window = $(window); - const windowWidth = parseInt($window.width(), 10); + const windowWidth = $window.width(); const $panel = $(".menu-panel"); $panel.addClass("animate"); $panel.css("right", -windowWidth); diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6 index ed848faa0d..cb75df16ae 100644 --- a/app/assets/javascripts/discourse/widgets/widget.js.es6 +++ b/app/assets/javascripts/discourse/widgets/widget.js.es6 @@ -4,11 +4,14 @@ import { WidgetKeyUpHook, WidgetKeyDownHook, WidgetMouseDownOutsideHook, - WidgetDragHook + WidgetDragHook, + WidgetInputHook, + WidgetChangeHook } from "discourse/widgets/hooks"; import { h } from "virtual-dom"; import DecoratorHelper from "discourse/widgets/decorator-helper"; import { Promise } from "rsvp"; +import ENV from "discourse-common/config/environment"; const _registry = {}; @@ -115,8 +118,10 @@ export default class Widget { this.appEvents = register.lookup("service:app-events"); this.keyValueStore = register.lookup("key-value-store:main"); + this.init(this.attrs); + // Helps debug widgets - if (Discourse.Environment === "development" || Ember.testing) { + if (Discourse.Environment === "development" || ENV.environment === "test") { const ds = this.defaultState(attrs); if (typeof ds !== "object") { throw new Error(`defaultState must return an object`); @@ -141,6 +146,8 @@ export default class Widget { return {}; } + init() {} + destroy() {} render(prev) { @@ -370,6 +377,14 @@ export default class Widget { properties["widget-drag"] = new WidgetDragHook(this); } + if (this.input) { + properties["widget-input"] = new WidgetInputHook(this); + } + + if (this.change) { + properties["widget-change"] = new WidgetChangeHook(this); + } + const attributes = properties["attributes"] || {}; properties.attributes = attributes; diff --git a/app/assets/javascripts/ember-addons/decorator-alias.js.es6 b/app/assets/javascripts/ember-addons/decorator-alias.js.es6 index 44d911a1dd..327dc7a2cb 100644 --- a/app/assets/javascripts/ember-addons/decorator-alias.js.es6 +++ b/app/assets/javascripts/ember-addons/decorator-alias.js.es6 @@ -2,7 +2,7 @@ import extractValue from "./utils/extract-value"; export default function decoratorAlias(fn, errorMessage) { return function(...params) { - // determine if user called as @computed('blah', 'blah') or @computed + // determine if user called as @discourseComputed('blah', 'blah') or @discourseComputed if (params.length === 0) { throw new Error(errorMessage); } else { diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index 68657e28b6..ec33b0c523 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -319,7 +319,8 @@ I18n.enableVerboseLocalization = function() { if (!_.isEmpty(value)) { message += ", parameters: " + JSON.stringify(value); } - Ember.Logger.info(message); + // eslint-disable-next-line no-console + console.info(message); } return t.apply(I18n, [scope, value]) + " (#" + current + ")"; }; diff --git a/app/assets/javascripts/markdown-it-bundle.js b/app/assets/javascripts/markdown-it-bundle.js index 2d0ec14ee5..052891aaf5 100644 --- a/app/assets/javascripts/markdown-it-bundle.js +++ b/app/assets/javascripts/markdown-it-bundle.js @@ -16,4 +16,5 @@ //= require ./pretty-text/engines/discourse-markdown/text-post-process //= require ./pretty-text/engines/discourse-markdown/upload-protocol //= require ./pretty-text/engines/discourse-markdown/inject-line-number +//= require ./pretty-text/engines/discourse-markdown/resize-controls //= require ./pretty-text/engines/discourse-markdown/d-wrap diff --git a/app/assets/javascripts/preload-application-data.js.no-module.es6 b/app/assets/javascripts/preload-application-data.js.no-module.es6 index f78ea11367..8dfab7b145 100644 --- a/app/assets/javascripts/preload-application-data.js.no-module.es6 +++ b/app/assets/javascripts/preload-application-data.js.no-module.es6 @@ -27,13 +27,15 @@ I18n.defaultLocale = setupData.defaultLocale; Discourse.start(); Discourse.set("assetVersion", setupData.assetVersion); - Discourse.Session.currentProp( + + let Session = require("discourse/models/session").default; + Session.currentProp( "disableCustomCSS", setupData.disableCustomCss === "true" ); if (setupData.safeMode) { - Discourse.Session.currentProp("safe_mode", setupData.safeMode); + Session.currentProp("safe_mode", setupData.safeMode); } Discourse.HighlightJSPath = setupData.highlightJsPath; diff --git a/app/assets/javascripts/preload-store.js.es6 b/app/assets/javascripts/preload-store.js.es6 index b096afd4c1..132588695b 100644 --- a/app/assets/javascripts/preload-store.js.es6 +++ b/app/assets/javascripts/preload-store.js.es6 @@ -4,6 +4,8 @@ @class PreloadStore **/ +import { Promise } from "rsvp"; + export default { data: {}, @@ -19,14 +21,14 @@ export default { **/ getAndRemove(key, finder) { if (this.data[key]) { - var promise = Ember.RSVP.resolve(this.data[key]); + let promise = Promise.resolve(this.data[key]); delete this.data[key]; return promise; } if (finder) { - return new Ember.RSVP.Promise(function(resolve, reject) { - var result = finder(); + return new Promise(function(resolve, reject) { + let result = finder(); // If the finder returns a promise, we support that too if (result && result.then) { @@ -39,7 +41,7 @@ export default { }); } - return Ember.RSVP.resolve(null); + return Promise.resolve(null); }, get(key) { diff --git a/app/assets/javascripts/pretty-text/emoji.js.es6 b/app/assets/javascripts/pretty-text/emoji.js.es6 index 2089902c3b..fec9b725c4 100644 --- a/app/assets/javascripts/pretty-text/emoji.js.es6 +++ b/app/assets/javascripts/pretty-text/emoji.js.es6 @@ -42,10 +42,31 @@ export function buildReplacementsList(emojiReplacements) { .join("|"); } -const unicodeRegexp = new RegExp( - buildReplacementsList(replacements) + "|\\B:[^\\s:]+(?::t\\d)?:?\\B", - "g" -); +let replacementListCache; +const unicodeRegexpCache = {}; + +function replacementList() { + if (replacementListCache === undefined) { + replacementListCache = buildReplacementsList(replacements); + } + + return replacementListCache; +} + +function unicodeRegexp(inlineEmoji) { + if (unicodeRegexpCache[inlineEmoji] === undefined) { + const emojiExpression = inlineEmoji + ? "|:[^\\s:]+(?::t\\d)?:?" + : "|\\B:[^\\s:]+(?::t\\d)?:?\\B"; + + unicodeRegexpCache[inlineEmoji] = new RegExp( + replacementList() + emojiExpression, + "g" + ); + } + + return unicodeRegexpCache[inlineEmoji]; +} // add all default emojis emojis.forEach(code => (emojiHash[code] = true)); @@ -56,12 +77,29 @@ Object.keys(aliases).forEach(name => { aliases[name].forEach(alias => (aliasHash[alias] = name)); }); +function isReplacableInlineEmoji(string, index, inlineEmoji) { + if (inlineEmoji) return true; + + // index depends on regex; when `inlineEmoji` is false, the regex starts + // with a `\B` character, so there's no need to subtract from the index + const beforeEmoji = string.slice(0, index - (inlineEmoji ? 1 : 0)); + + return ( + beforeEmoji.length === 0 || + /(?:\s|[>.,\/#!$%^&*;:{}=\-_`~()])$/.test(beforeEmoji) || + new RegExp(`(?:${replacementList()})$`).test(beforeEmoji) + ); +} + export function performEmojiUnescape(string, opts) { if (!string) { return; } - return string.replace(unicodeRegexp, m => { + const inlineEmoji = opts.inlineEmoji; + const regexp = unicodeRegexp(inlineEmoji); + + return string.replace(regexp, (m, index) => { const isEmoticon = opts.enableEmojiShortcuts && !!translations[m]; const isUnicodeEmoticon = !!replacements[m]; let emojiVal; @@ -78,7 +116,11 @@ export function performEmojiUnescape(string, opts) { ? "emoji emoji-custom" : "emoji"; - return url && (isEmoticon || hasEndingColon || isUnicodeEmoticon) + const isReplacable = + (isEmoticon || hasEndingColon || isUnicodeEmoticon) && + isReplacableInlineEmoji(string, index, inlineEmoji); + + return url && isReplacable ? `${emojiVal}` @@ -89,14 +131,19 @@ export function performEmojiUnescape(string, opts) { } export function performEmojiEscape(string, opts) { - return string.replace(unicodeRegexp, m => { - if (!!translations[m]) { - return opts.emojiShortcuts ? `:${translations[m]}:` : m; - } else if (!!replacements[m]) { - return `:${replacements[m]}:`; - } else { - return m; + const inlineEmoji = opts.inlineEmoji; + const regexp = unicodeRegexp(inlineEmoji); + + return string.replace(regexp, (m, index) => { + if (isReplacableInlineEmoji(string, index, inlineEmoji)) { + if (!!translations[m]) { + return opts.emojiShortcuts ? `:${translations[m]}:` : m; + } else if (!!replacements[m]) { + return `:${replacements[m]}:`; + } } + + return m; }); return string; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 index 7bf824de7f..c491468745 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown-it.js.es6 @@ -145,22 +145,22 @@ function renderImage(tokens, idx, options, env, slf) { // calculate using percentage if (match[5] && match[6] && match[6] === "%") { let percent = parseFloat(match[5]) / 100.0; - width = parseInt(width * percent); - height = parseInt(height * percent); + width = parseInt(width * percent, 10); + height = parseInt(height * percent, 10); } // calculate using only given width if (match[5] && match[6] && match[6] === "x") { let wr = parseFloat(match[5]) / width; - width = parseInt(match[5]); - height = parseInt(height * wr); + width = parseInt(match[5], 10); + height = parseInt(height * wr, 10); } // calculate using only given height if (match[5] && match[4] && match[4] === "x" && !match[6]) { let hr = parseFloat(match[5]) / height; - height = parseInt(match[5]); - width = parseInt(width * hr); + height = parseInt(match[5], 10); + width = parseInt(width * hr, 10); } if (token.attrIndex("width") === -1) { diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 index 4e23b196c1..98788ae063 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 @@ -185,7 +185,10 @@ export function setup(helper) { if (simpleUrlRegex.test(url)) { startToken.type = "link_open"; startToken.tag = "a"; - startToken.attrs = [["href", url], ["data-bbcode", "true"]]; + startToken.attrs = [ + ["href", url], + ["data-bbcode", "true"] + ]; startToken.content = ""; startToken.nesting = 1; @@ -214,7 +217,10 @@ export function setup(helper) { let email = tagInfo.attrs["_default"] || content; token = state.push("link_open", "a", 1); - token.attrs = [["href", "mailto:" + email], ["data-bbcode", "true"]]; + token.attrs = [ + ["href", "mailto:" + email], + ["data-bbcode", "true"] + ]; token = state.push("text", "", 0); token.content = content; @@ -228,7 +234,10 @@ export function setup(helper) { tag: "img", replace: function(state, tagInfo, content) { let token = state.push("image", "img", 0); - token.attrs = [["src", content], ["alt", ""]]; + token.attrs = [ + ["src", content], + ["alt", ""] + ]; token.children = []; return true; } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 index dbe6883606..e9e34ab27e 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 @@ -8,7 +8,10 @@ function addHashtag(buffer, matches, state) { if (result) { token = new state.Token("link_open", "a", 1); - token.attrs = [["class", "hashtag"], ["href", result[0]]]; + token.attrs = [ + ["class", "hashtag"], + ["href", result[0]] + ]; token.block = false; buffer.push(token); diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6 index 3203110c94..7358bdaa06 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/onebox.js.es6 @@ -1,6 +1,5 @@ import { lookupCache } from "pretty-text/oneboxer-cache"; import { cachedInlineOnebox } from "pretty-text/inline-oneboxer"; - import { INLINE_ONEBOX_LOADING_CSS_CLASS, INLINE_ONEBOX_CSS_CLASS diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 index e1c9a31bf0..6c2860577a 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 @@ -120,7 +120,8 @@ const rule = { title = performEmojiUnescape(topicInfo.title, { getURL: options.getURL, emojiSet: options.emojiSet, - enableEmojiShortcuts: options.enableEmojiShortcuts + enableEmojiShortcuts: options.enableEmojiShortcuts, + inlineEmoji: options.inlineEmoji }); } @@ -156,6 +157,7 @@ export function setup(helper) { opts.enableEmoji = siteSettings.enable_emoji; opts.emojiSet = siteSettings.emoji_set; opts.enableEmojiShortcuts = siteSettings.enable_emoji_shortcuts; + opts.inlineEmoji = siteSettings.enable_inline_emoji_translation; }); helper.registerPlugin(md => { diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 new file mode 100644 index 0000000000..bb8e381d33 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/resize-controls.js.es6 @@ -0,0 +1,160 @@ +function isUpload(token) { + return token.content.includes("upload://"); +} + +function hasMetadata(token) { + return token.content.match(/(\d{1,4}x\d{1,4})/); +} + +function buildToken(state, type, tag, klass, nesting) { + const token = new state.Token(type, tag, nesting); + token.block = true; + token.attrs = [["class", klass]]; + return token; +} + +function wrapImage(tokens, index, state, imgNumber) { + const imgToken = tokens[index]; + let selectedScale = imgToken.content + .split(",") + .pop() + .trim(); + tokens.splice( + index, + 0, + buildToken(state, "wrap_image_open", "div", "image-wrapper", 1) + ); + + const newElements = []; + const btnWrapper = buildToken( + state, + "wrap_button_open", + "div", + "button-wrapper", + 1 + ); + btnWrapper.attrs.push(["data-image-index", imgNumber]); + newElements.push(btnWrapper); + + const minimumScale = 50; + const scales = [100, 75, minimumScale]; + const overwriteScale = !scales.find(scale => `${scale}%` === selectedScale); + if (overwriteScale) selectedScale = "100%"; + + scales.forEach(scale => { + const scaleText = `${scale}%`; + + const btnClass = + scaleText === selectedScale ? "scale-btn active" : "scale-btn"; + const scaleBtn = buildToken( + state, + "scale_button_open", + "span", + btnClass, + 1 + ); + scaleBtn.attrs.push(["data-scale", scale]); + newElements.push(scaleBtn); + + let textToken = buildToken(state, "text", "", "", 0); + textToken.content = scaleText; + newElements.push(textToken); + + newElements.push(buildToken(state, "scale_button_close", "span", "", -1)); + + if (scale !== minimumScale) { + newElements.push(buildToken(state, "separator", "span", "separator", 1)); + let separatorToken = buildToken(state, "text", "", "", 0); + separatorToken.content = " • "; + newElements.push(separatorToken); + newElements.push(buildToken(state, "separator_close", "span", "", -1)); + } + }); + newElements.push(buildToken(state, "wrap_button_close", "div", "", -1)); + + newElements.push(buildToken(state, "wrap_image_close", "div", "", -1)); + + const afterImageIndex = index + 2; + tokens.splice(afterImageIndex, 0, ...newElements); +} + +function removeParagraph(tokens, imageIndex) { + if ( + tokens[imageIndex - 1] && + tokens[imageIndex - 1].type === "paragraph_open" + ) + tokens.splice(imageIndex - 1, 1); + if (tokens[imageIndex] && tokens[imageIndex].type === "paragraph_close") + tokens.splice(imageIndex, 1); +} + +function updateIndexes(indexes, name) { + indexes[name].push(indexes.current); + indexes.current++; +} + +function wrapImages(tokens, tokenIndexes, state, imgNumberIndexes) { + //We do this in reverse order because it's easier for #wrapImage to manipulate the tokens array. + for (let j = tokenIndexes.length - 1; j >= 0; j--) { + let index = tokenIndexes[j]; + removeParagraph(tokens, index); + wrapImage(tokens, index, state, imgNumberIndexes.pop()); + } +} + +function rule(state) { + let blockIndexes = []; + const indexNumbers = { current: 0, blocks: [], childrens: [] }; + + for (let i = 0; i < state.tokens.length; i++) { + let blockToken = state.tokens[i]; + const blockTokenImage = blockToken.tag === "img"; + + if (blockTokenImage && isUpload(blockToken) && hasMetadata(blockToken)) { + blockIndexes.push(i); + updateIndexes(indexNumbers, "blocks"); + } + + if (!blockToken.children) continue; + + const childrenIndexes = []; + for (let j = 0; j < blockToken.children.length; j++) { + let token = blockToken.children[j]; + const childrenImage = token.tag === "img"; + + if (childrenImage && isUpload(blockToken) && hasMetadata(token)) { + removeParagraph(state.tokens, i); + childrenIndexes.push(j); + updateIndexes(indexNumbers, "childrens"); + } + } + + wrapImages( + blockToken.children, + childrenIndexes, + state, + indexNumbers.childrens + ); + } + + wrapImages(state.tokens, blockIndexes, state, indexNumbers.blocks); +} + +export function setup(helper) { + const opts = helper.getOptions(); + if (opts.previewing) { + helper.whiteList([ + "div.image-wrapper", + "div.button-wrapper", + "span[class=scale-btn]", + "span[class=scale-btn active]", + "span.separator", + "span.scale-btn[data-scale]", + "span.button-wrapper[data-image-index]" + ]); + + helper.registerPlugin(md => { + md.core.ruler.after("upload-protocol", "resize-controls", rule); + }); + } +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 index 196f52066f..7790998e02 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/upload-protocol.js.es6 @@ -20,15 +20,12 @@ function rule(state) { addImage(uploads, blockToken); } - if (!blockToken.children) { - continue; - } + if (!blockToken.children) continue; for (let j = 0; j < blockToken.children.length; j++) { let token = blockToken.children[j]; - if (token.tag === "img" || token.tag === "a") { - addImage(uploads, token); - } + + if (token.tag === "img" || token.tag === "a") addImage(uploads, token); } } diff --git a/app/assets/javascripts/select-kit/components/admin-group-selector.js.es6 b/app/assets/javascripts/select-kit/components/admin-group-selector.js.es6 index ddc883ee07..2d99404c0a 100644 --- a/app/assets/javascripts/select-kit/components/admin-group-selector.js.es6 +++ b/app/assets/javascripts/select-kit/components/admin-group-selector.js.es6 @@ -1,5 +1,6 @@ import MultiSelectComponent from "select-kit/components/multi-select"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; + const { makeArray } = Ember; export default MultiSelectComponent.extend({ @@ -10,7 +11,7 @@ export default MultiSelectComponent.extend({ allowAny: false, buffer: null, - @computed("buffer") + @discourseComputed("buffer") values(buffer) { return buffer === null ? makeArray(this.selected).map(s => this.valueForContentItem(s)) diff --git a/app/assets/javascripts/select-kit/components/category-chooser.js.es6 b/app/assets/javascripts/select-kit/components/category-chooser.js.es6 index a1d453aa2a..8a91e9868e 100644 --- a/app/assets/javascripts/select-kit/components/category-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-chooser.js.es6 @@ -1,5 +1,5 @@ import ComboBoxComponent from "select-kit/components/combo-box"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import PermissionType from "discourse/models/permission-type"; import Category from "discourse/models/category"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; @@ -53,7 +53,7 @@ export default ComboBoxComponent.extend({ }); }, - @computed("rootNone", "rootNoneLabel") + @discourseComputed("rootNone", "rootNoneLabel") none(rootNone, rootNoneLabel) { if ( this.siteSettings.allow_uncategorized_topics || diff --git a/app/assets/javascripts/select-kit/components/category-drop.js.es6 b/app/assets/javascripts/select-kit/components/category-drop.js.es6 index 425c2bb038..165818ffb2 100644 --- a/app/assets/javascripts/select-kit/components/category-drop.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-drop.js.es6 @@ -1,8 +1,11 @@ +import { alias, not } from "@ember/object/computed"; import ComboBoxComponent from "select-kit/components/combo-box"; import DiscourseURL from "discourse/lib/url"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; +import Site from "discourse/models/site"; + const { isEmpty } = Ember; export default ComboBoxComponent.extend({ @@ -10,20 +13,25 @@ export default ComboBoxComponent.extend({ classNameBindings: ["categoryStyle"], classNames: "category-drop", verticalOffset: 3, - content: Ember.computed.alias("categoriesWithShortcuts"), + content: alias("categoriesWithShortcuts"), rowComponent: "category-row", headerComponent: "category-drop/category-drop-header", allowAutoSelectFirst: false, tagName: "li", - categoryStyle: Ember.computed.alias("siteSettings.category_style"), + categoryStyle: alias("siteSettings.category_style"), noCategoriesLabel: I18n.t("categories.no_subcategory"), fullWidthOnMobile: true, caretDownIcon: "caret-right", caretUpIcon: "caret-down", subCategory: false, - isAsync: Ember.computed.not("subCategory"), + isAsync: not("subCategory"), - @computed("categories", "hasSelection", "subCategory", "noSubcategories") + @discourseComputed( + "categories", + "hasSelection", + "subCategory", + "noSubcategories" + ) categoriesWithShortcuts( categories, hasSelection, @@ -70,12 +78,12 @@ export default ComboBoxComponent.extend({ this.forceValue(this.get("category.id")); }, - @computed("content") + @discourseComputed("content") filterable(content) { const contentLength = (content && content.length) || 0; return ( contentLength >= 15 || - (this.isAsync && contentLength < Discourse.Category.list().length) + (this.isAsync && contentLength < Category.list().length) ); }, @@ -107,7 +115,7 @@ export default ComboBoxComponent.extend({ return content; }, - @computed("parentCategory.name", "subCategory") + @discourseComputed("parentCategory.name", "subCategory") allCategoriesLabel(categoryName, subCategory) { if (subCategory) { return I18n.t("categories.all_subcategories", { categoryName }); @@ -115,12 +123,12 @@ export default ComboBoxComponent.extend({ return I18n.t("categories.all"); }, - @computed("parentCategory.url", "subCategory") + @discourseComputed("parentCategory.url", "subCategory") allCategoriesUrl(parentCategoryUrl, subCategory) { return Discourse.getURL(subCategory ? parentCategoryUrl || "/" : "/"); }, - @computed("parentCategory.url") + @discourseComputed("parentCategory.url") noCategoriesUrl(parentCategoryUrl) { return Discourse.getURL(`${parentCategoryUrl}/none`); }, @@ -135,8 +143,8 @@ export default ComboBoxComponent.extend({ categoryURL = Discourse.getURL(this.noCategoriesUrl); } else { const category = Category.findById(parseInt(categoryId, 10)); - const slug = Discourse.Category.slugFor(category); - categoryURL = Discourse.getURL("/c/") + slug; + const slug = Category.slugFor(category); + categoryURL = Discourse.getURL(`/c/${slug}/${categoryId}`); } DiscourseURL.routeTo(categoryURL); @@ -158,14 +166,11 @@ export default ComboBoxComponent.extend({ return; } - let results = Discourse.Category.search(filter); + let results = Category.search(filter); if (!this.siteSettings.allow_uncategorized_topics) { results = results.filter(result => { - return ( - result.id !== - Discourse.Site.currentProp("uncategorized_category_id") - ); + return result.id !== Site.currentProp("uncategorized_category_id"); }); } diff --git a/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 b/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 index c04fc30840..c84b7189d0 100644 --- a/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-drop/category-drop-header.js.es6 @@ -1,6 +1,7 @@ +import { alias } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; import ComboBoxSelectBoxHeaderComponent from "select-kit/components/combo-box/combo-box-header"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; export default ComboBoxSelectBoxHeaderComponent.extend({ @@ -9,9 +10,9 @@ export default ComboBoxSelectBoxHeaderComponent.extend({ classNames: "category-drop-header", classNameBindings: ["categoryStyleClass"], - categoryStyleClass: Ember.computed.alias("site.category_style"), + categoryStyleClass: alias("site.category_style"), - @computed("computedContent.value", "computedContent.name") + @discourseComputed("computedContent.value", "computedContent.name") category(value, name) { if (isEmpty(value)) { const uncat = Category.findUncategorized(); @@ -23,17 +24,17 @@ export default ComboBoxSelectBoxHeaderComponent.extend({ } }, - @computed("category.color") + @discourseComputed("category.color") categoryBackgroundColor(categoryColor) { return categoryColor || "#e9e9e9"; }, - @computed("category.text_color") + @discourseComputed("category.text_color") categoryTextColor(categoryTextColor) { return categoryTextColor || "#333"; }, - @computed("category", "categoryBackgroundColor", "categoryTextColor") + @discourseComputed("category", "categoryBackgroundColor", "categoryTextColor") categoryStyle(category, categoryBackgroundColor, categoryTextColor) { const categoryStyle = this.siteSettings.category_style; diff --git a/app/assets/javascripts/select-kit/components/category-notifications-button.js.es6 b/app/assets/javascripts/select-kit/components/category-notifications-button.js.es6 index 17c8e4de41..ecc000d8bd 100644 --- a/app/assets/javascripts/select-kit/components/category-notifications-button.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-notifications-button.js.es6 @@ -1,10 +1,11 @@ +import { or, alias } from "@ember/object/computed"; import NotificationOptionsComponent from "select-kit/components/notifications-button"; export default NotificationOptionsComponent.extend({ pluginApiIdentifiers: ["category-notifications-button"], classNames: "category-notifications-button", - isHidden: Ember.computed.or("category.deleted"), - headerIcon: Ember.computed.alias("iconForSelectedDetails"), + isHidden: or("category.deleted"), + headerIcon: alias("iconForSelectedDetails"), i18nPrefix: "category.notifications", showFullTitle: false, allowInitialValueMutation: false, diff --git a/app/assets/javascripts/select-kit/components/category-row.js.es6 b/app/assets/javascripts/select-kit/components/category-row.js.es6 index 8523bfb07e..dee781b294 100644 --- a/app/assets/javascripts/select-kit/components/category-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-row.js.es6 @@ -1,32 +1,34 @@ +import { bool } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import Category from "discourse/models/category"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; +import { isNone } from "@ember/utils"; export default SelectKitRowComponent.extend({ layoutName: "select-kit/templates/components/category-row", classNames: "category-row", - hideParentCategory: Ember.computed.bool("options.hideParentCategory"), - allowUncategorized: Ember.computed.bool("options.allowUncategorized"), - categoryLink: Ember.computed.bool("options.categoryLink"), + hideParentCategory: bool("options.hideParentCategory"), + allowUncategorized: bool("options.allowUncategorized"), + categoryLink: bool("options.categoryLink"), - @computed("options.displayCategoryDescription") + @discourseComputed("options.displayCategoryDescription") displayCategoryDescription(displayCategoryDescription) { - if (Ember.isNone(displayCategoryDescription)) { + if (isNone(displayCategoryDescription)) { return true; } return displayCategoryDescription; }, - @computed("descriptionText", "description", "category.name") + @discourseComputed("descriptionText", "description", "category.name") title(descriptionText, description, name) { return descriptionText || description || name; }, - @computed("computedContent.value", "computedContent.name") + @discourseComputed("computedContent.value", "computedContent.name") category(value, name) { if (isEmpty(value)) { const uncat = Category.findUncategorized(); @@ -38,7 +40,7 @@ export default SelectKitRowComponent.extend({ } }, - @computed("category", "parentCategory") + @discourseComputed("category", "parentCategory") badgeForCategory(category, parentCategory) { return categoryBadgeHTML(category, { link: this.categoryLink, @@ -47,7 +49,7 @@ export default SelectKitRowComponent.extend({ }).htmlSafe(); }, - @computed("parentCategory") + @discourseComputed("parentCategory") badgeForParentCategory(parentCategory) { return categoryBadgeHTML(parentCategory, { link: this.categoryLink, @@ -55,22 +57,22 @@ export default SelectKitRowComponent.extend({ }).htmlSafe(); }, - @computed("parentCategoryid") + @discourseComputed("parentCategoryid") parentCategory(parentCategoryId) { return Category.findById(parentCategoryId); }, - @computed("parentCategoryid") + @discourseComputed("parentCategoryid") hasParentCategory(parentCategoryid) { - return !Ember.isNone(parentCategoryid); + return !isNone(parentCategoryid); }, - @computed("category") + @discourseComputed("category") parentCategoryid(category) { return category.get("parent_category_id"); }, - @computed( + @discourseComputed( "category.totalTopicCount", "category.topic_count", "options.countSubcategories" @@ -79,19 +81,19 @@ export default SelectKitRowComponent.extend({ return countSubcats ? totalCount : topicCount; }, - @computed("displayCategoryDescription", "category.description") + @discourseComputed("displayCategoryDescription", "category.description") shouldDisplayDescription(displayCategoryDescription, description) { return displayCategoryDescription && description && description !== "null"; }, - @computed("category.description_text") + @discourseComputed("category.description_text") descriptionText(descriptionText) { if (descriptionText) { return this._formatCategoryDescription(descriptionText); } }, - @computed("category.description") + @discourseComputed("category.description") description(description) { if (description) { return this._formatCategoryDescription(description); diff --git a/app/assets/javascripts/select-kit/components/category-selector.js.es6 b/app/assets/javascripts/select-kit/components/category-selector.js.es6 index f000f00782..11ed11bacd 100644 --- a/app/assets/javascripts/select-kit/components/category-selector.js.es6 +++ b/app/assets/javascripts/select-kit/components/category-selector.js.es6 @@ -34,7 +34,10 @@ export default MultiSelectComponent.extend({ }, mutateValues(values) { - this.set("categories", values.map(v => Category.findById(v))); + this.set( + "categories", + values.map(v => Category.findById(v)) + ); }, filterComputedContent(computedContent, computedValues, filter) { diff --git a/app/assets/javascripts/select-kit/components/color-palettes/color-palettes-row.js.es6 b/app/assets/javascripts/select-kit/components/color-palettes/color-palettes-row.js.es6 index e0ae6b7039..7aa53eb596 100644 --- a/app/assets/javascripts/select-kit/components/color-palettes/color-palettes-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/color-palettes/color-palettes-row.js.es6 @@ -1,13 +1,13 @@ import { escapeExpression } from "discourse/lib/utilities"; import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default SelectKitRowComponent.extend({ layoutName: "select-kit/templates/components/color-palettes/color-palettes-row", classNames: "color-palettes-row", - @computed("computedContent.originalContent.colors") + @discourseComputed("computedContent.originalContent.colors") colors(colors) { return (colors || []).map(color => `#${escapeExpression(color.hex)}`); } diff --git a/app/assets/javascripts/select-kit/components/combo-box.js.es6 b/app/assets/javascripts/select-kit/components/combo-box.js.es6 index 09e8a7c7bd..71928d314f 100644 --- a/app/assets/javascripts/select-kit/components/combo-box.js.es6 +++ b/app/assets/javascripts/select-kit/components/combo-box.js.es6 @@ -1,8 +1,8 @@ import SingleSelectComponent from "select-kit/components/single-select"; import { on, - default as computed -} from "ember-addons/ember-computed-decorators"; + default as discourseComputed +} from "discourse-common/utils/decorators"; export default SingleSelectComponent.extend({ pluginApiIdentifiers: ["combo-box"], @@ -20,7 +20,7 @@ export default SingleSelectComponent.extend({ return content; }, - @computed("isExpanded", "caretUpIcon", "caretDownIcon") + @discourseComputed("isExpanded", "caretUpIcon", "caretDownIcon") caretIcon(isExpanded, caretUpIcon, caretDownIcon) { return isExpanded ? caretUpIcon : caretDownIcon; }, diff --git a/app/assets/javascripts/select-kit/components/combo-box/combo-box-header.js.es6 b/app/assets/javascripts/select-kit/components/combo-box/combo-box-header.js.es6 index 34e642bd7e..4dbe7d22c5 100644 --- a/app/assets/javascripts/select-kit/components/combo-box/combo-box-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/combo-box/combo-box-header.js.es6 @@ -1,14 +1,12 @@ +import { alias, and } from "@ember/object/computed"; import SelectKitHeaderComponent from "select-kit/components/select-kit/select-kit-header"; export default SelectKitHeaderComponent.extend({ layoutName: "select-kit/templates/components/combo-box/combo-box-header", classNames: "combo-box-header", - clearable: Ember.computed.alias("options.clearable"), - caretUpIcon: Ember.computed.alias("options.caretUpIcon"), - caretDownIcon: Ember.computed.alias("options.caretDownIcon"), - shouldDisplayClearableButton: Ember.computed.and( - "clearable", - "computedContent.hasSelection" - ) + clearable: alias("options.clearable"), + caretUpIcon: alias("options.caretUpIcon"), + caretDownIcon: alias("options.caretDownIcon"), + shouldDisplayClearableButton: and("clearable", "computedContent.hasSelection") }); diff --git a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 index 98b1d06410..f42643f03c 100644 --- a/app/assets/javascripts/select-kit/components/composer-actions.js.es6 +++ b/app/assets/javascripts/select-kit/components/composer-actions.js.es6 @@ -1,5 +1,5 @@ import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { PRIVATE_MESSAGE, CREATE_TOPIC, @@ -76,7 +76,7 @@ export default DropdownSelectBoxComponent.extend({ return content; }, - @computed("options", "canWhisper", "action") + @discourseComputed("options", "canWhisper", "action") content(options, canWhisper, action) { let items = []; @@ -132,7 +132,9 @@ export default DropdownSelectBoxComponent.extend({ (action !== REPLY && _topicSnapshot) || (action === REPLY && _topicSnapshot && - (options.userAvatar && options.userLink && options.topicLink)) + options.userAvatar && + options.userLink && + options.topicLink) ) { items.push({ name: I18n.t("composer.composer_actions.reply_to_topic.label"), diff --git a/app/assets/javascripts/select-kit/components/dropdown-select-box.js.es6 b/app/assets/javascripts/select-kit/components/dropdown-select-box.js.es6 index 1bbf6d510e..77c1927e96 100644 --- a/app/assets/javascripts/select-kit/components/dropdown-select-box.js.es6 +++ b/app/assets/javascripts/select-kit/components/dropdown-select-box.js.es6 @@ -1,5 +1,5 @@ import SingleSelectComponent from "select-kit/components/single-select"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; export default SingleSelectComponent.extend({ pluginApiIdentifiers: ["dropdown-select-box"], diff --git a/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-header.js.es6 b/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-header.js.es6 index 1acccd3067..d83156c018 100644 --- a/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-header.js.es6 @@ -1,5 +1,5 @@ import SelectKitHeaderComponent from "select-kit/components/select-kit/select-kit-header"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default SelectKitHeaderComponent.extend({ layoutName: @@ -9,7 +9,7 @@ export default SelectKitHeaderComponent.extend({ classNameBindings: ["btnClassName"], - @computed("options.showFullTitle") + @discourseComputed("options.showFullTitle") btnClassName(showFullTitle) { return `btn ${showFullTitle ? "btn-icon-text" : "no-text btn-icon"}`; } diff --git a/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-row.js.es6 b/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-row.js.es6 index 187fc460cb..8169762e9a 100644 --- a/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/dropdown-select-box/dropdown-select-box-row.js.es6 @@ -1,3 +1,4 @@ +import { alias } from "@ember/object/computed"; import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row"; export default SelectKitRowComponent.extend({ @@ -5,7 +6,5 @@ export default SelectKitRowComponent.extend({ "select-kit/templates/components/dropdown-select-box/dropdown-select-box-row", classNames: "dropdown-select-box-row", - description: Ember.computed.alias( - "computedContent.originalContent.description" - ) + description: alias("computedContent.originalContent.description") }); diff --git a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 index f5167afb20..9fec1611c1 100644 --- a/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 +++ b/app/assets/javascripts/select-kit/components/future-date-input-selector.js.es6 @@ -1,3 +1,4 @@ +import { equal } from "@ember/object/computed"; import { isEmpty } from "@ember/utils"; import ComboBoxComponent from "select-kit/components/combo-box"; import { CLOSE_STATUS_TYPE } from "discourse/controllers/edit-topic-timer"; @@ -188,8 +189,8 @@ export const FORMAT = "YYYY-MM-DD HH:mmZ"; export default ComboBoxComponent.extend(DatetimeMixin, { pluginApiIdentifiers: ["future-date-input-selector"], classNames: ["future-date-input-selector"], - isCustom: Ember.computed.equal("value", "pick_date_and_time"), - isBasedOnLastPost: Ember.computed.equal("value", "set_based_on_last_post"), + isCustom: equal("value", "pick_date_and_time"), + isBasedOnLastPost: equal("value", "set_based_on_last_post"), rowComponent: "future-date-input-selector/future-date-input-selector-row", headerComponent: "future-date-input-selector/future-date-input-selector-header", diff --git a/app/assets/javascripts/select-kit/components/future-date-input-selector/mixin.js.es6 b/app/assets/javascripts/select-kit/components/future-date-input-selector/mixin.js.es6 index dd111348de..9aa6cf56fe 100644 --- a/app/assets/javascripts/select-kit/components/future-date-input-selector/mixin.js.es6 +++ b/app/assets/javascripts/select-kit/components/future-date-input-selector/mixin.js.es6 @@ -1,6 +1,7 @@ import { CLOSE_STATUS_TYPE } from "discourse/controllers/edit-topic-timer"; import { timeframeDetails } from "select-kit/components/future-date-input-selector"; import Mixin from "@ember/object/mixin"; +import { isNone } from "@ember/utils"; export default Mixin.create({ _computeIconsForValue(value) { @@ -14,7 +15,7 @@ export default Mixin.create({ }, _computeDatetimeForValue(value) { - if (Ember.isNone(value)) { + if (isNone(value)) { return null; } diff --git a/app/assets/javascripts/select-kit/components/group-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/group-dropdown.js.es6 index 882bf46cb9..0e6ccfac0f 100644 --- a/app/assets/javascripts/select-kit/components/group-dropdown.js.es6 +++ b/app/assets/javascripts/select-kit/components/group-dropdown.js.es6 @@ -1,18 +1,19 @@ +import { alias } from "@ember/object/computed"; import ComboBoxComponent from "select-kit/components/combo-box"; import DiscourseURL from "discourse/lib/url"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default ComboBoxComponent.extend({ pluginApiIdentifiers: ["group-dropdown"], classNames: "group-dropdown", - content: Ember.computed.alias("groups"), + content: alias("groups"), tagName: "li", caretDownIcon: "caret-right", caretUpIcon: "caret-down", allowAutoSelectFirst: false, valueAttribute: "name", - @computed("content") + @discourseComputed("content") filterable(content) { return content && content.length >= 10; }, @@ -27,7 +28,7 @@ export default ComboBoxComponent.extend({ return content; }, - @computed + @discourseComputed collectionHeader() { if ( this.siteSettings.enable_group_directory || diff --git a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 index c32e15e19a..bc5d8c0b07 100644 --- a/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/mini-tag-chooser.js.es6 @@ -1,7 +1,8 @@ +import { empty, alias } from "@ember/object/computed"; import Category from "discourse/models/category"; import ComboBox from "select-kit/components/combo-box"; import TagsMixin from "select-kit/mixins/tags"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; import renderTag from "discourse/lib/render-tag"; import { escapeExpression } from "discourse/lib/utilities"; import { makeArray } from "discourse-common/lib/helpers"; @@ -19,11 +20,11 @@ export default ComboBox.extend(TagsMixin, { classNameBindings: ["noTags"], verticalOffset: 3, filterable: true, - noTags: Ember.computed.empty("selection"), + noTags: empty("selection"), allowCreate: null, - allowAny: Ember.computed.alias("allowCreate"), - caretUpIcon: Ember.computed.alias("caretIcon"), - caretDownIcon: Ember.computed.alias("caretIcon"), + allowAny: alias("allowCreate"), + caretUpIcon: alias("caretIcon"), + caretDownIcon: alias("caretIcon"), isAsync: true, fullWidthOnMobile: true, @@ -50,12 +51,13 @@ export default ComboBox.extend(TagsMixin, { parseInt( this.limit || this.maximum || - this.get("siteSettings.max_tags_per_topic") + this.get("siteSettings.max_tags_per_topic"), + 10 ) ); }, - @computed( + @discourseComputed( "computedValue", "filter", "collectionComputedContent.[]", @@ -98,12 +100,12 @@ export default ComboBox.extend(TagsMixin, { ); }, - @computed("hasReachedMaximum") + @discourseComputed("hasReachedMaximum") caretIcon(hasReachedMaximum) { return hasReachedMaximum ? null : "plus"; }, - @computed("tags") + @discourseComputed("tags") selection(tags) { return makeArray(tags).map(c => this.computeContentItem(c)); }, @@ -139,7 +141,7 @@ export default ComboBox.extend(TagsMixin, { return true; }, - @computed("tags.[]", "filter", "highlightedSelection.[]") + @discourseComputed("tags.[]", "filter", "highlightedSelection.[]") collectionHeader(tags, filter, highlightedSelection) { if (!isEmpty(tags)) { let output = ""; diff --git a/app/assets/javascripts/select-kit/components/multi-select.js.es6 b/app/assets/javascripts/select-kit/components/multi-select.js.es6 index 2bb2bbb610..40a5dc7f0b 100644 --- a/app/assets/javascripts/select-kit/components/multi-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select.js.es6 @@ -1,6 +1,8 @@ import SelectKitComponent from "select-kit/components/select-kit"; -import computed from "ember-addons/ember-computed-decorators"; -import { on } from "ember-addons/ember-computed-decorators"; +import { + default as discourseComputed, + on +} from "discourse-common/utils/decorators"; const { get, isNone, isEmpty, makeArray, run } = Ember; import { applyOnSelectPluginApiCallbacks, @@ -67,7 +69,7 @@ export default SelectKitComponent.extend({ }); }, - @computed("filter", "shouldDisplayCreateRow") + @discourseComputed("filter", "shouldDisplayCreateRow") createRowComputedContent(filter, shouldDisplayCreateRow) { if (shouldDisplayCreateRow) { let content = this.createContentFromInput(filter); @@ -75,12 +77,12 @@ export default SelectKitComponent.extend({ } }, - @computed("filter", "computedValues") + @discourseComputed("filter", "computedValues") shouldDisplayCreateRow(filter, computedValues) { return this._super() && !computedValues.includes(filter); }, - @computed + @discourseComputed shouldDisplayFilter() { return true; }, @@ -126,7 +128,7 @@ export default SelectKitComponent.extend({ }); }, - @computed("computedAsyncContent.[]", "computedValues.[]") + @discourseComputed("computedAsyncContent.[]", "computedValues.[]") filteredAsyncComputedContent(computedAsyncContent, computedValues) { computedAsyncContent = computedAsyncContent.filter(c => { return !computedValues.includes(get(c, "value")); @@ -139,7 +141,7 @@ export default SelectKitComponent.extend({ return computedAsyncContent; }, - @computed("computedContent.[]", "computedValues.[]", "filter") + @discourseComputed("computedContent.[]", "computedValues.[]", "filter") filteredComputedContent(computedContent, computedValues, filter) { computedContent = computedContent.filter(c => { return !computedValues.includes(get(c, "value")); @@ -182,7 +184,7 @@ export default SelectKitComponent.extend({ return content; }, - @computed("filter") + @discourseComputed("filter") templateForCreateRow() { return rowComponent => { return I18n.t("select_kit.create", { @@ -195,7 +197,7 @@ export default SelectKitComponent.extend({ return this._super() && !this.hasReachedMaximum; }, - @computed("computedValues.[]", "computedContent.[]") + @discourseComputed("computedValues.[]", "computedContent.[]") selection(computedValues, computedContent) { const selected = []; @@ -207,7 +209,7 @@ export default SelectKitComponent.extend({ return selected; }, - @computed("selection.[]") + @discourseComputed("selection.[]") hasSelection(selection) { return !isEmpty(selection); }, diff --git a/app/assets/javascripts/select-kit/components/multi-select/multi-select-filter.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/multi-select-filter.js.es6 index e9b9233448..4224d8d6b4 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/multi-select-filter.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/multi-select-filter.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; const { isEmpty } = Ember; import SelectKitFilterComponent from "select-kit/components/select-kit/select-kit-filter"; @@ -6,7 +6,7 @@ export default SelectKitFilterComponent.extend({ layoutName: "select-kit/templates/components/select-kit/select-kit-filter", classNames: ["multi-select-filter"], - @computed("placeholder", "hasSelection") + @discourseComputed("placeholder", "hasSelection") computedPlaceholder(placeholder, hasSelection) { if (hasSelection) return ""; return isEmpty(placeholder) ? "" : I18n.t(placeholder); diff --git a/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 index b16ed0c290..9b6c22f527 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/multi-select-header.js.es6 @@ -1,6 +1,7 @@ +import { alias, or } from "@ember/object/computed"; import { makeArray } from "discourse-common/lib/helpers"; -import { on } from "ember-addons/ember-computed-decorators"; -import computed from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import SelectKitHeaderComponent from "select-kit/components/select-kit/select-kit-header"; export default SelectKitHeaderComponent.extend({ @@ -13,13 +14,13 @@ export default SelectKitHeaderComponent.extend({ classNames: "multi-select-header", layoutName: "select-kit/templates/components/multi-select/multi-select-header", - selectedNameComponent: Ember.computed.alias("options.selectedNameComponent"), + selectedNameComponent: alias("options.selectedNameComponent"), - forceEscape: Ember.computed.alias("options.forceEscape"), + forceEscape: alias("options.forceEscape"), - ariaLabel: Ember.computed.or("computedContent.ariaLabel", "title", "names"), + ariaLabel: or("computedContent.ariaLabel", "title", "names"), - title: Ember.computed.or("computedContent.title", "names"), + title: or("computedContent.title", "names"), @on("didRender") _positionFilter() { @@ -38,14 +39,14 @@ export default SelectKitHeaderComponent.extend({ $filter.width(availableSpace - parentRightPadding * 4); }, - @computed("computedContent.selection.[]") + @discourseComputed("computedContent.selection.[]") names(selection) { return makeArray(selection) .map(s => s.name) .join(","); }, - @computed("computedContent.selection.[]") + @discourseComputed("computedContent.selection.[]") values(selection) { return makeArray(selection) .map(s => s.value) diff --git a/app/assets/javascripts/select-kit/components/multi-select/selected-category.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/selected-category.js.es6 index a24ed6eae3..155506095b 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/selected-category.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/selected-category.js.es6 @@ -1,12 +1,12 @@ import SelectedNameComponent from "select-kit/components/multi-select/selected-name"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; export default SelectedNameComponent.extend({ classNames: "selected-category", layoutName: "select-kit/templates/components/multi-select/selected-category", - @computed("computedContent.originalContent") + @discourseComputed("computedContent.originalContent") badge(category) { return categoryBadgeHTML(category, { allowUncategorized: true, diff --git a/app/assets/javascripts/select-kit/components/multi-select/selected-color.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/selected-color.js.es6 index f75e7a708f..3574550c20 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/selected-color.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/selected-color.js.es6 @@ -1,10 +1,10 @@ import SelectedNameComponent from "select-kit/components/multi-select/selected-name"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default SelectedNameComponent.extend({ classNames: "selected-color", - @computed("name") + @discourseComputed("name") footerContent(name) { return ``.htmlSafe(); } diff --git a/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 b/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 index 0c542410fb..ab6a36c36c 100644 --- a/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 +++ b/app/assets/javascripts/select-kit/components/multi-select/selected-name.js.es6 @@ -1,5 +1,7 @@ +import { or, alias } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; +import { computed } from "@ember/object"; export default Component.extend({ attributeBindings: [ @@ -16,14 +18,14 @@ export default Component.extend({ tagName: "span", tabindex: -1, - @computed("computedContent") + @discourseComputed("computedContent") guid(computedContent) { return Ember.guidFor(computedContent); }, - ariaLabel: Ember.computed.or("computedContent.ariaLabel", "title"), + ariaLabel: or("computedContent.ariaLabel", "title"), - @computed("computedContent.title", "name") + @discourseComputed("computedContent.title", "name") title(computedContentTitle, name) { if (computedContentTitle) return computedContentTitle; if (name) return name; @@ -31,17 +33,17 @@ export default Component.extend({ return null; }, - label: Ember.computed.or("computedContent.label", "title", "name"), + label: or("computedContent.label", "title", "name"), - name: Ember.computed.alias("computedContent.name"), + name: alias("computedContent.name"), - value: Ember.computed.alias("computedContent.value"), + value: alias("computedContent.value"), - isLocked: Ember.computed("computedContent.locked", function() { + isLocked: computed("computedContent.locked", function() { return this.getWithDefault("computedContent.locked", false); }), - @computed("computedContent", "highlightedSelection.[]") + @discourseComputed("computedContent", "highlightedSelection.[]") isHighlighted(computedContent, highlightedSelection) { return highlightedSelection.includes(this.computedContent); }, diff --git a/app/assets/javascripts/select-kit/components/none-category-row.js.es6 b/app/assets/javascripts/select-kit/components/none-category-row.js.es6 index 8488a93a4a..7d2aa1ef26 100644 --- a/app/assets/javascripts/select-kit/components/none-category-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/none-category-row.js.es6 @@ -1,12 +1,12 @@ import CategoryRowComponent from "select-kit/components/category-row"; import { categoryBadgeHTML } from "discourse/helpers/category-link"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default CategoryRowComponent.extend({ layoutName: "select-kit/templates/components/category-row", classNames: "none category-row", - @computed("category") + @discourseComputed("category") badgeForCategory(category) { return categoryBadgeHTML(category, { link: this.categoryLink, diff --git a/app/assets/javascripts/select-kit/components/notifications-button.js.es6 b/app/assets/javascripts/select-kit/components/notifications-button.js.es6 index fcfe0c5c8c..172282ba0b 100644 --- a/app/assets/javascripts/select-kit/components/notifications-button.js.es6 +++ b/app/assets/javascripts/select-kit/components/notifications-button.js.es6 @@ -1,9 +1,10 @@ +import { alias } from "@ember/object/computed"; import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; import { - default as computed, + default as discourseComputed, observes, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { buttonDetails } from "discourse/lib/notification-levels"; import { allLevels } from "discourse/lib/notification-levels"; @@ -20,7 +21,7 @@ export default DropdownSelectBoxComponent.extend({ i18nPrefix: "", i18nPostfix: "", - @computed("iconForSelectedDetails") + @discourseComputed("iconForSelectedDetails") headerIcon(iconForSelectedDetails) { return iconForSelectedDetails; }, @@ -34,7 +35,7 @@ export default DropdownSelectBoxComponent.extend({ }); }, - iconForSelectedDetails: Ember.computed.alias("selectedDetails.icon"), + iconForSelectedDetails: alias("selectedDetails.icon"), computeHeaderContent() { let content = this._super(...arguments); @@ -47,7 +48,7 @@ export default DropdownSelectBoxComponent.extend({ return content; }, - @computed("computedValue") + @discourseComputed("computedValue") selectedDetails(computedValue) { return buttonDetails(computedValue); } diff --git a/app/assets/javascripts/select-kit/components/notifications-button/notifications-button-row.js.es6 b/app/assets/javascripts/select-kit/components/notifications-button/notifications-button-row.js.es6 index d41133b9b6..5d62a82e4b 100644 --- a/app/assets/javascripts/select-kit/components/notifications-button/notifications-button-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/notifications-button/notifications-button-row.js.es6 @@ -1,26 +1,30 @@ +import { alias } from "@ember/object/computed"; import DropdownSelectBoxRoxComponent from "select-kit/components/dropdown-select-box/dropdown-select-box-row"; import { buttonDetails } from "discourse/lib/notification-levels"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; export default DropdownSelectBoxRoxComponent.extend({ classNames: "notifications-button-row", - i18nPrefix: Ember.computed.alias("options.i18nPrefix"), - i18nPostfix: Ember.computed.alias("options.i18nPostfix"), + i18nPrefix: alias("options.i18nPrefix"), + i18nPostfix: alias("options.i18nPostfix"), - @computed("computedContent.value", "i18nPrefix", "i18nPostfix") + @discourseComputed("computedContent.value", "i18nPrefix", "i18nPostfix") title(value, prefix, postfix) { const key = buttonDetails(value).key; return I18n.t(`${prefix}.${key}${postfix}.title`); }, - @computed("computedContent.name", "computedContent.originalContent.icon") + @discourseComputed( + "computedContent.name", + "computedContent.originalContent.icon" + ) icon(contentName, icon) { return iconHTML(icon, { class: contentName.dasherize() }); }, - @computed("_start") + @discourseComputed("_start") description(_start) { if (this.site && this.site.mobileView) { return null; @@ -29,12 +33,12 @@ export default DropdownSelectBoxRoxComponent.extend({ return Handlebars.escapeExpression(I18n.t(`${_start}.description`)); }, - @computed("_start") + @discourseComputed("_start") name(_start) { return Handlebars.escapeExpression(I18n.t(`${_start}.title`)); }, - @computed("i18nPrefix", "i18nPostfix", "computedContent.name") + @discourseComputed("i18nPrefix", "i18nPostfix", "computedContent.name") _start(prefix, postfix, contentName) { return `${prefix}.${contentName}${postfix}`; } diff --git a/app/assets/javascripts/select-kit/components/period-chooser.js.es6 b/app/assets/javascripts/select-kit/components/period-chooser.js.es6 index 56a2079988..60c6cd476a 100644 --- a/app/assets/javascripts/select-kit/components/period-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/period-chooser.js.es6 @@ -1,15 +1,16 @@ +import { oneWay, alias } from "@ember/object/computed"; import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; -import computed, { on } from "ember-addons/ember-computed-decorators"; +import discourseComputed, { on } from "discourse-common/utils/decorators"; export default DropdownSelectBoxComponent.extend({ classNames: ["period-chooser"], rowComponent: "period-chooser/period-chooser-row", headerComponent: "period-chooser/period-chooser-header", - content: Ember.computed.oneWay("site.periods"), - value: Ember.computed.alias("period"), - isHidden: Ember.computed.alias("showPeriods"), + content: oneWay("site.periods"), + value: alias("period"), + isHidden: alias("showPeriods"), - @computed("isExpanded") + @discourseComputed("isExpanded") caretIcon(isExpanded) { return isExpanded ? "caret-up" : "caret-down"; }, diff --git a/app/assets/javascripts/select-kit/components/period-chooser/period-chooser-row.js.es6 b/app/assets/javascripts/select-kit/components/period-chooser/period-chooser-row.js.es6 index 18a0b529fa..4cb25af58e 100644 --- a/app/assets/javascripts/select-kit/components/period-chooser/period-chooser-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/period-chooser/period-chooser-row.js.es6 @@ -1,12 +1,12 @@ import DropdownSelectBoxRowComponent from "select-kit/components/dropdown-select-box/dropdown-select-box-row"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default DropdownSelectBoxRowComponent.extend({ layoutName: "select-kit/templates/components/period-chooser/period-chooser-row", classNames: "period-chooser-row", - @computed("computedContent") + @discourseComputed("computedContent") title(computedContent) { return I18n.t(`filters.top.${computedContent.name || "this_week"}`).title; } diff --git a/app/assets/javascripts/select-kit/components/pinned-button.js.es6 b/app/assets/javascripts/select-kit/components/pinned-button.js.es6 index 9002d913b3..8332f0f41a 100644 --- a/app/assets/javascripts/select-kit/components/pinned-button.js.es6 +++ b/app/assets/javascripts/select-kit/components/pinned-button.js.es6 @@ -1,5 +1,5 @@ import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Component.extend({ pluginApiIdentifiers: ["pinned-button"], @@ -8,7 +8,7 @@ export default Component.extend({ classNameBindings: ["isHidden"], layoutName: "select-kit/templates/components/pinned-button", - @computed("topic.pinned_globally", "pinned") + @discourseComputed("topic.pinned_globally", "pinned") reasonText(pinnedGlobally, pinned) { const globally = pinnedGlobally ? "_globally" : ""; const pinnedKey = pinned ? `pinned${globally}` : "unpinned"; @@ -16,7 +16,7 @@ export default Component.extend({ return I18n.t(key); }, - @computed("pinned", "topic.deleted", "topic.unpinned") + @discourseComputed("pinned", "topic.deleted", "topic.unpinned") isHidden(pinned, deleted, unpinned) { return deleted || (!pinned && !unpinned); } diff --git a/app/assets/javascripts/select-kit/components/pinned-options.js.es6 b/app/assets/javascripts/select-kit/components/pinned-options.js.es6 index a560bff74d..c96856a804 100644 --- a/app/assets/javascripts/select-kit/components/pinned-options.js.es6 +++ b/app/assets/javascripts/select-kit/components/pinned-options.js.es6 @@ -1,5 +1,5 @@ import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import { iconHTML } from "discourse-common/lib/icon-library"; export default DropdownSelectBoxComponent.extend({ diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6 index 9179e66680..6c2199a5b7 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -1,7 +1,8 @@ +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; import Component from "@ember/component"; const { get, isNone, run, isEmpty, makeArray } = Ember; -import computed from "ember-addons/ember-computed-decorators"; + import UtilsMixin from "select-kit/mixins/utils"; import DomHelpersMixin from "select-kit/mixins/dom-helpers"; import EventsMixin from "select-kit/mixins/events"; @@ -223,7 +224,7 @@ export default Component.extend( return this.computeContentItem(contentItem, options); }, - @computed( + @discourseComputed( "isAsync", "isLoading", "filteredAsyncComputedContent.[]", @@ -250,28 +251,28 @@ export default Component.extend( return !this.hasReachedMaximum; }, - @computed("maximum", "selection.[]") + @discourseComputed("maximum", "selection.[]") hasReachedMaximum(maximum, selection) { if (!maximum) return false; selection = makeArray(selection); return selection.length >= maximum; }, - @computed("minimum", "selection.[]") + @discourseComputed("minimum", "selection.[]") hasReachedMinimum(minimum, selection) { if (!minimum) return true; selection = makeArray(selection); return selection.length >= minimum; }, - @computed("shouldFilter", "allowAny") + @discourseComputed("shouldFilter", "allowAny") shouldDisplayFilter(shouldFilter, allowAny) { if (shouldFilter) return true; if (allowAny) return true; return false; }, - @computed("filter", "collectionComputedContent.[]", "isLoading") + @discourseComputed("filter", "collectionComputedContent.[]", "isLoading") noContentRow(filter, collectionComputedContent, isLoading) { if ( filter.length > 0 && @@ -282,7 +283,7 @@ export default Component.extend( } }, - @computed("hasReachedMaximum", "hasReachedMinimum", "isExpanded") + @discourseComputed("hasReachedMaximum", "hasReachedMinimum", "isExpanded") validationMessage(hasReachedMaximum, hasReachedMinimum) { if (hasReachedMaximum && this.maximum) { const key = this.maximumLabel || "select_kit.max_content_reached"; @@ -295,14 +296,19 @@ export default Component.extend( } }, - @computed("allowAny") + @discourseComputed("allowAny") filterPlaceholder(allowAny) { return allowAny ? "select_kit.filter_placeholder_with_any" : "select_kit.filter_placeholder"; }, - @computed("filter", "filterable", "autoFilterable", "renderedFilterOnce") + @discourseComputed( + "filter", + "filterable", + "autoFilterable", + "renderedFilterOnce" + ) shouldFilter(filter, filterable, autoFilterable, renderedFilterOnce) { if (renderedFilterOnce && filterable) return true; if (filterable) return true; @@ -310,7 +316,7 @@ export default Component.extend( return false; }, - @computed( + @discourseComputed( "computedValue", "filter", "collectionComputedContent.[]", @@ -331,7 +337,7 @@ export default Component.extend( return false; }, - @computed("filter", "shouldDisplayCreateRow") + @discourseComputed("filter", "shouldDisplayCreateRow") createRowComputedContent(filter, shouldDisplayCreateRow) { if (shouldDisplayCreateRow) { let content = this.createContentFromInput(filter); @@ -343,17 +349,17 @@ export default Component.extend( } }, - @computed + @discourseComputed templateForRow() { return () => null; }, - @computed + @discourseComputed templateForNoneRow() { return () => null; }, - @computed("filter") + @discourseComputed("filter") templateForCreateRow() { return rowComponent => { return I18n.t("select_kit.create", { @@ -362,7 +368,7 @@ export default Component.extend( }; }, - @computed("none") + @discourseComputed("none") noneRowComputedContent(none) { if (isNone(none)) return null; @@ -427,7 +433,12 @@ export default Component.extend( this._boundaryActionHandler("onStopLoading"); }, - @computed("selection.[]", "isExpanded", "filter", "highlightedSelection.[]") + @discourseComputed( + "selection.[]", + "isExpanded", + "filter", + "highlightedSelection.[]" + ) collectionHeaderComputedContent() { return applyCollectionHeaderCallbacks( this.pluginApiIdentifiers, @@ -436,7 +447,7 @@ export default Component.extend( ); }, - @computed("selection.[]", "isExpanded", "headerIcon") + @discourseComputed("selection.[]", "isExpanded", "headerIcon") headerComputedContent() { return applyHeaderContentPluginApiCallbacks( this.pluginApiIdentifiers, diff --git a/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 b/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 index 9576816708..9212f52fda 100644 --- a/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit/select-kit-filter.js.es6 @@ -1,14 +1,16 @@ +import { not } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; + const { isEmpty } = Ember; export default Component.extend({ layoutName: "select-kit/templates/components/select-kit/select-kit-filter", classNames: ["select-kit-filter"], classNameBindings: ["isFocused", "isHidden"], - isHidden: Ember.computed.not("shouldDisplayFilter"), + isHidden: not("shouldDisplayFilter"), - @computed("placeholder") + @discourseComputed("placeholder") computedPlaceholder(placeholder) { return isEmpty(placeholder) ? "" : I18n.t(placeholder); } diff --git a/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 b/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 index 807326c990..525c9b6936 100644 --- a/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit/select-kit-header.js.es6 @@ -1,5 +1,7 @@ +import { alias, none, or } from "@ember/object/computed"; import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; + const { isEmpty, makeArray } = Ember; export default Component.extend({ @@ -15,15 +17,15 @@ export default Component.extend({ "name:data-name" ], - forceEscape: Ember.computed.alias("options.forceEscape"), + forceEscape: alias("options.forceEscape"), - isNone: Ember.computed.none("computedContent.value"), + isNone: none("computedContent.value"), ariaHasPopup: "true", - ariaLabel: Ember.computed.or("computedContent.ariaLabel", "sanitizedTitle"), + ariaLabel: or("computedContent.ariaLabel", "sanitizedTitle"), - @computed("computedContent.title", "name") + @discourseComputed("computedContent.title", "name") title(computedContentTitle, name) { if (computedContentTitle) return computedContentTitle; if (name) return name; @@ -33,18 +35,18 @@ export default Component.extend({ // this might need a more advanced solution // but atm it's the only case we have to handle - @computed("title") + @discourseComputed("title") sanitizedTitle(title) { return String(title).replace("…", ""); }, - label: Ember.computed.or("computedContent.label", "title", "name"), + label: or("computedContent.label", "title", "name"), - name: Ember.computed.alias("computedContent.name"), + name: alias("computedContent.name"), - value: Ember.computed.alias("computedContent.value"), + value: alias("computedContent.value"), - @computed("computedContent.icon", "computedContent.icons") + @discourseComputed("computedContent.icon", "computedContent.icons") icons(icon, icons) { return makeArray(icon) .concat(icons) diff --git a/app/assets/javascripts/select-kit/components/select-kit/select-kit-row.js.es6 b/app/assets/javascripts/select-kit/components/select-kit/select-kit-row.js.es6 index 5b016fe905..a50db81e0a 100644 --- a/app/assets/javascripts/select-kit/components/select-kit/select-kit-row.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit/select-kit-row.js.es6 @@ -1,6 +1,10 @@ +import { alias, or } from "@ember/object/computed"; import Component from "@ember/component"; -import { on } from "ember-addons/ember-computed-decorators"; -import computed from "ember-addons/ember-computed-decorators"; +import { + default as discourseComputed, + on +} from "discourse-common/utils/decorators"; + const { run, isPresent, makeArray, isEmpty } = Ember; import UtilsMixin from "select-kit/mixins/utils"; @@ -23,11 +27,11 @@ export default Component.extend(UtilsMixin, { "computedContent.originalContent.classNames" ], - forceEscape: Ember.computed.alias("options.forceEscape"), + forceEscape: alias("options.forceEscape"), - ariaLabel: Ember.computed.or("computedContent.ariaLabel", "title"), + ariaLabel: or("computedContent.ariaLabel", "title"), - @computed("computedContent.title", "name") + @discourseComputed("computedContent.title", "name") title(computedContentTitle, name) { if (computedContentTitle) return computedContentTitle; if (name) return name; @@ -35,18 +39,18 @@ export default Component.extend(UtilsMixin, { return null; }, - @computed("computedContent") + @discourseComputed("computedContent") guid(computedContent) { return Ember.guidFor(computedContent); }, - label: Ember.computed.or("computedContent.label", "title", "name"), + label: or("computedContent.label", "title", "name"), - name: Ember.computed.alias("computedContent.name"), + name: alias("computedContent.name"), - value: Ember.computed.alias("computedContent.value"), + value: alias("computedContent.value"), - @computed("templateForRow") + @discourseComputed("templateForRow") template(templateForRow) { return templateForRow(this); }, @@ -67,7 +71,7 @@ export default Component.extend(UtilsMixin, { } }, - @computed( + @discourseComputed( "computedContent.icon", "computedContent.icons", "computedContent.originalContent.icon" diff --git a/app/assets/javascripts/select-kit/components/single-select.js.es6 b/app/assets/javascripts/select-kit/components/single-select.js.es6 index 5b4f6cd877..14047fbb03 100644 --- a/app/assets/javascripts/select-kit/components/single-select.js.es6 +++ b/app/assets/javascripts/select-kit/components/single-select.js.es6 @@ -1,8 +1,8 @@ import SelectKitComponent from "select-kit/components/select-kit"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; const { get, isNone, isEmpty, isPresent, run, makeArray } = Ember; import { @@ -115,7 +115,7 @@ export default SelectKitComponent.extend({ return content; }, - @computed("computedAsyncContent.[]", "computedValue") + @discourseComputed("computedAsyncContent.[]", "computedValue") filteredAsyncComputedContent(computedAsyncContent, computedValue) { computedAsyncContent = (computedAsyncContent || []).filter(c => { return computedValue !== get(c, "value"); @@ -128,7 +128,12 @@ export default SelectKitComponent.extend({ return computedAsyncContent; }, - @computed("computedContent.[]", "computedValue", "filter", "shouldFilter") + @discourseComputed( + "computedContent.[]", + "computedValue", + "filter", + "shouldFilter" + ) filteredComputedContent( computedContent, computedValue, @@ -150,17 +155,17 @@ export default SelectKitComponent.extend({ return computedContent; }, - @computed("computedValue", "computedContent.[]") + @discourseComputed("computedValue", "computedContent.[]") selection(computedValue, computedContent) { return computedContent.findBy("value", computedValue); }, - @computed("selection") + @discourseComputed("selection") hasSelection(selection) { return selection !== this.noneRowComputedContent && !isNone(selection); }, - @computed( + @discourseComputed( "computedValue", "filter", "collectionComputedContent.[]", @@ -280,7 +285,11 @@ export default SelectKitComponent.extend({ this ); - this._boundaryActionHandler("onSelect", computedContentItem.value); + this._boundaryActionHandler( + "onSelect", + computedContentItem.value, + computedContentItem.originalContent + ); this._boundaryActionHandler("onSelectAny", computedContentItem); this.autoHighlight(); diff --git a/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 b/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 index 3e92b80a88..103a294060 100644 --- a/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/tag-chooser.js.es6 @@ -1,7 +1,8 @@ +import { alias } from "@ember/object/computed"; import MultiSelectComponent from "select-kit/components/multi-select"; import TagsMixin from "select-kit/mixins/tags"; import renderTag from "discourse/lib/render-tag"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { makeArray } from "discourse-common/lib/helpers"; const { get, run } = Ember; @@ -15,7 +16,9 @@ export default MultiSelectComponent.extend(TagsMixin, { blacklist: null, attributeBindings: ["categoryId"], allowCreate: null, - allowAny: Ember.computed.alias("allowCreate"), + allowAny: alias("allowCreate"), + excludeSynonyms: false, + excludeHasSynonyms: false, init() { this._super(...arguments); @@ -45,22 +48,26 @@ export default MultiSelectComponent.extend(TagsMixin, { parseInt( this.limit || this.maximum || - this.get("siteSettings.max_tags_per_topic") + this.get("siteSettings.max_tags_per_topic"), + 10 ) ); } }, mutateValues(values) { - this.set("tags", values.filter(v => v)); + this.set( + "tags", + values.filter(v => v) + ); }, - @computed("tags") + @discourseComputed("tags") values(tags) { return makeArray(tags); }, - @computed("tags") + @discourseComputed("tags") content(tags) { return makeArray(tags); }, @@ -113,6 +120,8 @@ export default MultiSelectComponent.extend(TagsMixin, { } if (!this.everyTag) data.filterForInput = true; + if (this.excludeSynonyms) data.excludeSynonyms = true; + if (this.excludeHasSynonyms) data.excludeHasSynonyms = true; this.searchTags("/tags/filter/search", data, this._transformJson); }, diff --git a/app/assets/javascripts/select-kit/components/tag-drop.js.es6 b/app/assets/javascripts/select-kit/components/tag-drop.js.es6 index 66958c3452..5de47f178b 100644 --- a/app/assets/javascripts/select-kit/components/tag-drop.js.es6 +++ b/app/assets/javascripts/select-kit/components/tag-drop.js.es6 @@ -1,23 +1,26 @@ +import { computed } from "@ember/object"; +import { alias } from "@ember/object/computed"; import { makeArray } from "discourse-common/lib/helpers"; import ComboBoxComponent from "select-kit/components/combo-box"; import DiscourseURL from "discourse/lib/url"; import TagsMixin from "select-kit/mixins/tags"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; const { isEmpty, run } = Ember; +import Category from "discourse/models/category"; +import deprecated from "discourse-common/lib/deprecated"; export default ComboBoxComponent.extend(TagsMixin, { pluginApiIdentifiers: ["tag-drop"], classNameBindings: ["categoryStyle", "tagClass"], classNames: "tag-drop", verticalOffset: 3, - value: Ember.computed.alias("tagId"), + value: alias("tagId"), headerComponent: "tag-drop/tag-drop-header", allowAutoSelectFirst: false, tagName: "li", - showFilterByTag: Ember.computed.alias("siteSettings.show_filter_by_tag"), - currentCategory: Ember.computed.or("secondCategory", "firstCategory"), + showFilterByTag: alias("siteSettings.show_filter_by_tag"), tagId: null, - categoryStyle: Ember.computed.alias("siteSettings.category_style"), + categoryStyle: alias("siteSettings.category_style"), mutateAttributes() {}, fullWidthOnMobile: true, caretDownIcon: "caret-right", @@ -25,18 +28,39 @@ export default ComboBoxComponent.extend(TagsMixin, { allowContentReplacement: true, isAsync: true, - @computed("tagId") + currentCategory: computed("secondCategory", "firstCategory", { + set(key, value) { + this.currentCategoryRaw = value; + return value; + }, + + get() { + if (this.currentCategoryRaw) { + return this.currentCategoryRaw; + } + + const result = this.secondCategory || this.firstCategory; + if (result) { + deprecated( + "Setting firstCategory and secondCategory on tag-drop directly is deprecated. Please use currentCategory instead." + ); + return result; + } + } + }), + + @discourseComputed("tagId") noTagsSelected() { return this.tagId === "none"; }, - @computed("showFilterByTag", "content") + @discourseComputed("showFilterByTag", "content") isHidden(showFilterByTag, content) { if (showFilterByTag && !isEmpty(content)) return false; return true; }, - @computed("content") + @discourseComputed("content") filterable(content) { return content && content.length >= 15; }, @@ -63,12 +87,12 @@ export default ComboBoxComponent.extend(TagsMixin, { return content; }, - @computed("tagId") + @discourseComputed("tagId") tagClass(tagId) { return tagId ? `tag-${tagId}` : "tag_all"; }, - @computed("firstCategory", "secondCategory") + @discourseComputed("currentCategory") allTagsUrl() { if (this.currentCategory) { return Discourse.getURL(this.get("currentCategory.url") + "?allTags=1"); @@ -77,26 +101,28 @@ export default ComboBoxComponent.extend(TagsMixin, { } }, - @computed("firstCategory", "secondCategory") - noTagsUrl() { - var url = "/tags"; - if (this.currentCategory) { - url += this.get("currentCategory.url"); + @discourseComputed("currentCategory") + noTagsUrl(currentCategory) { + let url = "/tags"; + + if (currentCategory) { + url += `/c/${Category.slugFor(currentCategory)}/${currentCategory.id}`; } + return Discourse.getURL(`${url}/none`); }, - @computed("tag") + @discourseComputed("tag") allTagsLabel() { return I18n.t("tagging.selector_all_tags"); }, - @computed("tag") + @discourseComputed("tag") noTagsLabel() { return I18n.t("tagging.selector_no_tags"); }, - @computed("tagId", "allTagsLabel", "noTagsLabel") + @discourseComputed("tagId", "allTagsLabel", "noTagsLabel") shortcuts(tagId, allTagsLabel, noTagsLabel) { const shortcuts = []; @@ -119,7 +145,7 @@ export default ComboBoxComponent.extend(TagsMixin, { return shortcuts; }, - @computed("site.top_tags", "shortcuts") + @discourseComputed("site.top_tags", "shortcuts") content(topTags, shortcuts) { if (this.siteSettings.tags_sort_alphabetically && topTags) { return shortcuts.concat(topTags.sort()); @@ -142,12 +168,16 @@ export default ComboBoxComponent.extend(TagsMixin, { results = results.sort((a, b) => a.id > b.id); return results.map(r => { - return { id: r.id, name: r.text }; + return { + id: r.id, + name: r.text, + targetTagId: r.target_tag || r.id + }; }); }, actions: { - onSelect(tagId) { + onSelect(tagId, tag) { let url; if (tagId === "all-tags") { @@ -156,10 +186,19 @@ export default ComboBoxComponent.extend(TagsMixin, { url = Discourse.getURL(this.noTagsUrl); } else { url = "/tags"; + if (this.currentCategory) { - url += this.get("currentCategory.url"); + url += `/c/${Category.slugFor(this.currentCategory)}/${ + this.currentCategory.id + }`; } - url = Discourse.getURL(`${url}/${tagId.toLowerCase()}`); + + if (tag && tag.targetTagId) { + url += `/${tag.targetTagId.toLowerCase()}`; + } else { + url += `/${tagId.toLowerCase()}`; + } + url = Discourse.getURL(url); } DiscourseURL.routeTo(url); diff --git a/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 b/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 index c3cb9d0ef3..2843ee1e0c 100644 --- a/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 +++ b/app/assets/javascripts/select-kit/components/tag-group-chooser.js.es6 @@ -1,7 +1,8 @@ import MultiSelectComponent from "select-kit/components/multi-select"; import TagsMixin from "select-kit/mixins/tags"; import renderTag from "discourse/lib/render-tag"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; + const { get, isEmpty, run, makeArray } = Ember; export default MultiSelectComponent.extend(TagsMixin, { @@ -26,15 +27,18 @@ export default MultiSelectComponent.extend(TagsMixin, { }, mutateValues(values) { - this.set("tagGroups", values.filter(v => v)); + this.set( + "tagGroups", + values.filter(v => v) + ); }, - @computed("tagGroups") + @discourseComputed("tagGroups") values(tagGroups) { return makeArray(tagGroups); }, - @computed("tagGroups") + @discourseComputed("tagGroups") content(tagGroups) { return makeArray(tagGroups); }, diff --git a/app/assets/javascripts/select-kit/components/tag-notifications-button.js.es6 b/app/assets/javascripts/select-kit/components/tag-notifications-button.js.es6 index 12aef37fac..c163c3d5d9 100644 --- a/app/assets/javascripts/select-kit/components/tag-notifications-button.js.es6 +++ b/app/assets/javascripts/select-kit/components/tag-notifications-button.js.es6 @@ -1,5 +1,5 @@ import NotificationOptionsComponent from "select-kit/components/notifications-button"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default NotificationOptionsComponent.extend({ pluginApiIdentifiers: ["tag-notifications-button"], @@ -16,7 +16,7 @@ export default NotificationOptionsComponent.extend({ return this.notificationLevel; }, - @computed("iconForSelectedDetails") + @discourseComputed("iconForSelectedDetails") headerIcon(iconForSelectedDetails) { return iconForSelectedDetails; } diff --git a/app/assets/javascripts/select-kit/components/timezone-input.js.es6 b/app/assets/javascripts/select-kit/components/timezone-input.js.es6 index e141839a1d..cc837e1d66 100644 --- a/app/assets/javascripts/select-kit/components/timezone-input.js.es6 +++ b/app/assets/javascripts/select-kit/components/timezone-input.js.es6 @@ -1,5 +1,5 @@ import ComboBoxComponent from "select-kit/components/combo-box"; -import { default as computed } from "ember-addons/ember-computed-decorators"; +import { default as discourseComputed } from "discourse-common/utils/decorators"; export default ComboBoxComponent.extend({ pluginApiIdentifiers: ["timezone-input"], @@ -9,7 +9,7 @@ export default ComboBoxComponent.extend({ filterable: true, allowAny: false, - @computed + @discourseComputed content() { let timezones; diff --git a/app/assets/javascripts/select-kit/components/toolbar-popup-menu-options.js.es6 b/app/assets/javascripts/select-kit/components/toolbar-popup-menu-options.js.es6 index 39d8348119..8e25330942 100644 --- a/app/assets/javascripts/select-kit/components/toolbar-popup-menu-options.js.es6 +++ b/app/assets/javascripts/select-kit/components/toolbar-popup-menu-options.js.es6 @@ -1,13 +1,14 @@ +import { empty } from "@ember/object/computed"; import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default DropdownSelectBoxComponent.extend({ pluginApiIdentifiers: ["toolbar-popup-menu-options"], classNames: ["toolbar-popup-menu-options"], - isHidden: Ember.computed.empty("computedContent"), + isHidden: empty("computedContent"), showFullTitle: false, - @computed("title") + @discourseComputed("title") collectionHeader(title) { return `

${title}

`; }, diff --git a/app/assets/javascripts/select-kit/components/topic-footer-mobile-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/topic-footer-mobile-dropdown.js.es6 index 178caa43b5..07d665486c 100644 --- a/app/assets/javascripts/select-kit/components/topic-footer-mobile-dropdown.js.es6 +++ b/app/assets/javascripts/select-kit/components/topic-footer-mobile-dropdown.js.es6 @@ -1,3 +1,4 @@ +import { empty } from "@ember/object/computed"; import ComboBoxComponent from "select-kit/components/combo-box"; export default ComboBoxComponent.extend({ @@ -8,7 +9,7 @@ export default ComboBoxComponent.extend({ allowInitialValueMutation: false, allowAutoSelectFirst: false, nameProperty: "label", - isHidden: Ember.computed.empty("content"), + isHidden: empty("content"), computeHeaderContent() { const content = this._super(...arguments); diff --git a/app/assets/javascripts/select-kit/components/topic-notifications-options.js.es6 b/app/assets/javascripts/select-kit/components/topic-notifications-options.js.es6 index e59e5d5d34..1001c7884d 100644 --- a/app/assets/javascripts/select-kit/components/topic-notifications-options.js.es6 +++ b/app/assets/javascripts/select-kit/components/topic-notifications-options.js.es6 @@ -1,8 +1,8 @@ import NotificationOptionsComponent from "select-kit/components/notifications-button"; import { - default as computed, + default as discourseComputed, on -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { topicLevels } from "discourse/lib/notification-levels"; export default NotificationOptionsComponent.extend({ @@ -12,7 +12,7 @@ export default NotificationOptionsComponent.extend({ i18nPrefix: "topic.notifications", allowInitialValueMutation: false, - @computed("topic.archetype") + @discourseComputed("topic.archetype") i18nPostfix(archetype) { return archetype === "private_message" ? "_pm" : ""; }, diff --git a/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 index 2289b2f667..2551e20b61 100644 --- a/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 +++ b/app/assets/javascripts/select-kit/components/user-notifications-dropdown.js.es6 @@ -1,7 +1,7 @@ import DropdownSelectBox from "select-kit/components/dropdown-select-box"; import { popupAjaxError } from "discourse/lib/ajax-error"; import showModal from "discourse/lib/show-modal"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default DropdownSelectBox.extend({ classNames: ["user-notifications", "user-notifications-dropdown"], @@ -36,7 +36,7 @@ export default DropdownSelectBox.extend({ return content; }, - @computed("value") + @discourseComputed("value") headerIcon(value) { return this.computeContent().find(row => row.id === value).icon; }, @@ -53,7 +53,7 @@ export default DropdownSelectBox.extend({ }); }, - @computed("user.ignored", "user.muted") + @discourseComputed("user.ignored", "user.muted") value() { if (this.get("user.ignored")) { return "changeToIgnored"; diff --git a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 index 98e79eba58..42c79a1f44 100644 --- a/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/dom-helpers.js.es6 @@ -1,6 +1,6 @@ import { next } from "@ember/runloop"; import { schedule } from "@ember/runloop"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; export default Mixin.create({ @@ -187,7 +187,7 @@ export default Mixin.create({ const offsetBottom = this.element.getBoundingClientRect().bottom; const windowWidth = $(window).width(); - if (this.fullWidthOnMobile && (this.site && this.site.isMobileDevice)) { + if (this.fullWidthOnMobile && this.site && this.site.isMobileDevice) { const margin = 10; const relativeLeft = $(this.element).offset().left - $(window).scrollLeft(); diff --git a/app/assets/javascripts/select-kit/mixins/events.js.es6 b/app/assets/javascripts/select-kit/mixins/events.js.es6 index 739d0925e3..4c54979d1f 100644 --- a/app/assets/javascripts/select-kit/mixins/events.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/events.js.es6 @@ -3,7 +3,7 @@ import { makeArray } from "discourse-common/lib/helpers"; import { isEmpty } from "@ember/utils"; import { throttle } from "@ember/runloop"; import { schedule } from "@ember/runloop"; -import { on } from "ember-addons/ember-computed-decorators"; +import { on } from "discourse-common/utils/decorators"; import Mixin from "@ember/object/mixin"; const { bind } = Ember.run; diff --git a/app/assets/javascripts/select-kit/mixins/plugin-api.js.es6 b/app/assets/javascripts/select-kit/mixins/plugin-api.js.es6 index 68dd1c8839..9386c595cf 100644 --- a/app/assets/javascripts/select-kit/mixins/plugin-api.js.es6 +++ b/app/assets/javascripts/select-kit/mixins/plugin-api.js.es6 @@ -1,8 +1,9 @@ import Mixin from "@ember/object/mixin"; +import { isNone } from "@ember/utils"; let _appendContentCallbacks = {}; function appendContent(pluginApiIdentifiers, contentFunction) { - if (Ember.isNone(_appendContentCallbacks[pluginApiIdentifiers])) { + if (isNone(_appendContentCallbacks[pluginApiIdentifiers])) { _appendContentCallbacks[pluginApiIdentifiers] = []; } @@ -11,7 +12,7 @@ function appendContent(pluginApiIdentifiers, contentFunction) { let _prependContentCallbacks = {}; function prependContent(pluginApiIdentifiers, contentFunction) { - if (Ember.isNone(_prependContentCallbacks[pluginApiIdentifiers])) { + if (isNone(_prependContentCallbacks[pluginApiIdentifiers])) { _prependContentCallbacks[pluginApiIdentifiers] = []; } @@ -20,7 +21,7 @@ function prependContent(pluginApiIdentifiers, contentFunction) { let _modifyContentCallbacks = {}; function modifyContent(pluginApiIdentifiers, contentFunction) { - if (Ember.isNone(_modifyContentCallbacks[pluginApiIdentifiers])) { + if (isNone(_modifyContentCallbacks[pluginApiIdentifiers])) { _modifyContentCallbacks[pluginApiIdentifiers] = []; } @@ -29,9 +30,7 @@ function modifyContent(pluginApiIdentifiers, contentFunction) { let _modifyHeaderComputedContentCallbacks = {}; function modifyHeaderComputedContent(pluginApiIdentifiers, contentFunction) { - if ( - Ember.isNone(_modifyHeaderComputedContentCallbacks[pluginApiIdentifiers]) - ) { + if (isNone(_modifyHeaderComputedContentCallbacks[pluginApiIdentifiers])) { _modifyHeaderComputedContentCallbacks[pluginApiIdentifiers] = []; } @@ -42,7 +41,7 @@ function modifyHeaderComputedContent(pluginApiIdentifiers, contentFunction) { let _modifyCollectionHeaderCallbacks = {}; function modifyCollectionHeader(pluginApiIdentifiers, contentFunction) { - if (Ember.isNone(_modifyCollectionHeaderCallbacks[pluginApiIdentifiers])) { + if (isNone(_modifyCollectionHeaderCallbacks[pluginApiIdentifiers])) { _modifyCollectionHeaderCallbacks[pluginApiIdentifiers] = []; } @@ -51,7 +50,7 @@ function modifyCollectionHeader(pluginApiIdentifiers, contentFunction) { let _onSelectNoneCallbacks = {}; function onSelectNone(pluginApiIdentifiers, mutationFunction) { - if (Ember.isNone(_onSelectNoneCallbacks[pluginApiIdentifiers])) { + if (isNone(_onSelectNoneCallbacks[pluginApiIdentifiers])) { _onSelectNoneCallbacks[pluginApiIdentifiers] = []; } @@ -60,7 +59,7 @@ function onSelectNone(pluginApiIdentifiers, mutationFunction) { let _onSelectCallbacks = {}; function onSelect(pluginApiIdentifiers, mutationFunction) { - if (Ember.isNone(_onSelectCallbacks[pluginApiIdentifiers])) { + if (isNone(_onSelectCallbacks[pluginApiIdentifiers])) { _onSelectCallbacks[pluginApiIdentifiers] = []; } diff --git a/app/assets/javascripts/wizard-application.js b/app/assets/javascripts/wizard-application.js index d5460545ae..3a409952c1 100644 --- a/app/assets/javascripts/wizard-application.js +++ b/app/assets/javascripts/wizard-application.js @@ -1,7 +1,7 @@ //= require_tree ./ember-addons/utils //= require ./ember-addons/decorator-alias //= require ./ember-addons/macro-alias -//= require ./ember-addons/ember-computed-decorators +//= require ./discourse-common/utils/decorators //= require_tree ./discourse-common //= require i18n-patches //= require_tree ./select-kit diff --git a/app/assets/javascripts/wizard/components/homepage-preview.js.es6 b/app/assets/javascripts/wizard/components/homepage-preview.js.es6 index f92f7b04f3..c0de35c55a 100644 --- a/app/assets/javascripts/wizard/components/homepage-preview.js.es6 +++ b/app/assets/javascripts/wizard/components/homepage-preview.js.es6 @@ -1,4 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent, LOREM, diff --git a/app/assets/javascripts/wizard/components/image-preview-favicon.js.es6 b/app/assets/javascripts/wizard/components/image-preview-favicon.js.es6 index 91a72ecda7..31df907e58 100644 --- a/app/assets/javascripts/wizard/components/image-preview-favicon.js.es6 +++ b/app/assets/javascripts/wizard/components/image-preview-favicon.js.es6 @@ -1,5 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; - +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent } from "wizard/lib/preview"; export default createPreviewComponent(371, 124, { diff --git a/app/assets/javascripts/wizard/components/image-preview-large-icon.js.es6 b/app/assets/javascripts/wizard/components/image-preview-large-icon.js.es6 index 17a605c23a..30ad47db87 100644 --- a/app/assets/javascripts/wizard/components/image-preview-large-icon.js.es6 +++ b/app/assets/javascripts/wizard/components/image-preview-large-icon.js.es6 @@ -1,4 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent } from "wizard/lib/preview"; export default createPreviewComponent(325, 125, { diff --git a/app/assets/javascripts/wizard/components/image-preview-logo-small.js.es6 b/app/assets/javascripts/wizard/components/image-preview-logo-small.js.es6 index db498c91ee..64ad289234 100644 --- a/app/assets/javascripts/wizard/components/image-preview-logo-small.js.es6 +++ b/app/assets/javascripts/wizard/components/image-preview-logo-small.js.es6 @@ -1,5 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; - +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent, drawHeader, LOREM } from "wizard/lib/preview"; export default createPreviewComponent(375, 100, { diff --git a/app/assets/javascripts/wizard/components/image-preview-logo.js.es6 b/app/assets/javascripts/wizard/components/image-preview-logo.js.es6 index f8b5d3f39c..3c37019b50 100644 --- a/app/assets/javascripts/wizard/components/image-preview-logo.js.es6 +++ b/app/assets/javascripts/wizard/components/image-preview-logo.js.es6 @@ -1,5 +1,4 @@ -import { observes } from "ember-addons/ember-computed-decorators"; - +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent, drawHeader } from "wizard/lib/preview"; export default createPreviewComponent(400, 100, { diff --git a/app/assets/javascripts/wizard/components/invite-list-user.js.es6 b/app/assets/javascripts/wizard/components/invite-list-user.js.es6 index cc07ab1a2d..8cc39470a3 100644 --- a/app/assets/javascripts/wizard/components/invite-list-user.js.es6 +++ b/app/assets/javascripts/wizard/components/invite-list-user.js.es6 @@ -1,10 +1,10 @@ import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Component.extend({ classNames: ["invite-list-user"], - @computed("user.role") + @discourseComputed("user.role") roleName(role) { return this.roles.findBy("id", role).label; } diff --git a/app/assets/javascripts/wizard/components/radio-button.js.es6 b/app/assets/javascripts/wizard/components/radio-button.js.es6 index e7ceaa898f..6184362bb5 100644 --- a/app/assets/javascripts/wizard/components/radio-button.js.es6 +++ b/app/assets/javascripts/wizard/components/radio-button.js.es6 @@ -1,6 +1,6 @@ import { next } from "@ember/runloop"; import Component from "@ember/component"; -import { observes, on } from "ember-addons/ember-computed-decorators"; +import { observes, on } from "discourse-common/utils/decorators"; export default Component.extend({ tagName: "label", diff --git a/app/assets/javascripts/wizard/components/staff-count.js.es6 b/app/assets/javascripts/wizard/components/staff-count.js.es6 index 2e7db93b3c..d37b291037 100644 --- a/app/assets/javascripts/wizard/components/staff-count.js.es6 +++ b/app/assets/javascripts/wizard/components/staff-count.js.es6 @@ -1,7 +1,7 @@ import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Component.extend({ - @computed("field.value") + @discourseComputed("field.value") showStaffCount: staffCount => staffCount > 1 }); diff --git a/app/assets/javascripts/wizard/components/theme-preview.js.es6 b/app/assets/javascripts/wizard/components/theme-preview.js.es6 index 2a098fb879..768e2e5522 100644 --- a/app/assets/javascripts/wizard/components/theme-preview.js.es6 +++ b/app/assets/javascripts/wizard/components/theme-preview.js.es6 @@ -1,6 +1,5 @@ -import computed from "ember-addons/ember-computed-decorators"; -import { observes } from "ember-addons/ember-computed-decorators"; - +import discourseComputed from "discourse-common/utils/decorators"; +import { observes } from "discourse-common/utils/decorators"; import { createPreviewComponent, darkLightDiff, @@ -14,7 +13,7 @@ export default createPreviewComponent(305, 165, { classNameBindings: ["isSelected"], - @computed("selectedId", "colorsId") + @discourseComputed("selectedId", "colorsId") isSelected(selectedId, colorsId) { return selectedId === colorsId; }, diff --git a/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 b/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 index a407bd5046..e97137707d 100644 --- a/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-field-image.js.es6 @@ -1,6 +1,6 @@ import Component from "@ember/component"; import getUrl from "discourse-common/lib/get-url"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { getToken } from "wizard/lib/ajax"; import { getOwner } from "discourse-common/lib/get-owner"; import { dasherize } from "@ember/string"; @@ -9,7 +9,7 @@ export default Component.extend({ classNames: ["wizard-image-row"], uploading: false, - @computed("field.id") + @discourseComputed("field.id") previewComponent(id) { const componentName = `image-preview-${dasherize(id)}`; const exists = getOwner(this).lookup(`component:${componentName}`); diff --git a/app/assets/javascripts/wizard/components/wizard-field.js.es6 b/app/assets/javascripts/wizard/components/wizard-field.js.es6 index 7636eee0d0..d5491a4ef9 100644 --- a/app/assets/javascripts/wizard/components/wizard-field.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-field.js.es6 @@ -1,17 +1,17 @@ import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import { dasherize } from "@ember/string"; export default Component.extend({ classNameBindings: [":wizard-field", "typeClass", "field.invalid"], - @computed("field.type") + @discourseComputed("field.type") typeClass: type => `${dasherize(type)}-field`, - @computed("field.id") + @discourseComputed("field.id") fieldClass: id => `field-${dasherize(id)} wizard-focusable`, - @computed("field.type", "field.id") + @discourseComputed("field.type", "field.id") inputComponentName(type, id) { return type === "component" ? dasherize(id) : `wizard-field-${type}`; } diff --git a/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 b/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 index 7bc7a8446e..461a5a0d18 100644 --- a/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 @@ -1,9 +1,9 @@ import Component from "@ember/component"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Component.extend({ classNameBindings: [":wizard-step-form", "customStepClass"], - @computed("step.id") + @discourseComputed("step.id") customStepClass: stepId => `wizard-step-${stepId}` }); diff --git a/app/assets/javascripts/wizard/components/wizard-step.js.es6 b/app/assets/javascripts/wizard/components/wizard-step.js.es6 index a1a3372473..c5008ac2ac 100644 --- a/app/assets/javascripts/wizard/components/wizard-step.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-step.js.es6 @@ -2,9 +2,9 @@ import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import getUrl from "discourse-common/lib/get-url"; import { - default as computed, + default as discourseComputed, observes -} from "ember-addons/ember-computed-decorators"; +} from "discourse-common/utils/decorators"; import { htmlSafe } from "@ember/template"; jQuery.fn.wiggle = function(times, duration) { @@ -34,16 +34,16 @@ export default Component.extend({ this.autoFocus(); }, - @computed("step.index") + @discourseComputed("step.index") showQuitButton: index => index === 0, - @computed("step.displayIndex", "wizard.totalSteps") + @discourseComputed("step.displayIndex", "wizard.totalSteps") showNextButton: (current, total) => current < total, - @computed("step.displayIndex", "wizard.totalSteps") + @discourseComputed("step.displayIndex", "wizard.totalSteps") showDoneButton: (current, total) => current === total, - @computed( + @discourseComputed( "step.index", "step.displayIndex", "wizard.totalSteps", @@ -53,10 +53,10 @@ export default Component.extend({ return index !== 0 && displayIndex !== total && completed; }, - @computed("step.index") + @discourseComputed("step.index") showBackButton: index => index > 0, - @computed("step.banner") + @discourseComputed("step.banner") bannerImage(src) { if (!src) { return; @@ -80,7 +80,7 @@ export default Component.extend({ } }, - @computed("step.index", "wizard.totalSteps") + @discourseComputed("step.index", "wizard.totalSteps") barStyle(displayIndex, totalSteps) { let ratio = parseFloat(displayIndex) / parseFloat(totalSteps - 1); if (ratio < 0) { diff --git a/app/assets/javascripts/wizard/controllers/application.js.es6 b/app/assets/javascripts/wizard/controllers/application.js.es6 index 58aede12a1..ca6d2d3e9e 100644 --- a/app/assets/javascripts/wizard/controllers/application.js.es6 +++ b/app/assets/javascripts/wizard/controllers/application.js.es6 @@ -1,10 +1,10 @@ import Controller from "@ember/controller"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export default Controller.extend({ currentStepId: null, - @computed("currentStepId") + @discourseComputed("currentStepId") showCanvas(currentStepId) { return currentStepId === "finished"; } diff --git a/app/assets/javascripts/wizard/lib/ajax.js.es6 b/app/assets/javascripts/wizard/lib/ajax.js.es6 index 64cc9571cc..afb8b23ce2 100644 --- a/app/assets/javascripts/wizard/lib/ajax.js.es6 +++ b/app/assets/javascripts/wizard/lib/ajax.js.es6 @@ -1,5 +1,6 @@ import { run } from "@ember/runloop"; import getUrl from "discourse-common/lib/get-url"; +import { Promise } from "rsvp"; import jQuery from "jquery"; let token; @@ -13,7 +14,7 @@ export function getToken() { } export function ajax(args) { - return new Ember.RSVP.Promise((resolve, reject) => { + return new Promise((resolve, reject) => { args.headers = { "X-CSRF-Token": getToken() }; args.success = data => run(null, resolve, data); args.error = xhr => run(null, reject, xhr); diff --git a/app/assets/javascripts/wizard/lib/preview.js.es6 b/app/assets/javascripts/wizard/lib/preview.js.es6 index d597bfae53..3f4d9fdf10 100644 --- a/app/assets/javascripts/wizard/lib/preview.js.es6 +++ b/app/assets/javascripts/wizard/lib/preview.js.es6 @@ -2,6 +2,7 @@ import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; /*eslint no-bitwise:0 */ import getUrl from "discourse-common/lib/get-url"; +import { Promise } from "rsvp"; export const LOREM = ` Lorem ipsum dolor sit amet, @@ -56,13 +57,13 @@ export function createPreviewComponent(width, height, obj) { loadImages() { const images = this.images(); if (images) { - return Ember.RSVP.Promise.all( + return Promise.all( Object.keys(images).map(id => { return loadImage(images[id]).then(img => (this[id] = img)); }) ); } - return Ember.RSVP.Promise.resolve(); + return Promise.resolve(); }, reload() { @@ -270,12 +271,12 @@ export function createPreviewComponent(width, height, obj) { function loadImage(src) { if (!src) { - return Ember.RSVP.Promise.resolve(); + return Promise.resolve(); } const img = new Image(); img.src = getUrl(src); - return new Ember.RSVP.Promise(resolve => (img.onload = () => resolve(img))); + return new Promise(resolve => (img.onload = () => resolve(img))); } export function parseColor(color) { diff --git a/app/assets/javascripts/wizard/mixins/valid-state.js.es6 b/app/assets/javascripts/wizard/mixins/valid-state.js.es6 index b2dc3e8954..1f82cccb91 100644 --- a/app/assets/javascripts/wizard/mixins/valid-state.js.es6 +++ b/app/assets/javascripts/wizard/mixins/valid-state.js.es6 @@ -1,4 +1,4 @@ -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; export const States = { UNCHECKED: 0, @@ -15,13 +15,13 @@ export default { this.set("_validState", States.UNCHECKED); }, - @computed("_validState") + @discourseComputed("_validState") valid: state => state === States.VALID, - @computed("_validState") + @discourseComputed("_validState") invalid: state => state === States.INVALID, - @computed("_validState") + @discourseComputed("_validState") unchecked: state => state === States.UNCHECKED, setValid(valid, description) { diff --git a/app/assets/javascripts/wizard/models/step.js.es6 b/app/assets/javascripts/wizard/models/step.js.es6 index cbc7edca18..7909d39aaf 100644 --- a/app/assets/javascripts/wizard/models/step.js.es6 +++ b/app/assets/javascripts/wizard/models/step.js.es6 @@ -1,15 +1,15 @@ import EmberObject from "@ember/object"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import ValidState from "wizard/mixins/valid-state"; import { ajax } from "wizard/lib/ajax"; export default EmberObject.extend(ValidState, { id: null, - @computed("index") + @discourseComputed("index") displayIndex: index => index + 1, - @computed("fields.[]") + @discourseComputed("fields.[]") fieldsById(fields) { const lookup = {}; fields.forEach(field => (lookup[field.get("id")] = field)); diff --git a/app/assets/javascripts/wizard/models/wizard.js.es6 b/app/assets/javascripts/wizard/models/wizard.js.es6 index 569a2d5bf7..e907687e11 100644 --- a/app/assets/javascripts/wizard/models/wizard.js.es6 +++ b/app/assets/javascripts/wizard/models/wizard.js.es6 @@ -1,11 +1,11 @@ import Step from "wizard/models/step"; import WizardField from "wizard/models/wizard-field"; import { ajax } from "wizard/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; +import discourseComputed from "discourse-common/utils/decorators"; import EmberObject from "@ember/object"; const Wizard = EmberObject.extend({ - @computed("steps.length") + @discourseComputed("steps.length") totalSteps: length => length, getTitle() { diff --git a/app/assets/javascripts/wizard/router.js.es6 b/app/assets/javascripts/wizard/router.js.es6 index 5a1596b8cd..05921b0a5d 100644 --- a/app/assets/javascripts/wizard/router.js.es6 +++ b/app/assets/javascripts/wizard/router.js.es6 @@ -1,8 +1,9 @@ import getUrl from "discourse-common/lib/get-url"; +import ENV from "discourse-common/config/environment"; const Router = Ember.Router.extend({ rootURL: getUrl("/wizard/"), - location: Ember.testing ? "none" : "history" + location: ENV.environment === "test" ? "none" : "history" }); Router.map(function() { diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 9b72049a89..a45e49ac60 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -313,9 +313,7 @@ $mobile-breakpoint: 700px; } td.x-value { max-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include ellipsis; } } .bar-container { @@ -861,9 +859,7 @@ table#user-badges { .value-list { .value { padding: 0.125em 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; display: flex; &:last-child { diff --git a/app/assets/stylesheets/common/admin/admin_report_counters.scss b/app/assets/stylesheets/common/admin/admin_report_counters.scss index 0496e7dd3b..1731ef65cb 100644 --- a/app/assets/stylesheets/common/admin/admin_report_counters.scss +++ b/app/assets/stylesheets/common/admin/admin_report_counters.scss @@ -37,9 +37,7 @@ &.title { text-align: left; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + @include ellipsis; .d-icon { color: $primary-low-mid; diff --git a/app/assets/stylesheets/common/admin/admin_report_table.scss b/app/assets/stylesheets/common/admin/admin_report_table.scss index e63330355a..2de64f7326 100644 --- a/app/assets/stylesheets/common/admin/admin_report_table.scss +++ b/app/assets/stylesheets/common/admin/admin_report_table.scss @@ -2,9 +2,7 @@ &.two-columns { .table .admin-report-table-cell:first-child, .table .admin-report-table-header:first-child { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + @include ellipsis; text-align: left; width: 80%; } @@ -32,8 +30,7 @@ outline: none; background: none; padding: 3px 8px; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } &.is-current-sort { @@ -106,9 +103,7 @@ } .admin-report-table-cell.term { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + @include ellipsis; } } @@ -132,9 +127,7 @@ .admin-report-table-cell.post, .admin-report-table-cell.edit_reason { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + @include ellipsis; } } diff --git a/app/assets/stylesheets/common/admin/backups.scss b/app/assets/stylesheets/common/admin/backups.scss index b5d8658639..a69f7c8003 100644 --- a/app/assets/stylesheets/common/admin/backups.scss +++ b/app/assets/stylesheets/common/admin/backups.scss @@ -96,3 +96,18 @@ button.ru { margin-left: 10px; } } + +.backup-options { + display: flex; + align-items: center; + flex-wrap: wrap; + .btn { + margin-right: 0.5em; + } + .backup-message { + margin-left: auto; + @include breakpoint(mobile-extra-large) { + margin: 1.25em 0 0; + } + } +} diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index 7bc0de21ac..e96f6cf903 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -150,6 +150,9 @@ a.license-url { display: inline-block; margin-right: 10px; + .d-icon { + margin-left: 5px; + } } .mini-title { @@ -316,7 +319,9 @@ } .theme.settings { - .theme-setting, + .theme-setting { + min-height: 35px; + } .theme-translation { padding-bottom: 0; margin-top: 18px; @@ -589,7 +594,11 @@ .category, .external_url, .post { - text-overflow: ellipsis; + @include ellipsis; + max-width: 100px; + @include breakpoint(tablet) { + max-width: 100%; + } } &.grid tr.admin-list-item { diff --git a/app/assets/stylesheets/common/admin/emails.scss b/app/assets/stylesheets/common/admin/emails.scss index cb71da61aa..4684d98500 100644 --- a/app/assets/stylesheets/common/admin/emails.scss +++ b/app/assets/stylesheets/common/admin/emails.scss @@ -15,16 +15,12 @@ } .username div { max-width: 180px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .addresses p { margin: 2px 0; max-width: 200px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } } diff --git a/app/assets/stylesheets/common/admin/users.scss b/app/assets/stylesheets/common/admin/users.scss index 87bf9bffa9..29f430a5de 100644 --- a/app/assets/stylesheets/common/admin/users.scss +++ b/app/assets/stylesheets/common/admin/users.scss @@ -102,10 +102,8 @@ .admin-users-list { td.username { - white-space: nowrap; + @include ellipsis; overflow-wrap: break-word; - overflow: hidden; - text-overflow: ellipsis; } @media screen and (max-width: 970px) and (min-width: 768px) { td.username { diff --git a/app/assets/stylesheets/common/base/category-list.scss b/app/assets/stylesheets/common/base/category-list.scss index ba256cc086..7ad6f5044b 100644 --- a/app/assets/stylesheets/common/base/category-list.scss +++ b/app/assets/stylesheets/common/base/category-list.scss @@ -7,8 +7,7 @@ .category-name { display: inline-block; max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; vertical-align: text-top; line-height: $line-height-medium; } @@ -109,8 +108,6 @@ color: dark-light-choose($primary-medium, $secondary-high); .overflow { max-height: 6em; - overflow: hidden; - text-overflow: ellipsis; } } @@ -122,7 +119,6 @@ text-align: center; color: $primary; overflow: hidden; - text-overflow: ellipsis; } .subcategories { @@ -131,9 +127,7 @@ .subcategory { display: flex; align-items: center; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; margin-right: 1em; margin-bottom: 0.6em; .subcategory-image-placeholder { @@ -142,8 +136,7 @@ } .subcategory-link { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .logo img { display: inline-block; @@ -166,7 +159,6 @@ font-size: $font-up-2; text-align: center; overflow: hidden; - text-overflow: ellipsis; } .category-box-heading { @@ -192,7 +184,6 @@ .overflow { max-height: 3em; overflow: hidden; - text-overflow: ellipsis; } .d-icon { margin-right: 0.15em; diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index ff516a3c3a..7dca1ac5ea 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -122,9 +122,7 @@ } .action-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .topic-link, @@ -136,9 +134,7 @@ .username { margin-right: 5px; max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include ellipsis; @media screen and (max-width: 500px) { display: none; } @@ -358,9 +354,7 @@ a { padding: 5px; display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; span.username { color: $primary; @@ -443,6 +437,9 @@ div.ac-wrap { margin: 0; background: transparent; min-height: unset; + &.fullwidth-input { + width: 100%; + } } } diff --git a/app/assets/stylesheets/common/base/emoji.scss b/app/assets/stylesheets/common/base/emoji.scss index cf3869e3df..0c59b17842 100644 --- a/app/assets/stylesheets/common/base/emoji.scss +++ b/app/assets/stylesheets/common/base/emoji.scss @@ -106,10 +106,8 @@ sup img.emoji { } .emoji-picker .info { - text-overflow: ellipsis; + @include ellipsis; padding-left: 8px; - white-space: nowrap; - overflow: hidden; font-weight: 700; max-width: 125px; } diff --git a/app/assets/stylesheets/common/base/header.scss b/app/assets/stylesheets/common/base/header.scss index 603585af54..f95b8ded1e 100644 --- a/app/assets/stylesheets/common/base/header.scss +++ b/app/assets/stylesheets/common/base/header.scss @@ -245,9 +245,7 @@ .topic-link { color: $header_primary; display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .topic-statuses { .d-icon { @@ -275,9 +273,7 @@ flex: 0 1 auto; min-width: 1px; .badge-wrapper { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; } } .badge-wrapper { @@ -305,9 +301,7 @@ flex: 1 0 0%; // unit on flex-basis is required for IE11 .discourse-tags { color: $header_primary-high; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; .discourse-tag { display: inline; // tags need to stay inline in order for them to truncate } @@ -357,9 +351,7 @@ $mobile-avatar-height: 1.532em; padding: 0 5px; border: 1px solid $primary-low; border-radius: 0.25em; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; .icon { display: flex; @@ -376,9 +368,7 @@ $mobile-avatar-height: 1.532em; } span { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; } } diff --git a/app/assets/stylesheets/common/base/lightbox.scss b/app/assets/stylesheets/common/base/lightbox.scss index 20ed8ebea6..b15a724b2a 100644 --- a/app/assets/stylesheets/common/base/lightbox.scss +++ b/app/assets/stylesheets/common/base/lightbox.scss @@ -47,9 +47,7 @@ $meta-element-margin: 6px; .filename { margin: $meta-element-margin; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; } .d-icon { @@ -74,3 +72,18 @@ $meta-element-margin: 6px; .mfp-preloader .spinner { margin: auto; } + +.discourse-no-touch { + @if is-light-color-scheme() { + a.lightbox { + -webkit-transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + transition: all 0.6s cubic-bezier(0.165, 0.84, 0.44, 1); + } + + a.lightbox:hover { + border-radius: 5px; + box-shadow: 0 2px 5px 0 rgba($primary, 0.2), + 0 2px 10px 0 rgba($primary, 0.2); + } + } +} diff --git a/app/assets/stylesheets/common/base/login.scss b/app/assets/stylesheets/common/base/login.scss index 00beeeccd6..fa1af85ec7 100644 --- a/app/assets/stylesheets/common/base/login.scss +++ b/app/assets/stylesheets/common/base/login.scss @@ -168,3 +168,21 @@ button#new-account-link { background: transparent; color: $primary-high; } + +#security-key { + display: flex; + flex-wrap: wrap; + align-items: center; + button { + margin-right: 1.5em; + } + p { + margin: 0; + font-size: $font-0; + } + a { + display: inline-block; + padding: 1em 0.5em 1em 0; + color: $tertiary; + } +} diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss index a02fccf8b8..c0693508f6 100644 --- a/app/assets/stylesheets/common/base/menu-panel.scss +++ b/app/assets/stylesheets/common/base/menu-panel.scss @@ -115,8 +115,6 @@ line-height: $line-height-large; } .badge-wrapper { - overflow: hidden; - text-overflow: ellipsis; &.bar, &.bullet { color: $primary; @@ -143,8 +141,6 @@ span.badge-category { max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; } div.discourse-tags { @@ -225,8 +221,8 @@ .none { padding-top: 5px; } - .spinner-container.visible { - min-height: 30px; + .spinner-container { + min-height: 2em; } .spinner { width: 20px; @@ -341,9 +337,7 @@ div.menu-links-header { span.d-label { display: block; max-width: 130px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include ellipsis; } @include breakpoint(mobile-medium) { diff --git a/app/assets/stylesheets/common/base/onebox.scss b/app/assets/stylesheets/common/base/onebox.scss index e44e7d2d7f..c09bfb54b6 100644 --- a/app/assets/stylesheets/common/base/onebox.scss +++ b/app/assets/stylesheets/common/base/onebox.scss @@ -562,21 +562,18 @@ aside.onebox.twitterstatus .onebox-body { .outer-box { position: absolute; z-index: z("base"); - overflow: hidden; font-size: $font-down-1; color: #fff; background-color: rgba(0, 0, 0, 0.6); - text-overflow: ellipsis; - max-width: 100%; + @include ellipsis; + max-width: 690px; padding: 5px 0; .inner-box { padding-left: 10px; padding-right: 10px; overflow: hidden; - text-overflow: ellipsis; - word-wrap: normal; - white-space: nowrap; + @include ellipsis; .album-title { width: 100%; diff --git a/app/assets/stylesheets/common/base/reviewables.scss b/app/assets/stylesheets/common/base/reviewables.scss index e47cb5287f..701bdf440a 100644 --- a/app/assets/stylesheets/common/base/reviewables.scss +++ b/app/assets/stylesheets/common/base/reviewables.scss @@ -121,6 +121,17 @@ .category-chooser { width: 100%; } + + .d-date-time-input-range { + width: inherit; + border: none; + padding: 0; + + .d-date-input { + flex: 1 1 auto; + border: 1px solid $primary-medium; + } + } } } @@ -284,7 +295,6 @@ .reviewed-by { display: flex; align-items: center; - white-space: nowrap; } .user-flag-percentage { @@ -309,8 +319,7 @@ &.user a, &.reviewed-by a { max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } } @@ -327,8 +336,7 @@ padding: 0.5em 1em 0.5em 0; } @include breakpoint("mobile-large") { - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; padding-right: 0.5em; } } diff --git a/app/assets/stylesheets/common/base/search-menu.scss b/app/assets/stylesheets/common/base/search-menu.scss index 0ba64993ba..04d4714463 100644 --- a/app/assets/stylesheets/common/base/search-menu.scss +++ b/app/assets/stylesheets/common/base/search-menu.scss @@ -153,9 +153,7 @@ .name, .slug { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .name { @@ -207,9 +205,7 @@ .username, .name { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .username { diff --git a/app/assets/stylesheets/common/base/share_link.scss b/app/assets/stylesheets/common/base/share_link.scss index 38d7340ef6..6f5e4f69dc 100644 --- a/app/assets/stylesheets/common/base/share_link.scss +++ b/app/assets/stylesheets/common/base/share_link.scss @@ -54,7 +54,10 @@ .social-link { margin-right: s(2); font-size: $font-up-4; - .d-icon-fab-facebook-square { + .d-icon { + color: dark-light-choose($tertiary, white); + } + .d-icon-fab-facebook { // Adheres to Facebook brand guidelines color: dark-light-choose($facebook, white); } diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss index 0802a3a8f5..ee88625578 100644 --- a/app/assets/stylesheets/common/base/tagging.scss +++ b/app/assets/stylesheets/common/base/tagging.scss @@ -5,6 +5,8 @@ .tag-list { margin-top: 2em; + padding-bottom: 1em; + border-bottom: 1px solid $primary-low; } #list-area .tag-list h3 { @@ -71,9 +73,7 @@ $tag-color: $primary-medium; .discourse-tag { max-width: 14em; display: inline-block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; vertical-align: middle; } @@ -90,6 +90,10 @@ $tag-color: $primary-medium; color: $header-primary_high !important; } + &.large { + font-size: $font-up-2; + } + &.box { background-color: $primary-low; color: $primary-high; @@ -106,6 +110,25 @@ $tag-color: $primary-medium; margin-right: 0; color: $primary-high; } + + &.bullet { + margin-right: 0.5em; + display: inline-flex; + align-items: center; + &:before { + background: $primary-low-mid; + margin-right: 5px; + position: relative; + width: 9px; + height: 9px; + display: inline-block; + content: ""; + } + &.large:before { + width: 13px; + height: 13px; + } + } } .discourse-tags, @@ -154,21 +177,6 @@ $tag-color: $primary-medium; } } -.discourse-tag.bullet { - margin-right: 0.5em; - display: inline-flex; - align-items: center; - &:before { - background: $primary-low-mid; - margin-right: 5px; - position: relative; - width: 9px; - height: 9px; - display: inline-block; - content: ""; - } -} - header .discourse-tag { color: $tag-color; } @@ -260,3 +268,26 @@ header .discourse-tag { } } } + +.tag-info { + margin-top: 1em; + margin-bottom: 1em; + + .delete-tag { + float: right; + } + .synonyms-list, + .add-synonyms, + .tag-associations { + margin-top: 1em; + } + .tag-list { + border: none; + .d-icon { + color: $primary-medium; + } + } + .field { + margin-bottom: 5px; + } +} diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 03c00c58ed..e1eafd1dc1 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -53,9 +53,7 @@ font-size: $font-0; margin-right: 8px; display: inline-block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; vertical-align: middle; a { color: dark-light-choose($primary-high, $secondary-low); @@ -235,8 +233,6 @@ blockquote { // !important here otherwise it won't work. img { max-width: 100% !important; - object-fit: cover; - object-position: top; } } @@ -912,7 +908,10 @@ a.mention-group { background-color: $tertiary-low; border-top: 1px solid $primary-low; display: flex; - max-width: calc(#{$topic-body-width} + #{$topic-avatar-width} - 0.1em); + max-width: calc( + #{$topic-body-width} + (#{$topic-body-width-padding} * 2) + #{$topic-avatar-width} - + (0.8em * 2) + ); padding: 0.8em; &.old { diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index 88c3c8e1dd..a4f1bdc34b 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -220,9 +220,7 @@ a.badge-category { display: flex; align-items: center; span:not(.badge) { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; } } ul { diff --git a/app/assets/stylesheets/common/base/user.scss b/app/assets/stylesheets/common/base/user.scss index 872661991c..8fdafe020f 100644 --- a/app/assets/stylesheets/common/base/user.scss +++ b/app/assets/stylesheets/common/base/user.scss @@ -143,8 +143,6 @@ dd { padding: 0; margin: 0 15px 0 0; - overflow: hidden; - text-overflow: ellipsis; color: $primary; &.groups { @@ -182,9 +180,7 @@ h2 { font-weight: normal; max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } h3 { diff --git a/app/assets/stylesheets/common/components/group-members-input.scss b/app/assets/stylesheets/common/components/group-members-input.scss deleted file mode 100644 index fde660de04..0000000000 --- a/app/assets/stylesheets/common/components/group-members-input.scss +++ /dev/null @@ -1,9 +0,0 @@ -.group-members-input { - .group-members-input-selector { - margin-top: 10px; - - .add { - margin-top: 7px; - } - } -} diff --git a/app/assets/stylesheets/common/components/user-card.scss b/app/assets/stylesheets/common/components/user-card.scss index c1132c06e5..21e15ec0b7 100644 --- a/app/assets/stylesheets/common/components/user-card.scss +++ b/app/assets/stylesheets/common/components/user-card.scss @@ -119,9 +119,7 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards h2, h3 { margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } h1, h2 { @@ -201,9 +199,7 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards } .website-name a, .location span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include ellipsis; color: $primary; } .location { @@ -226,17 +222,11 @@ $avatar_margin: -50px; // negative margin makes avatars extend above cards display: flex; align-items: flex-start; .user-badge { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; background: $primary-very-low; border: 1px solid $primary-low; color: $primary; } - .badge-display-name { - overflow: hidden; - text-overflow: ellipsis; - } .more-user-badges { a { @extend .user-badge; diff --git a/app/assets/stylesheets/common/components/user-info.scss b/app/assets/stylesheets/common/components/user-info.scss index fee047c488..b92b452882 100644 --- a/app/assets/stylesheets/common/components/user-info.scss +++ b/app/assets/stylesheets/common/components/user-info.scss @@ -24,9 +24,7 @@ } .name-line { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .username a { diff --git a/app/assets/stylesheets/common/components/user-stream-item.scss b/app/assets/stylesheets/common/components/user-stream-item.scss index 817225165f..284d0ba479 100644 --- a/app/assets/stylesheets/common/components/user-stream-item.scss +++ b/app/assets/stylesheets/common/components/user-stream-item.scss @@ -66,18 +66,14 @@ } .title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; display: block; } .name { font-size: $font-0; max-width: 400px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .edit-reason { diff --git a/app/assets/stylesheets/common/foundation/mixins.scss b/app/assets/stylesheets/common/foundation/mixins.scss index 99f7634547..b2ce6a55f7 100644 --- a/app/assets/stylesheets/common/foundation/mixins.scss +++ b/app/assets/stylesheets/common/foundation/mixins.scss @@ -57,6 +57,12 @@ $breakpoints: ( } } +@mixin ellipsis { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + // // -------------------------------------------------- diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index 0895022002..22da7ac9bc 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -198,11 +198,23 @@ $box-shadow: ( } } @function dark-light-choose($light-theme-result, $dark-theme-result) { - @if dc-color-brightness($primary) < dc-color-brightness($secondary) { + @if is-light-color-scheme() { @return $light-theme-result; } @else { @return $dark-theme-result; } } +@function is-light-color-scheme() { + @if dc-color-brightness($primary) < dc-color-brightness($secondary) { + @return true; + } @else { + @return false; + } +} + +@function is-dark-color-scheme() { + @return not is-light-color-scheme(); +} + @import "color_transformations"; diff --git a/app/assets/stylesheets/common/select-kit/dropdown-select-box.scss b/app/assets/stylesheets/common/select-kit/dropdown-select-box.scss index d257494c44..d1ef732cfd 100644 --- a/app/assets/stylesheets/common/select-kit/dropdown-select-box.scss +++ b/app/assets/stylesheets/common/select-kit/dropdown-select-box.scss @@ -78,9 +78,7 @@ font-size: $font-0; color: $primary; padding: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include ellipsis; max-width: 100%; } diff --git a/app/assets/stylesheets/common/select-kit/select-kit.scss b/app/assets/stylesheets/common/select-kit/select-kit.scss index c0d86c737b..8cb552acb1 100644 --- a/app/assets/stylesheets/common/select-kit/select-kit.scss +++ b/app/assets/stylesheets/common/select-kit/select-kit.scss @@ -71,9 +71,7 @@ text-align: left; flex: 0 1 auto; padding: 1px 0; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; + @include ellipsis; color: inherit; } diff --git a/app/assets/stylesheets/desktop/category-list.scss b/app/assets/stylesheets/desktop/category-list.scss index 2fc738478c..8e6e8afc9d 100644 --- a/app/assets/stylesheets/desktop/category-list.scss +++ b/app/assets/stylesheets/desktop/category-list.scss @@ -80,9 +80,7 @@ color: dark-light-choose($primary-medium, $secondary-high); } .title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; flex: 0 1 auto; } .topic-statuses { diff --git a/app/assets/stylesheets/desktop/compose.scss b/app/assets/stylesheets/desktop/compose.scss index 7f4db285c2..546f35c34e 100644 --- a/app/assets/stylesheets/desktop/compose.scss +++ b/app/assets/stylesheets/desktop/compose.scss @@ -15,6 +15,10 @@ width: 404px; } + .item + #private-message-users { + width: 150px; + } + .select-kit.is-expanded { z-index: z("composer", "dropdown") + 1; } diff --git a/app/assets/stylesheets/desktop/login.scss b/app/assets/stylesheets/desktop/login.scss index 40b59d5b68..0e2688db34 100644 --- a/app/assets/stylesheets/desktop/login.scss +++ b/app/assets/stylesheets/desktop/login.scss @@ -143,21 +143,25 @@ &:before { content: ""; display: block; - height: 25px; position: absolute; width: 100%; pointer-events: none; } &:after { bottom: 0; + height: 35px; + @media screen and (max-height: 650px) { + height: 45px; + } background-image: linear-gradient( to bottom, rgba($secondary, 0) 0%, - rgba($secondary, 1) 100% + rgba($secondary, 0.9) 100% ); } &:before { top: 0; + height: 25px; background-image: linear-gradient( to top, rgba($secondary, 0) 0%, @@ -167,10 +171,13 @@ form { box-sizing: border-box; padding: s(4 6); - margin-bottom: 5px; + margin-bottom: 0; max-height: 475px; - @media screen and (max-height: 768px) { - max-height: calc(60vh - 100px); + @media screen and (max-height: 650px) { + max-height: calc(65vh - 100px); + > *:last-child { + margin-bottom: 40px; + } } overflow-x: hidden; overflow-y: auto; @@ -203,6 +210,10 @@ } } + .user-fields { + margin-bottom: 20px; + } + .user-field { display: flex; flex-direction: column; diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss index 0e15c47fa6..03ab18a8b9 100644 --- a/app/assets/stylesheets/desktop/topic-list.scss +++ b/app/assets/stylesheets/desktop/topic-list.scss @@ -88,6 +88,9 @@ } } + $td-posters-height: 29px; // min-height of td with avatar glow + $td-posters-more-lh: $td-posters-height - 4; + .posters { // we know there are up to 5 avatars of fixed size // will be overridden by media width queries on narrow displays to 1 avatar's width @@ -98,10 +101,17 @@ &:last-of-type { margin-right: 0; } + + &.posters-more-count { + cursor: default; + color: dark-light-choose($primary-medium, $secondary-medium); + line-height: $td-posters-more-lh; + font-size: $font-down-1; + } } } td.posters { - height: 29px; // min-height of td with avatar glow + height: $td-posters-height; } .posters a:first-child .avatar.latest:not(.single) { box-shadow: 0 0 3px 1px desaturate($tertiary-medium, 35%); diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index 0ec4d3446b..4abaa8d21a 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -495,6 +495,14 @@ video { max-height: 500px; } +.video { + // Height determined by aspect-ratio + max-height: 500px; + > video { + max-height: unset; + } +} + @-webkit-keyframes fadein { from { opacity: 0; diff --git a/app/assets/stylesheets/desktop/user.scss b/app/assets/stylesheets/desktop/user.scss index b951457984..a7c73db6c5 100644 --- a/app/assets/stylesheets/desktop/user.scss +++ b/app/assets/stylesheets/desktop/user.scss @@ -124,16 +124,12 @@ } h1 { max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } .location-and-website { display: flex; max-width: 100%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + @include ellipsis; .user-profile-location { margin-right: 1em; } diff --git a/app/assets/stylesheets/mobile/components/user-card.scss b/app/assets/stylesheets/mobile/components/user-card.scss index 5d87399f9d..0345aaf743 100644 --- a/app/assets/stylesheets/mobile/components/user-card.scss +++ b/app/assets/stylesheets/mobile/components/user-card.scss @@ -28,9 +28,7 @@ $avatar_width: 120px; border-left: 0.5em solid transparent; } button { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } } } diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index 2f25202bbc..3919886d80 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -71,9 +71,7 @@ } .draft-text { width: calc(100% - 40px); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; } } diff --git a/app/assets/stylesheets/mobile/header.scss b/app/assets/stylesheets/mobile/header.scss index 43e9813f2e..413d753fc6 100644 --- a/app/assets/stylesheets/mobile/header.scss +++ b/app/assets/stylesheets/mobile/header.scss @@ -8,9 +8,7 @@ // some protection for text-only site titles .title { max-width: 75%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; -webkit-animation: fadein 0.5s; animation: fadein 0.5s; // This acts as a placeholder if for some reason the small logo takes a while diff --git a/app/assets/stylesheets/wizard.scss b/app/assets/stylesheets/wizard.scss index a5d2019184..2fd2067bd7 100644 --- a/app/assets/stylesheets/wizard.scss +++ b/app/assets/stylesheets/wizard.scss @@ -2,6 +2,7 @@ @import "vendor/sweetalert"; @import "common/foundation/colors"; @import "common/foundation/variables"; +@import "common/foundation/mixins"; @import "common/select-kit/*"; @import "common/components/svg"; @@ -129,7 +130,6 @@ body.wizard { .wizard-step-colors { max-height: 465px; - margin-bottom: 20px; overflow-y: auto; .grid { box-sizing: border-box; @@ -188,6 +188,7 @@ body.wizard { .wizard-step-contents { height: 550px; + margin-bottom: 2em; a { text-decoration: none; color: #6699ff; @@ -515,7 +516,7 @@ body.wizard { } .radio-field-choice { - margin-bottom: 1.5em; + margin-bottom: 1.25em; input { outline: 0; @@ -601,9 +602,6 @@ body.wizard { .wizard-footer { display: none !important; } - .wizard-field { - margin-bottom: 1em !important; - } .wizard-step-description { margin-bottom: 1em !important; } diff --git a/app/controllers/admin/backups_controller.rb b/app/controllers/admin/backups_controller.rb index 2e4762c05f..9d52e669b5 100644 --- a/app/controllers/admin/backups_controller.rb +++ b/app/controllers/admin/backups_controller.rb @@ -204,7 +204,7 @@ class Admin::BackupsController < Admin::AdminController begin upload_url = store.generate_upload_url(filename) rescue BackupRestore::BackupStore::BackupFileExists - return render_json_error(I18n("backup.file_exists")) + return render_json_error(I18n.t("backup.file_exists")) rescue BackupRestore::BackupStore::StorageError => e return render_json_error(e) end diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb index 0a1b741fd4..fe67475e26 100644 --- a/app/controllers/admin/badges_controller.rb +++ b/app/controllers/admin/badges_controller.rb @@ -125,6 +125,15 @@ class Admin::BadgesController < Admin::AdminController badge.save! end + if opts[:new].blank? + Jobs.enqueue( + :bulk_user_title_update, + new_title: badge.name, + granted_badge_id: badge.id, + action: Jobs::BulkUserTitleUpdate::UPDATE_ACTION + ) + end + errors rescue ActiveRecord::RecordInvalid errors.push(*badge.errors.full_messages) diff --git a/app/controllers/admin/site_settings_controller.rb b/app/controllers/admin/site_settings_controller.rb index 689b7bdc43..bc550ba103 100644 --- a/app/controllers/admin/site_settings_controller.rb +++ b/app/controllers/admin/site_settings_controller.rb @@ -26,28 +26,6 @@ class Admin::SiteSettingsController < Admin::AdminController SiteSetting.set_and_log(id, value, current_user) if update_existing_users - user_options = { - default_email_mailing_list_mode: "mailing_list_mode", - default_email_mailing_list_mode_frequency: "mailing_list_mode_frequency", - default_email_level: "email_level", - default_email_messages_level: "email_messages_level", - default_topics_automatic_unpin: "automatically_unpin_topics", - default_email_previous_replies: "email_previous_replies", - default_email_in_reply_to: "email_in_reply_to", - default_other_enable_quoting: "enable_quoting", - default_other_enable_defer: "enable_defer", - default_other_external_links_in_new_tab: "external_links_in_new_tab", - default_other_dynamic_favicon: "dynamic_favicon", - default_other_new_topic_duration_minutes: "new_topic_duration_minutes", - default_other_auto_track_topics_after_msecs: "auto_track_topics_after_msecs", - default_other_notification_level_when_replying: "notification_level_when_replying", - default_other_like_notification_frequency: "like_notification_frequency", - default_email_digest_frequency: "digest_after_minutes", - default_include_tl0_in_digests: "include_tl0_in_digests", - default_text_size: "text_size_key", - default_title_count_mode: "title_count_mode_key" - } - new_value = value || "" if (user_option = user_options[id.to_sym]).present? @@ -80,9 +58,7 @@ class Admin::SiteSettingsController < Admin::AdminController notification_level = NotificationLevels.all[:watching_first_post] end - (previous_category_ids - new_category_ids).each do |category_id| - CategoryUser.where(category_id: category_id, notification_level: notification_level).delete_all - end + CategoryUser.where(category_id: (previous_category_ids - new_category_ids), notification_level: notification_level).delete_all (new_category_ids - previous_category_ids).each do |category_id| skip_user_ids = CategoryUser.where(category_id: category_id).pluck(:user_id) @@ -109,9 +85,7 @@ class Admin::SiteSettingsController < Admin::AdminController notification_level = NotificationLevels.all[:watching_first_post] end - (previous_tag_ids - new_tag_ids).each do |tag_id| - TagUser.where(tag_id: tag_id, notification_level: notification_level).delete_all - end + TagUser.where(tag_id: (previous_tag_ids - new_tag_ids), notification_level: notification_level).delete_all (new_tag_ids - previous_tag_ids).each do |tag_id| skip_user_ids = TagUser.where(tag_id: tag_id).pluck(:user_id) @@ -128,8 +102,103 @@ class Admin::SiteSettingsController < Admin::AdminController render body: nil end + def user_count + params.require(:site_setting_id) + id = params[:site_setting_id] + raise Discourse::NotFound unless id.start_with?("default_") + new_value = params[id] || "" + + raise_access_hidden_setting(id) + previous_value = SiteSetting.send(id) || "" + json = {} + + if (user_option = user_options[id.to_sym]).present? + if user_option == "text_size_key" + previous_value = UserOption.text_sizes[previous_value.to_sym] + elsif user_option == "title_count_mode_key" + previous_value = UserOption.title_count_modes[previous_value.to_sym] + end + + json[:user_count] = UserOption.where(user_option => previous_value).count + elsif id.start_with?("default_categories_") + previous_category_ids = previous_value.split("|") + new_category_ids = new_value.split("|") + + case id + when "default_categories_watching" + notification_level = NotificationLevels.all[:watching] + when "default_categories_tracking" + notification_level = NotificationLevels.all[:tracking] + when "default_categories_muted" + notification_level = NotificationLevels.all[:muted] + when "default_categories_watching_first_post" + notification_level = NotificationLevels.all[:watching_first_post] + end + + user_ids = CategoryUser.where(category_id: previous_category_ids - new_category_ids, notification_level: notification_level).distinct.pluck(:user_id) + user_ids += User + .joins("CROSS JOIN categories c") + .joins("LEFT JOIN category_users cu ON users.id = cu.user_id AND c.id = cu.category_id") + .where("c.id IN (?) AND cu.notification_level IS NULL", new_category_ids - previous_category_ids) + .distinct + .pluck("users.id") + + json[:user_count] = user_ids.uniq.count + elsif id.start_with?("default_tags_") + previous_tag_ids = Tag.where(name: previous_value.split("|")).pluck(:id) + new_tag_ids = Tag.where(name: new_value.split("|")).pluck(:id) + + case id + when "default_tags_watching" + notification_level = TagUser.notification_levels[:watching] + when "default_tags_tracking" + notification_level = TagUser.notification_levels[:tracking] + when "default_tags_muted" + notification_level = TagUser.notification_levels[:muted] + when "default_tags_watching_first_post" + notification_level = TagUser.notification_levels[:watching_first_post] + end + + user_ids = TagUser.where(tag_id: previous_tag_ids - new_tag_ids, notification_level: notification_level).distinct.pluck(:user_id) + user_ids += User + .joins("CROSS JOIN tags t") + .joins("LEFT JOIN tag_users tu ON users.id = tu.user_id AND t.id = tu.tag_id") + .where("t.id IN (?) AND tu.notification_level IS NULL", new_tag_ids - previous_tag_ids) + .distinct + .pluck("users.id") + + json[:user_count] = user_ids.uniq.count + end + + render json: json + end + private + def user_options + { + default_email_mailing_list_mode: "mailing_list_mode", + default_email_mailing_list_mode_frequency: "mailing_list_mode_frequency", + default_email_level: "email_level", + default_email_messages_level: "email_messages_level", + default_topics_automatic_unpin: "automatically_unpin_topics", + default_email_previous_replies: "email_previous_replies", + default_email_in_reply_to: "email_in_reply_to", + default_other_enable_quoting: "enable_quoting", + default_other_enable_defer: "enable_defer", + default_other_external_links_in_new_tab: "external_links_in_new_tab", + default_other_dynamic_favicon: "dynamic_favicon", + default_other_new_topic_duration_minutes: "new_topic_duration_minutes", + default_other_auto_track_topics_after_msecs: "auto_track_topics_after_msecs", + default_other_notification_level_when_replying: "notification_level_when_replying", + default_other_like_notification_frequency: "like_notification_frequency", + default_email_digest_frequency: "digest_after_minutes", + default_include_tl0_in_digests: "include_tl0_in_digests", + default_text_size: "text_size_key", + default_title_count_mode: "title_count_mode_key" + } + end + def raise_access_hidden_setting(id) # note, as of Ruby 2.3 symbols are GC'd so this is considered safe if SiteSetting.hidden_settings.include?(id.to_sym) diff --git a/app/controllers/admin/site_texts_controller.rb b/app/controllers/admin/site_texts_controller.rb index ef594471d9..69d2665023 100644 --- a/app/controllers/admin/site_texts_controller.rb +++ b/app/controllers/admin/site_texts_controller.rb @@ -20,11 +20,15 @@ class Admin::SiteTextsController < Admin::AdminController extras = {} query = params[:q] || "" + + locale = params[:locale] || I18n.locale + raise Discourse::InvalidParameters.new(:locale) if !I18n.locale_available?(locale) + if query.blank? && !overridden extras[:recommended] = true - results = self.class.preferred_keys.map { |k| record_for(k) } + results = I18n.with_locale(locale) { self.class.preferred_keys.map { |k| record_for(k) } } else - results = find_translations(query, overridden) + results = I18n.with_locale(locale) { find_translations(query, overridden) } if results.any? extras[:regex] = I18n::Backend::DiscourseI18n.create_search_regexp(query, as_string: true) @@ -41,8 +45,15 @@ class Admin::SiteTextsController < Admin::AdminController end end - extras[:has_more] = true if results.size > 50 - render_serialized(results[0..49], SiteTextSerializer, root: 'site_texts', rest_serializer: true, extras: extras, overridden_keys: overridden_keys) + page = params[:page].to_i + raise Discourse::InvalidParameters.new(:page) if page < 0 + + per_page = 50 + first = page * per_page + last = first + per_page + + extras[:has_more] = true if results.size > last + render_serialized(results[first..last - 1], SiteTextSerializer, root: 'site_texts', rest_serializer: true, extras: extras, overridden_keys: overridden_keys) end def show @@ -59,6 +70,15 @@ class Admin::SiteTextsController < Admin::AdminController if translation_override.errors.empty? StaffActionLogger.new(current_user).log_site_text_change(id, value, old_value) + system_badge_id = Badge.find_system_badge_id_from_translation_key(id) + if system_badge_id.present? + Jobs.enqueue( + :bulk_user_title_update, + new_title: value, + granted_badge_id: system_badge_id, + action: Jobs::BulkUserTitleUpdate::UPDATE_ACTION + ) + end render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) else render json: failed_json.merge( @@ -69,10 +89,19 @@ class Admin::SiteTextsController < Admin::AdminController def revert site_text = find_site_text - old_text = I18n.t(site_text[:id]) - TranslationOverride.revert!(I18n.locale, site_text[:id]) + id = site_text[:id] + old_text = I18n.t(id) + TranslationOverride.revert!(I18n.locale, id) site_text = find_site_text - StaffActionLogger.new(current_user).log_site_text_change(site_text[:id], site_text[:value], old_text) + StaffActionLogger.new(current_user).log_site_text_change(id, site_text[:value], old_text) + system_badge_id = Badge.find_system_badge_id_from_translation_key(id) + if system_badge_id.present? + Jobs.enqueue( + :bulk_user_title_update, + granted_badge_id: system_badge_id, + action: Jobs::BulkUserTitleUpdate::RESET_ACTION + ) + end render_serialized(site_text, SiteTextSerializer, root: 'site_text', rest_serializer: true) end diff --git a/app/controllers/admin/staff_action_logs_controller.rb b/app/controllers/admin/staff_action_logs_controller.rb index 5ab3fbe4d0..117bdf8415 100644 --- a/app/controllers/admin/staff_action_logs_controller.rb +++ b/app/controllers/admin/staff_action_logs_controller.rb @@ -35,23 +35,23 @@ class Admin::StaffActionLogsController < Admin::AdminController diff_fields = {} - output = +"

#{CGI.escapeHTML(cur["name"].to_s)}

" + output = +"

#{CGI.escapeHTML(cur&.dig("name").to_s)}

" diff_fields["name"] = { - prev: prev["name"].to_s, - cur: cur["name"].to_s, + prev: prev&.dig("name").to_s, + cur: cur&.dig("name").to_s, } ["default", "user_selectable"].each do |f| diff_fields[f] = { - prev: (!!prev[f]).to_s, - cur: (!!cur[f]).to_s + prev: (!!prev&.dig(f)).to_s, + cur: (!!cur&.dig(f)).to_s } end diff_fields["color scheme"] = { - prev: prev["color_scheme"]&.fetch("name").to_s, - cur: cur["color_scheme"]&.fetch("name").to_s, + prev: prev&.dig("color_scheme", "name").to_s, + cur: cur&.dig("color_scheme", "name").to_s, } diff_fields["included themes"] = { @@ -76,13 +76,13 @@ class Admin::StaffActionLogsController < Admin::AdminController protected def child_themes(theme) - return "" unless children = theme["child_themes"] + return "" unless children = theme&.dig("child_themes") children.map { |row| row["name"] }.join(" ").to_s end def load_diff(hash, key, val) - if f = val["theme_fields"] + if f = val&.dig("theme_fields") f.each do |row| entry = hash[row["target"] + " " + row["name"]] ||= {} entry[key] = row["value"] diff --git a/app/controllers/admin/themes_controller.rb b/app/controllers/admin/themes_controller.rb index 9c16a0a068..9db615e519 100644 --- a/app/controllers/admin/themes_controller.rb +++ b/app/controllers/admin/themes_controller.rb @@ -23,12 +23,24 @@ class Admin::ThemesController < Admin::AdminController if upload.errors.count > 0 render_json_error upload else + # we assume a user intends to make some media public + # if they are uploading it to a theme component + mark_upload_insecure(upload) if upload.secure? render json: { upload_id: upload.id }, status: :created end end end end + def mark_upload_insecure(upload) + upload.update_secure_status(secure_override_value: false) + StaffActionLogger.new(current_user).log_change_upload_secure_status( + upload_id: upload.id, + new_value: false + ) + Jobs.enqueue(:rebake_posts_for_upload, id: upload.id) + end + def generate_key_pair require 'sshkey' k = SSHKey.generate @@ -164,19 +176,11 @@ class Admin::ThemesController < Admin::AdminController end if theme_params.key?(:child_theme_ids) - expected = theme_params[:child_theme_ids].map(&:to_i) + add_relative_themes!(:child, theme_params[:child_theme_ids]) + end - @theme.child_theme_relation.to_a.each do |child| - if expected.include?(child.child_theme_id) - expected.reject! { |id| id == child.child_theme_id } - else - child.destroy - end - end - - Theme.where(id: expected).each do |theme| - @theme.add_child_theme!(theme) - end + if theme_params.key?(:parent_theme_ids) + add_relative_themes!(:parent, theme_params[:parent_theme_ids]) end set_fields @@ -248,7 +252,7 @@ class Admin::ThemesController < Admin::AdminController headers['Content-Length'] = File.size(file_path).to_s send_data File.read(file_path), filename: File.basename(file_path), - content_type: "application/x-gzip" + content_type: "application/zip" ensure exporter.cleanup! end @@ -282,6 +286,26 @@ class Admin::ThemesController < Admin::AdminController private + def add_relative_themes!(kind, ids) + expected = ids.map(&:to_i) + + relation = kind == :child ? @theme.child_theme_relation : @theme.parent_theme_relation + + relation.to_a.each do |relative| + if kind == :child && expected.include?(relative.child_theme_id) + expected.reject! { |id| id == relative.child_theme_id } + elsif kind == :parent && expected.include?(relative.parent_theme_id) + expected.reject! { |id| id == relative.parent_theme_id } + else + relative.destroy + end + end + + Theme.where(id: expected).each do |theme| + @theme.add_relative_theme!(kind, theme) + end + end + def update_default_theme if theme_params.key?(:default) is_default = theme_params[:default].to_s == "true" @@ -298,6 +322,7 @@ class Admin::ThemesController < Admin::AdminController begin # deep munge is a train wreck, work around it for now params[:theme][:child_theme_ids] ||= [] if params[:theme].key?(:child_theme_ids) + params[:theme][:parent_theme_ids] ||= [] if params[:theme].key?(:parent_theme_ids) params.require(:theme).permit( :name, @@ -309,7 +334,8 @@ class Admin::ThemesController < Admin::AdminController settings: {}, translations: {}, theme_fields: [:name, :target, :value, :upload_id, :type_id], - child_theme_ids: [] + child_theme_ids: [], + parent_theme_ids: [] ) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d1682ee19b..f843ddc7e0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -687,6 +687,25 @@ class ApplicationController < ActionController::Base request.original_url unless request.original_url =~ /uploads/ end + def redirect_to_login + dont_cache_page + + if SiteSetting.enable_sso? + # save original URL in a session so we can redirect after login + session[:destination_url] = destination_url + redirect_to path('/session/sso') + elsif !SiteSetting.enable_local_logins && Discourse.enabled_authenticators.length == 1 && !cookies[:authentication_data] + # Only one authentication provider, direct straight to it. + # If authentication_data is present, then we are halfway though registration. Don't redirect offsite + cookies[:destination_url] = destination_url + redirect_to path("/auth/#{Discourse.enabled_authenticators.first.name}") + else + # save original URL in a cookie (javascript redirects after login in this case) + cookies[:destination_url] = destination_url + redirect_to path("/login") + end + end + def redirect_to_login_if_required return if request.format.json? && is_api? @@ -715,19 +734,8 @@ class ApplicationController < ActionController::Base if !current_user && SiteSetting.login_required? flash.keep - dont_cache_page - - if SiteSetting.enable_sso? - # save original URL in a session so we can redirect after login - session[:destination_url] = destination_url - redirect_to path('/session/sso') - return - else - # save original URL in a cookie (javascript redirects after login in this case) - cookies[:destination_url] = destination_url - redirect_to path("/login") - return - end + redirect_to_login + return end check_totp = current_user && diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 5e660f1981..191b2f433e 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -47,8 +47,7 @@ class CategoriesController < ApplicationController style = SiteSetting.desktop_category_page_style topic_options = { per_page: SiteSetting.categories_topics, - no_definitions: true, - exclude_category_ids: Category.where(suppress_from_latest: true).pluck(:id) + no_definitions: true } if style == "categories_and_latest_topics".freeze @@ -238,8 +237,7 @@ class CategoriesController < ApplicationController topic_options = { per_page: SiteSetting.categories_topics, - no_definitions: true, - exclude_category_ids: Category.where(suppress_from_latest: true).pluck(:id) + no_definitions: true } result = CategoryAndTopicLists.new @@ -283,6 +281,7 @@ class CategoriesController < ApplicationController if SiteSetting.tagging_enabled params[:allowed_tags] ||= [] params[:allowed_tag_groups] ||= [] + params[:required_tag_group_name] ||= '' end result = params.permit( @@ -291,7 +290,6 @@ class CategoriesController < ApplicationController :email_in, :email_in_allow_strangers, :mailinglist_mirror, - :suppress_from_latest, :all_topics_wiki, :parent_category_id, :auto_close_hours, diff --git a/app/controllers/extra_locales_controller.rb b/app/controllers/extra_locales_controller.rb index 4bee444976..e20c81ea42 100644 --- a/app/controllers/extra_locales_controller.rb +++ b/app/controllers/extra_locales_controller.rb @@ -9,15 +9,20 @@ class ExtraLocalesController < ApplicationController :verify_authenticity_token OVERRIDES_BUNDLE ||= 'overrides' + MD5_HASH_LENGTH ||= 32 def show bundle = params[:bundle] - raise Discourse::InvalidAccess.new if !valid_bundle?(bundle) - if params[:v]&.size == 32 - hash = ExtraLocalesController.bundle_js_hash(bundle) - immutable_for(1.year) if hash == params[:v] + version = params[:v] + if version.present? + if version.kind_of?(String) && version.length == MD5_HASH_LENGTH + hash = ExtraLocalesController.bundle_js_hash(bundle) + immutable_for(1.year) if hash == version + else + raise Discourse::InvalidParameters.new(:v) + end end render plain: ExtraLocalesController.bundle_js(bundle), content_type: "application/javascript" diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index f0ed98e57d..36e3b7b2da 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -208,20 +208,11 @@ class GroupsController < ApplicationController guardian.ensure_can_see_group_members!(group) - limit = (params[:limit] || 20).to_i + limit = (params[:limit] || 50).to_i offset = params[:offset].to_i - if limit < 0 - raise Discourse::InvalidParameters.new(:limit) - end - - if limit > 1000 - raise Discourse::InvalidParameters.new(:limit) - end - - if offset < 0 - raise Discourse::InvalidParameters.new(:offset) - end + raise Discourse::InvalidParameters.new(:limit) if limit < 0 || limit > 1000 + raise Discourse::InvalidParameters.new(:offset) if offset < 0 dir = (params[:desc] && !params[:desc].blank?) ? 'DESC' : 'ASC' order = "" @@ -278,7 +269,7 @@ class GroupsController < ApplicationController end end - users = users.select('users.*, group_users.created_at as added_at') + users = users.joins(:user_option).select('users.*, user_options.timezone, group_users.created_at as added_at') members = users .order('NOT group_users.owner') diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 7a62d95a56..a1bbedc5ce 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -40,7 +40,7 @@ class InvitesController < ApplicationController def perform_accept_invitation params.require(:id) - params.permit(:username, :name, :password, user_custom_fields: {}) + params.permit(:username, :name, :password, :timezone, user_custom_fields: {}) invite = Invite.find_by(invite_key: params[:id]) if invite.present? @@ -48,6 +48,7 @@ class InvitesController < ApplicationController user = invite.redeem(username: params[:username], name: params[:name], password: params[:password], user_custom_fields: params[:user_custom_fields], ip_address: request.remote_ip) if user.present? log_on_user(user) if user.active? + user.update_timezone_if_missing(params[:timezone]) post_process_invite(user) end diff --git a/app/controllers/list_controller.rb b/app/controllers/list_controller.rb index bba2d3c9b6..40c42fab07 100644 --- a/app/controllers/list_controller.rb +++ b/app/controllers/list_controller.rb @@ -52,15 +52,7 @@ class ListController < ApplicationController list_opts = build_topic_list_options list_opts.merge!(options) if options user = list_target_user - - if params[:category].blank? - if filter == :latest - list_opts[:no_definitions] = true - end - if [:latest, :categories].include?(filter) && list_opts[:exclude_category_ids].blank? - list_opts[:exclude_category_ids] = get_excluded_category_ids(list_opts[:category]) - end - end + list_opts[:no_definitions] = true if params[:category].blank? && filter == :latest list = TopicQuery.new(user, list_opts).public_send("list_#{filter}") @@ -246,10 +238,6 @@ class ListController < ApplicationController top_options.merge!(options) if options top_options[:per_page] = SiteSetting.topics_per_period_in_top_page - if "top".freeze == current_homepage && top_options[:exclude_category_ids].blank? - top_options[:exclude_category_ids] = get_excluded_category_ids(top_options[:category]) - end - user = list_target_user list = TopicQuery.new(user, top_options).list_top_for(period) list.for_period = period @@ -385,17 +373,14 @@ class ListController < ApplicationController end opts = opts.dup + if SiteSetting.unicode_usernames && opts[:group_name] + opts[:group_name] = URI.encode(opts[:group_name]) + end opts.delete(:category) if page_params.include?(:category_slug_path_with_id) public_send(method, opts.merge(page_params)).sub('.json?', '?') end - def get_excluded_category_ids(current_category = nil) - exclude_category_ids = Category.where(suppress_from_latest: true) - exclude_category_ids = exclude_category_ids.where.not(id: current_category) if current_category - exclude_category_ids.pluck(:id) - end - def self.best_period_for(previous_visit_at, category_id = nil) default_period = ((category_id && Category.where(id: category_id).pluck_first(:default_top_period)) || SiteSetting.top_page_default_timeframe).to_sym diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index 279cb99345..279bdfea1b 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -25,7 +25,13 @@ class MetadataController < ApplicationController private def default_manifest - display = Regexp.new(SiteSetting.pwa_display_browser_regex).match(request.user_agent) ? 'browser' : 'standalone' + display = "standalone" + if request.user_agent + regex = Regexp.new(SiteSetting.pwa_display_browser_regex) + if regex.match(request.user_agent) + display = "browser" + end + end manifest = { name: SiteSetting.title, diff --git a/app/controllers/onebox_controller.rb b/app/controllers/onebox_controller.rb index 10c500317b..f656ba3414 100644 --- a/app/controllers/onebox_controller.rb +++ b/app/controllers/onebox_controller.rb @@ -19,6 +19,8 @@ class OneboxController < ApplicationController invalidate = params[:refresh] == 'true' url = params[:url] + return render(body: nil, status: 404) if Oneboxer.recently_failed?(url) + hijack do Oneboxer.preview_onebox!(user_id) @@ -34,6 +36,7 @@ class OneboxController < ApplicationController Oneboxer.onebox_previewed!(user_id) if preview.blank? + Oneboxer.cache_failed!(url) render body: nil, status: 404 else render plain: preview diff --git a/app/controllers/post_readers_controller.rb b/app/controllers/post_readers_controller.rb index fdba0605a2..bc9c3a197b 100644 --- a/app/controllers/post_readers_controller.rb +++ b/app/controllers/post_readers_controller.rb @@ -8,16 +8,14 @@ class PostReadersController < ApplicationController ensure_can_see_readers!(post) readers = User + .real .where(staged: false) .where.not(id: post.user_id) .joins(:topic_users) .where.not(topic_users: { last_read_post_number: nil }) .where('topic_users.topic_id = ? AND topic_users.last_read_post_number >= ?', post.topic_id, post.post_number) - if post.whisper? - non_group_members = post.topic.topic_allowed_users.map(&:user_id) - readers = readers.where.not(id: non_group_members) - end + readers = readers.where('admin OR moderator') if post.whisper? readers = readers.map do |r| { diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index b63d2539fe..c28102530c 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -335,7 +335,7 @@ class PostsController < ApplicationController params.require(:post_ids) agree_with_first_reply_flag = (params[:agree_with_first_reply_flag] || true).to_s == "true" - posts = Post.where(id: post_ids_including_replies) + posts = Post.where(id: post_ids_including_replies).order(:id) raise Discourse::InvalidParameters.new(:post_ids) if posts.blank? # Make sure we can delete the posts @@ -478,8 +478,8 @@ class PostsController < ApplicationController post = find_post_from_params if params[:notice].present? - post.custom_fields["notice_type"] = Post.notices[:custom] - post.custom_fields["notice_args"] = PrettyText.cook(params[:notice], features: { onebox: false }) + post.custom_fields[Post::NOTICE_TYPE] = Post.notices[:custom] + post.custom_fields[Post::NOTICE_ARGS] = PrettyText.cook(params[:notice], features: { onebox: false }) post.save_custom_fields else post.delete_post_notices @@ -671,7 +671,8 @@ class PostsController < ApplicationController :auto_track, :typing_duration_msecs, :composer_open_duration_msecs, - :visible + :visible, + :draft_key ] Post.plugin_permitted_create_params.each do |key, plugin| diff --git a/app/controllers/reviewables_controller.rb b/app/controllers/reviewables_controller.rb index 2e9816bab1..ce5c2cd29e 100644 --- a/app/controllers/reviewables_controller.rb +++ b/app/controllers/reviewables_controller.rb @@ -20,18 +20,21 @@ class ReviewablesController < ApplicationController topic_id = params[:topic_id] ? params[:topic_id].to_i : nil category_id = params[:category_id] ? params[:category_id].to_i : nil + custom_keys = Reviewable.custom_filters.map(&:first) + additional_filters = JSON.parse(params.fetch(:additional_filters, {}), symbolize_names: true).slice(*custom_keys) filters = { status: status, category_id: category_id, topic_id: topic_id, - priority: params[:priority], - username: params[:username], - type: params[:type], - sort_order: params[:sort_order] + additional_filters: additional_filters.reject { |_, v| v.blank? } } - total_rows = Reviewable.list_for(current_user, filters).count - reviewables = Reviewable.list_for(current_user, filters.merge(limit: PER_PAGE, offset: offset)).to_a + %i[priority username from_date to_date type sort_order].each do |filter_key| + filters[filter_key] = params[filter_key] + end + + total_rows = Reviewable.list_for(current_user, **filters).count + reviewables = Reviewable.list_for(current_user, **filters.merge(limit: PER_PAGE, offset: offset)).to_a claimed_topics = ReviewableClaimedTopic.claimed_hash(reviewables.map { |r| r.topic_id }.uniq) diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 4fea08f068..3f0914e2dc 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -7,7 +7,7 @@ class SearchController < ApplicationController before_action :cancel_overloaded_search, only: [:query] def self.valid_context_types - %w{user topic category private_messages} + %w{user topic category private_messages tag} end def show @@ -169,6 +169,8 @@ class SearchController < ApplicationController context_obj = Category.find_by(id: search_context[:id].to_i) elsif 'topic' == search_context[:type] context_obj = Topic.find_by(id: search_context[:id].to_i) + elsif 'tag' == search_context[:type] + context_obj = Tag.where_name(search_context[:name]).first end type_filter = nil diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb index d7d4f45842..a0828685a7 100644 --- a/app/controllers/session_controller.rb +++ b/app/controllers/session_controller.rb @@ -320,7 +320,7 @@ class SessionController < ApplicationController def invalid_security_key(user, err_message = nil) stage_webauthn_security_key_challenge(user) if !params[:security_key_credential] - return render json: failed_json.merge( + render json: failed_json.merge( error: err_message || I18n.t("login.invalid_security_key"), reason: "invalid_security_key", backup_enabled: user.backup_codes_enabled? @@ -403,7 +403,7 @@ class SessionController < ApplicationController end end - return render json: { error: I18n.t('email_login.invalid_token') } + render json: { error: I18n.t('email_login.invalid_token') } rescue ::Webauthn::SecurityKeyError => err invalid_security_key(user, err.message) end @@ -540,6 +540,7 @@ class SessionController < ApplicationController def login(user) session.delete(ACTIVATE_USER_KEY) + user.update_timezone_if_missing(params[:timezone]) log_on_user(user) if payload = cookies.delete(:sso_payload) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 8aabc8f3db..55ec294cdf 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -5,6 +5,7 @@ class TagsController < ::ApplicationController include TopicQueryParams before_action :ensure_tags_enabled + before_action :ensure_visible, only: [:show, :info] requires_login except: [ :index, @@ -12,13 +13,16 @@ class TagsController < ::ApplicationController :tag_feed, :search, :check_hashtag, + :info, Discourse.anonymous_filters.map { |f| :"show_#{f}" } ].flatten skip_before_action :check_xhr, only: [:tag_feed, :show, :index] before_action :set_category_from_params, except: [:index, :update, :destroy, - :tag_feed, :search, :notifications, :update_notifications, :personal_messages] + :tag_feed, :search, :notifications, :update_notifications, :personal_messages, :info] + + before_action :fetch_tag, only: [:info, :create_synonyms, :destroy_synonym] def index @description_meta = I18n.t("tags.title") @@ -31,21 +35,21 @@ class TagsController < ::ApplicationController ungrouped_tags = ungrouped_tags.where("tags.topic_count > 0") unless show_all_tags grouped_tag_counts = TagGroup.visible(guardian).order('name ASC').includes(:tags).map do |tag_group| - { id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json(tag_group.tags) } + { id: tag_group.id, name: tag_group.name, tags: self.class.tag_counts_json(tag_group.tags.where(target_tag_id: nil)) } end @tags = self.class.tag_counts_json(ungrouped_tags) @extras = { tag_groups: grouped_tag_counts } else tags = show_all_tags ? Tag.all : Tag.where("tags.topic_count > 0") - unrestricted_tags = DiscourseTagging.filter_visible(tags, guardian) + unrestricted_tags = DiscourseTagging.filter_visible(tags.where(target_tag_id: nil), guardian) categories = Category.where("id IN (SELECT category_id FROM category_tags)") .where("id IN (?)", guardian.allowed_category_ids) .includes(:tags) category_tag_counts = categories.map do |c| - { id: c.id, tags: self.class.tag_counts_json(c.tags) } + { id: c.id, tags: self.class.tag_counts_json(c.tags.where(target_tag_id: nil)) } end @tags = self.class.tag_counts_json(unrestricted_tags) @@ -98,11 +102,13 @@ class TagsController < ::ApplicationController end def show - raise Discourse::NotFound if DiscourseTagging.hidden_tag_names(guardian).include?(params[:tag_id]) - show_latest end + def info + render_serialized(@tag, DetailedTagSerializer, root: :tag_info) + end + def update guardian.ensure_can_admin_tags! @@ -195,29 +201,25 @@ class TagsController < ::ApplicationController def search filter_params = { for_input: params[:filterForInput], - selected_tags: params[:selected_tags] + selected_tags: params[:selected_tags], + limit: params[:limit], + exclude_synonyms: params[:excludeSynonyms], + exclude_has_synonyms: params[:excludeHasSynonyms] } if params[:categoryId] filter_params[:category] = Category.find_by_id(params[:categoryId]) end - if params[:q] + if !params[:q].blank? clean_name = DiscourseTagging.clean_tag(params[:q]) filter_params[:term] = clean_name - - # Prioritize exact matches when ordering - order_query = Tag.sanitize_sql_for_order( - ["lower(name) = lower(?) DESC, topic_count DESC", clean_name] - ) - - tag_query = Tag.order(order_query).limit(params[:limit]) + filter_params[:order_search_results] = true else - tag_query = Tag.limit(params[:limit]) + filter_params[:order] = "topic_count DESC" end tags_with_counts = DiscourseTagging.filter_allowed_tags( - tag_query, guardian, filter_params ) @@ -230,19 +232,25 @@ class TagsController < ::ApplicationController # filter_allowed_tags determined that the tag entered is not allowed json_response[:forbidden] = params[:q] - category_names = tag.categories.where(id: guardian.allowed_category_ids).pluck(:name) - category_names += Category.joins(tag_groups: :tags).where(id: guardian.allowed_category_ids, "tags.id": tag.id).pluck(:name) - - if category_names.present? - category_names.uniq! - json_response[:forbidden_message] = I18n.t( - "tags.forbidden.restricted_to", - count: category_names.count, - tag_name: tag.name, - category_names: category_names.join(", ") - ) + if filter_params[:exclude_synonyms] && tag.synonym? + json_response[:forbidden_message] = I18n.t("tags.forbidden.synonym", tag_name: tag.target_tag.name) + elsif filter_params[:exclude_has_synonyms] && tag.synonyms.exists? + json_response[:forbidden_message] = I18n.t("tags.forbidden.has_synonyms", tag_name: tag.name) else - json_response[:forbidden_message] = I18n.t("tags.forbidden.in_this_category", tag_name: tag.name) + category_names = tag.categories.where(id: guardian.allowed_category_ids).pluck(:name) + category_names += Category.joins(tag_groups: :tags).where(id: guardian.allowed_category_ids, "tags.id": tag.id).pluck(:name) + + if category_names.present? + category_names.uniq! + json_response[:forbidden_message] = I18n.t( + "tags.forbidden.restricted_to", + count: category_names.count, + tag_name: tag.name, + category_names: category_names.join(", ") + ) + else + json_response[:forbidden_message] = I18n.t("tags.forbidden.in_this_category", tag_name: tag.name) + end end end @@ -282,14 +290,56 @@ class TagsController < ::ApplicationController render json: { tags: pm_tags } end + def create_synonyms + guardian.ensure_can_admin_tags! + value = DiscourseTagging.add_or_create_synonyms_by_name(@tag, params[:synonyms]) + if value.is_a?(Array) + render json: failed_json.merge( + failed_tags: value.inject({}) { |h, t| h[t.name] = t.errors.full_messages.first; h } + ) + else + render json: success_json + end + end + + def destroy_synonym + guardian.ensure_can_admin_tags! + synonym = Tag.where_name(params[:synonym_id]).first + raise Discourse::NotFound unless synonym + if synonym.target_tag == @tag + synonym.update!(target_tag: nil) + render json: success_json + else + render json: failed_json, status: 400 + end + end + private + def fetch_tag + @tag = Tag.find_by_name(params[:tag_id].force_encoding("UTF-8")) + raise Discourse::NotFound unless @tag + end + def ensure_tags_enabled raise Discourse::NotFound unless SiteSetting.tagging_enabled? end + def ensure_visible + raise Discourse::NotFound if DiscourseTagging.hidden_tag_names(guardian).include?(params[:tag_id]) + end + def self.tag_counts_json(tags) - tags.map { |t| { id: t.name, text: t.name, count: t.topic_count, pm_count: t.pm_topic_count } } + target_tags = Tag.where(id: tags.map(&:target_tag_id).compact.uniq).select(:id, :name) + tags.map do |t| + { + id: t.name, + text: t.name, + count: t.topic_count, + pm_count: t.pm_topic_count, + target_tag: t.target_tag_id ? target_tags.find { |x| x.id == t.target_tag_id }&.name : nil + } + end end def set_category_from_params @@ -396,7 +446,6 @@ class TagsController < ::ApplicationController options = super.merge( page: params[:page], topic_ids: param_to_integer_list(:topic_ids), - exclude_category_ids: params[:exclude_category_ids], category: @filter_on_category ? @filter_on_category.id : params[:category], order: params[:order], ascending: params[:ascending], diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 2c29594e66..e5989bc139 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -327,10 +327,9 @@ class TopicsController < ApplicationController if category && topic_tags = (params[:tags] || topic.tags.pluck(:name)).reject { |c| c.empty? } if topic_tags.present? allowed_tags = DiscourseTagging.filter_allowed_tags( - Tag.all, guardian, category: category - ).pluck("tags.name") + ).map(&:name) invalid_tags = topic_tags - allowed_tags @@ -450,7 +449,7 @@ class TopicsController < ApplicationController topic_status_update = topic.set_or_create_timer( status_type, params[:time], - options + **options ) if topic.save @@ -848,7 +847,23 @@ class TopicsController < ApplicationController end def reset_new - current_user.user_stat.update_column(:new_since, Time.now) + if params[:category_id].present? + category_ids = [params[:category_id]] + if params[:include_subcategories] == 'true' + category_ids = category_ids.concat(Category.where(parent_category_id: params[:category_id]).pluck(:id)) + end + category_ids.each do |category_id| + current_user + .category_users + .where(category_id: category_id) + .first_or_initialize + .update!(last_seen_at: Time.zone.now) + TopicTrackingState.publish_dismiss_new(current_user.id, category_id) + end + else + current_user.user_stat.update_column(:new_since, Time.zone.now) + TopicTrackingState.publish_dismiss_new(current_user.id) + end render body: nil end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 78fd13c8eb..caa6c8987f 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -5,9 +5,11 @@ require "mini_mime" class UploadsController < ApplicationController requires_login except: [:show, :show_short] - skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: [:show, :show_short] + skip_before_action :preload_json, :check_xhr, :redirect_to_login_if_required, only: [:show, :show_short, :show_secure] protect_from_forgery except: :show + before_action :is_asset_path, only: [:show, :show_short, :show_secure] + def create # capture current user for block later on me = current_user @@ -110,6 +112,17 @@ class UploadsController < ApplicationController end end + def show_secure + # do not serve uploads requested via XHR to prevent XSS + return xhr_not_allowed if request.xhr? + + if SiteSetting.secure_media? + redirect_to Discourse.store.signed_url_for_path("#{params[:path]}.#{params[:extension]}") + else + render_404 + end + end + def metadata params.require(:url) upload = Upload.get_from_url(params[:url]) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5498a9933b..8084793829 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -128,7 +128,7 @@ class UsersController < ApplicationController end end - json_result(user, serializer: UserSerializer, additional_errors: [:user_profile]) do |u| + json_result(user, serializer: UserSerializer, additional_errors: [:user_profile, :user_option]) do |u| updater = UserUpdater.new(current_user, user) updater.update(attributes.permit!) end @@ -195,14 +195,36 @@ class UsersController < ApplicationController guardian.ensure_can_edit!(user) user_badge = UserBadge.find_by(id: params[:user_badge_id]) + previous_title = user.title if user_badge && user_badge.user == user && user_badge.badge.allow_title? user.title = user_badge.badge.display_name - user.user_profile.badge_granted_title = true user.save! - user.user_profile.save! + + log_params = { + details: "title matching badge id #{user_badge.badge.id}", + previous_value: previous_title, + new_value: user.title + } + + if current_user.staff? && current_user != user + StaffActionLogger.new(current_user).log_title_change(user, log_params) + else + UserHistory.create!(log_params.merge(target_user_id: user.id, action: UserHistory.actions[:change_title])) + end else user.title = '' user.save! + + log_params = { + revoke_reason: 'user title was same as revoked badge name or custom badge name', + previous_value: previous_title + } + + if current_user.staff? && current_user != user + StaffActionLogger.new(current_user).log_title_revoke(user, log_params) + else + UserHistory.create!(log_params.merge(target_user_id: user.id, action: UserHistory.actions[:revoke_title])) + end end render body: nil @@ -365,7 +387,7 @@ class UsersController < ApplicationController params[:locale] ||= I18n.locale unless current_user - new_user_params = user_params + new_user_params = user_params.except(:timezone) user = User.unstage(new_user_params) user = User.new(new_user_params) if user.nil? @@ -413,6 +435,7 @@ class UsersController < ApplicationController if user.save authentication.finish activation.finish + user.update_timezone_if_missing(params[:timezone]) secure_session[HONEYPOT_KEY] = nil secure_session[CHALLENGE_KEY] = nil diff --git a/app/controllers/users_email_controller.rb b/app/controllers/users_email_controller.rb index f74f60a94a..aaead3bf62 100644 --- a/app/controllers/users_email_controller.rb +++ b/app/controllers/users_email_controller.rb @@ -4,8 +4,26 @@ class UsersEmailController < ApplicationController requires_login only: [:index, :update] - skip_before_action :check_xhr, only: [:confirm] - skip_before_action :redirect_to_login_if_required, only: [:confirm] + skip_before_action :check_xhr, only: [ + :confirm_old_email, + :show_confirm_old_email, + :confirm_new_email, + :show_confirm_new_email + ] + + skip_before_action :redirect_to_login_if_required, only: [ + :confirm_old_email, + :show_confirm_old_email, + :confirm_new_email, + :show_confirm_new_email + ] + + before_action :require_login, only: [ + :confirm_old_email, + :show_confirm_old_email, + :confirm_new_email, + :show_confirm_new_email + ] def index end @@ -29,38 +47,141 @@ class UsersEmailController < ApplicationController render_json_error(I18n.t("rate_limiter.slow_down")) end - def confirm - expires_now + def confirm_new_email + load_change_request(:new) - token = EmailToken.confirmable(params[:token]) - user = token&.user + if @change_request&.change_state != EmailChangeRequest.states[:authorizing_new] + @error = I18n.t("change_email.already_done") + end - change_request = - if user - user.email_change_requests.where(new_email_token_id: token.id).first - end + redirect_url = path("/u/confirm-new-email/#{params[:token]}") - if change_request&.change_state == EmailChangeRequest.states[:authorizing_new] && - user.totp_enabled? && !user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) + if !@error && @user.totp_enabled? && !@user.authenticate_second_factor(params[:second_factor_token], params[:second_factor_method].to_i) + RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! + flash[:invalid_second_factor] = true + redirect_to redirect_url + return + end - @update_result = :invalid_second_factor - @backup_codes_enabled = true if user.backup_codes_enabled? - - if params[:second_factor_token].present? - RateLimiter.new(nil, "second-factor-min-#{request.remote_ip}", 3, 1.minute).performed! - @show_invalid_second_factor_error = true - end - else + if !@error updater = EmailUpdater.new - @update_result = updater.confirm(params[:token]) - - if @update_result == :complete + if updater.confirm(params[:token]) == :complete updater.user.user_stat.reset_bounce_score! - log_on_user(updater.user) + else + @error = I18n.t("change_email.already_done") end end + if @error + flash[:error] = @error + redirect_to redirect_url + else + redirect_to "#{redirect_url}?done=true" + end + end + + def show_confirm_new_email + load_change_request(:new) + + if params[:done].to_s == "true" + @done = true + end + + if @change_request&.change_state != EmailChangeRequest.states[:authorizing_new] + @error = I18n.t("change_email.already_done") + end + + @show_invalid_second_factor_error = flash[:invalid_second_factor] + + if !@error + if @user.totp_enabled? + @backup_codes_enabled = @user.backup_codes_enabled? + if params[:show_backup].to_s == "true" && @backup_codes_enabled + @show_backup_codes = true + else + @show_second_factor = true + end + end + + @to_email = @change_request.new_email + end + render layout: 'no_ember' end + def confirm_old_email + load_change_request(:old) + + if @change_request&.change_state != EmailChangeRequest.states[:authorizing_old] + @error = I18n.t("change_email.already_done") + end + + redirect_url = path("/u/confirm-old-email/#{params[:token]}") + + if !@error + updater = EmailUpdater.new + if updater.confirm(params[:token]) != :authorizing_new + @error = I18n.t("change_email.already_done") + end + end + + if @error + flash[:error] = @error + redirect_to redirect_url + else + redirect_to "#{redirect_url}?done=true" + end + end + + def show_confirm_old_email + load_change_request(:old) + + if @change_request&.change_state != EmailChangeRequest.states[:authorizing_old] + @error = I18n.t("change_email.already_done") + end + + if params[:done].to_s == "true" + @almost_done = true + end + + if !@error + @from_email = @user.email + @to_email = @change_request.new_email + end + + render layout: 'no_ember' + end + + private + + def load_change_request(type) + expires_now + + @token = EmailToken.confirmable(params[:token]) + + if @token + if type == :old + @change_request = @token.user&.email_change_requests.where(old_email_token_id: @token.id).first + elsif type == :new + @change_request = @token.user&.email_change_requests.where(new_email_token_id: @token.id).first + end + end + + @user = @token&.user + + if (!@user || !@change_request) + @error = I18n.t("change_email.already_done") + end + + if current_user.id != @user&.id + @error = I18n.t 'change_email.wrong_account_error' + end + end + + def require_login + if !current_user + redirect_to_login + end + end + end diff --git a/app/helpers/user_notifications_helper.rb b/app/helpers/user_notifications_helper.rb index d7e619f029..0df7aa6926 100644 --- a/app/helpers/user_notifications_helper.rb +++ b/app/helpers/user_notifications_helper.rb @@ -47,8 +47,9 @@ module UserNotificationsHelper return result unless result.blank? - # If there is no first paragaph, return the first div (onebox) - doc.css('div').first + # If there is no first paragaph with text, return the first paragraph with + # something else (an image) or div (a onebox). + doc.css('body > p, body > div').first end def email_excerpt(html_arg, post = nil) diff --git a/app/jobs/regular/bulk_invite.rb b/app/jobs/regular/bulk_invite.rb index 6baa2232d0..a87344f079 100644 --- a/app/jobs/regular/bulk_invite.rb +++ b/app/jobs/regular/bulk_invite.rb @@ -84,7 +84,7 @@ module Jobs end end - return topic + topic end def send_invite(invite) diff --git a/app/jobs/regular/bulk_user_title_update.rb b/app/jobs/regular/bulk_user_title_update.rb new file mode 100644 index 0000000000..97d3f68ad5 --- /dev/null +++ b/app/jobs/regular/bulk_user_title_update.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Jobs + class BulkUserTitleUpdate < ::Jobs::Base + UPDATE_ACTION = 'update'.freeze + RESET_ACTION = 'reset'.freeze + + def execute(args) + new_title = args[:new_title] + granted_badge_id = args[:granted_badge_id] + action = args[:action] + + case action + when UPDATE_ACTION + update_titles_for_granted_badge(new_title, granted_badge_id) + when RESET_ACTION + reset_titles_for_granted_badge(granted_badge_id) + end + end + + private + + ## + # If a badge name or a system badge TranslationOverride changes + # then we need to set all titles granted based on that badge to + # the new name or custom translation + def update_titles_for_granted_badge(new_title, granted_badge_id) + DB.exec(<<~SQL, granted_title_badge_id: granted_badge_id, title: new_title, updated_at: Time.now) + UPDATE users AS u + SET title = :title, updated_at = :updated_at + FROM user_profiles AS up + WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id + SQL + end + + ## + # Reset granted titles for a badge back to the original + # badge name. When a system badge has its TranslationOverride + # revoked we want to have all titles based on that translation + # for the badge reset. + def reset_titles_for_granted_badge(granted_badge_id) + DB.exec(<<~SQL, granted_title_badge_id: granted_badge_id, updated_at: Time.now) + UPDATE users AS u + SET title = badges.name, updated_at = :updated_at + FROM user_profiles AS up + INNER JOIN badges ON badges.id = up.granted_title_badge_id + WHERE up.user_id = u.id AND up.granted_title_badge_id = :granted_title_badge_id + SQL + end + end +end diff --git a/app/jobs/regular/notify_category_change.rb b/app/jobs/regular/notify_category_change.rb index bac94a20e0..fbad7582e4 100644 --- a/app/jobs/regular/notify_category_change.rb +++ b/app/jobs/regular/notify_category_change.rb @@ -7,7 +7,7 @@ module Jobs if post&.topic&.visible? post_alerter = PostAlerter.new - post_alerter.notify_post_users(post, User.where(id: args[:notified_user_ids])) + post_alerter.notify_post_users(post, User.where(id: args[:notified_user_ids]), include_tag_watchers: false) post_alerter.notify_first_post_watchers(post, post_alerter.category_watchers(post.topic)) end end diff --git a/app/jobs/regular/notify_tag_change.rb b/app/jobs/regular/notify_tag_change.rb index 4725896cd4..dff6769de9 100644 --- a/app/jobs/regular/notify_tag_change.rb +++ b/app/jobs/regular/notify_tag_change.rb @@ -7,6 +7,7 @@ module Jobs if post&.topic&.visible? post_alerter = PostAlerter.new + post_alerter.notify_post_users(post, User.where(id: args[:notified_user_ids]), include_category_watchers: false) post_alerter.notify_first_post_watchers(post, post_alerter.tag_watchers(post.topic)) end end diff --git a/app/jobs/regular/rebake_posts_for_upload.rb b/app/jobs/regular/rebake_posts_for_upload.rb new file mode 100644 index 0000000000..a56b433643 --- /dev/null +++ b/app/jobs/regular/rebake_posts_for_upload.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + class RebakePostsForUpload < ::Jobs::Base + def execute(args) + upload = Upload.find_by(id: args[:id]) + return if upload.blank? + upload.posts.find_each(&:rebake!) + end + end +end diff --git a/app/jobs/regular/update_private_uploads_acl.rb b/app/jobs/regular/update_private_uploads_acl.rb index 4437ea9649..661f84437c 100644 --- a/app/jobs/regular/update_private_uploads_acl.rb +++ b/app/jobs/regular/update_private_uploads_acl.rb @@ -4,11 +4,11 @@ module Jobs class UpdatePrivateUploadsAcl < ::Jobs::Base # only runs when SiteSetting.prevent_anons_from_downloading_files is updated def execute(args) - return if !SiteSetting.enable_s3_uploads + return if !SiteSetting.Upload.enable_s3_uploads Upload.find_each do |upload| - if !FileHelper.is_supported_image?(upload.original_filename) - Discourse.store.update_upload_ACL(upload) + if !FileHelper.is_supported_media?(upload.original_filename) + upload.update_secure_status end end end diff --git a/app/jobs/scheduled/clean_up_inactive_users.rb b/app/jobs/scheduled/clean_up_inactive_users.rb index a0a4ab5f61..a54e1ee657 100644 --- a/app/jobs/scheduled/clean_up_inactive_users.rb +++ b/app/jobs/scheduled/clean_up_inactive_users.rb @@ -30,7 +30,8 @@ module Jobs User.transaction do ids.each do |id| begin - user = User.find(id) + user = User.find_by(id: id) + next unless user destroyer.destroy(user, transaction: false, context: I18n.t("user.destroy_reasons.inactive_user")) rescue => e Discourse.handle_job_exception(e, diff --git a/app/jobs/scheduled/invalidate_inactive_admins.rb b/app/jobs/scheduled/invalidate_inactive_admins.rb index 8b1468d762..15ac0117c0 100644 --- a/app/jobs/scheduled/invalidate_inactive_admins.rb +++ b/app/jobs/scheduled/invalidate_inactive_admins.rb @@ -8,10 +8,14 @@ module Jobs def execute(_) return if SiteSetting.invalidate_inactive_admin_email_after_days == 0 + timestamp = SiteSetting.invalidate_inactive_admin_email_after_days.days.ago + User.human_users .where(admin: true) .where(active: true) - .where('last_seen_at < ?', SiteSetting.invalidate_inactive_admin_email_after_days.days.ago) + .where('last_seen_at < ?', timestamp) + .where("NOT EXISTS ( SELECT 1 from api_keys WHERE api_keys.user_id = users.id AND COALESCE(last_used_at, updated_at) > ? )", timestamp) + .where("NOT EXISTS ( SELECT 1 from posts WHERE posts.user_id = users.id AND created_at > ?)", timestamp) .each do |user| User.transaction do diff --git a/app/jobs/scheduled/poll_feed.rb b/app/jobs/scheduled/poll_feed.rb deleted file mode 100644 index 14411da2a5..0000000000 --- a/app/jobs/scheduled/poll_feed.rb +++ /dev/null @@ -1,185 +0,0 @@ -# frozen_string_literal: true - -# -# Creates and Updates Topics based on an RSS or ATOM feed. -# -require 'digest/sha1' -require 'excon' - -module Jobs - class PollFeed < ::Jobs::Scheduled - every 5.minutes - - sidekiq_options retry: false - - def execute(args) - poll_feed if SiteSetting.feed_polling_enabled? && - SiteSetting.feed_polling_url.present? && - not_polled_recently? - end - - def feed_key - "feed-modified:#{Digest::SHA1.hexdigest(SiteSetting.feed_polling_url)}" - end - - def poll_feed - ensure_rss_loaded - # defer loading rss - feed = Feed.new - import_topics(feed.topics) - end - - private - - @@rss_loaded = false - - # rss lib is very expensive memory wise, no need to load it till it is needed - def ensure_rss_loaded - return if @@rss_loaded - require 'rss' - @@rss_loaded = true - end - - def not_polled_recently? - $redis.set( - 'feed-polled-recently', - "1", - ex: SiteSetting.feed_polling_frequency_mins.minutes - 10.seconds, - nx: true - ) - end - - def import_topics(feed_topics) - feed_topics.each do |topic| - import_topic(topic) - end - end - - def import_topic(topic) - if topic.user - TopicEmbed.import(topic.user, topic.url, topic.title, CGI.unescapeHTML(topic.content)) - end - end - - class Feed - def initialize - @feed_url = SiteSetting.feed_polling_url - @feed_url = "http://#{@feed_url}" if @feed_url !~ /^https?\:\/\// - end - - def topics - feed_topics = [] - - rss = parsed_feed - return feed_topics unless rss.present? - - rss.items.each do |i| - current_feed_topic = FeedTopic.new(i) - feed_topics << current_feed_topic if current_feed_topic.content - end - - return feed_topics - end - - private - - def parsed_feed - raw_feed, encoding = fetch_rss - return nil if raw_feed.nil? - - encoded_feed = Encodings.try_utf8(raw_feed, encoding) if encoding - encoded_feed = Encodings.to_utf8(raw_feed) unless encoded_feed - - return nil if encoded_feed.blank? - - if SiteSetting.embed_username_key_from_feed.present? - FeedElementInstaller.install(SiteSetting.embed_username_key_from_feed, encoded_feed) - end - - RSS::Parser.parse(encoded_feed) - rescue RSS::NotWellFormedError, RSS::InvalidRSSError - nil - end - - def fetch_rss - final_destination = FinalDestination.new(@feed_url, verbose: true) - feed_final_url = final_destination.resolve - return nil unless final_destination.status == :resolved - - response = Excon.new(feed_final_url.to_s).request(method: :get, expects: 200) - [response.body, detect_charset(response)] - rescue Excon::Error::HTTPStatus - nil - end - - def detect_charset(response) - if response.headers['Content-Type'] =~ /charset\s*=\s*([a-z0-9\-]+)/i - Encoding.find($1) - end - rescue ArgumentError - nil - end - end - - class FeedTopic - def initialize(article_rss_item) - @accessor = FeedItemAccessor.new(article_rss_item) - end - - def url - link = @accessor.link - if url?(link) - return link - else - return @accessor.element_content(:id) - end - end - - def content - content = nil - - %i[content_encoded content description].each do |content_element_name| - content ||= @accessor.element_content(content_element_name) - end - - content&.force_encoding('UTF-8')&.scrub - end - - def title - @accessor.element_content(:title).force_encoding('UTF-8').scrub - end - - def user - author_user || default_user - end - - private - - def url?(link) - if link.blank? || link !~ /^https?\:\/\// - return false - else - return true - end - end - - def author_username - @accessor.element_content(SiteSetting.embed_username_key_from_feed.sub(':', '_')) - end - - def default_user - find_user(SiteSetting.embed_by_username.downcase) - end - - def author_user - return nil if !author_username.present? - - find_user(author_username) - end - - def find_user(user_name) - User.where(username_lower: user_name).first - end - end - end -end diff --git a/app/jobs/scheduled/version_check.rb b/app/jobs/scheduled/version_check.rb index d28d943d93..37eebf01f9 100644 --- a/app/jobs/scheduled/version_check.rb +++ b/app/jobs/scheduled/version_check.rb @@ -17,8 +17,7 @@ module Jobs DiscourseUpdates.updated_at = Time.zone.now DiscourseUpdates.missing_versions = json['versions'] - if GlobalSetting.new_version_emails && - SiteSetting.new_version_emails && + if SiteSetting.new_version_emails && json['missingVersionsCount'] > (0) && prev_missing_versions_count < (json['missingVersionsCount'].to_i) diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index 26d5961424..0be2f2b3e1 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -349,13 +349,26 @@ class UserNotifications < ActionMailer::Base end def email_post_markdown(post, add_posted_by = false) - result = +"#{post.raw}\n\n" + result = +"#{post.with_secure_media? ? strip_secure_urls(post.raw) : post.raw}\n\n" if add_posted_by result << "#{I18n.t('user_notifications.posted_by', username: post.username, post_date: post.created_at.strftime("%m/%d/%Y"))}\n\n" end result end + def strip_secure_urls(raw) + urls = Set.new + raw.scan(URI.regexp(%w{http https})) { urls << $& } + + urls.each do |url| + if (url.start_with?(Discourse.store.s3_upload_host) && FileHelper.is_supported_media?(url)) + raw = raw.sub(url, "

#{I18n.t("emails.secure_media_placeholder")}

") + end + end + + raw + end + def self.get_context_posts(post, topic_user, user) if (user.user_option.email_previous_replies == UserOption.previous_replies_type[:never]) || SiteSetting.private_email? diff --git a/app/models/admin_dashboard_data.rb b/app/models/admin_dashboard_data.rb index 78504482dd..889be79bd9 100644 --- a/app/models/admin_dashboard_data.rb +++ b/app/models/admin_dashboard_data.rb @@ -84,7 +84,9 @@ class AdminDashboardData @problem_messages = [ 'dashboard.bad_favicon_url', 'dashboard.poll_pop3_timeout', - 'dashboard.poll_pop3_auth_error' + 'dashboard.poll_pop3_auth_error', + 'dashboard.deprecated_api_usage', + 'dashboard.update_mail_receiver' ] add_problem_check :rails_env_check, :host_names_check, :force_https_check, diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 198d746b30..91722b3603 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -57,9 +57,11 @@ end # allowed_ips :inet is an Array # hidden :boolean default(FALSE), not null # last_used_at :datetime +# revoked_at :datetime +# description :text # # Indexes # # index_api_keys_on_key (key) -# index_api_keys_on_user_id (user_id) UNIQUE +# index_api_keys_on_user_id (user_id) # diff --git a/app/models/badge.rb b/app/models/badge.rb index 3596190a8a..4db471bcbe 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -169,8 +169,17 @@ class Badge < ActiveRecord::Base end def self.display_name(name) - key = "badges.#{i18n_name(name)}.name" - I18n.t(key, default: name) + I18n.t(i18n_key(name), default: name) + end + + def self.i18n_key(name) + "badges.#{i18n_name(name)}.name" + end + + def self.find_system_badge_id_from_translation_key(translation_key) + return unless translation_key.starts_with?('badges.') + badge_name_klass = translation_key.split('.').second.camelize + "Badge::#{badge_name_klass}".constantize end def awarded_for_trust_level? @@ -208,6 +217,10 @@ class Badge < ActiveRecord::Base self.class.display_name(name) end + def translation_key + self.class.i18n_key(name) + end + def long_description key = "badges.#{i18n_name}.long_description" I18n.t(key, default: self[:long_description] || '', base_uri: Discourse.base_uri) diff --git a/app/models/category.rb b/app/models/category.rb index 4e2ec97a93..5af5eabac3 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -3,6 +3,7 @@ class Category < ActiveRecord::Base self.ignored_columns = %w{ uploaded_meta_id + suppress_from_latest } include Searchable @@ -12,7 +13,6 @@ class Category < ActiveRecord::Base include AnonCacheInvalidator include HasDestroyedWebHook - MAX_NESTING = 2 # category + subcategory REQUIRE_TOPIC_APPROVAL = 'require_topic_approval' REQUIRE_REPLY_APPROVAL = 'require_reply_approval' NUM_AUTO_BUMP_DAILY = 'num_auto_bump_daily' @@ -72,7 +72,6 @@ class Category < ActiveRecord::Base after_save :clear_url_cache after_save :index_search after_save :update_reviewables - after_save :clear_featured_cache after_destroy :reset_topic_ids_cache after_destroy :publish_category_deletion @@ -329,7 +328,7 @@ class Category < ActiveRecord::Base # This is used in a validation so has to produce accurate results before the # record has been saved - def height_of_ancestors(max_height = MAX_NESTING) + def height_of_ancestors(max_height = SiteSetting.max_category_nesting) parent_id = self.parent_category_id return max_height if parent_id == id @@ -357,7 +356,7 @@ class Category < ActiveRecord::Base # This is used in a validation so has to produce accurate results before the # record has been saved - def depth_of_descendants(max_depth = MAX_NESTING) + def depth_of_descendants(max_depth = SiteSetting.max_category_nesting) parent_id = self.parent_category_id return max_depth if parent_id == id @@ -390,7 +389,7 @@ class Category < ActiveRecord::Base errors.add(:base, I18n.t("category.errors.self_parent")) if parent_category_id == id total_depth = height_of_ancestors + 1 + depth_of_descendants - errors.add(:base, I18n.t("category.errors.depth")) if total_depth > MAX_NESTING + errors.add(:base, I18n.t("category.errors.depth")) if total_depth > SiteSetting.max_category_nesting end end @@ -532,6 +531,7 @@ class Category < ActiveRecord::Base topic = relation .visible .listable_topics + .exclude_scheduled_bump_topics .where(category_id: self.id) .where('id <> ?', self.topic_id) .where('bumped_at < ?', 1.day.ago) @@ -559,7 +559,7 @@ class Category < ActiveRecord::Base end def required_tag_group_name=(group_name) - self.required_tag_group = group_name ? TagGroup.where(name: group_name).first : nil + self.required_tag_group = group_name.blank? ? nil : TagGroup.where(name: group_name).first end def downcase_email @@ -656,10 +656,6 @@ class Category < ActiveRecord::Base @@url_cache.clear end - def clear_featured_cache - CategoryFeaturedTopic.clear_exclude_category_ids - end - def full_slug(separator = "-") start_idx = "#{Discourse.base_uri}/c/".length url[start_idx..-1].gsub("/", separator) @@ -915,12 +911,13 @@ end # subcategory_list_style :string(50) default("rows_with_featured_topics") # default_top_period :string(20) default("all") # mailinglist_mirror :boolean default(FALSE), not null -# suppress_from_latest :boolean default(FALSE) # minimum_required_tags :integer default(0), not null # navigate_to_first_post_after_read :boolean default(FALSE), not null # search_priority :integer default(0) # allow_global_tags :boolean default(FALSE), not null # reviewable_by_group_id :integer +# required_tag_group_id :integer +# min_tags_from_required_group :integer default(1), not null # # Indexes # diff --git a/app/models/category_featured_topic.rb b/app/models/category_featured_topic.rb index bc3b6ba0a2..2848383605 100644 --- a/app/models/category_featured_topic.rb +++ b/app/models/category_featured_topic.rb @@ -38,16 +38,6 @@ class CategoryFeaturedTopic < ActiveRecord::Base end end - @@exclude_category_ids = DistributedCache.new('excluded_category_ids_from_featured') - - def self.cached_exclude_category_ids - @@exclude_category_ids['ids'] ||= Category.where(suppress_from_latest: true).pluck(:id) - end - - def self.clear_exclude_category_ids - @@exclude_category_ids.clear - end - def self.clear_batch! Discourse.redis.del(NEXT_CATEGORY_ID_KEY) end @@ -59,8 +49,7 @@ class CategoryFeaturedTopic < ActiveRecord::Base per_page: c.num_featured_topics, except_topic_ids: [c.topic_id], visible: true, - no_definitions: true, - exclude_category_ids: CategoryFeaturedTopic.cached_exclude_category_ids + no_definitions: true } # It may seem a bit odd that we are running 2 queries here, when admin diff --git a/app/models/category_list.rb b/app/models/category_list.rb index f1f7b6a0d9..bc1d6a4980 100644 --- a/app/models/category_list.rb +++ b/app/models/category_list.rb @@ -92,16 +92,12 @@ class CategoryList @categories = @categories.to_a - category_user = {} - default_notification_level = nil - unless @guardian.anonymous? - category_user = Hash[*CategoryUser.where(user: @guardian.user).pluck(:category_id, :notification_level).flatten] - default_notification_level = CategoryUser.notification_levels[:regular] - end + notification_levels = CategoryUser.notification_levels_for(@guardian) + default_notification_level = CategoryUser.default_notification_level allowed_topic_create = Set.new(Category.topic_create_allowed(@guardian).pluck(:id)) @categories.each do |category| - category.notification_level = category_user[category.id] || default_notification_level + category.notification_level = notification_levels[category.id] || default_notification_level category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create.include?(category.id) category.has_children = category.subcategories.present? end diff --git a/app/models/category_user.rb b/app/models/category_user.rb index e25f917d32..afcd56ce19 100644 --- a/app/models/category_user.rb +++ b/app/models/category_user.rb @@ -197,6 +197,36 @@ class CategoryUser < ActiveRecord::Base SQL end + def self.default_notification_level + SiteSetting.mute_all_categories_by_default ? notification_levels[:muted] : notification_levels[:regular] + end + + def self.notification_levels_for(guardian) + if guardian.anonymous? + notification_levels = [ + SiteSetting.default_categories_watching.split("|"), + SiteSetting.default_categories_tracking.split("|"), + SiteSetting.default_categories_watching_first_post.split("|"), + ].flatten.map { |id| [id.to_i, self.notification_levels[:regular]] } + + notification_levels += SiteSetting.default_categories_muted.split("|").map { |id| [id.to_i, self.notification_levels[:muted]] } + else + notification_levels = CategoryUser.where(user: guardian.user).pluck(:category_id, :notification_level) + end + + Hash[*notification_levels.flatten] + end + + def self.lookup_for(user, category_ids) + return {} if user.blank? || category_ids.blank? + create_lookup(CategoryUser.where(category_id: category_ids, user_id: user.id)) + end + + def self.create_lookup(category_users) + category_users.each_with_object({}) do |category_user, acc| + acc[category_user.category_id] = category_user + end + end end # == Schema Information @@ -206,10 +236,12 @@ end # id :integer not null, primary key # category_id :integer not null # user_id :integer not null -# notification_level :integer not null +# notification_level :integer +# last_seen_at :datetime # # Indexes # -# idx_category_users_category_id_user_id (category_id,user_id) UNIQUE -# idx_category_users_user_id_category_id (user_id,category_id) UNIQUE +# idx_category_users_category_id_user_id (category_id,user_id) UNIQUE +# idx_category_users_user_id_category_id (user_id,category_id) UNIQUE +# index_category_users_on_user_id_and_last_seen_at (user_id,last_seen_at) # diff --git a/app/models/developer.rb b/app/models/developer.rb index 0b0dcd9320..a789dd6dd6 100644 --- a/app/models/developer.rb +++ b/app/models/developer.rb @@ -28,3 +28,7 @@ end # id :integer not null, primary key # user_id :integer not null # +# Indexes +# +# index_developers_on_user_id (user_id) UNIQUE +# diff --git a/app/models/draft.rb b/app/models/draft.rb index 2bbc3bcaf6..4f7a03711f 100644 --- a/app/models/draft.rb +++ b/app/models/draft.rb @@ -282,5 +282,5 @@ end # # Indexes # -# index_drafts_on_user_id_and_draft_key (user_id,draft_key) +# index_drafts_on_user_id_and_draft_key (user_id,draft_key) UNIQUE # diff --git a/app/models/embedding.rb b/app/models/embedding.rb index 3b67a41858..1f2be1ee0f 100644 --- a/app/models/embedding.rb +++ b/app/models/embedding.rb @@ -12,11 +12,7 @@ class Embedding < OpenStruct embed_truncate embed_whitelist_selector embed_blacklist_selector - embed_classname_whitelist - feed_polling_enabled - feed_polling_url - feed_polling_frequency_mins - embed_username_key_from_feed) + embed_classname_whitelist) end def base_url diff --git a/app/models/instagram_user_info.rb b/app/models/instagram_user_info.rb deleted file mode 100644 index 404876aa2a..0000000000 --- a/app/models/instagram_user_info.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class InstagramUserInfo < ActiveRecord::Base - - belongs_to :user - -end - -# == Schema Information -# -# Table name: instagram_user_infos -# -# id :integer not null, primary key -# user_id :integer -# screen_name :string -# instagram_user_id :integer -# created_at :datetime not null -# updated_at :datetime not null -# diff --git a/app/models/notification.rb b/app/models/notification.rb index fa6bb66490..fc0e60805b 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -4,6 +4,8 @@ class Notification < ActiveRecord::Base belongs_to :user belongs_to :topic + MEMBERSHIP_REQUEST_CONSOLIDATION_WINDOW_HOURS = 24 + validates_presence_of :data validates_presence_of :notification_type @@ -12,19 +14,34 @@ class Notification < ActiveRecord::Base scope :visible , lambda { joins('LEFT JOIN topics ON notifications.topic_id = topics.id') .where('topics.id IS NULL OR topics.deleted_at IS NULL') } - scope :filter_by_display_username_and_type, ->(username, notification_type) { - where("data::json ->> 'display_username' = ?", username) - .where(notification_type: notification_type) - .order(created_at: :desc) + scope :filter_by_consolidation_data, ->(notification_type, data) { + notifications = where(notification_type: notification_type) + + case notification_type + when types[:liked], types[:liked_consolidated] + key = "display_username" + consolidation_window = SiteSetting.likes_notification_consolidation_window_mins.minutes.ago + when types[:private_message] + key = "topic_title" + consolidation_window = MEMBERSHIP_REQUEST_CONSOLIDATION_WINDOW_HOURS.hours.ago + when types[:membership_request_consolidated] + key = "group_name" + consolidation_window = MEMBERSHIP_REQUEST_CONSOLIDATION_WINDOW_HOURS.hours.ago + end + + notifications = notifications.where("created_at > ? AND data::json ->> '#{key}' = ?", consolidation_window, data[key.to_sym]) if data[key&.to_sym].present? + notifications = notifications.where("data::json ->> 'username2' IS NULL") if notification_type == types[:liked] + + notifications } attr_accessor :skip_send_email - after_commit :send_email, on: :create after_commit :refresh_notification_count, on: [:create, :update, :destroy] after_commit(on: :create) do DiscourseEvent.trigger(:notification_created, self) + send_email unless NotificationConsolidator.new(self).consolidate! end def self.ensure_consistency! @@ -66,7 +83,8 @@ class Notification < ActiveRecord::Base liked_consolidated: 19, post_approved: 20, code_review_commit_approved: 21, - membership_request_accepted: 22 + membership_request_accepted: 22, + membership_request_consolidated: 23 ) end diff --git a/app/models/post.rb b/app/models/post.rb index 66e27fd419..73acc182d5 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -54,11 +54,13 @@ class Post < ActiveRecord::Base # We can pass several creating options to a post via attributes attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes, :cooking_options, :skip_unique_check, :skip_validation - LARGE_IMAGES ||= "large_images".freeze - BROKEN_IMAGES ||= "broken_images".freeze - DOWNLOADED_IMAGES ||= "downloaded_images".freeze - MISSING_UPLOADS ||= "missing uploads".freeze - MISSING_UPLOADS_IGNORED ||= "missing uploads ignored".freeze + LARGE_IMAGES ||= "large_images" + BROKEN_IMAGES ||= "broken_images" + DOWNLOADED_IMAGES ||= "downloaded_images" + MISSING_UPLOADS ||= "missing uploads" + MISSING_UPLOADS_IGNORED ||= "missing uploads ignored" + NOTICE_TYPE ||= "notice_type" + NOTICE_ARGS ||= "notice_args" SHORT_POST_CHARS ||= 1200 @@ -131,7 +133,8 @@ class Post < ActiveRecord::Base new_user_spam_threshold_reached: 3, flagged_by_tl3_user: 4, email_spam_header_found: 5, - flagged_by_tl4_user: 6) + flagged_by_tl4_user: 6, + email_authentication_result_header: 7) end def self.types @@ -300,6 +303,15 @@ class Post < ActiveRecord::Base options[:user_id] = post_user.id if post_user options[:omit_nofollow] = true if omit_nofollow? + if self.with_secure_media? + each_upload_url do |url| + uri = URI.parse(url) + if FileHelper.is_supported_media?(File.basename(uri.path)) + raw = raw.sub(Discourse.store.s3_upload_host, "#{Discourse.base_url}/secure-media-uploads") + end + end + end + cooked = post_analyzer.cook(raw, options) new_cooked = Plugin::Filter.apply(:after_post_cook, self, cooked) @@ -413,8 +425,8 @@ class Post < ActiveRecord::Base end def delete_post_notices - self.custom_fields.delete("notice_type") - self.custom_fields.delete("notice_args") + self.custom_fields.delete(Post::NOTICE_TYPE) + self.custom_fields.delete(Post::NOTICE_ARGS) self.save_custom_fields end @@ -492,6 +504,11 @@ class Post < ActiveRecord::Base ReviewableFlaggedPost.pending.find_by(target: self) end + def with_secure_media? + return false unless SiteSetting.secure_media? + topic&.private_message? || SiteSetting.login_required? + end + def hide!(post_action_type_id, reason = nil) return if hidden? @@ -882,6 +899,13 @@ class Post < ActiveRecord::Base end upload_ids |= Upload.where(id: downloaded_images.values).pluck(:id) + + disallowed_uploads = [] + if SiteSetting.secure_media? && !self.with_secure_media? + disallowed_uploads = Upload.where(id: upload_ids, secure: true).pluck(:original_filename) + end + return disallowed_uploads if disallowed_uploads.count > 0 + values = upload_ids.map! { |upload_id| "(#{self.id},#{upload_id})" }.join(",") PostUpload.transaction do @@ -893,6 +917,12 @@ class Post < ActiveRecord::Base end end + def update_uploads_secure_status + if Discourse.store.external? + self.uploads.each { |upload| upload.update_secure_status } + end + end + def downloaded_images JSON.parse(self.custom_fields[Post::DOWNLOADED_IMAGES].presence || "{}") rescue JSON::ParserError @@ -909,6 +939,7 @@ class Post < ActiveRecord::Base ] fragments ||= Nokogiri::HTML::fragment(self.cooked) + links = fragments.css("a/@href", "img/@src").map do |media| src = media.value next if src.blank? diff --git a/app/models/post_mover.rb b/app/models/post_mover.rb index 2cd39b80ae..facccc8ebd 100644 --- a/app/models/post_mover.rb +++ b/app/models/post_mover.rb @@ -126,6 +126,7 @@ class PostMover move_incoming_emails move_notifications update_reply_counts + update_quotes move_first_post_replies delete_post_replies copy_first_post_timings @@ -256,6 +257,18 @@ class PostMover SQL end + def update_quotes + DB.exec <<~SQL + UPDATE posts p + SET raw = REPLACE(p.raw, + ', post:' || mp.old_post_number || ', topic:' || mp.old_topic_id, + ', post:' || mp.new_post_number || ', topic:' || mp.new_topic_id), + baked_version = NULL + FROM moved_posts mp, quoted_posts qp + WHERE p.id = qp.post_id AND mp.old_post_id = qp.quoted_post_id + SQL + end + def move_first_post_replies DB.exec <<~SQL UPDATE post_replies pr diff --git a/app/models/remote_theme.rb b/app/models/remote_theme.rb index c2cd50fbcf..e278da0ddb 100644 --- a/app/models/remote_theme.rb +++ b/app/models/remote_theme.rb @@ -146,7 +146,7 @@ class RemoteTheme < ActiveRecord::Base importer.all_files.each do |filename| next unless opts = ThemeField.opts_from_file_path(filename) value = importer[filename] - updated_fields << theme.set_field(opts.merge(value: value)) + updated_fields << theme.set_field(**opts.merge(value: value)) end # Destroy fields that no longer exist in the remote theme diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index 8966621a7e..287d5a5f92 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -98,6 +98,18 @@ class Reviewable < ActiveRecord::Base %w[ReviewableFlaggedPost ReviewableQueuedPost ReviewableUser] end + def self.custom_filters + @reviewable_filters ||= [] + end + + def self.add_custom_filter(new_filter) + custom_filters << new_filter + end + + def self.clear_custom_filters! + @reviewable_filters = [] + end + def created_new! self.created_new = true self.topic = target.topic if topic.blank? && target.is_a?(Post) @@ -228,7 +240,7 @@ class Reviewable < ActiveRecord::Base priority ||= SiteSetting.reviewable_default_visibility id = Reviewable.priorities[priority.to_sym] return 0.0 if id.nil? - return PluginStore.get('reviewables', "priority_#{id}").to_f + PluginStore.get('reviewables', "priority_#{id}").to_f end def history @@ -406,7 +418,10 @@ class Reviewable < ActiveRecord::Base offset: nil, priority: nil, username: nil, - sort_order: nil + sort_order: nil, + from_date: nil, + to_date: nil, + additional_filters: {} ) min_score = Reviewable.min_score_for_priority(priority) @@ -434,6 +449,18 @@ class Reviewable < ActiveRecord::Base result = result.where(category_id: category_id) if category_id result = result.where(topic_id: topic_id) if topic_id result = result.where("score >= ?", min_score) if min_score > 0 + result = result.where("created_at >= ?", from_date) if from_date + result = result.where("created_at <= ?", to_date) if to_date + + if !custom_filters.empty? + result = custom_filters.reduce(result) do |memo, filter| + key = filter.first + filter_query = filter.last + + next(memo) unless additional_filters[key] + filter_query.call(result, additional_filters[key]) + end + end # If a reviewable doesn't have a target, allow us to filter on who created that reviewable. if user_id diff --git a/app/models/reviewable_flagged_post.rb b/app/models/reviewable_flagged_post.rb index 340d63c8b5..5f3a3a6d95 100644 --- a/app/models/reviewable_flagged_post.rb +++ b/app/models/reviewable_flagged_post.rb @@ -228,7 +228,7 @@ class ReviewableFlaggedPost < Reviewable def perform_delete_and_agree_replies(performed_by, args) result = agree(performed_by, args) - PostDestroyer.delete_with_replies(performed_by, post, self) + PostDestroyer.delete_with_replies(performed_by, post, self, defer_reply_flags: false) result end diff --git a/app/models/search_log.rb b/app/models/search_log.rb index a98f5fcaf2..003331250b 100644 --- a/app/models/search_log.rb +++ b/app/models/search_log.rb @@ -104,7 +104,7 @@ class SearchLog < ActiveRecord::Base details << { x: Date.parse(record['date'].to_s), y: record['count'] } end - return { + { type: "search_log_term", title: I18n.t("search_logs.graph_title"), start_date: start_of(period), diff --git a/app/models/site.rb b/app/models/site.rb index c888fb22c9..de8cfe0be5 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -55,15 +55,11 @@ class Site by_id = {} - category_user = {} - unless @guardian.anonymous? - category_user = Hash[*CategoryUser.where(user: @guardian.user).pluck(:category_id, :notification_level).flatten] - end - - regular = CategoryUser.notification_levels[:regular] + notification_levels = CategoryUser.notification_levels_for(@guardian) + default_notification_level = CategoryUser.default_notification_level categories.each do |category| - category.notification_level = category_user[category.id] || regular + category.notification_level = notification_levels[category.id] || default_notification_level category.permission = CategoryGroup.permission_types[:full] if allowed_topic_create&.include?(category.id) || @guardian.is_admin? category.has_children = with_children.include?(category.id) by_id[category.id] = category @@ -78,10 +74,6 @@ class Site Group.visible_groups(@guardian.user, "name ASC", include_everyone: true) end - def suppressed_from_latest_category_ids - categories.select { |c| c.suppress_from_latest == true }.map(&:id) - end - def archetypes Archetype.list.reject { |t| t.id == Archetype.private_message } end diff --git a/app/models/tag.rb b/app/models/tag.rb index 375832614d..30b86b38cf 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -5,15 +5,17 @@ class Tag < ActiveRecord::Base include HasDestroyedWebHook validates :name, presence: true, uniqueness: { case_sensitive: false } + validate :target_tag_validator, if: Proc.new { |t| t.new_record? || t.will_save_change_to_target_tag_id? } scope :where_name, ->(name) do name = Array(name).map(&:downcase) - where("lower(name) IN (?)", name) + where("lower(tags.name) IN (?)", name) end scope :unused, -> { where(topic_count: 0, pm_topic_count: 0) } + scope :base_tags, -> { where(target_tag_id: nil) } - has_many :tag_users # notification settings + has_many :tag_users, dependent: :destroy # notification settings has_many :topic_tags, dependent: :destroy has_many :topics, through: :topic_tags @@ -21,10 +23,14 @@ class Tag < ActiveRecord::Base has_many :category_tags, dependent: :destroy has_many :categories, through: :category_tags - has_many :tag_group_memberships + has_many :tag_group_memberships, dependent: :destroy has_many :tag_groups, through: :tag_group_memberships + belongs_to :target_tag, class_name: "Tag", optional: true + has_many :synonyms, class_name: "Tag", foreign_key: "target_tag_id", dependent: :destroy + after_save :index_search + after_save :update_synonym_associations after_commit :trigger_tag_created_event, on: :create after_commit :trigger_tag_updated_event, on: :update @@ -137,6 +143,25 @@ class Tag < ActiveRecord::Base SearchIndexer.index(self) end + def synonym? + !self.target_tag_id.nil? + end + + def target_tag_validator + if synonyms.exists? + errors.add(:target_tag_id, I18n.t("tags.synonyms_exist")) + elsif target_tag&.synonym? + errors.add(:target_tag_id, I18n.t("tags.invalid_target_tag")) + end + end + + def update_synonym_associations + if target_tag_id && saved_change_to_target_tag_id? + target_tag.tag_groups.each { |tag_group| tag_group.tags << self unless tag_group.tags.include?(self) } + target_tag.categories.each { |category| category.tags << self unless category.tags.include?(self) } + end + end + %i{ tag_created tag_updated diff --git a/app/models/tag_user.rb b/app/models/tag_user.rb index 820d750b66..b4bc5d9f17 100644 --- a/app/models/tag_user.rb +++ b/app/models/tag_user.rb @@ -21,6 +21,12 @@ class TagUser < ActiveRecord::Base tag_ids = tags.empty? ? [] : Tag.where_name(tags).pluck(:id) + Tag.where_name(tags).joins(:target_tag).each do |tag| + tag_ids[tag_ids.index(tag.id)] = tag.target_tag_id + end + + tag_ids.uniq! + remove = (old_ids - tag_ids) if remove.present? records.where('tag_id in (?)', remove).destroy_all @@ -41,7 +47,17 @@ class TagUser < ActiveRecord::Base end def self.change(user_id, tag_id, level) - tag_id = tag_id.id if tag_id.is_a?(::Tag) + if tag_id.is_a?(::Tag) + tag = tag_id + tag_id = tag.id + else + tag = Tag.find_by_id(tag_id) + end + + if tag.synonym? + tag_id = tag.target_tag_id + end + user_id = user_id.id if user_id.is_a?(::User) tag_id = tag_id.to_i diff --git a/app/models/theme.rb b/app/models/theme.rb index 0597cda86f..f0476e67a9 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -376,10 +376,15 @@ class Theme < ActiveRecord::Base fields.values end - def add_child_theme!(theme) - new_relation = child_theme_relation.new(child_theme_id: theme.id) + def add_relative_theme!(kind, theme) + new_relation = if kind == :child + child_theme_relation.new(child_theme_id: theme.id) + else + parent_theme_relation.new(parent_theme_id: theme.id) + end if new_relation.save child_themes.reload + parent_themes.reload save! Theme.clear_cache! else diff --git a/app/models/theme_field.rb b/app/models/theme_field.rb index 38c19da7bb..fe4e0bf644 100644 --- a/app/models/theme_field.rb +++ b/app/models/theme_field.rb @@ -71,6 +71,8 @@ class ThemeField < ActiveRecord::Base errors = [] javascript_cache || build_javascript_cache + errors << I18n.t("themes.errors.optimized_link") if contains_optimized_link?(html) + js_compiler = ThemeJavascriptCompiler.new(theme_id, self.theme.name) doc = Nokogiri::HTML.fragment(html) @@ -355,7 +357,11 @@ class ThemeField < ActiveRecord::Base result = ["failed"] begin result = compile_scss - self.error = nil unless error.nil? + if contains_optimized_link?(self.value) + self.error = I18n.t("themes.errors.optimized_link") + else + self.error = nil unless error.nil? + end rescue SassC::SyntaxError => e self.error = e.message unless self.destroyed? end @@ -367,6 +373,10 @@ class ThemeField < ActiveRecord::Base Theme.targets[target_id].to_s end + def contains_optimized_link?(text) + OptimizedImage::URL_REGEX.match?(text) + end + class ThemeFileMatcher OPTIONS = %i{name type target} # regex: used to match file names to fields (import). diff --git a/app/models/topic.rb b/app/models/topic.rb index 1af8598d2d..6e10a13c2f 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -135,6 +135,7 @@ class Topic < ActiveRecord::Base # When we want to temporarily attach some data to a forum topic (usually before serialization) attr_accessor :user_data + attr_accessor :category_user_data attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code attr_accessor :participants @@ -157,6 +158,8 @@ class Topic < ActiveRecord::Base scope :created_since, lambda { |time_ago| where('topics.created_at > ?', time_ago) } + scope :exclude_scheduled_bump_topics, -> { where.not(id: TopicTimer.scheduled_bump_topics) } + scope :secured, lambda { |guardian = nil| ids = guardian.secure_category_ids if guardian @@ -1371,7 +1374,8 @@ class Topic < ActiveRecord::Base post_type: Post.types[:regular] ).last || first_post - update!(bumped_at: post.created_at) + self.bumped_at = post.created_at + self.save(validate: false) end def auto_close_threshold_reached? diff --git a/app/models/topic_converter.rb b/app/models/topic_converter.rb index 650333749d..120920b412 100644 --- a/app/models/topic_converter.rb +++ b/app/models/topic_converter.rb @@ -30,9 +30,9 @@ class TopicConverter ) update_user_stats + update_post_uploads_secure_status Jobs.enqueue(:topic_action_converter, topic_id: @topic.id) Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id) - watch_topic(topic) end @topic @@ -49,6 +49,7 @@ class TopicConverter ) add_allowed_users + update_post_uploads_secure_status Jobs.enqueue(:topic_action_converter, topic_id: @topic.id) Jobs.enqueue(:delete_inaccessible_notifications, topic_id: @topic.id) @@ -60,31 +61,30 @@ class TopicConverter private + def posters + @posters ||= @topic.posts.distinct.pluck(:user_id).to_a + end + def update_user_stats - @topic.posts.where(deleted_at: nil).each do |p| - user = User.find(p.user_id) - # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. - user.user_stat.post_count += 1 - user.user_stat.save! - end + # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. # update topics count - @topic.user.user_stat.topic_count += 1 - @topic.user.user_stat.save! + UserStat.where(user_id: posters).update_all('post_count = post_count + 1') + UserStat.where(user_id: @topic.user_id).update_all('topic_count = topic_count + 1') end def add_allowed_users - @topic.posts.where(deleted_at: nil).each do |p| - user = User.find(p.user_id) - @topic.topic_allowed_users.build(user_id: user.id) unless @topic.topic_allowed_users.where(user_id: user.id).exists? - # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. - user.user_stat.post_count -= 1 - user.user_stat.save! - end - @topic.topic_allowed_users.build(user_id: @user.id) unless @topic.topic_allowed_users.where(user_id: @user.id).exists? - @topic.topic_allowed_users = @topic.topic_allowed_users.uniq(&:user_id) + # update posts count. NOTE that DirectoryItem.refresh will overwrite this by counting UserAction records. # update topics count - @topic.user.user_stat.topic_count -= 1 - @topic.user.user_stat.save! + UserStat.where(user_id: posters).update_all('post_count = post_count - 1') + UserStat.where(user_id: @topic.user_id).update_all('topic_count = topic_count - 1') + + existing_allowed_users = @topic.topic_allowed_users.pluck(:user_id) + users_to_allow = posters << @user.id + + (users_to_allow - existing_allowed_users).uniq.each do |user_id| + @topic.topic_allowed_users.build(user_id: user_id) + end + @topic.save! end @@ -97,4 +97,11 @@ class TopicConverter end end + def update_post_uploads_secure_status + @topic.posts.each do |post| + next if post.uploads.empty? + post.update_uploads_secure_status + post.rebake! + end + end end diff --git a/app/models/topic_link.rb b/app/models/topic_link.rb index 5222d438dc..ed0633ecd7 100644 --- a/app/models/topic_link.rb +++ b/app/models/topic_link.rb @@ -172,11 +172,12 @@ class TopicLink < ActiveRecord::Base internal = false topic_id = nil post_number = nil + topic = nil if upload = Upload.get_from_url(url) internal = Discourse.store.internal? # Store the same URL that will be used in the cooked version of the post - url = UrlHelper.cook_url(upload.url) + url = UrlHelper.cook_url(upload.url, secure: upload.secure?) elsif route = Discourse.route_for(parsed) internal = true @@ -185,9 +186,11 @@ class TopicLink < ActiveRecord::Base topic_id = route[:topic_id].to_i post_number = route[:post_number] || 1 + topic_slug = route[:id] # Store the canonical URL topic = Topic.find_by(id: topic_id) + topic ||= Topic.find_by(slug: topic_slug) if topic_slug topic_id = nil unless topic if topic.present? @@ -197,11 +200,11 @@ class TopicLink < ActiveRecord::Base end # Skip linking to ourselves - return nil if topic_id == post.topic_id + return nil if topic&.id == post.topic_id reflected_post = nil - if post_number && topic_id - reflected_post = Post.find_by(topic_id: topic_id, post_number: post_number.to_i) + if post_number && topic + reflected_post = Post.find_by(topic_id: topic.id, post_number: post_number.to_i) end url = url[0...TopicLink.max_url_length] @@ -216,7 +219,7 @@ class TopicLink < ActiveRecord::Base url: url, domain: parsed.host || Discourse.current_hostname, internal: internal, - link_topic_id: topic_id, + link_topic_id: topic&.id, link_post_id: reflected_post.try(:id), quote: link.is_quote, extension: file_extension) @@ -228,31 +231,27 @@ class TopicLink < ActiveRecord::Base reflected_id = nil # Create the reflection if we can - if topic_id.present? - topic = Topic.find_by(id: topic_id) + if topic && post.topic && topic.archetype != 'private_message' && post.topic.archetype != 'private_message' && post.topic.visible? + prefix = Discourse.base_url_no_prefix + reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" + tl = TopicLink.find_by(topic_id: topic&.id, + post_id: reflected_post&.id, + url: reflected_url) - if topic && post.topic && topic.archetype != 'private_message' && post.topic.archetype != 'private_message' && post.topic.visible? - prefix = Discourse.base_url_no_prefix - reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}" - tl = TopicLink.find_by(topic_id: topic_id, - post_id: reflected_post.try(:id), - url: reflected_url) + unless tl + tl = TopicLink.create(user_id: post.user_id, + topic_id: topic&.id, + post_id: reflected_post&.id, + url: reflected_url, + domain: Discourse.current_hostname, + reflection: true, + internal: true, + link_topic_id: post.topic_id, + link_post_id: post.id) - unless tl - tl = TopicLink.create(user_id: post.user_id, - topic_id: topic_id, - post_id: reflected_post.try(:id), - url: reflected_url, - domain: Discourse.current_hostname, - reflection: true, - internal: true, - link_topic_id: post.topic_id, - link_post_id: post.id) - - end - - reflected_id = tl.id if tl.persisted? end + + reflected_id = tl.id if tl.persisted? end [url, reflected_id] diff --git a/app/models/topic_list.rb b/app/models/topic_list.rb index 27730561d7..52b0ee0070 100644 --- a/app/models/topic_list.rb +++ b/app/models/topic_list.rb @@ -83,6 +83,7 @@ class TopicList # Attach some data for serialization to each topic @topic_lookup = TopicUser.lookup_for(@current_user, @topics) if @current_user + @category_user_lookup = CategoryUser.lookup_for(@current_user, @topics.map(&:category_id).uniq) if @current_user post_action_type = if @current_user @@ -114,6 +115,7 @@ class TopicList @topics.each do |ft| ft.user_data = @topic_lookup[ft.id] if @topic_lookup.present? + ft.category_user_data = @category_user_lookup[ft.category_id] if @category_user_lookup.present? if ft.user_data && post_action_lookup && actions = post_action_lookup[ft.id] ft.user_data.post_action_data = { post_action_type => actions } diff --git a/app/models/topic_participants_summary.rb b/app/models/topic_participants_summary.rb index 51ecfa15cc..df3f385670 100644 --- a/app/models/topic_participants_summary.rb +++ b/app/models/topic_participants_summary.rb @@ -3,6 +3,7 @@ # This is used on a topic page class TopicParticipantsSummary attr_reader :topic, :options + PARTICIPANT_COUNT = 5 # should match maxUserCount in topic list def initialize(topic, options = {}) @topic = topic @@ -26,7 +27,7 @@ class TopicParticipantsSummary end def top_participants - user_ids.map { |id| avatar_lookup[id] }.compact.uniq.take(4) + user_ids.map { |id| avatar_lookup[id] }.compact.uniq.take(PARTICIPANT_COUNT) end def user_ids diff --git a/app/models/topic_timer.rb b/app/models/topic_timer.rb index c6213a164e..1599f77ce5 100644 --- a/app/models/topic_timer.rb +++ b/app/models/topic_timer.rb @@ -17,6 +17,8 @@ class TopicTimer < ActiveRecord::Base validate :ensure_update_will_happen + scope :scheduled_bump_topics, -> { where(status_type: TopicTimer.types[:bump], deleted_at: nil).pluck(:topic_id) } + before_save do self.created_at ||= Time.zone.now if execute_at self.public_type = self.public_type? diff --git a/app/models/topic_tracking_state.rb b/app/models/topic_tracking_state.rb index 1b99db2095..3118214f8c 100644 --- a/app/models/topic_tracking_state.rb +++ b/app/models/topic_tracking_state.rb @@ -144,6 +144,15 @@ class TopicTrackingState MessageBus.publish(self.unread_channel_key(user_id), message.as_json, user_ids: [user_id]) end + def self.publish_dismiss_new(user_id, category_id = nil) + payload = category_id ? { category_id: category_id } : {} + message = { + message_type: "dismiss_new", + payload: payload + } + MessageBus.publish(self.unread_channel_key(user_id), message.as_json, user_ids: [user_id]) + end + def self.treat_as_new_topic_clause User.where("GREATEST(CASE WHEN COALESCE(uo.new_topic_duration_minutes, :default_duration) = :always THEN u.created_at @@ -159,7 +168,6 @@ class TopicTrackingState end def self.report(user, topic_id = nil) - # Sam: this is a hairy report, in particular I need custom joins and fancy conditions # Dropping to sql_builder so I can make sense of it. # @@ -173,7 +181,8 @@ class TopicTrackingState skip_unread: true, skip_order: true, staff: user.staff?, - admin: user.admin? + admin: user.admin?, + user: user ) sql << "\nUNION ALL\n\n" @@ -184,7 +193,8 @@ class TopicTrackingState skip_order: true, staff: user.staff?, filter_old_unread: true, - admin: user.admin? + admin: user.admin?, + user: user ) DB.query( @@ -221,7 +231,8 @@ class TopicTrackingState "1=0" else TopicQuery.new_filter(Topic, "xxx").where_clause.send(:predicates).join(" AND ").gsub!("'xxx'", treat_as_new_topic_clause) + - " AND topics.created_at > :min_new_topic_date" + " AND topics.created_at > :min_new_topic_date" + + " AND (category_users.last_seen_at IS NULL OR topics.created_at > category_users.last_seen_at)" end select = (opts[:select]) || " @@ -240,7 +251,7 @@ class TopicTrackingState append = "OR u.admin" if !opts.key?(:admin) <<~SQL ( - NOT c.read_restricted #{append} OR category_id IN ( + NOT c.read_restricted #{append} OR c.id IN ( SELECT c2.id FROM categories c2 JOIN category_groups cg ON cg.category_id = c2.id JOIN group_users gu ON gu.user_id = :user_id AND cg.group_id = gu.group_id @@ -265,6 +276,7 @@ class TopicTrackingState JOIN user_options AS uo ON uo.user_id = u.id JOIN categories c ON c.id = topics.category_id LEFT JOIN topic_users tu ON tu.topic_id = topics.id AND tu.user_id = u.id + LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{opts[:user].id} WHERE u.id = :user_id AND #{filter_old_unread} topics.archetype <> 'private_message' AND @@ -272,12 +284,9 @@ class TopicTrackingState #{visibility_filter} topics.deleted_at IS NULL AND #{category_filter} - NOT EXISTS( SELECT 1 FROM category_users cu - WHERE last_read_post_number IS NULL AND - cu.user_id = :user_id AND - cu.category_id = topics.category_id AND - cu.notification_level = #{CategoryUser.notification_levels[:muted]}) - + (category_users.id IS NULL OR + last_read_post_number IS NOT NULL OR + category_users.notification_level <> #{CategoryUser.notification_levels[:muted]}) SQL if opts[:topic_id] diff --git a/app/models/trust_level3_requirements.rb b/app/models/trust_level3_requirements.rb index 6b7746b223..3092259aaf 100644 --- a/app/models/trust_level3_requirements.rb +++ b/app/models/trust_level3_requirements.rb @@ -7,9 +7,14 @@ class TrustLevel3Requirements class PenaltyCounts attr_reader :silenced, :suspended - def initialize(row) + def initialize(user, row) @silenced = row['silence_count'] || 0 @suspended = row['suspend_count'] || 0 + + # If penalty started more than 6 months ago and still continues, it will + # not be selected by the query from 'penalty_counts'. + @silenced += 1 if @silenced == 0 && user.silenced? + @suspended += 1 if @suspended == 0 && user.suspended? end def total @@ -114,7 +119,7 @@ class TrustLevel3Requirements AND uh.created_at > :since SQL - PenaltyCounts.new(DB.query_hash(sql, args).first) + PenaltyCounts.new(@user, DB.query_hash(sql, args).first) end def min_days_visited diff --git a/app/models/upload.rb b/app/models/upload.rb index 5821afea84..a6842ce9a9 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -140,11 +140,6 @@ class Upload < ActiveRecord::Base !(url =~ /^(https?:)?\/\//) end - def private? - return false if self.for_theme || self.for_site_setting - SiteSetting.prevent_anons_from_downloading_files && !FileHelper.is_supported_image?(self.original_filename) - end - def fix_dimensions! return if !FileHelper.is_supported_image?("image.#{extension}") @@ -235,6 +230,34 @@ class Upload < ActiveRecord::Base self.posts.where("cooked LIKE '%/_optimized/%'").find_each(&:rebake!) end + def update_secure_status(secure_override_value: nil) + return false if self.for_theme || self.for_site_setting + mark_secure = secure_override_value.nil? ? should_be_secure? : secure_override_value + + self.update_column("secure", mark_secure) + Discourse.store.update_upload_ACL(self) if Discourse.store.external? + end + + def should_be_secure? + mark_secure = false + if FileHelper.is_supported_media?(self.original_filename) + if SiteSetting.secure_media? + mark_secure = true if SiteSetting.login_required? + unless SiteSetting.login_required? + # first post associated with upload determines secure status + # i.e. an already public upload will stay public even if added to a new PM + first_post_with_upload = self.posts.order(sort_order: :asc).first + mark_secure = first_post_with_upload ? first_post_with_upload.with_secure_media? : false + end + else + mark_secure = false + end + else + mark_secure = SiteSetting.prevent_anons_from_downloading_files? + end + mark_secure + end + def self.migrate_to_new_scheme(limit: nil) problems = [] @@ -385,6 +408,7 @@ end # thumbnail_width :integer # thumbnail_height :integer # etag :string +# secure :boolean default(FALSE), not null # # Indexes # diff --git a/app/models/user.rb b/app/models/user.rb index 6f5ec24ff1..44dbfa7b1f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,7 +56,6 @@ class User < ActiveRecord::Base has_many :user_associated_accounts, dependent: :destroy has_one :github_user_info, dependent: :destroy has_many :oauth2_user_infos, dependent: :destroy - has_one :instagram_user_info, dependent: :destroy has_many :user_second_factors, dependent: :destroy has_many :totps, -> { @@ -418,6 +417,7 @@ class User < ActiveRecord::Base def enqueue_staff_welcome_message(role) return unless staff? + return if role == :admin && User.real.where(admin: true).count == 1 Jobs.enqueue( :send_system_message, @@ -670,6 +670,15 @@ class User < ActiveRecord::Base create_visit_record!(date) unless visit_record_for(date) end + def update_timezone_if_missing(timezone) + return if timezone.blank? || !TimezoneValidator.valid?(timezone) + + # we only want to update the user's timezone if they have not set it themselves + UserOption + .where(user_id: self.id, timezone: nil) + .update_all(timezone: timezone) + end + def update_posts_read!(num_posts, opts = {}) now = opts[:at] || Time.zone.now _retry = opts[:retry] || false @@ -908,7 +917,7 @@ class User < ActiveRecord::Base def email_confirmed? email_tokens.where(email: email, confirmed: true).present? || email_tokens.empty? || - single_sign_on_record&.external_email == email + single_sign_on_record&.external_email&.downcase == email end def activate @@ -1478,8 +1487,13 @@ class User < ActiveRecord::Base def check_if_title_is_badged_granted if title_changed? && !new_record? && user_profile - badge_granted_title = title.present? && badges.where(allow_title: true, name: title).exists? - user_profile.update_column(:badge_granted_title, badge_granted_title) + badge_matching_title = title && badges.find do |badge| + badge.allow_title? && (badge.display_name == title || badge.name == title) + end + user_profile.update( + badge_granted_title: badge_matching_title.present?, + granted_title_badge_id: badge_matching_title&.id + ) end end diff --git a/app/models/user_api_key.rb b/app/models/user_api_key.rb index dbd6c52862..7d696246ca 100644 --- a/app/models/user_api_key.rb +++ b/app/models/user_api_key.rb @@ -67,9 +67,9 @@ class UserApiKey < ActiveRecord::Base end def self.invalid_auth_redirect?(auth_redirect) - return SiteSetting.allowed_user_api_auth_redirects - .split('|') - .none? { |u| WildcardUrlChecker.check_url(u, auth_redirect) } + SiteSetting.allowed_user_api_auth_redirects + .split('|') + .none? { |u| WildcardUrlChecker.check_url(u, auth_redirect) } end end diff --git a/app/models/user_auth_token.rb b/app/models/user_auth_token.rb index 30a38c68bc..48079e351d 100644 --- a/app/models/user_auth_token.rb +++ b/app/models/user_auth_token.rb @@ -8,6 +8,8 @@ class UserAuthToken < ActiveRecord::Base # used when token did not arrive at client URGENT_ROTATE_TIME = 1.minute + MAX_SESSION_COUNT = 60 + USER_ACTIONS = ['generate'] attr_accessor :unhashed_auth_token @@ -220,6 +222,14 @@ class UserAuthToken < ActiveRecord::Base end end + + def self.enforce_session_count_limit!(user_id) + tokens_to_destroy = where(user_id: user_id). + where('rotated_at > ?', SiteSetting.maximum_session_age.hours.ago). + order("rotated_at DESC").offset(MAX_SESSION_COUNT) + + tokens_to_destroy.delete_all # Returns the number of deleted rows + end end # == Schema Information diff --git a/app/models/user_history.rb b/app/models/user_history.rb index ec6fec4f11..3adbb62eb1 100644 --- a/app/models/user_history.rb +++ b/app/models/user_history.rb @@ -101,6 +101,9 @@ class UserHistory < ActiveRecord::Base api_key_create: 80, api_key_update: 81, api_key_destroy: 82, + revoke_title: 83, + change_title: 84, + override_upload_secure_status: 85 ) end @@ -175,9 +178,12 @@ class UserHistory < ActiveRecord::Base :change_theme_setting, :disable_theme_component, :enable_theme_component, + :revoke_title, + :change_title, :api_key_create, :api_key_update, :api_key_destroy, + :override_upload_secure_status ] end diff --git a/app/models/user_option.rb b/app/models/user_option.rb index 7f606671c7..10fd562c6c 100644 --- a/app/models/user_option.rb +++ b/app/models/user_option.rb @@ -42,6 +42,7 @@ class UserOption < ActiveRecord::Base validates :text_size_key, inclusion: { in: UserOption.text_sizes.values } validates :email_level, inclusion: { in: UserOption.email_level_types.values } validates :email_messages_level, inclusion: { in: UserOption.email_level_types.values } + validates :timezone, timezone: true def set_defaults self.mailing_list_mode = SiteSetting.default_email_mailing_list_mode @@ -224,6 +225,7 @@ end # email_messages_level :integer default(0), not null # title_count_mode_key :integer default(0), not null # enable_defer :boolean default(FALSE), not null +# timezone :string # # Indexes # diff --git a/app/models/user_profile.rb b/app/models/user_profile.rb index 7f16cfe90e..abd5ba06c2 100644 --- a/app/models/user_profile.rb +++ b/app/models/user_profile.rb @@ -9,6 +9,7 @@ class UserProfile < ActiveRecord::Base belongs_to :user, inverse_of: :user_profile belongs_to :card_background_upload, class_name: "Upload" belongs_to :profile_background_upload, class_name: "Upload" + belongs_to :granted_title_badge, class_name: "Badge" validates :bio_raw, length: { maximum: 3000 } validates :website, url: true, allow_blank: true, if: Proc.new { |c| c.new_record? || c.website_changed? } @@ -161,15 +162,18 @@ end # views :integer default(0), not null # profile_background_upload_id :integer # card_background_upload_id :integer +# granted_title_badge_id :bigint # # Indexes # -# index_user_profiles_on_bio_cooked_version (bio_cooked_version) -# index_user_profiles_on_card_background (card_background) -# index_user_profiles_on_profile_background (profile_background) +# index_user_profiles_on_bio_cooked_version (bio_cooked_version) +# index_user_profiles_on_card_background (card_background) +# index_user_profiles_on_granted_title_badge_id (granted_title_badge_id) +# index_user_profiles_on_profile_background (profile_background) # # Foreign Keys # # fk_rails_... (card_background_upload_id => uploads.id) +# fk_rails_... (granted_title_badge_id => badges.id) # fk_rails_... (profile_background_upload_id => uploads.id) # diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb index 3555522497..4a83a594b1 100644 --- a/app/serializers/admin_detailed_user_serializer.rb +++ b/app/serializers/admin_detailed_user_serializer.rb @@ -39,7 +39,7 @@ class AdminDetailedUserSerializer < AdminUserSerializer has_many :groups, embed: :object, serializer: BasicGroupSerializer def second_factor_enabled - object.totp_enabled? + object.totp_enabled? || object.security_keys_enabled? end def can_disable_second_factor diff --git a/app/serializers/admin_user_list_serializer.rb b/app/serializers/admin_user_list_serializer.rb index e670cd767f..5e1363f108 100644 --- a/app/serializers/admin_user_list_serializer.rb +++ b/app/serializers/admin_user_list_serializer.rb @@ -23,7 +23,6 @@ class AdminUserListSerializer < BasicUserSerializer :approved, :suspended_at, :suspended_till, - :suspended, :silenced, :silenced_till, :time_read, @@ -62,10 +61,6 @@ class AdminUserListSerializer < BasicUserSerializer object.silenced_till? end - def suspended - object.suspended? - end - def include_suspended_at? object.suspended? end diff --git a/app/serializers/basic_group_serializer.rb b/app/serializers/basic_group_serializer.rb index d3c012194a..df250920d8 100644 --- a/app/serializers/basic_group_serializer.rb +++ b/app/serializers/basic_group_serializer.rb @@ -45,7 +45,7 @@ class BasicGroupSerializer < ApplicationSerializer end def bio_excerpt - PrettyText.excerpt(object.bio_cooked, 110) if object.bio_cooked.present? + PrettyText.excerpt(object.bio_cooked, 110, keep_emoji_images: true) if object.bio_cooked.present? end def include_incoming_email? diff --git a/app/serializers/basic_user_badge_serializer.rb b/app/serializers/basic_user_badge_serializer.rb index 510f23b5e6..dd3880e423 100644 --- a/app/serializers/basic_user_badge_serializer.rb +++ b/app/serializers/basic_user_badge_serializer.rb @@ -10,6 +10,6 @@ class BasicUserBadgeSerializer < ApplicationSerializer end def grouping_position - object.badge.badge_grouping.position + object.badge&.badge_grouping&.position end end diff --git a/app/serializers/category_serializer.rb b/app/serializers/category_serializer.rb index b5bae438a7..cb5b2ff056 100644 --- a/app/serializers/category_serializer.rb +++ b/app/serializers/category_serializer.rb @@ -11,7 +11,6 @@ class CategorySerializer < SiteCategorySerializer :email_in, :email_in_allow_strangers, :mailinglist_mirror, - :suppress_from_latest, :all_topics_wiki, :can_delete, :cannot_delete_reason, @@ -82,10 +81,6 @@ class CategorySerializer < SiteCategorySerializer scope && scope.can_edit?(object) end - def include_suppress_from_latest? - scope && scope.can_edit?(object) - end - def notification_level user = scope && scope.user object.notification_level || diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb index 4ba1a982f8..a119bb324a 100644 --- a/app/serializers/current_user_serializer.rb +++ b/app/serializers/current_user_serializer.rb @@ -44,7 +44,8 @@ class CurrentUserSerializer < BasicUserSerializer :groups, :second_factor_enabled, :ignored_users, - :title_count_mode + :title_count_mode, + :timezone def groups object.visible_groups.pluck(:id, :name).map { |id, name| { id: id, name: name.downcase } } @@ -106,6 +107,10 @@ class CurrentUserSerializer < BasicUserSerializer object.user_option.redirected_to_top end + def timezone + object.user_option.timezone + end + def can_send_private_email_messages scope.can_send_private_messages_to_email? end @@ -210,6 +215,6 @@ class CurrentUserSerializer < BasicUserSerializer end def second_factor_enabled - object.totp_enabled? + object.totp_enabled? || object.security_keys_enabled? end end diff --git a/app/serializers/detailed_tag_serializer.rb b/app/serializers/detailed_tag_serializer.rb new file mode 100644 index 0000000000..25b0c555c9 --- /dev/null +++ b/app/serializers/detailed_tag_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class DetailedTagSerializer < TagSerializer + attributes :synonyms, :tag_group_names + + has_many :categories, serializer: BasicCategorySerializer + + def synonyms + TagsController.tag_counts_json(object.synonyms) + end + + def categories + Category.secured(scope).where( + id: object.categories.pluck(:id) + + object.tag_groups.includes(:categories).map do |tg| + tg.categories.map(&:id) + end.flatten + ) + end + + def include_tag_group_names? + scope.is_admin? || SiteSetting.tags_listed_by_group == true + end + + def tag_group_names + object.tag_groups.map(&:name) + end +end diff --git a/app/serializers/group_user_serializer.rb b/app/serializers/group_user_serializer.rb index 4b79988877..aa191cf18d 100644 --- a/app/serializers/group_user_serializer.rb +++ b/app/serializers/group_user_serializer.rb @@ -7,7 +7,8 @@ class GroupUserSerializer < BasicUserSerializer :title, :last_posted_at, :last_seen_at, - :added_at + :added_at, + :timezone def include_added_at object.respond_to? :added_at diff --git a/app/serializers/invite_serializer.rb b/app/serializers/invite_serializer.rb index 26b5c1794f..befa52fc21 100644 --- a/app/serializers/invite_serializer.rb +++ b/app/serializers/invite_serializer.rb @@ -2,7 +2,7 @@ class InviteSerializer < ApplicationSerializer - attributes :email, :created_at, :redeemed_at, :expired, :user + attributes :email, :updated_at, :redeemed_at, :expired, :user def include_email? !object.redeemed? diff --git a/app/serializers/listable_topic_serializer.rb b/app/serializers/listable_topic_serializer.rb index 1336a92c14..4e5829df02 100644 --- a/app/serializers/listable_topic_serializer.rb +++ b/app/serializers/listable_topic_serializer.rb @@ -59,6 +59,7 @@ class ListableTopicSerializer < BasicTopicSerializer def seen return true if !scope || !scope.user return true if object.user_data && !object.user_data.last_read_post_number.nil? + return true if object.category_user_data&.last_seen_at && object.created_at < object.category_user_data.last_seen_at return true if object.created_at < scope.user.user_option.treat_as_new_topic_start_date false end diff --git a/app/serializers/new_post_result_serializer.rb b/app/serializers/new_post_result_serializer.rb index ca6eee7f9c..9232062711 100644 --- a/app/serializers/new_post_result_serializer.rb +++ b/app/serializers/new_post_result_serializer.rb @@ -6,7 +6,9 @@ class NewPostResultSerializer < ApplicationSerializer :errors, :success, :pending_count, - :reason + :reason, + :message, + :route_to has_one :pending_post, serializer: TopicPendingPostSerializer, root: false, embed: :objects @@ -64,4 +66,20 @@ class NewPostResultSerializer < ApplicationSerializer pending_count.present? end + def route_to + object.route_to + end + + def include_route_to? + object.route_to.present? + end + + def message + object.message + end + + def include_message? + object.message.present? + end + end diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 0e2a7b3072..8651252e84 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -368,7 +368,7 @@ class PostSerializer < BasicPostSerializer end def notice_type - post_custom_fields["notice_type"] + post_custom_fields[Post::NOTICE_TYPE] end def include_notice_type? @@ -389,7 +389,7 @@ class PostSerializer < BasicPostSerializer end def notice_args - post_custom_fields["notice_args"] + post_custom_fields[Post::NOTICE_ARGS] end def include_notice_args? diff --git a/app/serializers/reviewable_serializer.rb b/app/serializers/reviewable_serializer.rb index b2cbdab459..af03419d2a 100644 --- a/app/serializers/reviewable_serializer.rb +++ b/app/serializers/reviewable_serializer.rb @@ -106,7 +106,7 @@ class ReviewableSerializer < ApplicationSerializer end def target_url - return object.target.url if object.target.is_a?(Post) && object.target.present? + return Discourse.base_url + object.target.url if object.target.is_a?(Post) && object.target.present? topic_url end @@ -115,7 +115,7 @@ class ReviewableSerializer < ApplicationSerializer end def topic_url - return object.topic&.url + object.topic&.url end def include_topic_url? diff --git a/app/serializers/site_serializer.rb b/app/serializers/site_serializer.rb index 3f8a653d96..d7c2ef58fe 100644 --- a/app/serializers/site_serializer.rb +++ b/app/serializers/site_serializer.rb @@ -15,7 +15,6 @@ class SiteSerializer < ApplicationSerializer :is_readonly, :disabled_plugins, :user_field_max_length, - :suppressed_from_latest_category_ids, :post_action_types, :topic_flag_types, :can_create_tag, diff --git a/app/serializers/tag_group_serializer.rb b/app/serializers/tag_group_serializer.rb index 3fa051bfa9..db61936fce 100644 --- a/app/serializers/tag_group_serializer.rb +++ b/app/serializers/tag_group_serializer.rb @@ -4,7 +4,7 @@ class TagGroupSerializer < ApplicationSerializer attributes :id, :name, :tag_names, :parent_tag_name, :one_per_topic, :permissions def tag_names - object.tags.map(&:name).sort + object.tags.base_tags.map(&:name).sort end def parent_tag_name diff --git a/app/serializers/topic_list_item_serializer.rb b/app/serializers/topic_list_item_serializer.rb index 658df93614..7962c400c0 100644 --- a/app/serializers/topic_list_item_serializer.rb +++ b/app/serializers/topic_list_item_serializer.rb @@ -14,7 +14,8 @@ class TopicListItemSerializer < ListableTopicSerializer :bookmarked_post_numbers, :liked_post_numbers, :featured_link, - :featured_link_root_domain + :featured_link_root_domain, + :allowed_user_count has_many :posters, serializer: TopicPosterSerializer, embed: :objects has_many :participants, serializer: TopicPosterSerializer, embed: :objects @@ -86,4 +87,12 @@ class TopicListItemSerializer < ListableTopicSerializer SiteSetting.topic_featured_link_enabled && object.featured_link.present? end + def allowed_user_count + object.allowed_users.count + end + + def include_allowed_user_count? + object.private_message? + end + end diff --git a/app/serializers/user_auth_token_log_serializer.rb b/app/serializers/user_auth_token_log_serializer.rb deleted file mode 100644 index 0a676f8c7c..0000000000 --- a/app/serializers/user_auth_token_log_serializer.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class UserAuthTokenLogSerializer < ApplicationSerializer - include UserAuthTokensMixin - - attributes :action - - def action - case object.action - when 'generate' - I18n.t('log_in') - when 'destroy' - I18n.t('unsubscribe.log_out') - else - I18n.t('staff_action_logs.unknown') - end - end -end diff --git a/app/serializers/user_option_serializer.rb b/app/serializers/user_option_serializer.rb index 4a8a5a1d0c..4c78c7b5bd 100644 --- a/app/serializers/user_option_serializer.rb +++ b/app/serializers/user_option_serializer.rb @@ -27,7 +27,8 @@ class UserOptionSerializer < ApplicationSerializer :hide_profile_and_presence, :text_size, :text_size_seq, - :title_count_mode + :title_count_mode, + :timezone def auto_track_topics_after_msecs object.auto_track_topics_after_msecs || SiteSetting.default_other_auto_track_topics_after_msecs diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index 52d1214cfd..79111200ef 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -164,7 +164,7 @@ class UserSerializer < BasicUserSerializer end def second_factor_enabled - object.totp_enabled? + object.totp_enabled? || object.security_keys_enabled? end def include_second_factor_backup_enabled? diff --git a/app/serializers/web_hook_post_serializer.rb b/app/serializers/web_hook_post_serializer.rb index 030b55203b..2099a17c4c 100644 --- a/app/serializers/web_hook_post_serializer.rb +++ b/app/serializers/web_hook_post_serializer.rb @@ -3,6 +3,7 @@ class WebHookPostSerializer < PostSerializer attributes :topic_posts_count, + :topic_filtered_posts_count, :topic_archetype, :category_slug @@ -34,6 +35,10 @@ class WebHookPostSerializer < PostSerializer object.topic ? object.topic.posts_count : 0 end + def topic_filtered_posts_count + object.topic ? object.topic.posts.where(post_type: Post.types[:regular]).count : 0 + end + def topic_archetype object.topic ? object.topic.archetype : '' end diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb index 737837fa67..25462cf467 100644 --- a/app/services/badge_granter.rb +++ b/app/services/badge_granter.rb @@ -72,8 +72,19 @@ class BadgeGranter StaffActionLogger.new(options[:revoked_by]).log_badge_revoke(user_badge) end - # If the user's title is the same as the badge name, remove their title. - if user_badge.user.title == user_badge.badge.name + # If the user's title is the same as the badge name OR the custom badge name, remove their title. + custom_badge_name = TranslationOverride.find_by(translation_key: user_badge.badge.translation_key)&.value + user_title_is_badge_name = user_badge.user.title == user_badge.badge.name + user_title_is_custom_badge_name = custom_badge_name.present? && user_badge.user.title == custom_badge_name + + if user_title_is_badge_name || user_title_is_custom_badge_name + if options[:revoked_by] + StaffActionLogger.new(options[:revoked_by]).log_title_revoke( + user_badge.user, + revoke_reason: 'user title was same as revoked badge name or custom badge name', + previous_value: user_badge.user.title + ) + end user_badge.user.title = nil user_badge.user.save! end diff --git a/app/services/notification_consolidator.rb b/app/services/notification_consolidator.rb new file mode 100644 index 0000000000..9ba3dc0ba4 --- /dev/null +++ b/app/services/notification_consolidator.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class NotificationConsolidator + attr_reader :notification, :notification_type, :consolidation_type, :data + + def initialize(notification) + @notification = notification + @notification_type = notification.notification_type + @data = notification.data_hash + + if notification_type == Notification.types[:liked] + @consolidation_type = Notification.types[:liked_consolidated] + @data[:username] = @data[:display_username] + elsif notification_type == Notification.types[:private_message] + post_id = @data[:original_post_id] + return if post_id.blank? + + custom_field = PostCustomField.select(:value).find_by(post_id: post_id, name: "requested_group_id") + return if custom_field.blank? + + group_id = custom_field.value.to_i + group_name = Group.select(:name).find_by(id: group_id)&.name + return if group_name.blank? + + @consolidation_type = Notification.types[:membership_request_consolidated] + @data[:group_name] = group_name + end + end + + def consolidate! + return if SiteSetting.notification_consolidation_threshold.zero? || consolidation_type.blank? + + update_consolidated_notification! || create_consolidated_notification! + end + + def update_consolidated_notification! + consolidated_notification = user_notifications.filter_by_consolidation_data(consolidation_type, data).first + return if consolidated_notification.blank? + + data_hash = consolidated_notification.data_hash + data_hash["count"] += 1 + + Notification.transaction do + consolidated_notification.update!( + data: data_hash.to_json, + read: false, + updated_at: timestamp + ) + notification.destroy! + end + + consolidated_notification + end + + def create_consolidated_notification! + notifications = user_notifications.unread.filter_by_consolidation_data(notification_type, data) + return if notifications.count <= SiteSetting.notification_consolidation_threshold + + consolidated_notification = nil + + Notification.transaction do + timestamp = notifications.last.created_at + data[:count] = notifications.count + + consolidated_notification = Notification.create!( + notification_type: consolidation_type, + user_id: notification.user_id, + data: data.to_json, + updated_at: timestamp, + created_at: timestamp + ) + + notifications.destroy_all + end + + consolidated_notification + end + + private + + def user_notifications + notification.user.notifications + end + + def timestamp + @timestamp ||= Time.zone.now + end +end diff --git a/app/services/post_alerter.rb b/app/services/post_alerter.rb index d71e4b4914..f769649e53 100644 --- a/app/services/post_alerter.rb +++ b/app/services/post_alerter.rb @@ -249,7 +249,7 @@ class PostAlerter end def should_notify_edit?(notification, opts) - return notification.data_hash["display_username"] != opts[:display_username] + notification.data_hash["display_username"] != opts[:display_username] end def should_notify_like?(user, notification) @@ -331,28 +331,19 @@ class PostAlerter notification_data = {} - if is_liked - if existing_notification_of_same_type && - existing_notification_of_same_type.created_at > 1.day.ago && - ( - user.user_option.like_notification_frequency == - UserOption.like_notification_frequency_type[:always] - ) + if is_liked && + existing_notification_of_same_type && + existing_notification_of_same_type.created_at > 1.day.ago && + ( + user.user_option.like_notification_frequency == + UserOption.like_notification_frequency_type[:always] + ) - data = existing_notification_of_same_type.data_hash - notification_data["username2"] = data["display_username"] - notification_data["count"] = (data["count"] || 1).to_i + 1 - # don't use destroy so we don't trigger a notification count refresh - Notification.where(id: existing_notification_of_same_type.id).destroy_all - elsif !SiteSetting.likes_notification_consolidation_threshold.zero? - notification = consolidate_liked_notifications( - user, - post, - opts[:display_username] - ) - - return notification if notification - end + data = existing_notification_of_same_type.data_hash + notification_data["username2"] = data["display_username"] + notification_data["count"] = (data["count"] || 1).to_i + 1 + # don't use destroy so we don't trigger a notification count refresh + Notification.where(id: existing_notification_of_same_type.id).destroy_all end collapsed = false @@ -559,7 +550,7 @@ class PostAlerter end end - def notify_post_users(post, notified) + def notify_post_users(post, notified, include_category_watchers: true, include_tag_watchers: true) return unless post.topic warn_if_not_sidekiq @@ -570,8 +561,14 @@ class PostAlerter FROM topic_users WHERE notification_level = :watching AND topic_id = :topic_id + /*category*/ + /*tags*/ + ) + SQL - UNION + if include_category_watchers + condition.sub! "/*category*/", <<~SQL + UNION SELECT cu.user_id FROM category_users cu @@ -580,14 +577,12 @@ class PostAlerter WHERE cu.notification_level = :watching AND cu.category_id = :category_id AND tu.user_id IS NULL - - /*tags*/ - ) - SQL + SQL + end tag_ids = post.topic.topic_tags.pluck('topic_tags.tag_id') - if tag_ids.present? + if include_tag_watchers && tag_ids.present? condition.sub! "/*tags*/", <<~SQL UNION @@ -621,82 +616,4 @@ class PostAlerter def warn_if_not_sidekiq Rails.logger.warn("PostAlerter.#{caller_locations(1, 1)[0].label} was called outside of sidekiq") unless Sidekiq.server? end - - private - - def consolidate_liked_notifications(user, post, username) - user_notifications = user.notifications - - consolidation_window = - SiteSetting.likes_notification_consolidation_window_mins.minutes.ago - - liked_by_user_notifications = - user_notifications - .filter_by_display_username_and_type( - username, Notification.types[:liked] - ) - .where( - "created_at > ? AND data::json ->> 'username2' IS NULL", - consolidation_window - ) - - user_liked_consolidated_notification = - user_notifications - .filter_by_display_username_and_type( - username, Notification.types[:liked_consolidated] - ) - .where("created_at > ?", consolidation_window) - .first - - if user_liked_consolidated_notification - return update_consolidated_liked_notification_count!( - user_liked_consolidated_notification - ) - elsif ( - liked_by_user_notifications.count >= - SiteSetting.likes_notification_consolidation_threshold - ) - return create_consolidated_liked_notification!( - liked_by_user_notifications, - post, - username - ) - end - end - - def update_consolidated_liked_notification_count!(notification) - data = notification.data_hash - data["count"] += 1 - - notification.update!( - data: data.to_json, - read: false - ) - - notification - end - - def create_consolidated_liked_notification!(notifications, post, username) - notification = nil - - Notification.transaction do - timestamp = notifications.last.created_at - - notification = Notification.create!( - notification_type: Notification.types[:liked_consolidated], - user_id: post.user_id, - data: { - username: username, - display_username: username, - count: notifications.count + 1 - }.to_json, - updated_at: timestamp, - created_at: timestamp - ) - - notifications.each(&:destroy!) - end - - notification - end end diff --git a/app/services/search_indexer.rb b/app/services/search_indexer.rb index 8b3c84944b..17817c4c09 100644 --- a/app/services/search_indexer.rb +++ b/app/services/search_indexer.rb @@ -137,7 +137,12 @@ class SearchIndexer end category_name = topic.category&.name if topic - tag_names = topic.tags.pluck(:name).join(' ') if topic + if topic + tags = topic.tags.select(:id, :name) + unless tags.empty? + tag_names = (tags.map(&:name) + Tag.where(target_tag_id: tags.map(&:id)).pluck(:name)).join(' ') + end + end if Post === obj && obj.raw.present? && ( diff --git a/app/services/site_settings_task.rb b/app/services/site_settings_task.rb index 4cddf6d4dc..f9d185cdf2 100644 --- a/app/services/site_settings_task.rb +++ b/app/services/site_settings_task.rb @@ -36,6 +36,6 @@ class SiteSettingsTask counts[:not_found] += 1 end end - return log, counts + [log, counts] end end diff --git a/app/services/staff_action_logger.rb b/app/services/staff_action_logger.rb index f6264da3eb..9130f34692 100644 --- a/app/services/staff_action_logger.rb +++ b/app/services/staff_action_logger.rb @@ -352,6 +352,38 @@ class StaffActionLogger )) end + def log_title_revoke(user, opts = {}) + raise Discourse::InvalidParameters.new(:user) unless user + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:revoke_title], + target_user_id: user.id, + details: opts[:revoke_reason], + previous_value: opts[:previous_value] + )) + end + + def log_title_change(user, opts = {}) + raise Discourse::InvalidParameters.new(:user) unless user + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:change_title], + target_user_id: user.id, + details: opts[:details], + new_value: opts[:new_value], + previous_value: opts[:previous_value] + )) + end + + def log_change_upload_secure_status(opts = {}) + UserHistory.create!(params(opts).merge( + action: UserHistory.actions[:override_upload_secure_status], + details: [ + "upload_id: #{opts[:upload_id]}", + "reason: #{I18n.t("uploads.marked_insecure_from_theme_component_reason")}" + ].join("\n"), + new_value: opts[:new_value] + )) + end + def log_check_email(user, opts = {}) raise Discourse::InvalidParameters.new(:user) unless user UserHistory.create!(params(opts).merge( diff --git a/app/services/themes_install_task.rb b/app/services/themes_install_task.rb index 1a3ac1bbcf..8466e7b8cb 100644 --- a/app/services/themes_install_task.rb +++ b/app/services/themes_install_task.rb @@ -25,7 +25,7 @@ class ThemesInstallTask end end - return log, counts + [log, counts] end attr_reader :url, :options diff --git a/app/services/user_anonymizer.rb b/app/services/user_anonymizer.rb index 99c7dc6dd4..bb5bd3f705 100644 --- a/app/services/user_anonymizer.rb +++ b/app/services/user_anonymizer.rb @@ -64,7 +64,6 @@ class UserAnonymizer @user.single_sign_on_record.try(:destroy) @user.oauth2_user_infos.try(:destroy_all) @user.user_associated_accounts.try(:destroy_all) - @user.instagram_user_info.try(:destroy) @user.user_open_ids.find_each { |x| x.destroy } @user.api_keys.find_each { |x| x.try(:destroy) } @user.user_emails.secondary.destroy_all diff --git a/app/services/user_updater.rb b/app/services/user_updater.rb index c5d539e1db..44c262d8f2 100644 --- a/app/services/user_updater.rb +++ b/app/services/user_updater.rb @@ -40,7 +40,8 @@ class UserUpdater :homepage_id, :hide_profile_and_presence, :text_size, - :title_count_mode + :title_count_mode, + :timezone ] def initialize(actor, user) diff --git a/app/views/users_email/confirm.html.erb b/app/views/users_email/confirm.html.erb deleted file mode 100644 index 0f236a1992..0000000000 --- a/app/views/users_email/confirm.html.erb +++ /dev/null @@ -1,43 +0,0 @@ -
- <% if @update_result == :authorizing_new %> -

<%= t 'change_email.authorizing_old.title' %>

-
-

<%= t 'change_email.authorizing_old.description' %>

- <% elsif @update_result == :complete %> -

<%= t 'change_email.confirmed' %>

-
- <%= t('change_email.please_continue', site_name: SiteSetting.title) %> - <% elsif @update_result == :invalid_second_factor%> -
-

<%= t('login.second_factor_title') %>

-
- <%=form_tag({}, method: :put) do %> - <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> -
<%= render 'common/second_factor_text_field' %>
- <% if @show_invalid_second_factor_error %> -
<%= t('login.invalid_second_factor_code') %>
- <% end %> - <%= submit_tag t('submit'), class: "btn btn-primary" %> - <% end %> -
- - <%if @backup_codes_enabled %> - - <%=t "login.second_factor_backup" %> - <%= render 'common/second_factor_form_script' %> - <%end%> - <% else %> -
- <%=t 'change_email.already_done' %> -
- <% end %> -
diff --git a/app/views/users_email/show_confirm_new_email.html.erb b/app/views/users_email/show_confirm_new_email.html.erb new file mode 100644 index 0000000000..fe14cf53df --- /dev/null +++ b/app/views/users_email/show_confirm_new_email.html.erb @@ -0,0 +1,51 @@ +
+ <% if @done %> +

+ <%= t 'change_email.confirmed' %> +

+

+ "><%= t('change_email.please_continue', site_name: SiteSetting.title) %> +

+ <% elsif @error %> +
+ <%= @error %> +
+ <% else %> +

<%= t 'change_email.authorizing_new.title' %>

+

+ <%= t 'change_email.authorizing_new.description' %> +

+

+ <%= @to_email %> +

+ + <%=form_tag(u_confirm_new_email_path, method: :put) do %> + <%= hidden_field_tag 'token', @token.token %> + + <% if @show_backup_codes %> +
+

<%= t('login.second_factor_backup_title') %>

+ <%= label_tag(:second_factor_token, t("login.second_factor_backup_description")) %> +
<%= render 'common/second_factor_backup_input' %>
+ <%= submit_tag(t("submit"), class: "btn btn-primary") %> +
+ <%= link_to t("login.second_factor_toggle.totp"), show_backup: "false" %> + <% elsif @show_second_factor %> +
+

<%= t('login.second_factor_title') %>

+ <%= label_tag(:second_factor_token, t('login.second_factor_description')) %> +
<%= render 'common/second_factor_text_field' %>
+ <% if @show_invalid_second_factor_error %> +
<%= t('login.invalid_second_factor_code') %>
+ <% end %> + <%= submit_tag t('submit'), class: "btn btn-primary" %> +
+ <% if @backup_codes_enabled %> + <%= link_to t("login.second_factor_toggle.backup_code"), show_backup: "true" %> + <% end %> + <% else %> + <%= submit_tag t('change_email.confirm'), class: "btn btn-primary" %> + <% end %> + <%end%> + <% end%> +
diff --git a/app/views/users_email/show_confirm_old_email.html.erb b/app/views/users_email/show_confirm_old_email.html.erb new file mode 100644 index 0000000000..6d81093186 --- /dev/null +++ b/app/views/users_email/show_confirm_old_email.html.erb @@ -0,0 +1,27 @@ +
+ <% if @almost_done %> +

<%= t 'change_email.authorizing_old.almost_done_title' %>

+

+ <%= t 'change_email.authorizing_old.almost_done_description' %> +

+ <% elsif @error %> +
+ <%= @error %> +
+ <% else %> +

<%= t 'change_email.authorizing_old.title' %>

+

+ <%= t 'change_email.authorizing_old.description' %> +
+
+ <%= t 'change_email.authorizing_old.old_email', email: @from_email %> +
+ <%= t 'change_email.authorizing_old.new_email', email: @to_email %> +

+ + <%=form_tag(u_confirm_old_email_path, method: :put) do %> + <%= hidden_field_tag 'token', @token.token %> + <%= submit_tag t('change_email.confirm'), class: "btn btn-primary" %> + <% end %> + <% end %> +
diff --git a/bin/unicorn b/bin/unicorn index 865e8d87dc..a9b9e04efb 100755 --- a/bin/unicorn +++ b/bin/unicorn @@ -4,12 +4,34 @@ require 'pathname' ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", Pathname.new(__FILE__).realpath) +RAILS_ROOT = File.expand_path("../../", Pathname.new(__FILE__).realpath) require 'rubygems' require 'bundler/setup' +require 'digest' dev_mode = false +def ensure_cache_clean! + all_plugin_directories = Pathname.new(RAILS_ROOT + '/plugins').children.select(&:directory?) + core_git_sha = `git rev-parse HEAD`.strip + plugins_combined_git_sha = `git ls-files -s plugins | git hash-object --stdin`.strip + super_sha = Digest::SHA1.hexdigest(core_git_sha + plugins_combined_git_sha) + hash_file = "#{RAILS_ROOT}/tmp/plugin-hash" + + old_hash = File.exists?(hash_file) ? File.read(hash_file) : nil + + if old_hash && old_hash != super_sha + puts "WARNING: It looks like your discourse plugins or core version have recently changed." + puts "The tmp/cache directory will be wiped to avoid development issues." + `rm -rf #{RAILS_ROOT}/tmp/cache` + puts + end + + FileUtils.mkdir_p(RAILS_ROOT + "/tmp") + File.write(hash_file, super_sha) +end + # in development do some fussing around, to automate config if !ARGV.include?("-E") && !ARGV.include?("--env") && @@ -38,6 +60,7 @@ if !ARGV.include?("-E") && ENV["UNICORN_SIDEKIQS"] ||= "1" + ensure_cache_clean! end if ARGV.include?("--help") diff --git a/config/discourse_defaults.conf b/config/discourse_defaults.conf index 2b0bd3149b..7af0d3c2a3 100644 --- a/config/discourse_defaults.conf +++ b/config/discourse_defaults.conf @@ -157,11 +157,6 @@ sidekiq_workers = 5 # adjust stylesheets to rtl (requires "rtlit" gem) rtl_css = false -# notify admin when a new version of discourse is released -# this is global so it is easier to set in multisites -# TODO allow for global overrides -new_version_emails = true - # connection reaping helps keep connection counts down, postgres # will not work properly with huge numbers of open connections # reap connections from pool that are older than 30 seconds @@ -212,7 +207,7 @@ max_reqs_per_ip_per_10_seconds = 50 max_asset_reqs_per_ip_per_10_seconds = 200 # global rate limiter will simply warn if the limit is exceeded, can be warn+block, warn, block or none -max_reqs_per_ip_mode = none +max_reqs_per_ip_mode = block # bypass rate limiting any IP resolved as a private IP max_reqs_rate_limit_on_private = false diff --git a/config/initializers/100-onebox_options.rb b/config/initializers/100-onebox_options.rb index e9d0f21c71..d98886e2ba 100644 --- a/config/initializers/100-onebox_options.rb +++ b/config/initializers/100-onebox_options.rb @@ -1,7 +1,16 @@ # frozen_string_literal: true -Onebox.options = { - twitter_client: TwitterApi, - redirect_limit: 3, - user_agent: "Discourse Forum Onebox v#{Discourse::VERSION::STRING}" -} +if Rails.env.development? && SiteSetting.port.to_i > 0 + Onebox.options = { + twitter_client: TwitterApi, + redirect_limit: 3, + user_agent: "Discourse Forum Onebox v#{Discourse::VERSION::STRING}", + allowed_ports: [80, 443, SiteSetting.port.to_i] + } +else + Onebox.options = { + twitter_client: TwitterApi, + redirect_limit: 3, + user_agent: "Discourse Forum Onebox v#{Discourse::VERSION::STRING}" + } +end diff --git a/config/locales/client.ar.yml b/config/locales/client.ar.yml index 1444b1e9b1..92a458dece 100644 --- a/config/locales/client.ar.yml +++ b/config/locales/client.ar.yml @@ -643,6 +643,7 @@ ar: collapse_profile: "إخفاء" bookmarks: "العلامات المرجعية" bio: "معلومات عنّي" + timezone: "المنطقة الزمنية" invited_by: "مدعو بواسطة" trust_level: "مستوى الثقة" notifications: "الإشعارات" @@ -2460,6 +2461,7 @@ ar: changed: "الأوسمة المعدلة:" tags: "الأوسمة" choose_for_topic: "الأوسمة الإختيارية" + add_synonyms: "اضافة" delete_tag: "احذف الوسم" delete_confirm: zero: "هل أنت متاكد انك تريد حذف هذا الوسم و إذالتة من {{count}} موضوع؟" @@ -3465,19 +3467,14 @@ ar: category: "انشر في قسم" add_host: "أضف مضيف" settings: "تضمين إعدادات" - feed_settings: "إعدادات التغذية " - feed_description: " توفير مغذي RSS/ATOM لموقعك سيطور قدرة Discourse على استيراد المحتوى الخاص بك." crawling_settings: "اعدادات الزاحف" crawling_description: "عندما ينشأ Discourse مواضيع لمشاركتك، إذا لم يتوفر مغذي RSS/ATOM سيحاول تحليل محتواك من HTML الخاص بك. أحيانا يمكن أن يكون تحديا استخراج محتواك، لذا نمنحك القدرة لتحديد قواعد CSS لجعل الاستخراج أسهل." embed_by_username: "اسم العضو للموضوع المنشأ" embed_post_limit: "أقصى عدد مشاركات مضمنة" - embed_username_key_from_feed: "مفتاح لسحب اسم عضو discourse من المغذي" embed_truncate: "بتر المشاركات المضمنة" embed_whitelist_selector: "منتقي CSS للعناصر التي تسمح في التضمينات." embed_blacklist_selector: "منتقي CSS للعناصر التي حذفت من التضمينات." embed_classname_whitelist: "اسماء اصناف المظاهر الجمالية المسموح بها" - feed_polling_enabled: "استورد المشاركات عبر RSS/ATOM" - feed_polling_url: "رابط مغذي RSS/ATOM للزحف" save: "أحفظ الإعدادات المضمنة" permalink: title: "الرابط الثابت" diff --git a/config/locales/client.be.yml b/config/locales/client.be.yml index 4899a14964..9461351fca 100644 --- a/config/locales/client.be.yml +++ b/config/locales/client.be.yml @@ -69,6 +69,7 @@ be: previous_month: "папярэдні месяц" next_month: "наступны месяц" share: + topic_html: 'Тэма: %{topicTitle}' post: "паведамленне #%{postNumber}" close: "схаваць" topic_admin_menu: "кіравання тэмай" @@ -98,7 +99,7 @@ be: joined: "Далучыўся (-ась)" admin_title: "Адмін панэль" show_more: "паказаць больш" - show_help: "Опцыі" + show_help: "налады" links: "Спасылкі" links_lowercase: one: "спасылка" @@ -136,10 +137,10 @@ be: related_messages: title: "падобныя паведамленні" suggested_topics: - title: "прапанаваныя тэмы" + title: "Прапанаваныя тэмы" pm_title: "прапанаваныя паведамленні" about: - simple_title: "аб" + simple_title: "Аб сайце" title: "Аб %{title}" stats: "статыстыка" our_admins: "адміны" @@ -151,7 +152,7 @@ be: like_count: "перавагі" topic_count: "Тэмы" post_count: "Паведамленні" - user_count: "Карыстачы" + user_count: "Карыстальнікі" active_user_count: "дзеючыя карыстальнікі" contact: "Кантакты" contact_info: "У выпадку сур'ёзных праблем з гэтым сайтам, калі ласка, звяжыцеся з намі праз %{contact_info}." @@ -183,14 +184,18 @@ be: undo: "Адмяніць" revert: "Вярнуць" failed: "Памылка" + switch_to_anon: "Увайсці як ананім" switch_from_anon: "Пакінуць ананімны прагляд" banner: close: "схаваць" edit: "Рэдагаваць гэты банер >>" choose_topic: - none_found: "Не знойдзена тэм." + none_found: "Не знойдзена тэмаў." title: placeholder: "увядзіце назву тэмы" + choose_message: + title: + placeholder: "увядзіце назву паведамлення тут" review: explain: total: "агульны" @@ -198,21 +203,24 @@ be: settings: save_changes: "Захаваць" title: "Налады" - title: "патрабуе перагляду" + moderation_history: "Гісторыя мадэрацыі" + title: "Патрабуе перагляду" topic: "Тэма:" filtered_user: "Карыстальнік" + show_all_topics: "паказаць усе тэмы" user: - username: "імя карыстальніка" - email: "электронная пошта" - name: "імя" + username: "Імя карыстальніка" + email: "Электронная пошта" + name: "Імя" topics: topic: "Тэма" reviewable_count: "падлічваць" + deleted: "[Тэма выдаленая]" edit: "Рэдагаваць" save: "Захаваць" cancel: "Адмяніць" filters: - all_categories: "(Усе катэгорыі)" + all_categories: "(усе катэгорыі)" type: title: "тып" refresh: "абнавіць" @@ -247,7 +255,7 @@ be: sent_by_you: "Адпраўлена Вамі <" directory: filter_name: "фільтраваць па імені карыстальніка" - title: "карыстальнікі" + title: "Карыстальнікі" likes_received: "атрымана" topics_entered: "прагледжана" topics_entered_long: "тэмаў перагледжаны" @@ -280,13 +288,13 @@ be: add_members: "Дадаць удзельнікаў" delete_member_confirm: "Выдаліць '%{username}' з групы '%{group}'?" profile: - title: профіль + title: Профіль interaction: posting: апублікаванне membership: access: доступ logs: - title: "часопісы" + title: "Логі" when: "калі" action: "дзеянне" subject: "тэма" @@ -296,17 +304,26 @@ be: add: "Дадаць" request: "запыт" message: "паведамленне" - name: "імя" - user_count: "карыстальнікі" + name: "Імя" + user_count: "Карыстальнікі" + bio: "Аб групе" selector_placeholder: "увядзіце імя карыстальніка" index: - title: "групы" + title: "Групы" + all: "Усе групы" + close_groups: "Закрытыя групы" closed: "Закрыта" public: "грамадскага" private: "прыватны" + public_groups: "Публічныя групы" my_groups: "Мае Групы" is_group_user: "член" - activity: "актыўнасць" + title: + one: "Група" + few: "Групаў" + many: "Групаў" + other: "Групы" + activity: "Актыўнасць" members: title: "Удзельнікі" filter_placeholder_admin: "імя карыстальніка або email" @@ -314,7 +331,7 @@ be: topics: "тэмы" posts: "паведамленні" mentions: "згадкі" - messages: "паведамленні" + messages: "Паведамленні" alias_levels: nobody: "ніхто" only_admins: "толькі адміністратары" @@ -325,7 +342,7 @@ be: watching: title: "сачыць" tracking: - title: "сачыць" + title: "Cачыць" muted: title: "ігнараваць" user_action_groups: @@ -340,6 +357,7 @@ be: "12": "дасланыя" "13": "ўваходныя" "14": "чакаецца" + "15": "Чарнавікі" categories: all: "усе катэгорыі" all_subcategories: "усе" @@ -353,10 +371,11 @@ be: latest: "Апошнія" latest_by: "апошнія па" toggle_ordering: "паказаць" - subcategories: "падкатэгорыі" + subcategories: "Падкатэгорыі" + n_more: "Катэгорыі (%{count} і больш) ..." ip_lookup: title: Пошук IP адрасы - hostname: імя хаста + hostname: Імя хосту location: месцазнаходжанне location_not_found: (Невядома) organisation: арганізацыя @@ -370,23 +389,23 @@ be: confirm_delete_other_accounts: "Вы ўпэўненыя, што жадаеце выдаліць гэтых карыстальнікаў?" user: said: "{{Username}}" - profile: "профіль" + profile: "Профіль" mute: "Заглушыць" edit: "рэдагаваць налады" new_private_message: "новае паведамленне" private_message: "паведамленне" - private_messages: "паведамленне" + private_messages: "Паведамленні" user_notifications: - ignore_duration_username: "імя карыстальніка" + ignore_duration_username: "Імя карыстальніка" ignore_duration_save: "ігнараваць" mute_option: "ігнараваць" - activity_stream: "актыўнасць" + activity_stream: "Актыўнасць" preferences: "Налады" bookmarks: "закладкі" - bio: "пра мяне" + bio: "Аба мне" invited_by: "Запрошаны (а)" trust_level: "ўзровень даверу" - notifications: "абвесткі" + notifications: "Натыфікацыі" statistics: "статыстыка" desktop_notifications: perm_default: "ўключыць абвесткі" @@ -400,9 +419,9 @@ be: muted_tags: "ігнаруемай" muted_tags_instructions: "Вы ня будзеце атрымоўваць паведамленні аб новых тэмах па гэтых цэтліках і яны не адлюструюцца ў спісе апошніх тэмаў." muted_categories: "ігнаруемай" - unread_message_count: "паведамленне" + unread_message_count: "Паведамленні" admin_delete: "Выдаліць" - users: "Карыстачы" + users: "Карыстальнікі" muted_users: "ігнаруемай" apps: "Аппы" theme: "тэма" @@ -417,11 +436,12 @@ be: select_all: "выбраць усе" tags: "Тэгі" preferences_nav: - profile: "профіль" - emails: "лісты" - notifications: "абвесткі" + account: "Аккаўнт" + profile: "Профіль" + emails: "Емейлы" + notifications: "Натыфікацыі" categories: "Катэгорыі" - users: "Карыстачы" + users: "Карыстальнікі" tags: "Цэтлікі" interface: "Інтэрфейс" apps: "Аппы" @@ -431,7 +451,7 @@ be: error: "(Памылка)" action: "Адправіць ліст для аднаўлення пароля" set_password: "ўсталяваць пароль" - choose_new: "% Менш, чым {лік} хвілін таму......................................................" + choose_new: "Абярыце новы пароль" choose: "% Менш, чым {лік} хвілін таму........................................................." second_factor_backup: regenerate: "Перагенераваць" @@ -439,7 +459,7 @@ be: enable: "Уключыць" second_factor: title: "Два фактары аўтэнтыфікацыі" - name: "імя" + name: "Імя" edit: "рэдагаваць" security_key: register: "рэгістрацыя" @@ -465,18 +485,19 @@ be: change_card_background: title: "Фон вашай візітоўкі" email: - title: "электронная пошта" + title: "Электронная пошта" invalid: "Калі ласка, увядзіце верны email" associated_accounts: + title: "Асацыяваныя аккаўнты" revoke: "ануляваць" cancel: "адмяніць" name: - title: "імя" + title: "Імя" instructions_required: "Ваша поўнае імя" too_short: "Ваша імя занадта кароткае" ok: "Ваша імя выглядае добра" username: - title: "імя карыстальніка" + title: "Імя карыстальніка" available: "Ваша імя даступна" not_available: "Не даступна. Паспрабуеце {{suggestion}}?" too_short: "Ваша імя кароткі" @@ -491,14 +512,15 @@ be: auth_tokens: ip: "IP" details: "дэталі" + log_out_all: "Выйсці паўсюль" last_posted: "Апошняе паведамленне" last_emailed: "Апошні электронны ліст" last_seen: "заўважаны апошні раз" created: "Далучыўся (лася)" - log_out: "выйсці" + log_out: "Выйсці" location: "месцазнаходжанне" website: "вэбсайт" - email_settings: "электронная пошта" + email_settings: "Электронная пошта" like_notification_frequency: always: "заўсёды" never: "ніколі" @@ -516,7 +538,7 @@ be: always: "заўсёды" never: "ніколі" other_settings: "іншае" - categories_settings: "наладкі катэгорыі" + categories_settings: "Катэгорыі" new_topic_duration: label: "Лічыць тэмы новымі, калі" not_viewed: "я іх яшчэ не прагледзеў" @@ -537,16 +559,16 @@ be: after_5_minutes: "пасля 5 хвілін" after_10_minutes: "пасля 10 хвілін" invited: - search: "шукаць запрашэнне ..." - title: "запрашэнне" + search: "шукаць запрашэнні ..." + title: "Запрашэнні" user: "запрошаны карыстальнік" sent: "адпраўлена" - redeemed: "прынятыя запрашэння" + redeemed: "Адазваныя запрашэнні" redeemed_tab: "прынята" redeemed_at: "прынята" - pending: "прынята..." + pending: "Няпрынятые запрашэнні" pending_tab: "прынята......" - topics_entered: "прынята........." + topics_entered: "Тэмаў прагледжана" posts_read_count: "прачытана паведамленняў" expired: "Тэрмін дзеяння гэтага запрашэння мінуў." rescind: "выдаліць" @@ -558,17 +580,23 @@ be: bulk_invite: text: "Масавае Запрашэнне з файла" password: - title: "пароль" + title: "Пароль" too_short: "Пароль занадта кароткі." common: "Гэты пароль занадта просты." same_as_username: "Гэты пароль занадта просты...." same_as_email: "Пароль ідэнтычны Вашаму email" ok: "Пароль добры." summary: - title: "вынік" + title: "Рэзюмэ" stats: "статыстыка" time_read: "пры чытанні" - more_topics: "больш тэм" + topic_count: + one: "тэма створана" + few: "тэмаў створана" + many: "тэмаў створана" + other: "тэмаў створана" + more_topics: "Больш тэмаў" + top_categories: "ТОП катэгорый" topics: "Тэмы" replies: "Адказы" ip_address: @@ -576,7 +604,7 @@ be: registration_ip_address: title: "IP Адрас Рэгістрацыі" avatar: - title: "аватар" + title: "Выява профілю" title: title: "Назва" filters: @@ -633,8 +661,8 @@ be: disable: "Паказаць выдаленыя паведамленні" private_message_info: title: "Паказаць выдаленыя паведамленні..." - email: "электронная пошта" - username: "імя карыстальніка" + email: "Электронная пошта" + username: "Імя карыстальніка" last_seen: "заўважаны апошні раз" created: "Створана" created_lowercase: "створана" @@ -654,7 +682,7 @@ be: login: title: "Увайсці" username: "Карыстальнік" - password: "пароль" + password: "Пароль" second_factor_title: "Два фактары аўтэнтыфікацыі" second_factor_backup_description: "Калі ласка, увядзіце адзін з рэзервовых кодаў:" email_placeholder: "электронная пошта ці імя карыстальніка" @@ -699,7 +727,7 @@ be: google: "Google" twitter: "Twitter" category_page_style: - categories_only: "катэгорыі толькі" + categories_only: "Толькі катэгорыі" categories_with_featured_topics: "Катэгорыі з Выбранымі Тэмамі" categories_and_latest_topics: "Катэгорыі і Апошнія тэмы" categories_and_top_topics: "Катэгорыі і асноўныя тэмы" @@ -711,10 +739,10 @@ be: from: ад to: да emoji_picker: - flags: Скаргі + flags: Пазначэнні composer: more_emoji: "яшчэ ..." - options: "Настройка" + options: "Налады" blockquote_text: "цытата" posting_not_on_topic: "На якую тэму Вы хочаце адказаць?" saved_local_draft_tip: "захавана лакальна" @@ -736,13 +764,16 @@ be: title: "Or press Ctrl Enter" users_placeholder: "дадаць карыстальніка" title_placeholder: "Пра што гэта абмеркаванне, у адным кароткім сказе?" + title_or_link_placeholder: "Увядзіце загаловак, альбо ўстаўце спасылку тут" edit_reason_placeholder: "чаму Вы рэдагуеце паведамленне?" - view_new_post: "Перагледзьце свой новы паведамленне." + reply_placeholder: "Увядзіце тэкст тут. Выкарыстоўвайце для фарматавання Markdown, BBCode або HTML. Перацягвайце або ўстаўляйце выявы." + reply_placeholder_no_images: "Увядзіце тэкст тут. Выкарыстоўвайце для фарматавання Markdown, BBCode або HTML." + view_new_post: "Праглядзець свой новы запіс." saving: "захаванне" saved: "Захавана!" uploading: "Запампоўванне..." show_preview: "папярэдні прагляд & raquo;" - hide_preview: "& Laquo; схаваць папярэдні прагляд" + hide_preview: "« схаваць папярэдні прагляд" quote_post_title: "Працытаваць увесь паведамленне цалкам" bold_title: "моцнае вылучэнне" bold_text: "Моцнае вылучэнне тэксту" @@ -752,9 +783,11 @@ be: link_description: "увядзіце апісанне спасылкі" link_dialog_title: "ўставіць гіперспасылку" link_optional_text: "неабавязкова назву" + link_url_placeholder: "Устаўце URL адрас альбо тып для пошуку тэмаў" quote_title: "цытата" quote_text: "цытата" code_title: "Папярэдне фарматаваны тэкст" + paste_code_text: "увядзіце ці ўстаўце код тут" upload_title: "спампаваць" upload_description: "увядзіце апісанне загрузкі" olist_title: "нумараваны спіс" @@ -768,9 +801,10 @@ be: reply: Адказаць edit: Рэдагаваць reply_as_private_message: + label: Новае паведамленне desc: Напісаць новае асабістае паведамленне create_topic: - label: "пачаць новую тэму" + label: "Стварыць новую тэму" notifications: popup: confirm_title: "Апавяшчэння ўключаная -% {SITE_TITLE}" @@ -790,13 +824,15 @@ be: sort_by: "сартаваць па" latest_post: "апошняе паведамленне" select_all: "выбраць усе" + title: "пошук тэмаў, пастоў, юзераў альбо катэгорыяў" + full_page_title: "пошук тэмаў альбо пастоў" no_results: "Нічога не знойдзена." searching: "Пошук ..." search_google_button: "Google" search_google_title: "Пошук на гэтым сайце" context: topic: "Пошук у гэтай тэме" - private_messages: "шукаць паведамленні" + private_messages: "Пошук паведамленняў" advanced: title: пашыраны пошук posted_by: @@ -817,28 +853,32 @@ be: bulk: select_all: "выбраць усе" reset_read: "Reset Read" - delete: "выдаліць тэмы" + delete: "Выдаліць тэмы" + dismiss_read: "Пазначыць усе як прачытаныя" dismiss_new: "Dismiss New" toggle: "перамыкач масавай дзеянні над тэмамі" actions: "масавыя дзеянні" - close_topics: "зачыніць тэмы" - notification_level: "абвесткі" + close_topics: "Закрыць тэму" + archive_topics: "Заархіваваныя тэмы" + notification_level: "Натыфікацыі" none: unread: "У вас няма непрачытаных тэм." new: "У вас няма новых тэм." read: "Вы яшчэ не прачыталі ніводнай тэмы." posted: "Вы яшчэ не дапісвалі ў адну тэму." latest: "Апошніх тэм няма. Шкада." + bookmarks: "У вас пакуль няма тэмаў у закладках." category: "У катэгорыі {{category}} няма тэм." top: "There are no top topics." bottom: - latest: "Больш няма апошніх тэм." + latest: "Усе апошнія тэмы адлюстраваныя." posted: "There are no more posted topics." read: "Больш няма прачытаных тэм." new: "Больш няма новых тэм." unread: "Больш няма непрачытаных тэм." category: "Больш няма тым у катэгорыі {{category}}." top: "There are no more top topics." + bookmarks: "Тэмы ў закладках адсутнічаюць." topic: create: "пачаць новую тэму" create_long: "Стварыць новую тэму" @@ -861,7 +901,7 @@ be: title: "Тэму не знойдзена" description: "Выбачайце, мы не змаглі знайсці гэтую тэму. Магчыма, яна была выдаленая мадэратарам?" back_to_list: "Вярнуцца да спісу тым" - options: "налада тэмы" + options: "Налады тэмы" show_links: "паказаць спасылкі ў гэтай тэме" toggle_information: "паказаць" read_more_in_category: "Хочаце пачытаць яшчэ? Глядзіце іншыя тэмы ў {{catLink}} або {{latestLink}}." @@ -875,12 +915,14 @@ be: auto_update_input: tomorrow: "заўтра" auto_close: + title: "Аўтаматычнае закрыццё тэмы" error: "Калі ласка, увядзіце карэктнае значэнне." auto_close_title: "Настройка аўтаматычнага закрыцця" timeline: back: "Назад" progress: title: прасоўванне па тэме + go_top: "топ" go_bottom: "кнопка" jump_bottom: "перайсці да апошняга паведамлення" jump_bottom_with_number: "перайсці да паведамлення %{post_number}" @@ -902,25 +944,32 @@ be: watching: title: "сачыць" tracking_pm: - title: "сачыць" + title: "Сачыць" tracking: - title: "сачыць" + title: "Сачыць" muted_pm: title: "ігнараваць" muted: title: "ігнараваць" actions: - recover: "Адклікаць выдалення тэмы" - delete: "выдаліць тэму" - open: "адкрыць тэму" - close: "зачыніць тэму" - unarchive: "разархіваваць тэму" - archive: "заархіваванага тэму" + recover: "Адмяніць выдаленне тэмы" + delete: "Выдаліць тэму" + open: "Адкрыць тэму" + close: "Закрыць тэму" + multi_select: "Абраць запісы ..." + timed_update: "Усталяваць таймер тэмы ..." + pin: "Замацаваць тэму ..." + unpin: "Адмацаваць тэму ..." + unarchive: "Разархіваваць тэму" + archive: "Заархіваваць тэму" + invisible: "Выдаліць са спісу" reset_read: "Скінуць дадзеныя аб прочитанисть" + make_private: "Стварыць асабістае паведамленне" + reset_bump_date: "Скінуць Dump дату" feature: - pin: "замацаваць тэму" - unpin: "распушчае мацаваньне тэму" - make_banner: "банер тэма" + pin: "Замацаваць тэму" + unpin: "Адмацаваць тэму" + make_banner: "Тэма-баннер" reply: title: "Адказаць" help: "пачаць складаць адказ на гэтую тэму" @@ -928,13 +977,13 @@ be: title: "распушчае мацаваньне" help: "Адмяніць замацаванне гэтай тэмы, каб яна больш не з'яўлялася ў пачатку Вашага пераліку тэм" share: - title: "распаўсюдзіць" + title: "Падзяліцца тэмай" help: "Распаўсюдзіць спасылку на гэтую тэму" print: title: "друк" flag_topic: - title: "Flag" - help: "privately flag this topic for attention or send a private notification about it" + title: "Пазначыць" + help: "прыватна пазначце гэтую тэму для ўвагі альбо адпраўце асабістае апавяшчэнне пра яе" success_message: "privately flag this topic for attention or send a private notification about it..." invite_private: group_name: "назва групы" @@ -954,6 +1003,9 @@ be: error: "Пры пераносе паведамленняў да гэтай тэмы адбылася памылка." move_to_new_message: radio_label: "новае паведамленне" + change_timestamp: + title: "Змяніць метку часу" + action: "змяніць метку часу" post: errors: upload: "Выбачайце, пры загрузцы гэтага файла адбылася памылка. Калі ласка, паспрабуйце яшчэ раз." @@ -962,18 +1014,22 @@ be: abandon: no_value: "Не, пачакайце" yes_value: "Так, пакінуць" + archetypes: + save: "Захаваць налады" controls: edit_action: "Рэдагаваць" more: "Болей" grant_badge: "даць Значок" delete_topic: "выдаліць тэму" actions: - flag: "паскардзіцца" + flag: "Пазначыць" people: off_topic: "заўважана гэта, як ад тэмы" spam: "пазначаны як спам" inappropriate: "пазначыў гэта як непрыстойныя" like: "гэта спадабалася" + by_you: + notify_moderators: "Вы пазначылі гэта для мадэрацыі" revisions: controls: first: "Першая рэвізія" @@ -1014,10 +1070,10 @@ be: color_placeholder: "Любы вэб-колер" delete_confirm: "Вы ўпэўненыя, што жадаеце выдаліць гэтую катэгорыю?" delete_error: "Пры выдаленні катэгорыі адбылася памылка." - list: "List Categories" + list: "Спіс катэгорыяў" change_in_category_topic: "рэдагаваць апісанне" already_used: "Гэты колер ўжо выкарыстоўваецца іншы катэгорыяй" - security: "бяспеку" + security: "Бяспека" images: "Выява" email_in: "Custom incoming email address:" allow_badges_label: "Дазволіць ўзнагароджваць значкамі ў гэтай катэгорыі" @@ -1030,7 +1086,7 @@ be: watching: title: "сачыць" tracking: - title: "сачыць" + title: "Сачыць" muted: title: "ігнаруемай" search_priority: @@ -1046,15 +1102,18 @@ be: created: "створаны" settings_sections: general: "Агульныя" - email: "электронная пошта" + moderation: "Мадэрацыя" + email: "Электронная пошта" flagging: - action: "Паскардзіцца на паведамленне" + action: "Пазначыць запіс" take_action: "прыняць меры" notify_action: "паведамленне" delete_spammer: "выдаліць спамера" yes_delete_spammer: "Так, выдаліць спамера" flagging_topic: notify_action: "паведамленне" + topic_map: + title: "Рэзюмэ запісу" topic_statuses: pinned: title: "замацаваныя" @@ -1070,7 +1129,7 @@ be: category_title: "Катэгорыя" history: "гісторыя" changed_by: "{{Author}}" - categories_list: "спіс катэгорый" + categories_list: "Спіс катэгорый" filters: latest: title: "Апошнія" @@ -1079,11 +1138,11 @@ be: title: "прачытаныя" help: "тэмы, якія вы прачыталі ў парадку, у якім Вы іх чыталі ў апошні раз" categories: - title: "катэгорыі" + title: "Катэгорыі" title_in: "Катэгорыя - {{categoryName}}" help: "усе тэмы згрупаваныя па катэгорыях" unread: - title: "непрачытаныя" + title: "Непрачытаныя" new: lower_title: "новая" title: "Новая" @@ -1096,7 +1155,7 @@ be: title: "{{CategoryName}}" help: "Апошнія тэмы ў катэгорыі {{categoryName}}" top: - title: "Top" + title: "Топ тэмаў" all: title: "Top..." yearly: @@ -1122,16 +1181,18 @@ be: lightbox: download: "загрузіць" keyboard_shortcuts_help: - title: "спалучэнне клавіш" + title: "Спалучэнні клавіш" jump_to: title: "на" + top: "%{shortcut} Топ" navigation: title: "рух" open: " o <" application: title: "дадатак" create: " c <" - notifications: " n <" + notifications: "%{shortcut}Адкрытыя абвесткі " + log_out: "%{shortcut}Выйсці" actions: title: "дзеянні" share_post: " s <" @@ -1142,8 +1203,8 @@ be: bookmark: " b <" edit: " e <" delete: " d <" - mark_muted: " m <" - mark_tracking: " m <" + mark_muted: "%{shortcut}Прыглушыць тэму" + mark_tracking: "%{shortcut}Сачыць за тэмай" badges: title: значкі select_badge_for_title: "Выберыце значок, будзе вашым званнем" @@ -1158,21 +1219,23 @@ be: name: апублікаванне tagging: tags: "тэгі" + choose_for_topic: "дадатковыя тэгі" + add_synonyms: "дадаць" sort_by: "Сартаваць па:" - sort_by_name: "імя" + sort_by_name: "Імя" cancel_delete_unused: "адмяніць" notifications: watching: - title: "сачыць" + title: "Сачыць" tracking: - title: "сачыць" + title: "Сачыць" regular: title: "звычайны" muted: title: "ігнараваць" groups: new: "Новая група" - save: "захаваць" + save: "Захаваць" delete: "выдаліць" topics: none: @@ -1180,14 +1243,16 @@ be: new: "У вас няма новых тэм." read: "Вы яшчэ не прачыталі ніводнай тэмы." posted: "Вы яшчэ не дапісвалі ў адну тэму." + bookmarks: "У вас пакуль няма тэмаў у закладках." top: "There are no top topics." bottom: - latest: "Больш няма апошніх тэм." + latest: "Усе апошнія тэмы адлюстраваныя." posted: "There are no more posted topics." read: "Больш няма прачытаных тэм." new: "Больш няма новых тэм." - unread: "Больш няма непрачытаных тэм." + unread: "Непрачытаныя тэмы скончыліся." top: "There are no more top topics." + bookmarks: "Тэмы ў закладках адсутнічаюць." admin_js: admin: moderator: "Мадэратар" @@ -1196,7 +1261,8 @@ be: always: "заўсёды" never: "ніколі" dashboard: - title: "майстэрня" + title: "Галоўная панэль" + last_updated: "Адноўлена:" version: "версія" up_to_date: "У вас апошняя версія!" critical_available: "Даступнае крытычнае абнаўленне." @@ -1214,26 +1280,32 @@ be: admins: "адміністратары:" suspended: "прыпыненыя:" private_messages_short: "Пвдмл" - private_messages_title: "паведамленне" - backups: "Backups" + private_messages_title: "Паведамленні" + mobile_title: "Мабільны" + uploads: "Загрузкі" + backups: "Бэкапы" traffic_short: "трафік" page_views: "прагледжваемыя" page_views_short: "прагледжваемыя" general_tab: "Агульныя" - security_tab: "бяспеку" - report_filter_any: "любой" + moderation_tab: "Мадэрацыя" + security_tab: "Бяспека" + reports_tab: "Справаздачы" + report_filter_any: "любы" + disabled: Адключана reports: today: "сёння" yesterday: "учора" last_7_days: "Апошнія 7" last_30_days: "Апошнія 30" - all_time: "ўвесь час" + all_time: "За ўвесь час" 7_days_ago: "7 дзён таму" 30_days_ago: "30 дзён таму" - all: "усе" + all: "Усё" + groups: "Усе групы" filters: group: - label: група + label: Група category: label: катэгорыя commits: @@ -1242,14 +1314,14 @@ be: groups: new: title: "Новая група" - create: "стварыць" + create: "Стварыць" manage: interaction: - email: электронная пошта + email: Электронная пошта visibility_levels: public: "Усе" - title: "групы" - edit: "мяняць групы" + title: "Групы" + edit: "Рэдагаваць групы" refresh: "абнавіць" about: "Edit your group membership and names here" group_members: "Удзельнікі групы" @@ -1264,10 +1336,12 @@ be: title: "API" created: створаны generate: "згенераваць" - revoke: "ануляваць" - all_users: "All Users" - show_details: дэталі - description: апісанне + revoke: "Адклікнуць" + all_users: "Усе карыстальнікі" + active_keys: "Актыўныя API ключы" + manage_keys: Кіраваць ключамі + show_details: Дэталі + description: Апісанне save: Захаваць web_hooks: create: "стварыць" @@ -1281,6 +1355,7 @@ be: title: "стан дастаўкі" failed: "памылка" successful: "паспяхова" + disabled: "Адключана" events: request: "запыт" headers: "загалоўкі" @@ -1288,15 +1363,15 @@ be: timestamp: "Створана" actions: "загалоўкі....................." plugins: - title: "убудовы" + title: "Плагіны" name: "Назва" version: "версія" change_settings_short: "Налады" backups: - title: "Backups" + title: "Бэкапы" menu: - backups: "Backups" - logs: "Logs" + backups: "Бэкапы" + logs: "Логі" none: "No backup available." read_only: enable: @@ -1309,7 +1384,7 @@ be: logs: none: "No logs yet ..." columns: - filename: "Filename" + filename: "Назва файлу" size: "Size" upload: label: "Загрузіць" @@ -1339,8 +1414,11 @@ be: button_text: "экспарт" export_json: button_text: "экспарт" + invite: + button_text: "Адправіць запрашэнні" + button_title: "Адправіць запрашэнні" customize: - title: "Customize" + title: "Кастамізаваць" long_title: "Site Customizations" preview: "preview" save: "Save" @@ -1351,7 +1429,7 @@ be: opacity: "непразрыстасць" copy: "капіяваць" email_templates: - title: "электронная пошта" + title: "Электронная пошта" subject: "тэма" body: "цела" revert: "вярнуць змены" @@ -1362,12 +1440,14 @@ be: create_type: "тып" create_name: "Назва" edit: "рэдагаваць" + mobile: "Мабільны" settings: "Налады" preview: "папярэдні прагляд" + uploads: "Загрузкі" upload: "Загрузіць" installed: "устаноўленая" install_popular: "папулярны" - about_theme: "аб" + about_theme: "Аб тэме" enable: "Уключыць" disable: "Адключыць" add: "Дадаць" @@ -1401,7 +1481,7 @@ be: name: "каханне" description: "Колер кнопкі \"Падабаецца\"." email: - title: "лістоў" + title: "Емейлы" settings: "Налады" templates: "шаблоны" preview_digest: "Кароткі выклад навін" @@ -1411,7 +1491,7 @@ be: received: "Атрымана" rejected: "адхілена" user: "карыстальнік" - email_type: "тып лісты" + email_type: "Тып емейлу" to_address: "скрыню атрымальніка" test_email_address: "электронны скрыню для праверкі" send_test: "Адправіць Тэставы Email" @@ -1419,7 +1499,7 @@ be: delivery_method: "спосаб дастаўкі" refresh: "абнавіць" send_digest: "адправіць" - sending_email: "Напрамак лісты ..." + sending_email: "Адпраўленне ліста ..." format: "фармат" html: "html" text: "тэкст" @@ -1450,7 +1530,7 @@ be: address_placeholder: "name@example.com" type_placeholder: "digest, signup ..." logs: - title: "часопісы" + title: "Логі" action: "дзеянне" created_at: "створаны" last_match_at: "апошняя адпаведнасць" @@ -1488,6 +1568,7 @@ be: revoke_badge: "адмяніць значок" check_email: "Праверыць электронную пошту" delete_topic: "выдаліць тэму" + recover_topic: "адмяніць выдаленне тэмы" delete_post: "выдаліць паведамленне" anonymize_user: "ананімны карыстальнік" change_category_settings: "змяніць налады катэгорыі" @@ -1496,13 +1577,13 @@ be: grant_admin: "зрабіць адміністратарам" revoke_admin: "адклікаць администраторство" grant_moderation: "зрабіць мадэратарам" - revoke_moderation: "адклікаць Модераторство" + revoke_moderation: "адклікаць правы мадэратара" deleted_tag: "выдаліць тэг" lock_trust_level: "заблакаваць ўзровень даверу" screened_emails: - title: "Screened Emails" + title: "Схаваныя емейлы" description: "Калі хто-то спрабуе стварыць новы ўліковы запіс, наступныя электронныя скрыні будуць правераны і рэгістрацыю заблакаваны, ці прынятыя нейкія іншых дзеянняў." - email: "электронная пошта" + email: "Электронная пошта" actions: allow: "дазволіць" screened_urls: @@ -1534,7 +1615,7 @@ be: download: спампаваць actions: block: "заблакаваць" - flag: "паскардзіцца" + flag: "Пазначыць" form: add: "дадаць" success: "поспех" @@ -1543,23 +1624,25 @@ be: not_found: "Карыстача не ўдалося знайсці." invalid: "Выбачайце, Вы не можаце ўвасобіцца ў карыстальніка." users: - title: "карыстальнікі" + title: "Карыстальнікі" create: "дадаць адміністратара" last_emailed: "Апошні емэйл" not_found: "Выбачайце, такога імя карыстальніка няма ў нашай сістэме." id_not_found: "Выбачайце, карыстальніка з такім нумарам няма ў нашай сістэме." - show_emails: "Выбачайце, карыстальніка з такім нумарам няма ў нашай сістэме...." + show_emails: "Паказаць емейл" nav: - new: "новыя" + new: "Новыя" active: "актыўныя" staff: "персанал" suspended: "прыпыненыя" silenced: "глушыцелем" + suspect: "Магчыма крэмлебот" approved: "Адобрана?" titles: - active: "дзеючыя карыстальнікі" - new: "новыя карыстальнікі" + active: "Актыўныя карыстальнікі" + new: "Новыя карыстальнікі" staff: "персанал" + suspect: "Крэмлеботы і тролі" user: suspend_reason: "прычына" silence_reason: "прычына" @@ -1572,17 +1655,17 @@ be: show_public_profile: "Паказаць публічны профіль" impersonate: "ўвасобіцца" ip_lookup: "пошук IP" - log_out: "выйсці" + log_out: "Выйсці" logged_out: "Карыстальнік выйшаў з сістэмы на ўсіх прыладах" revoke_admin: "адклікаць администраторство" grant_admin: "зрабіць адміністратарам" - revoke_moderation: "адклікаць Модераторство" + revoke_moderation: "Адклікаць правы мадэратара" grant_moderation: "зрабіць мадэратарам" unsuspend: "адмяніць прыпыненьне" suspend: "прыпыніць" reputation: рэпутацыя permissions: дазволу - activity: актыўнасць + activity: Актыўнасць last_100_days: "за апошнія 100 дзён" private_topics_count: прыватных тым posts_read_count: паведамленняў прачытана @@ -1593,7 +1676,7 @@ be: warnings_received_count: атрымана папярэджанняў approve: "ўхваліць" approved_by: "ўхвалена" - approve_success: "Карыстальніка адобрана і яму адпраўлена ліст з інструкцыямі для актывацыі." + approve_success: "Карыстальнік адобраны і яму адпраўлен ліст з інструкцыямі для актывацыі." approve_bulk_success: "Поспех! Ўсіх выбраных карыстальнікаў было прынята і паведамлена пра гэта." time_read: "час чытання" anonymize: "ананімны карыстальнік" @@ -1639,9 +1722,9 @@ be: likes_received: "атрыманыя перавагі" sso: external_id: "знешні ID" - external_username: "імя карыстальніка" + external_username: "Імя карыстальніка" external_name: "Імя" - external_email: "электронная пошта" + external_email: "Электронная пошта" user_fields: untitled: "без загалоўка" type: "тып поля" @@ -1677,12 +1760,12 @@ be: all_results: "усе" required: "абавязковыя" basic: "асноўныя ўстаноўкі" - users: "карыстальнікі" + users: "Карыстальнікі" posting: "апублікаванне" - email: "электронная пошта" + email: "Электронная пошта" files: "файлы" trust: "ўзроўні даверу" - security: "бяспеку" + security: "Бяспека" seo: "SEO" spam: "спам" rate_limits: "абмежаванне хуткасці" @@ -1691,21 +1774,21 @@ be: legal: "Legal" api: "API" uncategorized: "іншае" - backups: "Backups" + backups: "Бэкапы" login: "Уваход" - plugins: "убудовы" + plugins: "Плагіны" user_preferences: "Налады" tags: "тэгі" search: "пошук" groups: "групы" - dashboard: "майстэрня" + dashboard: "Галоўная панэль" default_categories: modal_yes: "ды" badges: title: значкі new_badge: новы значок - new: новы - name: назва + new: Новыя + name: Назва badge: значок display_name: Адлюстроўваецца імя description: апісанне @@ -1738,7 +1821,7 @@ be: sample: "Прыклад:" emoji: title: "Абразкі" - name: "імя" + name: "Імя" image: "Выява" embedding: confirm_delete: "Вы ўпэўненыя, што жадаеце выдаліць гэты хост?" @@ -1750,7 +1833,6 @@ be: embed_by_username: "Імя карыстальніка для стварэння тэмы" embed_post_limit: "Максімальную колькасць паведамленняў для ўстаўкі" embed_classname_whitelist: "Дазволеныя імёны класаў CSS" - feed_polling_enabled: "Імпартаваць паведамленні праз RSS" permalink: title: "Сталыя спасылкі" url: "URL спасылка" @@ -1768,7 +1850,7 @@ be: filter: "Пошук (URL або знешні URL)" reseed: modal: - categories: "Categories" + categories: "Катэгорыі" topics: "тэмы" wizard_js: wizard: @@ -1786,5 +1868,5 @@ be: moderator: "Мадэратар" regular: "звычайны карыстальнік" previews: - share_button: "распаўсюдзіць" + share_button: "Падзяліцца" reply_button: "Адказаць" diff --git a/config/locales/client.bg.yml b/config/locales/client.bg.yml index f121a3d606..3aeb4c0b3b 100644 --- a/config/locales/client.bg.yml +++ b/config/locales/client.bg.yml @@ -1955,6 +1955,7 @@ bg: changed: "променени тагове:" tags: "Тагове" choose_for_topic: "етикети по желание" + add_synonyms: "Добави" delete_tag: "Изтрийте таг" manage_groups_description: "Дефинирай групи за организране на етикетите" cancel_delete_unused: "Отмени" @@ -2791,18 +2792,13 @@ bg: category: "Пост в Категорията" add_host: "Добави Хост" settings: "Настройки за Ембедване" - feed_settings: "Feed настройки" - feed_description: "Чрез RSS/ATOM вие може да подобрите възможностите на Discourse да внедри вашето съдържание." crawling_settings: "Crawler настройки" crawling_description: "Когато Discourse създаде теми за вашите мнения и ако RSS / ATOM емисия не присъства, тя ще се опита да направи разбор на съдържанието от вашия HTML. Понякога това може да бъде предизвикателство за извличане на съдържанието ви, така че ние предоставяме възможността да се определи като CSS правила и да се направи екстракцията по-лесно." embed_by_username: "Потребител за създаване на темата" embed_post_limit: "Максимален брой публикации за вграждане." - embed_username_key_from_feed: "Ключ за изтегляне на discourse потребителя от feed-а" embed_truncate: "Изтрий вградените постове" embed_whitelist_selector: "Избор на CSS елементи които са разрешени за вграждане" embed_blacklist_selector: "Избор на CSS елементи които не са разрешени за вграждане" - feed_polling_enabled: "Внеси постове чреч RSS/ATOM" - feed_polling_url: "URL адрес на RSS/ATOM връзката" save: "Запамети настройките за вграждане" permalink: title: "Постоянни адреси" diff --git a/config/locales/client.bs_BA.yml b/config/locales/client.bs_BA.yml index 4cbce93054..505708d4d1 100644 --- a/config/locales/client.bs_BA.yml +++ b/config/locales/client.bs_BA.yml @@ -658,7 +658,7 @@ bs_BA: description: "Nećete biti obaviješteni o bilo kojoj poruci u ovoj grupi." flair_url: "Slika Avatara sposobnosti" flair_url_placeholder: "(Opciono) URL slike ili Font Awesome class" - flair_url_description: 'Koristite kvadratne slike ne manje od 20px od 20px ili FontAwesome ikone (prihvaćeni formati: "fa-icon", "far fa-icon" ili "fab fa-icon").' + flair_url_description: 'Koristite kvadratne slike ne manje od 20px od 20px ili FontAwesome ikone (prihvaćeni formati: "fa-icon", "far fa-icon" ili "fab fa-icon").' flair_bg_color: "Boja pozadine Slike Avatara sposobnosti" flair_bg_color_placeholder: "(Opciono) Hex broj boje" flair_color: "Boja Avatara sposobnosti" @@ -2437,7 +2437,6 @@ bs_BA: email_in_disabled: "Posting new topics via email is disabled in the Site Settings. To enable posting new topics via email, " email_in_disabled_click: 'enable the "email in" setting.' mailinglist_mirror: "Kategorija odražava mailing listu" - suppress_from_latest: "Suzite kategoriju od najnovijih tema." show_subcategory_list: "Prikaži listu podkategorija iznad tema u ovoj kategoriji." subcategory_num_featured_topics: "Broj istaknutih tema na stranici roditeljske kategorije:" all_topics_wiki: "Podrazumevano postavite nove teme" @@ -2812,6 +2811,7 @@ bs_BA: changed: "oznake promijenjene:" tags: "Oznake" choose_for_topic: "izborne oznake" + add_synonyms: "Dodaj" delete_tag: "Izbriši oznaku" delete_confirm: one: "Jeste li sigurni da želite izbrisati ovu oznaku i ukloniti je iz teme %{count} kojoj je dodijeljen?" @@ -2827,7 +2827,7 @@ bs_BA: manage_groups_description: "Definišite grupe za organizovanje oznaka" upload: "Prenesi oznake" upload_description: "Otpremite csv datoteku da biste grupisali oznake" - upload_instructions: "Jedan po liniji, opciono sa oznakom grupe u formatu 'tag_name, tag_group'." + upload_instructions: "Jedan po liniji, opciono sa oznakom grupe u formatu 'tag_name, tag_group'." upload_successful: "Oznake koje su uspješno prenesene" delete_unused_confirmation: one: "%{count} tag će biti izbrisan: %{tags}" @@ -3039,7 +3039,7 @@ bs_BA: delete: "Delete" delete_confirm: "Delete this group?" delete_failed: "Unable to delete group. If this is an automatic group, it cannot be destroyed." - delete_owner_confirm: "Ukloni privilegije vlasnika za '%{username}'?" + delete_owner_confirm: "Ukloni privilegije vlasnika za '%{username}'?" add: "Dodaj" custom: "Custom" automatic: "Automatsko" @@ -3190,7 +3190,7 @@ bs_BA: title: "Prenesite rezervnu kopiju na ovu instancu" uploading: "Uploading..." uploading_progress: "Snimanje ... {{progress}}% \\ t" - success: "'{{filename}}' je uspješno otpremljen. Datoteka se sada obrađuje i traje do jednog minuta da se pojavi na listi." + success: "'{{filename}}' je uspješno otpremljen. Datoteka se sada obrađuje i traje do jednog minuta da se pojavi na listi." error: "There has been an error while uploading '{{filename}}': {{message}}" operations: is_running: "An operation is currently running..." @@ -3249,7 +3249,7 @@ bs_BA: new_style: "New Style" install: "Instaliraj" delete: "Delete" - delete_confirm: 'Jeste li sigurni da želite izbrisati "%{theme_name}"?' + delete_confirm: 'Jeste li sigurni da želite izbrisati "%{theme_name}"?' color: "Color" opacity: "Opacity" copy: "Copy" @@ -3660,7 +3660,7 @@ bs_BA: filter: "Pretraži" roll_up: text: "Roll up" - title: "Stvara nove unose za zabranu podmreže ako postoje najmanje "min_ban_entries_for_roll_up" stavke." + title: "Stvara nove unose za zabranu podmreže ako postoje najmanje 'min_ban_entries_for_roll_up' stavke." search_logs: title: "Logovi pretrage" term: "Term" @@ -4104,15 +4104,12 @@ bs_BA: category: "Post u kategoriju" add_host: "Dodaj host" settings: "Postavke ugrađivanja" - feed_settings: "Postavke feeda" crawling_settings: "Postavke Crawlera" embed_title_scrubber: "Regular expression korišten za čistku naslova objava" embed_truncate: "Skrati embedovane objave" embed_whitelist_selector: "CSS selector za elemente koji su dozvoljeni u embeds" embed_blacklist_selector: "CSS selector za elemente koji su odstranjeni sa embeds" embed_classname_whitelist: "Dopuštena CSS class imena" - feed_polling_enabled: "Ubaci objave preko RSS/ATOM" - feed_polling_url: "URL ili RSS/ATOM feed za crawl" save: "Sačuvaj Embedding postavke" permalink: title: "Permalink-ovi" diff --git a/config/locales/client.ca.yml b/config/locales/client.ca.yml index ab9f00ae02..50f092f871 100644 --- a/config/locales/client.ca.yml +++ b/config/locales/client.ca.yml @@ -747,6 +747,7 @@ ca: collapse_profile: "Redueix" bookmarks: "Preferits" bio: "Quant a mi" + timezone: "Zona horària" invited_by: "Convidat per" trust_level: "Nivell de confiança" notifications: "Notificacions" @@ -950,6 +951,9 @@ ca: uploaded_avatar_empty: "Afegeix una foto personalitzada" upload_title: "Carrega la foto" image_is_not_a_square: "Atenció: hem retallat la vostra imatge; l'amplada i l'alçada no eren iguals." + change_profile_background: + title: "Capçalera de perfil" + instructions: "Les capçaleres de perfil estaran centrades i tindran una amplada predeterminada de 1110px." change_card_background: title: "Fons de la targeta d'usuari" instructions: "Les imatges de fons se centraran i tindran una amplada per defecte de 590px." @@ -1332,7 +1336,7 @@ ca: second_factor_backup_description: "Introduïu un dels vostres codis de còpia de seguretat:" second_factor: "Inici de sessió amb l’aplicació Authenticator" security_key_description: "Quan tingueu preparada la vostra clau de seguretat física, premeu el botó Autentica amb clau de seguretat." - security_key_alternative: "¿No podeu trobar la vostra clau de seguretat o voleu utilitzar un altre mètode?" + security_key_alternative: "Proveu d’una altra manera" security_key_authenticate: "Autenticació amb clau de seguretat" security_key_not_allowed_error: "El procés d'autenticació de claus de seguretat ha arribat al límit de temps o s'ha cancel·lat. " security_key_no_matching_credential_error: "No s'ha trobat cap credencial coincident amb la clau de seguretat proporcionada." @@ -1710,6 +1714,7 @@ ca: context: user: "Cerca publicacions de @{{username}}" category: "Cerca en la categoria #{{category}} " + tag: "Cerca l'etiqueta #{{tag}}" topic: "Cerca en aquest tema" private_messages: "Cerca missatges" advanced: @@ -2382,6 +2387,9 @@ ca: manage_tag_groups_link: "Gestioneu els grups d'etiquetes aquí." allow_global_tags_label: "Permet també altres etiquetes" tag_group_selector_placeholder: "(Opcional) Grup d’etiquetes" + required_tag_group_description: "Requereix que els temes nous tinguin etiquetes d’un grup d’etiquetes:" + min_tags_from_required_group_label: "Etiquetes num.:" + required_tag_group_label: "Grup d'etiquetes:" topic_featured_link_allowed: "Permet enllaços destacats dins aquesta categoria" delete: "Suprimeix categoria" create: "Nova categoria" @@ -2418,7 +2426,6 @@ ca: email_in_disabled: "Les publicacions des del correu electrònic estan desactivades en les preferències del lloc web. Per a activar les publicacions des del correu electrònic, " email_in_disabled_click: 'activa l''opció "email in".' mailinglist_mirror: "Una categoria reflecteix una llista de correu" - suppress_from_latest: "Suprimeix la categoria dels temes més recents." show_subcategory_list: "Mostra la llista de subcategories de temes en aquesta categoria. " num_featured_topics: "Nombre de temes mostrats en la pàgina de categories:" subcategory_num_featured_topics: "Nombre de temes destacats en la pàgina de la categoria primària:" @@ -2790,6 +2797,7 @@ ca: changed: "etiquetes canviades:" tags: "Etiquetes" choose_for_topic: "etiquetes opcionals" + add_synonyms: "Afegeix" delete_tag: "Suprimeix l'etiqueta" delete_confirm: one: "Esteu segur que voleu suprimir aquesta etiqueta i eliminar-la del tema %{count} al qual és assignada?" @@ -2847,6 +2855,7 @@ ca: parent_tag_description: "Les etiquetes d'aquest grup no es poden fer servir si no hi és l'etiqueta primària." one_per_topic_label: "Limita a una etiqueta per cada tema d'aquest grup" new_name: "Nou grup d'etiquetes" + name_placeholder: "Nom del grup d’etiquetes" save: "Desa" delete: "Suprimeix" confirm_delete: "Esteu segur que voleu suprimir aquest grup d'etiquetes?" @@ -3047,6 +3056,7 @@ ca: none: "No hi ha claus API actives ara mateix" user: "Usuari" title: "API" + key: "Clau" created: Creat updated: Actualitzat last_used: Darrer utilitzat @@ -3055,11 +3065,15 @@ ca: undo_revoke: "Desfés revocar" revoke: "Revoca" all_users: "Tots els usuaris" + active_keys: "Claus actives de l'API" show_details: Detalls description: Descripció no_description: (sense descripció) all_api_keys: Totes les claus de l'API user_mode: Nivell d’usuari + impersonate_all_users: Suplanta qualsevol usuari + single_user: "Usuari únic" + user_placeholder: Introduïu el nom d'usuari description_placeholder: "Per a què es farà servir aquesta clau?" save: Desa new_key: Nova clau d’API @@ -3646,6 +3660,9 @@ ca: change_theme_setting: "canvia la configuració de l'aparença" disable_theme_component: "inhabilita el component d'aparença" enable_theme_component: "habilita el component d'aparença" + revoke_title: "revoca el títol" + change_title: "canvia el títol" + api_key_create: "crea la clau API" screened_emails: title: "Correus sota supervisió" description: "Quan es prova de crear un compte nou, es revisaran les següents adreces i es blocarà el registre o es durà a terme alguna altra acció." @@ -4131,21 +4148,15 @@ ca: category: "Publica en la categoria" add_host: "Afegeix amfitrió" settings: "Configuració d'incrustacions" - feed_settings: "Configuració de sindicació" - feed_description: "Si proporcioneu una sindicació RSS/ATOM al vostre lloc web, millorareu la capacitat de Discourse per a importar els vostres continguts." crawling_settings: "Configuració del rastrejador" crawling_description: "Quan Discourse crea temes per a les vostres publicacions, si no hi ha sindicació RSS/ATOM, es provarà d'analitzar-ne els continguts a partir del vostre HTML. A vegades pot ser complicat extraure el vostre contingut, i per això oferim la possibilitat d'especificar regles CSS per a fer més fàcil l'extracció." embed_by_username: "Nom d'usuari per a crear un tema" embed_post_limit: "Nombre màxim de publicacions que s'incrusten " - embed_username_key_from_feed: "Clau per a llevar el nom d'usuari de Discourse de la sindicació" embed_title_scrubber: "Expressió regular emprada per a depurar el títol de les publicacions" embed_truncate: "Trunca les publicacions incrustades" embed_whitelist_selector: "Selector de CSS per a elements permesos en les incrustacions" embed_blacklist_selector: "Selector de CSS per a elements que han estat eliminats de les incrustacions" embed_classname_whitelist: "Noms permesos de classes de CSS" - feed_polling_enabled: "Importa publicacions amb RSS/ATOM" - feed_polling_url: "URL de la sindicació RSS/ATOM per a rastrejar" - feed_polling_frequency_mins: "Freqüència del mostratge del canal (en minuts)" save: "Desa la configuració d'incrustacions" permalink: title: "Enllaços permanents" diff --git a/config/locales/client.cs.yml b/config/locales/client.cs.yml index c987e863d9..0508ffe7c8 100644 --- a/config/locales/client.cs.yml +++ b/config/locales/client.cs.yml @@ -2197,7 +2197,6 @@ cs: email_in_disabled: "Přidávání nových témat před email je zakázáno v Nastavení fóra. K povolení nových témat přes email," email_in_disabled_click: 'povolit nastavení "email in"' mailinglist_mirror: "Kategorie kopíruje mailing list" - suppress_from_latest: "Potlač tuto kategorii na nejnovějších tématech." show_subcategory_list: "Ukázat seznam podkategorií nad tématy v této kategorii." num_featured_topics: "Počet témat, která se zobrazují na stránce kategorie " subcategory_num_featured_topics: "Počet zobrazených témat na stránce nadřazené kategorie:" @@ -2582,6 +2581,7 @@ cs: changed: "změněné štítky:" tags: "Štítky" choose_for_topic: "volitelné štítky" + add_synonyms: "Přidat" delete_tag: "Smaž štítek" delete_confirm: one: "Jsi si jist, že chceš smazat tento štítek a odstranit ho z tématu, kterému je přiřazen?" @@ -3687,21 +3687,15 @@ cs: category: "Příspěvek do kategorie" add_host: "Přidat host" settings: "Nastavení zabudování" - feed_settings: "Nastavení odebírání" - feed_description: "Poskytování RSS/ATOM zdroje pro vaše stránky pomůže zlepšit schopnost Discoursu importovat váš obsah." crawling_settings: "Nastavení procházení" crawling_description: " Discourse vytvoří téma pro tvůj příspěvek, pokud není k dispozici žádný RSS/ATOM zdroj, pokusí se o rozbor vašeho obsahu mimo vaše HTML. Občas může být náročné vybírat obsah, takže jsme schopní upřesnit CSS pravidla, abychom usnadnili vytažení." embed_by_username: "Uživatelské jméno pro vytvářéní témat" embed_post_limit: "Maximální počet příspěvků k zabudování" - embed_username_key_from_feed: "Klíč pro smazání uživatelského jména z feedu" embed_title_scrubber: "Běžný výraz užívaný k vyčištění názvů příspěvků" embed_truncate: "Useknout zabudované příspěvky" embed_whitelist_selector: "Volba CSS pro prvky, které jsou povoleny ve vložení." embed_blacklist_selector: "Volba CSS pro prvky, které jsou odstraněny z vložení." embed_classname_whitelist: "Povolená jména CSS tříd" - feed_polling_enabled: "Importovat příspěvky pomocí RSS/ATOM" - feed_polling_url: "URL adresa RSS/ATOM kanálu pro procházení" - feed_polling_frequency_mins: "Frekvece feed pollingu (v minutách)" save: "Uložit nastavení zabudování" permalink: title: "Trvalé odkazy" diff --git a/config/locales/client.da.yml b/config/locales/client.da.yml index b03690fb4e..cf68664385 100644 --- a/config/locales/client.da.yml +++ b/config/locales/client.da.yml @@ -745,6 +745,7 @@ da: collapse_profile: "Fald sammen" bookmarks: "Bogmærker" bio: "Om mig" + timezone: "Tidszone" invited_by: "Inviteret af" trust_level: "Tillidsniveau" notifications: "Underretninger" @@ -2384,7 +2385,6 @@ da: email_in_disabled: "Nye emner via email er deaktiveret i Site opsætning. For at aktivere oprettelse af nye emner via email," email_in_disabled_click: 'aktiver "email ind" indstilligen.' mailinglist_mirror: "Kategori spejler en mailing liste" - suppress_from_latest: "Undertryk kategori fra de seneste emner." show_subcategory_list: "Vis oversigt med subkategorier ovenover emner i denne kategori." num_featured_topics: "Antal emner som skal vises på siden med kategorier:" subcategory_num_featured_topics: "Antal af fremhævede emner på siden for den overordnede kategori:" @@ -2756,6 +2756,7 @@ da: changed: "tags skiftet:" tags: "Tags" choose_for_topic: "valgfri tags" + add_synonyms: "Tilføj" delete_tag: "Slet tag" delete_confirm: one: "Er du sikker på, at du vil slette dette tag og fjerne det fra et emne %{count}, der er tildelt det?" @@ -4069,21 +4070,15 @@ da: category: "Opret i kategorien" add_host: "Tilføj server" settings: "Indlejrings-indstillinger" - feed_settings: "Feed-indstillinger" - feed_description: "Hvis du angiver et RSS/ATOM-feed for dit site, kan det forbedre Discourses mulighed for at importere dit indhold." crawling_settings: "Robot-indstillinger" crawling_description: "Når Discourse opretter emner for dine indlæg, og der ikke er noget RSS/ATOM-feed, vil den forsøge at parse dit indhold ud fra din HTML. Det kan nogengange være en udfordring at udtrække dit indhold, så vi giver mulighed for at specificere CSS-regler for at gøre udtræk lettere." embed_by_username: "Brugernavn for oprettelse af emne" embed_post_limit: "Maksimalt antal indlæg der kan indlejres." - embed_username_key_from_feed: "Nøgle til at udtrække discourse-brugernavn fra feed" embed_title_scrubber: "Regular expression er brugt til at gøre titler pæne på indlæg" embed_truncate: "Beskær de indlejrede indlæg" embed_whitelist_selector: "CSS-selektorer for elementer der er tilladte i indlejringer" embed_blacklist_selector: "CSS-selektorer for elementer der fjernes fra indlejringer" embed_classname_whitelist: "Tillad CSS class names" - feed_polling_enabled: "Importer indlæg via RSS/ATOM" - feed_polling_url: "URL på RSS/ATOM feed der skal kravles" - feed_polling_frequency_mins: "Frekvens af 'feed polling' (i minutter)" save: "Gem indlejrings-indstillinger" permalink: title: "Permalinks" diff --git a/config/locales/client.de.yml b/config/locales/client.de.yml index fa1060f539..c04933ffed 100644 --- a/config/locales/client.de.yml +++ b/config/locales/client.de.yml @@ -747,6 +747,7 @@ de: collapse_profile: "Zuklappen" bookmarks: "Lesezeichen" bio: "Über mich" + timezone: "Zeitzone" invited_by: "Eingeladen von" trust_level: "Vertrauensstufe" notifications: "Benachrichtigungen" @@ -950,6 +951,9 @@ de: uploaded_avatar_empty: "Eigenes Bild hinzufügen" upload_title: "Lade dein Bild hoch" image_is_not_a_square: "Achtung: Wir haben dein Bild zugeschnitten, weil Höhe und Breite nicht übereingestimmt haben." + change_profile_background: + title: "Profil Kopfzeile" + instructions: "Profil Kopfzeilen werden zentriert und haben eine Standardbreite von 1110px." change_card_background: title: "Benutzerkarten-Hintergrund" instructions: "Hintergrundbilder werden zentriert und haben eine Standardbreite von 590px." @@ -1332,7 +1336,7 @@ de: second_factor_backup_description: "Bitte gib einen deiner Wiederherstellungs-Codes ein:" second_factor: "Anmeldung mit einer Authentifizierungs-App" security_key_description: "Wenn Du Deinen physischen Sicherheitsschlüssel vorbereitet hast, klicke unten auf die Schaltfläche \"Mit Sicherheitsschlüssel authentifizieren\"." - security_key_alternative: "Du kannst Deinen Sicherheitsschlüssel nicht finden oder möchtest eine andere Methode verwenden?" + security_key_alternative: "Versuche einen anderen Weg" security_key_authenticate: "Mit Sicherheitsschlüssel authentifizieren" security_key_not_allowed_error: "Der Authentifizierungsprozess für den Sicherheitsschlüssel ist abgelaufen oder wurde abgebrochen." security_key_no_matching_credential_error: "Im angegebenen Sicherheitsschlüssel wurden keine übereinstimmenden Anmeldeinformationen gefunden." @@ -1540,6 +1544,7 @@ de: link_description: "gib hier eine Link-Beschreibung ein" link_dialog_title: "Link einfügen" link_optional_text: "Optionaler Titel" + link_url_placeholder: "Füge eine URL ein oder tippe, um die Themen zu durchsuchen" quote_title: "Zitat" quote_text: "Zitat" code_title: "Vorformatierter Text" @@ -1629,6 +1634,7 @@ de: topic_reminder: "{{username}} {{description}}" watching_first_post: "New Topic {{description}}" membership_request_accepted: "Mitgliedschaft akzeptiert in '{{group_name}}' " + membership_request_consolidated: "{{count}} offene Gruppenmitgliedschaftsanfrage/n für die Gruppe '{{group_name}}'" group_message_summary: one: "{{count}} Nachricht in deinem {{group_name}} Posteingang" other: "{{count}} Nachrichten in deinem {{group_name}} Posteingang" @@ -1664,6 +1670,7 @@ de: topic_reminder: "Themen-Erinnerung" liked_consolidated: "neue „Gefällt mir“-Angaben" post_approved: "Beitrag genehmigt" + membership_request_consolidated: "Neue Gruppenmitgliedschaftsanfragen" upload_selector: title: "Ein Bild hinzufügen" title_with_attachments: "Ein Bild oder eine Datei hinzufügen" @@ -1709,6 +1716,7 @@ de: context: user: "Beiträge von @{{username}} durchsuchen" category: "Kategorie #{{category}} durchsuchen" + tag: "Den #{{tag}} tag suchen" topic: "Dieses Thema durchsuchen" private_messages: "Nachrichten durchsuchen" advanced: @@ -2231,8 +2239,10 @@ de: attachment_upload_not_allowed_for_new_user: "Entschuldige, neue Benutzer dürfen keine Dateien hochladen." attachment_download_requires_login: "Entschuldige, du musst angemeldet sein, um Dateien herunterladen zu können." abandon_edit: + confirm: "Sollen die Änderungen verworfen werden?" no_value: "Nein, behalten" no_save_draft: "Nein, speichere den Entwurf" + yes_value: "Ja, Änderungen verwerfen" abandon: confirm: "Möchtest du deinen Beitrag wirklich verwerfen?" no_value: "Nein, beibehalten" @@ -2375,9 +2385,14 @@ de: tags_allowed_tags: "Schlagwörter auf diese Kategorie einschränken:" tags_allowed_tag_groups: "Schlagwortgruppen auf diese Kategorie einschränken:" tags_placeholder: "(Optional) Liste erlaubter Schlagwörter" + tags_tab_description: "Die oben spezifizierten Tags und Tag-Gruppen werden nur in dieser Kategorie und anderen Kategorien, für die sie ebenfalls spezifiziert sind, verfügbar sein. Darüber hinaus werden sie nicht in weiteren Kategorien verwendbar sein." tag_groups_placeholder: "(Optional) Liste erlaubter Schlagwort-Gruppen" manage_tag_groups_link: "Verwalte hier die Schlagwort-Gruppen." allow_global_tags_label: "Erlaube auch andere Schlagwörter." + tag_group_selector_placeholder: "(Optional) Tag Gruppe" + required_tag_group_description: "Neue Themen müssen Tags von einer Tag Gruppe haben:" + min_tags_from_required_group_label: "Num Tags:" + required_tag_group_label: "Tag Gruppe:" topic_featured_link_allowed: "Erlaube hervorgehobene Links in dieser Kategorie" delete: "Kategorie löschen" create: "Neue Kategorie" @@ -2414,7 +2429,6 @@ de: email_in_disabled: "Das Erstellen von neuen Themen per E-Mail ist in den Website-Einstellungen deaktiviert. Um das Erstellen von neuen Themen per E-Mail zu erlauben," email_in_disabled_click: 'aktiviere die Einstellung „email in“.' mailinglist_mirror: "Kategorie spiegelt eine Mailingliste" - suppress_from_latest: "Unterdrücke die Kategorie bei den aktuellen Themen" show_subcategory_list: "Zeige Liste von Unterkategorien oberhalb von Themen in dieser Kategorie" num_featured_topics: "Anzahl der Themen, die auf der Kategorien-Seite angezeigt werden" subcategory_num_featured_topics: "Anzahl beworbener Themen, die auf der Seite der übergeordneten Kategorie angezeigt werden:" @@ -2782,11 +2796,30 @@ de: changed: "Geänderte Schlagwörter:" tags: "Schlagwörter" choose_for_topic: "optionale Schlagwörter" + info: "Info" + default_info: "Dieses Schlagwort ist nicht auf Kategorien beschränkt und hat keine Synonyme." + synonyms: "Synonyme" + synonyms_description: "Wenn die folgenden Schlagwörter verwendet werden, werden sie durch %{base_tag_name} ersetzt." + tag_groups_info: + one: 'Dieser Tag gehört zur Gruppe "{{tag_groups}}".' + other: "Dieses Schlagwort gehört zu diesen Gruppen: {{tag_groups}}." + category_restrictions: + one: "Es kann nur in dieser Kategorie verwendet werden:" + other: "Es kann nur in folgenden Kategorien verwendet werden:" + edit_synonyms: "Synonyme Verwalten" + add_synonyms_label: "Synonyme hinzufügen:" + add_synonyms: "Hinzufügen" + add_synonyms_failed: "Die folgenden Schlagwörter konnten nicht als Synonyme hinzugefügt werden: %{tag_names} . Stellen Sie sicher, dass sie keine Synonyme und keine Synonyme eines anderen Schlagwortes bereits haben." + remove_synonym: "Synonym entfernen" + delete_synonym_confirm: 'Bist du dir sicher das du das folgende Synonym entfernen möchtest "%{tag_name}" ?' delete_tag: "Schlagwört löschen" delete_confirm: one: "Bist du sicher, dass du dieses Schlagwort löschen und von einem zugeordneten Thema entfernen möchtest?" other: "Bist du sicher, dass du dieses Schlagwort löschen und von {{count}} zugeordneten Themen entfernen möchtest?" delete_confirm_no_topics: "Bist du sicher, dass du dieses Schlagwort löschen möchtest?" + delete_confirm_synonyms: + one: "Das Synonym wird ebenfalls gelöscht." + other: "Es werden {{count}} weitere Synonyme ebenfalls gelöscht." rename_tag: "Schlagwort umbenennen" rename_instructions: "Neuen Namen für das Schlagwort wählen:" sort_by: "Sortieren nach:" @@ -2839,6 +2872,7 @@ de: parent_tag_description: "Schlagwörter aus dieser Gruppe können nur verwendet werden, wenn das übergeordnete Schlagwort zugeordnet ist." one_per_topic_label: "Beschränke diese Gruppe auf ein Schlagwort pro Thema" new_name: "Neue Schlagwort-Gruppe" + name_placeholder: "Name der Tag Gruppe" save: "Speichern" delete: "Löschen" confirm_delete: "Möchtest du wirklich diese Schlagwort-Gruppe löschen?" @@ -3008,6 +3042,7 @@ de: membership: automatic: Automatisch trust_levels_title: "Vertrauensstufe, die neuen Mitgliedern automatisch verliehen wird:" + effects: Effekte trust_levels_none: "Keine" automatic_membership_email_domains: "Benutzer, deren E-Mail-Domain mit einem der folgenden Listeneinträge genau übereinstimmt, werden automatisch zu dieser Gruppe hinzugefügt:" automatic_membership_retroactive: "Diese Regel auch auf existierende Benutzer anwenden, um diese zur Gruppe hinzuzufügen." @@ -3038,14 +3073,30 @@ de: none: "Es gibt momentan keine aktiven API-Keys" user: "Benutzer" title: "API" + key: "Schlüssel" created: Erstellt updated: Aktualisiert + last_used: Zuletzt verwendet + never_used: (nie) generate: "Erzeugen" + undo_revoke: "Widerrufen zurückziehen" revoke: "Widerrufen" all_users: "Alle Benutzer" + active_keys: "Aktive API Schlüssel" + manage_keys: Schlüssel verwalten show_details: Details description: Beschreibung + no_description: (keine Beschreibung) + all_api_keys: Alle API Schlüssel + user_mode: Benutzerrang + impersonate_all_users: Als jeder Benutzer ausgeben + single_user: "Einzelbenutzer" + user_placeholder: Benutzernamen eingeben + description_placeholder: "Wofür wird dieser Schlüssel verwendet werden?" save: Speichern + new_key: Neuer API Schlüssel + revoked: Widerrufen + delete: Endgültig gelöscht web_hooks: title: "Webhooks" none: "Aktuell gibt es keine Webhooks." @@ -3288,6 +3339,7 @@ de: color_scheme_select: "Wähle Farben für dieses Theme" custom_sections: "Benutzerdefinierte Abschnitte:" theme_components: "Theme-Komponenten" + add_all_themes: "Alle Themen hinzufügen" convert: "Umwandeln" convert_component_alert: "Bist Du sicher, dass du diese Komponente in ein Theme umwandeln möchtest? Sie wird als Komponente entfernt von %{relatives}." convert_component_tooltip: "Wandle diese Komponente in ein Theme um" @@ -3320,6 +3372,9 @@ de: edit_css_html: "Bearbeite CSS/HTML" edit_css_html_help: "Du hast kein CSS oder HTML bearbeitet" delete_upload_confirm: "Upload löschen? (Theme-CSS funktioniert eventuell nicht mehr!)" + component_on_themes: "Komponente zu diesen Themen hinzufügen" + included_components: "Enthaltene Komponente" + add_all: "Alle hinzufügen" import_web_tip: "Repository mit dem Theme" import_web_advanced: "Erweitert..." import_file_tip: ".tar.gz, .zip, oder .dcstyle.json Datei, die ein Theme enthält" @@ -3628,6 +3683,12 @@ de: change_theme_setting: "Theme Einstellung ändern" disable_theme_component: "Theme-Komponente deaktivieren" enable_theme_component: "Theme-Komponente aktivieren" + revoke_title: "Titel widerrufen" + change_title: "Titel ändern" + api_key_create: "Api Schlüssel erstellen" + api_key_update: "Api Schlüssel aktualisieren" + api_key_destroy: "Api Schlüssel zerstören" + override_upload_secure_status: "Hochladesicherheitsstatus überschreiben" screened_emails: title: "Gefilterte E-Mails" description: "Wenn jemand ein Konto erstellt, werden die folgenden E-Mail-Adressen überprüft und es wird die Anmeldung blockiert oder eine andere Aktion ausgeführt." @@ -3825,7 +3886,7 @@ de: flags_given_count: Gemachte Meldungen flags_received_count: Erhaltene Meldungen warnings_received_count: Warnungen erhalten - flags_given_received_count: "Erhaltene / gemachte Meldungen" + flags_given_received_count: "Gemachte / erhaltene Meldungen" approve: "Genehmigen" approved_by: "genehmigt von" approve_success: "Benutzer wurde genehmigt und eine E-Mail mit Anweisungen zur Aktivierung wurde gesendet." @@ -4018,7 +4079,9 @@ de: secret_list: invalid_input: "Eingabefelder können nicht leer sein oder vertikale Balkenzeichen enthalten." default_categories: + modal_description: "Soll diese Änderung rückwirkend gelten? Das ändert die Voreinstellungen für %{count} bestehende Benutzer." modal_yes: "Ja" + modal_no: "Nein, die Änderung soll sich nur zukünftig auswirken" badges: title: Abzeichen new_badge: Neues Abzeichen @@ -4111,21 +4174,15 @@ de: category: "In Kategorie Beitrag schreiben" add_host: "Host hinzufügen" settings: "Einbettungseinstellungen" - feed_settings: "Feed-Einstellungen" - feed_description: "Wenn man RSS/ATOM Feeds für eine Website zur Verfügung stellt, können sich die Möglichkeiten des Imports verbessern. " crawling_settings: "Crawler-Einstellungen" crawling_description: "Wenn Discourse Themen für deine Beiträge erstellt wird es falls kein RSS/ATOM-Feed verfügbar ist versuchen, den Inhalt aus dem HTML-Code zu extrahieren. Dies ist teilweise schwierig, weshalb hier CSS-Regeln angegeben werden können, die die Extraktion erleichtern." embed_by_username: "Benutzername für Beitragserstellung" embed_post_limit: "Maximale Anzahl der Beiträge, welche eingebettet werden" - embed_username_key_from_feed: "Schlüssel, um Discourse-Benutzernamen aus Feed zu ermitteln." embed_title_scrubber: "Regulärer Ausdruck (Regex) um den Titel eines Beitrags zu bereinigen" embed_truncate: "Kürze die eingebetteten Beiträge" embed_whitelist_selector: "CSS Selektor für Elemente, die in Einbettungen erlaubt sind." embed_blacklist_selector: "CSS Selektor für Elemente, die in Einbettungen entfernt werden." embed_classname_whitelist: "Erlaubte CSS-Klassen" - feed_polling_enabled: "Beiträge über RSS/ATOM importieren" - feed_polling_url: "URL des RSS/ATOM Feeds für den Import" - feed_polling_frequency_mins: "Häufigkeit der Feed-Abfrage (in Minuten)" save: "Einbettungseinstellungen speichern" permalink: title: "Permanentlinks" diff --git a/config/locales/client.el.yml b/config/locales/client.el.yml index fc302bd0e3..f4d9a2e657 100644 --- a/config/locales/client.el.yml +++ b/config/locales/client.el.yml @@ -2144,6 +2144,7 @@ el: selector_no_tags: "καμία ετικέτα" changed: "αλλαγμένες ετικέτες:" tags: "Ετικέτες" + add_synonyms: "Προσθήκη" delete_tag: "Αφαίρεση Ετικέτας" delete_confirm: one: "Είσαι βέβαιος πως θέλεις να διαγράψεις αυτήν την ετικέτα και να την αφαιρέσεις από το %{count} νήμα στο οποίο είναι προσαρτημένη;" @@ -3163,21 +3164,15 @@ el: category: "Ανάρτηση στην Κατηγορία" add_host: "Προσθήκη host" settings: "Ρυθμίσεις Ενσωμάτωσης" - feed_settings: "Ρυθμίσεις Τροφοδοσίας" - feed_description: "Με το να συμπεριλαμβάνετε ένα RSS/ATOM feed για τον ιστότοπό σας βελτιώνετε την δυνατότητα του Discourse να εισάγει τα περιεχόμενά σας. " crawling_settings: "Ρυθμίσεις Crawler " crawling_description: "Όταν η ιστοσελίδα δημιουργεί νήματα για τις αναρτήσεις σου, άν δεν υπάρχει RSS/ATOM feed θα προσπαθήσει να αναλύσει το περιεχόμενο του HTML σου. Κάποιες φορές μπορεί να αποτελεί πρόκληση το να εξάγει το περιεχόμενο σου, έτσι σου παρέχουμε την ικανότητα να προσδιορίσεις τα CSS rules για να κάνεις την εξαγωγή ευκολότερη." embed_by_username: "Όνομα χρήστη για δημιουργία νήματος" embed_post_limit: "Μέγιστος αριθμός αναρτήσεων για συγχώνευση" - embed_username_key_from_feed: "Κλειδί για λήψη του ονόματος χρήστη από το feed" embed_title_scrubber: "Regular expression που χρησιμοποιείται για την λήψη των τίτλων των αναρτήσεων" embed_truncate: "Truncate the embedded posts" embed_whitelist_selector: "CSS selector για στοιχεία που επιτρέπονται στα συγχωνευμένα" embed_blacklist_selector: "CSS selector για στοιχεία που έχουν απομακρυνθεί από τα συγχωνευμένα" embed_classname_whitelist: "Επιτρεπόμενα CSS class names" - feed_polling_enabled: "Εισαγωγή αναρτήσεων μέσω RSS/ATOM" - feed_polling_url: "URL of RSS/ATOM feed to crawl" - feed_polling_frequency_mins: "Συχνότητα του feed polling (σε λεπτά)" save: "Αποθήκευση των ρυθμίσεων Embedding " permalink: title: "Permalinks" diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 125fae8223..8174e4aa63 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -821,6 +821,7 @@ en: collapse_profile: "Collapse" bookmarks: "Bookmarks" bio: "About me" + timezone: "Timezone" invited_by: "Invited By" trust_level: "Trust Level" notifications: "Notifications" @@ -1461,7 +1462,7 @@ en: second_factor_backup_description: "Please enter one of your backup codes:" second_factor: "Log in using Authenticator app" security_key_description: "When you have your physical security key prepared press the Authenticate with Security Key button below." - security_key_alternative: "Can't find your security key or want to use another method?" + security_key_alternative: "Try another way" security_key_authenticate: "Authenticate with Security Key" security_key_not_allowed_error: "The security key authentication process either timed out or was cancelled." security_key_no_matching_credential_error: "No matching credentials could be found in the provided security key." @@ -1781,6 +1782,7 @@ en: topic_reminder: "{{username}} {{description}}" watching_first_post: "New Topic {{description}}" membership_request_accepted: "Membership accepted in '{{group_name}}'" + membership_request_consolidated: "{{count}} open membership requests for '{{group_name}}'" group_message_summary: one: "{{count}} message in your {{group_name}} inbox" @@ -1819,6 +1821,7 @@ en: topic_reminder: "topic reminder" liked_consolidated: "new likes" post_approved: "post approved" + membership_request_consolidated: "new membership requests" upload_selector: title: "Add an image" @@ -1867,6 +1870,7 @@ en: context: user: "Search posts by @{{username}}" category: "Search the #{{category}} category" + tag: "Search the #{{tag}} tag" topic: "Search this topic" private_messages: "Search messages" @@ -2650,7 +2654,6 @@ en: email_in_disabled: "Posting new topics via email is disabled in the Site Settings. To enable posting new topics via email, " email_in_disabled_click: 'enable the "email in" setting.' mailinglist_mirror: "Category mirrors a mailing list" - suppress_from_latest: "Suppress category from latest topics." show_subcategory_list: "Show subcategory list above topics in this category." num_featured_topics: "Number of topics shown on the categories page:" subcategory_num_featured_topics: "Number of featured topics on parent category's page:" @@ -3040,11 +3043,30 @@ en: changed: "tags changed:" tags: "Tags" choose_for_topic: "optional tags" + info: "Info" + default_info: "This tag isn't restricted to any categories, and has no synonyms." + synonyms: "Synonyms" + synonyms_description: "When the following tags are used, they will be replaced with %{base_tag_name}." + tag_groups_info: + one: 'This tag belongs to the group "{{tag_groups}}".' + other: "This tag belongs to these groups: {{tag_groups}}." + category_restrictions: + one: "It can only be used in this category:" + other: "It can only be used in these categories:" + edit_synonyms: "Manage Synonyms" + add_synonyms_label: "Add synonyms:" + add_synonyms: "Add" + add_synonyms_failed: "The following tags couldn't be added as synonyms: %{tag_names}. Ensure they don't have synonyms and aren't synonyms of another tag." + remove_synonym: "Remove Synonym" + delete_synonym_confirm: 'Are you sure you want to delete the synonym "%{tag_name}"?' delete_tag: "Delete Tag" delete_confirm: one: "Are you sure you want to delete this tag and remove it from %{count} topic it is assigned to?" other: "Are you sure you want to delete this tag and remove it from {{count}} topics it is assigned to?" delete_confirm_no_topics: "Are you sure you want to delete this tag?" + delete_confirm_synonyms: + one: "Its synonym will also be deleted." + other: "Its {{count}} synonyms will also be deleted." rename_tag: "Rename Tag" rename_instructions: "Choose a new name for the tag:" sort_by: "Sort by:" @@ -3587,6 +3609,7 @@ en: color_scheme_select: "Select colors to be used by theme" custom_sections: "Custom sections:" theme_components: "Theme Components" + add_all_themes: "Add all themes" convert: "Convert" convert_component_alert: "Are you sure you want to convert this component to theme? It will be removed as a component from %{relatives}." convert_component_tooltip: "Convert this component to theme" @@ -3619,6 +3642,9 @@ en: edit_css_html: "Edit CSS/HTML" edit_css_html_help: "You have not edited any CSS or HTML" delete_upload_confirm: "Delete this upload? (Theme CSS may stop working!)" + component_on_themes: "Include component on these themes" + included_components: "Included components" + add_all: "Add all" import_web_tip: "Repository containing theme" import_web_advanced: "Advanced..." import_file_tip: ".tar.gz, .zip, or .dcstyle.json file containing theme" @@ -3930,9 +3956,12 @@ en: change_theme_setting: "change theme setting" disable_theme_component: "disable theme component" enable_theme_component: "enable theme component" + revoke_title: "revoke title" + change_title: "change title" api_key_create: "api key create" api_key_update: "api key update" api_key_destroy: "api key destroy" + override_upload_secure_status: "override upload secure status" screened_emails: title: "Screened Emails" description: "When someone tries to create a new account, the following email addresses will be checked and the registration will be blocked, or some other action performed." @@ -4432,22 +4461,16 @@ en: category: "Post to Category" add_host: "Add Host" settings: "Embedding Settings" - feed_settings: "Feed Settings" - feed_description: "Providing an RSS/ATOM feed for your site can improve Discourse's ability to import your content." crawling_settings: "Crawler Settings" crawling_description: "When Discourse creates topics for your posts, if no RSS/ATOM feed is present it will attempt to parse your content out of your HTML. Sometimes it can be challenging to extract your content, so we provide the ability to specify CSS rules to make extraction easier." embed_by_username: "Username for topic creation" embed_post_limit: "Maximum number of posts to embed" - embed_username_key_from_feed: "Key to pull discourse username from feed" embed_title_scrubber: "Regular expression used to scrub the title of posts" embed_truncate: "Truncate the embedded posts" embed_whitelist_selector: "CSS selector for elements that are allowed in embeds" embed_blacklist_selector: "CSS selector for elements that are removed from embeds" embed_classname_whitelist: "Allowed CSS class names" - feed_polling_enabled: "Import posts via RSS/ATOM" - feed_polling_url: "URL of RSS/ATOM feed to crawl" - feed_polling_frequency_mins: "Frequency of feed polling (in minutes)" save: "Save Embedding Settings" permalink: diff --git a/config/locales/client.es.yml b/config/locales/client.es.yml index d3508bdb2f..3c492577cc 100644 --- a/config/locales/client.es.yml +++ b/config/locales/client.es.yml @@ -747,6 +747,7 @@ es: collapse_profile: "Contraer" bookmarks: "Marcadores" bio: "Acerca de mí" + timezone: "Zona horaria" invited_by: "Invitado por" trust_level: "Nivel de confianza" notifications: "Notificaciones" @@ -951,7 +952,7 @@ es: upload_title: "Sube tu foto" image_is_not_a_square: "Advertencia: hemos recortado tu imagen porque la anchura y la altura no eran iguales." change_profile_background: - title: "Encabezado del perfil" + title: "Encabezado de perfil" instructions: "Por defecto, los encabezados de perfil estarán centrados y tendrán una anchura de 1110px." change_card_background: title: "Fondo de tarjeta de usuario" @@ -1335,7 +1336,7 @@ es: second_factor_backup_description: "Por favor, ingresa uno de los códigos de respaldo:" second_factor: "Iniciar sesión utilizando la app Authenticator" security_key_description: "Cuando tengas tu clave de seguridad física preparada, presiona el botón de autenticar con clave de seguridad que se encuentra debajo." - security_key_alternative: "¿No encuentras tu clave de seguridad o quieres utilizar otro método?" + security_key_alternative: "Intenta de otra manera" security_key_authenticate: "Autenticar con clave de seguridad" security_key_not_allowed_error: "La autenticación de la clave de seguridad fue cancelada o se agotó el tiempo." security_key_no_matching_credential_error: "No se encontraron credenciales que coincidan en la clave de seguridad provista." @@ -1633,6 +1634,7 @@ es: topic_reminder: "{{username}} {{description}}" watching_first_post: "Nuevo tema {{description}}" membership_request_accepted: "Membresía aceptada en «{{group_name}}»" + membership_request_consolidated: "{{count}} solicitudes de membresía abiertas para '{{group_name}}'" group_message_summary: one: "{{count}} mensaje en tu bandeja de {{group_name}}" other: "{{count}} mensajes en tu bandeja de {{group_name}} " @@ -1668,6 +1670,7 @@ es: topic_reminder: "recordatorio de tema" liked_consolidated: "nuevos me gusta" post_approved: "publicación aprobada" + membership_request_consolidated: "nuevas solicitudes de membresía" upload_selector: title: "Agregar imagen" title_with_attachments: "Agregar una imagen o archivo" @@ -1713,6 +1716,7 @@ es: context: user: "Buscar publicaciones de @{{username}}" category: "Buscar la categoría #{{category}}" + tag: "Buscar la etiqueta #{{tag}} " topic: "Buscar en este tema" private_messages: "Buscar en mensajes" advanced: @@ -2381,12 +2385,12 @@ es: tags_allowed_tags: "Restringir estas etiquetas a esta categoría:" tags_allowed_tag_groups: "Restringir estos grupos de etiquetas a esta categoría:" tags_placeholder: "(Opcional) lista de etiquetas permitidas" - tags_tab_description: "Las etiquetas y grupos de etiquetas arriba especificadas solo estarán disponibles en esta categoría y en las otras categorías que igualmente lo especifiquen. No se permitirá su uso en otras categorías." + tags_tab_description: "Las etiquetas y grupos de etiquetas arriba especificados solo estarán disponibles en esta categoría y en las otras categorías que igualmente lo especifiquen. No se permitirá su uso en otras categorías." tag_groups_placeholder: "(Opcional) lista de grupos de etiquetas permitidos" manage_tag_groups_link: "Gestiona los grupos de etiquetas aquí." allow_global_tags_label: "Permitir también otras etiquetas" tag_group_selector_placeholder: "(Opcional) Grupo de etiquetas" - required_tag_group_description: "Requiere temas nuevos para tener etiquetas de un grupo de etiquetas:" + required_tag_group_description: "Requerir que los nuevos temas tengan etiquetas de un grupo de etiquetas:" min_tags_from_required_group_label: "Número de etiquetas:" required_tag_group_label: "Grupo de etiquetas:" topic_featured_link_allowed: "Permitir enlaces destacados en esta categoría" @@ -2425,7 +2429,6 @@ es: email_in_disabled: "La posibilidad de publicar temas nuevos por correo electrónico está deshabilitada en los ajustes del sitio. Para habilitar la publicación de temas nuevos por correo electrónico," email_in_disabled_click: 'activa la opción «correo electrónico»' mailinglist_mirror: "La categoría es el reflejo de una lista de correo" - suppress_from_latest: "Ocultar la categoría de la lista de últimos temas." show_subcategory_list: "Mostrar la lista de subcategorías arriba de la lista de temas en esta categoría." num_featured_topics: "Número de temas que se muestran en la página de categorías:" subcategory_num_featured_topics: "Número de temas destacados a mostrar en la página principal de categorías:" @@ -2797,6 +2800,7 @@ es: changed: "etiquetas cambiadas:" tags: "Etiquetas" choose_for_topic: "etiquetas opcionales" + add_synonyms: "Agregar" delete_tag: "Eliminar etiqueta" delete_confirm: one: "¿Estás seguro de querer borrar esta etiqueta y eliminarla de %{count} tema asignado?" @@ -2854,7 +2858,7 @@ es: parent_tag_description: "Las etiquetas de este grupo no se pueden utilizar a menos que la etiqueta primaria esté presente. " one_per_topic_label: "Limitar las etiquetas de este grupo a utilizarse solo una vez por tema" new_name: "Nuevo grupo de etiquetas" - name_placeholder: "Nombre del grupo de etiquetas:" + name_placeholder: "Nombre del grupo de etiquetas" save: "Guardar" delete: "Eliminar" confirm_delete: "¿Estás seguro de que quieres eliminar este grupo de etiquetas?" @@ -3055,16 +3059,30 @@ es: none: "No hay ninguna clave de API activa en este momento." user: "Usuario" title: "API" + key: "Clave" created: Creado updated: Actualizado last_used: Usada por última vez never_used: (nunca) generate: "Generar clave de API" + undo_revoke: "Deshacer revocación" revoke: "Revocar" all_users: "Todos los usuarios" + active_keys: "Claves de API activas" + manage_keys: Gestionar clave show_details: Detalles description: Descripción + no_description: (sin descripción) + all_api_keys: Todas las claves de API + user_mode: Nivel de usuario + impersonate_all_users: Suplantar cualquier usuario + single_user: "Un usuario" + user_placeholder: Introducir nombre de usuario + description_placeholder: "¿Para qué se usará esta clave?" save: Guardar + new_key: Nueva clave de API + revoked: Revocada + delete: Eliminar permanentemente web_hooks: title: "Webhooks" none: "Ahora mismo no hay webhooks." @@ -3307,6 +3325,7 @@ es: color_scheme_select: "Selecciona colores para usar en el tema" custom_sections: "Secciones personalizadas:" theme_components: "Componentes del tema" + add_all_themes: "Agregar todos los temas" convert: "Convertir" convert_component_alert: "¿Estás seguro de que quieres convertir este componente en tema? Se eliminará como componente en %{relatives}." convert_component_tooltip: "Convertir este componente en tema" @@ -3339,6 +3358,7 @@ es: edit_css_html: "Editar CSS/HTML" edit_css_html_help: "No has editado ningún CSS o HTML" delete_upload_confirm: "¿Eliminar este archivo? (¡El tema CSS puede dejar de funcionar!)" + component_on_themes: "Incluir componentes en estos temas" import_web_tip: "Repositorio que contiene el tema" import_web_advanced: "Avanzado..." import_file_tip: "archivo .tar.gz, .zip o .dcstyle.json que contiene un tema" @@ -3647,6 +3667,12 @@ es: change_theme_setting: "cambiar la configuración del tema" disable_theme_component: "deshabilitar componente de tema" enable_theme_component: "habilitar componente de tema" + revoke_title: "revocar título" + change_title: "cambiar título" + api_key_create: "crear clave API" + api_key_update: "actualizar clave API" + api_key_destroy: "destruir clave API" + override_upload_secure_status: "sobrescribir estado seguro de la subida" screened_emails: title: "Correos bloqueados" description: "Cuando alguien trate de crear una cuenta nueva, los siguientes correos se revisarán y el registro se bloqueará, o se realizará alguna otra acción." @@ -4132,21 +4158,15 @@ es: category: "Publicar en categoría" add_host: "Añadir host" settings: "Ajustes de insertado" - feed_settings: "Ajustes de feed" - feed_description: "Discourse podrá importar tu contenido más facilmente si proporcionas un feed RSS/ATOM de tu sitio." crawling_settings: "Ajustes de crawlers" crawling_description: "Cuando Discourse crea temas para tus publicaciones, si no hay un feed RSS/ATOM presente intentará analizar el contenido de tu HTML. A veces puede ser difícil extraer tu contenido, por lo que damos la posibilidad de especificar las reglas CSS para hacer la extracción más fácil." embed_by_username: "Usuario para la creación de temas" embed_post_limit: "Número máximo de publicaciones que se pueden insertar" - embed_username_key_from_feed: "Clave para extraer usuario de discourse del feed" embed_title_scrubber: "Expresión regular utilizada para depurar el título de las publicaciones" embed_truncate: "Truncar las publicaciones insertadas" embed_whitelist_selector: "Selector CSS para elementos permitidos en los insertados" embed_blacklist_selector: "Selector CSS para los elementos que se eliminan de los insertados" embed_classname_whitelist: "Clases CSS permitidas" - feed_polling_enabled: "Importar publicaciones mediante RSS/ATOM" - feed_polling_url: "URL del feed RSS/ATOM para explorar datos" - feed_polling_frequency_mins: "Frecuencia del feed de la encuesta (en minutos)" save: "Guardar ajustes de insertado" permalink: title: "Enlaces permanentes" diff --git a/config/locales/client.et.yml b/config/locales/client.et.yml index 56db2d5300..7eff5cf5c8 100644 --- a/config/locales/client.et.yml +++ b/config/locales/client.et.yml @@ -2208,6 +2208,7 @@ et: changed: "muudetud sildid:" tags: "Sildid" choose_for_topic: "valikulised sildid" + add_synonyms: "Lisa" delete_tag: "Kustuta silt" rename_tag: "Nimeta silt ümber" rename_instructions: "Vali sildile uus nimi:" @@ -3212,20 +3213,15 @@ et: category: "Postita foorumisse" add_host: "Lisa host" settings: "Sängitamise sätted" - feed_settings: "Voo sätted" - feed_description: "Oma saidi RSS/ATOM voo ühendamine võib parendada Discourse'i võimet Sinu saidi sisu importida." crawling_settings: "Ämbliku sätted" crawling_description: "Kui Discourse loob Sinu postitustele teemasid ja RSS/ATOM voogu ei ole, üritab ta sisu Sinu HTML-st välja sõeluda. Mõnikord on sisu eraldamine raskendatud, mistõttu pakume sisu eraldamise hõlbustamiseks võimalust CSS-i reeglid ette anda." embed_by_username: "Kasutajanimi teema loomiseks" embed_post_limit: "Maksimaalne postituste arv, mida sängitada" - embed_username_key_from_feed: "Võti discourse kasutajanime eraldamiseks voost" embed_title_scrubber: "Regex postituste päiste puhastamiseks" embed_truncate: "Lühenda sängitatud postitused" embed_whitelist_selector: "CSS valik elementidele, mida lubada sängitamistes" embed_blacklist_selector: "CSS valik elementidele, mida eemaldada sängitamistes" embed_classname_whitelist: "CSS klasside lubatud nimed" - feed_polling_enabled: "Impordi postitused RSS/ATOM'i kaudu" - feed_polling_url: "URL või RSS/ATOM voog, mida kududa" save: "Salvesta sängitamise sätted" permalink: title: "Püsiviited" diff --git a/config/locales/client.fa_IR.yml b/config/locales/client.fa_IR.yml index c1be6cf0e9..8ca02b06fe 100644 --- a/config/locales/client.fa_IR.yml +++ b/config/locales/client.fa_IR.yml @@ -1310,7 +1310,6 @@ fa_IR: second_factor_backup: "ورود با استفاده از یک کد پشتیبان" second_factor_backup_title: "پشتیبان دو عامله" second_factor_backup_description: "لطفا یکی از کدهای پشتیبان را وارد کنید:" - security_key_alternative: "نمی توانید کلید امنیتی خود را پیدا کنید یا می خواهید از روش دیگری استفاده کنید؟" security_key_authenticate: "تأیید اعتبار با کلید امنیتی" security_key_not_allowed_error: "روند تأیید اعتبار کلید امنیتی به پایان رسیده است یا لغو شده است." email_placeholder: "ایمیل یا نام‌کاربری" @@ -2705,6 +2704,7 @@ fa_IR: changed: "برچسب‌های تغییر یافته:" tags: "برچسب‌ها" choose_for_topic: "برچسب‌های اختیاری" + add_synonyms: "افزودن" delete_tag: "حذف برچسب" delete_confirm_no_topics: "ایا از حذف این برچسب مطمعن هستید؟" rename_tag: "تغییر نام برچسب" @@ -3831,21 +3831,15 @@ fa_IR: category: "ارسال به دسته بندی" add_host: "اضافه کردن میزبان" settings: "تنظیمات جاسازی" - feed_settings: "تنظیمات خوراک" - feed_description: "اضافه کردن یک خوراک RSS/ATOM به وبسایت باعث افزایش توانایی Discourse برای وارد کردن محتوای شما میشود." crawling_settings: "تنظیمات خزنده" crawling_description: "وقتی که Discourse موضوعاتی برای ارسال های شما ایجاد میکند, اگر هیچ خوراک RSS/ATOM موجود نبود سعی میکند که محتوای شما را از HTML تان تجزیه کند. گاهی اوقات استخراج محتوای شما سخت است, برای همین ما قابلیت تعیین قوانین CSS را میدهیم که استخراج را آسان تر میکند." embed_by_username: "نام‌کاربری برای ایجاد موضوع" embed_post_limit: "حداکثر تعداد نوشته‌هایی که میتوان جاساز کرد" - embed_username_key_from_feed: "کلیدی برای کشیدن نام کاربری Discourse از خوراک" embed_title_scrubber: "regular expression‌ی که برای بهینه‌سازی عنوان نوشته استفاده می‌شود" embed_truncate: "کوتاه کردن نوشته های جاسازی شده" embed_whitelist_selector: "انتخاب کننده CSS برای المان هایی که اجازه دارند جاسازی شوند" embed_blacklist_selector: "انتخاب کننده CSS برای المان هایی که از جاسازی پاک شده اند" embed_classname_whitelist: " دسترسی به کلاس های CSS" - feed_polling_enabled: "وارد کردن نوشته‌ها توسط RSS/ATOM" - feed_polling_url: " پیوند خوراک RSS/ATOM برای خزیدن" - feed_polling_frequency_mins: "تعداد دفعات رای گیری خوراک (به دقیقه)" save: "ذخیره تنظیمات کدهای جاساز" permalink: title: " پیوند دائمی" diff --git a/config/locales/client.fi.yml b/config/locales/client.fi.yml index 631a7a9e7f..fa665dfbaf 100644 --- a/config/locales/client.fi.yml +++ b/config/locales/client.fi.yml @@ -747,6 +747,7 @@ fi: collapse_profile: "Supista" bookmarks: "Kirjanmerkit" bio: "Tietoa minusta" + timezone: "Aikavyöhyke" invited_by: "Kutsuja" trust_level: "Luottamustaso" notifications: "Ilmoitukset" @@ -950,6 +951,9 @@ fi: uploaded_avatar_empty: "Lisää oma kuva" upload_title: "Lataa oma kuva" image_is_not_a_square: "Varoitus: olemme rajanneet kuvaasti; korkeus ja leveys eivät olleet samoja" + change_profile_background: + title: "Profiilin yläkuva" + instructions: "Profiilin yläkuva keskitetään ja sen oletusleveys on 1110 px." change_card_background: title: "Käyttäjäkortin taustakuva" instructions: "Taustakuvan leveys on 590 pikseliä." @@ -1331,7 +1335,7 @@ fi: second_factor_backup_description: "Syötä yksi varakoodeistasi:" second_factor: "Kirjaudu tunnistautumissovelluksella" security_key_description: "Kun fyysinen tunnistautumislaite on kätesi ulottuvilla, klikkaa alla olevaa \"Tunnistaudu tunnistautumislaitteella\" -painiketta." - security_key_alternative: "Et löydä tunnistautumislaitetta tai haluat käyttää muuta tapaa?" + security_key_alternative: "Kokeile muuta tapaa" security_key_authenticate: "Tunnistaudu tunnistautumislaitteen avulla" security_key_not_allowed_error: "Tunnistaumislaitteella tunnistautumisprosessi joko vanheni tai peruutettiin." security_key_no_matching_credential_error: "Tunnistautumislaitteelta ei löytynyt kelpaavia pääsytietoja." @@ -1709,6 +1713,7 @@ fi: context: user: "Etsi @{{username}} viestejä" category: "Etsi alueelta #{{category}}" + tag: "Hae tunnistetta #{{tag}}" topic: "Etsi tästä ketjusta" private_messages: "Etsi viesteistä" advanced: @@ -2124,10 +2129,10 @@ fi: one: "Valitse ketju, johon haluat siirtää valitun viestin." other: "Valitse ketju, johon haluat siirtää valitut {{count}} viestiä." move_to_new_message: - title: "Siirrä uuteen keskusteluun" - action: "siirrä uuteen keskusteluun" + title: "Siirrä uuteen yksityiskeskusteluun" + action: "siirrä uuteen yksityiskeskusteluun" message_title: "Uuden keskustelun otsikko" - radio_label: "Uusi keskustelu" + radio_label: "Uusi yksityiskeskustelu" participants: "Osallistujat" instructions: one: "Olet luomassa uutta keskustelua, jossa olisi viesti, jonka olet valinnut." @@ -2377,9 +2382,14 @@ fi: tags_allowed_tags: "Rajaa nämä tunnisteet tälle alueelle:" tags_allowed_tag_groups: "Rajaa nämä tunnisteryhmät tälle alueelle:" tags_placeholder: "(Ei-pakollinen) lista sallituista tunnisteista" + tags_tab_description: "Yllä määritellyt tunnisteet ja tunnisteryhmät ovat käytössä vain tällä alueella sekä muilla alueilla, jotka samalla tapaa ovat ne määritelleet. Muilla alueilla ne eivät ole käytettävissä." tag_groups_placeholder: "(Ei-pakollinen) lista sallituista tunnisteryhmistä" manage_tag_groups_link: "Hallitse tunnisteryhmiä tästä." allow_global_tags_label: "Salli myös muut tunnisteet" + tag_group_selector_placeholder: "(Ei-pakollinen) tunnisteryhmä" + required_tag_group_description: "Edellytä, että uudella ketjulla on tunnisteita tunnisteryhmästä:" + min_tags_from_required_group_label: "Tunnisteiden määrä:" + required_tag_group_label: "Tunnisteryhmä:" topic_featured_link_allowed: "Salli ketjulinkit tällä alueella" delete: "Poista alue" create: "Uusi alue" @@ -2416,7 +2426,6 @@ fi: email_in_disabled: "Uusien ketjujen aloittaminen sähköpostitse on otettu pois käytöstä sivuston asetuksissa. Salliaksesi uusien ketjujen luomisen sähköpostilla, " email_in_disabled_click: 'ota käyttöön "email in" asetus.' mailinglist_mirror: "Alue jäljittelee postituslistaa" - suppress_from_latest: "Älä näytä alueen ketjuja Tuoreimmat-näkymässä" show_subcategory_list: "Näytä lista tytäralueista ketjujen yläpuolella tällä alueella." num_featured_topics: "Kuinka monta ketjua näytetään Keskustelualueet-sivulla:" subcategory_num_featured_topics: "Kuinka monta ketjua näytetään emoalueen sivulla:" @@ -2788,6 +2797,7 @@ fi: changed: "muutetut tunnisteet" tags: "Tunnisteet" choose_for_topic: "ei-pakolliset tunnisteet" + add_synonyms: "Lisää" delete_tag: "Poista tunniste" delete_confirm: one: "Haluatko varmasti poistaa tunnisteen, mikä poistaa sen myös yhdeltä ketjulta, jolla tunniste on?" @@ -2845,6 +2855,7 @@ fi: parent_tag_description: "Tämän ryhmän tunnisteita voi käyttää vain, jos emotunniste on asetettu" one_per_topic_label: "Rajoita tästä ryhmästä yhteen tunnisteeseen per ketju" new_name: "Uusi ryhmä tunnisteita" + name_placeholder: "Tunnisteryhmän nimi" save: "Tallenna" delete: "Poista" confirm_delete: "Oletko varma, että haluat poistaa tämän tunnisteryhmän?" @@ -3045,15 +3056,30 @@ fi: none: "Aktiivisia API avaimia ei ole määritelty." user: "Käyttäjä" title: "Rajapinta" + key: "Avain" created: Luotu + updated: Päivitetty last_used: Viimeksi käytetty never_used: (ei koskaan) generate: "Luo" + undo_revoke: "Kumoa jäädytys" revoke: "Peruuta" all_users: "Kaikki käyttäjät" + active_keys: "Aktiiviset rajapinta-avaimet" + manage_keys: Hallinnoi avaimia show_details: Yksityiskohdat description: Kuvaus + no_description: (ei kuvausta) + all_api_keys: Kaikki rajapinta-avaimet + user_mode: Käyttäjän taso + impersonate_all_users: Esiinny minä käyttäjänä tahansa + single_user: "Yksittäinen käyttäjä" + user_placeholder: Anna käyttäjänim + description_placeholder: "Mitä tällä avaimella voi tehdä?" save: Tallenna + new_key: Uusi rajapinta-avain + revoked: Jäädytetty + delete: Poista pysyvästi web_hooks: title: "Webhookit" none: "Webhookeja ei ole nyt." @@ -3634,6 +3660,11 @@ fi: change_theme_setting: "muutti teema-asetusta" disable_theme_component: "otti teemakomponentin käytöstä" enable_theme_component: "otti teemakomponentin käyttöön" + revoke_title: "peru titteli" + change_title: "muuta titteliä" + api_key_create: "loi rajapinta-avaimen" + api_key_update: "päivitti rajapinta-avainta" + api_key_destroy: "tuhosi rajapinta-avaimen" screened_emails: title: "Seulottavat sähköpostiosoitteet" description: "Uuden käyttäjätunnuksen luonnin yhteydessä annettua sähköpostiosoitetta verrataan alla olevaan listaan ja tarvittaessa tunnuksen luonti joko estetään tai suoritetaan muita toimenpiteitä." @@ -4119,21 +4150,15 @@ fi: category: "Julkaise alueelle" add_host: "Lisää isäntä" settings: "Upotuksen asetukset" - feed_settings: "Syötteen asetukset" - feed_description: "Tarjoamalla RSS/ATOM syötteen sivustollesi, voit lisätä Discoursen kykyä tuoda sisältöä." crawling_settings: "Crawlerin asetukset" crawling_description: "Kun Discourse aloittaa ketjuja kirjoituksistasi, se yrittää jäsentää kirjoitustesi sisältöä HTML:stä, jos RSS/ATOM-syötettä ei ole tarjolla, Joskus kirjoitusten sisällön poimiminen on haastavaa, joten tarjoamme mahdollisuuden määrittää CSS-sääntöjä sen helpottamiseksi." embed_by_username: "Käyttäjänimi, jonka nimissä ketjut aloitetaan" embed_post_limit: "Upotettavien viestien maksimimäärä" - embed_username_key_from_feed: "Avain, jolla erotetaan Discourse-käyttäjänimi syötteestä" embed_title_scrubber: "Säännöllinen lauseke, jolla riisutaan viestien otsikkoja" embed_truncate: "Typistä upotetut viestit" embed_whitelist_selector: "CSS valitsin elementeille, jotka sallitaan upotetuissa viesteissä" embed_blacklist_selector: "CSS valitstin elementeille, jotka poistetaan upotetuista viesteistä" embed_classname_whitelist: "Sallitut CSS luokat" - feed_polling_enabled: "Tuo kirjoitukset RSS/ATOM syötteen avulla" - feed_polling_url: "RSS/ATOM syötteen URL" - feed_polling_frequency_mins: "Syötteen kyselyn taajuus (minuutteina)" save: "Tallenna upotusasetukset" permalink: title: "Ikilinkit" diff --git a/config/locales/client.fr.yml b/config/locales/client.fr.yml index fd0fcfb40d..f7308bae64 100644 --- a/config/locales/client.fr.yml +++ b/config/locales/client.fr.yml @@ -321,9 +321,21 @@ fr: order_by: "Trier par" in_reply_to: "en réponse à" explain: + why: "expliquer pourquoi cet élément s'est retrouvé dans la file d'attente" + title: "Notation révisable" + formula: "Formule" + subtotal: "Sous-total" total: "Total" + min_score_visibility: "Score minimum pour la visibilité" + score_to_hide: "Score pour cacher le message" + take_action_bonus: + name: "intervenu" + title: "Lorsqu'un membre du personnel choisit d'agir, l'indicateur reçoit un bonus." + user_accuracy_bonus: + name: "précision de l’utilisateur" trust_level_bonus: name: "niveau de confiance" + title: "Les éléments révisables créés par les utilisateurs ayant un niveau de confiance plus élevé ont un score plus élevé." claim_help: optional: "Vous pouvez réserver cet élément pour empêcher d'autres de le vérifier." required: "Vous devez réserver des éléments avant des les vérifier." @@ -731,6 +743,7 @@ fr: collapse_profile: "Réduire" bookmarks: "Signets" bio: "À propos de moi" + timezone: "Fuseau horaire" invited_by: "Invité par" trust_level: "Niveau de confiance" notifications: "Notifications" @@ -963,8 +976,8 @@ fr: not_connected: "(non connecté)" confirm_modal_title: "Connectez le compte %{provider}" confirm_description: - account_specific: "Votre compte %{provider} '%{account_description}' sera utilisé pour l'authentification." - generic: "Votre compte %{provider} sera utilisé pour l'authentification." + account_specific: "Votre compte %{provider} '%{account_description}' sera utilisé pour l'authentification." + generic: "Votre compte %{provider} sera utilisé pour l'authentification." name: title: "Nom d'utilisateur" instructions: "votre nom complet (facultatif)" @@ -1217,6 +1230,9 @@ fr: enabled: "Le site est en mode lecture seule. Vous pouvez continer à naviguer, mais les réponses, J'aime et autre interactions sont désactivées pour l'instant." login_disabled: "La connexion est désactivée quand le site est en lecture seule." logout_disabled: "La déconnexion est désactivée quand le site est en lecture seule." + too_few_topics_and_posts_notice: "Commençons la discussion! Il y a / %{currentTopics} sujets et %{currentPosts} messages. Les visiteurs ont besoin de plus à consulter et répondre – %{requiredTopics} sujets et %{requiredPosts} messages sont recommandés. Seul le personnel peut voir ce message." + too_few_topics_notice: "Commençons la discussion! Il y a / %{currentTopics} sujets. Les visiteurs ont besoin de plus à consulter et répondre – %{requiredTopics} sujets sont recommandés. Seul le personnel peut voir ce message." + too_few_posts_notice: "Commençons la discussion! Il y a / %{currentPosts} messages. Les visiteurs ont besoin de plus à consulter et répondre – %{requiredPosts} messages sont recommandés. Seul le personnel peut voir ce message." logs_error_rate_notice: reached_hour_MF: "{relativeAge} – {rate, plural, one {# erreur/heure} other {# erreurs/heure}} arrive à la limite paramétrée de {limit, plural, one {# erreur/heure} other {# erreurs/heure}}." reached_minute_MF: "{relativeAge} – {rate, plural, one {# erreur/minute} other {# erreurs/minute}} arrive à la limite paramétrée de {limit, plural, one {# erreur/minute} other {# erreurs/minute}}." @@ -1317,7 +1333,6 @@ fr: second_factor_backup_description: "Veuillez entrer un de vos codes de secours :" second_factor: "Se connecter avec une application" security_key_description: "Dès que votre clé de sécurité physique est prête, appuyer sur le bouton S'authentifier avec une clé de sécurité ci-dessous." - security_key_alternative: "Vous ne trouvez pas votre clé de sécurité ou voulez utiliser une autre méthode ?" security_key_authenticate: "S'authentifier avec une clé de sécurité" security_key_not_allowed_error: "La procédure d'authentification de la clé de sécurité a expiré ou a été annulée." security_key_no_matching_credential_error: "Aucun identifiant correspondant n'a pu être trouvé dans la clé de sécurité donnée." @@ -1695,6 +1710,7 @@ fr: context: user: "Chercher dans les messages de @{{username}}" category: "Rechercher dans la catégorie #{{category}}" + tag: "Rechercher l'étiquette #{{tag}}" topic: "Rechercher dans ce sujet" private_messages: "Rechercher des messages directs" advanced: @@ -2368,6 +2384,7 @@ fr: manage_tag_groups_link: "Gérer les groupes d'étiquettes ici." allow_global_tags_label: "Permettre aussi d'autres étiquettes" tag_group_selector_placeholder: "(Facultatif) Groupe d'étiquettes" + required_tag_group_description: "Exiger que les nouveaux sujets aient des étiquette à partir d'un groupe d’étiquettes :" min_tags_from_required_group_label: "Nombre d'étiquettes :" required_tag_group_label: "Groupe d'étiquettes :" topic_featured_link_allowed: "Autoriser les liens à la une dans cette catégorie" @@ -2399,14 +2416,13 @@ fr: special_warning: "Avertissement : cette catégorie est une catégorie pré-remplie et les réglages de sécurité ne peuvent pas être modifiés. Si vous ne souhaitez pas utiliser cette catégorie, supprimez là au lieu de détourner sa fonction." uncategorized_security_warning: "Cette catégorie est spéciale. Elle sert de zone d'attente pour les sujets qui n'ont pas de catégorie ; vous ne pouvez pas changer ses paramètres de sécurité." uncategorized_general_warning: 'Cette catégorie est spéciale. Elle sert de catégorie par défaut pour les nouveaux sujets qui ne sont pas liés à une catégorie. Si vous souhaitez changer cela et forcer la sélection de catégorie, veuillez désactiver ce paramètre. Si vous voulez modifier son nom ou sa description, allez dans Personnaliser / Contenu.' - pending_permission_change_alert: "Vous n'avez pas ajouté %{group} à cette catégorie; cliquez sur ce bouton pour les ajouter." + pending_permission_change_alert: "Vous n'avez pas ajouté %{group} à cette catégorie; cliquez sur ce bouton pour les ajouter." images: "Images" email_in: "Adresse de courriel entrant personnalisée :" email_in_allow_strangers: "Accepter les courriels d'utilisateurs anonymes sans compte" email_in_disabled: "La possibilité de créer des nouveaux sujets via courriel est désactivé dans les Paramètres. Pour l'activer," email_in_disabled_click: 'activer le paramètre « email in ».' mailinglist_mirror: "La catégorie reflète une liste de diffusion" - suppress_from_latest: "Retirer cette catégorie des sujets récents." show_subcategory_list: "Afficher la liste des sous-catégories au dessus des sujets dans cette catégorie." num_featured_topics: "Nombre de sujets affichés sur la page des catégories :" subcategory_num_featured_topics: "Nombre de sujets à la une sur la page de la catégorie parente :" @@ -2791,6 +2807,7 @@ fr: changed: "étiquettes modifiées :" tags: "Étiquettes" choose_for_topic: "étiquettes optionnelles" + add_synonyms: "Ajouter" delete_tag: "Supprimer l'étiquette" delete_confirm: one: "Êtes-vous sûr de vouloir supprimer cette étiquettes et l'enlever de %{count} sujet auquel elle est assignée ?" @@ -3018,6 +3035,7 @@ fr: membership: automatic: Automatique trust_levels_title: "Niveau de confiance automatiquement attribué lorsque les membres sont ajoutés :" + effects: Effets trust_levels_none: "Aucun" automatic_membership_email_domains: "Les utilisateurs qui s'enregistrent avec un domaine courriel qui correspond exactement à un élément de cette liste seront automatiquement ajoutés à ce groupe :" automatic_membership_retroactive: "Appliquer la même règle de domaine courriel pour les utilisateurs existants" @@ -3056,9 +3074,20 @@ fr: generate: "Générer" revoke: "Révoquer" all_users: "Tous les utilisateurs" + active_keys: "Activer une clé pour l’API" + manage_keys: Gérer les clés show_details: Détails description: Description + all_api_keys: Toutes les clés pour l’API + user_mode: Niveau de l’utilisateur + impersonate_all_users: Usurper l’identité d'un utilisateur + single_user: "Utilisateur unique" + user_placeholder: Saisir un pseudo + description_placeholder: "À quoi servira cette clé ?" save: Sauvegarder + new_key: Nouvelle clé pour l’API + revoked: Révoquée + delete: Supprimer de façon permanente web_hooks: title: "Webhooks" none: "Il n'y a aucun Webhook actuellement." @@ -3122,7 +3151,7 @@ fr: details: "Quand un nouvel élément est prêt à être vérifié et quand son état est mis à jour." notification_event: name: "Notification d'Événement" - details: "Lorsqu'un utilisateur reçoit une notification dans son flux." + details: "Lorsqu'un utilisateur reçoit une notification dans son flux." delivery_status: title: "État de l'envoi" inactive: "Inactif" @@ -3641,6 +3670,11 @@ fr: change_theme_setting: "changer le réglage du thème" disable_theme_component: "désactiver le composant de thème" enable_theme_component: "activer le composant de thème" + revoke_title: "révoquer le titre" + change_title: "modifier le titre" + api_key_create: "créer une clé pour l’API" + api_key_update: "mettre à jour une clé pour l’API" + api_key_destroy: "détruire une clé pour l’API" screened_emails: title: "Courriels sous surveillance" description: "Lorsque quelqu'un essaye de créer un nouveau compte, les adresses de courriel suivantes seront vérifiées et l'inscription sera bloquée, ou une autre action sera réalisée." @@ -4126,21 +4160,15 @@ fr: category: "Écrire dans la catégorie" add_host: "Ajouter un hôte" settings: "Paramètres d'intégration externe" - feed_settings: "Paramètres de flux RSS/ATOM" - feed_description: "Fournir un flux RSS/ATOM pour votre site peut améliorer la capacité de Discourse à importer votre contenu." crawling_settings: "Paramètres de robot" crawling_description: "Quand Discourse crée des sujets pour vos messages, si aucun flux RSS/ATOM n'est présent, il essayera d'extraire le contenu à partir du HTML. Parfois, cette extraction peut être difficile alors nous donnons la possibilité de définir des règles CSS pour faciliter l'extraction." embed_by_username: "Pseudo pour création de sujet" embed_post_limit: "Le nombre maximum de messages à intégrer" - embed_username_key_from_feed: "Clé pour extraire le pseudo du flux." embed_title_scrubber: "Expression régulière utilisée pour nettoyer le titre des messages" embed_truncate: "Tronquer les messages intégrés" embed_whitelist_selector: "Sélecteur CSS pour les éléments qui seront autorisés dans les contenus intégrés" embed_blacklist_selector: "Sélecteur CSS pour les éléments qui seront interdits dans les contenus intégrés" embed_classname_whitelist: "Classes CSS autorisées" - feed_polling_enabled: "Importer les messages via flux RSS/ATOM" - feed_polling_url: "URL du flux RSS/ATOM à importer" - feed_polling_frequency_mins: "Fréquence de rafraichissement du flux (en minutes)" save: "Sauvegarder les paramètres d'intégration" permalink: title: "Permaliens" diff --git a/config/locales/client.gl.yml b/config/locales/client.gl.yml index bbdb734339..1d7ec924f3 100644 --- a/config/locales/client.gl.yml +++ b/config/locales/client.gl.yml @@ -1654,6 +1654,7 @@ gl:

tagging: + add_synonyms: "Engadir" cancel_delete_unused: "Cancelar" notifications: watching: @@ -2417,18 +2418,13 @@ gl: category: "Publicación a categoría" add_host: "Engadir host" settings: "Axustes para o encaixado" - feed_settings: "Axustes das fontes" - feed_description: "Dotar dunha fonte RSS/ATOM o teu sitio pode mellorar a capacidade de Discourse para importar o teu contido." crawling_settings: "Axustes do extractor" crawling_description: "Cando o Discourse crea temas para as túas publicacións, se non existe unha fonte RSS/ATOM, tentará analizar o contido do teu HTML. Ás veces pode ser difícil extraer o contido, por iso damos a posibilidade de especificar as regras do CSS para facilitar a extracción." embed_by_username: "Nome do usuario para crear o tema" embed_post_limit: "Número máximo de publicacións a encaixar" - embed_username_key_from_feed: "Clave para extraer da fonte o nome do usuario do discourse" embed_truncate: "Truncar as publicacións encaixadas" embed_whitelist_selector: "Selector CSS de elementos permitidos nos encaixados" embed_blacklist_selector: "Selector CSS para elementos retirados nos encaixados" - feed_polling_enabled: "Importar publicacións vía RSS/ATOM" - feed_polling_url: "URL da fonte RSS/ATOM para facer a extracción" save: "Gardar axustes de encaixado" permalink: title: "Ligazóns permanentes" diff --git a/config/locales/client.he.yml b/config/locales/client.he.yml index 141406dc21..1a3edc2a30 100644 --- a/config/locales/client.he.yml +++ b/config/locales/client.he.yml @@ -827,6 +827,7 @@ he: collapse_profile: "הקטן" bookmarks: "סימניות" bio: "אודותיי" + timezone: "אזור זמן" invited_by: "הוזמנו על ידי" trust_level: "דרגת אמון" notifications: "התראות" @@ -1437,7 +1438,7 @@ he: second_factor_backup_description: "נא להקליד אחד מהקודים לגיבוי שלך:" second_factor: "כניסה עם יישומון אימות" security_key_description: "כשמפתח האבטחה הפיזי שלך מוכן יש ללחוץ על כפתור האימות עם מפתח האבטחה שלהלן." - security_key_alternative: "לא הצלחת למצוא את מפתח האבטחה או שברצונך לנסות שיטה אחרת?" + security_key_alternative: "לנסות דרך אחרת" security_key_authenticate: "אימות עם מפתח אבטחה" security_key_not_allowed_error: "זמן תהליך אימות מפתח האבטחה פג או שבוטל." security_key_no_matching_credential_error: "לא ניתן למצוא פרטי גישה במפתח האבטחה שסופק." @@ -1749,6 +1750,7 @@ he: topic_reminder: "{{username}} {{description}}" watching_first_post: "נושא חדש {{description}}" membership_request_accepted: "התקבלת לחברות בקבוצה ‚{{group_name}}’" + membership_request_consolidated: "{{count}} בקשות חברות פתוחות מול ‚{{group_name}}’" group_message_summary: one: "הודעה {{count}} בתיבת ה{{group_name}} שלך" two: "{{count}} הודעות בתיבת ה{{group_name}} שלך" @@ -1786,6 +1788,7 @@ he: topic_reminder: "תזכורת נושא" liked_consolidated: "לייקים חדשים" post_approved: "פוסט אושר" + membership_request_consolidated: "בקשות חברות חדשות" upload_selector: title: "הוספת תמונה" title_with_attachments: "הוספת תמונה או קובץ" @@ -1833,6 +1836,7 @@ he: context: user: "חיפוש פוסטים לפי @{{username}}" category: "חפשו את הקטגוריה #{{category}}" + tag: "חיפוש אחר התגית #{{tag}}" topic: "חפשו בנושא זה" private_messages: "חיפוש הודעות" advanced: @@ -2607,7 +2611,6 @@ he: email_in_disabled: "אפשרות הפרסום של נושאים חדשים דרך דוא\"ל נוטרלה בהגדרות האתר. כדי לאפשר פרסום באמצעות משלוח דוא\"ל," email_in_disabled_click: 'אפשרו את את ההגדרה "דוא"ל נכנס"' mailinglist_mirror: "קטגוריה שמשקפת רשימת תפוצה" - suppress_from_latest: "הדחקת הקטגוריה מהנושאים האחרונים." show_subcategory_list: "הצגת רשימת קטגוריות משנה מעל נושאים בקטגוריה זו." num_featured_topics: "מספר הנושאים המוצגים בדף הקטגוריות:" subcategory_num_featured_topics: "מספר הנושאים המומלצים בדף קטגוריית ההורה:" @@ -3017,6 +3020,26 @@ he: changed: "תגיות ששונו:" tags: "תגיות" choose_for_topic: "תגיות רשות" + info: "פרטים" + default_info: "תגית זו אינה מוגבלת לקטגוריות כלשהן ואין לה מילים נרדפות." + synonyms: "מילים נרדפות" + synonyms_description: "תגיות אלו תוחלפנה בתגית %{base_tag_name}." + tag_groups_info: + one: 'תגית זו שייכת לקבוצה הזאת: {{tag_groups}}' + two: "תגית זו שייכת לקבוצות האלו: {{tag_groups}}" + many: "תגית זו שייכת לקבוצות האלו: {{tag_groups}}" + other: "תגית זו שייכת לקבוצות האלו: {{tag_groups}}" + category_restrictions: + one: "ניתן להשתמש בה בקטגוריה זו בלבד:" + two: "ניתן להשתמש בה בקטגוריות אלו בלבד:" + many: "ניתן להשתמש בה בקטגוריות אלו בלבד:" + other: "ניתן להשתמש בה בקטגוריות אלו בלבד:" + edit_synonyms: "ניהול מילים נרדפות" + add_synonyms_label: "הוספת מילים נרדפות:" + add_synonyms: "הוספה" + add_synonyms_failed: "לא ניתן להוסיף את התגיות הבאות בתור מילים נרדפות: %{tag_names}. נא לוודא שאין להן מילים נרדפות ושאינן כבר מילים נרדפות של תגית אחרת." + remove_synonym: "הסרת מילה נרדפת" + delete_synonym_confirm: 'למחוק את המילה הנרדפת „%{tag_name}”?' delete_tag: "מחק תגית" delete_confirm: one: "למחוק את התגית הזו ולהסיר אותה מהנושא אליו היא מוקצית?" @@ -3024,6 +3047,11 @@ he: many: "למחוק את התגית הזו ולהסיר אותה מכל {{count}} הנושאים אליהן היא מוקצית?" other: "למחוק את התגית הזו ולהסיר אותה מכל {{count}} הנושאים אליהן היא מוקצית?" delete_confirm_no_topics: "למחוק את התגית הזו?" + delete_confirm_synonyms: + one: "המילה הנרדפת שקשורה אליה תימחקנה גם כן." + two: "{{count}} המילים הנרדפות שקשורות אליה תימחקנה גם כן." + many: "{{count}} המילים הנרדפות שקשורות אליה תימחקנה גם כן." + other: "{{count}} המילים הנרדפות שקשורות אליה תימחקנה גם כן." rename_tag: "שינוי שם לתגית" rename_instructions: "בחרו שם חדש לתגית:" sort_by: "סידור לפי:" @@ -3553,6 +3581,7 @@ he: color_scheme_select: "בחירת צבעים לשימושה של ערכת העיצוב" custom_sections: "אזורים מותאמים אישית:" theme_components: "רכיבי ערכת העיצוב" + add_all_themes: "הוספת כל ערכות העיצוב" convert: "המרה" convert_component_alert: "להמיר את הרכיב הזה לערכת עיצוב? הוא יוסר כרכיב מתוך %{relatives}." convert_component_tooltip: "המרת הרכיב הזה לערכת עיצוב" @@ -3585,6 +3614,9 @@ he: edit_css_html: "עריכת CSS/HTML" edit_css_html_help: "לא ערכתם אף CSS או HTML" delete_upload_confirm: "להסיר העלאה זו? (ערכת נושא CSS עלולה להפסיק לעבוד!)" + component_on_themes: "לכלול רכיבים בערכות הנושא האלו" + included_components: "רכיבים כלולים" + add_all: "להוסיף הכול" import_web_tip: "מאגר שמכיל ערכת עיצוב" import_web_advanced: "מתקדם…" import_file_tip: "קובץ ‎.tar.gz,‏ ‎.zip,‏ או ‎.dcstyle.json שמכיל ערכת עיצוב" @@ -3895,9 +3927,12 @@ he: change_theme_setting: "שנוי הגדרות ערכת עיצוב" disable_theme_component: "נטרול רכיב בערכת עיצוב" enable_theme_component: "הפעלת רכיב בערכת עיצוב" + revoke_title: "כותרת השלילה" + change_title: "שינוי הכותרת" api_key_create: "נוצר מפתח api" api_key_update: "עודכן מפתח api" api_key_destroy: "הושמד מפתח api" + override_upload_secure_status: "דריסת מצב העלאה בטוח" screened_emails: title: "הודעות דואר מסוננות" description: "כשמישהו מנסה ליצור חשבון חדש, כתובות הדואר האלקטרוני הבאות ייבדקו וההרשמה תחסם או שיבוצו פעולות אחרות." @@ -4395,21 +4430,15 @@ he: category: "פרסם לקטגוריה" add_host: "הוספת שרת" settings: "הגדרות הטמעה" - feed_settings: "הגדרות פיד" - feed_description: "ציון הזנת RSS/ATOM לאתר שלך יכולה לשפר את היכולת של Discourse לייבא את התוכן שלך." crawling_settings: "הגדרות זחלן" crawling_description: "כאשר Discourse יוצר נושאים חדשים עבור פוסטים שלכם, אם לא קיים RSS/ATOM הוא ינסה לפענח את התוכן מתוך ה HTML שלכם. לפעמים זה מאתגר לחלץ את התכנים שלכם אז אנחנו מספקים את האפשרות להגדיר כללי CSS כדי שהחילוץ יהיה קל יותר." embed_by_username: "שם משתמש ליצירת נושא" embed_post_limit: "מספר מקסימלי של פרסומים להטמעה." - embed_username_key_from_feed: "מפתח למשיכת שם המשתמש ב-discourse מהפיד." embed_title_scrubber: "ביטוי רגולרי שמשמש כדי לנקות את הכותרת של פוסטים" embed_truncate: "חיתוך הפרסומים המוטמעים." embed_whitelist_selector: "בוררי CSS לאלמנטים שיותר להטמיע." embed_blacklist_selector: "בוררי CSS לאלמנטים שיוסרו מן ההטמעות." embed_classname_whitelist: "שמות מחלקות CSS מאושרות" - feed_polling_enabled: "יבוא פרסומים דרך RSS/ATOM" - feed_polling_url: "URL של תזרים RSS/ATOM לזחילה" - feed_polling_frequency_mins: "תדירות משיכת תזרים (בדקות)" save: "שמירת הגדרות הטמעה" permalink: title: "קישורים קבועים" diff --git a/config/locales/client.hu.yml b/config/locales/client.hu.yml index 64fb35ad9a..659867f6c4 100644 --- a/config/locales/client.hu.yml +++ b/config/locales/client.hu.yml @@ -2280,6 +2280,7 @@ hu: selector_no_tags: "címke nélküli" tags: "Címkék" choose_for_topic: "Megadható címke" + add_synonyms: "Hozzáadás" delete_tag: "Címke törlése" delete_confirm_no_topics: "Biztos vagy benne hogy elakarod távolítani ezt a címkét?" rename_tag: "Címke átnevezése" diff --git a/config/locales/client.hy.yml b/config/locales/client.hy.yml index adb01e91b1..1a1b6539d3 100644 --- a/config/locales/client.hy.yml +++ b/config/locales/client.hy.yml @@ -2175,7 +2175,6 @@ hy: email_in_disabled: "Էլ. փոստի միջոցով նոր թեմաների հրապարակումը անջատված է Կայքի Կարգավորումներում: Էլ. փոստի միջոցով նոր թեմաների հրապարակումը միացնելու համար, " email_in_disabled_click: 'միացրեք "email in" կարգավորումը:' mailinglist_mirror: "Կատեգորիան արտապատճենում է փոստային ցուցակ" - suppress_from_latest: "Թաքցնել կատեգորիան վերջին թեմաներից:" show_subcategory_list: "Այս կատեգորիայում ցուցադրել ենթակատեգորիաների ցանկը թեմաների վերևում:" num_featured_topics: "Կատեգորիաների էջում ցուցադրվող թեմաների քանակը՝" subcategory_num_featured_topics: "Մայր կատեգորիայի էջում հանրահայտ թեմաների քանակը" @@ -2529,6 +2528,7 @@ hy: changed: "փոփոխված թեգերը՝ " tags: "Թեգեր" choose_for_topic: "ընտրովի թեգեր" + add_synonyms: "Ավելացնել" delete_tag: "Ջնջել Թեգը" delete_confirm: one: "Դուք համոզվա՞ծ եք, որ ցանկանում եք ջնջել այս թեգը և հեռացնել այն %{count} թեմայից, որին այն վերագրված է:" @@ -3793,21 +3793,15 @@ hy: category: "Հրապարակել Կատեգորայում" add_host: "Ավելացնել Հոսթ" settings: "Զետեղման Կարգավորումներ" - feed_settings: "Լրահոսի Կարգավորումները" - feed_description: "RSS/ATOM լրահոսի տրամադրումը Ձեր կայքի համար կարող է բարելավել Ձեր բովանդակության ներբեռնման Discourse-ի հնարավորությունը:" crawling_settings: "Տվյալների հավաքագրիչի (crawler) կարգավորումներ" crawling_description: "Երբ Discourse -ը ստեղծում է թեմաներ Ձեր գրառումների համար, եթե ոչ մի RSS/ATOM լրահոս առկա չէ, այն կփորձի դուրս բերել Ձեր բովանդակությունը Ձեր HTML -ից: Երբեմն Ձեր բովանդակությունը արտահանելը կարող է դժվար լինել, այդ պատճառով մենք տրամադրում ենք CSS կանոններ տրամադրելու հնարավորություն՝ արտահանումը ավելի հեշտացնելու համար:" embed_by_username: "Օգտանուն թեմայի ստեղծման համար" embed_post_limit: "Զետեղման ենթակա գրառումների առավելագույն քանակը" - embed_username_key_from_feed: "Լրահոսից discourse օգտանվան դուրսբերման բանալի" embed_title_scrubber: "Գրառումների վերնագրի զտման համար օգտագործվող regular expression" embed_truncate: "Կրճատել զետեղված գրառումները" embed_whitelist_selector: "CSS սելեկտոր այն տարրերի համար, որոնք թույլատրված են զետեղումների մեջ" embed_blacklist_selector: "CSS սելեկտոր այն տարրերի համար, որոնք հեռացվում են զետեղումներից" embed_classname_whitelist: "Թույլատրված CSS կլասների անվանումներ" - feed_polling_enabled: "Ներմուծել գրառումներ RSS/ATOM-ի միջոցով" - feed_polling_url: "RSS/ATOM լրահոսի URL" - feed_polling_frequency_mins: "Լրահոսի հարցման հաճախականությունը (րոպեներով)" save: "Պահպանել Զետեղման Կարգավորումները" permalink: title: "Մշտահղումներ" diff --git a/config/locales/client.id.yml b/config/locales/client.id.yml index 924724e0b8..23270c767d 100644 --- a/config/locales/client.id.yml +++ b/config/locales/client.id.yml @@ -1095,6 +1095,7 @@ id:

tagging: + add_synonyms: "Menambahkan" sort_by_name: "nama" cancel_delete_unused: "Batal" notifications: diff --git a/config/locales/client.it.yml b/config/locales/client.it.yml index b76d34c060..27108b25dc 100644 --- a/config/locales/client.it.yml +++ b/config/locales/client.it.yml @@ -332,7 +332,7 @@ it: name: "ha preso provvedimenti" title: "Quando un membro dello staff sceglie intervenire, la segnalazione riceve un bonus." user_accuracy_bonus: - name: "precisione dell'utente" + name: "precisione dell'utente" title: "Agli utenti le cui segnalazioni sono state storicamente accettate viene concesso un bonus." trust_level_bonus: name: "livello di esperienza" @@ -747,6 +747,7 @@ it: collapse_profile: "Raggruppa" bookmarks: "Segnalibri" bio: "Su di me" + timezone: "Fuso orario" invited_by: "Invitato Da" trust_level: "Livello Esperienza" notifications: "Notifiche" @@ -902,7 +903,7 @@ it: extended_description: | L'autenticazione a due fattori aggiunge ulteriore sicurezza al tuo account attraverso la richiesta di un token usa e getta oltre alla tua password. I token possono essere generati su dispositivi Android e iOS . oauth_enabled_warning: "Tieni presente che gli accessi ai social network saranno disabilitati dopo aver attivato l'autenticazione a due fattori nel tuo account." - use: "Usa l'app Authenticator" + use: "Usa l'app Authenticator" enforced_notice: "E' obbligatorio abilitare l'autenticazione a due fattori per accedere a questo sito." disable: "disabilita" disable_title: "Disabilita Secondo Fattore" @@ -978,8 +979,8 @@ it: not_connected: "(non connesso)" confirm_modal_title: "Collega un account %{provider} " confirm_description: - account_specific: "Il tuo account %{provider} '%{account_description}' verrà utilizzato per l'autenticazione." - generic: "Il tuo account %{provider} verrà utilizzato per l'autenticazione." + account_specific: "Il tuo account %{provider} '%{account_description}' verrà utilizzato per l'autenticazione." + generic: "Il tuo account %{provider} verrà utilizzato per l'autenticazione." name: title: "Nome" instructions: "il tuo nome completo (opzionale)" @@ -1330,8 +1331,10 @@ it: second_factor_backup: "Accedi utilizzando un codice di backup" second_factor_backup_title: "Backup Due Fattori" second_factor_backup_description: "Per favore, inserisci uno dei tuoi codici di backup:" - second_factor: "Accedi utilizzando l'app Authenticator" + second_factor: "Accedi utilizzando l'app Authenticator" security_key_description: "Quando hai preparato la chiave di sicurezza fisica, premi il pulsante Autentica con Security Key qui sotto." + security_key_authenticate: "Autentica con Security Key" + security_key_not_allowed_error: "Il processo di autenticazione con Security Key è scaduto o è stato annullato." email_placeholder: "email o nome utente" caps_lock_warning: "Il Blocco Maiuscole è attivo" error: "Errore sconosciuto" @@ -1398,6 +1401,7 @@ it: apple_international: "Apple/Internazionale" google: "Google" twitter: "Twitter" + emoji_one: "JoyPixels (precedentemente EmojiOne)" win10: "Win10" google_classic: "Google Classic" facebook_messenger: "Facebook Messenger" @@ -2213,10 +2217,14 @@ it: attachment_upload_not_allowed_for_new_user: "Spiacenti, i nuovi utenti non possono caricare allegati." attachment_download_requires_login: "Spiacenti, devi essere connesso per poter scaricare gli allegati." abandon_edit: + confirm: "Sei sicuro di voler annullare le modifiche?" no_value: "No, mantieni" + no_save_draft: "No, salva in bozza" + yes_value: "Sì, scarta le modifiche" abandon: confirm: "Sicuro di voler abbandonare il tuo messaggio?" no_value: "No, mantienilo" + no_save_draft: "No, salva in bozza" yes_value: "Si, abbandona" via_email: "questo messaggio è arrivato via email" via_auto_generated_email: "questo messaggio è arrivato tramite una email auto generata" @@ -2230,6 +2238,7 @@ it: reply: "inizia a comporre una risposta a questo messaggio" like: "metti \"Mi piace\" al messaggio" has_liked: "ti è piaciuto questo messaggio" + read_indicator: "utenti che leggono questo messaggio" undo_like: "rimuovi il \"Mi piace\"" edit: "modifica questo messaggio" edit_action: "Modifica" @@ -2266,6 +2275,7 @@ it: delete_topic: "elimina argomento" add_post_notice: "Aggiungi Note Staff" remove_post_notice: "Rimuovi Note Staff" + remove_timer: "rimuovi il timer" actions: flag: "Segnala" defer_flags: @@ -2352,9 +2362,14 @@ it: tags_allowed_tags: "Limita queste Etichette a questa Categoria:" tags_allowed_tag_groups: "Limita questi gruppi di Etichette a questa Categoria:" tags_placeholder: "Elenco (opzionale) delle etichette permesse" + tags_tab_description: "Le etichette e i gruppi di etichette sopra specificati saranno disponibili solo in questa categoria, e nelle altre categorie che li specificano. Non saranno disponibili per l'uso in altre categorie." tag_groups_placeholder: "Elenco (opzionale) dei gruppi di etichette permessi" manage_tag_groups_link: "Gestisci qui i gruppi di Etichette." allow_global_tags_label: "Consenti anche ulteriori Etichette" + tag_group_selector_placeholder: "(Facoltativo) Gruppo di Etichette" + required_tag_group_description: "Rendi obbligatorio per i nuovi argomenti avere un'Etichetta da un Gruppo di Etichette:" + min_tags_from_required_group_label: "Numero Etichette:" + required_tag_group_label: "Gruppo di Etichette:" topic_featured_link_allowed: "Consenti collegamenti in primo piano in questa categoria" delete: "Elimina Categoria" create: "Crea Categoria" @@ -2384,13 +2399,13 @@ it: special_warning: "Attenzione: questa è una categoria predefinita e le impostazioni di sicurezza ne vietano la modifica. Se non vuoi usare questa categoria, cancellala invece di modificarla." uncategorized_security_warning: "Questa è una Categoria speciale. È utilizzata come area di parcheggio per gli Argomenti senza Categoria. Non può avere impostazioni di sicurezza." uncategorized_general_warning: 'Questa è una Categoria speciale. È usata come Categoria predefinita per i nuovi Argomenti senza una Categoria selezionata. Se vuoi prevenire questo comportamento e forzare la scelta di una Categoria, per favore disabilita l''impostazione qui. Se vuoi cambiare il nome o la descrizione, vai a Personalizza / Contenuto Testuale.' + pending_permission_change_alert: "Non hai aggiunto %{group} a questa categoria; fai clic su questo pulsante per aggiungerli." images: "Immagini" email_in: "Indirizzo email personalizzato:" email_in_allow_strangers: "Accetta email da utenti anonimi senza alcun account" email_in_disabled: "Le Impostazioni Sito non permettono di creare nuovi argomenti via email. Per abilitare la creazione di argomenti via email," email_in_disabled_click: 'abilita l''impostazione "email entrante".' mailinglist_mirror: "La categoria si comporta come una mailing list" - suppress_from_latest: "Nascondi la categoria dagli argomenti recenti." show_subcategory_list: "Mostra la lista delle sottocategorie sopra agli argomenti in questa categoria." num_featured_topics: "Numero degli argomenti mostrati nella pagina categorie:" subcategory_num_featured_topics: "Numero degli argomenti in evidenza nella pagina della categoria superiore" @@ -2531,6 +2546,8 @@ it: help: "Questo argomento è per te appuntato; verrà mostrato con l'ordinamento di default" unlisted: help: "Questo argomento è invisibile; non verrà mostrato nella liste di argomenti ed è possibile accedervi solo tramite collegamento diretto" + personal_message: + title: "Questo Argomento è un Messaggio Personale" posts: "Messaggi" posts_long: "ci sono {{number}} messaggi in questo argomento" posts_likes_MF: | @@ -2753,6 +2770,7 @@ it: changed: "etichette cambiate:" tags: "Etichette" choose_for_topic: "etichette facoltative" + add_synonyms: "Aggiungi" delete_tag: "Cancella Etichetta" delete_confirm: one: "Sei sicuro di voler eliminare questa etichetta e rimuoverla da %{count} argomento a cui è assegnata?" @@ -2804,11 +2822,13 @@ it: about: "Aggiungi etichette a gruppi per poterle gestire più facilmente." new: "Nuovo Gruppo" tags_label: "Etichette in questo gruppo:" + tags_placeholder: "etichette" parent_tag_label: "Etichetta padre:" parent_tag_placeholder: "Opzionale" parent_tag_description: "Le etichette di questo gruppo non possono essere usate finché è presente l'etichetta padre." one_per_topic_label: "Limita ad una sola etichetta per argomento in questo gruppo" new_name: "Nuovo Gruppo Etichette" + name_placeholder: "Nome Gruppo Etichette" save: "Salva" delete: "Elimina" confirm_delete: "Sicuro di voler cancellare questo gruppo di etichette?" @@ -4037,21 +4057,15 @@ it: category: "Pubblica nella Categoria" add_host: "Aggiungi Host" settings: "Impostazioni di incorporo" - feed_settings: "Impostazioni Feed" - feed_description: "Fornire un feed RSS/ATOM al tuo sito può migliorare la capacità di Discourse di importare i tuoi contenuti." crawling_settings: "Impostazioni Crawler" crawling_description: "Quando Discourse crea gli argomenti per i tuoi messaggi, se non è presente nessun feed RSS/ATOM, cercherà di estrarre il contenuto dal codice HTML. Il contenuto può risultate a volte ostico da estrarre e, per semplificare il processo, forniamo la possibilità di specificare le regole CSS." embed_by_username: "Nome utente per la creazione dell'argomento" embed_post_limit: "Numero massimo di messaggi da includere" - embed_username_key_from_feed: "Chiave per ottenere il nome utente discourse dal feed" embed_title_scrubber: "Espressione regolare usata per ripulire i titoli dei messaggi" embed_truncate: "Tronca i messaggi incorporati" embed_whitelist_selector: "Selettore CSS per gli elementi da permettere negli embed" embed_blacklist_selector: "Selettore CSS per gli elementi da rimuovere dagli embed" embed_classname_whitelist: "Classi CSS permesse" - feed_polling_enabled: "Importa i messaggi via RSS/ATOM" - feed_polling_url: "URL del feed RSS/ATOM da recuperare" - feed_polling_frequency_mins: "Frequenza del feed polling (in minuti)" save: "Salva Impostazioni Inclusione" permalink: title: "Collegamenti permanenti" diff --git a/config/locales/client.ja.yml b/config/locales/client.ja.yml index f37f11cf40..dfd1a99f4b 100644 --- a/config/locales/client.ja.yml +++ b/config/locales/client.ja.yml @@ -168,6 +168,7 @@ ja: submit: "送信" generic_error: "申し訳ありません、エラーが発生しました。" generic_error_with_reason: "エラーが発生しました: %{error}" + go_ahead: "どうぞ" sign_up: "アカウントを作成" log_in: "ログイン" age: "経過" @@ -184,6 +185,7 @@ ja: privacy: "プライバシー" tos: "利用規約" rules: "ルール" + conduct: "行動範囲" mobile_view: "モバイル表示" desktop_view: "デスクトップ表示" you: "あなた" @@ -292,8 +294,13 @@ ja: order_by: "順" in_reply_to: "こちらへの回答" explain: + why: "このアイテムが、キューに入れられた理由を教えて下さい" + title: "レビュー可能な得点" + formula: "式" subtotal: "小計" total: "合計" + min_score_visibility: "表示の最小スコア" + score_to_hide: "投稿を非表示にするスコア" trust_level_bonus: name: "トラストレベル" claim: @@ -344,6 +351,7 @@ ja: edit: "編集" save: "保存" cancel: "キャンセル" + new_topic: "このアイテムを承認すると新しいトピックが作成されます" filters: all_categories: "(すべてのカテゴリ)" type: @@ -355,8 +363,10 @@ ja: category: "カテゴリ" orders: priority: "優先度" + priority_asc: "優先度(逆順)" created_at: "作られた" priority: + title: "最小の優先度" medium: "普通" high: "高い" conversation: @@ -366,6 +376,8 @@ ja: date: "日付" type: "タイプ" status: "ステータス" + submitted_by: "投稿者:" + reviewed_by: "レビューアー:" statuses: pending: title: "保留中" @@ -466,6 +478,7 @@ ja: empty: posts: "このグループのメンバーによる投稿はありません。" members: "このグループにはメンバーがいません。" + requests: "このグループへのメンバーシップリクエストはありません" mentions: "このグループに対するメンションはありません。" messages: "このグループへのメッセージはありません。" topics: "このグループのメンバーによるトピックはありません。" @@ -520,6 +533,7 @@ ja: remove_owner: "オーナーから削除" remove_owner_description: "%{username} をこのグループから削除します" owner: "オーナー" + forbidden: "メンバーの閲覧は許可されていません" topics: "トピック" posts: "投稿" mentions: "メンション" @@ -532,6 +546,7 @@ ja: only_admins: "管理者のみ" mods_and_admins: "管理者とモデレータのみ" members_mods_and_admins: "管理者、モデレータ、グループメンバーのみ" + owners_mods_and_admins: "管理者、モデレータ、グループメンバーのみ" everyone: "だれでも" notifications: watching: @@ -629,6 +644,7 @@ ja: private_messages: "メッセージ" user_notifications: ignore_duration_username: "ユーザー名" + ignore_duration_when: "期間:" ignore_duration_save: "無視する" ignore_no_users: "無視するユーザーはいません。" ignore_option: "無視する" @@ -641,6 +657,7 @@ ja: collapse_profile: "折りたたむ" bookmarks: "ブックマーク" bio: "自己紹介" + timezone: "タイムゾーン" invited_by: "招待した人: " trust_level: "トラストレベル" notifications: "お知らせ" @@ -659,6 +676,7 @@ ja: dismiss_notifications: "すべて既読にする" dismiss_notifications_tooltip: "全ての未読の通知を既読にします" first_notification: "最初の通知です! 始めるために選択してください。" + dynamic_favicon: "ブラウザのアイコンに件数を表示する" theme_default_on_all_devices: "これをすべてのデバイスのデフォルトテーマにする" text_size_default_on_all_devices: "これをすべての端末のデフォルトのテキストサイズにする" allow_private_messages: "他のユーザーが私にパーソナルメッセージを送信できるようにする" @@ -703,6 +721,7 @@ ja: watched_first_post_tags_instructions: "これらのタグの新規トピック内の新規投稿は通知されます。" muted_categories: "通知しない" muted_categories_instructions: "これらのカテゴリの新しいトピックについては通知されず、カテゴリや最新のページにも表示されません。" + muted_categories_instructions_dont_hide: "グループ内の新規トピックについては何も通知されません。" no_category_access: "モデレータとしてカテゴリーへのアクセスが制限されたので、保存できませんでした。" delete_account: "アカウントを削除する" delete_account_confirm: "アカウントを削除してもよろしいですか?削除されたアカウントは復元できません。" @@ -770,6 +789,7 @@ ja: copied_to_clipboard: "クリップボードにコピーしました" copy_to_clipboard_error: "クリップボードにコピーする際にエラーが発生しました" remaining_codes: "{{count}} 個のバックアップコードが残っています。" + use: "バックアップコードを使用" codes: title: "バックアップコードが作られました" second_factor: @@ -2281,6 +2301,7 @@ ja: changed: "タグを変更しました:" tags: "タグ" choose_for_topic: "タグ(オプション)" + add_synonyms: "追加" delete_tag: "タグを削除" rename_tag: "タグの名前を変更" rename_instructions: "このタグの新しい名前を選択:" @@ -3158,14 +3179,10 @@ ja: category: "カテゴリへ投稿" add_host: "ホストの追加" settings: "埋め込みの設定" - feed_settings: "フィード設定" embed_truncate: "埋め込まれた投稿を削除する" embed_whitelist_selector: "埋め込みで許可される要素のCSSセレクタ" embed_blacklist_selector: "埋め込みから削除された要素のCSSセレクタ" embed_classname_whitelist: "許可されたCSSクラス名" - feed_polling_enabled: "RSS / ATOM経由で投稿をインポートする" - feed_polling_url: "クロールするRSS / ATOMフィードのURL" - feed_polling_frequency_mins: "フィードの更新間隔 (分)" save: "埋め込みの設定を保存" permalink: title: "パーマリンク" diff --git a/config/locales/client.ko.yml b/config/locales/client.ko.yml index 36667d1dfa..0cce98c131 100644 --- a/config/locales/client.ko.yml +++ b/config/locales/client.ko.yml @@ -605,6 +605,7 @@ ko: expand_profile: "확장" bookmarks: "북마크" bio: "내 소개" + timezone: "시간대" invited_by: "(이)가 초대했습니다." trust_level: "신뢰도" notifications: "알림" @@ -2248,6 +2249,7 @@ ko: changed: "바뀐 태그:" tags: "태그" choose_for_topic: "선택적 태그" + add_synonyms: "추가" delete_tag: "태그 삭제" delete_confirm: other: "정말로 이 태그를 삭제하고 이 태그가 붙은 {{count}} 개의 토픽에서 태그를 제거할까요?" @@ -3326,21 +3328,15 @@ ko: category: "카테고리에 게시" add_host: "Host 추가" settings: "삽입(Embedding) 설정" - feed_settings: "피드 설정" - feed_description: "당신 사이트의 RSS/ATOM 피드를 알려주시면 Discourse가 그 사이트 컨텐트를 더 잘 가져올 수 있습니다." crawling_settings: "크롤러 설정" crawling_description: "When Discourse creates topics for your posts, if no RSS/ATOM feed is present it will attempt to parse your content out of your HTML. Sometimes it can be challenging to extract your content, so we provide the ability to specify CSS rules to make extraction easier." embed_by_username: "토픽을 만들 때 사용할 아이디" embed_post_limit: "삽입(embed)할 글 최대갯수" - embed_username_key_from_feed: "피드에서 discourse usename을 꺼내오기 위한 키(key)" embed_title_scrubber: "게시글의 제목을 긁어오기 위해 정규표현식이 사용되었습니다" embed_truncate: "임베드된 글 뒷부분 잘라내기" embed_whitelist_selector: "CSS selector for elements that are allowed in embeds" embed_blacklist_selector: "CSS selector for elements that are removed from embeds" embed_classname_whitelist: "허용된 CSS 클래스" - feed_polling_enabled: "RSS/ATOM으로 글 가져오기" - feed_polling_url: "긁어올 RSS/ATOM 피드 URL" - feed_polling_frequency_mins: "피드 폴링 빈도(분당)" save: "삽입(Embedding) 설정 저장하기" permalink: title: "고유링크" diff --git a/config/locales/client.lt.yml b/config/locales/client.lt.yml index 9601193659..a1f7eb0bf0 100644 --- a/config/locales/client.lt.yml +++ b/config/locales/client.lt.yml @@ -2251,6 +2251,7 @@ lt: selector_all_tags: "visos žymos" selector_no_tags: "nėra žymų" tags: "Žymos" + add_synonyms: "Pridėti" delete_tag: "Ištrinti žymą" rename_tag: "Pervadinti žymą" sort_by: "Rūšiuoti pagal:" @@ -3151,19 +3152,14 @@ lt: category: "Rašyti Kategorijoje" add_host: "Pridėti Šeimininką" settings: "Įterpti Nustatymus" - feed_settings: "Naujienų nustatymai" - feed_description: "Providing an RSS/ATOM feed for your site can improve Discourse's ability to import your content." crawling_settings: "Crawler Nustatymai" crawling_description: "When Discourse creates topics for your posts, if no RSS/ATOM feed is present it will attempt to parse your content out of your HTML. Sometimes it can be challenging to extract your content, so we provide the ability to specify CSS rules to make extraction easier." embed_by_username: "Vartotojo vardas skirtas temų sukūrimui" embed_post_limit: "Maskimalus įterpiamų įrašų skaičius" - embed_username_key_from_feed: "Key to pull discourse username from feed" embed_truncate: "Truncate the embedded posts" embed_whitelist_selector: "CSS selector for elements that are allowed in embeds" embed_blacklist_selector: "CSS selector for elements that are removed from embeds" embed_classname_whitelist: "Leidžiamos CSS klasės" - feed_polling_enabled: "Importuoti įrašus per RSS/ATOM" - feed_polling_url: "URL of RSS/ATOM feed to crawl" save: "Išsaugoti Įterpimo Nustatymus" permalink: title: "Laikinos Nuorodos" diff --git a/config/locales/client.lv.yml b/config/locales/client.lv.yml index 2148a4200b..1fd2cb6b69 100644 --- a/config/locales/client.lv.yml +++ b/config/locales/client.lv.yml @@ -124,6 +124,9 @@ lv: topic_html: 'Temats: %{topicTitle}' post: "ieraksts #%{postNumber}" close: "aizvērt" + twitter: "Dalīties ar šo saiti Twitter" + facebook: "Dalīties ar šo saiti Facebook" + email: "Nosūtīt šo saiti e-pastā" action_codes: public_topic: "padarīja tēmu publisku %{when}" split_topic: "sadalīja šo tēmu %{when}" @@ -211,6 +214,8 @@ lv: every_hour: "katru stundu" daily: "katru dienu" weekly: "katru nedēļu" + every_month: "katru mēnesi" + every_six_months: "katrus sešus mēnešus" max_of_count: "ne vairāk kā {{count}}" alternation: "vai" character_count: @@ -278,13 +283,17 @@ lv: placeholder: "ierakstiet tēmas nosaukumu šeit" review: order_by: "Kārtot pēc" + explain: + total: "Kopā" delete: "Dzēst" settings: saved: "Saglabāts" save_changes: "Saglabāt izmaiņas" title: "Iestatījumi" + view_all: "Skatīt visus" topic: "Tēma:" filtered_user: "Lietotājs" + show_all_topics: "rādīt visas tēmas" deleted_post: "(ieraksts dzēsts)" deleted_user: "(lietotājs dzēsts)" user: @@ -307,6 +316,7 @@ lv: orders: priority: "Prioritāte" scores: + date: "Datums" status: "Statuss" statuses: pending: @@ -403,12 +413,14 @@ lv: allow_membership_requests: "Ļauj lietotājiem sūtīt dalības pieprasījumus grupu īpašniekiem" membership: "Piederība" name: "Vārds" + group_name: "Grupas nosaukums" user_count: "Lietotāji" bio: "Par grupu" selector_placeholder: "ievadi lietotājvārdu" owner: "īpašnieks" index: title: "Grupas" + all: "Visas grupas" empty: "Nav redzamu grupu." automatic: "Automātiski" closed: "Slēgts" @@ -532,6 +544,7 @@ lv: activity_stream: "Aktivitāte" preferences: "Iestatījumi" expand_profile: "Paplašināt" + collapse_profile: "Sakļaut" bookmarks: "Grāmatzīmes" bio: "Par mani" invited_by: "Uzaicināja" @@ -647,6 +660,7 @@ lv: copy_to_clipboard_error: "Radās kļūda, kopējot uz starpliktuvi (clipboard)" second_factor: name: "Vārds" + label: "Kods" edit: "Labot" security_key: delete: "Dzēst" @@ -724,7 +738,11 @@ lv: website: "Tīmekļa vietne" email_settings: "E-pasts" text_size: + title: "Teksta izmērs" + smaller: "Mazāks" normal: "Normāls" + larger: "Lielāks" + largest: "Lielākais" like_notification_frequency: title: "Paziņot, ja saņemta atzinība" always: "Vienmēr" @@ -741,6 +759,8 @@ lv: every_hour: "katru stundu" daily: "katru dienu" weekly: "katru nedēļu" + every_month: "katru mēnesi" + every_six_months: "katrus sešus mēnešus" email_level: title: "Sūtīt man e-pastu, kad kāds citē mani, atbild manam ierakstam, piemin manu @lietotājvārdu vai ielūdz mani kādā tēmā" always: "vienmēr" @@ -943,6 +963,7 @@ lv: disable: "Rādīt dzēstos ierakstus" private_message_info: title: "Ziņa" + edit: "Pievienot vai noņemt..." remove_allowed_user: "Vai jūs tiešām gribat dzēst {{name}} no šīs ziņas?" remove_allowed_group: "Vai jūs tiešām gribat dzēst {{name}} no šīs ziņas?" email: "E-pasts" @@ -1009,11 +1030,17 @@ lv: name: "Twitter" title: "ar Twitter" instagram: + name: "Instagram" title: "ar Instagram" facebook: + name: "Facebook" title: "ar Facebook" github: + name: "GitHub" title: "ar GitHub" + discord: + name: "Discord" + title: "ar Discord" invites: accept_title: "Ielūgums" welcome_to: "Laipni lūdzam %{site_name}!" @@ -1050,6 +1077,7 @@ lv: emoji_picker: filter_placeholder: Meklēt emoji objects: Priekšmeti + symbols: Simboli flags: Sūdzības custom: Lietotāja emoji recent: Nesen izmantotie @@ -1619,6 +1647,8 @@ lv: zero: "apslēpt {{count}} slēptās atbildes" one: "aplūjot %{count} slēpto atbildi" other: "aplūkot {{count}} slēptās atbildes" + notice: + new_user: "Šis ir pirmais {{user}} ieraksts — sagaidīsim viņu mūsu kopienā!" unread: "Ieraksts nav lasīts" has_replies: zero: "{{count}} atbildes" @@ -2116,6 +2146,7 @@ lv: selector_no_tags: "bez tagiem" changed: "mainītie tagi:" tags: "Tagi" + add_synonyms: "Pievienot" delete_tag: "Dzēst tagu" rename_tag: "Pārsaukt tagu" rename_instructions: "Izvēlēties jaunu nosaukumu tagam:" @@ -2455,6 +2486,7 @@ lv: color_scheme_select: "Izvēlieties krāsas, kuras lietos šis dizains" custom_sections: "Specifiskas sadaļas:" theme_components: "Dizaina komponenti" + collapse: Sakļaut uploads: "Augšupielādes" no_uploads: "Jūs varat augšupielādēt resursus, kas ir saistīti ar jūsu dizainu, piemēram, fontus un attēlus" add_upload: "Pievienot augšupielādējamu resursu" @@ -3016,21 +3048,15 @@ lv: category: "Ieraksti kategorijā" add_host: "Pievienot lietotāju" settings: "Ierakstu iestatījumi" - feed_settings: "Ierakstu iestatījumi" - feed_description: "(Providing an RSS/ATOM feed for your site can improve Discourse's ability to import your content.)" crawling_settings: "(Crawler Settings)" crawling_description: "(Kad diskurss rada tēmas par savu amatu, ja nav RSS / ATOM padeves ir klāt tā mēģinās parsēt jūsu saturu no jūsu HTML. Dažreiz tas var būt grūti, lai iegūtu savu saturu, tāpēc mēs piedāvājam iespēju noteikt CSS noteikumus, lai padarītu ieguve vieglāk.)" embed_by_username: "Lietotājvārds tēmas radīšanai" embed_post_limit: "Maksimālais iegulto ierakstu skaits" - embed_username_key_from_feed: "(Key to pull discourse username from feed)" embed_title_scrubber: "(Regular expression used to scrub the title of posts)" embed_truncate: "Saīsināt iegultos ierakstus" embed_whitelist_selector: "(CSS selector for elements that are allowed in embeds)" embed_blacklist_selector: "(CSS selector for elements that are removed from embeds)" embed_classname_whitelist: "Atļautie CSS nosaukumi (Allowed CSS class names)" - feed_polling_enabled: "Importēt ierakstus ar RSS/ATOM" - feed_polling_url: "(URL of RSS/ATOM feed to crawl)" - feed_polling_frequency_mins: "Aptauju biežums (minūtēs)" save: "Saglabāt iegulšanas iestatījumus" permalink: title: "Patstāvīgā saite" diff --git a/config/locales/client.nb_NO.yml b/config/locales/client.nb_NO.yml index 9e16295b5a..ebfaf94fab 100644 --- a/config/locales/client.nb_NO.yml +++ b/config/locales/client.nb_NO.yml @@ -2069,7 +2069,6 @@ nb_NO: email_in_disabled: "Publisering av nye emner via e-post er deaktivert i innstillingene for nettstedet. For å aktivere publisering av nye emner via e-post," email_in_disabled_click: 'aktiver innstillingen "e-post inn".' mailinglist_mirror: "Kategorien gjenspeiler en e-postliste" - suppress_from_latest: "Utelat denne kategorien fra siste emner." show_subcategory_list: "Plasser listen over underkategorien i toppen av emner i denne kategorien." num_featured_topics: "Antall emner som skal vises på kategori-siden:" subcategory_num_featured_topics: "Antall fremhevede emner på hovedkategoriens side:" @@ -2415,6 +2414,7 @@ nb_NO: changed: "stikkord endret:" tags: "Stikkord" choose_for_topic: "valgfrie stikkord" + add_synonyms: "Legg til" delete_tag: "Slett stikkord" delete_confirm: one: "Er du sikker på at du ønsker å slette dette stikkordet og fjerne det fra emnet det er tilknyttet?" @@ -3568,21 +3568,15 @@ nb_NO: category: "Legg til innlegg i kategori" add_host: "Legg til vert" settings: "Innbyggingsinnstillinger" - feed_settings: "Informasjonskanals-innstillinger" - feed_description: "Å tilby en RSS/ATOM-strøm for nettstedet ditt kan forbedre Discourses evne til å importere innholdet ditt." crawling_settings: "Innstillinger for søkeroboter" crawling_description: "Dersom Discourse oppretter emner for innleggene dine og ingen RSS/ATOM-informasjonskanal finnes, vil det prøve å tolke innholdet ditt ut fra HTML-koden din. Noen ganger kan det være utfordrende å hente ut innhold, så muligheten til å oppgi CSS-regler er der for å gjøre uthentingen enklere." embed_by_username: "Brukernavn for opprettelse av emne" embed_post_limit: "Maksimalt antall innlegg å bygge inn" - embed_username_key_from_feed: "Nøkkel for å hente Discourse-brukernavn fra informasjonskanal" embed_title_scrubber: "Regulære uttrykk brukt til å finne og korrigere feil i titler" embed_truncate: "Forkort de innebygde innleggene" embed_whitelist_selector: "CSS-velger for elementer som tillates i innbygginger" embed_blacklist_selector: "CSS-velger for element som fjernes fra innbygginger" embed_classname_whitelist: "Tillatte navn for CSS-klasser" - feed_polling_enabled: "Importer innlegg via RSS/ATOM" - feed_polling_url: "Nettadresse for RSS/ATOM-informasjonskanal å gjennomgangssøke" - feed_polling_frequency_mins: "Frekvens for strømspørring (i minutter)" save: "Lagre innbyggingsinnstillinger" permalink: title: "Permalenker" diff --git a/config/locales/client.nl.yml b/config/locales/client.nl.yml index 4f54aff1e5..7c52cd95d2 100644 --- a/config/locales/client.nl.yml +++ b/config/locales/client.nl.yml @@ -265,7 +265,7 @@ nl: remove: "Bladwijzer verwijderen" confirm_clear: "Weet u zeker dat u alle bladwijzers van dit topic wilt verwijderen?" drafts: - resume: "Doorgaan" + resume: "Hervatten" remove: "Verwijderen" new_topic: "Nieuw-topicconcept" new_private_message: "Nieuw-privéberichtconcept" @@ -310,7 +310,7 @@ nl: choose_topic: none_found: "Geen topics gevonden." title: - search: "Zoeken naar een topic op naam, url of id:" + search: "Zoeken naar een topic op naam, URL of ID:" placeholder: "typ hier de titel van het topic" choose_message: none_found: "Geen berichten gevonden." @@ -747,6 +747,7 @@ nl: collapse_profile: "Samenvouwen" bookmarks: "Favorieten" bio: "Over mij" + timezone: "Tijdzone" invited_by: "Uitgenodigd door" trust_level: "Vertrouwensniveau" notifications: "Meldingen" @@ -975,7 +976,7 @@ nl: connect: "Verbinden" revoke: "Intrekken" cancel: "Annuleren" - not_connected: "(niet verbonden)" + not_connected: "(niet gekoppeld)" confirm_modal_title: "%{provider}-account koppelen" confirm_description: account_specific: "Uw %{provider}-account '%{account_description}' wordt voor authenticatie gebruikt." @@ -1314,7 +1315,7 @@ nl: button_help: "Help" email_login: link_label: "Een koppeling voor aanmelding e-mailen" - button_label: "Met e-mail" + button_label: "via e-mail" complete_username: "Als een account overeenkomt met de gebruikersnaam %{username}, zou u spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." complete_email: "Als een account overeenkomt met %{email}, zou u spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." complete_username_found: "We hebben een account gevonden die overeenkomt met de gebruikersnaam %{username}. U zou spoedig een e-mail met een koppeling voor aanmelding moeten ontvangen." @@ -1335,7 +1336,7 @@ nl: second_factor_backup_description: "Voer een van uw back-upcodes in:" second_factor: "Aanmelden met authenticator-app" security_key_description: "Houd uw fysieke beveiligingssleutel gereed en klik op de onderstaande knop Authenticeren met beveiligingssleutel." - security_key_alternative: "Uw beveiligingssleutel niet gevonden of een andere methode gebruiken?" + security_key_alternative: "Andere manier proberen" security_key_authenticate: "Authenticeren met beveiligingssleutel" security_key_not_allowed_error: "Het authenticatieproces van de beveiligingssleutel had een time-out of is geannuleerd." security_key_no_matching_credential_error: "Geen referenties gevonden in de opgegeven beveiligingssleutel." @@ -1416,8 +1417,8 @@ nl: categories_with_featured_topics: "Categorieën met aanbevolen topics" categories_and_latest_topics: "Categorieën en nieuwste topics" categories_and_top_topics: "Categorieën en toptopics" - categories_boxes: "Boxjes met subcategorieën" - categories_boxes_with_topics: "Boxjes met aanbevolen topics" + categories_boxes: "Vakken met subcategorieën" + categories_boxes_with_topics: "Vakken met aanbevolen topics" shortcut_modifier_key: shift: "Shift" ctrl: "Ctrl" @@ -1633,6 +1634,7 @@ nl: topic_reminder: "{{username}} {{description}}" watching_first_post: "Nieuw Topic {{description}}" membership_request_accepted: "Lidmaatschap geaccepteerd in '{{group_name}}'" + membership_request_consolidated: "{{count}} open lidmaatschapsaanvragen voor '{{group_name}}'" group_message_summary: one: "{{count}} bericht in uw Postvak IN voor {{group_name}}" other: "{{count}} berichten in uw Postvak IN voor {{group_name}}" @@ -1668,6 +1670,7 @@ nl: topic_reminder: "topic-herinnering" liked_consolidated: "nieuwe likes" post_approved: "bericht goedgekeurd" + membership_request_consolidated: "nieuwe lidmaatschapsaanvragen" upload_selector: title: "Een afbeelding toevoegen" title_with_attachments: "Een afbeelding of bestand toevoegen" @@ -1713,6 +1716,7 @@ nl: context: user: "Berichten van @{{username}} doorzoeken" category: "De categorie #{{category}} doorzoeken" + tag: "De tag #{{tag}} doorzoeken" topic: "Dit topic doorzoeken" private_messages: "Berichten doorzoeken" advanced: @@ -2425,7 +2429,6 @@ nl: email_in_disabled: "Het plaatsen van nieuwe topics via e-mail is uitgeschakeld in de webite-instellingen. Om het plaatsen van nieuwe topics via e-mail mogelijk te maken, " email_in_disabled_click: 'schakelt u de instelling ''e-mail in'' in.' mailinglist_mirror: "Categorie weerspiegelt een mailinglijst" - suppress_from_latest: "Categorie bij nieuwste topics onderdrukken" show_subcategory_list: "Subcategorielijsten boven topics tonen in deze categorie" num_featured_topics: "Aantal getoonde topics op de categoriepagina:" subcategory_num_featured_topics: "Aantal aanbevolen topics op pagina van bovenliggende categorie:" @@ -2797,6 +2800,7 @@ nl: changed: "gewijzigde tags:" tags: "Tags" choose_for_topic: "optionele tags" + add_synonyms: "Toevoegen" delete_tag: "Tag verwijderen" delete_confirm: one: "Weet u zeker dat u deze tag wilt verwijderen en loskoppelen van %{count} topic waaraan deze is toegewezen?" @@ -3321,6 +3325,7 @@ nl: color_scheme_select: "Kleuren die door thema worden gebruikt selecteren" custom_sections: "Aangepaste secties:" theme_components: "Themaonderdelen" + add_all_themes: "Alle thema's toevoegen" convert: "Converteren" convert_component_alert: "Weet u zeker dat u dit onderdeel naar een thema wilt converteren? Het wordt als onderdeel van %{relatives} verwijderd." convert_component_tooltip: "Dit onderdeel naar een thema converteren" @@ -3353,6 +3358,7 @@ nl: edit_css_html: "CSS/HTML bewerken" edit_css_html_help: "U hebt geen CSS of HTML bewerkt" delete_upload_confirm: "Deze upload verwijderen? (Thema-CSS zou kunnen stoppen met werken!)" + component_on_themes: "Onderdeel voor deze thema's bijvoegen" import_web_tip: "Repository die thema bevat" import_web_advanced: "Geavanceerd..." import_file_tip: ".tar.gz-, .zip- of .dcstyle.json-bestand dat thema bevat" @@ -3661,9 +3667,12 @@ nl: change_theme_setting: "thema-instelling wijzigen" disable_theme_component: "themaonderdeel uitschakelen" enable_theme_component: "themaonderdeel inschakelen" + revoke_title: "titel intrekken" + change_title: "titel wijzigen" api_key_create: "api-sleutel maken" api_key_update: "api-sleutel bijwerken" api_key_destroy: "api-sleutel verwijderen" + override_upload_secure_status: "status beveiligd uploaden negeren" screened_emails: title: "Gecontroleerde e-mails" description: "Als iemand een nieuwe account probeert aan te maken, worden de volgende e-mailadressen gecontroleerd en de registratie geblokkeerd, of een andere actie uitgevoerd." @@ -4149,21 +4158,15 @@ nl: category: "Bericht naar categorie" add_host: "Host toevoegen" settings: "Inbeddingsinstellingen" - feed_settings: "Feedinstellingen" - feed_description: "Een RSS- of ATOM-feed van uw website kan het importeren van inhoud naar Discourse verbeteren." crawling_settings: "Crawlerinstellingen" crawling_description: "Als Discourse topics voor uw berichten aanmaakt en er geen RSS/ATOM-feed aanwezig is, wordt geprobeerd uw inhoud vanuit HTML te parsen. Omdat het soms een uitdaging kan zijn om inhoud te extraheren, bieden we de mogelijkheid voor het opgeven van CSS-regels om de extractie makkelijker te maken." embed_by_username: "Gebruikersnaam voor het maken van topics" embed_post_limit: "Maximale aantal berichten om in te bedden" - embed_username_key_from_feed: "Sleutel om Discourse-gebruikersnaam uit feed te halen" embed_title_scrubber: "Reguliere expressie voor het afleiden van de titels van berichten" embed_truncate: "Ingebedde berichten inkorten" embed_whitelist_selector: "CSS-selector voor elementen die bij inbedding worden toegestaan" embed_blacklist_selector: "CSS-selector voor elementen die bij inbedding worden verwijderd" embed_classname_whitelist: "Toegestane CSS-klassenamen" - feed_polling_enabled: "Berichten importeren via RSS/ATOM" - feed_polling_url: "URL van RSS/ATOM-feed voor crawlen" - feed_polling_frequency_mins: "Hoe vaak feed verversen (in minuten)" save: "Inbeddingsinstellingen opslaan" permalink: title: "Permalinks" diff --git a/config/locales/client.pl_PL.yml b/config/locales/client.pl_PL.yml index 3e61775efd..c16b3eb05d 100644 --- a/config/locales/client.pl_PL.yml +++ b/config/locales/client.pl_PL.yml @@ -816,6 +816,7 @@ pl_PL: collapse_profile: "Zwiń" bookmarks: "Zakładki" bio: "O mnie" + timezone: "Strefa czasowa" invited_by: "Zaproszono przez" trust_level: "Poziom zaufania" notifications: "Powiadomienia" @@ -1005,6 +1006,8 @@ pl_PL: uploaded_avatar_empty: "Dodaj zwyczajny obrazek" upload_title: "Wyślij swoją grafikę" image_is_not_a_square: "Uwaga: grafika została przycięta ponieważ jej wysokość i szerokość nie były równe. " + change_profile_background: + title: "Nagłówek profilu" change_card_background: title: "Tło karty użytkownika" instructions: "Tło karty użytkownika est wycentrowane i posiada domyślną szerokość 590px." @@ -1401,7 +1404,7 @@ pl_PL: second_factor_backup_title: "Kod zapasowy weryfikacji dwuskładnikowej" second_factor_backup_description: "Proszę wprowadź jeden ze swoich zapasowych kodów:" second_factor: "Zaloguj się przy użyciu aplikacji Authenticator" - security_key_alternative: "Nie możesz znaleźć swojego klucza bezpieczeństwa lub chcesz użyć innej metody?" + security_key_alternative: "Spróbuj w inny sposób" security_key_authenticate: "Uwierzytelnij się za pomocą klucza bezpieczeństwa" security_key_not_allowed_error: "Upłynął limit czasu procesu uwierzytelniania klucza bezpieczeństwa lub został on anulowany." email_placeholder: "adres email lub nazwa użytkownika" @@ -2040,8 +2043,8 @@ pl_PL: "2_4": "Widzisz ilość nowych odpowiedzi, ponieważ wypowiedziałeś się w tym wątku." "2_2": "Widzisz ilość nowych odpowiedzi, ponieważ śledzisz ten wątek." "2": 'Widzisz ilość nowych odpowiedzi, ponieważ przeczytałeś ten wątek.' - "1_2": "Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." - "1": "Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + "1_2": "Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + "1": "Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." "0_7": "Ignorujesz wszystkie powiadomienia z tej kategorii." "0_2": "Ignorujesz wszystkie powiadomienia w tym temacie." "0": "Ignorujesz wszystkie powiadomienia w tym temacie." @@ -2053,16 +2056,16 @@ pl_PL: description: "Dostaniesz powiadomienie o każdym nowym wpisie w tym temacie. Liczba nowych wpisów pojawi się obok jego tytułu na liście wiadomości." tracking_pm: title: "Śledzenie" - description: "Licznik nowych wpisów pojawi się obok tej dyskusji. Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + description: "Licznik nowych odpowiedzi zostanie pokazany dla tej wiadomości. Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." tracking: title: "Śledzenie" - description: "Licznik nowych odpowiedzi pojawi się obok tytułu tego tematu. Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + description: "Licznik nowych odpowiedzi zostanie pokazany dla tego tematu. Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." regular: title: "Normalny" - description: "Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + description: "Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." regular_pm: title: "Normalny" - description: "Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + description: "Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." muted_pm: title: "Wyciszono" description: "Nie będziesz otrzymywać powiadomień dotyczących tej dyskusji." @@ -2295,7 +2298,7 @@ pl_PL: many: "pokaż {{count}} ukrytych odpowiedzi" other: "pokaż {{count}} ukrytych odpowiedzi" notice: - new_user: "To pierwszy raz gdy {{user}}coś opublikował - witamy go w naszej społeczności!" + new_user: "{{user}} opublikował(a) coś po raz pierwszy - powitajmy tę osobę w naszej społeczności!" returning_user: "Minęło trochę czasu, odkąd widzieliśmy {{user}} - jego ostatni post był {{time}}." unread: "Nieprzeczytany wpis" has_replies: @@ -2411,6 +2414,12 @@ pl_PL: notify_user: "Wysłano wiadomość do tego użytkownika" bookmark: "Dodano zakładkę w tym wpisie" like: "Lajkujesz ten wpis" + delete: + confirm: + one: "Czy na pewno chcesz usunąć ten post?" + few: "Czy na pewno chcesz usunąć te {{count}} posty?" + many: "Czy na pewno chcesz usunąć te {{count}} postów? " + other: "Czy na pewno chcesz usunąć te {{count}} postów?" merge: confirm: one: "Czy na pewno chcesz połączyć te posty?" @@ -2453,14 +2462,19 @@ pl_PL: can: "może… " none: "(brak kategorii)" all: "Wszystkie kategorie" + choose: "kategoria…" edit: "Edytuj" + edit_dialog_title: "Edytuj: %{categoryName}" view: "Pokaż Tematy w Kategorii" general: "Ogólne" settings: "Ustawienia" topic_template: "Szablon tematu" tags: "Tagi" + tags_allowed_tags: "Zabroń następujących tagów w tej kategorii: " + tags_allowed_tag_groups: "Zabroń następujących grup tagów w tej kategorii:" tags_placeholder: "(Opcjonalnie) lista dozwolonych tagów" tag_groups_placeholder: "(Opcjonalnie) lista dozwolonych grup tagów" + allow_global_tags_label: "Zezwól dodatkowo na inne tagi" topic_featured_link_allowed: "Zezwól na wybrane linki w tej kategorii" delete: "Usuń kategorię" create: "Nowa kategoria" @@ -2521,7 +2535,7 @@ pl_PL: description: "Będziesz automatycznie śledzić wszystkie nowe tematy w tych kategoriach. Dostaniesz powiadomienie, gdy ktoś ci odpowie lub wspomni twoją @nazwę. Zobaczysz również liczbę odpowiedzi." regular: title: "Normalny" - description: "Dostaniesz powiadomienie jedynie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." + description: "Otrzymasz powiadomienie, gdy ktoś wspomni twoją @nazwę lub odpowie na twój wpis." muted: title: "Wyciszone" description: "Nie otrzymasz powiadomień o nowych tematach w tych kategoriach. Nie pojawią się na liście nieprzeczytanych." @@ -2531,6 +2545,7 @@ pl_PL: ignore: "Ignoruj" low: "Niski" high: "Wysoki" + very_high: "Bardzo wysoki" sort_options: default: "domyślny" likes: "Polubienia" @@ -2550,6 +2565,7 @@ pl_PL: boxes_with_featured_topics: "Ramki z wybranymi tematami" settings_sections: general: "Ogólne" + moderation: "Moderacja" email: "Email" flagging: title: "Dziękujemy za pomoc w utrzymaniu porządku w naszej społeczności!" @@ -2760,7 +2776,13 @@ pl_PL: readonly: "przeglądać" lightbox: download: "pobierz" + close: "Zamknij (Esc)" keyboard_shortcuts_help: + shortcut_key_delimiter_comma: "," + shortcut_key_delimiter_plus: "+" + shortcut_delimiter_or: "%{shortcut1} lub %{shortcut2}" + shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}" + shortcut_delimiter_space: "%{shortcut1}%{shortcut2}" title: "Skróty klawiszowe" jump_to: title: "Skocz do" @@ -2773,6 +2795,7 @@ pl_PL: bookmarks: "%{shortcut} Zakładki" profile: "%{shortcut} Profil" messages: "%{shortcut} Wiadomości" + drafts: "%{shortcut} szkice" navigation: title: "Nawigacja" jump: "%{shortcut} idź do postu #" @@ -2780,6 +2803,7 @@ pl_PL: up_down: "%{shortcut} Przesuń zaznaczenie ↑ ↓" open: "%{shortcut} Otwórz wybrany temat" next_prev: "%{shortcut} Następna/poprzednia sekcja" + go_to_unread_post: "%{shortcut} Idź do pierwszego nieprzeczytanego wpisu" application: title: "Aplikacja" create: "%{shortcut} utwórz nowy temat" @@ -2792,6 +2816,10 @@ pl_PL: dismiss_new_posts: "%{shortcut} wyczyść listę wpisów" dismiss_topics: "%{shortcut} wyczyść listę tematów" log_out: "%{shortcut} Wyloguj" + composing: + title: "Komponowanie" + return: "%{shortcut} Powróć do kompozytora" + fullscreen: "%{shortcut} Pełnoekranowy kompozytor" actions: title: "Akcje" bookmark_topic: "%{shortcut} dodaj/usuń zakładkę na temat" @@ -2812,6 +2840,7 @@ pl_PL: mark_tracking: "%{shortcut} śledź temat" mark_watching: "%{shortcut} Obserwuj wątek" print: "%{shortcut} Drukuj temat" + defer: "%{shortcut} Odrocz wątek" badges: earned_n_times: one: "Otrzymano tą odznakę %{count} raz" @@ -2840,6 +2869,7 @@ pl_PL: other: "%{count} przyznanych" select_badge_for_title: Wybierz odznakę do użycia jako twój tytuł none: "(brak)" + successfully_granted: "Przyznano %{badge} użytkownikowi %{username}" badge_grouping: getting_started: name: Pierwsze kroki @@ -2862,12 +2892,20 @@ pl_PL:

tagging: all_tags: "Wszystkie tagi" + other_tags: "Inne tagi" selector_all_tags: "wszystkie tagi" selector_no_tags: "brak tagów" changed: "zmienione tagi:" tags: "Tagi" choose_for_topic: "tagi opcjonalne" + add_synonyms: "Dodaj" delete_tag: "Usuń Tag" + delete_confirm: + one: "Czy na pewno chcesz usunąć ten tag i usunąć go z %{count} wątku, do którego jest przypisany?" + few: "Czy na pewno chcesz usunąć ten tag i usunąć go z {{count}} wątków, do których jest przypisany?" + many: "Czy na pewno chcesz usunąć ten tag i usunąć go z {{count}} wątków, do których jest przypisany?" + other: "Czy na pewno chcesz usunąć ten tag i usunąć go z {{count}} wątków, do których jest przypisany?" + delete_confirm_no_topics: "Czy na pewno chcesz usunąć ten tag?" rename_tag: "Zmień nazwę taga" rename_instructions: "Wybierz nową nazwę dla tego taga:" sort_by: "Sortuj po:" @@ -2875,6 +2913,22 @@ pl_PL: sort_by_name: "nazwa" manage_groups: "Zarządzaj grupą tagów" manage_groups_description: "Definiowanie grup do organizowania tagów" + upload: "Wgraj tagi" + upload_description: "Wczytaj plik .csv, by utworzyć wiele tagów naraz" + upload_instructions: "Jeden na linię, opcjonalnie z grupą tagu, w formacie \"nazwa_tagu,grupa_tagu\"." + upload_successful: "Tagi wgrane " + delete_unused_confirmation: + one: "%{count} tag zostanie usunięty: %{tags}" + few: "%{count} tagi zostaną usunięte: %{tags}" + many: "%{count} tagów zostanie usuniętych: %{tags}" + other: "%{count} tagów zostanie usuniętych: %{tags}" + delete_unused_confirmation_more_tags: + one: "%{tags} i %{count} więcej" + few: "%{tags} i %{count} więcej" + many: "%{tags} i %{count} więcej" + other: "%{tags} i %{count} więcej" + delete_unused: "Usuń nieużywane tagi" + delete_unused_description: "Usuń wszystkie tagi, które nie są dołączone do żadnych wątków ani wiadomości." cancel_delete_unused: "Anuluj" filters: without_category: "%{filter} %{tag} tematy" @@ -2898,11 +2952,13 @@ pl_PL: about: "Dodaj etykiety do grup aby zarządzać nimi łatwiej." new: "Nowa Grupa" tags_label: "Tagi w tej grupie:" + tags_placeholder: "tagi" parent_tag_label: "Nadrzędny tag:" parent_tag_placeholder: "Opcjonalnie" parent_tag_description: "Tagi z tej grupy nie mogą być wykorzystane, chyba że główny tag jest obecny." one_per_topic_label: "Ogranicz jeden tag na temat z tej grupy" new_name: "Nowa grupa tagów" + name_placeholder: "Nazwa grupy tagów" save: "Zapisz" delete: "Usuń" confirm_delete: "Czy na pewno chcesz usunąć ten tag grupy?" @@ -2940,9 +2996,14 @@ pl_PL: tags: remove_muted_tags_from_latest: always: "zawsze" + only_muted: "kiedy używany odrębnie lub z innymi wyciszonymi tagami" never: "nigdy" + reports: + title: "Lista dostępnych zgłoszeń" dashboard: title: "Raporty" + last_updated: "Zaktualizowano panel kontrolny:" + discourse_last_updated: "Zaktualizowano Discourse:" version: "Wersja" up_to_date: "Wersja Aktualna!" critical_available: "Ważna aktualizacja jest dostępna." @@ -2953,30 +3014,48 @@ pl_PL: version_check_pending: "Wygląda na to że ostatnio była wykonana aktualizacja. Fantastycznie!" installed_version: "Zainstalowana" latest_version: "Najnowsza" + problems_found: "Porada bazująca na twoich aktualnych ustawieniach" last_checked: "Ostatnio sprawdzana" refresh_problems: "Odśwież" no_problems: "Nie znaleziono problemów." moderators: "Moderatorzy:" admins: "Adminstratorzy:" + silenced: "Wyciszeni:" suspended: "Zawieszeni:" private_messages_short: "Wiad." private_messages_title: "Wiadomości" mobile_title: "Mobile" + space_used: "%{usedSize} użyty" + space_used_and_free: "%{usedSize}(%{freeSize} wolne)" uploads: "Pliki" backups: "Kopie zapasowe" + backup_count: + one: "%{count} kopia zapasowa w %{location}" + few: "%{count} kopie zapasowe w %{location}" + many: "%{count} kopii zapasowych w %{location}" + other: "%{count} kopii zapasowych w %{location}" + lastest_backup: "Ostatnie: %{date}" traffic_short: "Ruch" traffic: "Zapytania do aplikacji" page_views: "Wyświetlenia strony" page_views_short: "Wyświetlenia strony" show_traffic_report: "Pokaż szczegółowy raport ruchu" + community_health: Zdrowie społeczności + moderators_activity: Aktywność moderatorów + whats_new_in_discourse: "Co nowego w Discourse?" activity_metrics: Metryka aktywności + all_reports: "Wszystkie zgłoszenia" general_tab: "Ogólne" + moderation_tab: "Moderacja" security_tab: "Bezpieczeństwo" reports_tab: "Zgłoszenia" report_filter_any: "każdy" disabled: Wyłączony + timeout_error: "Przepraszamy, to wykonanie tego zapytania trwa za długo, wybierz proszę krótszy interwał" + exception_error: "Przepraszamy, podczas wykonywania zapytania pojawił się błąd" too_many_requests: "Wykonałeś tę akcję zbyt wiele razy. Poczekaj, zanim spróbujesz ponownie." not_found_error: "Przepraszamy, ten raport nie istnieje." + filter_reports: Filtruj zgłoszenia reports: today: "Dzisiaj" yesterday: "Wczoraj" @@ -2989,9 +3068,19 @@ pl_PL: view_table: "tabela" view_graph: "wykres" refresh_report: "Odśwież raport" + start_date: "Data startowa (UTC)" + end_date: "Data końcowa (UTC)" groups: "Wszystkie grupy" disabled: "Ten raport jest nieaktywny" + totals_for_sample: "Całkowite na próbkowanie" + average_for_sample: "Średnio na próbkowanie" + total: "Całkowity czas" + no_data: "Brak danych do wyświetlenia." + trending_search: + more: 'Logi wyszukiwania' filters: + file-extension: + label: Rozszerzenie pliku group: label: Grupa category: @@ -3006,6 +3095,7 @@ pl_PL: name: too_short: "Nazwa grupy jest zbyt krótka" too_long: "Nazwa grupy jest zbyt długa" + checking: "Sprawdzanie dostępności nazwy grupy..." available: "Nazwa grupy jest dostępna" not_available: "Nazwa grupy jest niedostępna" blank: "Nazwa grupy nie może być pusta" @@ -3013,6 +3103,8 @@ pl_PL: title: "Dodaj więcej do grupy" complete_users_not_added: "Podani użytkownicy nie mogą zostać dodani (Sprawdź, czy stworzyli konto):" paste: "Podaj listę nazw użytkowników lub adresów e-mail, każdy w oddzielnej linii:" + add_members: + as_owner: "Ustaw użytkownika/ów jako właściciel/i tej grupy" manage: interaction: email: Email @@ -3022,10 +3114,19 @@ pl_PL: visibility_levels: title: "Kto może widzieć tę grupę?" public: "Wszyscy" + logged_on_users: "Zalogowani użytkownicy" + members: "Właściciele grupy, członkowie" staff: "Właściciele grupy i zespół" + owners: "Właściciele grupy" + description: "Administratorzy mogą widzieć wszystkie grupy." + members_visibility_levels: + title: "Kto może widzieć członków grupy?" + description: "Administratorzy mogą widzieć członków wszystkich grup." + publish_read_state: "Wyświetlaj status przeczytania w grupowych wiadomościach" membership: automatic: Automatyczne trust_levels_title: "Domyślny poziom zaufania przyznawany dodawanym użytkownikom:" + effects: Efekty trust_levels_none: "Brak" automatic_membership_email_domains: "Użytkownicy rejestrujący się przy pomocy adresu z tej listy zostaną automatycznie przypisani do tej grupy." automatic_membership_retroactive: "Zastosuj tę regułę domenową do już istniejących użytkowników." @@ -3045,6 +3146,8 @@ pl_PL: add: "Dodaj" custom: "Niestandardowe" automatic: "Automatyczne" + default_title: "Domyślny tytuł" + default_title_description: "zostanie zastosowany dla wszystkich członków tej grupy" group_owners: Właściciele add_owners: Dodaj właścicieli none_selected: "Wybierz grupę, aby rozpocząć" @@ -3054,13 +3157,30 @@ pl_PL: none: "Nie ma teraz aktywnych kluczy API." user: "Użytkownik" title: "API" + key: "Klucz" created: Utworzono + updated: Zaktualizowane + last_used: Ostatnio używane + never_used: (nigdy) generate: "Generuj" + undo_revoke: "Cofnij unieważnienie" revoke: "Unieważnij" all_users: "Wszyscy użytkownicy" + active_keys: "Aktywuj klucze API" + manage_keys: Zarządzaj kluczami show_details: Detale description: Opis + no_description: (brak opisu) + all_api_keys: Wszystkie klucze API + user_mode: Poziom użytkownika + impersonate_all_users: "Podszyj się pod dowolnego użytkownika " + single_user: "Pojedynczy użytkownik" + user_placeholder: Wprowadź nazwę użytkownika + description_placeholder: "Do czego będzie używany ten klucz?" save: Zapisz + new_key: Nowy klucz API + revoked: Unieważniono + delete: Permanentnie usuń web_hooks: title: "Webhooks" none: "Brak webhooks" @@ -3090,6 +3210,8 @@ pl_PL: active_notice: "Dostarczymy detale wydarzenia, kiedy się ono odbędzie." categories_filter_instructions: "Istotne webhook'i będą uruchamiane tylko wtedy, gdy zdarzenie jest związane z określonymi kategoriami. Pozostaw puste, aby wywołać webhook'i dla wszystkich kategorii." categories_filter: "Wywołane kategorie" + tags_filter_instructions: "Istotne webhooki będą wywoływane tylko wtedy, gdy zdarzenie jest związane z określonymi tagami. Zostaw to miejsce puste, by wywoływać webhooki dla wszystkich tagów." + tags_filter: "Wywoływane tagi" groups_filter_instructions: "Istotne webhook'i będą uruchamiane tylko wtedy, gdy zdarzenie jest związane z określonymi grupami. Pozostaw puste, aby wywołać webhook'i dla wszystkich grup." groups_filter: "Wywołane grupy" delete_confirm: "Usunąć ten webhook?" @@ -3101,6 +3223,28 @@ pl_PL: details: "Kiedy pojawia się nowa odpowiedź, edytuj, usuń lub odzyskaj." user_event: name: "Wydarzenie użytkownika" + details: "Kiedy użytkownik się loguje, wylogowuje, jest tworzony, zatwierdzany lub aktualizowany." + group_event: + name: "Zdarzenie grupowe" + details: "Kiedy grupa jest tworzona, aktualizowana lub niszczona. " + category_event: + name: "Zdarzenie kategorii" + details: "Kiedy kategoria jest tworzona, aktualizowana lub niszczona." + tag_event: + name: "Zdarzenie tagu" + details: "Kiedy tag jest tworzony, aktualizowany lub niszczony." + flag_event: + name: "Zdarzenie flagi" + details: "Kiedy flaga jest tworzona, zatwierdzana, odrzucana lub ignorowana." + queued_post_event: + name: "Zdarzenie zatwierdzania wpisu" + details: "Kiedy wpis w kolejce jest tworzony, zatwierdzany lub odrzucany." + reviewable_event: + name: "Zdarzenie przeglądania" + details: "Kiedy nowy element jest gotowy do przejrzenia lub kiedy jego status jest aktualizowany." + notification_event: + name: "Zdarzenie powiadomienia" + details: "Kiedy użytkownik otrzymuje powiadomienie." delivery_status: title: "Status dostarczenia" inactive: "Nieaktywny" @@ -3204,6 +3348,8 @@ pl_PL: confirm: "Czy jesteś pewien, że chcesz przywrócić bazę danych do ostatniej działającej wersji ?" location: local: "Lokalne miejsce" + s3: "S3" + backup_storage_error: "Nie można uzyskać dostępu do miejsca zapisywania kopii zapasowej: %{error_message}" export_csv: success: "Rozpoczęto eksport: otrzymasz wiadomość, gdy proces zostanie zakończony." failed: "Eksport zakończył się niepowodzeniem. Sprawdź logi." @@ -3229,6 +3375,7 @@ pl_PL: new_style: "Nowy styl" install: "zainstalować" delete: "Usuń" + delete_confirm: 'Czy na pewno chcesz usunąć "%{theme_name}"?' color: "Kolor" opacity: "Widoczność" copy: "Kopiuj" @@ -3246,6 +3393,13 @@ pl_PL: revert_confirm: "Czy na pewno chcesz wycofać swoje zmiany?" theme: theme: "Motyw" + component: "Składnik" + components: "Składniki" + theme_name: "Nazwa motywu" + component_name: "Nazwa komponentu" + themes_intro: "Wybierz istniejący motyw lub zainstaluj nowy, by rozpocząć" + beginners_guide_title: "Poradnik początkującego użytkownika motywów Discourse" + developers_guide_title: "Poradnik kreatora motywów Discourse" browse_themes: "Przeglądaj motywy społeczności" customize_desc: "Personalizacja:" title: "Motywy" @@ -3255,12 +3409,14 @@ pl_PL: long_title: "Zmodyfikuj kolory, kod CSS i kod HTML zawartości Twojej strony" edit: "Edytuj" edit_confirm: "To jest zdalny styl. Jeśli edytujesz CSS/HTML, twoje zmiany zostaną usunięte po ponownej aktualizacji motywu." + update_confirm: "Te zmiany zostaną usunięte przez aktualizację. Czy na pewno chcesz kontynuować? " update_confirm_yes: "Tak, kontynuuj aktualizację" common: "Częste" desktop: "Komputer" mobile: "Mobilnie" settings: "Ustawienia" translations: "Tłumaczenia" + extra_scss: "Dodatkowy SCSS" preview: "Podgląd" show_advanced: "Pokaż pola zaawansowane" hide_advanced: "Ukryj pola zaawansowane" @@ -3272,9 +3428,13 @@ pl_PL: custom_sections: "Spersonalizowane sekcje:" theme_components: "Komponenty motywu" convert: "Konwertować" + convert_component_alert: "Czy na pewno chcesz konwertować ten składnik na motyw? Zostanie usunięty jako składnik z %{relatives}." convert_component_tooltip: "Konwertuj ten składnik na motyw" + convert_theme_alert: "Czy na pewno chcesz konwertować ten motyw na składnik. Zostanie usunięty jako rodzic z %{relatives}." + convert_theme_tooltip: "Konwertuj ten motyw na składnik" inactive_themes: "Nieaktywne motywy:" inactive_components: "Nieużywane komponenty:" + broken_theme_tooltip: "Ten motyw ma błędy w CSS, HTML lub YAML" disabled_component_tooltip: "Ten komponent został wyłączony" default_theme_tooltip: "Ten motyw jest domyślnym motywem witryny" updates_available_tooltip: "Dostępne są aktualizacje dla tego motywu" @@ -3285,9 +3445,13 @@ pl_PL: add_upload: "Dodaj plik" upload_file_tip: "Wybierz plik do wysłania (png, woff2, itp.)" variable_name: "Nazwa zmiennej SCSS:" + variable_name_invalid: "Niewłaściwa nazwa zmiennej. Dozwolone tylko nazwy alfanumeryczne. Musi zaczynać się od litery. Musi być unikatowa." variable_name_error: + invalid_syntax: "Niewłaściwa nazwa zmiennej. Dozwolone tylko nazwy alfanumeryczne. Musi zaczynać się od litery." + no_overwrite: "Niewłaściwa nazwa zmiennej. Nie może nadpisywać istniejącej zmiennej." must_be_unique: "Niepoprawna nazwa zmiennej. Musi być unikalna." upload: "Plik" + select_component: "Wybierz składnik..." unsaved_changes_alert: "Nie zapisałeś jeszcze zmian, czy chcesz je odrzucić i przejść dalej?" discard: "Odrzuć" stay: "Zostań" @@ -3296,7 +3460,11 @@ pl_PL: edit_css_html_help: "Nie modyfikowałeś CSS ani HTML" delete_upload_confirm: "Czy usunąć ten plik? (CSS motywu może przestać działać!)" import_web_tip: "Repozytorium zawierające motyw" + import_web_advanced: "Zaawansowane..." + import_file_tip: "plik .tar.gz, .zip, lub .dcstyle.json zawierający motyw" + is_private: "Motyw jest w prywatnym repozytorium git" remote_branch: "Nazwa oddziału (opcjonalnie)" + public_key: "Podaj następujący klucz publiczny pozwalający na dostęp do repozytorium:" install: "zainstalować" installed: "Zainstalowana" install_popular: "Popularne" @@ -3314,6 +3482,8 @@ pl_PL: disabled_by: "Ten komponent został wyłączony przez" required_version: error: "Ten motyw został automatycznie wyłączony, ponieważ nie jest kompatybilny z tą wersją Discourse." + minimum: "Wymaga wersji {{version}} Discourse'a lub wyższej." + maximum: "Wymaga wersji {{version}} Discourse'a lub niższej." component_of: "Składnik:" update_to_latest: "Aktualizuj do najnowszego" check_for_updates: "Sprawdź dostępność aktualizacji" @@ -3329,7 +3499,9 @@ pl_PL: few: "Motyw jest {{count}} aktualizacji w tyle!" many: "Motyw jest {{count}} aktualizacji w tyle!" other: "Motyw jest {{count}} aktualizacji w tyle!" + compare_commits: "(Sprawdź nowe commity)" repo_unreachable: "Nie można skontaktować się z repozytorium Git tego motywu. Komunikat o błędzie:" + imported_from_archive: "Motyw został zaimportowany z pliku .zip" scss: text: "CSS" title: "Wstaw własny CSS, przyjmujemy wszystkie prawidłowe style CSS i SCSS" @@ -3353,6 +3525,7 @@ pl_PL: title: "HTML, który zostanie umieszczony przed tagiem " yaml: text: "YAML" + title: "Zdefiniuj ustawienia motywu w formacie YAML" colors: select_base: title: "Wybierz podstawową paletę kolorów" @@ -3367,6 +3540,7 @@ pl_PL: undo: "cofnij" undo_title: "Cofnij zmiany tego koloru od ostatniego zapisu" revert: "przywróć" + revert_title: "Zresetuj ten kolor do domyślnej wartości palety Discourse." primary: name: "podstawowy" description: "Większość tekstu, ikon oraz krawędzi." @@ -3397,17 +3571,28 @@ pl_PL: love: name: "polubienie" description: "Kolor przycisku lajkuj" + robots: + title: "Nadpisz plik robots.txt swojej witryny:" + warning: "To ustawienie permanentnie nadpisze wszystkie powiązane ustawienia witryny." + overridden: Domyślny plik robots.txt twojej witryny został nadpisany email_style: + title: "Styl e-maili" + heading: "Zmień styl e-maili" + html: "Szablon HTML" css: "CSS" reset: "Przywróć ustawienia domyślne" + save_error_with_reason: "Twoje zmiany nie zostały zapisane. %{error}" email: title: "Emaile" settings: "Ustawienia" templates: "Szablony" preview_digest: "Pokaż zestawienie aktywności" advanced_test: + title: "Zaawansowany test" email: " Oryginalna wiadomość" run: "Przeprowadź Test" + text: "Wybrane body tekstu" + elided: "Pominięty tekst" sending_test: "Wysyłanie testowego emaila…" error: "BŁAD - %{server_error}" test_error: "Wystąpił problem podczas wysyłania testowego maila. Sprawdź ustawienia poczty, sprawdź czy Twój serwer nie blokuje połączeń pocztowych i spróbuj ponownie." @@ -3474,6 +3659,7 @@ pl_PL: silence_user: "Użytkownik wyciszony" delete_post: "Wpis usunięty" delete_topic: "Temat usunięty" + post_approved: "Wpis zatwierdzony" logs: title: "Logi" action: "Działanie" @@ -3518,10 +3704,13 @@ pl_PL: change_site_text: "zmiana tekstu serwisu" suspend_user: "zawieszenie użytkownika" unsuspend_user: "odwieszenie użytkownika" + removed_suspend_user: "zawieś użytkownika (usunięte)" + removed_unsuspend_user: "cofnij zawieszenie użytkownika (usunięte)" grant_badge: "przyznanie odznaki" revoke_badge: "odebranie odznaki" check_email: "sprawdzenie poczty" delete_topic: "usunięcie tematu" + recover_topic: "przywróć wątek" delete_post: "usunięcie wpisu" impersonate: "udawanie użytkownika" anonymize_user: "anonimizuj użytkownika" @@ -3531,12 +3720,15 @@ pl_PL: create_category: "Dodaj nową kategorię" silence_user: "wycisz użytkownika" unsilence_user: "cofnij wyciszenie użytkownika" + removed_silence_user: "wycisz użytkownika (usunięte)" + removed_unsilence_user: "cofnij wyciszenie użytkownika (usunięte)" grant_admin: "nadaj prawa admina" revoke_admin: "odbierz prawa admina" grant_moderation: "Przyznaj status moderatora" revoke_moderation: "cofnąć moderację" backup_create: "Wykonaj kopię zapasową" deleted_tag: "usunięty tag" + deleted_unused_tags: "usunięto nieużywane tagi" renamed_tag: "zmieniona nazwa tag'u" revoke_email: "cofnąć e-mail" lock_trust_level: "Zablokuj poziom zaufania" @@ -3548,9 +3740,34 @@ pl_PL: backup_destroy: "Zniszcz kopię zapasową " reviewed_post: "przejrzane posty" custom_staff: "spersonalizowana akcja wtyczki" + post_locked: "wpis zablokowany" + post_edit: "edycja wpisu" + post_unlocked: "wpis odblokowany" + check_personal_message: "sprawdź wiadomości prywatne" + disabled_second_factor: "wyłącz dwuskładnikowe uwierzytelnienie" + topic_published: "wątek opublikowany" post_approved: "post zatwierdzony" post_rejected: "wpis odrzucony" + create_badge: "stwórz odznakę" + change_badge: "zmień odznakę" + delete_badge: "usuń odznakę" + merge_user: "połącz użytkownika" + entity_export: "eksportuj jednostkę" change_name: "zmień nazwe" + topic_timestamps_changed: "zmieniono znaczniki czasowe wątku" + approve_user: "zatwierdzony użytkownik" + web_hook_create: "utwórz webhooka" + web_hook_update: "zaktualizuj webhooka" + web_hook_destroy: "zniszcz webhooka" + web_hook_deactivate: "deaktywuj webhooka" + change_theme_setting: "zmień ustawienia motywu" + disable_theme_component: "dezaktywuj składnik motywu" + enable_theme_component: "aktywuj składnik motywu" + revoke_title: "odbierz tytuł" + change_title: "zmień tytuł" + api_key_create: "utwórz klucz api" + api_key_update: "zaktualizuj klucz api" + api_key_destroy: "zniszcz klucz api" screened_emails: title: "Ekranowane emaile" description: "Kiedy ktoś próbuje założyć nowe konto, jego adres email zostaje sprawdzony i rejestracja zostaje zablokowana, lub inna akcja jest podejmowana." @@ -3590,6 +3807,8 @@ pl_PL: all_search_types: "Wszystkie typy wyszukiwania" header: "Nagłówek" full_page: "Pełna strona" + click_through_only: "wszystkie (tylko przeklikiwanie)" + header_search_results: "Wyniki wyszukiwania w nagłówku" logster: title: "Logi błędów" watched_words: @@ -3600,6 +3819,8 @@ pl_PL: one_word_per_line: "Jedno słowo w wierszu" download: Pobierz clear_all: Wyczyść wszystko + clear_all_confirm_block: "Czy na pewno chcesz wyczyścić wszystkie obserwowane słowa dla akcji Blokuj?" + clear_all_confirm_censor: "Czy na pewno chcesz wyczyścić wszystkie obserwowane słowa dla akcji Cenzuruj?" word_count: one: "%{count} słowo" few: "%{count} słówa" @@ -3627,6 +3848,7 @@ pl_PL: test: button_label: "Sprawdź" description: "Wpisz tekst poniżej, aby sprawdzić dopasowania z obserwowanymi słowami" + found_matches: "Znalezione wyniki:" no_matches: "Nie znaleziono dopasowań" impersonate: title: "Zaloguj się na to konto" @@ -3666,6 +3888,7 @@ pl_PL: silenced: "Wyciszeni użytkownicy" suspended: "Zawieszone konta" suspect: "Podejrzani użytkownicy" + staged: "Wystawieni użytkownicy" not_verified: "Niezweryfikowany" check_email: title: "Wyświetl adres email tego użytkownika" @@ -3724,6 +3947,8 @@ pl_PL: grant_moderation: "Przyznaj status moderatora" unsuspend: "Odwieś" suspend: "Zawieś" + show_flags_received: "Pokaż otrzymane flagi" + flags_received_by: "Flagi otrzymane przez %{username}" flags_received_none: "Ten użytkownik nie otrzymał żadnych flag." reputation: Reputacja permissions: Uprawnienia @@ -3825,6 +4050,8 @@ pl_PL: likes_received: "Lajki otrzymane" likes_received_days: "Lajki otrzymane: unikalne dni" likes_received_users: "Lajki otrzymane: unikalni użytkownicy" + suspended: "Zawieszony (ostatnie 6 miesięcy)" + silenced: "Uciszony (ostatnie 6 miesięcy)" qualifies: "Kwalifikuje się do 3 poziomu zaufania." does_not_qualify: "Nie kwalifikuje się do 3 poziomu zaufania." will_be_promoted: "Zostanie awansowany wkrótce." @@ -3934,7 +4161,9 @@ pl_PL: groups: "Grupy" dashboard: "Raporty" default_categories: + modal_description: "Czy chciałbyś wprowadzić tę zmianę wstecz? Zmieni to ustawienia %{count} istniejących użytkowników." modal_yes: "Tak" + modal_no: "Nie, wprowadź zmianę odtąd, na przyszłość" badges: title: Odznaki new_badge: Nowa odznaka @@ -3971,6 +4200,7 @@ pl_PL: enabled: Włącz odznakę icon: Ikona image: Grafika + icon_help: "Wprowadź nazwę ikony Font Awesome (użyj prefiksu \"far-\" dla zwykłych ikon i \"fab-\" dla ikon marek)" image_help: "Wprowadź adres URL obrazu (zastępuje pole ikony, jeśli oba są ustawione)" query: "Zapytanie odznaki (SQL) " target_posts: Wpisy powiązane z odznaką @@ -4008,6 +4238,7 @@ pl_PL: badge_intro: title: "Wybierz istniejącą odznakę lub utwórz nową, aby rozpocząć" what_are_badges_title: "Czym są odznaki?" + badge_query_examples_title: "Przykład wykonania zapytania odznaki" emoji: title: "Emoji" help: "Dodawanie nowych emoji. (PROTIP: przeciągnij i upuść wiele plików)" @@ -4027,24 +4258,19 @@ pl_PL: category: "Publikuj w kategorii" add_host: "Dodaj host" settings: "Ustawienia osadzania" - feed_settings: "Ustawienia kanału" - feed_description: "Wprowadzenie kanału RSS/ATOM twojego serwisu ułatwia import treści." crawling_settings: "Ustawienia crawlera" crawling_description: "Gdy Discourse tworzy tematy reprezentujące twoje wpisy, a kanał RSS/ATOM nie został podany, treść będzie pobierana poprzez parsowanie HTML. Proces ten może okazać się trudny dlatego umożliwiamy podanie dodatkowych reguł CSS, które usprawniają proces parsowania." embed_by_username: "Użytkownik tworzący tematy" embed_post_limit: "Maksymalna ilość osadzanych wpisów " - embed_username_key_from_feed: "Klucz używany do pobrania nazwy użytkownika z kanału" embed_title_scrubber: "Wyrażenie regularne używane do czyszczenia tytuł postów" embed_truncate: "Skracaj treść osadzanych wpisów" embed_whitelist_selector: "Selektor CSS elementów jakie mogą być osadzane" embed_blacklist_selector: "Selektor CSS elementów jakie są usuwane podczas osadzania" embed_classname_whitelist: "Dozwolone nazwy klas CSS" - feed_polling_enabled: "Importowanie wpisów via RSS/ATOM" - feed_polling_url: "URL kanału RSS/ATOM" - feed_polling_frequency_mins: "Częstotliwość aktualizacji strony (w minutach)" save: "Zapisz" permalink: title: "Permalinki" + description: "Zwróć uwagę na to, że to ustawienie zastosowane zostanie wyłącznie do zewnętrznych źródeł, linki umieszczone na forum nie ulegną przekierowaniu." url: "URL" topic_id: "ID tematu" topic_title: "Temat" @@ -4061,8 +4287,10 @@ pl_PL: reseed: action: label: "Zamień tekst…" + title: "Zamień tekst kategorii i wątków tłumaczeniami" modal: title: "Zamień tekst" + subtitle: "Zamień tekst systemowo generowanych kategorii i wątków ostatnimi tłumaczeniami" categories: "Kategorie" topics: "Tematy" replace: "Zamień" diff --git a/config/locales/client.pt.yml b/config/locales/client.pt.yml index e1e1c65222..fc3fe4b76c 100644 --- a/config/locales/client.pt.yml +++ b/config/locales/client.pt.yml @@ -1114,6 +1114,7 @@ pt: trust_level: "Nível de Confiança" search_hint: "nome de utilizador, e-mail ou endereço de IP" create_account: + disclaimer: "Ao registar-se, concorda com a política de privacidade e os termos do serviço." title: "Criar Nova Conta" failed: "Ocorreu algo de errado, este e-mail já pode estar registado. Tente utilizar a hiperligação \"Esqueci-me da Palavra-passe\"." forgot_password: @@ -2419,6 +2420,7 @@ pt: selector_no_tags: "sem etiquetas" changed: "etiquetas modificadas:" tags: "Etiquetas" + add_synonyms: "Adicionar" delete_tag: "Remover Etiqueta" rename_tag: "Renomear Etiqueta" rename_instructions: "Escolha o novo nome para a etiqueta:" @@ -3335,20 +3337,15 @@ pt: category: "Publicação para Categoria" add_host: "Adicionar Servidor" settings: "Configurações de Incorporação" - feed_settings: "Configurações do Feed" - feed_description: "Fornecer um fed RSS/ATOM para o seu sítio pode melhorar a habilidade do Discourse de importar o seu conteúdo." crawling_settings: "Configurações de Rastreio" crawling_description: "Quando o Discourse cria tópicos para as suas publicações, se nenhum feed RSS/ATOM está presente o Discourse irá tentar analisar o seu conteúdo fora do seu HTML. Algumas vezes pode ser um desafio extrair o seu conteúdo, por isso temos a habilidade de especificar regras CSS para tornar a extração mais fácil. " embed_by_username: "Nome de uilizador para criação do tópico" embed_post_limit: "Número máximo de publicações a incorporar" - embed_username_key_from_feed: "Chave para puxar o nome de utilizador discouse do feed" embed_title_scrubber: "Expressão regular usada para filtrar o título de publicações" embed_truncate: "Truncar as publicações incorporadas" embed_whitelist_selector: "Seletor CSS para elementos que são permitidos nas incorporações" embed_blacklist_selector: "Seletor CSS para elementos que são removidos das incorporações" embed_classname_whitelist: "Nomes de classes CSS permitidas" - feed_polling_enabled: "Importar publicações através de RSS/ATOM" - feed_polling_url: "URL do feed RSS/ATOM para rastreio" save: "Guardar Configurações de Incorporação" permalink: title: "Hiperligações Permanentes" diff --git a/config/locales/client.pt_BR.yml b/config/locales/client.pt_BR.yml index b20b460230..f0397188cf 100644 --- a/config/locales/client.pt_BR.yml +++ b/config/locales/client.pt_BR.yml @@ -720,6 +720,7 @@ pt_BR: collapse_profile: "Recolher" bookmarks: "Favoritos" bio: "Sobre mim" + timezone: "Fuso Horário" invited_by: "Convidado Por" trust_level: "Nível de Confiança" notifications: "Notificações" @@ -937,7 +938,7 @@ pt_BR: not_connected: "(não conectado)" confirm_modal_title: "Conecte-se à conta %{provider}" confirm_description: - account_specific: "Sua conta %{provider} '%{account_description}' será usada para autenticação." + account_specific: "Sua conta %{provider} '%{account_description}' será usada para autenticação." generic: "Sua conta %{provider} será usada para autenticação." name: title: "Nome" @@ -1575,7 +1576,7 @@ pt_BR: granted_badge: "Ganhou '{{description}}'" topic_reminder: "{{username}} {{description}}" watching_first_post: "Novo Tópico {{description}}" - membership_request_accepted: "Afiliação aceita em '{{group_name}}'" + membership_request_accepted: "Afiliação aceita em '{{group_name}}'" group_message_summary: one: "{{count}} mensagem na caixa de entrada de {{group_name}}" other: "{{count}} mensagens na caixa de entrada de {{group_name}}" @@ -2357,7 +2358,6 @@ pt_BR: email_in_disabled: "Postar novos tópicos via e-mail está desabilitado nas Configurações do Site. Para habilitar respostas em novos tópicos via e-mail," email_in_disabled_click: 'habilitar a configuração de "e-mail em".' mailinglist_mirror: "Categoria espelha uma lista de discussão" - suppress_from_latest: "Suprimir categoria dos tópicos mais recentes." show_subcategory_list: "Exibir lista de subcategorias acima dos tópicos nesta categoria." num_featured_topics: "Número de tópicos exibidos na página de Categorias:" subcategory_num_featured_topics: "Número de tópicos em destaque na página da categoria pai:" @@ -2729,6 +2729,7 @@ pt_BR: changed: "etiquetas alteradas:" tags: "Etiquetas" choose_for_topic: "etiquetas opcionais" + add_synonyms: "Adicionar" delete_tag: "Apagar marcação" delete_confirm: one: "Tem certeza de que deseja excluir esta tag e removê-la de um tópico para o qual ela está atribuída?" @@ -3646,7 +3647,7 @@ pt_BR: upload_successful: "Upload successful. Words have been added." test: button_label: "Testar" - modal_title: "Teste '%{action}' palavras assistidas" + modal_title: "Teste '%{action}' palavras assistidas" description: "Digite o texto abaixo para verificar as correspondências com palavras observadas" found_matches: "Encontros encontrados:" no_matches: "Nenhuma correspondência encontrada" @@ -3994,7 +3995,7 @@ pt_BR: enabled: Habilitar emblema icon: Ícone image: Imagem - icon_help: "Digite um nome de ícone do tipo Font Awesome (use o prefixo 'far-' para ícones regulares e 'fab-' para ícones de marca)" + icon_help: "Digite um nome de ícone do tipo Font Awesome (use o prefixo 'far-' para ícones regulares e 'fab-' para ícones de marca)" image_help: "Digite o URL da imagem (substitui o campo de ícone se ambos estiverem configurados)" query: Badge Query (SQL) target_posts: Consultar respostas selecionadas @@ -4050,21 +4051,15 @@ pt_BR: category: "Postar na Categoria" add_host: "Adicionar Host" settings: "Configurações de Incorporação" - feed_settings: "Configurações de Feed" - feed_description: "Prover um feed de RSS/ATOM de seu site pode melhorar a habilidade do Discourse para importar seu conteúdo." crawling_settings: "Configurações de Crawler" crawling_description: "Quando Discourse cria tópicos para suas postagens, se nenhum feed RSS/ATOM estiver presente ele tentar recuperar o conteúdo do seu HTML. Algumas vezes isto pode sem um desafio, então provemos a habilidade de prover as regras específicas de CSS para fazer a extração mais fácil." embed_by_username: "Nome de usuário para criação do tópico" embed_post_limit: "Número máximo de postagens para incorporar" - embed_username_key_from_feed: "Chave para obter o nome de usuário no discourse do feed" embed_title_scrubber: "Expressão regular usada para higienizar o título das publicações" embed_truncate: "Truncar as postagens incorporadas" embed_whitelist_selector: "Seletor de CSS para elementos que são permitidos na incorporação" embed_blacklist_selector: "Seletor de CSS para elementos que são removidos da incorporação" embed_classname_whitelist: "Nomes de classes CSS permitidas" - feed_polling_enabled: "Importar postagens via RSS/ATOM" - feed_polling_url: "URL do feed RSS/ATOM para pesquisar" - feed_polling_frequency_mins: "Frequência de pesquisa de feeds (em minutos)" save: "Salvar Configurações de Incorporação" permalink: title: "Links permanentes" diff --git a/config/locales/client.ro.yml b/config/locales/client.ro.yml index 519f0d0fda..d7653bd9aa 100644 --- a/config/locales/client.ro.yml +++ b/config/locales/client.ro.yml @@ -2298,6 +2298,7 @@ ro: changed: "etichete schimbate:" tags: "Etichete" choose_for_topic: "etichete opționale" + add_synonyms: "Adaugă" delete_tag: "Șterge etichetă" delete_confirm: one: "Ești sigur că vrei să ștergi această etichetă și să o scoți dintr-un subiect care o folosește?" @@ -3300,20 +3301,15 @@ ro: category: "Postează în categoria" add_host: "Adaugă host" settings: "Setări pentru embeding" - feed_settings: "Setări feed" - feed_description: "Furnizând un feed RSS/ATOM pentru site-ul tau, poți să îmbunătățești capacitatea Discourse de a-ți importa conținutul." crawling_settings: "Setări roboți de căutare" crawling_description: "Când Discourse creează subiecte pentru postările tale, dacă nu este prezent nici un feed RSS/ATOM, va încerca să extragă conținutul din codul HTML. Uneori pot apărea probleme la extragerea conținutului, așa că îți dăm posibilitatea să specifici regulile CSS pentru a ușura extracția." embed_by_username: "Nume utilizator pentru creare subiect" embed_post_limit: "Numărul maxim de postări de încorporat." - embed_username_key_from_feed: "Tastă pentru a retrage nume utilizator discourse din feed" embed_title_scrubber: "Expresie obișnuită pentru a curăța titlurile postărilor" embed_truncate: "Scurtează postările embedded." embed_whitelist_selector: "Selector CSS pentru elemente care nu sunt permise în embeds." embed_blacklist_selector: "Selector CSS pentru elemente care sunt șterse din emebds." embed_classname_whitelist: "Nume de clase CSS permise" - feed_polling_enabled: "Importă postări via RSS/ATOM" - feed_polling_url: "URL-ul feed-ului RSS/ATOM pentru indexare" save: "Salvați setările pentru embeding" permalink: title: "Adrese permanente" diff --git a/config/locales/client.ru.yml b/config/locales/client.ru.yml index c97588fcff..1830504bf9 100644 --- a/config/locales/client.ru.yml +++ b/config/locales/client.ru.yml @@ -827,6 +827,7 @@ ru: collapse_profile: "Свернуть" bookmarks: "Закладки" bio: "Обо мне" + timezone: "Часовой пояс" invited_by: "Пригласил" trust_level: "Уровень" notifications: "Уведомления" @@ -1437,7 +1438,7 @@ ru: second_factor_backup_description: "Введите запасной код:" second_factor: "Войти с помощью программы аутентификации" security_key_description: "Когда вы подготовите свой физический ключ безопасности, нажмите кнопку Аутентификация с ключом безопасности ниже." - security_key_alternative: "Не удается найти ключ безопасности или хотите использовать другой метод?" + security_key_alternative: "Попробуйте другой способ" security_key_authenticate: "Аутентификация с Ключом Безопасности." security_key_not_allowed_error: "Время проверки подлинности ключа безопасности истекло или было отменено." security_key_no_matching_credential_error: "В указанном ключе безопасности не найдено подходящих учетных данных." @@ -1749,6 +1750,7 @@ ru: topic_reminder: "{{username}} {{description}}" watching_first_post: "Новая тема {{description}}" membership_request_accepted: "Запрос на вступление принят '{{group_name}}'" + membership_request_consolidated: "{{count}} открытые запросы на вступления для '{{group_name}}'" group_message_summary: one: "{{count}} сообщение в вашей группе: {{group_name}} " few: "{{count}} сообщений в вашей группе: {{group_name}} " @@ -1786,6 +1788,7 @@ ru: topic_reminder: "напоминание о теме" liked_consolidated: "новые симпатии" post_approved: "сообщение утверждено" + membership_request_consolidated: "новые запросы на вступление" upload_selector: title: "Add an image" title_with_attachments: "Добавить изображение или файл" @@ -1833,6 +1836,7 @@ ru: context: user: "Искать сообщения от @{{username}}" category: "Искать в разделе #{{category}}" + tag: "Поиск по #{{tag}} тегу" topic: "Искать в этой теме" private_messages: "Искать в личных сообщениях" advanced: @@ -2605,7 +2609,6 @@ ru: email_in_disabled: "Создание новых тем через электронную почту отключено в настройках сайта. Чтобы разрешить создание новых тем через электронную почту," email_in_disabled_click: 'активируйте настройку "email in".' mailinglist_mirror: "Категория отражает список рассылки" - suppress_from_latest: "Скрыть категорию из последних тем." show_subcategory_list: "Показывать список подразделов над списком тем в этом разделе." num_featured_topics: "Количество тем на странице разделов" subcategory_num_featured_topics: "Количество избранных тем на странице родительской категории:" @@ -3015,6 +3018,26 @@ ru: changed: "Теги изменены:" tags: "Теги" choose_for_topic: "Выберите теги для этой темы (опционально)" + info: "Информация" + default_info: "Этот тег не ограничен никакими категориями и не имеет синонимов." + synonyms: "Синонимы" + synonyms_description: "При использовании следующих тегов они будут заменены на %{base_tag_name}." + tag_groups_info: + one: 'Этот тег принадлежит группе "{{tag_groups}}".' + few: "Этот тег принадлежит к этим группам: {{tag_groups}}." + many: "Этот тег принадлежит к этим группам: {{tag_groups}}." + other: "Этот тег принадлежит к этим группам: {{tag_groups}}." + category_restrictions: + one: "Его можно использовать только в этой категории:" + few: "Их можно использовать только в этой категории:" + many: "Их можно использовать только в этой категории:" + other: "Их можно использовать только в этой категории:" + edit_synonyms: "Управление Синонимами" + add_synonyms_label: "Добавить синонимы:" + add_synonyms: "Добавить" + add_synonyms_failed: "Следующие теги не могут быть добавлены в качестве синонимов: %{tag_names}. Убедитесь, что они не имеют синонимов и не являются синонимами другого тега." + remove_synonym: "Удалить Синоним" + delete_synonym_confirm: 'Вы уверены, что хотите удалить синоним "%{tag_name}"?' delete_tag: "Удалить тег" delete_confirm: one: "Вы действительно хотите удалить этот тэг и удалить его из %{count} топика, которому он присвоен?" @@ -3022,6 +3045,11 @@ ru: many: "Вы действительно хотите удалить этот тэг и удалить его из {{count}} топиков, которым он присвоен?" other: "Вы действительно хотите удалить этот тэг и удалить его из {{count}} топиков, которым он присвоен?" delete_confirm_no_topics: "Вы действительно хотите удалить этот тэг?" + delete_confirm_synonyms: + one: "Его синоним также будет удален." + few: "Его {{count}} синонимы также будут удалены." + many: "Его {{count}} синонимы также будут удалены." + other: "Его {{count}} синонимы также будут удалены." rename_tag: "Редактировать тег" rename_instructions: "Выберите новое название тега:" sort_by: "Сортировка:" @@ -3281,15 +3309,30 @@ ru: none: "Отсутствует ключ API." user: "Пользователь" title: "API" + key: "Ключ" created: Создано + updated: Обновленный last_used: Последнее Использование never_used: (никогда) generate: "Сгенерировать" + undo_revoke: "Отменить" revoke: "Отозвать" all_users: "Все пользователи" + active_keys: "Активные API  ключи" + manage_keys: Управление Ключами show_details: Детали description: Описание + no_description: (без описания) + all_api_keys: Все API ключи + user_mode: Уровень Пользователя + impersonate_all_users: Представиться как пользователь + single_user: "Отдельный Пользователь" + user_placeholder: Введите псевдоним + description_placeholder: "Для чего будет использоваться этот ключ?" save: Сохранить + new_key: Новый API ключ + revoked: Отозвать + delete: Окончательно Удалить web_hooks: title: "Webhooks" none: "Сейчас нет веб-перехватчиков." @@ -3536,6 +3579,7 @@ ru: color_scheme_select: "Выберите цвета для стиля" custom_sections: "Настройка секций:" theme_components: "Компоненты стиля" + add_all_themes: "Добавить все темы" convert: "Конвертировать" convert_component_alert: "Вы уверены, что хотите преобразовать этот компонент в тему? Он будет удален как компонент из %{relatives}." convert_component_tooltip: "Преобразовать этот компонент в тему" @@ -3568,6 +3612,9 @@ ru: edit_css_html: "Редактировать CSS/HTML" edit_css_html_help: "Вы не внесли никаких изменений в CSS или HTML" delete_upload_confirm: "Удалить этот файл? CSS стиля может перестать функционировать!" + component_on_themes: "Включить компонент по этим темам" + included_components: "Включенные компоненты" + add_all: "Добавить все" import_web_tip: "Репозиторий стиля" import_web_advanced: "Дополнительно..." import_file_tip: ".tar.gz, .zip, или .dcstyle.json файл, содержащий тему" @@ -3878,6 +3925,12 @@ ru: change_theme_setting: "изменить настройки темы" disable_theme_component: "отключить компонент темы" enable_theme_component: "включить компонент темы" + revoke_title: "отозвать название" + change_title: "изменить название" + api_key_create: "создать api ключ" + api_key_update: "обновление api ключа" + api_key_destroy: "уничтожить api ключ" + override_upload_secure_status: "перезаписать защищенный статус загрузки" screened_emails: title: "Почтовые адреса" description: "Когда кто-то создаёт новую учётную запись, проверяется данный почтовый адрес и регистрация блокируется или производятся другие дополнительные действия." @@ -4375,21 +4428,15 @@ ru: category: "Опубликовать в разделе" add_host: "Добавить хост" settings: "Настройки встраивания" - feed_settings: "Настройки Фида" - feed_description: "Поддержка RSS/ATOM вашим сайтом может улучшить возможности импорта данных." crawling_settings: "Настройки определения" crawling_description: "Если RSS/ATOM не поддерживается, то при создании тем Discourse попытается разобрать содержимое из HTML. В некоторых случаях извлечение содержимого оказывается сложным, поэтому мы предоставляем возможность задавать правила CSS, чтобы сделать извлечение проще." embed_by_username: "Имя пользователя, которое будет использоваться для создания темы" embed_post_limit: "Максимальное количество вложенных сообщений" - embed_username_key_from_feed: "Ключ для извлечения пользователя из ленты" embed_title_scrubber: "Регулярное выражение, используемое для очистки заголовка сообщений" embed_truncate: "Обрезать встроенные сообщения." embed_whitelist_selector: "Селекторы CSS которые разрешены для использования." embed_blacklist_selector: "Селекторы CSS которые запрещены для использования." embed_classname_whitelist: "Разрешённые CSS-классы" - feed_polling_enabled: "Импорт сообщений через RSS/ATOM" - feed_polling_url: "Ссылка на RSS/ATOM" - feed_polling_frequency_mins: "Частота опроса ленты (в минутах)" save: "Сохранить настройки встраивания" permalink: title: "Постоянные ссылки" diff --git a/config/locales/client.sk.yml b/config/locales/client.sk.yml index 4ee7bc6df4..b93686aaa0 100644 --- a/config/locales/client.sk.yml +++ b/config/locales/client.sk.yml @@ -2212,6 +2212,7 @@ sk: selector_no_tags: "žiadne štítky" changed: "zmenené štítky:" tags: "Štítky" + add_synonyms: "Pridať" delete_tag: "Zmazať štítok" rename_tag: "Premenovať štítok" rename_instructions: "Vyberte nové meno pre štítok:" @@ -3077,19 +3078,14 @@ sk: category: "Prispievať do kategórií" add_host: "Pridať hostiteľa" settings: "Nastavenia vkladania" - feed_settings: "Nastavenie kanálov" - feed_description: "Zadaním RSS/ATOM kanála Vašich stránok zlepší schopnosť Discourse vladať Váš obsah." crawling_settings: "Nastavenia vyhľadávača" crawling_description: "Ak Discourse vytvorí tému pre Váš príspevok a neexistuje žiadny RSS/ATOM kanál tak sa pokúsime získať Váš obsah z HTML. Získanie obsahu môže byt niekedy výzva a preto poskytujeme možnosť špecifikovať CSS pravidlá na uľahčenie získania obsahu." embed_by_username: "Používateľské meno pre vytváranie tém" embed_post_limit: "Maximálny počet vložených príspevkov" - embed_username_key_from_feed: "Kľúč na získanie používateľského mena discourse z kanála" embed_truncate: "Skrátiť vložené príspevky" embed_whitelist_selector: "CSS selector pre elementy ktoré je možné vkladať" embed_blacklist_selector: "CSS selector pre elementy ktoré nie je možné vkladať" embed_classname_whitelist: "Dovolené názvy CSS tried" - feed_polling_enabled: "importovať príspevky cez RSS/ATOM" - feed_polling_url: "URL adresa RSS/ATOM kanála na preskúmanie" save: "Uložiť Nastavenia vkladania" permalink: title: "Trvalé odkazy" diff --git a/config/locales/client.sl.yml b/config/locales/client.sl.yml index 209e36ac1e..4bf703ed07 100644 --- a/config/locales/client.sl.yml +++ b/config/locales/client.sl.yml @@ -764,6 +764,7 @@ sl: collapse_profile: "Skrči" bookmarks: "Zaznamki" bio: "O meni" + timezone: "Časovni pas" invited_by: "Povabilo od" trust_level: "Nivo zaupanja" notifications: "Obvestila" @@ -2485,7 +2486,6 @@ sl: email_in_disabled: "Objavljanje novih tem preko e-sporočila je onemogočeno v Nastavitvah strani. Za omogočanje objave novih tem preko e-sporočila, " email_in_disabled_click: 'vključite "email in" nastavitev.' mailinglist_mirror: "Kategorija zrcali poštni seznam" - suppress_from_latest: "Ne prikaži to kategorijo med najnovejšimi temami." show_subcategory_list: "Prikaži seznam podkategorij nad temami za to kategorijo." num_featured_topics: "Število tem prikazanih na seznamu kategorij:" subcategory_num_featured_topics: "Število osrednjih tem na strani nadrejene kategorije:" @@ -2895,6 +2895,7 @@ sl: changed: "spremenjene oznake:" tags: "Oznake" choose_for_topic: "neobvezne oznake" + add_synonyms: "Dodaj" delete_tag: "Izbriši oznako" delete_confirm: one: "Ali ste prepričani, da želite izbrisati to oznako in jo umaknili iz {{count}} teme s to oznako?" diff --git a/config/locales/client.sq.yml b/config/locales/client.sq.yml index a90b959f54..29ffdd9e02 100644 --- a/config/locales/client.sq.yml +++ b/config/locales/client.sq.yml @@ -1872,6 +1872,7 @@ sq: selector_no_tags: "asnjë etiketë" changed: "etiketat e ndryshuara:" tags: "Etiketat" + add_synonyms: "Shto" delete_tag: "Fshi etiketën" rename_tag: "Riemëro etiketën" rename_instructions: "Zgjidhni një emër të ri për këtë etiketë" diff --git a/config/locales/client.sr.yml b/config/locales/client.sr.yml index f274670118..0ad26ab967 100644 --- a/config/locales/client.sr.yml +++ b/config/locales/client.sr.yml @@ -1546,6 +1546,7 @@ sr: posting: name: Poruke tagging: + add_synonyms: "Dodaj" cancel_delete_unused: "Odustani" notifications: watching: diff --git a/config/locales/client.sv.yml b/config/locales/client.sv.yml index 12e9342356..52caaf536e 100644 --- a/config/locales/client.sv.yml +++ b/config/locales/client.sv.yml @@ -2012,6 +2012,7 @@ sv: selector_no_tags: "inga taggar" changed: "taggar ändrade:" tags: "Taggar" + add_synonyms: "Lägg till" delete_tag: "Radera tag" rename_tag: "Döp om taggen" rename_instructions: "Välj ett nytt namn för taggen:" @@ -2927,20 +2928,15 @@ sv: category: "Inlägg till kategori" add_host: "Lägg till värd" settings: "Inbäddningsinställningar" - feed_settings: "Flödes-inställningar" - feed_description: "Tillhandahållande av RSS/ATOM-flöde på din webbplats kan förbättra Discourse möjlighet att importera ditt innehåll." crawling_settings: "Inställningar för sökrobotar" crawling_description: "När Discourse skapar ämnen för dina inlägg, om det inte finns något RSS/ATOM-flöde närvarande så kommer det att försöka parsa ditt innehåll från din HTML. Ibland är det utmanande att extrahera ditt innehåll, så vi tillhandahåller möjligheten att specifiera CSS-reglerna för att göra extraheringen enklare." embed_by_username: "Användarnamn för skapandet av ämne" embed_post_limit: "Högsta tillåtna antal inlägg att bädda in" - embed_username_key_from_feed: "Nyckel för att hämta discourse användarnamn från flöde" embed_title_scrubber: "Reguljära uttryck som används för att hitta och korrigera fel i inläggsrubriken" embed_truncate: "Trunkera de inbäddade inläggen" embed_whitelist_selector: "CSS-väljare för element som tillåts bäddas in" embed_blacklist_selector: "CSS-väljare för element som tas bort från inbäddningar" embed_classname_whitelist: "Tillåtna CSS klassnamn" - feed_polling_enabled: "Importera inlägg via RSS/ATOM" - feed_polling_url: "URL för RSS/ATOM-flöde att webbsöka" save: "Spara inbäddningsinställningar" permalink: title: "Permalänkar" diff --git a/config/locales/client.sw.yml b/config/locales/client.sw.yml index 26727679fa..fd5feedca2 100644 --- a/config/locales/client.sw.yml +++ b/config/locales/client.sw.yml @@ -1951,7 +1951,6 @@ sw: email_in_allow_strangers: "Pokea barua pepe kutoka kwa watumiaji wasiojulikana ambao hawana akaunti" email_in_disabled: "Uchapishaji wa mada mpya kupitia barua pepe umesitishwa kwenye Mipangilio ya Tovuti. Kuruhusu uchapishaji wa mada mpya kupitia barua pepe," email_in_disabled_click: 'ruhusu mpangilio wa "barua pepe ndani"' - suppress_from_latest: "Husuru kategoria hizi kutokea kwenye mada za hivi karibuni." num_featured_topics: "Idadi ya mada zitakazo onyeshwa ndani ya ukurasa wa kategoria:" all_topics_wiki: "Hifadhi mada mpya kama chaguo msingi" sort_order: "Orodha ya Maneno Imepangwa Kulingana Na:" @@ -2240,6 +2239,7 @@ sw: changed: "lebo zilizobadilishwa:" tags: "Lebo" choose_for_topic: "lebo zisizo muhimu" + add_synonyms: "Ongeza" delete_tag: "futa lebo" delete_confirm_no_topics: "Una uhakika unataka kufuta lebo hii?" rename_tag: "Badili jina la lebo" @@ -3290,19 +3290,13 @@ sw: category: "Andika kwenye Kikundi" add_host: "Ongeza Mwenyeji" settings: "Mipangilio iliopachikwa" - feed_settings: "Mipangilio ya Taarifa nyingi" - feed_description: "Taarifa za RSS/ATOM za tovuti yako zinaweza kusaidia Discourse kuingiza taarifa zako kwa urahisi zaidi." crawling_settings: "Mipangilio ya kutembelea taarifa" crawling_description: "Discourse ikitengeneza mada za machapisho yako, kama hakuna taarifa kutoka kwa RSS/ATOM itajaribu kupata maneno kutoka kwenye HTML yako. Kuna changamoto zinazotokea wakati wa kupata hizo taarifa, tunakuruhusu uchague kanuni za CSS ili upatikanaji wa machapisho uwe rahisi zaidi." embed_by_username: "Jina la mtumiaji kwa ajili ya kutengeneza mada" embed_post_limit: "Kiwango cha Juu cha Kupachika machapisho" - embed_username_key_from_feed: "Ufunguo wa kuvuta majina ya watumiaji wa discourse kutoka kwenye taarifa nyingi" embed_title_scrubber: "Neno linalotumika kufuta vichwa vya machapisho" embed_truncate: "Fupisha machapisho yaliyopachikwa" embed_classname_whitelist: "Ruhusu majina ya madarasa ya CSS " - feed_polling_enabled: "Ingiza machapisho kupitia RSS/ATOM" - feed_polling_url: "Anwani ya mtandao ya taarifa za RSS/ATOM za kutembelea" - feed_polling_frequency_mins: "Uchaguzi wa taarifa unarudia mara (ndani ya dakika)" save: "Hifadhi Mipangilio Iliyopachikwa" permalink: title: "Anwani za mtandao" diff --git a/config/locales/client.te.yml b/config/locales/client.te.yml index 4354bc07a8..7be0f4608c 100644 --- a/config/locales/client.te.yml +++ b/config/locales/client.te.yml @@ -1287,6 +1287,7 @@ te: name: రాస్తున్నారు tagging: tags: "ట్యాగులు" + add_synonyms: "కలుపు" cancel_delete_unused: "రద్దుచేయి" notifications: watching: diff --git a/config/locales/client.th.yml b/config/locales/client.th.yml index 1e72bd7338..2c04218720 100644 --- a/config/locales/client.th.yml +++ b/config/locales/client.th.yml @@ -1471,6 +1471,7 @@ th: selector_all_tags: "แท็กทั้งหมด" changed: "เปลี่ยนแท็ก:" tags: "ป้าย" + add_synonyms: "เพิ่ม" delete_tag: "ลบแท็ก" rename_tag: "เปลี่ยนชื่อแท็ก" rename_instructions: "เลือกชื่อใหม่สำหรับแท็ก:" @@ -1945,7 +1946,6 @@ th: category: "โพสไปยังหมวดหมู่" add_host: "เพิ่มโฮส" settings: "การตั้งการการฝั่ง" - feed_settings: "การตั้งค่าฟีด" save: "บันทึกการตั้งค่าการฝั่ง" permalink: title: "ลิงค์ถาวร" diff --git a/config/locales/client.tr_TR.yml b/config/locales/client.tr_TR.yml index 97c86b0a13..6383602c99 100644 --- a/config/locales/client.tr_TR.yml +++ b/config/locales/client.tr_TR.yml @@ -95,6 +95,12 @@ tr_TR: x_days: one: "%{count} gün önce" other: "%{count} gün önce" + x_months: + one: "%{count} ay önce" + other: "%{count} ay önce" + x_years: + one: "%{count} yıl önce" + other: "%{count} yıl önce" later: x_days: one: "%{count} gün sonra" @@ -315,9 +321,25 @@ tr_TR: order_by: "Sırala" in_reply_to: "cevap olarak" explain: + why: "bu makalenin neden sıraya girdiğini açıkla" + title: "Gözden geçirilebilir Puanlama" + formula: "Formül" + subtotal: "ara toplam" total: "Toplam" + min_score_visibility: "Görünürlük için Minimum Puan" + score_to_hide: "Gönderiyi Gizlemek için Puan" + take_action_bonus: + name: "harekete geçti" + title: "Bir personel harekete geçmeyi seçtiğinde bayrağa bonus verilir." + user_accuracy_bonus: + name: "kullanıcı doğruluğu" + title: "Bayrakları tarihsel olarak kararlaştırılmış olanlara bonus verilir." trust_level_bonus: name: "güven seviyesi" + title: "Güven düzeyi yüksek kullanıcılar tarafından oluşturulan, incelenebilir öğeler daha yüksek bir puana sahiptir." + type_bonus: + name: "tür bonusu" + title: "Bazı gözden geçirilebilir türlere daha yüksek öncelikli olmaları için personel tarafından bir bonus tahsis edilebilir." claim_help: optional: "Başkalarının incelemesini engellemek için bu öğeyi talep edebilirsiniz." required: "Öğeleri inceleyebilmeniz için önce hak talebinde bulunmalısınız." @@ -725,6 +747,7 @@ tr_TR: collapse_profile: "Daralt" bookmarks: "İşaretlenenler" bio: "Hakkımda" + timezone: "Saat dilimi" invited_by: "Tarafından Davet Edildi" trust_level: "Güven Seviyesi" notifications: "Bildirimler" @@ -856,6 +879,7 @@ tr_TR: copied_to_clipboard: "Panoya kopyalandı" copy_to_clipboard_error: "Panoya kopyalanırken hata oluştu" remaining_codes: "{{count}} yedek kodun kaldı " + use: "Bir yedekleme kodu kullanın" enable_prerequisites: "Yedek kodları oluşturmadan önce birincil ikinci faktörü etkinleştirmelisiniz." codes: title: "Yedek kod oluşturuldu" @@ -863,6 +887,7 @@ tr_TR: second_factor: title: "İki Faktörlü Kimlik Doğrulama" enable: "İki Adımlı Kimlik Doğrulamayı Düzenle" + forgot_password: "Şifrenizi mi unuttunuz?" confirm_password_description: "Devam etmek için lütfen şifrenizi onaylayın" name: "İsim" label: "Kod" @@ -876,6 +901,7 @@ tr_TR: extended_description: | İki faktörlü kimlik doğrulama, şifrenize ek olarak bir kerelik bir belirteç gerektirerek hesabınıza ekstra güvenlik sağlar. Tokenler Android ve iOS cihazlarda yaratılabilir. oauth_enabled_warning: "Hesabınızda iki faktörlü kimlik doğrulaması etkinleştirildikten sonra sosyal girişlerin devre dışı bırakılacağını lütfen unutmayın." + use: "Authenticator uygulamasını kullan" enforced_notice: "Bu siteye erişmeden önce iki faktörlü kimlik doğrulamasını etkinleştirmeniz gerekir." disable: "devre dışı bırak" disable_title: "İkinci Faktör İnaktif" @@ -883,12 +909,20 @@ tr_TR: edit: "Düzenle" edit_title: "İkinci Faktörü Düzenle" edit_description: "İkinci Faktör Adı" + enable_security_key_description: "Fiziksel güvenlik anahtarınızı hazırladığınızda, aşağıdaki Kayıt düğmesine basın." totp: title: "Token Tabanlı Doğrulayıcılar" add: "Yeni Doğrulayıcı" default_name: "Benim Doğrulayıcım" security_key: register: "Kayıt Ol" + title: "Güvenlik Sözcükleri" + add: "Güvenlik Anahtarını Kaydedin" + default_name: "Ana Güvenlik Anahtarı" + not_allowed_error: "Güvenlik anahtarı kayıt işlemi zaman aşımına uğradı veya iptal edildi." + already_added_error: "Bu güvenlik anahtarın daha önce kaydettiğiniz için tekrar kaydetmeniz gerekmez." + edit: "Güvenlik Anahtarını Düzenle" + edit_description: "Güvenlik Anahtarı Adı" delete: "Sil" change_about: title: "\"Hakkımda\"yı Değiştir" @@ -915,6 +949,9 @@ tr_TR: uploaded_avatar_empty: "Kişisel bir resim ekle" upload_title: "Resmini yükle" image_is_not_a_square: "Uyarı: Genişliği ve yüksekliği eşit olmadığı için görseli kesmek durumunda kaldık." + change_profile_background: + title: "Profil Başlığı" + instructions: "Profil başlıkları ortalanacak ve varsayılan olarak 1110 piksel boyutunda olacaktır." change_card_background: title: "Kullanıcı Kartı Arkaplanı" instructions: "Profil arkaplanları ortalanacak ve genişliği 590px olacak. " @@ -1283,8 +1320,16 @@ tr_TR: password: "Şifre" second_factor_title: "İki Faktörlü Kimlik Doğrulama" second_factor_description: "Lütfen uygulamadan \"Kimlik Doğrulama Kodu\"nu gir:" + second_factor_backup: "Bir yedekleme kodu kullanarak giriş yapın" second_factor_backup_title: "İki Faktörlü Yedekleme" second_factor_backup_description: "Lütfen yedek kodlarından birini gir:" + second_factor: "Authenticator uygulamasını kullanarak giriş yapın" + security_key_description: "Fiziksel güvenlik anahtarınızı hazırladığınızda, aşağıdaki Güvenlik Anahtarıyla Kimlik Doğrula düğmesine basın." + security_key_alternative: "Başka bir yol dene" + security_key_authenticate: "Güvenlik Anahtarı ile Kimlik Doğrulama" + security_key_not_allowed_error: "Güvenlik anahtarı kimlik doğrulama işlemi zaman aşımına uğradı veya iptal edildi." + security_key_no_matching_credential_error: "Sağlanan güvenlik anahtarında eşleşen kimlik bilgisi bulunamadı." + security_key_support_missing_error: "Geçerli cihazınız veya tarayıcınız güvenlik tuşlarının kullanımını desteklemiyor. Lütfen farklı bir yöntem kullanın." email_placeholder: "e-posta veya kullanıcı adı" caps_lock_warning: "Caps Lock açık" error: "Bilinmeyen hata" @@ -1332,6 +1377,9 @@ tr_TR: discord: name: "Discord" title: "Discord ile" + second_factor_toggle: + totp: "Bunun yerine bir doğrulama uygulaması kullanın" + backup_code: "Bunun yerine bir yedekleme kodu kullanın" invites: accept_title: "Davet" welcome_to: "%{site_name} hoş geldin!" @@ -1349,6 +1397,7 @@ tr_TR: apple_international: "Apple/Uluslararası" google: "Google" twitter: "Twitter" + emoji_one: "JoyPixels (eski adıyla EmojiOne)" win10: "Win10" google_classic: "Google Classic" facebook_messenger: "Facebook Messenger" @@ -1433,14 +1482,17 @@ tr_TR: cannot_see_mention: category: "{{username}} adlı kullanıcıdan bahsettin fakat bildirim gönderilmeyecek çünkü kullanıcının bu kategoriye ulaşma izni yok. Kullanıcının bildirimi görebilmesi için onu bu gruba eklemen gerekiyor. " private: "{{username}} adlı kullanıcıdan bahsettin fakat bildirim gönderilmeyecek çünkü kullanıcının bu kişisel mesaja ulaşma izni yok. Kişisel mesaja ulaşabilmesi için kullanıcıyı PM'ye eklemen gerekiyor. " + reference_topic_title: "RE: {{title}}" error: title_missing: "Başlık gerekli" title_too_short: "Başlık en az {{min}} karakter olmalı" title_too_long: "Başlık {{max}} karakterden daha uzun olamaz" + post_missing: "Gönderi boş olamaz" post_length: "Gönderi en az {{min}} karakter olmalı" try_like: "{{heart}} düğmesini denediniz mi?" category_missing: "Bir kategori seçmelisin" tags_missing: "En azından {{count}} etiket seçmelisin" + topic_template_not_modified: "Lütfen konu şablonunu düzenleyerek daha fazla ayrıntı ekleyin." save_edit: "Değişikliği Kaydet" overwrite_edit: "Üzerine Yaz" reply_original: "Asıl konu üzerinden cevap ver" @@ -1480,6 +1532,7 @@ tr_TR: link_description: "buraya bağlantı açıklamasını gir" link_dialog_title: "Hyperlink ekle" link_optional_text: "isteğe bağlı başlık" + link_url_placeholder: "Arama konularına bir URL yapıştırın veya yazın" quote_title: "Blok-alıntı" quote_text: "Blok-alıntı" code_title: "Önceden biçimlendirilmiş yazı" @@ -1528,6 +1581,7 @@ tr_TR: label: "Paylaşılan Taslak" desc: "Sadece görevli tarafından görülebilecek bir konu tasarla" toggle_topic_bump: + label: "Konu detaylarını değiştir" desc: "Son cevap tarihini değiştirmeden yanıtla" notifications: tooltip: @@ -1553,6 +1607,9 @@ tr_TR: liked_many: one: "{{username}}, {{username2}} ve {{count}} diğer {{description}}" other: "{{username}}, {{username2}} ve {{count}} diğer {{description}}" + liked_consolidated_description: + one: "gönderilerinizden {{count}} tanesi beğenildi" + other: "gönderilerinizden {{count}} tanesi beğenildi" liked_consolidated: "{{username}} {{description}}" private_message: "{{username}} {{description}}" invited_to_private_message: "

{{username}} {{description}}" @@ -1563,6 +1620,11 @@ tr_TR: granted_badge: "'{{description}}' kazandı" topic_reminder: "{{username}} {{description}}" watching_first_post: "Yeni Konu {{description}}" + membership_request_accepted: "'{{group_name}}' üyeliğine kabul edildi" + membership_request_consolidated: "{{count}} '{{group_name}}' için açık üyelik talepleri" + group_message_summary: + one: "{{group_name}} gelen kutunuzdaki {{count}} mesaj var" + other: "{{group_name}} gelen kutunuzda {{count}} mesajları" popup: mentioned: '{{username}}, "{{topic}}" başlıklı konuda sizden bahsetti - {{site_title}}' group_mentioned: '{{username}} sizden bahsetti "{{topic}}" - {{site_title}}' @@ -1592,6 +1654,7 @@ tr_TR: topic_reminder: "konu hatırlatıcısı" liked_consolidated: "yeni beğeniler" post_approved: "gönderi onaylandı" + membership_request_consolidated: "Yeni üyelik talepleri" upload_selector: title: "Resim ekle" title_with_attachments: "Resim ya da dosya ekle" @@ -1637,6 +1700,7 @@ tr_TR: context: user: "@{{username}} kullancısına ait gönderilerde ara" category: "#{{category}} kategorisini ara" + tag: "# {{tag}} etiketini arayın" topic: "Bu konuyu ara" private_messages: "Mesajlarda ara" advanced: @@ -2137,8 +2201,10 @@ tr_TR: attachment_upload_not_allowed_for_new_user: "Üzgünüz, yeni kullanıcılar dosya yükleyemez." attachment_download_requires_login: "Üzgünüz, eklentileri indirebilmek için giriş yapman gerekiyor." abandon_edit: + confirm: "Değişikliklerinizi silmek istediğinizden emin misiniz?" no_value: "Hayır, kalsın" no_save_draft: "Hayır, taslağı kaydet" + yes_value: "Evet, düzenlemeyi iptal et" abandon: confirm: "Gönderinden vazgeçtiğine emin misin?" no_value: "Hayır, kalsın" @@ -2281,6 +2347,9 @@ tr_TR: tag_groups_placeholder: "(Seçmeli) izin verilen etiket gruplarının listesi" manage_tag_groups_link: "Etiket gruplarını burada yönetin." allow_global_tags_label: "Diğer etiketlere de izin ver" + tag_group_selector_placeholder: "(Opsiyonel) Etiket grubu" + min_tags_from_required_group_label: "Etiketler:" + required_tag_group_label: "Etiket grubu:" topic_featured_link_allowed: "Bu kategoride özellikli bağlantılara izin ver" delete: "Kategoriyi Sil" create: "Yeni Kategori" @@ -2316,7 +2385,6 @@ tr_TR: email_in_disabled: "E-posta üzerinden yeni konu oluşturma özelliği Site Ayarları'nda devre dışı bırakılmış. E-posta üzerinden yeni konu oluşturma özelliğini etkinleştirmek için," email_in_disabled_click: '"e-posta" ayarını etkinleştir' mailinglist_mirror: "Kategori bir e-posta listesini yansıtır" - suppress_from_latest: "Kategoriyi son konulardan gizle" show_subcategory_list: "Bu kategorideki alt kategori listesini üst başlıklarda göster" num_featured_topics: "Kategoriler sayfasında gösterilen konu sayısı:" subcategory_num_featured_topics: "Üst kategori sayfasındaki öne çıkan konuların sayısı:" @@ -2575,6 +2643,9 @@ tr_TR: keyboard_shortcuts_help: shortcut_key_delimiter_comma: "," shortcut_key_delimiter_plus: "+" + shortcut_delimiter_or: "%{shortcut1} veya %{shortcut2}" + shortcut_delimiter_slash: "%{shortcut1}/%{shortcut2}" + shortcut_delimiter_space: "%{shortcut1} %{shortcut2}" title: "Klavye Kısayolları" jump_to: title: "Şuraya git" @@ -2679,6 +2750,7 @@ tr_TR: changed: "değişen etiketler:" tags: "Etiketler" choose_for_topic: "opsiyonel etiketler" + add_synonyms: "Ekle" delete_tag: "Etiketi Sil" delete_confirm_no_topics: "Bu etiketi silmek istediğinize emin misiniz?" rename_tag: "Etiketi Yeniden Adlandır" @@ -2726,6 +2798,7 @@ tr_TR: parent_tag_description: "Bu gruptaki etiketler üst etiket olduğu sürece kullanılamaz." one_per_topic_label: "Bu etiket grubundan her konu için bir etiket ile sınır koy" new_name: "Yeni Etiket Grubu" + name_placeholder: "Etiket Grubu Adı" save: "Kaydet" delete: "Sil" confirm_delete: "Bu etiket grubunu silmek istediğine emin misin?" @@ -2764,6 +2837,7 @@ tr_TR: tags: remove_muted_tags_from_latest: always: "her zaman" + only_muted: "tek başına veya diğer sessize alınmış etiketlerle kullanıldığında" never: "asla" reports: title: "Mevcut raporlar listesi" @@ -2888,6 +2962,7 @@ tr_TR: membership: automatic: Otomatik trust_levels_title: "Güven seviyesi, üyelere -eklendiklerinde- otomatik olarak verilir:" + effects: Etkileri trust_levels_none: "Hiçbiri" automatic_membership_email_domains: "Listede, e-posta alan adıyla tam olarak eşleşen kayıtlı kullanıcılar, otomatik olarak bu gruba eklenir:" automatic_membership_retroactive: "Mevcut kayıtlı kullanıcıları eklemek için aynı e-posta alanı kuralını uygula" @@ -2918,13 +2993,30 @@ tr_TR: none: "Şu an etkin API anahtarı bulunmuyor." user: "Kullanıcı" title: "API" + key: "anahtar" created: Oluşturuldu + updated: Güncellenmiş + last_used: Son kullanılan + never_used: (asla) generate: "Oluştur" + undo_revoke: "İptal Etmeyi Geri Al" revoke: "İptal Et" all_users: "Tüm Kullanıcılar" + active_keys: "Aktif API Anahtarları" + manage_keys: Anahtarları Yönet show_details: Detaylar description: Açıklama + no_description: (açıklama yok) + all_api_keys: Tüm API Anahtarları + user_mode: Kullanıcı Seviyesi + impersonate_all_users: Herhangi bir kullanıcı rolüne gir + single_user: "Tekil kullanıcı" + user_placeholder: Kullanıcı adı girin + description_placeholder: "Bu anahtar ne için kullanılacak?" save: Kaydet + new_key: Yeni API Anahtarı + revoked: İptal edilmiş + delete: Kalıcı Sil web_hooks: title: "Web Kancaları" none: "Şu anda bir web kancası yok." @@ -2982,6 +3074,11 @@ tr_TR: queued_post_event: name: "Etkinlik Onayı Gönderisi" details: "Yeni bir kuyruklu posta oluşturulduğunda, onaylanan veya reddedilen." + reviewable_event: + name: "Görüntülenebilen Etkinlik" + notification_event: + name: "Bildirim etkinliği" + details: "Bir kullanıcı yayınında bir bildirim aldığında." delivery_status: title: "Teslim Durumu" inactive: "Aktif Değil" @@ -3080,6 +3177,7 @@ tr_TR: confirm: "Veritabanını çalışan son haline döndürmek istediğine emin misin?" location: local: "Yerel Depolama" + s3: "S3" export_csv: success: "Dışa aktarma işlemi başlatıldı. İşlem tamamlandığında mesajla bilgilendirileceksin." failed: "Dışa aktarımda bir hata oluştu. Lütfen kayıtları kontrol et." @@ -3122,6 +3220,13 @@ tr_TR: revert_confirm: "Değişiklikleri geri almak istediğine emin misin?" theme: theme: "Tema" + component: "Bileşen" + components: "Bileşenler" + theme_name: "Tema adı" + component_name: "Bileşen Adı" + themes_intro: "Başlamak için mevcut bir tema seçin veya yeni bir tema\n yükleyin" + beginners_guide_title: "Başlangıç Temalarını Kullanma Kılavuzu" + developers_guide_title: "Forum Temaları için Geliştirici kılavuzu" browse_themes: "Topluluk temalarına göz atın" customize_desc: "Kişiselleştir:" title: "Temalar" @@ -3131,19 +3236,35 @@ tr_TR: long_title: "Sitenin renklerini, CSS ve HTML içeriğini değiştir" edit: "Düzenle" edit_confirm: "Bu, uzak bir temadır. Eğer CSS / HTML'yi düzenlersen, temayı güncellediğinde değişiklikler silinir." + update_confirm: "Bu yerel değişiklikler güncelleme ile silinecektir. Devam etmek istediğine emin misin?" + update_confirm_yes: "Evet, güncellemeye devam et" common: "Ortak" desktop: "Masaüstü" mobile: "Mobil" settings: "Ayarlar" translations: "Çeviriler" + extra_scss: "Ekstra SCSS" preview: "Önizleme" + show_advanced: "Gelişmiş klasörleri göster" + hide_advanced: "Gelişmiş klasörleri gizle" + hide_unused_fields: "Kullanılmayan alanları gizle" is_default: "Tema varsayılan olarak etkinleştirildi" user_selectable: "Tema kullanıcılar tarafından seçilebilir" color_scheme: "Renk Paleti" color_scheme_select: "Temada kullanılacak renkleri seç" custom_sections: "İsteğe uyarlanmış bölümler:" theme_components: "Tema Öğeleri" + add_all_themes: "Tüm temaları ekle" convert: "Dönüştür" + convert_component_tooltip: "Bu bileşeni temaya dönüştür" + convert_theme_tooltip: "Bu temayı bileşene dönüştür" + inactive_themes: "Etkin olmayan temalar:" + inactive_components: "Kullanılmayan bileşenler:" + broken_theme_tooltip: "Bu temanın CSS, HTML veya YAML kodlarında hata var" + disabled_component_tooltip: "Bu bileşen inaktif" + default_theme_tooltip: "Bu tema sitenin varsayılan temasıdır" + updates_available_tooltip: "Bu tema için güncellemeler var" + and_x_more: "ve {{count}} tane daha." collapse: Daralt uploads: "Yüklemeler" no_uploads: "Fontlar ve resimler gibi temayla ilişkili varlıkları yükleyebilirsin" @@ -3156,27 +3277,40 @@ tr_TR: no_overwrite: "Geçersiz değişken adı. Mevcut bir değişkenin üzerine yazılmamalıdır." must_be_unique: "Geçersiz değişken adı. Benzersiz olmalıdır." upload: "Yükle" + select_component: "Bir bileşen seçin..." + unsaved_changes_alert: "Değişikliklerinizi henüz kaydetmediniz, Silmek ve devam etmek istediğinize emin misiniz?" discard: "At" stay: "Kalmak" css_html: "İsteğe uyarlanmış CSS/HTML" edit_css_html: "CSS/HTML Düzenle" edit_css_html_help: "Herhangi bir CSS veya HTML düzenlemedin" delete_upload_confirm: "Yükleme silinsin mi?(Tema CSS çalışmayı durdurabilir!)" + component_on_themes: "Bu temalara bileşen ekle" import_web_tip: "Veri havuzu içeren tema" import_web_advanced: "Gelişmiş..." + import_file_tip: "tema içeren .tar.gz, .zip veya .dcstyle.json dosyası" is_private: "Tema özel bir git veri havuzunda" remote_branch: "Şube adı (isteğe bağlı)" public_key: "Repo'ya aşağıdaki genel anahtar erişimini ver:" install: "Yükle" installed: "Yüklendi" install_popular: "Gözde" + install_upload: "Cihazınızdan" + install_git_repo: "Git deposundan" install_create: "Yeni oluştur" about_theme: "Hakkında" license: "Lisans" version: "Versiyon:" + authors: "Yazan:" source_url: "Kaynak" enable: "Etkinleştir" disable: "Devre dışı bırak" + disabled: "Bu bileşen devre dışı bırakıldı." + disabled_by: "Bu bileşeni devre dışı bırakan kişi" + required_version: + error: "Bu tema otomatik olarak devre dışı bırakıldı çünkü forum bu sürüm ile uyumlu değil." + minimum: "Forum sürümü {{version}} veya üstünü gerektirir." + maximum: "Forum sürümü {{version}} veya daha düşük gerektirir." component_of: "Bileşen:" update_to_latest: "Sona Doğru Güncelle" check_for_updates: "Güncellemeleri kontrol et" @@ -3185,6 +3319,14 @@ tr_TR: add: "Ekle" theme_settings: "Tema Ayarları" no_settings: "Bu temada ayarlar mevcut değil." + theme_translations: "Tema Çevirileri" + empty: "Öğe yok" + commits_behind: + one: "%{count} temalar geride kaldı!" + other: "{{count}} tema geride kaldı!" + compare_commits: "(Yeni yorumlara bakınız)" + repo_unreachable: "Bu temanın Git deposuyla bağlantı kurulamadı. Hata mesajı:" + imported_from_archive: "Bu tema bir .zip dosyasından içe aktarıldı" scss: text: "CSS" title: "İsteğe uyarlanmış CSS'yi gir. Tüm geçerli CSS ve SCSS tiplerini kabul ediyoruz. " @@ -3210,11 +3352,20 @@ tr_TR: text: "YAML" title: "Tema ayarlarını YAML formatında tanımla" colors: + select_base: + title: "Temel renk paletini seç" + description: "Temel palet:" title: "Renkler" + edit: "Renk Paletlerini Düzenle" + long_title: "Renk Paletleri" + about: "Temalarında kullandığın renkleri değiştir. Başlamak için yeni bir renk paleti oluştur." + new_name: "Yeni Renk Paleti" copy_name_prefix: "Kopyası" + delete_confirm: "Bu renk paletini sil?" undo: "geri al" undo_title: " Son kayıt esnasında yapılan bu renkteki değişiklikleri geri al." revert: "eski haline getir" + revert_title: "Bu rengi Discourse'un varsayılan renk paletine sıfırla." primary: name: "Ana" description: "Çoğu yazı, ikonlar ve kenarlar" @@ -3245,13 +3396,20 @@ tr_TR: love: name: "sevgi" description: "Beğen düğmesinin rengi." + robots: + title: "Sitenizin robots.txt dosyasını geçersiz kılın:" + warning: "Bu, ilgili tüm site ayarlarını kalıcı olarak geçersiz kılar." email_style: css: "CSS" + reset: "Varsayılana sıfırla" + save_error_with_reason: "Değişiklikleriniz kaydedilmedi. %{error}" email: title: "E-postalar" settings: "Ayarlar" templates: "Şablonlar" preview_digest: "Özeti Önizle" + advanced_test: + email: "Orjinal ileti" sending_test: "Test e-postası gönderiliyor..." error: "HATA - %{server_error}" test_error: "Test e-postasının gönderilmesinde sorun yaşandı. Lütfen e-posta ayarlarını tekrar kontrol et, yer sağlayıcının e-posta bağlantılarını engellemediğinden emin ol ve tekrar dene." @@ -3318,6 +3476,7 @@ tr_TR: silence_user: "Kullanıcı Susturuldu" delete_post: "Gönderi Silindi" delete_topic: "Konu Silindi" + post_approved: "Gönderi Onaylandı" logs: title: "Kayıtlar" action: "Eylem" @@ -3409,6 +3568,17 @@ tr_TR: change_badge: "rozet değiştir" delete_badge: "rozet sil" merge_user: "kullanıcıyı birleştir" + change_name: "isim değiştir" + approve_user: "onaylanmış kullanıcı" + web_hook_create: "webhook oluştur" + web_hook_update: "webhook'u güncelle" + web_hook_destroy: "webhook'u yok et" + web_hook_deactivate: "webhook'u devre dışı bırak" + change_title: "başlığı değiştir" + api_key_create: "api anahtarı oluştur" + api_key_update: "api anahtarını güncelleştir" + api_key_destroy: "api anahtarını yok et" + override_upload_secure_status: "yükleme güvenlik durumunu geçersiz kıl" screened_emails: title: "Taranmış E-postalar" description: "Biri yeni bir hesap oluşturmaya çalıştığında aşağıdaki e-posta adresleri kontrol edilecek ve kayıt önlenecek ya da başka bir eylem gerçekleşecek." @@ -3456,6 +3626,7 @@ tr_TR: search: "ara" clear_filter: "Temizle" show_words: "kelimeleri göster" + one_word_per_line: "Satır başına bir kelime" download: İndir clear_all: Tümünü Temizle word_count: @@ -3478,6 +3649,7 @@ tr_TR: add: "Ekle" success: "Başarılı" exists: "Zaten mevcut" + upload: "Dosyadan ekle" upload_successful: "Yükleme başarılı oldu. Kelimeler eklendi." test: button_label: "Deneme" @@ -3493,7 +3665,9 @@ tr_TR: last_emailed: "Son E-posta Gönderimi" not_found: "Üzgünüz, bu kullanıcı adı sistemimizde bulunmuyor. " id_not_found: "Üzgünüz, bu kullanıcı kimliği sistemimizde bulunmuyor." + active: "Etkinleştirildi" show_emails: "E-postaları Göster" + hide_emails: "E-postaları gizle" nav: new: "Yeni" active: "Aktif" @@ -3545,8 +3719,11 @@ tr_TR: suspended_until: "(%{until} a kadar)" cant_suspend: "Bu kullanıcı askıya alınamaz." delete_all_posts: "Tüm gönderileri sil" + delete_posts_progress: "Gönderiler siliniyor..." + delete_posts_failed: "Gönderiler silinirken bir sorun oluştu." penalty_post_actions: "İlişkili gönderi ile ne yapmak istiyorsun? " penalty_post_delete: "Gönderiyi sil" + penalty_post_delete_replies: "Bu gönderi ve bütün cevapları sil" penalty_post_edit: "Gönderiyi düzenle" penalty_post_none: "Hiçbir şey yapma" penalty_count: "Ceza Sayımı" @@ -3669,6 +3846,8 @@ tr_TR: likes_received: "Alınan Beğeniler" likes_received_days: "Alınan beğeniler: Benzersiz günlerde" likes_received_users: "Alınan beğeniler: Benzersiz kullanıcılar" + suspended: "Askıya alındı (6 ay)" + silenced: "Susturuldu (6 ay)" qualifies: "Güven seviyesi 3 için hak kazanan" does_not_qualify: "Güven seviyesi 3 için yeterli değil." will_be_promoted: "Yakında yükseltilecek." @@ -3738,6 +3917,7 @@ tr_TR: clear_filter: "Temizle" add_url: "URL ekle" add_host: "sunucu ekle" + add_group: "grup ekle" uploaded_image_list: label: "Listeyi düzenle" empty: "Henüz hiç resim yok. Lütfen bir tane yükle." @@ -3774,6 +3954,8 @@ tr_TR: search: "Arama" groups: "Gruplar" dashboard: "Gösterge Paneli" + secret_list: + invalid_input: "Giriş alanları boş olamaz veya özel karakter içeremez." default_categories: modal_yes: "Evet" badges: @@ -3863,21 +4045,15 @@ tr_TR: category: "Kategoriye Gönder" add_host: "Sunucu Ekle" settings: "Yerleştirme Ayarları" - feed_settings: "Yayın Ayarları" - feed_description: "Site için bir RSS/ATOM yayını sağlaman Discourse'un içeriği aktarma kabiliyetini geliştirmesini sağlar." crawling_settings: "Yazı Ayarları" crawling_description: "Discourse gönderilerin için bir konu oluşturduğunda eğer bir RSS/ATOM beslemesi yoksa içeriği HTML'den ayrıştırmaya çalışacaktır. Bazen içeriğini çıkartmak zor olabilir. İçeriğini kolayca çıkartabilmen için CSS kuralları belirtme yeteneği sağlıyoruz." embed_by_username: "Konu oluşturmak için kullanıcı adı" embed_post_limit: "Yerleştirmek için en fazla gönderi sayısı" - embed_username_key_from_feed: "Kullanıcı adını yayımdan çıkarmak için anahtar" embed_title_scrubber: "Gönderilerin başlığını temizlemek için kullanılan düzenli ifade" embed_truncate: "Saklı gönderileri kırp" embed_whitelist_selector: "Gömülü olarak izin verilen öğeler için CSS seçici" embed_blacklist_selector: "Gömülmüş öğelerden kaldırılan öğeler için CSS seçici" embed_classname_whitelist: "İzin verilen CSS sınıfı isimleri" - feed_polling_enabled: "RSS/ATOM ile gönderileri içe aktar" - feed_polling_url: "Taranacak RSS / ATOM yayın URL'si" - feed_polling_frequency_mins: "Besleme yoklama sıklığı (dakika cinsinden)" save: "Gömme Ayarlarını Kaydet" permalink: title: "Kalıcı Bağlantılar" @@ -3895,7 +4071,10 @@ tr_TR: add: "Ekle" filter: "Ara (URL veya Harici URL)" reseed: + action: + label: "Metni Değiştir…" modal: + title: "Metni Değiştir" categories: "Kategoriler" topics: "Konular" replace: "Değiştir" diff --git a/config/locales/client.uk.yml b/config/locales/client.uk.yml index a7db0a732b..c31bc7415e 100644 --- a/config/locales/client.uk.yml +++ b/config/locales/client.uk.yml @@ -827,6 +827,7 @@ uk: collapse_profile: "Згорнути" bookmarks: "Закладки" bio: "Про мене" + timezone: "Часовий пояс" invited_by: "Запрошений(а)" trust_level: "Рівень довіри" notifications: "Сповіщення" @@ -1437,7 +1438,6 @@ uk: second_factor_backup_description: "Введіть запасний код:" second_factor: "Увійти за допомогою програми аутентифікації" security_key_description: "Коли ви підготуєте свій фізичний ключ безпеки, натисніть кнопку Аутентификация з ключем безпеки нижче." - security_key_alternative: "Неможливо знайти ключ безпеки або хочете використовувати інший метод?" security_key_authenticate: "Аутентифікація з Ключем Безпеки." security_key_not_allowed_error: "Час перевірки автентичності ключа безпеки минув або було скасовано." security_key_no_matching_credential_error: "У зазначеному ключі безпеки не знайдено відповідних облікових даних." @@ -2156,7 +2156,7 @@ uk: visible: "Включити в списки" reset_read: "Скинути дані про прочитаність" make_public: "Зробити тему публічною" - make_private: "Написати особисте повідомлення" + make_private: "Особисте повідомлення" reset_bump_date: "Скинути дату підняття" feature: pin: "Закріпити тему" @@ -2311,7 +2311,7 @@ uk: many: "Будь ласка, виберіть нового власника для повідомлень @{{old_user}}" other: "Будь ласка, виберіть нового власника {{count}} повідомлень для @{{old_user}}" change_timestamp: - title: "Змінити часову мітку..." + title: "Змінити дату..." action: "змінити часову мітку" invalid_timestamp: "Тимчасова мітка не може бути в майбутньому" error: "При зміні часової мітки теми виникла помилка" @@ -2454,7 +2454,7 @@ uk: revert_to_regular: "Прибрати колір модератора" rebake: "Перебудувати HTML" unhide: "Знову зробити видимим" - change_owner: "Змінити власність" + change_owner: "Змінити власника" grant_badge: "Надати Значок" lock_post: "Заморозити повідомлення" lock_post_description: "заборонити автору редагувати цей пост" @@ -2463,7 +2463,7 @@ uk: delete_topic_disallowed_modal: "У вас немає дозволу на видалення цієї теми. Якщо ви дійсно хочете, щоб вона була видалена, використовуйте функцію прапора модератору разом з аргументацією." delete_topic_disallowed: "у вас немає дозволу на видалення цієї теми" delete_topic: "видалити тему" - add_post_notice: "Додати повідомлення модератору" + add_post_notice: "Повідомлення модератору" remove_post_notice: "Видалити повідомлення модератору" remove_timer: "скасувати таймер" actions: @@ -2605,7 +2605,6 @@ uk: email_in_disabled: "Створення нових тем через електронну пошту відключено в налаштуваннях сайту. Щоб дозволити створення нових тем через електронну пошту," email_in_disabled_click: 'активуйте налаштування "email in".' mailinglist_mirror: "Категорія відображає список розсилки" - suppress_from_latest: "Приховати категорію з останніх тем." show_subcategory_list: "Показувати список підрозділів над списком тем в цьому розділі." num_featured_topics: "Кількість тем на сторінці розділів" subcategory_num_featured_topics: "Кількість обраних тем на сторінці батьківської категорії:" @@ -3015,6 +3014,7 @@ uk: changed: "мітки змінилися:" tags: "Мітки" choose_for_topic: "необов'язкові мітки" + add_synonyms: "Додати" delete_tag: "Вилучити мітку" delete_confirm: one: "Ви впевнені, що хочете вилучити цю мітку і прибрати її з %{count} теми, де вона використана?" @@ -3709,7 +3709,7 @@ uk: save_error_with_reason: "Ваші зміни не були збережені. %{error}" instructions: "Налаштування шаблону, в якому відображаються всі html-повідомлення e-mail пошти, та стиль за допомогою CSS." email: - title: "Листів" + title: "Листи" settings: "Налаштування" templates: "Шаблони" preview_digest: "Стислий виклад новин" @@ -3893,6 +3893,8 @@ uk: change_theme_setting: "змінити налаштування теми" disable_theme_component: "відключити компонент теми" enable_theme_component: "включити компонент теми" + revoke_title: "відкликати назву" + change_title: "змінити назву" api_key_create: "api ключ створено" api_key_update: "api ключ оновлено" api_key_destroy: "api ключ знищено" @@ -4000,7 +4002,7 @@ uk: active: "Активні" staff: "Персонал" suspended: "Призупинені" - silenced: "Відключений" + silenced: "Відключені" suspect: "Підозрілі" staged: "Недокористувач" approved: "Схвалено?" @@ -4393,21 +4395,15 @@ uk: category: "Допис у Категорію" add_host: "Додати Хост" settings: "Налаштування вбудовування" - feed_settings: "Налаштування Фіда" - feed_description: "Підтримка RSS/ATOM вашим сайтом може поліпшити можливості імпорту даних." crawling_settings: "налаштування визначення" crawling_description: "Якщо RSS/ATOM не підтримується, то при створенні тем Discourse спробує розібрати вміст з HTML. У деяких випадках вилучення вмісту виявляється складним, тому ми надаємо можливість задавати правила CSS, щоб зробити витяг простіше." embed_by_username: "Ім'я користувача для створення теми" embed_post_limit: "Максимальна кількість дописів для вставки" - embed_username_key_from_feed: "Ключ для вилучення користувача з стрічки" embed_title_scrubber: "Регулярний вираз, що використовується для очищення заголовка повідомлень" embed_truncate: "Обрізати вбудовані повідомлення." embed_whitelist_selector: "Селектори CSS які дозволені для використання." embed_blacklist_selector: "Селектори CSS які заборонені для використання." embed_classname_whitelist: "Дозволені імена класів CSS" - feed_polling_enabled: "Імпортувати дописи через RSS/ATOM" - feed_polling_url: "Посилання на RSS/ATOM" - feed_polling_frequency_mins: "Частота опитування стрічки (в хвилинах)" save: "Зберегти налаштування вбудовування" permalink: title: "Постійні посилання" diff --git a/config/locales/client.ur.yml b/config/locales/client.ur.yml index 2546801448..e39cf8ba28 100644 --- a/config/locales/client.ur.yml +++ b/config/locales/client.ur.yml @@ -747,6 +747,7 @@ ur: collapse_profile: "بند کریں" bookmarks: "بُکمارکس" bio: "سائٹ کے بارے میں" + timezone: "ٹائم زون" invited_by: "کی طرف سے مدعو کیا گیا:" trust_level: "ٹرسٹ لَیول" notifications: "اطلاعات" @@ -2389,7 +2390,6 @@ ur: email_in_disabled: "ویب سائٹ کی سیٹِنگ میں اِیمیل کے ذریعے نئے ٹاپک پوسٹ کرنا غیر فعال کیا ہوا ہے۔ اِیمیل کے ذریعے نئے ٹاپک شائع کرنے کو چالو کرنے کے لئے،" email_in_disabled_click: 'سیٹِنگ میں "اِیمیل اِن" فعال کریں۔' mailinglist_mirror: "زُمرہ میلنگ فہرست کا عکس ہے" - suppress_from_latest: "تازہ ترین ٹاپکس سے زُمرہ کو دبائیں۔" show_subcategory_list: "اس زمرہ میں ذیلی زمرہ جات کی فہرست ٹاپکس سے مندرجہ بالا دکھائیں۔" num_featured_topics: "زمرہ کے صفحے پر دکھائے گئے ٹاپکس کی تعداد:" subcategory_num_featured_topics: "بالائی زمرہ کے صفحے پر دکھائے گئے نمایاں ٹاپکس کی تعداد:" @@ -2761,6 +2761,7 @@ ur: changed: "تبدیل کیے گئے ٹیگ:" tags: "ٹیگز" choose_for_topic: "اختیاری ٹیگز" + add_synonyms: "شامل کریں" delete_tag: "ٹیگ حذف کریں" delete_confirm: one: "کیا آپ واقعی اس ٹیگ کو حذف اور %{count} ٹاپک، جس کو یہ آسائین ہواوا ہے، سے ہٹا دینا چاہتے ہیں؟" @@ -4088,21 +4089,15 @@ ur: category: "زمرہ میں پوسٹ کریں" add_host: "ہَوسٹ شامل کریں" settings: "اَیمبَیڈ کرنے کی سیٹِنگ" - feed_settings: "فیڈ کی سیٹِنگ" - feed_description: "اپنی وَیب سائٹ کیلئے ایک RSS/ATOM فِیڈ فراہم کرنے سے ڈِسکورس کی آپ کے مواد کو درآمد کرنے کی صلاحیت بہترہو سکتی ہے۔" crawling_settings: "کرالر کی سیٹِنگ" crawling_description: "جب ڈِسکورس آپ کی پوسٹس کیلئے ٹاپک بناتا ہے، اگر کوئی RSS/ATOM فِیڈ موجود نہ ہو تو وہ آپ کا مواد آپ کے HTML سے پارس کرنے کی کوشش کرے گا۔ کبھی کبھار آپ کا مواد نکالنا دشوار ہو سکتا ہے، اِس لیے ہم اِس نکالنے کو آسان بنانے کیلئے CSS اصولوں کی وضاحت کرنے کی صلاحیت فراہم کرتے ہیں۔" embed_by_username: "ٹاپک کی تخلیق کیلئے صارف نام" embed_post_limit: "پعسٹس اَیمبَیڈ کرنے کی زیادہ سے زیادہ تعداد۔" - embed_username_key_from_feed: "فیڈ سے ڈِسکورس صارف نام کھیںچنے کی کِی" embed_title_scrubber: "پوسٹس کے عنوان صاف کرنے کیلئے استعمال ہونے والا رَیگولَر اَیکسپرَیشن" embed_truncate: "اَیمبَیڈ کی گئی پوسٹس کو تراشیں" embed_whitelist_selector: "عناصر کیلئے CSS سلیکٹر جن کی اَیمبَیڈ میں اجازت ہے" embed_blacklist_selector: "عناصر کیلئے CSS سلیکٹر جن کو اَیمبَیڈ سے ہٹایا گیا ہے" embed_classname_whitelist: "اجازت یافتہ CSS کلاسوں کے نام" - feed_polling_enabled: "RSS / ATOM کے ذریعے پوسٹس درآمد کریں" - feed_polling_url: "کرال کرنے کیلئے RSS / ATOM کا URL" - feed_polling_frequency_mins: "فیڈ پولنگ کی فریکوئینسی (منٹوں میں)" save: "اَیمبَیڈ کرنے کی سیٹِنگ محفوظ کریں" permalink: title: "دائمی لِنکس" diff --git a/config/locales/client.vi.yml b/config/locales/client.vi.yml index 8fdb04c863..4c853c59fc 100644 --- a/config/locales/client.vi.yml +++ b/config/locales/client.vi.yml @@ -782,6 +782,7 @@ vi: title: "Xác minh hai bước" name: "Tên" show_key_description: "Nhập thủ công" + oauth_enabled_warning: "Xin lưu ý rằng thông tin đăng nhập xã hội sẽ bị vô hiệu hóa khi xác thực hai yếu tố đã được bật trên tài khoản của bạn." edit: "Sửa" security_key: delete: "Xóa" @@ -1102,6 +1103,7 @@ vi: trust_level: "Độ tin tưởng" search_hint: "username, email or IP address" create_account: + disclaimer: "Bằng cách đăng ký, bạn đồng ý với chính sách bảo mậtđiều khoản dịch vụ." title: "Tạo tài khoản mới" failed: "Có gì đó không đúng, có thể email này đã được đăng ký, thử liên kết quên mật khẩu" forgot_password: @@ -1120,6 +1122,10 @@ vi: email_login: link_label: "Gửi liên kết đăng nhập qua email" button_label: "với email" + complete_username: "Nếu một tài khoản khớp với tên người dùng %{username}, bạn sẽ sớm nhận được email có liên kết đăng nhập." + complete_email: "Nếu một tài khoản phù hợp với %{email}, bạn sẽ sớm nhận được email có liên kết đăng nhập." + complete_username_found: "Chúng tôi đã tìm thấy một tài khoản phù hợp với tên người dùng %{username}, bạn sẽ sớm nhận được email có liên kết đăng nhập." + complete_email_found: "Chúng tôi đã tìm thấy một tài khoản phù hợp với %{email}, bạn sẽ sớm nhận được email có liên kết đăng nhập." complete_username_not_found: "Không có tài khoản phù hợp với tên thành viên %{username} " complete_email_not_found: "Không tìm thấy tài khoản nào tương ứng với %{email}" confirm_title: "Tiếp tục tới %{site_name}" @@ -1174,6 +1180,7 @@ vi: accept_title: "Lời mời" welcome_to: "Chào mừng bạn đến với %{site_name}!" invited_by: "Bạn đã được mời bởi:" + social_login_available: "Bạn cũng có thể đăng nhập bằng bất kỳ thông tin đăng nhập xã hội nào bằng email đó." your_email: "Địa chỉ email của bạn là %{email}." accept_invite: "Chấp nhận lời mời" name_label: "T" @@ -1295,6 +1302,7 @@ vi: post_approved: "Bài đăng của bạn đã được phê duyệt" liked_consolidated_description: other: "đã thích {{count}} bài viết của bạn" + invited_to_private_message: "

{{username}}{{description}}" invitee_accepted: "{{username}} đã chấp nhận lời mời của bạn" moved_post: "{{username}} đã chuyển {{description}}" popup: @@ -1331,11 +1339,14 @@ vi: select_all: "Chọn tất cả" clear_all: "Xóa tất cả" too_short: "Từ khoá tìm kiếm của bạn quá ngắn." + result_count: + other: "Hơn {{count}}{{plus}} kết quả cho{{term}}" title: "tìm kiếm chủ đề, bài viết, tài khoản hoặc các danh mục" no_results: "Không tìm thấy kết quả." no_more_results: "Không tìm thấy kết quả" searching: "Đang tìm ..." post_format: "#{{post_number}} bởi {{username}}" + results_page: "Kết quả tìm kiếm cho '{{term}}'" search_google_button: "G" search_google_title: "Tìm trong trang n" context: @@ -2125,6 +2136,7 @@ vi: selector_no_tags: "không có thẻ" changed: "thẻ đã đổi:" tags: "Thẻ" + add_synonyms: "Thêm" delete_tag: "Xoá thẻ" rename_tag: "Đổi tên thẻ" rename_instructions: "Chọn tên mới cho thẻ:" @@ -2201,9 +2213,14 @@ vi: private_messages_title: "Tin nhắn" mobile_title: "Điện thoại" backups: "Sao lưu" + backup_count: + other: "%{count} bản sao lưu trên %{location}" traffic_short: "Băng thông" traffic: "Application web requests" + page_views: "Số lượt xem" + page_views_short: "Số lượt xem" show_traffic_report: "Xem chi tiết Báo cáo Lưu lượng" + moderators_activity: Người điều hành hoạt động general_tab: "Chung" security_tab: "Bảo mật" report_filter_any: "bất kì" @@ -2395,12 +2412,18 @@ vi: mobile: "Điện thoại" settings: "Xác lập" upload: "Tải lên" + import_web_tip: "Kho chứa chủ đề" + is_private: "Theme nằm trong kho git riêng" installed: "Đã cài đặt" install_popular: "Phổ biến" + install_git_repo: "Từ kho git" about_theme: "Giới thiệu" enable: "Kích hoạt" disable: "Vô hiệu hóa" + update_to_latest: "Cập nhật lên mới nhất" + up_to_date: "Chủ đề được cập nhật, kiểm tra lần cuối:" add: "Thêm" + repo_unreachable: "Không thể liên hệ với kho Git chứa chủ đề này. Thông báo lỗi:" scss: text: "CSS" header: @@ -2510,6 +2533,9 @@ vi: address_placeholder: "name@example.com" type_placeholder: "tập san, đăng ký..." reply_key_placeholder: "key phản hồi" + moderation_history: + actions: + suspend_user: "Thành viên đã tạm ngưng" logs: title: "Log" action: "Hành động" @@ -2566,6 +2592,7 @@ vi: revoke_admin: "hủy bỏ quản trị" grant_moderation: "cấp điều hành" revoke_moderation: "hủy bỏ điều hành" + activate_user: "kích hoạt thành viên" screened_emails: title: "Screened Emails" description: "Khi ai đó cố gắng tạo tài khoản mới, các địa chỉ email sau sẽ được kiểm tra và đăng ký sẽ bị chặn, hoặc một số hành động khác được thực hiện." @@ -2597,6 +2624,8 @@ vi: text: "Cuộn lên" title: "Tạo mạng con mới các entry cấm nếu có ít nhất 'min_ban_entries_for_roll_up' entry." search_logs: + term: "Thuật ngữ" + searches: "Số lần tìm kiếm" types: header: "Header" logster: @@ -2657,6 +2686,7 @@ vi: suspend_reason: "Lý do" suspended_by: "Tạm khóa bởi" silence_reason: "Lý do" + cant_suspend: "Thành viên này không thể bị tạm ngưng" delete_all_posts: "Xóa tất cả bài viết" moderator: "Mod?" admin: "Quản trị?" @@ -2927,18 +2957,13 @@ vi: category: "Đăng vào Danh mục" add_host: "Thêm Host" settings: "Thiết lập nhúng" - feed_settings: "Cấu hình Feed" - feed_description: "Cung cấp RSS/ATOM cho website để cải thiện khả năng Discourse import nội dung của bạn." crawling_settings: "Cấu hình Crawler" crawling_description: "Khi Discourse tạo chủ đề cho các bài viết của bạn, nếu không có RSS/ATOM thì hệ thống sẽ thử phân tích nội dung HTML. Đôi khi có thể gặp khó khăn khi trích xuất nội dung, vì vậy hệ thống cung cấp khả năng chỉ định quy tắc CSS để giúp quá trình trích xuất dễ dàng hơn." embed_by_username: "Tên thành viên để tạo chủ đề" embed_post_limit: "Số lượng tối đa bài viết được nhúng" - embed_username_key_from_feed: "Key to pull discourse username from feed" embed_truncate: "Cắt ngắn các bài viết được nhúng" embed_whitelist_selector: "Bộ chọn các thành phần CSS được hỗ trợ khi nhúng" embed_blacklist_selector: "CSS selector for elements that are removed from embeds" - feed_polling_enabled: "Nhập bài viết bằng RSS/ATOM" - feed_polling_url: "URL của RSS/ATOM để thu thập" save: "Lưu thiết lập nhúng" permalink: title: "Liên kết cố định" diff --git a/config/locales/client.zh_CN.yml b/config/locales/client.zh_CN.yml index aaad2a4296..75ec32e6c7 100644 --- a/config/locales/client.zh_CN.yml +++ b/config/locales/client.zh_CN.yml @@ -312,6 +312,7 @@ zh_CN: title: "待审阅项目由较高信任级别且具有较高分数的用户创建的。" type_bonus: name: "奖励类型" + title: "某些可审核的类型可以由管理人员加权,以使其具有更高的优先级。" claim_help: optional: "你可以认领此条目以防止他人审核。" required: "在你审核之前你必须认领此条目。" @@ -706,6 +707,7 @@ zh_CN: collapse_profile: "折叠" bookmarks: "收藏" bio: "我是谁" + timezone: "时区" invited_by: "邀请人" trust_level: "信任等级" notifications: "通知" @@ -839,6 +841,7 @@ zh_CN: copied_to_clipboard: "已复制到剪贴板" copy_to_clipboard_error: "复制到剪贴板时出错" remaining_codes: "你有{{count}}个备份码" + use: "使用备份码" enable_prerequisites: "你必须在生成备份代码之前启用主要第二因素。" codes: title: "备份码生成" @@ -846,6 +849,7 @@ zh_CN: second_factor: title: "双重验证" enable: "管理两步验证" + forgot_password: "忘记密码?" confirm_password_description: "确认密码以继续" name: "名称" label: "编码" @@ -859,6 +863,7 @@ zh_CN: extended_description: | 双重身份验证除了你的密码之外还需要一次性令牌,从而为你的帐户增加了额外的安全性。 可以在AndroidiOS设备。 oauth_enabled_warning: "请注意,一旦你的帐户启用了双重身份验证,系统就会停用社交登录。" + use: "使用身份验证器app" enforced_notice: "在访问此站点之前,你需要启用双重身份验证。" disable: "停用" disable_title: "禁用次要身份验证器" @@ -866,12 +871,20 @@ zh_CN: edit: "编辑" edit_title: "编辑次要身份验证器" edit_description: "次要身份验证器名称" + enable_security_key_description: "当你准备好物理安全密钥后,请按下面的“注册”按钮。" totp: title: "基于凭证的身份验证器" add: "新增身份验证器" default_name: "我的身份验证器" security_key: register: "注册" + title: "安全密钥" + add: "注册安全密钥" + default_name: "主要安全密钥" + not_allowed_error: "安全密钥注册过程已超时或被取消。" + already_added_error: "你已注册此安全密钥,无需再次注册。" + edit: "编辑安全密钥" + edit_description: "安全密钥名称" delete: "删除" change_about: title: "更改个人信息" @@ -900,6 +913,7 @@ zh_CN: image_is_not_a_square: "注意:图片不是正方形的,我们裁剪了部分图像。" change_profile_background: title: "个人档头部" + instructions: "个人资料的页头会被居中显示且默认宽度为1110px。" change_card_background: title: "用户卡背景" instructions: "显示在用户卡片中,上传的图片将被居中且默认宽度为 590px。" @@ -1170,6 +1184,7 @@ zh_CN: login_disabled: "只读模式下不允许登录。" logout_disabled: "站点在只读模式下无法登出。" too_few_topics_notice: "让我们开始讨论吧!现在有%{currentTopics}个主题。 用户需要进行更多阅读与回复 – 我们推荐至少%{requiredTopics} 个主题。 此消息仅管理员可见。" + too_few_posts_notice: "让我们开始讨论吧!现在有%{currentPosts}个主题。 用户需要进行更多的阅读或回复 – 我们推荐至少%{requiredPosts} 个主题。 此消息仅管理人员可见。" logs_error_rate_notice: reached_hour_MF: "{relativeAge}{rate, plural, one {# error/hour} other {# errors/hour}}达到了站点设置中的限制{limit, plural, one {# error/hour} other {# errors/hour}}。" reached_minute_MF: "{relativeAge}1 – {rate, plural, one {# error/minute} other {# errors/minute}}已经达到站点设置限制 {limit, plural, one {# error/minute} other {# errors/minute}}。" @@ -1264,12 +1279,16 @@ zh_CN: password: "密码" second_factor_title: "双重验证" second_factor_description: "请输入来自 app 的验证码:" + second_factor_backup: "使用备用码登录" second_factor_backup_title: "两步验证备份" second_factor_backup_description: "请输入你的备份码:" - security_key_alternative: "找不到您的安全密钥,是否要使用其他方法?" + second_factor: "使用身份验证器app登录" + security_key_description: "当你准备好物理安全密钥后,请按下面的“使用安全密钥进行身份验证”按钮。" + security_key_alternative: "尝试另一种方式" security_key_authenticate: "使用安全密钥进行身份验证" security_key_not_allowed_error: "安全密钥验证超时或被取消。" security_key_no_matching_credential_error: "在提供的安全密钥中找不到匹配的凭据。" + security_key_support_missing_error: "您当前的设备或浏览器不支持使用安全密钥。请使用其他方法。" email_placeholder: "电子邮件或者用户名" caps_lock_warning: "大写锁定开启" error: "未知错误" @@ -1633,6 +1652,7 @@ zh_CN: context: user: "搜索 @{{username}} 的帖子" category: "搜索 #{{category}} 分类" + tag: "搜索#{{tag}}标签" topic: "搜索本主题" private_messages: "搜索私信" advanced: @@ -2131,8 +2151,10 @@ zh_CN: attachment_upload_not_allowed_for_new_user: "抱歉,新用户无法上传附件。" attachment_download_requires_login: "抱歉,你需要登录后才能下载附件。" abandon_edit: + confirm: "您确定要放弃所做的更改吗?" no_value: "不,保持" no_save_draft: "不,保存草稿" + yes_value: "是的,忽略编辑" abandon: confirm: "确定要放弃编辑帖子吗?" no_value: "否" @@ -2272,6 +2294,9 @@ zh_CN: tag_groups_placeholder: "(可选)允许使用的标签组列表" manage_tag_groups_link: "管理这里的标签组。" allow_global_tags_label: "总是允许其它标题" + tag_group_selector_placeholder: "(可选)标签组" + required_tag_group_description: "要求新主题包含标签组中的标签:" + required_tag_group_label: "标签组:" topic_featured_link_allowed: "允许在该分类中发布特色链接标题" delete: "删除分类" create: "新分类" @@ -2308,7 +2333,6 @@ zh_CN: email_in_disabled: "站点设置中已经禁用通过邮件发表新主题。欲启用通过邮件发表新主题," email_in_disabled_click: '启用“邮件发表”设置。' mailinglist_mirror: "分类镜像了一个邮件列表" - suppress_from_latest: "从最新主题列表中剔除该分类" show_subcategory_list: "在这个分类中把子分类列表显示在主题的上面" num_featured_topics: "分类页面上显示的主题数量:" subcategory_num_featured_topics: "父分类页面上的推荐主题数量:" @@ -2661,6 +2685,7 @@ zh_CN: changed: "标签被修改:" tags: "标签" choose_for_topic: "可选标签" + add_synonyms: "新增" delete_tag: "删除标签" delete_confirm: other: "你确定你想要删除这个标签以及撤销在{{count}}个主题中的关联么?" @@ -2715,6 +2740,7 @@ zh_CN: parent_tag_description: "未设置上级标签前群组内标签无法使用。" one_per_topic_label: "只可给主题设置一个该组内的标签" new_name: "新建标签组" + name_placeholder: "标签组名称" save: "保存" delete: "删除" confirm_delete: "确定要删除此标签组吗?" @@ -2879,6 +2905,7 @@ zh_CN: members_visibility_levels: title: "谁可以看见这个群组的成员?" description: "管理员可以查看所有群组的成员。" + publish_read_state: "在群组消息中发布群组阅读状态" membership: automatic: 自动 trust_levels_title: "这些用户加入时,将自动赋予信任等级:" @@ -2912,13 +2939,30 @@ zh_CN: none: "当前没有可用的 API 密钥。" user: "用户" title: "API" + key: "密钥" created: 创建时间 + updated: 已更新 + last_used: 最后使用 + never_used: (从不) generate: "生成" + undo_revoke: "取消撤销" revoke: "撤销" all_users: "所有用户" + active_keys: "激活API密钥" + manage_keys: 管理密钥 show_details: 详情 description: 描述 + no_description: (没有描述) + all_api_keys: 所有API密钥 + user_mode: 用户等级 + impersonate_all_users: 模拟任意用户 + single_user: "单个用户" + user_placeholder: 输入用户名 + description_placeholder: 此密钥将被如何使用? save: 保存 + new_key: 新建API密钥 + revoked: 已撤销 + delete: 永久删除 web_hooks: title: "Webhooks" none: "当前没有 Webhooks。" @@ -3230,6 +3274,7 @@ zh_CN: other: "主题落后了 {{count}} 个变更!" compare_commits: "(查看新提交)" repo_unreachable: "无法联系此主题的Git存储库。错误信息:" + imported_from_archive: "此主题是从一个.zip文件导入的" scss: text: "CSS" title: "输入自定义 CSS,我们接受所有有效的 CSS 和 SCSS 样式" @@ -3497,6 +3542,11 @@ zh_CN: change_theme_setting: "更改主题设置" disable_theme_component: "停用主题组件" enable_theme_component: "启用主题组件" + revoke_title: "撤销头衔" + change_title: "修改头衔" + api_key_create: "创建api密钥" + api_key_update: "更新api密钥" + api_key_destroy: "销毁api密钥" screened_emails: title: "被屏蔽的邮件地址" description: "当有人试图用以下邮件地址注册时,将受到阻止或其它系统操作。" @@ -3882,7 +3932,9 @@ zh_CN: secret_list: invalid_input: "输入字段不能为空或包含竖线字符。" default_categories: + modal_description: "你想在已存在的设置上应用此更改吗?这将更改%{count}位现有用户的首选项。" modal_yes: "是" + modal_no: "不,仅应用以后的更改" badges: title: 徽章 new_badge: 新徽章 @@ -3974,21 +4026,15 @@ zh_CN: category: "发布到分类" add_host: "增加主机" settings: "嵌入设置" - feed_settings: "源设置" - feed_description: "为你的站点提供一份 RSS/ATOM 源能改善 Discourse 导入你的内容的能力" crawling_settings: "爬虫设置" crawling_description: "当 Discourse 为你的帖子创建了主题时,如果没有 RSS/ATOM 流存在,它将尝试从 HTML 中解析内容。有时分离其中的内容时可能是很有挑战性的,所以我们提供了指定 CSS 规则的能力来帮助分离过程。" embed_by_username: "主题创建者的用户名" embed_post_limit: "嵌入的最大帖子数量。" - embed_username_key_from_feed: "从流中拉取 Discourse 用户名的 Key " embed_title_scrubber: "从帖子中提取标题的正则表达式" embed_truncate: "截断嵌入的帖子" embed_whitelist_selector: "使用 CSS 选择器选择允许的嵌入元素" embed_blacklist_selector: "使用 CSS 选择器移除嵌入元素" embed_classname_whitelist: "允许 CSS class 名称" - feed_polling_enabled: "通过 RSS/ATOM 导入帖子" - feed_polling_url: "用于抓取的 RSS/ATOM 流的 URL" - feed_polling_frequency_mins: "消息轮询频率(分钟)" save: "保存嵌入设置" permalink: title: "永久链接" diff --git a/config/locales/client.zh_TW.yml b/config/locales/client.zh_TW.yml index b5ac3ac8a7..b2ac44fd20 100644 --- a/config/locales/client.zh_TW.yml +++ b/config/locales/client.zh_TW.yml @@ -2195,7 +2195,6 @@ zh_TW: email_in_disabled: "\"用電子郵件張貼新的話題\"功能已被關閉。若要使用此功能," email_in_disabled_click: '請啟用"email in"功能' mailinglist_mirror: "以類別來區分郵件列表" - suppress_from_latest: "從最新話題中不顯示此分類" show_subcategory_list: "在此分類中,將子分類顯示在話題上方。" num_featured_topics: "分類頁面中顯示的話題數量:" subcategory_num_featured_topics: "類別頁上的精選話題數量:" @@ -2539,6 +2538,7 @@ zh_TW: changed: "標籤被修改:" tags: "標籤" choose_for_topic: "可選標籤" + add_synonyms: "新增" delete_tag: "刪除標籤" delete_confirm: other: "您確定要刪除此標籤並將它從{{count}}個話題中移除嗎?" @@ -3803,21 +3803,15 @@ zh_TW: category: "張貼到分類" add_host: "新增主機" settings: "嵌入設定" - feed_settings: "源設置" - feed_description: "為你的站點提供一份 RSS/ATOM 源能改善 Discourse 導入你的內容的能力" crawling_settings: "爬蟲設定" crawling_description: "當 Discourse 為你的貼文開啟了話題時,如果沒有 RSS/ATOM 流存在,它將嘗試從 HTML 中解析內容。有時分離其中的內容時可能是很有挑戰性的,所以我們提供了指定 CSS 規則的能力來幫助分離過程。" embed_by_username: "話題開啟者的使用者名稱" embed_post_limit: "嵌入的最大貼文數量。" - embed_username_key_from_feed: "從流中拉取 Discourse 使用者名的 Key " embed_title_scrubber: "從貼文中提取標題的正則表達式 \"regular expression\"" embed_truncate: "截斷嵌入的貼文" embed_whitelist_selector: "使用 CSS 選擇器選擇允許的嵌入元素" embed_blacklist_selector: "使用 CSS 選擇器移除嵌入元素" embed_classname_whitelist: "允許 CSS class 名稱" - feed_polling_enabled: "匯入貼文藉由 RSS/ATOM" - feed_polling_url: "用於抓取的 RSS/ATOM 流的 URL" - feed_polling_frequency_mins: "訊息更新頻率(分鐘)" save: "儲存崁入設定" permalink: title: "固定連結" diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index f2adf79e85..d503fe12ec 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -574,9 +574,6 @@ ar: error: "حدث خطأ في تغيير عنوان بريدك الإلكتروني. لربما يكون العنوان مستخدما بالفعل." error_staged: "حدث خطأ في تغيير عنوان بريدك الإلكتروني. العنوان الذى ادخلته مستخدم من قبل مشترك عبر البريد الالكترونى." already_done: "نأسف عنوان التاكيد هدا لم يعد صالحا بعد الان. ربما تم تغيير بريدك بالفعل؟" - authorizing_old: - title: "شكرا لك لتاكيدك عنوان بريدك الحالي." - description: "نحن الان نقوم بمراسله عنوانك الجديد للتأكيد." activation: action: "انقر هنا لتنشيط حسابك." already_done: "آسفون، لم يعد رابط تأكيد الحساب صالحا. لربما يكون الحساب نشطا بالفعل." @@ -1453,20 +1450,8 @@ ar: subject_template: "حسابك الجديد [%{email_prefix}]" confirm_new_email: subject_template: "أكّد عنوان بريد الإلكتروني الجديد %{email_prefix}" - text_body_template: | - أكد عنوان بريدك الإلكتروني لـ %{site_name} بالضغط على الرابط التالي : - - %{email_token}/u/authorize-email/%{base_url} confirm_old_email: subject_template: "أكّد عنوان بريد الإلكتروني الحالي %{email_prefix}" - text_body_template: | - قبل أن نُغيّر عنوان بريد الإلكتروني ، نحتاجك إلى تأكيد تحكمك - بالبريد الالكتروني الحالي للحساب ، بعد إكمال هذه الخطوة ، سوف نؤكد لك - عنوان البريد الإلكتروني الجديد . - - أكّد عنوان بريدك الإلكتروني الحالي لـ %{site_name} بالضغط على الرابط التالي : - - %{email_token}/u/authorize-email/%{base_url} notify_old_email: subject_template: "عنوان بريد الإلكتروني تم تغييرة %{email_prefix}" signup_after_approval: diff --git a/config/locales/server.be.yml b/config/locales/server.be.yml index ee28bbe1ee..978ccf24f0 100644 --- a/config/locales/server.be.yml +++ b/config/locales/server.be.yml @@ -416,6 +416,7 @@ be: half_a_minute: "толькі што" less_than_x_seconds: "толькі што" password_reset: + choose_new: "Абярыце новы пароль" title: "скінуць пароль" user_auth_tokens: browser: @@ -448,9 +449,6 @@ be: error: "Была памылка змены вашага адрасу электроннай пошты. Магчыма, адрас ужо выкарыстоўваецца?" error_staged: "Была памылка змены вашага адрасу электроннай пошты. Адрас ужо выкарыстоўваецца паэтапным карыстальнікам." already_done: "Ня На жаль, гэтая спасылка для пацверджання ужо не дзейнічае. Можа быць, ваша электронная пошта ўжо змянілася?" - authorizing_old: - title: "Дзякуй за пацверджанне Вашага бягучага адрасы электроннай пошты" - description: "Цяпер мы па электроннай пошце свой новы адрас для пацверджання." associated_accounts: revoke_failed: "Не атрымалася адмяніць свой рахунак з %{provider_name}." activation: @@ -978,7 +976,6 @@ be: content_security_policy: "Ўключыць Content-Security-Policy" content_security_policy_report_only: "Ўключыць Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Ўключыць CSP збор справаздач парушэнні ў" - content_security_policy_script_src: "Дадатковыя крыніцы белага спісу сцэнараў. У цяперашні час гаспадар і CDN ўключаны па змаўчанні." invalidate_inactive_admin_email_after_days: "Адміністратар рахункаў, якія не наведвалі сайт у гэтым колькасць дзён трэба будзе паўторна праверыць іх адрасы электроннай пошты перад уваходам у сістэму. Усталюйце 0, каб адключыць." top_menu: "Вызначце, якія з'яўляюцца элементы хатняй старонкі навігацыі, і ў якім парадку. Прыклад апошнія | Новыя | непрачытаныя | катэгорыя | топ | прачытаныя | Адпраўленыя | закладкі" post_menu: "Вызначце, якія элементы з'яўляюцца ў меню пасля, і ў якім парадку. Прыклад як | рэдагаваць | сцяг | выдаліць | Share | закладкі | адказаць" @@ -1273,8 +1270,6 @@ be: header_dropdown_category_count: "Колькі катэгорый могуць быць адлюстраваны ў меню загалоўка спісу." global_notice: "Дысплей настойлівая, EMERGENCY, неотстранимый глабальны банэр апавяшчэння для ўсіх наведвальнікаў, каб змяніць поле пустым, каб схаваць яго (HTML дазволена)." disable_system_edit_notifications: "Адключае рэдагаваць апавяшчэння карыстальніка сістэмы, калі «download_remote_images_to_local» актыўна." - likes_notification_consolidation_threshold: "Колькасць ўпадабаных апавяшчэнняў, атрыманых да апавяшчэнняў аб'яднаны ў адну. Усталюйце 0, каб адключыць. Акно можа быць сканфігураваны з дапамогай `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Працягласць ў хвілінах, дзе упадабаныя апавяшчэння аб'яднаны ў адно апавяшчэнне, як толькі парог дасягнуты. Парог можа быць сканфігураваны з дапамогай `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Аўтаматычна распушчае мацаваньне тэмы, калі карыстальнік дасягае дна." read_time_word_count: "Колькасць слоў у хвіліну для разліку разліковага часу чытання." native_app_install_banner_ios: "Адлюстроўвае DiscourseHub прыкладанне банэр на IOS прылад для звычайных карыстальнікаў (мэтавай ўзровень 1 і вышэй)." @@ -1875,13 +1870,9 @@ be: confirm_new_email: title: "Пацвердзіце новы Email" subject_template: "[% {Email_prefix}] Пацвердзіце свой новы адрас электроннай пошты" - text_body_template: |- - Пацвердзіце свой новы адрас электроннай пошты для% {site_name}, націснуўшы на наступную спасылку:% {Base_url} confirm_old_email: title: "Пацвердзіце стары адрас электроннай пошты" subject_template: "[% {Email_prefix}] Пацвердзіце свой бягучы адрас электроннай пошты" - text_body_template: |- - Перш чым мы можам змяніць свой адрас электроннай пошты, мы павінны пацвердзіць, што вы кантралюецебягучы рахунак па электроннай пошце. Пасля завяршэння гэтага кроку, мы будзем мець вас пацвердзіцьновы адрас электроннай пошты.Пацвердзіце свой бягучы адрас электроннай пошты для% {site_name}, націснуўшы на наступную спасылку:% {Base_url} notify_old_email: title: "Апавяшчаць Стары e-mail" subject_template: "[% {Email_prefix}] Ваш электронны адрас быў зменены" @@ -2434,6 +2425,8 @@ be: user_delete_self: "Выдаленая сябе ад% {URL}" webhook_deactivation_reason: "Ваш webhook быў аўтаматычна адключаны. Мы атрымалі некалькі «% {стану}» HTTP адказаў адмовы статусу." reviewables: + sensitivity: + disabled: "Адключана" missing_version: "Вам трэба будзе падаць параметр версіі" conflict: "Быў абнаўленне канфлікт перашкаджае вам рабіць гэта." reasons: diff --git a/config/locales/server.bg.yml b/config/locales/server.bg.yml index 0cb619c736..72f2fd93ad 100644 --- a/config/locales/server.bg.yml +++ b/config/locales/server.bg.yml @@ -375,8 +375,6 @@ bg: confirmed: "Вашият имейл беше актуализиран." please_continue: "Продължете към %{site_name}" error: "Възникна грешка при промяната на вашия имейл адрес. Може би адресът вече се използва?" - authorizing_old: - title: "Благодаря че потвърдихте имейл адреса си" activation: action: "Кликнете тук, за да активирате вашия профил" already_done: "Съжаляваме, този линк за потвърждаване на акаунта вече е невалиден. Може би вашият акаунт е вече активен?" diff --git a/config/locales/server.bs_BA.yml b/config/locales/server.bs_BA.yml index 610bf86559..e85cd90c13 100644 --- a/config/locales/server.bs_BA.yml +++ b/config/locales/server.bs_BA.yml @@ -84,8 +84,8 @@ bs_BA: missing_attachment: "(Nedostaje prilog %{filename})" errors: empty_email_error: "Dešava se kada je sirova pošta koju smo primili bila prazna." - no_message_id_error: "Dešava se kada pošta nema zaglavlje 'Message-Id'." - auto_generated_email_error: "Dešava se kada je zaglavlje 'prvenstva' postavljeno na: listu, bezvrijedno, skupno ili auto_reply, ili kada bilo koje drugo zaglavlje sadrži: automatsko slanje, automatsko odgovaranje ili automatsko generiranje." + no_message_id_error: "Dešava se kada pošta nema zaglavlje 'Message-Id'." + auto_generated_email_error: "Dešava se kada je zaglavlje 'prvenstva' postavljeno na: listu, bezvrijedno, skupno ili auto_reply, ili kada bilo koje drugo zaglavlje sadrži: automatsko slanje, automatsko odgovaranje ili automatsko generiranje." no_body_detected_error: "Dešava se kada nismo mogli da izvučemo telo i nije bilo nikakvih vezanosti." no_sender_detected_error: "Dešava se kada nismo mogli pronaći važeću adresu e-pošte u zaglavlju From." from_reply_by_address_error: "Događa se kada se zaglavlje From poklapa s odgovorom na adresu e-pošte." diff --git a/config/locales/server.ca.yml b/config/locales/server.ca.yml index ace7461e4b..bfbb3e3b10 100644 --- a/config/locales/server.ca.yml +++ b/config/locales/server.ca.yml @@ -92,6 +92,7 @@ ca: from_reply_by_address_error: "Passa quan la capçalera del remitent coincideix amb l'adreça de correu de resposta." inactive_user_error: "Passa quan l'emissor no és actiu." silenced_user_error: "Passa quan el remitent ha estat silenciat." + bad_destination_address: "Passa quan cap de les adreces electròniques dels camps A/Cc no coincideix amb una adreça de correu entrant configurada." strangers_not_allowed_error: "Passa quan un usuari ha provat de crear un tema nou en una categoria de la qual no és membre." insufficient_trust_level_error: "Passa quan un usuari ha provat de crear un tema nou en una categoria per a la qual no té el nivell de confiança necessari." reply_user_not_matching_error: "Passa quan una resposta arriba des d'una adreça de correu diferent d'aquella a la qual s'ha enviat la notificació." @@ -123,6 +124,7 @@ ca: inclusion: no és inclòs en la llista invalid: no és vàlid is_invalid: "no sembla clar. És una frase sencera?" + invalid_timezone: "'%{tz}' no és una zona horària vàlida" contains_censored_words: "conté les següents paraules censurades: %{censored_words}" less_than: "ha de ser menys de %{count}" less_than_or_equal_to: "ha de ser igual o menor que %{count}" @@ -246,6 +248,7 @@ ca: other: "%{count} 'm'agrada'" last_reply: "Darrera resposta" created: "Creat" + new_topic: "Crea un tema nou" no_mentions_allowed: "No podeu mencionar altres usuaris" too_many_mentions: one: "Només podeu mencionar un usuari en una publicació." @@ -711,14 +714,23 @@ ca: windows: "Microsoft Windows" unknown: "sistema operatiu desconegut" change_email: + wrong_account_error: "Heu iniciat sessió amb un compte equivocat. Tanqueu la sessió i torneu-ho a provar." confirmed: "El vostre correu electrònic ha estat actualitzat." please_continue: "Continua a %{site_name}" error: "Hi ha hagut un error en canviar la vostra adreça de correu. Potser l'adreça ja està en ús." error_staged: "Hi ha hagut un error en canviar la vostra adreça electrònica. L'adreça ja està en ús per un usuari fictici." already_done: "Aquest enllaç de confirmació ja no és vàlid. Potser s'ha canviat el vostre correu electrònic." + confirm: "Confirma" + authorizing_new: + title: "Confirmeu la vostra nova adreça electrònica" + description: "Confirmeu que voleu canviar la vostra nova adreça de correu electrònic a:" authorizing_old: - title: "Gràcies per confirmar la vostra adreça actual de correu." - description: "Us enviem ara per correu la vostra adreça per a confirmar-la." + title: "Canvieu la vostra adreça de correu electrònic" + description: "Confirmeu el canvi d’adreça electrònica" + old_email: "Adreça electrònica antiga: %{email}" + new_email: "Adreça electrònica nova: %{email}" + almost_done_title: "Confirmant la nova adreça electrònica" + almost_done_description: "Hem enviat un correu electrònic a la vostra nova adreça electrònica per a confirmar el canvi." associated_accounts: revoke_failed: "No s'ha pogut revocar el vostre compte amb %{provider_name}." connected: "(connectat)" @@ -733,6 +745,7 @@ ca: activated: "Aquest compte ja ha estat activat." admin_confirm: title: "Confirma el compte d'administrador" + description: "Esteu segur que voleu que %{target_username}(%{target_email}) sigui administrador?" grant: "Atorga accés d'administrador" complete: "%{target_username} és ara administrador." back_to: "Torna a %{title}" @@ -781,6 +794,10 @@ ca: description: "M'agrada aquesta publicació" short_description: "M'agrada aquesta publicació" long_form: "ha fet 'M'agrada'" + draft: + sequence_conflict_error: + title: "error d'esborrany" + description: "L’esborrany s’està editant en una altra finestra. Torneu a carregar aquesta pàgina." draft_backup: pm_title: "Esborranys de còpia de seguretat de temes en curs" pm_body: "Tema que conté esborranys de còpia de seguretat" @@ -1375,7 +1392,6 @@ ca: content_security_policy: "Activa Content-Security-Policy (CSP)" content_security_policy_report_only: "Activa Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Habilita la recol·lecció de reports de violació de CSP en /csp_reports" - content_security_policy_script_src: "Fonts addicionals de scripts permeses. L'amfitrió actual i el CDN s'hi inclouen per defecte." invalidate_inactive_admin_email_after_days: "Els comptes d'administrador que no hagin visitat el lloc web en aquest nombre de dies hauran de tornar a validar la seva adreça de correu abans d'iniciar la sessió. 0 per a desactivar-ho." top_menu: "Determineu quins elements apareixen en la navegació de la pàgina principal i en quin ordre. Per exemple, latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determineu quins elements apareixen en el menú de publicacions i en qui ordre. Exemple: like|edit|flag|delete|share|bookmark|reply" @@ -1669,6 +1685,9 @@ ca: private_email: "No incloguis contingut de publicacions o temes en el títol o el cos del correu electrònic. NOTA: també desactiva els correus electrònics de resum." email_total_attachment_size_limit_kb: "Mida total màxima dels fitxers adjuntats als correus sortints. 0 per a inhabilitar l’enviament d'adjunts." post_excerpts_in_emails: "En els correus de notificació, envia sempre fragments en lloc de publicacions completes." + raw_email_max_length: "Quants caràcters s’han d’emmagatzemar per al correu electrònic entrant." + raw_rejected_email_max_length: "Quants caràcters s’han d’emmagatzemar per al correu electrònic entrant rebutjat." + delete_rejected_email_after_days: "Suprimeix els correus electrònics rebutjats amb més de (n) dies." manual_polling_enabled: "Envia correus (push) fent servir l'API per a respostes de correu." pop3_polling_enabled: "Consulta via POP3 les respostes de correu" pop3_polling_ssl: "Fes servir SSL en connectar amb el servidor POP3 (recomanat)." @@ -1727,6 +1746,7 @@ ca: ignored_users_count_message_threshold: "Notifica als moderadors si un usuari determinat és ignorat per molts altres usuaris." ignored_users_message_gap_days: "Quant de temps haureu d'esperar abans de notificar de nou als moderadors sobre un usuari ignorat per molts altres." clean_up_inactive_users_after_days: "Nombre de dies abans d'eliminar un usuari inactiu (nivell de confiança 0 sense publicacions). Per a desactivar la neteja, poseu 0." + user_selected_primary_groups: "Permet als usuaris configurar el seu propi grup principal" user_website_domains_whitelist: "El lloc web de l'usuari serà verificat contra aquests dominis. Llista delimitada amb barres verticals." allow_profile_backgrounds: "Permet que els usuaris carreguin fons de perfil." sequential_replies_threshold: "Nombre de publicacions seguides que un usuari ha de fer en un tema abans de ser advertit de fer massa respostes seqüencials." @@ -1739,8 +1759,6 @@ ca: permalink_normalizations: "Aplica la següent expressió regular abans de cercar enllaços permanents coincidents. Per exemple: /(topic.*)\\?.*/\\1 eliminarà les cadenes de consulta de les rutes dels temes. El format és regex+string; utilitzeu \\1 \\2... per a accedir a captures." global_notice: "Mostra un bàner d'avís global d'EMERGÈNCIA URGENT, no descartable, a tots els visitants. Canvieu a blanc per a amagar-lo. (Es permet HTML.)" disable_system_edit_notifications: "Inhabilita les notificacions d'edició per l'usuari del sistema quan 'download_remote_images_to_local' és actiu." - likes_notification_consolidation_threshold: "Nombre de notificacions amb 'M'agrada' rebudes abans de consolidar les notificacions en una de sola. 0 per desactivar. La finestra es pot configurar mitjançant `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Durada en minuts en què les notificacions amb 'M'agrada' es consoliden en una única notificació una vegada s'ha assolit el llindar. El llindar es pot configurar mitjançant `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Desafixa temes automàticament quan l'usuari arribi al capdavall." read_time_word_count: "Recompte de paraules per minut per a calcular el temps estimat de lectura." topic_page_title_includes_category: "L'etiqueta del títol de la pàgina del tema inclou el nom de la categoria." @@ -1816,10 +1834,15 @@ ca: default_categories_tracking: "Llista de categories seguides per defecte." default_categories_muted: "Llista de categories silenciades per defecte." default_categories_watching_first_post: "La llista de categories en què per defecte es vigilarà la primera publicació en cada tema nou. " + default_tags_watching: "Llista d’etiquetes que són vigilades per defecte." + default_tags_tracking: "Llista d’etiquetes que són seguides per defecte." + default_tags_muted: "Llista d'etiquetes que són silenciades per defecte." + default_tags_watching_first_post: "Llista d'etiquetes en les quals la primera publicació en cada tema nou serà vigilada per defecte." default_text_size: "Mida del text seleccionada per defecte" default_title_count_mode: "Mode predeterminat per al comptador de títols de la pàgina" retain_web_hook_events_period_days: "Nombre de dies per a conservar els registres d'esdeveniments webhook." retry_web_hook_events: "Reintenta automàticament quatre vegades els esdeveniments webhook fallits. Els intervals de temps entre els reintents són 1, 5, 25 i 125 minuts." + revoke_api_keys_days: "Nombre de dies abans que una clau d’API no utilitzada sigui revocada automàticament (0 per a mai)" allow_user_api_keys: "Permet la generació de claus API d'usuari" allow_user_api_key_scopes: "Llista d'àmbits permesos per a les claus d'API d'usuari" max_api_keys_per_user: "Nombre màxim de claus API personals per usuari" @@ -1908,6 +1931,8 @@ ca: topic: "Resultats" user: "Usuaris" results_page: "Resultats de la cerca per a '%{term}'" + audio: "[àudio]" + video: "[vídeo]" sso: login_error: "Error d'inici de sessió" not_found: "No s'ha trobat el vostre compte. Contacteu amb l'administrador del lloc web." @@ -1994,7 +2019,7 @@ ca: auto_deleted_by_timer: "S'ha suprimit automàticament per temporitzador." login: security_key_description: "Quan tingueu preparada la vostra clau de seguretat física, premeu el botó Autentica amb clau de seguretat." - security_key_alternative: "¿No podeu trobar la vostra clau de seguretat o voleu utilitzar un altre mètode?" + security_key_alternative: "Proveu d’una altra manera" security_key_authenticate: "Autenticació amb clau de seguretat" security_key_not_allowed_error: "El procés d'autenticació de claus de seguretat ha arribat al límit de temps o s'ha cancel·lat. " security_key_no_matching_credential_error: "No s'ha trobat cap credencial coincident amb la clau de seguretat proporcionada." @@ -2121,6 +2146,10 @@ ca: admin_confirmation_mailer: title: "Confirmació d'administració" subject_template: "[%{email_prefix}] Confirmeu un nou compte d'administració" + text_body_template: | + Confirmeu que voleu afegir **%{target_username} (%{target_email}) ** com a administrador del fòrum. + + [Confirma el compte d’administrador](%{admin_confirm_url}) test_mailer: title: "Test" subject_template: "[%{email_prefix}] Test de lliurament de correu electrònic" @@ -2212,7 +2241,7 @@ ca: Estem molt contents que passeu temps amb nosaltres, i ens agradaria conèixer-vos més bé. Dediqueu un moment a [omplir el vostre perfil](%{base_url}/my/preferences/profile), o bé podeu [començar un tema nou](%{base_url}/categories). welcome_staff: title: "Benvingut membre de l'equip responsable" - subject_template: "Enhorabona, ara sou %{role}!" + subject_template: "Enhorabona, se us ha concedit estatus de: %{role}!" welcome_invite: title: "Benvingut convidat" subject_template: "Benvingut a %{site_name}!" @@ -2745,20 +2774,13 @@ ca: title: "Confirmeu l'adreça de correu nova" subject_template: "[%{email_prefix}] Confirmeu la vostra nova adreça de correu" text_body_template: | - Confirmeu la vostra nova adreça de correu per a %{site_name} fent clic en l'enllaç següent: + Confirmeu la vostra nova adreça electrònica per a %{site_name} fent clic en l'enllaç següent: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-new-email/%{email_token} confirm_old_email: title: "Confirmeu l'adreça de correu antiga" subject_template: "[%{email_prefix}] Confirmeu la vostra adreça de correu actual" - text_body_template: | - Abans de poder canviar la vostra adreça de correu, cal que confirmeu que controleu - el compte de correu actual. Una vegada completat aquest pas, us farem confirmar - la nova adreça de correu. - - Confirmeu la vostra adreça actual per a %{site_name} fent clic en l'enllaç següent: - - %{base_url}/u/authorize-email/%{email_token} + text_body_template: "Abans de canviar la vostra adreça electrònica, necessitem que confirmeu que controleu\nel compte de correu electrònic actual. Després de completar aquest pas, us haurem de confirmar \nla nova adreça electrònica. \n\nConfirmeu la vostra adreça electrònica actual per a %{site_name} fent clic en l'enllaç següent:\n\n%{base_url}/u/confirm-old-email/%{email_token}\n" notify_old_email: title: "Notifica l'adreça de correu antiga" subject_template: "[%{email_prefix}] La vostra adreça de correu ha canviat" @@ -3137,10 +3159,13 @@ ca: invalid: one: "No es pot fer servir l'etiqueta que heu seleccionat" other: "No es pot fer servir cap de les etiquetes que heu seleccionat" - in_this_category: '"%{tag_name}" no es pot utilitzar en aquesta categoria' + in_this_category: '"%{tag_name}" no es pot utilitzar en aquesta categoria' restricted_to: - one: '"%{tag_name}" està restringit a la categoria "%{category_names}"' - other: '"%{tag_name}" és restringit a les següents categories: %{category_names}' + one: '"%{tag_name}" està restringit a la categoria "%{category_names}"' + other: '"%{tag_name}" és restringit a les següents categories: %{category_names}' + required_tags_from_group: + one: "Heu d’incloure almenys %{count} etiqueta %{tag_group_name}." + other: "Heu d’incloure com a mínim %{count} etiquetes %{tag_group_name}." rss_by_tag: "Temes etiquetats amb %{tag}" finish_installation: congratulations: "Enhorabona, heu instal·lat Discourse!" @@ -3306,7 +3331,7 @@ ca: posted: '%{username} publicat en "%{topic}" - %{site_title}' private_message: '%{username} us ha enviat un missatge privat en "%{topic}" - %{site_title}' linked: '%{username} ha enllaçat a la vostra publicació des de "%{topic}" - %{site_title}' - watching_first_post: '%{username} ha creat un tema nou "%{topic}" - %{site_title}' + watching_first_post: '%{username} ha creat un tema nou "%{topic}" - %{site_title}' confirm_title: "Notificacions activades - %{site_title}" confirm_body: "Èxit! S'han activat les notificacions." custom: "Notificació de %{username} en %{site_title}" diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml index 8bd03a3786..56b77c67c9 100644 --- a/config/locales/server.da.yml +++ b/config/locales/server.da.yml @@ -297,10 +297,10 @@ da: one: "%{count} bruger er føjet til gruppen." other: "%{count} brugere er blevet føjet til gruppen." errors: - grant_trust_level_not_valid: "'%{trust_level}' er ikke et gyldigt tillidsniveau." + grant_trust_level_not_valid: "'%{trust_level}' er ikke et gyldigt tillidsniveau." can_not_modify_automatic: "Du kan ikke modificere en automatisk gruppe" member_already_exist: - one: "'%{username}' er allerede medlem af denne gruppe." + one: "'%{username}' er allerede medlem af denne gruppe." other: "Følgende brugere er allerede medlemmer af denne gruppe: %{username}" invalid_domain: "'%{domain}' er ikke et gyldigt domæne." invalid_incoming_email: "'%{email}' er ikke en gyldig email adresse." @@ -573,9 +573,6 @@ da: error: "Der opstod en fejl under opdateringen af din e-mail-adresse. Måske er adressen allerede i brug?" error_staged: "Der opstod en fejl under opdateringen af din e-mail-adresse. Måske er adressen allerede i brug?" already_done: "Beklager, linket er ikke længere gyldigt - har du ændret din emal adresse?" - authorizing_old: - title: "Tak fordi du bekræftede din email adresse!" - description: "Vi sender nu en ny email der skal verificeres." activation: action: "Klik her for at aktivere din konto" already_done: "Beklager, dette bekræftelses-link er ikke længere gyldigt. Måske er din konto allerede aktiv?" diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 0a2f196bad..c3d59de2a3 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -696,9 +696,6 @@ de: error: "Es gab einen Fehler beim Ändern deiner E-Mail-Adresse. Wird vielleicht diese Adresse bereits verwendet?" error_staged: "Es gab einen Fehler beim Ändern deiner E-Mail-Adresse. Die Adresse wird bereits von einem vorbereiteten Benutzer verwendet." already_done: "Entschuldige, dieser Bestätigungs-Link ist nicht mehr gültig. Wurde vielleicht die E-Mail-Adresse bereits geändert?" - authorizing_old: - title: "Vielen Dank für die Bestätigung deiner aktuellen E-Mail-Adresse" - description: "Wir senden dir jetzt zur Bestätigung eine E-Mail an deine neue Adresse." associated_accounts: revoke_failed: "Das Widerrufen deines Kontos bei %{provider_name} ist fehlgeschlagen." connected: "(verbunden)" @@ -1336,7 +1333,6 @@ de: content_security_policy: "Aktiviere Content-Security-Policy" content_security_policy_report_only: "Aktiviere Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Aktiviere die Sammlung von Berichten zu CSP-Verstößen unter /csp_reports" - content_security_policy_script_src: "Zusätzlich erlaubte Script-Quellen. Der aktuelle Hostname und das Content Delivery Network (CDN) sind standardmäßig enthalten." invalidate_inactive_admin_email_after_days: "Admin-Konten, die die Seite diese Anzahl Tage nicht mehr besucht haben, müssen ihre E-Mail-Adresse neu bestätigen, bevor sie sich anmelden. Ein Wert von 0 deaktiviert die Funktion." top_menu: "Legt fest, welche Elemente in der Navigationsleiste der Startseite auftauchen sollen, und in welcher Reihenfolge. Beispiel: latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Legt fest, welche Funktionen in welcher Reihenfolge im Beitragsmenü auftauchen. Beispiel: like|edit|flag|delete|share|bookmark|reply" @@ -1700,8 +1696,6 @@ 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 Bearbeitungshinweise durch den System-Benutzer, wenn die 'download_remote_images_to_local' Einstellung aktiviert ist." - likes_notification_consolidation_threshold: "Anzahl der Like-Benachrichtigungen, bevor die Benachrichtigungen in eine einzelne zusammengeführt werden. Ein Wert von 0 deaktiviert die Funktion. Das Zeitfenster kann via ``SiteSetting.likes_notification_consolidation_window_mins` eingestellt werden." - likes_notification_consolidation_window_mins: "Zeitfenster in Minuten, in dem mehrere Like-Benachrichtigungen in eine einzelne Benachrichtigung zusammengeführt werden, sobald der Schwellenwert erreicht wird. Der Schwellenwert kann via `SiteSetting.likes_notification_consolidation_threshold` eingestellt werden." automatically_unpin_topics: "Themen automatisch loslösen, wenn ein Benutzer das Ende erreicht." read_time_word_count: "Wörter pro Minute für die Berechnung der geschätzten Lesezeit." topic_page_title_includes_category: "Themen-Seite title tag enthält einen Kategorienamen." @@ -1955,7 +1949,7 @@ de: auto_deleted_by_timer: "Automatisch gelöscht durch Timer." login: security_key_description: "Wenn Du Deinen physischen Sicherheitsschlüssel vorbereitet hast, klicke unten auf die Schaltfläche \"Mit Sicherheitsschlüssel authentifizieren\"." - security_key_alternative: "Du kannst Deinen Sicherheitsschlüssel nicht finden oder möchtest eine andere Methode verwenden?" + security_key_alternative: "Versuche einen anderen Weg" security_key_authenticate: "Mit Sicherheitsschlüssel authentifizieren" security_key_not_allowed_error: "Der Authentifizierungsprozess für den Sicherheitsschlüssel ist abgelaufen oder wurde abgebrochen." security_key_no_matching_credential_error: "Im angegebenen Sicherheitsschlüssel wurden keine übereinstimmenden Anmeldeinformationen gefunden." @@ -3017,19 +3011,9 @@ de: confirm_new_email: title: "E-Mail-Adresse bestätigen (an neue)" subject_template: "[%{email_prefix}] Bestätige deine neue E-Mail-Adresse" - text_body_template: | - Bestätige deine neue E-Mail-Adresse für %{site_name}, indem du dem diesem Link folgst: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "E-Mail-Adresse bestätigen (an alte)" subject_template: "[%{email_prefix}] Bestätige deine aktuelle E-Mail-Adresse" - text_body_template: | - Bevor wir deine E-Mail-Adresse ändern können, ist es nötig, dass du die Kontrolle über deine aktuelle E-Mail-Adresse bestätigst. Wenn du diesen Schritt erledigst, werden wir dich bitten, deine neue E-Mail-Adresse zu bestätigen. - - Bestätige deine aktuelle E-Mail-Adresse für %{site_name}, indem du diesem Link folgst: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Benachrichtigung an alte E-Mail-Adresse" subject_template: "[%{email_prefix}] Deine E-Mail-Adresse wurde geändert" @@ -3982,6 +3966,8 @@ de: user_merged: "%{username} wurde mit diesem Konto zusammengeführt" user_delete_self: "Selbst gelöscht von %{url}" webhook_deactivation_reason: "Dein Webhook wurde automatisch deaktiviert. Wir bekommen zahlreiche '%{status}' fehlgeschlagene HTTP Status Antworten." + api_key: + revoked: Widerrufen reviewables: priorities: low: "Niedrig" diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 0e57b3621e..1c9be5dd3b 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -490,9 +490,6 @@ el: error: "Υπήρξε ένα σφάλμα κατά την αλλαγή της διεύθυνσης email σου. Ίσως αυτή η διεύθυνση είναι ήδη σε χρήση;" error_staged: "Υπήρξε ένα σφάλμα κατά την αλλαγή της διεύθυνσης email σου. Η δεύθυνση αυτή χρησιμοποιείται ήδη από αλλό χρήστη. " already_done: "Λυπούμαστε, αυτός ο σύνδεσμος επιβεβαίωσης του λογαριασμού σας δεν είναι πλέον έγκυρος. Ίσως η διεύθυνση email σας να έχει αλλάξει ήδη." - authorizing_old: - title: "Ευχαριστούμε για την επιβεβαίωση της τρέχουσας διεύθυνσης email" - description: "Σας στέλνουμε τώρα email για την επιβεβαίωση της νέας σας διέυθυνσης." activation: action: "Πατήστε εδώ για να ενεργοποιήσετε το λογαριασμό σας." already_done: "Συγνώμη, αυτός ο σύνδεσμος επιβεβαίωσης του λογαριασμού σας δεν είναι πλέον έγκυρος. Ίσως ο λογαριασμός σας είναι ήδη ενεργός;" @@ -2080,25 +2077,9 @@ el: confirm_new_email: title: "Επιβεβαίωση νέας διεύθυνσης email" subject_template: "[%{email_prefix}] Επικυρώστε την νέα σας διεύθυνση email" - text_body_template: |2 - - Επικυρώστε την νέα σας διεύθυνση email στην %{site_name} κάνοντας κλικ στον παρακάτω σύνδεσμο: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Επιβεβαίωση παλιάς διεύθυνσης email" subject_template: "[%{email_prefix}] Επικυρώστε την νέα σας διεύθυνση email" - text_body_template: |2 - - Προτού αλλάξουμε την διεύθυνση email σας, θα πρέπει να επιβεβαιώσουμε ότι σας ανήκει - - η τρέχουσα διεύθυνση email. Αφού ολοκληρώσετε αυτό το βήμα, θα σας ζητήσουμε να - - επιβεβαιώσετε την νέα σας διεύθυνση email. - - Επιβεβαιώστε την τρέχουσα διεύθυνση email στην%{site_name} κάνοντας κλικ στον παρακάτω σύνδεσμο: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Ειδοποίηση παλιάς διεύθυνσης email" subject_template: "[%{email_prefix}] Η διεύθυνση email σας έχει αλλαχθεί" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index fded4fd68d..1a89507cd5 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -92,6 +92,7 @@ en: component_no_default: "Theme components can't be default theme" component_no_color_scheme: "Theme components can't have color palettes" no_multilevels_components: "Themes with child themes can't be child themes themselves" + optimized_link: Optimized image links are ephemeral and should not be included in theme source code. settings_errors: invalid_yaml: "Provided YAML is invalid." data_type_not_a_number: "Setting `%{name}` type is unsupported. Supported types are `integer`, `bool`, `list` and `enum`" @@ -139,6 +140,7 @@ en: unsubscribe_not_allowed: "Happens when unsubscribing via email is not allowed for this user." email_not_allowed: "Happens when the email address is not on the whitelist or is on the blacklist." unrecognized_error: "Unrecognized Error" + secure_media_placeholder: "Redacted: this site has secure media enabled, visit the topic to see the attached image/audio/video." errors: &errors format: ! "%{attribute} %{message}" @@ -161,6 +163,7 @@ en: inclusion: is not included in the list invalid: is invalid is_invalid: "seems unclear, is it a complete sentence?" + invalid_timezone: "'%{tz}' is not a valid timezone" contains_censored_words: "contains the following censored words: %{censored_words}" less_than: must be less than %{count} less_than_or_equal_to: must be less than or equal to %{count} @@ -203,6 +206,10 @@ en: enable_s3_uploads_is_required: "You cannot enable inventory to S3 unless you've enabled the S3 uploads." s3_backup_requires_s3_settings: "You cannot use S3 as backup location unless you've provided the '%{setting_name}'." s3_bucket_reused: "You cannot use the same bucket for 's3_upload_bucket' and 's3_backup_bucket'. Choose a different bucket or use a different path for each bucket." + secure_media_requirements: "S3 uploads must be enabled before enabling secure media." + second_factor_cannot_be_enforced_with_disabled_local_login: "You cannot enforce 2FA if local logins are disabled." + local_login_cannot_be_disabled_if_second_factor_enforced: "You cannot disable local login if 2FA is enforced. Disable enforced 2FA before disabling local logins." + cannot_enable_s3_uploads_when_s3_enabled_globally: "You cannot enable S3 uploads because S3 uploads are already globally enabled, and enabling this site-level could cause critical issues with uploads" conflicting_google_user_id: 'The Google Account ID for this account has changed; staff intervention is required for security reasons. Please contact staff and point them to
https://meta.discourse.org/t/76575' activemodel: @@ -330,6 +337,7 @@ en: max_pm_recepients: "Sorry, you can send a message to maximum %{recipients_limit} recipients." pm_reached_recipients_limit: "Sorry, you can't have more than %{recipients_limit} recipients in a message." removed_direct_reply_full_quotes: "Automatically removed quote of whole previous post." + secure_upload_not_allowed_in_public_topic: "Sorry, the following secure upload(s) cannot be used in a public topic: %{upload_filenames}." just_posted_that: "is too similar to what you recently posted" invalid_characters: "contains invalid characters" @@ -798,14 +806,25 @@ en: unknown: "unknown operating system" change_email: + wrong_account_error: "You are logged in the wrong account, please log out and try again." confirmed: "Your email has been updated." please_continue: "Continue to %{site_name}" error: "There was an error changing your email address. Perhaps the address is already in use?" error_staged: "There was an error changing your email address. The address is already in use by a staged user." already_done: "Sorry, this confirmation link is no longer valid. Perhaps your email was already changed?" + confirm: "Confirm" + + authorizing_new: + title: "Confirm your new email" + description: "Please confirm you would like your new email address changed to:" + authorizing_old: - title: "Thanks for confirming your current email address" - description: "We're now emailing your new address for confirmation." + title: "Change your email address" + description: "Please confirm your email address change" + old_email: "Old email: %{email}" + new_email: "New email: %{email}" + almost_done_title: "Confirming new email address" + almost_done_description: "We have sent an email to your new email address to confirm the change!" associated_accounts: revoke_failed: "Failed to revoke your account with %{provider_name}." @@ -952,6 +971,9 @@ en: topic_description: "To re-subscribe to %{link}, use the notification control at the bottom or right of the topic." private_topic_description: "To re-subscribe, use the notification control at the bottom or right of the topic." + uploads: + marked_insecure_from_theme_component_reason: "upload used in theme component" + unsubscribe: title: "Unsubscribe" stop_watching_topic: "Stop watching this topic, %{link}" @@ -1338,6 +1360,8 @@ en: other: "Email polling has generated %{count} errors in the past 24 hours. Look at the logs for more details." missing_mailgun_api_key: "The server is configured to send emails via Mailgun but you haven't provided an API key used to verify the webhook messages." bad_favicon_url: "The favicon is failing to load. Check your favicon setting in Site Settings." + deprecated_api_usage: "We detected an API request using a deprecated authentication method. Please update it to use header based auth. After updating this message may take 24 hours to disappear." + update_mail_receiver: "We detected an outdated version of mail-receiver. Click here for update instructions. After updating this message may take 24 hours to disappear." poll_pop3_timeout: "Connection to the POP3 server is timing out. Incoming email could not be retrieved. Please check your POP3 settings and service provider." poll_pop3_auth_error: "Connection to the POP3 server is failing with an authentication error. Please check your POP3 settings." force_https_warning: "Your website is using SSL. But `force_https` is not yet enabled in your site settings." @@ -1491,7 +1515,7 @@ en: content_security_policy: "Enable Content-Security-Policy" content_security_policy_report_only: "Enable Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Enable CSP violation report collection at /csp_reports" - content_security_policy_script_src: "Additional whitelisted script sources. The current host and CDN are included by default." + content_security_policy_script_src: "Additional whitelisted script sources. The current host and CDN are included by default. See Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Admin accounts that have not visited the site in this number of days will need to re-validate their email address before logging in. Set to 0 to disable." top_menu: "Determine which items appear in the homepage navigation, and in what order. Example latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determine which items appear on the post menu, and in what order. Example like|edit|flag|delete|share|bookmark|reply" @@ -1879,6 +1903,7 @@ en: log_mail_processing_failures: "Log all email processing failures to /logs" email_in: 'Allow users to post new topics via email (requires manual or pop3 polling). Configure the addresses in the "Settings" tab of each category.' email_in_min_trust: "The minimum trust level a user needs to have to be allowed to post new topics via email." + email_in_authserv_id: "The identifier of the service doing authentication checks on incoming emails. See https://meta.discourse.org/t/134358 for instructions on how to configure this." email_in_spam_header: "The email header to detect spam." email_prefix: "The [label] used in the subject of emails. It will default to 'title' if not set." email_site_title: "The title of the site used as the sender of emails from the site. Default to 'title' if not set. If your 'title' contains characters that are not allowed in email sender strings, use this setting." @@ -1963,9 +1988,9 @@ en: disable_system_edit_notifications: "Disables edit notifications by the system user when 'download_remote_images_to_local' is active." - likes_notification_consolidation_threshold: "Number of liked notifications received before the notifications are consolidated into a single one. Set to 0 to disable. The window can be configured via `SiteSetting.likes_notification_consolidation_window_mins`." + 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.likes_notification_consolidation_threshold`." + 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`." automatically_unpin_topics: "Automatically unpin topics when the user reaches the bottom." @@ -2012,7 +2037,7 @@ en: bootstrap_mode_min_users: "Minimum number of users required to disable bootstrap mode (set to 0 to disable)" prevent_anons_from_downloading_files: "Prevent anonymous users from downloading attachments. WARNING: this will prevent any non-image site assets posted as attachments from working." - + secure_media: 'Limits access to media uploads (images, video, audio). If "login required" is enabled, only logged-in users can access media uploads. Otherwise, access will be limited only for media uploads in private messages. Note: S3 uploads must be enabled prior to enabling this setting.' slug_generation_method: "Choose a slug generation method. 'encoded' will generate percent encoding string. 'none' will disable slug at all." enable_emoji: "Enable emoji" @@ -2066,6 +2091,7 @@ en: default_categories_tracking: "List of categories that are tracked by default." default_categories_muted: "List of categories that are muted by default." default_categories_watching_first_post: "List of categories in which first post in each new topic will be watched by default." + mute_all_categories_by_default: "Set the default notification level of all the categories to muted. Require users opt-in to categories for them to appear in 'latest' and 'categories' pages. If you wish to amend the defaults for anonymous users set 'default_categories_' settings." default_tags_watching: "List of tags that are watched by default." default_tags_tracking: "List of tags that are tracked by default." @@ -2275,7 +2301,7 @@ en: login: security_key_description: "When you have your physical security key prepared press the Authenticate with Security Key button below." - security_key_alternative: "Can't find your security key or want to use another method?" + security_key_alternative: "Try another way" security_key_authenticate: "Authenticate with Security Key" security_key_not_allowed_error: "The security key authentication process either timed out or was cancelled." security_key_no_matching_credential_error: "No matching credentials could be found in the provided security key." @@ -2680,7 +2706,7 @@ en: welcome_staff: title: "Welcome Staff" - subject_template: "Congratulations, you’re now a %{role}!" + subject_template: "Congratulations, you’ve been granted %{role} status!" text_body_template: | You’ve been granted %{role} status by a fellow staff member. @@ -3535,7 +3561,7 @@ en: text_body_template: | Confirm your new email address for %{site_name} by clicking on the following link: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-new-email/%{email_token} confirm_old_email: title: "Confirm Old Email" @@ -3547,7 +3573,7 @@ en: Confirm your current email address for %{site_name} by clicking on the following link: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: title: "Notify Old Email" @@ -4352,9 +4378,13 @@ en: restricted_to: one: '"%{tag_name}" is restricted to the "%{category_names}" category' other: '"%{tag_name}" is restricted to the following categories: %{category_names}' + synonym: 'Synonyms are not allowed. Use "%{tag_name}" instead.' + has_synonyms: '"%{tag_name}" cannot be used because it has synonyms.' required_tags_from_group: one: "You must include at least %{count} %{tag_group_name} tag." other: "You must include at least %{count} %{tag_group_name} tags." + invalid_target_tag: "cannot be a synonym of a synonym" + synonyms_exist: "is not allowed while synonyms exist" rss_by_tag: "Topics tagged %{tag}" finish_installation: diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index a7a3ba7aed..39efdd0a92 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -56,6 +56,7 @@ es: component_no_default: "Los componentes del tema no pueden ser tema predeterminado" component_no_color_scheme: "Los componentes del tema no pueden tener paletas de colores" no_multilevels_components: "Los temas con temas secundarios no pueden ser temas secundarios en sí mismos." + optimized_link: Los enlaces de imagen optimizados son efímeros y no deben incluirse en el código fuente del tema. settings_errors: invalid_yaml: "El YAML provisto es inválido." data_type_not_a_number: "El tipo de ajuste «%{name}» no está soportado. Los tipos soportados son: «integer», «bool», «list» y «enum»" @@ -103,6 +104,7 @@ es: unsubscribe_not_allowed: "Sucede cuando no se le permite a este usuario anular la subscripción por correo electrónico." email_not_allowed: "Sucede cuando la dirección de correo electrónico no está en la lista blanca o está en la lista negra." unrecognized_error: "Error no identificado" + secure_media_placeholder: "Redactado: este sitio tiene medios seguros habilitados. Visita el tema para ver las imágenes/audio/video adjuntas." errors: &errors format: "%{attribute} %{message}" format_with_full_message: "%{attribute}: %{message}" @@ -124,6 +126,7 @@ es: inclusion: no está incluido en la lista invalid: es inválido is_invalid: "parece poco claro, ¿es una oración completa?" + invalid_timezone: "'%{tz}' no es una zona horaria" contains_censored_words: "contiene las siguientes palabras censuradas: %{censored_words}" less_than: "debe ser menor que %{count}" less_than_or_equal_to: "debe ser menor o igual que %{count}" @@ -166,6 +169,10 @@ es: enable_s3_uploads_is_required: "No puedes activar el inventario en S3 a menos que se haya habilitado las subidas en S3." s3_backup_requires_s3_settings: "No puedes usar S3 como método de respaldo salvo que hayas rellenado «%{setting_name}»." s3_bucket_reused: "No puedes usar el mismo bucket para «s3_upload_bucket» y «s3_backup_bucket». Por favor, selecciona otro bucket o usa una ruta diferente para cada bucket." + secure_media_requirements: "Se debe habilitar la subida de S3 antes de habilitar medios seguros." + second_factor_cannot_be_enforced_with_disabled_local_login: "No puede aplicar 2FA si los inicios de sesión locales están deshabilitados." + local_login_cannot_be_disabled_if_second_factor_enforced: "No puede deshabilitar el inicio de sesión local si se aplica 2FA. Deshabilite 2FA antes de deshabilitar los inicios de sesión locales." + cannot_enable_s3_uploads_when_s3_enabled_globally: "No puedes habilitar las cargas S3 porque las cargas S3 ya están habilitadas globalmente, y habilitar este nivel de sitio podría causar problemas críticos con las cargas." conflicting_google_user_id: 'El ID de la cuenta Google para esta cuenta ha cambiado; el staff debe intervenir por razones de seguridad. Por favor, ponte en contacto con el staff y envía esta referencia
https://meta.discourse.org/t/76575' activemodel: errors: @@ -248,6 +255,7 @@ es: other: "%{count} me gusta" last_reply: "Última respuesta" created: "Creado" + new_topic: "Crear tema nuevo" no_mentions_allowed: "Lo sentimos, no puedes mencionar a otros usuarios." too_many_mentions: one: "Lo sentimos, solo puedes mencionar a un usuario en un post." @@ -279,6 +287,7 @@ es: max_pm_recepients: "Lo sentimos, puedes enviar un mensaje a un máximo de %{recipients_limit} destinatarios." pm_reached_recipients_limit: "Lo sentimos, no puedes tener más de %{recipients_limit} destinatarios en un mensaje." removed_direct_reply_full_quotes: "Cita de toda la publicación anterior eliminada automáticamente." + secure_upload_not_allowed_in_public_topic: "Lo sentimos, la(s) siguiente(s) subida(s) no se pueden utilizar en un tema público: %{upload_filenames}." just_posted_that: "es demasiado parecido a lo que has publicado recientemente" invalid_characters: "contiene caracteres inválidos" is_invalid: "parece poco claro, ¿es una oración completa?" @@ -713,14 +722,23 @@ es: windows: "Microsoft Windows" unknown: "sistema operativo desconocido" change_email: + wrong_account_error: "Iniciaste sesión en la cuenta equivocada. Por favor, cierra sesión e intenta nuevamente." confirmed: "Se actualizó tu correo electrónico." please_continue: "Continuar a %{site_name}" error: "Hubo un problema al cambiar tu dirección de correo electrónico. ¿Quizás la dirección ya está en uso?" error_staged: "Se produjo un error al cambiar tu correo electrónico. La dirección ya está en uso por un usuario temporal." already_done: "Lo sentimos, este enlace de confirmación ya no es válido. ¿Quizá tu correo electrónico ya fue cambiado?" + confirm: "Confirmar" + authorizing_new: + title: "Confirmar tu nuevo correo electrónico" + description: "Por favor, confirma que quisieras cambiar tu correo electrónico por el nuevo: " authorizing_old: - title: "Gracias por confirmar tu dirección de correo electrónico actual" - description: "Te enviaremos un correo electrónico a tu nueva dirección para confirmar." + title: "Cambiar tu dirección de correo electrónico" + description: "Por favor, confirma el cambio de tu dirección de correo electrónico" + old_email: "Correo electrónico antiguo: %{email}" + new_email: "Correo electrónico nuevo: %{email}" + almost_done_title: "Confirmando la nueva dirección de correo electrónico" + almost_done_description: "¡Enviamos un correo a tu nueva dirección de correo electrónico para confirmar el cambio!" associated_accounts: revoke_failed: "No pudo revocar la conexión con %{provider_name}." connected: "(conectados)" @@ -735,6 +753,7 @@ es: activated: "Disculpa, esta cuenta ya fue activada." admin_confirm: title: "Confirmar cuenta de administrador" + description: "¿Estás seguro de que querer convertir a %{target_username} (%{target_email}) en un administrador?" grant: "Conceder acceso de administrador" complete: "%{target_username} ahora es un administrador." back_to: "Volver al %{title}" @@ -855,6 +874,8 @@ es: description: "las preferencias de correo electrónico para %{email} han sido actualizadas. Para cambiar tus configuraciones de correo electrónico, visita tus preferencias de usuario." topic_description: "Para volver a suscribirte a %{link}, cambia los ajustes de notificación en la parte inferior o en la parte derecha del tema." private_topic_description: "Para volver a suscribirte, cambia los ajustes de notificación en la parte inferior o en la parte derecha del tema." + uploads: + marked_insecure_from_theme_component_reason: "subida usada en componente de tema" unsubscribe: title: "Cancelar suscripción" stop_watching_topic: "Dejar de vigilar este tema, %{link}" @@ -1238,6 +1259,8 @@ es: other: "El polling por correo electrónico ha generado %{count} errores en las últimas 24 horas. Revisa los registros para más detalles." missing_mailgun_api_key: "El servidor está configurado para enviar correos electrónicos a través de Mailgun pero no has proporcionado una clave API que se utiliza para verificar los mensajes de webhook." bad_favicon_url: "El favicon está produciendo errores en el proceso de carga. Revisa la opción favicon en los ajustes del sitio." + deprecated_api_usage: "Detectamos una solicitud de API utilizando un método de autenticación obsoleto. Actualicelo para usar la autenticación basada en un encabezado. Después de actualizar, este mensaje puede tardar 24 horas en desaparecer." + update_mail_receiver: "Detectamos una versión desactualizada del receptor de correo. Clic aquí para instrucciones de actualización. Después de actualizar, este mensaje puede tardar 24 horas en desaparecer." poll_pop3_timeout: "La conexión al servidor POP3 está superando el tiempo de espera. No se pudieron recuperar los correos electrónicos entrantes. Por favor, revisa los ajustes de POP3 y tu proveedor de servicio." poll_pop3_auth_error: "La conexión al servidor POP3 está fallando debido a un error de autenticación. Por favor, revisa los ajustes POP3." force_https_warning: "Tu sitio web está usando SSL. Pero «force_https» no está habilitado todavía en la configuración de tu sitio." @@ -1381,7 +1404,7 @@ es: content_security_policy: "Activar la política de seguridad de contenido (CSP)" content_security_policy_report_only: "Activar solo el informe de la política de seguridad de contenido (CPS)" content_security_policy_collect_reports: "Habilitar la recolección de reportes de violación de CSP en /csp_reports" - content_security_policy_script_src: "Fuentes adicionales de script en la lista blanca. El host actual y CDN se incluyen por defecto." + content_security_policy_script_src: "Fuentes de script adicionales en la lista blanca. El host actual y CDN se incluyen por defecto. Leer Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Las cuentas administrativas que no hayan visitado la página en este número de días deberán validar de nuevo su dirección de correo electrónico antes de iniciar sesión. Establecer a 0 para desactivar." top_menu: "Determinar los elementos que aparecen en el menú de navegación de la página de inicio y su orden. Ejemplo últimos|nuevos|no leídos|categorías|destacados|leídos|publicados|marcadores" post_menu: "Determinar los elementos que aparecen en el menú de publicación y su orden. Ejemplo: me gusta|editar|reportar|eliminar|compartir|guardar en marcadores|responder" @@ -1691,6 +1714,7 @@ es: log_mail_processing_failures: "Registra todos los fallos de procesamiento de /registros" email_in: 'Permitir a los usuarios crear nuevos temas por correo electrónico (requiere el polling manual o pop3). Configura las direcciones en la pestaña «ajustes» de cada categoría.' email_in_min_trust: "El nivel de confianza mínimo requerido para poder publicar temas nuevos por correo electrónico." + email_in_authserv_id: "El identificador del servicio realizando revisiones de autenticación en correos electrónicos entrantes. Ver: https://meta.discourse.org/t/134358 para obtener instrucciones sobre cómo configurar esto." email_in_spam_header: "El encabezado del correo electrónico para detectar spam." email_prefix: "La [etiqueta] utilizada en el asunto de los correos electrónicos. Si no está configurado, será por defecto el «título»." email_site_title: "El título del sitio utilizado como remitente de los correos electrónicos desde el sitio. Si no está configurado, será por defecto «título». Si tu «título» contiene caracteres que no están permitidos en las cadenas del remitente del correo electrónico, usa esta opción." @@ -1749,8 +1773,6 @@ 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 anuncio 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: "Inhabilitar editar notificaciones por el usuario del sistema cuando «download_remote_images_to_local» este activo." - likes_notification_consolidation_threshold: "Número de notificaciones de me gusta recibidas antes de que las notificaciones se consoliden en una sola. Establece el valor en 0 para deshabilitar. La ventana se puede configurar a través de `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Duración en minutos tras los que se consolidan las notificaciones de me gusta en una sola notificación una vez que se ha alcanzado el umbral. El umbral se puede configurar a través de `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Quitar destacado automáticamente cuando el usuario llega al final del tema." read_time_word_count: "Número de palabras por minuto para calcular el tiempo de lectura estimado." topic_page_title_includes_category: "La etiqueta del título de la página del tema incluye el nombre de la categoría." @@ -1784,6 +1806,7 @@ es: delete_drafts_older_than_n_days: "Eliminar borradores de más de (n) días de antigüedad." bootstrap_mode_min_users: "Número mínimo de usuarios requerido para desactivar el modo bootstrap (establece en 0 para desactivar esta opción)" prevent_anons_from_downloading_files: "Impedir que los usuarios anónimos descarguen archivos. ADVERTENCIA: Esto impedirá que funcione cualquier recurso del sitio publicado como adjunto que no sea una imagen." + secure_media: 'Limita el acceso a los medios subidos (imágenes, video, audio). Si está habilitado «inicio de sesión requerido», solo los usuarios que hayan iniciado sesión pueden acceder a los medios subidos. En caso contrario, se limitará el acceso únicamente a los medios subidos en mensajes privados. Nota: se deben habilitar las subidas S3 antes de poder habilitar esta configuración.' slug_generation_method: "Elegir un método de generación de slug. «encoded» generará cadenas con código porcentual. «none» deshabilitará completamente el slug." enable_emoji: "Habilitar emoji" enable_emoji_shortcuts: "Texto común de emoticones como :) :p :( se convertirán a emojis" @@ -1826,6 +1849,7 @@ es: default_categories_tracking: "Lista de categorías que están seguidas por defecto" default_categories_muted: "Lista de categorías que están silenciadas por defecto." default_categories_watching_first_post: "Lista de categorías en las que el primer mensaje de cada tema nuevo se vigilará por defecto." + mute_all_categories_by_default: "Establezca el nivel de notificación predeterminado de todas las categorías en silenciado. Solicite a los usuarios que opten por las categorías para que aparezcan en las páginas de 'latest' y 'categories'. Si desea modificar los valores predeterminados para usuarios anónimos, establezca la configuración en 'default_categories_'." default_tags_watching: "Lista de etiquetas vigiladas por defecto." default_tags_tracking: "Lista de etiquetas seguidas por defecto." default_tags_muted: "Lista de etiquetas silenciadas por defecto." @@ -1834,6 +1858,7 @@ es: default_title_count_mode: "Modo por defecto para el contador del título" retain_web_hook_events_period_days: "Número de días para retener registros de eventos de web hook." retry_web_hook_events: "Reintentar automáticamente 4 veces los eventos del web hook fallidos. Los intervalos de tiempo entre los reintentos son 1, 5, 25 y 125 minutos." + revoke_api_keys_days: "Número de días antes de que una clave de API sin usar sea revocada automáticamente (0 para nunca)" allow_user_api_keys: "Permitir que se generen claves de API de usuario" allow_user_api_key_scopes: "Lista de ámbitos permitidos para las claves API de usuario" max_api_keys_per_user: "Número máximo de claves API de usuario por usuario" @@ -2010,7 +2035,7 @@ es: auto_deleted_by_timer: "Eliminado automáticamente por el temporizador." login: security_key_description: "Cuando tengas tu clave de seguridad física preparada, presiona el botón de autenticar con clave de seguridad que se encuentra debajo." - security_key_alternative: "¿No encuentras tu clave de seguridad o quieres utilizar otro método?" + security_key_alternative: "Intenta de otra manera" security_key_authenticate: "Autenticar con clave de seguridad" security_key_not_allowed_error: "La autenticación de la clave de seguridad fue cancelada o se agotó el tiempo." security_key_no_matching_credential_error: "No se encontraron credenciales que coincidan en la clave de seguridad provista." @@ -2200,6 +2225,10 @@ es: admin_confirmation_mailer: title: "Confirmación de administrador" subject_template: "[%{email_prefix}] Confirma nueva cuenta de administrador" + text_body_template: | + Por favor, confirma que quieres añadir **%{target_username} (%{target_email})** como un administrador en tu foro. + + [Confirmar cuenta de administrador](%{admin_confirm_url}) test_mailer: title: "Correo electrónico de prueba" subject_template: "[%{email_prefix}] Prueba de envío de correo electrónico" @@ -2382,6 +2411,15 @@ es: Hola. Hemos visto que has estado leyendo, lo cual nos parece fantástico, ¡por lo que te hemos subido tu [nivel de confianza](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/)! Nos alegramos de ver que estés pasando tiempo con nosotros, y nos gustaría saber más sobre ti. Cuando puedas, [rellena tu perfil](%{base_url}/my/preferences/profile) o [crea un nuevo tema](%{base_url}/categories). + welcome_staff: + title: "Te damos la bienvenida al equipo del foro" + subject_template: "Felicidades, ¡te han hecho %{role}!" + text_body_template: | + ¡Felicidades! Un miembro del equipo del foro te ha hecho %{role}. + + Como %{role}, ahora tienes acceso a la interfaz de administración. + + Con un gran poder viene una gran responsabilidad. Si no tienes experiencia moderando, por favor, revisa la [Guía de moderación](https://meta.discourse.org/t/discourse-moderation-guide/63116). welcome_invite: title: "Bienvenida al invitado" subject_template: "¡Bienvenido a %{site_name}!" @@ -3127,18 +3165,17 @@ es: text_body_template: | Confirma tu nueva dirección de correo electrónico para %{site_name} haciendo clic en el siguiente enlace: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-old-email/%{email_token} confirm_old_email: title: "Confirmar correo electrónico antiguo" subject_template: "[%{email_prefix}] Confirma tu dirección actual de correo electrónico" text_body_template: | - Antes de cambiar tu dirección de correo electrónico, necesitamos que confirmes que controlas - el correo actual. Después de completar este paso, podrás confirmar - la nueva dirección de correo electrónico. + Antes de que podamos cambiar tu dirección de correo electrónico, es necesario que confirmes + que la cuenta de correo electrónica actual está bajo tu control. Tras completar este paso, deberemos confirmar también la nueva dirección. - Confirma tu correo electrónico actual para %{site_name} haciendo clic en el siguiente enlace: + Confirma tu dirección de correo electrónico actual para %{site_name} haciendo clic en el siguiente enlace: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: title: "Antiguo correo electrónico de notificaciones" subject_template: "[%{email_prefix}] Tu dirección de correo electrónico ha sido cambiada" @@ -4095,6 +4132,12 @@ es: user_merged: "%{username} se ha unido en esta cuenta" user_delete_self: "Eliminado a sí mismo de %{url}" webhook_deactivation_reason: "Tu webhook ha sido desactivado automáticamente. Recibimos varias respuestas «%{status}» HTTP error de status." + api_key: + automatic_revoked: + one: "Revocada automáticamente, última actividad hace más de %{count} día." + other: "Revocada automáticamente, última actividad hace más de %{count} días." + revoked: Revocada + restored: Restaurada reviewables: priorities: low: "Bajo" diff --git a/config/locales/server.et.yml b/config/locales/server.et.yml index 4a1b073d68..2f0ae45d26 100644 --- a/config/locales/server.et.yml +++ b/config/locales/server.et.yml @@ -452,9 +452,6 @@ et: error: "Sinu meiliaadressi muutmisel esines tõrge. Äkki on see juba kasutuses?" error_staged: "Sinu meiliaadressi muutmisel esines tõrge. Ettevalmistamisel olev kasutaja juba kasutab seda." already_done: "Vabandust, see kinnitamise link ei kehti enam. Äkki on teie meiliaadress juba muudetud?" - authorizing_old: - title: "Täname, et oma kehtiva meiliaadressi kinnitasid" - description: "Saadame nüüd kinnituse Teie uuele meiliaadressile." activation: action: "Oma konto aktiveerimiseks kliki siia" already_done: "Vabandust, see konto kinnitamise link ei kehti enam. Äkki on Teie konto juba aktiveeritud?" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index 4179077907..020bc0bf58 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -488,9 +488,6 @@ fa_IR: error: "در تغییر ایمیلتان خطایی روی داد. شاید قبلا در سایت استفاده شده است؟" error_staged: "خطایی در تغییر ایمیل رخ داده است. این ایمیل در حال حاضر در یک حساب‌کاربری خودکار استفاده می‌شود." already_done: "با عرض پوزش، پیوند تایید نامعتبر است. شاید ایمیل شما تغییر کرده؟" - authorizing_old: - title: "با تشکر از شما بابت اینکه ایمیل خود را تایید کردید." - description: "در حال ارسال ایمیل تایید به شما هستیم." activation: action: "برای فعال کردن حساب‌کاربری خود اینجا کلیک کنید" already_done: "متاسفیم، این پیوند تاییدیه حساب کاربری دیگر معتبر نیست. شاید حساب‌کاربری شما در حال حاضر فعال است." @@ -1333,7 +1330,6 @@ fa_IR: autoclosed_disabled_lastpost: "این موضوع در حال حاضر باز است. پاسخ‌های جدید اجازه‌ی ثبت دارند." auto_deleted_by_timer: "به صورت خودکار توسط زمان‌سنج حذف شده." login: - security_key_alternative: "نمی توانید کلید امنیتی خود را پیدا کنید یا می خواهید از روش دیگری استفاده کنید؟" security_key_authenticate: "تأیید اعتبار با کلید امنیتی" security_key_not_allowed_error: "مراحل تأیید اعتبار کلید امنیتی به پایان رسیده است یا لغو شده است." not_approved: "حساب کاربری شما هنوز تایید نشده است. وقتی شما آماده ورود به سیستم شوید به شما اطلاع داده می شود." @@ -1893,21 +1889,9 @@ fa_IR: confirm_new_email: title: "تایید ایمیل" subject_template: "[%{email_prefix}] ایمیل جدیدتان را تایید کنید" - text_body_template: | - ایمیل جدید خود را برای %{site_name} با کلیک روی لینک زیر تایید کنید: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "تایید ایمیل قبلی" subject_template: "[%{email_prefix}] ایمیل فعلی خود را تایید کنید" - text_body_template: | - قبل از تغییر ایمیل شما، باید تایید کنیم که ایمیل تحت کنترل شما است. - برای ایمیل فعلی بعد از تکمیل این گام، ایمیل تایید ارسال خواهد شد - ایمیل جدید. - - ایمیل فعلی خود در سایت %{site_name} را با کلیکل روی لینک زیر تعیید کنید: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "اعلام ایمیل قبلی" subject_template: "[%{email_prefix}] ایمیل شما تغییر کرده است" diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 0dd7a503ef..7d81527c5c 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -92,6 +92,7 @@ fi: from_reply_by_address_error: "Näin käy, kun lähettäjän osoite (From) on sama kuin vastausten sähköpostiosoite." inactive_user_error: "Näin käy, kun lähettäjä ei ole aktiivinen." silenced_user_error: "Näin käy, kun lähettäjä on hiljennetty." + bad_destination_address: "Näin käy, kun yksikään viestin vastaanottaja/kopio-kenttien osoitteista ei täsmää asetettuihin saapuvan sähköpostin osoitteisiin." strangers_not_allowed_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka jäsen ei ole." insufficient_trust_level_error: "Näin käy, kun käyttäjä yrittää aloittaa ketjun alueella, jonka vähimmäisluottamustasovaatimusta ei täytä." reply_user_not_matching_error: "Näin käy, kun vastaus saapuu eri sähköpostiosoitteesta kuin mihin ilmoitus lähetettiin." @@ -102,6 +103,7 @@ fi: unsubscribe_not_allowed: "Näin käy, kun tämä käyttäjä ei voi perua tilausta sähköpostitse." email_not_allowed: "Näin käy, kun sähköpostiosoite ei ole sallittujen listalla tai on kiellettyjen listalla." unrecognized_error: "Tuntematon virhe" + secure_media_placeholder: "Salattu: sivustolla medialataukset on salattuja; vieraile ketjussa niin näet liitetyt kuvat, äänet ja videot." errors: &errors format: "%{attribute} %{message}" format_with_full_message: "%{attribute}: %{message}" @@ -123,6 +125,7 @@ fi: inclusion: ei ole listalla invalid: ei kelpaa is_invalid: "vaikuttaa epäselvältä, olihan se kokonainen virke?" + invalid_timezone: "'%{tz}' ei ole käypä aikavyöhyke" contains_censored_words: "sisältää nämä sensuroidut sanat: %{censored_words}" less_than: "täytyy olla vähemmän kuin %{count}" less_than_or_equal_to: "täytyy olla yhtä suuri tai pienempi kuin %{count}" @@ -146,6 +149,7 @@ fi: one: on väärän mittainen (pitäisi olla %{count} merkki) other: on väärän mittainen (pitäisi olla %{count} merkkiä) other_than: "pitää olla muu kuin %{count}" + sso_overrides_username: "Käyttäjänimeä täytyy muokata SSO-tarjoajan päässä, sillä `sso_overrides_username` -asetus on kytketty." template: body: "Seuraavien kenttien kanssa oli ongelmia:" header: @@ -159,10 +163,14 @@ fi: one: "Määrittämäsi valinta ei kelpaa %{name}" other: "Määrittämäsi valinnat eivät kelpaa %{name}" default_categories_already_selected: "Et voi valita aluetta, joka on käytössä toisella listalla" + default_tags_already_selected: "Et voi valita aluetta, joka on käytössä toisella listalla" s3_upload_bucket_is_required: "Et voi ottaa s3 latausta käyttöön, jos et ole määrittänyt 's3_upload_bucket'." enable_s3_uploads_is_required: "Et voi ottaa S3-inventorya käyttöön, jollei S3-lataukset ole käytössä." s3_backup_requires_s3_settings: "Et voi käyttää S3:a varmuuskopiosijaintina, jos %{setting_name} ei ole määritetty." s3_bucket_reused: "Sama säiliö ei voi olla sekä 's3_upload_bucket' että 's3_backup_bucket'. Valitse eri säiliö tai määritä eri polku jokaiselle säiliölle." + secure_media_requirements: "S3-lataukset täytyy ottaa käyttöön, jotta voi ottaa käyttöön suojatut medialataukset." + second_factor_cannot_be_enforced_with_disabled_local_login: "Et voi pakottaa kaksivaiheista tunnistautumista, jos paikallinen kirjautuminen on pois käytöstä." + local_login_cannot_be_disabled_if_second_factor_enforced: "Et voi ottaa paikallista kirjautumista käytöstä, jos kaksivaiheinen tunnistautuminen on pakollinen. Kun otat kaksivaiheisen tunnistautumisen pakollisuuden käytöstä, voit ottaa käytöstä paikallisen kirjautumisen." conflicting_google_user_id: 'Tämän käyttäjätilin Google Account ID on muuttunut. Henkilökunnan toimenpiteet ovat tarpeen tietoturvasyistä. Ota yhteyttä henkilökuntaan ja ohjaa heidät osoitteeseen
https://meta.discourse.org/t/76575' activemodel: errors: @@ -245,6 +253,7 @@ fi: other: "%{count} tykkäystä" last_reply: "Viimeisin vastaus" created: "Luotu" + new_topic: "Aloita uusi ketju" no_mentions_allowed: "Pahoittelut, et voi mainita muita käyttäjiä." too_many_mentions: one: "Pahoittelut, voit mainita viestissä vain yhden käyttäjän." @@ -276,6 +285,7 @@ fi: max_pm_recepients: "Pahoittelut, voit lähettää viestin enintään %{recipients_limit} vastaanottajalle." pm_reached_recipients_limit: "Pahoittelut, yksityisviestillä ei voi olla yli %{recipients_limit} vastaanottajaa." removed_direct_reply_full_quotes: "Jos edellinen viesti lainataan kokonaan, poista lainaus automaattisesti." + secure_upload_not_allowed_in_public_topic: "Pahoittelut, näitä suojattuja latauksia ei voi käyttää julkisessa ketjussa: %{upload_filenames}" just_posted_that: "on liian samanlainen kuin aiempi viestisi" invalid_characters: "sisältää epäkelpoja merkkejä" is_invalid: "vaikuttaa epäselvältä, olihan se kokonainen virke?" @@ -710,14 +720,23 @@ fi: windows: "Microsoft Windows" unknown: "tuntematon käyttöjärjestelmä" change_email: + wrong_account_error: "Olet kirjautuneena väärällä käyttäjätilillä. Kirjaudu ulos ja yritä uudelleen." confirmed: "Sähköpostiosoite päivitetty." please_continue: "Jatka sivustolle %{site_name}" error: "Sähköpostiosoitteen vaihdossa tapahtui virhe. Ehkäpä tämä sähköpostiosoite on jo käytössä?" error_staged: "Sähköpostiosoitetta muutettaessa tapahtui virhe. Osoite on automaattisesti luodun esikäyttäjän käytössä." already_done: "Pahoittelut, tämä varmennuslinnkki ei ole enää voimassa. Ehkäpä sähköpostiosoitteesi on jo vaihdettu?" + confirm: "Vahvista" + authorizing_new: + title: "Vahvista uusi sähköpostiosoite" + description: "Vahvista, että haluat vaihtaa uudeksi sähköpostiosoitteeksesi:" authorizing_old: - title: "Kiitos sähköpostiosoitteesi varmentamisesta" - description: "Lähetämme sinulle sähköpostin varmennusta varten." + title: "Vaihda sähköpostiosoitettasi" + description: "Vahvista sähköpostiosoitteesi vaihdos" + old_email: "Vanha sähköposti: %{email}" + new_email: "Uusi sähköposti: %{email}" + almost_done_title: "Vahvista uusi sähköpostiosoite" + almost_done_description: "Lähetimme sinulle sähköpostin uuteen osoitteeseesi, jotta voit vahvistaa vaihdoksen!" associated_accounts: revoke_failed: "Tunnustasi palveluntarjoajalla %{provider_name} ei onnistuttu perumaan." connected: "(yhdistetty)" @@ -732,6 +751,7 @@ fi: activated: "Pahoittelut, tämä tili on jo aktivoitu." admin_confirm: title: "Vahvista ylläpitäjätili" + description: "Oletko varma, että haluat käyttäjästä %{target_username} (%{target_email}) ylläpitäjän?" grant: "Myönnä ylläpitäjäoikeudet" complete: "%{target_username} on nyt ylläpitäjä." back_to: "Palaa %{title}" @@ -780,6 +800,10 @@ fi: description: "Tykkää viestistä" short_description: "Tykkää viestistä" long_form: "tykkäsi tästä" + draft: + sequence_conflict_error: + title: "luonnosvirhe" + description: "Luonnosta muokataan toisessa ikkunassa. Lataa tämä sivu uudelleen." draft_backup: pm_title: "Varmuuskopiot ketjuihin suunnatuista luonnoksista" pm_body: "Ketju johon varmuuskopiot luonnoksista sijoitetaan" @@ -1221,6 +1245,7 @@ fi: other: "Sähköpostin pollaus aiheutti %{count} virhettä edellisen 24 tunnin aikana. Tarkastele lokeja saadaksesi lisätietoja." missing_mailgun_api_key: "Palvelin on määritelty lähettämään sähköpostit Mailgunin avulla, muttet ole määritellyt rajapinta-avainta, jolla varmistetaan webhook-viestien aitous." bad_favicon_url: "Favicon ei lataudu. Tarkista favicon-asetus sivuston asetuksissa." + update_mail_receiver: "Huomasimme, että sähköpostipalvelin (mail-receiver) on vanhentunut. Klikkaa tästä päivitysohjeet. Kun olet päivittänyt, voi mennä 24 tuntia ennen kuin tämä viesti katoaa." poll_pop3_timeout: "Yhteyttä POP3-palvelimelle aikakatkaistaan ja saapuvaa sähköpostia ei voitu hakea. Tarkista POP3-asetukset ja palveluntarjoaja." poll_pop3_auth_error: "Yhteys POP3-palvelimelle epäonnistuu autentikaatiovirheen vuoksi. Tarkista POP3-asetukset." force_https_warning: "Sivusto käyttää SSL-salausta, mutta `force_https` ei ole valittuna asetuksissa." @@ -1274,6 +1299,8 @@ fi: editing_grace_period_max_diff: "Kuinka monen merkin muutos sallitaan katumusaikana. Jos muutos on isompi tallennetaan uusi viestirevisio (luottamustasolla 0 ja 1)." editing_grace_period_max_diff_high_trust: "Kuinka monen merkin muutos sallitaan katumusaikana. Jos muutos on isompi tallennetaan uusi viestirevisio (luottamustasosta 2 ylöspäin)." staff_edit_locks_post: "Viesti lukitaan muokkauksilta, jos henkilökunnan jäsen muokkaa sitä" + post_edit_time_limit: "Lt0- tai lt1-kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0 niin voi muokata aina." + tl2_post_edit_time_limit: "Lt2+ -kirjoittaja voi muokata viestiään (n) minuutin ajan viestin lähettämisen jälkeen. Aseta 0 niin voi muokata aina." edit_history_visible_to_public: "Salli kaikkien nähdä muokatun viestin edelliset versiot. Jos asetus otetaan pois käytöstä, vain henkilökunta näkee versiot." delete_removed_posts_after: "Kirjoittajalta poistetut viestit poistetaan automaattisesti (n) tunnin kuluttua. Jos asetetaan 0, viestit poistetaan välittömästi." max_image_width: "Esikatselukuvan suurin sallittu leveys viestissä" @@ -1290,14 +1317,21 @@ fi: inline_onebox_domains_whitelist: "Verkko-osoitteet, joista luodaan Onebox-esikatselu, jos niihin linkitetään määrittämättä otsikkoa." enable_inline_onebox_on_all_domains: "Poista inline_onebox_domain_whitelist -sivustoasetus käytöstä ja salli rivi-oneboxit kaikista verkko-osoitteista." max_oneboxes_per_post: "Oneboxien enimmäismäärä yhdessä viestissä" - logo: "Kuva, joka toimii sivuston logona sivuston vasemmassa yläkulmassa. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätät tyhjäksi, tilalla näytetään sivuston nimi." - logo_small: "Kuva, joka toimii sivuston pienenä logona sivuston yläkulmassa, kun vieritetään alaspäin. Valitse neliönmuotoinen kuva. Valitse neliönmallinen, 120×120-kokoinen kuva. Jos jätät tyhjäksi, tilalla näytetään koti-merkki." - digest_logo: "Vaihtoehtoinen logo, jota käytetään sivustosi sähköpostitiivistelmien yläosassa. Valitse leveä suorakulmainen kuva. Älä käytä SVG-kuvaa. Jos jätät tyhjäksi, käytetään \"logo\"-asetuksen kuvaa." - mobile_logo: "Logo, jota sivuston mobiiliversio käyttää. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätät tyhjäksi, käytetään `logo` -asetuksen kuvaa." + logo: "Kuva, joka toimii sivuston logona sivuston vasemmassa yläkulmassa. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätetty tyhjäksi, tilalla näytetään sivuston nimi." + logo_small: "Kuva, joka toimii sivuston pienenä logona sivuston yläkulmassa, kun vieritetään alaspäin. Valitse neliönmuotoinen kuva. Valitse neliönmallinen, 120×120-kokoinen kuva. Jos jätetty tyhjäksi, tilalla näytetään koti-merkki." + digest_logo: "Vaihtoehtoinen logo, jota käytetään sivustosi sähköpostitiivistelmien yläosassa. Valitse leveä suorakulmainen kuva. Älä käytä SVG-kuvaa. Jos jätetty tyhjäksi, `logo`-kuvaketta käytetään." + mobile_logo: "Logo, jota sivuston mobiiliversio käyttää. Valitse suorakulmion muotoinen kuva, jolla on korkeutta vähintään 120 ja jonka kuvasuhde on vähintään 3:1. Jos jätetty tyhjäksi, `logo`-kuvaketta käytetään." + large_icon: "Kuva, josta rakennetaan muut metadata-ikonit. Tulisi ideaalitapauksessa olla suurempi kuin 512×512. Jos jätetty tyhjäksi, logo_small -kuvaketta käytetään." + manifest_icon: "Kuva, jota käytetään logo/splash -kuvana Androidissa. Skaalataan automaattisesti kokoon 512×512. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään." + favicon: "Palstan favicon, ks. https://en.wikipedia.org/wiki/Favicon. Täytyy olla pgn, jotta toimii CDN:n kanssa. Skaalataan kokoon 32x32. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään." apple_touch_icon: "Apple touch laitteilla käytetty kuvake. Muunnetaan automaattisesti kokoon 180x180. Jos jätetään tyhjäksi, käytetään large_icon. " + opengraph_image: "Oletuksena käytettävä opengraph-kuva, käytetään kun sivulla ei ole muuta sopivaa kuvaa. Jos jätetty tyhjäksi, large_icon -kuvaketta käytetään" + twitter_summary_large_image: "Twitter-tiivistelmäkortin \"summary large image\" (leveyttä tulisi olla ainakin 280 ja korkeutta ainakin 150). Jos jätetty tyhjäksi, tavallisen kortin metadata luodaan opengraph_image -kuvakkeen avulla." notification_email: "Sähköpostiosoite, josta kaikki tärkeät järjestelmän lähettämät sähköpostiviestit lähetetään. Verkkotunnuksen SPF, DKIM ja reverse PTR tietueiden täytyy olla kunnossa, jotta sähköpostit menevät perille." email_custom_headers: "Pystyviivalla eroteltu lista mukautetuista sähköpostin tunnisteista" email_subject: "Mukauta sähköpostiviestien otsikon muoto. Katso englanninkielinen ohje: https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + detailed_404: "Kertoo käyttäjälle tarkemmin, miksi hän ei pääse tiettyyn ketjuun. Huomioi: tämä vaarantaa tietoturvaa, koska käyttäjä saa tietää, että URL osoittaa olemassa olevaan ketjuun." + enforce_second_factor: "Pakottaa käyttäjiä ottamaan kaksivaiheisen tunnistautumisen käyttöön. Voit pakottaa sen kaikille valitsemalla \"kaikki\". Vaihtoehto \"henkilökunta\" pakottaa sen vain henkilökunnalle." force_https: "Pakota sivusto käyttämään vain HTTPS:ää. VAROITUS: älä ota tätä käyttöön ennen kuin HTTPS on täysin käytössä ja toimii täysin kaikkialla! Tarkastitko käyttämäsi CDN, kaikki sosiaaliset kirjautumiset ja kaikki ulkoiset logot / muut riippuvuudet ovat myös HTTPS-yhteensopivia?" summary_score_threshold: "Viestin minimipistemäärä, jotta se näytetään ketjun tiivistelmässä." summary_posts_required: "Montako viestiä ketjussa täytyy olla, jotta ketjun tiivistelmä otetaan käyttöön" @@ -1330,6 +1364,7 @@ fi: enable_markdown_typographer: "Paranna tekstin luettavuutta typografisten sääntöjen avulla: suorat lainausmerkit korvataan 'kaarevilla lainausmerkeillä’, (c) (tm) korvataan symboleilla, -- korvataan m-ajatusviivalla – jne." enable_markdown_linkify: "Tee linkin näköisestä tekstistä automaattisesti linkki: www.esimerkki.fi ja https://esimerkki,fi muutetaan automaattisesti linkeiksi" markdown_linkify_tlds: "Luettelo ylätason verkkotunnuksista, jotka muutetaan esiintyessään linkeiksi automaattisesti" + markdown_typographer_quotation_marks: "Luettelo (puoli)lainausmerkkipareista, joilla korvataan" post_undo_action_window_mins: "Kuinka monta minuuttia käyttäjällä on aikaa perua viestiin kohdistuva toimi (tykkäys, liputus, etc)." must_approve_users: "Henkilökunnan täytyy hyväksyä kaikki tilit, ennen uusien käyttäjien päästämistä sivustolle." pending_users_reminder_delay: "Ilmoita valvojille, jos uusi käyttäjä on odottanut hyväksyntää kauemmin kuin näin monta tuntia. Aseta -1, jos haluat kytkeä ilmoitukset pois päältä." @@ -1348,13 +1383,12 @@ fi: content_security_policy: "Ota käyttöön epäilyttävän sisällön seulonta (Content-Security-Policy)" content_security_policy_report_only: "Ota käyttöön epäilyttävästä sisällöstä raportointi (Content-Security-Policy-Report-Only)" content_security_policy_collect_reports: "Ota käyttöön CSP-seulontojen kerääminen lokiin /csp_reports" - content_security_policy_script_src: "Muut sallitut skriptien lähteet. Nykyinen webhotelli ja CDN ovat sallittuja jo valmiiksi." invalidate_inactive_admin_email_after_days: "Ylläpitäjätunnukset jotka eivät ole vierailleet sivustolla näin moneen päivään joutuvat vahvistamaan sähköpostiosoitteensa uudelleen ennen sisäänkirjautumista. Aseta 0 poistaaksesi käytöstä." top_menu: "Mitkä painikkeet näytetään kotisivun navigointipalkissa, ja missä järjestyksessä. Esimerkiksi latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Mitkä painikkeet näytetään viestin valikossa, ja missä järjestyksessä. Esimerkiksi like|edit|flag|delete|share|bookmark|reply" post_menu_hidden_items: "Piilotettavat painikkeet viestin valikosta, kunnes '...' klikataan." share_links: "Mitkä painikkeet näytetään Jaa-valikossa ja missä järjestyksessä." - site_contact_username: "Henkilökuntaan kuuluvan käyttäjä, jonka nimissä kaikki automaattiset viestit lähetetään. Jos jätetään tyhjäksi, oletuksena on System-käyttäjä." + site_contact_username: "Henkilökuntaan kuuluvan käyttäjä, jonka nimissä kaikki automaattiset viestit lähetetään. Jos jätetty tyhjäksi, oletuksena on System-käyttäjä." site_contact_group_name: "Käypä ryhmän nimi, joka kutsutaan kaikkiin automaattisesti luotuihin yksityiskeskusteluihin." send_welcome_message: "Lähetä kaikille uusille käyttäjille tervetuliaisviesti, jossa on pikakäyttöopas" send_tl1_welcome_message: "Lähetä luottamustason 1 saavuttaville käyttäjille yksityinen tervetuloviesti." @@ -1386,6 +1420,7 @@ fi: min_username_length: "Käyttäjänimen vähimmäispituus merkeissä. VAROITUS: Jos olemassa olevalla käyttäjällä tai ryhmällä on tätä lyhyempi nimi, sivustosi hajoaa!" max_username_length: "Käyttäjänimen vähimmäispituus merkeissä. VAROITUS: Jos olemassa olevalla käyttäjällä tai ryhmällä on tätä pidempi nimi, sivustosi hajoaa!" unicode_usernames: "Salli käyttäjänimien ja ryhmänimien sisältää Unicode-kirjaimia ja -numeroita." + unicode_username_character_whitelist: "Säännöllinen lauseke, jolla sallitaan vain tietyt Unicode-merkit käyttäjänimissä. ASCII-kirjaimet ja numerot ovat aina sallittuja eikä niitä tarvitse sisällyttää." reserved_usernames: "Käyttäjänimet, joita ei voi rekisteröidä. Jokerimerkkiä * voi käyttää korvaamaan merkin nolla kertaa tai useammin." min_password_length: "Salasanan vähimmäispituus." min_admin_password_length: "Ylläpitäjän salasanan vähimmäispituus." @@ -1399,6 +1434,9 @@ fi: sso_overrides_email: "Ohittaa paikallisen sähköpostiosoitteen SSO:n kautta saatavalla ulkopuolisella osoitteella ja estää paikalliset muutokset (VAROITUS: eroavuuksia saattaa syntyä johtuen paikallisten sähköpostiosoitteiden normalisoinnista)" sso_overrides_username: "Ohittaa paikallisen käyttäjänimen SSO:n kautta saatavalla ulkopuolisella nimellä ja estää paikalliset muutokset (VAROITUS: eroavuuksia saattaa syntyä johtuen erilaisista vaatimuksista ja pituudesta)" sso_overrides_name: "Ohittaa paikallisen koko nimen SSO:n kautta saatavalla ulkopuolisella nimellä jokaisella kirjautumiskerralla ja estää paikalliset muutokset" + sso_overrides_avatar: "Syrjäyttää käyttäjän avatarin ulkoisella kertakirjautumisen kautta haetulla avatarilla. Jos tämä on käytössä, käyttäjä ei voi ladata avataria Discourseen." + sso_overrides_profile_background: "Syrjäyttää käyttäjän profiilin taustakuvan ulkoisella kertakirjautumisen kautta haetulla avatarilla." + sso_overrides_card_background: "Syrjäyttää käyttäjän käyttäjäkortin taustakuvan ulkopuolisella kertakirjautumisen kautta haetulla avatarilla." sso_not_approved_url: "Uudelleenohjaa hyväksymättömät SSO-tilit tähän osoitteeseen" sso_allows_all_return_paths: "Älä rajoita SSO:n antamien palautuspolkujen verkkotunnusta (oletuksena palautuspolun on oltava nykyisellä sivustolla)" enable_local_logins: "Salli kirjautuminen paikallisesti käyttäjänimen ja salasanan avulla. Tämä tulee olla päällä, jotta kutsuminen voi toimia. VAROITUS: jos ei ole käytössä, voi sinun olla mahdotonta kirjautua sisään, jollet ole aiemmin määritellyt ainakin yhtä muuta kirjautumistapaa." @@ -1421,6 +1459,9 @@ fi: enable_github_logins: "Ota käyttöön Facebook-tunnistautuminen, vaaditaan github_client_id ja github_client_secret. Katso Configuring GitHub login for Discourse." github_client_id: "GitHub-tunnistautumisen client id, joka rekisteröidään palvelussa https://github.com/settings/developers" github_client_secret: "GitHub-tunnistautumisen client secret, joka rekisteröidään palvelussa https://github.com/settings/developers" + enable_discord_logins: "Salli käyttäjien kirjautua Discordin avulla?" + discord_client_id: 'Discordin Client ID (Tarvitsetko? Käy Discordin kehittäjäportaalissa)' + discord_secret: "Discordin Secret Key" readonly_mode_during_backup: "Ota käyttöön vain luku -tila, kun otetaan varmuuskopiota" enable_backups: "Salli ylläpitäjien tehdä varmuuskopioita palstasta" allow_restore: "Salli palautus, joka korvaa KAIKEN sivuston datan! Jätä valitsematta, jos et aio palauttaa sivuston varmuuskopiota" @@ -1428,10 +1469,14 @@ fi: automatic_backups_enabled: "Tee automaattinen varmuuskopiointi, kuten tiheysasetus on määritelty" backup_frequency: "Kuinka monen päivän välein otetaan varmuuskopio." s3_backup_bucket: "Amazon S3 bucket johon varmuuskopiot ladataan. VAROITUS: Varmista, että se on yksityinen." + s3_endpoint: "Kohdeasemaksi voidaan vaihtaa muu S3-yhteensopiva palvelu kuten DigitalOcean Spaces tai Minio. VAROITUS: Jätä tyhjäksi, jos käytät AWS S3:a." + s3_configure_tombstone_policy: "Ota käyttöön tombstone-hakemiston automaattinen tyhjennys. TÄRKEÄÄ: Jos ei käytössä, tilaa ei vapaudu kun ladattuja tiedostoja poistetaan." s3_disable_cleanup: "Älä poista varmuuskopiota S3:sta, kun se poistetaan paikallisesti." backup_time_of_day: "UTC-kellonaika, jolloin varmuuskopio tehdään." backup_with_uploads: "Sisällytä lataukset ajastettuihin varmuuskopioihin. Jos tämä on pois käytöstä, vain tietokanta varmuuskopioidaan." backup_location: "SIjainti, jonne varmuuskopiot säilötään. TÄRKEÄÄ: S3 vaatii toimiakseen, että käyvät S3-käyttöoikeustiedot on syötetty Tiedostot-asetuksiin." + backup_gzip_compression_level_for_uploads: "Gzip-pakkausaste, jota käytetään kun pakataan ladattuja tiedostoja." + include_thumbnails_in_backups: "Sisällytä luodut esikatselukuvat varmuuskopioihin. Ottaminen pois käytöstä pienentää varmuuskopioita, mutta varmuuskopiopalautuksen yhteydessä kaikki viestit on rakennettava uudelleen." active_user_rate_limit_secs: "Kuinka usein 'last_seen_at' kenttä päivitetään, sekunneissa" verbose_localization: "Näytä laajennetut lokalisointitiedot käyttöliittymässä" previous_visit_timeout_hours: "Kuinka kauan vierailun on täytynyt kestää, jotta se lasketaan 'edelliseksi' vierailuksi, tunneissa" @@ -1462,6 +1507,7 @@ fi: suggested_topics: "Ehdotettujen ketjujen määrä ketjun alaosassa." limit_suggested_to_category: "Ehdota ketjuja vain nykyiseltä alueelta." suggested_topics_max_days_old: "Ehdotettujen ketjujen ei tulisi olla yli n päivää vanhoja." + suggested_topics_unread_max_days_old: "Ehdotusten lukemattomista ketjuista ei tule olla yli n päivää vanhoja." clean_up_uploads: "Poista orpoutuneet liitetiedostot, joita ei käytetä viesteissä, laittoman hostauksen estämiseksi. VAROITUS: kannattaa varmuuskopioida /uploads kansio ennen tämän asetuksen ottamista käyttöön." clean_orphan_uploads_grace_period_hours: "Varoaika (tunteina) kunnes orpoutuneet liitetiedostot poistetaan" purge_deleted_uploads_grace_period_days: "Varoaika (päivinä) kunnes poistettu liitetiedosto tuhotaan." @@ -1472,6 +1518,7 @@ fi: avatar_sizes: "Profiilikuvista automaattisesti luotavat koot." external_system_avatars_enabled: "Käytä ulkopuolista avatarpalvelua." external_system_avatars_url: "Ulkoisen avatarpalvelun URL. Sallitut vaihdokset ovat {username} {first_letter} {color} {size}" + restrict_letter_avatar_colors: "Luettelo kuusinumeroisista heksadesimaalisista väriarvoista, joita käytetään kirjainavatareja luotaessa." selectable_avatars_enabled: "Pakota käyttäjä valitsemaan avatarinsa listalta." selectable_avatars: "Avatarit, joista käyttäjä voi valita." allow_all_attachments_for_group_messages: "Salli kaikki sähköpostiliitteet ryhmäviesteissä." @@ -1549,6 +1596,7 @@ fi: max_similar_results: "Kuinka monta samankaltaista ketjua näytetään viestikentän päällä uutta ketjua aloitettaessa. Vertailu perustuu sekä otsikkoon että leipätekstiin." max_image_megapixels: "Kuvan enimmäiskoko megapikseleinä." title_prettify: "Estä yleiset kirjoitusvirheet otsikossa, kuten pelkät isot kirjaimet, pieni ensimmäinen kirjain, useat !- ja ?-merkit ym." + automatic_topic_heat_values: 'Päivitä "topic views heat" ja "topic post like heat" -asetuksia sivuston aktiivisuuden perusteella automaattisesti.' topic_views_heat_low: "Näin monen katselun jälkeen katselut-saraketta korostetaan hieman." topic_views_heat_medium: "Näin monen katselun jälkeen katselut-saraketta korostetaan kohtalaisesti." topic_views_heat_high: "Näin monen katselun jälkeen katselut-saraketta korostetaan voimakkaasti." @@ -1582,10 +1630,14 @@ fi: auto_silence_fast_typers_on_first_post: "Hiljennä automaattisesti käyttäjät, joiden ensimmäisen viestin kirjoittamiseen ei kulu min_first_post_typing_time" auto_silence_fast_typers_max_trust_level: "Enimmäisluottamustaso, jolla nopea kirjoittaja voidaan hiljentää automaattisesti" auto_silence_first_post_regex: "Isoista ja pienistä kirjaimista riippumaton säännöllinen lauseke, joka osuessaan aiheuttaa käyttäjän ensimmäisen viestin hiljennyksen ja viesti viedään arvioitavaksi. Esimerkki: hemmetti|a[bc]a aiheuttaa hiljennyksen, jos viesti sisältää sanan 'hemmetti', 'aba' tai 'aca'. Koskee vain käyttäjän ensimmäistä viestiä." + reviewable_claiming: "Tarvitseeko arvioitava sisältö omia ennen kuin sen voi käsitellä?" + reviewable_default_topics: "Oletuksena, näytä arvioitava sisältö ryhmiteltynä ketjuittain" + reviewable_default_visibility: "Älä näytä arvioitavia asioita jollei niiden prioriteetti ole vähintään tämän verran" reply_by_email_enabled: "Ota käyttöön vastaukset sähköpostin avulla." reply_by_email_address: "Saapuvien sähköpostivastausten sähköpostiosoitekaava, esimerkiksi: %%{reply_key}@reply.esimerkki.fi or replies+%%{reply_key}@esimerkki.fi" alternative_reply_by_email_addresses: "Lista vaihtoehtoisista saapuvien sähköpostivastausten sähköpostiosoitekaavoista, esimerkiksi: %%{reply_key}@reply.esimerkki.fi tai replies+%%{reply_key}@esimerkki.fi" incoming_email_prefer_html: "Käytä HTML:ää tekstin sijaan saapuvissa sähköposteissa." + strip_incoming_email_lines: "Poista saapuvien sähköpostien jokaisen rivin alusta ja lopusta tyhjämerkit." disable_emails: "Estä Discoursea lähettämästä minkäänlaisia sähköposteja. Ota pois sähköpostit kaikilta käyttäjiltä valitsemalla \"yes\" . \"Non-staff\" poistaa sähköpostit vain muilta kuin henkilökunnalta." strip_images_from_short_emails: "Poista kuvat sähköposteista, joiden koko on alle 2800 tavua" short_email_length: "Lyhyen sähköpostin pituus tavuissa" @@ -1670,8 +1722,6 @@ fi: permalink_normalizations: "Sovella tätä säännöllistä lauseketta ennen ikilinkkien sovittamista, esim. /(topic.*)\\?.*/\\1 riisuu hakulausekkeet ketjujen reiteistä. Muoto on regex+string, \\1 jne. avulla pääset käsiksi captureihin." global_notice: "Näytä kaikilla sivuilla kaikille käyttäjille KIIREELLISESTÄ HÄTÄTAPAUKSESTA kertova banneri, jota ei voi piilottaa. Vaihda tyhjäksi piilottaaksesi sen (HTML sallittu)." disable_system_edit_notifications: "Poista muokkausilmoitukset system-käyttäjältä, kun 'download_remote_images_to_local' on asetettu." - likes_notification_consolidation_threshold: "Kuinka monta tykkäysilmoitusta pitää saada, jotta ilmoitukset niputetaan yhdeksi. Aseta 0 poistaaksesi käytöstä. Aikaikkunan määrittää asetus `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Kuinka monessa minuutissa tykkäysilmoitukset pitää saada, jotta ilmoitukset niputetaan yhdeksi, jos ilmoitusten yläraja on ylittymässä. Ylärajan määrittää asetus `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Poista ketjun kiinnitys automaattisesti, kun käyttäjä on sen lopussa." read_time_word_count: "Sanamäärä minuutissa, jota käytetään lukuajan arviointiin." share_anonymized_statistics: "Julkaise yksilöimättömät käyttötilastot." @@ -1888,7 +1938,7 @@ fi: auto_deleted_by_timer: "Poistettiin ajastetusti." login: security_key_description: "Kun fyysinen tunnistautumislaite on kätesi ulottuvilla, klikkaa alla olevaa \"Tunnistaudu tunnistautumislaitteella\" -painiketta." - security_key_alternative: "Et löydä tunnistautumislaitetta tai haluat käyttää muuta tapaa?" + security_key_alternative: "Kokeile muuta tapaa" security_key_authenticate: "Tunnistaudu tunnistautumislaitteen avulla" security_key_not_allowed_error: "Tunnistaumislaitteella tunnistautumisprosessi joko vanheni tai peruutettiin." security_key_no_matching_credential_error: "Tunnistautumislaitteelta ei löytynyt kelpaavia pääsytietoja." @@ -2857,19 +2907,9 @@ fi: confirm_new_email: title: "Vahvista uusi sähköpostiosoite" subject_template: "[%{email_prefix}] Vahvista uusi sähköpostiosoite" - text_body_template: | - Vahvista uusi sähköpostiosoitteesi sivustolla %{site_name} klikkaamalla linkkiä: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Vahvista vanha sähköpostiosoite" subject_template: "[%{email_prefix}] Vahvista nykyinen sähköpostiosoitteesi" - text_body_template: | - Ennen kuin vaihdamme sähköpostiosoitteesi täytyy varmistaa, että hallinnoit nykyistä sähköpostiosoitetta. Tämän jälkeen varmistamme vielä uudenkin sähköpostiosoitteen. - - Vahvista nykyinen sähköpostiosoitteesi sivustolla %{site_name} klikkaamalla linkkiä: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Ilmoita vanhaan sähköpostiosoitteeseen" subject_template: "[%{email_prefix}] Sähköpostiosoitteesi on vaihdettu" @@ -3605,6 +3645,8 @@ fi: unknown: "tuntematon" user_merged: "%{username} yhdistettiin tähän käyttäjätiliin" user_delete_self: "Poisti itsensä asetuksistaan %{url}" + api_key: + revoked: Jäädytetty reviewables: priorities: low: "Matala" diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 5a4ec2164a..b3d472acf9 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -711,9 +711,6 @@ fr: error: "Il y a eu une erreur lors de la modification de votre adresse de courriel. Elle est peut-être déjà utilisée ?" error_staged: "Une erreur est survenue lors de la modification de votre adresse courriel. Cette adresse est déjà utilisée par un utilisateur distant." already_done: "Désolé, ce lien de confirmation n'est plus valide. Votre adresse de courriel a peut-être déjà été changée ?" - authorizing_old: - title: "Merci d'avoir confirmé votre adresse de courriel" - description: "Nous envoyons un courriel sur votre nouvelle adresse pour confirmation." associated_accounts: revoke_failed: "Echec de révocation de votre compte avec %{provider_name}." connected: "(connecté)" @@ -1351,7 +1348,6 @@ fr: content_security_policy: "Activer Content-Security-Policy" content_security_policy_report_only: "Activer Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Activer collecte de rapports CSP violation à /csp_reports" - content_security_policy_script_src: "Sources supplementaires de scripts acceptés. L'hôte actuel et le CDN sont inclus par défaut." invalidate_inactive_admin_email_after_days: "Les comptes admin qui n'ont pas visité le site depuis ce nombre de jours devront re-valider leurs adresses courriel avant de se connecter. 0 pour désactiver." top_menu: "Déterminer les éléments qui apparaissent dans la navigation de la page d'accueil, et dans quel ordre. Exemple latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "L'ordre des éléments dans le menu de rédaction. Exemple like|edit|flag|delete|share|bookmark|reply" @@ -1715,8 +1711,6 @@ fr: permalink_normalizations: "Appliquer l'expression régulière suivante avant de détecter les permaliens, par exemple /(\\/topic.*)\\?.*/\\1 supprimera les chaînes de requête des chemins de sujet. Le format est regex+string, utilisez \\1 etc. pour capturer des séquences" global_notice: "Afficher une bannière de notification globale, d'URGENCE, et qui ne peut pas être ignoré, à tous les visiteurs, vide pour cacher (HTML admis)." disable_system_edit_notifications: "Désactiver les notifications de modifications par l'utilisateur système lorsque l'option 'download_remote_images_to_local' est activée." - likes_notification_consolidation_threshold: "Nombre de notifications reçues et aimées avant que les notifications ne soient regroupées en une seule. Régler à 0 pour désactiver. La fenêtre peut être configurée via `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Durée en minutes où les notifications aimées sont regroupées en une seule une fois le seuil atteint. Le seuil peut être configuré via `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Désépingler automatiquement le sujet lorsque l'utilisateur atteint la fin." read_time_word_count: "Nombre de mots par minute servant de base de calcul à l'estimation du temps de lecture." topic_page_title_includes_category: "La balise de titre de la page du sujet comprend le nom de la catégorie." @@ -1967,7 +1961,6 @@ fr: auto_deleted_by_timer: "Supprimé automatiquement par planification" login: security_key_description: "Dès que votre clé de sécurité physique est prête, appuyer sur le bouton S'authentifier avec une clé de sécurité ci-dessous." - security_key_alternative: "Vous ne trouvez pas votre clé de sécurité ou voulez utiliser une autre méthode ?" security_key_authenticate: "S'authentifier avec une clé de sécurité" security_key_not_allowed_error: "La procédure d'authentification de la clé de sécurité a expiré ou a été annulée." security_key_no_matching_credential_error: "Aucun identifiant correspondant n'a pu être trouvé dans la clé de sécurité donnée." @@ -3007,19 +3000,9 @@ fr: confirm_new_email: title: "Confirmer votre nouvelle adresse courriel" subject_template: "[%{email_prefix}] Confirmez votre nouvelle adresse email" - text_body_template: | - Confirmez votre nouvelle adresse email pour %{site_name} en cliquant sur le lien suivant : - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Confirmez votre ancienne adresse email" subject_template: "[%{email_prefix}] Confirmez votre adresse email actuelle" - text_body_template: | - Avant de modifier votre adresse email, nous devons confirmer votre adresse email actuelle. Ensuite, nous vous demanderons de vérifier votre nouvelle adresse email. - - Confirmez votre adresse email actuelle pour %{site_name} en cliquant sur le lien suivant : - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Notifier l'ancienne adresse courriel" subject_template: "[%{email_prefix}] Votre adresse email a été modifié" @@ -4008,6 +3991,8 @@ fr: user_merged: "%{username} a être fusionné avec ce compte" user_delete_self: "Supprimer par l'utilisateur depuis %{url}" webhook_deactivation_reason: "Votre compte Web a été désactivé automatiquement. Nous avons reçu plusieurs réponses d'échec d'état HTTP '%{status}'." + api_key: + revoked: Révoquée reviewables: priorities: low: "Faible" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index eb16aead39..488195ef01 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -56,6 +56,7 @@ he: component_no_default: "רכיבי ערכת העיצוב לא יכולים להיות בררת המחדל של ערכת העיצוב" component_no_color_scheme: "לרכיבי ערכת עיצוב לא יכולים להיות מבחרי צבעים" no_multilevels_components: "ערכות עיצוב עם ערכות עיצוב צאצאיות לא יכולות להיות צאצאיות בעצמן" + optimized_link: קישורים משופרים לתמונות הם בני חלוף ואין לכלול אותם בקוד המקור של ערכת העיצוב. settings_errors: invalid_yaml: "ה־YAML שסופק שגוי." data_type_not_a_number: "הגדרת הסוג `%{name}` אינה נתמכת. הסוגים הנתמכים הם `integer`,‏ `bool`,‏ `list` ו־`enum`" @@ -103,6 +104,7 @@ he: unsubscribe_not_allowed: "מתרחש כאשר למשתמש אין הרשאה לבטל מינוי דרך דוא״ל." email_not_allowed: "מתרחש כאשר כתובת דוא״ל אינה ברשימות ההיתר או האיסור." unrecognized_error: "שגיאה לא מוכרת" + secure_media_placeholder: "חידוש: באתר זה מופעלת מדיה מאובטחת, יש לבקר בנושא כדי לצפות בתמונה/קטע שמע/סרטון שצורפו." errors: &errors format: "%{attribute} %{message}" format_with_full_message: "%{attribute}: %{message}" @@ -124,6 +126,7 @@ he: inclusion: לא נכלל ברשימה invalid: לא תקין is_invalid: "נראה לא ברור, האם זה משפט שלם?" + invalid_timezone: "‚%{tz}’ אינו אזור זמן תקף" contains_censored_words: "מכיל את המילים המצונזרות הבאות: %{censored_words}" less_than: "חייב להיות קטן מ־%{count}" less_than_or_equal_to: "חייב להיות קטן או שווה ל־%{count}" @@ -176,6 +179,10 @@ he: enable_s3_uploads_is_required: "אי אפשר להגדיר מאגר ל־S3 אלמלא הפעלת העלאות ל־S3." s3_backup_requires_s3_settings: "לא ניתן להשתמש ב־S3 כמיקום לגיבוי אלמלא סיפקת את ‚%{setting_name}’." s3_bucket_reused: "אין לך אפשרות להשתמש באותו הדלי עבור ‚s3_upload_bucket’ וגם ‚s3_backup_bucket’. נא לבחור בדלי שונה או להשתמש בנתיב שונה לכל דלי." + secure_media_requirements: "יש להפעיל העלאה ל־S3 בטרם הפעלת מדיה מאובטחת." + second_factor_cannot_be_enforced_with_disabled_local_login: "אי אפשר לאכוף אימות דו־שלבי אם כניסה מקומית מושבתת." + local_login_cannot_be_disabled_if_second_factor_enforced: "אי אפשר להשבית כניסה מקומית אם נאכף אימות דו־שלבי. יש להשבית את אכיפת האימות הדו־שלבי בטרם השבתת כניסה מקומית." + cannot_enable_s3_uploads_when_s3_enabled_globally: "לא ניתן להפעיל העלאות ל־S3 כיוון שהעלאות ל־S3 כבר פעילות באופן גלובלי והפעלת האפשרות הזאת ברמת האתר עשויה להוביל לתקלות חמורות בהעלאה." conflicting_google_user_id: 'מזהה חשבון ה־Google לחשבון זה השתנה, התערבות של חבר סגל נדרשת מטעמי אבטחה. נא ליצור קשר עם אחד מחברי הסגל ולהפנות אותו אל
https://meta.discourse.org/t/76575' activemodel: errors: @@ -310,6 +317,7 @@ he: max_pm_recepients: "ניתן לשלוח הודעה ל־%{recipients_limit} נמענים לכל היותר, עמך הסליחה." pm_reached_recipients_limit: "להודעה לא יכולים להיות למעלה מ־%{recipients_limit} נמענים, עמך הסליחה." removed_direct_reply_full_quotes: "הוסר ציטוט של כל הפוסט הקודם אוטומטית." + secure_upload_not_allowed_in_public_topic: "לא ניתן להשתמש בהעלאות המאובטחות הבאות בנושא ציבורי: %{upload_filenames}, עמך הסליחה." just_posted_that: "דומה מדי למה שפרסמת לאחרונה" invalid_characters: "מכיל תווים לא תקניים" is_invalid: "נראה לא ברור, האם זה משפט שלם?" @@ -782,14 +790,23 @@ he: windows: "Windows מבית Microsoft" unknown: "מערכת הפעלה בלתי מוכרת" change_email: + wrong_account_error: "נכנסת לחשבון הלא נכון, נא לצאת ולנסות שוב." confirmed: "כתובת הדוא״ל שלך עודכנה." please_continue: "להמשיך אל %{site_name}" error: "הייתה שגיאה בעדכון כתובת הדוא״ל. אולי היא כבר בשימוש?" error_staged: "אירעה שגיאה בהחלפת כתובת הדוא״ל שלך. הכתובת כבר נמצאת בשימוש על ידי מועמדים לשימוש במערכת." already_done: "קישור אימות זה אינו תקף עוד, עמך הסליחה. אולי כתובת הדוא״ל שלך כבר הוחלפה?" + confirm: "אישור" + authorizing_new: + title: "אישור כתובת הדוא״ל החדשה שלך" + description: "נא לאשר את החלפת כתובת הדוא״ל שלך לכתובת:" authorizing_old: - title: "תודה על אישור כתובת הדוא״ל הנוכחית שלך" - description: "אנחנו שולחים כעת הודעה לכתובת הדוא״ל החדשה לאישור." + title: "החלפת כתובת הדוא״ל שלך" + description: "נא לאשר את החלפת כתובת הדוא״ל שלך" + old_email: "כתובת דוא״ל ישנה: %{email}" + new_email: "כתובת דוא״ל חדשה: %{email}" + almost_done_title: "אישור כתובת הדוא״ל החדשה" + almost_done_description: "שלחנו הודעה לכתובת הדוא״ל החדשה שלך כדי לאשר את השינוי!" associated_accounts: revoke_failed: "שלילת החשבון שלך מול %{provider_name} נכשלה." connected: "(מחובר)" @@ -925,6 +942,8 @@ he: description: "העדפות הדוא״ל עבור %{email} עודכנו. כדי לשנות את הגדרות הדוא״ל שךף יש לבקר בהעדפות המשתמש שלך." topic_description: "כדי להרשם מחדש ל %{link}, השתמשו בהגדרות ההתראות בתחתית או משמאל לנושא." private_topic_description: "כדי להירשם מחדש, יש להשתמש בבקרת ההתראות בתחתית הנושא או משמאל לו." + uploads: + marked_insecure_from_theme_component_reason: "ההעלאה בה נעשה שימוש ברכיב ערכת עיצוב" unsubscribe: title: "בטלו את המנוי" stop_watching_topic: "הפסיקו לצפות בנושא זה, %{link}" @@ -1310,6 +1329,8 @@ he: other: "ניסיונות שליחת מיילים יצרו %{count} תקלות ב 24 השעות האחרונות. צפו ביומנים לפרטים נוספים." missing_mailgun_api_key: "השרת מוגדר לשלוח דוא״ל דרך Mailgun אך לא סיפקת מפתח API שישמש לאימות ההודעות דרך ההתליה." bad_favicon_url: "טעינת סמל האתר נכשלה. נא לבדוק את הגדרות סמל האתר שלך תחת הגדרות האתר." + deprecated_api_usage: "זיהינו בקשת API שהשתמשה בשיטת אימות ישנה. נא לעדכן את הלקוח להשתמש באימות בכותרת. לאחר העדכון ההודעה הזו תיעלם תוך 24 שעות." + update_mail_receiver: "זיהינו גרסה מיושנת של mail-receiver. יש ללחוץ כאן להנחיות על עדכון. לאחר העדכון ההודעה הזו תיעלם תוך 24 שעות." poll_pop3_timeout: "החיבור לשרת POP3 התנתק. דוא\"ל נכנס לא יכול להשלף ואינו מאוחזר. אנא בדקו את הגדרות ה-POP3 שלכם ואת ספק השירות." poll_pop3_auth_error: "החיבור לשרת POP3 נכשל בשל שגיאת הזדהות. אנא בדקו את הגדרות ה-POP3 שלכם." force_https_warning: "האתר שלך משתמש ב־SSL. אך `force_https` לא מופעל עדיין בהגדרות האתר שלך." @@ -1453,7 +1474,7 @@ he: content_security_policy: "הפעלת Content-Security-Policy (מדיניות אבטחת תוכן)" content_security_policy_report_only: "הפעלת Content-Security-Policy-Report-Only (מדיניות אבטחת תוכן בדיווח בלבד)" content_security_policy_collect_reports: "הפעלת איסוף דוחות הפרה של CSP (מדיניות אבטחת תוכן) תחת ‎/csp_reports" - content_security_policy_script_src: "רשימת מקורות מורשים נוספים לסקריפטים. המארח וה־CDN הנוכחיים נוספים כבררת מחדל." + content_security_policy_script_src: "מקורות נוספים לסקריפטים שעברו אישור. המארח וה־CDN הנוכחיים נכללים כבררת מחדל. ניתן לעיין בהתקפות XSS בעזרת CSP - מדיניות אבטחת תוכן." invalidate_inactive_admin_email_after_days: "חשבונות מנהלים שלא ביקרו באתר מעל כמות כזו של ימים יאלצו לאמת מחדש את כתובת הדוא״ל שלהם כדי לשוב ולהיכנס. להגדיר כ־0 כדי לנטרל." top_menu: "החליטו אילו פריטים יופיעו בניווט עמוד הבית ובאיזה סדר לדוגמה |אחרונים|חדשים|קטגוריות|מובילים|נקראו|פורסמו|סימניות" post_menu: "החליטו אילו פריטים מופיעים בתפריט הפוסט, ובאיזה סדר. למשל like|edit|flag|delete|share|bookmark|reply" @@ -1763,6 +1784,7 @@ he: log_mail_processing_failures: "לתעד את כל שגיאות עיבוד הדוא״ל אל ‎/logs" email_in: 'לאפשר למשתמשים לפרסם נושאים חדשים באמצעות דוא״ל (נדרש תשאול ידני או דרך pop3). יש להגדיר את הכתובות בלשונית ה„הגדרות” שבכל קטגוריה.' email_in_min_trust: "רמת האמון המינימלית הנדרשת למשתמשים כדי שיוכלו להעלות נושאים חדשים באמצעות הדוא\"ל." + email_in_authserv_id: "מזהה השירות מבצע בדיקות אימות על הודעות דוא״ל נכנסות. יש לעיין ב־https://meta.discourse.org/t/134358 לקבלת הנחיות כיצד להגדיר זאת." email_in_spam_header: "כותרת הודעת הדוא״ל לאיתור ספאם." email_prefix: "ה[תווית] שתשמש כנושא של מיילים. אם לא יוגדר, ברירת המחדל תכוון ל'כותרת' אם לא יוגדר אחרת." email_site_title: "הכותרת של האתר שתשמש כשם השולח של דוא\"ל מהאתר. במידה ולא יוגדר ערך, תכוון ברירת המחדל ל\"כותרת\". אם ה\"כותרת\" שלכם מכילה תוים שאינם מותרים לשימוש במחרוזות \"שם השולח\" בדוא\"ל, השתמשו בהגדרה זו." @@ -1821,8 +1843,8 @@ he: permalink_normalizations: "החילו את הביטויים הרגולריים האלו לפני שמתאימים קישורים-קבועים, למשל: /(topic.*)\\?.*/\\1 יסיר מחרוזות שאילתה מנתיבי נושאים. הפורמט הוא regex+string משתמש ב \\1 וכד׳ כדי לגשת להתאמות" global_notice: "הצגת מודעה גלובלית דחופה בגדר חירום לכל המבקרים, יש להחליף בתוכן ריק כדי להסתיר אותה (מותר HTML)." disable_system_edit_notifications: "ביטול התראות עריכה על ידי משתמש המערכת כאשר 'download_remote_images_to_local' פעיל." - likes_notification_consolidation_threshold: "מספר ההתראות שסומנו בלייק שהתקבלו לפני שההתראות קובצו להתראה אחת. יש להגדיר ל־0 כדי לנטרל. ניתן להגדיר את החלון `SiteSetting.likes_notification_consolidation_window_mins` (חלון קיבוץ התראות על לייקים בדקות)." - likes_notification_consolidation_window_mins: "משך הזמן בשניות בו התראות מקובצות להתראה אחת לאחר שהגיעו לסף הזה. ניתן להגדיר את הסף דרך `SiteSetting.likes_notification_consolidation_threshold` (סף קיבוץ התראות לייקים)." + notification_consolidation_threshold: "מספר ההתראות שסומנו בלייק או בקשות שהתקבלו לפני שההתראות קובצו להתראה אחת. יש להגדיר ל־0 כדי להשבית." + likes_notification_consolidation_window_mins: "משך הזמן בשניות בו התראות מקובצות להתראה אחת לאחר שהגיעו לסף הזה. ניתן להגדיר את הסף דרך `SiteSetting.notification_consolidation_threshold` (סף קיבוץ התראות)." automatically_unpin_topics: "הסרת נעיצה אוטומטית של נושאים כאשר המשתמשים מגיעים לתחתית." read_time_word_count: "מספר המילים לדקה כדי להעריך את זמן הקריאה." topic_page_title_includes_category: "תגית הכותרת (title) בעמוד הנושא מכילה את שם הקטגוריה." @@ -1856,6 +1878,7 @@ he: delete_drafts_older_than_n_days: "מחקו טיוטות בנות יותר מ (n) ימים." bootstrap_mode_min_users: "מספר משתמשים מינימלי שנדרש כדי לנטרל מצב איתחול (קבעו ל 0 כדי לנטרל)" prevent_anons_from_downloading_files: "מונע ממשתמשים אנונימיים להוריד צרופות (attachments). אזהרה: דבר זה ימנע מכל משאב שאינו תמונה ופורסם כצרופה לעבוד." + secure_media: 'הגבלת הגישה להעלאות מדיה (תמונות, סרטונים, קטעי שמע). אם מופעלת „דרישת כניסה”, רק למשתמשים שנכנסו לחשבון במערכת תהיה גישה למדיה שהועלתה. אחרת, הגישה תוגבל רק למדיה שנשלחה בהודעות פרטיות. לתשומת לבך: חובה להפעיל העלאות ל־S3 בטרם הפעלת הגדרה זו.' slug_generation_method: "בחרו צורת ייצור slug. צורה של 'encoded' תגרום למחרוזות עם קידוד אחוזים. 'none' ינטרל slug לחלוטין." enable_emoji: "הפעלת אמוג׳י" enable_emoji_shortcuts: "חייכנים נפוצים כגון ‎:) :p :(‎ יומרו לאמוג׳ים" @@ -1898,6 +1921,7 @@ he: default_categories_tracking: "רשימת קטגוריות שנעקבת כברירת מחדל." default_categories_muted: "רשימת קטגוריות שמושתקות כברירת מחדל." default_categories_watching_first_post: "רשימת קטגוריות שבה הפוסט הראשון בכל נושא ייצפה אוטומטית." + mute_all_categories_by_default: "הגדרת כל רמות ההתראות כבררת מחדל בכל הקטגוריות למושתק. לדרוש ממשתמשים לבחור באופן יזום קטגוריות שיופיעו תחת ‚אחרונים’ ו־‚קטגוריות’. אם ברצונך להוסיף את בררות המחדל למשתמשים אלמוניים עליך להגדיר את ‚default_categories_‎’." default_tags_watching: "רשימת תגיות שבצפייה כבררת מחדל." default_tags_tracking: "רשימת תגיות שבמעקב כבררת מחדל" default_tags_muted: "רשימת תגיות שמושתקות כבררת מחדל." @@ -2121,7 +2145,7 @@ he: auto_deleted_by_timer: "יימחק אוטומטית על ידי שעון." login: security_key_description: "כשמפתח האבטחה הפיזי שלך מוכן יש ללחוץ על כפתור האימות עם מפתח האבטחה שלהלן." - security_key_alternative: "לא הצלחת למצוא את מפתח האבטחה או שברצונך לנסות שיטה אחרת?" + security_key_alternative: "לנסות דרך אחרת" security_key_authenticate: "אימות עם מפתח אבטחה" security_key_not_allowed_error: "זמן תהליך אימות מפתח האבטחה פג או שבוטל." security_key_no_matching_credential_error: "לא ניתן למצוא פרטי גישה במפתח האבטחה שסופק." @@ -2512,7 +2536,7 @@ he: אנו מאוד שמחים לראות שנעים לך לקחת חלק בפעילות הקהילתית ונשמח לדעת עוד עליך. ממליצים לך להשקיע רגע קט כדי [למלא את הפרופיל שלך](%{base_url}/my/preferences/profile), או סתם [לפתוח דיון חדש](%{base_url}/categories). welcome_staff: title: "ברוך בואך לסגל" - subject_template: "ברכותינו, הרשאותיך מעכשיו הן %{role}!" + subject_template: "ברכותינו, מצבך מעתה הוא %{role}!" text_body_template: | קיבלת הרשאות %{role} על ידי מישהו מבין חברי הסגל. @@ -3253,19 +3277,20 @@ he: title: "אישור מייל חדש" subject_template: "[%{email_prefix}] אשרו את כתובת המייל החדשה שלכם" text_body_template: | - אשרו את כתובת המייל החדשה שלכם עבור %{site_name} על ידי לחיצה על הקישור הבא: + נא לאשר את כתובת הדוא״ל שתשמש אותך לגשת אל %{site_name} על ידי לחיצה על הקישור הבא: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-new-email/%{email_token} confirm_old_email: title: "אישור מייל ישן" subject_template: "[%{email_prefix}] אשרו את כתובת המייל הנוכחית שלכם" text_body_template: | - לפני שנוכל לשנות את כתובת המייל שלכם, אנחנו צריכים שתאשרו שאתם שולטים - בחשבון המייל הנוכחי. אחרי שתשלימו שלב זה, נבקש שתאשרו את כתובת המייל החדשה. + לפני שנוכל להחליף את כתובת הדוא״ל שלך, עלינו לאשר שיש לך שליטה + בחשבון הדוא״ל הנוכחי. לאחר השלמת השלב הזה, נבקש ממך לאשר + את כתובת הדוא״ל החדשה. - אשרו את כתובת המייל הנוכחית עבור %{site_name} על ידי לחיצה על הקישור הבא: + ניתן לאשר את כתובת הדוא״ל הנוכחית שלך לגישה לאתר %{site_name} בלחיצה על הקישור הבא: - %{base_url}/u/authorize-email/%{email_token} + %{base_url}/u/confirm-old-email/%{email_token} notify_old_email: title: "התראת דוא״ל ישן" subject_template: "[%{email_prefix}] כתובת הדוא״ל שלך הוחלפה" @@ -3887,11 +3912,15 @@ he: two: '„%{tag_name}” מוגבלת לקטגוריות הבאות: %{category_names}' many: '„%{tag_name}” מוגבלת לקטגוריות הבאות: %{category_names}' other: '„%{tag_name}” מוגבלת לקטגוריות הבאות: %{category_names}' + synonym: 'אסור להשתמש במילים נרדפות. יש להשתמש ב־„%{tag_name}” במקום.' + has_synonyms: 'לא ניתן להשתמש ב־„%{tag_name}” כיוון שיש לו מילים נרדפות.' required_tags_from_group: one: "עליך לכלול תגית %{tag_group_name} %{count} לפחות" two: "עליך לכלול %{count} תגיות %{tag_group_name} לפחות" many: "עליך לכלול %{count} תגיות %{tag_group_name} לפחות" other: "עליך לכלול %{count} תגיות %{tag_group_name} לפחות" + invalid_target_tag: "לא יכול להיות מילה נרדפת של מילה נרדפת" + synonyms_exist: "אסור לשימוש כל עוד קיימות מילים נרדפות" rss_by_tag: "נושאים מתוייגים %{tag}" finish_installation: congratulations: "ברכותינו, התקנת את Discourse!" diff --git a/config/locales/server.hu.yml b/config/locales/server.hu.yml index 7e1033b216..777a53dd5d 100644 --- a/config/locales/server.hu.yml +++ b/config/locales/server.hu.yml @@ -451,8 +451,6 @@ hu: confirmed: "Az e-mail címe frissítve lett." please_continue: "Tovább a(z) %{site_name} oldalra" error: "Hiba történt az e-mail cím módosításakor. Lehet, hogy már használatban van?" - authorizing_old: - title: "Köszönjük, hogy megerősítette az e-mail címét" activation: action: "Kattintson ide a felhasználói fiókja aktiválásához" already_done: "Ez a fiók megerősítési hivatkozás már nem érvényes. Lehet, hogy a fiókja már aktiválva lett?" diff --git a/config/locales/server.hy.yml b/config/locales/server.hy.yml index 0c0bab06d0..af0c6cd501 100644 --- a/config/locales/server.hy.yml +++ b/config/locales/server.hy.yml @@ -622,9 +622,6 @@ hy: error: "Ձեր էլ. հասցեն փոփոխելիս տեղի է ունեցել սխալ: Միգուցե հասցեն արդեն օգտագործվո՞ւմ է:" error_staged: "Ձեր էլ. հասցեն փոփոխելիս տեղի է ունեցել սխալ: Հասցեն արդեն օգտագործվում է աստիճանավորված օգտատիրոջ կողմից:" already_done: "Ներողություն, այս հաստատման հղումը այլևս վավեր չէ: Միգուցե Ձեր էլ. հասցեն արդեն փոխվե՞լ է:" - authorizing_old: - title: "Շնորհակալ ենք Ձեր ընթացիկ էլ. հասցեն հաստատելու համար" - description: "Մենք այժմ նամակ ենք գրում Ձեր հասցեին հաստատման համար:" associated_accounts: revoke_failed: "Չհաջողվեց ետ կանչել Ձեր հաշիվը %{provider_name} -ով:" activation: @@ -1225,7 +1222,6 @@ hy: content_security_policy: "Միացնել Content-Security-Policy" content_security_policy_report_only: "Միացնել Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Միացնել CSP խախտումների հաշվետվությունների հավաքածուն /csp_reports -ում" - content_security_policy_script_src: "Լրացուցիչ մաքրագրված սկրիպտի աղբյուրներ: Ներկայիս հոսթը և CDN -ը լռելյայն կերպով ներառված են:" invalidate_inactive_admin_email_after_days: "Ադմինի հաշիվները, որոնք չեն այցելել կայք այսքան օրվա ընթացքում, պետք է վերա-վավերացնեն իրենց էլ. հասցեն մինչ մուտք գործելը: Սահմանեք 0 անջատելու համար:" top_menu: "Որոշեք, թե որ տարրերը պետք է հայտնվեն գլխավոր էջի նավիգացիայում և ինչ հերթականությամբ: Օրինակ՝ վերջին|նոր|չկարդացած|կատեգորիաներ|թոփ|կարդացած|հրապարակված|էջանշումներ" post_menu: "Որոշեք թե, որ տարրերը պետք է հայտնվեն գրառումների մենյուում և ինչ հերթականությամբ: Օրինակ՝ հավանել|խմբագրել|դրոշակավորել|ջնջել|կիսվել|էջանշել|պատասխանել" @@ -1566,8 +1562,6 @@ hy: permalink_normalizations: "Կիրառել հետևյալ կարգավորումը՝ մինչ մշտահղումները համապատասխանեցնելը, օրինակ՝ /(topic.*)\\?.*/\\1 -ը կառանձնացնի հարցման տողերը թեմայի ուղիներից: Ֆորմատը՝ regex+string , օգտագործեք \\1 և այլն՝ հասանելիության գրավման համար:" global_notice: "Ցուցադրել ՇՏԱՊ, ԱՆՀՐԱԺԵՇՏՈՒԹՅՈՒՆ, ոչ-չեղարկելի գլոբալ բանների նշումը բոլոր այցելուներին, փոխեք դատարկի՝ թաքցնելու համար (HTML -ը թույլատրված է):" disable_system_edit_notifications: "Անջատում է խմբագրման ծանուցումները համակարգի օգտատիրոջ կողմից, երբ 'download_remote_images_to_local' -ը ակտիվ է:" - likes_notification_consolidation_threshold: "Հավանումների մասին ստացված ծանուցումների քանակը, որից հետո ծանուցումները միավորվում են մեկի տակ: Սահմանեք 0՝ անջատելու համար: Պատուհանը կարող է կարգավորվել հետևյալի միջոցով՝ `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Տևողությունը րոպեներով, երբ հավանումների ծանուցումները միավորվում են մեկի տակ, հենց որ հատվում է սահմանը: Սահմանը կարող է կարգավորվոել հետևյալի միջոցով՝ `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Ավտոմատ կերպով ապակցել թեմաները, երբ օգտատերը հասնում է ներքև:" read_time_word_count: "Յուրաքանչյուր րոպեում բառերի քանակը՝ կարդալու մոտավոր ժամանակը հաշվարկելու համար:" share_anonymized_statistics: "Կիսվել անանուն օգտագործումների վիճակագրությամբ:" @@ -2699,20 +2693,9 @@ hy: confirm_new_email: title: "Հաստատել Նոր Էլ. հասցեն" subject_template: "[%{email_prefix}] Հաստատեք Ձեր նոր էլ. հասցեն" - text_body_template: | - Հաստատեք Ձեր նոր էլ. հասցեն %{site_name} -ի համար՝ սեղմելով հետևյալ հղումը. - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Հաստատել Հին Էլ. Հասցեն" subject_template: "[%{email_prefix}] Հաստատել Ձեր ընթացիկ էլ. հասցեն" - text_body_template: | - Մինչ մենք կարող ենք փոփոխել Ձեր էլ. հասցեն, անհրաժեշտ է, որ Դուք հաստատեք, որ Դուք վերահսկում եք - ընթացիկ էլ. փոստի հաշիվը: Այս քայլը ավարտելուց հետո մենք կհաստատենք - նոր էլ. հասցեն: - - Հաստատեք Ձեր ընթացիկ էլ. հասցեն %{site_name} -ի համար՝ սեղմելով հետևյալ հղումը՝ - %{base_url}/u/նույնականացնել-հասցեն/%{email_token} notify_old_email: title: "Ծանուցել Հին Էլ. Նամակը" subject_template: "[%{email_prefix}] Ձեր էլ. հասցեն փոփոխվել է " diff --git a/config/locales/server.id.yml b/config/locales/server.id.yml index 68866d96a5..912ef4e9c4 100644 --- a/config/locales/server.id.yml +++ b/config/locales/server.id.yml @@ -275,9 +275,6 @@ id: error: "Ada kesalahan dalam merubah alamat email Anda. Mungkinkah alamat ini telah digunakan oleh pengguna lain?" error_staged: "Ada kesalahan dalam merubah alamat email Anda. Alamat ini telah digunakan oleh pengguna lain." already_done: "Maaf, tautan konfirmasi ini sudah tidak valid. Apakah anda telah mengubah surel anda?" - authorizing_old: - title: "Terima kasih telah mengkonfirmasi alamat email terkini Anda" - description: "Sekarang kami akan mengirimkan surel konfirmasi ke alamat email baru Anda." activation: action: "Klik disini untuk mengaktifkan akun anda" already_done: "Maaf, tautan konfirmasi ini sudah tidak valid. Apakah akun anda sudah aktif?" diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index c326d6c534..db56667c8c 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -694,9 +694,6 @@ it: error: "Si è verificato un errore durante la modifica del tuo indirizzo email. Forse l'indirizzo è già in uso?" error_staged: "Si è verificato un errore durante il cambio di indirizzo email. L'indirizzo è già stato usato da un utente temporaneo." already_done: "Spiacenti, il collegamento di conferma non è più valido. Hai forse già cambiato email?" - authorizing_old: - title: "Grazie per aver confermato il tuo attuale indirizzo email" - description: "Ti stiamo inviando una email al nuovo indirizzo per conferma." associated_accounts: revoke_failed: "Impossibile revocare il tuo account con %{provider_name}." connected: "(connesso)" @@ -1333,7 +1330,6 @@ it: content_security_policy: "Abilita Content-Security-Policy" content_security_policy_report_only: "Abilita Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Abilita raccolta di report di violazione CSP in /csp_reports" - content_security_policy_script_src: "Ulteriori fonti di script nella whitelist. L'host attuale e il CDN sono inclusi per impostazione predefinita." invalidate_inactive_admin_email_after_days: "Gli account amministratore che non hanno visitato il sito in questo numero di giorni dovranno riconvalidare il proprio indirizzo email prima di accedere. Impostare su 0 per disabilitare." top_menu: "Determina quali oggetti appaiono nella navigazione della pagina iniziale, e in quale ordine. Esempio latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determina quali elementi vengono mostrati nel menu del messaggio, e in quale ordine. Esempio like|edit|flag|delete|share|bookmark|reply" @@ -1693,8 +1689,6 @@ it: permalink_normalizations: "Applicare il seguente regex prima di accedere ai permalink, ad esempio: /(topic.*)\\?.*/\\1 eliminerà le stringhe query dalla route degli argomenti. Il formato è regex+string use \\1 ecc. per accedere alle catture" global_notice: "Mostra un banner di avviso globale URGENTE, EMERGENZA, non eliminabile a tutti i visitatori, imposta a vuoto per nasconderlo (HTML consentito)." disable_system_edit_notifications: "Disabilita le notifiche di modifica dall'utente system quando 'download_remote_images_to_local' è attivo." - likes_notification_consolidation_threshold: "Numero di notifiche da ricevere oltre il quale saranno consolidate in una singola notifica. Impostare su 0 per disabilitare. Il valore può essere configurato tramite 'SiteSetting.likes_notification_consolidation_window_mins'." - likes_notification_consolidation_window_mins: "Tempo in minuti nel quale le notifiche preferite vengono consolidate in un'unica notifica una volta raggiunta una determinata soglia. La soglia può essere configurata tramite 'SiteSetting.likes_notification_consolidation_threshold'." automatically_unpin_topics: " Spunta automaticamente gli argomenti quando l'utente arriva in fondo. " read_time_word_count: "Conteggio di parole al minuto per calcolare il tempo stimato di lettura." topic_page_title_includes_category: "La pagina dell'argomento titolo etichetta include il nome della categoria." @@ -1945,6 +1939,8 @@ it: auto_deleted_by_timer: "Eliminato automaticamente dal timer." login: security_key_description: "Quando hai preparato la chiave di sicurezza fisica, premi il pulsante Autentica con Security Key qui sotto." + security_key_authenticate: "Autentica con Security Key" + security_key_not_allowed_error: "Il processo di autenticazione con Security Key è scaduto o è stato annullato." not_approved: "Il tuo account non è ancora stato approvato. Verrai avvertito via mail quando potrai collegarti." incorrect_username_email_or_password: "Nome utente, email o password errati" incorrect_password: "Password errata" @@ -2159,7 +2155,7 @@ it: ignored: "Grazie per averci informato. Stiamo provvedendo." ignored_and_deleted: "Grazie per averci informato. Abbiamo rimosso il messaggio." temporarily_closed_due_to_flags: - one: "Questo argomento è temporaneamente chiuso per almeno un'ora %{count} a causa di un numero elevato di flag di comunità." + one: "Questo argomento è temporaneamente chiuso per almeno un'ora %{count} a causa di un numero elevato di flag di comunità." other: "Questo argomento resterà temporaneamente chiuso per almeno %{count} ore a causa di un numero elevato di segnalazioni dalla comunità." system_messages: private_topic_title: "Argomento #%{id}" @@ -2858,21 +2854,9 @@ it: confirm_new_email: title: "Conferma Nuova Email" subject_template: "[%{email_prefix}] Conferma il tuo nuovo indizzo email" - text_body_template: | - Conferma il tuo nuovo indirizzo email su %{site_name} cliccando il seguente collegamento: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Conferma Vecchia Email" subject_template: "[%{email_prefix}] Conferma il tuo attuale indirizzo email" - text_body_template: | - Prima di poter cambiare il tuo indirizzo email abbiamo bisogno che tu confermi di controllare - l'indirizzo email attuale. Dopo aver completato questo passaggio, dovrai confermare - il nuovo indirizzo email. - - Conferma il tuo attuale indirizzo email su %{site_name} cliccando il seguente collegamento: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Notifica Vecchia Email" subject_template: "[%{email_prefix}] Il tuo indirizzo email è stato cambiato" @@ -3455,8 +3439,8 @@ it: other: "Nessuno dei tags selezionati può essere utilizzato" in_this_category: '''%{tag_name}'' non può essere utilizzato in questa categoria' restricted_to: - one: '"%{tag_name}" è limitato alla categoria "%{category_names}"' - other: '''%{tag_name}'' è limitato alle seguenti categorie: %{category_names}' + one: '"%{tag_name}" è limitato alla categoria "%{category_names}"' + other: '"%{tag_name}" è limitato alle seguenti categorie: %{category_names}' rss_by_tag: "Argomenti etichettati %{tag}" finish_installation: congratulations: "Congratulazioni, hai installato Discourse!" diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index 529d544a95..3470fc6bd8 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -416,9 +416,6 @@ ja: error: "メールアドレスの変更中にエラーが発生しました。このアドレスはすでに使われている可能性があります。" error_staged: "メールアドレスの変更中にエラーが発生しました。このアドレスはすでに使われている可能性があります。" already_done: "申し訳ありませんが、この確認リンクは有効ではありません。既にあなたのメールは変更されていませんか?" - authorizing_old: - title: "メールアドレスを確認していただきありがとうございます!\U0001F609" - description: "登録されたメールアドレスに確認メールを送りました\U0001F606" activation: action: "クリックしてアカウントを認証する" already_done: "申し訳ありませんが、このアカウント認証リンクは無効です。既にアカウントがアクティブになっていませんか?" @@ -1297,10 +1294,6 @@ ja: confirm_new_email: title: "メールを確認してください" subject_template: "[%{email_prefix}]新しいメールアドレスを確認してください" - text_body_template: | - %{site_name}への新しいメールアドレスを下のリンクから確認してください。 - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "古いメールの確認" subject_template: "[%{email_prefix}]現在のメールアドレスの確認" diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 9b6aeb0811..3a4fa3f764 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -514,9 +514,6 @@ ko: error: "이메일 주소를 변경하는데 문제가 있습니다. 주소가 이미 사용되고 있나요?" error_staged: "이메일 주소 변경중에 에러가 발생했습니다. 이 주소는 격리된 사용자가 이미 사용중입니다." already_done: "죄송합니다. 이 확인 링크는 더 이상 유효하지 않습니다. 이메일이 이미 변경되지는 않았나요?" - authorizing_old: - title: "이메일 주소를 확인해 주셔서 감사합니다." - description: "확인을 위하여 새로운 이메일 주소로 메일을 전송합니다." activation: action: "여기를 눌러 계정을 활성화하세요." already_done: "죄송합니다. 이 계정 확인 링크는 더 이상 유효하지 않습니다." diff --git a/config/locales/server.lv.yml b/config/locales/server.lv.yml index 4077eacf79..8cf6e85a6f 100644 --- a/config/locales/server.lv.yml +++ b/config/locales/server.lv.yml @@ -140,6 +140,8 @@ lv: every_hour: "katru stundu" daily: "katru dienu" weekly: "katru nedēļu" + every_month: "katru mēnesi" + every_six_months: "katrus sešus mēnešus" user_api_key: read: "izlasīts" otp_confirmation: @@ -151,6 +153,7 @@ lv: day: Diena post_edits: labels: + edited_at: Datums post: Ieraksts edit_reason: Iemesls user_flagging_ratio: @@ -255,11 +258,13 @@ lv: http_5xx_reqs: xaxis: "Diena" http_total_reqs: + title: "Kopā" xaxis: "Diena" time_to_first_response: xaxis: "Diena" topics_with_no_response: xaxis: "Diena" + yaxis: "Kopā" mobile_visits: xaxis: "Diena" web_crawlers: diff --git a/config/locales/server.nb_NO.yml b/config/locales/server.nb_NO.yml index 283ba4c69a..dcd7f75400 100644 --- a/config/locales/server.nb_NO.yml +++ b/config/locales/server.nb_NO.yml @@ -575,9 +575,6 @@ nb_NO: error: "Det oppsto en feil ved endring av din e-postadresse. Kanskje addressen allerede er i bruk?" error_staged: "En feil oppstod ved endring av e-postadressen din. Den nye adressen er allerede i bruk av en arrangert bruker." already_done: "Beklager, denne godkjenningslenken er ikke lenger gyldig. Kanskje e-postadressen din allerede er byttet?" - authorizing_old: - title: "Takk for at du bekreftet din nåværende e-postadresse" - description: "Vi sender deg en e-post til din nye adresse for bekreftelse." associated_accounts: revoke_failed: "Klarte ikke å oppheve kontotilknytningen til %{provider_name}." activation: diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index 3b1c409637..882bd9e6d8 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -56,6 +56,7 @@ nl: component_no_default: "Themaonderdelen kunnen geen standaardthema zijn" component_no_color_scheme: "Themaonderdelen kunnen geen kleurenpaletten bevatten" no_multilevels_components: "Thema's met onderliggende thema's kunnen zelf geen onderliggend thema zijn" + optimized_link: Geoptimaliseerde afbeeldingskoppelingen zijn kortstondig en dienen niet in broncode van thema's te worden opgenomen. settings_errors: invalid_yaml: "Opgegeven YAML is ongeldig." data_type_not_a_number: "Type van instelling `%{name}` wordt niet ondersteund. Ondersteunde typen zijn `integer`, `bool`, `list` en `enum`." @@ -103,6 +104,7 @@ nl: unsubscribe_not_allowed: "Gebeurt wanneer uitschrijven via e-mail niet is toegestaan voor deze gebruiker." email_not_allowed: "Gebeurt wanneer het e-mailadres zich niet op de whitelist of wel op de blacklist bevindt." unrecognized_error: "Niet-herkende fout" + secure_media_placeholder: "Geredigeerd: deze website heeft beveiligde media ingeschakeld; bezoek het topic om de gekoppelde afbeelding/audio/video te zien." errors: &errors format: "%{attribute} %{message}" format_with_full_message: "%{attribute}: %{message}" @@ -124,6 +126,7 @@ nl: inclusion: komt niet voor in de lijst invalid: is ongeldig is_invalid: "lijkt onduidelijk, is het een volledige zin?" + invalid_timezone: "'%{tz}' is geen geldige tijdzone" contains_censored_words: "bevat de volgende gecensureerde woorden: %{censored_words}" less_than: "moet minder zijn dan %{count}" less_than_or_equal_to: "moet minder zijn dan of gelijk zijn aan %{count}" @@ -166,6 +169,10 @@ nl: enable_s3_uploads_is_required: "U kunt geen inventory naar S3 inschakelen voordat u de S3-uploads hebt ingeschakeld." s3_backup_requires_s3_settings: "U kunt S3 niet als back-uplocatie gebruiken voordat u de '%{setting_name}' hebt opgegeven." s3_bucket_reused: "U kunt niet dezelfde bucket voor 's3_upload_bucket' en 's3_backup_bucket' gebruiken. Kies een andere bucket of gebruik een ander pad voor elke bucket." + secure_media_requirements: "S3-uploads moeten zijn ingeschakeld voordat u beveiligde media inschakelt." + second_factor_cannot_be_enforced_with_disabled_local_login: "U kunt geen 2FA afdwingen als lokale aanmeldingen zijn uitgeschakeld." + local_login_cannot_be_disabled_if_second_factor_enforced: "U kunt geen lokale aanmelding uitschakelen als 2FA is afgedwongen. Schakel afgedwongen 2FA uit voordat u lokale aanmeldingen uitschakelt." + cannot_enable_s3_uploads_when_s3_enabled_globally: "U kunt geen S3-uploads inschakelen, omdat S3-uploads al globaal zijn ingeschakeld, en inschakelen hiervan op websiteniveau kan kritieke problemen met uploads veroorzaken" conflicting_google_user_id: 'De Google-account-ID voor deze account is gewijzigd; om beveiligingsredenen is stafinterventie vereist. Neem contact op met een staflid en wijs hem of haar op
https://meta.discourse.org/t/76575' activemodel: errors: @@ -280,6 +287,7 @@ nl: max_pm_recepients: "Sorry, u kunt naar maximaal %{recipients_limit} ontvangers een bericht sturen." pm_reached_recipients_limit: "Sorry, u kunt niet meer dan %{recipients_limit} ontvangers in een bericht hebben." removed_direct_reply_full_quotes: "Citaat van hele voorgaande bericht is automatisch verwijderd." + secure_upload_not_allowed_in_public_topic: "Sorry, de volgende beveiligde upload(s) kan/kunnen niet in een publiek topic wordt gebruikt: %{upload_filenames}." just_posted_that: "lijkt te veel op wat u onlangs hebt geplaatst" invalid_characters: "bevat ongeldige tekens" is_invalid: "lijkt onduidelijk, is het een volledige zin?" @@ -714,14 +722,23 @@ nl: windows: "Microsoft Windows" unknown: "onbekend besturingssysteem" change_email: + wrong_account_error: "U bent bij de verkeerde account aangemeld; meld u af en probeer het opnieuw." confirmed: "Uw e-mailadres is bijgewerkt." please_continue: "Doorgaan naar %{site_name}" error: "Er is een fout opgetreden bij het wijzigen van uw e-mailadres. Misschien is het adres al in gebruik?" error_staged: "Er is een fout opgetreden bij het wijzigen van uw e-mailadres. Het adres is al in gebruik door een staged gebruiker." already_done: "Sorry, deze bevestigingskoppeling is niet meer geldig. Misschien is uw e-mailadres al gewijzigd?" + confirm: "Bevestigen" + authorizing_new: + title: "Bevestig uw nieuwe e-mailadres" + description: "Bevestig dat u uw nieuwe e-mailadres gewijzigd wilt zien naar:" authorizing_old: - title: "Bedankt voor het bevestigen van uw huidige e-mailadres" - description: "We sturen nu ter bevestiging een e-mail naar uw nieuwe adres." + title: "Uw e-mailadres wijzigen" + description: "Bevestig de wijziging van uw e-mailadres" + old_email: "Oude e-mailadres: %{email}" + new_email: "Nieuwe e-mailadres: %{email}" + almost_done_title: "Nieuwe e-mailadres bevestigen" + almost_done_description: "Er is een e-mail naar uw nieuwe e-mailadres verstuurd om de wijziging te bevestigen!" associated_accounts: revoke_failed: "Intrekken van uw account bij %{provider_name} is mislukt." connected: "(verbonden)" @@ -857,6 +874,8 @@ nl: description: "e-mailvoorkeuren voor %{email} zijn bijgewerkt. Bezoek uw gebruikersvoorkeuren om uw e-mailinstellingen te wijzigen." topic_description: "Gebruik de meldingsinstellingen onder of rechts van het topic om u opnieuw voor %{link} in te schrijven." private_topic_description: "Gebruik de meldingsinstellingen onder of rechts van het topic om u opnieuw in te schrijven." + uploads: + marked_insecure_from_theme_component_reason: "upload gebruikt in themaonderdeel" unsubscribe: title: "Uitschrijven" stop_watching_topic: "Dit topic niet meer in de gaten houden, %{link}" @@ -1213,12 +1232,12 @@ nl: filesize: Bestandsgrootte description: "Lijst van alle uploads op extensie, bestandsgrootte en maker." top_ignored_users: - title: "Meest Genegeerde / Gedempte Gebruikers" + title: "Meest genegeerde / gedempte gebruikers" labels: ignored_user: Genegeerde gebruiker ignores_count: Aantal genegeerd mutes_count: Aantal gedempt - description: "Gebruikers die gedempt en/of genegeerd worden door vele andere gebruikers." + description: "Gebruikers die door veel andere gebruikers zijn gedempt en/of genegeerd." dashboard: rails_env_warning: "Uw server werkt in de modus voor %{env}." host_names_warning: "Uw bestand config/database.yml gebruikt de standaardhostnaam localhost. Werk deze bij naar de hostnaam van uw website." @@ -1240,7 +1259,9 @@ nl: other: "E-mailpolling heeft de afgelopen 24 uur %{count} fouten gegenereerd. Bekijk de logboeken voor meer details." missing_mailgun_api_key: "De server is geconfigureerd om e-mails via Mailgun te verzenden, maar u hebt geen API-sleutel opgegeven voor verificatie van de webhookberichten." bad_favicon_url: "De favicon wordt niet geladen. Controleer uw favicon-instelling in de Website-instellingen." - poll_pop3_timeout: "Time-out voor verbinding met de POP3-server. Binnenkomende e-mail kon niet worden opgehaald. Controleer uw POP3-instellingen en serviceprovider." + deprecated_api_usage: "We hebben een API-aanvraag via een verouderde authenticatiemethode gedetecteerd. Werk deze bij zodat op headers gebaseerde authenticatie wordt gebruikt. Na het bijwerken zou dit bericht pas na 24 uur kunnen verschijnen." + update_mail_receiver: "We hebben een verouderde versie van mail-receiver gedetecteerd. Klik hier voor update-instructies. Na het bijwerken zou dit bericht pas na 24 uur kunnen verschijnen." + poll_pop3_timeout: "Time-out voor verbinding met de POP3-server. Inkomende e-mail kon niet worden opgehaald. Controleer uw POP3-instellingen en serviceprovider." poll_pop3_auth_error: "Verbinding met de POP3-server is mislukt met een authenticatiefout. Controleer uw POP3-instellingen." force_https_warning: "Uw website gebruikt SSL, maar `force_https` is nog niet ingeschakeld in uw website-instellingen." out_of_date_themes: "Er zijn updates voor de volgende thema's beschikbaar:" @@ -1316,11 +1337,11 @@ nl: logo_small: "De kleine logoafbeelding links bovenaan op uw website, zichtbaar bij omlaag scrollen. Gebruik een vierkante afbeelding van 120 × 120. Bij leeg laten wordt een startpaginasymbool getoond." digest_logo: "De alternatieve logoafbeelding bovenaan de e-mailsamenvatting van uw website. Gebruik een brede rechthoekige afbeelding. Gebruik geen SVG-afbeelding. Bij leeg laten wordt de afbeelding van de instelling `logo` gebruikt." mobile_logo: "Het logo dat op de mobiele versie van uw website wordt gebruikt. Gebruik een brede rechthoekige afbeelding met een hoogte van 120 en een hoogte-breedteverhouding groter dan 3:1. Bij leeg laten wordt de afbeelding van de instelling `logo` gebruikt." - large_icon: "Afbeelding die als basis wordt gebruikt voor andere metadata iconen. Moet idealiter groter zijn dan 512 x 512. Als het leeg gelaten wordt, dan zal logo_small worden gebruikt." - manifest_icon: "Afbeelding gebruikt als logo/splash-afbeelding op Android. Zal vanzelf worden geschaald naar 512 x 512. Als het leeg gelaten wordt, dan zal large_icon gebruikt worden." - favicon: "Een favicon voor jouw website, zie https://en.wikipedia.org/wiki/Favicon. Om correct te werken via een CDN, moet dit een png zijn. Zal worden geschaald naar 32x32. Als het leeg gelaten wordt, dan zal large_icon gebruikt worden." - apple_touch_icon: "Icoon gebruikt voor Apple touch-apparaten. Zal geschaald worden naar 180x180. Als het leeg gelaten wordt, dan zal large_icon worden gebruikt." - opengraph_image: "Standaard opengraph-afbeelding, gebruikt wanneer de pagina geen andere afbeelding bevat. Als het leeg gelaten wordt, dan zal large_icon worden gebruikt" + large_icon: "Afbeelding die als basis voor andere metagegevenspictogrammen wordt gebruikt. Moet idealiter groter zijn dan 512 x 512. Bij leeg laten wordt logo_small gebruikt." + manifest_icon: "Afbeelding die als logo/splashafbeelding op Android wordt gebruikt. Wordt automatisch verkleind naar 512 x 512. Bij leeg laten wordt large_icon gebruikt." + favicon: "Een favicon voor uw website, zie https://nl.wikipedia.org/wiki/Favicon. Om goed via een CDN te werken, moet dit een png zijn. Wordt verkleind naar 32x32. Bij leeg laten wordt large_icon gebruikt." + apple_touch_icon: "Pictogram dat voor Apple touch-apparaten wordt gebruikt. Wordt automatisch verkleind naar 180x180. Bij leeg laten wordt large_icon gebruikt." + opengraph_image: "Standaard opengraph-afbeelding, gebruikt wanneer de pagina geen andere geschikte afbeelding bevat. Bij leeg laten wordt large_icon gebruikt." twitter_summary_large_image: "'summary large image' van Twitter-card (dient minstens 280 in breedte, en minstens 150 in hoogte te zijn). Bij leeg laten worden reguliere kaartmetagegevens gegenereerd via de opengraph_image." notification_email: "Het Van:-e-mailadres adres dat wordt gebruikt voor het verzenden van alle essentiële systeem-e-mails. Het hier opgegeven domein moet goed ingestelde SPF-, DKIM- en reverse-PTR-records hebben om e-mails te doen aankomen." email_custom_headers: "Een door een pipe (|) gescheiden lijst van aangepaste e-mailheaders" @@ -1383,7 +1404,7 @@ nl: content_security_policy: "Content-Security-Policy inschakelen" content_security_policy_report_only: "Content-Security-Policy-Report-Only inschakelen" content_security_policy_collect_reports: "Rapportverzameling voor CSP-schendingen inschakelen via /csp_reports" - content_security_policy_script_src: "Aanvullende toegestane scriptbronnen. De huidige host en CDN zijn standaard opgenomen." + content_security_policy_script_src: "Aanvullende scriptbronnen op de whitelist. De huidige host en CDN zijn standaard inbegrepen. Zie Mitigate XSS Attacks with Content Security Policy." invalidate_inactive_admin_email_after_days: "Beheerdersaccounts die de website dit aantal dagen niet hebben bezocht, dienen hun e-mailadres voor het aanmelden opnieuw te valideren. Stel dit in op 0 om uit te schakelen." top_menu: "Bepalen welke items in het hoofdnavigatiemenu verschijnen, en in welke volgorde. Voorbeeld: latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Bepalen welke items in het berichtmenu verschijnen, en in welke volgorde. Voorbeeld: like|edit|flag|delete|share|bookmark|reply" @@ -1649,7 +1670,7 @@ nl: reply_by_email_address: "Sjabloon voor adres voor inkomende e-mail bij antwoorden per e-mail, bijvoorbeeld: %%{reply_key}@reply.example.com of replies+%%{reply_key}@example.com" alternative_reply_by_email_addresses: "Lijst van alternatieve sjablonen voor adressen voor inkomende e-mail bij antwoorden per e-mail. Voorbeeld: %%{reply_key}@reply.example.com|replies+%%{reply_key}@example.com" incoming_email_prefer_html: "HTML in plaats van tekst gebruiken voor inkomende e-mail." - strip_incoming_email_lines: "Voorloop- en volgspaties uit elke regel van binnenkomende e-mails verwijderen." + strip_incoming_email_lines: "Voorloop- en volgspaties uit elke regel van inkomende e-mails verwijderen." disable_emails: "Voorkomen dat Discourse alle soorten e-mail verstuurt. Selecteer 'yes' om e-mails voor alle gebruikers uit te schakelen. Selecteer 'non-staff' om alleen e-mails voor stafleden uit te schakelen." strip_images_from_short_emails: "Afbeeldingen met grootte van minder dan 2800 bytes uit e-mails verwijderen" short_email_length: "Lengte van korte e-mail in bytes" @@ -1693,6 +1714,7 @@ nl: log_mail_processing_failures: "Alle fouten van e-mailverwerking opslaan in /logs" email_in: 'Gebruikers mogen nieuwe topics maken via e-mail (vereist handmatige of POP3-polling). Configureer de adressen in het tabblad ''Instellingen'' van elke categorie.' email_in_min_trust: "Het minimale vertrouwensniveau dat een gebruiker moet hebben om nieuwe topics te kunnen plaatsen via e-mail." + email_in_authserv_id: "De ID van de service die authenticatiecontroles op inkomende e-mails uitvoert. Zie https://meta.discourse.org/t/134358 voor instructies over het configureren hiervan." email_in_spam_header: "De e-mailheader voor het detecteren van spam." email_prefix: "Het [label] dat in het onderwerp van e-mails wordt gebruikt. Als niets is ingevuld, wordt 'title' gebruikt." email_site_title: "De titel van de website die als de afzender van e-mails van de website wordt gebruikt. Standaard wordt 'titel' gebruikt als niets is ingesteld. Gebruik deze instelling als uw 'titel' tekens bevat die niet in tekenreeksen van e-mailafzenders zijn toegestaan." @@ -1751,8 +1773,6 @@ nl: permalink_normalizations: "De volgende reguliere expressie toepassen voordat permalinks worden verwerkt. Voorbeeld: /(topic.*)\\?.*/\\1 verwijdert querystrings uit topicroutes. Notatie is regex+strings, gebruik \\1 etc. voor deeluitdrukkingen." global_notice: "Een algemene niet te verbergen DRINGEND, NOODGEVAL-bannermelding voor alle gebruikers weergeven. Laat leeg om deze te verbergen (HTML toegestaan)." disable_system_edit_notifications: "Schakelt bewerkingsmeldingen van de systeemgebruiker uit als 'download_remote_images_to_local' actief is." - likes_notification_consolidation_threshold: "Aantal ontvangen like-meldingen voordat de meldingen in een enkele worden samengevoegd. Stel dit in op 0 om uit te schakelen. Het venster kan worden geconfigureerd via `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Tijdsduur in minuten waarin like-meldingen in een enkele melding worden samengevoegd zodra de drempel is bereikt. De drempel kan worden geconfigureerd via `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Topics automatisch losmaken wanneer de gebruiker de onderkant bereikt." read_time_word_count: "Aantal woorden per minuut voor het berekenen van geschatte leestijd." topic_page_title_includes_category: "Topicpagina titeltag bevat de categorienaam." @@ -1786,6 +1806,7 @@ nl: delete_drafts_older_than_n_days: "Concepten ouder dan (n) dagen verwijderen." bootstrap_mode_min_users: "Minimale aantal vereiste gebruikers om bootstrapmodus uit te schakelen (stel dit in op 0 om uit te schakelen)" prevent_anons_from_downloading_files: "Voorkomen dat anonieme gebruikers bijlagen downloaden. WAARSCHUWING: hierdoor zullen websiteonderdelen anders dan afbeeldingen die als bijlage zijn geplaatst niet werken." + secure_media: 'Beperkt toegang tot media-uploads (afbeeldingen, video, audio). Als ''aanmelding vereist'' is ingeschakeld, hebben alleen aangemelde gebruikers toegang tot media-uploads. Anders is toegang beperkt tot media-uploads in privéberichten. Opmerking: S3-uploads moeten zijn ingeschakeld voordat u deze instelling inschakelt.' slug_generation_method: "Kies een slug-generatiemodus. 'encoded' genereert een percentage-coderingsstring. 'none' schakelt de slug helemaal uit." enable_emoji: "Emoji inschakelen" enable_emoji_shortcuts: "Gebruikelijke smileyteksten zoals :) :p :( worden naar emoji geconverteerd" @@ -1828,6 +1849,7 @@ nl: default_categories_tracking: "Lijst van categorieën die standaard worden gevolgd." default_categories_muted: "Lijst van categorieën die standaard worden gedempt." default_categories_watching_first_post: "Lijst van categorieën waarin het eerste bericht in elk nieuw topic standaard in de gaten wordt gehouden." + mute_all_categories_by_default: "Het standaard meldingsniveau van alle categorieën instellen op gedempt. Gebruikers moeten zich bij categorieën aanmelden om deze in de pagina's 'nieuwste' en 'categorieën' te laten verschijnen. Als u de standaardwaarden voor anonieme gebruikers wilt wijzigen, stel dan de instellingen voor 'default_categories_' in." default_tags_watching: "Lijst van tags die standaard in de gaten worden gehouden." default_tags_tracking: "Lijst van tags die standaard worden gevolgd." default_tags_muted: "Lijst van tags die standaard worden gedempt." @@ -2013,7 +2035,7 @@ nl: auto_deleted_by_timer: "Automatisch verwijderd door timer." login: security_key_description: "Houd uw fysieke beveiligingssleutel gereed en klik op de onderstaande knop Authenticeren met beveiligingssleutel." - security_key_alternative: "Uw beveiligingssleutel niet gevonden of een andere methode gebruiken?" + security_key_alternative: "Andere manier proberen" security_key_authenticate: "Authenticeren met beveiligingssleutel" security_key_not_allowed_error: "Het authenticatieproces van de beveiligingssleutel had een time-out of is geannuleerd." security_key_no_matching_credential_error: "Geen referenties gevonden in de opgegeven beveiligingssleutel." @@ -2098,40 +2120,40 @@ nl: other: "%{count} items moeten beoordeeld worden" unsubscribe_mailer: title: "Mailer Uitschrijven" - subject_template: "Bevestig dat je niet langer email-updates wilt krijgen van %{site_title}" + subject_template: "Bevestig dat u geen e-mailupdates van %{site_title} meer wilt ontvangen" text_body_template: | - Iemand (mogelijk jij?) vroeg aan om niet langer email-updates te krijgen van %{site_domain_name} naar dit adres. - Als je dit wilt bevestigen, klik dan op de link: + Iemand (mogelijk u?) heeft gevraagd geen e-mailupdates van %{site_domain_name} meer naar dit adres te sturen. + Als u dit wilt bevestigen, klikt u op deze koppeling: %{confirm_unsubscribe_link} - Als je nogsteeds email-updates wilt krijgen, dan mag je deze mail negeren. + Als u e-mailupdates wilt blijven ontvangen, kunt u dit e-mailbericht negeren. invite_mailer: title: "Mailer Uitnodigen" - subject_template: "%{inviter_name} heeft jouw uitgenodigd in '%{topic_title}' op %{site_domain_name}" + subject_template: "%{inviter_name} heeft u uitgenodigd in '%{topic_title}' op %{site_domain_name}" text_body_template: | - %{inviter_name} heeft jouw uitgenodigd bij een discussie + %{inviter_name} heeft u uitgenodigd voor een discussie > **%{topic_title}** > > %{topic_excerpt} - bij + op > %{site_title} -- %{site_description} - Als je geïnteresseerd bent, klik dan op onderstaande link: + Als u geïnteresseerd bent, klik dan op de onderstaande koppeling: %{invite_link} custom_invite_mailer: - subject_template: "%{inviter_name} heeft jouw uitgenodigd in '%{topic_title}' op %{site_domain_name}" + subject_template: "%{inviter_name} heeft u uitgenodigd in '%{topic_title}' op %{site_domain_name}" invite_password_instructions: subject_template: "Stel het wachtwoord in voor uw %{site_name}-account" download_backup_mailer: no_token: | - Sorry, deze backup-downloadlink is al gebruikt of is verlopen. + Sorry, deze back-up-downloadkoppeling is al gebruikt of is verlopen. admin_confirmation_mailer: - subject_template: "[%{email_prefix}] Bevestig nieuw Admin-Account" + subject_template: "[%{email_prefix}] Bevestig nieuwe beheerdersaccount" flag_reasons: off_topic: "Uw bericht is gemarkeerd als **off-topic**: de gemeenschap vindt dat het niet goed bij het onderwerp past, zoals momenteel bepaald door de titel en het eerste bericht." inappropriate: "Uw bericht is gemarkeerd als **ongepast**: de gemeenschap vindt het bericht beledigend, grof, of een schending van [onze gemeenschapsrichtlijnen](%{base_path}/guidelines)." @@ -2146,13 +2168,13 @@ nl: system_messages: private_topic_title: "Topic #%{id}" post_hidden: - title: "Bericht Verborgen" + title: "Bericht verborgen" subject_template: "Bericht verborgen door gemeenschapsmarkeringen" flags_agreed_and_post_deleted: - title: "Gemarkeerd bericht verwijderd door personeel" - subject_template: "Gemarkeerd bericht verwijderd door personeel" + title: "Gemarkeerd bericht verwijderd door staflid" + subject_template: "Gemarkeerd bericht verwijderd door staflid" welcome_user: - title: "Welkom Gebruiker" + title: "Welkom gebruiker" subject_template: "Welkom bij %{site_name}!" text_body_template: | Fijn dat u lid bent geworden van %{site_name}, en welkom! @@ -2163,10 +2185,10 @@ nl: Geniet van uw verblijf! welcome_tl1_user: - title: "Welkom TL1 Gebruiker" - subject_template: "Bedankt dat je tijd met ons doorbrengt" + title: "Welkom TL1-gebruiker" + subject_template: "Bedankt voor de tijd die u met ons doorbrengt" welcome_invite: - title: "Welkom Uitnodiging" + title: "Welkomstuitnodiging" subject_template: "Welkom bij %{site_name}!" backup_succeeded: title: "Back-up geslaagd" @@ -2175,9 +2197,9 @@ nl: title: "Back-up mislukt" subject_template: "Back-up mislukt" text_body_template: | - De back-up heeft gefaald. + De back-up is mislukt. - Hier is de log: + Hier zijn de details: ``` text %{logs} @@ -2608,8 +2630,12 @@ nl: icons: title: "Pictogrammen" fields: + favicon: + label: "Browserpictogram" + description: "Pictogramafbeelding die uw website vertegenwoordigt in webbrowsers en er goed uitziet bij kleine afmetingen. Aanbevolen afbeeldingsextensies zijn PNG of JPG. Standaard wordt het vierkante logo gebruikt." large_icon: label: "Groot pictogram" + description: "Pictogramafbeelding die uw website vertegenwoordigt op moderne apparaten en er goed uitziet bij grotere afmetingen. Moet idealiter groter zijn dan 512 x 512. Standaard wordt het vierkante logo gebruikt." homepage: description: "Het tonen van de nieuwste topics op uw startpagina wordt aanbevolen, maar als u dat liever hebt, kunt u ook categorieën (groepen of topics) op de startpagina tonen." title: "Startpagina" diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index c6924dcdee..b3980a8f05 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -14,7 +14,7 @@ pl_PL: formats: short: "%d.%m.%Y" short_no_year: "%-d %B" - date_only: "%-d %b %Y" + date_only: "%-d %B %Y" long: "%B %-d, %Y, %l:%M%P" no_day: "%B %Y" date: @@ -241,7 +241,14 @@ pl_PL: few: "%{count}odpowiedzi" many: "%{count}odpowiedzi" other: "%{count}odpowiedzi" + likes: + one: "%{count} lajk" + few: "%{count} lajki" + many: "%{count} lajków" + other: "%{count} lajków" + last_reply: "Ostatnia odpowiedź" created: "Utworzono" + new_topic: "Utwórz nowy wątek" no_mentions_allowed: "Przepraszamy, nie możesz odwoływać się do innych użytkowników." too_many_mentions: one: "Przepraszamy, możesz wspomnieć tylko o jednym użytkowniku we wpisie." @@ -529,6 +536,11 @@ pl_PL: cannot_delete: uncategorized: "Ta kategoria ma specjalny charakter. Jest przeznaczona jako miejsce do przechowywania tematów nieprzypisanych do żadnej kategorii i jako taka nie może zostać usunięta." has_subcategories: "Nie można usunąć tej kategorii ponieważ posiada podkategorie. " + topic_exists: + one: "Nie można usunąć tej kategorii, bo zawiera %{count} wątek. Najstarszy wątek to %{topic_link}." + few: "Nie można usunąć tej kategorii, bo zawiera %{count} wątki. Najstarszy wątek to %{topic_link}." + many: "Nie można usunąć tej kategorii, bo zawiera %{count} wątków. Najstarszy wątek to %{topic_link}." + other: "Nie można usunąć tej kategorii, bo zawiera %{count} wątków. Najstarszy wątek to %{topic_link}." topic_exists_no_oldest: "Nie można usunąć tej kategorii z uwagi na liczbę tematów: %{count}." uncategorized_description: "Tematy które nie potrzebują kategorii, albo nie nadają się do żadnej innej." trust_levels: @@ -738,14 +750,23 @@ pl_PL: windows: "Microsoft Windows" unknown: "nieznany system operacyjny" change_email: + wrong_account_error: "Jesteś zalogowany ze złego konta, wyloguj się proszę i spróbuj ponownie." confirmed: "Twój email został zmieniony." please_continue: "Przejdź do %{site_name}" error: "Podczas próby zmiany Twojego adresu email wystąpił błąd. Być może ten adres jest już używany?" error_staged: "Podczas próby zmiany Twojego adresu email wystąpił błąd. Być może ten adres jest już używany?" already_done: "Przepraszamy, ten link aktywujący konto jest już nieważny. Być może Twój mail został zmieniony?" + confirm: "Potwierdź" + authorizing_new: + title: "Potwierdź swój nowy adres e-mail" + description: "Potwierdź zmianę adresu e-mail na:" authorizing_old: - title: "Dziękujemy za potwierdzenie twojego aktualnego adresu e-mail" - description: "Wysyłamy teraz twój nowy adres do potwierdzenia." + title: "Zmień swój adres e-mail" + description: "Potwierdź zmianę adresu e-mail" + old_email: "Stary adres e-mail: %{email}" + new_email: "Nowy adres e-mail: %{email}" + almost_done_title: "Potwierdzanie nowego adresu e-mail" + almost_done_description: "Wysłaliśmy e-maila na nowy adres, by potwierdzić tę zmianę!" associated_accounts: connected: "(połączony)" activation: @@ -1314,14 +1335,17 @@ pl_PL: enable_instagram_logins: "Włącz uwierzytelnienie za pomocą Instagramu, wymaga instagram_consumer_key i instagram_consumer_secret." instagram_consumer_key: "Klucz konsumenta dla uwierzytelnienia Instragram" instagram_consumer_secret: "Sekret konsumenta dla uwierzytelnienia Instragram" + discord_secret: "Sekretny klucz Discorda" readonly_mode_during_backup: "Włącz tryb tylko do odczytu podczas wykonywania kopii zapasowej" allow_restore: "Dopuść przywracanie, które może zamienić WSZYSTKIE dane strony! Zostaw fałsz, chyba że planujesz przywrócić kopię zapasową" maximum_backups: "Maksymalna liczba kopii zapasowych do przechowywania na dysku. Starsze kopie zapasowe zostaną automatycznie usunięte." automatic_backups_enabled: "Uruchom automatyczne kopie zapasowe zgodnie z ustawioną częstotliwością kopii" + backup_frequency: "Liczba dni pomiędzy tworzeniem kopii zapasowych." s3_backup_bucket: "Zdalne wiadro do przechowywania kopii zapasowych. UWAGA: Upewnij się, że jest to wiadro prywatne." s3_disable_cleanup: "Dezaktywuj usuwanie kopii zapasowych z S3 kiedy usunięte lokalnie." backup_time_of_day: "Godzina (UTC) wykonania kopii zapasowej." backup_with_uploads: "Uwzględniaj przesyły w zaplanowanych backupach. Wyłączenie tej opcji spowoduje backup jedynie bazy danych." + backup_gzip_compression_level_for_uploads: "Poziom kompresji gzip używany przy kompresowaniu wgranych plików." active_user_rate_limit_secs: "How frequently we update the 'last_seen_at' field, in seconds" verbose_localization: "Wyświetlaj dodatkowe identyfikatory tłumaczeń w treści etykiet" previous_visit_timeout_hours: "How long a visit lasts before we consider it the 'previous' visit, in hours" @@ -1329,6 +1353,7 @@ pl_PL: top_topics_formula_first_post_likes_multiplier: "wartość mnożnika polubień pierwszego posta (n) w tematach formuła: `log(views_count) * 2 + op_likes_count * (n) + LEAST(likes_count / posts_count, 3) + 10 + log(posts_count)`" top_topics_formula_least_likes_per_post_multiplier: "wartość mnożnika najmniejszej liczby polubień na post w (n) tematach formuła: `log(views_count) * 2 + op_likes_count * 0.5 + LEAST(likes_count / posts_count, (n)) + 10 + log(posts_count)`" rebake_old_posts_count: "Liczba starych postów, które są ponownie wypalane co 15 minut." + enable_safe_mode: "Zezwól użytkownikom na wchodzenie do trybu awaryjnego w celu debudowania wtyczek." rate_limit_create_topic: "Po otworzeniu tematu użytkownicy muszą odczekać (n) sekund, zanim otworzą inny temat." rate_limit_create_post: "Po napisaniu posta użytkownicy muszą odczekać (n) sekund, zanim napiszą inny post." rate_limit_new_user_create_topic: "Po otworzeniu tematu nowi użytkownicy muszą odczekać (n) sekund, zanim otworzą inny temat." @@ -1343,21 +1368,27 @@ pl_PL: max_topic_invitations_per_day: "Maksymalna dzienna liczba zaproszeń do tematu, jakie może wysłać użytkownik." max_logins_per_ip_per_hour: "Maksymalna liczba logowań dozwolona per adres IP na godzinę" max_logins_per_ip_per_minute: "Maksymalna liczba logowań dozwolona per adres IP na minutę" + max_post_deletions_per_minute: "Maksymalna liczba wpisów, które użytkownik może usunąć w minutę." + max_post_deletions_per_day: "Maksymalna liczba wpisów, które użytkownik może usunąć jednego dnia." alert_admins_if_errors_per_minute: "Liczba błędów na minutę do zaalarmowania admina. Wartość 0 wyłącza tę funkcjonalność. UWAGA: wymaga restartu." alert_admins_if_errors_per_hour: "Liczba błędów na godzinę do zaalarmowania admina. Wartość 0 wyłącza tę funkcjonalność. UWAGA: wymaga restartu." categories_topics: "Liczba wątków do wyświetlenia na stronie /categories." suggested_topics: "Liczba sugerowanych tematów widocznych na końcu aktualnego tematu." limit_suggested_to_category: "Sugeruj tematy jedynie z tej samej kategorii." suggested_topics_max_days_old: "Sugerowane tematy nie powinny być starsze niż n dni." + suggested_topics_unread_max_days_old: "Sugerowane nieprzeczytane tematy nie powinny być starsze niż n dni." clean_up_uploads: "Usuń osierocone pliki aby zapobiec wykorzystywaniu forum jako hosting. UWAGA: przed włączeniem tej opcji zaleca się wykonanie kopii katalogu /uploads." clean_orphan_uploads_grace_period_hours: "Okres karencji (w dniach) przed wysłaniem sierot zostanie skasowany." purge_deleted_uploads_grace_period_days: "Okres karencji (w dniach) przed usunięciem upload zostanie skasowany." + purge_unactivated_users_grace_period_days: "Okres karencji w dniach, po upływie którego nieaktywowane konto użytkownika zostaje usunięte. Ustaw 0, by nigdy nie usuwań nieaktywnych użytkowników." enable_s3_uploads: "Umieść przesyły w pamięci Amazon S3. Ważne: wymaga ważnych danych uwierzytelniających (zarówno klucza id i tajnego klucza dostępu)" s3_upload_bucket: "Nazwa koszyka Amazon S3, do którego zostaną przesłane pliki. Ostrzeżenie: bez wielkich liter, kropek czy podkreślenia." s3_cdn_url: "URL CDN używany dla wszystkich aktywów s3 (na przykład : https://cdn.somewhere.com). Ostrzeżenie: po zmianie tego ustawienia musisz ponownie przywrócić stare posty." avatar_sizes: "Lista automatycznie wygenerowanych rozmiarów awatarów." external_system_avatars_enabled: "Użyj zewnętrzny system awatarów." external_system_avatars_url: "Adres URL zewnętrznego dostawcy awatarów. Dozwolone podstawienia: {username} {first_letter} {color} {size}" + selectable_avatars_enabled: "Wymuś na użytkownikach wybieranie awatara z listy." + selectable_avatars: "Lista awatarów, z których mogą wybierać użytkownicy." allow_all_attachments_for_group_messages: "Zezwól na wszystkie załączniki email dla wiadomości grupowych." png_to_jpg_quality: "Jakość skonwertowanego pliku JPG (1 to najniższa jakość, 99 to najlepsza jakość, 100 aby dezaktywować)." allow_staff_to_upload_any_file_in_pm: "Pozwól personelowi przesyłać pliki w wiadomościach." @@ -1390,9 +1421,13 @@ pl_PL: tl3_requires_likes_received: "Minimalna liczba polubień, które użytkownik musi otrzymać w ostatnich (tl3 okres) dniach, aby zakwalifikować się do awansu na poziom zaufania 3." tl3_links_no_follow: "Nie usuwaj rel=nofollow z linków od użytkowników z trzecim poziomem zaufania." min_trust_to_create_topic: "The minimum trust level required to create a new topic." + allow_flagging_staff: "Jeżeli włączone, użytkownicy mogą flagować wpisy członków zespołu." min_trust_to_edit_wiki_post: "Minimalny poziom zaufania wymagany do edycji posta oznaczonego jako wiki." min_trust_to_edit_post: "Minimalny poziom zaufania potrzebny do edytowania postów." min_trust_to_allow_self_wiki: "Wymagany poziom zaufania, by post użytkownika uczynić wiki." + min_trust_to_send_messages: "Minimalny poziom zaufania wymagany do tworzenia wiadomości prywatnych." + min_trust_to_flag_posts: "Minimalny poziom zaufania potrzebny do flagowania postów" + min_trust_to_post_links: "Minimalny poziom zaufania potrzebny do umieszczania linków we wpisach" newuser_max_links: "Ile linków może dodać nowy użytkownik do posta." newuser_max_images: "Ile obrazów może dodać nowy użytkownik do posta." newuser_max_attachments: "Ile załączników może dodać nowy użytkownik do posta." @@ -1400,8 +1435,10 @@ pl_PL: newuser_max_replies_per_topic: "Maksymalna liczba odpowiedzi, jakie nowy użytkownik może dodać w pojedynczym temacie zanim ktoś odpowie." max_mentions_per_post: "Maksymalna liczba powiadomień poprzez @nazwę w jednym wpisie (dla wszystkich)." max_users_notified_per_group_mention: "Maksymalna liczba użytkowników, którzy mogą otrzymać powiadomienie jeśli ktoś wspomniał o grupie (jeśli próg został osiągnięty, nie będzie żadnych powiadomień)" + enable_mentions: "Zezwól użytkownikom na wzmianki innych użytkowników." create_thumbnails: "Stwórz miniatury i obrazy lightbox, które są za duże, aby pasować do postu." email_time_window_mins: "Odczekaj (n) minut przed wysłaniem maila z powiadomieniem, aby dać użytkownikom szansę na edytowanie i ukończenie postów." + personal_email_time_window_seconds: "Poczekaj (n) sekund przed wysłaniem powiadomienia na e-mail o prywatnej wiadomości, by użytkownicy mogli edytować i dokończyć swoje wiadomości." email_posts_context: "Jak wiele poprzednich wiadomości zawrzeć jako kontekst i emailu powiadamiającym." flush_timings_secs: "W sekundach, jak często wysyłamy dane czasowe na serwer." title_max_word_length: "Maksymalna dozwolona długość słowa, w znakach, jako tytuł tematu." @@ -1482,6 +1519,7 @@ pl_PL: pop3_polling_username: "Nazwa użytkownika dla konta POP3 do przeglądania cyklicznego emaila." pop3_polling_password: "Hasło dla konta POP3 do przeglądania cyklicznego maila." email_in_min_trust: "Minimalny poziom zaufania, który musi posiadać użytkownik, aby móc dodawać nowe posty w tematach poprzez email." + email_in_spam_header: "Nagłówek e-maila do wykrywania spamu." email_prefix: "Etykieta używana w temacie emaili. Domyślnie będzie ustawiana jako \"tytuł\", w przypadku braku ustawienia." email_site_title: "Tytuł strony używany jako nadawca emaili ze strony. Domyślnie ustawiony jako \"tytuł\", w przypadku braku ustawienia. Jeśli twój \"tytuł\" zawiera znaki, które nie są dozwolone przez ciągi znaków nadawcy emaila, użyj tego ustawienia." find_related_post_with_key: "Używaj \"klucza odpowiedzi\" tylko po to, aby znaleźć post, na który odpowiadasz. Ostrzeżenie: Wyłączenie tej funkcji, pozwoli na personifikację użytkownika na podstawie adresu email." @@ -1692,7 +1730,7 @@ pl_PL: autoclosed_disabled_lastpost: "Temat został otwarty. Pisanie odpowiedzi jest możliwe." auto_deleted_by_timer: "Automatycznie usunięte przez regulator czasowy." login: - security_key_alternative: "Nie możesz znaleźć swojego klucza bezpieczeństwa lub chcesz użyć innej metody?" + security_key_alternative: "Spróbuj w inny sposób" security_key_authenticate: "Uwierzytelnij się za pomocą klucza bezpieczeństwa" security_key_not_allowed_error: "Upłynął limit czasu procesu uwierzytelniania klucza bezpieczeństwa lub został on anulowany." not_approved: "Twoje konto nie zostało jeszcze aktywowane. Zostaniesz powiadomiony emailem gdy będziesz mógł się zalogować." @@ -1752,6 +1790,17 @@ pl_PL: max_new_accounts_per_registration_ip: "Z twojego adresu IP nowe rejestracje nie są możliwe (wyczerpany limit). Skonaktuj się z personelem." website: domain_not_allowed: "Błędny adres strony internetowej. Dozwolone domeny: %{domains}" + destroy_reasons: + unused_staged_user: "Nieużywany użytkownik wystawiony" + fixed_primary_email: "Ustalony główny adres e-mail wystawionego użytkownika" + same_ip_address: "Taki sam adres IP (%{ip_address}) jak inni użytkownicy" + inactive_user: "Nieaktywny użytkownik" + reviewables_reminder: + subject_template: + one: "%{count} wpis czeka na przejrzenie" + few: "%{count} wpisy czekają na przejrzenie" + many: "%{count} wpisów czeka na przejrzenie" + other: "%{count} wpisów czeka na przejrzenie" unsubscribe_mailer: title: "Przestań obserwować mailera" subject_template: "Potwierdź, że nie chcesz już otrzymywać powiadomień mailowych ze strony %{site_title}" @@ -2310,19 +2359,9 @@ pl_PL: confirm_new_email: title: "Potwierdź Nowy Email" subject_template: "[%{email_prefix}] Potwierdź swój nowy adres email" - text_body_template: | - Potwierdź swój nowy adres email dla %{site_name} poprzez kliknięcia na następujący link: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Potwierdź stary email" subject_template: "[%{site_name}] Potwierdź aktualny adres email" - text_body_template: | - Przed zmianą twojego adresu email, potrzebujemy, abyś potwierdził, że masz dostęp do tego adresu. Po zakończeniu tego kroku, poinformujemy cię o nowym adresie email. - - Potwierdź obecny adres email dla %{site_name}poprzez naciśnięcie na następujący link: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Powiadom Stary Email" subject_template: "[%{site_name}] Twój adres email został zmieniony" @@ -2934,6 +2973,9 @@ pl_PL: popup: confirm_title: "Powiadomienia włączone - %{site_title}" confirm_body: "Powodzenie! Powiadomienia zostały włączone." + staff_action_logs: + api_key: + revoked: Unieważniono reviewables: priorities: low: "Niski" diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index 2bd5e7075f..f8a9da6c72 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -496,9 +496,6 @@ pt: error: "Ocorreu um erro ao alterar o seu endereço de email. Talvez o endereço já esteja a ser utilizado?" error_staged: "Ocorreu um erro ao alterar o seu endereço de email. O endereço de email já está a ser utilizado por um utilizador temporário." already_done: "Pedimos desculpa, mas esta hiperligação de confirmação já não é valida. Talvez o seu email já tenha sido alterado?" - authorizing_old: - title: "Obrigado por confirmar o seu endereço de email actual." - description: "Estamos a enviar um email para o seu novo endereço para confirmação." activation: action: "Clique aqui para ativar a sua conta" already_done: "Pedimos desculpa, esta hiperligação de confirmação já não está válida. Talvez a sua conta já esteja ativa?" @@ -884,10 +881,10 @@ pt: 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 usada para solicitação ao servidor (quando um CDN serve conteúdo dinâmico, certifique-se de configurá-lo para a 'pull' original) ex: http://origem.sítio.com" + 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: "Quantidade de tempo que um servidor deve esperar antes de notificar os clientes quando não há dados para serem enviados (apenas utilizadores ligados)" polling_interval: "Quando não está a ocorrer uma solicitação ao servidor, com que frequência devem os clientes ligados requerer uma atualização, em milissegundos" - anon_polling_interval: "Com que frequência os clientes não registados podem fazer solicitações ao servidor, em milisegundos" + anon_polling_interval: "Com que frequência os clientes anónimos podem sondar o servidor em milisegundos" background_polling_interval: "Com que frequência deverão os clientes solicitar o servidor, em milissegundos (quando a janela está em plano de fundo)" cooldown_minutes_after_hiding_posts: "Número de minutos que o utilizador deve esperar antes de poder editar uma mensagem oculta devido a sinalizações por parte da comunidade" max_topics_in_first_day: "O número máximo de tópicos que o utilizador pode criar no período de 24 horas após criar a sua primeira publicação" @@ -1101,10 +1098,10 @@ pt: pop3_polling_ssl: "Utilize SSL ao ligar a um servidor POP3. (Recomendado)" pop3_polling_openssl_verify: "Verificar o certificado de TLS do server. (Por defeito: activado)" pop3_polling_period_mins: "Período em minutos entre a verificação da conta POP3 para o email. NOTA: requer reinicialização." - pop3_polling_port: "Porto para resgatar uma conta POP3." + pop3_polling_port: "A porta para obter uma conta POP3." pop3_polling_host: "Servidor para solicitações de email via POP3." - pop3_polling_username: "Nome de utilizador para a conta POP3 para resgatar emails." - pop3_polling_password: "Palavra-passe para a conta POP3 solicitar emails ao servidor." + pop3_polling_username: "O nome de utilizador para a conta POP3 para obter e-mail." + pop3_polling_password: "A palavra-passe para a conta POP3 obter e-mail." email_in_min_trust: "Nível de Confiança mínimo que um utilizador necessita de ter para poder publicar novos tópicos por email." email_prefix: "A [etiqueta] usada no assunto dos emails. Se não estiver configurada, será 'Título' por defeito." email_site_title: "Título do sítio usado como remetente de emails. Se não for configurado, será 'título' por defeito. Se o seu 'título' contém caracteres que não são permitidos na string do remetente de email, utilize esta configuração." @@ -1219,8 +1216,8 @@ pt: invalid_string_max: "Não deve ser mais que %{max} caracteres." invalid_reply_by_email_address: "O valor deve conter '%{reply_key}' e ser diferente do email de notificação." invalid_alternative_reply_by_email_addresses: "Todos os valores devem conter '%{reply_key}' e ser diferentes do email de notificação." - pop3_polling_host_is_empty: "Deve configurar um 'pop3 polling host' antes de ativar o polling POP3." - pop3_polling_username_is_empty: "Deve configurar um 'nome de utilizador de polling pop3' antes de ativar o polling POP3." + pop3_polling_host_is_empty: "Deve definir um 'anfitrião de obtenção pop3' antes de ativar a obtenção POP3." + pop3_polling_username_is_empty: "Deve definir um 'nome de utilizador de sondagem pop3' antes de ativar a sondagem POP3." pop3_polling_password_is_empty: "Deve configurar uma 'palavra-passe de polling pop3' antes de ativar o polling POP3." pop3_polling_authentication_failed: "Autenticação POP3 falhada. Por favor verifique as suas credenciais pop3." reply_by_email_address_is_empty: "É necessário definir um email para 'resposta por endereço de email' antes de ligar a resposta por email." diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index b0829d3a2b..218119be28 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -699,9 +699,6 @@ pt_BR: error: "Houve um erro ao alterar o seu endereço de email. Talvez o endereço já esteja sendo utilizado?" error_staged: "Houve um erro ao alterar o seu endereço de email. O endereço já está usado por outro usuário que ainda não confirmou seu email." already_done: "Desculpe, este link de confirmação não está mais válido. Talvez o seu e-mail já foi alterado?" - authorizing_old: - title: "Obrigado por confirmar o seu endereço de e-mail atual." - description: "Estamos enviando um e-mail ao seu novo endereço para confirmação." associated_accounts: revoke_failed: "Falha ao revogar sua conta com %{provider_name}." connected: "(conectado)" @@ -1335,7 +1332,6 @@ pt_BR: content_security_policy: "Habilitar Content-Security-Policy" content_security_policy_report_only: "Habilitar Content-Security-Policy-Report-Only" content_security_policy_collect_reports: "Habilitar a coleta de relatórios de violação de CSP em /csp_reports" - content_security_policy_script_src: "Origens adicionais de script na lista de permissões. O host atual e o CDN são incluídos por padrão." invalidate_inactive_admin_email_after_days: "As contas de administrador que não visitaram o site neste número de dias precisarão validar novamente seu endereço de e-mail antes de efetuar login. Defina como 0 para desabilitar." top_menu: "Determina quais items aparecem na navegação da homepage, e em qual ordem. Exemplo latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Determine quais itens aparecem no menu de postagem e em qual ordem. Exemplo como | editar | sinalizar | excluir | compartilhar | marcador | resposta" @@ -1690,8 +1686,6 @@ pt_BR: permalink_normalizations: "Aplique o seguinte regex antes de combinar permalinks, por exemplo: /(topic.*)\\?.*/\\1 irá retirar as strings de consulta das rotas de tópicos. Formato é regex + string use \\ 1 etc. para acessar capturas" global_notice: "Exibir um aviso de banner global URGENTE, EMERGENCIAL e não descartável para todos os visitantes, deixe em branco para ocultá-lo (permitido por HTML)." disable_system_edit_notifications: "Desabilitar modificação de notificações pelo sistema quando 'download_remote_images_to_local' estiver ativado." - likes_notification_consolidation_threshold: "Número de notificações de curtidas recebidas antes que as notificações sejam consolidadas em uma única. Defina como 0 para desabilitar. A janela pode ser configurada via `SiteSetting.likes_notification_consolidation_window_mins`." - likes_notification_consolidation_window_mins: "Duração em minutos em que as notificações de curtidas são consolidadas em uma única notificação quando o limite é atingido. O limite pode ser configurado via `SiteSetting.likes_notification_consolidation_threshold`." automatically_unpin_topics: "Desafixar automaticamente os tópicos quando o usuário atinge o fundo." read_time_word_count: "Palavras por minuto para calcular o tempo estimado de leitura." native_app_install_banner_ios: "Exibir o banner do aplicativo DiscourseHub em dispositivos iOS para usuários regulares (nível de confiança 1 e superior)." @@ -2923,14 +2917,9 @@ pt_BR: confirm_new_email: title: "Confirmar novo e-mail" subject_template: "[%{email_prefix}] Confirme seu novo endereço de e-mail" - text_body_template: | - Confirme seu novo endereço de e-mail para %{site_name} clicando no seguinte link: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Confirmar Antigo e-mail" subject_template: "[%{email_prefix}] Confirme seu endereço de e-mail atual" - text_body_template: "Antes que possamos alterar seu endereço de e-mail, precisamos que você confirme que você controla a conta de e-mail atual. \nDepois de concluir esta etapa, você terá que confirmar\no novo endereço de e-mail.\n\nConfirme seu endereço de e-mail atual para %{site_name} clicando no seguinte link:\n\n%{base_url}/u/authorize-email/%{email_token}\n" notify_old_email: title: "Notificar e-mail antigo" subject_template: "[%{email_prefix}] Seu endereço de e-mail foi alterado" diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index 8f3ed1b9de..02260c5426 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -456,9 +456,6 @@ ro: error: "A apărut o eroare la schimbarea adresei de email. Poate adresa e deja folosită?" error_staged: "A apărut o eroare la schimbarea adresei tale de email. Adresa este deja folosită de un utilizator în așteptare." already_done: "Ne pare rău, dar acest link de confirmare nu mai este valid. Poate că emailul tău a fost deja schimbat?" - authorizing_old: - title: "Îți mulțumim că ne-ai confirmat noua ta adresă de email." - description: "Acum îți trimitem un email la noua ta adresă, pentru confirmare." activation: action: "Click aici pentru a-ți activa contul" already_done: "Ne pare rău, această adresă pentru confirmarea contului nu mai este valabilă. Poate contul tău este deja activ?" diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index 432daac499..9e334c6129 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -749,9 +749,6 @@ ru: error: "При смене электронного адреса произошла ошибка. Возможно, этот адрес уже используется?" error_staged: "При смене электронного адреса произошла ошибка. Этот адрес уже используется другим пользователем." already_done: "Извините, ссылка для подтверждения устарела. Возможно, ваш email уже изменен?" - authorizing_old: - title: "Спасибо за подтверждение вашего email-адреса" - description: "Мы отправили сообщение с ссылкой для подтверждения вашего нового адреса." associated_accounts: revoke_failed: "Не удалось отозвать учетную запись %{provider_name}." connected: "(связанный)" @@ -1755,7 +1752,7 @@ ru: auto_deleted_by_timer: "Автоматически удалить по таймеру." login: security_key_description: "Когда вы подготовите свой физический ключ безопасности, нажмите кнопку Аутентификация с ключом безопасности ниже." - security_key_alternative: "Не удается найти ключ безопасности или хотите использовать другой метод?" + security_key_alternative: "Попробуйте другой способ" security_key_authenticate: "Аутентификация с Ключом Безопасности." security_key_not_allowed_error: "Время проверки подлинности ключа безопасности истекло или было отменено." security_key_no_matching_credential_error: "В указанном ключе безопасности не найдено подходящих учетных данных." @@ -2214,19 +2211,6 @@ ru: subject_template: "[%{email_prefix}] Ваш Новый Аккаунт" confirm_new_email: title: "Подтвердить Новый E-mail" - text_body_template: | - Подтвердите ваш новый адрес e-mail почты для %{site_name} нажав на следующую ссылку: - - %{base_url}/u/authorize-email/%{email_token} - confirm_old_email: - text_body_template: | - Прежде чем мы сможем изменить ваш адрес электронной почты, нам нужно, чтобы вы подтвердили, что вы контролируете - текущая учетная запись e-mail почты. После выполнения этого шага мы попросим вас подтвердить - новый адрес e-mail почты. - - Подтвердите свой текущий адрес e-mail почты для %{site_name} нажав на следующую ссылку: - - %{base_url}/u/authorize-email/%{email_token} signup_after_approval: title: "Регистрация После Утверждения" subject_template: "Ваша учетная запись на сайте %{site_name} одобрена!" @@ -2744,6 +2728,8 @@ ru: unknown: "неизвестно" user_merged: "%{username} был объединен с этой учетной записью" user_delete_self: "Удалено самостоятельно из %{url}" + api_key: + revoked: Отозвать reviewables: priorities: low: "Низкий" diff --git a/config/locales/server.sl.yml b/config/locales/server.sl.yml index 6a5711a141..5ac34f6343 100644 --- a/config/locales/server.sl.yml +++ b/config/locales/server.sl.yml @@ -508,9 +508,6 @@ sl: error: "Prišlo je do napake med spremembo vašega e-naslova. Mogoče je e-naslov že uporabljen?" error_staged: "Prišlo je do napake pri menjavi vašega e-naslova. E-naslov je že v uporabi s strani prirejenega uporabnika." already_done: "Oprostite, vendar povezava za potrditev ni več veljavna. Mogoče je bil vaš e-naslov že spremenjen?" - authorizing_old: - title: "Hvala za potrditev vašega trenutnega e-naslova" - description: "Na nov e-naslov smo vam poslali povezavo za potrditev." associated_accounts: revoke_failed: "Nismo uspeli razveljaviti vaš račun pri %{provider_name}." activation: @@ -1656,19 +1653,9 @@ sl: confirm_new_email: title: "Potrdite nov e-naslov" subject_template: "[%{email_prefix}] Potrdite vaš novi e-naslov" - text_body_template: | - Potrdite vaš nov e-naslov pri %{site_name} tako da sledite povezavi: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Potrdite star e-naslov" subject_template: "[%{email_prefix}] Potrdite vaš trenutni e-naslov" - text_body_template: | - Preden lahko spremenite vaš e-naslov, morate potrditi da imate nadzor nad vašim trenutnim e-naslovom. Po tem pa vam bomo omogočili potrditev novega e-naslova. - - Potrdite vaš trenutni e-naslov pri %{site_name} tako da sledite povezavi: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "Obvesti stari e-naslov" subject_template: "[%{email_prefix}] Vaš e-naslov je bil spremenjen" diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml index 6130e9b8ad..b7ee69b39d 100644 --- a/config/locales/server.sq.yml +++ b/config/locales/server.sq.yml @@ -328,9 +328,6 @@ sq: please_continue: "Vazhdo tek %{site_name}" error: "Hasëm një gabim gjatë ndryshimit të adresës email. Mos vallë është në përdorim nga një llogari tjetër në faqe?" already_done: "Na vjen keq, ky link nuk vlen më. Mbase adresa juaj email u ndryshua para pak kohësh?" - authorizing_old: - title: "Ju falënderojmë për konfirmimin e adresës tuaj email" - description: "Tani po ju dërgojmë një mesazh tek adresa e re e emailit për t'a konfirmuar." activation: action: "Klikoni këtu për të aktivizuar llogarinë tuaj" already_done: "Na vjen keq, ky link nuk vlen më. Mbase llogaria është që tani e aktivizuar?" @@ -1160,10 +1157,6 @@ sq: title: "Vendos Fjalëkalim" confirm_new_email: subject_template: "[%{email_prefix}] Konfirmoni adresën tuaj të re të emailit" - text_body_template: | - Konfirmoni adresën tuaj të re të emailit për "%{site_name}" duke klikuar linkun më poshtë: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: subject_template: "[%{email_prefix}] Konfirmoni adresën e tanishme të emailit " notify_old_email: diff --git a/config/locales/server.sr.yml b/config/locales/server.sr.yml index 0841a18caf..ce0eb68dff 100644 --- a/config/locales/server.sr.yml +++ b/config/locales/server.sr.yml @@ -124,9 +124,6 @@ sr: password_reset: save: "Postavi Šifru" title: "Resetujte Šifru" - change_email: - authorizing_old: - title: "Hvala što ste potvrdili svoju važeću email adresu" activation: welcome_to: "Dobrodošao na %{site_name}!" reviewable_score_types: diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index cb7156fc47..57185e581f 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -407,9 +407,6 @@ sv: error: "Det uppstod ett fel med ändringen av din e-postadress. Adressen kanske redan används?" error_staged: "Ett problem uppstod vid ändring av din e-postadress. Adressen används redan av en annan användare. " already_done: "Tyvärr har den här aktiveringslänken löpt ut. Kanske är din e-postadress redan ändrad? " - authorizing_old: - title: "Tack för att du bekräftar din nuvarande e-postadress" - description: "Vi skickar nu e-post för bekräftelse till din nya adress " activation: action: "Klicka här för att aktivera ditt konto" already_done: "Tyvärr, denna kontoaktiveringslänk är inte längre giltig. Kanske är ditt konto redan aktiverat?" diff --git a/config/locales/server.sw.yml b/config/locales/server.sw.yml index eda4995399..b7c324730e 100644 --- a/config/locales/server.sw.yml +++ b/config/locales/server.sw.yml @@ -409,9 +409,6 @@ sw: error: "Hitilafu imetokea wakati wa kubadilisha barua pepe yako. Labda barua pepe tayari inatumika?" error_staged: "Hitilafu imetokea wakati wa kubadilisha barua pepe yako. Barua pepe inatumika na mtumiaji aliyekuwa staged." already_done: "Samahani, kiungo cha uthibitisho cha akaunti ni batili. Labda barua pepe yako imeshabadilishwa??" - authorizing_old: - title: "Asante kwa kuthibitisha barua pepe yako" - description: "Tunakutumia barua pepe mpya ya kuthibitisha." activation: action: "Bonyeza hapa kuanzisha akkaunti yako" already_done: "Samahani, kiungo cha uthibitisho cha akaunti ni batili. Labda akaunti yako tayari ipo hewani?" @@ -1723,21 +1720,9 @@ sw: confirm_new_email: title: "Thibitisha Barua Pepe Mpya" subject_template: "[%{email_prefix}] Thibitisha barua pepe mpya" - text_body_template: | - Thibitisha barua pepe yako kwenye %{site_name} kwa kubofya kiungo kifuatacho: - - %{base_url}/u/authorize-baruapepe/%{email_token} confirm_old_email: title: "Thibitisha Barua pepe ya Zamani" subject_template: "[%{email_prefix}] Thibitisha barua pepe ya sasa" - text_body_template: | - Kabla hatujabadilisha barua pepe yako, tunahitaji kuthibitisha kuwa unamiliki - barua pepe yako ya sasa. Baada ya kumaliza hatua hii, tutathibitisha - barua pepe yako mpya. - - Thibitisha barua pepe yako ya sasa kwenye %{site_name} kwa kubonyeza kiungo kifuatacho: - - %{base_url}/u/authorize-barua pepe/%{email_token} notify_old_email: title: "Ijulishe Barua Pepe ya Zamani" subject_template: "[%{email_prefix}] Barua pepe yako imebadilishwa" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 8e1355ae12..d32a0e5c4e 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -386,9 +386,6 @@ tr_TR: error: "E-posta adresiniz değiştirilirken bir hata oluştu. Bu adres zaten kullanımda olabilir." error_staged: "E-posta adresiniz değiştirilirken bir hata oluştu. Bu adres zaten başka bir kullanıcı tarafından kullanımdadır." already_done: "Üzgünüz, bu doğrulama bağlantısı geçerli değil. E-posta adresiniz değiştirilmiş olabilir mi?" - authorizing_old: - title: "Geçerli e-posta adresi onayladığınız için teşekkür ederiz" - description: "Doğrulama için yeni adresine şimdi e-posta gönderiyoruz." activation: action: "Hesabınızı etkinleştirmek için buraya tıklayın" already_done: "Üzgünüz, hesap doğrulama bağlantısı artık geçerli değil. Hesabınız zaten etkin olabilir mi?" @@ -1073,6 +1070,12 @@ tr_TR: autoclosed_disabled: "Konu şimdi açıldı. Yeni cevaplara izin var." autoclosed_disabled_lastpost: "Konu şimdi açıldı. Yeni cevaplara izin var." login: + security_key_description: "Fiziksel güvenlik anahtarınızı hazırladığınızda, aşağıdaki Güvenlik Anahtarıyla Kimlik Doğrula düğmesine basın." + security_key_alternative: "Başka bir yol dene" + security_key_authenticate: "Güvenlik Anahtarı ile Kimlik Doğrulama" + security_key_not_allowed_error: "Güvenlik anahtarı kimlik doğrulama işlemi zaman aşımına uğradı veya iptal edildi." + security_key_no_matching_credential_error: "Sağlanan güvenlik anahtarında eşleşen kimlik bilgisi bulunamadı." + security_key_support_missing_error: "Geçerli cihazınız veya tarayıcınız güvenlik tuşlarının kullanımını desteklemiyor. Lütfen farklı bir yöntem kullanın." not_approved: "Hesabını henüz onaylanmadı. Giriş yapmak için hazır olduğunuzda e-posta ile bilgilendirileceksiniz." incorrect_username_email_or_password: "Yanlış kullanıcı adı, e-posta ya da parola" wait_approval: "Kayıt olduğunuz için teşekkürler. Hesabınız onaylandığında sizi haberdar edeceğiz." @@ -1094,6 +1097,9 @@ tr_TR: missing_user_field: "Kullanıcı alanlarının tamamını doldurmadınız" second_factor_title: "İki Faktörlü Kimlik Doğrulama" second_factor_backup_description: "Lütfen yedek kodlarından birini gir:" + second_factor_toggle: + totp: "Bunun yerine bir doğrulama uygulaması kullanın" + backup_code: "Bunun yerine bir yedekleme kodu kullanın" admin: email: sent_test: "gönderildi!" @@ -1695,6 +1701,9 @@ tr_TR: popup: confirm_title: "Bildirimler etkin - %{site_title}" confirm_body: "Başarılı! Bildirimler etkinleştirildi." + staff_action_logs: + api_key: + revoked: İptal edilmiş reviewables: priorities: low: "Düşük" diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index 25504ee025..6e6a596d4c 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -172,10 +172,10 @@ uk: other: "Ви вказали недійсні варіанти %{name}" default_categories_already_selected: "Ви не можете вибрати категорію, що використовується в іншому списку." default_tags_already_selected: "Ви не можете вибрати тег, який використовується в іншому списку." - s3_upload_bucket_is_required: "Ви не можете ввімкнути завантаження на S3, якщо ви не надали "s3_upload_bucket"." + s3_upload_bucket_is_required: "Ви не можете ввімкнути завантаження на S3, якщо ви не надали 's3_upload_bucket'." enable_s3_uploads_is_required: "Ви не можете включити інвентар до S3, якщо ви не включили завантаження S3." - s3_backup_requires_s3_settings: "Ви не можете використовувати S3 як резервне розташування, якщо ви не вказали "%{setting_name}"." - s3_bucket_reused: "Не можна використовувати одне й те саме bucket для 's3_upload_bucket' та 's3_backup_bucket'. Виберіть інше bucket або використовуйте інший шлях для кожного bucket." + s3_backup_requires_s3_settings: "Ви не можете використовувати S3 як резервне розташування, якщо ви не вказали '%{setting_name}'." + s3_bucket_reused: "Не можна використовувати одне й те саме bucket для 's3_upload_bucket' та 's3_backup_bucket'. Виберіть інше bucket або використовуйте інший шлях для кожного bucket." conflicting_google_user_id: 'Ідентифікатор облікового запису Google для цього облікового запису змінився; втручання персоналу потрібно з міркувань безпеки. Будь ласка, зв''яжіться з персоналом і вкажіть
https://meta.discourse.org/t/76575' activemodel: errors: @@ -512,7 +512,7 @@ uk: translation_overrides: attributes: value: - invalid_interpolation_keys: 'Наступні інтерполяційні ключі недійсні: "%{keys}"' + invalid_interpolation_keys: 'Наступні інтерполяційні ключі недійсні: "%{keys}"' watched_word: attributes: word: @@ -767,9 +767,6 @@ uk: error: "Під час зміни адреси Вашої електронної скриньки трапилася помилка. Можливо, ця адреса вже використовується?" error_staged: "Під час зміни вашої електронної адреси сталася помилка. Адреса вже використовується іншим користувачем." already_done: "На жаль, це підтвердження більше не дійсне. Можливо, вашу електронну пошту вже змінено?" - authorizing_old: - title: "Дякую за підтвердження вашої email-адреси" - description: "Ми відправили повідомлення з посиланням для підтвердження вашої нової адреси." associated_accounts: revoke_failed: "Не вдалося від’єднати від облікового запису %{provider_name}." connected: "(пов’язаний)" @@ -784,6 +781,7 @@ uk: activated: "Вибачте, цей обліковий запис уже активовано." admin_confirm: title: "Підтвердіть Обліковий запис Адміністратора" + description: "Ви впевнені, що хочете, щоб %{target_username} (%{target_email}) став адміністратором?" grant: "Надати Доступ Адміністратора" complete: "%{target_username} зараз є адміністратором." back_to: "Повернутися до %{title}" @@ -931,8 +929,8 @@ uk: read: "читання" read_write: "читання/запис" description: '"%{application_name}" запитує наступний доступ до вашого облікового запису:' - instructions: 'Ми щойно створили для вас новий ключ API для користування "%{application_name}", вставте наступний ключ у свою програму:' - otp_description: 'Ви хочете дозволити "%{application_name}" отримати доступ до цього сайту?' + instructions: 'Ми щойно створили для вас новий ключ API для користування "%{application_name}", вставте наступний ключ у свою програму:' + otp_description: 'Ви хочете дозволити "%{application_name}" отримати доступ до цього сайту?' otp_confirmation: confirm_title: "Продовжити у %{site_name}" logging_in_as: "Увійти як %{username}" @@ -1273,14 +1271,19 @@ uk: sidekiq_warning: 'Sidekiq is not running. Many tasks, like sending emails, are executed asynchronously by sidekiq. Please ensure at least one sidekiq process is running. Learn about Sidekiq here.' queue_size_warning: "The number of queued jobs is %{queue_size}, which is high. This could indicate a problem with the Sidekiq process(es), or you may need to add more Sidekiq workers." memory_warning: "Your server is running with less than 1 GB of total memory. At least 1 GB of memory is recommended." + failing_emails_warning: 'Не вдалося виконати завдання електронної пошти %{num_failed_jobs}. Перевірте свою програму app.yml і переконайтесь, що налаштування поштового сервера є правильними. Перегляньте невдалі завдання в Sidekiq .' subfolder_ends_in_slash: "Неправильно налаштована ваша папка; DISCOURSE_RELATIVE_URL_ROOT закінчується косою рискою." missing_mailgun_api_key: "Сервер налаштований для надсилання електронної пошти через Mailgun, але ви не надали ключ API, який використовується для перевірки повідомлень webhook." bad_favicon_url: "Фавікон не завантажується. Перевірте налаштування Фавікон в налаштуваннях сайту." + poll_pop3_timeout: "Час підключення до сервера POP3 закінчився. Не вдалося отримати електронну пошту. Перевірте свої налаштування POP3 та постачальника послуг." + poll_pop3_auth_error: "Підключення до сервера POP3 не вдається з помилкою аутентифікації. Перевірте свої налаштування POP3 ." + force_https_warning: "Ваш веб-сайт використовує SSL. Але `force_https` ще не ввімкнено в налаштуваннях вашого сайту." out_of_date_themes: "Оновлення доступні для таких тем:" unreachable_themes: "Нам не вдалося перевірити наявність оновлень за наступними темами:" site_settings: censored_words: "Слова, які будуть автоматично замінені на ■■■■" delete_old_hidden_posts: "Автоматично видаляти повідомлення, приховані довше ніж 30 днів." + default_locale: "Мова за замовчуванням цього екземпляра дискурсу. Ви можете замінити текст системно згенерованих категорій та тем на Customize / Текст ." allow_user_locale: "Дозволяти користувачам вибирати мову інтерфейсу" set_locale_from_accept_language_header: "встановити мову інтерфейсу для анонімних користувачів з мовних заголовків їх веб-браузера. (ЕКСПЕРИМЕНТАЛЬНО, не працює з анонімним кешем)" support_mixed_text_direction: "Підтримка змішаних напрямків тексту зліва направо і справа наліво." @@ -1330,6 +1333,7 @@ uk: delete_removed_posts_after: "Повідомлення, яке було видалено автором, буде автоматично видалено через (n) годин. Якщо встановлено 0, то повідомлення буде видалено негайно." max_image_width: "Максимальная ширина ескізів картинок, що показуються в дописах" max_image_height: "Максимальная висота ескізів картинок, що показуються в дописах" + responsive_post_image_sizes: "Змініть розмір попереднього перегляду зображень лайтбоксу, щоб забезпечити екрани з високим DPI з наступним співвідношенням пікселів. Видаліть усі значення, щоб відключити гнучкі зображення." fixed_category_positions: "Якщо включено, розділи можна буде впорядкувати в певному порядку. Інакше розділи будуть відображатися в порядку активності в них." fixed_category_positions_on_create: "Якщо прапорець встановлений, порядок категорій буде підтримуватися в діалоговому вікні створення теми (потрібно fixed_category_positions)." add_rel_nofollow_to_user_content: 'Додати "rel nofollow" для всіх посилань за винятком внутрішніх (включаючи батьківський домен). Зміна цього параметра потребує оновлення всіх повідомлень за допомогою команди "rake posts:rebake"' @@ -1355,6 +1359,7 @@ uk: notification_email: "Відправник: E-mail використовується при відправленні всіх системних листів. Домен, вказаний тут повинен мати правильно сконфігуровані SPF, DKIM і reverse PTR записи, для успішного відправлення." email_custom_headers: "Список додаткових заголовків у листах електронної пошти, розділених вертикальною рискою" email_subject: "Налаштовуваний формат теми для стандартних листів. Дивитися: https://meta.discourse.org/t/customize-subject-format-for-standard-emails/20801" + detailed_404: "Надає користувачам більше інформації про те, чому він не може отримати доступ до певної теми. Примітка: Це не дуже безпечно, оскільки користувачі дізнаються, чи URL-адреса посилається на дійсну тему." enforce_second_factor: "Змушує користувачів включити двохфакторную перевірку справжності. Виберіть 'всі', щоб застосувати його до всіх користувачів. Виберіть 'персонал', щоб примусово застосовувати його тільки до співробітників." summary_score_threshold: "Мінімальна оцінка повідомлення, необхідна для його включення в зведення по темі" summary_posts_required: "Мінімальна кількість повідомлень в темі для активації кнопки \"Зведення по темі\"" @@ -1404,7 +1409,6 @@ uk: content_security_policy: "Увімкнути політику безпеки вмісту" content_security_policy_report_only: "Увімкнути тільки звіт про політику безпеки вмісту" content_security_policy_collect_reports: "Увімкнути збір звітів про порушення CSP на /csp_reports" - content_security_policy_script_src: "Додаткові джерела скриптів у списку. Поточний хост і CDN включено за замовчуванням" invalidate_inactive_admin_email_after_days: "Облікові записи адміністраторів, які не відвідували сайт протягом цієї кількості днів, потрібно буде повторно підтвердити свою електронну адресу пошти перед входом у систему. Встановіть 0, щоб відключити." top_menu: "Визначає, які елементи відображаються в навігації на головній сторінці та в якому порядку. Наприклад latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "Визначає, які елементи з'являються в меню допису, та в якому порядку. Наприклад, like|edit|flag|delete|share|bookmark|reply" @@ -1455,6 +1459,7 @@ uk: sso_overrides_email: "Перезаписує локальну електронну пошту на електронну пошту зовнішнього сайту з завантаженням SSO на кожному вході та запобігає локальним змінам. (ПОПЕРЕДЖЕННЯ: розбіжності можуть виникнути через нормалізацію локальної електронної пошти)" sso_overrides_username: "Перезаписує локальне ім'я користувача на ім'я зовнішнього сайту з завантаженням SSO на кожному вході та запобігає локальним змінам. (ПОПЕРЕДЖЕННЯ: розбіжності можуть виникнути через різницю в довжині / вимогах імені користувача)" sso_not_approved_url: "Перенаправляти непідтверджені SSO-аккаунти на цю URL-адресу" + enable_local_logins: "Увімкнути локальні акаунти для входу в систему для імені користувача та пароля. Це потрібно ввімкнути для запрошень на роботу. ПОПЕРЕДЖЕННЯ: коли його відключено, ви, можливо, не зможете увійти в систему, якщо раніше не створили хоча б один метод альтернативного входу." enable_local_logins_via_email: "Дозволити користувачам запитувати посилання для входу в один клік та надсилати їм електронною поштою цього посилання." allow_new_registrations: "Дозволити реєстрацію нових користувачів. Вимкніть, щоб заборонити відвідувачам створювати нові облікові записи." enable_signup_cta: "Покажіть повідомлення анонімним користувачам, які повернулися, з пропозицією зареєструвати обліковий запис." @@ -1584,8 +1589,8 @@ uk: max_image_size_kb: "Максимальний розмір завантажуваних картинок в кілобайтах. Переконайтеся, що ви також налаштували обмеження в nginx (client_max_body_size) / apache або проксі." max_attachment_size_kb: "Максимальний розмір завантажуваних файлів в кілобайтах. Переконайтеся, що ви також налаштували обмеження в nginx (client_max_body_size) / apache або проксі." authorized_extensions: "Список розширень файлів, дозволених до завантаження. Використовуйте '*', щоб дозволити будь-які типи файлів." - authorized_extensions_for_staff: "Список розширень файлів, дозволених для завантаження для користувачів персоналу на додатково до списку, визначеного в налаштуваннях сайту як `authorized_extensions`. (використовувати "*", щоб увімкнути всі типи файлів)" - theme_authorized_extensions: "Список розширень файлів, дозволених для завантаження в темі (використовуйте "*", щоб увімкнути всі типи файлів)" + authorized_extensions_for_staff: "Список розширень файлів, дозволених для завантаження для користувачів персоналу на додатково до списку, визначеного в налаштуваннях сайту як `authorized_extensions`. (використовувати '*', щоб увімкнути всі типи файлів)" + theme_authorized_extensions: "Список розширень файлів, дозволених для завантаження в темі (використовуйте '*', щоб увімкнути всі типи файлів)" max_similar_results: "Кількість схожих тим, що показуються користувачеві під час створення нової теми. Порівняння виконується на підставі назви і тексту теми." max_image_megapixels: "Максимально допустима кількість мегапікселів для зображення." title_prettify: "Запобігати типовим опискам та помилкам, таким як: всі літери - великі, перша літера - мала, багаторазові ! та ?, надлишкові . в кінці, тощо." @@ -1614,7 +1619,7 @@ uk: 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) записів." + min_ban_entries_for_roll_up: "При натисканні кнопки \"Згорнути\" буде створено новий запис блокування підмережі, якщо є принаймні (N) записів." max_age_unmatched_emails: "Видаляти невідповідні записи електронної пошти через (N) днів." max_age_unmatched_ips: "Видаляти невідповідні IP-записи через (N) днів." num_flaggers_to_close_topic: "Мінімальна кількість унікальних прапорів, необхідних для автоматичного призупинення теми для втручання" @@ -1629,7 +1634,7 @@ uk: reply_by_email_address: "Шаблон адреси електронної скриньки у формі для відповідей через електронну пошту, наприклад: %%{reply_key}@reply.myforum.com" incoming_email_prefer_html: "Використовуйте HTML замість тексту для вхідної електронної пошти." strip_incoming_email_lines: "Видаляти проміжні пробіли та пробіли з кожного рядка вхідних електронних листів." - disable_emails: "Захистити Discourse від надсилання будь-яких електронних листів. Виберіть "так", щоб вимкнути електронні листи для всіх користувачів. Виберіть "не персонал", щоб вимкнути електронні листи лише для користувачів, які не є персоналом." + disable_emails: "Захистити Discourse від надсилання будь-яких електронних листів. Виберіть 'так', щоб вимкнути електронні листи для всіх користувачів. Виберіть 'не персонал', щоб вимкнути електронні листи лише для користувачів, які не є персоналом." strip_images_from_short_emails: "Видаляти зображення з електронних листів розміром менше 2800 байт" short_email_length: "Найменший розмір електронних листів у байтах" display_name_on_email_from: "Відображати повні імена в Email з полів" @@ -1655,13 +1660,14 @@ uk: delete_rejected_email_after_days: "Видалити відхилені електронні листи старші ніж (n) днів." manual_polling_enabled: "Відправляти електронні листи за допомогою API для відповідей на електронну пошту." pop3_polling_enabled: "Опитування через POP3 для відповідей електронною поштою." + pop3_polling_username: "Ім’я користувача облікового запису POP3 для опитувань електронною поштою." log_mail_processing_failures: "Журнал усіх помилок обробки електронної пошти в /logs" email_in_min_trust: "Мінімальний рівень довіри, який потрібен користувачу для дозволу створювати нові теми через email. " email_in_spam_header: "Заголовок електронної пошти для виявлення спаму." - email_prefix: "[Label], який використовується в темі електронних листів. За замовчуванням встановлено значення "title", якщо воно явно не встановлено." + email_prefix: "[Label], який використовується в темі електронних листів. За замовчуванням встановлено значення 'title', якщо воно явно не встановлено." delete_user_max_post_age: "Не дозволяти видаляти користувачів, чия перша публікація старша (x) днів." delete_all_posts_max: "Максимальне число дописів, які можна видалити за один раз кнопкою \"Видалити всі дописи\". Якщо користувач має більше дописів, ніж це число, їх не можна буде видалити за один раз, і користувача також." - username_change_period: "Максимальна кількість днів після реєстрації, щоб облікові записи могли змінити своє ім'я користувача (0, щоб заборонити зміну імені користувача)." + username_change_period: "Максимальна кількість днів після реєстрації, щоб облікові записи могли змінити своє ім'я користувача (0, щоб заборонити зміну імені користувача)." email_editable: "Дозволити користувачам змінювати свою електронну скриньку після реєстрації." logout_redirect: "URL для переадресації браузера після виходу (наприклад: https://example.com/logout)" allow_uploaded_avatars: "Дозволити користувачам завантажувати власні зображення профілю." @@ -1671,15 +1677,15 @@ uk: automatically_download_gravatars: "Завантажувати Gravatars для користувачів після створення облікового запису чи зміни електронної пошти." digest_topics: "Максимальна кількість популярних тем для відображення в підсумковому листі електронної пошти." digest_posts: "Максимальна кількість популярних публікацій для відображення в підсумковому листі електронної пошти." - digest_other_topics: "Максимальна кількість тем для показу в розділі "Нове в темах і категоріях, які ви читаєте" у підсумковому листі електронної пошти." + digest_other_topics: "Максимальна кількість тем для показу в розділі 'Нове в темах і категоріях, які ви читаєте' у підсумковому листі електронної пошти." digest_min_excerpt_length: "Мінімальний витяг з публікації в підсумковий лист електронної пошти, символів." suppress_digest_email_after_days: "Припиняти надсилати підсумкові електронні листи для користувачів, яких не бачили на сайті більше (n) днів." digest_suppress_categories: "Видаляти ці категорії з підсумкових електронних листів." disable_digest_emails: "Вимкнути підсумкові електронні листи для всіх користувачів." apply_custom_styles_to_digest: "Користувацький шаблон електронної пошти та css, які застосовуються для підсумкових електронних листів." - email_accent_bg_color: "Колір акценту, який використовується як фон деяких елементів у HTML-листах HTML. Введіть назву кольору ('red') або шістнадцяткове значення hex ('# FF0000')." - email_accent_fg_color: "Колір тексту, що відображається на тлі bg в листі електронної пошти HTML. Введіть назву кольору ('white') або шістнадцяткове значення hex ('#FFFFFF')." - email_link_color: "Колір посилань у HTML-листах. Введіть назву кольору ('blue') або шістнадцяткове значення hex ('#0000FF')." + email_accent_bg_color: "Колір акценту, який використовується як фон деяких елементів у HTML-листах HTML. Введіть назву кольору ('red') або шістнадцяткове значення hex ('# FF0000')." + email_accent_fg_color: "Колір тексту, що відображається на тлі bg в листі електронної пошти HTML. Введіть назву кольору ('white') або шістнадцяткове значення hex ('#FFFFFF')." + email_link_color: "Колір посилань у HTML-листах. Введіть назву кольору ('blue') або шістнадцяткове значення hex ('#0000FF')." detect_custom_avatars: "Чи потрібно перевіряти, що користувачі завантажували власні зображення профілю." max_daily_gravatar_crawls: "Скільки максимальну кількість разів Discourse буде перевіряти Gravatar на користувацькі аватари за день" public_user_custom_fields: "Список користувацьких полів, які можна отримати за допомогою API." @@ -1714,7 +1720,7 @@ uk: delete_drafts_older_than_n_days: "Видалити чернетки, старші ніж (n) днів." bootstrap_mode_min_users: "Мінімальна кількість користувачів, необхідних для відключення режиму початкового налаштування (встановлено 0 для відключення)" prevent_anons_from_downloading_files: "Заборонити анонімним користувачам завантажувати вкладення. ПОПЕРЕДЖЕННЯ: це зашкодить завантаженню всіх активів сайту, що не мають зображень, розміщених як додатки." - slug_generation_method: "Виберіть спосіб генерації slug (рядок ідентифікатор). 'закодований' буде генерувати рядок закодований з %. "none" зовсім вимкне slug." + slug_generation_method: "Виберіть спосіб генерації slug (рядок ідентифікатор). 'закодований' буде генерувати рядок закодований з %. 'none' зовсім вимкне slug." enable_emoji: "Увімкнути іконки emoji" enable_emoji_shortcuts: "Поширений смайлик, наприклад :): p :( буде перетворений на emoji" emoji_set: "Які смайлики подобаються вам?" @@ -1799,21 +1805,22 @@ uk: invalid_integer_max: "Значення не може перевищувати %{max}." invalid_integer: "Значення має бути цілим." regex_mismatch: "Значення не відповідає потрібному формату." - must_include_latest: "Верхнє меню повинно містити вкладку "останні"." + must_include_latest: "Верхнє меню повинно містити вкладку 'останні'." invalid_string: "Некоректне значення." invalid_string_min_max: "Має бути між %{min} та %{max} символів." invalid_string_min: "Має бути не менше %{max} символів." invalid_string_max: "Має бути не більше ніж %{max} символів." - invalid_reply_by_email_address: "Значення повинно містити '%{reply_key}' і відрізнятися від електронного листа зі сповіщеннями." - invalid_alternative_reply_by_email_addresses: "Усі значення повинні містити '%{reply_key}' та відрізнятися від електронного листа із сповіщеннями." - reply_by_email_address_is_empty: "Ви повинні дозволити "відповідь електронною поштою", перш ніж вмикати відповіді електронною поштою." - user_locale_not_enabled: "Перш ніж увімкнути цей параметр, потрібно спочатку ввімкнути "дозволити локальну інформацію користувача"" + invalid_reply_by_email_address: "Значення повинно містити '%{reply_key}' і відрізнятися від електронного листа зі сповіщеннями." + invalid_alternative_reply_by_email_addresses: "Усі значення повинні містити '%{reply_key}' та відрізнятися від електронного листа із сповіщеннями." + pop3_polling_username_is_empty: "Ви повинні встановити 'ім’я користувача для опитування через pop3', перш ніж активувати опитування POP3." + reply_by_email_address_is_empty: "Ви повинні дозволити 'відповідь електронною поштою', перш ніж вмикати відповіді електронною поштою." + user_locale_not_enabled: "Перш ніж увімкнути цей параметр, потрібно спочатку ввімкнути 'дозволити локальну інформацію користувача'" invalid_regex: "Regex недійсний або заборонений." - email_editable_enabled: "Перш ніж увімкнути цей параметр, ви повинні відключити "редагування електронної пошти"." - enable_sso_disabled: "Перш ніж активувати цей параметр, спочатку потрібно ввімкнути "enable sso"." - staged_users_disabled: "Перш ніж активувати цей параметр, потрібно спочатку ввімкнути "поетапних користувачів"." - reply_by_email_disabled: "Перш ніж активувати це налаштування, потрібно спочатку увімкнути "відповідь електронною поштою"." - sso_url_is_empty: "Ви повинні встановити 'sso URL', перш ніж увімкнути це налаштування." + email_editable_enabled: "Перш ніж увімкнути цей параметр, ви повинні відключити 'редагування електронної пошти'." + enable_sso_disabled: "Перш ніж активувати цей параметр, спочатку потрібно ввімкнути 'enable sso'." + staged_users_disabled: "Перш ніж активувати цей параметр, потрібно спочатку ввімкнути 'поетапних користувачів'." + reply_by_email_disabled: "Перш ніж активувати це налаштування, потрібно спочатку увімкнути 'відповідь електронною поштою'." + sso_url_is_empty: "Ви повинні встановити 'sso url', перш ніж увімкнути це налаштування." sso_invite_only: "Ви не можете ввімкнути sso та запрошення, лише одночасно." enable_local_logins_disabled: "Перш ніж активувати цей параметр, спочатку потрібно ввімкнути функцію 'enable local logins'." min_username_length_exists: "Ви не можете встановити мінімальну довжину імені користувача менше найкоротшого імені користувача (%{username})." @@ -1842,7 +1849,7 @@ uk: category: "Категорії" topic: "Результати" user: "Користувачі" - results_page: "Результати пошуку для '%{term}'" + results_page: "Результати пошуку для '%{term}'" sso: login_error: "Помилка входу" not_found: "Ваш обліковий запис не вдалося знайти. Зверніться до адміністратора сайту." @@ -1862,8 +1869,8 @@ uk: merge_posts: edit_reason: one: "Повідомлення було об’єднано %{username}" - few: "%{count} повідомлення були об'єднані %{username}" - many: "%{count} повідомлення були об'єднані %{username}" + few: "%{count} повідомлення були об'єднані %{username}" + many: "%{count} повідомлення були об'єднані %{username}" other: "%{count} повідомлення були об’єднані %{username}" errors: different_topics: "Повідомлення, що належать до різних тем, неможливо об’єднати." @@ -1886,8 +1893,8 @@ uk: other: "Дописи %{count} були об’єднані в існуючу тему: %{topic_link}" existing_message_moderator_post: one: "Повідомлення було об’єднано в існуюче повідомлення: %{topic_link}" - few: "Повідомлення %{count} були об'єднані в існуюче повідомлення: %{topic_link}" - many: "Повідомлення %{count} були об'єднані в існуюче повідомлення: %{topic_link}" + few: "Повідомлення %{count} були об'єднані в існуюче повідомлення: %{topic_link}" + many: "Повідомлення %{count} були об'єднані в існуюче повідомлення: %{topic_link}" other: "Дописи %{count} були об’єднані в існуюче повідомлення: %{topic_link}" change_owner: post_revision_text: "Право власності передано" @@ -1941,8 +1948,7 @@ uk: autoclosed_disabled_lastpost: "Цю тему відкрито. Нові відповіді дозволено." auto_deleted_by_timer: "Автоматично видаляється за таймером." login: - security_key_description: "Підготувавши свій фізичний ключ безпеки, натисніть кнопку "Аутентифікація за допомогою ключа безпеки" нижче." - security_key_alternative: "Не можете знайти ключ безпеки, або хочете скористатися іншим методом?" + security_key_description: "Підготувавши свій фізичний ключ безпеки, натисніть кнопку 'Аутентифікація за допомогою ключа безпеки' нижче." security_key_authenticate: "Аутентифікувати за допомогою ключа безпеки" security_key_not_allowed_error: "Процес аутентифікації з ключем безпеки або вичерпано, або скасовано." security_key_no_matching_credential_error: "У наданому ключі безпеки не знайдено відповідних облікових даних." @@ -1970,7 +1976,7 @@ uk: omniauth_error_unknown: "Something went wrong processing your log in, please try again." omniauth_confirm_title: "Увійдіть, використовуючи %{provider}" omniauth_confirm_button: "Продовжити" - authenticator_error_no_valid_email: "Не допускаються жодні адреси електронної пошти, пов'язані з %{account}. Можливо, вам доведеться налаштувати свій обліковий запис за допомогою іншої адреси електронної пошти." + authenticator_error_no_valid_email: "Не допускаються жодні адреси електронної пошти, пов'язані з %{account}. Можливо, вам доведеться налаштувати свій обліковий запис за допомогою іншої адреси електронної пошти." new_registrations_disabled: "Наразі реєстрація нових облікових записів заборонена." password_too_long: "Паролі обмежені 200 символами." email_too_long: "Ви вказали електронну пошту Email занадто довгу. Адреса поштової скриньки повинна містити не більше 254 символів, а доменні імена не більше 253 символів." @@ -2051,6 +2057,8 @@ uk: admin_confirmation_mailer: title: "Підтвердження адміністратора" subject_template: "[%{email_prefix}] Підтвердіть новий обліковий запис Адміністратора " + text_body_template: | + Підтвердіть, що ви хочете додати ** %{target_username} (%{target_email}) ** як адміністратора вашого форуму. [Підтвердити обліковий запис адміністратора] (%{admin_confirm_url}) test_mailer: title: "Тест Mailer" subject_template: "[%{email_prefix}] Тест надійності електронної пошти" @@ -2084,6 +2092,7 @@ uk: welcome_invite: title: "Ласкаво просимо" subject_template: "Ласкаво просимо до сайта %{site_name}!" + text_body_template: "Дякуємо, що прийняли наше запрошення на %{site_name} -- ласкаво просимо! \n\n- Ми створили для вас цей новий акаунт **%{username}**. Змініть своє ім’я або пароль, відвідавши [your user profile][prefs].\n\n- Коли ви входите в систему, будь ласка **використовуйте ту саму адресу електронної пошти від свого початкового запрошення** -- інакше ми не зможемо сказати, що це ви! \n\n%{new_user_tips} Ми віримо в [civilized community behavior](%{base_url}/guidelines) у всі часи. \n\nНасолоджуйтеся своїм перебуванням! \n\n[prefs]: %{user_preferences_url}\n" backup_succeeded: title: "Резервне копіювання вдалося" subject_template: "Резервне копіювання успішно завершено" @@ -2212,6 +2221,7 @@ uk: visit_link_to_respond_pm: "[Відвідайте повідомлення] (%{base_url}%{url}), щоб відповісти на %{participants}." posted_by: "Опубліковано %{username} %{post_date}" pm_participants: "Учасники: %{participants}" + invited_group_to_private_message_body: "%{username} запросив @%{group_name} у повідомлення\n\n> ** [%{topic_title}] (%{topic_url}) **\n>\n> %{topic_excerpt}\n\nна \n\n%{site_title} -- %{site_description}\n\nЩоб приєднатись, натисніть посилання нижче:\n\n%{topic_url}\n" user_invited_to_private_message_pm_group: title: "Користувач запрошено групою до PM" subject_template: "[%{email_prefix}] %{username} запросив @%{group_name} до теми '%{topic_title}'" @@ -2415,10 +2425,6 @@ uk: confirm_new_email: title: "Підтвердіть нову електронну пошту" subject_template: "[%{email_prefix}] Підтвердіть свою нову електронну пошту" - text_body_template: | - Підтвердіть свою нову електронну адресу для %{site_name} натиснувши наступне посилання: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "Підтвердіть стару електронну пошту" subject_template: "[%{email_prefix}] Підтвердіть свою поточну адресу електроної пошти" @@ -2473,8 +2479,8 @@ uk: avatar: missing: "На жаль, ми не можемо знайти жодного аватара, пов’язаного з цією електронною адресою. Чи можете ви спробувати завантажити ще раз?" flag_reason: - sockpuppet: "Новий користувач створив тему, а інший новий користувач за тією ж IP-адресою (%{ip_address}) відповів. Дивіться налаштування сайту "flag_sockpuppets "." - spam_hosts: "Цей новий користувач намагався створити кілька публікацій із посиланнями на один і той же домен. Усі публікації цього користувача, які містять посилання, повинні бути переглянуті. Дивіться налаштування сайту "newuser_spam_host_threshold "." + sockpuppet: "Новий користувач створив тему, а інший новий користувач за тією ж IP-адресою (%{ip_address}) відповів. Дивіться налаштування сайту `flag_sockpuppets`." + spam_hosts: "Цей новий користувач намагався створити кілька публікацій із посиланнями на один і той же домен. Усі публікації цього користувача, які містять посилання, повинні бути переглянуті. Дивіться налаштування сайту `newuser_spam_host_threshold`." skipped_email_log: exceeded_emails_limit: " Перевищено max_emails_per_day_per_user" exceeded_bounces_limit: "Перевищено bounce_score_threshold" @@ -2726,8 +2732,8 @@ uk: initial_topic_title: Звіти про ефективність веб-сайту tags: title: "Теги" - staff_tag_disallowed: 'Тег "%{tag}" може застосовувати лише персонал.' - staff_tag_remove_disallowed: 'Тег "%{tag}" може видаляти лише персонал.' + staff_tag_disallowed: 'Тег "%{tag}" може застосовувати лише персонал.' + staff_tag_remove_disallowed: 'Тег "%{tag}" може видаляти лише персонал.' minimum_required_tags: one: "Ви повинні вибрати принаймні тег %{count}." few: "Ви повинні вибрати принаймні %{count} теги." @@ -2740,12 +2746,12 @@ uk: few: "Жоден із вибраних вами тегів не може бути використаний" many: "Жоден із вибраних вами тегів не може бути використаний" other: "Жоден із вибраних вами тегів не може бути використаний" - in_this_category: '"%{tag_name}" не можна використовувати в цій категорії' + in_this_category: '"%{tag_name}" не можна використовувати в цій категорії' restricted_to: - one: '"%{tag_name}" обмежений категорією "%{category_names}"' - few: '"%{tag_name}" обмежено такими категоріями: %{category_names}' - many: '"%{tag_name}" обмежено такими категоріями: %{category_names}' - other: '"%{tag_name}" обмежено такими категоріями: %{category_names}' + one: '"%{tag_name}" обмежений категорією "%{category_names}"' + few: '"%{tag_name}" обмежено такими категоріями: %{category_names}' + many: '"%{tag_name}" обмежено такими категоріями: %{category_names}' + other: '"%{tag_name}" обмежено такими категоріями: %{category_names}' required_tags_from_group: one: "Ви повинні включити принаймні тег %{count} %{tag_group_name}." few: "Ви повинні включити принаймні теги %{count} %{tag_group_name}." @@ -2944,7 +2950,7 @@ uk: missing_version: "Ви повинні надати параметр версії" conflict: "Був конфлікт з оновленням, який заважав вам це робити." reasons: - post_count: "Перші кілька повідомлень від кожного користувача повинні бути затверджені персоналом. Див. "approve_post_count"." + post_count: "Перші кілька повідомлень від кожного користувача повинні бути затверджені персоналом. Див. `approve_post_count`." trust_level: "Відповіді користувачів з низьким рівнем довіри повинні бути затверджені персоналом. Перегляньте розділ `approve_unless_trust_level`." new_topics_unless_trust_level: "Теми користувачів з низьким рівнем довіри повинні бути затверджені персоналом. Див. розділ `approve_new_topics_unless_trust_level`." fast_typer: "Новий користувач набрав своє перше повідомлення підозріло швидко, є підозра про поведінку бота чи спамера. Див. `min_first_post_typing_time`." diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index ac6c782dba..070cddb1c5 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -711,9 +711,6 @@ ur: error: "آپ کا اِی میل ایڈریس تبدیل کرنے میں ایک خرابی کا سامنا کرنا پڑا۔ شاید یہ ایڈریس پہلے سے استعمال میں ہے؟" error_staged: "آپ کا اِی میل تبدیل کرنے میں ایک خرابی کا سامنا کرنا پڑا۔ یہ ایڈریس پہلے سے ہی ایک سٹَیجڈ صارف کے استعمال میں ہے۔" already_done: "معذرت، یہ تصدیقی لِنک اب درست نہیں ہے۔ شاید آپ کا اِی میل پہلے ہی بدل چکا تھا؟" - authorizing_old: - title: "اپنے موجودہ اِی میل ایڈریس کی تصدیق کرنے کیلئے شکریہ" - description: "اب ہم تصدیق کیلئے آپ کے نئے ایڈریس پر اِی میل کر رہے ہیں۔" associated_accounts: revoke_failed: "%{provider_name} کے ساتھ آپ کے اکاؤنٹ کو منسوخ کرنے میں ناکامی۔" connected: "(کنَیکٹ شدہ)" @@ -1351,7 +1348,6 @@ ur: content_security_policy: "کَنٹینٹ-سیکورٹی-پالیسی فعال کریں" content_security_policy_report_only: "کَنٹینٹ-حفاظتی-پالیسی-رپورٹ-اَونلی فعال کریں" content_security_policy_collect_reports: "سی ایس پی کی خلاف ورزی کی رپورٹ کو /csp_reports پر جمع کرنے کو فعال کریں" - content_security_policy_script_src: "اضافی وائِٹ لِسٹ کردہ سکرپٹ کے ذرائع۔ موجودہ ہَوسٹ اور سی ڈی این ڈیفالٹ سے شامل ہیں۔" invalidate_inactive_admin_email_after_days: "ایڈمن اکاؤنٹس جنہوں نے دنوں کی اتنی تعداد میں سائٹ کا دورہ نہیں کیا، اُن کو لاگ اِن کرنے سے پہلے اپنے ایمیل ایڈریس کی دوبارہ توثیق کرنے کی ضرورت ہوگی۔ غیر فعال کرنے کیلئے 0 پر سَیٹ کریں۔" top_menu: "اِس بات کا تعین کریں کہ ہَوم پیج نیویگیشن پر کونسی، اور کس ترتیب کے ساتھ اشیاء ظاہر ہوں۔ مثال کے طور پر تازہ ترین|نئی|بغیر پڑھی|زُمرہ جات|ٹاپ|پڑھ لیے گئے|شائع کیے|بُکمارکس" post_menu: "اِس بات کا تعین کریں کہ پوسٹ مَینِیو پر کونسی، اور کس ترتیب کے ساتھ اشیاء ظاہر ہوں۔ مثال کے طور پر لائیک|ترمیم| فلَیگ|حذف|شئیر|بُکمارک|جواب" @@ -1715,8 +1711,6 @@ ur: permalink_normalizations: "دائمی لِنکس میچ کرنے سے پہلے درج ذیل رَیج اَیکس کا اطلاق کریں، مثال کے طور پر: /(topic.*)\\?.*/\\1 ٹاپک روٹس میں سے قُوَیری سٹرِنگ کو نکال دے گا۔ فارمیٹ regex+string ہے، میچ کردہ کو ایکسَیس کرنے کیلئے \\1 وغیرہ کا استعمال کریں" global_notice: "تمام زائرین کو فوری،اَیمرجنسی ناقابلِ برطرف گلوبل بَینر نوٹِس دکھائیں، اِسے چھپانے کیلئے خالی جگہ میں تبدیل کریں (HTML کی اجازت)۔" disable_system_edit_notifications: "سِسٹم صارف کی طرف سے ترمیم اطلاعات کو غیر فعال کریں جب 'download_remote_images_to_local' فعال ہو۔" - likes_notification_consolidation_threshold: "لائیک اطلاعات کی تعداد جس سے پہلے اطلاعات ایک میں جمع کر دی جائیں۔ غیر فعال کرنے کیلئے 0 پر سَیٹ کریں۔ ونڈو بذریعہ `SiteSetting.likes_notification_consolidation_window_mins` ترتیب دی جا سکتی ہے۔" - likes_notification_consolidation_window_mins: "منٹوں میں مدت جہاں لائیک اطلاعات کو ایک میں جمع کر دیا جاتا ہے، جب حد تک پہنچ جایا جائے۔ حد کو بذریعہ `SiteSetting.likes_notification_consolidation_threshold` ترتیب دیا جا سکتا ہے۔" automatically_unpin_topics: "صارفین جب ٹاپک کے آخر تک پہنچ جائیں تو خود بخود ٹاپکس پر سے پِن ہٹا دیں۔" read_time_word_count: "متوقع پڑھنے کے وقت کا حساب لگانے کیلئے فی منٹ الفاظ کی تعداد۔" topic_page_title_includes_category: "ٹاپک صفحہ کے عنوان ٹَیگ میں زُمرہ کا نام شامل ہے۔" @@ -3031,21 +3025,9 @@ ur: confirm_new_email: title: "نئے ای میل کی تصدیق کریں" subject_template: "[%{email_prefix}] اپنے نئے ای میل ایڈریس کی تصدیق کریں" - text_body_template: | - %{site_name} پر اپنے نئے ای میل ایڈریس کی تصدیق کرنے کیلئے مندرجہ ذیل لِنک پر کلِک کریں: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "پرانی ای میل تصدیق" subject_template: "[%{email_prefix}] اپنا موجودہ ای میل ایڈریس تصدیق کریں" - text_body_template: | - اِس سے پہلے کہ ہم آپ کا ای میل ایڈریس تبدیل کر سکیں، ہمیں آپ کے اِس بات کی تصدیق کرنے کی ضرورت ہے کہ آپ کنٹرول کرتے ہیں - موجودہ ای میل اکاؤنٹ۔ آپ کا یہ قدم مکمل کرنے کے بعد، ہم آپ کی طرف سے تصدیق کروائئں گے - نئے ای میل ایڈریس کی۔ - - %{site_name} پر اپنے موجودہ ای میل ایڈریس کی تصدیق کرنے کیلئے مندرجہ ذیل لِنک پر کلک کریں: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "پرانا ای میل مطلع" subject_template: "[%{email_prefix}] آپ کا ای میل ایڈریس تبدیل ہوگیا ہے" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index c195f1791a..de8b79ea00 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -14,7 +14,7 @@ vi: formats: short: "%m-%d-%Y" short_no_year: "%B %-d" - date_only: "%b %-d, %Y" + date_only: "%B %-d, %Y" date: month_names: [~, Tháng Một, Tháng Hai, Tháng Ba, Tháng Tư, Tháng Năm, Tháng Sáu, Tháng Bảy, Tháng Tám, Tháng Chín, Tháng Mười, Tháng Mười Một, Tháng Mười Hai] <<: *datetime_formats @@ -31,9 +31,13 @@ vi: submit: "Gửi đi" disable_remote_images_download_reason: "Không thể tải ảnh về máy chủ vì không dung lượng lưu trữ." anonymous: "Ẩn danh" + themes: + import_error: + git: "Lỗi nhân bản kho git, truy cập bị từ chối hoặc không tìm thấy kho lưu trữ" emails: incoming: show_trimmed_content: "Hiện nội dung đã ẩn" + maximum_staged_user_per_email_reached: "Đạt số lượng thành viên theo giai đoạn tối đa được tạo trên mỗi email." errors: empty_email_error: "Xảy ra khi hệ thống nhận được một thư rỗng." no_message_id_error: "Xảy ra khi thư không có phần 'Message-Id'." @@ -338,9 +342,6 @@ vi: error: "Có một lỗi khi thay đổi địa chỉ email của bạn. Có lẽ email này đã được sử dụng rồi?" error_staged: "Có lỗi khi thay đổi địa chỉ email của bạn, email đã được người khác sử dụng." already_done: "Xin lỗi, liên kết xác nhận này không còn giá trị. Có lẽ email của bạn đã được thay đổi?" - authorizing_old: - title: "Cám ơn bạn đã xác nhận địa chỉ email." - description: "Chúng tôi sẽ không gửi email tới địa chỉ mới của bạn để xác nhận." activation: action: "Nhấn vào đây để kích hoạt tài khoản của bạn" already_done: "Xin lỗi, liên kết để xác nhận tài khoản này không còn hợp lệ. Có thể tài khoản của bạn được kích hoạt?" @@ -423,36 +424,48 @@ vi: otp_confirmation: confirm_title: "Tiếp tục tới %{site_name}" confirm_button: Kết thúc đăng nhập + scopes: + one_time_password: "Tạo mã token đăng nhập một lần" reports: default: labels: count: Đếm day: Ngày post_edits: + title: "Sửa Bài Viết" labels: edited_at: ày post: Bài viết editor: Biên tập edit_reason: Lý do + description: "Số bài viết mới chỉnh sửa." user_flagging_ratio: + title: "Tỷ lệ gắn cờ người dùng" labels: user: Người dùng score: Điểm số moderators_activity: + title: "Hoạt động của người điều hành" labels: moderator: Điều hành + description: "Danh sách hoạt động của người điều hành bao gồm các cờ được xem xét, thời gian đọc, chủ đề được tạo, bài đăng được tạo, tin nhắn cá nhân được tạo và sửa đổi." flags_status: + title: "Tình trạng cờ" labels: flag: Loại + description: "Danh sách các trạng thái của cờ bao gồm loại cờ, người đăng, người gắn cờ và thời gian giải quyết." visits: title: "Các thành viên truy cập" xaxis: "Ngày" yaxis: "Số lần truy cập" + description: "Số lượt truy cập của người dùng." signups: xaxis: "Ngày" new_contributors: xaxis: "Ngày" + description: "Số thành viên thực hiện bài đăng đầu tiên của họ trong giai đoạn này." consolidated_page_views: + title: "Số lượt xem hợp nhất" yaxis: "Ngày" labels: post: Bài viết @@ -462,6 +475,7 @@ vi: xaxis: "Ngày" daily_engaged_users: xaxis: "Ngày" + description: "Số Thành viên đã thích hoặc đăng trong ngày cuối cùng" profile_views: title: "Xem hồ sơ người dùng" xaxis: "Ngày" @@ -487,10 +501,12 @@ vi: xaxis: "Ngày" yaxis: "Số đánh dấu mới" users_by_trust_level: - title: "Thành viên ở mõi bậc tin tưởng" + title: "Người dùng trên mỗi cấp độ tin cậy" xaxis: "Bậc tin tưởng" yaxis: "Số thành viên" + description: "Số lượng thành viên được nhóm theo mức độ tin cậy." users_by_type: + title: "Người dùng mỗi loại" xaxis: "Loại" yaxis: "Số thành viên" labels: @@ -499,6 +515,13 @@ vi: admin: Quản trị moderator: Điều hành suspended: Đã tạm khóa + description: "Số thành viên được nhóm bởi quản trị viên, điều hành viên, đã tạm ngưng và im lặng." + trending_search: + title: Xu hướng tìm kiếm + labels: + term: Thuật ngữ + searches: Số lần tìm kiếm + description: "Các thuật ngữ tìm kiếm phổ biến nhất với tỷ lệ nhấp chuột của họ." emails: title: "Email đã gửi" xaxis: "Ngày" @@ -525,6 +548,7 @@ vi: title: "Thông báo người dùng" xaxis: "Ngày" yaxis: "Số lượng tin nhắn" + description: "Số lần Thành viên đã được thông báo riêng bởi một lá cờ." top_referrers: title: "Giới thiệu hàng đầu" xaxis: "Người dùng" @@ -534,6 +558,7 @@ vi: user: "Người dùng" num_clicks: "Clicks" num_topics: "Chủ đề" + description: "Danh sách thành viên theo số lần nhấp vào liên kết họ đã chia sẻ." top_traffic_sources: title: "Nguồn truy cập" xaxis: "Tên miền" @@ -555,13 +580,16 @@ vi: page_view_logged_in_reqs: title: "Đã đăng nhập" xaxis: "Ngày" + description: "Số lượt xem trang mới từ Thành viên đã đăng nhập." page_view_crawler_reqs: xaxis: "Ngày" page_view_total_reqs: + title: "Số lượt xem" xaxis: "Ngày" yaxis: "Tổng lượt xem trang" page_view_logged_in_mobile_reqs: xaxis: "Ngày" + description: "Số lượt xem trang mới từ thành viên trên thiết bị di động và đăng nhập vào tài khoản." page_view_anon_mobile_reqs: xaxis: "Ngày" http_background_reqs: @@ -592,21 +620,32 @@ vi: title: "Thời gian để phản hồi lần đầu" xaxis: "Ngày" yaxis: "Thời gian trung bình (giờ)" + description: "Thời gian trung bình (tính bằng giờ) của phản hồi đầu tiên cho các chủ đề mới." topics_with_no_response: title: "Chủ đề không có phản hồi" xaxis: "Ngày" yaxis: "Tổng số" mobile_visits: + title: "Lượt truy cập của người dùng (di động)" xaxis: "Ngày" yaxis: "Số lần truy cập" + description: "Số Thành viên duy nhất đã truy cập bằng thiết bị di động." + web_crawlers: + labels: + page_views: "Số lượt xem" suspicious_logins: + title: "Đăng nhập đáng ngờ" labels: user: Người dùng location: Vị trí + description: "Chi tiết về thông tin đăng nhập mới khác biệt đáng ngờ với thông tin đăng nhập trước đó." staff_logins: + title: "Đăng nhập quản trị viên" labels: user: Người dùng location: Vị trí + login_at: Đăng nhập lúc + description: "Danh sách thời gian đăng nhập của quản trị viên với các địa điểm." top_uploads: labels: filename: Tên tập tin @@ -621,6 +660,7 @@ vi: other: "Email đã tạo %{count} lỗi trong 24 giờ qua, xem nhật ký để biết thêm chi tiết." poll_pop3_timeout: "Không thể kết nối với POP3 server, sẽ không nhận được email gửi đến. Bạn hãy kiểm tra thiết lập POP3 và nhà cung cấp dịch vụ." poll_pop3_auth_error: "Không thể kết nối với POP3 server do lỗi chứng thực. Bạn hãy kiểm tra thiết lập POP3." + unreachable_themes: "Chúng tôi không thể kiểm tra cập nhật về các chủ đề sau:" site_settings: censored_words: "Từ sẽ tự động thay thế bằng ■■■■" delete_old_hidden_posts: "Tự động ẩn bất kỳ bài viết ở ẩn hơn 30 ngày." @@ -657,6 +697,7 @@ vi: post_onebox_maxlength: "Số ký tự tối đa của một bài onebox Discourse." notification_email: "Địa chỉ email 'Từ:' được dùng để gửi các email thiết yếu của hệ thống. Các tên miền quy định ở đây phải có SPF, DKIM và bản ghi PTR phải được thiết lập chính xác cho email đến." email_custom_headers: "Danh sách xác định email header tùy chỉnh" + force_https: "Buộc trang web của bạn chỉ sử dụng HTTPS. CẢNH BÁO: KHÔNG kích hoạt tính năng này cho đến khi bạn xác minh HTTPS được thiết lập đầy đủ và hoạt động hoàn toàn ở mọi nơi! Bạn đã kiểm tra CDN của mình, tất cả thông tin đăng nhập xã hội và bất kỳ biểu trưng / phụ thuộc bên ngoài nào để đảm bảo tất cả chúng đều tương thích với HTTPS?" summary_score_threshold: "Số điểm tối thiểu yêu cầu cho một bài viết bao gồm 'Tóm tắt chủ đề này'" summary_posts_required: "Số bài viết tối thiểu trong một chủ đề trước khi 'Tóm tắt chủ đề này' được kích hoạt" summary_likes_required: "Số lượt thích trong một chủ đề trước khi 'Tóm tắt chủ đề này' được kích hoạt" @@ -788,6 +829,7 @@ vi: newuser_max_mentions_per_post: "Số tối đa thông báo @name mà thành viên mới có thể sử dụng trong bài viết." newuser_max_replies_per_topic: "Số lượng tối đa trả lời mà thành viên có thể thực hiện trong một chủ đề cho đến khi có ai đó gửi trả lời." max_mentions_per_post: "Số tối đa thông báo @name mà tất cả mọi người có thể sử dụng trong bài viết." + max_users_notified_per_group_mention: "Số thành viên tối đa có thể nhận được thông báo nếu một nhóm được đề cập (nếu đáp ứng ngưỡng sẽ không có thông báo nào được nêu ra)" create_thumbnails: "Tạo ảnh nhỏ và ảnh lightbox nếu quá lớn để vừa trong một bài đăng." email_time_window_mins: "Chờ (n) phút trước khi gửi bất kỳ một email thông báo nào, để cung cấp cho người dùng cơ hội để chỉnh sửa và hoàn tất bài viết của họ." email_posts_context: "Có bao nhiêu trả lời trước được kèm theo như là bối cảnh trong email thông báo." @@ -840,6 +882,7 @@ vi: delete_email_logs_after_days: "Xóa nhật ký email sau (N) ngày. 0 để giữ lại vĩnh viễn" max_emails_per_day_per_user: "Số lượng tối đa email thành viên được gửi mỗi ngày. 0 để không giới hạn" enable_staged_users: "Tự động tạo các giai đoạn người dùng khi xử lý các email gửi đến." + maximum_staged_users_per_email: "Số lượng tối đa thành viên theo giai đoạn được tạo khi xử lý email đến." manual_polling_enabled: "Push email bằng cách sử dụng API để trả lời email." pop3_polling_enabled: "Poll thông qua POP3 các email trả lời." pop3_polling_ssl: "Sử dụng SSL khi kết nối tới POP3 server. (Đề nghị sử dụng)" @@ -891,7 +934,9 @@ vi: embed_username_required: "Tên tài khoản để tạo chủ đề là bắt buộc." show_create_topics_notice: "Nếu trang có ít hơn 5 chủ đề công khai, hiển thị một thông báo yêu cầu quản trị tạo thêm các chủ đề mới" delete_drafts_older_than_n_days: "Xóa các bản nháp cũ hơn (n) ngày." + bootstrap_mode_min_users: "Số thành viên tối thiểu được yêu cầu để tắt chế độ bootstrap (đặt thành 0 để tắt)" prevent_anons_from_downloading_files: "Cấm khách truy cập tải các tập tin đính kèm. CẢNH BÁO: việc này sẽ chặn những hình ảnh không thuộc giao diện trang hoạt động" + secure_media: 'Giới hạn truy cập để tải lên phương tiện truyền thông (hình ảnh, video, âm thanh). Nếu "yêu cầu đăng nhập" được bật, chỉ Thành viên đã đăng nhập mới có thể truy cập tải lên phương tiện. Nếu không, quyền truy cập sẽ chỉ bị giới hạn đối với các phương tiện tải lên trong các tin nhắn riêng tư. Lưu ý: Tải lên S3 phải được bật trước khi bật cài đặt này.' slug_generation_method: "Chọn phương thức tạo slug. 'encoded' sẽ tạo ra phần trăm chuỗi mã hóa. 'none' sẽ tắt slug." enable_emoji: "Kích hoạt emoji" emoji_set: "Bạn thích dùng gói emoji nào?" @@ -940,6 +985,7 @@ vi: category: "Thư mục" topic: "Kết quả" user: "Thành viên" + results_page: "Search results for '%{term}'" sso: not_found: "Không tìm thấy tài khoản của bạn, xin hãy liên hệ với BQT." account_not_approved: "Tài khoản của bạn chưa được BQT chấp thuận, bạn sẽ nhận được email thông báo khi được chấp nhận." @@ -1101,6 +1147,9 @@ vi: download_remote_images_disabled: subject_template: "Download ảnh từ xa đã bị vô hiệu" text_body_template: "Thiết lập `download_remote_images_to_local` đã bị vô hiệu do khoảng trống ổ cứng `download_remote_images_threshold` đã đạt mức giới hạn." + new_user_of_the_month: + title: "Bạn là thành viên mới của tháng!" + subject_template: "Bạn là một thành viên mới của tháng!" subject_re: "Re:" subject_pm: "[PM]" user_notifications: @@ -1133,6 +1182,8 @@ vi: %{new_email} signup_after_approval: subject_template: "Bạn đã được kiểm duyệt ở %{site_name}!" + suspicious_login: + subject_template: "[%{site_name}] Đăng nhập mới từ %{location}" page_not_found: title: "Rất tiếc! Địa chỉ này không tồn tại hoặc riêng tư." popular_topics: "Phổ biến" @@ -1339,6 +1390,8 @@ vi: description: Sử dụng Emoji trong bài viết first_mention: name: Đề cập đầu tiên + new_user_of_the_month: + name: "Thành viên mới của tháng" admin_login: success: "Gửi mail lỗi" email_input: "Email quản trị" @@ -1370,6 +1423,7 @@ vi: placeholder: "name@example.com" corporate: title: "Công ty" + description: "Thông tin này sẽ được nhập vào Điều khoản dịch vụ của bạn, đây là một chủ đề bạn có thể chỉnh sửa trong danh mục Nhân viên. Nếu bạn không có một công ty, hãy bỏ qua bước này ngay bây giờ." colors: title: "Giao diện" homepage: diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index 2c4b010fa9..f1965be405 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -124,6 +124,7 @@ zh_CN: inclusion: 不包括在列表中 invalid: 无效 is_invalid: "似乎不清楚,这是一个完整的句子?" + invalid_timezone: "“%{tz}”不是一个有效的时区" contains_censored_words: "包含了以下敏感词:%{censored_words}" less_than: "必须小于 %{count}" less_than_or_equal_to: "必须小于等于 %{count}" @@ -161,6 +162,7 @@ zh_CN: enable_s3_uploads_is_required: "除非已启用S3上载,否则无法将库存启用到S3。" s3_backup_requires_s3_settings: "除非你提供了'%{setting_name}',否则无法将S3用作备份位置。" s3_bucket_reused: "您不可把同一个 bucket 既用作 ‘s3_upload_bucket’ 又用作 ‘s3_backup_bucket’。请选择不同的 bucket 或为每个 bucket 使用不同的路径。" + secure_media_requirements: "在启用安全媒体之前,必须先启用S3上传。" conflicting_google_user_id: '此帐户的Google帐户ID已更改; 出于安全原因,需要管理人员干预。请联系工作人员并将其指向https://meta.discourse.org/t/76575' activemodel: errors: @@ -675,9 +677,8 @@ zh_CN: error: "在修改你的电子邮箱地址时出现了错误,可能此邮箱已经在论坛中使用了?" error_staged: "在修改你的电子邮箱地址时出现了错误。这个邮箱已经被一个暂存用户占用了。" already_done: "抱歉,此激活链接已经失效。可能你已经修改了邮箱?" - authorizing_old: - title: "感谢你确认你目前的邮箱地址" - description: "我们正向你的新地址发送确认邮件。" + authorizing_new: + title: "确认您的新邮箱" associated_accounts: revoke_failed: "无法使用%{provider_name}撤消你的帐户。" connected: "(已连接)" @@ -1334,7 +1335,6 @@ zh_CN: content_security_policy: "启用内容安全策略" content_security_policy_report_only: "只启用内容安全报告策略" content_security_policy_collect_reports: "在/csp_reports上启用内容安全策略违规报告收集" - content_security_policy_script_src: "其他列入白名单的脚本来源。默认包含当前主机和CDN。" invalidate_inactive_admin_email_after_days: "在此天数内未访问过网站的管理员帐户需要在登录前重新验证邮件地址。设置为0禁用。" top_menu: "确定在主页导航条包含哪些条目,以及排列顺序。例如:latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "确定在帖子菜单条包含哪些条目,以及排列顺序。例如:like|edit|flag|delete|share|bookmark|reply" @@ -1699,8 +1699,6 @@ zh_CN: permalink_normalizations: "在匹配永久链接之前应用如下正则表达式,例如:/(topic.*)\\?.*/\\1 将去掉所有主题路径的参数字符串。格式为使用正则表达式+使用 \\1 等字符串来访问捕获内容" global_notice: "为所有访客显示紧急的,不可取消的全局横幅通知,修改为空以隐藏(可使用HTML)。" disable_system_edit_notifications: "当 'download_remote_images_to_local' 启用时禁用系统编辑提醒。" - likes_notification_consolidation_threshold: "合并为单个提醒前接受的被赞提醒数量。设置为0以关闭。窗口可以通过`SiteSetting.likes_notification_consolidation_window_mins`配置。" - likes_notification_consolidation_window_mins: "当达到阈值时多个赞的提醒合并到单个的持续时间。阈值可通过`SiteSetting.likes_notification_consolidation_threshold`配置。" automatically_unpin_topics: "当用户到达底部时自动解除主题置顶。" read_time_word_count: "一分钟阅读的词的数量,用于估计阅读时间。" topic_page_title_includes_category: "主题页面标题标签包含分类名。" @@ -1936,10 +1934,12 @@ zh_CN: autoclosed_disabled_lastpost: "本主题现在开放了。可以添加新的回复。" auto_deleted_by_timer: "由计时器自动删除。" login: - security_key_alternative: "找不到您的安全密钥,是否要使用其他方法?" + security_key_description: "当你准备好物理安全密钥后,请按下面的“使用安全密钥进行身份验证”按钮。" + security_key_alternative: "尝试另一种方式" security_key_authenticate: "使用安全密钥进行身份验证" security_key_not_allowed_error: "安全密钥验证超时或被取消。" security_key_no_matching_credential_error: "在提供的安全密钥中找不到匹配的凭据。" + security_key_support_missing_error: "您当前的设备或浏览器不支持使用安全密钥。请使用其他方法。" security_key_invalid: "验证安全密钥时出错。" not_approved: "你的账户尚未获得批准。一旦你的账户获得批准,你会收到一封电子邮件。" incorrect_username_email_or_password: "用户名、电子邮箱或密码不正确" @@ -2278,6 +2278,8 @@ zh_CN: 嘿。我们看到你一直在忙着阅读,这太棒了,所以我们已经提升你了[信任等级!](https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/) 我们很高兴你和我们共度时光,我们很想知道更多关于你的事情。花一点时间[填写你的个人资料](%{base_url}/我的/设置/个人信息),或随时[开始一个新主题](%{base_url}/categories)。 + welcome_staff: + subject_template: "恭喜,你已获得%{role}身份!" welcome_invite: title: "欢迎邀请" subject_template: "欢迎来到 %{site_name}!" @@ -3009,19 +3011,9 @@ zh_CN: confirm_new_email: title: "确认新邮箱" subject_template: "[%{email_prefix}] 确认你的新电子邮箱地址" - text_body_template: | - 点击下面的链接来确认你在%{site_name}上的新电子邮箱地址: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "确认旧邮箱" subject_template: "[%{email_prefix}] 确认你现在的电子邮箱地址" - text_body_template: | - 在修改你的邮箱地址前,我们需要确认你现在的邮箱。在你完成这步之后,我们将确认你的新邮件地址。 - - 点击下面的链接来确认你在%{site_name}正使用的邮件: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "通知旧邮箱" subject_template: "[%{email_prefix}] 你的邮箱已经修改成功" @@ -3973,6 +3965,9 @@ zh_CN: user_merged: "%{username}已合并到此账户" user_delete_self: "已从%{url}自行删除" webhook_deactivation_reason: "你的webhook已被自动停用。我们收到多个“%{status}”HTTP失败状态响应。" + api_key: + revoked: 已撤销 + restored: 已恢复 reviewables: priorities: low: "低" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index 4beb44a1b3..c6ac6ebc43 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -649,9 +649,6 @@ zh_TW: error: "修改你的電郵位址時發生錯誤,可能此郵箱已有人使用了。" error_staged: "在修改你的電子郵箱地址時出現了錯誤。這個郵箱已經被一個暫存使用者占用了。" already_done: "抱歉,此激活連結已經失效。可能你已經修改了郵箱?" - authorizing_old: - title: "感謝你確認你目前的郵箱地址" - description: "我們正向你的新地址發送確認郵件。" associated_accounts: revoke_failed: "無法使用%{provider_name}刪除你的帳戶。" activation: @@ -1273,7 +1270,6 @@ zh_TW: content_security_policy: "啟用內容安全性政策" content_security_policy_report_only: "僅啟用內容安全性政策報告" content_security_policy_collect_reports: "在 /csp_reports 啟用CSP違規報告蒐集" - content_security_policy_script_src: "其他列入白名單的腳本來源。預設情況下包括當前主機和 CDN。" invalidate_inactive_admin_email_after_days: "在這幾天沒有訪問網站的管理員帳戶,在登入前需要重新驗證該管理員帳戶的電子郵件。設定為0禁用。" top_menu: "選擇在主頁導航列包含哪些項目,以及排列次序。例如:latest|new|unread|categories|top|read|posted|bookmarks" post_menu: "確定在貼文功能表條包含哪些條目,以及排列順序。例如:like|edit|flag|delete|share|bookmark|reply" @@ -1625,8 +1621,6 @@ zh_TW: permalink_normalizations: "在匹配永久連結之前應用如下正則表達式,例如:/(topic.*)\\?.*/\\1 將去掉所有主題路徑的參數字元串。格式為使用正則表達式+使用 \\1 等字元串來訪問捕獲內容" global_notice: "向所有訪客顯示緊急的、不可駁回的全版面橫幅通知,設定留空將會隱藏它(允許使用HTML)。" disable_system_edit_notifications: "當 'download_remote_images_to_local' 啟用時禁用系統編輯提醒。" - likes_notification_consolidation_threshold: "在將通知合併為單個通知之前收到的已通知數。 設定0為禁用。該視窗可以通過`SiteSetting.likes_notification_consolidation_window_mins`進行設定。" - likes_notification_consolidation_window_mins: "達到門檻值後,將受歡迎的通知合併為單個通知的持續時間(分鐘)。 可以通過`SiteSetting.likes_notification_consolidation_threshold`設定門檻值。" automatically_unpin_topics: "當使用者到達底部時自動解除主題置頂。" read_time_word_count: "一分鐘閲讀的詞的數量,用於估計閲讀時間。" native_app_install_banner_ios: "將iOS裝置上的DiscourseHub 橫幅主題顯示給一般使用者(信任等級1以上)。" @@ -2808,20 +2802,9 @@ zh_TW: confirm_new_email: title: "確認新郵件地址" subject_template: "[%{email_prefix}] 確認你的新郵件地址" - text_body_template: | - 點擊以下連結,確認你 %{site_name} 的新郵件地址: - - %{base_url}/u/authorize-email/%{email_token} confirm_old_email: title: "確認原郵件地址" subject_template: "[%{email_prefix}] 確認你的現行郵件地址" - text_body_template: | - 在我們修改你的郵件地址前,我們需要你確認你擁有當前的郵件帳號。 - 完成此步驟後,我們將請你確認新的郵件地址。 - - 點擊下面的連結,以確認你當前在 %{site_name} 的郵件地址: - - %{base_url}/u/authorize-email/%{email_token} notify_old_email: title: "通知原郵件地址" subject_template: "[%{email_prefix}] 已變更你的郵件地址" diff --git a/config/nginx.sample.conf b/config/nginx.sample.conf index 5e67988416..538e29fab4 100644 --- a/config/nginx.sample.conf +++ b/config/nginx.sample.conf @@ -10,7 +10,10 @@ upstream discourse { server unix:/var/www/discourse/tmp/sockets/thin.3.sock; } -proxy_cache_path /var/nginx/cache keys_zone=one:10m max_size=200m; +# inactive means we keep stuff around for 1440m minutes regardless of last access (1 week) +# levels means it is a 2 deep heirarchy cause we can have lots of files +# max_size limits the size of the cache +proxy_cache_path /var/nginx/cache inactive=1440m levels=1:2 keys_zone=one:10m max_size=600m; # see: https://meta.discourse.org/t/x/74060 proxy_buffer_size 8k; diff --git a/config/routes.rb b/config/routes.rb index 8fb57fab5e..0c32e16ec8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,8 @@ Discourse::Application.routes.draw do collection do get "category/:id" => "site_settings#index" end + + put "user_count" => "site_settings#user_count" end get "reports" => "reports#index" @@ -406,8 +408,13 @@ Discourse::Application.routes.draw do put "#{root_path}/password-reset/:token" => "users#password_reset" get "#{root_path}/activate-account/:token" => "users#activate_account" put({ "#{root_path}/activate-account/:token" => "users#perform_account_activation" }.merge(index == 1 ? { as: 'perform_activate_account' } : {})) - get "#{root_path}/authorize-email/:token" => "users_email#confirm" - put "#{root_path}/authorize-email/:token" => "users_email#confirm" + + get "#{root_path}/confirm-old-email/:token" => "users_email#show_confirm_old_email" + put "#{root_path}/confirm-old-email" => "users_email#confirm_old_email" + + get "#{root_path}/confirm-new-email/:token" => "users_email#show_confirm_new_email" + put "#{root_path}/confirm-new-email" => "users_email#confirm_new_email" + get({ "#{root_path}/confirm-admin/:token" => "users#confirm_admin", constraints: { token: /[0-9a-f]+/ } @@ -500,6 +507,7 @@ Discourse::Application.routes.draw do get "uploads/:site/original/:tree:sha(.:extension)" => "uploads#show", constraints: { site: /\w+/, tree: /([a-z0-9]+\/)+/i, sha: /\h{40}/, extension: /[a-z0-9\.]+/i } # used to download attachments (old route) get "uploads/:site/:id/:sha" => "uploads#show", constraints: { site: /\w+/, id: /\d+/, sha: /\h{16}/, format: /.*/ } + get "secure-media-uploads/*path(.:extension)" => "uploads#show_secure", constraints: { extension: /[a-z0-9\.]+/i } get "posts" => "posts#latest", id: "latest_posts", constraints: { format: /(json|rss)/ } get "private-posts" => "posts#latest", id: "private_posts", constraints: { format: /(json|rss)/ } @@ -854,10 +862,13 @@ Discourse::Application.routes.draw do get '/:tag_id.rss' => 'tags#tag_feed' get '/:tag_id' => 'tags#show', as: 'tag_show' get '/intersection/:tag_id/*additional_tag_ids' => 'tags#show', as: 'tag_intersection' + get '/:tag_id/info' => 'tags#info' get '/:tag_id/notifications' => 'tags#notifications' put '/:tag_id/notifications' => 'tags#update_notifications' put '/:tag_id' => 'tags#update' delete '/:tag_id' => 'tags#destroy' + post '/:tag_id/synonyms' => 'tags#create_synonyms' + delete '/:tag_id/synonyms/:synonym_id' => 'tags#destroy_synonym' Discourse.filters.each do |filter| get "/:tag_id/l/#{filter}" => "tags#show_#{filter}", as: "tag_show_#{filter}" diff --git a/config/site_settings.yml b/config/site_settings.yml index 63f1aecf1c..b4ef1349cb 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -240,6 +240,12 @@ basic: - box - bullet - none + max_category_nesting: + client: true + default: 2 + min: 2 + max: 3 + hidden: true enable_mobile_theme: client: true default: true @@ -389,12 +395,12 @@ login: enable_discord_logins: default: false discord_client_id: - default: '' + default: "" discord_secret: - default: '' + default: "" secret: true discord_trusted_guilds: - default: '' + default: "" type: list enable_sso: client: true @@ -965,6 +971,8 @@ email: email_in_min_trust: default: 2 enum: "TrustLevelSetting" + email_in_authserv_id: + default: "" email_in_spam_header: type: enum default: "none" @@ -1127,6 +1135,9 @@ files: prevent_anons_from_downloading_files: default: false client: true + secure_media: + default: false + client: true enable_s3_uploads: default: false client: true @@ -1339,7 +1350,7 @@ security: list_type: compact crawler_user_agents: hidden: true - default: "rss|bot|spider|crawler|facebook|archive|wayback|ping|monitor" + default: "rss|bot|spider|crawler|facebook|archive|wayback|ping|monitor|lighthouse" type: list list_type: compact crawler_check_bypass_agents: @@ -1368,7 +1379,7 @@ security: list_type: compact blacklisted_crawler_user_agents: type: list - default: "mauibot|semrushbot|ahrefsbot|blexbot" + default: "mauibot|semrushbot|ahrefsbot|blexbot|seo spider" list_type: compact slow_down_crawler_user_agents: type: list @@ -1384,6 +1395,9 @@ security: content_security_policy_script_src: type: list default: "" + content_security_policy_allow_unsafe_eval: + default: true + hidden: true invalidate_inactive_admin_email_after_days: default: 365 min: 0 @@ -1581,24 +1595,10 @@ developer: client: true embedding: - feed_polling_enabled: - default: false - hidden: true - feed_polling_url: - default: "" - hidden: true - feed_polling_frequency_mins: - min: 5 - max: 180 - default: 60 - hidden: true embed_by_username: default: "" type: username hidden: true - embed_username_key_from_feed: - default: "" - hidden: true embed_post_limit: default: 100 hidden: true @@ -1857,7 +1857,7 @@ uncategorized: disable_system_edit_notifications: true - likes_notification_consolidation_threshold: + notification_consolidation_threshold: default: 3 min: 0 @@ -2022,6 +2022,9 @@ user_preferences: default_categories_watching_first_post: type: category_list default: "" + mute_all_categories_by_default: + default: false + client: true default_tags_watching: type: tag_list @@ -2056,7 +2059,7 @@ api: default: 30 retry_web_hook_events: default: false - api_key_last_used_epoch: + api_key_last_used_epoch: default: "" # Value is added in a migration hidden: true revoke_api_keys_days: diff --git a/db/migrate/20190716173854_add_secure_to_uploads.rb b/db/migrate/20190716173854_add_secure_to_uploads.rb new file mode 100644 index 0000000000..837cff98f6 --- /dev/null +++ b/db/migrate/20190716173854_add_secure_to_uploads.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AddSecureToUploads < ActiveRecord::Migration[5.2] + def up + add_column :uploads, :secure, :boolean, default: false, null: false + + prevent_anons_from_downloading_files = \ + DB.query_single("SELECT value FROM site_settings WHERE name = 'prevent_anons_from_downloading_files'").first == 't' + + if prevent_anons_from_downloading_files + execute( + <<-SQL + UPDATE uploads SET secure = 't' WHERE id IN ( + SELECT DISTINCT(uploads.id) FROM uploads + INNER JOIN post_uploads ON post_uploads.upload_id = uploads.id + WHERE LOWER(original_filename) NOT SIMILAR TO '%\.(jpg|jpeg|png|gif|svg|ico)' + ) + SQL + ) + end + end + + def down + remove_column :uploads, :secure + end +end diff --git a/db/migrate/20191031052711_add_granted_title_badge_id_to_user_profile.rb b/db/migrate/20191031052711_add_granted_title_badge_id_to_user_profile.rb new file mode 100644 index 0000000000..f76ea34ef4 --- /dev/null +++ b/db/migrate/20191031052711_add_granted_title_badge_id_to_user_profile.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AddGrantedTitleBadgeIdToUserProfile < ActiveRecord::Migration[6.0] + def up + add_reference :user_profiles, :granted_title_badge, foreign_key: { to_table: :badges }, index: true, null: true + + # update all the regular badge derived titles based + # on the normal badge name + ActiveRecord::Base.connection.execute <<-SQL + UPDATE user_profiles + SET granted_title_badge_id = b.id + FROM users + INNER JOIN badges b ON users.title = b.name + WHERE users.id = user_profiles.user_id + AND user_profiles.granted_title_badge_id IS NULL + SQL + + # update all of the system badge derived titles where the + # badge has had custom text set for it via TranslationOverride + ActiveRecord::Base.connection.execute <<-SQL + UPDATE user_profiles + SET granted_title_badge_id = badges.id + FROM users + JOIN translation_overrides ON translation_overrides.value = users.title + JOIN badges ON ('badges.' || LOWER(REPLACE(badges.name, ' ', '_')) || '.name') = translation_overrides.translation_key + JOIN user_badges ON user_badges.user_id = users.id AND user_badges.badge_id = badges.id + WHERE users.id = user_profiles.user_id + AND user_profiles.granted_title_badge_id IS NULL + SQL + end + + def down + remove_column :user_profiles, :granted_title_badge_id + end +end diff --git a/db/migrate/20191107025041_add_last_seen_to_category_users.rb b/db/migrate/20191107025041_add_last_seen_to_category_users.rb new file mode 100644 index 0000000000..7ed1b506a8 --- /dev/null +++ b/db/migrate/20191107025041_add_last_seen_to_category_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastSeenToCategoryUsers < ActiveRecord::Migration[6.0] + def change + add_column :category_users, :last_seen_at, :datetime + end +end diff --git a/db/migrate/20191107025140_add_index_to_last_seen_at_on_category_users.rb b/db/migrate/20191107025140_add_index_to_last_seen_at_on_category_users.rb new file mode 100644 index 0000000000..e26839625d --- /dev/null +++ b/db/migrate/20191107025140_add_index_to_last_seen_at_on_category_users.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddIndexToLastSeenAtOnCategoryUsers < ActiveRecord::Migration[6.0] + disable_ddl_transaction! + + def up + if !index_exists?(:category_users, [:user_id, :last_seen_at]) + add_index :category_users, [:user_id, :last_seen_at], algorithm: :concurrently + end + end + + def down + remove_index :category_users, [:user_id, :last_seen_at] + end +end diff --git a/db/migrate/20191108000414_add_unique_index_to_drafts.rb b/db/migrate/20191108000414_add_unique_index_to_drafts.rb new file mode 100644 index 0000000000..f50d1b7c49 --- /dev/null +++ b/db/migrate/20191108000414_add_unique_index_to_drafts.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class AddUniqueIndexToDrafts < ActiveRecord::Migration[6.0] + def up + + execute <<~SQL + DELETE FROM drafts d1 + USING ( + SELECT MAX(id) as id, draft_key, user_id + FROM drafts + GROUP BY draft_key, user_id + HAVING COUNT(*) > 1 + ) d2 + WHERE + d1.draft_key = d2.draft_key AND + d1.user_id = d2.user_id AND + d1.id <> d2.id + SQL + + remove_index :drafts, [:user_id, :draft_key] + add_index :drafts, [:user_id, :draft_key], unique: true + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20191113193141_add_target_tag_id_to_tags.rb b/db/migrate/20191113193141_add_target_tag_id_to_tags.rb new file mode 100644 index 0000000000..75d19e272b --- /dev/null +++ b/db/migrate/20191113193141_add_target_tag_id_to_tags.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTargetTagIdToTags < ActiveRecord::Migration[6.0] + def change + add_column :tags, :target_tag_id, :integer + end +end diff --git a/db/migrate/20191120015344_add_timezone_to_user_options.rb b/db/migrate/20191120015344_add_timezone_to_user_options.rb new file mode 100644 index 0000000000..8284842cf6 --- /dev/null +++ b/db/migrate/20191120015344_add_timezone_to_user_options.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddTimezoneToUserOptions < ActiveRecord::Migration[6.0] + def up + add_column :user_options, :timezone, :string + execute( + <<-SQL + UPDATE user_options + SET timezone = ucf.value + FROM user_custom_fields AS ucf + WHERE ucf.user_id = user_options.user_id AND ucf.name = 'timezone' + SQL + ) + end + + def down + remove_column :user_options, :timezone + end +end diff --git a/db/migrate/20191128222140_add_unique_index_to_developers.rb b/db/migrate/20191128222140_add_unique_index_to_developers.rb new file mode 100644 index 0000000000..d839b15770 --- /dev/null +++ b/db/migrate/20191128222140_add_unique_index_to_developers.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddUniqueIndexToDevelopers < ActiveRecord::Migration[6.0] + def up + execute <<~SQL + DELETE FROM developers d1 + USING ( + SELECT MAX(id) as id, user_id + FROM developers + GROUP BY user_id + HAVING COUNT(*) > 1 + ) d2 + WHERE + d1.user_id = d2.user_id AND + d1.id <> d2.id + SQL + + add_index :developers, %i(user_id), unique: true + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20191107032231_change_notification_level.rb b/db/post_migrate/20191107032231_change_notification_level.rb new file mode 100644 index 0000000000..6f88d41809 --- /dev/null +++ b/db/post_migrate/20191107032231_change_notification_level.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ChangeNotificationLevel < ActiveRecord::Migration[6.0] + def up + change_column :category_users, :notification_level, :integer, null: true + end + + def down + change_column :category_users, :notification_level, :integer, null: false + end +end diff --git a/db/post_migrate/20191107190330_remove_suppress_from_latest_from_category.rb b/db/post_migrate/20191107190330_remove_suppress_from_latest_from_category.rb new file mode 100644 index 0000000000..7d047c17c0 --- /dev/null +++ b/db/post_migrate/20191107190330_remove_suppress_from_latest_from_category.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class RemoveSuppressFromLatestFromCategory < ActiveRecord::Migration[6.0] + DROPPED_COLUMNS ||= { + categories: %i{suppress_from_latest} + } + + def up + ids = DB.query_single("SELECT id::text FROM categories WHERE suppress_from_latest = TRUE") + + if ids.present? + muted_ids = DB.query_single("SELECT value from site_settings WHERE name = 'default_categories_muted'").first + ids += muted_ids.split("|") if muted_ids.present? + ids.uniq! + + # We shouldn't encourage to have more than 10 categories in `default_categories_muted` site setting. + if ids.count <= 10 + # CategoryUser.notification_levels[:muted] is 0, avoid reaching to object model + DB.exec(<<~SQL, muted: 0) + INSERT INTO category_users (category_id, user_id, notification_level) + SELECT c.id category_id, u.id user_id, :muted + FROM users u + CROSS JOIN categories c + LEFT JOIN category_users cu + ON u.id = cu.user_id + AND c.id = cu.category_id + WHERE c.suppress_from_latest = TRUE + AND cu.notification_level IS NULL + ON CONFLICT DO NOTHING + SQL + + DB.exec(<<~SQL, value: ids.join("|")) + UPDATE site_settings + SET value = :value + WHERE name = 'default_categories_muted' + SQL + end + end + + DROPPED_COLUMNS.each do |table, columns| + Migration::ColumnDropper.execute_drop(table, columns) + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/post_migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb b/db/post_migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb new file mode 100644 index 0000000000..f1b3df2e60 --- /dev/null +++ b/db/post_migrate/20191129144706_drop_unused_google_instagram_auth_tables.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'migration/table_dropper' + +class DropUnusedGoogleInstagramAuthTables < ActiveRecord::Migration[6.0] + DROPPED_TABLES ||= %i{ + google_user_infos + instagram_user_infos + } + + def up + DROPPED_TABLES.each do |table| + Migration::TableDropper.execute_drop(table) + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/lefthook.yml b/lefthook.yml index d085046ad6..1ac4f9c87f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,23 +2,23 @@ pre-commit: parallel: true commands: rubocop: - glob: '*.rb' + glob: "*.rb" run: bundle exec rubocop {staged_files} eslint: - glob: '*.{js,es6}' + glob: "*.{js,es6}" run: yarn eslint --ext .es6 -f compact {staged_files} yaml-syntax: - glob: '*.{yaml,yml}' + glob: "*.{yaml,yml}" run: bundle exec yaml-lint {staged_files} commands: &commands bundle-install: files: git diff --name-only HEAD master - glob: '{Gemfile,Gemfile.lock,*.gemspec}' + glob: "{Gemfile,Gemfile.lock,*.gemspec}" run: bundle install yarn-install: files: git diff --name-only HEAD master - glob: '{package.json,yarn.lock}' + glob: "{package.json,yarn.lock}" run: yarn install post-checkout: @@ -36,7 +36,8 @@ lints: rubocop: run: bundle exec rubocop --parallel prettier: - run: yarn prettier --list-different app/assets/stylesheets/**/*.scss app/assets/javascripts/**/*.es6 test/javascripts/**/*.es6 + glob: "{app/assets/stylesheets/**/*.scss,app/assets/javascripts/**/*.es6,test/javascripts/**/*.es6}" + run: yarn prettier --list-different {all_files} eslint-assets: run: yarn eslint --ext .es6 app/assets/javascripts eslint-test: diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index ea6afa0192..74b5d403e9 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -84,7 +84,7 @@ class AdminUserIndexQuery .human_users .joins(:user_profile, :user_stat) .where("users.created_at <= ?", 1.day.ago) - .where("LENGTH(COALESCE(user_profiles.bio_raw, '')) > 0") + .where("LENGTH(COALESCE(user_profiles.bio_raw, user_profiles.website, '')) > 0") .where("user_stats.posts_read_count <= 1 AND user_stats.topics_entered <= 1") end diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb index 3535f714f9..3bc8957ea3 100644 --- a/lib/auth/default_current_user_provider.rb +++ b/lib/auth/default_current_user_provider.rb @@ -164,6 +164,9 @@ class Auth::DefaultCurrentUserProvider unstage_user(user) make_developer_admin(user) enable_bootstrap_mode(user) + + UserAuthToken.enforce_session_count_limit!(user.id) + @env[CURRENT_USER_KEY] = user end @@ -284,6 +287,17 @@ class Auth::DefaultCurrentUserProvider if api_key = ApiKey.active.where(key: api_key_value).includes(:user).first api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME] + # Check for deprecated api auth + if !header_api_key? + if request.path == "/admin/email/handle_mail" + # Notify admins that the mail receiver is still using query auth and to update + AdminDashboardData.add_problem_message('dashboard.update_mail_receiver', 1.day) + else + # Notify admins of deprecated auth method + AdminDashboardData.add_problem_message('dashboard.deprecated_api_usage', 1.day) + end + end + if api_key.allowed_ips.present? && !api_key.allowed_ips.any? { |ip| ip.include?(request.ip) } Rails.logger.warn("[Unauthorized API Access] username: #{api_username}, IP address: #{request.ip}") return nil diff --git a/lib/auth/github_authenticator.rb b/lib/auth/github_authenticator.rb index b59030fe06..96fe5bfa7a 100644 --- a/lib/auth/github_authenticator.rb +++ b/lib/auth/github_authenticator.rb @@ -38,7 +38,7 @@ class Auth::GithubAuthenticator < Auth::Authenticator def valid?() @validator.validate_each(self, :email, @email) - return errors.blank? + errors.blank? end end diff --git a/lib/autospec/manager.rb b/lib/autospec/manager.rb index 6c3e88a951..cefce3e76a 100644 --- a/lib/autospec/manager.rb +++ b/lib/autospec/manager.rb @@ -18,6 +18,7 @@ class Autospec::Manager def initialize(opts = {}) @opts = opts @debug = opts[:debug] + @auto_run_all = ENV["AUTO_RUN_ALL"] != "0" @queue = [] @mutex = Mutex.new @signal = ConditionVariable.new @@ -42,12 +43,13 @@ class Autospec::Manager exit end - ensure_all_specs_will_run + ensure_all_specs_will_run if @auto_run_all start_runners start_service_queue listen_for_changes puts "Press [ENTER] to stop the current run" + puts "Press [ENTER] while stopped to run all specs" unless @auto_run_all while @runners.any?(&:running?) STDIN.gets process_queue @@ -138,7 +140,7 @@ class Autospec::Manager has_failed = true if result > 0 focus_on_failed_tests(current) - ensure_all_specs_will_run(runner) + ensure_all_specs_will_run(runner) if @auto_run_all end end @@ -343,7 +345,7 @@ class Autospec::Manager end # push run all specs to end of queue in correct order - ensure_all_specs_will_run(runner) + ensure_all_specs_will_run(runner) if @auto_run_all end puts "@@@@@@@@@@@@ specs queued" if @debug puts "@@@@@@@@@@@@ #{@queue}" if @debug @@ -364,8 +366,9 @@ class Autospec::Manager puts puts if specs.length == 0 - puts "No specs have failed yet! " + puts "No specs have failed yet! Aborting anyway" puts + abort_runners else puts "The following specs have failed:" specs.each { |s| puts s } diff --git a/lib/autospec/simple_runner.rb b/lib/autospec/simple_runner.rb index 816a499b3e..1f944d906f 100644 --- a/lib/autospec/simple_runner.rb +++ b/lib/autospec/simple_runner.rb @@ -32,7 +32,7 @@ module Autospec end # launch rspec - Dir.chdir(Rails.root) do + Dir.chdir(Rails.root) do # rubocop:disable DiscourseCops/NoChdir because this is not part of the app env = { "RAILS_ENV" => "test" } if specs.split(' ').any? { |s| s =~ /^(.\/)?plugins/ } env["LOAD_PLUGINS"] = "1" diff --git a/lib/backup_restore/backuper.rb b/lib/backup_restore/backuper.rb index 1306d365e7..0770af2c86 100644 --- a/lib/backup_restore/backuper.rb +++ b/lib/backup_restore/backuper.rb @@ -245,12 +245,11 @@ module BackupRestore Discourse::Utils.execute_command('tar', '--create', '--file', tar_filename, '--files-from', '/dev/null') log "Archiving data dump..." - FileUtils.cd(File.dirname(@dump_filename)) do - Discourse::Utils.execute_command( - 'tar', '--append', '--dereference', '--file', tar_filename, File.basename(@dump_filename), - failure_message: "Failed to archive data dump." - ) - end + Discourse::Utils.execute_command( + 'tar', '--append', '--dereference', '--file', tar_filename, File.basename(@dump_filename), + failure_message: "Failed to archive data dump.", + chdir: File.dirname(@dump_filename) + ) add_local_uploads_to_archive(tar_filename) add_remote_uploads_to_archive(tar_filename) if SiteSetting.Upload.enable_s3_uploads @@ -268,17 +267,16 @@ module BackupRestore log "Archiving uploads..." upload_directory = "uploads/" + @current_db - FileUtils.cd(File.join(Rails.root, "public")) do - if File.directory?(upload_directory) - exclude_optimized = SiteSetting.include_thumbnails_in_backups ? '' : "--exclude=#{upload_directory}/optimized" + if File.directory?(File.join(Rails.root, "public", upload_directory)) + exclude_optimized = SiteSetting.include_thumbnails_in_backups ? '' : "--exclude=#{upload_directory}/optimized" - Discourse::Utils.execute_command( - 'tar', '--append', '--dereference', exclude_optimized, '--file', tar_filename, upload_directory, - failure_message: "Failed to archive uploads.", success_status_codes: [0, 1] - ) - else - log "No local uploads found. Skipping archiving of local uploads..." - end + Discourse::Utils.execute_command( + 'tar', '--append', '--dereference', exclude_optimized, '--file', tar_filename, upload_directory, + failure_message: "Failed to archive uploads.", success_status_codes: [0, 1], + chdir: File.join(Rails.root, "public") + ) + else + log "No local uploads found. Skipping archiving of local uploads..." end end @@ -294,30 +292,29 @@ module BackupRestore upload_directory = File.join("uploads", @current_db) count = 0 - FileUtils.cd(@tmp_directory) do - Upload.find_each do |upload| - next if upload.local? - filename = File.join(@tmp_directory, upload_directory, store.get_path_for_upload(upload)) + Upload.find_each do |upload| + next if upload.local? + filename = File.join(@tmp_directory, upload_directory, store.get_path_for_upload(upload)) - begin - FileUtils.mkdir_p(File.dirname(filename)) - store.download_file(upload, filename) - rescue StandardError => ex - log "Failed to download file with upload ID #{upload.id} from S3", ex - end - - if File.exists?(filename) - Discourse::Utils.execute_command( - 'tar', '--append', '--file', tar_filename, upload_directory, - failure_message: "Failed to add #{upload.original_filename} to archive.", success_status_codes: [0, 1] - ) - - File.delete(filename) - end - - count += 1 - log "#{count} files have already been downloaded. Still downloading..." if count % 500 == 0 + begin + FileUtils.mkdir_p(File.dirname(filename)) + store.download_file(upload, filename) + rescue StandardError => ex + log "Failed to download file with upload ID #{upload.id} from S3", ex end + + if File.exists?(filename) + Discourse::Utils.execute_command( + 'tar', '--append', '--file', tar_filename, upload_directory, + failure_message: "Failed to add #{upload.original_filename} to archive.", success_status_codes: [0, 1], + chdir: @tmp_directory + ) + + File.delete(filename) + end + + count += 1 + log "#{count} files have already been downloaded. Still downloading..." if count % 500 == 0 end log "No uploads found on S3. Skipping archiving of uploads stored on S3..." if count == 0 diff --git a/lib/backup_restore/restorer.rb b/lib/backup_restore/restorer.rb index 98ad91c1c5..c76685c659 100644 --- a/lib/backup_restore/restorer.rb +++ b/lib/backup_restore/restorer.rb @@ -432,29 +432,28 @@ module BackupRestore public_uploads_path = File.join(Rails.root, "public") - FileUtils.cd(public_uploads_path) do - FileUtils.mkdir_p("uploads") + FileUtils.mkdir_p(File.join(public_uploads_path, "uploads")) - tmp_uploads_path = Dir.glob(File.join(@tmp_directory, "uploads", "*")).first - return if tmp_uploads_path.blank? - previous_db_name = BackupMetadata.value_for("db_name") || File.basename(tmp_uploads_path) - current_db_name = RailsMultisite::ConnectionManagement.current_db - optimized_images_exist = File.exist?(File.join(tmp_uploads_path, 'optimized')) + tmp_uploads_path = Dir.glob(File.join(@tmp_directory, "uploads", "*")).first + return if tmp_uploads_path.blank? + previous_db_name = BackupMetadata.value_for("db_name") || File.basename(tmp_uploads_path) + current_db_name = RailsMultisite::ConnectionManagement.current_db + optimized_images_exist = File.exist?(File.join(tmp_uploads_path, 'optimized')) - Discourse::Utils.execute_command( - 'rsync', '-avp', '--safe-links', "#{tmp_uploads_path}/", "uploads/#{current_db_name}/", - failure_message: "Failed to restore uploads." - ) + Discourse::Utils.execute_command( + 'rsync', '-avp', '--safe-links', "#{tmp_uploads_path}/", "uploads/#{current_db_name}/", + failure_message: "Failed to restore uploads.", + chdir: public_uploads_path + ) - remap_uploads(previous_db_name, current_db_name) + remap_uploads(previous_db_name, current_db_name) - if SiteSetting.Upload.enable_s3_uploads - migrate_to_s3 - remove_local_uploads(File.join(public_uploads_path, "uploads/#{current_db_name}")) - end - - generate_optimized_images unless optimized_images_exist + if SiteSetting.Upload.enable_s3_uploads + migrate_to_s3 + remove_local_uploads(File.join(public_uploads_path, "uploads/#{current_db_name}")) end + + generate_optimized_images unless optimized_images_exist end def remap_uploads(previous_db_name, current_db_name) diff --git a/lib/content_security_policy/default.rb b/lib/content_security_policy/default.rb index 1e056f78bb..b326ee57f9 100644 --- a/lib/content_security_policy/default.rb +++ b/lib/content_security_policy/default.rb @@ -45,13 +45,13 @@ class ContentSecurityPolicy def script_src [ - :unsafe_eval, :report_sample, "#{base_url}/logs/", "#{base_url}/sidekiq/", "#{base_url}/mini-profiler-resources/", *script_assets ].tap do |sources| + sources << :unsafe_eval if SiteSetting.content_security_policy_allow_unsafe_eval sources << 'https://www.google-analytics.com/analytics.js' if SiteSetting.ga_universal_tracking_code.present? sources << 'https://www.googletagmanager.com/gtm.js' if SiteSetting.gtm_container_id.present? end diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb index 78591ee254..3c0f2c1aa1 100644 --- a/lib/cooked_post_processor.rb +++ b/lib/cooked_post_processor.rb @@ -208,9 +208,7 @@ class CookedPostProcessor # minus emojis @doc.css("img.emoji") - # minus oneboxed images - oneboxed_images - - # minus images inside quotes - @doc.css(".quote img") + oneboxed_images end def extract_images_for_post @@ -281,6 +279,10 @@ class CookedPostProcessor absolute_url = url absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ /^\/[^\/]/ + if url&.start_with?("/secure-media-uploads/") + absolute_url = Discourse.store.signed_url_for_path(url.sub("/secure-media-uploads/", "")) + end + return unless absolute_url # FastImage fails when there's no scheme @@ -344,7 +346,8 @@ class CookedPostProcessor end end - add_lightbox!(img, original_width, original_height, upload, cropped: crop) + add_lightbox!(img, original_width, original_height, upload, cropped: crop) if img.ancestors('.quote').blank? + optimize_image!(img, upload, cropped: crop) if upload end def loading_image(upload) @@ -369,6 +372,39 @@ class CookedPostProcessor .each { |r| yield r if r > 1 } end + def optimize_image!(img, upload, cropped: false) + w, h = img["width"].to_i, img["height"].to_i + + # note: optimize_urls cooks the src and data-small-upload further after this + thumbnail = upload.thumbnail(w, h) + if thumbnail && thumbnail.filesize.to_i < upload.filesize + img["src"] = thumbnail.url + + srcset = +"" + + each_responsive_ratio do |ratio| + resized_w = (w * ratio).to_i + resized_h = (h * ratio).to_i + + if !cropped && upload.width && resized_w > upload.width + cooked_url = UrlHelper.cook_url(upload.url, secure: @post.with_secure_media?) + srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" + elsif t = upload.thumbnail(resized_w, resized_h) + cooked_url = UrlHelper.cook_url(t.url, secure: @post.with_secure_media?) + srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" + end + + img["srcset"] = "#{UrlHelper.cook_url(img["src"], secure: @post.with_secure_media?)}#{srcset}" if srcset.present? + end + else + img["src"] = upload.url + end + + if small_upload = loading_image(upload) + img["data-small-upload"] = small_upload.url + end + end + def add_lightbox!(img, original_width, original_height, upload, cropped: false) # first, create a div to hold our lightbox lightbox = create_node("div", LIGHTBOX_WRAPPER_CSS_CLASS) @@ -376,7 +412,8 @@ class CookedPostProcessor lightbox.add_child(img) # then, the link to our larger image - a = create_link_node("lightbox", img["src"]) + src = UrlHelper.cook_url(img["src"], secure: @post.with_secure_media?) + a = create_link_node("lightbox", src) img.add_next_sibling(a) if upload @@ -385,39 +422,6 @@ class CookedPostProcessor a.add_child(img) - # replace the image by its thumbnail - w, h = img["width"].to_i, img["height"].to_i - - if upload - thumbnail = upload.thumbnail(w, h) - if thumbnail && thumbnail.filesize.to_i < upload.filesize - img["src"] = thumbnail.url - - srcset = +"" - - each_responsive_ratio do |ratio| - resized_w = (w * ratio).to_i - resized_h = (h * ratio).to_i - - if !cropped && upload.width && resized_w > upload.width - cooked_url = UrlHelper.cook_url(upload.url) - srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" - elsif t = upload.thumbnail(resized_w, resized_h) - cooked_url = UrlHelper.cook_url(t.url) - srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" - end - - img["srcset"] = "#{UrlHelper.cook_url(img["src"])}#{srcset}" if srcset.present? - end - else - img["src"] = upload.url - end - - if small_upload = loading_image(upload) - img["data-small-upload"] = small_upload.url - end - end - # then, some overlay informations meta = create_node("div", "meta") img.add_next_sibling(meta) @@ -437,7 +441,7 @@ class CookedPostProcessor def get_filename(upload, src) return File.basename(src) unless upload return upload.original_filename unless upload.original_filename =~ /^blob(\.png)?$/i - return I18n.t("upload.pasted_image_filename") + I18n.t("upload.pasted_image_filename") end def create_node(tag_name, klass) @@ -595,7 +599,7 @@ class CookedPostProcessor %w{src data-small-upload}.each do |selector| @doc.css("img[#{selector}]").each do |img| - img[selector] = UrlHelper.cook_url(img[selector].to_s) + img[selector] = UrlHelper.cook_url(img[selector].to_s, secure: @post.with_secure_media?) end end end diff --git a/lib/discourse.rb b/lib/discourse.rb index 1a78d0d0ed..d4b6b4eb68 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -24,20 +24,52 @@ module Discourse end class Utils - def self.execute_command(*command, failure_message: "", success_status_codes: [0], chdir: ".") - stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + # Usage: + # Discourse::Utils.execute_command("pwd", chdir: 'mydirectory') + # or with a block + # Discourse::Utils.execute_command(chdir: 'mydirectory') do |runner| + # runner.exec("pwd") + # end + def self.execute_command(*command, **args) + runner = CommandRunner.new(**args) - if !status.exited? || !success_status_codes.include?(status.exitstatus) - failure_message = "#{failure_message}\n" if !failure_message.blank? - raise "#{caller[0]}: #{failure_message}#{stderr}" + if block_given? + raise RuntimeError.new("Cannot pass command and block to execute_command") if command.present? + yield runner + else + runner.exec(*command) end - - stdout end def self.pretty_logs(logs) logs.join("\n".freeze) end + + private + + class CommandRunner + def initialize(**init_params) + @init_params = init_params + end + + def exec(*command, **exec_params) + raise RuntimeError.new("Cannot specify same parameters at block and command level") if (@init_params.keys & exec_params.keys).present? + execute_command(*command, **@init_params.merge(exec_params)) + end + + private + + def execute_command(*command, failure_message: "", success_status_codes: [0], chdir: ".") + stdout, stderr, status = Open3.capture3(*command, chdir: chdir) + + if !status.exited? || !success_status_codes.include?(status.exitstatus) + failure_message = "#{failure_message}\n" if !failure_message.blank? + raise "#{caller[0]}: #{failure_message}#{stderr}" + end + + stdout + end + end end # Log an exception. @@ -129,11 +161,11 @@ module Discourse end def self.top_menu_items - @top_menu_items ||= Discourse.filters + [:category, :categories, :top] + @top_menu_items ||= Discourse.filters + [:categories, :top] end def self.anonymous_top_menu_items - @anonymous_top_menu_items ||= Discourse.anonymous_filters + [:category, :categories, :top] + @anonymous_top_menu_items ||= Discourse.anonymous_filters + [:categories, :top] end PIXEL_RATIOS ||= [1, 1.5, 2, 3] @@ -152,29 +184,8 @@ module Discourse end def self.activate_plugins! - all_plugins = Plugin::Instance.find_all("#{Rails.root}/plugins") - - if Rails.env.development? - plugin_hash = Digest::SHA1.hexdigest(all_plugins.map { |p| p.path }.sort.join('|')) - hash_file = "#{Rails.root}/tmp/plugin-hash" - - old_hash = begin - File.read(hash_file) - rescue Errno::ENOENT - end - - if old_hash && old_hash != plugin_hash - puts "WARNING: It looks like your discourse plugins have recently changed." - puts "It is highly recommended to remove your `tmp` directory, otherwise" - puts "plugins might not work." - puts - else - File.write(hash_file, plugin_hash) - end - end - @plugins = [] - all_plugins.each do |p| + Plugin::Instance.find_all("#{Rails.root}/plugins").each do |p| v = p.metadata.required_version || Discourse::VERSION::STRING if Discourse.has_needed_version?(Discourse::VERSION::STRING, v) p.activate! diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 002c7f3659..50faf123a7 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -2,11 +2,11 @@ module DiscourseTagging - TAGS_FIELD_NAME = "tags" - TAGS_FILTER_REGEXP = /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> - TAGS_STAFF_CACHE_KEY = "staff_tag_names" + TAGS_FIELD_NAME ||= "tags" + TAGS_FILTER_REGEXP ||= /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> + TAGS_STAFF_CACHE_KEY ||= "staff_tag_names" - TAG_GROUP_TAG_IDS_SQL = <<-SQL + TAG_GROUP_TAG_IDS_SQL ||= <<-SQL SELECT tag_id FROM tag_group_memberships tgm INNER JOIN tag_groups tg @@ -17,6 +17,12 @@ module DiscourseTagging if guardian.can_tag?(topic) tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || [] + if !tag_names.empty? + Tag.where_name(tag_names).joins(:target_tag).includes(:target_tag).each do |tag| + tag_names[tag_names.index(tag.name)] = tag.target_tag.name + end + end + old_tag_names = topic.tags.pluck(:name) || [] new_tag_names = tag_names - old_tag_names removed_tag_names = old_tag_names - tag_names @@ -49,12 +55,14 @@ module DiscourseTagging # guardian is explicitly nil cause we don't want to strip all # staff tags that already passed validation tags = filter_allowed_tags( - Tag.where_name(tag_names), nil, # guardian for_topic: true, category: category, - selected_tags: tag_names - ).to_a + selected_tags: tag_names, + only_tag_names: tag_names + ) + + tags = Tag.where(id: tags.map(&:id)).all.to_a if tags.size > 0 if tags.size < tag_names.size && (category.nil? || category.allow_global_tags || (category.tags.count == 0 && category.tag_groups.count == 0)) tag_names.each do |name| @@ -141,17 +149,122 @@ module DiscourseTagging end end + TAG_GROUP_RESTRICTIONS_SQL ||= <<~SQL + tag_group_restrictions AS ( + SELECT t.name as tag_name, t.id as tag_id, tgm.id as tgm_id, tg.id as tag_group_id, tg.parent_tag_id as parent_tag_id, + tg.one_per_topic as one_per_topic + FROM tags t + LEFT OUTER JOIN tag_group_memberships tgm ON tgm.tag_id = t.id /*and_name_like*/ + LEFT OUTER JOIN tag_groups tg ON tg.id = tgm.tag_group_id + ) + SQL + + CATEGORY_RESTRICTIONS_SQL ||= <<~SQL + category_restrictions AS ( + SELECT t.name as tag_name, t.id as tag_id, ct.id as ct_id, ct.category_id as category_id + FROM tags t + INNER JOIN category_tags ct ON t.id = ct.tag_id /*and_name_like*/ + + UNION + + SELECT t.name as tag_name, t.id as tag_id, ctg.id as ctg_id, ctg.category_id as category_id + FROM tags t + INNER JOIN tag_group_memberships tgm ON tgm.tag_id = t.id /*and_name_like*/ + INNER JOIN category_tag_groups ctg ON tgm.tag_group_id = ctg.tag_group_id + ) + SQL + + PERMITTED_TAGS_SQL ||= <<~SQL + permitted_tag_groups AS ( + SELECT tg.id as tag_group_id, tgp.group_id as group_id, tgp.permission_type as permission_type + FROM tags t + INNER JOIN tag_group_memberships tgm ON tgm.tag_id = t.id /*and_name_like*/ + INNER JOIN tag_groups tg ON tg.id = tgm.tag_group_id + INNER JOIN tag_group_permissions tgp + ON tg.id = tgp.tag_group_id + AND tgp.group_id = #{Group::AUTO_GROUPS[:everyone]} + AND tgp.permission_type = #{TagGroupPermission.permission_types[:full]} + ) + SQL + # Options: # term: a search term to filter tags by name + # order: order by for the query + # limit: max number of results # category: a Category to which the object being tagged belongs # for_input: result is for an input field, so only show permitted tags # for_topic: results are for tagging a topic # selected_tags: an array of tag names that are in the current selection - def self.filter_allowed_tags(query, guardian, opts = {}) + # only_tag_names: limit results to tags with these names + # exclude_synonyms: exclude synonyms from results + def self.filter_allowed_tags(guardian, opts = {}) selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : [] + category = opts[:category] + category_has_restricted_tags = category ? (category.tags.count > 0 || category.tag_groups.count > 0) : false - if !opts[:for_topic] && !selected_tag_ids.empty? - query = query.where('tags.id NOT IN (?)', selected_tag_ids) + # If guardian is nil, it means the caller doesn't want tags to be filtered + # based on guardian rules. Use the same rules as for staff users. + filter_for_non_staff = !guardian.nil? && !guardian.is_staff? + + builder_params = {} + + unless selected_tag_ids.empty? + builder_params[:selected_tag_ids] = selected_tag_ids + end + + sql = +"WITH #{TAG_GROUP_RESTRICTIONS_SQL}, #{CATEGORY_RESTRICTIONS_SQL}" + if (opts[:for_input] || opts[:for_topic]) && filter_for_non_staff + sql << ", #{PERMITTED_TAGS_SQL} " + end + + outer_join = category.nil? || category.allow_global_tags || !category_has_restricted_tags + + sql << <<~SQL + SELECT t.id, t.name, t.topic_count, t.pm_topic_count, + tgr.tgm_id as tgm_id, tgr.tag_group_id as tag_group_id, tgr.parent_tag_id as parent_tag_id, + tgr.one_per_topic as one_per_topic, t.target_tag_id + FROM tags t + INNER JOIN tag_group_restrictions tgr ON tgr.tag_id = t.id + #{outer_join ? "LEFT OUTER" : "INNER"} + JOIN category_restrictions cr ON t.id = cr.tag_id + /*where*/ + /*order_by*/ + /*limit*/ + SQL + + builder = DB.build(sql) + + if !opts[:for_topic] && builder_params[:selected_tag_ids] + builder.where("id NOT IN (:selected_tag_ids)") + end + + if opts[:only_tag_names] + builder.where("LOWER(name) IN (:only_tag_names)") + builder_params[:only_tag_names] = opts[:only_tag_names].map(&:downcase) + end + + # parent tag requirements + if opts[:for_input] + builder.where( + builder_params[:selected_tag_ids] ? + "tgm_id IS NULL OR parent_tag_id IS NULL OR parent_tag_id IN (:selected_tag_ids)" : + "tgm_id IS NULL OR parent_tag_id IS NULL" + ) + end + + if category && category_has_restricted_tags + builder.where( + category.allow_global_tags ? "category_id = ? OR category_id IS NULL" : "category_id = ?", + category.id + ) + elsif category || opts[:for_input] || opts[:for_topic] + # tags not restricted to any categories + builder.where("category_id IS NULL") + end + + if filter_for_non_staff && (opts[:for_input] || opts[:for_topic]) + # exclude staff-only tag groups + builder.where("tag_group_id IS NULL OR tag_group_id IN (SELECT tag_group_id FROM permitted_tag_groups)") end term = opts[:term] @@ -159,128 +272,64 @@ module DiscourseTagging term = term.gsub("_", "\\_") clean_tag(term) term.downcase! - query = query.where('lower(tags.name) like ?', "%#{term}%") + builder.where("LOWER(name) LIKE :term") + builder_params[:cleaned_term] = term + builder_params[:term] = "%#{term}%" + sql.gsub!("/*and_name_like*/", "AND LOWER(t.name) LIKE :term") + else + sql.gsub!("/*and_name_like*/", "") end - # Filters for category-specific tags: - category = opts[:category] - - if opts[:for_input] && !guardian.nil? && !guardian.is_staff? && category&.required_tag_group + if opts[:for_input] && filter_for_non_staff && category&.required_tag_group required_tag_ids = category.required_tag_group.tags.pluck(:id) if (required_tag_ids & selected_tag_ids).size < category.min_tags_from_required_group - query = query.where('tags.id IN (?)', required_tag_ids) + builder.where("id IN (?)", required_tag_ids) end end - if category && (category.tags.count > 0 || category.tag_groups.count > 0) - if category.allow_global_tags - # Select tags that: - # * are restricted to the given category - # * belong to no tag groups and aren't restricted to other categories - # * belong to tag groups that are not restricted to any categories - query = query.where(<<~SQL, category.tag_groups.pluck(:id), category.id) - tags.id IN ( - SELECT t.id FROM tags t - LEFT JOIN category_tags ct ON t.id = ct.tag_id - LEFT JOIN ( - SELECT xtgm.tag_id, xtgm.tag_group_id - FROM tag_group_memberships xtgm - INNER JOIN category_tag_groups ctg - ON xtgm.tag_group_id = ctg.tag_group_id - ) AS tgm ON t.id = tgm.tag_id - WHERE (tgm.tag_group_id IS NULL AND ct.category_id IS NULL) - OR tgm.tag_group_id IN (?) - OR ct.category_id = ? - ) - SQL - else - # Select only tags that are restricted to the given category - query = query.where(<<~SQL, category.id, category.tag_groups.pluck(:id)) - tags.id IN ( - SELECT tag_id FROM category_tags WHERE category_id = ? - UNION - SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?) - ) - SQL - end - elsif opts[:for_input] || opts[:for_topic] || category - # exclude tags that are restricted to other categories - if CategoryTag.exists? - query = query.where("tags.id NOT IN (SELECT tag_id FROM category_tags)") - end + if filter_for_non_staff + # remove hidden tags + builder.where(<<~SQL, Group::AUTO_GROUPS[:everyone]) + id NOT IN ( + SELECT tag_id + FROM tag_group_memberships tgm + WHERE tag_group_id NOT IN (SELECT tag_group_id FROM tag_group_permissions WHERE group_id = ?) + ) + SQL + end - if CategoryTagGroup.exists? - tag_group_ids = CategoryTagGroup.pluck(:tag_group_id).uniq - query = query.where("tags.id NOT IN (SELECT tag_id FROM tag_group_memberships WHERE tag_group_id IN (?))", tag_group_ids) + if builder_params[:selected_tag_ids] && (opts[:for_input] || opts[:for_topic]) + one_tag_per_group_ids = DB.query(<<~SQL, builder_params[:selected_tag_ids]).map(&:id) + SELECT DISTINCT(tg.id) + FROM tag_groups tg + INNER JOIN tag_group_memberships tgm ON tg.id = tgm.tag_group_id AND tgm.tag_id IN (?) + WHERE tg.one_per_topic + SQL + + if !one_tag_per_group_ids.empty? + builder.where( + "tag_group_id IS NULL OR tag_group_id NOT IN (?) OR id IN (:selected_tag_ids)", + one_tag_per_group_ids + ) end end - if opts[:for_input] || opts[:for_topic] - unless guardian.nil? || guardian.is_staff? - all_staff_tags = DiscourseTagging.staff_tag_names - query = query.where('tags.name NOT IN (?)', all_staff_tags) if all_staff_tags.present? - end + if opts[:exclude_synonyms] + builder.where("target_tag_id IS NULL") end - if opts[:for_input] - # exclude tag groups that have a parent tag which is missing from selected_tags - - if selected_tag_ids.empty? - sql = "tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE tg.parent_tag_id IS NOT NULL)" - query = query.where(sql) - else - exclude_group_ids = one_per_topic_group_ids(selected_tag_ids) - - if exclude_group_ids.empty? - # tags that don't belong to groups that require a parent tag, - # and tags that belong to groups with parent tag selected - query = query.where(<<~SQL, selected_tag_ids, selected_tag_ids) - tags.id NOT IN ( - #{TAG_GROUP_TAG_IDS_SQL} - WHERE tg.parent_tag_id NOT IN (?) - ) - OR tags.id IN ( - #{TAG_GROUP_TAG_IDS_SQL} - WHERE tg.parent_tag_id IN (?) - ) - SQL - else - # It's possible that the selected tags violate some one-tag-per-group restrictions, - # so filter them out by picking one from each group. - limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id') - .where(tag_id: selected_tag_ids) - .where(tag_group_id: exclude_group_ids) - .map(&:tag_id) - sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.parent_tag_id NOT IN (?) OR tg.id in (?))) OR tags.id IN (?))" - query = query.where(sql, selected_tag_ids, exclude_group_ids, limit_tag_ids) - end - end - elsif opts[:for_topic] && !selected_tag_ids.empty? - # One tag per group restriction - exclude_group_ids = one_per_topic_group_ids(selected_tag_ids) - - unless exclude_group_ids.empty? - limit_tag_ids = TagGroupMembership.select('distinct on (tag_group_id) tag_id') - .where(tag_id: selected_tag_ids) - .where(tag_group_id: exclude_group_ids) - .map(&:tag_id) - sql = "(tags.id NOT IN (#{TAG_GROUP_TAG_IDS_SQL} WHERE (tg.id in (?))) OR tags.id IN (?))" - query = query.where(sql, exclude_group_ids, limit_tag_ids) - end + if opts[:exclude_has_synonyms] + builder.where("id NOT IN (SELECT target_tag_id FROM tags WHERE target_tag_id IS NOT NULL)") end - if guardian.nil? || guardian.is_staff? - query - else - filter_visible(query, guardian) + builder.limit(opts[:limit]) if opts[:limit] + if opts[:order] + builder.order_by(opts[:order]) + elsif opts[:order_search_results] && !term.blank? + builder.order_by("lower(name) = lower(:cleaned_term) DESC, topic_count DESC") end - end - def self.one_per_topic_group_ids(selected_tag_ids) - TagGroup.where(one_per_topic: true) - .joins(:tag_group_memberships) - .where('tag_group_memberships.tag_id in (?)', selected_tag_ids) - .pluck(:id) + result = builder.query(builder_params).uniq { |t| t.id } end def self.filter_visible(query, guardian = nil) @@ -342,22 +391,35 @@ module DiscourseTagging tag_names.uniq! end - return opts[:unlimited] ? tag_names : tag_names[0...SiteSetting.max_tags_per_topic] + opts[:unlimited] ? tag_names : tag_names[0...SiteSetting.max_tags_per_topic] end def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {}) tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || [] if taggable.tags.pluck(:name).sort != tag_names.sort taggable.tags = Tag.where_name(tag_names).all - if taggable.tags.size < tag_names.size - new_tag_names = tag_names - taggable.tags.map(&:name) - new_tag_names.each do |name| - taggable.tags << Tag.create(name: name) - end + new_tag_names = taggable.tags.size < tag_names.size ? tag_names - taggable.tags.map(&:name) : [] + taggable.tags << Tag.where(target_tag_id: taggable.tags.map(&:id)).all + new_tag_names.each do |name| + taggable.tags << Tag.create(name: name) end end end + # Returns true if all were added successfully, or an Array of the + # tags that failed to be added, with errors on each Tag. + def self.add_or_create_synonyms_by_name(target_tag, synonym_names) + tag_names = DiscourseTagging.tags_for_saving(synonym_names, Guardian.new(Discourse.system_user)) || [] + existing = Tag.where_name(tag_names).all + target_tag.synonyms << existing + (tag_names - target_tag.synonyms.map(&:name)).each do |name| + target_tag.synonyms << Tag.create(name: name) + end + successful = existing.select { |t| !t.errors.present? } + TopicTag.where(tag_id: successful.map(&:id)).update_all(tag_id: target_tag.id) + (existing - successful).presence || true + end + def self.muted_tags(user) return [] unless user TagUser.lookup(user, :muted).joins(:tag).pluck('tags.name') diff --git a/lib/email/authentication_results.rb b/lib/email/authentication_results.rb new file mode 100644 index 0000000000..c5ad6e2249 --- /dev/null +++ b/lib/email/authentication_results.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Email + class AuthenticationResults + attr_reader :results + + VERDICT = Enum.new( + :gray, + :pass, + :fail, + start: 0, + ) + + def initialize(headers) + authserv_id = SiteSetting.email_in_authserv_id + @results = Array(headers).map do |header| + parse_header(header.to_s) + end.filter do |result| + authserv_id.blank? || authserv_id == result[:authserv_id] + end + end + + def action + @action ||= calc_action + end + + def verdict + @verdict ||= calc_verdict + end + + private + + def calc_action + if verdict == :fail + :hide + else + :accept + end + end + + def calc_verdict + VERDICT[calc_dmarc] + end + + def calc_dmarc + verdict = VERDICT[:gray] + @results.each do |result| + result[:resinfo].each do |resinfo| + if resinfo[:method] == "dmarc" + v = VERDICT[resinfo[:result].to_sym].to_i + verdict = v if v > verdict + end + end + end + verdict = VERDICT[:gray] if SiteSetting.email_in_authserv_id.blank? && verdict == VERDICT[:pass] + verdict + end + + def parse_header(header) + # based on https://tools.ietf.org/html/rfc8601#section-2.2 + cfws = /\s*(\([^()]*\))?\s*/ + value = /(?:"([^"]*)")|(?:([^\s";]*))/ + authserv_id = value + authres_version = /\d+#{cfws}?/ + no_result = /#{cfws}?;#{cfws}?none/ + keyword = /([a-zA-Z0-9-]*[a-zA-Z0-9])/ + authres_payload = /\A#{cfws}?#{authserv_id}(?:#{cfws}#{authres_version})?(?:#{no_result}|([\S\s]*))/ + + method_version = authres_version + method = /#{keyword}\s*(?:#{cfws}?\/#{cfws}?#{method_version})?/ + result = keyword + methodspec = /#{cfws}?#{method}#{cfws}?=#{cfws}?#{result}/ + reasonspec = /reason#{cfws}?=#{cfws}?#{value}/ + resinfo = /#{cfws}?;#{methodspec}(?:#{cfws}#{reasonspec})?(?:#{cfws}([^;]*))?/ + + ptype = keyword + property = value + pvalue = /#{cfws}?#{value}#{cfws}?/ + propspec = /#{ptype}#{cfws}?\.#{cfws}?#{property}#{cfws}?=#{pvalue}/ + + authres_payload_match = authres_payload.match(header) + parsed_authserv_id = authres_payload_match[2] || authres_payload_match[3] + resinfo_val = authres_payload_match[-1] + + if resinfo_val + resinfo_scan = resinfo_val.scan(resinfo) + parsed_resinfo = resinfo_scan.map do |x| + { + method: x[2], + result: x[8], + reason: x[12] || x[13], + props: x[-1].scan(propspec).map do |y| + { + ptype: y[0], + property: y[4], + pvalue: y[8] || y[9] + } + end + } + end + end + + { + authserv_id: parsed_authserv_id, + resinfo: parsed_resinfo + } + end + + end +end diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 41035dc381..836405aae5 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -196,7 +196,14 @@ module Email end def hidden_reason_id - @hidden_reason_id ||= is_spam? ? Post.hidden_reasons[:email_spam_header_found] : nil + @hidden_reason_id ||= + if is_spam? + Post.hidden_reasons[:email_spam_header_found] + elsif auth_res_action == :hide + Post.hidden_reasons[:email_authentication_result_header] + else + nil + end end def log_and_validate_user(user) @@ -308,6 +315,10 @@ module Email end end + def auth_res_action + @auth_res_action ||= AuthenticationResults.new(@mail.header[:authentication_results]).action + end + def select_body text = nil html = nil diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 9354f52666..62f98a89c3 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -198,6 +198,7 @@ module Email style('code', 'background-color: #f1f1ff; padding: 2px 5px;') style('pre code', 'display: block; background-color: #f1f1ff; padding: 5px;') style('.featured-topic a', "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;") + style('.secure-image-notice', 'font-style: italic; background-color: #f1f1ff; padding: 5px;') style('.summary-email', "-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%") style('.previous-discussion', 'font-size: 17px; color: #444; margin-bottom:10px;') @@ -237,6 +238,7 @@ module Email def to_html strip_classes_and_ids replace_relative_urls + replace_secure_media_urls @fragment.to_html end @@ -284,6 +286,23 @@ module Email end end + def replace_secure_media_urls + @fragment.css('[href]').each do |a| + if a['href'][/secure-media-uploads/] + a.add_next_sibling "

#{I18n.t("emails.secure_media_placeholder")}

" + a.remove + end + end + + @fragment.search('img').each do |img| + next unless img['src'] + if img['src'][/secure-media-uploads/] + img.add_next_sibling "

#{I18n.t("emails.secure_media_placeholder")}

" + img.remove + end + end + end + def correct_first_body_margin @fragment.css('div.body p').each do |element| element['style'] = "margin-top:0; border: 0;" diff --git a/lib/emoji/db.json b/lib/emoji/db.json index 757aa6690f..ce808c9e8a 100644 --- a/lib/emoji/db.json +++ b/lib/emoji/db.json @@ -6619,6 +6619,10 @@ { "code": "1f90e", "name": "brown_heart" + }, + { + "code": "1f3f3-fe0f-200d-26a7", + "name": "transgender_flag" } ], "tonableEmojis": [ diff --git a/lib/emoji/groups.json b/lib/emoji/groups.json index 80286b1456..9fe4b12da3 100644 --- a/lib/emoji/groups.json +++ b/lib/emoji/groups.json @@ -845,10 +845,6 @@ "name": "woman", "diversity": true }, - { - "name": "blonde_woman", - "diversity": true - }, { "name": "woman_red_haired", "diversity": false @@ -865,6 +861,10 @@ "name": "woman_bald", "diversity": false }, + { + "name": "blonde_woman", + "diversity": true + }, { "name": "older_adult", "diversity": true diff --git a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb index 21e786665f..f56f99d602 100644 --- a/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb +++ b/lib/es6_module_transpiler/tilt/es6_module_transpiler_template.rb @@ -82,26 +82,6 @@ JS end end - def whitelisted?(path) - - @@whitelisted ||= Set.new( - ["discourse/models/nav-item", - "discourse/models/user-action", - "discourse/routes/discourse", - "discourse/models/category", - "discourse/models/trust-level", - "discourse/models/site", - "discourse/models/user", - "discourse/models/session", - "discourse/models/model", - "discourse/models/topic", - "discourse/models/post", - "discourse/views/grouped"] - ) - - @@whitelisted.include?(path) || path =~ /discourse\/mixins/ - end - def babel_transpile(source) klass = self.class klass.protect do @@ -139,39 +119,6 @@ JS @output = klass.v8.eval(source) end - # For backwards compatibility with plugins, for now export the Global format too. - # We should eventually have an upgrade system for plugins to use ES6 or some other - # resolve based API. - if whitelisted?(scope.logical_path) && - scope.logical_path =~ /(discourse|admin)\/(controllers|components|views|routes|mixins|models)\/(.*)/ - - type = Regexp.last_match[2] - file_name = Regexp.last_match[3].gsub(/[\-\/]/, '_') - class_name = file_name.classify - - # Rails removes pluralization when calling classify - if file_name.end_with?('s') && (!class_name.end_with?('s')) - class_name << "s" - end - require_name = module_name(scope.root_path, scope.logical_path) - - if require_name !~ /\-test$/ && require_name !~ /^discourse\/plugins\// - result = "#{class_name}#{type.classify}" - - # HAX - result = "Controller" if result == "ControllerController" - result = "Route" if result == "DiscourseRoute" - result = "View" if result == "ViewView" - - result = result.gsub(/Mixin$/, '') - result = result.gsub(/Model$/, '') - - if result != "PostMenuView" - @output << "\n\nDiscourse.#{result} = require('#{require_name}').default;\n" - end - end - end - @output end diff --git a/lib/file_helper.rb b/lib/file_helper.rb index 443cceb4bc..6d39ef5477 100644 --- a/lib/file_helper.rb +++ b/lib/file_helper.rb @@ -17,6 +17,10 @@ class FileHelper filename =~ supported_images_regexp end + def self.is_supported_media?(filename) + filename =~ supported_media_regexp + end + class FakeIO attr_accessor :status end @@ -132,8 +136,20 @@ class FileHelper @@supported_images ||= Set.new %w{jpg jpeg png gif svg ico} end + def self.supported_audio + @@supported_audio ||= Set.new %w{mp3 ogg wav m4a} + end + + def self.supported_video + @@supported_video ||= Set.new %w{mov mp4 webm ogv} + end + def self.supported_images_regexp @@supported_images_regexp ||= /\.(#{supported_images.to_a.join("|")})$/i end + def self.supported_media_regexp + media = supported_images | supported_audio | supported_video + @@supported_media_regexp ||= /\.(#{media.to_a.join("|")})$/i + end end diff --git a/lib/file_store/base_store.rb b/lib/file_store/base_store.rb index 6e8c53442d..0c4c14d2be 100644 --- a/lib/file_store/base_store.rb +++ b/lib/file_store/base_store.rb @@ -54,6 +54,10 @@ module FileStore not_implemented end + def s3_upload_host + not_implemented + end + def external? not_implemented end @@ -77,7 +81,11 @@ module FileStore if !file max_file_size_kb = [SiteSetting.max_image_size_kb, SiteSetting.max_attachment_size_kb].max.kilobytes - url = Discourse.store.cdn_url(upload.url) + + url = upload.secure? ? + Discourse.store.signed_url_for_path(upload.url) : + Discourse.store.cdn_url(upload.url) + url = SiteSetting.scheme + ":" + url if url =~ /^\/\// file = FileHelper.download( url, @@ -139,7 +147,13 @@ module FileStore FileUtils.mkdir_p(dir) unless Dir.exist?(dir) FileUtils.cp(file.path, path) # keep latest 500 files - `ls -tr #{CACHE_DIR} | head -n -#{CACHE_MAXIMUM_SIZE} | awk '$0="#{CACHE_DIR}"$0' | xargs rm -f` + processes = Open3.pipeline( + "ls -t #{CACHE_DIR}", + "tail -n +#{CACHE_MAXIMUM_SIZE + 1}", + "awk '$0=\"#{CACHE_DIR}\"$0'", + "xargs rm -f" + ) + raise "Error clearing old cache" if !processes.all?(&:success?) end private diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 2b1cc0a83c..bde900f5c1 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -21,13 +21,13 @@ module FileStore def store_upload(file, upload, content_type = nil) path = get_path_for_upload(upload) - url, upload.etag = store_file(file, path, filename: upload.original_filename, content_type: content_type, cache_locally: true, private: upload.private?) + url, upload.etag = store_file(file, path, filename: upload.original_filename, content_type: content_type, cache_locally: true, private_acl: upload.secure?) url end - def store_optimized_image(file, optimized_image, content_type = nil) + def store_optimized_image(file, optimized_image, content_type = nil, secure: false) path = get_path_for_optimized_image(optimized_image) - url, optimized_image.etag = store_file(file, path, content_type: content_type) + url, optimized_image.etag = store_file(file, path, content_type: content_type, private_acl: secure) url end @@ -42,12 +42,12 @@ module FileStore # cache file locally when needed cache_file(file, File.basename(path)) if opts[:cache_locally] options = { - acl: opts[:private] ? "private" : "public-read", + acl: opts[:private_acl] ? "private" : "public-read", cache_control: 'max-age=31556952, public, immutable', content_type: opts[:content_type].presence || MiniMime.lookup_by_filename(filename)&.content_type } # add a "content disposition" header for "attachments" - options[:content_disposition] = "attachment; filename=\"#{filename}\"" unless FileHelper.is_supported_image?(filename) + options[:content_disposition] = "attachment; filename=\"#{filename}\"" unless FileHelper.is_supported_media?(filename) path.prepend(File.join(upload_path, "/")) if Rails.configuration.multisite @@ -55,7 +55,7 @@ module FileStore path, etag = @s3_helper.upload(file, path, options) # return the upload url and etag - return File.join(absolute_base_url, path), etag + [File.join(absolute_base_url, path), etag] end def remove_file(url, path) @@ -88,6 +88,10 @@ module FileStore @absolute_base_url ||= SiteSetting.Upload.absolute_base_url end + def s3_upload_host + SiteSetting.Upload.s3_cdn_url.present? ? SiteSetting.Upload.s3_cdn_url : "https:#{absolute_base_url}" + end + def external? true end @@ -111,22 +115,9 @@ module FileStore end def url_for(upload, force_download: false) - if upload.private? || force_download - opts = { expires_in: S3Helper::DOWNLOAD_URL_EXPIRES_AFTER_SECONDS } - - if force_download - opts[:response_content_disposition] = ActionDispatch::Http::ContentDisposition.format( - disposition: "attachment", filename: upload.original_filename - ) - end - - obj = @s3_helper.object(get_upload_key(upload)) - url = obj.presigned_url(:get, opts) - else - url = upload.url - end - - url + upload.secure? || force_download ? + presigned_url(get_upload_key(upload), force_download: force_download, filename: upload.original_filename) : + upload.url end def cdn_url(url) @@ -136,6 +127,11 @@ module FileStore url.sub(File.join("#{schema}#{absolute_base_url}", folder), File.join(SiteSetting.Upload.s3_cdn_url, "/")) end + def signed_url_for_path(path) + key = path.sub(absolute_base_url + "/", "") + presigned_url(key) + end + def cache_avatar(avatar, user_id) source = avatar.url.sub(absolute_base_url + "/", "") destination = avatar_template(avatar, user_id).sub(absolute_base_url + "/", "") @@ -163,14 +159,15 @@ module FileStore end def update_upload_ACL(upload) - private_uploads = SiteSetting.prevent_anons_from_downloading_files key = get_upload_key(upload) + update_ACL(key, upload.secure?) - begin - @s3_helper.object(key).acl.put(acl: private_uploads ? "private" : "public-read") - rescue Aws::S3::Errors::NoSuchKey - Rails.logger.warn("Could not update ACL on upload with key: '#{key}'. Upload is missing.") + upload.optimized_images.find_each do |optimized_image| + optimized_image_key = get_path_for_optimized_image(optimized_image) + update_ACL(optimized_image_key, upload.secure?) end + + true end def download_file(upload, destination_path) @@ -179,6 +176,18 @@ module FileStore private + def presigned_url(url, force_download: false, filename: false) + opts = { expires_in: S3Helper::DOWNLOAD_URL_EXPIRES_AFTER_SECONDS } + if force_download && filename + opts[:response_content_disposition] = ActionDispatch::Http::ContentDisposition.format( + disposition: "attachment", filename: filename + ) + end + + obj = @s3_helper.object(url) + obj.presigned_url(:get, opts) + end + def get_upload_key(upload) if Rails.configuration.multisite File.join(upload_path, "/", get_path_for_upload(upload)) @@ -187,6 +196,14 @@ module FileStore end end + def update_ACL(key, secure) + begin + @s3_helper.object(key).acl.put(acl: secure ? "private" : "public-read") + rescue Aws::S3::Errors::NoSuchKey + Rails.logger.warn("Could not update ACL on upload with key: '#{key}'. Upload is missing.") + end + end + def list_missing(model, prefix) connection = ActiveRecord::Base.connection.raw_connection connection.exec('CREATE TEMP TABLE verified_ids(val integer PRIMARY KEY)') diff --git a/lib/final_destination.rb b/lib/final_destination.rb index cd124321e0..4177800ae6 100644 --- a/lib/final_destination.rb +++ b/lib/final_destination.rb @@ -37,6 +37,7 @@ class FinalDestination @opts = opts || {} @force_get_hosts = @opts[:force_get_hosts] || [] @preserve_fragment_url_hosts = @opts[:preserve_fragment_url_hosts] || [] + @force_custom_user_agent_hosts = @opts[:force_custom_user_agent_hosts] || [] @opts[:max_redirects] ||= 5 @opts[:lookup_ip] ||= lambda { |host| FinalDestination.lookup_ip(host) } @@ -66,6 +67,7 @@ class FinalDestination @timeout = @opts[:timeout] || nil @preserve_fragment_url = @preserve_fragment_url_hosts.any? { |host| hostname_matches?(host) } @validate_uri = @opts.fetch(:validate_uri) { true } + @user_agent = @force_custom_user_agent_hosts.any? { |host| hostname_matches?(host) } ? Onebox.options.user_agent : "Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36" end def self.connection_timeout @@ -82,7 +84,7 @@ class FinalDestination def request_headers result = { - "User-Agent" => "Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36", + "User-Agent" => @user_agent, "Accept" => "*/*", "Host" => @uri.hostname } diff --git a/lib/freedom_patches/active_record_attribute_methods.rb b/lib/freedom_patches/active_record_attribute_methods.rb index fbaf5afce6..815159ff4e 100644 --- a/lib/freedom_patches/active_record_attribute_methods.rb +++ b/lib/freedom_patches/active_record_attribute_methods.rb @@ -14,7 +14,7 @@ module ActiveRecord module ClassMethods # this is for Rails 5 def enforce_raw_sql_whitelist(*args) - return + nil end BLANK_ARRAY = [].freeze @@ -23,7 +23,7 @@ module ActiveRecord def disallow_raw_sql!(args, permit: nil) # we may consider moving to https://github.com/rails/rails/pull/33330 # once all frozen string hints are in place - return BLANK_ARRAY + BLANK_ARRAY end end end diff --git a/lib/freedom_patches/rails6.rb b/lib/freedom_patches/rails6.rb deleted file mode 100644 index cf2f96de05..0000000000 --- a/lib/freedom_patches/rails6.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -# see: https://github.com/rails/rails/pull/36949#issuecomment-530698779 -# -# Without this patch each time we close a DB connection we spin a thread - -module ::ActiveRecord - module ConnectionAdapters - class AbstractAdapter - class StaticThreadLocalVar - attr_reader :value - - def initialize(value) - @value = value - end - - def bind(value) - raise "attempting to change immutable local var" if value != @value - if block_given? - yield - end - end - end - - # we have no choice but to perform an aggressive patch here - # if we simply hook the method we will still call a finalizer - # on Concurrent::ThreadLocalVar - - def initialize(connection, logger = nil, config = {}) # :nodoc: - super() - - @connection = connection - @owner = nil - @instrumenter = ActiveSupport::Notifications.instrumenter - @logger = logger - @config = config - @pool = ActiveRecord::ConnectionAdapters::NullPool.new - @idle_since = Concurrent.monotonic_time - @visitor = arel_visitor - @statements = build_statement_pool - @lock = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new - - if self.class.type_cast_config_to_boolean(config.fetch(:prepared_statements) { true }) - @prepared_statement_status = Concurrent::ThreadLocalVar.new(true) - @visitor.extend(DetermineIfPreparableVisitor) - else - #@prepared_statement_status = Concurrent::ThreadLocalVar.new(false) - @prepared_statement_status = StaticThreadLocalVar.new(false) - end - - @advisory_locks_enabled = self.class.type_cast_config_to_boolean( - config.fetch(:advisory_locks, true) - ) - end - end - end -end diff --git a/lib/freedom_patches/translate_accelerator.rb b/lib/freedom_patches/translate_accelerator.rb index 26dff3594c..704da6ec4b 100644 --- a/lib/freedom_patches/translate_accelerator.rb +++ b/lib/freedom_patches/translate_accelerator.rb @@ -110,7 +110,7 @@ module I18n end if dup_options.present? - return translate_no_cache(key, options) + return translate_no_cache(key, **options) end locale ||= config.locale diff --git a/lib/guardian.rb b/lib/guardian.rb index d6c996937d..9920fa89b2 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -120,7 +120,7 @@ class Guardian def can_see?(obj) if obj see_method = method_name_for :see, obj - return (see_method ? public_send(see_method, obj) : true) + (see_method ? public_send(see_method, obj) : true) end end @@ -539,7 +539,7 @@ class Guardian def can_do?(action, obj) if obj && authenticated? action_method = method_name_for action, obj - return (action_method ? public_send(action_method, obj) : true) + (action_method ? public_send(action_method, obj) : true) else false end diff --git a/lib/import_export/base_exporter.rb b/lib/import_export/base_exporter.rb index 87ad104d5d..ec0bd2b428 100644 --- a/lib/import_export/base_exporter.rb +++ b/lib/import_export/base_exporter.rb @@ -6,7 +6,7 @@ module ImportExport CATEGORY_ATTRS = [:id, :name, :color, :created_at, :user_id, :slug, :description, :text_color, :auto_close_hours, :parent_category_id, :auto_close_based_on_last_post, - :topic_template, :suppress_from_latest, :all_topics_wiki, :permissions_params] + :topic_template, :all_topics_wiki, :permissions_params] GROUP_ATTRS = [ :id, :name, :created_at, :mentionable_level, :messageable_level, :visibility_level, :automatic_membership_email_domains, :automatic_membership_retroactive, diff --git a/lib/introduction_updater.rb b/lib/introduction_updater.rb index d22f084a63..b6c1a45966 100644 --- a/lib/introduction_updater.rb +++ b/lib/introduction_updater.rb @@ -27,7 +27,7 @@ class IntroductionUpdater protected def summary_from_post(post) - return post ? post.raw.split("\n").first : nil + post ? post.raw.split("\n").first : nil end def find_welcome_post diff --git a/lib/middleware/discourse_public_exceptions.rb b/lib/middleware/discourse_public_exceptions.rb index ffc281d85a..c40f319a3a 100644 --- a/lib/middleware/discourse_public_exceptions.rb +++ b/lib/middleware/discourse_public_exceptions.rb @@ -29,7 +29,15 @@ module Middleware begin fake_controller = ApplicationController.new fake_controller.response = response - fake_controller.request = ActionDispatch::Request.new(env) + fake_controller.request = request = ActionDispatch::Request.new(env) + + begin + request.format + rescue Mime::Type::InvalidMimeType + # got to do something here, we can not ship invalid format + # to the exception handler cause it will explode + request.format = "html" + end if ApplicationController.rescue_with_handler(exception, object: fake_controller) body = response.body diff --git a/lib/mini_sql_multisite_connection.rb b/lib/mini_sql_multisite_connection.rb index c08d3813a9..a1cd6a6897 100644 --- a/lib/mini_sql_multisite_connection.rb +++ b/lib/mini_sql_multisite_connection.rb @@ -26,8 +26,9 @@ class MiniSqlMultisiteConnection < MiniSql::Postgres::Connection end class AfterCommitWrapper - def initialize - @callback = Proc.new + def initialize(&blk) + raise ArgumentError, "tried to create a Proc without a block in AfterCommitWrapper" if !blk + @callback = blk end def committed!(*) diff --git a/lib/new_post_result.rb b/lib/new_post_result.rb index 70bf3f7262..25d61f497a 100644 --- a/lib/new_post_result.rb +++ b/lib/new_post_result.rb @@ -9,6 +9,8 @@ class NewPostResult attr_accessor :post attr_accessor :reviewable attr_accessor :pending_count + attr_accessor :route_to + attr_accessor :message def initialize(action, success = false) @action = action diff --git a/lib/oneboxer.rb b/lib/oneboxer.rb index eee69530f2..6575bc9822 100644 --- a/lib/oneboxer.rb +++ b/lib/oneboxer.rb @@ -23,13 +23,17 @@ module Oneboxer end def self.ignore_redirects - @ignore_redirects ||= ['http://www.dropbox.com', 'http://store.steampowered.com', Discourse.base_url] + @ignore_redirects ||= ['http://www.dropbox.com', 'http://store.steampowered.com', 'http://vimeo.com', Discourse.base_url] end def self.force_get_hosts @force_get_hosts ||= ['http://us.battle.net'] end + def self.force_custom_user_agent_hosts + @force_custom_user_agent_hosts ||= ['http://codepen.io'] + end + def self.allowed_post_types @allowed_post_types ||= [Post.types[:regular], Post.types[:moderator_action]] end @@ -68,6 +72,7 @@ module Oneboxer def self.invalidate(url) Discourse.cache.delete(onebox_cache_key(url)) + Discourse.cache.delete(onebox_failed_cache_key(url)) end # Parse URLs out of HTML, returning the document when finished. @@ -132,6 +137,14 @@ module Oneboxer Onebox::Matcher.new(url).oneboxed end + def self.recently_failed?(url) + Discourse.cache.read(onebox_failed_cache_key(url)).present? + end + + def self.cache_failed!(url) + Discourse.cache.write(onebox_failed_cache_key(url), true, expires_in: 1.hour) + end + private def self.preview_key(user_id) @@ -146,6 +159,10 @@ module Oneboxer "onebox__#{url}" end + def self.onebox_failed_cache_key(url) + "onebox_failed__#{url}" + end + def self.onebox_raw(url, opts = {}) url = URI(url).to_s local_onebox(url, opts) || external_onebox(url) @@ -174,7 +191,15 @@ module Oneboxer def self.local_upload_html(url) case File.extname(URI(url).path || "") when VIDEO_REGEX - "" + <<~HTML +
+ +
+ HTML when AUDIO_REGEX "" end @@ -270,7 +295,12 @@ module Oneboxer def self.external_onebox(url) Discourse.cache.fetch(onebox_cache_key(url), expires_in: 1.day) do - fd = FinalDestination.new(url, ignore_redirects: ignore_redirects, ignore_hostnames: blacklisted_domains, force_get_hosts: force_get_hosts, preserve_fragment_url_hosts: preserve_fragment_url_hosts) + fd = FinalDestination.new(url, + ignore_redirects: ignore_redirects, + ignore_hostnames: blacklisted_domains, + force_get_hosts: force_get_hosts, + force_custom_user_agent_hosts: force_custom_user_agent_hosts, + preserve_fragment_url_hosts: preserve_fragment_url_hosts) uri = fd.resolve return blank_onebox if uri.blank? || blacklisted_domains.map { |hostname| uri.hostname.match?(hostname) }.any? @@ -281,10 +311,6 @@ module Oneboxer options[:cookie] = fd.cookie if fd.cookie - if Rails.env.development? && SiteSetting.port.to_i > 0 - Onebox.options = { allowed_ports: [80, 443, SiteSetting.port.to_i] } - end - r = Onebox.preview(uri.to_s, options) { onebox: r.to_s, preview: r&.placeholder_html.to_s } diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 945def58ba..9256916f9e 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -418,7 +418,10 @@ class Plugin::Instance end def register_html_builder(name, &block) - DiscoursePluginRegistry.register_html_builder(name, &block) + plugin = self + DiscoursePluginRegistry.register_html_builder(name) do |*args| + block.call(*args) if plugin.enabled? + end end def register_asset(file, opts = nil) @@ -663,6 +666,15 @@ class Plugin::Instance File.exists?(js_file_path) end + # Receives an array with two elements: + # 1. A symbol that represents the name of the value to filter. + # 2. A Proc that takes the existing ActiveRecord::Relation and the value received from the front-end. + def add_custom_reviewable_filter(filter) + reloadable_patch do + Reviewable.add_custom_filter(filter) + end + end + protected def self.js_path diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 1b41fef5aa..d914a73583 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -35,6 +35,7 @@ class PostCreator # call `enqueue_jobs` after the transaction is comitted. # hidden_reason_id - Reason for hiding the post (optional) # skip_validations - Do not validate any of the content in the post + # draft_key - the key of the draft we are creating (will be deleted on success) # # When replying to a topic: # topic_id - topic we're replying to @@ -177,9 +178,12 @@ class PostCreator update_user_counts create_embedded_topic link_post_uploads + update_uploads_secure_status ensure_in_allowed_users if guardian.is_staff? unarchive_message - @post.advance_draft_sequence unless @opts[:import_mode] + if !@opts[:import_mode] + DraftSequence.next!(@user, draft_key) + end @post.save_reply_relationships end end @@ -291,10 +295,13 @@ class PostCreator protected + def draft_key + @draft_key ||= @opts[:draft_key] + @draft_key ||= @topic ? "topic_#{@topic.id}" : "new_topic" + end + def build_post_stats if PostCreator.track_post_stats - draft_key = @topic ? "topic_#{@topic.id}" : "new_topic" - sequence = DraftSequence.current(@user, draft_key) revisions = Draft.where(sequence: sequence, user_id: @user.id, @@ -366,7 +373,17 @@ class PostCreator end def link_post_uploads - @post.link_post_uploads + disallowed_uploads = @post.link_post_uploads + if disallowed_uploads.is_a? Array + @post.errors.add(:base, I18n.t('secure_upload_not_allowed_in_public_topic', upload_filenames: disallowed_uploads.join(", "))) + rollback_from_errors!(@post) + end + end + + def update_uploads_secure_status + if SiteSetting.secure_media? || SiteSetting.prevent_anons_from_downloading_files? + @post.update_uploads_secure_status + end end def handle_spam @@ -541,10 +558,10 @@ class PostCreator .first if !last_post_time - @post.custom_fields["notice_type"] = Post.notices[:new_user] + @post.custom_fields[Post::NOTICE_TYPE] = Post.notices[:new_user] elsif SiteSetting.returning_users_days > 0 && last_post_time < SiteSetting.returning_users_days.days.ago - @post.custom_fields["notice_type"] = Post.notices[:returning_user] - @post.custom_fields["notice_args"] = last_post_time.iso8601 + @post.custom_fields[Post::NOTICE_TYPE] = Post.notices[:returning_user] + @post.custom_fields[Post::NOTICE_ARGS] = last_post_time.iso8601 end end diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index 48517527d8..4e09ed8f88 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -39,11 +39,11 @@ class PostDestroyer end end - def self.delete_with_replies(performed_by, post, reviewable = nil) + def self.delete_with_replies(performed_by, post, reviewable = nil, defer_reply_flags: true) reply_ids = post.reply_ids(Guardian.new(performed_by), only_replies_to_single_post: false) replies = Post.where(id: reply_ids.map { |r| r[:id] }) PostDestroyer.new(performed_by, post, reviewable: reviewable).destroy - replies.each { |reply| PostDestroyer.new(performed_by, reply).destroy } + replies.each { |reply| PostDestroyer.new(performed_by, reply, defer_flags: defer_reply_flags).destroy } end def initialize(user, post, opts = {}) @@ -56,8 +56,9 @@ class PostDestroyer def destroy payload = WebHook.generate_payload(:post, @post) if WebHook.active_web_hooks(:post).exists? topic = @post.topic + is_first_post = @post.is_first_post? && topic - if @post.is_first_post? && topic + if is_first_post topic_view = TopicView.new(topic.id, Discourse.system_user) topic_payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer) if WebHook.active_web_hooks(:topic).exists? end @@ -75,10 +76,10 @@ class PostDestroyer DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user) WebHook.enqueue_post_hooks(:post_destroyed, @post, payload) - if @post.is_first_post? && @post.topic - UserActionManager.topic_destroyed(@post.topic) - DiscourseEvent.trigger(:topic_destroyed, @post.topic, @user) - WebHook.enqueue_topic_hooks(:topic_destroyed, @post.topic, topic_payload) + if is_first_post + UserActionManager.topic_destroyed(topic) + DiscourseEvent.trigger(:topic_destroyed, topic, @user) + WebHook.enqueue_topic_hooks(:topic_destroyed, topic, topic_payload) end end diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index fc94c3077f..f20e286429 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -94,7 +94,8 @@ class PostRevisor tc.record_change('tags', prev_tags, tags) DB.after_commit do post = tc.topic.ordered_posts.first - Jobs.enqueue(:notify_tag_change, post_id: post.id) + notified_user_ids = [post.user_id, post.last_editor_id].uniq + Jobs.enqueue(:notify_tag_change, post_id: post.id, notified_user_ids: notified_user_ids) end end end @@ -129,8 +130,9 @@ class PostRevisor @fields[:user_id] = @fields[:user_id].to_i if @fields.has_key?(:user_id) @fields[:category_id] = @fields[:category_id].to_i if @fields.has_key?(:category_id) - # always reset edit_reason unless provided - @fields[:edit_reason] = nil unless @fields[:edit_reason].present? + # always reset edit_reason unless provided, do not set to nil else + # previous reasons are lost + @fields.delete(:edit_reason) if @fields[:edit_reason].blank? return false unless should_revise? @@ -243,7 +245,11 @@ class PostRevisor def should_create_new_version? return false if @skip_revision - edited_by_another_user? || !ninja_edit? || owner_changed? || force_new_version? + edited_by_another_user? || !ninja_edit? || owner_changed? || force_new_version? || edit_reason_specified? + end + + def edit_reason_specified? + @fields[:edit_reason].present? && @fields[:edit_reason] != @post.edit_reason end def edited_by_another_user? diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 8be1397497..b01117733a 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -218,6 +218,7 @@ module PrettyText set = SiteSetting.emoji_set.inspect custom = Emoji.custom.map { |e| [e.name, e.url] }.to_h.to_json + protect do v8.eval(<<~JS) __paths = #{paths_json}; @@ -225,7 +226,8 @@ module PrettyText getURL: __getURL, emojiSet: #{set}, customEmoji: #{custom}, - enableEmojiShortcuts: #{SiteSetting.enable_emoji_shortcuts} + enableEmojiShortcuts: #{SiteSetting.enable_emoji_shortcuts}, + inlineEmoji: #{SiteSetting.enable_inline_emoji_translation} }); JS end @@ -238,7 +240,10 @@ module PrettyText protect do v8.eval(<<~JS) - __performEmojiEscape(#{title.inspect}, { emojiShortcuts: #{replace_emoji_shortcuts} }); + __performEmojiEscape(#{title.inspect}, { + emojiShortcuts: #{replace_emoji_shortcuts}, + inlineEmoji: #{SiteSetting.enable_inline_emoji_translation} + }); JS end end @@ -381,9 +386,19 @@ module PrettyText end end + def self.strip_secure_media(doc) + doc.css("a[href]").each do |a| + if a["href"].include?("/secure-media-uploads/") && FileHelper.is_supported_media?(a["href"]) + target = %w(video audio).include?(a&.parent&.parent&.name) ? a.parent.parent : a + target.replace "

#{I18n.t("emails.secure_media_placeholder")}

" + end + end + end + def self.format_for_email(html, post = nil) doc = Nokogiri::HTML.fragment(html) DiscourseEvent.trigger(:reduce_cooked, doc, post) + strip_secure_media(doc) if post&.with_secure_media? strip_image_wrapping(doc) convert_vimeo_iframes(doc) make_all_links_absolute(doc) diff --git a/lib/pretty_text/helpers.rb b/lib/pretty_text/helpers.rb index 417600469b..eac9384afb 100644 --- a/lib/pretty_text/helpers.rb +++ b/lib/pretty_text/helpers.rb @@ -64,13 +64,15 @@ module PrettyText reverse_map[value] << key end - Upload.where(sha1: map.values).pluck(:sha1, :url, :extension).each do |row| - sha1, url, extension = row + Upload.where(sha1: map.values).pluck(:sha1, :url, :extension, :original_filename, :secure).each do |row| + sha1, url, extension, original_filename, secure = row if short_urls = reverse_map[sha1] + secure_media = FileHelper.is_supported_media?(original_filename) && SiteSetting.secure_media? && secure + short_urls.each do |short_url| result[short_url] = { - url: Discourse.store.cdn_url(url), + url: secure_media ? secure_media_url(url) : Discourse.store.cdn_url(url), short_path: Upload.short_path(sha1: sha1, extension: extension), base62_sha1: Upload.base62_sha1(sha1) } @@ -82,6 +84,10 @@ module PrettyText result end + def secure_media_url(url) + url.sub(SiteSetting.Upload.absolute_base_url, "/secure-media-uploads") + end + def get_topic_info(topic_id) return unless topic_id.is_a?(Integer) # TODO this only handles public topics, secured one do not get this diff --git a/lib/promotion.rb b/lib/promotion.rb index fe810aebcd..3734ab8faa 100644 --- a/lib/promotion.rb +++ b/lib/promotion.rb @@ -74,6 +74,7 @@ class Promotion @user.save! @user.user_profile.recook_bio @user.user_profile.save! + DiscourseEvent.trigger(:user_promoted, user_id: @user.id, new_trust_level: new_level, old_trust_level: old_level) Group.user_trust_level_change!(@user.id, @user.trust_level) BadgeGranter.queue_badge_grant(Badge::Trigger::TrustLevelChange, user: @user) end @@ -102,7 +103,7 @@ class Promotion return false if (stat.time_read / 60) < SiteSetting.tl1_requires_time_spent_mins return false if ((Time.now - user.created_at) / 60) < SiteSetting.tl1_requires_time_spent_mins - return true + true end def self.tl3_met?(user) @@ -141,5 +142,4 @@ class Promotion user.change_trust_level!(2, log_action_for: performed_by || Discourse.system_user) end end - end diff --git a/lib/rake_helpers.rb b/lib/rake_helpers.rb new file mode 100644 index 0000000000..033fdeeb09 --- /dev/null +++ b/lib/rake_helpers.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RakeHelpers + def self.print_status_with_label(label, current, max) + print "\r\033[K%s%9d / %d (%5.1f%%)" % [label, current, max, ((current.to_f / max.to_f) * 100).round(1)] + end + + def self.print_status(current, max) + print "\r\033[K%9d / %d (%5.1f%%)" % [current, max, ((current.to_f / max.to_f) * 100).round(1)] + end +end diff --git a/lib/retrieve_title.rb b/lib/retrieve_title.rb index 9089707722..3c55ec7aec 100644 --- a/lib/retrieve_title.rb +++ b/lib/retrieve_title.rb @@ -67,6 +67,6 @@ module RetrieveTitle title = extract_title(current) throw :done if title || max_size < current.length end - return title + title end end diff --git a/lib/rtl.rb b/lib/rtl.rb index 2df7acb4fc..abf91ebf5c 100644 --- a/lib/rtl.rb +++ b/lib/rtl.rb @@ -13,7 +13,7 @@ class Rtl end def current_user_rtl? - SiteSetting.allow_user_locale && user.try(:locale).in?(rtl_locales) + SiteSetting.allow_user_locale && (user&.locale || SiteSetting.default_locale).in?(rtl_locales) end def site_locale_rtl? diff --git a/lib/s3_helper.rb b/lib/s3_helper.rb index 161295202f..15e137b760 100644 --- a/lib/s3_helper.rb +++ b/lib/s3_helper.rb @@ -47,7 +47,7 @@ class S3Helper end end - return path, etag.gsub('"', '') + [path, etag.gsub('"', '')] end def remove(s3_filename, copy_to_tombstone = false) diff --git a/lib/search.rb b/lib/search.rb index c43580ba41..56503df5d4 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -862,6 +862,11 @@ class Search elsif @search_context.is_a?(Topic) posts.where("topics.id = #{@search_context.id}") .order("posts.post_number #{@order == :latest ? "DESC" : ""}") + elsif @search_context.is_a?(Tag) + posts = posts + .joins("LEFT JOIN topic_tags ON topic_tags.topic_id = topics.id") + .joins("LEFT JOIN tags ON tags.id = topic_tags.tag_id") + posts.where("tags.id = #{@search_context.id}") end else posts = categories_ignored(posts) unless @category_filter_matched diff --git a/lib/secure_session.rb b/lib/secure_session.rb index 52ac364ae6..cd28dc7683 100644 --- a/lib/secure_session.rb +++ b/lib/secure_session.rb @@ -16,10 +16,14 @@ class SecureSession def set(key, val, expires: nil) expires ||= SecureSession.expiry - Discourse.redis.setex(prefixed_key(key), SecureSession.expiry.to_i, val.to_s) + Discourse.redis.setex(prefixed_key(key), expires.to_i, val.to_s) true end + def ttl(key) + Discourse.redis.ttl(prefixed_key(key)) + end + def [](key) Discourse.redis.get(prefixed_key(key)) end diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index 9d530b04e1..2c651d0b61 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -413,6 +413,22 @@ module SiteSettingExtension end end + if defined?(Rails::Console) + # Convenience method for debugging site setting issues + # Returns a hash with information about a specific setting + def info(name) + { + resolved_value: get(name), + default_value: defaults[name], + global_override: GlobalSetting.respond_to?(name) ? GlobalSetting.public_send(name) : nil, + database_value: provider.find(name)&.value, + refresh?: refresh_settings.include?(name), + client?: client_settings.include?(name), + secret?: secret_settings.include?(name), + } + end + end + protected def clear_cache! diff --git a/lib/site_settings/validations.rb b/lib/site_settings/validations.rb index be622022c7..a24da43db5 100644 --- a/lib/site_settings/validations.rb +++ b/lib/site_settings/validations.rb @@ -118,7 +118,13 @@ module SiteSettings::Validations end def validate_enable_s3_uploads(new_val) - validate_error :s3_upload_bucket_is_required if new_val == "t" && SiteSetting.s3_upload_bucket.blank? + return if new_val == "f" + validate_error :cannot_enable_s3_uploads_when_s3_enabled_globally if GlobalSetting.use_s3? + validate_error :s3_upload_bucket_is_required if SiteSetting.s3_upload_bucket.blank? + end + + def validate_secure_media(new_val) + validate_error :secure_media_requirements if new_val == "t" && !SiteSetting.Upload.enable_s3_uploads end def validate_enable_s3_inventory(new_val) @@ -143,6 +149,17 @@ module SiteSettings::Validations validate_bucket_setting("s3_backup_bucket", SiteSetting.s3_upload_bucket, new_val) end + def validate_enforce_second_factor(new_val) + return if SiteSetting.enable_local_logins + validate_error :second_factor_cannot_be_enforced_with_disabled_local_login + end + + def validate_enable_local_logins(new_val) + return if new_val == "t" + return if SiteSetting.enforce_second_factor == "no" + validate_error :local_login_cannot_be_disabled_if_second_factor_enforced + end + private def validate_bucket_setting(setting_name, upload_bucket, backup_bucket) diff --git a/lib/slug.rb b/lib/slug.rb index f50492bddf..25f2325b0f 100644 --- a/lib/slug.rb +++ b/lib/slug.rb @@ -19,8 +19,6 @@ module Slug when :encoded then self.encoded_generator(string) when :none then self.none_generator(string) end - # Reject slugs that only contain numbers, because they would be indistinguishable from id's. - slug = (slug =~ /[^\d]/ ? slug : '') slug = self.prettify_slug(slug, max_length: max_length) slug.blank? ? default : slug end @@ -33,6 +31,9 @@ module Slug private def self.prettify_slug(slug, max_length:) + # Reject slugs that only contain numbers, because they would be indistinguishable from id's. + slug = (slug =~ /[^\d]/ ? slug : '') + slug .tr("_", "-") .truncate(max_length, omission: '') diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 0408fc2817..da8ddf72d5 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -239,7 +239,7 @@ module SvgSprite def self.version(theme_ids = []) get_set_cache("version_#{Theme.transform_ids(theme_ids).join(',')}") do - Digest::SHA1.hexdigest(all_icons(theme_ids).join('|')) + Digest::SHA1.hexdigest(bundle(theme_ids)) end end diff --git a/lib/tasks/api.rake b/lib/tasks/api.rake index 8bff7dd5a2..bf8c37ecd3 100644 --- a/lib/tasks/api.rake +++ b/lib/tasks/api.rake @@ -1,8 +1,9 @@ # frozen_string_literal: true -desc "generate api key if missing, return existing if already there" -task "api_key:get" => :environment do - api_key = ApiKey.create_master_key +desc "find or generate a master api key with given description" +task "api_key:get_or_create_master", [:description] => :environment do |task, args| + raise "Supply a description for the key" if !args[:description] + api_key = ApiKey.find_or_create_by!(description: args[:description], revoked_at: nil, user_id: nil) - puts api_key.key + puts api_key.key end diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake index 7625fece58..a1c7397781 100644 --- a/lib/tasks/assets.rake +++ b/lib/tasks/assets.rake @@ -78,15 +78,9 @@ def compress_node(from, to) source_map_root = assets + ((d = File.dirname(from)) == "." ? "" : "/#{d}") source_map_url = cdn_path "/assets/#{to}.map" - cmd = if `uglifyjs -V`.match?(/2(.\d*){2}/) - <<~EOS - uglifyjs '#{assets_path}/#{from}' -p relative -m -c -o '#{to_path}' --source-map-root '#{source_map_root}' --source-map '#{assets_path}/#{to}.map' --source-map-url '#{source_map_url}' - EOS - else - <<~EOS + cmd = <<~EOS uglifyjs '#{assets_path}/#{from}' -m -c -o '#{to_path}' --source-map "root='#{source_map_root}',url='#{source_map_url}'" --output '#{to_path}' - EOS - end + EOS STDERR.puts cmd result = `#{cmd} 2>&1` diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 0375d96687..c0ceea5e19 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -50,6 +50,13 @@ end begin Rake::Task["db:migrate"].clear + Rake::Task["db:rollback"].clear +end + +task 'db:rollback' => ['environment', 'set_locale'] do |_, args| + step = ENV["STEP"] ? ENV["STEP"].to_i : 1 + ActiveRecord::Base.connection.migration_context.rollback(step) + Rake::Task['db:_dump'].invoke end # we need to run seed_fu every time we run rake db:migrate diff --git a/lib/tasks/docker.rake b/lib/tasks/docker.rake index db325dc00a..a5cefd3d3a 100644 --- a/lib/tasks/docker.rake +++ b/lib/tasks/docker.rake @@ -146,7 +146,7 @@ task 'docker:test' do puts "travis_fold:start:ruby_tests" if ENV["TRAVIS"] if ENV['WARMUP_TMP_FOLDER'] - run_or_fail('bundle exec rspec ./spec/requests/clicks_controller_spec.rb') + run_or_fail('bundle exec rspec ./spec/requests/groups_controller_spec.rb') end unless ENV["SKIP_CORE"] @@ -191,7 +191,8 @@ task 'docker:test' do if ENV["SINGLE_PLUGIN"] @good &&= run_or_fail("bundle exec rake plugin:spec['#{ENV["SINGLE_PLUGIN"]}']") else - @good &&= run_or_fail("RSPEC_FAILFAST=1 bundle exec rake plugin:spec") + fail_fast = "RSPEC_FAILFAST=1" unless ENV["SKIP_FAILFAST"] + @good &&= run_or_fail("#{fail_fast} bundle exec rake plugin:spec") end end puts "travis_fold:end:ruby_tests" if ENV["TRAVIS"] diff --git a/lib/tasks/import.rake b/lib/tasks/import.rake index ca8fdc9486..d10eea7f03 100644 --- a/lib/tasks/import.rake +++ b/lib/tasks/import.rake @@ -391,8 +391,8 @@ def update_users , MAX(p.created_at) max_created_at FROM posts p JOIN topics t ON t.id = p.topic_id AND t.archetype <> ? - WHERE deleted_at IS NULL - GROUP BY user_id + WHERE p.deleted_at IS NULL + GROUP BY p.user_id ) UPDATE users SET first_seen_at = X.min_created_at diff --git a/lib/tasks/release_note.rake b/lib/tasks/release_note.rake index ba5cc88247..e39a6dcec2 100644 --- a/lib/tasks/release_note.rake +++ b/lib/tasks/release_note.rake @@ -47,8 +47,12 @@ def better(line) line = remove_prefix(line) line = escape_brackets(line) line[0] = '\#' if line[0] == '#' - line[0] = line[0].capitalize - "- " + line + if line[0] + line[0] = line[0].capitalize + "- " + line + else + nil + end end def remove_prefix(line) diff --git a/lib/tasks/topics.rake b/lib/tasks/topics.rake index e91c4a6f31..a47a0b0c07 100644 --- a/lib/tasks/topics.rake +++ b/lib/tasks/topics.rake @@ -1,8 +1,6 @@ # frozen_string_literal: true -def print_status_with_label(label, current, max) - print "\r%s%9d / %d (%5.1f%%)" % [label, current, max, ((current.to_f / max.to_f) * 100).round(1)] -end +require_dependency "rake_helpers" def close_old_topics(category) topics = Topic.where(closed: false, category_id: category.id) @@ -23,7 +21,7 @@ def close_old_topics(category) topics.find_each do |topic| topic.update_status("closed", true, Discourse.system_user) - print_status_with_label(" closing old topics: ", topics_closed += 1, total) + RakeHelpers.print_status_with_label(" closing old topics: ", topics_closed += 1, total) end end @@ -49,7 +47,7 @@ def apply_auto_close(category) topics.find_each do |topic| topic.inherit_auto_close_from_category - print_status_with_label(" applying auto-close to topics: ", topics_closed += 1, total) + RakeHelpers.print_status_with_label(" applying auto-close to topics: ", topics_closed += 1, total) end end @@ -77,7 +75,7 @@ task "topics:watch_all_replied_topics" => :environment do t.topic_users.where(posted: true).find_each do |tp| tp.update!(notification_level: TopicUser.notification_levels[:watching], notifications_reason_id: TopicUser.notification_reasons[:created_post]) end - print_status(count += 1, total) + RakeHelpers.print_status(count += 1, total) end puts "", "Done" @@ -96,12 +94,8 @@ task "topics:update_fancy_titles" => :environment do Topic.find_each do |topic| topic.fancy_title - print_status(count += 1, total) + RakeHelpers.print_status(count += 1, total) end puts "", "Done" end - -def print_status(current, max) - print "\r%9d / %d (%5.1f%%)" % [current, max, ((current.to_f / max.to_f) * 100).round(1)] -end diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake index f21ddd6e62..5cfb1b8785 100644 --- a/lib/tasks/uploads.rake +++ b/lib/tasks/uploads.rake @@ -8,6 +8,8 @@ require "base62" # gather # ################################################################################ +require_dependency "rake_helpers" + task "uploads:gather" => :environment do ENV["RAILS_DB"] ? gather_uploads : gather_uploads_for_all_sites end @@ -426,7 +428,7 @@ def migrate_to_s3 %Q{attachment; filename="#{upload.original_filename}"} end - if upload&.private? + if upload&.secure options[:acl] = "private" end end @@ -907,6 +909,108 @@ task "uploads:recover" => :environment do end end +## +# Run this task whenever the secure_media or login_required +# settings are changed for a Discourse instance to update +# the upload secure flag and S3 upload ACLs. +task "uploads:ensure_correct_acl" => :environment do + RailsMultisite::ConnectionManagement.each_connection do |db| + unless Discourse.store.external? + puts "This task only works for external storage." + exit 1 + end + + puts "Ensuring correct ACL for uploads in #{db}...", "" + + Upload.transaction do + mark_secure_in_loop_because_no_login_required = false + + # First of all only get relevant uploads (supported media). + # + # Also only get uploads that are not for a theme or a site setting, so only + # get post related uploads. + uploads_with_supported_media = Upload.includes(:posts, :optimized_images).where( + "LOWER(original_filename) SIMILAR TO '%\.(jpg|jpeg|png|gif|svg|ico|mp3|ogg|wav|m4a|mov|mp4|webm|ogv)'" + ).joins(:post_uploads) + + puts "There are #{uploads_with_supported_media.count} upload(s) with supported media that could be marked secure.", "" + + # Simply mark all these uploads as secure if login_required because no anons will be able to access them + if SiteSetting.login_required? + mark_all_as_secure_login_required(uploads_with_supported_media) + else + + # If NOT login_required, then we have to go for the other slower flow, where in the loop + # we mark the upload as secure if the first post it is used in is with_secure_media? + mark_secure_in_loop_because_no_login_required = true + puts "Marking posts as secure in the next step because login_required is false." + end + + puts "", "Rebaking #{uploads_with_supported_media.count} upload posts and updating ACLs in S3.", "" + + upload_ids_to_mark_as_secure, uploads_skipped_because_of_error = update_acls_and_rebake_upload_posts( + uploads_with_supported_media, mark_secure_in_loop_because_no_login_required + ) + + log_rebake_errors(uploads_skipped_because_of_error) + mark_specific_uploads_as_secure_no_login_required(upload_ids_to_mark_as_secure) + end + end + puts "", "Done" +end + +def mark_all_as_secure_login_required(uploads_with_supported_media) + puts "Marking #{uploads_with_supported_media.count} upload(s) as secure because login_required is true.", "" + uploads_with_supported_media.update_all(secure: true) + puts "Finished marking upload(s) as secure." +end + +def log_rebake_errors(uploads_skipped_because_of_error) + return if uploads_skipped_because_of_error.empty? + puts "Skipped the following uploads due to error:", "" + uploads_skipped_because_of_error.each do |message| + puts message + end +end + +def mark_specific_uploads_as_secure_no_login_required(upload_ids_to_mark_as_secure) + return if upload_ids_to_mark_as_secure.empty? + puts "Marking #{upload_ids_to_mark_as_secure.length} uploads as secure because their first post contains secure media." + Upload.where(id: upload_ids_to_mark_as_secure).update_all(secure: true) + puts "Finished marking uploads as secure." +end + +def update_acls_and_rebake_upload_posts(uploads_with_supported_media, mark_secure_in_loop_because_no_login_required) + upload_ids_to_mark_as_secure = [] + uploads_skipped_because_of_error = [] + + i = 0 + uploads_with_supported_media.find_each(batch_size: 50) do |upload_with_supported_media| + RakeHelpers.print_status_with_label("Updating ACL for upload.......", i, uploads_with_supported_media.count) + + Discourse.store.update_upload_ACL(upload_with_supported_media) + + RakeHelpers.print_status_with_label("Rebaking posts for upload.....", i, uploads_with_supported_media.count) + begin + upload_with_supported_media.posts.each { |post| post.rebake! } + + if mark_secure_in_loop_because_no_login_required + first_post_with_upload = upload_with_supported_media.posts.order(sort_order: :asc).first + mark_secure = first_post_with_upload ? first_post_with_upload.with_secure_media? : false + upload_ids_to_mark_as_secure << upload_with_supported_media.id if mark_secure + end + rescue => e + uploads_skipped_because_of_error << "#{upload_with_supported_media.original_filename} (#{upload_with_supported_media.url}) #{e.message}" + end + + i += 1 + end + RakeHelpers.print_status_with_label("Rebaking complete! ", i, uploads_with_supported_media.count) + puts "" + + [upload_ids_to_mark_as_secure, uploads_skipped_because_of_error] +end + def inline_uploads(post) replaced = false diff --git a/lib/theme_store/git_importer.rb b/lib/theme_store/git_importer.rb index b844547e7d..0fccf3886c 100644 --- a/lib/theme_store/git_importer.rb +++ b/lib/theme_store/git_importer.rb @@ -33,14 +33,14 @@ class ThemeStore::GitImporter exporter = ThemeStore::ZipExporter.new(theme) local_temp_folder = exporter.export_to_folder - Dir.chdir(@temp_folder) do - Discourse::Utils.execute_command("git", "checkout", local_version) - Discourse::Utils.execute_command("rm -rf ./*/") - Discourse::Utils.execute_command("cp", "-rf", "#{local_temp_folder}/#{exporter.export_name}/.", @temp_folder) - Discourse::Utils.execute_command("git", "checkout", "about.json") - # adding and diffing on staged so that we catch uploads - Discourse::Utils.execute_command("git", "add", "-A") - return Discourse::Utils.execute_command("git", "diff", "--staged", "--no-renames") + Discourse::Utils.execute_command(chdir: @temp_folder) do |runner| + runner.exec("git", "checkout", local_version) + runner.exec("rm -rf ./*/") + runner.exec("cp", "-rf", "#{local_temp_folder}/#{exporter.export_name}/.", @temp_folder) + runner.exec("git", "checkout", "about.json") + # add + diff staged to catch uploads but exclude renamed assets + runner.exec("git", "add", "-A") + return runner.exec("git", "diff", "--staged", "--diff-filter=r") end ensure FileUtils.rm_rf local_temp_folder if local_temp_folder @@ -49,18 +49,16 @@ class ThemeStore::GitImporter def commits_since(hash) commit_hash, commits_behind = nil - Dir.chdir(@temp_folder) do - commit_hash = Discourse::Utils.execute_command("git", "rev-parse", "HEAD").strip - commits_behind = Discourse::Utils.execute_command("git", "rev-list", "#{hash}..HEAD", "--count").strip + Discourse::Utils.execute_command(chdir: @temp_folder) do |runner| + commit_hash = runner.exec("git", "rev-parse", "HEAD").strip + commits_behind = runner.exec("git", "rev-list", "#{hash}..HEAD", "--count").strip end [commit_hash, commits_behind] end def version - Dir.chdir(@temp_folder) do - Discourse::Utils.execute_command("git", "rev-parse", "HEAD").strip - end + Discourse::Utils.execute_command("git", "rev-parse", "HEAD", chdir: @temp_folder).strip end def cleanup! @@ -82,9 +80,7 @@ class ThemeStore::GitImporter end def all_files - Dir.chdir(@temp_folder) do - Dir.glob("**/*").reject { |f| File.directory?(f) } - end + Dir.glob("**/*", base: @temp_folder).reject { |f| File.directory?(File.join(@temp_folder, f)) } end def [](value) @@ -111,10 +107,8 @@ class ThemeStore::GitImporter ssh_folder = "#{Pathname.new(Dir.tmpdir).realpath}/discourse_theme_ssh_#{SecureRandom.hex}" FileUtils.mkdir_p ssh_folder - Dir.chdir(ssh_folder) do - File.write('id_rsa', @private_key.strip) - FileUtils.chmod(0600, 'id_rsa') - end + File.write("#{ssh_folder}/id_rsa", @private_key.strip) + FileUtils.chmod(0600, "#{ssh_folder}/id_rsa") begin git_ssh_command = { 'GIT_SSH_COMMAND' => "ssh -i #{ssh_folder}/id_rsa -o StrictHostKeyChecking=no" } diff --git a/lib/theme_store/zip_exporter.rb b/lib/theme_store/zip_exporter.rb index 6fd17c635c..6280601b48 100644 --- a/lib/theme_store/zip_exporter.rb +++ b/lib/theme_store/zip_exporter.rb @@ -26,34 +26,32 @@ class ThemeStore::ZipExporter end def export_to_folder - FileUtils.mkdir(@temp_folder) + destination_folder = File.join(@temp_folder, @export_name) + FileUtils.mkdir_p(destination_folder) - Dir.chdir(@temp_folder) do - FileUtils.mkdir(@export_name) + @theme.theme_fields.each do |field| + next unless path = field.file_path - @theme.theme_fields.each do |field| - next unless path = field.file_path + # Belt and braces approach here. All the user input should already be + # sanitized, but check for attempts to leave the temp directory anyway + pathname = Pathname.new(File.join(destination_folder, path)) + folder_path = pathname.parent.cleanpath + raise RuntimeError.new("Theme exporter tried to leave directory") unless folder_path.to_s.starts_with?(destination_folder) + pathname.parent.mkpath + path = pathname.realdirpath + raise RuntimeError.new("Theme exporter tried to leave directory") unless path.to_s.starts_with?(destination_folder) - # Belt and braces approach here. All the user input should already be - # sanitized, but check for attempts to leave the temp directory anyway - pathname = Pathname.new("#{@export_name}/#{path}") - folder_path = pathname.parent.cleanpath - raise RuntimeError.new("Theme exporter tried to leave directory") unless folder_path.to_s.starts_with?("#{@export_name}") - pathname.parent.mkpath - path = pathname.realdirpath - raise RuntimeError.new("Theme exporter tried to leave directory") unless path.to_s.starts_with?("#{@temp_folder}/#{@export_name}") - - if ThemeField.types[field.type_id] == :theme_upload_var - filename = Discourse.store.path_for(field.upload) - content = filename ? File.read(filename) : Discourse.store.download(field.upload).read - else - content = field.value - end - File.write(path, content) + if ThemeField.types[field.type_id] == :theme_upload_var + filename = Discourse.store.path_for(field.upload) + content = filename ? File.read(filename) : Discourse.store.download(field.upload).read + else + content = field.value end - - File.write("#{@export_name}/about.json", JSON.pretty_generate(@theme.generate_metadata_hash)) + File.write(path, content) end + + File.write(File.join(destination_folder, "about.json"), JSON.pretty_generate(@theme.generate_metadata_hash)) + @temp_folder end @@ -62,6 +60,6 @@ class ThemeStore::ZipExporter def export_package export_to_folder - Dir.chdir(@temp_folder) { Compression::Zip.new.compress(@temp_folder, @export_name) } + Compression::Zip.new.compress(@temp_folder, @export_name) end end diff --git a/lib/theme_store/zip_importer.rb b/lib/theme_store/zip_importer.rb index d252037ace..deff98d3df 100644 --- a/lib/theme_store/zip_importer.rb +++ b/lib/theme_store/zip_importer.rb @@ -17,12 +17,10 @@ class ThemeStore::ZipImporter def import! FileUtils.mkdir(@temp_folder) - Dir.chdir(@temp_folder) do - available_size = SiteSetting.decompressed_theme_max_file_size_mb - Compression::Engine.engine_for(@original_filename).tap do |engine| - engine.decompress(@temp_folder, @filename, available_size) - engine.strip_directory(@temp_folder, @temp_folder, relative: true) - end + available_size = SiteSetting.decompressed_theme_max_file_size_mb + Compression::Engine.engine_for(@original_filename).tap do |engine| + engine.decompress(@temp_folder, @filename, available_size) + engine.strip_directory(@temp_folder, @temp_folder, relative: true) end rescue RuntimeError raise RemoteTheme::ImportError, I18n.t("themes.import_error.unpack_failed") @@ -53,9 +51,7 @@ class ThemeStore::ZipImporter end def all_files - Dir.chdir(@temp_folder) do - Dir.glob("**/**").reject { |f| File.directory?(f) } - end + Dir.glob("**/**", base: @temp_folder).reject { |f| File.directory?(File.join(@temp_folder, f)) } end def [](value) diff --git a/lib/topic_query.rb b/lib/topic_query.rb index 8a2c4637f3..7e63bb024a 100644 --- a/lib/topic_query.rb +++ b/lib/topic_query.rb @@ -28,8 +28,7 @@ class TopicQuery { max_posts: zero_up_to_max_int, min_posts: zero_up_to_max_int, - page: zero_up_to_max_int, - exclude_category_ids: array_int_or_int + page: zero_up_to_max_int } end end @@ -49,7 +48,6 @@ class TopicQuery before bumped_before topic_ids - exclude_category_ids category order ascending @@ -517,6 +515,7 @@ class TopicQuery result = remove_muted_topics(result, @user) result = remove_muted_categories(result, @user, exclude: options[:category]) result = remove_muted_tags(result, @user, options) + result = remove_already_seen_for_category(result, @user) self.class.results_filter_callbacks.each do |filter_callback| result = filter_callback.call(:new, result, @user, options) @@ -700,18 +699,15 @@ class TopicQuery end end - # ALL TAGS: something like this? - # Topic.joins(:tags).where('tags.name in (?)', @options[:tags]).group('topic_id').having('count(*)=?', @options[:tags].size).select('topic_id') - if SiteSetting.tagging_enabled result = result.preload(:tags) - tags = @options[:tags] + tags_arg = @options[:tags] - if tags && tags.size > 0 - tags = tags.split if String === tags + if tags_arg && tags_arg.size > 0 + tags_arg = tags_arg.split if String === tags_arg - tags = tags.map do |t| + tags_arg = tags_arg.map do |t| if String === t t.downcase else @@ -719,12 +715,12 @@ class TopicQuery end end + tags_query = tags_arg[0].is_a?(String) ? Tag.where_name(tags_arg) : Tag.where(id: tags_arg) + tags = tags_query.select(:id, :target_tag_id).map { |t| t.target_tag_id || t.id }.uniq + if @options[:match_all_tags] # ALL of the given tags: - tags_count = tags.length - tags = Tag.where_name(tags).pluck(:id) unless Integer === tags[0] - - if tags_count == tags.length + if tags_arg.length == tags.length tags.each_with_index do |tag, index| sql_alias = ['t', index].join result = result.joins("INNER JOIN topic_tags #{sql_alias} ON #{sql_alias}.topic_id = topics.id AND #{sql_alias}.tag_id = #{tag}") @@ -734,12 +730,7 @@ class TopicQuery end else # ANY of the given tags: - result = result.joins(:tags) - if Integer === tags[0] - result = result.where("tags.id in (?)", tags) - else - result = result.where("lower(tags.name) in (?)", tags) - end + result = result.joins(:tags).where("tags.id in (?)", tags) end # TODO: this is very side-effecty and should be changed @@ -755,10 +746,6 @@ class TopicQuery result = apply_ordering(result, options) result = result.listable_topics - if options[:exclude_category_ids] && options[:exclude_category_ids].is_a?(Array) && options[:exclude_category_ids].size > 0 - result = result.where("categories.id NOT IN (?)", options[:exclude_category_ids].map(&:to_i)).references(:categories) - end - # Don't include the category topics if excluded if options[:no_definitions] result = result.where('COALESCE(categories.topic_id, 0) <> topics.id') @@ -870,20 +857,32 @@ class TopicQuery category_id = get_category_id(opts[:exclude]) if opts if user - list = list.references("cu") - .where(" - NOT EXISTS ( - SELECT 1 - FROM category_users cu - WHERE cu.user_id = :user_id - AND cu.category_id = topics.category_id - AND cu.notification_level = :muted - AND cu.category_id <> :category_id - AND (tu.notification_level IS NULL OR tu.notification_level < :tracking) - )", user_id: user.id, - muted: CategoryUser.notification_levels[:muted], - tracking: TopicUser.notification_levels[:tracking], - category_id: category_id || -1) + default_notification_level = SiteSetting.mute_all_categories_by_default ? CategoryUser.notification_levels[:muted] : CategoryUser.notification_levels[:regular] + + list = list + .references("cu") + .joins("LEFT JOIN category_users ON category_users.category_id = topics.category_id AND category_users.user_id = #{user.id}") + .where("topics.category_id = :category_id + OR COALESCE(category_users.notification_level, :default) <> :muted + OR tu.notification_level > :regular", + category_id: category_id || -1, + default: default_notification_level, + muted: CategoryUser.notification_levels[:muted], + regular: TopicUser.notification_levels[:regular]) + elsif SiteSetting.mute_all_categories_by_default + category_ids = [ + SiteSetting.default_categories_watching.split("|"), + SiteSetting.default_categories_tracking.split("|"), + SiteSetting.default_categories_watching_first_post.split("|") + ].flatten.map(&:to_i) + category_ids << category_id if category_id.present? && category_ids.exclude?(category_id) + + list = list.where("topics.category_id IN (?)", category_ids) if category_ids.present? + else + category_ids = SiteSetting.default_categories_muted.split("|").map(&:to_i) + category_ids -= [category_id] if category_id.present? && category_ids.include?(category_id) + + list = list.where("topics.category_id NOT IN (?)", category_ids) if category_ids.present? end list @@ -922,6 +921,15 @@ class TopicQuery end end + def remove_already_seen_for_category(list, user) + if user + list = list + .where("category_users.last_seen_at IS NULL OR topics.created_at > category_users.last_seen_at") + end + + list + end + def new_messages(params) query = TopicQuery .new_filter(messages_for_groups_or_user(params[:my_group_ids]), Time.at(SiteSetting.min_new_topics_time).to_datetime) diff --git a/lib/topic_retriever.rb b/lib/topic_retriever.rb index 95e749db83..3da6f932a2 100644 --- a/lib/topic_retriever.rb +++ b/lib/topic_retriever.rb @@ -36,12 +36,6 @@ class TopicRetriever # It's possible another process or job found the embed already. So if that happened bail out. return if TopicEmbed.where(embed_url: @embed_url).exists? - # First check RSS if that is enabled - if SiteSetting.feed_polling_enabled? - Jobs::PollFeed.new.execute({}) - return if TopicEmbed.where(embed_url: @embed_url).exists? - end - fetch_http end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index aaa4ccc086..b95254d14a 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -35,7 +35,7 @@ class TopicView end def self.default_post_custom_fields - @default_post_custom_fields ||= ["action_code_who", "notice_type", "notice_args", "requested_group_id"] + @default_post_custom_fields ||= [Post::NOTICE_TYPE, Post::NOTICE_ARGS, "action_code_who", "requested_group_id"] end def self.post_custom_fields_whitelisters @@ -152,7 +152,7 @@ class TopicView def next_page @next_page ||= begin - if last_post && (@topic.highest_post_number > last_post.post_number) + if last_post && highest_post_number && (highest_post_number > last_post.post_number) @page + 1 end end diff --git a/lib/unread.rb b/lib/unread.rb index b37dc06ed9..917c2e396d 100644 --- a/lib/unread.rb +++ b/lib/unread.rb @@ -27,7 +27,7 @@ class Unread new_posts = (highest_post_number - @topic_user.highest_seen_post_number) new_posts = 0 if new_posts < 0 - return new_posts + new_posts end protected diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index 0a0c8d43c4..ab1fa9a4a8 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -118,6 +118,13 @@ class UploadCreator @upload.for_site_setting = true if @opts[:for_site_setting] @upload.for_gravatar = true if @opts[:for_gravatar] + if !FileHelper.is_supported_media?(@filename) && + !@upload.for_theme && + !@upload.for_site_setting && + SiteSetting.prevent_anons_from_downloading_files + @upload.secure = true + end + return @upload unless @upload.save # store the file and update its url diff --git a/lib/url_helper.rb b/lib/url_helper.rb index b156013295..420420d3f0 100644 --- a/lib/url_helper.rb +++ b/lib/url_helper.rb @@ -38,6 +38,11 @@ class UrlHelper url.sub(/^http:/i, "") end + def self.secure_proxy_without_cdn(url) + url = url.sub(SiteSetting.Upload.absolute_base_url, "/secure-media-uploads") + self.absolute(url, nil) + end + DOUBLE_ESCAPED_REGEXP ||= /%25([0-9a-f]{2})/i # Prevents double URL encode @@ -48,16 +53,21 @@ class UrlHelper encoded end - def self.cook_url(url) + def self.cook_url(url, secure: false) return url unless is_local(url) uri = URI.parse(url) filename = File.basename(uri.path) - is_attachment = !FileHelper.is_supported_image?(filename) + is_attachment = !FileHelper.is_supported_media?(filename) no_cdn = SiteSetting.login_required || SiteSetting.prevent_anons_from_downloading_files - url = absolute_without_cdn(url) + url = secure ? secure_proxy_without_cdn(url) : absolute_without_cdn(url) + + # we always want secure media to come from + # Discourse.base_url_no_prefix/secure-media-uploads + # to avoid asset_host mixups + return schemaless(url) if secure unless is_attachment && no_cdn url = Discourse.store.cdn_url(url) diff --git a/lib/user_name_suggester.rb b/lib/user_name_suggester.rb index d849816cc9..2f7c7c920a 100644 --- a/lib/user_name_suggester.rb +++ b/lib/user_name_suggester.rb @@ -11,7 +11,7 @@ module UserNameSuggester end def self.parse_name_from_email(name_or_email) - return name_or_email if name_or_email !~ User::EMAIL + return name_or_email if name_or_email.to_s !~ User::EMAIL # When 'walter@white.com' take 'walter' name = Regexp.last_match[1] diff --git a/lib/validators/censored_words_validator.rb b/lib/validators/censored_words_validator.rb index d36a0931d8..b65f6620e3 100644 --- a/lib/validators/censored_words_validator.rb +++ b/lib/validators/censored_words_validator.rb @@ -2,9 +2,13 @@ class CensoredWordsValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) - if WordWatcher.words_for_action(:censor).present? && (censored_words = censor_words(value, censored_words_regexp)).present? + words_regexp = censored_words_regexp + if WordWatcher.words_for_action(:censor).present? && !words_regexp.nil? + censored_words = censor_words(value, words_regexp) + return if censored_words.blank? record.errors.add( - attribute, :contains_censored_words, + attribute, + :contains_censored_words, censored_words: join_censored_words(censored_words) ) end diff --git a/lib/validators/timezone_validator.rb b/lib/validators/timezone_validator.rb new file mode 100644 index 0000000000..93ff1a2d78 --- /dev/null +++ b/lib/validators/timezone_validator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TimezoneValidator < ActiveModel::EachValidator + def self.valid?(value) + ok = ActiveSupport::TimeZone[value].present? + Rails.logger.warn("Invalid timezone '#{value}' detected!") if !ok + ok + end + + def self.error_message(value) + I18n.t("errors.messages.invalid_timezone", tz: value) + end + + def validate_each(record, attribute, value) + return if value.blank? || TimezoneValidator.valid?(value) + record.errors.add( + attribute, + :timezone, + message: TimezoneValidator.error_message(value) + ) + end +end diff --git a/lib/version.rb b/lib/version.rb index 748dd2f438..61eeb63112 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -9,7 +9,7 @@ module Discourse MAJOR = 2 MINOR = 4 TINY = 0 - PRE = 'beta7' + PRE = 'beta8' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/lib/webauthn.rb b/lib/webauthn.rb index d53eefe363..c8656d8fa7 100644 --- a/lib/webauthn.rb +++ b/lib/webauthn.rb @@ -6,7 +6,10 @@ require 'webauthn/security_key_authentication_service' module Webauthn ACCEPTABLE_REGISTRATION_TYPE = "webauthn.create".freeze ACCEPTABLE_AUTHENTICATION_TYPE = "webauthn.get".freeze - SUPPORTED_ALGORITHMS = [-7].freeze + + # -7 - ES256 + # -257 - RS256 (Windows Hello supported alg.) + SUPPORTED_ALGORITHMS = [-7, -257].freeze VALID_ATTESTATION_FORMATS = ['none', 'packed', 'fido-u2f'].freeze class SecurityKeyError < StandardError; end diff --git a/lib/webauthn/security_key_registration_service.rb b/lib/webauthn/security_key_registration_service.rb index bea9e322f2..ff8544f1ac 100644 --- a/lib/webauthn/security_key_registration_service.rb +++ b/lib/webauthn/security_key_registration_service.rb @@ -49,7 +49,7 @@ module Webauthn # attribute of one of the items in options.pubKeyCredParams. # https://w3c.github.io/webauthn/#table-attestedCredentialData # See https://www.iana.org/assignments/cose/cose.xhtml#algorithms for supported algorithm - # codes, -7 which Discourse uses is ECDSA w/ SHA-256 + # codes. credential_public_key, credential_public_key_bytes, credential_id = extract_public_key_and_credential_from_attestation(auth_data) raise(UnsupportedPublicKeyAlgorithmError, I18n.t('webauthn.validation.unsupported_public_key_algorithm_error')) if ::Webauthn::SUPPORTED_ALGORITHMS.exclude?(credential_public_key.alg) diff --git a/package.json b/package.json index 4761adf6d2..91dd808343 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "author": "Discourse", "license": "MIT", "dependencies": { - "@fortawesome/fontawesome-free": "5.7.2", + "@fortawesome/fontawesome-free": "5.11.2", "ace-builds": "1.4.2", "bootbox": "3.2.0", "bootstrap": "v3.4.1", - "chart.js": "2.7.3", + "chart.js": "2.9.3", "favcount": "https://github.com/chrishunt/favcount", "handlebars": "^4.1.2", "highlight.js": "https://github.com/highlightjs/highlight.js", @@ -39,22 +39,17 @@ }, "devDependencies": { "@arkweid/lefthook": "^0.6.3", - "babel-eslint": "^8.2", - "chrome-launcher": "^0.10", + "chrome-launcher": "^0.12.0", "chrome-remote-interface": "^0.25", - "eslint": "^4.19", - "eslint-config-discourse": "1.0.5", - "install-peerdeps": "^1.10.2", + "eslint-config-discourse": "1.1.0", "lodash-cli": "https://github.com/lodash-archive/lodash-cli.git", "pretender": "^1.6", - "prettier": "^1.18.2", "puppeteer": "1.20", "qunit": "2.8.0", "route-recognizer": "^0.3.3", "sinon": "^7.2.5" }, "scripts": { - "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('NPM is not supported, please use Yarn instead. ')\"", - "postinstall": "install-peerdeps --dev eslint-config-discourse -Y --extra-args \"--ignore-scripts\"" + "preinstall": "node -e \"if(process.env.npm_execpath.indexOf('yarn') === -1) throw new Error('NPM is not supported, please use Yarn instead. ')\"" } } diff --git a/plugins/discourse-details/config/locales/server.uk.yml b/plugins/discourse-details/config/locales/server.uk.yml index 8017dfd076..37693dc767 100644 --- a/plugins/discourse-details/config/locales/server.uk.yml +++ b/plugins/discourse-details/config/locales/server.uk.yml @@ -7,6 +7,6 @@ uk: site_settings: - details_enabled: 'Увімкніть функцію деталей. Якщо змінити, то потрібно відредагувати всі дописи: "rake posts:rebake".' + details_enabled: 'Увімкніть функцію деталей. Якщо змінити, то потрібно відредагувати всі дописи: "rake posts:rebake".' details: excerpt_details: "(натисніть для отримання детальної інформації)" diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.no-module.es6 b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.no-module.es6 index 8e87d88681..f2226a6dfd 100644 --- a/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.no-module.es6 +++ b/plugins/discourse-local-dates/assets/javascripts/discourse-local-dates.js.no-module.es6 @@ -22,7 +22,6 @@ const dateTime = options.time ? `${options.date} ${options.time}` : options.date; - let utcDateTime; let displayedTimezone; if (options.time) { @@ -33,6 +32,7 @@ } // if timezone given we convert date and time from given zone to Etc/UTC + let utcDateTime; if (options.timezone) { utcDateTime = _applyZoneToDateTime(dateTime, options.timezone); } else { @@ -43,7 +43,7 @@ // if event is in the past we want to bump it no next occurrence when // recurring is set if (options.recurring) { - utcDateTime = _applyRecurrence(utcDateTime, options.recurring); + utcDateTime = _applyRecurrence(utcDateTime, options); } else { $element.addClass("past"); } @@ -78,7 +78,10 @@ .find(".relative-time") .text(formatedDateTime); - this.timeout = setTimeout(() => processElement($element, options), 10000); + this.timeout = setTimeout( + () => processElement($element, options), + 60 * 1000 + ); } function _formatTimezone(timezone) { @@ -122,6 +125,14 @@ } function _isEqualZones(timezoneA, timezoneB) { + if ((timezoneA || timezoneB) && (!timezoneA || !timezoneB)) { + return false; + } + + if (timezoneA.includes(timezoneB) || timezoneB.includes(timezoneA)) { + return true; + } + return ( moment.tz(timezoneA).utcOffset() === moment.tz(timezoneB).utcOffset() ); @@ -188,22 +199,37 @@ return dateTime; } - function _applyRecurrence(dateTime, recurring) { + function _applyRecurrence(dateTime, { recurring, timezone }) { const parts = recurring.split("."); const count = parseInt(parts[0], 10); const type = parts[1]; const diff = moment().diff(dateTime, type); const add = Math.ceil(diff + count); + + // we create new moment object from format + // to ensure it's created in user context const wasDST = moment(dateTime.format()).isDST(); - let dateTimeWithRecurrence = dateTime.add(add, type); + let dateTimeWithRecurrence = moment(dateTime).add(add, type); const isDST = moment(dateTimeWithRecurrence.format()).isDST(); + // these dates are more or less DST "certain" + const noDSTOffset = moment + .tz({ month: 0, day: 1 }, timezone || "Etc/UTC") + .utcOffset(); + const withDSTOffset = moment + .tz({ month: 5, day: 1 }, timezone || "Etc/UTC") + .utcOffset(); + + // we remove the DST offset present when the date was created, + // and add current DST offset if (!wasDST && isDST) { - dateTimeWithRecurrence.subtract(1, "hour"); + dateTimeWithRecurrence.add(-withDSTOffset + noDSTOffset, "minutes"); } + // we add the DST offset present when the date was created, + // and remove current DST offset if (wasDST && !isDST) { - dateTimeWithRecurrence.add(1, "hour"); + dateTimeWithRecurrence.add(withDSTOffset - noDSTOffset, "minutes"); } return dateTimeWithRecurrence; @@ -219,7 +245,9 @@ const previewedTimezones = []; const watchingUserTimezone = moment.tz.guess(); const timezones = options.timezones.filter( - timezone => timezone !== watchingUserTimezone + timezone => + !_isEqualZones(timezone, watchingUserTimezone) && + !_isEqualZones(timezone, options.timezone) ); previewedTimezones.push({ @@ -241,26 +269,24 @@ timezones.unshift(options.timezone); } - timezones - .filter(z => z) - .forEach(timezone => { - if (_isEqualZones(timezone, displayedTimezone)) { - return; - } + Array.from(new Set(timezones.filter(Boolean))).forEach(timezone => { + if (_isEqualZones(timezone, displayedTimezone)) { + return; + } - if (_isEqualZones(timezone, watchingUserTimezone)) { - timezone = watchingUserTimezone; - } + if (_isEqualZones(timezone, watchingUserTimezone)) { + timezone = watchingUserTimezone; + } - previewedTimezones.push({ - timezone, - dateTime: options.time - ? moment(dateTime) - .tz(timezone) - .format("LLL") - : _createDateTimeRange(dateTime, timezone) - }); + previewedTimezones.push({ + timezone, + dateTime: options.time + ? moment(dateTime) + .tz(timezone) + .format("LLL") + : _createDateTimeRange(dateTime, timezone) }); + }); if (!previewedTimezones.length) { previewedTimezones.push({ 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.es6 index de01b5ef9c..20b104c0a3 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.es6 @@ -6,7 +6,7 @@ import { propertyNotEqual } from "discourse/lib/computed"; import loadScript from "discourse/lib/load-script"; import { default as computed } from "ember-addons/ember-computed-decorators"; import { cookAsync } from "discourse/lib/text"; -import debounce from "discourse/lib/debounce"; +import discourseDebounce from "discourse/lib/debounce"; export default Component.extend({ timeFormat: "HH:mm:ss", @@ -51,7 +51,7 @@ export default Component.extend({ }); }, - _renderPreview: debounce(function() { + _renderPreview: discourseDebounce(function() { const markup = this.markup; if (markup) { 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.es6 index 083794bd9c..d4e526c787 100644 --- 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.es6 @@ -13,10 +13,12 @@ function addLocalDate(buffer, matches, state) { countdown: null }; + const matchString = matches[1].replace(/„|“/g, '"'); + let parsed = parseBBCodeTag( - "[date date" + matches[1] + "]", + "[date date" + matchString + "]", 0, - matches[1].length + 11 + matchString.length + 11 ); config.date = parsed.attrs.date; 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 c4edbf91a9..66593514d7 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 @@ -198,9 +198,7 @@ border: 0; outline: none; flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + @include ellipsis; width: 100%; text-align: left; } diff --git a/plugins/discourse-local-dates/config/locales/client.lv.yml b/plugins/discourse-local-dates/config/locales/client.lv.yml index cda5fe6581..216917a15e 100644 --- a/plugins/discourse-local-dates/config/locales/client.lv.yml +++ b/plugins/discourse-local-dates/config/locales/client.lv.yml @@ -10,4 +10,5 @@ lv: discourse_local_dates: create: form: + date_title: Datums time_title: Laiks 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 4e1a086ede..216f1aefb9 100644 --- a/plugins/discourse-local-dates/config/locales/client.tr_TR.yml +++ b/plugins/discourse-local-dates/config/locales/client.tr_TR.yml @@ -12,3 +12,4 @@ tr_TR: form: date_title: Tarih time_title: Saat + timezone: Saat dilimi diff --git a/plugins/discourse-local-dates/config/locales/client.uk.yml b/plugins/discourse-local-dates/config/locales/client.uk.yml index 6bf7c007ea..45fdd9739e 100644 --- a/plugins/discourse-local-dates/config/locales/client.uk.yml +++ b/plugins/discourse-local-dates/config/locales/client.uk.yml @@ -20,7 +20,7 @@ uk: insert: Вставити advanced_mode: Розширений режим simple_mode: Простий режим - format_description: "Формат, який використовується для відображення дати користувачеві. Використовуйте "\\T\\Z", щоб відобразити часовий пояс користувача словами (Європа/Париж)" + format_description: "Формат, який використовується для відображення дати користувачеві. Використовуйте \"\\T\\Z\", щоб відобразити часовий пояс користувача словами (Європа/Париж)" timezones_title: Часові пояси показати timezones_description: Часові пояси будуть використовуватися для відображення дат у попередньому та резервному режимі. recurring_title: Повторення diff --git a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb index 6a1a4ec76f..853e73d83c 100644 --- a/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb +++ b/plugins/discourse-local-dates/spec/lib/pretty_text_spec.rb @@ -80,4 +80,13 @@ describe PrettyText do expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)") end end + + context 'german quotes' do + let(:post) { Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=„America/New_York“]') } + + it 'converts german quotes to regular quotes' do + excerpt = PrettyText.excerpt(post.cooked, 200) + expect(excerpt).to eq('Wednesday, October 16, 2019 6:00 PM (UTC)') + end + end end diff --git a/plugins/discourse-narrative-bot/autoload/jobs/send_advanced_tutorial_message.rb b/plugins/discourse-narrative-bot/autoload/jobs/send_advanced_tutorial_message.rb new file mode 100644 index 0000000000..b10f416f0a --- /dev/null +++ b/plugins/discourse-narrative-bot/autoload/jobs/send_advanced_tutorial_message.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Jobs + class SendAdvancedTutorialMessage < ::Jobs::Base + def execute(args) + user = User.find_by(id: args[:user_id]) + raise Discourse::InvalidParameters.new(:user_id) if user.nil? + + PostCreator.create!( + Discourse.system_user, + title: I18n.t("discourse_narrative_bot.tl2_promotion_message.subject_template"), + raw: I18n.t("discourse_narrative_bot.tl2_promotion_message.text_body_template"), + archetype: Archetype.private_message, + target_usernames: user.username, + skip_validations: true + ) + end + end +end diff --git a/plugins/discourse-narrative-bot/autoload/jobs/send_default_welcome_message.rb b/plugins/discourse-narrative-bot/autoload/jobs/send_default_welcome_message.rb index 1b2c54aa6c..4eeda6c004 100644 --- a/plugins/discourse-narrative-bot/autoload/jobs/send_default_welcome_message.rb +++ b/plugins/discourse-narrative-bot/autoload/jobs/send_default_welcome_message.rb @@ -9,7 +9,7 @@ module Jobs title = I18n.t("system_messages.#{type}.subject_template", params) raw = I18n.t("system_messages.#{type}.text_body_template", params) - discobot_user = User.find(-2) + discobot_user = ::DiscourseNarrativeBot::Base.new.discobot_user post = PostCreator.create!( discobot_user, diff --git a/plugins/discourse-narrative-bot/config/locales/client.nl.yml b/plugins/discourse-narrative-bot/config/locales/client.nl.yml index 4ef46341ee..e534234bd3 100644 --- a/plugins/discourse-narrative-bot/config/locales/client.nl.yml +++ b/plugins/discourse-narrative-bot/config/locales/client.nl.yml @@ -9,5 +9,5 @@ nl: js: discourse_narrative_bot: welcome_post_type: - new_user_track: "Start de nieuwe gebruikerstutorial voor alle nieuwe gebruikers" - welcome_message: "Stuur een welkomstbericht met een snelle start handleiding naar alle nieuwe gebruikers" + new_user_track: "De handleiding voor nieuwe gebruikers starten voor alle nieuwe gebruikers" + welcome_message: "Een welkomstbericht met een snelstartgids naar alle nieuwe gebruikers sturen" diff --git a/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml b/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml index 0b868c78ea..d912946b5e 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.bs_BA.yml @@ -118,7 +118,7 @@ bs_BA: Hej, hvala za odgovor! Nažalost, kako sam programiran ograničenim mogućnostima, ne mogu jasno razumjeti na što ste mislili. :frowning: - track_response: Možete pokušati ponovo, ili ako želite preskočiti ovaj korak, recite `%{skip_trigger}`. Inače, za početak, recite '%{reset_trigger}'. + track_response: Možete pokušati ponovo, ili ako želite preskočiti ovaj korak, recite `%{skip_trigger}`. Inače, za početak, recite '%{reset_trigger}'. second_response: |- Žao mi je. Još uvek ne shvatam. : anguished: Ja sam samo bot, ali ako želite doći do prave osobe, pogledajte [našu kontakt stranicu] (%{base_path} / about). U međuvremenu, neću vam smetati. new_user_narrative: @@ -179,7 +179,7 @@ bs_BA: reply: |- _Da li je netko rekao moje ime !? _: raise_hand: Vjerujem da jeste! : wave: Pa, evo me! Hvala što si me spomenuo. : ok_hand: \ t not_found: |- - Ne vidim svoje ime tamo negdje: namršteno: Možete li pokušati spomenuti mene kao '@ %{discobot_username}` opet? (I da, moje korisničko ime je napisano _disco_, kao u plesnoj ludosti sedamdesetih godina. Ja [volim noćni život!] (Https://www.youtube.com/watch?v=B_wGI3_sGf8): plesač :) + Ne vidim svoje ime tamo negdje: namršteno: Možete li pokušati spomenuti mene kao `@ %{discobot_username}` opet? (I da, moje korisničko ime je napisano _disco_, kao u plesnoj ludosti sedamdesetih godina. Ja [volim noćni život!] (Https://www.youtube.com/watch?v=B_wGI3_sGf8): plesač :) flag: instructions: |- Mi volimo naše diskusije prijateljske, i potrebna nam je vaša pomoć da [održimo civilizovane stvari] (%{guidelines_url}). Ako vidite problem, označite da privatno pustite autora, ili [naše korisno osoblje] (%{about_url}), da znate za njega. Napisao sam nešto ružno ovdje, pretpostavljam da znaš šta da radiš. Naprijed i ** označite ovaj post ** kao neprikladno! @@ -189,7 +189,7 @@ bs_BA: Oh ne, moj gadan post još nije označen. : worried: Možete li označiti kao neprikladan pomoću ** zastavice ** ? Ne zaboravite da koristite dugme show more da otkrije više akcija za svaki post. search: instructions: |- - _psst_… Sakrio sam iznenađenje u ovoj temi. Ako ste spremni za izazov, ** odaberite ikonu za pretraživanje ** gore desno ↗ da ga potražite. Pokušajte tražiti pojam "capy bara" u ovoj temi + _psst_… Sakrio sam iznenađenje u ovoj temi. Ako ste spremni za izazov, ** odaberite ikonu za pretraživanje ** gore desno ↗ da ga potražite. Pokušajte tražiti pojam "capy​bara" u ovoj temi reply: |- Yay ste ga pronašli: tada: - Za detaljnije pretrage, pređite na [full search page] (%{search_url}). - Da biste skočili bilo gdje u dugoj diskusiji, pokušajte kontrolirati vremensku liniju teme desno (i na dnu, na mobitelu). - Ako imate fizičku tipkovnicu: pritisnite ? da biste videli naše korisne prečice na tastaturi. not_found: |- @@ -230,14 +230,14 @@ bs_BA: Uf, to je bilo blizu! Hvala za popravku da: wink: Imajte na umu da imate samo %{deletion_after} sat (e) da povratite post. category_hashtag: instructions: |- - Da li ste znali da možete koristiti kategorije i oznake u svom postu? Na primjer, jeste li vidjeli kategoriju %{category}? Upišite "#" u sredinu rečenice i odaberite bilo koju kategoriju ili oznaku. + Da li ste znali da možete koristiti kategorije i oznake u svom postu? Na primjer, jeste li vidjeli kategoriju %{category}? Upišite `#` u sredinu rečenice i odaberite bilo koju kategoriju ili oznaku. not_found: |- Hmm, tamo ne vidim nijednu kategoriju. Imajte na umu da `#` ne može biti prvi znak. Možete li ovo kopirati u sljedećem odgovoru? `` `tekst Mogu kreirati link za kategoriju preko #` `` reply: |- Odlično! Zapamtite ovo radi za obje kategorije _i_ oznake, ako su oznake omogućene. change_topic_notification_level: instructions: |- - Svaka tema ima nivo obaveštenja. Počinje na 'normalnom', što znači da ćete obično biti obaviješteni samo kada netko govori izravno vama. Podrazumevano, nivo obaveštenja za privatnu poruku postavljen je na najviši nivo "gledanja", što znači da ćete biti obaviješteni o svakom novom odgovoru. Ali možete pregaziti nivo obavijesti za _any_ temu da biste 'gledali', 'pratili' ili 'isključili'. Pokušajmo da promenimo nivo obaveštenja za ovu temu. Na dnu teme naći ćete dugme koje pokazuje da ** gledate ** ovu temu. Možete li promijeniti nivo obavijesti na ** praćenje **? + Svaka tema ima nivo obaveštenja. Počinje na 'normalnom', što znači da ćete obično biti obaviješteni samo kada netko govori izravno vama. Podrazumevano, nivo obaveštenja za privatnu poruku postavljen je na najviši nivo 'gledanja', što znači da ćete biti obaviješteni o svakom novom odgovoru. Ali možete pregaziti nivo obavijesti za _any_ temu da biste 'gledali', 'pratili' ili 'isključili'. Pokušajmo da promenimo nivo obaveštenja za ovu temu. Na dnu teme naći ćete dugme koje pokazuje da ** gledate ** ovu temu. Možete li promijeniti nivo obavijesti na ** praćenje **? not_found: |- Izgleda da i dalje gledate: oči: ova tema! Ako imate problema sa pronalaženjem, dugme nivoa obaveštenja nalazi se na dnu teme. reply: |- @@ -251,7 +251,7 @@ bs_BA: Hej, lepa anketa! Kako sam te naučio? [anketa] *: +1: *: -1: [/ poll] details: instructions: |- - Ponekad možete poželeti da ** sakrijete detalje ** u odgovorima: - Kada raspravljate o točkama filma ili TV emisije koje bi se smatrale spojlerom. - Kada je vašoj poruci potrebno puno neobaveznih detalja koji mogu biti neodoljivi kada se pročitaju odjednom. [details = Izaberite ovo da vidite kako radi!] 1. Izaberite opremu u uredniku. 2. Izaberite "Sakrij detalje". 3. Uredite sažetak detalja i dodajte svoj sadržaj. Možete li koristiti opremite u uredniku da dodate sledeći odeljak detaljima? + Ponekad možete poželeti da ** sakrijete detalje ** u odgovorima: - Kada raspravljate o točkama filma ili TV emisije koje bi se smatrale spojlerom. - Kada je vašoj poruci potrebno puno neobaveznih detalja koji mogu biti neodoljivi kada se pročitaju odjednom. [details = Izaberite ovo da vidite kako radi!] 1. Izaberite opremu u uredniku. 2. Izaberite "Sakrij detalje". 3. Uredite sažetak detalja i dodajte svoj sadržaj. Možete li koristiti opremite u uredniku da dodate sledeći odeljak detaljima? not_found: |- Imate problema sa kreiranjem widgeta detalja? Pokušajte uključiti sljedeće u sljedećem odgovoru: `` `tekst [detalji = Izaberite me za detalje] Ovdje su detalji [/ details]` `` reply: |- diff --git a/plugins/discourse-narrative-bot/config/locales/server.ca.yml b/plugins/discourse-narrative-bot/config/locales/server.ca.yml index 8499bafac9..c1b8bbfb91 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.ca.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.ca.yml @@ -26,6 +26,8 @@ ca: Aquesta insígnia es concedeix després d'haver completat amb èxit el tutorial interactiu d'usuari avançat. Heu dominat les eines avançades de discussió, i ara teniu esteu completament acreditat! discourse_narrative_bot: bio: "Hola, no sóc una persona real, sóc un robot que us pot ensenyar coses sobre aquest lloc web. Per a interactuar amb mi, envieu-me un missatge o feu una menció a **%{discobot_username}** en qualsevol lloc. " + tl2_promotion_message: + subject_template: "Enhorabona per la vostra promoció de nivell de confiança!" timeout: message: |- Bon dia @%{username}, diff --git a/plugins/discourse-narrative-bot/config/locales/server.de.yml b/plugins/discourse-narrative-bot/config/locales/server.de.yml index 3c19805ba8..56aa5d27a6 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.de.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.de.yml @@ -26,6 +26,8 @@ de: Das Abzeichen wird verliehen, wenn das interaktive Tutorial für fortgeschrittene Benutzer erfolgreich abgeschlossen wurde. Du beherrscht die fortgeschrittenen Werkzeuge für Diskussionen erlernt und besitzt nun die Lizenz zum Diskutieren. discourse_narrative_bot: bio: "Hallo! Ich bin keine reale Person. Ich bin ein Bot, der dir etwas über diese Website beibringen kann. Schick mir eine Nachricht oder erwähne irgendwo **`@%{discobot_username}`**, um mit mir zu interagieren." + tl2_promotion_message: + subject_template: "Herzlichen Glückwunsch zur Beförderung ihrer Vertrauensstufe!" timeout: message: |- Hallo @%{username}! Ich wollte mich nur wieder einmal melden, weil ich schon länger nichts von dir gehört habe. diff --git a/plugins/discourse-narrative-bot/config/locales/server.en.yml b/plugins/discourse-narrative-bot/config/locales/server.en.yml index f0d54c7f55..00de771a4d 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.en.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.en.yml @@ -22,6 +22,15 @@ en: discourse_narrative_bot: bio: "Hi, I’m not a real person. I’m a bot that can teach you about this site. To interact with me, send me a message or mention **`@%{discobot_username}`** anywhere." + tl2_promotion_message: + subject_template: "Congratulations on your trust level promotion!" + text_body_template: | + Now that you’ve been promoted, it’s time to learn about some advanced features! + + Reply to this message with `@discobot start advanced tutorial` to find out more about what you can do. + + We invite you to keep getting involved – we enjoy having you around. + timeout: message: |- Hey @%{username}, just checking in because I haven’t heard from you in a while. diff --git a/plugins/discourse-narrative-bot/config/locales/server.es.yml b/plugins/discourse-narrative-bot/config/locales/server.es.yml index 0e5c59a59c..7bf6fcc14f 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.es.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.es.yml @@ -26,6 +26,14 @@ es: Esta medalla se otorga al completar con éxito el tutorial interactivo para usuarios avanzados. Has dominado las herramientas avanzadas de discusión y ¡ahora tienes licencia completa! discourse_narrative_bot: bio: "¡Hola! No soy una persona real, soy un bot que te puede enseñar acerca este sitio. Para interactuar conmigo, envíame un mensaje o menciona **`%{discobot_username}`** en cualquier lugar." + tl2_promotion_message: + subject_template: "¡Felicitaciones por tu promoción del nivel de confianza!" + text_body_template: | + ¡Ahora que has sido promovido, es tiempo de que aprendas sobre las características avanzadas! + + Responde a este mensaje con `@discobot start advanced tutorial` para saber más sobre lo que puedes hacer. + + Te invitamos a que sigas participando – disfrutamos tenerte cerca. timeout: message: |- Hey, @%{username}, te quería decir que estoy pendiente de ti porque no he tenido noticias tuyas en mucho tiempo. @@ -146,7 +154,7 @@ es: hello: title: "¡Saludos!" message: |- - Gracias por unirte %{title}, ¡y bienvenido! + Gracias por unirte a %{title}, ¡y bienvenido! - Solo soy un robot, pero [nuestro amigable staff](%{base_uri}/about) también está aquí para ayudarte si necesitas comunicarte con una persona. diff --git a/plugins/discourse-narrative-bot/config/locales/server.he.yml b/plugins/discourse-narrative-bot/config/locales/server.he.yml index b744f4e6ba..ae68e58ba7 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.he.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.he.yml @@ -26,6 +26,14 @@ he: עיטור זה מוענק עם השלמת המדריך האינטראקטיבי משתמשים מתקדמים אינטראקטיבי בהצלחה. צברת מומחיות בתחום כלי הדיון המתקדמים וכעת קיבלת רישיון! discourse_narrative_bot: bio: "היי, אני לא בן אדם אמיתי. אני בוט שיכול ללמד אותך על האתר הזה. כדי לתקשר איתי, יש לשלוח אלי הודעה או לאזכר את **‎`@%{discobot_username}`** בכל מקום שהוא." + tl2_promotion_message: + subject_template: "ברכותינו על התקדמותך בסולם דרגות האמון!" + text_body_template: | + עכשיו, לאחר התקדמותך, הגיע הזמן ללמוד על תכונות מתקדמות! + + יש להגיב להודעה זו עם ‎`@discobot start advanced tutorial` כדי ללמוד עוד על דברים שבאפשרותך לבצע. + + אנו מזמינים אותך להמשיך ולהשתתף – פעילותך חשובה לנו. timeout: message: |- היי @%{username}, מוודא מה אתך כי לא שמעתי ממך זמן מה. diff --git a/plugins/discourse-narrative-bot/config/locales/server.nl.yml b/plugins/discourse-narrative-bot/config/locales/server.nl.yml index 6044fa4fa0..d7b4461f79 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.nl.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.nl.yml @@ -7,23 +7,31 @@ nl: site_settings: - disable_discourse_narrative_bot_welcome_post: "Discourse Narrative Bot welkomstbericht uitschakelen" + disable_discourse_narrative_bot_welcome_post: "Het Discourse Narrative Bot-welkomstbericht uitschakelen" discourse_narrative_bot_ignored_usernames: "Gebruikersnamen die de Discourse Narrative Bot moet negeren" discourse_narrative_bot_disable_public_replies: "Antwoorden door de Discourse Narrative Bot uitschakelen" - discourse_narrative_bot_welcome_post_type: "Wat voor soort welkomstbericht zou de Discourse Narrative Bot moeten uitsturen" - discourse_narrative_bot_welcome_post_delay: "Wacht (n) seconden voordat de Discourse Narrative Bot het welkomstbericht uitstuurd." + discourse_narrative_bot_welcome_post_type: "Type welkomstbericht dat de Discourse Narrative Bot zou moeten uitsturen" + discourse_narrative_bot_welcome_post_delay: "(n) seconden wachten voordat de Discourse Narrative Bot het welkomstbericht uitstuurt." badges: certified: name: Gecertificeerd description: "Doorloop onze tutorial voor nieuwe gebruikers" long_description: | - Deze badge wordt verleend wanneer de interactieve handleiding met succes is doorlopen. Je hebt het initiatief genomen om de basis hulpmiddelen voor discussie te leren en je bent nu gecertificeerd! + Deze badge wordt verleend wanneer de interactieve handleiding voor nieuwe gebruikers met succes is doorlopen. U hebt het initiatief genomen om de basishulpmiddelen voor discussie te leren, en u bent nu gecertificeerd! licensed: - description: "Onze geavanceerde gebruikers handleiding afgerond" + description: "Onze geavanceerde gebruikershandleiding afgerond" discourse_narrative_bot: bio: "Hallo, ik ben geen echt persoon. Ik ben een bot die jou uitleg geeft over deze website. Om met mij te communiceren, stuur me een bericht of vermeld mij **`@%{discobot_username}`** ergens." + tl2_promotion_message: + subject_template: "Gefeliciteerd met de promotie van uw vertrouwensniveau!" + text_body_template: | + Nu u bent gepromoveerd, is het tijd voor wat geavanceerde functies! + + Beantwoord dit bericht met `@discobot start advanced tutorial` om meer te lezen over wat u kunt doen. + + U bent van harte uitgenodigd om mee te blijven werken – we genieten van uw aanwezigheid. dice: - trigger: "gooi" + trigger: "gooien" invalid: |- Sorry, het is wiskundig onmogelijk om die combinatie te gooien. :confounded: results: |- diff --git a/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml b/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml index 4cbc4d5650..95974e1967 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.pl_PL.yml @@ -26,6 +26,14 @@ pl_PL: Odznaka została nadana za ukończenie interaktywnego tutorialu dla zaawansowanych użytkowników. Poznałeś zaawansowane narzędzia dyskusji i jesteś teraz w pełni licencjonowany! discourse_narrative_bot: bio: "Witaj, nie jestem prawdziwą osobą. Jestem botem, który może nauczyć Cię korzystania z tej witryny. Aby skomunikować się ze mną, wyślij do mnie wiadomość lub oznacz **`@%{discobot_username}`** w dowolnym miejscu." + tl2_promotion_message: + subject_template: "Gratuluję zdobycia nowego poziomu zaufania!" + text_body_template: | + Jako że wszedłeś na wyższy poziom zaufania, czas nauczyć się czegoś o zaawansowanych funkcjach! + + Odpisz na tę wiadomość tekstem "@discobot start advanced tutorial", aby dowiedzieć się, co możesz zrobić. + + Zapraszamy do bycia zaangażowanym - cieszymy się, że jesteś z nami. timeout: message: |- Witaj @%{username}, przypominam o sobie bo od dawna do mnie nie zaglądałeś. diff --git a/plugins/discourse-narrative-bot/config/locales/server.vi.yml b/plugins/discourse-narrative-bot/config/locales/server.vi.yml index 5c7e3d659c..b9cf411ebb 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.vi.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.vi.yml @@ -42,6 +42,15 @@ vi: bookmark: instructions: |- Nếu bạn muốn tìm hiểu thêm, hãy chọn nút bên dưới và chọn để **đánh dấu tin nhắn cá nhân này**. Nếu bạn làm như vậy, bạn có thể có một :gift: trong tương lai! + search: + reply: |- + Bạn có thể tìm thấy nó :tada: + + - Để tìm kiếm chi tiết hơn, hãy đến [trang tìm kiếm đầy đủ](%{search_url}). + + - Để nhảy đến bất cứ nơi nào trong một cuộc thảo luận dài, hãy thử các điều khiển dòng thời gian của chủ đề ở bên phải (và dưới cùng, trên thiết bị di động). + + - Nếu bạn có :keyboard: vật lý, nhấn ? để xem các phím tắt tiện dụng của chúng tôi. certificate: alt: "Giấy chứng nhận thành tích" advanced_user_narrative: diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb index 31bbfa3676..342e149128 100644 --- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/actions.rb @@ -18,7 +18,8 @@ module DiscourseNarrativeBot raw: raw, topic_id: post.topic_id, reply_to_post_number: post.post_number, - post_alert_options: defaut_post_alert_opts + post_alert_options: defaut_post_alert_opts, + skip_validations: true } new_post = PostCreator.create!(self.discobot_user, default_opts.merge(opts)) @@ -27,7 +28,8 @@ module DiscourseNarrativeBot else PostCreator.create!(self.discobot_user, { post_alert_options: defaut_post_alert_opts, - raw: raw + raw: raw, + skip_validations: true }.merge(opts)) end end diff --git a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb index 411cf1c8a2..a158521149 100644 --- a/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb +++ b/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/certificate_generator.rb @@ -18,7 +18,7 @@ module DiscourseNarrativeBot end @date = I18n.l(date, format: :date_only) - @discobot_user = User.find(-2) + @discobot_user = ::DiscourseNarrativeBot::Base.new.discobot_user end def new_user_track @@ -93,7 +93,12 @@ module DiscourseNarrativeBot end def fetch_image(url) - URI(url).open('rb', redirect: true, allow_redirections: :all).read + FileHelper.download( + url.to_s, + max_file_size: SiteSetting.max_image_size_kb.kilobytes, + tmp_file_name: 'narrative-bot-logo', + follow_redirect: true + )&.read rescue OpenURI::HTTPError # Ignore if fetching image returns a non 200 response end diff --git a/plugins/discourse-narrative-bot/plugin.rb b/plugins/discourse-narrative-bot/plugin.rb index 9b598a681f..5ea07c9090 100644 --- a/plugins/discourse-narrative-bot/plugin.rb +++ b/plugins/discourse-narrative-bot/plugin.rb @@ -33,6 +33,7 @@ after_initialize do '../autoload/jobs/narrative_timeout.rb', '../autoload/jobs/narrative_init.rb', '../autoload/jobs/send_default_welcome_message.rb', + '../autoload/jobs/send_advanced_tutorial_message.rb', '../autoload/jobs/onceoff/grant_badges.rb', '../autoload/jobs/onceoff/remap_old_bot_images.rb', '../lib/discourse_narrative_bot/actions.rb', @@ -108,7 +109,12 @@ after_initialize do def fetch_avatar_url(user) avatar_url = UrlHelper.absolute(Discourse.base_uri + user.avatar_template.gsub('{size}', '250')) - URI(avatar_url).open('rb', redirect: true, allow_redirections: :all).read + FileHelper.download( + avatar_url.to_s, + max_file_size: SiteSetting.max_image_size_kb.kilobytes, + tmp_file_name: 'narrative-bot-avatar', + follow_redirect: true + )&.read rescue OpenURI::HTTPError # Ignore if fetching image returns a non 200 response end @@ -245,4 +251,18 @@ after_initialize do ) end end + + self.on(:user_promoted) do |args| + promoted_from_tl1 = args[:new_trust_level] == TrustLevel[2] && + args[:old_trust_level] == TrustLevel[1] + + if SiteSetting.discourse_narrative_bot_enabled && promoted_from_tl1 + # The event 'user_promoted' is sometimes called from inside a transaction. + # Use this helper to ensure the job is enqueued after commit to prevent + # any race conditions. + DB.after_commit do + Jobs.enqueue(:send_advanced_tutorial_message, user_id: args[:user_id]) + end + end + end end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb index 85ff8a5b3c..d9ea4c9e2e 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/advanced_user_narrative_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do - let(:discobot_user) { User.find(-2) } + let(:discobot_user) { ::DiscourseNarrativeBot::Base.new.discobot_user } let(:first_post) { Fabricate(:post, user: discobot_user) } let(:user) { Fabricate(:user) } @@ -125,6 +125,14 @@ RSpec.describe DiscourseNarrativeBot::AdvancedUserNarrative do expect(new_post.raw).to eq(expected_raw.chomp) expect(new_post.topic.id).to_not eq(topic.id) end + + it 'should not explode if title emojis are disabled' do + SiteSetting.max_emojis_in_title = 0 + narrative.reset_bot(user, other_post) + + expect(Topic.last.title).to eq(I18n.t('discourse_narrative_bot.advanced_user_narrative.title')) + end + end end diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb index b7c5ec34d4..bb642a733e 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/new_user_narrative_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' describe DiscourseNarrativeBot::NewUserNarrative do let!(:welcome_topic) { Fabricate(:topic, title: 'Welcome to Discourse') } - let(:discobot_user) { User.find(-2) } + let(:discobot_user) { ::DiscourseNarrativeBot::Base.new.discobot_user } let(:first_post) { Fabricate(:post, user: discobot_user) } let(:user) { Fabricate(:user) } diff --git a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb index d466a29dc0..c957cfe627 100644 --- a/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb +++ b/plugins/discourse-narrative-bot/spec/discourse_narrative_bot/track_selector_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' describe DiscourseNarrativeBot::TrackSelector do let(:user) { Fabricate(:user) } - let(:discobot_user) { User.find(-2) } + let(:discobot_user) { ::DiscourseNarrativeBot::Base.new.discobot_user } let(:narrative) { DiscourseNarrativeBot::NewUserNarrative.new } let(:random_mention_reply) do diff --git a/plugins/discourse-narrative-bot/spec/jobs/onceoff/remap_old_bot_iamges_spec.rb b/plugins/discourse-narrative-bot/spec/jobs/onceoff/remap_old_bot_iamges_spec.rb index d697682890..6543cbd954 100644 --- a/plugins/discourse-narrative-bot/spec/jobs/onceoff/remap_old_bot_iamges_spec.rb +++ b/plugins/discourse-narrative-bot/spec/jobs/onceoff/remap_old_bot_iamges_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Jobs::DiscourseNarrativeBot::RemapOldBotImages do context "when bot's post contains an old link" do let(:post) do Fabricate(:post, - user: User.find(-2), + user: ::DiscourseNarrativeBot::Base.new.discobot_user, raw: 'If you’d like to learn more, select below and **bookmark this private message**. If you do, there may be a :gift: in your future!' ) end @@ -27,7 +27,7 @@ RSpec.describe Jobs::DiscourseNarrativeBot::RemapOldBotImages do context 'subfolder' do let(:post) do Fabricate(:post, - user: User.find(-2), + user: ::DiscourseNarrativeBot::Base.new.discobot_user, raw: 'If you’d like to learn more, select below and **bookmark this private message**. If you do, there may be a :gift: in your future!' ) end diff --git a/plugins/discourse-narrative-bot/spec/jobs/send_advanced_tutorial_message_spec.rb b/plugins/discourse-narrative-bot/spec/jobs/send_advanced_tutorial_message_spec.rb new file mode 100644 index 0000000000..8c3038d30c --- /dev/null +++ b/plugins/discourse-narrative-bot/spec/jobs/send_advanced_tutorial_message_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +RSpec.describe Jobs::SendAdvancedTutorialMessage do + before do + Jobs.run_immediately! + SiteSetting.discourse_narrative_bot_enabled = true + end + + it 'sends a message to the promoted user' do + user = Fabricate(:user) + system_user = Discourse.system_user + Jobs.enqueue(:send_advanced_tutorial_message, user_id: user.id) + + topic = Topic.last + + expect(topic).not_to be_nil + expect(topic.user).to eq(system_user) + expect(topic.archetype).to eq(Archetype.private_message) + expect(topic.topic_allowed_users.pluck(:user_id)).to contain_exactly( + system_user.id, user.id + ) + end +end diff --git a/plugins/discourse-nginx-performance-report/app/jobs/scheduled/daily_performance_report.rb b/plugins/discourse-nginx-performance-report/app/jobs/scheduled/daily_performance_report.rb deleted file mode 100644 index f79c3a20d5..0000000000 --- a/plugins/discourse-nginx-performance-report/app/jobs/scheduled/daily_performance_report.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DailyPerformanceReport < ::Jobs::Scheduled - every 1.day - per_host - - def execute(args) - if SiteSetting.daily_performance_report && - RailsMultisite::ConnectionManagement.current_db == "default" - result = `ruby #{Rails.root}/plugins/discourse-nginx-performance-report/script/nginx_analyze.rb --limit 1440` - - report_data = - if result.strip.empty? - <<~TEXT - Report is only available in latest image, please run: - - ```text - cd /var/discourse - ./launcher rebuild app - ``` - TEXT - else - "```text\n#{result}\n```" - end - - PostCreator.create(Discourse.system_user, - topic_id: performance_topic_id, - raw: report_data, - skip_validations: true) - - end - end - - def performance_topic_id - if SiteSetting.performance_report_topic_id > 0 - topic = Topic.find_by(id: SiteSetting.performance_report_topic_id) - return topic.id if topic - end - - staff_category = Category.find_by(id: SiteSetting.staff_category_id) - raise StandardError, "Staff category was not found" unless staff_category - - post = PostCreator.create(Discourse.system_user, - raw: I18n.t('performance_report.initial_post_raw'), - category: staff_category.name, - title: I18n.t('performance_report.initial_topic_title'), - skip_validations: true) - - unless post && post.topic_id - raise StandardError, "Could not create or retrieve performance report topic id" - end - - SiteSetting.performance_report_topic_id = post.topic_id - end - - end -end diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ar.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ar.yml deleted file mode 100644 index 6d72a5333e..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ar.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ar: - site_settings: - daily_performance_report: "تحليل سجلّات الخادم NGINX يوميًا ونشر موضوع مفصّل مع طاقم الموقع" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.be.yml b/plugins/discourse-nginx-performance-report/config/locales/server.be.yml deleted file mode 100644 index 59ac412bb7..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.be.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -be: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.bg.yml b/plugins/discourse-nginx-performance-report/config/locales/server.bg.yml deleted file mode 100644 index 2d50be1373..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.bg.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -bg: - site_settings: - daily_performance_report: "Ежедневно анализирай на NGINX логовете в публиковай в темата която е само за екипа с детайли." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.bs_BA.yml b/plugins/discourse-nginx-performance-report/config/locales/server.bs_BA.yml deleted file mode 100644 index 3e17cc6c54..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.bs_BA.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -bs_BA: - site_settings: - daily_performance_report: "Analiziraj NGINX logove dnevno i objavljuj u Staff Only temu sa detaljima" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ca.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ca.yml deleted file mode 100644 index fa9817a01b..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ca.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ca: - site_settings: - daily_performance_report: "Analitza els registres (logs) de NGINX diàriament, i publica un tema sols per a l'equip responsable amb els detalls." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.cs.yml b/plugins/discourse-nginx-performance-report/config/locales/server.cs.yml deleted file mode 100644 index 3ee9703c97..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.cs.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -cs: - site_settings: - daily_performance_report: "Analyzovat denní logy NGINX a zaslat téma s podrobnostmi přístupné pouze členům redakce." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.da.yml b/plugins/discourse-nginx-performance-report/config/locales/server.da.yml deleted file mode 100644 index c5fd58f56d..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.da.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -da: - site_settings: - daily_performance_report: "Analysér NGINX-logfiler dagligt, og send et 'kun hjælperteam'-emne med detaljer" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.de.yml b/plugins/discourse-nginx-performance-report/config/locales/server.de.yml deleted file mode 100644 index d03cf19445..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.de.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -de: - site_settings: - daily_performance_report: "Analysiere die NGINX-Logs täglich. Poste anschließend eine Zusammenfassung als Beitrag, welcher nur für Moderatoren oder Administratoren zugänglich ist." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.el.yml b/plugins/discourse-nginx-performance-report/config/locales/server.el.yml deleted file mode 100644 index 63bf4b0e2f..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.el.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -el: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.en.yml b/plugins/discourse-nginx-performance-report/config/locales/server.en.yml deleted file mode 100644 index ba3f80b0f7..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.en.yml +++ /dev/null @@ -1,3 +0,0 @@ -en: - site_settings: - daily_performance_report: "Analyze NGINX logs daily and post a Staff Only topic with details" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.es.yml b/plugins/discourse-nginx-performance-report/config/locales/server.es.yml deleted file mode 100644 index b304e80445..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.es.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -es: - site_settings: - daily_performance_report: "Analizar los registros de NGINX diariamente y publicar un tema solo para el staff con detalles" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.et.yml b/plugins/discourse-nginx-performance-report/config/locales/server.et.yml deleted file mode 100644 index 8c601aa1ed..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.et.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -et: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.fa_IR.yml b/plugins/discourse-nginx-performance-report/config/locales/server.fa_IR.yml deleted file mode 100644 index a2a48af4a4..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.fa_IR.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fa_IR: - site_settings: - daily_performance_report: "تحلیل لاگ‌های NGINX و ارسال یک پست شامل جزئیات ویژه مدیران" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.fi.yml b/plugins/discourse-nginx-performance-report/config/locales/server.fi.yml deleted file mode 100644 index e438a7bf68..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.fi.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fi: - site_settings: - daily_performance_report: "Analysoi NGINX lokit päivittäin ja julkaise vain henkilökunnalle näkyvä ketju yksityiskohdista" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.fr.yml b/plugins/discourse-nginx-performance-report/config/locales/server.fr.yml deleted file mode 100644 index 6867c86975..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.fr.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fr: - site_settings: - daily_performance_report: "Analyser les logs de NGINX quotidiennement et poster un sujet Responsables Uniquement avec les détails" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.gl.yml b/plugins/discourse-nginx-performance-report/config/locales/server.gl.yml deleted file mode 100644 index 695b5cf287..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.gl.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -gl: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.he.yml b/plugins/discourse-nginx-performance-report/config/locales/server.he.yml deleted file mode 100644 index 8a96541962..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.he.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -he: - site_settings: - daily_performance_report: "Analyze NGINX logs daily and post a Staff Only topic with details" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.hu.yml b/plugins/discourse-nginx-performance-report/config/locales/server.hu.yml deleted file mode 100644 index 636956c586..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.hu.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -hu: - site_settings: - daily_performance_report: "NGINX naplók napi elemzése és egy csak stábtagoknak téma létrehozása a részletekkel" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.hy.yml b/plugins/discourse-nginx-performance-report/config/locales/server.hy.yml deleted file mode 100644 index 81e891a73f..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.hy.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -hy: - site_settings: - daily_performance_report: "Վերլուծեք NGINX-ի գրառումներն ամեն օր և մանրամասները հրապարակեք Միայն ԱՆձնակազմի համար թեմայում" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.id.yml b/plugins/discourse-nginx-performance-report/config/locales/server.id.yml deleted file mode 100644 index 2112cad6a2..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.id.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -id: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.it.yml b/plugins/discourse-nginx-performance-report/config/locales/server.it.yml deleted file mode 100644 index 1baea30d06..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.it.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -it: - site_settings: - daily_performance_report: "Analizza i log di NGINX giornalmente, e pubblica un argomento con i dettagli visibile solo allo Staff" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ja.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ja.yml deleted file mode 100644 index 12af1da55f..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ja.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ja: - site_settings: - daily_performance_report: "日別のNGINXのログを解析し、Staff Onlyトピックへ詳細を投稿する" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ko.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ko.yml deleted file mode 100644 index 7e6a71596b..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ko.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ko: - site_settings: - daily_performance_report: "NGINX 일별 로그를 분석하고 상세정보를 스태프들에게 글타래 게시" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.lt.yml b/plugins/discourse-nginx-performance-report/config/locales/server.lt.yml deleted file mode 100644 index 8541587912..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.lt.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -lt: - site_settings: - daily_performance_report: "Analizuokite NGINX logs kasdien ir paskelbkite \"Staff Only\" temą su detalėmis" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.lv.yml b/plugins/discourse-nginx-performance-report/config/locales/server.lv.yml deleted file mode 100644 index dc48bb58ee..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.lv.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -lv: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.nb_NO.yml b/plugins/discourse-nginx-performance-report/config/locales/server.nb_NO.yml deleted file mode 100644 index 9beb50d498..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.nb_NO.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -nb_NO: - site_settings: - daily_performance_report: "Analyser NGINX logger hver dag og opprett et emne for Staben med detaljer " diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.nl.yml b/plugins/discourse-nginx-performance-report/config/locales/server.nl.yml deleted file mode 100644 index e9b5e384e0..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.nl.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -nl: - site_settings: - daily_performance_report: "Analyseer elke dag NGINX logs en post een Alleen Voor Medewerkers topic met de details" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.pl_PL.yml b/plugins/discourse-nginx-performance-report/config/locales/server.pl_PL.yml deleted file mode 100644 index 8642c1a2b1..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.pl_PL.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pl_PL: - site_settings: - daily_performance_report: "Analizuj logi NGINX codziennie i umieszczaj szczegóły w temacie widocznym tylko dla członków zespołu" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.pt.yml b/plugins/discourse-nginx-performance-report/config/locales/server.pt.yml deleted file mode 100644 index 894545936b..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.pt.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt: - site_settings: - daily_performance_report: "Analise os logs diários NGINX e publique um tópico visível apenas para o pessoal com detalhes" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.pt_BR.yml b/plugins/discourse-nginx-performance-report/config/locales/server.pt_BR.yml deleted file mode 100644 index d082901b20..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.pt_BR.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt_BR: - site_settings: - daily_performance_report: "Analizar logs do NGINX diariamente e postar um tópico somente para a Staff com os detalhes" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ro.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ro.yml deleted file mode 100644 index 31d1c44013..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ro.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ro: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ru.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ru.yml deleted file mode 100644 index c4212530c4..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ru.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ru: - site_settings: - daily_performance_report: "Ежедневно анализировать логи сервера NGINX и отправлять сообщение с результатами анализа в тему, видимую только персоналу." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sk.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sk.yml deleted file mode 100644 index d2b1d85b8d..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sk.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sk: - site_settings: - daily_performance_report: "Denne analyzuj NGINX logy a z detailov vytváraj témy iba pre zamestnancov." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sl.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sl.yml deleted file mode 100644 index 154d194f86..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sl.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sl: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sq.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sq.yml deleted file mode 100644 index 463900e4fb..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sq.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sq: - site_settings: - daily_performance_report: "Analyze NGINX logs daily and post a Staff Only topic with details" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sr.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sr.yml deleted file mode 100644 index 7e57a61dd2..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sr.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sr: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sv.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sv.yml deleted file mode 100644 index 5b71c8af10..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sv.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sv: - site_settings: - daily_performance_report: "Analysera NGINX-loggar dagligen och skriv ett inlägg för personalen med detaljerna" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.sw.yml b/plugins/discourse-nginx-performance-report/config/locales/server.sw.yml deleted file mode 100644 index e32f712689..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.sw.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sw: - site_settings: - daily_performance_report: "Pitia batli za NGINX kila siku na chapisha mada za wasaidizi tu pamoja na maelezo" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.te.yml b/plugins/discourse-nginx-performance-report/config/locales/server.te.yml deleted file mode 100644 index 49141baa04..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.te.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -te: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.th.yml b/plugins/discourse-nginx-performance-report/config/locales/server.th.yml deleted file mode 100644 index 7ed98e15d8..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.th.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -th: {} diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.tr_TR.yml b/plugins/discourse-nginx-performance-report/config/locales/server.tr_TR.yml deleted file mode 100644 index c6ce04582f..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.tr_TR.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -tr_TR: - site_settings: - daily_performance_report: "NGINX kayıtlarını analiz edip detaylı bir şekilde günlük Yetkili kategorisinde bir konu içerisinde paylaş." diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.uk.yml b/plugins/discourse-nginx-performance-report/config/locales/server.uk.yml deleted file mode 100644 index 4e6ab0d32f..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.uk.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -uk: - site_settings: - daily_performance_report: "Щодня аналізуйте журнали NGINX та публікуйте тему лише для персоналу з деталями" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.ur.yml b/plugins/discourse-nginx-performance-report/config/locales/server.ur.yml deleted file mode 100644 index 0d61b8f9d5..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.ur.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ur: - site_settings: - daily_performance_report: "Analyze NGINX logs daily and post a Staff Only topic with details" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.vi.yml b/plugins/discourse-nginx-performance-report/config/locales/server.vi.yml deleted file mode 100644 index 0ad2a948e2..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.vi.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -vi: - site_settings: - daily_performance_report: "Phân tích nhật ký NGINX hàng ngày và gửi cho BQT thông tin chi tiết" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.zh_CN.yml b/plugins/discourse-nginx-performance-report/config/locales/server.zh_CN.yml deleted file mode 100644 index b3ee6f8f27..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.zh_CN.yml +++ /dev/null @@ -1,10 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -zh_CN: - site_settings: - daily_performance_report: "每日分析 NGINX 日志并且发布详情主题到管理人员才能看到的主题" diff --git a/plugins/discourse-nginx-performance-report/config/locales/server.zh_TW.yml b/plugins/discourse-nginx-performance-report/config/locales/server.zh_TW.yml deleted file mode 100644 index 92c18de9ae..0000000000 --- a/plugins/discourse-nginx-performance-report/config/locales/server.zh_TW.yml +++ /dev/null @@ -1,8 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -zh_TW: {} diff --git a/plugins/discourse-nginx-performance-report/config/settings.yml b/plugins/discourse-nginx-performance-report/config/settings.yml deleted file mode 100644 index 352b5fbe94..0000000000 --- a/plugins/discourse-nginx-performance-report/config/settings.yml +++ /dev/null @@ -1,6 +0,0 @@ -plugins: - # Reporting - daily_performance_report: false - performance_report_topic_id: - default: -1 - hidden: true diff --git a/plugins/discourse-nginx-performance-report/lib/log_analyzer.rb b/plugins/discourse-nginx-performance-report/lib/log_analyzer.rb deleted file mode 100644 index 19a299a8e2..0000000000 --- a/plugins/discourse-nginx-performance-report/lib/log_analyzer.rb +++ /dev/null @@ -1,158 +0,0 @@ -# frozen_string_literal: true - -class LogAnalyzer - - class LineParser - - # log_format log_discourse '[$time_local] "$http_host" $remote_addr "$request" "$http_user_agent" "$sent_http_x_discourse_route" $status $bytes_sent "$http_referer" $upstream_response_time $request_time "$sent_http_x_discourse_username"'; - - attr_accessor :time, :ip_address, :url, :route, :user_agent, :rails_duration, :total_duration, - :username, :status, :bytes_sent, :referer - - PATTERN = /\[(.*)\](?: ".*")? (\S+) \"(.*)\" \"(.*)\" \"(.*)\" ([0-9]+) ([0-9]+) \"(.*)\" ([0-9.]+) ([0-9.]+) "(.*)"/ - - TIME_FORMAT = "%d/%b/%Y:%H:%M:%S %Z" - - def self.parse(line) - - result = new - _, result.time, result.ip_address, result.url, result.user_agent, - result.route, result.status, result.bytes_sent, result.referer, - result.rails_duration, result.total_duration, result.username = line.match(PATTERN).to_a - - result.rails_duration = result.rails_duration.to_f - result.total_duration = result.total_duration.to_f - - verb = result.url[0..3] if result.url - if verb && verb == "POST" - result.route += " (POST)" - end - - if verb && verb == "PUT" - result.route += " (PUT)" - end - - result.url = self.sanitize_url(result.url) if result.url - - result - end - - def is_mobile? - user_agent =~ /Mobile|Android|webOS/ && !(user_agent =~ /iPad|Nexus (7|10)/) - end - - def parsed_time - DateTime.strptime(time, TIME_FORMAT) if time - end - - private - - def self.sanitize_url(url) - url.gsub(/api_key=([\w.\-]+)/, 'api_key=[FILTERED]') - end - end - - attr_reader :total_requests, :message_bus_requests, :filenames, - :ip_to_rails_duration, :username_to_rails_duration, - :route_to_rails_duration, :url_to_rails_duration, - :status_404_to_count, :from_time, :to_time - - def self.analyze(filenames, args) - new(filenames, args).analyze - end - - class Aggeregator - - attr_accessor :aggregate_type - - def initialize - @data = {} - @aggregate_type = :duration - end - - def add(id, duration, aggregate = nil) - ary = (@data[id] ||= [0, 0]) - ary[0] += duration - ary[1] += 1 - unless aggregate.nil? - ary[2] ||= Hash.new(0) - if @aggregate_type == :duration - ary[2][aggregate] += duration - elsif @aggregate_type == :count - ary[2][aggregate] += 1 - end - end - end - - def top(n, aggregator_formatter = nil) - @data.sort { |a, b| b[1][0] <=> a[1][0] }.first(n).map do |metric, ary| - metric = metric.to_s - metric = "[empty]" if metric.length == 0 - result = [metric, ary[0], ary[1]] - # handle aggregate - if ary[2] - if aggregator_formatter - result.push aggregator_formatter.call(ary[2], ary[0], ary[1]) - else - result.push ary[2].sort { |a, b| b[1] <=> a[1] }.first(5).map { |k, v| - v = "%.2f" % v if Float === v - "#{k}(#{v})"}.join(" ") - end - end - - result - end - end - end - - def initialize(filenames, args = {}) - @filenames = filenames - @ip_to_rails_duration = Aggeregator.new - @username_to_rails_duration = Aggeregator.new - - @route_to_rails_duration = Aggeregator.new - @route_to_rails_duration.aggregate_type = :count - - @url_to_rails_duration = Aggeregator.new - @status_404_to_count = Aggeregator.new - - @total_requests = 0 - @message_bus_requests = 0 - @limit = args[:limit] - end - - def analyze - now = DateTime.now - - @filenames.each do |filename| - File.open(filename).each_line do |line| - @total_requests += 1 - parsed = LineParser.parse(line) - - next unless parsed.time - next if @limit && ((now - parsed.parsed_time) * 24 * 60).to_i > @limit - - @from_time ||= parsed.time - @to_time = parsed.time - - if parsed.url =~ /(POST|GET) \/message-bus/ - @message_bus_requests += 1 - next - end - - @ip_to_rails_duration.add(parsed.ip_address, parsed.rails_duration) - - username = parsed.username == "-" ? "[Anonymous]" : parsed.username - @username_to_rails_duration.add(username, parsed.rails_duration, parsed.route) - - @route_to_rails_duration.add(parsed.route, parsed.rails_duration, parsed.is_mobile? ? "mobile" : "desktop") - - @url_to_rails_duration.add(parsed.url, parsed.rails_duration) - - @status_404_to_count.add(parsed.url, 1) if parsed.status == "404" - end - end - self - end - -end diff --git a/plugins/discourse-nginx-performance-report/plugin.rb b/plugins/discourse-nginx-performance-report/plugin.rb deleted file mode 100644 index 92531a5004..0000000000 --- a/plugins/discourse-nginx-performance-report/plugin.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -# name: discourse-nginx-performance-report -# about: Analyzing Discourse Performance using NGINX logs -# version: 0.1 -# url: https://github.com/discourse/discourse/tree/master/plugins/discourse-nginx-performance-report - -enabled_site_setting :daily_performance_report -enabled_site_setting_filter "daily_performance_report" -hide_plugin if self.respond_to?(:hide_plugin) - -after_initialize do - load File.expand_path("../app/jobs/scheduled/daily_performance_report.rb", __FILE__) -end diff --git a/plugins/discourse-nginx-performance-report/script/nginx_analyze.rb b/plugins/discourse-nginx-performance-report/script/nginx_analyze.rb deleted file mode 100644 index 2f8d57d9c6..0000000000 --- a/plugins/discourse-nginx-performance-report/script/nginx_analyze.rb +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -require 'date' -require_relative '../lib/log_analyzer' - -args = ARGV.dup - -if args[0] == "--limit" - args.shift - limit = args.shift.to_i -end - -filenames = args if args[0] -filenames ||= ["/var/log/nginx/access.log", "/var/log/nginx/access.log.1"] - -analyzer = LogAnalyzer.analyze(filenames, limit: limit) - -SPACER = "-" * 100 - -# don't feel like pulling in active support -def map_with_index(ary, &block) - idx = 0 - ary.map do |item| - v = block.call(item, idx) - idx += 1 - v - end -end - -def top(cols, aggregator, count, aggregator_formatter = nil) - sorted = aggregator.top(count, aggregator_formatter) - - col_just = [] - - col_widths = map_with_index(cols) do |name, idx| - max_width = name.length - - if cols[idx].respond_to? :align - col_just[idx] = cols[idx].align - skip_just_detection = true - else - col_just[idx] = :ljust - end - - sorted.each do |row| - col_just[idx] = :rjust unless (String === row[idx] || row[idx].nil?) && !skip_just_detection - row[idx] = '%.2f' % row[idx] if Float === row[idx] - row[idx] = row[idx].to_s - max_width = row[idx].length if row[idx].length > max_width - end - [max_width, 80].min - end - - puts(map_with_index(cols) do |name, idx| - name.ljust(col_widths[idx]) - end.join(" ")) - - puts(map_with_index(cols) do |name, idx| - ("-" * name.length).ljust(col_widths[idx]) - end.join(" ")) - - sorted.each do |raw_row| - - rows = [] - idx = 0 - raw_row.each do |col| - j = 0 - col.to_s.scan(/(.{1,80}($|\s)|.{1,80})/).each do |r| - rows[j] ||= [] - rows[j][idx] = r[0] - j += 1 - end - idx += 1 - end - - if rows.length > 1 - puts - end - - rows.each do |row| - cols.length.times do |i| - print row[i].to_s.public_send(col_just[i], col_widths[i]) - print " " - end - puts - end - - if rows.length > 1 - puts - end - - end -end - -class Column < String - attr_accessor :align - - def initialize(val, align) - super(val) - @align = align - end -end - -puts -puts "Analyzed: #{analyzer.filenames.join(",")} on #{`hostname`}" -if limit - puts "Limited to #{DateTime.now - (limit.to_f / (60 * 24.0))} - #{DateTime.now}" -end -puts SPACER -puts "#{analyzer.from_time} - #{analyzer.to_time}" -puts SPACER -puts "Total Requests: #{analyzer.total_requests} ( MessageBus: #{analyzer.message_bus_requests} )" -puts SPACER -puts "Top 30 IPs by Server Load" -puts -top(["IP Address", "Duration", "Reqs"], analyzer.ip_to_rails_duration, 30) -puts SPACER -puts -puts "Top 30 users by Server Load" -puts -top(["Username", "Duration", "Reqs", "Routes"], analyzer.username_to_rails_duration, 30) -puts SPACER -puts -puts "Top 100 routes by Server Load" -puts -top(["Route", "Duration", "Reqs", Column.new("Mobile", :rjust)], analyzer.route_to_rails_duration, 100, lambda { - |hash, name, total| - "#{hash["mobile"] || 0} (#{"%.2f" % (((hash["mobile"] || 0) / (total + 0.0)) * 100)})%"} -) -puts SPACER -puts -puts "Top 30 urls by Server Load" -puts -top(["Url", "Duration", "Reqs"], analyzer.url_to_rails_duration, 30) - -puts "(all durations in seconds)" -puts SPACER -puts -puts "Top 30 not found urls (404s)" -puts -top(["Url", "Count"], analyzer.status_404_to_count, 30) diff --git a/plugins/discourse-nginx-performance-report/spec/line_parser_spec.rb b/plugins/discourse-nginx-performance-report/spec/line_parser_spec.rb deleted file mode 100644 index 6fe38ba8d9..0000000000 --- a/plugins/discourse-nginx-performance-report/spec/line_parser_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' -require_relative '../lib/log_analyzer' - -describe LogAnalyzer::LineParser do - describe '.parse' do - let(:line) { '[22/Sep/2016:07:32:00 +0000] 172.0.0.1 "GET /about.json?api_username=system&api_key=1234567 HTTP/1.1" "Some usename" "about/index" 200 1641 "-" 0.014 0.014 "system"' } - - it "should filter out the api_key" do - result = described_class.parse(line) - expect(result.url).to eq('GET /about.json?api_username=system&api_key=[FILTERED] HTTP/1.1') - end - end -end diff --git a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 b/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 index b34dd942b8..3d913e77c0 100644 --- a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 +++ b/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js.es6 @@ -14,7 +14,10 @@ export default { $(".lazyYT", $elem).lazyYT({ onPlay(e, $el) { // don't cloak posts that have playing videos in them - const postId = parseInt($el.closest("article").data("post-id")); + const postId = parseInt( + $el.closest("article").data("post-id"), + 10 + ); if (postId) { api.preventCloak(postId); } diff --git a/plugins/poll/app/models/poll.rb b/plugins/poll/app/models/poll.rb index 1dd0d0b718..88e918a190 100644 --- a/plugins/poll/app/models/poll.rb +++ b/plugins/poll/app/models/poll.rb @@ -32,6 +32,11 @@ class Poll < ActiveRecord::Base everyone: 1, } + enum chart_type: { + bar: 0, + pie: 1 + } + validates :min, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 } validates :max, numericality: { allow_nil: true, only_integer: true, greater_than: 0 } validates :step, numericality: { allow_nil: true, only_integer: true, greater_than: 0 } @@ -74,6 +79,7 @@ end # anonymous_voters :integer # created_at :datetime not null # updated_at :datetime not null +# chart_type :integer default("bar"), not null # # Indexes # diff --git a/plugins/poll/app/serializers/poll_serializer.rb b/plugins/poll/app/serializers/poll_serializer.rb index e955be9c40..3d73264d03 100644 --- a/plugins/poll/app/serializers/poll_serializer.rb +++ b/plugins/poll/app/serializers/poll_serializer.rb @@ -12,7 +12,8 @@ class PollSerializer < ApplicationSerializer :options, :voters, :close, - :preloaded_voters + :preloaded_voters, + :chart_type def public true diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 index fdb7e0498e..0bcf1e2c7d 100644 --- a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 @@ -2,8 +2,11 @@ import Controller from "@ember/controller"; import { default as computed, observes -} from "ember-addons/ember-computed-decorators"; -import InputValidation from "discourse/models/input-validation"; +} from "discourse-common/utils/decorators"; +import EmberObject from "@ember/object"; + +export const BAR_CHART_TYPE = "bar"; +export const PIE_CHART_TYPE = "pie"; export default Controller.extend({ regularPollType: "regular", @@ -14,6 +17,10 @@ export default Controller.extend({ votePollResult: "on_vote", closedPollResult: "on_close", staffPollResult: "staff_only", + pollChartTypes: [ + { name: BAR_CHART_TYPE.capitalize(), value: BAR_CHART_TYPE }, + { name: PIE_CHART_TYPE.capitalize(), value: PIE_CHART_TYPE } + ], init() { this._super(...arguments); @@ -38,6 +45,11 @@ export default Controller.extend({ ]; }, + @computed("chartType", "pollType", "numberPollType") + isPie(chartType, pollType, numberPollType) { + return pollType !== numberPollType && chartType === PIE_CHART_TYPE; + }, + @computed( "alwaysPollResult", "votePollResult", @@ -140,7 +152,7 @@ export default Controller.extend({ ) pollMaxOptions(isRegular, isMultiple, isNumber, count, pollMin, pollStep) { if (isRegular) return; - const pollMinInt = parseInt(pollMin) || 1; + const pollMinInt = parseInt(pollMin, 10) || 1; if (isMultiple) { return this._comboboxOptions(pollMinInt + 1, count + 1); @@ -159,7 +171,7 @@ export default Controller.extend({ @computed("isNumber", "pollMax") pollStepOptions(isNumber, pollMax) { if (!isNumber) return; - return this._comboboxOptions(1, (parseInt(pollMax) || 1) + 1); + return this._comboboxOptions(1, (parseInt(pollMax, 10) || 1) + 1); }, @computed( @@ -173,6 +185,7 @@ export default Controller.extend({ "pollMax", "pollStep", "autoClose", + "chartType", "date", "time" ) @@ -187,6 +200,7 @@ export default Controller.extend({ pollMax, pollStep, autoClose, + chartType, date, time ) { @@ -212,6 +226,8 @@ export default Controller.extend({ if (pollMax) pollHeader += ` max=${pollMax}`; if (isNumber) pollHeader += ` step=${step}`; if (publicPoll) pollHeader += ` public=true`; + if (chartType && pollType !== "number") + pollHeader += ` chartType=${chartType}`; if (autoClose) { let closeDate = moment( date + " " + time, @@ -260,7 +276,7 @@ export default Controller.extend({ }; } - return InputValidation.create(options); + return EmberObject.create(options); }, @computed("pollStep") @@ -274,7 +290,7 @@ export default Controller.extend({ }; } - return InputValidation.create(options); + return EmberObject.create(options); }, @computed("disableInsert") @@ -288,7 +304,7 @@ export default Controller.extend({ }; } - return InputValidation.create(options); + return EmberObject.create(options); }, _comboboxOptions(start_index, end_index) { @@ -306,6 +322,7 @@ export default Controller.extend({ pollMax: null, pollStep: 1, autoClose: false, + chartType: BAR_CHART_TYPE, date: moment() .add(1, "day") .format("YYYY-MM-DD"), diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs index a23eb84e32..739e0a40dc 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs @@ -17,6 +17,15 @@ valueAttribute="value"}}
+ {{#unless isNumber}} +
+ + {{combo-box content=pollChartTypes + value=chartType + valueAttribute="value"}} +
+ {{/unless}} + {{#if showMinMax}}
@@ -56,12 +65,14 @@ {{input-tip validation=minNumOfOptionsValidation}} {{/unless}} -
- -
+ {{#unless isPie}} +
+ +
+ {{/unless}}
';var a=e.childNodes[0],o=e.childNodes[1];e._reset=function(){a.scrollLeft=1e6,a.scrollTop=1e6,o.scrollLeft=1e6,o.scrollTop=1e6};var r=function(){e._reset(),t()};return x(a,"scroll",r.bind(a,"expand")),x(o,"scroll",r.bind(o,"shrink")),e}((o=!(n=function(){if(c.resizer)return t(y("resize",i))}),r=[],function(){r=Array.prototype.slice.call(arguments),a=a||this,o||(o=!0,f.requestAnimFrame.call(window,function(){o=!1,n.apply(a,r)}))}));l=function(){if(c.resizer){var t=e.parentNode;t&&t!==h.parentNode&&t.insertBefore(h,t.firstChild),h._reset()}},u=(s=e)[g]||(s[g]={}),d=u.renderProxy=function(t){t.animationName===v&&l()},f.each(b,function(t){x(s,t,d)}),u.reflow=!!s.offsetParent,s.classList.add(m)}function o(t){var e,i,n,a=t[g]||{},o=a.resizer;delete a.resizer,i=(e=t)[g]||{},(n=i.renderProxy)&&(f.each(b,function(t){r(e,t,n)}),delete i.renderProxy),e.classList.remove(m),o&&o.parentNode&&o.parentNode.removeChild(o)}e.exports={_enabled:"undefined"!=typeof window&&"undefined"!=typeof document,initialize:function(){var t,e,i,n="from{opacity:0.99}to{opacity:1}";e="@-webkit-keyframes "+v+"{"+n+"}@keyframes "+v+"{"+n+"}."+m+"{-webkit-animation:"+v+" 0.001s;animation:"+v+" 0.001s;}",i=(t=this)._style||document.createElement("style"),t._style||(e="/* Chart.js */\n"+e,(t._style=i).setAttribute("type","text/css"),document.getElementsByTagName("head")[0].appendChild(i)),i.appendChild(document.createTextNode(e))},acquireContext:function(t,e){"string"==typeof t?t=document.getElementById(t):t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas);var i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){var i=t.style,n=t.getAttribute("height"),a=t.getAttribute("width");if(t[g]={initial:{height:n,width:a,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",null===a||""===a){var o=l(t,"width");void 0!==o&&(t.width=o)}if(null===n||""===n)if(""===t.style.height)t.height=t.width/(e.options.aspectRatio||2);else{var r=l(t,"height");void 0!==o&&(t.height=r)}}(t,e),i):null},releaseContext:function(t){var i=t.canvas;if(i[g]){var n=i[g].initial;["height","width"].forEach(function(t){var e=n[t];f.isNullOrUndef(e)?i.removeAttribute(t):i.setAttribute(t,e)}),f.each(n.style||{},function(t,e){i.style[e]=t}),i.width=i.width,delete i[g]}},addEventListener:function(o,t,r){var e=o.canvas;if("resize"!==t){var i=r[g]||(r[g]={});x(e,t,(i.proxies||(i.proxies={}))[o.id+"_"+t]=function(t){var e,i,n,a;r((i=o,n=s[(e=t).type]||e.type,a=f.getRelativePosition(e,i),y(n,i,a.x,a.y,e)))})}else a(e,r,o)},removeEventListener:function(t,e,i){var n=t.canvas;if("resize"!==e){var a=((i[g]||{}).proxies||{})[t.id+"_"+e];a&&r(n,e,a)}else o(n)}},f.addEvent=x,f.removeEvent=r},{46:46}],49:[function(t,e,i){"use strict";var n=t(46),a=t(47),o=t(48),r=o._enabled?o:a;e.exports=n.extend({initialize:function(){},acquireContext:function(){},releaseContext:function(){},addEventListener:function(){},removeEventListener:function(){}},r)},{46:46,47:47,48:48}],50:[function(t,e,i){"use strict";e.exports={},e.exports.filler=t(51),e.exports.legend=t(52),e.exports.title=t(53)},{51:51,52:52,53:53}],51:[function(t,e,i){"use strict";var u=t(26),h=t(41),d=t(46);u._set("global",{plugins:{filler:{propagate:!0}}});var f={dataset:function(t){var e=t.fill,i=t.chart,n=i.getDatasetMeta(e),a=n&&i.isDatasetVisible(e)&&n.dataset._children||[],o=a.length||0;return o?function(t,e){return e');for(var i=0;i'),t.data.datasets[i].label&&e.push(t.data.datasets[i].label),e.push("");return e.push(""),e.join("")}});var r=n.extend({initialize:function(t){D.extend(this,t),this.legendHitBoxes=[],this.doughnutMode=!1},beforeUpdate:o,update:function(t,e,i){var n=this;return n.beforeUpdate(),n.maxWidth=t,n.maxHeight=e,n.margins=i,n.beforeSetDimensions(),n.setDimensions(),n.afterSetDimensions(),n.beforeBuildLabels(),n.buildLabels(),n.afterBuildLabels(),n.beforeFit(),n.fit(),n.afterFit(),n.afterUpdate(),n.minSize},afterUpdate:o,beforeSetDimensions:o,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:o,beforeBuildLabels:o,buildLabels:function(){var e=this,i=e.options.labels||{},t=D.callback(i.generateLabels,[e.chart],e)||[];i.filter&&(t=t.filter(function(t){return i.filter(t,e.chart.data)})),e.options.reverse&&t.reverse(),e.legendItems=t},afterBuildLabels:o,beforeFit:o,fit:function(){var n=this,t=n.options,a=t.labels,e=t.display,o=n.ctx,i=_.global,r=D.valueOrDefault,s=r(a.fontSize,i.defaultFontSize),l=r(a.fontStyle,i.defaultFontStyle),u=r(a.fontFamily,i.defaultFontFamily),d=D.fontString(s,l,u),c=n.legendHitBoxes=[],h=n.minSize,f=n.isHorizontal();if(h.height=f?(h.width=n.maxWidth,e?10:0):(h.width=e?10:0,n.maxHeight),e)if(o.font=d,f){var g=n.lineWidths=[0],p=n.legendItems.length?s+a.padding:0;o.textAlign="left",o.textBaseline="top",D.each(n.legendItems,function(t,e){var i=P(a,s)+s/2+o.measureText(t.text).width;g[g.length-1]+i+a.padding>=n.width&&(p+=s+a.padding,g[g.length]=n.left),c[e]={left:0,top:0,width:i,height:s},g[g.length-1]+=i+a.padding}),h.height+=p}else{var m=a.padding,v=n.columnWidths=[],b=a.padding,x=0,y=0,k=s+m;D.each(n.legendItems,function(t,e){var i=P(a,s)+s/2+o.measureText(t.text).width;y+k>h.height&&(b+=x+a.padding,v.push(x),y=x=0),x=Math.max(x,i),y+=k,c[e]={left:0,top:0,width:i,height:s}}),b+=x,v.push(x),h.width+=b}n.width=h.width,n.height=h.height},afterFit:o,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var c=this,h=c.options,f=h.labels,g=_.global,p=g.elements.line,m=c.width,v=c.lineWidths;if(h.display){var b,x=c.ctx,y=D.valueOrDefault,t=y(f.fontColor,g.defaultFontColor),k=y(f.fontSize,g.defaultFontSize),e=y(f.fontStyle,g.defaultFontStyle),i=y(f.fontFamily,g.defaultFontFamily),n=D.fontString(k,e,i);x.textAlign="left",x.textBaseline="middle",x.lineWidth=.5,x.strokeStyle=t,x.fillStyle=t,x.font=n;var M=P(f,k),w=c.legendHitBoxes,C=c.isHorizontal();b=C?{x:c.left+(m-v[0])/2,y:c.top+f.padding,line:0}:{x:c.left+f.padding,y:c.top+f.padding,line:0};var S=k+f.padding;D.each(c.legendItems,function(t,e){var i,n,a,o,r,s=x.measureText(t.text).width,l=M+k/2+s,u=b.x,d=b.y;C?m<=u+l&&(d=b.y+=S,b.line++,u=b.x=c.left+(m-v[b.line])/2):d+S>c.bottom&&(u=b.x=u+c.columnWidths[b.line]+f.padding,d=b.y=c.top+f.padding,b.line++),function(t,e,i){if(!(isNaN(M)||M<=0)){x.save(),x.fillStyle=y(i.fillStyle,g.defaultColor),x.lineCap=y(i.lineCap,p.borderCapStyle),x.lineDashOffset=y(i.lineDashOffset,p.borderDashOffset),x.lineJoin=y(i.lineJoin,p.borderJoinStyle),x.lineWidth=y(i.lineWidth,p.borderWidth),x.strokeStyle=y(i.strokeStyle,g.defaultColor);var n=0===y(i.lineWidth,p.borderWidth);if(x.setLineDash&&x.setLineDash(y(i.lineDash,p.borderDash)),h.labels&&h.labels.usePointStyle){var a=k*Math.SQRT2/2,o=a/Math.SQRT2,r=t+o,s=e+o;D.canvas.drawPoint(x,i.pointStyle,a,r,s)}else n||x.strokeRect(t,e,M,k),x.fillRect(t,e,M,k);x.restore()}}(u,d,t),w[e].left=u,w[e].top=d,i=t,n=s,o=M+(a=k/2)+u,r=d+a,x.fillText(i.text,o,r),i.hidden&&(x.beginPath(),x.lineWidth=2,x.moveTo(o,r),x.lineTo(o+n,r),x.stroke()),C?b.x+=l+f.padding:b.y+=S})}},handleEvent:function(t){var e=this,i=e.options,n="mouseup"===t.type?"click":t.type,a=!1;if("mousemove"===n){if(!i.onHover)return}else{if("click"!==n)return;if(!i.onClick)return}var o=t.x,r=t.y;if(o>=e.left&&o<=e.right&&r>=e.top&&r<=e.bottom)for(var s=e.legendHitBoxes,l=0;l=u.left&&o<=u.left+u.width&&r>=u.top&&r<=u.top+u.height){if("click"===n){i.onClick.call(e,t.native,e.legendItems[l]),a=!0;break}if("mousemove"===n){i.onHover.call(e,t.native,e.legendItems[l]),a=!0;break}}}return a}});function s(t,e){var i=new r({ctx:t.ctx,options:e,chart:t});a.configure(t,i,e),a.addBox(t,i),t.legend=i}e.exports={id:"legend",_element:r,beforeInit:function(t){var e=t.options.legend;e&&s(t,e)},beforeUpdate:function(t){var e=t.options.legend,i=t.legend;e?(D.mergeIf(e,_.global.legend),i?(a.configure(t,i,e),i.options=e):s(t,e)):i&&(a.removeBox(t,i),delete t.legend)},afterEvent:function(t,e){var i=t.legend;i&&i.handleEvent(e)}}},{26:26,27:27,31:31,46:46}],53:[function(t,e,i){"use strict";var M=t(26),n=t(27),w=t(46),a=t(31),o=w.noop;M._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,lineHeight:1.2,padding:10,position:"top",text:"",weight:2e3}});var r=n.extend({initialize:function(t){w.extend(this,t),this.legendHitBoxes=[]},beforeUpdate:o,update:function(t,e,i){var n=this;return n.beforeUpdate(),n.maxWidth=t,n.maxHeight=e,n.margins=i,n.beforeSetDimensions(),n.setDimensions(),n.afterSetDimensions(),n.beforeBuildLabels(),n.buildLabels(),n.afterBuildLabels(),n.beforeFit(),n.fit(),n.afterFit(),n.afterUpdate(),n.minSize},afterUpdate:o,beforeSetDimensions:o,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:o,beforeBuildLabels:o,buildLabels:o,afterBuildLabels:o,beforeFit:o,fit:function(){var t=this,e=w.valueOrDefault,i=t.options,n=i.display,a=e(i.fontSize,M.global.defaultFontSize),o=t.minSize,r=w.isArray(i.text)?i.text.length:1,s=w.options.toLineHeight(i.lineHeight,a),l=n?r*s+2*i.padding:0;t.isHorizontal()?(o.width=t.maxWidth,o.height=l):(o.width=l,o.height=t.maxHeight),t.width=o.width,t.height=o.height},afterFit:o,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,i=w.valueOrDefault,n=t.options,a=M.global;if(n.display){var o,r,s,l=i(n.fontSize,a.defaultFontSize),u=i(n.fontStyle,a.defaultFontStyle),d=i(n.fontFamily,a.defaultFontFamily),c=w.fontString(l,u,d),h=w.options.toLineHeight(n.lineHeight,l),f=h/2+n.padding,g=0,p=t.top,m=t.left,v=t.bottom,b=t.right;e.fillStyle=i(n.fontColor,a.defaultFontColor),e.font=c,t.isHorizontal()?(r=m+(b-m)/2,s=p+f,o=b-m):(r="left"===n.position?m+f:b-f,s=p+(v-p)/2,o=v-p,g=Math.PI*("left"===n.position?-.5:.5)),e.save(),e.translate(r,s),e.rotate(g),e.textAlign="center",e.textBaseline="middle";var x=n.text;if(w.isArray(x))for(var y=0,k=0;kr.max&&(r.max=i))})});r.min=isFinite(r.min)&&!isNaN(r.min)?r.min:0,r.max=isFinite(r.max)&&!isNaN(r.max)?r.max:1,this.handleTickRangeOptions()},getTickLimit:function(){var t,e=this.options.ticks;if(this.isHorizontal())t=Math.min(e.maxTicksLimit?e.maxTicksLimit:11,Math.ceil(this.width/50));else{var i=c.valueOrDefault(e.fontSize,n.global.defaultFontSize);t=Math.min(e.maxTicksLimit?e.maxTicksLimit:11,Math.ceil(this.height/(2*i)))}return t},handleDirectionalChanges:function(){this.isHorizontal()||this.ticks.reverse()},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},getPixelForValue:function(t){var e=this,i=e.start,n=+e.getRightValue(t),a=e.end-i;return e.isHorizontal()?e.left+e.width/a*(n-i):e.bottom-e.height/a*(n-i)},getValueForPixel:function(t){var e=this,i=e.isHorizontal(),n=i?e.width:e.height,a=(i?t-e.left:e.bottom-t)/n;return e.start+(e.end-e.start)*a},getPixelForTick:function(t){return this.getPixelForValue(this.ticksAsNumbers[t])}});a.registerScaleType("linear",i,e)}},{26:26,34:34,35:35,46:46}],56:[function(t,e,i){"use strict";var c=t(46),n=t(33);e.exports=function(t){var e=c.noop;t.LinearScaleBase=n.extend({getRightValue:function(t){return"string"==typeof t?+t:n.prototype.getRightValue.call(this,t)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var i=c.sign(t.min),n=c.sign(t.max);i<0&&n<0?t.max=0:0=t.max&&(a?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:e,handleDirectionalChanges:e,buildTicks:function(){var t=this,e=t.options.ticks,i=t.getTickLimit(),n={maxTicks:i=Math.max(2,i),min:e.min,max:e.max,precision:e.precision,stepSize:c.valueOrDefault(e.fixedStepSize,e.stepSize)},a=t.ticks=function(t,e){var i,n,a,o=[];if(t.stepSize&&0r.max&&(r.max=i),0!==i&&(null===r.minNotZero||ir.r&&(r.r=g.end,s.r=h),p.startr.b&&(r.b=p.end,s.b=h)}t.setReductions(o,r,s)}(this):(t=this,e=Math.min(t.height/2,t.width/2),t.drawingArea=Math.round(e),t.setCenterPoint(0,0,0,0))},setReductions:function(t,e,i){var n=e.l/Math.sin(i.l),a=Math.max(e.r-this.width,0)/Math.sin(i.r),o=-e.t/Math.cos(i.t),r=-Math.max(e.b-this.height,0)/Math.cos(i.b);n=s(n),a=s(a),o=s(o),r=s(r),this.drawingArea=Math.min(Math.round(t-(n+a)/2),Math.round(t-(o+r)/2)),this.setCenterPoint(n,a,o,r)},setCenterPoint:function(t,e,i,n){var a=this,o=a.width-e-a.drawingArea,r=t+a.drawingArea,s=i+a.drawingArea,l=a.height-n-a.drawingArea;a.xCenter=Math.round((r+o)/2+a.left),a.yCenter=Math.round((s+l)/2+a.top)},getIndexAngle:function(t){return t*(2*Math.PI/b(this))+(this.chart.options&&this.chart.options.startAngle?this.chart.options.startAngle:0)*Math.PI*2/360},getDistanceFromCenterForValue:function(t){var e=this;if(null===t)return 0;var i=e.drawingArea/(e.max-e.min);return e.options.ticks.reverse?(e.max-t)*i:(t-e.min)*i},getPointPosition:function(t,e){var i=this.getIndexAngle(t)-Math.PI/2;return{x:Math.round(Math.cos(i)*e)+this.xCenter,y:Math.round(Math.sin(i)*e)+this.yCenter}},getPointPositionForValue:function(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))},getBasePosition:function(){var t=this.min,e=this.max;return this.getPointPositionForValue(0,this.beginAtZero?0:t<0&&e<0?e:0>1)-1]||null,o=t[n],!a)return{lo:null,hi:o};if(o[e]i))return{lo:a,hi:o};s=n-1}}return{lo:o,hi:null}}(t,e,i),o=a.lo?a.hi?a.lo:t[t.length-2]:t[0],r=a.lo?a.hi?a.hi:t[t.length-1]:t[1],s=r[e]-o[e],l=s?(i-o[e])/s:0,u=(r[n]-o[n])*l;return o[n]+u}function C(t,e){var i=e.parser,n=e.parser||e.format;return"function"==typeof i?i(t):"string"==typeof t&&"string"==typeof n?x(t,n):(t instanceof x||(t=x(t)),t.isValid()?t:"function"==typeof n?n(t):t)}function S(t,e){if(m.isNullOrUndef(t))return null;var i=e.options.time,n=C(e.getRightValue(t),i);return n.isValid()?(i.round&&n.startOf(i.round),n.valueOf()):null}function _(t){for(var e=k.indexOf(t)+1,i=k.length;e=k.indexOf(e);a--)if(o=k[a],y[o].common&&r.as(o)>=t.length)return o;return k[e?k.indexOf(e):0]}(b,m.minUnit,h.min,h.max),h._majorUnit=_(h._unit),h._table=function(t,e,i,n){if("linear"===n||!t.length)return[{time:e,pos:0},{time:i,pos:1}];var a,o,r,s,l,u=[],d=[e];for(a=0,o=t.length;a1&&(a-=1)),[360*a,100*r,100*u]},a.rgb.hwb=function(t){var e=t[0],n=t[1],i=t[2];return[a.rgb.hsl(t)[0],100*(1/255*Math.min(e,Math.min(n,i))),100*(i=1-1/255*Math.max(e,Math.max(n,i)))]},a.rgb.cmyk=function(t){var e,n=t[0]/255,i=t[1]/255,a=t[2]/255;return[100*((1-n-(e=Math.min(1-n,1-i,1-a)))/(1-e)||0),100*((1-i-e)/(1-e)||0),100*((1-a-e)/(1-e)||0),100*e]},a.rgb.keyword=function(t){var i=n[t];if(i)return i;var a,r,o,s=1/0;for(var l in e)if(e.hasOwnProperty(l)){var u=e[l],d=(r=t,o=u,Math.pow(r[0]-o[0],2)+Math.pow(r[1]-o[1],2)+Math.pow(r[2]-o[2],2));d.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.3576*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)+.1805*(i=i>.04045?Math.pow((i+.055)/1.055,2.4):i/12.92)),100*(.2126*e+.7152*n+.0722*i),100*(.0193*e+.1192*n+.9505*i)]},a.rgb.lab=function(t){var e=a.rgb.xyz(t),n=e[0],i=e[1],r=e[2];return i/=100,r/=108.883,n=(n/=95.047)>.008856?Math.pow(n,1/3):7.787*n+16/116,[116*(i=i>.008856?Math.pow(i,1/3):7.787*i+16/116)-16,500*(n-i),200*(i-(r=r>.008856?Math.pow(r,1/3):7.787*r+16/116))]},a.hsl.rgb=function(t){var e,n,i,a,r,o=t[0]/360,s=t[1]/100,l=t[2]/100;if(0===s)return[r=255*l,r,r];e=2*l-(n=l<.5?l*(1+s):l+s-l*s),a=[0,0,0];for(var u=0;u<3;u++)(i=o+1/3*-(u-1))<0&&i++,i>1&&i--,r=6*i<1?e+6*(n-e)*i:2*i<1?n:3*i<2?e+(n-e)*(2/3-i)*6:e,a[u]=255*r;return a},a.hsl.hsv=function(t){var e=t[0],n=t[1]/100,i=t[2]/100,a=n,r=Math.max(i,.01);return n*=(i*=2)<=1?i:2-i,a*=r<=1?r:2-r,[e,100*(0===i?2*a/(r+a):2*n/(i+n)),100*((i+n)/2)]},a.hsv.rgb=function(t){var e=t[0]/60,n=t[1]/100,i=t[2]/100,a=Math.floor(e)%6,r=e-Math.floor(e),o=255*i*(1-n),s=255*i*(1-n*r),l=255*i*(1-n*(1-r));switch(i*=255,a){case 0:return[i,l,o];case 1:return[s,i,o];case 2:return[o,i,l];case 3:return[o,s,i];case 4:return[l,o,i];case 5:return[i,o,s]}},a.hsv.hsl=function(t){var e,n,i,a=t[0],r=t[1]/100,o=t[2]/100,s=Math.max(o,.01);return i=(2-r)*o,n=r*s,[a,100*(n=(n/=(e=(2-r)*s)<=1?e:2-e)||0),100*(i/=2)]},a.hwb.rgb=function(t){var e,n,i,a,r,o,s,l=t[0]/360,u=t[1]/100,d=t[2]/100,h=u+d;switch(h>1&&(u/=h,d/=h),i=6*l-(e=Math.floor(6*l)),0!=(1&e)&&(i=1-i),a=u+i*((n=1-d)-u),e){default:case 6:case 0:r=n,o=a,s=u;break;case 1:r=a,o=n,s=u;break;case 2:r=u,o=n,s=a;break;case 3:r=u,o=a,s=n;break;case 4:r=a,o=u,s=n;break;case 5:r=n,o=u,s=a}return[255*r,255*o,255*s]},a.cmyk.rgb=function(t){var e=t[0]/100,n=t[1]/100,i=t[2]/100,a=t[3]/100;return[255*(1-Math.min(1,e*(1-a)+a)),255*(1-Math.min(1,n*(1-a)+a)),255*(1-Math.min(1,i*(1-a)+a))]},a.xyz.rgb=function(t){var e,n,i,a=t[0]/100,r=t[1]/100,o=t[2]/100;return n=-.9689*a+1.8758*r+.0415*o,i=.0557*a+-.204*r+1.057*o,e=(e=3.2406*a+-1.5372*r+-.4986*o)>.0031308?1.055*Math.pow(e,1/2.4)-.055:12.92*e,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:12.92*n,i=i>.0031308?1.055*Math.pow(i,1/2.4)-.055:12.92*i,[255*(e=Math.min(Math.max(0,e),1)),255*(n=Math.min(Math.max(0,n),1)),255*(i=Math.min(Math.max(0,i),1))]},a.xyz.lab=function(t){var e=t[0],n=t[1],i=t[2];return n/=100,i/=108.883,e=(e/=95.047)>.008856?Math.pow(e,1/3):7.787*e+16/116,[116*(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116)-16,500*(e-n),200*(n-(i=i>.008856?Math.pow(i,1/3):7.787*i+16/116))]},a.lab.xyz=function(t){var e,n,i,a=t[0];e=t[1]/500+(n=(a+16)/116),i=n-t[2]/200;var r=Math.pow(n,3),o=Math.pow(e,3),s=Math.pow(i,3);return n=r>.008856?r:(n-16/116)/7.787,e=o>.008856?o:(e-16/116)/7.787,i=s>.008856?s:(i-16/116)/7.787,[e*=95.047,n*=100,i*=108.883]},a.lab.lch=function(t){var e,n=t[0],i=t[1],a=t[2];return(e=360*Math.atan2(a,i)/2/Math.PI)<0&&(e+=360),[n,Math.sqrt(i*i+a*a),e]},a.lch.lab=function(t){var e,n=t[0],i=t[1];return e=t[2]/360*2*Math.PI,[n,i*Math.cos(e),i*Math.sin(e)]},a.rgb.ansi16=function(t){var e=t[0],n=t[1],i=t[2],r=1 in arguments?arguments[1]:a.rgb.hsv(t)[2];if(0===(r=Math.round(r/50)))return 30;var o=30+(Math.round(i/255)<<2|Math.round(n/255)<<1|Math.round(e/255));return 2===r&&(o+=60),o},a.hsv.ansi16=function(t){return a.rgb.ansi16(a.hsv.rgb(t),t[2])},a.rgb.ansi256=function(t){var e=t[0],n=t[1],i=t[2];return e===n&&n===i?e<8?16:e>248?231:Math.round((e-8)/247*24)+232:16+36*Math.round(e/255*5)+6*Math.round(n/255*5)+Math.round(i/255*5)},a.ansi16.rgb=function(t){var e=t%10;if(0===e||7===e)return t>50&&(e+=3.5),[e=e/10.5*255,e,e];var n=.5*(1+~~(t>50));return[(1&e)*n*255,(e>>1&1)*n*255,(e>>2&1)*n*255]},a.ansi256.rgb=function(t){if(t>=232){var e=10*(t-232)+8;return[e,e,e]}var n;return t-=16,[Math.floor(t/36)/5*255,Math.floor((n=t%36)/6)/5*255,n%6/5*255]},a.rgb.hex=function(t){var e=(((255&Math.round(t[0]))<<16)+((255&Math.round(t[1]))<<8)+(255&Math.round(t[2]))).toString(16).toUpperCase();return"000000".substring(e.length)+e},a.hex.rgb=function(t){var e=t.toString(16).match(/[a-f0-9]{6}|[a-f0-9]{3}/i);if(!e)return[0,0,0];var n=e[0];3===e[0].length&&(n=n.split("").map((function(t){return t+t})).join(""));var i=parseInt(n,16);return[i>>16&255,i>>8&255,255&i]},a.rgb.hcg=function(t){var e,n=t[0]/255,i=t[1]/255,a=t[2]/255,r=Math.max(Math.max(n,i),a),o=Math.min(Math.min(n,i),a),s=r-o;return e=s<=0?0:r===n?(i-a)/s%6:r===i?2+(a-n)/s:4+(n-i)/s+4,e/=6,[360*(e%=1),100*s,100*(s<1?o/(1-s):0)]},a.hsl.hcg=function(t){var e=t[1]/100,n=t[2]/100,i=1,a=0;return(i=n<.5?2*e*n:2*e*(1-n))<1&&(a=(n-.5*i)/(1-i)),[t[0],100*i,100*a]},a.hsv.hcg=function(t){var e=t[1]/100,n=t[2]/100,i=e*n,a=0;return i<1&&(a=(n-i)/(1-i)),[t[0],100*i,100*a]},a.hcg.rgb=function(t){var e=t[0]/360,n=t[1]/100,i=t[2]/100;if(0===n)return[255*i,255*i,255*i];var a,r=[0,0,0],o=e%1*6,s=o%1,l=1-s;switch(Math.floor(o)){case 0:r[0]=1,r[1]=s,r[2]=0;break;case 1:r[0]=l,r[1]=1,r[2]=0;break;case 2:r[0]=0,r[1]=1,r[2]=s;break;case 3:r[0]=0,r[1]=l,r[2]=1;break;case 4:r[0]=s,r[1]=0,r[2]=1;break;default:r[0]=1,r[1]=0,r[2]=l}return a=(1-n)*i,[255*(n*r[0]+a),255*(n*r[1]+a),255*(n*r[2]+a)]},a.hcg.hsv=function(t){var e=t[1]/100,n=e+t[2]/100*(1-e),i=0;return n>0&&(i=e/n),[t[0],100*i,100*n]},a.hcg.hsl=function(t){var e=t[1]/100,n=t[2]/100*(1-e)+.5*e,i=0;return n>0&&n<.5?i=e/(2*n):n>=.5&&n<1&&(i=e/(2*(1-n))),[t[0],100*i,100*n]},a.hcg.hwb=function(t){var e=t[1]/100,n=e+t[2]/100*(1-e);return[t[0],100*(n-e),100*(1-n)]},a.hwb.hcg=function(t){var e=t[1]/100,n=1-t[2]/100,i=n-e,a=0;return i<1&&(a=(n-i)/(1-i)),[t[0],100*i,100*a]},a.apple.rgb=function(t){return[t[0]/65535*255,t[1]/65535*255,t[2]/65535*255]},a.rgb.apple=function(t){return[t[0]/255*65535,t[1]/255*65535,t[2]/255*65535]},a.gray.rgb=function(t){return[t[0]/100*255,t[0]/100*255,t[0]/100*255]},a.gray.hsl=a.gray.hsv=function(t){return[0,0,t[0]]},a.gray.hwb=function(t){return[0,100,t[0]]},a.gray.cmyk=function(t){return[0,0,0,t[0]]},a.gray.lab=function(t){return[t[0],0,0]},a.gray.hex=function(t){var e=255&Math.round(t[0]/100*255),n=((e<<16)+(e<<8)+e).toString(16).toUpperCase();return"000000".substring(n.length)+n},a.rgb.gray=function(t){return[(t[0]+t[1]+t[2])/3/255*100]}}));n.rgb,n.hsl,n.hsv,n.hwb,n.cmyk,n.xyz,n.lab,n.lch,n.hex,n.keyword,n.ansi16,n.ansi256,n.hcg,n.apple,n.gray;function i(t){var e=function(){for(var t={},e=Object.keys(n),i=e.length,a=0;a1&&(e=Array.prototype.slice.call(arguments));var n=t(e);if("object"==typeof n)for(var i=n.length,a=0;a1&&(e=Array.prototype.slice.call(arguments)),t(e))};return"conversion"in t&&(e.conversion=t.conversion),e}(i)}))}));var s=o,l={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},u={getRgba:d,getHsla:h,getRgb:function(t){var e=d(t);return e&&e.slice(0,3)},getHsl:function(t){var e=h(t);return e&&e.slice(0,3)},getHwb:c,getAlpha:function(t){var e=d(t);if(e)return e[3];if(e=h(t))return e[3];if(e=c(t))return e[3]},hexString:function(t,e){e=void 0!==e&&3===t.length?e:t[3];return"#"+v(t[0])+v(t[1])+v(t[2])+(e>=0&&e<1?v(Math.round(255*e)):"")},rgbString:function(t,e){if(e<1||t[3]&&t[3]<1)return f(t,e);return"rgb("+t[0]+", "+t[1]+", "+t[2]+")"},rgbaString:f,percentString:function(t,e){if(e<1||t[3]&&t[3]<1)return g(t,e);var n=Math.round(t[0]/255*100),i=Math.round(t[1]/255*100),a=Math.round(t[2]/255*100);return"rgb("+n+"%, "+i+"%, "+a+"%)"},percentaString:g,hslString:function(t,e){if(e<1||t[3]&&t[3]<1)return p(t,e);return"hsl("+t[0]+", "+t[1]+"%, "+t[2]+"%)"},hslaString:p,hwbString:function(t,e){void 0===e&&(e=void 0!==t[3]?t[3]:1);return"hwb("+t[0]+", "+t[1]+"%, "+t[2]+"%"+(void 0!==e&&1!==e?", "+e:"")+")"},keyword:function(t){return b[t.slice(0,3)]}};function d(t){if(t){var e=[0,0,0],n=1,i=t.match(/^#([a-fA-F0-9]{3,4})$/i),a="";if(i){a=(i=i[1])[3];for(var r=0;rn?(e+.05)/(n+.05):(n+.05)/(e+.05)},level:function(t){var e=this.contrast(t);return e>=7.1?"AAA":e>=4.5?"AA":""},dark:function(){var t=this.values.rgb;return(299*t[0]+587*t[1]+114*t[2])/1e3<128},light:function(){return!this.dark()},negate:function(){for(var t=[],e=0;e<3;e++)t[e]=255-this.values.rgb[e];return this.setValues("rgb",t),this},lighten:function(t){var e=this.values.hsl;return e[2]+=e[2]*t,this.setValues("hsl",e),this},darken:function(t){var e=this.values.hsl;return e[2]-=e[2]*t,this.setValues("hsl",e),this},saturate:function(t){var e=this.values.hsl;return e[1]+=e[1]*t,this.setValues("hsl",e),this},desaturate:function(t){var e=this.values.hsl;return e[1]-=e[1]*t,this.setValues("hsl",e),this},whiten:function(t){var e=this.values.hwb;return e[1]+=e[1]*t,this.setValues("hwb",e),this},blacken:function(t){var e=this.values.hwb;return e[2]+=e[2]*t,this.setValues("hwb",e),this},greyscale:function(){var t=this.values.rgb,e=.3*t[0]+.59*t[1]+.11*t[2];return this.setValues("rgb",[e,e,e]),this},clearer:function(t){var e=this.values.alpha;return this.setValues("alpha",e-e*t),this},opaquer:function(t){var e=this.values.alpha;return this.setValues("alpha",e+e*t),this},rotate:function(t){var e=this.values.hsl,n=(e[0]+t)%360;return e[0]=n<0?360+n:n,this.setValues("hsl",e),this},mix:function(t,e){var n=t,i=void 0===e?.5:e,a=2*i-1,r=this.alpha()-n.alpha(),o=((a*r==-1?a:(a+r)/(1+a*r))+1)/2,s=1-o;return this.rgb(o*this.red()+s*n.red(),o*this.green()+s*n.green(),o*this.blue()+s*n.blue()).alpha(this.alpha()*i+n.alpha()*(1-i))},toJSON:function(){return this.rgb()},clone:function(){var t,e,n=new y,i=this.values,a=n.values;for(var r in i)i.hasOwnProperty(r)&&(t=i[r],"[object Array]"===(e={}.toString.call(t))?a[r]=t.slice(0):"[object Number]"===e?a[r]=t:console.error("unexpected color value:",t));return n}},y.prototype.spaces={rgb:["red","green","blue"],hsl:["hue","saturation","lightness"],hsv:["hue","saturation","value"],hwb:["hue","whiteness","blackness"],cmyk:["cyan","magenta","yellow","black"]},y.prototype.maxes={rgb:[255,255,255],hsl:[360,100,100],hsv:[360,100,100],hwb:[360,100,100],cmyk:[100,100,100,100]},y.prototype.getValues=function(t){for(var e=this.values,n={},i=0;i=0;a--)e.call(n,t[a],a);else for(a=0;a=1?t:-(Math.sqrt(1-t*t)-1)},easeOutCirc:function(t){return Math.sqrt(1-(t-=1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),-i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n))},easeOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:1===t?1:(n||(n=.3),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),i*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/n)+1)},easeInOutElastic:function(t){var e=1.70158,n=0,i=1;return 0===t?0:2==(t/=.5)?1:(n||(n=.45),i<1?(i=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/i),t<1?i*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*-.5:i*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*.5+1)},easeInBack:function(t){var e=1.70158;return t*t*((e+1)*t-e)},easeOutBack:function(t){var e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack:function(t){var e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:function(t){return 1-S.easeOutBounce(1-t)},easeOutBounce:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:function(t){return t<.5?.5*S.easeInBounce(2*t):.5*S.easeOutBounce(2*t-1)+.5}},C={effects:S};M.easingEffects=S;var P=Math.PI,A=P/180,D=2*P,T=P/2,I=P/4,F=2*P/3,L={clear:function(t){t.ctx.clearRect(0,0,t.width,t.height)},roundedRect:function(t,e,n,i,a,r){if(r){var o=Math.min(r,a/2,i/2),s=e+o,l=n+o,u=e+i-o,d=n+a-o;t.moveTo(e,l),se.left-1e-6&&t.xe.top-1e-6&&t.y0&&this.requestAnimationFrame()},advance:function(){for(var t,e,n,i,a=this.animations,r=0;r=n?(V.callback(t.onAnimationComplete,[t],e),e.animating=!1,a.splice(r,1)):++r}},J=V.options.resolve,Q=["push","pop","shift","splice","unshift"];function tt(t,e){var n=t._chartjs;if(n){var i=n.listeners,a=i.indexOf(e);-1!==a&&i.splice(a,1),i.length>0||(Q.forEach((function(e){delete t[e]})),delete t._chartjs)}}var et=function(t,e){this.initialize(t,e)};V.extend(et.prototype,{datasetElementType:null,dataElementType:null,_datasetElementOptions:["backgroundColor","borderCapStyle","borderColor","borderDash","borderDashOffset","borderJoinStyle","borderWidth"],_dataElementOptions:["backgroundColor","borderColor","borderWidth","pointStyle"],initialize:function(t,e){var n=this;n.chart=t,n.index=e,n.linkScales(),n.addElements(),n._type=n.getMeta().type},updateIndex:function(t){this.index=t},linkScales:function(){var t=this.getMeta(),e=this.chart,n=e.scales,i=this.getDataset(),a=e.options.scales;null!==t.xAxisID&&t.xAxisID in n&&!i.xAxisID||(t.xAxisID=i.xAxisID||a.xAxes[0].id),null!==t.yAxisID&&t.yAxisID in n&&!i.yAxisID||(t.yAxisID=i.yAxisID||a.yAxes[0].id)},getDataset:function(){return this.chart.data.datasets[this.index]},getMeta:function(){return this.chart.getDatasetMeta(this.index)},getScaleForId:function(t){return this.chart.scales[t]},_getValueScaleId:function(){return this.getMeta().yAxisID},_getIndexScaleId:function(){return this.getMeta().xAxisID},_getValueScale:function(){return this.getScaleForId(this._getValueScaleId())},_getIndexScale:function(){return this.getScaleForId(this._getIndexScaleId())},reset:function(){this._update(!0)},destroy:function(){this._data&&tt(this._data,this)},createMetaDataset:function(){var t=this.datasetElementType;return t&&new t({_chart:this.chart,_datasetIndex:this.index})},createMetaData:function(t){var e=this.dataElementType;return e&&new e({_chart:this.chart,_datasetIndex:this.index,_index:t})},addElements:function(){var t,e,n=this.getMeta(),i=this.getDataset().data||[],a=n.data;for(t=0,e=i.length;tn&&this.insertElements(n,i-n)},insertElements:function(t,e){for(var n=0;na?(r=a/e.innerRadius,t.arc(o,s,e.innerRadius-a,i+r,n-r,!0)):t.arc(o,s,a,i+Math.PI/2,n-Math.PI/2),t.closePath(),t.clip()}function rt(t,e,n){var i="inner"===e.borderAlign;i?(t.lineWidth=2*e.borderWidth,t.lineJoin="round"):(t.lineWidth=e.borderWidth,t.lineJoin="bevel"),n.fullCircles&&function(t,e,n,i){var a,r=n.endAngle;for(i&&(n.endAngle=n.startAngle+it,at(t,n),n.endAngle=r,n.endAngle===n.startAngle&&n.fullCircles&&(n.endAngle+=it,n.fullCircles--)),t.beginPath(),t.arc(n.x,n.y,n.innerRadius,n.startAngle+it,n.startAngle,!0),a=0;as;)a-=it;for(;a=o&&a<=s,u=r>=n.innerRadius&&r<=n.outerRadius;return l&&u}return!1},getCenterPoint:function(){var t=this._view,e=(t.startAngle+t.endAngle)/2,n=(t.innerRadius+t.outerRadius)/2;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},getArea:function(){var t=this._view;return Math.PI*((t.endAngle-t.startAngle)/(2*Math.PI))*(Math.pow(t.outerRadius,2)-Math.pow(t.innerRadius,2))},tooltipPosition:function(){var t=this._view,e=t.startAngle+(t.endAngle-t.startAngle)/2,n=(t.outerRadius-t.innerRadius)/2+t.innerRadius;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},draw:function(){var t,e=this._chart.ctx,n=this._view,i="inner"===n.borderAlign?.33:0,a={x:n.x,y:n.y,innerRadius:n.innerRadius,outerRadius:Math.max(n.outerRadius-i,0),pixelMargin:i,startAngle:n.startAngle,endAngle:n.endAngle,fullCircles:Math.floor(n.circumference/it)};if(e.save(),e.fillStyle=n.backgroundColor,e.strokeStyle=n.borderColor,a.fullCircles){for(a.endAngle=a.startAngle+it,e.beginPath(),e.arc(a.x,a.y,a.outerRadius,a.startAngle,a.endAngle),e.arc(a.x,a.y,a.innerRadius,a.endAngle,a.startAngle,!0),e.closePath(),t=0;tt.x&&(e=vt(e,"left","right")):t.basen?n:i,r:l.right||a<0?0:a>e?e:a,b:l.bottom||r<0?0:r>n?n:r,l:l.left||o<0?0:o>e?e:o}}function xt(t,e,n){var i=null===e,a=null===n,r=!(!t||i&&a)&&mt(t);return r&&(i||e>=r.left&&e<=r.right)&&(a||n>=r.top&&n<=r.bottom)}z._set("global",{elements:{rectangle:{backgroundColor:gt,borderColor:gt,borderSkipped:"bottom",borderWidth:0}}});var yt=X.extend({_type:"rectangle",draw:function(){var t=this._chart.ctx,e=this._view,n=function(t){var e=mt(t),n=e.right-e.left,i=e.bottom-e.top,a=bt(t,n/2,i/2);return{outer:{x:e.left,y:e.top,w:n,h:i},inner:{x:e.left+a.l,y:e.top+a.t,w:n-a.l-a.r,h:i-a.t-a.b}}}(e),i=n.outer,a=n.inner;t.fillStyle=e.backgroundColor,t.fillRect(i.x,i.y,i.w,i.h),i.w===a.w&&i.h===a.h||(t.save(),t.beginPath(),t.rect(i.x,i.y,i.w,i.h),t.clip(),t.fillStyle=e.borderColor,t.rect(a.x,a.y,a.w,a.h),t.fill("evenodd"),t.restore())},height:function(){var t=this._view;return t.base-t.y},inRange:function(t,e){return xt(this._view,t,e)},inLabelRange:function(t,e){var n=this._view;return pt(n)?xt(n,t,null):xt(n,null,e)},inXRange:function(t){return xt(this._view,t,null)},inYRange:function(t){return xt(this._view,null,t)},getCenterPoint:function(){var t,e,n=this._view;return pt(n)?(t=n.x,e=(n.y+n.base)/2):(t=(n.x+n.base)/2,e=n.y),{x:t,y:e}},getArea:function(){var t=this._view;return pt(t)?t.width*Math.abs(t.y-t.base):t.height*Math.abs(t.x-t.base)},tooltipPosition:function(){var t=this._view;return{x:t.x,y:t.y}}}),_t={},kt=ot,wt=ut,Mt=ft,St=yt;_t.Arc=kt,_t.Line=wt,_t.Point=Mt,_t.Rectangle=St;var Ct=V._deprecated,Pt=V.valueOrDefault;function At(t,e,n){var i,a,r=n.barThickness,o=e.stackCount,s=e.pixels[t],l=V.isNullOrUndef(r)?function(t,e){var n,i,a,r,o=t._length;for(a=1,r=e.length;a0?Math.min(o,Math.abs(i-n)):o,n=i;return o}(e.scale,e.pixels):-1;return V.isNullOrUndef(r)?(i=l*n.categoryPercentage,a=n.barPercentage):(i=r*o,a=1),{chunk:i/o,ratio:a,start:s-i/2}}z._set("bar",{hover:{mode:"label"},scales:{xAxes:[{type:"category",offset:!0,gridLines:{offsetGridLines:!0}}],yAxes:[{type:"linear"}]}}),z._set("global",{datasets:{bar:{categoryPercentage:.8,barPercentage:.9}}});var Dt=nt.extend({dataElementType:_t.Rectangle,_dataElementOptions:["backgroundColor","borderColor","borderSkipped","borderWidth","barPercentage","barThickness","categoryPercentage","maxBarThickness","minBarLength"],initialize:function(){var t,e,n=this;nt.prototype.initialize.apply(n,arguments),(t=n.getMeta()).stack=n.getDataset().stack,t.bar=!0,e=n._getIndexScale().options,Ct("bar chart",e.barPercentage,"scales.[x/y]Axes.barPercentage","dataset.barPercentage"),Ct("bar chart",e.barThickness,"scales.[x/y]Axes.barThickness","dataset.barThickness"),Ct("bar chart",e.categoryPercentage,"scales.[x/y]Axes.categoryPercentage","dataset.categoryPercentage"),Ct("bar chart",n._getValueScale().options.minBarLength,"scales.[x/y]Axes.minBarLength","dataset.minBarLength"),Ct("bar chart",e.maxBarThickness,"scales.[x/y]Axes.maxBarThickness","dataset.maxBarThickness")},update:function(t){var e,n,i=this.getMeta().data;for(this._ruler=this.getRuler(),e=0,n=i.length;e=0&&p.min>=0?p.min:p.max,y=void 0===p.start?p.end:p.max>=0&&p.min>=0?p.max-p.min:p.min-p.max,_=g.length;if(v||void 0===v&&void 0!==b)for(i=0;i<_&&(a=g[i]).index!==t;++i)a.stack===b&&(r=void 0===(u=h._parseValue(f[a.index].data[e])).start?u.end:u.min>=0&&u.max>=0?u.max:u.min,(p.min<0&&r<0||p.max>=0&&r>0)&&(x+=r));return o=h.getPixelForValue(x),l=(s=h.getPixelForValue(x+y))-o,void 0!==m&&Math.abs(l)=0&&!c||y<0&&c?o-m:o+m),{size:l,base:o,head:s,center:s+l/2}},calculateBarIndexPixels:function(t,e,n,i){var a="flex"===i.barThickness?function(t,e,n){var i,a=e.pixels,r=a[t],o=t>0?a[t-1]:null,s=t=Ot?-Rt:b<-Ot?Rt:0)+m,y=Math.cos(b),_=Math.sin(b),k=Math.cos(x),w=Math.sin(x),M=b<=0&&x>=0||x>=Rt,S=b<=zt&&x>=zt||x>=Rt+zt,C=b<=-zt&&x>=-zt||x>=Ot+zt,P=b===-Ot||x>=Ot?-1:Math.min(y,y*p,k,k*p),A=C?-1:Math.min(_,_*p,w,w*p),D=M?1:Math.max(y,y*p,k,k*p),T=S?1:Math.max(_,_*p,w,w*p);u=(D-P)/2,d=(T-A)/2,h=-(D+P)/2,c=-(T+A)/2}for(i=0,a=g.length;i0&&!isNaN(t)?Rt*(Math.abs(t)/e):0},getMaxBorderWidth:function(t){var e,n,i,a,r,o,s,l,u=0,d=this.chart;if(!t)for(e=0,n=d.data.datasets.length;e(u=s>u?s:u)?l:u);return u},setHoverStyle:function(t){var e=t._model,n=t._options,i=V.getHoverColor;t.$previousStyle={backgroundColor:e.backgroundColor,borderColor:e.borderColor,borderWidth:e.borderWidth},e.backgroundColor=Lt(n.hoverBackgroundColor,i(n.backgroundColor)),e.borderColor=Lt(n.hoverBorderColor,i(n.borderColor)),e.borderWidth=Lt(n.hoverBorderWidth,n.borderWidth)},_getRingWeightOffset:function(t){for(var e=0,n=0;n0&&Vt(l[t-1]._model,s)&&(n.controlPointPreviousX=u(n.controlPointPreviousX,s.left,s.right),n.controlPointPreviousY=u(n.controlPointPreviousY,s.top,s.bottom)),t0&&(r=t.getDatasetMeta(r[0]._datasetIndex).data),r},"x-axis":function(t,e){return ie(t,e,{intersect:!1})},point:function(t,e){return te(t,Jt(e,t))},nearest:function(t,e,n){var i=Jt(e,t);n.axis=n.axis||"xy";var a=ne(n.axis);return ee(t,i,n.intersect,a)},x:function(t,e,n){var i=Jt(e,t),a=[],r=!1;return Qt(t,(function(t){t.inXRange(i.x)&&a.push(t),t.inRange(i.x,i.y)&&(r=!0)})),n.intersect&&!r&&(a=[]),a},y:function(t,e,n){var i=Jt(e,t),a=[],r=!1;return Qt(t,(function(t){t.inYRange(i.y)&&a.push(t),t.inRange(i.x,i.y)&&(r=!0)})),n.intersect&&!r&&(a=[]),a}}},re=V.extend;function oe(t,e){return V.where(t,(function(t){return t.pos===e}))}function se(t,e){return t.sort((function(t,n){var i=e?n:t,a=e?t:n;return i.weight===a.weight?i.index-a.index:i.weight-a.weight}))}function le(t,e,n,i){return Math.max(t[n],e[n])+Math.max(t[i],e[i])}function ue(t,e,n){var i,a,r=n.box,o=t.maxPadding;if(n.size&&(t[n.pos]-=n.size),n.size=n.horizontal?r.height:r.width,t[n.pos]+=n.size,r.getPadding){var s=r.getPadding();o.top=Math.max(o.top,s.top),o.left=Math.max(o.left,s.left),o.bottom=Math.max(o.bottom,s.bottom),o.right=Math.max(o.right,s.right)}if(i=e.outerWidth-le(o,t,"left","right"),a=e.outerHeight-le(o,t,"top","bottom"),i!==t.w||a!==t.h)return t.w=i,t.h=a,n.horizontal?i!==t.w:a!==t.h}function de(t,e){var n=e.maxPadding;function i(t){var i={left:0,top:0,right:0,bottom:0};return t.forEach((function(t){i[t]=Math.max(e[t],n[t])})),i}return i(t?["left","right"]:["top","bottom"])}function he(t,e,n){var i,a,r,o,s,l,u=[];for(i=0,a=t.length;idiv{position:absolute;width:1000000px;height:1000000px;left:0;top:0}.chartjs-size-monitor-shrink>div{position:absolute;width:200%;height:200%;left:0;top:0}"}))&&fe.default||fe,me="$chartjs",ve="chartjs-size-monitor",be="chartjs-render-monitor",xe="chartjs-render-animation",ye=["animationstart","webkitAnimationStart"],_e={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"};function ke(t,e){var n=V.getStyle(t,e),i=n&&n.match(/^(\d+)(\.\d+)?px$/);return i?Number(i[1]):void 0}var we=!!function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("e",null,e)}catch(t){}return t}()&&{passive:!0};function Me(t,e,n){t.addEventListener(e,n,we)}function Se(t,e,n){t.removeEventListener(e,n,we)}function Ce(t,e,n,i,a){return{type:t,chart:e,native:a||null,x:void 0!==n?n:null,y:void 0!==i?i:null}}function Pe(t){var e=document.createElement("div");return e.className=t||"",e}function Ae(t,e,n){var i,a,r,o,s=t[me]||(t[me]={}),l=s.resizer=function(t){var e=Pe(ve),n=Pe(ve+"-expand"),i=Pe(ve+"-shrink");n.appendChild(Pe()),i.appendChild(Pe()),e.appendChild(n),e.appendChild(i),e._reset=function(){n.scrollLeft=1e6,n.scrollTop=1e6,i.scrollLeft=1e6,i.scrollTop=1e6};var a=function(){e._reset(),t()};return Me(n,"scroll",a.bind(n,"expand")),Me(i,"scroll",a.bind(i,"shrink")),e}((i=function(){if(s.resizer){var i=n.options.maintainAspectRatio&&t.parentNode,a=i?i.clientWidth:0;e(Ce("resize",n)),i&&i.clientWidth0){var r=t[0];r.label?n=r.label:r.xLabel?n=r.xLabel:a>0&&r.index-1?t.split("\n"):t}function We(t){var e=z.global;return{xPadding:t.xPadding,yPadding:t.yPadding,xAlign:t.xAlign,yAlign:t.yAlign,rtl:t.rtl,textDirection:t.textDirection,bodyFontColor:t.bodyFontColor,_bodyFontFamily:Re(t.bodyFontFamily,e.defaultFontFamily),_bodyFontStyle:Re(t.bodyFontStyle,e.defaultFontStyle),_bodyAlign:t.bodyAlign,bodyFontSize:Re(t.bodyFontSize,e.defaultFontSize),bodySpacing:t.bodySpacing,titleFontColor:t.titleFontColor,_titleFontFamily:Re(t.titleFontFamily,e.defaultFontFamily),_titleFontStyle:Re(t.titleFontStyle,e.defaultFontStyle),titleFontSize:Re(t.titleFontSize,e.defaultFontSize),_titleAlign:t.titleAlign,titleSpacing:t.titleSpacing,titleMarginBottom:t.titleMarginBottom,footerFontColor:t.footerFontColor,_footerFontFamily:Re(t.footerFontFamily,e.defaultFontFamily),_footerFontStyle:Re(t.footerFontStyle,e.defaultFontStyle),footerFontSize:Re(t.footerFontSize,e.defaultFontSize),_footerAlign:t.footerAlign,footerSpacing:t.footerSpacing,footerMarginTop:t.footerMarginTop,caretSize:t.caretSize,cornerRadius:t.cornerRadius,backgroundColor:t.backgroundColor,opacity:0,legendColorBackground:t.multiKeyBackground,displayColors:t.displayColors,borderColor:t.borderColor,borderWidth:t.borderWidth}}function Ve(t,e){return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-t.xPadding:t.x+t.xPadding}function He(t){return Be([],Ee(t))}var je=X.extend({initialize:function(){this._model=We(this._options),this._lastActive=[]},getTitle:function(){var t=this,e=t._options,n=e.callbacks,i=n.beforeTitle.apply(t,arguments),a=n.title.apply(t,arguments),r=n.afterTitle.apply(t,arguments),o=[];return o=Be(o,Ee(i)),o=Be(o,Ee(a)),o=Be(o,Ee(r))},getBeforeBody:function(){return He(this._options.callbacks.beforeBody.apply(this,arguments))},getBody:function(t,e){var n=this,i=n._options.callbacks,a=[];return V.each(t,(function(t){var r={before:[],lines:[],after:[]};Be(r.before,Ee(i.beforeLabel.call(n,t,e))),Be(r.lines,i.label.call(n,t,e)),Be(r.after,Ee(i.afterLabel.call(n,t,e))),a.push(r)})),a},getAfterBody:function(){return He(this._options.callbacks.afterBody.apply(this,arguments))},getFooter:function(){var t=this,e=t._options.callbacks,n=e.beforeFooter.apply(t,arguments),i=e.footer.apply(t,arguments),a=e.afterFooter.apply(t,arguments),r=[];return r=Be(r,Ee(n)),r=Be(r,Ee(i)),r=Be(r,Ee(a))},update:function(t){var e,n,i,a,r,o,s,l,u,d,h=this,c=h._options,f=h._model,g=h._model=We(c),p=h._active,m=h._data,v={xAlign:f.xAlign,yAlign:f.yAlign},b={x:f.x,y:f.y},x={width:f.width,height:f.height},y={x:f.caretX,y:f.caretY};if(p.length){g.opacity=1;var _=[],k=[];y=Ne[c.position].call(h,p,h._eventPosition);var w=[];for(e=0,n=p.length;ei.width&&(a=i.width-e.width),a<0&&(a=0)),"top"===d?r+=h:r-="bottom"===d?e.height+h:e.height/2,"center"===d?"left"===u?a+=h:"right"===u&&(a-=h):"left"===u?a-=c:"right"===u&&(a+=c),{x:a,y:r}}(g,x,v=function(t,e){var n,i,a,r,o,s=t._model,l=t._chart,u=t._chart.chartArea,d="center",h="center";s.yl.height-e.height&&(h="bottom");var c=(u.left+u.right)/2,f=(u.top+u.bottom)/2;"center"===h?(n=function(t){return t<=c},i=function(t){return t>c}):(n=function(t){return t<=e.width/2},i=function(t){return t>=l.width-e.width/2}),a=function(t){return t+e.width+s.caretSize+s.caretPadding>l.width},r=function(t){return t-e.width-s.caretSize-s.caretPadding<0},o=function(t){return t<=f?"top":"bottom"},n(s.x)?(d="left",a(s.x)&&(d="center",h=o(s.y))):i(s.x)&&(d="right",r(s.x)&&(d="center",h=o(s.y)));var g=t._options;return{xAlign:g.xAlign?g.xAlign:d,yAlign:g.yAlign?g.yAlign:h}}(this,x),h._chart)}else g.opacity=0;return g.xAlign=v.xAlign,g.yAlign=v.yAlign,g.x=b.x,g.y=b.y,g.width=x.width,g.height=x.height,g.caretX=y.x,g.caretY=y.y,h._model=g,t&&c.custom&&c.custom.call(h,g),h},drawCaret:function(t,e){var n=this._chart.ctx,i=this._view,a=this.getCaretPosition(t,e,i);n.lineTo(a.x1,a.y1),n.lineTo(a.x2,a.y2),n.lineTo(a.x3,a.y3)},getCaretPosition:function(t,e,n){var i,a,r,o,s,l,u=n.caretSize,d=n.cornerRadius,h=n.xAlign,c=n.yAlign,f=t.x,g=t.y,p=e.width,m=e.height;if("center"===c)s=g+m/2,"left"===h?(a=(i=f)-u,r=i,o=s+u,l=s-u):(a=(i=f+p)+u,r=i,o=s-u,l=s+u);else if("left"===h?(i=(a=f+d+u)-u,r=a+u):"right"===h?(i=(a=f+p-d-u)-u,r=a+u):(i=(a=n.caretX)-u,r=a+u),"top"===c)s=(o=g)-u,l=o;else{s=(o=g+m)+u,l=o;var v=r;r=i,i=v}return{x1:i,x2:a,x3:r,y1:o,y2:s,y3:l}},drawTitle:function(t,e,n){var i,a,r,o=e.title,s=o.length;if(s){var l=ze(e.rtl,e.x,e.width);for(t.x=Ve(e,e._titleAlign),n.textAlign=l.textAlign(e._titleAlign),n.textBaseline="middle",i=e.titleFontSize,a=e.titleSpacing,n.fillStyle=e.titleFontColor,n.font=V.fontString(i,e._titleFontStyle,e._titleFontFamily),r=0;r0&&n.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},i={x:e.x,y:e.y},a=Math.abs(e.opacity<.001)?0:e.opacity,r=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&r&&(t.save(),t.globalAlpha=a,this.drawBackground(i,e,t,n),i.y+=e.yPadding,V.rtl.overrideTextDirection(t,e.textDirection),this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),V.rtl.restoreTextDirection(t,e.textDirection),t.restore())}},handleEvent:function(t){var e,n=this,i=n._options;return n._lastActive=n._lastActive||[],"mouseout"===t.type?n._active=[]:(n._active=n._chart.getElementsAtEventForMode(t,i.mode,i),i.reverse&&n._active.reverse()),(e=!V.arrayEquals(n._active,n._lastActive))&&(n._lastActive=n._active,(i.enabled||i.custom)&&(n._eventPosition={x:t.x,y:t.y},n.update(!0),n.pivot())),e}}),qe=Ne,Ue=je;Ue.positioners=qe;var Ye=V.valueOrDefault;function Ge(){return V.merge({},[].slice.call(arguments),{merger:function(t,e,n,i){if("xAxes"===t||"yAxes"===t){var a,r,o,s=n[t].length;for(e[t]||(e[t]=[]),a=0;a=e[t].length&&e[t].push({}),!e[t][a].type||o.type&&o.type!==e[t][a].type?V.merge(e[t][a],[Oe.getScaleDefaults(r),o]):V.merge(e[t][a],o)}else V._merger(t,e,n,i)}})}function Xe(){return V.merge({},[].slice.call(arguments),{merger:function(t,e,n,i){var a=e[t]||{},r=n[t];"scales"===t?e[t]=Ge(a,r):"scale"===t?e[t]=V.merge(a,[Oe.getScaleDefaults(r.type),r]):V._merger(t,e,n,i)}})}function Ke(t){var e=t.options;V.each(t.scales,(function(e){ge.removeBox(t,e)})),e=Xe(z.global,z[t.config.type],e),t.options=t.config.options=e,t.ensureScalesHaveIDs(),t.buildOrUpdateScales(),t.tooltip._options=e.tooltips,t.tooltip.initialize()}function Ze(t,e,n){var i,a=function(t){return t.id===i};do{i=e+n++}while(V.findIndex(t,a)>=0);return i}function $e(t){return"top"===t||"bottom"===t}function Je(t,e){return function(n,i){return n[t]===i[t]?n[e]-i[e]:n[t]-i[t]}}z._set("global",{elements:{},events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,maintainAspectRatio:!0,responsive:!0,responsiveAnimationDuration:0});var Qe=function(t,e){return this.construct(t,e),this};V.extend(Qe.prototype,{construct:function(t,e){var n=this;e=function(t){var e=(t=t||{}).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=Xe(z.global,z[t.type],t.options||{}),t}(e);var i=Fe.acquireContext(t,e),a=i&&i.canvas,r=a&&a.height,o=a&&a.width;n.id=V.uid(),n.ctx=i,n.canvas=a,n.config=e,n.width=o,n.height=r,n.aspectRatio=r?o/r:null,n.options=e.options,n._bufferedRender=!1,n._layers=[],n.chart=n,n.controller=n,Qe.instances[n.id]=n,Object.defineProperty(n,"data",{get:function(){return n.config.data},set:function(t){n.config.data=t}}),i&&a?(n.initialize(),n.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return Le.notify(t,"beforeInit"),V.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.initToolTip(),Le.notify(t,"afterInit"),t},clear:function(){return V.canvas.clear(this),this},stop:function(){return $.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,i=e.canvas,a=n.maintainAspectRatio&&e.aspectRatio||null,r=Math.max(0,Math.floor(V.getMaximumWidth(i))),o=Math.max(0,Math.floor(a?r/a:V.getMaximumHeight(i)));if((e.width!==r||e.height!==o)&&(i.width=e.width=r,i.height=e.height=o,i.style.width=r+"px",i.style.height=o+"px",V.retinaScale(e,n.devicePixelRatio),!t)){var s={width:r,height:o};Le.notify(e,"resize",[s]),n.onResize&&n.onResize(e,s),e.stop(),e.update({duration:n.responsiveAnimationDuration})}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;V.each(e.xAxes,(function(t,n){t.id||(t.id=Ze(e.xAxes,"x-axis-",n))})),V.each(e.yAxes,(function(t,n){t.id||(t.id=Ze(e.yAxes,"y-axis-",n))})),n&&(n.id=n.id||"scale")},buildOrUpdateScales:function(){var t=this,e=t.options,n=t.scales||{},i=[],a=Object.keys(n).reduce((function(t,e){return t[e]=!1,t}),{});e.scales&&(i=i.concat((e.scales.xAxes||[]).map((function(t){return{options:t,dtype:"category",dposition:"bottom"}})),(e.scales.yAxes||[]).map((function(t){return{options:t,dtype:"linear",dposition:"left"}})))),e.scale&&i.push({options:e.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),V.each(i,(function(e){var i=e.options,r=i.id,o=Ye(i.type,e.dtype);$e(i.position)!==$e(e.dposition)&&(i.position=e.dposition),a[r]=!0;var s=null;if(r in n&&n[r].type===o)(s=n[r]).options=i,s.ctx=t.ctx,s.chart=t;else{var l=Oe.getScaleConstructor(o);if(!l)return;s=new l({id:r,type:o,options:i,ctx:t.ctx,chart:t}),n[s.id]=s}s.mergeTicksOptions(),e.isDefault&&(t.scale=s)})),V.each(a,(function(t,e){t||delete n[e]})),t.scales=n,Oe.addScalesToLayout(this)},buildOrUpdateControllers:function(){var t,e,n=this,i=[],a=n.data.datasets;for(t=0,e=a.length;t=0;--n)this.drawDataset(e[n],t);Le.notify(this,"afterDatasetsDraw",[t])}},drawDataset:function(t,e){var n={meta:t,index:t.index,easingValue:e};!1!==Le.notify(this,"beforeDatasetDraw",[n])&&(t.controller.draw(e),Le.notify(this,"afterDatasetDraw",[n]))},_drawTooltip:function(t){var e=this.tooltip,n={tooltip:e,easingValue:t};!1!==Le.notify(this,"beforeTooltipDraw",[n])&&(e.draw(),Le.notify(this,"afterTooltipDraw",[n]))},getElementAtEvent:function(t){return ae.modes.single(this,t)},getElementsAtEvent:function(t){return ae.modes.label(this,t,{intersect:!0})},getElementsAtXAxis:function(t){return ae.modes["x-axis"](this,t,{intersect:!0})},getElementsAtEventForMode:function(t,e,n){var i=ae.modes[e];return"function"==typeof i?i(this,t,n):[]},getDatasetAtEvent:function(t){return ae.modes.dataset(this,t,{intersect:!0})},getDatasetMeta:function(t){var e=this.data.datasets[t];e._meta||(e._meta={});var n=e._meta[this.id];return n||(n=e._meta[this.id]={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e.order||0,index:t}),n},getVisibleDatasetCount:function(){for(var t=0,e=0,n=this.data.datasets.length;e3?n[2]-n[1]:n[1]-n[0];Math.abs(i)>1&&t!==Math.floor(t)&&(i=t-Math.floor(t));var a=V.log10(Math.abs(i)),r="";if(0!==t)if(Math.max(Math.abs(n[0]),Math.abs(n[n.length-1]))<1e-4){var o=V.log10(Math.abs(t)),s=Math.floor(o)-Math.floor(a);s=Math.max(Math.min(s,20),0),r=t.toExponential(s)}else{var l=-1*Math.floor(a);l=Math.max(Math.min(l,20),0),r=t.toFixed(l)}else r="0";return r},logarithmic:function(t,e,n){var i=t/Math.pow(10,Math.floor(V.log10(t)));return 0===t?"0":1===i||2===i||5===i||0===e||e===n.length-1?t.toExponential():""}}},on=V.isArray,sn=V.isNullOrUndef,ln=V.valueOrDefault,un=V.valueAtIndexOrDefault;function dn(t,e,n){var i,a=t.getTicks().length,r=Math.min(e,a-1),o=t.getPixelForTick(r),s=t._startPixel,l=t._endPixel;if(!(n&&(i=1===a?Math.max(o-s,l-o):0===e?(t.getPixelForTick(1)-o)/2:(o-t.getPixelForTick(r-1))/2,(o+=rl+1e-6)))return o}function hn(t,e,n,i){var a,r,o,s,l,u,d,h,c,f,g,p,m,v=n.length,b=[],x=[],y=[];for(a=0;ae){for(n=0;n=c||d<=1||!s.isHorizontal()?s.labelRotation=h:(e=(t=s._getLabelSizes()).widest.width,n=t.highest.height-t.highest.offset,i=Math.min(s.maxWidth,s.chart.width-e),e+6>(a=l.offset?s.maxWidth/d:i/(d-1))&&(a=i/(d-(l.offset?.5:1)),r=s.maxHeight-cn(l.gridLines)-u.padding-fn(l.scaleLabel),o=Math.sqrt(e*e+n*n),f=V.toDegrees(Math.min(Math.asin(Math.min((t.highest.height+6)/a,1)),Math.asin(Math.min(r/o,1))-Math.asin(n/o))),f=Math.max(h,Math.min(c,f))),s.labelRotation=f)},afterCalculateTickRotation:function(){V.callback(this.options.afterCalculateTickRotation,[this])},beforeFit:function(){V.callback(this.options.beforeFit,[this])},fit:function(){var t=this,e=t.minSize={width:0,height:0},n=t.chart,i=t.options,a=i.ticks,r=i.scaleLabel,o=i.gridLines,s=t._isVisible(),l="bottom"===i.position,u=t.isHorizontal();if(u?e.width=t.maxWidth:s&&(e.width=cn(o)+fn(r)),u?s&&(e.height=cn(o)+fn(r)):e.height=t.maxHeight,a.display&&s){var d=pn(a),h=t._getLabelSizes(),c=h.first,f=h.last,g=h.widest,p=h.highest,m=.4*d.minor.lineHeight,v=a.padding;if(u){var b=0!==t.labelRotation,x=V.toRadians(t.labelRotation),y=Math.cos(x),_=Math.sin(x),k=_*g.width+y*(p.height-(b?p.offset:0))+(b?0:m);e.height=Math.min(t.maxHeight,e.height+k+v);var w,M,S=t.getPixelForTick(0)-t.left,C=t.right-t.getPixelForTick(t.getTicks().length-1);b?(w=l?y*c.width+_*c.offset:_*(c.height-c.offset),M=l?_*(f.height-f.offset):y*f.width+_*f.offset):(w=c.width/2,M=f.width/2),t.paddingLeft=Math.max((w-S)*t.width/(t.width-S),0)+3,t.paddingRight=Math.max((M-C)*t.width/(t.width-C),0)+3}else{var P=a.mirror?0:g.width+v+m;e.width=Math.min(t.maxWidth,e.width+P),t.paddingTop=c.height/2,t.paddingBottom=f.height/2}}t.handleMargins(),u?(t.width=t._length=n.width-t.margins.left-t.margins.right,t.height=e.height):(t.width=e.width,t.height=t._length=n.height-t.margins.top-t.margins.bottom)},handleMargins:function(){var t=this;t.margins&&(t.margins.left=Math.max(t.paddingLeft,t.margins.left),t.margins.top=Math.max(t.paddingTop,t.margins.top),t.margins.right=Math.max(t.paddingRight,t.margins.right),t.margins.bottom=Math.max(t.paddingBottom,t.margins.bottom))},afterFit:function(){V.callback(this.options.afterFit,[this])},isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},isFullWidth:function(){return this.options.fullWidth},getRightValue:function(t){if(sn(t))return NaN;if(("number"==typeof t||t instanceof Number)&&!isFinite(t))return NaN;if(t)if(this.isHorizontal()){if(void 0!==t.x)return this.getRightValue(t.x)}else if(void 0!==t.y)return this.getRightValue(t.y);return t},_convertTicksToLabels:function(t){var e,n,i,a=this;for(a.ticks=t.map((function(t){return t.value})),a.beforeTickToLabelConversion(),e=a.convertTicksToLabels(t)||a.ticks,a.afterTickToLabelConversion(),n=0,i=t.length;nn-1?null:this.getPixelForDecimal(t*i+(e?i/2:0))},getPixelForDecimal:function(t){return this._reversePixels&&(t=1-t),this._startPixel+t*this._length},getDecimalForPixel:function(t){var e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e},getBasePixel:function(){return this.getPixelForValue(this.getBaseValue())},getBaseValue:function(){var t=this.min,e=this.max;return this.beginAtZero?0:t<0&&e<0?e:t>0&&e>0?t:0},_autoSkip:function(t){var e,n,i,a,r=this.options.ticks,o=this._length,s=r.maxTicksLimit||o/this._tickSize()+1,l=r.major.enabled?function(t){var e,n,i=[];for(e=0,n=t.length;es)return function(t,e,n){var i,a,r=0,o=e[0];for(n=Math.ceil(n),i=0;iu)return r;return Math.max(u,1)}(l,t,0,s),u>0){for(e=0,n=u-1;e1?(h-d)/(u-1):null,vn(t,i,V.isNullOrUndef(a)?0:d-a,d),vn(t,i,h,V.isNullOrUndef(a)?t.length:h+a),mn(t)}return vn(t,i),mn(t)},_tickSize:function(){var t=this.options.ticks,e=V.toRadians(this.labelRotation),n=Math.abs(Math.cos(e)),i=Math.abs(Math.sin(e)),a=this._getLabelSizes(),r=t.autoSkipPadding||0,o=a?a.widest.width+r:0,s=a?a.highest.height+r:0;return this.isHorizontal()?s*n>o*i?o/n:s/i:s*i=0&&(o=t),void 0!==r&&(t=n.indexOf(r))>=0&&(s=t),e.minIndex=o,e.maxIndex=s,e.min=n[o],e.max=n[s]},buildTicks:function(){var t=this._getLabels(),e=this.minIndex,n=this.maxIndex;this.ticks=0===e&&n===t.length-1?t:t.slice(e,n+1)},getLabelForIndex:function(t,e){var n=this.chart;return n.getDatasetMeta(e).controller._getValueScaleId()===this.id?this.getRightValue(n.data.datasets[e].data[t]):this._getLabels()[t]},_configure:function(){var t=this,e=t.options.offset,n=t.ticks;xn.prototype._configure.call(t),t.isHorizontal()||(t._reversePixels=!t._reversePixels),n&&(t._startValue=t.minIndex-(e?.5:0),t._valueRange=Math.max(n.length-(e?0:1),1))},getPixelForValue:function(t,e,n){var i,a,r,o=this;return yn(e)||yn(n)||(t=o.chart.data.datasets[n].data[e]),yn(t)||(i=o.isHorizontal()?t.x:t.y),(void 0!==i||void 0!==t&&isNaN(e))&&(a=o._getLabels(),t=V.valueOrDefault(i,t),e=-1!==(r=a.indexOf(t))?r:e,isNaN(e)&&(e=t)),o.getPixelForDecimal((e-o._startValue)/o._valueRange)},getPixelForTick:function(t){var e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t],t+this.minIndex)},getValueForPixel:function(t){var e=Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange);return Math.min(Math.max(e,0),this.ticks.length-1)},getBasePixel:function(){return this.bottom}}),kn={position:"bottom"};_n._defaults=kn;var wn=V.noop,Mn=V.isNullOrUndef;var Sn=xn.extend({getRightValue:function(t){return"string"==typeof t?+t:xn.prototype.getRightValue.call(this,t)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var n=V.sign(t.min),i=V.sign(t.max);n<0&&i<0?t.max=0:n>0&&i>0&&(t.min=0)}var a=void 0!==e.min||void 0!==e.suggestedMin,r=void 0!==e.max||void 0!==e.suggestedMax;void 0!==e.min?t.min=e.min:void 0!==e.suggestedMin&&(null===t.min?t.min=e.suggestedMin:t.min=Math.min(t.min,e.suggestedMin)),void 0!==e.max?t.max=e.max:void 0!==e.suggestedMax&&(null===t.max?t.max=e.suggestedMax:t.max=Math.max(t.max,e.suggestedMax)),a!==r&&t.min>=t.max&&(a?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:function(){var t,e=this.options.ticks,n=e.stepSize,i=e.maxTicksLimit;return n?t=Math.ceil(this.max/n)-Math.floor(this.min/n)+1:(t=this._computeTickLimit(),i=i||11),i&&(t=Math.min(i,t)),t},_computeTickLimit:function(){return Number.POSITIVE_INFINITY},handleDirectionalChanges:wn,buildTicks:function(){var t=this,e=t.options.ticks,n=t.getTickLimit(),i={maxTicks:n=Math.max(2,n),min:e.min,max:e.max,precision:e.precision,stepSize:V.valueOrDefault(e.fixedStepSize,e.stepSize)},a=t.ticks=function(t,e){var n,i,a,r,o=[],s=t.stepSize,l=s||1,u=t.maxTicks-1,d=t.min,h=t.max,c=t.precision,f=e.min,g=e.max,p=V.niceNum((g-f)/u/l)*l;if(p<1e-14&&Mn(d)&&Mn(h))return[f,g];(r=Math.ceil(g/p)-Math.floor(f/p))>u&&(p=V.niceNum(r*p/u/l)*l),s||Mn(c)?n=Math.pow(10,V._decimalPlaces(p)):(n=Math.pow(10,c),p=Math.ceil(p*n)/n),i=Math.floor(f/p)*p,a=Math.ceil(g/p)*p,s&&(!Mn(d)&&V.almostWhole(d/p,p/1e3)&&(i=d),!Mn(h)&&V.almostWhole(h/p,p/1e3)&&(a=h)),r=(a-i)/p,r=V.almostEquals(r,Math.round(r),p/1e3)?Math.round(r):Math.ceil(r),i=Math.round(i*n)/n,a=Math.round(a*n)/n,o.push(Mn(d)?i:d);for(var m=1;me.length-1?null:this.getPixelForValue(e[t])}}),Tn=Cn;Dn._defaults=Tn;var In=V.valueOrDefault,Fn=V.math.log10;var Ln={position:"left",ticks:{callback:rn.formatters.logarithmic}};function On(t,e){return V.isFinite(t)&&t>=0?t:e}var Rn=xn.extend({determineDataLimits:function(){var t,e,n,i,a,r,o=this,s=o.options,l=o.chart,u=l.data.datasets,d=o.isHorizontal();function h(t){return d?t.xAxisID===o.id:t.yAxisID===o.id}o.min=Number.POSITIVE_INFINITY,o.max=Number.NEGATIVE_INFINITY,o.minNotZero=Number.POSITIVE_INFINITY;var c=s.stacked;if(void 0===c)for(t=0;t0){var e=V.min(t),n=V.max(t);o.min=Math.min(o.min,e),o.max=Math.max(o.max,n)}}))}else for(t=0;t0?t.minNotZero=t.min:t.max<1?t.minNotZero=Math.pow(10,Math.floor(Fn(t.max))):t.minNotZero=1)},buildTicks:function(){var t=this,e=t.options.ticks,n=!t.isHorizontal(),i={min:On(e.min),max:On(e.max)},a=t.ticks=function(t,e){var n,i,a=[],r=In(t.min,Math.pow(10,Math.floor(Fn(e.min)))),o=Math.floor(Fn(e.max)),s=Math.ceil(e.max/Math.pow(10,o));0===r?(n=Math.floor(Fn(e.minNotZero)),i=Math.floor(e.minNotZero/Math.pow(10,n)),a.push(r),r=i*Math.pow(10,n)):(n=Math.floor(Fn(r)),i=Math.floor(r/Math.pow(10,n)));var l=n<0?Math.pow(10,Math.abs(n)):1;do{a.push(r),10===++i&&(i=1,l=++n>=0?1:l),r=Math.round(i*Math.pow(10,n)*l)/l}while(ne.length-1?null:this.getPixelForValue(e[t])},_getFirstTickValue:function(t){var e=Math.floor(Fn(t));return Math.floor(t/Math.pow(10,e))*Math.pow(10,e)},_configure:function(){var t=this,e=t.min,n=0;xn.prototype._configure.call(t),0===e&&(e=t._getFirstTickValue(t.minNotZero),n=In(t.options.ticks.fontSize,z.global.defaultFontSize)/t._length),t._startValue=Fn(e),t._valueOffset=n,t._valueRange=(Fn(t.max)-Fn(e))/(1-n)},getPixelForValue:function(t){var e=this,n=0;return(t=+e.getRightValue(t))>e.min&&t>0&&(n=(Fn(t)-e._startValue)/e._valueRange+e._valueOffset),e.getPixelForDecimal(n)},getValueForPixel:function(t){var e=this,n=e.getDecimalForPixel(t);return 0===n&&0===e.min?0:Math.pow(10,e._startValue+(n-e._valueOffset)*e._valueRange)}}),zn=Ln;Rn._defaults=zn;var Nn=V.valueOrDefault,Bn=V.valueAtIndexOrDefault,En=V.options.resolve,Wn={display:!0,animate:!0,position:"chartArea",angleLines:{display:!0,color:"rgba(0,0,0,0.1)",lineWidth:1,borderDash:[],borderDashOffset:0},gridLines:{circular:!1},ticks:{showLabelBackdrop:!0,backdropColor:"rgba(255,255,255,0.75)",backdropPaddingY:2,backdropPaddingX:2,callback:rn.formatters.linear},pointLabels:{display:!0,fontSize:10,callback:function(t){return t}}};function Vn(t){var e=t.ticks;return e.display&&t.display?Nn(e.fontSize,z.global.defaultFontSize)+2*e.backdropPaddingY:0}function Hn(t,e,n,i,a){return t===i||t===a?{start:e-n/2,end:e+n/2}:ta?{start:e-n,end:e}:{start:e,end:e+n}}function jn(t){return 0===t||180===t?"center":t<180?"left":"right"}function qn(t,e,n,i){var a,r,o=n.y+i/2;if(V.isArray(e))for(a=0,r=e.length;a270||t<90)&&(n.y-=e.h)}function Yn(t){return V.isNumber(t)?t:0}var Gn=Sn.extend({setDimensions:function(){var t=this;t.width=t.maxWidth,t.height=t.maxHeight,t.paddingTop=Vn(t.options)/2,t.xCenter=Math.floor(t.width/2),t.yCenter=Math.floor((t.height-t.paddingTop)/2),t.drawingArea=Math.min(t.height-t.paddingTop,t.width)/2},determineDataLimits:function(){var t=this,e=t.chart,n=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;V.each(e.data.datasets,(function(a,r){if(e.isDatasetVisible(r)){var o=e.getDatasetMeta(r);V.each(a.data,(function(e,a){var r=+t.getRightValue(e);isNaN(r)||o.data[a].hidden||(n=Math.min(r,n),i=Math.max(r,i))}))}})),t.min=n===Number.POSITIVE_INFINITY?0:n,t.max=i===Number.NEGATIVE_INFINITY?0:i,t.handleTickRangeOptions()},_computeTickLimit:function(){return Math.ceil(this.drawingArea/Vn(this.options))},convertTicksToLabels:function(){var t=this;Sn.prototype.convertTicksToLabels.call(t),t.pointLabels=t.chart.data.labels.map((function(){var e=V.callback(t.options.pointLabels.callback,arguments,t);return e||0===e?e:""}))},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},fit:function(){var t=this.options;t.display&&t.pointLabels.display?function(t){var e,n,i,a=V.options._parseFont(t.options.pointLabels),r={l:0,r:t.width,t:0,b:t.height-t.paddingTop},o={};t.ctx.font=a.string,t._pointLabelSizes=[];var s,l,u,d=t.chart.data.labels.length;for(e=0;er.r&&(r.r=f.end,o.r=h),g.startr.b&&(r.b=g.end,o.b=h)}t.setReductions(t.drawingArea,r,o)}(this):this.setCenterPoint(0,0,0,0)},setReductions:function(t,e,n){var i=this,a=e.l/Math.sin(n.l),r=Math.max(e.r-i.width,0)/Math.sin(n.r),o=-e.t/Math.cos(n.t),s=-Math.max(e.b-(i.height-i.paddingTop),0)/Math.cos(n.b);a=Yn(a),r=Yn(r),o=Yn(o),s=Yn(s),i.drawingArea=Math.min(Math.floor(t-(a+r)/2),Math.floor(t-(o+s)/2)),i.setCenterPoint(a,r,o,s)},setCenterPoint:function(t,e,n,i){var a=this,r=a.width-e-a.drawingArea,o=t+a.drawingArea,s=n+a.drawingArea,l=a.height-a.paddingTop-i-a.drawingArea;a.xCenter=Math.floor((o+r)/2+a.left),a.yCenter=Math.floor((s+l)/2+a.top+a.paddingTop)},getIndexAngle:function(t){var e=this.chart,n=(t*(360/e.data.labels.length)+((e.options||{}).startAngle||0))%360;return(n<0?n+360:n)*Math.PI*2/360},getDistanceFromCenterForValue:function(t){var e=this;if(V.isNullOrUndef(t))return NaN;var n=e.drawingArea/(e.max-e.min);return e.options.ticks.reverse?(e.max-t)*n:(t-e.min)*n},getPointPosition:function(t,e){var n=this.getIndexAngle(t)-Math.PI/2;return{x:Math.cos(n)*e+this.xCenter,y:Math.sin(n)*e+this.yCenter}},getPointPositionForValue:function(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))},getBasePosition:function(t){var e=this.min,n=this.max;return this.getPointPositionForValue(t||0,this.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0)},_drawGrid:function(){var t,e,n,i=this,a=i.ctx,r=i.options,o=r.gridLines,s=r.angleLines,l=Nn(s.lineWidth,o.lineWidth),u=Nn(s.color,o.color);if(r.pointLabels.display&&function(t){var e=t.ctx,n=t.options,i=n.pointLabels,a=Vn(n),r=t.getDistanceFromCenterForValue(n.ticks.reverse?t.min:t.max),o=V.options._parseFont(i);e.save(),e.font=o.string,e.textBaseline="middle";for(var s=t.chart.data.labels.length-1;s>=0;s--){var l=0===s?a/2:0,u=t.getPointPosition(s,r+l+5),d=Bn(i.fontColor,s,z.global.defaultFontColor);e.fillStyle=d;var h=t.getIndexAngle(s),c=V.toDegrees(h);e.textAlign=jn(c),Un(c,t._pointLabelSizes[s],u),qn(e,t.pointLabels[s],u,o.lineHeight)}e.restore()}(i),o.display&&V.each(i.ticks,(function(t,n){0!==n&&(e=i.getDistanceFromCenterForValue(i.ticksAsNumbers[n]),function(t,e,n,i){var a,r=t.ctx,o=e.circular,s=t.chart.data.labels.length,l=Bn(e.color,i-1),u=Bn(e.lineWidth,i-1);if((o||s)&&l&&u){if(r.save(),r.strokeStyle=l,r.lineWidth=u,r.setLineDash&&(r.setLineDash(e.borderDash||[]),r.lineDashOffset=e.borderDashOffset||0),r.beginPath(),o)r.arc(t.xCenter,t.yCenter,n,0,2*Math.PI);else{a=t.getPointPosition(0,n),r.moveTo(a.x,a.y);for(var d=1;d=0;t--)e=i.getDistanceFromCenterForValue(r.ticks.reverse?i.min:i.max),n=i.getPointPosition(t,e),a.beginPath(),a.moveTo(i.xCenter,i.yCenter),a.lineTo(n.x,n.y),a.stroke();a.restore()}},_drawLabels:function(){var t=this,e=t.ctx,n=t.options.ticks;if(n.display){var i,a,r=t.getIndexAngle(0),o=V.options._parseFont(n),s=Nn(n.fontColor,z.global.defaultFontColor);e.save(),e.font=o.string,e.translate(t.xCenter,t.yCenter),e.rotate(r),e.textAlign="center",e.textBaseline="middle",V.each(t.ticks,(function(r,l){(0!==l||n.reverse)&&(i=t.getDistanceFromCenterForValue(t.ticksAsNumbers[l]),n.showLabelBackdrop&&(a=e.measureText(r).width,e.fillStyle=n.backdropColor,e.fillRect(-a/2-n.backdropPaddingX,-i-o.size/2-n.backdropPaddingY,a+2*n.backdropPaddingX,o.size+2*n.backdropPaddingY)),e.fillStyle=s,e.fillText(r,0,-i))})),e.restore()}},_drawTitle:V.noop}),Xn=Wn;Gn._defaults=Xn;var Kn=V._deprecated,Zn=V.options.resolve,$n=V.valueOrDefault,Jn=Number.MIN_SAFE_INTEGER||-9007199254740991,Qn=Number.MAX_SAFE_INTEGER||9007199254740991,ti={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},ei=Object.keys(ti);function ni(t,e){return t-e}function ii(t){return V.valueOrDefault(t.time.min,t.ticks.min)}function ai(t){return V.valueOrDefault(t.time.max,t.ticks.max)}function ri(t,e,n,i){var a=function(t,e,n){for(var i,a,r,o=0,s=t.length-1;o>=0&&o<=s;){if(a=t[(i=o+s>>1)-1]||null,r=t[i],!a)return{lo:null,hi:r};if(r[e]n))return{lo:a,hi:r};s=i-1}}return{lo:r,hi:null}}(t,e,n),r=a.lo?a.hi?a.lo:t[t.length-2]:t[0],o=a.lo?a.hi?a.hi:t[t.length-1]:t[1],s=o[e]-r[e],l=s?(n-r[e])/s:0,u=(o[i]-r[i])*l;return r[i]+u}function oi(t,e){var n=t._adapter,i=t.options.time,a=i.parser,r=a||i.format,o=e;return"function"==typeof a&&(o=a(o)),V.isFinite(o)||(o="string"==typeof r?n.parse(o,r):n.parse(o)),null!==o?+o:(a||"function"!=typeof r||(o=r(e),V.isFinite(o)||(o=n.parse(o))),o)}function si(t,e){if(V.isNullOrUndef(e))return null;var n=t.options.time,i=oi(t,t.getRightValue(e));return null===i?i:(n.round&&(i=+t._adapter.startOf(i,n.round)),i)}function li(t,e,n,i){var a,r,o,s=ei.length;for(a=ei.indexOf(t);a=0&&(e[r].major=!0);return e}(t,r,o,n):r}var di=xn.extend({initialize:function(){this.mergeTicksOptions(),xn.prototype.initialize.call(this)},update:function(){var t=this,e=t.options,n=e.time||(e.time={}),i=t._adapter=new an._date(e.adapters.date);return Kn("time scale",n.format,"time.format","time.parser"),Kn("time scale",n.min,"time.min","ticks.min"),Kn("time scale",n.max,"time.max","ticks.max"),V.mergeIf(n.displayFormats,i.formats()),xn.prototype.update.apply(t,arguments)},getRightValue:function(t){return t&&void 0!==t.t&&(t=t.t),xn.prototype.getRightValue.call(this,t)},determineDataLimits:function(){var t,e,n,i,a,r,o,s=this,l=s.chart,u=s._adapter,d=s.options,h=d.time.unit||"day",c=Qn,f=Jn,g=[],p=[],m=[],v=s._getLabels();for(t=0,n=v.length;t1?function(t){var e,n,i,a={},r=[];for(e=0,n=t.length;e1e5*u)throw e+" and "+n+" are too far apart with stepSize of "+u+" "+l;for(a=h;a=a&&n<=r&&d.push(n);return i.min=a,i.max=r,i._unit=l.unit||(s.autoSkip?li(l.minUnit,i.min,i.max,h):function(t,e,n,i,a){var r,o;for(r=ei.length-1;r>=ei.indexOf(n);r--)if(o=ei[r],ti[o].common&&t._adapter.diff(a,i,o)>=e-1)return o;return ei[n?ei.indexOf(n):0]}(i,d.length,l.minUnit,i.min,i.max)),i._majorUnit=s.major.enabled&&"year"!==i._unit?function(t){for(var e=ei.indexOf(t)+1,n=ei.length;ee&&s=0&&t0?s:1}}),hi={position:"bottom",distribution:"linear",bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,displayFormat:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{autoSkip:!1,source:"auto",major:{enabled:!1}}};di._defaults=hi;var ci={category:_n,linear:Dn,logarithmic:Rn,radialLinear:Gn,time:di},fi={datetime:"MMM D, YYYY, h:mm:ss a",millisecond:"h:mm:ss.SSS a",second:"h:mm:ss a",minute:"h:mm a",hour:"hA",day:"MMM D",week:"ll",month:"MMM YYYY",quarter:"[Q]Q - YYYY",year:"YYYY"};an._date.override("function"==typeof t?{_id:"moment",formats:function(){return fi},parse:function(e,n){return"string"==typeof e&&"string"==typeof n?e=t(e,n):e instanceof t||(e=t(e)),e.isValid()?e.valueOf():null},format:function(e,n){return t(e).format(n)},add:function(e,n,i){return t(e).add(n,i).valueOf()},diff:function(e,n,i){return t(e).diff(t(n),i)},startOf:function(e,n,i){return e=t(e),"isoWeek"===n?e.isoWeekday(i).valueOf():e.startOf(n).valueOf()},endOf:function(e,n){return t(e).endOf(n).valueOf()},_create:function(e){return t(e)}}:{}),z._set("global",{plugins:{filler:{propagate:!0}}});var gi={dataset:function(t){var e=t.fill,n=t.chart,i=n.getDatasetMeta(e),a=i&&n.isDatasetVisible(e)&&i.dataset._children||[],r=a.length||0;return r?function(t,e){return e=n)&&i;switch(r){case"bottom":return"start";case"top":return"end";case"zero":return"origin";case"origin":case"start":case"end":return r;default:return!1}}function mi(t){return(t.el._scale||{}).getPointPositionForValue?function(t){var e,n,i,a,r,o=t.el._scale,s=o.options,l=o.chart.data.labels.length,u=t.fill,d=[];if(!l)return null;for(e=s.ticks.reverse?o.max:o.min,n=s.ticks.reverse?o.min:o.max,i=o.getPointPositionForValue(0,e),a=0;a0;--r)V.canvas.lineTo(t,n[r],n[r-1],!0);else for(o=n[0].cx,s=n[0].cy,l=Math.sqrt(Math.pow(n[0].x-o,2)+Math.pow(n[0].y-s,2)),r=a-1;r>0;--r)t.arc(o,s,l,n[r].angle,n[r-1].angle,!0)}}function _i(t,e,n,i,a,r){var o,s,l,u,d,h,c,f,g=e.length,p=i.spanGaps,m=[],v=[],b=0,x=0;for(t.beginPath(),o=0,s=g;o=0;--n)(e=l[n].$filler)&&e.visible&&(a=(i=e.el)._view,r=i._children||[],o=e.mapper,s=a.backgroundColor||z.global.defaultColor,o&&s&&r.length&&(V.canvas.clipArea(u,t.chartArea),_i(u,r,o,a,s,i._loop),V.canvas.unclipArea(u)))}},wi=V.rtl.getRtlAdapter,Mi=V.noop,Si=V.valueOrDefault;function Ci(t,e){return t.usePointStyle&&t.boxWidth>e?e:t.boxWidth}z._set("global",{legend:{display:!0,position:"top",align:"center",fullWidth:!0,reverse:!1,weight:1e3,onClick:function(t,e){var n=e.datasetIndex,i=this.chart,a=i.getDatasetMeta(n);a.hidden=null===a.hidden?!i.data.datasets[n].hidden:null,i.update()},onHover:null,onLeave:null,labels:{boxWidth:40,padding:10,generateLabels:function(t){var e=t.data.datasets,n=t.options.legend||{},i=n.labels&&n.labels.usePointStyle;return t._getSortedDatasetMetas().map((function(n){var a=n.controller.getStyle(i?0:void 0);return{text:e[n.index].label,fillStyle:a.backgroundColor,hidden:!t.isDatasetVisible(n.index),lineCap:a.borderCapStyle,lineDash:a.borderDash,lineDashOffset:a.borderDashOffset,lineJoin:a.borderJoinStyle,lineWidth:a.borderWidth,strokeStyle:a.borderColor,pointStyle:a.pointStyle,rotation:a.rotation,datasetIndex:n.index}}),this)}}},legendCallback:function(t){var e,n,i,a=document.createElement("ul"),r=t.data.datasets;for(a.setAttribute("class",t.id+"-legend"),e=0,n=r.length;el.width)&&(h+=o+n.padding,d[d.length-(e>0?0:1)]=0),s[e]={left:0,top:0,width:i,height:o},d[d.length-1]+=i+n.padding})),l.height+=h}else{var c=n.padding,f=t.columnWidths=[],g=t.columnHeights=[],p=n.padding,m=0,v=0;V.each(t.legendItems,(function(t,e){var i=Ci(n,o)+o/2+a.measureText(t.text).width;e>0&&v+o+2*c>l.height&&(p+=m+n.padding,f.push(m),g.push(v),m=0,v=0),m=Math.max(m,i),v+=o+c,s[e]={left:0,top:0,width:i,height:o}})),p+=m,f.push(m),g.push(v),l.width+=p}t.width=l.width,t.height=l.height}else t.width=l.width=t.height=l.height=0},afterFit:Mi,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var t=this,e=t.options,n=e.labels,i=z.global,a=i.defaultColor,r=i.elements.line,o=t.height,s=t.columnHeights,l=t.width,u=t.lineWidths;if(e.display){var d,h=wi(e.rtl,t.left,t.minSize.width),c=t.ctx,f=Si(n.fontColor,i.defaultFontColor),g=V.options._parseFont(n),p=g.size;c.textAlign=h.textAlign("left"),c.textBaseline="middle",c.lineWidth=.5,c.strokeStyle=f,c.fillStyle=f,c.font=g.string;var m=Ci(n,p),v=t.legendHitBoxes,b=function(t,i){switch(e.align){case"start":return n.padding;case"end":return t-i;default:return(t-i+n.padding)/2}},x=t.isHorizontal();d=x?{x:t.left+b(l,u[0]),y:t.top+n.padding,line:0}:{x:t.left+n.padding,y:t.top+b(o,s[0]),line:0},V.rtl.overrideTextDirection(t.ctx,e.textDirection);var y=p+n.padding;V.each(t.legendItems,(function(e,i){var f=c.measureText(e.text).width,g=m+p/2+f,_=d.x,k=d.y;h.setWidth(t.minSize.width),x?i>0&&_+g+n.padding>t.left+t.minSize.width&&(k=d.y+=y,d.line++,_=d.x=t.left+b(l,u[d.line])):i>0&&k+y>t.top+t.minSize.height&&(_=d.x=_+t.columnWidths[d.line]+n.padding,d.line++,k=d.y=t.top+b(o,s[d.line]));var w=h.x(_);!function(t,e,i){if(!(isNaN(m)||m<=0)){c.save();var o=Si(i.lineWidth,r.borderWidth);if(c.fillStyle=Si(i.fillStyle,a),c.lineCap=Si(i.lineCap,r.borderCapStyle),c.lineDashOffset=Si(i.lineDashOffset,r.borderDashOffset),c.lineJoin=Si(i.lineJoin,r.borderJoinStyle),c.lineWidth=o,c.strokeStyle=Si(i.strokeStyle,a),c.setLineDash&&c.setLineDash(Si(i.lineDash,r.borderDash)),n&&n.usePointStyle){var s=m*Math.SQRT2/2,l=h.xPlus(t,m/2),u=e+p/2;V.canvas.drawPoint(c,i.pointStyle,s,l,u,i.rotation)}else c.fillRect(h.leftForLtr(t,m),e,m,p),0!==o&&c.strokeRect(h.leftForLtr(t,m),e,m,p);c.restore()}}(w,k,e),v[i].left=h.leftForLtr(w,v[i].width),v[i].top=k,function(t,e,n,i){var a=p/2,r=h.xPlus(t,m+a),o=e+a;c.fillText(n.text,r,o),n.hidden&&(c.beginPath(),c.lineWidth=2,c.moveTo(r,o),c.lineTo(h.xPlus(r,i),o),c.stroke())}(w,k,e,f),x?d.x+=g+n.padding:d.y+=y})),V.rtl.restoreTextDirection(t.ctx,e.textDirection)}},_getLegendItemAt:function(t,e){var n,i,a,r=this;if(t>=r.left&&t<=r.right&&e>=r.top&&e<=r.bottom)for(a=r.legendHitBoxes,n=0;n=(i=a[n]).left&&t<=i.left+i.width&&e>=i.top&&e<=i.top+i.height)return r.legendItems[n];return null},handleEvent:function(t){var e,n=this,i=n.options,a="mouseup"===t.type?"click":t.type;if("mousemove"===a){if(!i.onHover&&!i.onLeave)return}else{if("click"!==a)return;if(!i.onClick)return}e=n._getLegendItemAt(t.x,t.y),"click"===a?e&&i.onClick&&i.onClick.call(n,t.native,e):(i.onLeave&&e!==n._hoveredItem&&(n._hoveredItem&&i.onLeave.call(n,t.native,n._hoveredItem),n._hoveredItem=e),i.onHover&&e&&i.onHover.call(n,t.native,e))}});function Ai(t,e){var n=new Pi({ctx:t.ctx,options:e,chart:t});ge.configure(t,n,e),ge.addBox(t,n),t.legend=n}var Di={id:"legend",_element:Pi,beforeInit:function(t){var e=t.options.legend;e&&Ai(t,e)},beforeUpdate:function(t){var e=t.options.legend,n=t.legend;e?(V.mergeIf(e,z.global.legend),n?(ge.configure(t,n,e),n.options=e):Ai(t,e)):n&&(ge.removeBox(t,n),delete t.legend)},afterEvent:function(t,e){var n=t.legend;n&&n.handleEvent(e)}},Ti=V.noop;z._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,padding:10,position:"top",text:"",weight:2e3}});var Ii=X.extend({initialize:function(t){V.extend(this,t),this.legendHitBoxes=[]},beforeUpdate:Ti,update:function(t,e,n){var i=this;return i.beforeUpdate(),i.maxWidth=t,i.maxHeight=e,i.margins=n,i.beforeSetDimensions(),i.setDimensions(),i.afterSetDimensions(),i.beforeBuildLabels(),i.buildLabels(),i.afterBuildLabels(),i.beforeFit(),i.fit(),i.afterFit(),i.afterUpdate(),i.minSize},afterUpdate:Ti,beforeSetDimensions:Ti,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:Ti,beforeBuildLabels:Ti,buildLabels:Ti,afterBuildLabels:Ti,beforeFit:Ti,fit:function(){var t,e=this,n=e.options,i=e.minSize={},a=e.isHorizontal();n.display?(t=(V.isArray(n.text)?n.text.length:1)*V.options._parseFont(n).lineHeight+2*n.padding,e.width=i.width=a?e.maxWidth:t,e.height=i.height=a?t:e.maxHeight):e.width=i.width=e.height=i.height=0},afterFit:Ti,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,n=t.options;if(n.display){var i,a,r,o=V.options._parseFont(n),s=o.lineHeight,l=s/2+n.padding,u=0,d=t.top,h=t.left,c=t.bottom,f=t.right;e.fillStyle=V.valueOrDefault(n.fontColor,z.global.defaultFontColor),e.font=o.string,t.isHorizontal()?(a=h+(f-h)/2,r=d+l,i=f-h):(a="left"===n.position?h+l:f-l,r=d+(c-d)/2,i=c-d,u=Math.PI*("left"===n.position?-.5:.5)),e.save(),e.translate(a,r),e.rotate(u),e.textAlign="center",e.textBaseline="middle";var g=n.text;if(V.isArray(g))for(var p=0,m=0;m=0;i--){var a=t[i];if(e(a))return a}},V.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},V.almostEquals=function(t,e,n){return Math.abs(t-e)=t},V.max=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.max(t,e)}),Number.NEGATIVE_INFINITY)},V.min=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.min(t,e)}),Number.POSITIVE_INFINITY)},V.sign=Math.sign?function(t){return Math.sign(t)}:function(t){return 0===(t=+t)||isNaN(t)?t:t>0?1:-1},V.toRadians=function(t){return t*(Math.PI/180)},V.toDegrees=function(t){return t*(180/Math.PI)},V._decimalPlaces=function(t){if(V.isFinite(t)){for(var e=1,n=0;Math.round(t*e)/e!==t;)e*=10,n++;return n}},V.getAngleFromPoint=function(t,e){var n=e.x-t.x,i=e.y-t.y,a=Math.sqrt(n*n+i*i),r=Math.atan2(i,n);return r<-.5*Math.PI&&(r+=2*Math.PI),{angle:r,distance:a}},V.distanceBetweenPoints=function(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))},V.aliasPixel=function(t){return t%2==0?0:.5},V._alignPixel=function(t,e,n){var i=t.currentDevicePixelRatio,a=n/2;return Math.round((e-a)*i)/i+a},V.splineCurve=function(t,e,n,i){var a=t.skip?e:t,r=e,o=n.skip?e:n,s=Math.sqrt(Math.pow(r.x-a.x,2)+Math.pow(r.y-a.y,2)),l=Math.sqrt(Math.pow(o.x-r.x,2)+Math.pow(o.y-r.y,2)),u=s/(s+l),d=l/(s+l),h=i*(u=isNaN(u)?0:u),c=i*(d=isNaN(d)?0:d);return{previous:{x:r.x-h*(o.x-a.x),y:r.y-h*(o.y-a.y)},next:{x:r.x+c*(o.x-a.x),y:r.y+c*(o.y-a.y)}}},V.EPSILON=Number.EPSILON||1e-14,V.splineCurveMonotone=function(t){var e,n,i,a,r,o,s,l,u,d=(t||[]).map((function(t){return{model:t._model,deltaK:0,mK:0}})),h=d.length;for(e=0;e0?d[e-1]:null,(a=e0?d[e-1]:null,a=e=t.length-1?t[0]:t[e+1]:e>=t.length-1?t[t.length-1]:t[e+1]},V.previousItem=function(t,e,n){return n?e<=0?t[t.length-1]:t[e-1]:e<=0?t[0]:t[e-1]},V.niceNum=function(t,e){var n=Math.floor(V.log10(t)),i=t/Math.pow(10,n);return(e?i<1.5?1:i<3?2:i<7?5:10:i<=1?1:i<=2?2:i<=5?5:10)*Math.pow(10,n)},V.requestAnimFrame="undefined"==typeof window?function(t){t()}:window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)},V.getRelativePosition=function(t,e){var n,i,a=t.originalEvent||t,r=t.target||t.srcElement,o=r.getBoundingClientRect(),s=a.touches;s&&s.length>0?(n=s[0].clientX,i=s[0].clientY):(n=a.clientX,i=a.clientY);var l=parseFloat(V.getStyle(r,"padding-left")),u=parseFloat(V.getStyle(r,"padding-top")),d=parseFloat(V.getStyle(r,"padding-right")),h=parseFloat(V.getStyle(r,"padding-bottom")),c=o.right-o.left-l-d,f=o.bottom-o.top-u-h;return{x:n=Math.round((n-o.left-l)/c*r.width/e.currentDevicePixelRatio),y:i=Math.round((i-o.top-u)/f*r.height/e.currentDevicePixelRatio)}},V.getConstraintWidth=function(t){return n(t,"max-width","clientWidth")},V.getConstraintHeight=function(t){return n(t,"max-height","clientHeight")},V._calculatePadding=function(t,e,n){return(e=V.getStyle(t,e)).indexOf("%")>-1?n*parseInt(e,10)/100:parseInt(e,10)},V._getParentNode=function(t){var e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e},V.getMaximumWidth=function(t){var e=V._getParentNode(t);if(!e)return t.clientWidth;var n=e.clientWidth,i=n-V._calculatePadding(e,"padding-left",n)-V._calculatePadding(e,"padding-right",n),a=V.getConstraintWidth(t);return isNaN(a)?i:Math.min(i,a)},V.getMaximumHeight=function(t){var e=V._getParentNode(t);if(!e)return t.clientHeight;var n=e.clientHeight,i=n-V._calculatePadding(e,"padding-top",n)-V._calculatePadding(e,"padding-bottom",n),a=V.getConstraintHeight(t);return isNaN(a)?i:Math.min(i,a)},V.getStyle=function(t,e){return t.currentStyle?t.currentStyle[e]:document.defaultView.getComputedStyle(t,null).getPropertyValue(e)},V.retinaScale=function(t,e){var n=t.currentDevicePixelRatio=e||"undefined"!=typeof window&&window.devicePixelRatio||1;if(1!==n){var i=t.canvas,a=t.height,r=t.width;i.height=a*n,i.width=r*n,t.ctx.scale(n,n),i.style.height||i.style.width||(i.style.height=a+"px",i.style.width=r+"px")}},V.fontString=function(t,e,n){return e+" "+t+"px "+n},V.longestText=function(t,e,n,i){var a=(i=i||{}).data=i.data||{},r=i.garbageCollect=i.garbageCollect||[];i.font!==e&&(a=i.data={},r=i.garbageCollect=[],i.font=e),t.font=e;var o,s,l,u,d,h=0,c=n.length;for(o=0;on.length){for(o=0;oi&&(i=r),i},V.numberOfLabelLines=function(t){var e=1;return V.each(t,(function(t){V.isArray(t)&&t.length>e&&(e=t.length)})),e},V.color=k?function(t){return t instanceof CanvasGradient&&(t=z.global.defaultColor),k(t)}:function(t){return console.error("Color.js not found!"),t},V.getHoverColor=function(t){return t instanceof CanvasPattern||t instanceof CanvasGradient?t:V.color(t).saturate(.5).darken(.1).rgbString()}}(),tn._adapters=an,tn.Animation=Z,tn.animationService=$,tn.controllers=$t,tn.DatasetController=nt,tn.defaults=z,tn.Element=X,tn.elements=_t,tn.Interaction=ae,tn.layouts=ge,tn.platform=Fe,tn.plugins=Le,tn.Scale=xn,tn.scaleService=Oe,tn.Ticks=rn,tn.Tooltip=Ue,tn.helpers.each(ci,(function(t,e){tn.scaleService.registerScaleType(e,t,t._defaults)})),Li)Li.hasOwnProperty(Ni)&&tn.plugins.register(Li[Ni]);tn.platform.initialize();var Bi=tn;return"undefined"!=typeof window&&(window.Chart=tn),tn.Chart=tn,tn.Legend=Li.legend._element,tn.Title=Li.title._element,tn.pluginService=tn.plugins,tn.PluginBase=tn.Element.extend({}),tn.canvasHelpers=tn.helpers.canvas,tn.layoutService=tn.layouts,tn.LinearScaleBase=Sn,tn.helpers.each(["Bar","Bubble","Doughnut","Line","PolarArea","Radar","Scatter"],(function(t){tn[t]=function(e,n){return new tn(e,tn.helpers.merge(n||{},{type:t.charAt(0).toLowerCase()+t.slice(1)}))}})),Bi})); diff --git a/script/bench.rb b/script/bench.rb index b847db2f3c..44645f5549 100644 --- a/script/bench.rb +++ b/script/bench.rb @@ -184,7 +184,7 @@ puts "Populating Profile DB" run("bundle exec ruby script/profile_db_generator.rb") puts "Getting api key" -api_key = `bundle exec rake api_key:get`.split("\n")[-1] +api_key = `bundle exec rake api_key:get_or_create_master[bench]`.split("\n")[-1] def bench(path, name) puts "Running apache bench warmup" diff --git a/script/bulk_import/base.rb b/script/bulk_import/base.rb index 3951f35802..b72104e31c 100644 --- a/script/bulk_import/base.rb +++ b/script/bulk_import/base.rb @@ -778,7 +778,7 @@ class BulkImport::Base def normalize_charset(text) return text if @encoding == Encoding::UTF_8 - return text && text.encode(@encoding).force_encoding(Encoding::UTF_8) + text && text.encode(@encoding).force_encoding(Encoding::UTF_8) end end diff --git a/script/import_scripts/discuz_x.rb b/script/import_scripts/discuz_x.rb index dc8f28ebfe..9aed7a7cc0 100644 --- a/script/import_scripts/discuz_x.rb +++ b/script/import_scripts/discuz_x.rb @@ -275,7 +275,6 @@ class ImportScripts::DiscuzX < ImportScripts::Base description: row['description'], position: row['position'].to_i + max_position, color: color, - suppress_from_latest: (row['status'] == (0) || row['status'] == (3)), post_create_action: lambda do |category| if slug = @category_slug[row['id']] category.update(slug: slug) @@ -295,6 +294,10 @@ class ImportScripts::DiscuzX < ImportScripts::Base category.save! end end + + if row['status'] == (0) || row['status'] == (3) + SiteSetting.default_categories_muted = [SiteSetting.default_categories_muted, category.id].reject(&:blank?).join("|") + end category end } @@ -882,7 +885,7 @@ class ImportScripts::DiscuzX < ImportScripts::Base return nil end - return upload, real_filename + [upload, real_filename] end # find the uploaded file and real name from the db @@ -938,12 +941,12 @@ class ImportScripts::DiscuzX < ImportScripts::Base return nil end - return upload, real_filename + [upload, real_filename] rescue Mysql2::Error => e puts "SQL Error" puts e.message puts sql - return nil + nil end def first_exists(*items) diff --git a/script/import_scripts/disqus.rb b/script/import_scripts/disqus.rb index cb3679967e..5dbeb08775 100644 --- a/script/import_scripts/disqus.rb +++ b/script/import_scripts/disqus.rb @@ -196,7 +196,7 @@ class DisqusSAX < Nokogiri::XML::SAX::Document end def inside?(*params) - return !params.find { |p| !@inside[p] } + !params.find { |p| !@inside[p] } end def normalize diff --git a/script/import_scripts/friendsmegplus.rb b/script/import_scripts/friendsmegplus.rb index 6a53108e82..cd9c045c3b 100644 --- a/script/import_scripts/friendsmegplus.rb +++ b/script/import_scripts/friendsmegplus.rb @@ -457,7 +457,7 @@ class ImportScripts::FMGP < ImportScripts::Base end # FIXME: import G+ "+1" as "like" if F+MG+E feature request implemented - return mapped + mapped end def parse_title(post, created_at) @@ -524,7 +524,7 @@ class ImportScripts::FMGP < ImportScripts::Base words << fragment[1] end end - return words + words end def formatted_message(post) @@ -588,10 +588,10 @@ class ImportScripts::FMGP < ImportScripts::Base return text end elsif fragment[0] == 1 - return "\n" + "\n" elsif fragment[0] == 2 urls_seen.add(fragment[2]) - return formatted_link_text(fragment[2], fragment[1]) + formatted_link_text(fragment[2], fragment[1]) elsif fragment[0] == 3 # reference to a user if @usermap.include?(fragment[2].to_s) @@ -619,7 +619,7 @@ class ImportScripts::FMGP < ImportScripts::Base end elsif fragment[0] == 4 # hashtag, the octothorpe is included - return fragment[1] + fragment[1] else raise RuntimeError.new("message code #{fragment[0]} not recognized!") end diff --git a/script/import_scripts/google_groups.rb b/script/import_scripts/google_groups.rb index 6d627db25b..6e3f2f0969 100755 --- a/script/import_scripts/google_groups.rb +++ b/script/import_scripts/google_groups.rb @@ -69,11 +69,31 @@ def find(css, parent_element = driver) end end +def base_url + if @domain.nil? + "https://groups.google.com/forum/?_escaped_fragment_=categories" + else + "https://groups.google.com/a/#{@domain}/forum/?_escaped_fragment_=categories" + end +end + def crawl_categories 1.step(nil, 100).each do |start| - url = "https://groups.google.com/forum/?_escaped_fragment_=categories/#{@groupname}[#{start}-#{start + 99}]" + url = "#{base_url}/#{@groupname}[#{start}-#{start + 99}]" get(url) + begin + if start == 1 && find("h2").text == "Error 403" + exit_with_error(<<~MSG.red.bold) + Unable to find topics. Try running the script with the "--domain example.com" + option if you are a G Suite user and your group's URL contains a path with + your domain that looks like "/a/example.com". + MSG + end + rescue Selenium::WebDriver::Error::NoSuchElementError + # Ignore this error. It simply means there wasn't an error. + end + topic_urls = extract(".subject a[href*='#{@groupname}']") { |a| a["href"].sub("/d/topic/", "/forum/?_escaped_fragment_=topic/") } break if topic_urls.size == 0 @@ -208,6 +228,7 @@ def parse_arguments opts.banner = "Usage: google_groups.rb [options]" opts.on("-g", "--groupname GROUPNAME") { |v| @groupname = v } + opts.on("-d", "--domain DOMAIN") { |v| @domain = v } opts.on("-c", "--cookies PATH", "path to cookies.txt") { |v| @cookies = v } opts.on("--path PATH", "output path for emails") { |v| @path = v } opts.on("-f", "--force", "force import when user isn't allowed to see email addresses") { @force_import = true } diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 83700a87ca..ac18a3fa36 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -855,7 +855,7 @@ SQL return nil end - return upload, real_filename + [upload, real_filename] end def post_process_posts diff --git a/script/import_scripts/mybb.rb b/script/import_scripts/mybb.rb index 79b2377edf..259e855c37 100644 --- a/script/import_scripts/mybb.rb +++ b/script/import_scripts/mybb.rb @@ -181,7 +181,7 @@ class ImportScripts::MyBB < ImportScripts::Base if count > 5 puts "Warning: probably incorrect quote in post #{post_id}" end - return username + username end # Take an original post id and return the migrated topic id and post number for it diff --git a/script/import_scripts/smf2.rb b/script/import_scripts/smf2.rb index 1471d20d3b..611dabc81b 100644 --- a/script/import_scripts/smf2.rb +++ b/script/import_scripts/smf2.rb @@ -244,7 +244,7 @@ class ImportScripts::Smf2 < ImportScripts::Base raise "Attachment for post #{post[:id]} failed: #{attachment[:filename]}" unless path.present? upload = create_upload(post[:user_id], path, attachment[:filename]) raise "Attachment for post #{post[:id]} failed: #{upload.errors.full_messages.join(', ')}" unless upload.persisted? - return upload + upload rescue SystemCallError => err raise "Attachment for post #{post[:id]} failed: #{err.message}" end @@ -280,7 +280,7 @@ class ImportScripts::Smf2 < ImportScripts::Base return __query(db, sql).to_a if opts[:as] == :array return __query(db, sql, as: :array).first[0] if opts[:as] == :single return __query(db, sql, stream: true).each(&block) if block_given? - return __query(db, sql, stream: true) + __query(db, sql, stream: true) end def __query(db, sql, **opts) @@ -345,7 +345,7 @@ class ImportScripts::Smf2 < ImportScripts::Base end end - return opts[:ignore_quotes] ? body : convert_quotes(body) + opts[:ignore_quotes] ? body : convert_quotes(body) end def get_upload_markdown(upload) diff --git a/script/import_scripts/vbulletin5.rb b/script/import_scripts/vbulletin5.rb index 463d3e4e02..38489314b2 100644 --- a/script/import_scripts/vbulletin5.rb +++ b/script/import_scripts/vbulletin5.rb @@ -359,12 +359,12 @@ class ImportScripts::VBulletin < ImportScripts::Base return nil end - return upload, real_filename + [upload, real_filename] rescue Mysql2::Error => e puts "SQL Error" puts e.message puts sql - return nil + nil end def import_attachments diff --git a/script/memstats.rb b/script/memstats.rb index 3eff26b826..f387a88337 100755 --- a/script/memstats.rb +++ b/script/memstats.rb @@ -85,7 +85,7 @@ def consume_mapping(map_lines, totals) Mapping::FIELDS.each do |field| totals[field] += m.public_send(field) end - return m + m end def create_memstats_not_available(totals) @@ -136,7 +136,7 @@ def get_commandline(pid) loop { break if commandline.shift == "-jar" } return "[java] #{commandline.shift}" end - return commandline.join(' ') + commandline.join(' ') end if ARGV.include? '--yaml' diff --git a/script/plugin-translations.rb b/script/plugin-translations.rb index 3bcc5f936b..78535b4a54 100644 --- a/script/plugin-translations.rb +++ b/script/plugin-translations.rb @@ -42,7 +42,7 @@ class PluginTxUpdater PLUGINS.each do |plugin_name| plugin_dir = File.join(@base_dir, plugin_name) Bundler.with_clean_env do - Dir.chdir(plugin_dir) do + Dir.chdir(plugin_dir) do # rubocop:disable DiscourseCops/NoChdir because this is not part of the app puts '', plugin_dir, '-' * 80, '' begin diff --git a/script/theme-watcher b/script/theme-watcher deleted file mode 100755 index 208691fbf7..0000000000 --- a/script/theme-watcher +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require 'fileutils' -require 'pathname' -require 'tempfile' -require 'securerandom' -require 'minitar' -require 'zlib' -require 'find' -require 'net/http' -require 'net/http/post/multipart' -require 'uri' -require 'listen' -require 'json' - -# Work in progress theme watcher for Discourse -# -# Monitor a theme directory locally and automatically keep it in sync with Discourse - -def usage - puts "Usage: theme-watcher DIR SITE" - exit 1 -end - -WATCHER_SETTINGS_FILE = File.expand_path("~/.discourse-theme-watcher") - -$api_key = ENV['DISCOURSE_API_KEY'] -$dir = ARGV[0] -$site = ARGV[1] -$theme_id = nil - -if $site !~ /https?:\/\//i - $site = "http://#{$site}" -end - -puts "Watching #{$dir} and uploading changes to #{$site}" - -if !$api_key && File.exist?(WATCHER_SETTINGS_FILE) - $api_key = File.read(WATCHER_SETTINGS_FILE).strip - puts "Using previously stored api key in #{WATCHER_SETTINGS_FILE}" -end - -if !$api_key - puts "No API key found in DISCOURSE_API_KEY env var enter your API key: " - $api_key = STDIN.gets.strip - puts "Would you like me to store this API key in #{WATCHER_SETTINGS_FILE}? (Yes|No)" - answer = STDIN.gets.strip - if answer =~ /y(es)?/i - File.write WATCHER_SETTINGS_FILE, $api_key - end -end - -if !File.exist?("#{$dir}/about.json") - puts "No about.json file found in #{dir}!" - puts - usage -end - -def compress_dir(gzip, dir) - sgz = Zlib::GzipWriter.new(File.open(gzip, 'wb')) - tar = Archive::Tar::Minitar::Output.new(sgz) - - Dir.chdir(dir + "/../") do - Find.find(File.basename(dir)) do |x| - Find.prune if File.basename(x)[0] == ?. - next if File.directory?(x) - - Minitar.pack_file(x, tar) - end - end -ensure - tar.close - sgz.close -end - -def diagnose_errors(json) - count = 0 - json["theme"]["theme_fields"].each do |row| - if (error = row["error"]) && error.length > 0 - if count == 0 - puts - end - count += 1 - puts - puts "Error in #{row["target"]} #{row["name"]}: #{row["error"]}" - puts - end - end - count -end - -def upload_theme_field(target: , name: , type_id: , value:) - args = { - theme: { - theme_fields: [{ - name: name, - target: target, - type_id: type_id, - value: value - }] - } - } - - uri = URI.parse($site + "/admin/themes/#{$theme_id}?api_key=#{$api_key}") - - http = Net::HTTP.new(uri.host, uri.port) - request = Net::HTTP::Put.new(uri.request_uri, 'Content-Type' => 'application/json') - request.body = args.to_json - - http.start do |h| - response = h.request(request) - if response.code.to_i == 200 - json = JSON.parse(response.body) - if diagnose_errors(json) == 0 - puts "(done)" - end - else - puts "Error importing field status: #{response.code}" - end - end -end - -def upload_full_theme(dir, site) - filename = "#{Pathname.new(Dir.tmpdir).realpath}/bundle_#{SecureRandom.hex}.tar.gz" - compress_dir(filename, dir) - - # new full upload endpoint - uri = URI.parse(site + "/admin/themes/import.json?api_key=#{$api_key}") - http = Net::HTTP.new(uri.host, uri.port) - File.open(filename) do |tgz| - - request = Net::HTTP::Post::Multipart.new( - uri.request_uri, - "bundle" => UploadIO.new(tgz, "application/tar+gzip", "bundle.tar.gz"), - ) - response = http.request(request) - if response.code.to_i == 201 - json = JSON.parse(response.body) - $theme_id = json["theme"]["id"] - if diagnose_errors(json) == 0 - puts "(done)" - end - else - puts "Error importing theme status: #{response.code}" - end - end - -ensure - FileUtils.rm_f filename -end - -print "Uploading theme: " -upload_full_theme($dir, $site) - -def resolve_file(path) - dir_len = File.expand_path($dir).length - name = File.expand_path(path)[dir_len + 1..-1] - - target, file = name.split("/") - - if ["common", "desktop", "mobile"].include?(target) - if file = "#{target}.scss" - # a CSS file - return [target, "scss", 1] - end - end - - nil -end - -listener = Listen.to($dir) do |modified, added, removed| - if modified.length == 1 && - added.length == 0 && - removed.length == 0 && - (target, name, type_id = resolve_file(modified[0])) - print "Updating #{target} #{name}: " - upload_theme_field(target: target, name: name, value: File.read(modified[0]), type_id: type_id) - else - print "Full re-sync is required, re-uploading theme: " - upload_full_theme($dir, $site) - end -end - -listener.start -sleep diff --git a/spec/components/admin_user_index_query_spec.rb b/spec/components/admin_user_index_query_spec.rb index 5471bcd059..8060066f88 100644 --- a/spec/components/admin_user_index_query_spec.rb +++ b/spec/components/admin_user_index_query_spec.rb @@ -92,14 +92,23 @@ describe AdminUserIndexQuery do end describe 'with a suspected user' do - fab!(:user) { Fabricate(:active_user, created_at: 1.day.ago) } fab!(:bot) { Fabricate(:active_user, id: -10, created_at: 1.day.ago) } + fab!(:regular_user) { Fabricate(:user, created_at: 1.day.ago) } + fab!(:user_with_bio) { Fabricate(:active_user, created_at: 1.day.ago) } + fab!(:user_with_website) { Fabricate(:user, created_at: 1.day.ago) } + + before do + user_with_website.user_profile.website = 'https://example.com' + user_with_website.user_profile.save! + end it 'finds the suspected user' do bot - user + regular_user + user_with_bio + user_with_website query = AdminUserIndexQuery.new(query: 'suspect') - expect(query.find_users).to eq([user]) + expect(query.find_users).to contain_exactly(user_with_bio, user_with_website) end end diff --git a/spec/components/auth/default_current_user_provider_spec.rb b/spec/components/auth/default_current_user_provider_spec.rb index 482fa6d83e..c5cd696641 100644 --- a/spec/components/auth/default_current_user_provider_spec.rb +++ b/spec/components/auth/default_current_user_provider_spec.rb @@ -32,8 +32,8 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct per-user api key" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1) - good_provider = provider("/?api_key=hello") + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1) + good_provider = provider("/?api_key=#{api_key.key}") expect(good_provider.current_user.id).to eq(user.id) expect(good_provider.is_api?).to eq(true) expect(good_provider.is_user_api?).to eq(false) @@ -42,23 +42,23 @@ describe Auth::DefaultCurrentUserProvider do user.update_columns(active: false) expect { - provider("/?api_key=hello").current_user + provider("/?api_key=#{api_key.key}").current_user }.to raise_error(Discourse::InvalidAccess) user.update_columns(active: true, suspended_till: 1.day.from_now) expect { - provider("/?api_key=hello").current_user + provider("/?api_key=#{api_key.key}").current_user }.to raise_error(Discourse::InvalidAccess) end it "raises for a user pretending" do user = Fabricate(:user) user2 = Fabricate(:user) - key = ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1) + key = ApiKey.create!(user_id: user.id, created_by_id: -1) expect { - provider("/?api_key=hello&api_username=#{user2.username.downcase}").current_user + provider("/?api_key=#{key.key}&api_username=#{user2.username.downcase}").current_user }.to raise_error(Discourse::InvalidAccess) key.reload @@ -67,16 +67,16 @@ describe Auth::DefaultCurrentUserProvider do it "raises for a revoked key" do user = Fabricate(:user) - key = ApiKey.create!(key: "hello") + key = ApiKey.create! expect( - provider("/?api_key=hello&api_username=#{user.username.downcase}").current_user.id + provider("/?api_key=#{key.key}&api_username=#{user.username.downcase}").current_user.id ).to eq(user.id) key.reload.update(revoked_at: Time.zone.now, last_used_at: nil) expect(key.reload.last_used_at).to eq(nil) expect { - provider("/?api_key=hello&api_username=#{user.username.downcase}").current_user + provider("/?api_key=#{key.key}&api_username=#{user.username.downcase}").current_user }.to raise_error(Discourse::InvalidAccess) key.reload @@ -85,10 +85,10 @@ describe Auth::DefaultCurrentUserProvider do it "raises for a user with a mismatching ip" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1, allowed_ips: ['10.0.0.0/24']) + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1, allowed_ips: ['10.0.0.0/24']) expect { - provider("/?api_key=hello&api_username=#{user.username.downcase}", "REMOTE_ADDR" => "10.1.0.1").current_user + provider("/?api_key=#{api_key.key}&api_username=#{user.username.downcase}", "REMOTE_ADDR" => "10.1.0.1").current_user }.to raise_error(Discourse::InvalidAccess) end @@ -97,14 +97,14 @@ describe Auth::DefaultCurrentUserProvider do freeze_time user = Fabricate(:user) - key = ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1, allowed_ips: ['100.0.0.0/24']) + key = ApiKey.create!(user_id: user.id, created_by_id: -1, allowed_ips: ['100.0.0.0/24']) - found_user = provider("/?api_key=hello&api_username=#{user.username.downcase}", + found_user = provider("/?api_key=#{key.key}&api_username=#{user.username.downcase}", "REMOTE_ADDR" => "100.0.0.22").current_user expect(found_user.id).to eq(user.id) - found_user = provider("/?api_key=hello&api_username=#{user.username.downcase}", + found_user = provider("/?api_key=#{key.key}&api_username=#{user.username.downcase}", "HTTP_X_FORWARDED_FOR" => "10.1.1.1, 100.0.0.22").current_user expect(found_user.id).to eq(user.id) @@ -114,48 +114,48 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct system api key" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - expect(provider("/?api_key=hello&api_username=#{user.username.downcase}").current_user.id).to eq(user.id) + api_key = ApiKey.create!(created_by_id: -1) + expect(provider("/?api_key=#{api_key.key}&api_username=#{user.username.downcase}").current_user.id).to eq(user.id) end it "raises for a mismatched api_key param and header username" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) params = { "HTTP_API_USERNAME" => user.username.downcase } expect { - provider("/?api_key=hello", params).current_user + provider("/?api_key=#{api_key.key}", params).current_user }.to raise_error(Discourse::InvalidAccess) end it "finds a user for a correct system api key with external id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) SingleSignOnRecord.create(user_id: user.id, external_id: "abc", last_payload: '') - expect(provider("/?api_key=hello&api_user_external_id=abc").current_user.id).to eq(user.id) + expect(provider("/?api_key=#{api_key.key}&api_user_external_id=abc").current_user.id).to eq(user.id) end it "raises for a mismatched api_key param and header external id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) SingleSignOnRecord.create(user_id: user.id, external_id: "abc", last_payload: '') params = { "HTTP_API_USER_EXTERNAL_ID" => "abc" } expect { - provider("/?api_key=hello", params).current_user + provider("/?api_key=#{api_key.key}", params).current_user }.to raise_error(Discourse::InvalidAccess) end it "finds a user for a correct system api key with id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - expect(provider("/?api_key=hello&api_user_id=#{user.id}").current_user.id).to eq(user.id) + api_key = ApiKey.create!(created_by_id: -1) + expect(provider("/?api_key=#{api_key.key}&api_user_id=#{user.id}").current_user.id).to eq(user.id) end it "raises for a mismatched api_key param and header user id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) params = { "HTTP_API_USER_ID" => user.id } expect { - provider("/?api_key=hello", params).current_user + provider("/?api_key=#{api_key.key}", params).current_user }.to raise_error(Discourse::InvalidAccess) end @@ -174,8 +174,8 @@ describe Auth::DefaultCurrentUserProvider do freeze_time user = Fabricate(:user) - key = SecureRandom.hex - api_key = ApiKey.create!(key: key, created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) + key = api_key.key provider("/?api_key=#{key}&api_username=#{user.username.downcase}").current_user provider("/?api_key=#{key}&api_username=system").current_user @@ -198,9 +198,8 @@ describe Auth::DefaultCurrentUserProvider do # should not rake limit a random key api_key.destroy - key = SecureRandom.hex - ApiKey.create!(key: key, created_by_id: -1) - provider("/?api_key=#{key}&api_username=#{user.username.downcase}").current_user + api_key = ApiKey.create!(created_by_id: -1) + provider("/?api_key=#{api_key.key}&api_username=#{user.username.downcase}").current_user end end @@ -218,8 +217,8 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct per-user api key" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1) - params = { "HTTP_API_KEY" => "hello" } + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key } good_provider = provider("/", params) expect(good_provider.current_user.id).to eq(user.id) @@ -243,8 +242,8 @@ describe Auth::DefaultCurrentUserProvider do it "raises for a user pretending" do user = Fabricate(:user) user2 = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1) - params = { "HTTP_API_KEY" => "hello", "HTTP_API_USERNAME" => user2.username.downcase } + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user2.username.downcase } expect { provider("/", params).current_user @@ -253,9 +252,9 @@ describe Auth::DefaultCurrentUserProvider do it "raises for a user with a mismatching ip" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1, allowed_ips: ['10.0.0.0/24']) + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1, allowed_ips: ['10.0.0.0/24']) params = { - "HTTP_API_KEY" => "hello", + "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase, "REMOTE_ADDR" => "10.1.0.1" } @@ -268,9 +267,9 @@ describe Auth::DefaultCurrentUserProvider do it "allows a user with a matching ip" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1, allowed_ips: ['100.0.0.0/24']) + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1, allowed_ips: ['100.0.0.0/24']) params = { - "HTTP_API_KEY" => "hello", + "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase, "REMOTE_ADDR" => "100.0.0.22", } @@ -280,7 +279,7 @@ describe Auth::DefaultCurrentUserProvider do expect(found_user.id).to eq(user.id) params = { - "HTTP_API_KEY" => "hello", + "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase, "HTTP_X_FORWARDED_FOR" => "10.1.1.1, 100.0.0.22" } @@ -292,15 +291,15 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct system api key" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - params = { "HTTP_API_KEY" => "hello", "HTTP_API_USERNAME" => user.username.downcase } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase } expect(provider("/", params).current_user.id).to eq(user.id) end it "raises for a mismatched api_key header and param username" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - params = { "HTTP_API_KEY" => "hello" } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key } expect { provider("/?api_username=#{user.username.downcase}", params).current_user }.to raise_error(Discourse::InvalidAccess) @@ -308,17 +307,17 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct system api key with external id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) SingleSignOnRecord.create(user_id: user.id, external_id: "abc", last_payload: '') - params = { "HTTP_API_KEY" => "hello", "HTTP_API_USER_EXTERNAL_ID" => "abc" } + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USER_EXTERNAL_ID" => "abc" } expect(provider("/", params).current_user.id).to eq(user.id) end it "raises for a mismatched api_key header and param external id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) + api_key = ApiKey.create!(created_by_id: -1) SingleSignOnRecord.create(user_id: user.id, external_id: "abc", last_payload: '') - params = { "HTTP_API_KEY" => "hello" } + params = { "HTTP_API_KEY" => api_key.key } expect { provider("/?api_user_external_id=abc", params).current_user }.to raise_error(Discourse::InvalidAccess) @@ -326,15 +325,15 @@ describe Auth::DefaultCurrentUserProvider do it "finds a user for a correct system api key with id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - params = { "HTTP_API_KEY" => "hello", "HTTP_API_USER_ID" => user.id } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USER_ID" => user.id } expect(provider("/", params).current_user.id).to eq(user.id) end it "raises for a mismatched api_key header and param user id" do user = Fabricate(:user) - ApiKey.create!(key: "hello", created_by_id: -1) - params = { "HTTP_API_KEY" => "hello" } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key } expect { provider("/?api_user_id=#{user.id}", params).current_user }.to raise_error(Discourse::InvalidAccess) @@ -355,9 +354,8 @@ describe Auth::DefaultCurrentUserProvider do freeze_time user = Fabricate(:user) - key = SecureRandom.hex - api_key = ApiKey.create!(key: key, created_by_id: -1) - params = { "HTTP_API_KEY" => key, "HTTP_API_USERNAME" => user.username.downcase } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase } system_params = params.merge("HTTP_API_USERNAME" => "system") provider("/", params).current_user @@ -381,9 +379,8 @@ describe Auth::DefaultCurrentUserProvider do # should not rate limit a random key api_key.destroy - key = SecureRandom.hex - ApiKey.create!(key: key, created_by_id: -1) - params = { "HTTP_API_KEY" => key, "HTTP_API_USERNAME" => user.username.downcase } + api_key = ApiKey.create!(created_by_id: -1) + params = { "HTTP_API_KEY" => api_key.key, "HTTP_API_USERNAME" => user.username.downcase } provider("/", params).current_user end @@ -467,10 +464,10 @@ describe Auth::DefaultCurrentUserProvider do it "should update last seen for API calls with Discourse-Visible header" do user = Fabricate(:user) - ApiKey.create!(key: "hello", user_id: user.id, created_by_id: -1) + api_key = ApiKey.create!(user_id: user.id, created_by_id: -1) params = { :method => "POST", "HTTP_X_REQUESTED_WITH" => "XMLHttpRequest", - "HTTP_API_KEY" => "hello" + "HTTP_API_KEY" => api_key.key } expect(provider("/topic/anything/goes", params).should_update_last_seen?).to eq(false) @@ -586,6 +583,33 @@ describe Auth::DefaultCurrentUserProvider do expect(UserAuthToken.where(user_id: user.id).count).to eq(2) end + it "cleans up old sessions when a user logs in" do + user = Fabricate(:user) + + yesterday = 1.day.ago + + UserAuthToken.insert_all((1..(UserAuthToken::MAX_SESSION_COUNT + 2)).to_a.map do |i| + { + user_id: user.id, + created_at: yesterday + i.seconds, + updated_at: yesterday + i.seconds, + rotated_at: yesterday + i.seconds, + prev_auth_token: "abc#{i}", + auth_token: "abc#{i}" + } + end) + + # Check the oldest 3 still exist + expect(UserAuthToken.where(auth_token: (1..3).map { |i| "abc#{i}" }).count).to eq(3) + + # On next login, gets fixed + provider('/').log_on_user(user, {}, {}) + expect(UserAuthToken.where(user_id: user.id).count).to eq(UserAuthToken::MAX_SESSION_COUNT) + + # Oldest sessions are 1, 2, 3. They should now be deleted + expect(UserAuthToken.where(auth_token: (1..3).map { |i| "abc#{i}" }).count).to eq(0) + end + it "sets secure, same site lax cookies" do SiteSetting.force_https = false SiteSetting.same_site_cookies = "Lax" diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index c1870ed6b1..eec42c7c2b 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -4,6 +4,17 @@ require "rails_helper" require "cooked_post_processor" require "file_store/s3_store" +def s3_setup + Rails.configuration.action_controller.stubs(:asset_host).returns("https://local.cdn.com") + + SiteSetting.s3_upload_bucket = "some-bucket-on-s3" + SiteSetting.s3_access_key_id = "s3-access-key-id" + SiteSetting.s3_secret_access_key = "s3-secret-access-key" + SiteSetting.s3_cdn_url = "https://s3.cdn.com" + SiteSetting.enable_s3_uploads = true + SiteSetting.authorized_extensions = "png|jpg|gif|mov|ogg|" +end + describe CookedPostProcessor do fab!(:upload) { Fabricate(:upload) } @@ -491,6 +502,37 @@ describe CookedPostProcessor do end end + context "s3_uploads" do + before do + s3_setup + stored_path = Discourse.store.get_path_for_upload(upload) + upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}") + + stub_request(:any, /some-bucket-on-s3\.s3\.amazonaws\.com/) + + OptimizedImage.expects(:resize).returns(true) + FileStore::BaseStore.any_instance.expects(:get_depth_for).returns(0) + Discourse.store.class.any_instance.expects(:has_been_uploaded?).at_least_once.returns(true) + + SiteSetting.login_required = true + SiteSetting.secure_media = true + upload.update_column(:secure, true) + end + + let(:post) do + Fabricate(:post, raw: "![large.png|600x500](#{upload.short_url})") + end + + it "handles secure images with the correct lightbox link href" do + cpp.post_process + + expect(cpp.html).to match_html <<~HTML +

+ HTML + end + end end context "with tall images" do @@ -558,14 +600,12 @@ describe CookedPostProcessor do end let(:cpp) { CookedPostProcessor.new(post, disable_loading_image: true) } - let(:base_url) { "http://test.localhost/subfolder" } - let(:base_uri) { "/subfolder" } before do + set_subfolder "/subfolder" + SiteSetting.max_image_height = 2000 SiteSetting.create_thumbnails = true - Discourse.stubs(:base_url).returns(base_url) - Discourse.stubs(:base_uri).returns(base_uri) FastImage.expects(:size).returns([1750, 2000]) OptimizedImage.expects(:resize).returns(true) @@ -814,8 +854,8 @@ describe CookedPostProcessor do it "is always allowed to crawl our own images" do store = stub + Discourse.expects(:store).returns(store).at_least_once store.expects(:has_been_uploaded?).returns(true) - Discourse.expects(:store).returns(store) FastImage.expects(:size).returns([100, 200]) expect(cpp.get_size("http://foo.bar/image2.png")).to eq([100, 200]) end @@ -874,6 +914,40 @@ describe CookedPostProcessor do end + context "#convert_to_link" do + fab!(:thumbnail) { Fabricate(:optimized_image, upload: upload, width: 512, height: 384) } + + before do + CookedPostProcessor.any_instance.stubs(:get_size).with(upload.url).returns([1024, 768]) + end + + it "adds lightbox and optimizes images" do + post = Fabricate(:post, raw: "![image|1024x768, 50%](#{upload.short_url})") + + cpp = CookedPostProcessor.new(post, disable_loading_image: true) + cpp.post_process + + doc = Nokogiri::HTML::fragment(cpp.html) + expect(doc.css('.lightbox-wrapper').size).to eq(1) + expect(doc.css('img').first['srcset']).to_not eq(nil) + end + + it "optimizes images in quotes" do + post = Fabricate(:post, raw: <<~MD) + [quote] + ![image|1024x768, 50%](#{upload.short_url}) + [/quote] + MD + + cpp = CookedPostProcessor.new(post, disable_loading_image: true) + cpp.post_process + + doc = Nokogiri::HTML::fragment(cpp.html) + expect(doc.css('.lightbox-wrapper').size).to eq(0) + expect(doc.css('img').first['srcset']).to_not eq(nil) + end + end + context "#post_process_oneboxes" do let(:post) { build(:post_with_youtube, id: 123) } let(:cpp) { CookedPostProcessor.new(post, invalidate_oneboxes: true) } @@ -1106,40 +1180,169 @@ describe CookedPostProcessor do HTML end - it "uses the right CDN when uploads are on S3" do - Rails.configuration.action_controller.stubs(:asset_host).returns("https://local.cdn.com") + context "s3_uploads" do + before do + s3_setup - SiteSetting.s3_upload_bucket = "some-bucket-on-s3" - SiteSetting.s3_access_key_id = "s3-access-key-id" - SiteSetting.s3_secret_access_key = "s3-secret-access-key" - SiteSetting.s3_cdn_url = "https://s3.cdn.com" - SiteSetting.enable_s3_uploads = true + uploaded_file = file_from_fixtures("smallest.png") + upload_sha1 = Digest::SHA1.hexdigest(File.read(uploaded_file)) - uploaded_file = file_from_fixtures("smallest.png") - upload_sha1 = Digest::SHA1.hexdigest(File.read(uploaded_file)) + upload.update!( + original_filename: "smallest.png", + width: 10, + height: 20, + sha1: upload_sha1, + extension: "png", + ) + end - upload.update!( - original_filename: "smallest.png", - width: 10, - height: 20, - sha1: upload_sha1, - extension: "png", - ) + it "uses the right CDN when uploads are on S3" do + stored_path = Discourse.store.get_path_for_upload(upload) + upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}") - stored_path = Discourse.store.get_path_for_upload(upload) - upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}") + the_post = Fabricate(:post, raw: %Q{This post has a local emoji :+1: and an external upload\n\n![smallest.png|10x20](#{upload.short_url})}) - the_post = Fabricate(:post, raw: %Q{This post has a local emoji :+1: and an external upload\n\n![smallest.png|10x20](#{upload.short_url})}) + cpp = CookedPostProcessor.new(the_post) + cpp.optimize_urls - cpp = CookedPostProcessor.new(the_post) - cpp.optimize_urls + expect(cpp.html).to match_html <<~HTML +

This post has a local emoji :+1: and an external upload

+

smallest.png

+ HTML + end - expect(cpp.html).to match_html <<~HTML -

This post has a local emoji :+1: and an external upload

-

smallest.png

- HTML + it "doesn't use CDN for secure media" do + SiteSetting.secure_media = true + + stored_path = Discourse.store.get_path_for_upload(upload) + upload.update_column(:url, "#{SiteSetting.Upload.absolute_base_url}/#{stored_path}") + upload.update_column(:secure, true) + + the_post = Fabricate(:post, raw: %Q{This post has a local emoji :+1: and an external upload\n\n![smallest.png|10x20](#{upload.short_url})}) + + cpp = CookedPostProcessor.new(the_post) + cpp.optimize_urls + + expect(cpp.html).to match_html <<~HTML +

This post has a local emoji :+1: and an external upload

+

smallest.png

+ HTML + end + + context "media uploads" do + fab!(:image_upload) { Fabricate(:upload) } + fab!(:audio_upload) { Fabricate(:upload, extension: "ogg") } + fab!(:video_upload) { Fabricate(:upload, extension: "mov") } + + before do + video_upload.update!(url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(video_upload)}") + stub_request(:head, video_upload.url) + end + + it "ignores prevent_anons_from_downloading_files and oneboxes video uploads" do + SiteSetting.prevent_anons_from_downloading_files = true + + the_post = Fabricate(:post, raw: "This post has an S3 video onebox:\n#{video_upload.url}") + + cpp = CookedPostProcessor.new(the_post) + cpp.post_process_oneboxes + + expect(cpp.html).to match_html <<~HTML +

This post has an S3 video onebox:

+
+ +
+ HTML + end + + it "oneboxes video using secure url when secure_media is enabled" do + SiteSetting.login_required = true + SiteSetting.secure_media = true + video_upload.update_column(:secure, true) + + the_post = Fabricate(:post, raw: "This post has an S3 video onebox:\n#{video_upload.url}") + + cpp = CookedPostProcessor.new(the_post) + cpp.post_process_oneboxes + + secure_url = video_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-media-uploads") + + expect(cpp.html).to match_html <<~HTML +

This post has an S3 video onebox:
+

+ +
+

+ HTML + end + + it "oneboxes only audio/video and not images when secure_media is enabled" do + SiteSetting.login_required = true + SiteSetting.secure_media = true + + video_upload.update_column(:secure, true) + + audio_upload.update!( + url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(audio_upload)}", + secure: true + ) + + image_upload.update!( + url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(image_upload)}", + secure: true + ) + + stub_request(:head, audio_upload.url) + stub_request(:get, image_upload.url) + + raw = <<~RAW + This post has a video upload. + #{video_upload.url} + + This post has an audio upload. + #{audio_upload.url} + + And an image upload. + ![logo.png](upload://#{image_upload.base62_sha1}.#{image_upload.extension}) + RAW + + the_post = Fabricate(:post, raw: raw) + + cpp = CookedPostProcessor.new(the_post) + cpp.post_process_oneboxes + + secure_video_url = video_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-media-uploads") + secure_audio_url = audio_upload.url.sub(SiteSetting.s3_cdn_url, "#{Discourse.base_url}/secure-media-uploads") + + expect(cpp.html).to match_html <<~HTML +

This post has a video upload.

+
+ +
+ +

This post has an audio upload.
+ +

+

And an image upload.
+ #{image_upload.original_filename}

+ + HTML + end + + end end - end end diff --git a/spec/components/crawler_detection_spec.rb b/spec/components/crawler_detection_spec.rb index dd9ce42d22..db45d2cbd6 100644 --- a/spec/components/crawler_detection_spec.rb +++ b/spec/components/crawler_detection_spec.rb @@ -47,6 +47,7 @@ describe CrawlerDetection do crawler! "LogicMonitor SiteMonitor/1.0" crawler! "Java/1.8.0_151" crawler! "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)" + crawler! "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3694.0 Mobile Safari/537.36 Chrome-Lighthouse" end it "returns true when VIA header contains 'web.archive.org'" do diff --git a/spec/components/discourse_spec.rb b/spec/components/discourse_spec.rb index 106eefdc21..38625ee86d 100644 --- a/spec/components/discourse_spec.rb +++ b/spec/components/discourse_spec.rb @@ -391,4 +391,42 @@ describe Discourse do end end + describe "Utils.execute_command" do + it "works for individual commands" do + expect(Discourse::Utils.execute_command("pwd").strip).to eq(Rails.root.to_s) + expect(Discourse::Utils.execute_command("pwd", chdir: "plugins").strip).to eq("#{Rails.root.to_s}/plugins") + end + + it "works with a block" do + Discourse::Utils.execute_command do |runner| + expect(runner.exec("pwd").strip).to eq(Rails.root.to_s) + end + + result = Discourse::Utils.execute_command(chdir: "plugins") do |runner| + expect(runner.exec("pwd").strip).to eq("#{Rails.root.to_s}/plugins") + runner.exec("pwd") + end + + # Should return output of block + expect(result.strip).to eq("#{Rails.root.to_s}/plugins") + end + + it "does not leak chdir between threads" do + has_done_chdir = false + has_checked_chdir = false + + thread = Thread.new do + Discourse::Utils.execute_command(chdir: "plugins") do + has_done_chdir = true + sleep(0.01) until has_checked_chdir + end + end + + sleep(0.01) until has_done_chdir + expect(Discourse::Utils.execute_command("pwd").strip).to eq(Rails.root.to_s) + has_checked_chdir = true + thread.join + end + end + end diff --git a/spec/components/discourse_tagging_spec.rb b/spec/components/discourse_tagging_spec.rb index 7605b09fa5..dcd0f7f1c3 100644 --- a/spec/components/discourse_tagging_spec.rb +++ b/spec/components/discourse_tagging_spec.rb @@ -25,7 +25,7 @@ describe DiscourseTagging do describe 'filter_allowed_tags' do context 'for input fields' do it "doesn't return selected tags if there's a search term" do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), selected_tags: [tag2.name], for_input: true, term: 'fun' @@ -34,27 +34,54 @@ describe DiscourseTagging do end it "doesn't return selected tags if there's no search term" do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), selected_tags: [tag2.name], for_input: true ).map(&:name) expect(tags).to contain_exactly(tag1.name, tag3.name) end + context 'tag with colon' do + fab!(:tag_with_colon) { Fabricate(:tag, name: 'with:colon') } + + it "can use it as selected tag" do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + selected_tags: [tag_with_colon.name], + for_input: true + ).map(&:name) + expect(tags).to contain_exactly(tag1.name, tag2.name, tag3.name) + end + + it "can search for tags with colons" do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_input: true, + term: 'with:c', + order_search_results: true + ).map(&:name) + expect(tags).to contain_exactly(tag_with_colon.name) + end + + it "can limit results to the tag" do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_topic: true, + only_tag_names: [tag_with_colon.name] + ).map(&:name) + expect(tags).to contain_exactly(tag_with_colon.name) + end + end + context 'with tags visible only to staff' do fab!(:hidden_tag) { Fabricate(:tag) } let!(:staff_tag_group) { Fabricate(:tag_group, permissions: { "staff" => 1 }, tag_names: [hidden_tag.name]) } it 'should return all tags to staff' do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(admin)).to_a - expect(tags).to contain_exactly(tag1, tag2, tag3, hidden_tag) - expect(tags.size).to eq(4) + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(admin)).to_a + expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag1, tag2, tag3, hidden_tag])) end it 'should not return hidden tag to non-staff' do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user)).to_a - expect(tags).to contain_exactly(tag1, tag2, tag3) - expect(tags.size).to eq(3) + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user)).to_a + expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag1, tag2, tag3])) end end @@ -63,42 +90,82 @@ describe DiscourseTagging do fab!(:category) { Fabricate(:category, required_tag_group: tag_group, min_tags_from_required_group: 1) } it "returns the required tags if none have been selected" do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), for_input: true, category: category, term: 'fun' ).to_a - expect(tags).to contain_exactly(tag1, tag2) + expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag1, tag2])) end it "returns all allowed tags if a required tag is selected" do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), for_input: true, category: category, selected_tags: [tag1.name], term: 'fun' ).to_a - expect(tags).to contain_exactly(tag2, tag3) + expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag2, tag3])) end it "returns required tags if not enough are selected" do category.update!(min_tags_from_required_group: 2) - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), for_input: true, category: category, selected_tags: [tag1.name], term: 'fun' ).to_a - expect(tags).to contain_exactly(tag2) + expect(sorted_tag_names(tags)).to contain_exactly(tag2.name) end it "let's staff ignore the requirement" do - tags = DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(admin), + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(admin), for_input: true, category: category, term: 'fun' ).to_a - expect(tags).to contain_exactly(tag1, tag2, tag3) + expect(sorted_tag_names(tags)).to eq(sorted_tag_names([tag1, tag2, tag3])) + end + end + + context 'tag synonyms' do + fab!(:base_tag) { Fabricate(:tag, name: 'discourse') } + fab!(:synonym) { Fabricate(:tag, name: 'discource', target_tag: base_tag) } + + it 'returns synonyms by default' do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_input: true, + term: 'disc' + ).map(&:name) + expect(tags).to contain_exactly(base_tag.name, synonym.name) + end + + it 'excludes synonyms with exclude_synonyms param' do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_input: true, + exclude_synonyms: true, + term: 'disc' + ).map(&:name) + expect(tags).to contain_exactly(base_tag.name) + end + + it 'excludes tags with synonyms with exclude_has_synonyms params' do + tags = DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_input: true, + exclude_has_synonyms: true, + term: 'disc' + ).map(&:name) + expect(tags).to contain_exactly(synonym.name) + end + + it 'can exclude synonyms and tags with synonyms' do + expect(DiscourseTagging.filter_allowed_tags(Guardian.new(user), + for_input: true, + exclude_has_synonyms: true, + exclude_synonyms: true, + term: 'disc' + )).to be_empty end end end @@ -326,6 +393,27 @@ describe DiscourseTagging do expect(valid).to eq(true) end end + + context 'tag synonyms' do + fab!(:topic) { Fabricate(:topic) } + + fab!(:syn1) { Fabricate(:tag, name: 'synonym1', target_tag: tag1) } + fab!(:syn2) { Fabricate(:tag, name: 'synonym2', target_tag: tag1) } + + it "uses the base tag when a synonym is given" do + valid = DiscourseTagging.tag_topic_by_names(topic, Guardian.new(user), [syn1.name]) + expect(valid).to eq(true) + expect(topic.errors[:base]).to be_empty + expect_same_tag_names(topic.reload.tags, [tag1]) + end + + it "handles multiple synonyms for the same tag" do + valid = DiscourseTagging.tag_topic_by_names(topic, Guardian.new(user), [tag1.name, syn1.name, syn2.name]) + expect(valid).to eq(true) + expect(topic.errors[:base]).to be_empty + expect_same_tag_names(topic.reload.tags, [tag1]) + end + end end describe '#tags_for_saving' do @@ -409,4 +497,67 @@ describe DiscourseTagging do expect(DiscourseTagging.staff_tag_names).to contain_exactly(other_staff_tag.name) end end + + describe '#add_or_create_synonyms_by_name' do + it "can add an existing tag" do + expect { + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])).to eq(true) + }.to_not change { Tag.count } + expect_same_tag_names(tag1.reload.synonyms, [tag2]) + expect(tag2.reload.target_tag).to eq(tag1) + end + + it "can add existing tag with wrong case" do + expect { + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name.upcase])).to eq(true) + }.to_not change { Tag.count } + expect_same_tag_names(tag1.reload.synonyms, [tag2]) + expect(tag2.reload.target_tag).to eq(tag1) + end + + it "can create new tags" do + expect { + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, ['synonym1'])).to eq(true) + }.to change { Tag.count }.by(1) + s = Tag.where_name('synonym1').first + expect_same_tag_names(tag1.reload.synonyms, [s]) + expect(s.target_tag).to eq(tag1) + end + + it "can add existing and new tags" do + expect { + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name, 'synonym1'])).to eq(true) + }.to change { Tag.count }.by(1) + s = Tag.where_name('synonym1').first + expect_same_tag_names(tag1.reload.synonyms, [tag2, s]) + expect(s.target_tag).to eq(tag1) + expect(tag2.reload.target_tag).to eq(tag1) + end + + it "can change a synonym's target tag" do + synonym = Fabricate(:tag, name: 'synonym1', target_tag: tag1) + expect { + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag2, [synonym.name])).to eq(true) + }.to_not change { Tag.count } + expect_same_tag_names(tag2.reload.synonyms, [synonym]) + expect(tag1.reload.synonyms.count).to eq(0) + expect(synonym.reload.target_tag).to eq(tag2) + end + + it "doesn't allow tags that have synonyms to become synonyms" do + tag2.synonyms << Fabricate(:tag) + value = DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name]) + expect(value).to be_a(Array) + expect(value.size).to eq(1) + expect(value.first.errors[:target_tag_id]).to be_present + expect(tag1.reload.synonyms.count).to eq(0) + expect(tag2.reload.synonyms.count).to eq(1) + end + + it "changes tag of topics" do + topic = Fabricate(:topic, tags: [tag2]) + expect(DiscourseTagging.add_or_create_synonyms_by_name(tag1, [tag2.name])).to eq(true) + expect_same_tag_names(topic.reload.tags, [tag1]) + end + end end diff --git a/spec/components/distributed_mutex_spec.rb b/spec/components/distributed_mutex_spec.rb index e990e65f3e..97c3d0096c 100644 --- a/spec/components/distributed_mutex_spec.rb +++ b/spec/components/distributed_mutex_spec.rb @@ -42,7 +42,13 @@ describe DistributedMutex do expect(Time.now.to_i).to be <= start + 1 end - it 'allows the validity of the lock to be configured' do + # expected: 1574200319 + # got: 1574200320 + # + # (compared using ==) + # ./spec/components/distributed_mutex_spec.rb:60:in `block (3 levels) in
' + # ./lib/distributed_mutex.rb:33:in `block in synchronize' + xit 'allows the validity of the lock to be configured' do freeze_time mutex = DistributedMutex.new(key, validity: 2) diff --git a/spec/components/email/authentication_results_spec.rb b/spec/components/email/authentication_results_spec.rb new file mode 100644 index 0000000000..8a103c89bd --- /dev/null +++ b/spec/components/email/authentication_results_spec.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +require "rails_helper" +require "email/authentication_results" + +describe Email::AuthenticationResults do + describe "#results" do + it "parses 'Nearly Trivial Case: Service Provided, but No Authentication Done' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.2 + results = described_class.new(" example.org 1; none").results + expect(results[0][:authserv_id]).to eq "example.org" + expect(results[0][:resinfo]).to be nil + end + + it "parses 'Service Provided, Authentication Done' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.3 + results = described_class.new(<<~EOF + example.com; + spf=pass smtp.mailfrom=example.net + EOF + ).results + expect(results[0][:authserv_id]).to eq "example.com" + expect(results[0][:resinfo][0][:method]).to eq "spf" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "smtp" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "mailfrom" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "example.net" + end + + it "parses 'Service Provided, Several Authentications Done, Single MTA' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.4 + results = described_class.new([<<~EOF , + example.com; + auth=pass (cram-md5) smtp.auth=sender@example.net; + spf=pass smtp.mailfrom=example.net + EOF + <<~EOF , + example.com; iprev=pass + policy.iprev=192.0.2.200 + EOF + ]).results + expect(results[0][:authserv_id]).to eq "example.com" + expect(results[0][:resinfo][0][:method]).to eq "auth" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "smtp" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "auth" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "sender@example.net" + expect(results[0][:resinfo][1][:method]).to eq "spf" + expect(results[0][:resinfo][1][:result]).to eq "pass" + expect(results[0][:resinfo][1][:reason]).to be nil + expect(results[0][:resinfo][1][:props][0][:ptype]).to eq "smtp" + expect(results[0][:resinfo][1][:props][0][:property]).to eq "mailfrom" + expect(results[0][:resinfo][1][:props][0][:pvalue]).to eq "example.net" + expect(results[1][:authserv_id]).to eq "example.com" + expect(results[1][:resinfo][0][:method]).to eq "iprev" + expect(results[1][:resinfo][0][:result]).to eq "pass" + expect(results[1][:resinfo][0][:reason]).to be nil + expect(results[1][:resinfo][0][:props][0][:ptype]).to eq "policy" + expect(results[1][:resinfo][0][:props][0][:property]).to eq "iprev" + expect(results[1][:resinfo][0][:props][0][:pvalue]).to eq "192.0.2.200" + end + + it "parses 'Service Provided, Several Authentications Done, Different MTAs' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.5 + results = described_class.new([<<~EOF , + example.com; + dkim=pass (good signature) header.d=example.com + EOF + <<~EOF , + example.com; + auth=pass (cram-md5) smtp.auth=sender@example.com; + spf=fail smtp.mailfrom=example.com + EOF + ]).results + + expect(results[0][:authserv_id]).to eq "example.com" + expect(results[0][:resinfo][0][:method]).to eq "dkim" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "header" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "d" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "example.com" + expect(results[1][:authserv_id]).to eq "example.com" + expect(results[1][:resinfo][0][:method]).to eq "auth" + expect(results[1][:resinfo][0][:result]).to eq "pass" + expect(results[1][:resinfo][0][:reason]).to be nil + expect(results[1][:resinfo][0][:props][0][:ptype]).to eq "smtp" + expect(results[1][:resinfo][0][:props][0][:property]).to eq "auth" + expect(results[1][:resinfo][0][:props][0][:pvalue]).to eq "sender@example.com" + expect(results[1][:resinfo][1][:method]).to eq "spf" + expect(results[1][:resinfo][1][:result]).to eq "fail" + expect(results[1][:resinfo][1][:reason]).to be nil + expect(results[1][:resinfo][1][:props][0][:ptype]).to eq "smtp" + expect(results[1][:resinfo][1][:props][0][:property]).to eq "mailfrom" + expect(results[1][:resinfo][1][:props][0][:pvalue]).to eq "example.com" + end + + it "parses 'Service Provided, Multi-tiered Authentication Done' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.6 + results = described_class.new([<<~EOF , + example.com; + dkim=pass reason="good signature" + header.i=@mail-router.example.net; + dkim=fail reason="bad signature" + header.i=@newyork.example.com + EOF + <<~EOF , + example.net; + dkim=pass (good signature) header.i=@newyork.example.com + EOF + ]).results + + expect(results[0][:authserv_id]).to eq "example.com" + expect(results[0][:resinfo][0][:method]).to eq "dkim" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to eq "good signature" + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "header" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "i" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "@mail-router.example.net" + expect(results[0][:resinfo][1][:method]).to eq "dkim" + expect(results[0][:resinfo][1][:result]).to eq "fail" + expect(results[0][:resinfo][1][:reason]).to eq "bad signature" + expect(results[0][:resinfo][1][:props][0][:ptype]).to eq "header" + expect(results[0][:resinfo][1][:props][0][:property]).to eq "i" + expect(results[0][:resinfo][1][:props][0][:pvalue]).to eq "@newyork.example.com" + expect(results[1][:authserv_id]).to eq "example.net" + expect(results[1][:resinfo][0][:method]).to eq "dkim" + expect(results[1][:resinfo][0][:result]).to eq "pass" + expect(results[1][:resinfo][0][:reason]).to be nil + expect(results[1][:resinfo][0][:props][0][:ptype]).to eq "header" + expect(results[1][:resinfo][0][:props][0][:property]).to eq "i" + expect(results[1][:resinfo][0][:props][0][:pvalue]).to eq "@newyork.example.com" + end + + it "parses 'Comment-Heavy Example' correctly" do + # https://tools.ietf.org/html/rfc8601#appendix-B.7 + results = described_class.new(<<~EOF + foo.example.net (foobar) 1 (baz); + dkim (Because I like it) / 1 (One yay) = (wait for it) fail + policy (A dot can go here) . (like that) expired + (this surprised me) = (as I wasn't expecting it) 1362471462 + EOF + ).results + + expect(results[0][:authserv_id]).to eq "foo.example.net" + expect(results[0][:resinfo][0][:method]).to eq "dkim" + expect(results[0][:resinfo][0][:result]).to eq "fail" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "policy" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "expired" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "1362471462" + end + + it "parses header with no props correctly" do + results = described_class.new(" example.com; dmarc=pass").results + expect(results[0][:authserv_id]).to eq "example.com" + expect(results[0][:resinfo][0][:method]).to eq "dmarc" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props]).to eq [] + end + + it "parses header with multiple props correctly" do + results = described_class.new(<<~EOF + mx.google.com; + dkim=pass header.i=@email.example.com header.s=20111006 header.b=URn9MW+F; + spf=pass (google.com: domain of foo@b.email.example.com designates 1.2.3.4 as permitted sender) smtp.mailfrom=foo@b.email.example.com; + dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=email.example.com + EOF + ).results + + expect(results[0][:authserv_id]).to eq "mx.google.com" + expect(results[0][:resinfo][0][:method]).to eq "dkim" + expect(results[0][:resinfo][0][:result]).to eq "pass" + expect(results[0][:resinfo][0][:reason]).to be nil + expect(results[0][:resinfo][0][:props][0][:ptype]).to eq "header" + expect(results[0][:resinfo][0][:props][0][:property]).to eq "i" + expect(results[0][:resinfo][0][:props][0][:pvalue]).to eq "@email.example.com" + expect(results[0][:resinfo][0][:props][1][:ptype]).to eq "header" + expect(results[0][:resinfo][0][:props][1][:property]).to eq "s" + expect(results[0][:resinfo][0][:props][1][:pvalue]).to eq "20111006" + expect(results[0][:resinfo][0][:props][2][:ptype]).to eq "header" + expect(results[0][:resinfo][0][:props][2][:property]).to eq "b" + expect(results[0][:resinfo][0][:props][2][:pvalue]).to eq "URn9MW+F" + expect(results[0][:resinfo][1][:method]).to eq "spf" + expect(results[0][:resinfo][1][:result]).to eq "pass" + expect(results[0][:resinfo][1][:reason]).to be nil + expect(results[0][:resinfo][1][:props][0][:ptype]).to eq "smtp" + expect(results[0][:resinfo][1][:props][0][:property]).to eq "mailfrom" + expect(results[0][:resinfo][1][:props][0][:pvalue]).to eq "foo@b.email.example.com" + expect(results[0][:resinfo][2][:method]).to eq "dmarc" + expect(results[0][:resinfo][2][:result]).to eq "pass" + expect(results[0][:resinfo][2][:reason]).to be nil + expect(results[0][:resinfo][2][:props][0][:ptype]).to eq "header" + expect(results[0][:resinfo][2][:props][0][:property]).to eq "from" + expect(results[0][:resinfo][2][:props][0][:pvalue]).to eq "email.example.com" + end + end + + describe "#verdict" do + before do + SiteSetting.email_in_authserv_id = "valid.com" + end + + shared_examples "is verdict" do |verdict| + it "is #{verdict}" do + expect(described_class.new(headers).verdict).to eq verdict + end + end + + context "with no authentication-results headers" do + let(:headers) { "" } + + it "is gray" do + expect(described_class.new(headers).verdict).to eq :gray + end + end + + context "with a single authentication-results header" do + context "with a valid fail" do + let(:headers) { "valid.com; dmarc=fail" } + include_examples "is verdict", :fail + end + + context "with a valid pass" do + let(:headers) { "valid.com; dmarc=pass" } + include_examples "is verdict", :pass + end + + context "with a valid error" do + let(:headers) { "valid.com; dmarc=error" } + include_examples "is verdict", :gray + end + + context "with no email_in_authserv_id set" do + before { SiteSetting.email_in_authserv_id = "" } + + context "with a fail" do + let(:headers) { "foobar.com; dmarc=fail" } + include_examples "is verdict", :fail + end + + context "with a pass" do + let(:headers) { "foobar.com; dmarc=pass" } + include_examples "is verdict", :gray + end + end + end + + context "with multiple authentication-results headers" do + context "with a valid fail, and an invalid pass" do + let(:headers) { ["valid.com; dmarc=fail", "invalid.com; dmarc=pass"] } + include_examples "is verdict", :fail + end + + context "with a valid fail, and a valid pass" do + let(:headers) { ["valid.com; dmarc=fail", "valid.com; dmarc=pass"] } + include_examples "is verdict", :fail + end + + context "with a valid error, and a valid pass" do + let(:headers) { ["valid.com; dmarc=foobar", "valid.com; dmarc=pass"] } + include_examples "is verdict", :pass + end + + context "with no email_in_authserv_id set" do + before { SiteSetting.email_in_authserv_id = "" } + + context "with an error, and a pass" do + let(:headers) { ["foobar.com; dmarc=foobar", "foobar.com; dmarc=pass"] } + include_examples "is verdict", :gray + end + end + end + end + + describe "#action" do + it "hides a fail verdict" do + results = described_class.new("") + results.expects(:verdict).returns(:fail) + expect(results.action).to eq (:hide) + end + + it "accepts a pass verdict" do + results = described_class.new("") + results.expects(:verdict).returns(:pass) + expect(results.action).to eq (:accept) + end + + it "accepts a gray verdict" do + results = described_class.new("") + results.expects(:verdict).returns(:gray) + expect(results.action).to eq (:accept) + end + end + +end diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 811fc3839c..400f72385c 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -1021,6 +1021,19 @@ describe Email::Receiver do expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:email_spam_header_found]) end + it "creates hidden topic for failed Authentication-Results header" do + Fabricate(:user, email: "existing@bar.com", trust_level: SiteSetting.email_in_min_trust) + expect { process(:dmarc_fail) }.to change { Topic.count }.by(1) # Topic created + + topic = Topic.last + expect(topic.visible).to eq(false) + + post = Post.last + expect(post.hidden).to eq(true) + expect(post.hidden_at).not_to eq(nil) + expect(post.hidden_reason_id).to eq(Post.hidden_reasons[:email_authentication_result_header]) + end + it "adds the 'elided' part of the original message when always_show_trimmed_content is enabled" do SiteSetting.always_show_trimmed_content = true diff --git a/spec/components/email/styles_spec.rb b/spec/components/email/styles_spec.rb index 2d4702f51a..a6f9e28505 100644 --- a/spec/components/email/styles_spec.rb +++ b/spec/components/email/styles_spec.rb @@ -199,4 +199,19 @@ describe Email::Styles do end end + context "replace_relative_urls" do + it "replaces secure media within a link with a placeholder" do + frag = html_fragment("") + expect(frag.at('p.secure-media-notice')).to be_present + expect(frag.at('img')).not_to be_present + expect(frag.at('a')).not_to be_present + end + + it "replaces secure images with a placeholder" do + frag = html_fragment("") + expect(frag.at('p.secure-media-notice')).to be_present + expect(frag.at('img')).not_to be_present + end + end + end diff --git a/spec/components/file_store/base_store_spec.rb b/spec/components/file_store/base_store_spec.rb index f633b1e455..13d0c36f8a 100644 --- a/spec/components/file_store/base_store_spec.rb +++ b/spec/components/file_store/base_store_spec.rb @@ -86,5 +86,18 @@ RSpec.describe FileStore::BaseStore do expect(file.class).to eq(File) end + + it "should return the file when secure media are enabled" do + SiteSetting.login_required = true + SiteSetting.secure_media = true + + stub_request(:head, "https://s3-upload-bucket.s3.amazonaws.com/") + signed_url = Discourse.store.signed_url_for_path(upload_s3.url) + stub_request(:get, signed_url).to_return(status: 200, body: "Hello world") + + file = store.download(upload_s3) + + expect(file.class).to eq(File) + end end end diff --git a/spec/components/file_store/local_store_spec.rb b/spec/components/file_store/local_store_spec.rb index 5baf22a9eb..680deb2204 100644 --- a/spec/components/file_store/local_store_spec.rb +++ b/spec/components/file_store/local_store_spec.rb @@ -114,11 +114,6 @@ describe FileStore::LocalStore do end - def stub_for_subfolder - GlobalSetting.stubs(:relative_url_root).returns('/forum') - Discourse.stubs(:base_uri).returns("/forum") - end - describe "#absolute_base_url" do it "is present" do @@ -126,7 +121,7 @@ describe FileStore::LocalStore do end it "supports subfolder" do - stub_for_subfolder + set_subfolder "/forum" expect(store.absolute_base_url).to eq("http://test.localhost/forum/uploads/default") end @@ -139,7 +134,7 @@ describe FileStore::LocalStore do end it "supports subfolder" do - stub_for_subfolder + set_subfolder "/forum" expect(store.relative_base_url).to eq("/forum/uploads/default") end diff --git a/spec/components/file_store/s3_store_spec.rb b/spec/components/file_store/s3_store_spec.rb index bb73b9aaf3..99f57c7104 100644 --- a/spec/components/file_store/s3_store_spec.rb +++ b/spec/components/file_store/s3_store_spec.rb @@ -43,16 +43,16 @@ describe FileStore::S3Store do let(:s3_object) { stub } let(:etag) { "etag" } - before do - s3_object.stubs(:put).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) - end - describe "#store_upload" do it "returns an absolute schemaless url" do store.expects(:get_depth_for).with(upload.id).returns(0) s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once - s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object) + s3_object.expects(:put).with( + acl: "public-read", + cache_control: "max-age=31556952, public, immutable", + content_type: "image/png", + body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to eq( "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png" @@ -62,6 +62,7 @@ describe FileStore::S3Store do describe "when s3_upload_bucket includes folders path" do before do + s3_object.stubs(:put).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads" end @@ -78,28 +79,36 @@ describe FileStore::S3Store do end end - describe "when private uploads are enabled" do - it "returns signed URL for eligible private upload" do + describe "when secure uploads are enabled" do + it "saves secure attachment using private ACL" do SiteSetting.prevent_anons_from_downloading_files = true SiteSetting.authorized_extensions = "pdf|png|jpg|gif" - upload.update!(original_filename: "small.pdf", extension: "pdf") + upload.update!(original_filename: "small.pdf", extension: "pdf", secure: true) - s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once - s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.pdf").returns(s3_object).at_least_once - s3_object.expects(:presigned_url).with(:get, expires_in: S3Helper::DOWNLOAD_URL_EXPIRES_AFTER_SECONDS) + s3_helper.expects(:s3_bucket).returns(s3_bucket) + s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.pdf").returns(s3_object) + s3_object.expects(:put).with( + acl: "private", + cache_control: "max-age=31556952, public, immutable", + content_type: "application/pdf", + content_disposition: "attachment; filename=\"#{upload.original_filename}\"", + body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to eq( "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/original/1X/#{upload.sha1}.pdf" ) - - expect(store.url_for(upload)).not_to eq(upload.url) end - it "returns regular URL for ineligible private upload" do + it "saves image upload using public ACL" do SiteSetting.prevent_anons_from_downloading_files = true s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object).at_least_once + s3_object.expects(:put).with( + acl: "public-read", + cache_control: "max-age=31556952, public, immutable", + content_type: "image/png", + body: uploaded_file).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) expect(store.store_upload(uploaded_file, upload)).to eq( "//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png" @@ -111,6 +120,10 @@ describe FileStore::S3Store do end describe "#store_optimized_image" do + before do + s3_object.stubs(:put).returns(Aws::S3::Types::PutObjectOutput.new(etag: "\"#{etag}\"")) + end + it "returns an absolute schemaless url" do store.expects(:get_depth_for).with(optimized_image.upload.id).returns(0) s3_helper.expects(:s3_bucket).returns(s3_bucket) @@ -355,23 +368,27 @@ describe FileStore::S3Store do include_context "s3 helpers" let(:s3_object) { stub } + before do + SiteSetting.authorized_extensions = "pdf|png" + end + describe ".update_upload_ACL" do - it "sets acl to private when private uploads are enabled" do - SiteSetting.prevent_anons_from_downloading_files = true + it "sets acl to public by default" do + upload.update!(original_filename: "small.pdf", extension: "pdf") s3_helper.expects(:s3_bucket).returns(s3_bucket) - s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object) + s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.pdf").returns(s3_object) s3_object.expects(:acl).returns(s3_object) - s3_object.expects(:put).with(acl: "private").returns(s3_object) + s3_object.expects(:put).with(acl: "public-read").returns(s3_object) expect(store.update_upload_ACL(upload)).to be_truthy end - it "sets acl to public when private uploads are disabled" do - SiteSetting.prevent_anons_from_downloading_files = false + it "sets acl to private when upload is marked secure" do + upload.update!(original_filename: "small.pdf", extension: "pdf", secure: true) s3_helper.expects(:s3_bucket).returns(s3_bucket) - s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object) + s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.pdf").returns(s3_object) s3_object.expects(:acl).returns(s3_object) - s3_object.expects(:put).with(acl: "public-read").returns(s3_object) + s3_object.expects(:put).with(acl: "private").returns(s3_object) expect(store.update_upload_ACL(upload)).to be_truthy end @@ -386,8 +403,7 @@ describe FileStore::S3Store do # none of this should matter at all # subfolder should not leak into uploads - global_setting :relative_url_root, '/community' - Discourse.stubs(:base_uri).returns("/community") + set_subfolder "/community" url = "//s3-upload-bucket.s3.dualstack.us-east-1.amazonaws.com/livechat/original/gif.png" @@ -422,4 +438,21 @@ describe FileStore::S3Store do end end + describe ".signed_url_for_path" do + include_context "s3 helpers" + let(:s3_object) { stub } + + it "returns signed URL for a given path" do + s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once + s3_bucket.expects(:object).with("special/optimized/file.png").returns(s3_object) + opts = { + expires_in: S3Helper::DOWNLOAD_URL_EXPIRES_AFTER_SECONDS + } + + s3_object.expects(:presigned_url).with(:get, opts) + + expect(store.signed_url_for_path("special/optimized/file.png")).not_to eq(upload.url) + end + end + end diff --git a/spec/components/freedom_patches/schema_migration_details_spec.rb b/spec/components/freedom_patches/schema_migration_details_spec.rb index 02e0a898c3..05a7a0d9a6 100644 --- a/spec/components/freedom_patches/schema_migration_details_spec.rb +++ b/spec/components/freedom_patches/schema_migration_details_spec.rb @@ -15,13 +15,13 @@ describe FreedomPatches::SchemaMigrationDetails do end it "logs information on migration" do - migration = TestMigration.new("awesome_migration", "20160225050318") + migration = TestMigration.new("awesome_migration", "20110225050318") ActiveRecord::Base.connection_pool.with_connection do |conn| migration.exec_migration(conn, :up) end - info = SchemaMigrationDetail.find_by(version: "20160225050318") + info = SchemaMigrationDetail.find_by(version: "20110225050318") expect(info.duration).to be > 0 expect(info.git_version).to eq Discourse.git_version diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb index 34a2eae70e..46ef188bcc 100644 --- a/spec/components/guardian_spec.rb +++ b/spec/components/guardian_spec.rb @@ -2890,7 +2890,7 @@ describe Guardian do expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(false) theme2.update!(user_selectable: false, component: true) - theme.add_child_theme!(theme2) + theme.add_relative_theme!(:child, theme2) expect(user_guardian.allow_themes?([theme.id, theme2.id])).to eq(true) expect(user_guardian.allow_themes?([theme2.id])).to eq(false) end diff --git a/spec/components/middleware/discourse_public_exceptions_spec.rb b/spec/components/middleware/discourse_public_exceptions_spec.rb new file mode 100644 index 0000000000..ac1a0cc6ce --- /dev/null +++ b/spec/components/middleware/discourse_public_exceptions_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Middleware::DiscoursePublicExceptions do + before do + @orig_logger = Rails.logger + Rails.logger = @fake_logger = FakeLogger.new + end + + after do + Rails.logger = @orig_logger + end + + def env(opts = {}) + { + "HTTP_HOST" => "http://test.com", + "REQUEST_URI" => "/path?bla=1", + "REQUEST_METHOD" => "GET", + "rack.input" => "" + }.merge(opts) + end + + it "should not log for invalid mime type requests" do + ex = Middleware::DiscoursePublicExceptions.new("/test") + + ex.call(env( + "HTTP_ACCEPT" => "../broken../", + "action_dispatch.exception" => ActionController::RoutingError.new("abc") + )) + + expect(@fake_logger.warnings.length).to eq(0) + end + +end diff --git a/spec/components/middleware/request_tracker_spec.rb b/spec/components/middleware/request_tracker_spec.rb index 98a7581c38..8faeaf079e 100644 --- a/spec/components/middleware/request_tracker_spec.rb +++ b/spec/components/middleware/request_tracker_spec.rb @@ -129,7 +129,8 @@ describe Middleware::RequestTracker do Middleware::RequestTracker.new(app) end - it "does nothing by default" do + it "does nothing if configured to do nothing" do + global_setting :max_reqs_per_ip_mode, "none" global_setting :max_reqs_per_ip_per_10_seconds, 1 status, _ = middleware.call(env) diff --git a/spec/components/oneboxer_spec.rb b/spec/components/oneboxer_spec.rb index f90eb5ba75..c04287ecb9 100644 --- a/spec/components/oneboxer_spec.rb +++ b/spec/components/oneboxer_spec.rb @@ -12,6 +12,17 @@ describe Oneboxer do expect(Oneboxer.onebox("http://boom.com")).to eq("") end + describe "#invalidate" do + let(:url) { "http://test.com" } + it "clears the cached preview for the onebox URL and the failed URL cache" do + Discourse.cache.write(Oneboxer.onebox_cache_key(url), "test") + Discourse.cache.write(Oneboxer.onebox_failed_cache_key(url), true) + Oneboxer.invalidate(url) + expect(Discourse.cache.read(Oneboxer.onebox_cache_key(url))).to eq(nil) + expect(Discourse.cache.read(Oneboxer.onebox_failed_cache_key(url))).to eq(nil) + end + end + context "local oneboxes" do def link(url) diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index 7286f5cc0a..50064c3ad8 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -32,7 +32,10 @@ describe Plugin::Instance do context "with a plugin that extends things" do - class Trout; end + class Trout + attr_accessor :data + end + class TroutSerializer < ApplicationSerializer attribute :name @@ -90,7 +93,6 @@ describe Plugin::Instance do end it "checks enabled/disabled functionality for extensions" do - # with an enabled plugin @plugin.enabled = true expect(@trout.status?).to eq("evil") @@ -114,6 +116,17 @@ describe Plugin::Instance do expect(@child_serializer.include_scales?).to eq(false) expect(@child_serializer.name).to eq("a trout jr") end + + it "only returns HTML if enabled" do + ctx = Trout.new + ctx.data = "hello" + + @plugin.register_html_builder('test:html') { |c| "
#{c.data}
" } + @plugin.enabled = false + expect(DiscoursePluginRegistry.build_html('test:html', ctx)).to eq("") + @plugin.enabled = true + expect(DiscoursePluginRegistry.build_html('test:html', ctx)).to eq("
hello
") + end end end diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index 9dcb712723..547b38736d 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -1358,10 +1358,10 @@ describe PostCreator do it "generates post notices for new users" do post = PostCreator.create!(user, title: "one of my first topics", raw: "one of my first posts") - expect(post.custom_fields["notice_type"]).to eq("new_user") + expect(post.custom_fields[Post::NOTICE_TYPE]).to eq(Post.notices[:new_user]) post = PostCreator.create!(user, title: "another one of my first topics", raw: "another one of my first posts") - expect(post.custom_fields["notice_type"]).to eq(nil) + expect(post.custom_fields[Post::NOTICE_TYPE]).to eq(nil) end it "generates post notices for returning users" do @@ -1369,12 +1369,12 @@ describe PostCreator do old_post = Fabricate(:post, user: user, created_at: 31.days.ago) post = PostCreator.create!(user, title: "this is a returning topic", raw: "this is a post") - expect(post.custom_fields["notice_type"]).to eq(Post.notices[:returning_user]) - expect(post.custom_fields["notice_args"]).to eq(old_post.created_at.iso8601) + expect(post.custom_fields[Post::NOTICE_TYPE]).to eq(Post.notices[:returning_user]) + expect(post.custom_fields[Post::NOTICE_ARGS]).to eq(old_post.created_at.iso8601) post = PostCreator.create!(user, title: "this is another topic", raw: "this is my another post") - expect(post.custom_fields["notice_type"]).to eq(nil) - expect(post.custom_fields["notice_args"]).to eq(nil) + expect(post.custom_fields[Post::NOTICE_TYPE]).to eq(nil) + expect(post.custom_fields[Post::NOTICE_ARGS]).to eq(nil) end it "does not generate for non-human, staged or anonymous users" do @@ -1383,9 +1383,80 @@ describe PostCreator do [anonymous, Discourse.system_user, staged].each do |user| expect(user.posts.size).to eq(0) post = PostCreator.create!(user, title: "#{user.username}'s first topic", raw: "#{user.name}'s first post") - expect(post.custom_fields["notice_type"]).to eq(nil) - expect(post.custom_fields["notice_args"]).to eq(nil) + expect(post.custom_fields[Post::NOTICE_TYPE]).to eq(nil) + expect(post.custom_fields[Post::NOTICE_ARGS]).to eq(nil) end end end + + context "secure media uploads" do + fab!(:image_upload) { Fabricate(:upload, secure: true) } + fab!(:user2) { Fabricate(:user) } + fab!(:public_topic) { Fabricate(:topic) } + + before do + SiteSetting.enable_s3_uploads = true + SiteSetting.authorized_extensions = "png|jpg|gif|mp4" + SiteSetting.s3_upload_bucket = "s3-upload-bucket" + SiteSetting.s3_access_key_id = "some key" + SiteSetting.s3_secret_access_key = "some secret key" + SiteSetting.secure_media = true + + stub_request(:head, "https://#{SiteSetting.s3_upload_bucket}.s3.amazonaws.com/") + + stub_request( + :put, + "https://#{SiteSetting.s3_upload_bucket}.s3.amazonaws.com/original/1X/#{image_upload.sha1}.#{image_upload.extension}?acl" + ) + end + + it "does not allow a secure image to be used in a public topic" do + public_post = PostCreator.create( + user, + topic_id: public_topic.id, + raw: "A public post with an image.\n![](#{image_upload.short_path})" + ) + + expect(public_post.errors.count).to be(1) + expect(public_post.errors.full_messages).to include(I18n.t('secure_upload_not_allowed_in_public_topic', upload_filenames: image_upload.original_filename)) + + # secure upload CAN be used in another PM + pm = PostCreator.create( + user, + title: 'this is another private message', + raw: "with an upload: \n![](#{image_upload.short_path})", + archetype: Archetype.private_message, + target_usernames: [user2.username].join(',') + ) + + expect(pm.errors).to be_blank + end + + it "does not allow a secure video to be used in a public topic" do + video_upload = Fabricate(:upload_s3, extension: 'mp4', original_filename: "video.mp4", secure: true) + + public_post = PostCreator.create( + user, + topic_id: public_topic.id, + raw: "A public post with a video onebox:\n#{video_upload.url}" + ) + + expect(public_post.errors.count).to be(1) + expect(public_post.errors.full_messages).to include(I18n.t('secure_upload_not_allowed_in_public_topic', upload_filenames: video_upload.original_filename)) + end + + it "allows an existing upload to be used again in nonPM topics in login_required sites" do + SiteSetting.login_required = true + + public_post = PostCreator.create( + user, + topic_id: public_topic.id, + raw: "Reusing this image on a public topic in a login_required site:\n![](#{image_upload.short_path})" + ) + + expect(public_post.errors.count).to be(0) + end + + end + end diff --git a/spec/components/post_destroyer_spec.rb b/spec/components/post_destroyer_spec.rb index 76c1fd9220..52c7a18b6b 100644 --- a/spec/components/post_destroyer_spec.rb +++ b/spec/components/post_destroyer_spec.rb @@ -798,4 +798,28 @@ describe PostDestroyer do end end + describe '#delete_with_replies' do + let(:reporter) { Discourse.system_user } + fab!(:post) { Fabricate(:post) } + + before do + reply = Fabricate(:post, topic: post.topic) + post.update(replies: [reply]) + PostActionCreator.off_topic(reporter, post) + + @reviewable_reply = PostActionCreator.off_topic(reporter, reply).reviewable + end + + it 'ignores flagged replies' do + PostDestroyer.delete_with_replies(reporter, post) + + expect(@reviewable_reply.reload.status).to eq Reviewable.statuses[:ignored] + end + + it 'approves flagged replies' do + PostDestroyer.delete_with_replies(reporter, post, defer_reply_flags: false) + + expect(@reviewable_reply.reload.status).to eq Reviewable.statuses[:approved] + end + end end diff --git a/spec/components/post_revisor_spec.rb b/spec/components/post_revisor_spec.rb index 08e3ecb353..cf008c3056 100644 --- a/spec/components/post_revisor_spec.rb +++ b/spec/components/post_revisor_spec.rb @@ -198,6 +198,35 @@ describe PostRevisor do end end + describe 'edit reasons' do + it "does create a new version if an edit reason is provided" do + post = Fabricate(:post, raw: 'hello world') + revisor = PostRevisor.new(post) + revisor.revise!(post.user, { raw: 'hello world123456789', edit_reason: 'this is my reason' }, revised_at: post.updated_at + 1.second) + post.reload + expect(post.version).to eq(2) + expect(post.revisions.count).to eq(1) + end + + it "does not create a new version if an edit reason is provided and its the same as the current edit reason" do + post = Fabricate(:post, raw: 'hello world', edit_reason: 'this is my reason') + revisor = PostRevisor.new(post) + revisor.revise!(post.user, { raw: 'hello world123456789', edit_reason: 'this is my reason' }, revised_at: post.updated_at + 1.second) + post.reload + expect(post.version).to eq(1) + expect(post.revisions.count).to eq(0) + end + + it "does not clobber the existing edit reason for a revision if it is not provided in a subsequent revision" do + post = Fabricate(:post, raw: 'hello world') + revisor = PostRevisor.new(post) + revisor.revise!(post.user, { raw: 'hello world123456789', edit_reason: 'this is my reason' }, revised_at: post.updated_at + 1.second) + post.reload + revisor.revise!(post.user, { raw: 'hello some other thing' }, revised_at: post.updated_at + 1.second) + expect(post.revisions.first.modifications[:edit_reason]).to eq([nil, 'this is my reason']) + end + end + describe 'revision much later' do let!(:revised_at) { post.updated_at + 2.minutes } diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index a0b547a03a..794c787b5a 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -269,12 +269,8 @@ describe PrettyText do fab!(:user) { Fabricate(:user) } context "subfolder" do - before do - GlobalSetting.stubs(:relative_url_root).returns("/forum") - Discourse.stubs(:base_uri).returns("/forum") - end - it "should have correct avatar url" do + set_subfolder "/forum" md = <<~MD [quote="#{user.username}, post:123, topic:456, full:true"] ddd @@ -331,12 +327,9 @@ describe PrettyText do end context 'subfolder' do - before do - GlobalSetting.stubs(:relative_url_root).returns('/forum') - Discourse.stubs(:base_uri).returns("/forum") - end - it "handles user and group mentions correctly" do + set_subfolder "/forum" + Fabricate(:user, username: 'user1') Fabricate(:group, name: 'groupA', mentionable_level: Group::ALIAS_LEVELS[:everyone]) @@ -817,6 +810,50 @@ describe PrettyText do html = "

Check out this video – .

" expect(PrettyText.format_for_email(html, post)).to match(Regexp.escape("https://vimeo.com/329875646/%3E%20%3Cscript%3Ealert(1)%3C/script%3E")) end + + describe "#strip_secure_media" do + before do + SiteSetting.s3_upload_bucket = "some-bucket-on-s3" + SiteSetting.s3_access_key_id = "s3-access-key-id" + SiteSetting.s3_secret_access_key = "s3-secret-access-key" + SiteSetting.s3_cdn_url = "https://s3.cdn.com" + SiteSetting.enable_s3_uploads = true + SiteSetting.secure_media = true + SiteSetting.login_required = true + end + + it "replaces secure video content" do + html = <<~HTML + + HTML + + md = PrettyText.format_for_email(html, post) + + expect(md).not_to include(' + + Audio label + + + HTML + + md = PrettyText.format_for_email(html, post) + + expect(md).not_to include(' +From: Foo Bar +To: category@bar.com +Subject: This is a topic from an existing user +Date: Fri, 15 Jan 2016 00:12:43 +0100 +Message-ID: <32@foo.bar.mail> +Mime-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable +Authentication-Results: example.com; dmarc=fail + +Hey, this is a topic from an existing user ;) diff --git a/spec/fixtures/json/import-export.json b/spec/fixtures/json/import-export.json index bae4e2b7bf..aaf292a1c2 100644 --- a/spec/fixtures/json/import-export.json +++ b/spec/fixtures/json/import-export.json @@ -4,12 +4,12 @@ {"id":42,"name":"custom_group_import","created_at":"2017-10-26T15:33:46.328Z","mentionable_level":0,"messageable_level":0,"visibility_level":0,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":false,"title":null,"grant_trust_level":null,"incoming_email":null,"user_ids":[2]} ], "categories":[ - {"id":8,"name":"Custom Category","color":"0088CC","created_at":"2017-10-26T15:32:44.083Z","user_id":1,"slug":"custom-category","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":3,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group":1,"everyone":2}}, - {"id":10,"name":"Site Feedback Import","color":"27AA5B","created_at":"2017-10-26T17:12:39.995Z","user_id":-1,"slug":"site-feedback-import","description":"Discussion about this site, its organization, how it works, and how we can improve it.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{}}, - {"id":11,"name":"Uncategorized Import","color":"0088CC","created_at":"2017-10-26T17:12:32.359Z","user_id":-1,"slug":"uncategorized-import","description":"","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{}}, - {"id":12,"name":"Lounge Import","color":"A461EF","created_at":"2017-10-26T17:12:39.490Z","user_id":-1,"slug":"lounge-import","description":"A category exclusive to members with trust level 3 and higher.","text_color":"652D90","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"trust_level_3":1}}, - {"id":13,"name":"Staff Import","color":"E45735","created_at":"2017-10-26T17:12:42.806Z","user_id":2,"slug":"staff-import","description":"Private category for staff discussions. Topics are only visible to admins and moderators.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"staff":1}}, - {"id":15,"name":"Custom Category Import","color":"0088CC","created_at":"2017-10-26T15:32:44.083Z","user_id":2,"slug":"custom-category-import","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":10,"auto_close_based_on_last_post":false,"topic_template":"","suppress_from_latest":false,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"everyone":2}} + {"id":8,"name":"Custom Category","color":"0088CC","created_at":"2017-10-26T15:32:44.083Z","user_id":1,"slug":"custom-category","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":3,"auto_close_based_on_last_post":false,"topic_template":"","all_topics_wiki":false,"permissions_params":{"custom_group":1,"everyone":2}}, + {"id":10,"name":"Site Feedback Import","color":"27AA5B","created_at":"2017-10-26T17:12:39.995Z","user_id":-1,"slug":"site-feedback-import","description":"Discussion about this site, its organization, how it works, and how we can improve it.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"all_topics_wiki":false,"permissions_params":{}}, + {"id":11,"name":"Uncategorized Import","color":"0088CC","created_at":"2017-10-26T17:12:32.359Z","user_id":-1,"slug":"uncategorized-import","description":"","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"all_topics_wiki":false,"permissions_params":{}}, + {"id":12,"name":"Lounge Import","color":"A461EF","created_at":"2017-10-26T17:12:39.490Z","user_id":-1,"slug":"lounge-import","description":"A category exclusive to members with trust level 3 and higher.","text_color":"652D90","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"all_topics_wiki":false,"permissions_params":{"trust_level_3":1}}, + {"id":13,"name":"Staff Import","color":"E45735","created_at":"2017-10-26T17:12:42.806Z","user_id":2,"slug":"staff-import","description":"Private category for staff discussions. Topics are only visible to admins and moderators.","text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":null,"auto_close_based_on_last_post":false,"topic_template":null,"all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"staff":1}}, + {"id":15,"name":"Custom Category Import","color":"0088CC","created_at":"2017-10-26T15:32:44.083Z","user_id":2,"slug":"custom-category-import","description":null,"text_color":"FFFFFF","auto_close_hours":null,"parent_category_id":10,"auto_close_based_on_last_post":false,"topic_template":"","all_topics_wiki":false,"permissions_params":{"custom_group_import":1,"everyone":2}} ], "users":[ {"id":1,"email":"email@example.com","username":"example","name":"Example","created_at":"2017-10-07T15:01:24.597Z","trust_level":4,"active":true,"last_emailed_at":null}, diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index c03af37509..a268bbe704 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -31,19 +31,15 @@ describe ApplicationHelper do global_setting :s3_cdn_url, 'https://s3cdn.com' end - after do - ActionController::Base.config.relative_url_root = nil - end - it "deals correctly with subfolder" do - ActionController::Base.config.relative_url_root = "/community" + set_subfolder "/community" expect(helper.preload_script("application")).to include('https://s3cdn.com/assets/application.js') end it "replaces cdn URLs with s3 cdn subfolder paths" do global_setting :s3_cdn_url, 'https://s3cdn.com/s3_subpath' set_cdn_url "https://awesome.com" - ActionController::Base.config.relative_url_root = "/community" + set_subfolder "/community" expect(helper.preload_script("application")).to include('https://s3cdn.com/s3_subpath/assets/application.js') end diff --git a/spec/helpers/user_notifications_helper_spec.rb b/spec/helpers/user_notifications_helper_spec.rb index a0d266e2b1..7610495b25 100644 --- a/spec/helpers/user_notifications_helper_spec.rb +++ b/spec/helpers/user_notifications_helper_spec.rb @@ -13,6 +13,19 @@ describe UserNotificationsHelper do paragraphs.join("\n") end + let(:post_quote) do + <<~HTML + + HTML + end + it "can return the first paragraph" do SiteSetting.digest_min_excerpt_length = 50 expect(helper.email_excerpt(cooked)).to eq(paragraphs[0]) @@ -54,6 +67,24 @@ describe UserNotificationsHelper do expect(helper.email_excerpt(cooked)).to eq "

BEFORE

\n

This is a user quote

\n

AFTER

" end + + it "defaults to content after post quote (image w/ no text)" do + image_paragraph = '

' + cooked = <<~HTML + #{post_quote} + #{image_paragraph} + HTML + expect(helper.email_excerpt(cooked)).to eq(image_paragraph) + end + + it "defaults to content after post quote (onebox)" do + aside_onebox = '' + cooked = <<~HTML + #{post_quote} + #{aside_onebox} + HTML + expect(helper.email_excerpt(cooked)).to eq(aside_onebox) + end end describe '#logo_url' do diff --git a/spec/integration/category_tag_spec.rb b/spec/integration/category_tag_spec.rb index 60ea91bbb3..521a389723 100644 --- a/spec/integration/category_tag_spec.rb +++ b/spec/integration/category_tag_spec.rb @@ -5,18 +5,15 @@ require 'rails_helper' describe "category tag restrictions" do - def sorted_tag_names(tag_records) - tag_records.map(&:name).sort - end - def filter_allowed_tags(opts = {}) - DiscourseTagging.filter_allowed_tags(Tag.all, Guardian.new(user), opts) + DiscourseTagging.filter_allowed_tags(Guardian.new(user), opts) end fab!(:tag1) { Fabricate(:tag, name: 'tag1') } fab!(:tag2) { Fabricate(:tag, name: 'tag2') } fab!(:tag3) { Fabricate(:tag, name: 'tag3') } fab!(:tag4) { Fabricate(:tag, name: 'tag4') } + let(:tag_with_colon) { Fabricate(:tag, name: 'with:colon') } fab!(:user) { Fabricate(:user) } fab!(:admin) { Fabricate(:admin) } @@ -45,25 +42,30 @@ describe "category tag restrictions" do it "search can show only permitted tags" do expect(filter_allowed_tags.count).to eq(Tag.count) - expect(filter_allowed_tags(for_input: true, category: category_with_tags)).to contain_exactly(tag1, tag2) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name])).to contain_exactly(tag2) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name], term: 'tag')).to contain_exactly(tag2) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name])).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag')).to contain_exactly(tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags), [tag1, tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name]), [tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name], term: 'tag'), [tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name]), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag'), [tag4]) + end + + it "search can handle colons in tag names" do + tag_with_colon + expect_same_tag_names(filter_allowed_tags(for_input: true, term: 'with:c'), [tag_with_colon]) end it "can't create new tags in a restricted category" do post = create_post(category: category_with_tags, tags: [tag1.name, "newtag"]) - expect(post.topic.tags).to contain_exactly(tag1) + expect_same_tag_names(post.topic.tags, [tag1]) post = create_post(category: category_with_tags, tags: [tag1.name, "newtag"], user: admin) - expect(post.topic.tags).to contain_exactly(tag1) + expect_same_tag_names(post.topic.tags, [tag1]) end it "can create new tags in a non-restricted category" do post = create_post(category: other_category, tags: [tag3.name, "newtag"]) - expect(post.topic.tags.map(&:name).sort).to eq([tag3.name, "newtag"].sort) + expect_same_tag_names(post.topic.tags, [tag3.name, "newtag"]) end it "can create tags when changing category settings" do @@ -76,9 +78,9 @@ describe "category tag restrictions" do before { category_with_tags.update!(required_tag_group: tag_group, min_tags_from_required_group: 1) } it "search only returns the allowed tags" do - expect(filter_allowed_tags(for_input: true, category: category_with_tags)).to contain_exactly(tag1) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name])).to contain_exactly(tag2) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag2.name])).to contain_exactly(tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags), [tag1]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name]), [tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag2.name]), [tag1]) end end @@ -89,20 +91,20 @@ describe "category tag restrictions" do it "search can show the permitted tags" do expect(filter_allowed_tags.count).to eq(Tag.count) - expect(filter_allowed_tags(for_input: true, category: category_with_tags)).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name], term: 'tag')).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name])).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag')).to contain_exactly(tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name]), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name], term: 'tag'), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name]), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag'), [tag4]) end it "works if no tags are restricted to the category" do other_category.update!(allow_global_tags: true) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name])).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag')).to contain_exactly(tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name]), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category, selected_tags: [tag3.name], term: 'tag'), [tag4]) end context 'required tags from tag group' do @@ -110,9 +112,9 @@ describe "category tag restrictions" do before { category_with_tags.update!(required_tag_group: tag_group, min_tags_from_required_group: 1) } it "search only returns the allowed tags" do - expect(filter_allowed_tags(for_input: true, category: category_with_tags)).to contain_exactly(tag1, tag3) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag2.name])).to contain_exactly(tag1, tag3) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag1.name]), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category_with_tags, selected_tags: [tag2.name]), [tag1, tag3]) end end end @@ -130,22 +132,22 @@ describe "category tag restrictions" do end it "tags in the group are used by category tag restrictions" do - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag3, tag4]) tag_group1.tags = [tag2, tag3, tag4] - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag1]) end it "groups and individual tags can be mixed" do category.allowed_tags = [tag4.name] category.reload - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag4) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag3) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3]) end it "enforces restrictions when creating a topic" do @@ -153,14 +155,19 @@ describe "category tag restrictions" do expect(post.topic.tags.map(&:name)).to eq([tag1.name]) end + it "handles colons" do + tag_with_colon + expect_same_tag_names(filter_allowed_tags(for_input: true, term: 'with:c'), [tag_with_colon]) + end + context 'required tags from tag group' do fab!(:tag_group) { Fabricate(:tag_group, tags: [tag1, tag3]) } before { category.update!(required_tag_group: tag_group, min_tags_from_required_group: 1) } it "search only returns the allowed tags" do - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name])).to contain_exactly(tag2) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name])).to contain_exactly(tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name]), [tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name]), [tag1]) end end @@ -170,21 +177,21 @@ describe "category tag restrictions" do end it 'filters tags correctly' do - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3, tag4]) tag_group1.tags = [tag2, tag3, tag4] - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag1]) end it "works if no tags are restricted to the category" do other_category.update!(allow_global_tags: true) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag3, tag4]) tag_group1.tags = [tag2, tag3, tag4] - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag1]) end context 'required tags from tag group' do @@ -192,9 +199,9 @@ describe "category tag restrictions" do before { category.update!(required_tag_group: tag_group, min_tags_from_required_group: 1) } it "search only returns the allowed tags" do - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag3) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name])).to contain_exactly(tag1, tag3) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name]), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name]), [tag1, tag3]) end end @@ -209,19 +216,19 @@ describe "category tag restrictions" do end it 'filters tags correctly' do - expect(filter_allowed_tags(for_input: true, category: category2)).to contain_exactly(tag2, tag3) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category2), [tag2, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag4]) end it "doesn't care about tags in a group that isn't used in a category" do unused_tag_group = Fabricate(:tag_group) unused_tag_group.tags = [tag4] - expect(filter_allowed_tags(for_input: true, category: category2)).to contain_exactly(tag2, tag3) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category2), [tag2, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag4]) end end @@ -230,10 +237,10 @@ describe "category tag restrictions" do it "doesn't filter tags that are also restricted in another category" do category2.tags = [tag2, tag3] - expect(filter_allowed_tags(for_input: true, category: category2)).to contain_exactly(tag2, tag3) - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag4) - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category2), [tag2, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2, tag4]) end end end @@ -243,17 +250,17 @@ describe "category tag restrictions" do it "for input field, filter_allowed_tags returns results based on whether parent tag is present or not" do tag_group = Fabricate(:tag_group, parent_tag_id: tag1.id) tag_group.tags = [tag3, tag4] - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag2) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name, tag3.name])).to contain_exactly(tag2, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name]), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name, tag3.name]), [tag2, tag4]) end it "for tagging a topic, filter_allowed_tags allows tags without parent tag" do tag_group = Fabricate(:tag_group, parent_tag_id: tag1.id) tag_group.tags = [tag3, tag4] - expect(filter_allowed_tags(for_topic: true)).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_topic: true, selected_tags: [tag1.name])).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_topic: true, selected_tags: [tag1.name, tag3.name])).to contain_exactly(tag1, tag2, tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_topic: true), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_topic: true, selected_tags: [tag1.name]), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_topic: true, selected_tags: [tag1.name, tag3.name]), [tag1, tag2, tag3, tag4]) end it "filter_allowed_tags returns tags common to more than one tag group with parent tag" do @@ -263,14 +270,24 @@ describe "category tag restrictions" do tag_group = Fabricate(:tag_group, parent_tag_id: tag3.id) tag_group.tags = [tag4] - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag3) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, common) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag3.name])).to contain_exactly(tag4, tag1) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name]), [tag2, tag3, common]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag3.name]), [tag4, tag1]) tag_group.tags = [tag4, common] - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag3) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, common) - expect(filter_allowed_tags(for_input: true, selected_tags: [tag3.name])).to contain_exactly(tag4, tag1, common) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name]), [tag2, tag3, common]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag3.name]), [tag4, tag1, common]) + + parent_tag_group = Fabricate(:tag_group, tags: [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name]), [tag2, tag3, common]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag3.name]), [tag4, tag1, common]) + + parent_tag_group.update!(one_per_topic: true) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag3]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag1.name]), [tag2, common]) + expect_same_tag_names(filter_allowed_tags(for_input: true, selected_tags: [tag3.name]), [tag4, common]) end context 'required tags from tag group' do @@ -279,10 +296,10 @@ describe "category tag restrictions" do it "search only returns the allowed tags" do tag_group_with_parent = Fabricate(:tag_group, parent_tag_id: tag1.id, tags: [tag3, tag4]) - expect(filter_allowed_tags(for_input: true, category: category)).to contain_exactly(tag1, tag2) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name])).to contain_exactly(tag1) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name])).to contain_exactly(tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name, tag2.name])).to contain_exactly(tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category), [tag1, tag2]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag2.name]), [tag1]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name]), [tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: category, selected_tags: [tag1.name, tag2.name]), [tag3, tag4]) end end @@ -314,8 +331,8 @@ describe "category tag restrictions" do it "handles all those rules" do # car tags can't be used outside of car category: - expect(filter_allowed_tags(for_input: true)).to contain_exactly(tag1, tag2, tag3, tag4) - expect(filter_allowed_tags(for_input: true, category: other_category)).to contain_exactly(tag1, tag2, tag3, tag4) + expect_same_tag_names(filter_allowed_tags(for_input: true), [tag1, tag2, tag3, tag4]) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: other_category), [tag1, tag2, tag3, tag4]) # in car category, a make tag must be given first: expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category))).to eq(['ford', 'honda']) @@ -323,6 +340,26 @@ describe "category tag restrictions" do # model tags depend on which make is chosen: expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['honda']))).to eq(['accord', 'civic', 'ford']) expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['ford']))).to eq(['honda', 'mustang', 'taurus']) + + makes.update!(one_per_topic: true) + expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['honda']))).to eq(['accord', 'civic']) + expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['ford']))).to eq(['mustang', 'taurus']) + + honda_group.update!(one_per_topic: true) + ford_group.update!(one_per_topic: true) + expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['honda']))).to eq(['accord', 'civic']) + expect(sorted_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['ford']))).to eq(['mustang', 'taurus']) + + car_category.update!(allow_global_tags: true) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: car_category), + ['ford', 'honda', tag1, tag2, tag3, tag4] + ) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['ford']), + ['mustang', 'taurus', tag1, tag2, tag3, tag4] + ) + expect_same_tag_names(filter_allowed_tags(for_input: true, category: car_category, selected_tags: ['ford', 'mustang']), + [tag1, tag2, tag3, tag4] + ) end it "can apply the tags to a topic" do diff --git a/spec/integration/rate_limiting_spec.rb b/spec/integration/rate_limiting_spec.rb index 5f609cf319..13fe8f7359 100644 --- a/spec/integration/rate_limiting_spec.rb +++ b/spec/integration/rate_limiting_spec.rb @@ -56,7 +56,7 @@ describe 'rate limiter integration' do #request.set_header("action_dispatch.show_exceptions", true) admin = Fabricate(:admin) - api_key = Fabricate(:api_key, key: SecureRandom.hex, user: admin) + api_key = Fabricate(:api_key, user: admin) global_setting :max_admin_api_reqs_per_key_per_minute, 1 diff --git a/spec/jobs/invalidate_inactive_admins_spec.rb b/spec/jobs/invalidate_inactive_admins_spec.rb index e7b5b435d1..2b2c87e337 100644 --- a/spec/jobs/invalidate_inactive_admins_spec.rb +++ b/spec/jobs/invalidate_inactive_admins_spec.rb @@ -46,6 +46,18 @@ describe Jobs::InvalidateInactiveAdmins do expect(UserAssociatedAccount.where(user_id: not_seen_admin.id).exists?).to eq(false) end end + + it "doesn't deactivate admins with recent posts" do + Fabricate(:post, user: not_seen_admin) + subject + expect(not_seen_admin.reload.active).to eq(true) + end + + it "doesn't deactivate admins with recently used api keys" do + Fabricate(:api_key, user: not_seen_admin, last_used_at: 1.day.ago) + subject + expect(not_seen_admin.reload.active).to eq(true) + end end context 'invalidate_inactive_admin_email_after_days = 0 to disable this feature' do diff --git a/spec/jobs/notify_tag_change_spec.rb b/spec/jobs/notify_tag_change_spec.rb new file mode 100644 index 0000000000..9d6e0053e3 --- /dev/null +++ b/spec/jobs/notify_tag_change_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ::Jobs::NotifyTagChange do + + fab!(:user) { Fabricate(:user) } + fab!(:regular_user) { Fabricate(:trust_level_4) } + fab!(:post) { Fabricate(:post, user: regular_user) } + fab!(:tag) { Fabricate(:tag, name: 'test') } + + it "creates notification for watched tag" do + TagUser.create!( + user_id: user.id, + tag_id: tag.id, + notification_level: NotificationLevels.topic_levels[:watching] + ) + TopicTag.create!( + topic_id: post.topic.id, + tag_id: tag.id + ) + + expect { described_class.new.execute(post_id: post.id, notified_user_ids: [regular_user.id]) }.to change { Notification.count } + notification = Notification.last + expect(notification.user_id).to eq(user.id) + expect(notification.topic_id).to eq(post.topic_id) + end + + it 'doesnt create notification for user watching category' do + CategoryUser.create!( + user_id: user.id, + category_id: post.topic.category_id, + notification_level: TopicUser.notification_levels[:watching] + ) + expect { described_class.new.execute(post_id: post.id, notified_user_ids: [regular_user.id]) }.not_to change { Notification.count } + end +end diff --git a/spec/jobs/poll_feed_spec.rb b/spec/jobs/poll_feed_spec.rb deleted file mode 100644 index 5ddc7a7d6d..0000000000 --- a/spec/jobs/poll_feed_spec.rb +++ /dev/null @@ -1,190 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Jobs::PollFeed do - let(:poller) { Jobs::PollFeed.new } - - context "execute" do - let(:url) { "http://eviltrout.com" } - - before do - $redis.del("feed-polled-recently") - end - - it "requires feed_polling_enabled?" do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = nil - poller.expects(:poll_feed).never - poller.execute({}) - end - - it "requires feed_polling_url" do - SiteSetting.feed_polling_enabled = false - SiteSetting.feed_polling_url = nil - poller.expects(:poll_feed).never - poller.execute({}) - end - - it "delegates to poll_feed" do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = url - poller.expects(:poll_feed).once - poller.execute({}) - end - - it "won't poll if it has polled recently" do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = url - poller.expects(:poll_feed).once - poller.execute({}) - poller.execute({}) - end - end - - describe '#poll_feed' do - let(:embed_by_username) { 'eviltrout' } - let(:embed_username_key_from_feed) { 'discourse:username' } - fab!(:default_user) { Fabricate(:evil_trout) } - fab!(:feed_author) { Fabricate(:user, username: 'xrav3nz', email: 'hi@bye.com') } - - shared_examples 'topic creation based on the the feed' do - describe 'author username parsing' do - context 'when neither embed_by_username nor embed_username_key_from_feed is set' do - before do - SiteSetting.embed_by_username = "" - SiteSetting.embed_username_key_from_feed = "" - end - - it 'does not import topics' do - expect { poller.poll_feed }.not_to change { Topic.count } - end - end - - context 'when embed_by_username is set' do - before do - SiteSetting.embed_by_username = embed_by_username - SiteSetting.embed_username_key_from_feed = "" - end - - it 'creates the new topics under embed_by_username' do - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.user).to eq(default_user) - end - end - - context 'when embed_username_key_from_feed is set' do - before do - SiteSetting.embed_username_key_from_feed = embed_username_key_from_feed - end - - it 'creates the new topics under the username found' do - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.user).to eq(feed_author) - end - - it "updates the post if it had been polled" do - embed_url = 'https://blog.discourse.org/2017/09/poll-feed-spec-fixture' - post = TopicEmbed.import(Fabricate(:user), embed_url, 'old title', 'old content') - - expect { poller.poll_feed }.to_not change { Topic.count } - - post.reload - expect(post.raw).to include('

This is the body & content.

') - expect(post.user).to eq(feed_author) - end - end - end - - it 'parses creates a new post correctly' do - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.title).to eq('Poll Feed Spec Fixture') - expect(Topic.last.first_post.raw).to include('

This is the body & content.

') - expect(Topic.last.topic_embed.embed_url).to eq('https://blog.discourse.org/2017/09/poll-feed-spec-fixture') - end - end - - context 'when parsing RSS feed' do - before do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/' - SiteSetting.embed_by_username = embed_by_username - - stub_request(:head, SiteSetting.feed_polling_url) - stub_request(:get, SiteSetting.feed_polling_url).to_return( - body: file_from_fixtures('feed.rss', 'feed').read, - headers: { "Content-Type" => "application/rss+xml" } - ) - end - - include_examples 'topic creation based on the the feed' - end - - context 'when parsing ATOM feed' do - before do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/atom/' - SiteSetting.embed_by_username = embed_by_username - - stub_request(:head, SiteSetting.feed_polling_url) - stub_request(:get, SiteSetting.feed_polling_url).to_return( - body: file_from_fixtures('feed.atom', 'feed').read, - headers: { "Content-Type" => "application/atom+xml" } - ) - end - - include_examples 'topic creation based on the the feed' - end - - it "aborts when it can't fetch the feed" do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/atom/' - SiteSetting.embed_by_username = 'eviltrout' - - stub_request(:head, SiteSetting.feed_polling_url).to_return(status: 404) - stub_request(:get, SiteSetting.feed_polling_url).to_return(status: 404) - - expect { poller.poll_feed }.to_not change { Topic.count } - end - - context 'encodings' do - before do - SiteSetting.feed_polling_enabled = true - SiteSetting.feed_polling_url = 'https://blog.discourse.org/feed/atom/' - SiteSetting.embed_by_username = 'eviltrout' - - stub_request(:head, SiteSetting.feed_polling_url) - end - - it 'works with encodings other than UTF-8' do - stub_request(:get, SiteSetting.feed_polling_url).to_return( - body: file_from_fixtures('utf-16le-feed.rss', 'feed').read, - headers: { "Content-Type" => "application/rss+xml" } - ) - - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.first_post.raw).to include('

This is the body & content.

') - end - - it 'respects the charset in the Content-Type header' do - stub_request(:get, SiteSetting.feed_polling_url).to_return( - body: file_from_fixtures('iso-8859-15-feed.rss', 'feed').read, - headers: { "Content-Type" => "application/rss+xml; charset=ISO-8859-15" } - ) - - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.first_post.raw).to include('

This is the body & content. 100€

') - end - - it 'works when the charset in the Content-Type header is unknown' do - stub_request(:get, SiteSetting.feed_polling_url).to_return( - body: file_from_fixtures('feed.rss', 'feed').read, - headers: { "Content-Type" => "application/rss+xml; charset=foo" } - ) - - expect { poller.poll_feed }.to change { Topic.count }.by(1) - expect(Topic.last.first_post.raw).to include('

This is the body & content.

') - end - end - end -end diff --git a/spec/jobs/regular/bulk_user_title_update_spec.rb b/spec/jobs/regular/bulk_user_title_update_spec.rb new file mode 100644 index 0000000000..f6cf97eb38 --- /dev/null +++ b/spec/jobs/regular/bulk_user_title_update_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Jobs::BulkUserTitleUpdate do + fab!(:badge) { Fabricate(:badge, name: 'Protector of the Realm', allow_title: true) } + fab!(:user) { Fabricate(:user) } + fab!(:other_user) { Fabricate(:user) } + + describe 'update action' do + before do + BadgeGranter.grant(badge, user) + user.update(title: badge.name) + end + + it 'updates the title of all users with the attached granted title badge id on their profile' do + execute_update + expect(user.reload.title).to eq('King of the Forum') + end + + it 'does not set the title for any other users' do + execute_update + expect(other_user.reload.title).not_to eq('King of the Forum') + end + + def execute_update + described_class.new.execute(new_title: 'King of the Forum', granted_badge_id: badge.id, action: described_class::UPDATE_ACTION) + end + end + + describe 'reset action' do + let(:customized_badge_name) { 'Merit Badge' } + + before do + TranslationOverride.upsert!(I18n.locale, Badge.i18n_key(badge.name), customized_badge_name) + BadgeGranter.grant(badge, user) + user.update(title: customized_badge_name) + end + + it 'updates the title of all users back to the original badge name' do + expect(user.reload.title).to eq(customized_badge_name) + described_class.new.execute(granted_badge_id: badge.id, action: described_class::RESET_ACTION) + expect(user.reload.title).to eq('Protector of the Realm') + end + + after do + TranslationOverride.revert!(I18n.locale, Badge.i18n_key(badge.name)) + end + end +end diff --git a/spec/jobs/regular/update_private_uploads_acl_spec.rb b/spec/jobs/regular/update_private_uploads_acl_spec.rb new file mode 100644 index 0000000000..d80713d632 --- /dev/null +++ b/spec/jobs/regular/update_private_uploads_acl_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Jobs::UpdatePrivateUploadsAcl do + let(:args) { [] } + + before do + SiteSetting.authorized_extensions = "pdf" + end + + describe '#execute' do + context "if not SiteSetting.Upload.enable_s3_uploads" do + before do + SiteSetting.Upload.stubs(:enable_s3_uploads).returns(false) + end + it "returns early and changes no uploads" do + Upload.expects(:find_each).never + subject.execute(args) + end + end + context "if SiteSetting.Upload.enable_s3_uploads" do + let!(:upload) { Fabricate(:upload_s3, extension: 'pdf', original_filename: "watchmen.pdf", secure: false) } + before do + SiteSetting.login_required = true + SiteSetting.prevent_anons_from_downloading_files = true + SiteSetting::Upload.stubs(:enable_s3_uploads).returns(true) + Discourse.stubs(:store).returns(stub(external?: false)) + end + + it "changes the upload to secure" do + subject.execute(args) + expect(upload.reload.secure).to eq(true) + end + end + end +end diff --git a/spec/lib/site_settings/validations_spec.rb b/spec/lib/site_settings/validations_spec.rb index c58a270cdc..7f346d4d5c 100644 --- a/spec/lib/site_settings/validations_spec.rb +++ b/spec/lib/site_settings/validations_spec.rb @@ -105,4 +105,145 @@ describe SiteSettings::Validations do end end end + + describe "enforce second factor & local login interplay" do + describe "#validate_enforce_second_factor" do + let(:error_message) { I18n.t("errors.site_settings.second_factor_cannot_be_enforced_with_disabled_local_login") } + context "when local logins are disabled" do + before do + SiteSetting.enable_local_logins = false + end + + it "should raise an error" do + expect { subject.validate_enforce_second_factor("t") }.to raise_error(Discourse::InvalidParameters, error_message) + end + end + + context "when local logins are enabled" do + before do + SiteSetting.enable_local_logins = true + end + + it "should be ok" do + expect { subject.validate_enforce_second_factor("t") }.not_to raise_error + end + end + end + + describe "#validate_enable_local_logins" do + let(:error_message) { I18n.t("errors.site_settings.local_login_cannot_be_disabled_if_second_factor_enforced") } + + context "when the new value is false" do + context "when enforce second factor is enabled" do + before do + SiteSetting.enforce_second_factor = "all" + end + + it "should raise an error" do + expect { subject.validate_enable_local_logins("f") }.to raise_error(Discourse::InvalidParameters, error_message) + end + end + + context "when enforce second factor is disabled" do + before do + SiteSetting.enforce_second_factor = "no" + end + + it "should be ok" do + expect { subject.validate_enable_local_logins("f") }.not_to raise_error + end + end + end + + context "when the new value is true" do + it "should be ok" do + expect { subject.validate_enable_local_logins("t") }.not_to raise_error + end + end + end + + describe "#validate_secure_media" do + let(:error_message) { I18n.t("errors.site_settings.secure_media_requirements") } + + context "when the new value is true" do + context 'if site setting for enable_s3_uploads is enabled' do + before do + SiteSetting.enable_s3_uploads = true + end + + it "should be ok" do + expect { subject.validate_secure_media("t") }.not_to raise_error + end + end + + context 'if site setting for enable_s3_uploads is not enabled' do + before do + SiteSetting.enable_s3_uploads = false + end + + it "is not ok" do + expect { subject.validate_secure_media("t") }.to raise_error(Discourse::InvalidParameters, error_message) + end + + context "if global s3 setting is enabled" do + before do + GlobalSetting.stubs(:use_s3?).returns(true) + end + + it "should be ok" do + expect { subject.validate_secure_media("t") }.not_to raise_error + end + end + end + end + end + + describe "#validate_enable_s3_uploads" do + let(:error_message) { I18n.t("errors.site_settings.cannot_enable_s3_uploads_when_s3_enabled_globally") } + + context "when the new value is true" do + context "when s3 uploads are already globally enabled" do + before do + GlobalSetting.stubs(:use_s3?).returns(true) + end + + it "is not ok" do + expect { subject.validate_enable_s3_uploads("t") }.to raise_error(Discourse::InvalidParameters, error_message) + end + end + + context "when s3 uploads are not already globally enabled" do + before do + GlobalSetting.stubs(:use_s3?).returns(false) + end + + it "should be ok" do + expect { subject.validate_enable_s3_uploads("t") }.not_to raise_error + end + end + + context "when the s3_upload_bucket is blank" do + let(:error_message) { I18n.t("errors.site_settings.s3_upload_bucket_is_required") } + + before do + SiteSetting.s3_upload_bucket = nil + end + + it "is not ok" do + expect { subject.validate_enable_s3_uploads("t") }.to raise_error(Discourse::InvalidParameters, error_message) + end + end + + context "when the s3_upload_bucket is not blank" do + before do + SiteSetting.s3_upload_bucket = "some-bucket" + end + + it "should be ok" do + expect { subject.validate_enable_s3_uploads("t") }.not_to raise_error + end + end + end + end + end end diff --git a/spec/lib/upload_creator_spec.rb b/spec/lib/upload_creator_spec.rb index 5754a5c6cb..b26d718307 100644 --- a/spec/lib/upload_creator_spec.rb +++ b/spec/lib/upload_creator_spec.rb @@ -170,7 +170,7 @@ RSpec.describe UploadCreator do end end - describe 'private uploads' do + describe 'secure attachments' do let(:filename) { "small.pdf" } let(:file) { file_from_fixtures(filename, "pdf") } @@ -179,31 +179,31 @@ RSpec.describe UploadCreator do SiteSetting.authorized_extensions = 'pdf|svg|jpg' end - it 'should mark uploads as private' do + it 'should mark attachments as secure' do upload = UploadCreator.new(file, filename).create_for(user.id) stored_upload = Upload.last - expect(stored_upload.private?).to eq(true) + expect(stored_upload.secure?).to eq(true) end - it 'should not mark theme uploads as private' do + it 'should not mark theme uploads as secure' do fname = "custom-theme-icon-sprite.svg" upload = UploadCreator.new(file_from_fixtures(fname), fname, for_theme: true).create_for(-1) - expect(upload.private?).to eq(false) + expect(upload.secure?).to eq(false) end - it 'should not mark image uploads as private' do + it 'should not apply prevent_anons_from_downloading_files to image uploads' do fname = "logo.jpg" upload = UploadCreator.new(file_from_fixtures(fname), fname).create_for(user.id) stored_upload = Upload.last expect(stored_upload.original_filename).to eq(fname) - expect(stored_upload.private?).to eq(false) + expect(stored_upload.secure?).to eq(false) end end - describe 'uploading to s3' do + context 'uploading to s3' do let(:filename) { "should_be_jpeg.png" } let(:file) { file_from_fixtures(filename) } let(:pdf_filename) { "small.pdf" } @@ -233,7 +233,7 @@ RSpec.describe UploadCreator do expect(upload.etag).to eq('ETag') end - it 'should return signed URL for private uploads in S3' do + it 'should return signed URL for secure attachments in S3' do SiteSetting.prevent_anons_from_downloading_files = true SiteSetting.authorized_extensions = 'pdf' @@ -241,7 +241,7 @@ RSpec.describe UploadCreator do stored_upload = Upload.last signed_url = Discourse.store.url_for(stored_upload) - expect(stored_upload.private?).to eq(true) + expect(stored_upload.secure?).to eq(true) expect(stored_upload.url).not_to eq(signed_url) expect(signed_url).to match(/Amz-Credential/) end diff --git a/spec/lib/upload_recovery_spec.rb b/spec/lib/upload_recovery_spec.rb index f248d009f5..3d8b840b1f 100644 --- a/spec/lib/upload_recovery_spec.rb +++ b/spec/lib/upload_recovery_spec.rb @@ -49,7 +49,7 @@ RSpec.describe UploadRecovery do describe '#recover' do describe 'when given an invalid sha1' do - it 'should not do anything' do + xit 'should not do anything' do upload_recovery.expects(:recover_from_local).never post.update!( @@ -66,7 +66,7 @@ RSpec.describe UploadRecovery do end end - it 'accepts a custom ActiveRecord relation' do + xit 'accepts a custom ActiveRecord relation' do post.update!(updated_at: 2.days.ago) upload.destroy! @@ -85,7 +85,7 @@ RSpec.describe UploadRecovery do ).tap(&:link_post_uploads) end - it 'should recover the attachment' do + xit 'should recover the attachment' do expect do upload2.destroy! end.to change { post.reload.uploads.count }.from(1).to(0) @@ -99,7 +99,7 @@ RSpec.describe UploadRecovery do end end - it 'should recover uploads and attachments' do + xit 'should recover uploads and attachments' do stub_request(:get, "http://test.localhost#{upload.url}") .to_return(status: 200) @@ -125,7 +125,7 @@ RSpec.describe UploadRecovery do ).tap(&:link_post_uploads) end - it 'should recover the upload' do + xit 'should recover the upload' do stub_request(:get, "http://test.localhost#{upload.url}") .to_return(status: 200) @@ -152,7 +152,7 @@ RSpec.describe UploadRecovery do ).tap(&:link_post_uploads) end - it 'should recover the upload' do + xit 'should recover the upload' do stub_request(:get, "http://test.localhost#{upload.url}") .to_return(status: 200) @@ -179,7 +179,7 @@ RSpec.describe UploadRecovery do ).tap(&:link_post_uploads) end - it 'should recover the upload' do + xit 'should recover the upload' do stub_request(:get, "http://test.localhost#{upload.url}") .to_return(status: 200) diff --git a/spec/lib/validators/censored_words_validator_spec.rb b/spec/lib/validators/censored_words_validator_spec.rb new file mode 100644 index 0000000000..391c914363 --- /dev/null +++ b/spec/lib/validators/censored_words_validator_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe CensoredWordsValidator do + let(:value) { 'some new bad text' } + let(:record) { Fabricate(:post, raw: 'this is a test') } + let(:attribute) { :raw } + + describe "#validate_each" do + context "when there are censored words for action" do + let!(:watched_word) { Fabricate(:watched_word, action: WatchedWord.actions[:censor], word: 'bad') } + + context "when there is a nil word_matcher_regexp" do + before do + WordWatcher.stubs(:word_matcher_regexp).returns(nil) + end + + it "adds no errors to the record" do + validate + expect(record.errors.empty?).to eq(true) + end + end + + context "when there is word_matcher_regexp" do + context "when the new value does not contain the watched word" do + let(:value) { 'some new good text' } + + it "adds no errors to the record" do + validate + expect(record.errors.empty?).to eq(true) + end + end + + context "when the new value does contain the watched word" do + let(:value) { 'some new bad text' } + + it "adds errors to the record" do + validate + expect(record.errors.empty?).to eq(false) + end + end + end + end + end + + def validate + described_class.new(attributes: :test).validate_each(record, attribute, value) + end +end diff --git a/spec/lib/validators/timezone_validator_spec.rb b/spec/lib/validators/timezone_validator_spec.rb new file mode 100644 index 0000000000..4fec4b53ba --- /dev/null +++ b/spec/lib/validators/timezone_validator_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe TimezoneValidator do + describe "#valid?" do + context "when timezone is ok" do + it "returns true" do + expect(described_class.valid?("Australia/Brisbane")).to eq(true) + end + end + + context "when timezone is not ok" do + it "returns false" do + expect(described_class.valid?("Mars")).to eq(false) + end + end + end + + describe "#validate_each" do + let(:record) { Fabricate(:active_user).user_option } + + context "when timezone is ok" do + it "adds no errors to the record" do + record.timezone = "Australia/Melbourne" + record.save + expect(record.errors.full_messages.empty?).to eq(true) + end + end + + context "when timezone is blank" do + it "adds no errors to the record" do + record.timezone = nil + record.save + expect(record.errors.full_messages.empty?).to eq(true) + end + end + + context "when timezone is not ok" do + it "adds errors to the record" do + record.timezone = "Mars" + record.save + expect(record.errors.full_messages).to include( + "Timezone 'Mars' is not a valid timezone" + ) + end + end + end +end diff --git a/spec/lib/webauthn/security_key_registration_service_spec.rb b/spec/lib/webauthn/security_key_registration_service_spec.rb index 17bc9ff149..672ddac56e 100644 --- a/spec/lib/webauthn/security_key_registration_service_spec.rb +++ b/spec/lib/webauthn/security_key_registration_service_spec.rb @@ -87,7 +87,7 @@ describe Webauthn::SecurityKeyRegistrationService do before do @original_supported_alg_value = Webauthn::SUPPORTED_ALGORITHMS silence_warnings do - Webauthn::SUPPORTED_ALGORITHMS = [-257] + Webauthn::SUPPORTED_ALGORITHMS = [-999] end end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index bde80b0b3e..a623d87f4e 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -225,8 +225,7 @@ describe UserNotifications do end it "supports subfolder" do - GlobalSetting.stubs(:relative_url_root).returns('/forum') - Discourse.stubs(:base_uri).returns("/forum") + set_subfolder "/forum" html = subject.html_part.body.to_s text = subject.text_part.body.to_s expect(html).to be_present @@ -675,6 +674,50 @@ describe UserNotifications do expect(mail.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) end + describe "secure media" do + let(:video_upload) { Fabricate(:upload, extension: "mov") } + let(:user) { Fabricate(:user) } + let(:post) { Fabricate(:post) } + + before do + SiteSetting.s3_upload_bucket = "some-bucket-on-s3" + SiteSetting.s3_access_key_id = "s3-access-key-id" + SiteSetting.s3_secret_access_key = "s3-secret-access-key" + SiteSetting.s3_cdn_url = "https://s3.cdn.com" + SiteSetting.enable_s3_uploads = true + SiteSetting.secure_media = true + SiteSetting.login_required = true + + video_upload.update!(url: "#{SiteSetting.s3_cdn_url}/#{Discourse.store.get_path_for_upload(video_upload)}") + user.email_logs.create!( + email_type: 'blah', + to_address: user.email, + user_id: user.id + ) + end + + it "replaces secure audio/video with placeholder" do + reply = Fabricate(:post, topic_id: post.topic_id, raw: "Video: #{video_upload.url}") + + notification = Fabricate( + :notification, + topic_id: post.topic_id, + post_number: reply.post_number, + user: post.user, + data: { original_username: 'bob' }.to_json + ) + + mail = UserNotifications.user_replied( + user, + post: reply, + notification_type: notification.notification_type, + notification_data_hash: notification.data_hash + ) + + expect(mail.body.to_s).to match(I18n.t("emails.secure_media_placeholder")) + end + end + def expects_build_with(condition) UserNotifications.any_instance.expects(:build_email).with(user.email, condition) mailer = UserNotifications.public_send( diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb index 41cf3edcfd..bd1639a69c 100644 --- a/spec/models/badge_spec.rb +++ b/spec/models/badge_spec.rb @@ -95,6 +95,33 @@ describe Badge do end end + describe '.find_system_badge_id_from_translation_key' do + let(:translation_key) { 'badges.regular.name' } + + it 'uses a translation key to get a system badge id, mainly to find which badge a translation override corresponds to' do + expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq( + Badge::Regular + ) + end + + context 'when the translation key is snake case' do + let(:translation_key) { 'badges.crazy_in_love.name' } + + it 'works to get the badge' do + expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq( + Badge::CrazyInLove + ) + end + end + + context 'when a translation key not for a badge is provided' do + let(:translation_key) { 'reports.flags.title' } + it 'returns nil' do + expect(Badge.find_system_badge_id_from_translation_key(translation_key)).to eq(nil) + end + end + end + context "First Quote" do let(:quoted_post_badge) do Badge.find(Badge::FirstQuote) diff --git a/spec/models/category_featured_topic_spec.rb b/spec/models/category_featured_topic_spec.rb index 42448484ba..0812237863 100644 --- a/spec/models/category_featured_topic_spec.rb +++ b/spec/models/category_featured_topic_spec.rb @@ -12,10 +12,6 @@ describe CategoryFeaturedTopic do fab!(:category) { Fabricate(:category) } let!(:category_post) { PostCreator.create(user, raw: "I put this post in the category", title: "categorize THIS", category: category.id) } - before do - CategoryFeaturedTopic.clear_exclude_category_ids - end - it "works in batched mode" do category2 = Fabricate(:category) post2 = create_post(category: category2.id) @@ -57,20 +53,6 @@ describe CategoryFeaturedTopic do expect(CategoryFeaturedTopic.count).to be(1) end - it 'should not include topics from suppressed categories' do - CategoryFeaturedTopic.feature_topics_for(category) - expect( - CategoryFeaturedTopic.where(category_id: category.id).order('rank asc').pluck(:topic_id) - ).to contain_exactly(category_post.topic.id) - - category.update(suppress_from_latest: true) - - CategoryFeaturedTopic.feature_topics_for(category) - expect( - CategoryFeaturedTopic.where(category_id: category.id).order('rank asc').pluck(:topic_id) - ).to_not contain_exactly(category_post.topic.id) - end - it 'should feature stuff in the correct order' do category = Fabricate(:category, num_featured_topics: 2) _t5 = Fabricate(:topic, category_id: category.id, bumped_at: 12.minutes.ago) diff --git a/spec/models/category_list_spec.rb b/spec/models/category_list_spec.rb index 85f4507894..bc6e03963d 100644 --- a/spec/models/category_list_spec.rb +++ b/spec/models/category_list_spec.rb @@ -65,6 +65,35 @@ describe CategoryList do end end + context "when mute_all_categories_by_default enabled" do + fab!(:category) { Fabricate(:category) } + + before do + SiteSetting.mute_all_categories_by_default = true + end + + it "removes the category by default" do + expect(category_list.categories).not_to include(category) + end + + it "returns correct notification level for user tracking category" do + CategoryUser.set_notification_level_for_category(user, NotificationLevels.all[:tracking], category.id) + notification_level = category_list.categories.find { |c| c.id == category.id }.notification_level + expect(notification_level).to eq(CategoryUser.notification_levels[:tracking]) + end + + it "returns correct notification level in default categories for anonymous" do + SiteSetting.default_categories_watching = category.id.to_s + notification_level = CategoryList.new(Guardian.new).categories.find { |c| c.id == category.id }.notification_level + expect(notification_level).to eq(CategoryUser.notification_levels[:regular]) + end + + it "removes the default muted categories for anonymous" do + SiteSetting.default_categories_muted = category.id.to_s + expect(CategoryList.new(Guardian.new).categories).not_to include(category) + end + end + context "with a category" do fab!(:topic_category) { Fabricate(:category_with_definition, num_featured_topics: 2) } @@ -114,11 +143,11 @@ describe CategoryList do expect(category.notification_level).to eq(NotificationLevels.all[:watching]) end - it "returns no notication level for anonymous users" do + it "returns default notication level for anonymous users" do category_list = CategoryList.new(Guardian.new(nil)) category = category_list.categories.find { |c| c.id == topic_category.id } - expect(category.notification_level).to be_nil + expect(category.notification_level).to eq(NotificationLevels.all[:regular]) end end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index f9a63c5151..f268c2c735 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -446,8 +446,7 @@ describe Category do end it "correctly creates permalink when category slug is changed in subfolder install" do - GlobalSetting.stubs(:relative_url_root).returns('/forum') - Discourse.stubs(:base_uri).returns("/forum") + set_subfolder '/forum' old_url = @category.url @category.update(slug: 'new-category') permalink = Permalink.last @@ -892,6 +891,43 @@ describe Category do expect(Category.auto_bump_topic!).to eq(false) end + + it 'should not automatically bump topics with a bump scheduled' do + freeze_time 1.second.ago + category = Fabricate(:category_with_definition) + category.clear_auto_bump_cache! + + freeze_time 1.second.from_now + post1 = create_post(category: category) + + # no limits on post creation or category creation please + RateLimiter.enable + + time = 1.month.from_now + freeze_time time + + expect(category.auto_bump_topic!).to eq(false) + expect(Topic.where(bumped_at: time).count).to eq(0) + + category.num_auto_bump_daily = 2 + category.save! + + topic = Topic.find_by_id(post1.topic_id) + + TopicTimer.create!( + user_id: -1, + topic: topic, + execute_at: 1.hour.from_now, + status_type: TopicTimer.types[:bump] + ) + + expect(Topic.joins(:topic_timers).where(topic_timers: { status_type: 6, deleted_at: nil }).count).to eq(1) + + expect(category.auto_bump_topic!).to eq(false) + expect(Topic.where(bumped_at: time).count).to eq(0) + # does not include a bump message + expect(post1.topic.reload.posts_count).to eq(1) + end end describe "validate permissions compatibility" do diff --git a/spec/models/embeddable_host_spec.rb b/spec/models/embeddable_host_spec.rb index d55e112e60..449c87aa2f 100644 --- a/spec/models/embeddable_host_spec.rb +++ b/spec/models/embeddable_host_spec.rb @@ -133,20 +133,14 @@ describe EmbeddableHost do host2 = Fabricate(:embeddable_host) SiteSetting.embed_post_limit = 300 - SiteSetting.feed_polling_url = "http://test.com" - SiteSetting.feed_polling_enabled = true host2.destroy expect(SiteSetting.embed_post_limit).to eq(300) - expect(SiteSetting.feed_polling_url).to eq("http://test.com") - expect(SiteSetting.feed_polling_enabled).to eq(true) host.destroy expect(SiteSetting.embed_post_limit).to eq(SiteSetting.defaults[:embed_post_limit]) - expect(SiteSetting.feed_polling_url).to eq(SiteSetting.defaults[:feed_polling_url]) - expect(SiteSetting.feed_polling_enabled).to eq(SiteSetting.defaults[:feed_polling_enabled]) end end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index eeb26d3f00..eabbbc68b5 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -258,7 +258,7 @@ describe Notification do end end - describe '.filter_by_display_username_and_type' do + describe '.filter_by_consolidation_data' do let(:post) { Fabricate(:post) } fab!(:user) { Fabricate(:user) } @@ -267,8 +267,8 @@ describe Notification do end it 'should return the right notifications' do - expect(Notification.filter_by_display_username_and_type( - user.username_lower, Notification.types[:liked] + expect(Notification.filter_by_consolidation_data( + Notification.types[:liked], display_username: user.username_lower )).to eq([]) expect do @@ -280,8 +280,8 @@ describe Notification do PostActionCreator.like(user, post) end.to change { Notification.count }.by(2) - expect(Notification.filter_by_display_username_and_type( - user.username_lower, Notification.types[:liked] + expect(Notification.filter_by_consolidation_data( + Notification.types[:liked], display_username: user.username_lower )).to contain_exactly( Notification.find_by(notification_type: Notification.types[:liked]) ) @@ -355,5 +355,49 @@ describe Notification do expect(Notification.recent_report(user)).to contain_exactly(notification) end end + + describe '#consolidate_membership_requests' do + fab!(:group) { Fabricate(:group, name: "XXsssssddd") } + fab!(:user) { Fabricate(:user) } + fab!(:post) { Fabricate(:post) } + + def create_membership_request_notification + Notification.create( + notification_type: Notification.types[:private_message], + user_id: user.id, + data: { + topic_title: I18n.t('groups.request_membership_pm.title', group_name: group.name), + original_post_id: post.id + }.to_json, + updated_at: Time.zone.now, + created_at: Time.zone.now + ) + end + + before do + PostCustomField.create!(post_id: post.id, name: "requested_group_id", value: group.id) + 2.times { create_membership_request_notification } + end + + it 'should consolidate membership requests to a new notification' do + notification = create_membership_request_notification + notification.reload + + notification = create_membership_request_notification + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + + notification = Notification.last + expect(notification.notification_type).to eq(Notification.types[:membership_request_consolidated]) + + data = notification.data_hash + expect(data[:group_name]).to eq(group.name) + expect(data[:count]).to eq(4) + + notification = create_membership_request_notification + expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound) + + expect(Notification.last.data_hash[:count]).to eq(5) + end + end end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index 3413cb273a..c4c85d985f 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -263,13 +263,13 @@ describe PostAction do fab!(:likee) { Fabricate(:user) } it "can be disabled" do - SiteSetting.likes_notification_consolidation_threshold = 0 + SiteSetting.notification_consolidation_threshold = 0 expect do PostActionCreator.like(liker, Fabricate(:post, user: likee)) end.to change { likee.reload.notifications.count }.by(1) - SiteSetting.likes_notification_consolidation_threshold = 1 + SiteSetting.notification_consolidation_threshold = 1 expect do PostActionCreator.like(liker, Fabricate(:post, user: likee)) @@ -285,7 +285,7 @@ describe PostAction do end it 'should consolidate likes notification when the threshold is reached' do - SiteSetting.likes_notification_consolidation_threshold = 2 + SiteSetting.notification_consolidation_threshold = 2 expect do 3.times do @@ -353,7 +353,7 @@ describe PostAction do end it 'should consolidate liked notifications when threshold is reached' do - SiteSetting.likes_notification_consolidation_threshold = 2 + SiteSetting.notification_consolidation_threshold = 2 post = Fabricate(:post, user: likee) diff --git a/spec/models/post_mover_spec.rb b/spec/models/post_mover_spec.rb index 78c1cc72ad..99ea605ba1 100644 --- a/spec/models/post_mover_spec.rb +++ b/spec/models/post_mover_spec.rb @@ -90,6 +90,27 @@ describe PostMover do expect(move_message.post_type).to eq(Post.types[:small_action]) expect(move_message.raw).to include("3 posts were split") end + + it "correctly remaps quotes" do + raw = <<~RAW + [quote="dan, post:#{p2.post_number}, topic:#{p2.topic_id}, full:true"] + some quote from the other post + [/quote] + + the quote above should be updated with new post number and topic id + RAW + + p3.update!(raw: raw) + p3.rebake! + + expect { topic.move_posts(user, [p2.id], title: "new testing topic name") } + .to change { p2.reload.topic_id } + .and change { p2.post_number } + .and change { p3.reload.raw } + .and change { p3.baked_version }.to nil + + expect(p3.raw).to include("post:#{p2.post_number}, topic:#{p2.topic_id}") + end end context "errors" do @@ -313,12 +334,12 @@ describe PostMover do new_topic = topic.move_posts(user, [p3.id], title: "new testing topic name") - n3.reload + n3 = Notification.find(n3.id) expect(n3.topic_id).to eq(new_topic.id) expect(n3.post_number).to eq(1) expect(n3.data_hash[:topic_title]).to eq(new_topic.title) - n4.reload + n4 = Notification.find(n4.id) expect(n4.topic_id).to eq(topic.id) expect(n4.post_number).to eq(4) end @@ -328,7 +349,7 @@ describe PostMover do topic.move_posts(user, [p1.id], title: "new testing topic name") - n1.reload + n1 = Notification.find(n1.id) expect(n1.topic_id).to eq(topic.id) expect(n1.data_hash[:topic_title]).to eq(topic.title) expect(n1.post_number).to eq(1) @@ -554,12 +575,12 @@ describe PostMover do moved_to = topic.move_posts(user, [p3.id], destination_topic_id: destination_topic.id) - n3.reload + n3 = Notification.find(n3.id) expect(n3.topic_id).to eq(moved_to.id) expect(n3.post_number).to eq(2) expect(n3.data_hash[:topic_title]).to eq(moved_to.title) - n4.reload + n4 = Notification.find(n4.id) expect(n4.topic_id).to eq(topic.id) expect(n4.post_number).to eq(4) end diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb index d035fdfac4..72bf2cd063 100644 --- a/spec/models/post_spec.rb +++ b/spec/models/post_spec.rb @@ -138,8 +138,8 @@ describe Post do context 'a post with notices' do let(:post) { post = Fabricate(:post, post_args) - post.custom_fields["notice_type"] = Post.notices[:returning_user] - post.custom_fields["notice_args"] = 1.day.ago + post.custom_fields[Post::NOTICE_TYPE] = Post.notices[:returning_user] + post.custom_fields[Post::NOTICE_ARGS] = 1.day.ago post.save_custom_fields post } @@ -1245,7 +1245,7 @@ describe Post do expect(post.revisions.pluck(:number)).to eq([1, 2]) end - describe '#link_post_uploads' do + describe 'uploads' do fab!(:video_upload) { Fabricate(:upload, extension: "mp4") } fab!(:image_upload) { Fabricate(:upload) } fab!(:audio_upload) { Fabricate(:upload, extension: "ogg") } @@ -1257,7 +1257,7 @@ describe Post do let(:video_url) { "#{base_url}#{video_upload.url}" } let(:audio_url) { "#{base_url}#{audio_upload.url}" } - let(:raw) do + let(:raw_multiple) do <<~RAW Link [test|attachment](#{attachment_upload_2.short_url}) @@ -1276,36 +1276,121 @@ describe Post do RAW end - let(:post) { Fabricate(:post, raw: raw) } + let(:post) { Fabricate(:post, raw: raw_multiple) } - it "finds all the uploads in the post" do - post.custom_fields[Post::DOWNLOADED_IMAGES] = { - "/uploads/default/original/1X/1/1234567890123456.csv": attachment_upload.id - } + context "#link_post_uploads" do + it "finds all the uploads in the post" do + post.custom_fields[Post::DOWNLOADED_IMAGES] = { + "/uploads/default/original/1X/1/1234567890123456.csv": attachment_upload.id + } - post.save_custom_fields - post.link_post_uploads + post.save_custom_fields + post.link_post_uploads - expect(PostUpload.where(post: post).pluck(:upload_id)).to contain_exactly( - video_upload.id, - image_upload.id, - audio_upload.id, - attachment_upload.id, - attachment_upload_2.id, - attachment_upload_3.id - ) + expect(PostUpload.where(post: post).pluck(:upload_id)).to contain_exactly( + video_upload.id, + image_upload.id, + audio_upload.id, + attachment_upload.id, + attachment_upload_2.id, + attachment_upload_3.id + ) + end + + it "cleans the reverse index up for the current post" do + post.link_post_uploads + + post_uploads_ids = post.post_uploads.pluck(:id) + + post.link_post_uploads + + expect(post.reload.post_uploads.pluck(:id)).to_not contain_exactly( + post_uploads_ids + ) + end end - it "cleans the reverse index up for the current post" do - post.link_post_uploads + context '#update_uploads_secure_status' do + fab!(:user) { Fabricate(:user, trust_level: 0) } - post_uploads_ids = post.post_uploads.pluck(:id) + let(:raw) do + <<~RAW + Link + + RAW + end - post.link_post_uploads + before do + SiteSetting.authorized_extensions = "pdf|png|jpg|csv" + SiteSetting.enable_s3_uploads = true + SiteSetting.s3_upload_bucket = "s3-upload-bucket" + SiteSetting.s3_access_key_id = "some key" + SiteSetting.s3_secret_access_key = "some secret key" + SiteSetting.secure_media = true + attachment_upload.update!(original_filename: "hello.csv") - expect(post.reload.post_uploads.pluck(:id)).to_not contain_exactly( - post_uploads_ids - ) + stub_request(:head, "https://#{SiteSetting.s3_upload_bucket}.s3.amazonaws.com/") + + stub_request( + :put, + "https://#{SiteSetting.s3_upload_bucket}.s3.amazonaws.com/original/1X/#{attachment_upload.sha1}.#{attachment_upload.extension}?acl" + ) + + stub_request( + :put, + "https://#{SiteSetting.s3_upload_bucket}.s3.amazonaws.com/original/1X/#{image_upload.sha1}.#{image_upload.extension}?acl" + ) + end + + it "marks image uploads as secure in PMs when secure_media is ON" do + post = Fabricate(:post, raw: raw, user: user, topic: Fabricate(:private_message_topic, user: user)) + post.link_post_uploads + post.update_uploads_secure_status + + expect(PostUpload.where(post: post).joins(:upload).pluck(:upload_id, :secure)).to contain_exactly( + [attachment_upload.id, false], + [image_upload.id, true] + ) + end + + it "marks image uploads as not secure in PMs when when secure_media is ON" do + SiteSetting.secure_media = false + post = Fabricate(:post, raw: raw, user: user, topic: Fabricate(:private_message_topic, user: user)) + post.link_post_uploads + post.update_uploads_secure_status + + expect(PostUpload.where(post: post).joins(:upload).pluck(:upload_id, :secure)).to contain_exactly( + [attachment_upload.id, false], + [image_upload.id, false] + ) + end + + it "marks attachments as secure when relevant setting is enabled" do + SiteSetting.prevent_anons_from_downloading_files = true + post = Fabricate(:post, raw: raw, user: user, topic: Fabricate(:topic, user: user)) + post.link_post_uploads + post.update_uploads_secure_status + + expect(PostUpload.where(post: post).joins(:upload).pluck(:upload_id, :secure)).to contain_exactly( + [attachment_upload.id, true], + [image_upload.id, false] + ) + end + + it "does not mark an upload as secure if it has already been used in a public topic" do + post = Fabricate(:post, raw: raw, user: user, topic: Fabricate(:topic, user: user)) + post.link_post_uploads + post.update_uploads_secure_status + + pm = Fabricate(:post, raw: raw, user: user, topic: Fabricate(:private_message_topic, user: user)) + pm.link_post_uploads + pm.update_uploads_secure_status + + expect(PostUpload.where(post: pm).joins(:upload).pluck(:upload_id, :secure)).to contain_exactly( + [attachment_upload.id, false], + [image_upload.id, false] + ) + end end end diff --git a/spec/models/remote_theme_spec.rb b/spec/models/remote_theme_spec.rb index e1e8af9fb4..0c467e962e 100644 --- a/spec/models/remote_theme_spec.rb +++ b/spec/models/remote_theme_spec.rb @@ -11,8 +11,8 @@ describe RemoteTheme do `cd #{repo_dir} && git init . ` `cd #{repo_dir} && git config user.email 'someone@cool.com'` `cd #{repo_dir} && git config user.name 'The Cool One'` - `cd #{repo_dir} && mkdir desktop mobile common assets locales scss stylesheets` files.each do |name, data| + FileUtils.mkdir_p(Pathname.new("#{repo_dir}/#{name}").dirname) File.write("#{repo_dir}/#{name}", data) `cd #{repo_dir} && git add #{name}` end @@ -52,6 +52,7 @@ describe RemoteTheme do "scss/oldpath.scss" => ".class2{color:blue}", "stylesheets/file.scss" => ".class1{color:red}", "stylesheets/empty.scss" => "", + "javascripts/discourse/controllers/test.js.es6" => "console.log('test');", "common/header.html" => "I AM HEADER", "common/random.html" => "I AM SILLY", "common/embedded.scss" => "EMBED", @@ -83,7 +84,7 @@ describe RemoteTheme do expect(remote.theme_version).to eq("1.0") expect(remote.minimum_discourse_version).to eq("1.0.0") - expect(@theme.theme_fields.length).to eq(8) + expect(@theme.theme_fields.length).to eq(9) mapped = Hash[*@theme.theme_fields.map { |f| ["#{f.target_id}-#{f.name}", f.value] }.flatten] expect(mapped["0-header"]).to eq("I AM HEADER") @@ -96,7 +97,7 @@ describe RemoteTheme do expect(mapped["4-en"]).to eq("sometranslations") - expect(mapped.length).to eq(8) + expect(mapped.length).to eq(9) expect(@theme.settings.length).to eq(1) expect(@theme.settings.first.value).to eq(true) diff --git a/spec/models/reviewable_spec.rb b/spec/models/reviewable_spec.rb index 6e8cfa32ce..84235644b1 100644 --- a/spec/models/reviewable_spec.rb +++ b/spec/models/reviewable_spec.rb @@ -435,4 +435,29 @@ RSpec.describe Reviewable, type: :model do expect(Reviewable.min_score_for_priority).to eq(45.6) end end + + context "custom filters" do + after do + Reviewable.clear_custom_filters! + end + + it 'correctly add a new filter' do + Reviewable.add_custom_filter([:assigned_to, Proc.new { |results, value| results }]) + + expect(Reviewable.custom_filters.size).to eq(1) + end + + it 'applies the custom filter' do + admin = Fabricate(:admin) + first_reviewable = Fabricate(:reviewable) + second_reviewable = Fabricate(:reviewable) + custom_filter = [:target_id, Proc.new { |results, value| results.where(target_id: value) }] + Reviewable.add_custom_filter(custom_filter) + + results = Reviewable.list_for(admin, additional_filters: { target_id: first_reviewable.target_id }) + + expect(results.size).to eq(1) + expect(results.first).to eq first_reviewable + end + end end diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb index 53b5f7493e..c97aafe10b 100644 --- a/spec/models/site_spec.rb +++ b/spec/models/site_spec.rb @@ -41,6 +41,16 @@ describe Site do end + it "returns correct notification level for categories" do + category = Fabricate(:category) + guardian = Guardian.new + expect(Site.new(guardian).categories.last.notification_level).to eq(1) + SiteSetting.mute_all_categories_by_default = true + expect(Site.new(guardian).categories.last.notification_level).to eq(0) + SiteSetting.default_categories_tracking = category.id.to_s + expect(Site.new(guardian).categories.last.notification_level).to eq(1) + end + it "omits categories users can not write to from the category list" do category = Fabricate(:category) user = Fabricate(:user) diff --git a/spec/models/tag_group_spec.rb b/spec/models/tag_group_spec.rb index ef00c45e1c..f126194379 100644 --- a/spec/models/tag_group_spec.rb +++ b/spec/models/tag_group_spec.rb @@ -87,4 +87,35 @@ describe TagGroup do include_examples "correct visible tag groups" end end + + describe 'tag_names=' do + let(:tag_group) { Fabricate(:tag_group) } + fab!(:tag) { Fabricate(:tag) } + + before { SiteSetting.tagging_enabled = true } + + it "can use existing tags and create new ones" do + expect { + tag_group.tag_names = [tag.name, 'new-tag'] + }.to change { Tag.count }.by(1) + expect_same_tag_names(tag_group.reload.tags, [tag, 'new-tag']) + end + + context 'with synonyms' do + fab!(:synonym) { Fabricate(:tag, name: 'synonym', target_tag: tag) } + + it "adds synonyms from base tags too" do + expect { + tag_group.tag_names = [tag.name, 'new-tag'] + }.to change { Tag.count }.by(1) + expect_same_tag_names(tag_group.reload.tags, [tag, 'new-tag', synonym]) + end + + it "removes tags correctly" do + tag_group.update!(tag_names: [tag.name]) + tag_group.tag_names = ['new-tag'] + expect_same_tag_names(tag_group.reload.tags, ['new-tag']) + end + end + end end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 88ac08ded5..441bb734ac 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -13,6 +13,7 @@ describe Tag do end let(:tag) { Fabricate(:tag) } + let(:tag2) { Fabricate(:tag) } let(:topic) { Fabricate(:topic, tags: [tag]) } before do @@ -46,6 +47,12 @@ describe Tag do expect(event[:event_name]).to eq(:tag_destroyed) expect(event[:params].first).to eq(subject) end + + it 'removes it from its tag group' do + tag_group = Fabricate(:tag_group, tags: [tag]) + expect { tag.destroy }.to change { TagGroupMembership.count }.by(-1) + expect(tag_group.reload.tags).to be_empty + end end it "can delete tags on deleted topics" do @@ -188,4 +195,56 @@ describe Tag do expect(Tag.unused.pluck(:name)).to contain_exactly("unused1", "unused2") end end + + context "synonyms" do + let(:synonym) { Fabricate(:tag, target_tag: tag) } + + it "can be a synonym for another tag" do + expect(synonym).to be_synonym + expect(synonym.target_tag).to eq(tag) + end + + it "cannot have a synonym of a synonym" do + synonym2 = Fabricate.build(:tag, target_tag: synonym) + expect(synonym2).to_not be_valid + expect(synonym2.errors[:target_tag_id]).to be_present + end + + it "a tag with synonyms cannot become a synonym" do + synonym + tag.target_tag = Fabricate(:tag) + expect(tag).to_not be_valid + expect(tag.errors[:target_tag_id]).to be_present + end + + it "can be added to a tag group" do + tag_group = Fabricate(:tag_group, tags: [tag]) + synonym + expect(tag_group.reload.tags).to include(synonym) + end + + it "can be added to a category" do + category = Fabricate(:category, tags: [tag]) + synonym + expect(category.reload.tags).to include(synonym) + end + + it "destroying a tag destroys its synonyms" do + synonym + expect { tag.destroy }.to change { Tag.count }.by(-2) + expect(Tag.find_by_id(synonym.id)).to be_nil + end + + it "can add a tag from the same tag group as a synonym" do + tag_group = Fabricate(:tag_group, tags: [tag, tag2]) + tag2.update!(target_tag: tag) + expect(tag_group.reload.tags).to include(tag2) + end + + it "can add a tag restricted to the same category as a synonym" do + category = Fabricate(:category, tags: [tag, tag2]) + tag2.update!(target_tag: tag) + expect(category.reload.tags).to include(tag2) + end + end end diff --git a/spec/models/tag_user_spec.rb b/spec/models/tag_user_spec.rb index 258aa19f66..23a679ca5a 100644 --- a/spec/models/tag_user_spec.rb +++ b/spec/models/tag_user_spec.rb @@ -40,6 +40,28 @@ describe TagUser do TagUser.change(user.id, tag.id, regular) expect(TopicUser.get(topic, user).notification_level).to eq tracking end + + it "watches or tracks on change using a synonym" do + user = Fabricate(:user) + tag = Fabricate(:tag) + synonym = Fabricate(:tag, target_tag: tag) + post = create_post(tags: [tag.name]) + topic = post.topic + + TopicUser.change(user.id, topic.id, total_msecs_viewed: 1) + + TagUser.change(user.id, synonym.id, tracking) + expect(TopicUser.get(topic, user).notification_level).to eq tracking + + TagUser.change(user.id, synonym.id, watching) + expect(TopicUser.get(topic, user).notification_level).to eq watching + + TagUser.change(user.id, synonym.id, regular) + expect(TopicUser.get(topic, user).notification_level).to eq tracking + + expect(TagUser.where(user_id: user.id, tag_id: synonym.id).first).to be_nil + expect(TagUser.where(user_id: user.id, tag_id: tag.id).first).to be_present + end end context "batch_set" do @@ -65,6 +87,30 @@ describe TagUser do expect(TopicUser.get(topic, user).notification_level).to eq tracking end + + it "watches and unwatches tags correctly using tag synonym" do + + user = Fabricate(:user) + tag = Fabricate(:tag) + synonym = Fabricate(:tag, target_tag: tag) + post = create_post(tags: [tag.name]) + topic = post.topic + + # we need topic user record to ensure watch picks up other wise it is implicit + TopicUser.change(user.id, topic.id, total_msecs_viewed: 1) + + TagUser.batch_set(user, :tracking, [synonym.name]) + + expect(TopicUser.get(topic, user).notification_level).to eq tracking + + TagUser.batch_set(user, :watching, [synonym.name]) + + expect(TopicUser.get(topic, user).notification_level).to eq watching + + TagUser.batch_set(user, :watching, []) + + expect(TopicUser.get(topic, user).notification_level).to eq tracking + end end context "integration" do diff --git a/spec/models/theme_field_spec.rb b/spec/models/theme_field_spec.rb index 5506305924..c6fc9c5764 100644 --- a/spec/models/theme_field_spec.rb +++ b/spec/models/theme_field_spec.rb @@ -34,6 +34,30 @@ describe ThemeField do expect(theme_field.value_baked).to_not include(' + HTML + theme_field.ensure_baked! + expect(theme_field.error).to include(I18n.t("themes.errors.optimized_link")) + + theme_field = ThemeField.create!(theme_id: 1, target_id: 0, name: "scss", value: <<~SCSS) + body { + background: url(http://mysite.invalid/uploads/default/optimized/1X/6d749a141f513f88f167e750e528515002043da1_2_1282x1000.png); + } + SCSS + theme_field.ensure_baked! + expect(theme_field.error).to include(I18n.t("themes.errors.optimized_link")) + + theme_field.update(value: <<~SCSS) + body { + background: url(http://notdiscourse.invalid/optimized/my_image.png); + } + SCSS + theme_field.ensure_baked! + expect(theme_field.error).to eq(nil) + end + it 'only extracts inline javascript to an external file' do html = <<~HTML