diff --git a/.gitignore b/.gitignore index 601f196f5c..cf4493d442 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,7 @@ discourse.sublime-workspace *~ *.swp *.swo +*.swm # don't check in multisite config config/multisite.yml diff --git a/Gemfile b/Gemfile index 4a7c9a2147..c89be78a7c 100644 --- a/Gemfile +++ b/Gemfile @@ -185,3 +185,5 @@ if ENV["IMPORT"] == "1" gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md' gem 'reverse_markdown' end + +gem 'webpush', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 668d8882a2..c5b26bd87c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -131,6 +131,7 @@ GEM hashie (3.5.5) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http_accept_language (2.0.5) i18n (0.8.6) @@ -164,7 +165,7 @@ GEM mail (2.7.1.rc1) mini_mime (>= 0.1.1) memory_profiler (0.9.10) - message_bus (2.1.4) + message_bus (2.1.5) rack (>= 1.1.3) metaclass (0.0.4) method_source (0.8.2) @@ -251,7 +252,7 @@ GEM public_suffix (2.0.5) puma (3.9.1) r2 (0.2.6) - rack (2.0.4) + rack (2.0.5) rack-mini-profiler (1.0.0) rack (>= 1.2.0) rack-openid (1.3.1) @@ -388,6 +389,9 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff + webpush (0.3.2) + hkdf (~> 0.2) + jwt PLATFORMS ruby @@ -498,6 +502,7 @@ DEPENDENCIES unf unicorn webmock + webpush BUNDLED WITH 1.16.1 diff --git a/app/assets/images/push-notifications/check.png b/app/assets/images/push-notifications/check.png new file mode 100644 index 0000000000..ad223d26f3 Binary files /dev/null and b/app/assets/images/push-notifications/check.png differ diff --git a/app/assets/images/push-notifications/discourse.png b/app/assets/images/push-notifications/discourse.png new file mode 100644 index 0000000000..46d9720633 Binary files /dev/null and b/app/assets/images/push-notifications/discourse.png differ diff --git a/app/assets/images/push-notifications/group_mentioned.png b/app/assets/images/push-notifications/group_mentioned.png new file mode 100644 index 0000000000..ebb5560414 Binary files /dev/null and b/app/assets/images/push-notifications/group_mentioned.png differ diff --git a/app/assets/images/push-notifications/linked.png b/app/assets/images/push-notifications/linked.png new file mode 100644 index 0000000000..5e25f2426a Binary files /dev/null and b/app/assets/images/push-notifications/linked.png differ diff --git a/app/assets/images/push-notifications/mentioned.png b/app/assets/images/push-notifications/mentioned.png new file mode 100644 index 0000000000..ebb5560414 Binary files /dev/null and b/app/assets/images/push-notifications/mentioned.png differ diff --git a/app/assets/images/push-notifications/posted.png b/app/assets/images/push-notifications/posted.png new file mode 100644 index 0000000000..41d02aff0e Binary files /dev/null and b/app/assets/images/push-notifications/posted.png differ diff --git a/app/assets/images/push-notifications/private_message.png b/app/assets/images/push-notifications/private_message.png new file mode 100644 index 0000000000..8e71e69c7f Binary files /dev/null and b/app/assets/images/push-notifications/private_message.png differ diff --git a/app/assets/images/push-notifications/quoted.png b/app/assets/images/push-notifications/quoted.png new file mode 100644 index 0000000000..01d889b468 Binary files /dev/null and b/app/assets/images/push-notifications/quoted.png differ diff --git a/app/assets/images/push-notifications/replied.png b/app/assets/images/push-notifications/replied.png new file mode 100644 index 0000000000..41d02aff0e Binary files /dev/null and b/app/assets/images/push-notifications/replied.png differ diff --git a/app/assets/javascripts/admin/components/admin-report-counts.js.es6 b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 index 46ab32f609..1739a186b3 100644 --- a/app/assets/javascripts/admin/components/admin-report-counts.js.es6 +++ b/app/assets/javascripts/admin/components/admin-report-counts.js.es6 @@ -1,4 +1,5 @@ export default Ember.Component.extend({ + allTime: true, tagName: 'tr', reverseColors: Ember.computed.match('report.type', /^(time_to_first_response|topics_with_no_response)$/), classNameBindings: ['reverseColors'] diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 index 22443906ac..82eceb6691 100644 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 @@ -1,23 +1,38 @@ -import { ajax } from 'discourse/lib/ajax'; +import { ajax } from "discourse/lib/ajax"; import Report from "admin/models/report"; import AsyncReport from "admin/mixins/async-report"; export default Ember.Component.extend(AsyncReport, { classNames: ["dashboard-table", "dashboard-inline-table", "fixed"], - isLoading: true, help: null, helpPage: null, - fetchReport() { - this.set("isLoading", true); + loadReport(report_json) { + return Report.create(report_json); + }, - ajax(this.get("dataSource")) - .then((response) => { - this._setPropertiesFromReport(Report.create(response.report)); - }).finally(() => { - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - }; - }); + fetchReport() { + this._super(); + + let payload = { data: { cache: true, facets: ["total", "prev30Days"] } }; + + if (this.get("startDate")) { + payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("endDate")) { + payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("limit")) { + payload.data.limit = this.get("limit"); + } + + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.get("reports").pushObject(this.loadReport(response.report)); + }); + })); } }); diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 index f5ff432a84..0ac44e2bbb 100644 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 @@ -1,123 +1,151 @@ import { ajax } from "discourse/lib/ajax"; -import computed from "ember-addons/ember-computed-decorators"; import AsyncReport from "admin/mixins/async-report"; import Report from "admin/models/report"; import { number } from 'discourse/lib/formatter'; +import loadScript from "discourse/lib/load-script"; +import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip"; + +function collapseWeekly(data, average) { + let aggregate = []; + let bucket, i; + let offset = data.length % 7; + for(i = offset; i < data.length; i++) { + + if (bucket && (i % 7 === offset)) { + if (average) { + bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); + } + aggregate.push(bucket); + bucket = null; + } + + bucket = bucket || { x: data[i].x, y: 0 }; + bucket.y += data[i].y; + } + return aggregate; +} export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-mini-chart"], - classNameBindings: ["thirtyDayTrend", "oneDataPoint"], - isLoading: true, - thirtyDayTrend: Ember.computed.alias("report.thirtyDayTrend"), - oneDataPoint: false, - backgroundColor: "rgba(200,220,240,0.3)", - borderColor: "#08C", - average: false, + classNames: ["chart", "dashboard-mini-chart"], + total: 0, - willDestroyEelement() { + init() { this._super(); - this.messageBus.unsubscribe(this.get("dataSource")); + this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"]; }, - @computed("dataSourceName") - dataSource(dataSourceName) { - if (dataSourceName) { - return `/admin/reports/${dataSourceName}`; - } + didRender() { + this._super(); + registerTooltip($(this.element).find("[data-tooltip]")); }, - @computed("thirtyDayTrend") - trendIcon(thirtyDayTrend) { - switch (thirtyDayTrend) { - case "trending-up": - return "angle-up"; - case "trending-down": - return "angle-down"; - case "high-trending-up": - return "angle-double-up"; - case "high-trending-down": - return "angle-double-down"; - default: - return null; - } + willDestroyElement() { + this._super(); + unregisterTooltip($(this.element).find("[data-tooltip]")); + }, + + pickColorAtIndex(index) { + return this._colorsPool[index] || this._colorsPool[0]; }, fetchReport() { - this.set("isLoading", true); + this._super(); let payload = { - data: { async: true } + data: { cache: true, facets: ["prev_period"] } }; if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); + payload.data.start_date = this.get("startDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); } if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); + payload.data.end_date = this.get("endDate").locale('en').format('YYYY-MM-DD[T]HH:mm:ss.SSSZZ'); } - ajax(this.get("dataSource"), payload) - .then((response) => { - // if (!Ember.isEmpty(response.report.data)) { - this._setPropertiesFromReport(Report.create(response.report)); - // } - }) - .finally(() => { - if (this.get("oneDataPoint")) { - this.set("isLoading", false); - return; - } + if (this._chart) { + this._chart.destroy(); + this._chart = null; + } - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - this.renderReport(); - } - }); + return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => { + return ajax(dataSource, payload) + .then(response => { + this.get("reports").pushObject(this.loadReport(response.report)); + }); + })); + }, + + loadReport(report, previousReport) { + Report.fillMissingDates(report); + + if (report.data && report.data.length > 40) { + report.data = collapseWeekly(report.data, report.average); + } + + if (previousReport && previousReport.color.length) { + report.color = previousReport.color; + } else { + const dataSourceNameIndex = this.get("dataSourceNames").split(",").indexOf(report.type); + report.color = this.pickColorAtIndex(dataSourceNameIndex); + } + + return Report.create(report); }, renderReport() { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - if (this.get("oneDataPoint")) return; + this._super(); Ember.run.schedule("afterRender", () => { const $chartCanvas = this.$(".chart-canvas"); - if (!$chartCanvas.length) return; const context = $chartCanvas[0].getContext("2d"); + const reportsForPeriod = this.get("reportsForPeriod"); + + const labels = Ember.makeArray(reportsForPeriod.get("firstObject.data")).map(d => d.x); + const data = { - labels: this.get("labels"), - datasets: [{ - data: Ember.makeArray(this.get("values")), - backgroundColor: this.get("backgroundColor"), - borderColor: this.get("borderColor") - }] + labels, + datasets: reportsForPeriod.map(report => { + return { + data: Ember.makeArray(report.data).map(d => d.y), + backgroundColor: "rgba(200,220,240,0.3)", + borderColor: report.color + }; + }) }; - this._chart = new window.Chart(context, this._buildChartConfig(data)); + if (this._chart) { + this._chart.destroy(); + this._chart = null; + } + + loadScript("/javascripts/Chart.min.js").then(() => { + if (this._chart) { + this._chart.destroy(); + } + this._chart = new window.Chart(context, this._buildChartConfig(data)); + }); }); }, - _setPropertiesFromReport(report) { - const oneDataPoint = (this.get("startDate") && this.get("endDate")) && - this.get("startDate").isSame(this.get("endDate"), "day"); - - report.set("average", this.get("average")); - - this.setProperties({ oneDataPoint, report }); - }, - _buildChartConfig(data) { return { type: "line", data, options: { + tooltips: { + callbacks: { + title: (context) => moment(context[0].xLabel, "YYYY-MM-DD").format("LL") + } + }, legend: { display: false }, responsive: true, + maintainAspectRatio: false, layout: { padding: { left: 0, @@ -133,6 +161,7 @@ export default Ember.Component.extend(AsyncReport, { }], xAxes: [{ display: true, + gridLines: { display: false }, type: "time", time: { parser: "YYYY-MM-DD" diff --git a/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 b/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 deleted file mode 100644 index f6523f6841..0000000000 --- a/app/assets/javascripts/admin/components/dashboard-table-trending-search.js.es6 +++ /dev/null @@ -1,8 +0,0 @@ -import DashboardTable from "admin/components/dashboard-table"; -import AsyncReport from "admin/mixins/async-report"; - -export default DashboardTable.extend(AsyncReport, { - layoutName: "admin/templates/components/dashboard-table", - - classNames: ["dashboard-table", "dashboard-table-trending-search"] -}); diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6 deleted file mode 100644 index b674836707..0000000000 --- a/app/assets/javascripts/admin/components/dashboard-table.js.es6 +++ /dev/null @@ -1,50 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import Report from "admin/models/report"; -import AsyncReport from "admin/mixins/async-report"; -import computed from "ember-addons/ember-computed-decorators"; -import { number } from 'discourse/lib/formatter'; - -export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-table"], - help: null, - helpPage: null, - - @computed("report") - values(report) { - if (!report) return; - return Ember.makeArray(report.data) - .sort((a, b) => a.x >= b.x) - .map(x => { - return [ x[0], number(x[1]), number(x[2]) ]; - }); - }, - - @computed("report") - labels(report) { - if (!report) return; - return Ember.makeArray(report.labels); - }, - - fetchReport() { - this.set("isLoading", true); - - let payload = { data: { async: true } }; - - if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); - } - - ajax(this.get("dataSource"), payload) - .then((response) => { - this._setPropertiesFromReport(Report.create(response.report)); - }).finally(() => { - if (!Ember.isEmpty(this.get("report.data"))) { - this.set("isLoading", false); - }; - }); - } -}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 index 2d7fc62183..2f24cf9edd 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 @@ -1,15 +1,39 @@ +import { setting } from "discourse/lib/computed"; import DiscourseURL from "discourse/lib/url"; import computed from "ember-addons/ember-computed-decorators"; import AdminDashboardNext from "admin/models/admin-dashboard-next"; import Report from "admin/models/report"; +import VersionCheck from "admin/models/version-check"; + +const PROBLEMS_CHECK_MINUTES = 1; export default Ember.Controller.extend({ queryParams: ["period"], - period: "all", + period: "monthly", isLoading: false, dashboardFetchedAt: null, exceptionController: Ember.inject.controller("exception"), + showVersionChecks: setting("version_checks"), diskSpace: Ember.computed.alias("model.attributes.disk_space"), + logSearchQueriesEnabled: setting("log_search_queries"), + availablePeriods: ["yearly", "quarterly", "monthly", "weekly"], + + @computed("problems.length") + foundProblems(problemsLength) { + return this.currentUser.get("admin") && (problemsLength || 0) > 0; + }, + + @computed("foundProblems") + thereWereProblems(foundProblems) { + if (!this.currentUser.get("admin")) { return false; } + + if (foundProblems) { + this.set("hadProblems", true); + return true; + } else { + return this.get("hadProblems") || false; + } + }, fetchDashboard() { if (this.get("isLoading")) return; @@ -17,7 +41,14 @@ export default Ember.Controller.extend({ if (!this.get("dashboardFetchedAt") || moment().subtract(30, "minutes").toDate() > this.get("dashboardFetchedAt")) { this.set("isLoading", true); + const versionChecks = this.siteSettings.version_checks; + AdminDashboardNext.find().then(adminDashboardNextModel => { + + if (versionChecks) { + this.set("versionCheck", VersionCheck.create(adminDashboardNextModel.version_check)); + } + this.setProperties({ dashboardFetchedAt: new Date(), model: adminDashboardNextModel, @@ -30,34 +61,57 @@ export default Ember.Controller.extend({ this.set("isLoading", false); }); } + + if (!this.get("problemsFetchedAt") || moment().subtract(PROBLEMS_CHECK_MINUTES, "minutes").toDate() > this.get("problemsFetchedAt")) { + this.loadProblems(); + } + }, + + loadProblems() { + this.set("loadingProblems", true); + this.set("problemsFetchedAt", new Date()); + AdminDashboardNext.fetchProblems().then(d => { + this.set("problems", d.problems); + }).finally(() => { + this.set("loadingProblems", false); + }); + }, + + @computed("problemsFetchedAt") + problemsTimestamp(problemsFetchedAt) { + return moment(problemsFetchedAt).locale("en").format("LLL"); }, @computed("period") startDate(period) { + let fullDay = moment().locale("en").utc().subtract(1, "day"); + switch (period) { case "yearly": - return moment().subtract(1, "year").startOf("day"); + return fullDay.subtract(1, "year").startOf("day"); break; case "quarterly": - return moment().subtract(3, "month").startOf("day"); + return fullDay.subtract(3, "month").startOf("day"); break; case "weekly": - return moment().subtract(1, "week").startOf("day"); + return fullDay.subtract(1, "week").startOf("day"); break; case "monthly": - return moment().subtract(1, "month").startOf("day"); - break; - case "daily": - return moment().startOf("day"); + return fullDay.subtract(1, "month").startOf("day"); break; default: - return null; + return fullDay.subtract(1, "month").startOf("day"); } }, - @computed("period") - endDate(period) { - return period === "all" ? null : moment().endOf("day"); + @computed() + lastWeek() { + return moment().locale("en").utc().endOf("day").subtract(1, "week"); + }, + + @computed() + endDate() { + return moment().locale("en").utc().subtract(1, "day").endOf("day"); }, @computed("updated_at") @@ -73,10 +127,13 @@ export default Ember.Controller.extend({ actions: { changePeriod(period) { DiscourseURL.routeTo(this._reportsForPeriodURL(period)); - } + }, + refreshProblems() { + this.loadProblems(); + }, }, _reportsForPeriodURL(period) { - return `/admin/dashboard-next?period=${period}`; + return Discourse.getURL(`/admin?period=${period}`); } }); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 index 6652002fb4..973cd3d57c 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard.js.es6 @@ -1,11 +1,8 @@ -import { setting } from 'discourse/lib/computed'; import AdminDashboard from 'admin/models/admin-dashboard'; -import VersionCheck from 'admin/models/version-check'; import Report from 'admin/models/report'; import AdminUser from 'admin/models/admin-user'; import computed from 'ember-addons/ember-computed-decorators'; -const PROBLEMS_CHECK_MINUTES = 1; const ATTRIBUTES = [ 'disk_space','admins', 'moderators', 'silenced', 'suspended', 'top_traffic_sources', 'top_referred_topics', 'updated_at']; @@ -18,35 +15,13 @@ export default Ember.Controller.extend({ loading: null, versionCheck: null, dashboardFetchedAt: null, - showVersionChecks: setting('version_checks'), exceptionController: Ember.inject.controller('exception'), - @computed('problems.length') - foundProblems(problemsLength) { - return this.currentUser.get('admin') && (problemsLength || 0) > 0; - }, - - @computed('foundProblems') - thereWereProblems(foundProblems) { - if (!this.currentUser.get('admin')) { return false; } - - if (foundProblems) { - this.set('hadProblems', true); - return true; - } else { - return this.get('hadProblems') || false; - } - }, - fetchDashboard() { if (!this.get('dashboardFetchedAt') || moment().subtract(30, 'minutes').toDate() > this.get('dashboardFetchedAt')) { this.set('loading', true); - const versionChecks = this.siteSettings.version_checks; AdminDashboard.find().then(d => { this.set('dashboardFetchedAt', new Date()); - if (versionChecks) { - this.set('versionCheck', VersionCheck.create(d.version_check)); - } REPORTS.forEach(name => this.set(name, d[name].map(r => Report.create(r)))); @@ -64,26 +39,8 @@ export default Ember.Controller.extend({ this.set('loading', false); }); } - - if (!this.get('problemsFetchedAt') || moment().subtract(PROBLEMS_CHECK_MINUTES, 'minutes').toDate() > this.get('problemsFetchedAt')) { - this.loadProblems(); - } }, - loadProblems() { - this.set('loadingProblems', true); - this.set('problemsFetchedAt', new Date()); - AdminDashboard.fetchProblems().then(d => { - this.set('problems', d.problems); - }).finally(() => { - this.set('loadingProblems', false); - }); - }, - - @computed('problemsFetchedAt') - problemsTimestamp(problemsFetchedAt) { - return moment(problemsFetchedAt).format('LLL'); - }, @computed('updated_at') updatedTimestamp(updatedAt) { @@ -91,9 +48,6 @@ export default Ember.Controller.extend({ }, actions: { - refreshProblems() { - this.loadProblems(); - }, showTrafficReport() { this.set("showTrafficReport", true); } 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 df0738bed1..c2d96201bd 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,16 +1,16 @@ +import computed from "ember-addons/ember-computed-decorators"; + export default Ember.Controller.extend({ categoryNameKey: null, adminSiteSettings: Ember.inject.controller(), - filteredContent: function() { - if (!this.get('categoryNameKey')) { return []; } - - const category = (this.get('adminSiteSettings.model') || []).findBy('nameKey', this.get('categoryNameKey')); - if (category) { - return category.siteSettings; - } else { - return []; - } - }.property('adminSiteSettings.model', 'categoryNameKey') + @computed("adminSiteSettings.model", "categoryNameKey") + category(categories, nameKey) { + return (categories || []).findBy("nameKey", nameKey); + }, + @computed("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 6edbe9e01f..6469efa6cc 100644 --- a/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-site-settings.js.es6 @@ -3,7 +3,6 @@ import debounce from 'discourse/lib/debounce'; export default Ember.Controller.extend({ filter: null, onlyOverridden: false, - filtered: Ember.computed.notEmpty('filter'), filterContentNow(category) { // If we have no content, don't bother filtering anything @@ -14,9 +13,9 @@ export default Ember.Controller.extend({ filter = this.get('filter').toLowerCase(); } - if ((filter === undefined || filter.length < 1) && !this.get('onlyOverridden')) { + if ((!filter || 0 === filter.length) && !this.get('onlyOverridden')) { this.set('model', this.get('allSiteSettings')); - this.transitionToRoute("adminSiteSettings"); + this.transitionToRoute('adminSiteSettings'); return; } @@ -28,11 +27,11 @@ export default Ember.Controller.extend({ const siteSettings = settingsCategory.siteSettings.filter(item => { if (this.get('onlyOverridden') && !item.get('overridden')) return false; if (filter) { - if (item.get('setting').toLowerCase().indexOf(filter) > -1) return true; - if (item.get('setting').toLowerCase().replace(/_/g, ' ').indexOf(filter) > -1) return true; - if (item.get('description').toLowerCase().indexOf(filter) > -1) return true; - if ((item.get('value') || '').toLowerCase().indexOf(filter) > -1) return true; - return false; + const setting = item.get('setting').toLowerCase(); + return setting.includes(filter) || + setting.replace(/_/g, ' ').includes(filter) || + item.get('description').toLowerCase().includes(filter) || + (item.get('value') || '').toLowerCase().includes(filter); } else { return true; } @@ -49,15 +48,16 @@ export default Ember.Controller.extend({ }); all.siteSettings.pushObjects(matches.slice(0, 30)); - all.count = matches.length; + all.hasMore = matches.length > 30; + all.count = all.hasMore ? '30+' : matches.length; this.set('model', matchesGroupedByCategory); - this.transitionToRoute("adminSiteSettingsCategory", category || "all_results"); + this.transitionToRoute('adminSiteSettingsCategory', category || 'all_results'); }, filterContent: debounce(function() { - if (this.get("_skipBounce")) { - this.set("_skipBounce", false); + if (this.get('_skipBounce')) { + this.set('_skipBounce', false); } else { this.filterContentNow(); } 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 56e43ccbd8..f47869d646 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,6 +5,24 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; const THEME_FIELD_VARIABLE_TYPE_IDS = [2, 3, 4]; +const SCSS_VARIABLE_NAMES = [ + // common/foundation/colors.scss + "primary", "secondary", "tertiary", "quaternary", "header_background", + "header_primary", "highlight", "danger", "success", "love", + // common/foundation/math.scss + "E", "PI", "LN2", "SQRT2", + // common/foundation/variables.scss + "small-width", "medium-width", "large-width", + "google", "instagram", "facebook", "cas", "twitter", "yahoo", "github", + "base-font-size", "base-line-height", "base-font-family", + "primary-low", "primary-medium", + "secondary-low", "secondary-medium", + "tertiary-low", "quaternary-low", + "highlight-low", "highlight-medium", + "danger-low", "danger-medium", + "success-low", "love-low", +]; + export default Ember.Controller.extend(ModalFunctionality, { adminCustomizeThemesShow: Ember.inject.controller(), @@ -19,10 +37,23 @@ export default Ember.Controller.extend(ModalFunctionality, { disabled: Em.computed.not('enabled'), @computed('name', 'adminCustomizeThemesShow.model.theme_fields') - nameValid(name, themeFields) { - return name && - name.match(/^[a-z_][a-z0-9_-]*$/i) && - !themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name); + errorMessage(name, themeFields) { + if (name) { + if (!name.match(/^[a-z_][a-z0-9_-]*$/i)) { + return I18n.t("admin.customize.theme.variable_name_error.invalid_syntax"); + } else if (SCSS_VARIABLE_NAMES.includes(name.toLowerCase())) { + return I18n.t("admin.customize.theme.variable_name_error.no_overwrite"); + } else if (themeFields.some(tf => THEME_FIELD_VARIABLE_TYPE_IDS.includes(tf.type_id) && name === tf.name)) { + return I18n.t("admin.customize.theme.variable_name_error.must_be_unique"); + } + } + + return null; + }, + + @computed('errorMessage') + nameValid(errorMessage) { + return null === errorMessage; }, @observes('name') diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 index 5206f65186..2c22ab15c2 100644 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ b/app/assets/javascripts/admin/mixins/async-report.js.es6 @@ -1,68 +1,80 @@ -import computed from 'ember-addons/ember-computed-decorators'; -import Report from "admin/models/report"; +import computed from "ember-addons/ember-computed-decorators"; export default Ember.Mixin.create({ classNameBindings: ["isLoading"], - - report: null, + reports: null, + isLoading: false, + dataSourceNames: "", + title: null, init() { this._super(); + this.set("reports", []); + }, - this.messageBus.subscribe(this.get("dataSource"), report => { - const formatDate = (date) => moment(date).format("YYYYMMDD"); + @computed("dataSourceNames") + dataSources(dataSourceNames) { + return dataSourceNames.split(",").map(source => `/admin/reports/${source}`); + }, - // this check is done to avoid loading a chart after period has changed - if ( - (this.get("startDate") && formatDate(report.start_date) === formatDate(this.get("startDate"))) && - (this.get("endDate") && formatDate(report.end_date) === formatDate(this.get("endDate"))) - ) { - this._setPropertiesFromReport(Report.create(report)); - this.set("isLoading", false); - this.renderReport(); + @computed("reports.[]", "startDate", "endDate", "dataSourceNames") + reportsForPeriod(reports, startDate, endDate, dataSourceNames) { + // on a slow network fetchReport could be called multiple times between + // T and T+x, and all the ajax responses would occur after T+(x+y) + // to avoid any inconsistencies we filter by period and make sure + // the array contains only unique values + reports = reports.uniqBy("report_key"); + + + const sort = (r) => { + if (r.length > 1) { + return dataSourceNames + .split(",") + .map(name => r.findBy("type", name)); } else { - this._setPropertiesFromReport(Report.create(report)); - this.set("isLoading", false); - this.renderReport(); + return r; } - }); + }; + + if (!startDate || !endDate) { + return sort(reports); + } + + + return sort(reports.filter(report => { + return report.report_key.includes(startDate.format("YYYYMMDD")) && + report.report_key.includes(endDate.format("YYYYMMDD")); + })); }, didInsertElement() { this._super(); - Ember.run.later(this, function() { - this.fetchReport(); - }, 500); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, didUpdateAttrs() { this._super(); - this.fetchReport(); + this.fetchReport() + .finally(() => { + this.renderReport(); + }); }, - renderReport() {}, - - @computed("dataSourceName") - dataSource(dataSourceName) { - return `/admin/reports/${dataSourceName}`; + renderReport() { + if (!this.element || this.isDestroying || this.isDestroyed) return; + this.set("title", this.get("reportsForPeriod").map(r => r.title).join(", ")); + this.set("isLoading", false); }, - @computed("report") - labels(report) { - if (!report) return; - return Ember.makeArray(report.data).map(r => r.x); - }, + loadReport() {}, - @computed("report") - values(report) { - if (!report) return; - return Ember.makeArray(report.data).map(r => r.y); + fetchReport() { + this.set("reports", []); + this.set("isLoading", true); }, - - _setPropertiesFromReport(report) { - if (!this.element || this.isDestroying || this.isDestroyed) { return; } - this.setProperties({ report }); - } }); diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 index 4747529fe7..db3adf2248 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 @@ -13,10 +13,13 @@ AdminDashboardNext.reopenClass({ @return {jqXHR} a jQuery Promise object **/ find() { + return ajax("/admin/dashboard-next.json").then(function(json) { + var model = AdminDashboardNext.create(); model.set("reports", json.reports); + model.set("version_check", json.version_check); const attributes = {}; ATTRIBUTES.forEach(a => attributes[a] = json[a]); @@ -24,6 +27,25 @@ AdminDashboardNext.reopenClass({ model.set("loaded", true); + return model; + }); + }, + + + /** + Only fetch the list of problems that should be rendered on the dashboard. + The model will only have its "problems" attribute set. + + @method fetchProblems + @return {jqXHR} a jQuery Promise object + **/ + fetchProblems: function() { + return ajax("/admin/dashboard/problems.json", { + type: 'GET', + dataType: 'json' + }).then(function(json) { + var model = AdminDashboardNext.create(json); + model.set('loaded', true); return model; }); } diff --git a/app/assets/javascripts/admin/models/admin-dashboard.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard.js.es6 index ac44a7677f..49ed8e1a07 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard.js.es6 @@ -19,23 +19,6 @@ AdminDashboard.reopenClass({ }); }, - /** - Only fetch the list of problems that should be rendered on the dashboard. - The model will only have its "problems" attribute set. - - @method fetchProblems - @return {jqXHR} a jQuery Promise object - **/ - fetchProblems: function() { - return ajax("/admin/dashboard/problems.json", { - type: 'GET', - dataType: 'json' - }).then(function(json) { - var model = AdminDashboard.create(json); - model.set('loaded', true); - return model; - }); - } }); export default AdminDashboard; diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6 index 6cd414e976..b826426473 100644 --- a/app/assets/javascripts/admin/models/admin-user.js.es6 +++ b/app/assets/javascripts/admin/models/admin-user.js.es6 @@ -300,7 +300,8 @@ const AdminUser = Discourse.User.extend({ deactivate() { return ajax('/admin/users/' + this.id + '/deactivate', { - type: 'PUT' + type: 'PUT', + data: { context: document.location.pathname } }).then(function() { window.location.reload(); }).catch(function(e) { diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index 1113f2e5d1..5441be004a 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -1,17 +1,22 @@ import { ajax } from 'discourse/lib/ajax'; import round from "discourse/lib/round"; -import { fmt } from 'discourse/lib/computed'; import { fillMissingDates } from 'discourse/lib/utilities'; import computed from 'ember-addons/ember-computed-decorators'; const Report = Discourse.Model.extend({ average: false, + percent: false, - reportUrl: fmt("type", "/admin/reports/%@"), + @computed("type", "start_date", "end_date") + reportUrl(type, start_date, end_date) { + start_date = moment(start_date).locale('en').format("YYYY-MM-DD"); + end_date = moment(end_date).locale('en').format("YYYY-MM-DD"); + return Discourse.getURL(`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`); + }, valueAt(numDaysAgo) { if (this.data) { - const wantedDate = moment().subtract(numDaysAgo, "days").format("YYYY-MM-DD"); + const wantedDate = moment().subtract(numDaysAgo, "days").locale('en').format("YYYY-MM-DD"); const item = this.data.find(d => d.x === wantedDate); if (item) { return item.y; @@ -90,6 +95,50 @@ const Report = Discourse.Model.extend({ } }, + @computed('data') + currentTotal(data){ + return _.reduce(data, (cur, pair) => cur + pair.y, 0); + }, + + @computed('data', 'currentTotal') + currentAverage(data, total) { + return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1)); + }, + + @computed("trend") + trendIcon(trend) { + switch (trend) { + case "trending-up": + return "angle-up"; + case "trending-down": + return "angle-down"; + case "high-trending-up": + return "angle-double-up"; + case "high-trending-down": + return "angle-double-down"; + default: + return null; + } + }, + + @computed('prev_period', 'currentTotal', 'currentAverage') + trend(prev, currentTotal, currentAverage) { + const total = this.get('average') ? currentAverage : currentTotal; + const change = ((total - prev) / total) * 100; + + if (change > 50) { + return "high-trending-up"; + } else if (change > 0) { + return "trending-up"; + } else if (change === 0) { + return "no-change"; + } else if (change < -50) { + return "high-trending-down"; + } else if (change < 0) { + return "trending-down"; + } + }, + @computed('prev30Days', 'lastThirtyDaysCount') thirtyDayTrend(prev30Days, lastThirtyDaysCount) { const currentPeriod = lastThirtyDaysCount; @@ -110,6 +159,9 @@ const Report = Discourse.Model.extend({ @computed('type') icon(type) { + if (type.indexOf("message") > -1) { + return "envelope"; + } switch (type) { case "flags": return "flag"; case "likes": return "heart"; @@ -138,6 +190,22 @@ const Report = Discourse.Model.extend({ } }, + @computed('prev_period', 'currentTotal', 'currentAverage') + trendTitle(prev, currentTotal, currentAverage) { + let current = this.get('average') ? currentAverage : currentTotal; + let percent = this.percentChangeString(current, prev); + + if (this.get('average')) { + prev = prev ? prev.toFixed(1) : "0"; + if (this.get('percent')) { + current += '%'; + prev += '%'; + } + } + + return I18n.t('admin.dashboard.reports.trend_title', {percent: percent, prev: prev, current: current}); + }, + changeTitle(val1, val2, prevPeriodString) { const percentChange = this.percentChangeString(val1, val2); var title = ""; @@ -176,6 +244,15 @@ const Report = Discourse.Model.extend({ Report.reopenClass({ + fillMissingDates(report) { + if (_.isArray(report.data)) { + + const startDateFormatted = moment.utc(report.start_date).locale('en').format('YYYY-MM-DD'); + const endDateFormatted = moment.utc(report.end_date).locale('en').format('YYYY-MM-DD'); + report.data = fillMissingDates(report.data, startDateFormatted, endDateFormatted); + } + }, + find(type, startDate, endDate, categoryId, groupId) { return ajax("/admin/reports/" + type, { data: { @@ -186,11 +263,7 @@ Report.reopenClass({ } }).then(json => { // Add zero values for missing dates - if (json.report.data.length > 0) { - const startDateFormatted = moment(json.report.start_date).utc().format('YYYY-MM-DD'); - const endDateFormatted = moment(json.report.end_date).utc().format('YYYY-MM-DD'); - json.report.data = fillMissingDates(json.report.data, startDateFormatted, endDateFormatted); - } + Report.fillMissingDates(json.report); const model = Report.create({ type: type }); model.setProperties(json.report); diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 index c877f07bf5..30ca9b033c 100644 --- a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 @@ -1,9 +1,5 @@ -import loadScript from "discourse/lib/load-script"; - export default Discourse.Route.extend({ activate() { - loadScript("/javascripts/Chart.min.js").then(() => { - this.controllerFor('admin-dashboard-next').fetchDashboard(); - }); + this.controllerFor('admin-dashboard-next').fetchDashboard(); } }); diff --git a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 index 820c5207d4..8c5886f4a4 100644 --- a/app/assets/javascripts/admin/routes/admin-plugins.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-plugins.js.es6 @@ -8,12 +8,15 @@ export default Ember.Route.extend({ const controller = this.controllerFor('adminSiteSettings'); this.transitionTo('adminSiteSettingsCategory', 'plugins').then(() => { if (plugin) { + const siteSettingFilter = plugin.get('enabled_setting_filter'); const match = /^(.*)_enabled/.exec(plugin.get('enabled_setting')); - if (match[1]) { + const filter = siteSettingFilter || match[1]; + + if (filter) { // filterContent() is normally on a debounce from typing. // Because we don't want the default of "All Results", we tell it // to skip the next debounce. - controller.set('filter', match[1]); + controller.set('filter', filter); controller.set('_skipBounce', true); controller.filterContentNow('plugins'); } @@ -22,4 +25,3 @@ export default Ember.Route.extend({ } } }); - diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 5b42f045e1..5a033e1efa 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,7 +1,7 @@ export default function() { this.route('admin', { resetNamespace: true }, function() { - this.route('dashboard', { path: '/' }); - this.route('dashboardNext', { path: '/dashboard-next' }); + this.route('dashboard', { path: '/dashboard-old' }); + this.route('dashboardNext', { path: '/' }); this.route('adminSiteSettings', { path: '/site_settings', resetNamespace: true }, function() { this.route('adminSiteSettingsCategory', { path: 'category/:category_id', resetNamespace: true} ); }); diff --git a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp b/app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl similarity index 58% rename from plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp rename to app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl index 7411da1944..23cc50b1f3 100644 Binary files a/plugins/discourse-local-dates/assets/javascripts/lib/discourse-markdown/.discourse-cronos.js.es6.swp and b/app/assets/javascripts/admin/templates/.dashboard_next.hbs.swl differ diff --git a/app/assets/javascripts/admin/templates/admin.hbs b/app/assets/javascripts/admin/templates/admin.hbs index 092133b7c1..cf4d231d4e 100644 --- a/app/assets/javascripts/admin/templates/admin.hbs +++ b/app/assets/javascripts/admin/templates/admin.hbs @@ -3,7 +3,7 @@
| {{label}} | - {{/each}} -
|---|
| {{number value}} | - {{/each}} -
| {{label}} | + {{/each}} + {{else}} + {{#each report.data as |data|}} +{{data.x}} | + {{/each}} + {{/if}} +
|---|---|
| {{number data.y}} | +
| {{label}} | - {{/each}} -
|---|
| {{v}} | - {{/each}} -
+ {{i18n 'admin.dashboard.last_checked'}}: {{problemsTimestamp}} + {{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}} +
+ {{/conditional-loading-spinner}} ++ {{i18n 'admin.dashboard.no_problems'}} + {{d-button action="refreshProblems" class="btn-small" icon="refresh" label="admin.dashboard.refresh_problems"}} +
+| {{i18n 'admin.dashboard.reports.yesterday'}} | {{i18n 'admin.dashboard.reports.last_7_days'}} | {{i18n 'admin.dashboard.reports.last_30_days'}} | -{{i18n 'admin.dashboard.reports.all'}} |
|---|
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
@@ -95,30 +109,77 @@
{{/if}}
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
- {{i18n "admin.dashboard.last_updated"}} {{updatedTimestamp}} +
{{updatedTimestamp}}
+ + {{i18n "admin.dashboard.whats_new_in_discourse"}} + ++ {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}}
- - {{i18n "admin.dashboard.whats_new_in_discourse"}} - -{{model.description}}
+{{/if}} +{{i18n 'admin.site_settings.more_than_30_results'}}
+ {{/if}} {{/d-section}} {{else}}| - | {{i18n 'admin.dashboard.installed_version'}} | -{{i18n 'admin.dashboard.latest_version'}} | -{{i18n 'admin.dashboard.version'}} | -{{dash-if-empty versionCheck.installed_describe}} | +— | -- {{d-icon "frown-o"}} - | -- {{i18n 'admin.dashboard.no_check_performed'}} - | +{{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}} | -- {{#if versionCheck.version_check_pending}} - {{d-icon "smile-o"}} - {{else}} - {{d-icon "frown-o"}} - {{/if}} - | -
-
+
+
+ {{i18n 'admin.dashboard.latest_version'}}+{{#if versionCheck.version_check_pending}}{{dash-if-empty versionCheck.installed_version}}{{/if}}+
+
{{#if versionCheck.version_check_pending}}
- {{i18n 'admin.dashboard.version_check_pending'}}
+ {{d-icon "smile-o"}}
{{else}}
- {{i18n 'admin.dashboard.stale_data'}}
+ {{d-icon "frown-o"}}
{{/if}}
-
- |
- {{else}}
- {{dash-if-empty versionCheck.latest_version}} | -
- {{#if versionCheck.upToDate }}
- {{d-icon "smile-o"}}
- {{else}}
-
- {{#if versionCheck.behindByOneVersion}}
- {{d-icon "meh-o"}}
+
+
+
+ {{#if versionCheck.version_check_pending}}
+ {{i18n 'admin.dashboard.version_check_pending'}}
{{else}}
- {{d-icon "frown-o"}}
+ {{i18n 'admin.dashboard.stale_data'}}
{{/if}}
- {{/if}}
- |
- - {{#if versionCheck.upToDate }} - {{i18n 'admin.dashboard.up_to_date'}} - {{else}} - {{i18n 'admin.dashboard.critical_available'}} - {{i18n 'admin.dashboard.updates_available'}} - {{i18n 'admin.dashboard.please_upgrade'}} - {{/if}} - | + + + {{else}} +
|---|---|---|---|---|
{{i18n "topic.feature_topic.pin_note"}}
-- {{{pinMessage}}} - {{d-icon "clock-o"}} - {{date-picker-future value=model.pinnedInCategoryUntil}} - {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} -
+ {{#if site.isMobileDevice}} ++ {{{pinMessage}}} +
++ {{date-picker-future value=model.pinnedInCategoryUntil}} + {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} +
+ {{else}} ++ {{{pinMessage}}} + {{d-icon "clock-o"}} + {{date-picker-future value=model.pinnedInCategoryUntil}} + {{popup-input-tip validation=pinInCategoryValidation shownAt=pinInCategoryTipShownAt}} +
+ {{/if}}{{d-button action="pin" icon="thumb-tack" label="topic.feature.pin" class="btn-primary"}}
@@ -62,12 +72,22 @@{{i18n "topic.feature_topic.global_pin_note"}}
-- {{i18n "topic.feature_topic.pin_globally"}} - {{d-icon "clock-o"}} - {{date-picker-future value=model.pinnedGloballyUntil}} - {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} -
+ {{#if site.isMobileDevice}} ++ {{i18n "topic.feature_topic.pin_globally"}} +
++ {{date-picker-future value=model.pinnedGloballyUntil}} + {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} +
+ {{else}} ++ {{i18n "topic.feature_topic.pin_globally"}} + {{d-icon "clock-o"}} + {{date-picker-future value=model.pinnedGloballyUntil}} + {{popup-input-tip validation=pinGloballyValidation shownAt=pinGloballyTipShownAt}} +
+ {{/if}}{{d-button action="pinGlobally" icon="thumb-tack" label="topic.feature.pin_globally" class="btn-primary"}}
diff --git a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs index 6d6a535f59..d4d258c748 100644 --- a/app/assets/javascripts/discourse/templates/preferences/notifications.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/notifications.hbs @@ -24,6 +24,7 @@ {{desktop-notification-config}}لست متأكداً اي عنوان بريد الكتروني قمت بأستخدامة؟ قم بأدخال عنوان البريد الالكتروني و سنعلمك ان كان موجوداً هنا
ان لم يعد بأمكانك الوصول الى البريد الالكتروني في حسابك, رجاء اتصل بـ طاقم الدعم الخاص بنا
" button_ok: "حسنا" button_help: "مساعدة" + email_login: + button_label: "مع البريد الإلكتروني" login: title: "تسجيل دخول" username: "اسم المستخدم" @@ -1789,7 +1806,6 @@ ar: other: الرجاء اختيار الكاتب الجديد لـ {{count}} منشور نُشر بواسطة {{old_user}}. two: الرجاء اختيار المالك الجديد لمنشورين نُشروا بواسطة {{old_user}}. zero: لم يتم تحديد أي منشورات! - instructions_warn: "ملاحطة لن يتم نقل الاشعارت القديمة الخاصة بالمنشور للكاتب الجديدDeine Einladung zu %{site_name} wurde bereits zurückgezogen.
+ +Wenn du dein Passwort noch weißt, kannst du dich anmelden.
+ +Ansonsten kannst du dein Passwort zurücksetzen.
user_exists: "Es ist nicht nötig, %{email} einzuladen, er/sie hat schon ein Konto!" bulk_invite: file_should_be_csv: "Die hochzuladende Datei sollte im CSV-Format vorliegen." @@ -761,10 +767,21 @@ de: title: "Neue Benutzer" xaxis: "Tag" yaxis: "Anzahl neuer Benutzer" + description: "Neue Registrierungen in dieser Zeit" new_contributors: title: "Neue Mitwirkende" xaxis: "Tag" yaxis: "Anzahl neuer Mitwirkender" + description: "Anzahl der Benutzer, die ihren ersten Beitrag in dieser Zeit erstellt haben" + dau_by_mau: + title: "TAB/MAB" + xaxis: "Tag" + yaxis: "TAB/MAJ" + daily_engaged_users: + title: "Täglich engagierte Benutzer" + xaxis: "Tag" + yaxis: "Engagierte Benutzer" + description: "Anzahl der Benutzer, die innerhalb des letzten Tages einen Beitrag geschrieben oder mit einem Like markiert haben" profile_views: title: "Profilaufrufe" xaxis: "Tag" @@ -773,10 +790,12 @@ de: title: "Themen" xaxis: "Tag" yaxis: "Anzahl neuer Themen" + description: "Neue Themen, die in dieser Zeit erstellt wurden" posts: title: "Beiträge" xaxis: "Tag" yaxis: "Anzahl neuer Beiträge" + description: "Neue Beiträge, die in dieser Zeit erstellt wurden" likes: title: "Likes" xaxis: "Tag" @@ -811,15 +830,17 @@ de: labels: term: Begriff searches: Suchen - unique: Eindeutig + click_through: Klickrate emails: title: "Gesendete E-Mails" xaxis: "Tag" yaxis: "Anzahl der E-Mails" user_to_user_private_messages: + title: "Benutzer-an-Benutzer (ohne Antworten)" xaxis: "Tag" yaxis: "Anzahl der Nachrichten" user_to_user_private_messages_with_replies: + title: "Benutzer-an-Benutzer (mit Antworten)" xaxis: "Tag" yaxis: "Anzahl der Nachrichten" system_private_messages: @@ -866,7 +887,6 @@ de: xaxis: "Tag" yaxis: "Aufrufe durch Suchmaschinen" page_view_total_reqs: - title: "Gesamt" xaxis: "Tag" yaxis: "Seitenaufrufe insgesamt" page_view_logged_in_mobile_reqs: @@ -1089,7 +1109,6 @@ de: version_checks: "Kontaktiere den Discourse Hub zur Überprüfung auf neue Versionen und zeige Benachrichtigungen über neue Versionen auf der Administratorkonsole an." new_version_emails: "Sende eine E-Mail an die contact_email Adresse wenn eine neue Version von Discourse verfügbar ist." invite_expiry_days: "Tage, die Benutzereinladungen gültig bleiben." - invite_passthrough_hours: "Wie viele Stunden ein Benutzer einen bereits eingelösten Einladungsschlüssel zum Anmelden verwenden kann" invite_only: "Die öffentliche Registrierungen ist deaktiviert, neue Benutzer müssen explizit vom Team eingeladen werden." login_required: "Nur angemeldete Benutzer dürfen Inhalte der Site lesen, anonyme Zugriffe sind verboten." min_username_length: "Minimale Zeichenlänge für Benutzernamen. WARNUNG: Wenn bestehende Benutzer oder Gruppen kürzere Namen haben, wird deine Seite nicht mehr funktionieren!" @@ -1110,8 +1129,11 @@ de: sso_overrides_username: "Überschreibt lokalen Benutzernamen mit dem Benutzernamen der externen Site aus dem SSO-Payload (WARNUNG: Diskrepanzen können aufgrund von Normalisierung von lokalen Benutzernamen auftreten)" sso_overrides_name: "Überschreibt den vollen Namen des Benutzers mit den Daten von der externen Site aus dem SSO-Payload bei jedem Login. Außerdem werden lokale Änderungen verhindert." sso_overrides_avatar: "Überschreibt das Profilbild des Benutzers mit dem Profilbild aus dem SSO-Payload. Wenn aktiv, dann sollte allow_uploaded_avatars deaktiviert werden." + sso_overrides_profile_background: "Ersetzt das Hintergrundbild des Benutzerprofils mit dem Avatar der externen Seite aus den SSO-Daten" + sso_overrides_card_background: "Ersetzt das Hintergrundbild der Benutzerkarte mit dem Avatar der externen Seite aus den SSO-Daten" sso_not_approved_url: "Nicht genehmigte SSO-Konten zu dieser URL weiterleiten" sso_allows_all_return_paths: "Alle Domain für SSO-Return-Paths zulassen (standardmäßig muss der Return Path auf der aktuellen Seite sein)" + enable_local_logins: "Aktiviert lokale Konten mit Anmeldung über Benutzername und Passwort. Dies muss aktiviert sein, damit Einladungen funktionieren. WARNUNG: Wenn deaktiviert, kannst du dich möglicherweise nicht mehr anmelden, wenn du nicht vorher mindestens eine alternative Login-Methode konfiguriert hast." enable_local_logins_via_email: "Erlaube Benutzern, einen einmaligen Anmeldelink anzufordern, der ihnen als E-Mail geschickt wird." allow_new_registrations: "Erlaube das Registrieren neuer Benutzerkonten. Wird dies deaktiviert, so kann niemand mehr ein neues Konto erstellen." enable_signup_cta: "Zeige wiederkehrenden Gästen einen Hinweis, dass diese sich Anmelden oder Registrieren sollen." @@ -1296,6 +1318,7 @@ de: auto_silence_fast_typers_max_trust_level: "Maximale Vertrauensstufe, um „Schnelltipper“ stummzuschalten." auto_silence_first_post_regex: "Regulärer Ausdruck (ohne Groß- und Kleinschreibung), der dafür sorgt dass entsprechende erste Beiträge von Benutzern stummgeschaltet und in die Genehmigungswarteschlange verschoben werden. Beispiel: raging|a[bc]a sorgt dafür, dass Beiträge, die raging, aba oder aca enthalten, zunächst stummgeschaltet werden. Dies betrifft jedoch nur den ersten Beitrag." flags_default_topics: "Zeige gemeldete Themen standardmäßig im Administrationsbereich" + min_flags_staff_visibility: "Die minimale Anzahl an Meldungen, die ein Beitrag haben muss, bevor ihn das Team im Administrationsbereich sehen kann." reply_by_email_enabled: "Aktviere das Antworten auf Themen via E-Mail." reply_by_email_address: "Vorlage für die Antwort einer per E-Mail eingehender E-Mail-Adresse, zum Beispiel: %{reply_key}@reply.example.com oder replies+%{reply_key}@example.com" alternative_reply_by_email_addresses: "Liste alternativer Vorlagen für eingehende E-Mail-Adressen für das Antworten per E-Mail. Beispiel: %{reply_key}@antwort.example.com|antworten+%{reply_key}@example.com" @@ -1407,7 +1430,7 @@ de: allowed_href_schemes: "URI-Schemas, die in Links zusätzlich zu http und https erlaubt sind." embed_post_limit: "Maximale Anzahl der Beiträge die eingebettet werden." embed_username_required: "Der Benutzername ist für die Themenerstellung notwendig" - notify_about_flags_after: "Wenn es Meldungen gibt, die nicht innerhalb der angegebenen Anzahl von Stunden behandelt wurden, sende eine Nachricht an das Team. Ein Wert von 0 deaktiviert die Funktion." + notify_about_flags_after: "Wenn es Meldungen gibt, die nicht innerhalb der angegebenen Anzahl von Stunden behandelt wurden, sende eine Nachricht an die Moderatoren. Ein Wert von 0 deaktiviert die Funktion." show_create_topics_notice: "Administratoren eine Warnmeldung anzeigen, wenn im Forum weniger als 5 öffentlich sichtbare Themen existieren." delete_drafts_older_than_n_days: "Lösche Entwürfe, die mehr als (n) Tage alt sind." bootstrap_mode_min_users: "Erforderliche Benutzeranzahl, um den Starthilfe-Modus zu deaktivieren (0 = ausgeschaltet)" @@ -1475,6 +1498,8 @@ de: company_full_name: "Firmenname (komplett)" company_domain: "Firmendomain" shared_drafts_category: "Aktiviere die Funktion „Gemeinsame Vorlagen“, indem du eine Kategorie für Themen-Vorlagen bestimmst." + push_notifications_prompt: "Zeige eine Aufforderung zur Benutzerzustimmung an." + push_notifications_icon_url: "Das Abzeichen-Icon, das in der Benachrichtigungsecke erscheint. Empfohlene Größe ist 96px × 96px." errors: invalid_email: "Ungültige E-Mail-Adresse" invalid_username: "Es gibt keinen Benutzer mit diesem Benutzernamen." @@ -2132,6 +2157,13 @@ de: Es tut uns leid, aber deine E-Mail-Nachricht an %{destination} (mit dem Betreff %{former_title}) hat nicht funktioniert. Keine der Empfängeradressen wurden erkannt oder der Message-ID-Header in der E-Mail ist verändert worden. Bitte stelle sicher, dass du an die richtige E-Mail-Adresse schickst, die vom Team bekanntgegeben wurde. + email_reject_old_destination: + title: "E-Mail abgelehnt weil alter Empfänger" + subject_template: "[%{email_prefix}] E-Mail-Problem -- Du versuchst, auf eine alte Benachrichtigung zu antworten" + text_body_template: | + Entschuldige, aber deine E-Mail-Nachricht an %{destination}(mit dem Betreff ) konnte nicht zugestellt werden. + + Aus Sicherheitsgründen akzeptieren wir Antworten auf ursprüngliche Benachrichtigungen nur innerhalb von 90 Tagen. Bitte [besuche das Thema](%{short_url}), um die Unterhaltung fortzusetzen. email_reject_topic_not_found: title: "E-Mail abgelehnt weil Thema nicht gefunden" subject_template: "[%{email_prefix}] E-Mail-Problem -- Thema nicht gefunden" @@ -2296,6 +2328,7 @@ de: visit_link_to_respond: "[Rufe das Thema auf](%{base_url}%{url}), um zu antworten." visit_link_to_respond_pm: "[Rufe die Nachricht auf](%{base_url}%{url}), um zu antworten." posted_by: "Erstellt von %{username} am %{post_date}" + pm_participants: "Teilnehmer:" invited_group_to_private_message_body: | %{username} hat @%{group_name} eingeladen zu einer Nachricht @@ -2416,6 +2449,17 @@ de: %{context} + %{respond_instructions} + user_mentioned_pm: + title: "Benutzer hat Nachricht erwähnt" + subject_template: "[%{email_prefix}] [PN] %{topic_title}" + text_body_template: | + %{header_instructions} + + %{message} + + %{context} + %{respond_instructions} user_group_mentioned: title: "Gruppe wurde erwähnt" @@ -3417,3 +3461,14 @@ de: search_logs: graph_title: "Anzahl Suchen" joined: "Beigetreten" + discourse_push_notifications: + popup: + mentioned: '%{username} hat dich erwähnt in "%{topic}" - %{site_title}' + group_mentioned: '%{username} hat dich erwähnt in "%{topic}" - %{site_title}' + quoted: '%{username} hat dich zitiert in "%{topic}" - %{site_title}' + replied: '%{username} hat dir geantwortet in "%{topic}" - %{site_title}' + posted: '%{username} hat geschrieben in "%{topic}" - %{site_title}' + private_message: '%{username} hat dir eine Nachricht geschickt in "%{topic}" - %{site_title}' + linked: '%{username} hat deinen Beitrag verlinkt in "%{topic}" - %{site_title}' + confirm_title: 'Benachrichtigungen aktiviert – %{site_title}' + confirm_body: 'Erfolgreich! Benachrichtigungen wurden aktiviert.' diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 679ed3148a..ad10228e5b 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -770,7 +770,6 @@ el: xaxis: "Ημέρα" yaxis: "Θεάσεις σελίδας από Web Crawlers" page_view_total_reqs: - title: "Σύνολο" xaxis: "Ημέρα" yaxis: "Σύνολο προβολών σελίδας" page_view_logged_in_mobile_reqs: @@ -970,7 +969,6 @@ el: version_checks: "Έλεγξε το Hub του Discourse για αναβαθμίσεις και δείξε μηνύματα για νέες ενημερώσεις στην σελίδα διαχείρισης. " new_version_emails: "Αποστολή email στην contact_email διεύθυνση όταν μια νέα έκδοση του Discourse είναι διαθέσιμη." invite_expiry_days: "Για πόσο καιρό οι κύριες προσκλήσεις ισχύουν, σε μέρες " - invite_passthrough_hours: "Για πόσο καιρό ο χρήστης μπορεί να χρησιμοποιήσει ένα ήδη εξαργυρωμένο κλειδί πρόσκλησης για να συνδεθεί, σε ώρες" invite_only: "Οι ανοιχτές εγγραφές είναι απενεργοποιημένες, όλα τα νέα μέλη θα πρέπει να προσκληθούν από το προσωπικό." login_required: "Απαιτήστε την επικύρωση για να διαβάσετε το περιεχόμενο σε αυτήν την ιστοσελίδα, απαγορεύσετε την ανώνυμη πρόσβαση." min_username_length: "Ελάχιστο μέγεθος ονόματος χρήστη σε χαρακτήρες. ΠΡΟΣΟΧΗ: εάν υπάρχουν χρήστες ή ομάδες που έχουν ονόματα πιο μικρά από την ρύθμιση αυτή, θα δημιουργηθεί πρόβλημα στην λειτουργία της ιστοσελίδας!" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 41b7fdcab2..d67781b656 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -171,6 +171,12 @@ en: invite: not_found: "Your invite token is invalid. Please contact the site's administrator." + not_found_template: | +Your invite to %{site_name} has already been redeemed.
+ +If you remember your password you can Login.
+ +Otherwise please Reset Password.
user_exists: "There's no need to invite %{email}, they already have an account!" bulk_invite: @@ -846,16 +852,11 @@ en: xaxis: "Day" yaxis: "Number of new contributors" description: "Number of users who made their first post during this period" - inactive_users: - title: "Inactive Users" - xaxis: "Day" - yaxis: "Number of new inactive users" - description: "Number of users that haven’t logged on for the last 3 months" dau_by_mau: title: "DAU/MAU" xaxis: "Day" yaxis: "DAU/MAY" - description: "Daily Active Users / Monthly Active Users" + description: "DAU / MAU – no. of members that logged in in the last day divided by no. of members that logged in in the last month – returns a %" daily_engaged_users: title: "Daily Engaged Users" xaxis: "Day" @@ -874,6 +875,7 @@ en: title: "Posts" xaxis: "Day" yaxis: "Number of new posts" + description: "New posts created during this period" likes: title: "Likes" xaxis: "Day" @@ -908,7 +910,7 @@ en: labels: term: Term searches: Searches - unique: Unique + click_through: CTR emails: title: "Emails Sent" xaxis: "Day" @@ -918,7 +920,7 @@ en: xaxis: "Day" yaxis: "Number of messages" user_to_user_private_messages_with_replies: - title: "User-to-User (including replies)" + title: "User-to-User (with replies)" xaxis: "Day" yaxis: "Number of messages" system_private_messages: @@ -965,7 +967,7 @@ en: xaxis: "Day" yaxis: "Web Crawler Pageviews" page_view_total_reqs: - title: "Total" + title: "Pageviews" xaxis: "Day" yaxis: "Total Pageviews" page_view_logged_in_mobile_reqs: @@ -1204,7 +1206,6 @@ en: new_version_emails: "Send an email to the contact_email address when a new version of Discourse is available." invite_expiry_days: "How long user invitation keys are valid, in days" - invite_passthrough_hours: "How long a user can use a previously redeemed invitation key to log in, in hours" invite_only: "Public registration is disabled, all new users must be explicitly invited by staff." @@ -1231,6 +1232,8 @@ en: sso_overrides_username: "Overrides local username with external site username from SSO payload on every login, and prevent local changes. (WARNING: discrepancies can occur due to differences in username length/requirements)" sso_overrides_name: "Overrides local full name with external site full name from SSO payload on every login, and prevent local changes." sso_overrides_avatar: "Overrides user avatar with external site avatar from SSO payload. If enabled, disabling allow_uploaded_avatars is highly recommended" + sso_overrides_profile_background: "Overrides user profile background with external site avatar from SSO payload." + sso_overrides_card_background: "Overrides user card background with external site avatar from SSO payload." sso_not_approved_url: "Redirect unapproved SSO accounts to this URL" sso_allows_all_return_paths: "Do not restrict the domain for return_paths provided by SSO (by default return path must be on current site)" @@ -1473,6 +1476,7 @@ en: auto_silence_fast_typers_max_trust_level: "Maximum trust level to auto silence fast typers" auto_silence_first_post_regex: "Case insensitive regex that if passed will cause first post by user to be silenced and sent to approval queue. Example: raging|a[bc]a , will cause all posts containing raging or aba or aca to be silenced on first. Only applies to first post." flags_default_topics: "Show flagged topics by default in the admin section" + min_flags_staff_visibility: "The minimum amount of flags on a post must have before staff can see it in the admin section" reply_by_email_enabled: "Enable replying to topics via email." reply_by_email_address: "Template for reply by email incoming email address, for example: %{reply_key}@reply.example.com or replies+%{reply_key}@example.com" @@ -1622,7 +1626,7 @@ en: allowed_href_schemes: "Schemes allowed in links in addition to http and https." embed_post_limit: "Maximum number of posts to embed." embed_username_required: "The username for topic creation is required." - notify_about_flags_after: "If there are flags that haven't been handled after this many hours, send a personal message to staff. Set to 0 to disable." + notify_about_flags_after: "If there are flags that haven't been handled after this many hours, send a personal message to moderators. Set to 0 to disable." show_create_topics_notice: "If the site has fewer than 5 public topics, show a notice asking admins to create some topics." delete_drafts_older_than_n_days: Delete drafts older than (n) days. @@ -1710,6 +1714,9 @@ en: shared_drafts_category: "Enable the Shared Drafts feature by designating a category for topic drafts." + push_notifications_prompt: "Display user consent prompt." + push_notifications_icon_url: "The badge icon that appears in the notification corner. Recommended size is 96px by 96px." + errors: invalid_email: "Invalid email address." invalid_username: "There's no user with that username." @@ -2427,6 +2434,14 @@ en: None of the destination email addresses are recognized, or the Message-ID header in the email has been modified. Please make sure that you are sending to the correct email address provided by staff. + email_reject_old_destination: + title: "Email Reject Old Destination" + subject_template: "[%{email_prefix}] Email issue -- You are trying to reply to an old notification" + text_body_template: | + We're sorry, but your email message to %{destination} (titled %{former_title}) didn't work. + + For security reasons we only accept replies to original notifications for 90 days. Please [visit the topic](%{short_url}) to continue the conversation. + email_reject_topic_not_found: title: "Email Reject Topic Not Found" subject_template: "[%{email_prefix}] Email issue -- Topic Not Found" @@ -3853,3 +3868,15 @@ en: graph_title: "Search Count" joined: "Joined" + + discourse_push_notifications: + popup: + mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}' + group_mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}' + quoted: '%{username} quoted you in "%{topic}" - %{site_title}' + replied: '%{username} replied to you in "%{topic}" - %{site_title}' + posted: '%{username} posted in "%{topic}" - %{site_title}' + private_message: '%{username} sent you a private message in "%{topic}" - %{site_title}' + linked: '%{username} linked to your post from "%{topic}" - %{site_title}' + confirm_title: 'Notifications enabled - %{site_title}' + confirm_body: 'Success! Notifications have been enabled.' diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index ae7db17185..e736c0a67e 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -805,7 +805,6 @@ es: labels: term: Término searches: Búsquedas - unique: Único emails: title: "Correos enviados" xaxis: "Día" @@ -860,7 +859,6 @@ es: xaxis: "Día" yaxis: "Páginas vistas de rastreadores web" page_view_total_reqs: - title: "Total" xaxis: "Día" yaxis: "Total páginas vistas" page_view_logged_in_mobile_reqs: @@ -1076,7 +1074,6 @@ es: version_checks: "Ping el 'Discourse Hub' para actualizaciones de versión y mostrar mensajes del número de versión en el dashboard /admin" new_version_emails: "Enviar un email a la dirección contact_email cuando esté disponible una nueva versión de Discourse." invite_expiry_days: "Durante cuánto tiempo son válidas las claves de invitación de usuarios, en días" - invite_passthrough_hours: "Durante cuánto tiempo, en horas, un usuario puede utilizar una clave de invitación redimida para iniciar sesión iniciar sesión" invite_only: "El registro público está deshabilitado, todos los nuevos usuarios deberán ser invitados explícitamente por el staff." login_required: "Se requiere haber iniciado sesión para leer el contenido de este sitio, deshabilita el acceso anónimo." min_username_length: "Longitud mínima del nombre de usuario en caracteres. ADVERTENCIA: si existen usuarios o grupos con nombres de menor longitud que ésta, tu sitio se romperá." @@ -1383,7 +1380,6 @@ es: allowed_href_schemes: "Esquemas permitidos en enlaces además de http y https." embed_post_limit: "Número máximo de posts a embeber." embed_username_required: "Se requiere el nombre de usuario para la creación de temas." - notify_about_flags_after: "Si hay reportes que no han sido atendidos después de este número de horas, enviar un mensaje personal al staff. Dejar en 0 para desactivar." show_create_topics_notice: "Si el sitio tiene menos de 5 temas abiertos al público, mostrar un aviso pidiendo a los administradores crear más temas." delete_drafts_older_than_n_days: Eliminar borradores de más de (n) días de antigüedad. bootstrap_mode_min_users: "Mínimo número de usuarios requerido para desactivar el modo bootstrap (pon 0 para desactivar esta opción)" diff --git a/config/locales/server.et.yml b/config/locales/server.et.yml index 2cdc3785e5..6ee59cc2a2 100644 --- a/config/locales/server.et.yml +++ b/config/locales/server.et.yml @@ -626,7 +626,6 @@ et: page_view_crawler_reqs: xaxis: "Päev" page_view_total_reqs: - title: "Kokku" xaxis: "Päev" page_view_logged_in_mobile_reqs: xaxis: "Päev" diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index e6c14e35c7..ca681baeed 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -87,15 +87,19 @@ fa_IR: one: "نمی توان رکورد را پاک کرد زیرا یک %{record} وابسته وجود دارد." many: "نمی توان رکورد را پاک کرد زیرا یک %{record} وابسته وجود دارد." too_long: + one: خیلی طولانی است ( حداکثر مقدار قابل قبول %{count} نویسه است) other: خیلی طولانی است ( حداکثر مقدار قابل قبول %{count} نویسه است) too_short: + one: خیلی کوتاه است (حداقل مقدار قابل قبول %{count} نویسه است) other: خیلی کوتاه است (حداقل مقدار قابل قبول %{count} نویسه است) wrong_length: + one: مقدار طول اشتباه است (باید %{count} نویسه باشد) other: مقدار طول اشتباه است (باید %{count} نویسه باشد) other_than: "باید غیر از {count}% باشد" template: body: 'مشکلاتی با زمینههای دنبال شده وجود دارد:' header: + one: '%{count} خطا از ذخیره شدن %{model} جلوگیری میکنند.' other: '%{count} خطا از ذخیره شدن %{model} جلوگیری میکنند.' embed: load_from_remote: "خطایی در بارگذاری آن نوشته رخ داده است." @@ -129,6 +133,7 @@ fa_IR: reading_time: "زمان مطالعه" likes: "پسندها" too_many_replies: + one: متأسفیم، ولی کاربران جدید موقتا محدود به {count}% پاسخ در یک موضوع هستند. other: متأسفیم، ولی کاربران جدید موقتا محدود به {count}% پاسخ در یک موضوع هستند. embed: start_discussion: "شروع بحث" @@ -139,27 +144,34 @@ fa_IR: no_hosts: "میزبانی برای قرارگیری فایلها تعبیه نشده." configure: "پیکربندی کدهای جاسازی شده" more_replies: + one: '%{count} پاسخ دیگر' other: '%{count} پاسخ دیگر' loading: "بارگزاری بحث..." permalink: " پیوند یکتا" imported_from: "این بحثوگفتگوی همراه برای ورود اصلی در %{link} است" in_reply_to: "◀ %{username}" replies: + one: '%{count} پاسخ' other: '%{count} پاسخ' no_mentions_allowed: "متأسفیم، شما نمیتوانید به کاربران دیگر اشاره کنید." too_many_mentions: + one: متأسفیم، شما در هر نوشته تنها میتوانید به %{count} کاربر اشاره کنید. other: متأسفیم، شما در هر نوشته تنها میتوانید به %{count} کاربر اشاره کنید. no_mentions_allowed_newuser: "متأسفیم، کاربران تازه نمیتوانند به کاربران دیگر اشاره کنند." too_many_mentions_newuser: + one: متأسفیم، کاربران تازه در هر نوشته تنها میتوانند به %{count} کاربر دیگر اشاره کنند. other: متأسفیم، کاربران تازه در هر نوشته تنها میتوانند به %{count} کاربر دیگر اشاره کنند. no_images_allowed: "متأسفیم، کاربران تازه نمیتوانند در نوشتهها تصویر بگذارند." too_many_images: + one: متأسفیم، کاربران تازه تنها میتوانند %{count} تصویر در هر نوشته بگذارند. other: متأسفیم، کاربران تازه تنها میتوانند %{count} تصویر در هر نوشته بگذارند. no_attachments_allowed: "متأسفیم، کاربران تازه امکان قرار دادن پیوست را در نوشته هایشان ندارند." too_many_attachments: + one: متأسفیم ، کاربران تازه تنها میتوانند %{count} پیوست در هر نوشته قرار بدهند. other: متأسفیم ، کاربران تازه تنها میتوانند %{count} پیوست در هر نوشته قرار بدهند. no_links_allowed: "متأسفیم، کاربران تازه نمیتوانند در نوشتهها پیوند بگذارند." too_many_links: + one: متأسفیم، کاربران تازه تنها میتوانند در هر نوشته %{count} پیوند بگذارند. other: متأسفیم، کاربران تازه تنها میتوانند در هر نوشته %{count} پیوند بگذارند. spamming_host: "متأسفانه شما نمیتوانید پیوندی با آن میزبان را بفرستید." user_is_suspended: "کاربر تعلیق شده نمیتواند نوشتهای ارسال کند." @@ -224,6 +236,7 @@ fa_IR: title: "درخواست عضویت در @%{group_name}" education: until_posts: + one: '%{count} نوشته' other: '%{count} نوشته' 'new-topic': | به %{site_name} &mdash خوش آمدید؛ **با تشکر از شما برای شروع گفتگو!** @@ -401,6 +414,7 @@ fa_IR: uncategorized: "بدون دستهبندی را نمی توان حذف کرد" has_subcategories: "این دستهبندی به دلیل داشتن زیردسته قابل پاک شدن نیست." topic_exists: + one: این دستهبندی باید پاک شود زیرا این موضوع دارای %{count} است. قدیمی ترین موضوع %{topic_link}. other: این دستهبندی باید پاک شود زیرا این موضوع دارای %{count} است. قدیمی ترین موضوع %{topic_link}. topic_exists_no_oldest: "این دسته را نمی توان پاک کرد زیرا تعداد موضوع %{count} است " uncategorized_description: "موضوعاتی که نیاز به دستهبندی ندارند، یا قابل دستهبندی نیستند." @@ -421,59 +435,84 @@ fa_IR: broken: "عکس خراب شده است." rate_limiter: hours: + one: '%{count} ساعت' other: '%{count} ساعت' minutes: + one: '%{count} دقیقه' other: '%{count} دقیقه' seconds: + one: '%{count} ثانیه' other: '%{count} ثانیه' datetime: distance_in_words: half_a_minute: "< 1 دقیقه" less_than_x_seconds: + one: < %{count} ثانیه other: < %{count} ثانیه x_seconds: + one: '%{count} ثانیه' other: '%{count} ثانیه' less_than_x_minutes: + one: < %{count} دقیقه other: < %{count} دقیقه x_minutes: + one: '%{count} دقیقه' other: '%{count} دقیقه' about_x_hours: + one: '%{count} ساعت' other: '%{count} ساعت' x_days: + one: '%{count} روز' other: '%{count} روز' about_x_months: + one: '%{count} ماه' other: '%{count} ماه' x_months: + one: '%{count} ماه' other: '%{count} ماه' about_x_years: + one: '%{count} سال' other: '%{count} سال' over_x_years: + one: '> %{count} سال' other: '> %{count} سال' almost_x_years: + one: '%{count} سال' other: '%{count} سال' distance_in_words_verbose: half_a_minute: "هم اکنون" less_than_x_seconds: + one: هم اکنون other: هم اکنون x_seconds: + one: '%{count} ثانیه قبل' other: '%{count} ثانیه قبل' less_than_x_minutes: + one: کمتر از %{count} دقیقه قبل other: کمتر از %{count} دقیقه قبل x_minutes: + one: '%{count} دقیقه قبل' other: '%{count} دقیقه قبل' about_x_hours: + one: '%{count} ساعت قبل' other: '%{count} ساعت قبل' x_days: + one: '%{count} روز قبل' other: '%{count} روز قبل' about_x_months: + one: حدود %{count} ماه قبل other: حدود %{count} ماه قبل x_months: + one: '%{count} ماه قبل' other: '%{count} ماه قبل' about_x_years: + one: حدود %{count} سال قبل other: حدود %{count} سال قبل over_x_years: + one: بیش از %{count} سال قبل other: بیش از %{count} سال قبل almost_x_years: + one: تقریبا %{count} سال قبل other: تقریبا %{count} سال قبل password_reset: no_token: "متآسفیم, پیوند تغییر رمز عبور بسیار قدیمی است. دکمه ورود را انتخاب کنید و از 'من رمز عبور خود را فراموش کرده ام' برای دریافت یک پیوند جدید استفاده کنید." @@ -710,7 +749,6 @@ fa_IR: xaxis: "روز" yaxis: "نمایش صفحات به خزندگان وب" page_view_total_reqs: - title: "مجموع" xaxis: "روز" yaxis: "مجموع بازدید صفحات" page_view_logged_in_mobile_reqs: @@ -774,6 +812,7 @@ fa_IR: failing_emails_warning: '%{num_failed_jobs} ایمیل زمانبندی شده ناموفق وجود دارد. فایل app.yml را بررسی کنید و مطمئن شوید که تنظیمات سرور ایمیل درست است. نمایش زمابندیهای ناموفق Sidekiq .' subfolder_ends_in_slash: "تنظیمات زیرپوشه نادرست است، مقدار DISCOURSE_RELATIVE_URL_ROOT با نویسهی slash تمام میشود." email_polling_errored_recently: + one: رایگیری ایمیلی %{count} خطا در 24 ساعت گذشته ایجاد کرده. گزارشات را ببینید. other: رایگیری ایمیلی %{count} خطا در 24 ساعت گذشته ایجاد کرده. گزارشات را ببینید. bad_favicon_url: "favicon نمیتواند بارگذاری شود. مقدار favicon_url را در تنظیمات سایت بررسی کنید." poll_pop3_timeout: "اتصال به سرور POP3 انجام نشد. ایمیلهای ورودی ممکن است دریافت نشوند. لطفا تنظیمات POP3 و فراهم کننده خدمات را بررسی کنید." @@ -891,7 +930,6 @@ fa_IR: version_checks: "Discourse Hub را پینگ کن برای نسخه بروزرسانی و پیام نسخه جدید را صفحه آمار ادمین نشان بده." new_version_emails: "ارسال ایمیل به contact_email address وقتی نسخه جدید Discourse موجود است. " invite_expiry_days: "کلید دعوتنامه فراخوان برای چه مدت اعتبار دارد، واحد روز" - invite_passthrough_hours: "کاربر برای چه مدت می تواند از کلید فراخوان استفاده شده برای ورود به سیستم استفاده کند، واحد ساعت" invite_only: "ثبتنام عمومی غیرفعال است، تمام کاربران جدید باید توسط کارمندان دعوت شوند." login_required: "برای خواندن محتوا در سایت نیاز به تصدیق است، دسترسی ناشناس رد شود." min_username_length: "حداقل طول نام کاربری به نویسه. توجه: اگر کاربری در حال حاضر نام کاربری کوتاه تر از این مقدار داشته باشد سایت به مشکل برمیخورد." @@ -1274,14 +1312,17 @@ fa_IR: not_seen_in_a_month: "خوش آمدید دوباره! شما را برای مدت طولانی ندیدهایم. اینها بهترین موضعات از وقتی که شما نبودید، هستند." merge_posts: edit_reason: + one: '%{count} نوشته ترکیب شده توسط %{username}' other: '%{count} نوشته ترکیب شده توسط %{username}' errors: different_topics: "نوشتهها به موضوعات مختلفی تعلق دارند و امکان ترکیب آنها وجود ندارد." different_users: "نوشتهها به کاربران مختلفی تعلق دارند و امکان ترکیب آنها وجود ندارد." move_posts: new_topic_moderator_post: + one: '%{count} نوشته تبدیل به یک موضوع جدید شدند: %{topic_link}' other: '%{count} نوشته تبدیل به یک موضوع جدید شدند: %{topic_link}' existing_topic_moderator_post: + one: '%{count} نوشته با یک موضوع موجود ترکیب شدند: %{topic_link}' other: '%{count} نوشته با یک موضوع موجود ترکیب شدند: %{topic_link}' change_owner: post_revision_text: "حق مالکیت از کاربر {old_user}% به کاربر {new_user}% منتقل شد" @@ -1292,32 +1333,46 @@ fa_IR: closed_enabled: "این موضوع در حال حاضر بسته شده است. پاسخ جدید دیگر مجاز نیست." closed_disabled: "موضوع در حال حاضر باز است. پاسخهای جدید اجازهی ثبت دارند." autoclosed_message_max_posts: + one: این پیام بعد از رسیدن به حداکثر تعداد پاسخها %{count} بسته شد. other: این پیام بعد از رسیدن به حداکثر تعداد پاسخها %{count} بسته شد. autoclosed_topic_max_posts: + one: این موضوع بعد از رسیدن به حداکثر تعداد پاسخها %{count} بسته شد. other: این موضوع بعد از رسیدن به حداکثر تعداد پاسخها %{count} بسته شد. autoclosed_enabled_days: + one: این موضوع به صورت خود کار بعد از %{count} روز بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} روز بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_enabled_hours: + one: این موضوع به صورت خود کار بعد از %{count} ساعت بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} ساعت بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_enabled_minutes: + one: این موضوع به صورت خود کار بعد از %{count} دقیقه بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} دقیقه بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_enabled_lastpost_days: + one: این موضوع به صورت خود کار بعد از %{count} روز بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} روز بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_enabled_lastpost_hours: + one: این موضوع به صورت خود کار بعد از %{count} ساعت بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} ساعت بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_enabled_lastpost_minutes: + one: این موضوع به صورت خود کار بعد از %{count} دقیقه بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. other: این موضوع به صورت خود کار بعد از %{count} دقیقه بعد از آخرین پاسخ بسته شد. پاسخ جدید دیگر مجاز نیست. autoclosed_disabled_days: + one: این موضوع بعد از %{count} روز به صورت خودکار باز شد. other: این موضوع بعد از %{count} روز به صورت خودکار باز شد. autoclosed_disabled_hours: + one: این موضوع بعد از %{count} ساعت به صورت خودکار باز شد. other: این موضوع بعد از %{count} ساعت به صورت خودکار باز شد. autoclosed_disabled_minutes: + one: این موضوع بعد از %{count} دقیقه به صورت خودکار باز شد. other: این موضوع بعد از %{count} دقیقه به صورت خودکار باز شد. autoclosed_disabled_lastpost_days: + one: این موضوع %{count} روز بعد از آخرین پاسخ به صورت خودکار باز شد. other: این موضوع %{count} روز بعد از آخرین پاسخ به صورت خودکار باز شد. autoclosed_disabled_lastpost_hours: + one: این موضوع %{count} ساعت بعد از آخرین پاسخ به صورت خودکار باز شد. other: این موضوع %{count} ساعت بعد از آخرین پاسخ به صورت خودکار باز شد. autoclosed_disabled_lastpost_minutes: + one: این موضوع %{count} دقیقه بعد از آخرین پاسخ به صورت خودکار باز شد. other: این موضوع %{count} دقیقه بعد از آخرین پاسخ به صورت خودکار باز شد. autoclosed_disabled: "این موضوع در حال حاضر باز است. پاسخهای جدید اجازهی ثبت دارند." autoclosed_disabled_lastpost: "این موضوع در حال حاضر باز است. پاسخهای جدید اجازهی ثبت دارند." @@ -1378,8 +1433,10 @@ fa_IR: domain_not_allowed: "سایت نامعتبر است. دامنههای مجاز: %{domains}" flags_reminder: flags_were_submitted: + one: پرچمها %{count} ساعت پیش ثبت شدند. [لطفا بررسی کنید](/admin/flags). other: پرچمها %{count} ساعت پیش ثبت شدند. [لطفا بررسی کنید](/admin/flags). subject_template: + one: '%{count} نشانه ها منتظر استفاده' other: '%{count} نشانه ها منتظر استفاده' unsubscribe_mailer: title: "لغو اشتراک نامهرسان" @@ -1450,6 +1507,7 @@ fa_IR: deferred: "ممنون از اینکه به ما اطلاع دادید. ما بررسی می کنیم" deferred_and_deleted: "ممنون از اینکه به ما اطلاع دادید. ما نوشته را حذف کردیم." temporarily_closed_due_to_flags: + one: این موضوع موقتا به دلیل دریافت تعداد زیادی پرچم به مدت %{count} ساعت بسته میشود. other: این موضوع موقتا به دلیل دریافت تعداد زیادی پرچم به مدت %{count} ساعت بسته میشود. system_messages: private_topic_title: "موضوع #%{id}" @@ -1669,6 +1727,7 @@ fa_IR: pending_users_reminder: title: "یاداور کاربران در انتظار" subject_template: + one: '%{count} کاربران منتظر تایید' other: '%{count} کاربران منتظر تایید' text_body_template: | تعدادی درخواست کاربران جدید عضو شده برای تایید (یا رد شدن) منتظر هستند. قبل از اینکه به انجمن دسترسی داشته باشند. @@ -1692,6 +1751,7 @@ fa_IR: queued_posts_reminder: title: "یادآور نوشتههای زمانبندی شده" subject_template: + one: '%{count} نوشته منتظر بررسی است' other: '%{count} نوشته منتظر بررسی است' unsubscribe_link: | برای لغو اشتراک از این ایمیلها، [کلیک کنید](%{unsubscribe_url}). @@ -1706,6 +1766,7 @@ fa_IR: user_notifications: previous_discussion: "پاسخهای قبلی" reached_limit: + one: 'توجه کنید: ما حداکثر %{count} ایمیل در روز ارسال میکنیم. برای دیدن موارد ارسال نشده سایت را بررسی کنید. پ.ن: از شما برای محبوب بودنتان تشکر میکنیم!' other: 'توجه کنید: ما حداکثر %{count} ایمیل در روز ارسال میکنیم. برای دیدن موارد ارسال نشده سایت را بررسی کنید. پ.ن: از شما برای محبوب بودنتان تشکر میکنیم!' in_reply_to: "در پاسخ به" unsubscribe: diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index 2cdadfacaa..96fa1f510e 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -768,7 +768,6 @@ fi: xaxis: "Päivä" yaxis: "Hakurobottien sivunlataukset" page_view_total_reqs: - title: "Yhteensä" xaxis: "Päivä" yaxis: "Sivunlatauksia yhteensä" page_view_logged_in_mobile_reqs: @@ -964,7 +963,6 @@ fi: version_checks: "Pingaa Discourse Hubia päivityksistä ja näytä viesti /admin hallintapaneelissa kun uusi versio on saatavilla" new_version_emails: "Lähetä sähköposti contact_email osoitteeseen kun uusi versio Discoursesta on saatavilla." invite_expiry_days: "Kuinka monta päivää käyttäjäkutsujen avaimet ovat voimassa" - invite_passthrough_hours: "Kuinka pitkään käyttäjä voi käyttää vastatun kutsun avainta kirjautuakseen sisään, tunneissa" invite_only: "Julkinen rekisteröityminen ei ole käytössä, uuden tilin luominen vaatii kutsun henkilökunnalta." login_required: "Vaadi kirjautumista sivuston lukemiseen, epää anonyymi pääsy." min_username_length: "Käyttäjänimen vähimmäispituus merkeissä. VAROITUS: Jos olemassa olevalla käyttäjällä tai ryhmällä on tätä lyhyempi nimi, sivustosi hajoaa!" @@ -1131,7 +1129,7 @@ fi: topic_post_like_heat_high: "Kun tykkäysten suhde viestien määrään ylittää tämän, viestien lukumäärän saraketta korostetaan voimakkaasti." faq_url: "Jos haluat käyttää sivuston ulkopuolella ylläpidettyä UKK-listaa, syötä URL tähän." tos_url: "Jos haluat ylläpitää käyttöehtoja sivuston ulkopuolella, syötä URL tähän." - privacy_policy_url: "Jos haluat ylläpitää rekisteriselostetta sivuston ulkopuolella, syötä URL tähän." + privacy_policy_url: "Jos haluat ylläpitää tietosuojaselostetta sivuston ulkopuolella, syötä URL tähän." newuser_spam_host_threshold: "Kuinka monta kertaa uusi käyttäjä voi linkittää samalle sivustolle `newuser_spam_host_posts` viesteissään, ennen kuin se tulkitaan roskapostin lähettämiseksi." white_listed_spam_host_domains: "Lista verkkotunnuksista, joita ei oteta huomioon roskapostin tunnistamisessa. Uusilla käyttäjillä ei ole rajoituksia linkkaamisessa näihin tunnuksiin." staff_like_weight: "Kuinka suuri ylimääräinen arvo on henkilökunnan tykkäyksillä." @@ -2440,7 +2438,7 @@ fi: light_theme_name: "Vaalea" about: "Tietoja" guidelines: "Ohjeet" - privacy: "Yksityisyys" + privacy: "Tietosuoja" edit_this_page: "Muokkaa tätä sivua" csv_export: boolean_yes: "Kyllä" @@ -2546,11 +2544,11 @@ fi: ## [Käyttöehdot](#tos) - Kyllä, lakikieli on tylsää, mutta meidän on suojeltava itseämme – ja siinä sivussa sinua ja sinun tietojasi – epäsuotuisia tahoja vastaan. Palstalla on [käyttöehdot](/tos), jotka säätelevät sinun (ja meidän) toimintaa ja oikeuksia sisältöön, yksityisyyteen ja lakeihin liittyen. Jotta voit käyttää palvelua, sinun on hyväksyttävä [käyttöehdot](/tos). + Kyllä, lakikieli on tylsää, mutta meidän on suojeltava itseämme – ja siinä sivussa sinua ja sinun tietojasi – epäsuotuisia tahoja vastaan. Palstalla on [käyttöehdot](/tos), jotka säätelevät sinun (ja meidän) toimintaa ja oikeuksia sisältöön, tietosuojaan ja lakeihin liittyen. Jotta voit käyttää palvelua, sinun on hyväksyttävä [käyttöehdot](/tos). tos_topic: title: "Käyttöehdot" privacy_topic: - title: "Rekisteriseloste" + title: "Tietosuojaseloste" body: | @@ -2618,7 +2616,7 @@ fi: ## [Tietosuojaseloste koskee vain internetiä](#online) - Tämä tietosuojakäytäntö koskee vain sivustomme kautta kerättyä tietoa eikä siten koske verkon ulkopuolelta kerättyä tietoa. + Tämä tietosuojaseloste koskee vain sivustomme kautta kerättyä tietoa eikä siten koske verkon ulkopuolelta kerättyä tietoa. @@ -2949,7 +2947,7 @@ fi: description: "Tämän käyttäjän nimissä Discourse lähettää käyttäjille kaikki automaattiset yksityisviestit, kuten liputusvaroitukset ja ilmoitukset valmistuneista varmuuskopioista." corporate: title: "Organisaatio" - description: "Nämä nimet näkyvät rekisteriselosteen ja käyttöehtojen yhteydessä, joita voit milloin vain muokata henkilökunta-alueella. Jos taustalla ei ole yritystä, voit hypätä tämän vaiheen yli toistaiseksi." + description: "Nämä nimet näkyvät tietosuojaselosteen ja käyttöehtojen yhteydessä, joita voit milloin vain muokata henkilökunta-alueella. Jos taustalla ei ole yritystä, voit hypätä tämän vaiheen yli toistaiseksi." fields: company_short_name: label: "Yrityksen nimi (lyhyesti)" diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index 8cdda41f5b..ba2ebe8ab9 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -147,6 +147,12 @@ fr: <<: *errors invite: not_found: "Votre jeton d'invitation est invalide. Veuillez contacter l'administrateur du site." + not_found_template: | +Votre invitation à %{site_name} a déjà été utilisée.
+ +Si vous vous rappelez de votre mot de passe vous pouvez vous Connecter.
+ +Sinon, veuillez Réinitialiser mot de passe.
user_exists: "Il n'y a pas besoin d'inviter %{email} qui a déjà un compte !" bulk_invite: file_should_be_csv: "Le fichier envoyé doit être au format CSV." @@ -767,16 +773,11 @@ fr: xaxis: "Jour" yaxis: "Nombre de nouveaux contributeurs" description: "Nombre d'utilisateurs qui ont fait leur première contribution durant cette période" - inactive_users: - title: "Utilisateurs inactifs" - xaxis: "Jour" - yaxis: "Nombre de nouveaux utilisateurs inactifs" - description: "Nombre d'utilisateurs ne s'étant pas connectés dans les derniers 3 mois" dau_by_mau: title: "DAU/MAU" xaxis: "Jour" yaxis: "DAU/MAY" - description: "Utilisateurs actifs quotidien / Utilisateurs actifs mensuel" + description: "DAU / MAU – nombre d'utilisateurs qui se sont connectés dans la dernière journée, divisé par le nombre d'utilisateurs qui se sont connectés dans le dernier mois – en %" daily_engaged_users: title: "Utilisateurs impliqués au quotidien" xaxis: "Jour" @@ -795,6 +796,7 @@ fr: title: "Messages" xaxis: "Jour" yaxis: "Nombre de nouveaux messages" + description: "Nouveaux messages crées pour cette période" likes: title: "J'aime" xaxis: "Jour" @@ -829,7 +831,7 @@ fr: labels: term: Terme searches: Recherches - unique: Unique + click_through: Taux de clics suivis emails: title: "Courriels envoyés" xaxis: "Jour" @@ -886,7 +888,7 @@ fr: xaxis: "Jour" yaxis: "Nombre de vues par les robots d'indexation" page_view_total_reqs: - title: "Total" + title: "Pages vues" xaxis: "Jour" yaxis: "Total de pages vues" page_view_logged_in_mobile_reqs: @@ -1109,7 +1111,6 @@ fr: version_checks: "Ping les serveurs de Discourse afin d'obtenir les mises à jours et affiche les nouveaux messages d'information dans le tableau de bord /admin" new_version_emails: "Envoyer un courriel à contact_email quand une nouvelle version de Discourse est disponible." invite_expiry_days: "Combien de temps (en jours) les clés d'invitation sont-elles valides" - invite_passthrough_hours: "Combien de temps un utilisateur peut encore utilisé une clé d'invitation après son expiration, en heure" invite_only: "Les inscriptions publiques sont désactivées, elles ne peuvent se faire uniquement sur invitation par les responsables." login_required: "Authentification requise pour lire le contenu du site, interdit l'accès anonyme." min_username_length: "Longueur minimale du nom d'utilisateur en caractères. ATTENTION : si des utilisateurs ou groupes existants ont des noms plus courts que celui-ci, votre site se cassera !" @@ -1130,6 +1131,8 @@ fr: sso_overrides_username: "Surcharger les pseudos locaux avec les pseudos externes d'un SSO à chaque connexion, et prévenir les modifications locales. (ATTENTION : des écarts peuvent se produire dûes aux différences de longueur et d'exigences sur les pseudos)" sso_overrides_name: "Surcharger les noms complets locaux avec les noms complets externes d'un SSO à chaque connexion, et prévenir les modifications locales." sso_overrides_avatar: "Surcharge les avatars des utilisateurs avec les avatars d'un SSO. Si activé, il est fortement recommandé de désactiver allow_uploaded_avatars." + sso_overrides_profile_background: "Remplacer arrière-plan du profil utilisateur avec un avatar d'un site externe à travers SSO payload." + sso_overrides_card_background: "Remplacer arrière-plan de la carte de l'utilisateur avec un avatar d'un site externe à travers SSO payload." sso_not_approved_url: "Rediriger les comptes SSO non validés vers cette URL" sso_allows_all_return_paths: "Ne pas restreindre le domaine pour les return_paths fournis par le SSO (par défaut, le chemin de retour doit être sur le site actuel)" enable_local_logins: "Activer les comptes locaux avec pseudo et mot de passe. Ceci doit être activé pour que les invitations fonctionnent. ATTENTION : si désactivé, vous ne pourrez peut-être plus vous connecter si vous n'avez pas configuré au préalable au moins une autre méthode de connection." @@ -1317,6 +1320,7 @@ fr: auto_silence_fast_typers_max_trust_level: "Niveau de confiance maximum pour automatiquement mettre sous silence les utilisateurs rédigeant des messages trop rapidement." auto_silence_first_post_regex: "Regex non sensible à la casse qui, si elle est déclenchée, mettre sous silence le premier message de l'utilisateur et l'enverra dans la file d'attente d'approbation.\nExemple: rageux|a[bc]a mettre sous slience les premiers messages contenant rageux ou aba ou aca." flags_default_topics: "Afficher les sujets signalés par défaut dans la section administrateur" + min_flags_staff_visibility: "Le nombre minimum de signalements sur un message avant qu'un responsable puisse le voir sous admin." reply_by_email_enabled: "Activer les réponses aux sujets via courriel." reply_by_email_address: "Modèle pour la réponse par courriel entrant; exemple : %{reply_key}@reply.example.com ou replies+%{reply_key}@example.com" alternative_reply_by_email_addresses: "Liste des templates alternatifs pour les adresses des courriels entrants de la réponse par courriel. Exemple : %{reply_key}@reply.example.com|replies+%{reply_key}@example.com" @@ -1428,7 +1432,7 @@ fr: allowed_href_schemes: "Préfixes autorisés dans les liens en plus de http et https." embed_post_limit: "Nombre maximum de messages à embarquer." embed_username_required: "Un pseudo d'utilisateur pour la création du sujet est nécessaire." - notify_about_flags_after: "S'il y a des signalements qui n'ont pas été traités après ce nombre d'heures, envoyer un message direct aux responsables. Mettre 0 pour désactiver." + notify_about_flags_after: "S'il y a des signalements qui n'ont pas été gérés après ce nombre d'heures, envoyer un message direct aux modérateurs. 0 pour désactiver." show_create_topics_notice: "Si le site contient moins de 5 sujets publics, afficher un message pour demander aux administrateurs de créer d'autres sujets." delete_drafts_older_than_n_days: Supprimer les brouillons plus vieux que (n) jours. bootstrap_mode_min_users: "Nombre minimum d'utilisateurs nécessaire pour désactiver le mode de démarrage (mettre à 0 pour désactiver)" @@ -1496,6 +1500,8 @@ fr: company_full_name: "Nom de société (complet)" company_domain: "Domaine de la société" shared_drafts_category: "Activez la fonction Ébauches partagées en désignant une catégorie pour les ébauches de sujet." + push_notifications_prompt: "Afficher la demande de consentement de l'utilisateur" + push_notifications_icon_url: "L'icône de badge qui apparait dans le coin notifications. Taille recommandée: 96px x 96px." errors: invalid_email: "Adresse de courriel invalide." invalid_username: "Il n'y a pas d'utilisateur ayant ce pseudo." @@ -2157,6 +2163,13 @@ fr: Nous sommes désolés, mais l'envoi de votre courriel à %{destination} (intitulé %{former_title}) n'a pas fonctionné. Aucune des adresses de destination n'est reconnue, ou l'entête Message-ID dans le courriel a été modifié. Veuillez vérifier que vous envoyez bien à l'adresse de courriel fournie par les responsables. + email_reject_old_destination: + title: "Courriel rejeté - Mauvaise ancienne destination" + subject_template: "[%{email_prefix}] Problème de courriel -- Vous essayez de répondre à une ancienne notification" + text_body_template: | + Nous sommes désolé, mais l'envoi de votre courriel à destination de %{destination} (intitulé %{former_title}) n'a pas fonctionné. + + Pour des raisons de sécurité, nous n'acceptons des réponses aux notifications que pendant 90 jours. Veuillez vous rendre sur [le sujet](%{short_url}) pour continuer la conversation. email_reject_topic_not_found: title: "Courriel rejeté - Sujet introuvable" subject_template: "[%{email_prefix}] Problème de courriel -- Sujet introuvable" @@ -3308,3 +3321,14 @@ fr: search_logs: graph_title: "Nombre de recherches" joined: "Inscrit" + discourse_push_notifications: + popup: + mentioned: '%{username} vous a mentionné dans « %{topic} » - %{site_title}' + group_mentioned: '%{username} vous a mentionné dans « %{topic} » - %{site_title}' + quoted: '%{username} vous a cité dans « %{topic} » - %{site_title}' + replied: '%{username} vous a répondu dans « %{topic} » - %{site_title}' + posted: '%{username} a posté dans « %{topic} » - %{site_title}' + private_message: '%{username} vous a envoyé un message direct dans « %{topic} » - %{site_title}' + linked: '%{username} a créé un lien vers votre message posté dans « %{topic} » - %{site_title}' + confirm_title: 'Notifications activées - %{site_title}' + confirm_body: 'Les notifications ont été activées.' diff --git a/config/locales/server.gl.yml b/config/locales/server.gl.yml index 7402115ad8..dd3ab2f7f0 100644 --- a/config/locales/server.gl.yml +++ b/config/locales/server.gl.yml @@ -350,7 +350,6 @@ gl: title: "Extractores web" xaxis: "Día" page_view_total_reqs: - title: "Total" xaxis: "Día" page_view_logged_in_mobile_reqs: xaxis: "Día" diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index 86f346d9c0..5bd9322171 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -93,18 +93,22 @@ he: too_long: one: ארוך מידי (המקסימום האפשרי הוא תו אחד) other: ארוך מידי (המקסימום האפשרי הוא %{count} תוים) + two: ארוך מידי (המקסימום האפשרי הוא %{count} תוים) too_short: one: קצר מידי (המינימום הנדרש הוא תו אחד) other: קצר מידי (המינימום הנדרש הוא %{count} תוים) + two: קצר מידי (המינימום הנדרש הוא %{count} תוים) wrong_length: one: באורך שגוי (צריך להיות באורך תו אחד) other: באורך שגוי (צריך להיות %{count} תוים) + two: באורך שגוי (צריך להיות %{count} תוים) other_than: "צריך להיות שונה מ-%{count}" template: body: 'היו בעיות עם השדות הבאים:' header: one: שגיאה מנעה מ-%{model} להישמר. other: '%{count} שגיאות מנעו מ-%{model} להישמר.' + two: '%{count} שגיאות מנעו מ-%{model} להישמר.' embed: load_from_remote: "ארעה שגיאה בטעינת הפוסט הזה." site_settings: @@ -139,6 +143,7 @@ he: too_many_replies: one: אנחנו מצטערים, אבל משתמשים חדשים מוגבלים זמנית לתגובה אחת לאותו הנושא. other: אנחנו מצטערים, אבל משתמשים חדשים מוגבלים זמנית ל-%{count} תגובות לאותו הנושא. + two: אנחנו מצטערים, אבל משתמשים חדשים מוגבלים זמנית ל-%{count} תגובות לאותו הנושא. embed: start_discussion: "התחלת דיון" continue: "המשך דיון" @@ -150,6 +155,7 @@ he: more_replies: one: עוד תגובה אחת other: עוד %{count} תגובות + two: עוד %{count} תגובות loading: "טוען דיון..." permalink: "קישור" imported_from: "זה נושא דיון מלווה לערך המקורי ב- %{link}" @@ -157,26 +163,32 @@ he: replies: one: תגובה אחת other: '%{count} תגובות' + two: '%{count} תגובות' no_mentions_allowed: "מצטערים, אך אינכם יכולים לאזכר משתמשים אחרים." too_many_mentions: one: מצטערים, אתם יכולים לאזכר רק משתמש אחד בפוסט other: מצטערים, אתם יכולים לאזכר רק %{count} משתמשים בפוסט + two: מצטערים, אתם יכולים לאזכר רק %{count} משתמשים בפוסט no_mentions_allowed_newuser: "מצטערים, משתמשים חדשים לא יכולים להזכיר משתמשים אחרים." too_many_mentions_newuser: one: מצטערים, משתמשים חדשים יכולים לאזכר רק משתמש אחד בפוסט. other: 'מצטערים, משתמשים חדשים יכולים להזכיר רק %{count} משתמשים אחרים בפוסט. ' + two: 'מצטערים, משתמשים חדשים יכולים להזכיר רק %{count} משתמשים אחרים בפוסט. ' no_images_allowed: "מצטערים, משתמשים חדשים לא יכולים להוסיף תמונות לפוסטים." too_many_images: one: מצטערים, משתמשים חדשים יכולים להוסיף רק תמונה אחת לפוסט. other: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} תמונות לפוסט. + two: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} תמונות לפוסט. no_attachments_allowed: "מצטערים, משתמשים חדשים לא יכולים להוסיף קבצים לפוסטים." too_many_attachments: one: מצטערים, משתמשים חדשים יכולים להוסיף רק צירוף אחד לפוסט. other: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} צירופים לפוסט. + two: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} צירופים לפוסט. no_links_allowed: "מצטערים, משתמשים חדשים לא יכולים להוסיף קישורים לפוסטים." too_many_links: one: מצטערים, משתמשים חדשים יכולים להוסיף רק קישור אחד בפוסט. other: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} קישורים בפוסט. + two: מצטערים, משתמשים חדשים יכולים להוסיף רק %{count} קישורים בפוסט. spamming_host: "סליחה אך אינכם יכולים להוסיף קישור לאתר זה." user_is_suspended: "משתמשים מושעים אינם מורשים לפרסם." topic_not_found: "משהו השתבש אולי נושא זה נסגר או נמחק בזמן שקראתם אותו?" @@ -243,6 +255,7 @@ he: until_posts: one: פוסט אחד other: '%{count} פוסטים' + two: '%{count} פוסטים' 'new-topic': | ברוכים באים ל%{site_name} — **תודה שהתחלתם שיחה חדשה! ** -הכותרת נשמעת מעניינת כשאתם קוראים אותה בקול רם? האם היא תקציר טוב? @@ -418,6 +431,7 @@ he: topic_exists: one: לא ניתן למחוק את הקטגוריה הזו משום שיש בה נושא אחד. הנושא הותיק ביותר הוא %{topic_link}. other: לא ניתן למחוק את הקטגוריה הזו משום שיש בה %{count} נושאים. הנושא הותיק ביותר הוא %{topic_link}. + two: לא ניתן למחוק את הקטגוריה הזו משום שיש בה %{count} נושאים. הנושא הותיק ביותר הוא %{topic_link}. topic_exists_no_oldest: "לא ניתן למחוק קטגוריה זו משום שסך הנושאים הוא %{count}." uncategorized_description: "נושאים שלא דורשים קטגוריה מסויימת, או שאינם מתאימים לאף קטגוריה קיימת." trust_levels: @@ -439,83 +453,108 @@ he: hours: one: שעה אחת other: '%{count} שעות' + two: '%{count} שעות' minutes: one: דקה אחת other: '%{count} דקות' + two: '%{count} דקות' seconds: one: שניה אחת other: '%{count} שניות' + two: '%{count} שניות' datetime: distance_in_words: half_a_minute: "פחות מדקה" less_than_x_seconds: one: פחות משנייה other: פחות מ-%{count} שניות + two: פחות מ-%{count} שניות x_seconds: one: שנייה other: '%{count} שניות' + two: '%{count} שניות' less_than_x_minutes: one: פחות מדקה other: פחות מ-%{count} דקות + two: פחות מ-%{count} דקות x_minutes: one: דקה other: '%{count} דקות' + two: '%{count} דקות' about_x_hours: one: שעה other: '%{count} שעות' + two: '%{count} שעות' x_days: one: יום other: '%{count} ימים' + two: '%{count} ימים' about_x_months: one: חודש other: '%{count} חודשים' + two: '%{count} חודשים' x_months: one: חודש other: '%{count} חודשים' + two: '%{count} חודשים' about_x_years: one: שנה other: '%{count} שנים' + two: '%{count} שנים' over_x_years: one: יותר משנה other: יותר מ-%{count} שנים + two: יותר מ-%{count} שנים almost_x_years: one: שנה other: '%{count} שנים' + two: '%{count} שנים' distance_in_words_verbose: half_a_minute: "ממש עכשיו" less_than_x_seconds: one: ממש עכשיו other: ממש עכשיו + two: ממש עכשיו x_seconds: one: לפני שנייה other: לפני %{count} שניות + two: לפני %{count} שניות less_than_x_minutes: one: לפני פחות מדקה other: לפני פחות מ-%{count} דקות + two: לפני פחות מ-%{count} דקות x_minutes: one: לפני דקה other: לפני %{count} דקות + two: לפני %{count} דקות about_x_hours: one: לפני שעה other: לפני %{count} שעות + two: לפני %{count} שעות x_days: one: אתמול other: לפני %{count} ימים + two: לפני %{count} ימים about_x_months: one: לפני בערך חודש other: לפני בערך %{count} חודשים + two: לפני בערך %{count} חודשים x_months: one: לפני חודש other: לפני %{count} חודשים + two: לפני %{count} חודשים about_x_years: one: לפני בערך שנה other: לפני בערך %{count} שנים + two: לפני בערך %{count} שנים over_x_years: one: לפני יותר משנה other: לפני יותר מ %{count} שנים + two: לפני יותר מ %{count} שנים almost_x_years: one: לפני כמעט שנה other: לפני כמעט %{count} שנים + two: לפני כמעט %{count} שנים password_reset: no_token: "מצטערים, הקישור לשינוי הסיסמה ישן מדי. לחצו על כפתור הכניסה ובחרו ב\"שכחתי את הסיסמה שלי\" כדי לקבל קישור חדש." choose_new: "בחרו סיסמה חדשה" @@ -752,7 +791,6 @@ he: xaxis: "יום" yaxis: "צפיות בדפים של זחלני רשת" page_view_total_reqs: - title: "סה\"כ" xaxis: "יום" yaxis: "צפיות כוללות" page_view_logged_in_mobile_reqs: @@ -818,6 +856,7 @@ he: email_polling_errored_recently: one: ניסיונות שליחת מיילים יצרו תקלה ב 24 השעות האחרונות. צפו ביומנים לפרטים נוספים. other: ניסיונות שליחת מיילים יצרו %{count} תקלות ב 24 השעות האחרונות. צפו ביומנים לפרטים נוספים. + two: ניסיונות שליחת מיילים יצרו %{count} תקלות ב 24 השעות האחרונות. צפו ביומנים לפרטים נוספים. bad_favicon_url: "ה favicon לא עולה. אנא בדקו את הגדרת ה favicon_url ב הגדרות האתר." poll_pop3_timeout: "החיבור לשרת POP3 התנתק. דוא\"ל נכנס לא יכול להשלף ואינו מאוחזר. אנא בדקו את הגדרות ה-POP3 שלכם ואת ספק השירות." poll_pop3_auth_error: "החיבור לשרת POP3 נכשל בשל שגיאת הזדהות. אנא בדקו את הגדרות ה-POP3 שלכם." @@ -935,7 +974,6 @@ he: version_checks: "שלחו פינג להאב של Discourse לעדכוני גרסה וכדי להציג מסרים אודות גרסאות בלוח התצוגה ב /admin" new_version_emails: "שלחו דוא\"ל לכתובת של contact_email כשגרסה חדשה של Discourse זמינה." invite_expiry_days: "מה התוקף מפתחות הזמנת משתמשים, בימים" - invite_passthrough_hours: "כמה זמן, בשעות, יכולים משתמשים לעשות שימוש בהזמנה שנשלחה כדי להתחבר " invite_only: "הרשמה פומבית מנוטרלת, כל המשתמשים החדשים חייבים להיות מוזמנים על ידי הצוות." login_required: "דרשו הזדהות לקריאת תוכן באתר זה, אל תאפשרו גישה אנונימית." min_username_length: "אורך שמות משתמשים מינימלי בתווים. אזהרה: אם למשתמשים קיימים או קבוצות כבר יש שם קצר יותר, האתר שלכם ישבר!" @@ -1322,6 +1360,7 @@ he: edit_reason: one: פוסט מוזג על ידי %{username} other: '%{count} פוסטים מוזגו על ידי %{username}' + two: '%{count} פוסטים מוזגו על ידי %{username}' errors: different_topics: "לא ניתן למזג פוסטים ששייכים לנושאים שונים." different_users: "לא ניתן למזג פוסטים ששייכים למשתמשים שונים." @@ -1329,9 +1368,11 @@ he: new_topic_moderator_post: one: 'תגובה פוצלה לנושא חדש: %{topic_link}' other: '%{count} תגובות פוצלו לנושא חדש: %{topic_link}' + two: '%{count} תגובות פוצלו לנושא חדש: %{topic_link}' existing_topic_moderator_post: one: 'תגובה אוחדה לנושא קיים: %{topic_link}' other: '%{count} תגובות אוחדו לנושא קיים: %{topic_link}' + two: '%{count} תגובות אוחדו לנושא קיים: %{topic_link}' change_owner: post_revision_text: "בעלות הועברה מהמשתמש %{old_user} אל %{new_user}" deleted_user: "משתמש שנמחק" @@ -1343,45 +1384,59 @@ he: autoclosed_message_max_posts: one: 'הודעה זו נסגרה אוטומטית אחרי שהגיעה למספר המקסימלי של תגובות: 1 .' other: 'הודעה זו נסגרה אוטומטית אחרי שהגיעה למספר המקסימלי של תגובות: %{count} .' + two: 'הודעה זו נסגרה אוטומטית אחרי שהגיעה למספר המקסימלי של תגובות: %{count} .' autoclosed_topic_max_posts: one: 'נושא זה נסגר אוטומטית לאחר שהגיע למספר המקסימלי של תגובות: 1.' other: 'נושא זה נסגר אוטומטית אחרי שהגיע למספר המקסימלי של תגובות: %{count} .' + two: 'נושא זה נסגר אוטומטית אחרי שהגיע למספר המקסימלי של תגובות: %{count} .' autoclosed_enabled_days: one: הנושא הזה ננעל אוטומטית לאחר יום אחד. תגובות חדשות לא מתקבלות. other: נושא זה נסגר באופן אוטומטי לאחר %{count} ימים. לא ניתן להוסיף תגובות חדשות. + two: נושא זה נסגר באופן אוטומטי לאחר %{count} ימים. לא ניתן להוסיף תגובות חדשות. autoclosed_enabled_hours: one: נושא זה ננעל לאחר שעה אחת. לא ניתן להוסיף תגובות חדשות. other: נושא זה נסגר אוטומטית לאחר %{count} שעות. לא ניתן להוסיף תגובות חדשות. + two: נושא זה נסגר אוטומטית לאחר %{count} שעות. לא ניתן להוסיף תגובות חדשות. autoclosed_enabled_minutes: one: הנושא הזה ננעל אוטומטית לאחר דקה. תגובות חדשות לא מתקבלות. other: הנושא הזה נסגר אוטומטית לאחר %{count} דקות. תגובות חדשות לא מתקבלות. + two: הנושא הזה נסגר אוטומטית לאחר %{count} דקות. תגובות חדשות לא מתקבלות. autoclosed_enabled_lastpost_days: one: נושא זה ננעל אוטומטית לאחר יום אחד מהתגובה האחרונה. תגובות חדשות לא מתקבלות. other: נושא זה נסגר אוטומטית לאחר %{count} ימים מהתגובה האחרונה. תגובות חדשות לא מתקבלות. + two: נושא זה נסגר אוטומטית לאחר %{count} ימים מהתגובה האחרונה. תגובות חדשות לא מתקבלות. autoclosed_enabled_lastpost_hours: one: נושא זה נסגר שעה לאחר התגובה האחרונה. תגובות נוספות אינן מותרות יותר. other: נושא זה נסגר אוטומטית %{count} שעות לאחר התגובה האחרונה. תגובות נוספות אינן מותרות יותר. + two: נושא זה נסגר אוטומטית %{count} שעות לאחר התגובה האחרונה. תגובות נוספות אינן מותרות יותר. autoclosed_enabled_lastpost_minutes: one: נושא זה ננעל אוטומטית לאחר דקה מהתגובה האחרונה. תגובות חדשות לא מתקבלות. other: נושא זה נסגר אוטומטית לאחר %{count} דקות מהתגובה האחרונה. תגובות חדשות לא מתקבלות. + two: נושא זה נסגר אוטומטית לאחר %{count} דקות מהתגובה האחרונה. תגובות חדשות לא מתקבלות. autoclosed_disabled_days: one: נושא זה נפתח אוטומטית לאחר יום 1. other: נושא זה נפתח אוטומטית לאחר %{count} ימים. + two: נושא זה נפתח אוטומטית לאחר %{count} ימים. autoclosed_disabled_hours: one: נושא זה נפתח אוטומטית לאחר שעה 1. other: נושא זה נפתח אוטומטית לאחר %{count} שעות. + two: נושא זה נפתח אוטומטית לאחר %{count} שעות. autoclosed_disabled_minutes: one: נושא זה נפתח אוטומטית לאחר דקה 1. other: נושא זה נפתוח אוטומטית לאחר %{count} דקות. + two: נושא זה נפתוח אוטומטית לאחר %{count} דקות. autoclosed_disabled_lastpost_days: one: הנושא נפתח אוטומטית יום 1 אחרי התגובה האחרונה. other: הנושא נפתח אוטומטית %{count} ימים אחרי התגובה האחרונה. + two: הנושא נפתח אוטומטית %{count} ימים אחרי התגובה האחרונה. autoclosed_disabled_lastpost_hours: one: נושא זה נפתח אוטומטית שעה 1 אחרי התגובה האחרונה. other: נושא זה נפתח אוטומטית %{count} שעות אחרי התגובה האחרונה. + two: נושא זה נפתח אוטומטית %{count} שעות אחרי התגובה האחרונה. autoclosed_disabled_lastpost_minutes: one: נושא זה נפתח אוטומטית דקה 1 אחרי התגובה האחרונה. other: נושא זה נפתח אוטומטית %{count} דקות אחרי התגובה האחרונה. + two: נושא זה נפתח אוטומטית %{count} דקות אחרי התגובה האחרונה. autoclosed_disabled: "הנושא הזה נפתח. ניתן להגיב תגובות חדשות." autoclosed_disabled_lastpost: "הנושא הזה נפתח. ניתן להגיב תגובות חדשות." pinned_enabled: "הנושא הזה ננעץ. הוא יופיע בראש הקטגוריה שלו עד שיוסר מנעיצה על ידי מנהל או שכפתור נקה נעיצות נלחץ." @@ -1443,9 +1498,11 @@ he: flags_were_submitted: one: דגלים נרשמו לפני יותר משעה. [אנא סיקרו אותם](/admin/flags). other: דגלים נרשמו לפני יותר מ %{count} שעות. [אנא סיקרו אותם](/admin/flags). + two: דגלים נרשמו לפני יותר מ %{count} שעות. [אנא סיקרו אותם](/admin/flags). subject_template: one: דגל אחד ממתין לטיפול other: '%{count} דגלים ממתינים לטיפול' + two: '%{count} דגלים ממתינים לטיפול' unsubscribe_mailer: title: "שולח-מיילים לביטול מנוי" subject_template: "אשרו שאינכם מעוניינים יותר לקבל עדכוני דוא\"ל מ%{site_title}" @@ -1516,6 +1573,7 @@ he: temporarily_closed_due_to_flags: one: נושא זה סגור זמנית לשעה 1 עקב מספר רב של דגלי קהילה. other: נושא זה סגור זמנית ל-%{count} שעות עקב מספר רב של דגלי קהילה. + two: נושא זה סגור זמנית ל-%{count} שעות עקב מספר רב של דגלי קהילה. system_messages: private_topic_title: "נושא #%{id}" contents_hidden: "אנא בקרו בפוסט כדי לצפות בתוכן שלו." @@ -1721,6 +1779,7 @@ he: subject_template: one: משתמש 1 ממתין לאישורכם other: '%{count} משתמשים ממתינים לאישורכם' + two: '%{count} משתמשים ממתינים לאישורכם' text_body_template: | ישנן הרשמות של משתמשים חדשים שממתינות לאישור (או דחייה) לפני שהם יוכלו לגשת לפורום זה. @@ -1756,6 +1815,7 @@ he: reached_limit: one: 'שימו לב: אנחנו שולחים לכל היותר מייל 1 יומי. בידקו באתר כדי לראות את אלו שעלולים להשאר מאחור.' other: 'שימו לב: אנחנו שולחים לכל היותר %{count} מיילים יומיים. בידקו באתר כדי לראות את אלו שעלולים להשאר מאחור. נ.ב: תודה שאתם פופולאריים!' + two: 'שימו לב: אנחנו שולחים לכל היותר %{count} מיילים יומיים. בידקו באתר כדי לראות את אלו שעלולים להשאר מאחור. נ.ב: תודה שאתם פופולאריים!' in_reply_to: "בתגובה ל" unsubscribe: title: "ביטול מנוי" diff --git a/config/locales/server.id.yml b/config/locales/server.id.yml index 92e0823b16..e938a17e86 100644 --- a/config/locales/server.id.yml +++ b/config/locales/server.id.yml @@ -386,7 +386,6 @@ id: title: "Penjelajah Web" xaxis: "Hari" page_view_total_reqs: - title: "Total" xaxis: "Hari" page_view_logged_in_mobile_reqs: xaxis: "Hari" diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index c15953a29a..2abea1653b 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -764,7 +764,6 @@ it: xaxis: "Giorno" yaxis: "Visualizzazioni pagina da Spider web" page_view_total_reqs: - title: "Totali" xaxis: "Giorno" yaxis: "Visualizzazioni pagina totali" page_view_logged_in_mobile_reqs: @@ -962,7 +961,6 @@ it: version_checks: "Verifica su Discourse Hub l'esistenza di aggiornamenti e mostra i messaggi per le nuove versioni nel cruscotto /admin" new_version_emails: "Invia un'email all'indirizzo contact_email quando è disponibile una nuova versione di Discourse." invite_expiry_days: "Per quanti giorni le chiavi per inviti utente sono valide" - invite_passthrough_hours: "Per quanto tempo un utente può usare una chiave d'invito per connettersi, espresso in ore" invite_only: "La registrazione pubblica è disabilitata, tutti i nuovi utenti devono essere esplicitamente invitati dallo staff." login_required: "E' richiesta l'autenticazione per leggere contenuti su questo sito, disabilita l'accesso anonimo." min_username_length: "Lunghezza minima di un nome utente in caratteri. ATTENZIONE: se utenti o gruppi già esistenti hanno nomi più brevi di questo, il tuo sito si romperà!" diff --git a/config/locales/server.ja.yml b/config/locales/server.ja.yml index c4fa244432..6f602f90e8 100644 --- a/config/locales/server.ja.yml +++ b/config/locales/server.ja.yml @@ -487,7 +487,6 @@ ja: xaxis: "日" yaxis: "ウェブクローラからの閲覧数" page_view_total_reqs: - title: "全体" xaxis: "日" yaxis: "合計閲覧数" page_view_logged_in_mobile_reqs: @@ -630,7 +629,6 @@ ja: version_checks: "Discourse Hubからのアップデートを確認し、管理ページにバージョンやアップデートメッセージを表示する" new_version_emails: "Discourseの新しいバージョンが利用可能になった際に contact_email アドレスにメールで通知する" invite_expiry_days: "招待キーの有効期間 (日)" - invite_passthrough_hours: "ユーザーが、以前の招待キーを使う事ができる時間" login_required: "サイトのコンテンツを閲覧するには認証を必須にして、匿名アクセスを拒否する" min_password_length: "パスワードの最小の長さ" block_common_passwords: "最もよく使われている10,000個のパスワードを許可しない" diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 09dc910b64..3da2962cf4 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -694,7 +694,6 @@ ko: xaxis: "일" yaxis: "로봇 페이지뷰" page_view_total_reqs: - title: "총" xaxis: "일" yaxis: "총 페이지뷰" page_view_logged_in_mobile_reqs: @@ -877,7 +876,6 @@ ko: version_checks: "Dicousre Hub에 ping을 날려 버전 업데이트와 새 버전 알림을 /admin 대시보드에 보이게 합니다." new_version_emails: "사용가능한 새로운 업데이트가 있으면 등록된 contact_email 주소로 메일을 발송하여 알려줍니다." invite_expiry_days: "사용자 초대키 유효 기간, 일" - invite_passthrough_hours: "사용되어진 방문키를 로그인하는데 재사용할 수 있는 기간, 시간" invite_only: "가입 비공개화, 운영진에 의한 초대로만 신규 사용자 가입." login_required: "글을 읽으려면 인증(로그인)이 필요함" min_username_length: "최소 아이디 글자수. 경고: 이미 가입한 사용자나 그룹의 아이디 길이가 이 값보다 작으면 사이트가 깨집니다!" diff --git a/config/locales/server.nb_NO.yml b/config/locales/server.nb_NO.yml index 501d3b7465..7c53ed3dda 100644 --- a/config/locales/server.nb_NO.yml +++ b/config/locales/server.nb_NO.yml @@ -630,7 +630,6 @@ nb_NO: title: "Web crawlere" xaxis: "Dag" page_view_total_reqs: - title: "Totalt" xaxis: "Dag" yaxis: "Totalt antall sidevisninger" page_view_logged_in_mobile_reqs: diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index 2643275873..dcb64e44af 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -746,7 +746,6 @@ nl: xaxis: "Dag" yaxis: "Webcrawler-paginaweergaven" page_view_total_reqs: - title: "Totaal" xaxis: "Dag" yaxis: "Totale paginaweergaven" page_view_logged_in_mobile_reqs: @@ -917,7 +916,6 @@ nl: version_checks: "Ping de Discourse-Hub voor updates en laat meldingen van nieuwe versies zien op het dashboard van de beheerder." new_version_emails: "Stuur een email naar het contact_email adres wanneer een nieuwe versie van Discourse beschikbaar is." invite_expiry_days: "Hoe lang uitnodigingscodes geldig blijven (in dagen)." - invite_passthrough_hours: "Hoe lang een gebruiker een eerder verkregen uitnodigingssleutel kan gebruiken om in te loggen, in uren gespecificeerd" invite_only: "Openbare registratie is uitgeschakeld; alle nieuwe gebruikers moeten expliciet worden uitgenodigd door stafleden." login_required: "Authenticatie vereist om inhoud te lezen op deze site, verbied anonieme toegang." min_password_length: "Minimum lengte wachtwoord." diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index f22796c715..0dc4535086 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -813,7 +813,6 @@ pl_PL: xaxis: "Dzień" yaxis: "Odsłony botów indeksujących" page_view_total_reqs: - title: "Łącznie" xaxis: "Dzień" yaxis: "Wszystkie odsłony" page_view_logged_in_mobile_reqs: @@ -1003,7 +1002,6 @@ pl_PL: version_checks: "Odpytuj Discourse Hub o aktualizacje i wyświetlaj wiadomości o nowej wersji w panelu /admin" new_version_emails: "Wyślij email na adres contact_email, kiedy nowa wersja Discourse będzie dostępna." invite_expiry_days: "Jak długo klucz zaproszenie użytkownika jest ważny, w dniach." - invite_passthrough_hours: "Jak długo użytkownik może użyć poprzednio wykorzystanego klucza zaproszenia by się zalogować, w godzinach" invite_only: "Rejestracja jest wyłączona. Nowi użytkownicy muszą zostać zaproszeni przez administrację." login_required: "Wymagaj autoryzacji do wyświetlenia zawartości strony, zablokuj możliwość anonimowego dostępu." min_username_length: "Minimalna długość nazwy użytkownika w znakach. OSTRZEŻENIE: jeśli jacykolwiek istniejący użytkownicy lub grupy mają nazwy krótsze niż ta, twój serwis się uszkodzi!" diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index c98bb28c2c..ac24c95070 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -692,7 +692,6 @@ pt: xaxis: "Dia" yaxis: "Vistas de Página de Web Crawler" page_view_total_reqs: - title: "Total" xaxis: "Dia" yaxis: "Total de Vistas de Página" page_view_logged_in_mobile_reqs: @@ -869,7 +868,6 @@ pt: version_checks: "Fazer o ping do Discourse Hub para atualização de versões e mostrar mensagens sobre novas versões no painel de administração" new_version_emails: "Enviar um email para o endereço 'contact_email' quando uma nova versão do Discourse estiver disponível." invite_expiry_days: "Durante quantos dias as chaves de convite são válidas." - invite_passthrough_hours: "Quanto tempo pode um utilizador utilizar uma chave de convite previamente recuperada, em horas" login_required: "Requerer autenticação para ler conteúdo neste sítio, não permitir acesso anónimo." min_password_length: "Tamanho mínimo da palavra-passe." min_admin_password_length: "Tamanho mínimo da palavra-passe para Administração." diff --git a/config/locales/server.pt_BR.yml b/config/locales/server.pt_BR.yml index b4ae2e6265..4293cda920 100644 --- a/config/locales/server.pt_BR.yml +++ b/config/locales/server.pt_BR.yml @@ -701,7 +701,6 @@ pt_BR: xaxis: "Dia" yaxis: "Visualizações por indexadores web" page_view_total_reqs: - title: "Total" xaxis: "Dia" yaxis: "Visualizações totais" page_view_logged_in_mobile_reqs: @@ -874,7 +873,6 @@ pt_BR: version_checks: "Pingar Discourse Hub para atualizações de versão e exibir mensagens de versão no Painel em /admin" new_version_emails: "Enviar um email para o endereço contact_email quando uma nova versão do Discourse estiver disponível." invite_expiry_days: "Quantos dias as chaves de convite são válidas." - invite_passthrough_hours: "Quando tempo um usuário pode usar uma chave de convite previamente usada para logar, em horas" login_required: "Exigir autenticação para ler conteúdo neste site, desabilitar acesso anônimo." min_password_length: "Comprimento mínimo da senha." min_admin_password_length: "Tamanho de senha mínima para Administradores." diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index 2916b4393c..e863cddca4 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -696,7 +696,6 @@ ro: xaxis: "Zi" yaxis: "Vizualizări de pagină făcute de roboți web" page_view_total_reqs: - title: "Total" xaxis: "Zi" yaxis: "Total vizualizări de pagină" page_view_logged_in_mobile_reqs: @@ -874,7 +873,6 @@ ro: version_checks: "Verifică Hub-ul Discourse pentru actualizări și arată notificările de versiuni noi pe spațiul de lucru /admin ." new_version_emails: "Trimite un email la adresa contact_email când o nouă versiune de Discourse este disponibilă." invite_expiry_days: "Cât timp sunt valabile cheile de invitație ale utilizatorilor, în zile" - invite_passthrough_hours: "Cât timp un utilizator poate folosi o cheie de invitație acceptată anterior pentru autentificare, în ore" login_required: "Cere autentificare pentru a citi conținutul acestui site, blochează accesul anonim." min_password_length: "Lungimea minimă a parolei." min_admin_password_length: "Lungimea maximă a parolei pentru Admin." diff --git a/config/locales/server.ru.yml b/config/locales/server.ru.yml index 037ce0653e..cc5caa0345 100644 --- a/config/locales/server.ru.yml +++ b/config/locales/server.ru.yml @@ -740,7 +740,6 @@ ru: xaxis: "Дата" yaxis: "Просмотров страниц поисковыми системами" page_view_total_reqs: - title: "Всего" xaxis: "Дата" yaxis: "Всего Просмотров Страниц" page_view_logged_in_mobile_reqs: @@ -882,7 +881,6 @@ ru: version_checks: "Проверять обновления и показывать сообщения о новых версиях на панели администратора" new_version_emails: "Отправлять сообщение на адрес contact_email когда будут доступны новые версии." invite_expiry_days: "Срок валидности ключей, высланных приглашенному пользователю, в днях" - invite_passthrough_hours: "Как долго пользователь может воспользоваться ключем приглашения для входа на сайт, указывается в часах" login_required: "Требовать авторизации для доступа к сайту, анонимный доступ запретить." min_password_length: "Минимальная длина пароля" min_admin_password_length: "Минимальная длина пароля для Администратора." diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml index f14300ed28..8581e71d63 100644 --- a/config/locales/server.sk.yml +++ b/config/locales/server.sk.yml @@ -83,14 +83,17 @@ sk: many: "Záznam nemôže byť zmazaný z dôvodu zavislosti na zázname: %{record} " too_long: few: príliiš dlhé (maximum je %{count} znaky) + many: príliiš dlhé (maximum je %{count} znakov) one: príliš dlhé (maximálne 1 znak) other: príliiš dlhé (maximum je %{count} znakov) too_short: few: príliiš krátke (minimum je %{count} znaky) + many: príliiš krátke (minimum je %{count} znakov) one: príliiš krátke (minimum je 1 znak) other: príliiš krátke (minimum je %{count} znakov) wrong_length: few: nesprávna dĺžka (musí byť %{count} znaky) + many: nesprávna dĺžka (musí byť %{count} znakov) one: nesprávna dĺžka (musí byť 1 znak) other: nesprávna dĺžka (musí byť %{count} znakov) other_than: "musí byť iný než %{count}" @@ -98,6 +101,7 @@ sk: body: 'Nastal problém s nasledujúcimi položkami:' header: few: Uloženie %{model} zlyhalo kôli %{count} chybám + many: 'Uloženie %{model} zlyhalo kôli %{count} chybám ' one: Uloženie %{model} zlyhalo kôli chybe other: 'Uloženie %{model} zlyhalo kôli %{count} chybám ' embed: @@ -129,6 +133,7 @@ sk: likes: "Páči sa mi" too_many_replies: few: Ľutujeme, noví používatelia majú dočasne obmedzený počet príspevkov na %{count} v rámci jednej témy. + many: Ľutujeme, noví používatelia majú dočasne obmedzený počet príspevkov na %{count} v rámci jednej témy. one: Ľutujeme, noví používatelia majú dočasne obmedzený počet príspevkov na jeden v rámci jednej témy. other: Ľutujeme, noví používatelia majú dočasne obmedzený počet príspevkov na %{count} v rámci jednej témy. embed: @@ -136,6 +141,7 @@ sk: continue: "Pokračovať v diskusii" more_replies: few: '%{count} ďalšie odpovede' + many: '%{count} ďalších odpovedí' one: 1 ďalšia odpoveď other: '%{count} ďalších odpovedí' loading: "Nahrávanie Diskusie ..." @@ -144,32 +150,38 @@ sk: in_reply_to: "▶ %{username}" replies: few: '%{count} odpovede' + many: '%{count} odpovedí' one: 1 odpoveď other: '%{count} odpovedí' no_mentions_allowed: "Ľutujeme, nesmiete zmieňovať iných používateľov" too_many_mentions: few: Ľutujeme, v príspevku môžete zmieniť maximálne %{count} používatelov. + many: Ľutujeme, v príspevku môžete zmieniť maximálne %{count} používatelov. one: Ľutujeme, v príspevku môžete zmieniť maximálne jedného používateľa. other: Ľutujeme, v príspevku môžete zmieniť maximálne %{count} používatelov. no_mentions_allowed_newuser: "Ľutujeme, noví používatelia nesmú zmieňovať iných použivateľov" too_many_mentions_newuser: few: Ľutujeme, noví používatelia môžu zmieniť v príspevku maximálne %{count} používatelov. + many: Ľutujeme, noví používatelia môžu zmieniť v príspevku maximálne %{count} používatelov. one: Ľutujeme, noví používatelia môžu zmieniť v príspevku maximálne jedného používateľa. other: Ľutujeme, noví používatelia môžu zmieniť v príspevku maximálne %{count} používatelov. no_images_allowed_trust: "Ľutujeme, do príspevku nemôžete vložiť obrázok" no_images_allowed: "Ľutujeme, noví používatelia nemôžu vkladať obrázky do príspevkov." too_many_images: few: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} obrázky do príspevku. + many: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} obrázkov do príspevku. one: Ľutujeme, noví používatelia môžu vložiť maximálne jeden obrázok do príspevku. other: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} obrázkov do príspevku. no_attachments_allowed: "Ľutujeme, noví používatelia nemôžu vkladať prílohy do príspevkov." too_many_attachments: few: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} prílohy do príspevku. + many: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} príloh do príspevku. one: Ľutujeme, noví používatelia môžu vložiť maximálne jednu prílohu do príspevku. other: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} príloh do príspevku. no_links_allowed: "Ľutujeme, noví používatelia nemôžu vkladať odkazy do príspevkov." too_many_links: few: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} odkazy do príspevku. + many: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} odkazov do príspevku. one: Ľutujeme, noví používatelia môžu vložiť maximálne jeden odkaz do príspevku. other: Ľutujeme, noví používatelia môžu vložiť maximálne %{count} odkazov do príspevku. spamming_host: "Ľutujeme, nemôžete publikovať odkaz na tento zdroj" @@ -226,6 +238,7 @@ sk: education: until_posts: few: '%{count} príspevky' + many: '%{count} príspevkov' one: jeden príspevok other: '%{count} príspevkov' 'new-topic': | @@ -340,6 +353,7 @@ sk: has_subcategories: "Nemôžete vymazať kategóriu pretože obsahuje podkategórie." topic_exists: few: Nemôžete vymazať kategóriu pretože obsahuje %{count} témy. Najstaršia téma je %{topic_link}. + many: Nemôžete vymazať kategóriu pretože obsahuje %{count} tém. Najstaršia téma je %{topic_link}. one: Nemôžete vymazať kategóriu pretože obsahuje tému %{topic_link}. other: Nemôžete vymazať kategóriu pretože obsahuje %{count} tém. Najstaršia téma je %{topic_link}. topic_exists_no_oldest: "Nemôžete vymazať túto kategóriu pretože obsahuje %{count} tém." @@ -362,14 +376,17 @@ sk: rate_limiter: hours: few: '%{count} hodiny' + many: '%{count} hodín' one: 1 hodina other: '%{count} hodín' minutes: few: '%{count} minúty' + many: '%{count} minút' one: 1 minútu other: '%{count} minút' seconds: few: '%{count} sekundy' + many: '%{count} sekúnd' one: ' 1 sekundu' other: '%{count} sekúnd' datetime: @@ -377,92 +394,114 @@ sk: half_a_minute: "< 1m" less_than_x_seconds: few: < %{count}s + many: < %{count}s one: < 1s other: < %{count}s x_seconds: few: '%{count}s' + many: '%{count}s' one: 1s other: '%{count}s' less_than_x_minutes: few: < %{count}m + many: < %{count}m one: < 1m other: < %{count}m x_minutes: few: '%{count}m' + many: '%{count}m' one: 1m other: '%{count}m' about_x_hours: few: '%{count}h' + many: '%{count}h' one: 1h other: '%{count}h' x_days: few: '%{count}d' + many: '%{count}d' one: 1d other: '%{count}d' about_x_months: few: '%{count}mes' + many: '%{count}mes' one: ' 1mes' other: '%{count}mes' x_months: few: '%{count}mes' + many: '%{count}mes' one: 1mes other: '%{count}mes' about_x_years: few: '%{count}r' + many: '%{count}r' one: 1r other: '%{count}r' over_x_years: few: '> %{count}r' + many: '> %{count}r' one: '> 1r' other: '> %{count}r' almost_x_years: few: '%{count}r' + many: '%{count}r' one: 1r other: '%{count}r' distance_in_words_verbose: half_a_minute: "práve teraz" less_than_x_seconds: few: práve teraz + many: práve teraz one: práve teraz other: práve teraz x_seconds: few: pred %{count} sekundami + many: pred %{count} sekundami one: pred sekundou other: pred %{count} sekundami less_than_x_minutes: few: menej ako pred %{count} minútami + many: menej ako pred %{count} minútami one: menej ako pred minútou other: menej ako pred %{count} minútami x_minutes: few: pred %{count} minútami + many: pred %{count} minútami one: pred minútou other: pred %{count} minútami about_x_hours: few: pred %{count} hodinami + many: pred %{count} hodinami one: pred hodinou other: pred %{count} hodinami x_days: few: pred %{count} dňami + many: pred %{count} dňami one: včera other: pred %{count} dňami about_x_months: few: približne pred %{count} mesiacmi + many: približne pred %{count} mesiacmi one: približne pred mesiacom other: približne pred %{count} mesiacmi x_months: few: pred %{count} mesiacmi + many: pred %{count} mesiacmi one: pred mesiacom other: pred %{count} mesiacmi about_x_years: few: približne pred %{count} rokmi + many: približne pred %{count} rokmi one: približne pred rokom other: približne pred %{count} rokmi over_x_years: few: pred vyše %{count} rokmi + many: pred vyše %{count} rokmi one: pred vyše rokom other: pred vyše %{count} rokmi almost_x_years: few: pred takmer %{count} rokmi + many: pred takmer %{count} rokmi one: pred takmer rokom other: pred takmer %{count} rokmi password_reset: @@ -662,7 +701,6 @@ sk: title: "Webové prehľadávače" xaxis: "Deň" page_view_total_reqs: - title: "Celkovo" xaxis: "Deň" page_view_logged_in_mobile_reqs: xaxis: "Deň" @@ -808,7 +846,6 @@ sk: version_checks: "Zisťovať aktualizácie na Discourse Hub a zobrazovať správy o novej verzii na stránke admina" new_version_emails: "Poslať email na kontaktnú emailovú adresu ak je zistená nová verzia Discourse." invite_expiry_days: "Ako dlho je platná pozvánka pre používateľa, v dňoch" - invite_passthrough_hours: "Ako dlho môže používateľ používať získaný pozývací kľúč na prihlasovanie, v hodinách" login_required: "Požadovať prihlásenie pre čítanie obshau tejto stránky, zakáž anonymný prístup. " min_password_length: "Minimálna dĺžka hesla." min_admin_password_length: "Minimálna dĺžka hesla pre Administrátora." @@ -1065,10 +1102,12 @@ sk: move_posts: new_topic_moderator_post: few: '%{count} príspevky boli oddelené do novej témy: %{topic_link}' + many: '%{count} príspevkov bolo oddelených do novej témy: %{topic_link}' one: 'Príspevok bol oddelený do novej témy: %{topic_link}' other: '%{count} príspevkov bolo oddelených do novej témy: %{topic_link}' existing_topic_moderator_post: few: '%{count} príspevky boli pripojené k existujúcej téme: %{topic_link}' + many: '%{count} príspevkov bolo pripojených k existujúcej téme: %{topic_link}' one: 'Príspevok bol pripojený k existujúcej téme: %{topic_link}' other: '%{count} príspevkov bolo pripojených k existujúcej téme: %{topic_link}' change_owner: @@ -1081,34 +1120,42 @@ sk: closed_disabled: "Táto téma je teraz otvorená. Nové odpovede sú povolené." autoclosed_message_max_posts: few: Táto správa bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. + many: Táto správa bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. one: Táto správa bola automaticky uzavretá po dosiahnutí maximálneho limitu 1 odpovede. other: Táto správa bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. autoclosed_topic_max_posts: few: Táto téma bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. + many: Táto téma bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. one: Táto téma bola automaticky uzavretá po dosiahnutí maximálneho limitu 1 odpovede. other: Táto téma bola automaticky uzavretá po dosiahnutí maximálneho limitu %{count} odpovedí. autoclosed_enabled_days: few: Táto téma bola automaticky uzavretá po %{count} dňoch . Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} dňoch . Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po 1 dni . Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} dňoch . Nové odpovede už nie sú povolené. autoclosed_enabled_hours: few: Táto téma bola automaticky uzavretá po %{count} hodinách. Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} hodinách. Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po hodine. Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} hodinách. Nové odpovede už nie sú povolené. autoclosed_enabled_minutes: few: Táto téma bola automaticky uzavretá po %{count} minútach. Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} minútach. Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po minúte. Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} minútach. Nové odpovede už nie sú povolené. autoclosed_enabled_lastpost_days: few: Táto téma bola automaticky uzavretá po %{count} dňoch od poslednej odpovede. Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} dňoch od poslednej odpovede. Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po 1 dni od poslednej odpovede. Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} dňoch od poslednej odpovede. Nové odpovede už nie sú povolené. autoclosed_enabled_lastpost_hours: few: Táto téma bola automaticky uzavretá po %{count} hodinách od poslednej odpovede. Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} hodinách od poslednej odpovede. Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po1 hodine od poslednej odpovede. Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} hodinách od poslednej odpovede. Nové odpovede už nie sú povolené. autoclosed_enabled_lastpost_minutes: few: Táto téma bola automaticky uzavretá po %{count} minútach od poslednej odpovede. Nové odpovede už nie sú povolené. + many: Táto téma bola automaticky uzavretá po %{count} minútach od poslednej odpovede. Nové odpovede už nie sú povolené. one: Táto téma bola automaticky uzavretá po 1 minúte od poslednej odpovede. Nové odpovede už nie sú povolené. other: Táto téma bola automaticky uzavretá po %{count} minútach od poslednej odpovede. Nové odpovede už nie sú povolené. autoclosed_disabled: "Táto téma je teraz otvorená. Nové odpovede sú povolené." @@ -1157,6 +1204,7 @@ sk: flags_reminder: subject_template: few: '%{count} značky čakajú na zpracovanie' + many: '%{count} značiek čaká na zpracovanie' one: 1 značká čaká na zpracovanie other: '%{count} značiek čaká na zpracovanie' unsubscribe_mailer: @@ -1233,6 +1281,7 @@ sk: pending_users_reminder: subject_template: few: '%{count} užívateľia čakajú na schválenie' + many: '%{count} užívateľov čaká na schválenie' one: 1 užívateľ čaká na schválenie other: '%{count} užívateľov čaká na schválenie' download_remote_images_disabled: diff --git a/config/locales/server.sq.yml b/config/locales/server.sq.yml index fb180cb859..5dd0eadf35 100644 --- a/config/locales/server.sq.yml +++ b/config/locales/server.sq.yml @@ -478,7 +478,6 @@ sq: title: "Web Crawlers" xaxis: "Day" page_view_total_reqs: - title: "Total" xaxis: "Day" page_view_logged_in_mobile_reqs: xaxis: "Ditë" @@ -617,7 +616,6 @@ sq: version_checks: "Ping the Discourse Hub for version updates and show new version messages on the /admin dashboard" new_version_emails: "Send an email to the contact_email address when a new version of Discourse is available." invite_expiry_days: "How long user invitation keys are valid, in days" - invite_passthrough_hours: "How long a user can use a previously redeemed invitation key to log in, in hours" login_required: "Require authentication to read content on this site, disallow anonymous access." min_password_length: "Minimum password length." block_common_passwords: "Mos lejo fjalëkalimet që gjenden në 10,000 fjalëkalimet më të përdorshme." diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index 479f3da5de..3e40f05ef1 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -647,7 +647,6 @@ sv: xaxis: "Dag" yaxis: "Sökrobotars sidvisningar" page_view_total_reqs: - title: "Total" xaxis: "Dag" yaxis: "Totalt antal sidvisningar" page_view_logged_in_mobile_reqs: @@ -822,7 +821,6 @@ sv: version_checks: "Pinga Discourse Hubben för versionsuppdatering och visa nya versionsmeddelanden på /admin översiktspanelen" new_version_emails: "Skicka ett mejl till contact_email när en ny version av Discourse finns tillgängligt." invite_expiry_days: "Hur länge användarinbjudningsnycklar är giltiga, i dagar" - invite_passthrough_hours: "Hur länge en användare kan använda en tidigare utlöst inbjudningsnyckel för att logga in, i timmar" login_required: "Kräv autentisering för att läsa innehåll på den här webbplatsen, avvisa anonym tillgång." min_username_length: "Minsta längd på användarnamnet. Varning: om någon existerande användare eller grupp har namn kortare än detta, er sida kommer att gå sönder!" max_username_length: "Längsta användarnamn. Varning: om någon existerande användare eller grupp har ett namn längre än detta, er sida kommer att gå sönder." @@ -1229,6 +1227,7 @@ sv: incorrect_username_email_or_password: "Felaktigt användarnamn, e-post eller lösenord" wait_approval: "Tack för din registrering. Vi meddelar dig när ditt konto blivit godkänt." active: "Ditt konto är aktiverat och redo att användas." + activate_email: "Du är nästan där! Vi har skickat ett aktiveringsmejl till %{email}. Vänligen följ instruktionerna i mejlet och aktivera ditt konto. Får du inget mejl, kolla skräpposten. " not_activated: "Du kan inte logga in ännu. Vi har skickat ett aktiveringsmejl till dig. Vänligen följ instruktionerna i mejlet för att aktivera ditt konto." not_allowed_from_ip_address: "Du kan inte logga in som %{username} från den IP-adressen." admin_not_allowed_from_ip_address: "Du kan inte logga in som admin från den IP-adressen." @@ -1605,6 +1604,14 @@ sv: subject_template: "Du har blivit godkänd på %{site_name}!" signup: title: "Bli medlem" + subject_template: "%{email_prefix}Bekräfta ditt nya konto" + text_body_template: | + Välkommen till %{site_name}! + + Klicka på följande länk för att bekräfta och aktivera ditt nya konto: + %{base_url}/u/activate-account/%{email_token} + + Om ovan länk inte är klickbar, försök kopiera och klistra in den i adressfältet i din webbläsare. page_not_found: title: "Hoppsan! Den sidan finns inte eller så är den privat." popular_topics: "Populära" diff --git a/config/locales/server.te.yml b/config/locales/server.te.yml index f956cdc7ea..768b665ea1 100644 --- a/config/locales/server.te.yml +++ b/config/locales/server.te.yml @@ -421,7 +421,6 @@ te: title: "జాల క్రాలర్లు" xaxis: "రోజు" page_view_total_reqs: - title: "మొత్తం" xaxis: "రోజు" http_background_reqs: title: "వెనుతలం" diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 467f4384b3..40ae373422 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -367,7 +367,7 @@ tr_TR: one: '%{count}yıl' other: '%{count}yıl' distance_in_words_verbose: - half_a_minute: "şu anda" + half_a_minute: "hemen şimdi" less_than_x_seconds: one: hemen şimdi other: hemen şimdi @@ -419,7 +419,7 @@ tr_TR: 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 linki artık geçerli değil. Hesabınız zaten etkin olabilir mi?" + already_done: "Üzgünüz, hesap doğrulama bağlantısı artık geçerli değil. Hesabınız zaten etkin olabilir mi?" please_continue: "Hesabınız doğrulandı; şimdi ana sayfaya yönlendirileceksiniz." continue_button: "%{site_name} adresine devam edin" welcome_to: "%{site_name} topluluğuna hoş geldiniz!" @@ -597,7 +597,6 @@ tr_TR: title: "Arama botları" xaxis: "Gün" page_view_total_reqs: - title: "Toplam" xaxis: "Gün" yaxis: "Toplam Sayfa Görüntülemeleri" page_view_logged_in_mobile_reqs: @@ -763,7 +762,6 @@ tr_TR: version_checks: "Discourse Hub'a sürüm güncellemeleri için haber yolla ve yeni versiyon iletilerine /admin gösterge panelinde yer ver" new_version_emails: "Discourse'un yeni sürümü çıktığında contact_email adresine e-posta gönder." invite_expiry_days: "Kullanıcı davet anahtarlarının geçerlilik süresi, gün olarak" - invite_passthrough_hours: "Daha önce kabul edilmiş davetiye anahtarının kullanım süresi, saat olarak" login_required: "Bu sitede içerik görüntülenebilmesi için kimlik doğrulamayı zorunlu kıl, isimsiz girişe izin verme." min_password_length: "Parolanın en az uzunluğu." min_admin_password_length: "Yönetici için parolanın en az uzunluğu." @@ -1697,10 +1695,10 @@ tr_TR: fields: title: label: "Topluluğunuzun ismi" - placeholder: "Oktay'ın Yeri" + placeholder: "Ali'nin Yeri" site_description: label: "Topluluğunuzu kısa bir cümle ile tanımlayın" - placeholder: "Oktay ve arkadaşlarının ilginç şeyleri tartışabilecekleri bir yer" + placeholder: "Ali ve arkadaşlarının ilginç şeyleri tartışabilecekleri bir yer" introduction: title: "Tanıtım" fields: diff --git a/config/locales/server.uk.yml b/config/locales/server.uk.yml index 1df0b22103..58e448dfa8 100644 --- a/config/locales/server.uk.yml +++ b/config/locales/server.uk.yml @@ -30,6 +30,7 @@ uk: not_an_integer: має бути цілим числом too_long: few: надто довге (максимум %{count} символів) + many: надто довге (максимум %{count} символів) one: надто довге (максимум 1 символ) other: надто довге (максимум %{count} символів) other_than: "має бути іншим ніж %{count}" @@ -53,6 +54,7 @@ uk: continue: "Продовжити Дискусію" more_replies: few: '%{count} більше відповідей' + many: '%{count} більше відповідей' one: 1 більше відповідей other: '%{count} більше відповідей' loading: "Завантаження обговорення..." @@ -60,21 +62,25 @@ uk: imported_from: "Discussion topic for the original blog entry at: %{link}" replies: few: '%{count} відповідей' + many: '%{count} відповідей' one: 1 відповідь other: '%{count} відповідей' no_images_allowed: "Даруйте, нові користувачі не можуть вставляти зображення в дописи." too_many_images: few: Даруйте, нові користувачі можуть вставляти тільки %{count} зображень в допис. + many: Даруйте, нові користувачі можуть вставляти тільки %{count} зображень в допис. one: Даруйте, нові користувачі можуть вставляти тільки одне зображення в допис. other: Даруйте, нові користувачі можуть вставляти тільки %{count} зображень в допис. no_attachments_allowed: "Даруйте, нові користувачі не можуть вставляти прикріплення в дописи." too_many_attachments: few: Даруйте, нові користувачі можуть вставляти тільки %{count} прикріплень в допис. + many: Даруйте, нові користувачі можуть вставляти тільки %{count} прикріплень в допис. one: Даруйте, нові користувачі можуть вставляти тільки одне прикріплення в допис. other: Даруйте, нові користувачі можуть вставляти тільки %{count} прикріплень в допис. no_links_allowed: "Даруйте, нові користувачі не можуть вставляти посилання в дописи." too_many_links: few: Даруйте, нові користувачі можуть вставляти тільки %{count} посилань в допис. + many: Даруйте, нові користувачі можуть вставляти тільки %{count} посилань в допис. one: Даруйте, нові користувачі можуть вставляти тільки одне посилання в допис. other: Даруйте, нові користувачі можуть вставляти тільки %{count} посилань в допис. spamming_host: "Даруйте, Ви не можете вставити посилання на цей хост." @@ -150,14 +156,17 @@ uk: rate_limiter: hours: few: '%{count} години' + many: '%{count} годин' one: 1 година other: '%{count} годин' minutes: few: '%{count} хвилини' + many: '%{count} хвилин' one: 1 хвилина other: '%{count} хвилин' seconds: few: '%{count} секунди' + many: '%{count} секунд' one: 1 секунда other: '%{count} секунд' datetime: @@ -167,6 +176,7 @@ uk: half_a_minute: "щойно" x_seconds: few: '%{count} секунд тому' + many: '%{count} секунд тому' one: 1 секунда тому other: '%{count} секунд тому' password_reset: diff --git a/config/locales/server.ur.yml b/config/locales/server.ur.yml index a6f43e48bc..a12687d07d 100644 --- a/config/locales/server.ur.yml +++ b/config/locales/server.ur.yml @@ -835,7 +835,6 @@ ur: xaxis: "دن" yaxis: "صفحہ کے وَیب کرالر ملاحظات" page_view_total_reqs: - title: "کُل" xaxis: "دن" yaxis: "صفحہ کے کُل ملاحظات" page_view_logged_in_mobile_reqs: @@ -1054,7 +1053,6 @@ ur: version_checks: "ورژن اپ ڈیٹ کیلئے ڈِسکورس ہَب کو پِنگ کریں اور /admin ڈیش بورڈ پر نئے ورژن کا پیغام دکھائیں" new_version_emails: "جب ڈِسکورس کا نیا ورژن دستیاب ہو تو contact_email ایڈریس پر ایک ای میل بھیجیں۔" invite_expiry_days: "دنوں میں، کتنے عرصے تک صارف دعوت نامہ کلیدیں درست رہیں" - invite_passthrough_hours: "گھنٹوں میں، کتنے عرصے تک ایک صارف لاگ اِن کرنے کیلئے قبل از استعمال شدہ دعوتی کلید استعمال کرسکتا ہے" invite_only: "عوامی رجسٹریشن غیر فعال ہے، تمام نئے صارفین کو واضح طور پر سٹاف کی طرف سے مدعو کیا جانا لاذمی ہے۔" login_required: "اِس سائٹ پر مواد پڑھنے کیلئے اکاؤنٹ کی توثیق ہونا ضروری بنائیں، گمنام صارفین تک رسائی کو مسترد کریں۔" min_username_length: "حروف میں صارف نام کی کم از کم لمبائی۔ انتباہ: اگر کوئی موجودہ صارفین یا گروپوں کے نام اِس سے چھوٹے ہیں، تو آپ کی سائٹ ٹوٹ جائے گی!" @@ -1370,7 +1368,6 @@ ur: allowed_href_schemes: "http اور https کے علاوہ لِنکس میں اجازت دی گئی اسکیمز۔" embed_post_limit: "اَیمبَیڈ کیے جانے والی پوسٹس کی زیادہ سے زیادہ تعداد۔" embed_username_required: "ٹاپک کی تخلیق کیلئے صارف نام ضروری ہے۔" - notify_about_flags_after: "اگر ایسے فلَیگ موجود ہیں جن پر اتنے گھنٹوں کے بعد بھی کام نہیں کیا جا سکا، تو سٹاف کو ذاتی پیغام بھیجیں۔ غیر فعال کرنے کیلئے 0 پر سَیٹ کریں۔" show_create_topics_notice: "اگر سائٹ میں 5 سے کم عوامی ٹاپک موجود ہیں، تو کچھ ٹاپک تخلیق کرنے کیلئے منتظمین کو ایک نوٹس دکھائیں۔" delete_drafts_older_than_n_days: (ن) دنوں سے پرانے ڈرافٹ حذف کریں۔ bootstrap_mode_min_users: "بُوٹسٹرَیپ مَوڈ غیر فعال کرنے کیلئے درکار صارفین کی کم از کم تعداد (غیر فعال کرنے کیلئے 0 پر سَیٹ کریں)" diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index f7cc8f2a5a..feec5266ba 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -532,7 +532,6 @@ vi: title: "Thu thập thông tin web" xaxis: "Ngày" page_view_total_reqs: - title: "Tổng số" xaxis: "Ngày" yaxis: "Tổng lượt xem trang" page_view_logged_in_mobile_reqs: @@ -686,7 +685,6 @@ vi: version_checks: "Ping Discourse Hub để cập nhật phiên bản và hiện thông báo phiên bản mới trong bảng điều khiển quản trị" new_version_emails: "Gửi email đến địa chỉ contact_email khi có phiên bản Discourse mới." invite_expiry_days: "Key mời người dùng có giới hạn bao lâu? tính theo ngày" - invite_passthrough_hours: "Bao lâu người dùng có thể sử dụng mã lời mời trước đó để đăng nhập, theo giờ" login_required: "Yêu cầu chứng thực để đọc nội dung trên trang web, không cho phép người dùng nặc danh truy cập." min_password_length: "Chiều dài mật khẩu tối thiểu." min_admin_password_length: "Chiều dài mật khẩu tối thiểu đối với Admin." diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index d9550fb94c..d40e15c65a 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -742,7 +742,6 @@ zh_CN: xaxis: "天" yaxis: "Web 爬虫页面访问" page_view_total_reqs: - title: "总量" xaxis: "天" yaxis: "总浏览量" page_view_logged_in_mobile_reqs: @@ -941,7 +940,6 @@ zh_CN: version_checks: "访问 Discourse Hub 来检查版本更新,并在管理面板 /admin 显示新版本信息" new_version_emails: "当新版本发布时,发送一封邮件至 contact_email 设置的地址。" invite_expiry_days: "多少天以内用户的邀请码是有效的" - invite_passthrough_hours: "用户多久才能使用一个已经使用过的邀请代码,以小时计" invite_only: "公开注册已被禁用,所有新用户必须由管理人员邀请进来。" login_required: "需要验证才能继续在该站阅读,不允许匿名访问。" min_username_length: "最小用户名长度。警告:如果任何现存的用户或小组名字长度比这短,站点将无法正常工作!" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index c97b988392..58a3a1e02f 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -670,7 +670,6 @@ zh_TW: xaxis: "天" yaxis: "Web 爬蟲頁面訪問" page_view_total_reqs: - title: "總數" xaxis: "天" yaxis: "總瀏覽量" page_view_logged_in_mobile_reqs: @@ -850,7 +849,6 @@ zh_TW: version_checks: "訪問 Discourse Hub 來檢查版本更新,並在管理面板 /admin 顯示新版本訊息" new_version_emails: "當新版本發佈時,將會發送一封新的 EMail 至 contact_email 設定的位址" invite_expiry_days: "多少天以內用戶的邀請碼是有效的" - invite_passthrough_hours: "邀請號碼如已被使用,用戶仍可使用多少小時" login_required: "需要登入才能進入網站,不允許匿名操作" min_username_length: "最短用戶名長度。警告:如果任何現有帳號或群組名稱短於此,你的網站會炸掉。" max_username_length: "最長用戶名長度。警告:如果任何現有帳號或群組名稱長於此,你的網站會炸掉。" diff --git a/config/routes.rb b/config/routes.rb index 76a3a69063..cebaf579dc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -231,6 +231,7 @@ Discourse::Application.routes.draw do get "version_check" => "versions#show" get "dashboard-next" => "dashboard_next#index" + get "dashboard-old" => "dashboard#index" resources :dashboard, only: [:index] do collection do @@ -728,7 +729,7 @@ Discourse::Application.routes.draw do # current site before updating to a new Service Worker. # Support the old Service Worker path to avoid routing error filling up the # logs. - get "/service-worker.js" => redirect(relative_url_root + service_worker_asset), format: :js + get "/service-worker.js" => redirect(relative_url_root + service_worker_asset, status: 302), format: :js get service_worker_asset => "static#service_worker_asset", format: :js elsif Rails.env.development? get "/service-worker.js" => "static#service_worker_asset", format: :js @@ -802,4 +803,7 @@ Discourse::Application.routes.draw do get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new + post "/push_notifications/subscribe" => "push_notification#subscribe" + post "/push_notifications/unsubscribe" => "push_notification#unsubscribe" + end diff --git a/config/site_settings.yml b/config/site_settings.yml index a6bfc78d92..def3802eaf 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -229,6 +229,21 @@ basic: enable_whispers: client: true default: false + push_notifications_prompt: + default: false + client: true + push_notifications_icon_url: + default: '' + vapid_public_key_bytes: + default: '' + client: true + hidden: true + vapid_public_key: + default: '' + hidden: true + vapid_private_key: + default: '' + hidden: true login: invite_only: @@ -333,6 +348,8 @@ login: sso_overrides_avatar: default: false client: true + sso_overrides_profile_background: false + sso_overrides_card_background: false sso_not_approved_url: '' email_domains_blacklist: default: 'mailinator.com' @@ -391,7 +408,6 @@ users: default: true invite_expiry_days: default: 30 - invite_passthrough_hours: 0 invites_per_page: client: true default: 40 @@ -762,7 +778,8 @@ email: unsubscribe_via_email_footer: default: false delete_email_logs_after_days: - default: 365 + default: 90 + shadowed_by_global: true max_emails_per_day_per_user: 100 enable_staged_users: true maximum_staged_users_per_email: 10 @@ -1096,6 +1113,7 @@ spam: flags_default_topics: default: false client: true + min_flags_staff_visibility: 1 rate_limits: unique_posts_mins: 5 @@ -1305,7 +1323,9 @@ search: search_recent_posts_size: default: 100000 max: 100000 - log_search_queries: true + log_search_queries: + client: true + default: true search_query_log_max_size: default: 1000000 max: 1000000 diff --git a/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb b/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb new file mode 100644 index 0000000000..62a9393dad --- /dev/null +++ b/db/migrate/20180316092939_add_external_profile_and_card_background_url_to_single_sign_on_record.rb @@ -0,0 +1,6 @@ +class AddExternalProfileAndCardBackgroundUrlToSingleSignOnRecord < ActiveRecord::Migration[5.1] + def change + add_column :single_sign_on_records, :external_profile_background_url, :string + add_column :single_sign_on_records, :external_card_background_url, :string + end +end diff --git a/db/migrate/20180425185749_create_push_subscription.rb b/db/migrate/20180425185749_create_push_subscription.rb new file mode 100644 index 0000000000..67936fcec0 --- /dev/null +++ b/db/migrate/20180425185749_create_push_subscription.rb @@ -0,0 +1,9 @@ +class CreatePushSubscription < ActiveRecord::Migration[5.1] + def change + create_table :push_subscriptions do |t| + t.integer :user_id, null: false + t.string :data, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20180508142711_remove_invite_passthrough_hours.rb b/db/migrate/20180508142711_remove_invite_passthrough_hours.rb new file mode 100644 index 0000000000..5fbe7083cb --- /dev/null +++ b/db/migrate/20180508142711_remove_invite_passthrough_hours.rb @@ -0,0 +1,9 @@ +class RemoveInvitePassthroughHours < ActiveRecord::Migration[5.1] + def up + execute "DELETE FROM site_settings WHERE name = 'invite_passthrough_hours'" + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20180514133440_add_pm_topic_count_to_tags.rb b/db/migrate/20180514133440_add_pm_topic_count_to_tags.rb new file mode 100644 index 0000000000..9bcf5724eb --- /dev/null +++ b/db/migrate/20180514133440_add_pm_topic_count_to_tags.rb @@ -0,0 +1,5 @@ +class AddPmTopicCountToTags < ActiveRecord::Migration[5.1] + def change + add_column :tags, :pm_topic_count, :integer, null: false, default: 0 + end +end diff --git a/lib/admin_user_index_query.rb b/lib/admin_user_index_query.rb index 339066c645..baf20275da 100644 --- a/lib/admin_user_index_query.rb +++ b/lib/admin_user_index_query.rb @@ -98,6 +98,7 @@ class AdminUserIndexQuery when 'suspended' then @query.suspended when 'pending' then @query.not_suspended.where(approved: false, active: true) when 'suspect' then suspect_users + when 'staged' then @query.where(staged: true) end end diff --git a/lib/auth/default_current_user_provider.rb b/lib/auth/default_current_user_provider.rb index bf837c3d56..4d7521046c 100644 --- a/lib/auth/default_current_user_provider.rb +++ b/lib/auth/default_current_user_provider.rb @@ -131,7 +131,6 @@ class Auth::DefaultCurrentUserProvider end def refresh_session(user, session, cookies) - # if user was not loaded, no point refreshing session # it could be an anonymous path, this would add cost return if is_api? || !@env.key?(CURRENT_USER_KEY) @@ -162,6 +161,7 @@ class Auth::DefaultCurrentUserProvider client_ip: @request.ip) cookies[TOKEN_COOKIE] = cookie_hash(@user_token.unhashed_auth_token) + unstage_user(user) make_developer_admin(user) enable_bootstrap_mode(user) @env[CURRENT_USER_KEY] = user @@ -182,6 +182,13 @@ class Auth::DefaultCurrentUserProvider hash end + def unstage_user(user) + if user.staged + user.unstage + user.save + end + end + def make_developer_admin(user) if user.active? && !user.admin && @@ -193,11 +200,16 @@ class Auth::DefaultCurrentUserProvider end def enable_bootstrap_mode(user) - Jobs.enqueue(:enable_bootstrap_mode, user_id: user.id) if user.admin && user.last_seen_at.nil? && !SiteSetting.bootstrap_mode_enabled && user.is_singular_admin? + return if SiteSetting.bootstrap_mode_enabled + + if user.admin && user.last_seen_at.nil? && user.is_singular_admin? + Jobs.enqueue(:enable_bootstrap_mode, user_id: user.id) + end end def log_off_user(session, cookies) user = current_user + if SiteSetting.log_out_strict && user user.user_auth_tokens.destroy_all diff --git a/lib/autospec/rspec_runner.rb b/lib/autospec/rspec_runner.rb index 03b49f63d8..d09acd5450 100644 --- a/lib/autospec/rspec_runner.rb +++ b/lib/autospec/rspec_runner.rb @@ -23,6 +23,7 @@ module Autospec watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" } watch(%r{^plugins/.*/spec/.*\.rb}) + watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" } watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" } RELOADERS = Set.new diff --git a/lib/discourse.rb b/lib/discourse.rb index 0673e1f119..79e5984598 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -164,6 +164,14 @@ module Discourse @plugins ||= [] end + def self.hidden_plugins + @hidden_plugins ||= [] + end + + def self.visible_plugins + self.plugins - self.hidden_plugins + end + def self.plugin_themes @plugin_themes ||= plugins.map(&:themes).flatten end diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb index 98426e2ed7..030927df29 100644 --- a/lib/discourse_tagging.rb +++ b/lib/discourse_tagging.rb @@ -181,9 +181,7 @@ module DiscourseTagging end def self.hidden_tag_names(guardian = nil) - return [] if guardian&.is_staff? - - hidden_tags_query.pluck(:name) + guardian&.is_staff? ? [] : hidden_tags_query.pluck(:name) end def self.hidden_tags_query diff --git a/lib/email/processor.rb b/lib/email/processor.rb index f212fef667..b996dae629 100644 --- a/lib/email/processor.rb +++ b/lib/email/processor.rb @@ -55,6 +55,7 @@ module Email when ActiveRecord::Rollback then :email_reject_invalid_post when Email::Receiver::InvalidPostAction then :email_reject_invalid_post_action when Discourse::InvalidAccess then :email_reject_invalid_access + when Email::Receiver::OldDestinationError then :email_reject_old_destination else :email_reject_unrecognized_error end @@ -75,6 +76,10 @@ module Email Rails.logger.error(msg) end + if message_template == :email_reject_old_destination + template_args[:short_url] = e.message + end + if message_template # inform the user about the rejection message = Mail::Message.new(mail_string) diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb index 4cbcfb175c..06c9cb6761 100644 --- a/lib/email/receiver.rb +++ b/lib/email/receiver.rb @@ -33,6 +33,7 @@ module Email class InvalidPostAction < ProcessingError; end class UnsubscribeNotAllowed < ProcessingError; end class EmailNotAllowed < ProcessingError; end + class OldDestinationError < ProcessingError; end attr_reader :incoming_email attr_reader :raw_email @@ -42,8 +43,7 @@ module Email COMMON_ENCODINGS ||= [-"utf-8", -"windows-1252", -"iso-8859-1"] def self.formats - @formats ||= Enum.new(plaintext: 1, - markdown: 2) + @formats ||= Enum.new(plaintext: 1, markdown: 2) end def initialize(mail_string, opts = {}) @@ -163,7 +163,15 @@ module Email end end - raise first_exception || BadDestinationAddress + raise first_exception if first_exception + + if post = find_related_post(force: true) + if Guardian.new(user).can_see_post?(post) && post.created_at < 90.days.ago + raise OldDestinationError.new("#{Discourse.base_url}/p/#{post.id}") + end + end + + raise BadDestinationAddress end end @@ -200,33 +208,24 @@ module Email end def self.update_bounce_score(email, score) - # only update bounce score once per day - key = "bounce_score:#{email}:#{Date.today}" + if user = User.find_by_email(email) + old_bounce_score = user.user_stat.bounce_score + new_bounce_score = old_bounce_score + score + range = (old_bounce_score + 1..new_bounce_score) - if $redis.setnx(key, "1") - $redis.expire(key, 25.hours) + user.user_stat.bounce_score = new_bounce_score + user.user_stat.reset_bounce_score_after = SiteSetting.reset_bounce_score_after_days.days.from_now + user.user_stat.save! - if user = User.find_by_email(email) - user.user_stat.bounce_score += score - user.user_stat.reset_bounce_score_after = SiteSetting.reset_bounce_score_after_days.days.from_now - user.user_stat.save! - - bounce_score = user.user_stat.bounce_score - if user.active && bounce_score >= SiteSetting.bounce_score_threshold_deactivate - user.update!(active: false) - reason = I18n.t("user.deactivated", email: user.email) - StaffActionLogger.new(Discourse.system_user).log_user_deactivate(user, reason) - elsif bounce_score >= SiteSetting.bounce_score_threshold - # NOTE: we check bounce_score before sending emails, nothing to do - # here other than log it happened. - reason = I18n.t("user.email.revoked", email: user.email, date: user.user_stat.reset_bounce_score_after) - StaffActionLogger.new(Discourse.system_user).log_revoke_email(user, reason) - end + if user.active && range === SiteSetting.bounce_score_threshold_deactivate + user.update!(active: false) + reason = I18n.t("user.deactivated", email: user.email) + StaffActionLogger.new(Discourse.system_user).log_user_deactivate(user, reason) + elsif range === SiteSetting.bounce_score_threshold + # NOTE: we check bounce_score before sending emails, nothing to do here other than log it happened. + reason = I18n.t("user.email.revoked", email: user.email, date: user.user_stat.reset_bounce_score_after) + StaffActionLogger.new(Discourse.system_user).log_revoke_email(user, reason) end - - true - else - false end end @@ -737,8 +736,8 @@ module Email @category_email_in_regex ||= Regexp.union Category.pluck(:email_in).select(&:present?).map { |e| e.split("|") }.flatten.uniq end - def find_related_post - return if SiteSetting.find_related_post_with_key && !sent_to_mailinglist_mirror? + def find_related_post(force: false) + return if !force && SiteSetting.find_related_post_with_key && !sent_to_mailinglist_mirror? message_ids = Email::Receiver.extract_reply_message_ids(@mail, max_message_id_count: 5) return if message_ids.empty? diff --git a/lib/email/sender.rb b/lib/email/sender.rb index dbd0bdb983..6ded4b88e9 100644 --- a/lib/email/sender.rb +++ b/lib/email/sender.rb @@ -119,7 +119,7 @@ module Email # subcategory case if !topic.category.parent_category_id.nil? parent_category_name = Category.find_by(id: topic.category.parent_category_id).name - list_id = "#{SiteSetting.title} | #{topic.category.name} #{parent_category_name} <#{topic.category.name.downcase.tr(' ', '-')}.#{parent_category_name.downcase.tr(' ', '-')}.#{host}>" + list_id = "#{SiteSetting.title} | #{parent_category_name} #{topic.category.name} <#{topic.category.name.downcase.tr(' ', '-')}.#{parent_category_name.downcase.tr(' ', '-')}.#{host}>" end else list_id = "#{SiteSetting.title} <#{host}>" diff --git a/lib/email_updater.rb b/lib/email_updater.rb index e8515b8645..d3d8eaf339 100644 --- a/lib/email_updater.rb +++ b/lib/email_updater.rb @@ -86,6 +86,7 @@ class EmailUpdater when EmailChangeRequest.states[:authorizing_new] change_req.update_column(:change_state, EmailChangeRequest.states[:complete]) user.primary_email.update!(email: token.email) + user.set_automatic_groups confirm_result = :complete end else diff --git a/lib/flag_query.rb b/lib/flag_query.rb index 268e4abef1..11db2aa887 100644 --- a/lib/flag_query.rb +++ b/lib/flag_query.rb @@ -21,12 +21,16 @@ module FlagQuery total_rows = actions.count - post_ids = actions.limit(per_page) + post_ids_relation = actions.limit(per_page) .offset(offset) .group(:post_id) .order('MIN(post_actions.created_at) DESC') - .pluck(:post_id) - .uniq + + if opts[:filter] != "old" && SiteSetting.min_flags_staff_visibility > 1 + post_ids_relation = post_ids_relation.having("count(*) >= ?", SiteSetting.min_flags_staff_visibility) + end + + post_ids = post_ids_relation.pluck(:post_id).uniq posts = SqlBuilder.new(" SELECT p.id, @@ -182,18 +186,25 @@ module FlagQuery ft_by_id = {} users_by_id = {} topics_by_id = {} + counts_by_post = {} results.each do |pa| if pa.post.present? && pa.post.topic.present? - ft = ft_by_id[pa.post.topic.id] ||= OpenStruct.new( + topic_id = pa.post.topic.id + + ft = ft_by_id[topic_id] ||= OpenStruct.new( topic: pa.post.topic, flag_counts: {}, user_ids: [], - last_flag_at: pa.created_at + last_flag_at: pa.created_at, + meets_minimum: false ) - topics_by_id[pa.post.topic.id] = pa.post.topic + counts_by_post[pa.post.id] ||= 0 + sum = counts_by_post[pa.post.id] += 1 + ft.meets_minimum = true if sum >= SiteSetting.min_flags_staff_visibility + topics_by_id[topic_id] = pa.post.topic ft.flag_counts[pa.post_action_type_id] ||= 0 ft.flag_counts[pa.post_action_type_id] += 1 @@ -204,9 +215,11 @@ module FlagQuery end end + flagged_topics = ft_by_id.values.select { |ft| ft.meets_minimum } + Topic.preload_custom_fields(topics_by_id.values, TopicList.preloaded_custom_fields) - { flagged_topics: ft_by_id.values, users: users_by_id.values } + { flagged_topics: flagged_topics, users: users_by_id.values } end private diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 0bc54a09c8..81b7d7e243 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -493,6 +493,18 @@ JS PluginGem.load(path, name, version, opts) end + def hide_plugin + Discourse.hidden_plugins << self + end + + def enabled_site_setting_filter(filter = nil) + if filter + @enabled_setting_filter = filter + else + @enabled_setting_filter + end + end + def enabled_site_setting(setting = nil) if setting @enabled_site_setting = setting diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 082835eb91..f157800e38 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -82,6 +82,8 @@ module PrettyText ctx_load_manifest(ctx, "markdown-it-bundle.js") root_path = "#{Rails.root}/app/assets/javascripts/" + apply_es6_file(ctx, root_path, "discourse/helpers/parse-html") + apply_es6_file(ctx, root_path, "discourse/lib/to-markdown") apply_es6_file(ctx, root_path, "discourse/lib/utilities") PrettyText::Helpers.instance_methods.each do |method| @@ -341,29 +343,36 @@ module PrettyText fragment.to_html end - # Given a Nokogiri doc, convert all links to absolute - def self.make_all_links_absolute(doc) - site_uri = nil - doc.css("a").each do |link| - href = link["href"].to_s - begin - uri = URI(href) - site_uri ||= URI(Discourse.base_url) - link["href"] = "#{site_uri}#{link['href']}" unless uri.host.present? - rescue URI::InvalidURIError, URI::InvalidComponentError - # leave it - end - end - end + def self.make_all_links_absolute(doc) + site_uri = nil + doc.css("a").each do |link| + href = link["href"].to_s + begin + uri = URI(href) + site_uri ||= URI(Discourse.base_url) + link["href"] = "#{site_uri}#{link['href']}" unless uri.host.present? + rescue URI::InvalidURIError, URI::InvalidComponentError + # leave it + end + end + end def self.strip_image_wrapping(doc) doc.css(".lightbox-wrapper .meta").remove end + def self.convert_vimeo_iframes(doc) + doc.css("iframe[src*='player.vimeo.com']").each do |iframe| + vimeo_id = iframe['src'].split('/').last + iframe.replace "" + end + end + def self.format_for_email(html, post = nil) doc = Nokogiri::HTML.fragment(html) DiscourseEvent.trigger(:reduce_cooked, doc, post) strip_image_wrapping(doc) + convert_vimeo_iframes(doc) make_all_links_absolute(doc) doc.to_html end diff --git a/lib/scheduler/defer.rb b/lib/scheduler/defer.rb index e63cde145a..37116f58ed 100644 --- a/lib/scheduler/defer.rb +++ b/lib/scheduler/defer.rb @@ -28,7 +28,7 @@ module Scheduler def later(desc = nil, db = RailsMultisite::ConnectionManagement.current_db, &blk) if @async - start_thread unless (@thread && @thread.alive?) || @paused + start_thread unless @thread&.alive? || @paused @queue << [db, blk, desc] else blk.call @@ -36,13 +36,13 @@ module Scheduler end def stop! - @thread.kill if @thread && @thread.alive? + @thread.kill if @thread&.alive? @thread = nil end # test only def stopped? - !(@thread && @thread.alive?) + !@thread&.alive? end def do_all_work @@ -55,12 +55,8 @@ module Scheduler def start_thread @mutex.synchronize do - return if @thread && @thread.alive? - @thread = Thread.new { - while true - do_work - end - } + return if @thread&.alive? + @thread = Thread.new { do_work while true } end end diff --git a/lib/search.rb b/lib/search.rb index a391ebbd5a..adab7e3a7e 100644 --- a/lib/search.rb +++ b/lib/search.rb @@ -73,7 +73,10 @@ class Search return if day == 0 || month == 0 || day > 31 || month > 12 - return Time.zone.parse("#{year}-#{month}-#{day}") rescue nil + return begin + Time.zone.parse("#{year}-#{month}-#{day}") + rescue ArgumentError + end end if str.downcase == "yesterday" @@ -657,10 +660,11 @@ class Search .joins(:post_search_data, :topic) .joins("LEFT JOIN categories ON categories.id = topics.category_id") .where("topics.deleted_at" => nil) - .where("topics.visible") is_topic_search = @search_context.present? && @search_context.is_a?(Topic) + posts = posts.where("topics.visible") unless is_topic_search + if opts[:private_messages] || (is_topic_search && @search_context.private_message?) posts = posts.where("topics.archetype = ?", Archetype.private_message) @@ -694,7 +698,7 @@ class Search posts = posts.where("post_search_data.search_data @@ #{ts_query(weight_filter: weights)}") exact_terms = @term.scan(/"([^"]+)"/).flatten exact_terms.each do |exact| - posts = posts.where("posts.raw ilike ?", "%#{exact}%") + posts = posts.where("posts.raw ilike :exact OR topics.title ilike :exact", exact: "%#{exact}%") end end end diff --git a/lib/single_sign_on.rb b/lib/single_sign_on.rb index a0fdf25705..3e449b8462 100644 --- a/lib/single_sign_on.rb +++ b/lib/single_sign_on.rb @@ -1,7 +1,7 @@ class SingleSignOn ACCESSORS = [:nonce, :name, :username, :email, :avatar_url, :avatar_force_update, :require_activation, :bio, :external_id, :return_sso_url, :admin, :moderator, :suppress_welcome_message, :title, - :add_groups, :remove_groups, :groups] + :add_groups, :remove_groups, :groups, :profile_background_url, :card_background_url] FIXNUMS = [] BOOLS = [:avatar_force_update, :admin, :moderator, :require_activation, :suppress_welcome_message] NONCE_EXPIRY_TIME = 10.minutes diff --git a/lib/site_setting_extension.rb b/lib/site_setting_extension.rb index 20cd574a58..8ea79430c9 100644 --- a/lib/site_setting_extension.rb +++ b/lib/site_setting_extension.rb @@ -278,7 +278,10 @@ module SiteSettingExtension def set_and_log(name, value, user = Discourse.system_user) prev_value = send(name) set(name, value) - StaffActionLogger.new(user).log_site_setting_change(name, prev_value, value) if has_setting?(name) + if has_setting?(name) + value = prev_value = "[FILTERED]" if name.to_s =~ /_secret/ + StaffActionLogger.new(user).log_site_setting_change(name, prev_value, value) + end end protected diff --git a/lib/stylesheet/watcher.rb b/lib/stylesheet/watcher.rb index 952118ba45..a1150e734f 100644 --- a/lib/stylesheet/watcher.rb +++ b/lib/stylesheet/watcher.rb @@ -3,6 +3,14 @@ require 'listen' module Stylesheet class Watcher + def self.theme_key=(v) + @theme_key = v + end + + def self.theme_key + @theme_key || SiteSetting.default_theme_key + end + def self.watch(paths = nil) watcher = new(paths) watcher.start @@ -71,7 +79,7 @@ module Stylesheet { target: name, new_href: Stylesheet::Manager.stylesheet_href(name.to_sym), - theme_key: SiteSetting.default_theme_key + theme_key: Stylesheet::Watcher.theme_key } end MessageBus.publish '/file-change', message diff --git a/lib/tasks/categories.rake b/lib/tasks/categories.rake new file mode 100644 index 0000000000..407bf65f74 --- /dev/null +++ b/lib/tasks/categories.rake @@ -0,0 +1,23 @@ +task "categories:move_topics", [:from_category, :to_category] => [:environment] do |_, args| + from_category_id = args[:from_category] + to_category_id = args[:to_category] + + if !from_category_id || !to_category_id + puts "ERROR: Expecting categories:move_topics[from_category_id,to_category_id]" + exit 1 + end + + from_category = Category.find(from_category_id) + to_category = Category.find(to_category_id) + + if from_category.present? && to_category.present? + puts "Moving topics from #{from_category.slug} to #{to_category.slug}..." + Topic.where(category_id: from_category.id).update_all(category_id: to_category.id) + from_category.update_attribute(:topic_count, 0) + + puts "Updating category stats..." + Category.update_stats + end + + puts "", "Done!", "" +end diff --git a/lib/tasks/emails.rake b/lib/tasks/emails.rake index 1ab9c71d35..c4258bd9d3 100644 --- a/lib/tasks/emails.rake +++ b/lib/tasks/emails.rake @@ -54,3 +54,10 @@ task "emails:import" => :environment do RateLimiter.enable end end + +desc 'Send email test message' +task 'emails:test', [:email] => [:environment] do |_, args| + email = args[:email] + + Email::Sender.new(TestMailer.send_test(email), :test_message).send +end diff --git a/lib/tasks/qunit.rake b/lib/tasks/qunit.rake index d4d9029932..884cc69630 100644 --- a/lib/tasks/qunit.rake +++ b/lib/tasks/qunit.rake @@ -78,7 +78,7 @@ task "qunit:test", [:timeout, :qunit_path] => :environment do |_, args| puts "Warming up Rails server" begin Net::HTTP.get(uri) - rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL + rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Net::ReadTimeout sleep 1 retry unless elapsed() > 60 puts "Timed out. Can no connect to forked server!" diff --git a/lib/tasks/smoke_test.rake b/lib/tasks/smoke_test.rake index 2558558ad8..99f09a8462 100644 --- a/lib/tasks/smoke_test.rake +++ b/lib/tasks/smoke_test.rake @@ -26,12 +26,21 @@ task "smoke:test" do request.basic_auth(ENV['AUTH_USER'], ENV['AUTH_PASSWORD']) end - response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| - http.request(request) - end + start = Time.now + while true + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(request) + end - if response.code != "200" - raise "TRIVIAL GET FAILED WITH #{response.code}" + break if response.code == "200" + + # retry for up to 5 minutes + if Time.now - start < 300 + puts "Connection failed with #{response.code}. Retrying in 5 seconds..." + sleep(5) + else + raise "TRIVIAL GET FAILED WITH #{response.code}" + end end results = "" diff --git a/lib/twitter_api.rb b/lib/twitter_api.rb index d85df9b9a0..96c3e96bae 100644 --- a/lib/twitter_api.rb +++ b/lib/twitter_api.rb @@ -54,27 +54,27 @@ class TwitterApi def link_handles_in(text) text.scan(/(?:^|\s)@(\w+)/).flatten.uniq.each do |handle| - text.gsub!("@#{handle}", [ - "", + text.gsub!(/(?:^|\s)@#{handle}/, [ + " ", "@#{handle}", "" ].join) end - text + text.strip end def link_hashtags_in(text) text.scan(/(?:^|\s)#(\w+)/).flatten.uniq.each do |hashtag| - text.gsub!("##{hashtag}", [ - "", "##{hashtag}", "" ].join) end - text + text.strip end def user_timeline_uri_for(screen_name) diff --git a/lib/version.rb b/lib/version.rb index f9cba4ffe3..aeed4d4aa9 100644 --- a/lib/version.rb +++ b/lib/version.rb @@ -5,7 +5,7 @@ module Discourse MAJOR = 2 MINOR = 0 TINY = 0 - PRE = 'beta9' + PRE = 'beta10' STRING = [MAJOR, MINOR, TINY, PRE].compact.join('.') end diff --git a/plugins/discourse-details/config/locales/client.id.yml b/plugins/discourse-details/config/locales/client.id.yml index 2112cad6a2..c14130cc20 100644 --- a/plugins/discourse-details/config/locales/client.id.yml +++ b/plugins/discourse-details/config/locales/client.id.yml @@ -5,4 +5,10 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -id: {} +id: + js: + details: + title: Sembunyikan Detail + composer: + details_title: Ringkasan + details_text: "Teks ini akan disembunyikan" diff --git a/plugins/discourse-details/config/locales/client.ja.yml b/plugins/discourse-details/config/locales/client.ja.yml index 8c2dc00904..bd8974e7da 100644 --- a/plugins/discourse-details/config/locales/client.ja.yml +++ b/plugins/discourse-details/config/locales/client.ja.yml @@ -5,4 +5,10 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -ja: {} +ja: + js: + details: + title: 詳細を隠す + composer: + details_title: サマリー + details_text: "こちらの文字は表示させません。" diff --git a/plugins/discourse-details/config/locales/client.tr_TR.yml b/plugins/discourse-details/config/locales/client.tr_TR.yml index fc060416c0..871764bf8d 100644 --- a/plugins/discourse-details/config/locales/client.tr_TR.yml +++ b/plugins/discourse-details/config/locales/client.tr_TR.yml @@ -8,7 +8,7 @@ tr_TR: js: details: - title: Gizli Detaylar + title: Detayları Gizle composer: details_title: Özet details_text: "Bu metin gizlenecek" diff --git a/plugins/discourse-details/config/locales/server.ca.yml b/plugins/discourse-details/config/locales/server.ca.yml index 0f42481a9a..e155a119c2 100644 --- a/plugins/discourse-details/config/locales/server.ca.yml +++ b/plugins/discourse-details/config/locales/server.ca.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -ca: - site_settings: - details_enabled: "Activa el complement de detalls. Si ho canvies, hauràs de refer totes les publicacions amb: \"rake posts:rebake\"." +ca: {} diff --git a/plugins/discourse-details/config/locales/server.de.yml b/plugins/discourse-details/config/locales/server.de.yml index 20962ca5c9..9bbf19d9e8 100644 --- a/plugins/discourse-details/config/locales/server.de.yml +++ b/plugins/discourse-details/config/locales/server.de.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -de: - site_settings: - details_enabled: "Aktiviert das Details-Plugin. Wenn du dies änderst, musst du alle Beiträge neu „backen“ mit: \"rake posts:rebake\"." +de: {} diff --git a/plugins/discourse-details/config/locales/server.en.yml b/plugins/discourse-details/config/locales/server.en.yml index cca3dd701c..80264438ca 100644 --- a/plugins/discourse-details/config/locales/server.en.yml +++ b/plugins/discourse-details/config/locales/server.en.yml @@ -1,3 +1,3 @@ en: site_settings: - details_enabled: "Enable the details plugin. If you change this, you must rebake all posts with: \"rake posts:rebake\"." + details_enabled: "Enable the details feature. If you change this, you must rebake all posts with: \"rake posts:rebake\"." diff --git a/plugins/discourse-details/config/locales/server.es.yml b/plugins/discourse-details/config/locales/server.es.yml index 39761e82e6..ccb6e22af1 100644 --- a/plugins/discourse-details/config/locales/server.es.yml +++ b/plugins/discourse-details/config/locales/server.es.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -es: - site_settings: - details_enabled: "Activar el plugin de detalles (details). Si cambias esto debes hacer rebake de todos los mensajes con: \"rake posts:rebake\"." +es: {} diff --git a/plugins/discourse-details/config/locales/server.fi.yml b/plugins/discourse-details/config/locales/server.fi.yml index 0937810275..b947adbbec 100644 --- a/plugins/discourse-details/config/locales/server.fi.yml +++ b/plugins/discourse-details/config/locales/server.fi.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -fi: - site_settings: - details_enabled: "Laita yhteenveto-plugin päälle. Jos muutat: \"rake posts:rebake\"." +fi: {} diff --git a/plugins/discourse-details/config/locales/server.fr.yml b/plugins/discourse-details/config/locales/server.fr.yml index d9a93b9293..ca92bfb77f 100644 --- a/plugins/discourse-details/config/locales/server.fr.yml +++ b/plugins/discourse-details/config/locales/server.fr.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -fr: - site_settings: - details_enabled: "Activer le plugin details. Si vous modifiez ceci, vous devez exécuter la commande \"rake posts:rebake\"." +fr: {} diff --git a/plugins/discourse-details/config/locales/server.he.yml b/plugins/discourse-details/config/locales/server.he.yml index 4f3160d3b9..96b15d6088 100644 --- a/plugins/discourse-details/config/locales/server.he.yml +++ b/plugins/discourse-details/config/locales/server.he.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -he: - site_settings: - details_enabled: "הפעלת תוסף הפרטים. שינוי הגדרה זו יאלץ אותך לאפות את הרשומות שלך מחדש עם: „rake posts:rebake”" +he: {} diff --git a/plugins/discourse-details/config/locales/server.pt.yml b/plugins/discourse-details/config/locales/server.pt.yml index 8ec3f5207b..d12527a51f 100644 --- a/plugins/discourse-details/config/locales/server.pt.yml +++ b/plugins/discourse-details/config/locales/server.pt.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -pt: - site_settings: - details_enabled: "Activar o plugin de detalhes. Se alterar isto, é necessário refazer todos as publicações com: \"rake posts:rebake\"." +pt: {} diff --git a/plugins/discourse-details/config/locales/server.ur.yml b/plugins/discourse-details/config/locales/server.ur.yml index c4e0b1fa94..58bc9735bc 100644 --- a/plugins/discourse-details/config/locales/server.ur.yml +++ b/plugins/discourse-details/config/locales/server.ur.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -ur: - site_settings: - details_enabled: "Enable the details plugin. If you change this, you must rebake all posts with: \"rake posts:rebake\"." +ur: {} diff --git a/plugins/discourse-details/config/locales/server.zh_CN.yml b/plugins/discourse-details/config/locales/server.zh_CN.yml index abd25af388..53c9902287 100644 --- a/plugins/discourse-details/config/locales/server.zh_CN.yml +++ b/plugins/discourse-details/config/locales/server.zh_CN.yml @@ -5,6 +5,4 @@ # To work with us on translations, join this project: # https://www.transifex.com/projects/p/discourse-org/ -zh_CN: - site_settings: - details_enabled: "启用细节插件。如果你改变了这个选项,必须重新调制全部帖子:\"rake posts:rebake\"。" +zh_CN: {} diff --git a/plugins/discourse-details/plugin.rb b/plugins/discourse-details/plugin.rb index 8b537509b9..a9a8883fd5 100644 --- a/plugins/discourse-details/plugin.rb +++ b/plugins/discourse-details/plugin.rb @@ -5,6 +5,7 @@ # url: https://github.com/discourse/discourse/tree/master/plugins/discourse-details enabled_site_setting :details_enabled +hide_plugin if self.respond_to?(:hide_plugin) register_asset "javascripts/details.js" register_asset "stylesheets/details.scss" diff --git a/plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp b/plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp deleted file mode 100644 index 0d6133e033..0000000000 Binary files a/plugins/discourse-local-dates/assets/javascripts/discourse/components/.discourse-cronos-create-form.js.es6.swp and /dev/null differ diff --git a/plugins/discourse-local-dates/config/locales/server.de.yml b/plugins/discourse-local-dates/config/locales/server.de.yml index 59e8ffa808..24411c8bfd 100644 --- a/plugins/discourse-local-dates/config/locales/server.de.yml +++ b/plugins/discourse-local-dates/config/locales/server.de.yml @@ -7,6 +7,5 @@ de: site_settings: - discourse_local_dates_enabled: "Aktiviert das discourse-local-dates Plugin. Wenn du diese Einstellung änderst, musst du alle Beiträge mit \"rake posts:rebake\" neu generieren." discourse_local_dates_default_formats: "Häufig verwendete Datums- und Zeitformate, siehe: momentjs string format" discourse_local_dates_default_timezones: "Standard-Liste von Zeitzonen, muss eine gültige TZ sein" diff --git a/plugins/discourse-local-dates/config/locales/server.en.yml b/plugins/discourse-local-dates/config/locales/server.en.yml index f08e768c5a..a952dec4c6 100644 --- a/plugins/discourse-local-dates/config/locales/server.en.yml +++ b/plugins/discourse-local-dates/config/locales/server.en.yml @@ -1,5 +1,5 @@ en: site_settings: - discourse_local_dates_enabled: "Enable the discourse-local-dates plugin. This will add support to local timezone aware dates in posts using the [date] element." + discourse_local_dates_enabled: "Enable the discourse-local-dates feature. This will add support to local timezone aware dates in posts using the [date] element." discourse_local_dates_default_formats: "Frequently used date time formats, see: momentjs string format" discourse_local_dates_default_timezones: "Default list of timezones, must be a valid TZ" diff --git a/plugins/discourse-local-dates/config/locales/server.es.yml b/plugins/discourse-local-dates/config/locales/server.es.yml index 712147e36f..c95d7c1cf3 100644 --- a/plugins/discourse-local-dates/config/locales/server.es.yml +++ b/plugins/discourse-local-dates/config/locales/server.es.yml @@ -7,6 +7,5 @@ es: site_settings: - discourse_local_dates_enabled: "Activar el plugin discourse-local-dates. Si cambias esto, debes hacer rebake de todos los posts con \"rake posts:rebake\"." discourse_local_dates_default_formats: "Formatos de fecha utilizados frecuentemente, ver: momentjs string format" discourse_local_dates_default_timezones: "Lista de zonas horarias por defecto, deben ser TZ válidas" diff --git a/plugins/discourse-local-dates/config/locales/server.fr.yml b/plugins/discourse-local-dates/config/locales/server.fr.yml index cf94887d5d..186e939046 100644 --- a/plugins/discourse-local-dates/config/locales/server.fr.yml +++ b/plugins/discourse-local-dates/config/locales/server.fr.yml @@ -7,6 +7,5 @@ fr: site_settings: - discourse_local_dates_enabled: "Activer le plugin discourse-local-dates. Si vous modifiez ceci, vous devez regénérer tous les messages avec : \"rake posts:rebake\"." discourse_local_dates_default_formats: "Formats de date fréquemment utilisés, voir : momentjs string format" discourse_local_dates_default_timezones: "Liste de fuseaux horaires par défaut, doit être un fuseaux horaire valide Wikipedia (anglais)" diff --git a/plugins/discourse-local-dates/config/locales/server.he.yml b/plugins/discourse-local-dates/config/locales/server.he.yml index 296bd25120..dced9360b8 100644 --- a/plugins/discourse-local-dates/config/locales/server.he.yml +++ b/plugins/discourse-local-dates/config/locales/server.he.yml @@ -7,6 +7,5 @@ he: site_settings: - discourse_local_dates_enabled: "הפעלת התוסף discourse-local-dates. שינוי ההגדרה הזאת יאלץ אותך לאפות את כל הרשומות מחדש עם: „rake posts:rebake”." discourse_local_dates_default_formats: "תבניות זמן נפוצות, ניתן לעיין ב: תבנית מחרוזת momentjs" discourse_local_dates_default_timezones: "רשימת בררת מחדל של אזורי זמן, חיי להיות TZ תקני" diff --git a/plugins/discourse-local-dates/config/locales/server.ru.yml b/plugins/discourse-local-dates/config/locales/server.ru.yml index 6d2cd631ab..cd8414ecf7 100644 --- a/plugins/discourse-local-dates/config/locales/server.ru.yml +++ b/plugins/discourse-local-dates/config/locales/server.ru.yml @@ -7,6 +7,5 @@ ru: site_settings: - discourse_local_dates_enabled: "Включить плагин discourse-local-dates. Если вы измените это, вы должны rebake все посты: \"rake posts:rebake\"." discourse_local_dates_default_formats: "Часто используемые форматы даты и времени, см.: формат строки momentjs" discourse_local_dates_default_timezones: "Список часовых поясов по умолчанию должен быть допустимым TZ" diff --git a/plugins/discourse-local-dates/config/locales/server.ur.yml b/plugins/discourse-local-dates/config/locales/server.ur.yml index ed0bcb67f4..c7dda36022 100644 --- a/plugins/discourse-local-dates/config/locales/server.ur.yml +++ b/plugins/discourse-local-dates/config/locales/server.ur.yml @@ -7,6 +7,5 @@ ur: site_settings: - discourse_local_dates_enabled: "ڈِسکورس-کرَونَوس پلگ اِن فعال کریں۔ اگر آپ اس کو تبدیل کرتے ہیں تو، آپ کو تمام پوسٹس کو دوبارہ رِیبَیک کرنا ہوگا: \"rake posts:rebake\"" discourse_local_dates_default_formats: "اکثر استعمال ہونے والے تاریخ ٹائم فارمیٹس، دیکھیے: momentjs سٹرِنگ فارمَیٹ" discourse_local_dates_default_timezones: "ٹائم زَونَوں کی ڈِیفالٹ فہرست، ایک درست TZ ہونا لازمی ہے" diff --git a/plugins/discourse-local-dates/plugin.rb b/plugins/discourse-local-dates/plugin.rb index 92d6d3d84a..e5e3348fe0 100644 --- a/plugins/discourse-local-dates/plugin.rb +++ b/plugins/discourse-local-dates/plugin.rb @@ -2,6 +2,7 @@ # about: Display a date in your local timezone # version: 0.1 # author: Joffrey Jaffeux +hide_plugin if self.respond_to?(:hide_plugin) register_asset "javascripts/discourse-local-dates.js" register_asset "stylesheets/discourse-local-dates.scss" diff --git a/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml b/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml index 219f0cef40..001d22f93e 100644 --- a/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml +++ b/plugins/discourse-narrative-bot/config/locales/server.nb_NO.yml @@ -41,6 +41,10 @@ nb_NO: trigger: "rull" invalid: |- Jeg beklager, det er matematisk umulig å rulle den kombinasjonen av terninger. :confounded: + not_enough_dice: |- + Jeg har bare %{num_of_dice}terninger. Jeg vet det er [flaut] (http://www.therobotsvoice.com/2009/04/the_10_most_shameful_rpg_dice.php)! + out_of_range: |- + Visste du at [maksimalt antall sider](https://www.wired.com/2016/05/mathematical-challenge-of-designing-the-worlds-most-complex-120-sided-dice) på en matematisk rettferdig terning er 120? results: |- > :game_die: %{results} quote: @@ -49,24 +53,36 @@ nb_NO: quote: "I midten av hver utfordring finnes muligheter" author: "Albert Einstein" '2': + quote: "Frihet er ikke verdt å ha hvis det ikke inkluderer friheten til å gjøre feil." author: "Mahatma Gandhi" '3': quote: "Ikke gråt fordi det er over, smil fordi det skjedde." + author: "Dr Seuss" '4': quote: "Hvis du vil ha noe velgjort, gjør det selv." + author: "Charles-Guillaume Étienne" '5': quote: "Tro at du kan, og du finner deg selv på halvveien." author: "Theodore Roosevelt" '6': + quote: "Livet er som en eske konfekt. Du vet aldri hva du kommer til å få." author: "Forrest Gumps Mor" '7': + quote: "Et lite steg for et menneske, et gigantisk sprang for menneskeheten." author: "Neil Armstrong" '8': quote: "Gjør én ting om dagen som skremmer deg." + author: "Eleanor Roosevelt" '9': quote: "Feil kan alltid tilgis, hvis man har mot nok til å innrømme dem." author: "Bruce Lee" + '10': + quote: "Alt hva menneskesinnet kan unnfange og ha tro på, kan det også oppnå." + author: "Napoleon Hill" + results: |- + > :left_speech_bubble: _%{quote}_ — %{author} magic_8_ball: + trigger: 'spådom' answers: '1': "Det er sikkert" '2': "Det er virkelig slik" @@ -97,24 +113,278 @@ nb_NO: random_mention: reply: |- Hei! Finn ut hva jeg kan gjøre, si `@%{discobot_username} %{help_trigger}`. + tracks: |- + For tiden vet jeg hvordan man gjør følgende oppgaver: + + `@%{discobot_username} %{reset_trigger} %{default_track}` + > Starter en av de følgende interaktive narrativene: %{tracks}. + bot_actions: |- + `@%{discobot_username} %{dice_trigger} 2d6` + > :game_die: 3, 6 + + `@%{discobot_username} %{quote_trigger}` + > :left_speech_bubble: _Utfør gode gjerninger, uten forventning om belønning, trygg i vissheten om at noen en dag kanskje vil gjøre det samme for deg_ — Prinsesse Diana + + `@%{discobot_username} %{magic_8_ball_trigger}` + > :crystal_ball: Du kan stole på det + do_not_understand: + first_response: |- + Hei, takk for svaret! + + Men dessverre, siden jeg er en dårlig programmert robot så skjønte jeg ikke helt hva du mente. :frowning: + track_response: Du kan prøve igjen, eller hvis du ønsker å hoppe over dette steget kan du svare `%{skip_trigger}`. Hvis du vil begynne på nytt helt fra starten kan du si `%{reset_trigger}`. + second_response: |- + Æsj, unnskyld. Jeg skjønner fremdeles ingenting. :anguished: + + Jeg er bare en robot, men hvis du trenger å snakke med en ekte person kan du gå til denne [siden for kontakter](/about). + + I mellomtiden skal jeg ikke plage deg mer. new_user_narrative: reset_trigger: "ny bruker" + cert_title: "Som bevis for vellykket gjennomføring av introduksjonen for nye brukere" hello: title: ":robot: Heisann!" + message: |- + Velkommen til %{title}, takk for at du ville bli medlem! + + - Jeg er bare en robot, men [det vennlige personalet](/about) er også her for å hjelpe hvis du trenger assistanse fra en person. + + - Av sikkerhetshensyn begrenser vi hva nye brukere kan gjøre helt i starten. Du vil gradvis få flere tillatelser (og [merker](/badges)) etterhvert som vi blir kjent med deg. + + - Vi prøver å holde [samfunnets oppførsel sivilisert](/guidelines) til enhver tid. + onebox: + instructions: |- + Neste steg, kan du dele en av disse lenkene med meg? Svar med **en frittstående lenke på en ny linje**, da vil den automatisk utvides til å inkludere et stilig sammendrag. + + For å kopiere en lenke, trykk og hold på den med en mobil enhet, eller høyreklikk med en gammeldags mus: + + - https://en.wikipedia.org/wiki/Inherently_funny_word + - https://en.wikipedia.org/wiki/Death_by_coconut + - https://en.wikipedia.org/wiki/Calculator_spelling + reply: |- + Kult! Dette fungerer for de aller fleste
+
+ Hvis du liker bildet (og hvem i all verden er det som ikke liker sånt!) kan du trykke på knappen med :heart: på under dette innlegget så jeg får vite det.
+
+ Kan du **svare med et bilde?** Et hvilket som helst bilde duger i massevis! Trekk og slipp bildet, trykk på "Last opp" knappen, eller du kan til og med kopiere og lime inn rett fra utklippstavlen.
+ reply: |-
+ Et flott bilde - jeg trykket på :heart: knappen så du også skal få vite at jeg satte stor pris på det :heart_eyes:
+ like_not_found: |-
+ Glemte du å trykke :heart:? Liker du ikke [innlegget mitt?](%{url}) :crying_cat_face:
+ not_found: |-
+ Det ser ut som du ikke lastet opp et bilde, så da valgte jeg like godt et bilde som jeg er _sikker_ på at du synes er fint for deg.
+
+ `%{image_url}`
+
+ Prøv å laste opp dette bildet på neste forsøk, eller bare lim inn lenken til bildet på en egen linje!
+ formatting:
+ instructions: |-
+ Klarer du å gjøre noen ord **uthevet** eller _kursiv_ i svaret ditt?
+
+ - skriv `**uthevet**` eller `_kursiv_`
+
+ - eller trykk på F eller K knappene i redigeringsfeltet
+ reply: |-
+ Godt jobbet! HTML og BBCode kan også brukes til formatering av tekst - for å lære mer om formatering, [prøv denne manualen](http://commonmark.org/help):nerd:
+ not_found: |-
+ Uff, jeg fant ingen formatering i svaret ditt. :pencil2:
+
+ Kan du prøve på nytt? Det holder å bruke Ffet eller K kursiv i knappelinjen hvis du står fast.
quoting:
+ instructions: |-
+ Kan du prøve å sitere meg når du svarer, så jeg vet nøyaktig hvilken del av innlegget du svarer på?
+
+ > Hvis dette er kaffe, vennligst bring meg en kopp te; men hvis dette er te, vennligst hent meg en kopp kaffe.
+ >
+ > En fordel med å prate med deg selv er at du i det minste vet at noen lytter til hva du har å si.
+ >
+ > Noen folk kan virkelig å formulere seg, og andre folk... æh, øh, ja du vet.
+
+ Merk teksten på et av ↑ sitatene du liker, deretter kan du trykke på **Sitat** knappen som spretter opp over den valgte teksten - eller trykk på **Svar** knappen under dette innlegget.
+
+ Under sitatet kan du skrive noen ord om hvorfor du valgte akkurat det sitatet, for jeg er litt nysgjerrig :thinking:
reply: |-
Godt jobbet, du valgte mitt favorittsitat! :left_speech_bubble:
+ not_found: |-
+ Hmm, det ser ut som du ikke siterte meg i svaret ditt?
+
+ Når du merker teksten skal det sprette opp en **Siter** knapp. Og å trykke på **Svar** etter at du har markert tekst skal fungere det også! Kan du gjøre et nytt forsøk?
+ bookmark:
+ instructions: |-
+ Hvis du ønsker å lære mer, velg
+
+ La du merke til at du er tilbake ved starten? Gi denne sultne capybaraen litt å spise ved å **svare med `:herb:` emojien**, da blir du automatisk tatt tilbake til slutten.
+ reply: |-
+ Hurra! Du fant den :tada:
+
+ - For mer detaljerte søk, besøk [hovedsiden for søk](%{search_url}).
+
+ - For å hoppe til et vilkårlig punkt i en lang diskusjon, bruk kontrollen for tidslinje til høyre for diskusjonen (og nederst på mobil).
+
+ - Hvis du har et fysisk :keyboard:, trykk ? på det for å få en oversikt over hendige tastatursnarveier.
+ not_found: |-
+ Hmm... det ser ut som du kanskje har trøbbel. Beklager så mye! Brukte du " + youtube_id = yt["data-youtube-id"] + parameters = yt["data-parameters"] + uri = URI("https://www.youtube.com/embed/#{youtube_id}?autoplay=1{parameters}") + yt.replace %{
https://#{uri.host}#{uri.path}
} rescue URI::InvalidURIError - # If the URL is weird, remove it - i.remove + # remove any invalid/weird URIs + yt.remove end end end + end diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 index 7b9ce4c774..f51c12e3ad 100644 --- a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 +++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 @@ -7,6 +7,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error'; import evenRound from "discourse/plugins/poll/lib/even-round"; import { avatarFor } from 'discourse/widgets/post'; import round from "discourse/lib/round"; +import { relativeAge } from 'discourse/lib/formatter'; function optionHtml(option) { return new RawHtml({ html: `${option.html}` }); @@ -353,8 +354,12 @@ createWidget('discourse-poll-info', { if (poll.close) { const closeDate = moment.utc(poll.close); + const title = closeDate.format("LLL"); const timeLeft = moment().to(closeDate.local(), true); - result.push(new RawHtml({ html: `${I18n.t("poll.automatic_close.closes_in", { timeLeft })}` })); + + result.push(new RawHtml({ + html: `${I18n.t("poll.automatic_close.closes_in", { timeLeft })}` + })); } } @@ -404,6 +409,16 @@ createWidget('discourse-poll-buttons', { })); } + if (attrs.isAutomaticallyClosed) { + const closeDate = moment.utc(poll.get("close")); + const title = closeDate.format("LLL"); + const age = relativeAge(closeDate.toDate(), { addAgo: true }); + + results.push(new RawHtml({ + html: `${I18n.t("poll.automatic_close.age", { age })}` + })); + } + if (this.currentUser && (this.currentUser.get("id") === post.get('user_id') || this.currentUser.get("staff")) && diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss index ee7239fa9c..09460788e0 100644 --- a/plugins/poll/assets/stylesheets/common/poll.scss +++ b/plugins/poll/assets/stylesheets/common/poll.scss @@ -75,6 +75,10 @@ div.poll { button { float: none; } + .info-text { + margin: 0 5px; + color: $text-color; + } } .poll-voters-list { diff --git a/plugins/poll/config/locales/client.cs.yml b/plugins/poll/config/locales/client.cs.yml index 8d477afbdb..4a60ddabd9 100644 --- a/plugins/poll/config/locales/client.cs.yml +++ b/plugins/poll/config/locales/client.cs.yml @@ -11,10 +11,12 @@ cs: voters: one: "hlasující" few: "hlasujících" + many: "hlasující" other: "hlasující" total_votes: one: "hlas celkem" few: "hlasy celkem" + many: "hlasů celkem" other: "hlasů celkem" average_rating: "Průměrné hodnocení: %{average}." public: @@ -24,14 +26,17 @@ cs: at_least_min_options: one: "Zvolte alespoň 1 možnost." few: "Zvolte alespoň %{count} možnosti." + many: "Zvolte alespoň %{count} možností." other: "Zvolte alespoň %{count} možností." up_to_max_options: one: "Zvolte maximálně 1 možnost." few: "Zvolte maximálně %{count} možnosti." + many: "Zvolte maximálně %{count} možností" other: "Zvolte maximálně %{count} možností" x_options: one: "Zvolte 1 možnost." few: "Zvolte %{count} možnosti." + many: "Zvolte %{count} možností" other: "Zvolte %{count} možností" between_min_and_max_options: "Zvolte mezi %{min} a %{max} možnostmi" cast-votes: diff --git a/plugins/poll/config/locales/client.de.yml b/plugins/poll/config/locales/client.de.yml index 99afb460b6..e267d58cb6 100644 --- a/plugins/poll/config/locales/client.de.yml +++ b/plugins/poll/config/locales/client.de.yml @@ -46,6 +46,9 @@ de: title: "Umfrage beenden" label: "Beenden" confirm: "Möchtest du diese Umfrage wirklich beenden?" + automatic_close: + closes_in: "schließt in %{timeLeft}" + age: "geschlossen %{age}" error_while_toggling_status: "Entschuldige, beim Wechseln des Status dieser Umfrage ist ein Fehler aufgetreten." error_while_casting_votes: "Entschuldige, beim Abgeben deiner Stimme ist ein Fehler aufgetreten." error_while_fetching_voters: "Entschuldige, beim Anzeigen der Teilnehmer ist ein Fehler aufgetreten." @@ -69,3 +72,5 @@ de: label: Anzeigen, wer abgestimmt hat poll_options: label: Bitte gib eine Umfrageoption pro Zeile ein + automatic_close: + label: Umfrage automatisch schließen diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml index 45479d33f7..7e03127b05 100644 --- a/plugins/poll/config/locales/client.en.yml +++ b/plugins/poll/config/locales/client.en.yml @@ -67,6 +67,7 @@ en: automatic_close: closes_in: "closes in %{timeLeft}" + age: "closed %{age}" error_while_toggling_status: "Sorry, there was an error toggling the status of this poll." error_while_casting_votes: "Sorry, there was an error casting your votes." diff --git a/plugins/poll/config/locales/client.fa_IR.yml b/plugins/poll/config/locales/client.fa_IR.yml index a3888c8d13..d8513854ee 100644 --- a/plugins/poll/config/locales/client.fa_IR.yml +++ b/plugins/poll/config/locales/client.fa_IR.yml @@ -9,8 +9,10 @@ fa_IR: js: poll: voters: + one: "رأی دهندگان" other: "رأی دهندگان" total_votes: + one: "مجموع آرا" other: "مجموع آرا" average_rating: "میانگین امتیاز: %{average}." public: @@ -18,10 +20,13 @@ fa_IR: multiple: help: at_least_min_options: + one: "حداقل %{count} گزینه انتخاب کنید." other: "حداقل %{count} گزینه انتخاب کنید." up_to_max_options: + one: "حداکثر %{count} گزینه انتخاب کنید" other: "حداکثر %{count} گزینه انتخاب کنید" x_options: + one: "%{count} گزینه انتخاب کنید" other: "%{count} گزینه انتخاب کنید" between_min_and_max_options: "بین %{min} تا %{max} گزینه انتخاب کنید" cast-votes: diff --git a/plugins/poll/config/locales/client.fr.yml b/plugins/poll/config/locales/client.fr.yml index 255ba7e0e2..8af325d93c 100644 --- a/plugins/poll/config/locales/client.fr.yml +++ b/plugins/poll/config/locales/client.fr.yml @@ -48,6 +48,7 @@ fr: confirm: "Êtes-vous sûr de vouloir fermer ce sondage ?" automatic_close: closes_in: "fermera dans %{timeLeft}" + age: "fermé %{age}" error_while_toggling_status: "Désolé, il y a eu une erreur lors du changement de statut de ce sondage." error_while_casting_votes: "Désolé, il y a eu une erreur lors de l'envoi de vos votes." error_while_fetching_voters: "Désolé, il y a eu une erreur lors de l'affichage des votants." diff --git a/plugins/poll/config/locales/client.he.yml b/plugins/poll/config/locales/client.he.yml index 73d725c0ed..872266a3d8 100644 --- a/plugins/poll/config/locales/client.he.yml +++ b/plugins/poll/config/locales/client.he.yml @@ -10,9 +10,11 @@ he: poll: voters: one: "מצביע" + two: "מצביעים" other: "מצביעים" total_votes: one: "מספר הצבעות כולל" + two: "מספר הצבעות כולל" other: "מספר הצבעות כולל" average_rating: "דירוג ממוצע: %{average}." public: @@ -21,12 +23,15 @@ he: help: at_least_min_options: one: "בחרו לפחות אפשרות אחת" + two: "בחרו לפחות %{count} אפשרויות" other: "בחרו לפחות %{count} אפשרויות" up_to_max_options: one: "בחרו לכל היותר אפשרות אחת." + two: "בחרו עד %{count} אפשרויות" other: "בחרו עד %{count} אפשרויות" x_options: one: "בחרו אפשרות אחת" + two: "בחרו %{count} אפשרויות" other: "בחרו %{count} אפשרויות" between_min_and_max_options: "בחרו בין %{min} ל-%{max} אפשרויות" cast-votes: diff --git a/plugins/poll/config/locales/client.ro.yml b/plugins/poll/config/locales/client.ro.yml index 1b9159d8bd..9c1d31abf7 100644 --- a/plugins/poll/config/locales/client.ro.yml +++ b/plugins/poll/config/locales/client.ro.yml @@ -51,6 +51,9 @@ ro: title: "Închide sondajul" label: "Închide sondajul" confirm: "Ești sigur că vrei să închizi acest sondaj?" + automatic_close: + closes_in: "se închide în %{timeLeft}" + age: "închis %{age}" error_while_toggling_status: "Ne pare rău, a apărut o eroare la schimbarea stării acestui sondaj." error_while_casting_votes: "Ne pare rău, a apărut o eroare la exercitarea voturilor tale." error_while_fetching_voters: "Ne pare rău, a apărut o eroare la afișarea votanților." @@ -74,3 +77,5 @@ ro: label: Arată cine a votat poll_options: label: Arată o singură opțiune de sondaj pe linie + automatic_close: + label: Închide sondaj automat diff --git a/plugins/poll/config/locales/client.sk.yml b/plugins/poll/config/locales/client.sk.yml index 1e8b8f6e33..8c4fc811e5 100644 --- a/plugins/poll/config/locales/client.sk.yml +++ b/plugins/poll/config/locales/client.sk.yml @@ -11,10 +11,12 @@ sk: voters: one: "volič" few: "voliči" + many: "účastníci" other: "účastníci" total_votes: one: "hlas celkom" few: "hlasy celkom" + many: "hlasov celkom" other: "hlasov celkom" average_rating: "Priemerné hodnotenie: %{average}." public: @@ -24,14 +26,17 @@ sk: at_least_min_options: one: "Vyberte aspoň 1 možnosť" few: "Vyberte aspoň %{count} možnosti" + many: "Vyberte aspoň %{count} možností" other: "Vyberte aspoň %{count} možností" up_to_max_options: one: "Vyberte najviac 1 možnosť" few: "Vyberte najviac %{count} možnosti" + many: "Vyberte najviac %{count} možností" other: "Vyberte najviac %{count} možností" x_options: one: "Vyberte 1 možnosť" few: "Vyberte %{count} možnosti" + many: "Vyberte %{count} možností" other: "Vyberte %{count} možností" between_min_and_max_options: "Vyberte medzi %{min} a %{max} možnosťami" cast-votes: diff --git a/plugins/poll/config/locales/client.tr_TR.yml b/plugins/poll/config/locales/client.tr_TR.yml index d8255fe434..e98e99bbf2 100644 --- a/plugins/poll/config/locales/client.tr_TR.yml +++ b/plugins/poll/config/locales/client.tr_TR.yml @@ -10,7 +10,7 @@ tr_TR: poll: voters: one: "oylayan" - other: "oylayan" + other: "oylayanlar" total_votes: one: "toplam oy" other: "toplam oy" diff --git a/plugins/poll/config/locales/client.uk.yml b/plugins/poll/config/locales/client.uk.yml index aa468da1dc..6007d7deaf 100644 --- a/plugins/poll/config/locales/client.uk.yml +++ b/plugins/poll/config/locales/client.uk.yml @@ -11,6 +11,7 @@ uk: total_votes: one: "голос" few: "голосів" + many: "голоси" other: "голоси" average_rating: "Середній рейтинг: %{average}." multiple: @@ -18,6 +19,7 @@ uk: at_least_min_options: one: "Виберіть хоча б 1 варіант" few: "Виберіть хоча б %{count} варіантів" + many: "Виберіть хоча б %{count} варіанти" other: "Виберіть хоча б %{count} варіанти" cast-votes: label: "Проголосувати!" diff --git a/plugins/poll/config/locales/server.cs.yml b/plugins/poll/config/locales/server.cs.yml index f5ece062e9..12a6854165 100644 --- a/plugins/poll/config/locales/server.cs.yml +++ b/plugins/poll/config/locales/server.cs.yml @@ -19,10 +19,12 @@ cs: default_poll_must_have_less_options: one: "Hlasování nesmí mít více než 1 možnost." few: "Hlasování nesmí mít více než %{count} možnosti." + many: "Hlasování nesmí mít více než %{count} možností." other: "Hlasování nesmí mít více než %{count} možností." named_poll_must_have_less_options: one: "Hlasování %{name} nesmí mít více než 1 možnost." few: "Hlasování %{name} nesmí mít více než %{count} možnosti." + many: "Hlasování %{name} nesmí mít více než %{count} možností." other: "Hlasování %{name} nesmí mít více než %{count} možností." default_poll_must_have_different_options: "Hlasování musí mít různé odpovědi." named_poll_must_have_different_options: "Hlasování %{name} musí mít různé odpovědi." diff --git a/plugins/poll/config/locales/server.de.yml b/plugins/poll/config/locales/server.de.yml index db37d3ff8a..db28cf365d 100644 --- a/plugins/poll/config/locales/server.de.yml +++ b/plugins/poll/config/locales/server.de.yml @@ -28,6 +28,7 @@ de: named_poll_with_multiple_choices_has_invalid_parameters: "Die Mehrfachauswahl-Umfrage mit dem Namen %{name} hat ungültige Parameter." requires_at_least_1_valid_option: "Du musst mindestens eine gültige Option auswählen." default_cannot_be_made_public: "Umfragen mit abgegebenen Stimmen können nicht öffentlich gemacht werden." + named_cannot_be_made_public: "Die Umfrage %{name} hat Stimmen und kann nicht öffentlich gemacht werden." edit_window_expired: op_cannot_edit_options: "Du kannst Umfrageoptionen nur in den ersten %{minutes} Minuten hinzufügen oder entfernen. Kontaktiere bitte einen Moderator, wenn du Optionen ändern möchtest." staff_cannot_add_or_remove_options: "Du kannst Umfrageoptionen nur in den ersten %{minutes} Minuten hinzufügen oder entfernen. Du solltest dieses Thema schließen und stattdessen ein neues erstellen." diff --git a/plugins/poll/config/locales/server.fa_IR.yml b/plugins/poll/config/locales/server.fa_IR.yml index 1412d95608..f2eaa6e7b0 100644 --- a/plugins/poll/config/locales/server.fa_IR.yml +++ b/plugins/poll/config/locales/server.fa_IR.yml @@ -16,8 +16,10 @@ fa_IR: default_poll_must_have_at_least_2_options: "نظرسنجی باید حداقل 2 گزینه داشته باشد." named_poll_must_have_at_least_2_options: "اسم نظرسنجی %{name} باید حداقل 2 گزینه داشته باشد. " default_poll_must_have_less_options: + one: "نظرسنجی باید کمتر از %{count} گزینه داشته باشد." other: "نظرسنجی باید کمتر از %{count} گزینه داشته باشد." named_poll_must_have_less_options: + one: "نظرسنجی با نام %{name} باید کمتر از %{count} گزینه داشته باشد." other: "نظرسنجی با نام %{name} باید کمتر از %{count} گزینه داشته باشد." default_poll_must_have_different_options: "نظرسنجی باید گزینه های متفاوت داشته باشد. " named_poll_must_have_different_options: "Poll named %{name} must have different options." diff --git a/plugins/poll/config/locales/server.he.yml b/plugins/poll/config/locales/server.he.yml index abd0790f08..5670a923e4 100644 --- a/plugins/poll/config/locales/server.he.yml +++ b/plugins/poll/config/locales/server.he.yml @@ -16,9 +16,11 @@ he: named_poll_must_have_at_least_2_options: "לסקר בשם %{name} צריך שיהיו לפחות שתי אפשרויות." default_poll_must_have_less_options: one: "לסקר חייבת להיות פחות מאפשרות אחת." + two: "לסקר חייבות להיות פחות מ-%{count} אפשרויות." other: "לסקר חייבות להיות פחות מ-%{count} אפשרויות." named_poll_must_have_less_options: one: "לסקר בשם %{name} צריכה להיות פחות מאפשרות אחת." + two: "לסקר בשם %{name} צריכות להיות פחות מ-%{count} אפשרויות." other: "לסקר בשם %{name} צריכות להיות פחות מ-%{count} אפשרויות." default_poll_must_have_different_options: "לסקר צריך שתהיינה אפשרויות שונות. " named_poll_must_have_different_options: "לסקר בשם %{name} צריך שתהינה אפשרויות שונות." diff --git a/plugins/poll/config/locales/server.sk.yml b/plugins/poll/config/locales/server.sk.yml index ceaf9343e0..0f2313dfd5 100644 --- a/plugins/poll/config/locales/server.sk.yml +++ b/plugins/poll/config/locales/server.sk.yml @@ -19,10 +19,12 @@ sk: default_poll_must_have_less_options: one: "Hlasovanie musí mať menej ako jednu možnosť." few: "Hlasovanie musí mať menej ako %{count} možnosti." + many: "Hlasovanie musí mať menej ako %{count} možnosti." other: "Hlasovanie musí mať menej ako %{count} možnosti." named_poll_must_have_less_options: one: "Hlasovanie s názvom %{name} musí mať menej ako 1 možnost." few: "Hlasovanie s názvom %{name} musí mať menej ako %{count} možnosti." + many: "Hlasovanie s názvom %{name} musí mať menej ako %{count} možností." other: "Hlasovanie s názvom %{name} musí mať menej ako %{count} možností." default_poll_must_have_different_options: "Hlasovanie musí mať rôzne možnosti." named_poll_must_have_different_options: "Hlasovanie s názvom %{name} musí mať rôzne možnosti." diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 82efa7fd43..c4a5adcf32 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -9,6 +9,9 @@ register_asset "stylesheets/common/poll-ui-builder.scss" register_asset "stylesheets/desktop/poll.scss", :desktop register_asset "stylesheets/mobile/poll.scss", :mobile +enabled_site_setting :poll_enabled +hide_plugin if self.respond_to?(:hide_plugin) + PLUGIN_NAME ||= "discourse_poll".freeze DATA_PREFIX ||= "data-poll-".freeze @@ -66,7 +69,12 @@ after_initialize do # ensure no race condition when poll is automatically closed if poll["close"].present? - close_date = Time.zone.parse(poll["close"]) rescue nil + close_date = + begin + close_date = Time.zone.parse(poll["close"]) + rescue ArgumentError + end + raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if close_date && close_date <= Time.zone.now end @@ -159,7 +167,12 @@ after_initialize do Jobs.cancel_scheduled_job(:close_poll, post_id: post.id, poll_name: name) if poll["status"] == "open" && poll["close"].present? - close_date = Time.zone.parse(poll["close"]) rescue nil + close_date = + begin + Time.zone.parse(poll["close"]) + rescue ArgumentError + end + Jobs.enqueue_at(close_date, :close_poll, post_id: post.id, poll_name: name) if close_date && close_date > Time.zone.now end end diff --git a/plugins/poll/spec/controllers/posts_controller_spec.rb b/plugins/poll/spec/controllers/posts_controller_spec.rb index bd39395419..917592e53c 100644 --- a/plugins/poll/spec/controllers/posts_controller_spec.rb +++ b/plugins/poll/spec/controllers/posts_controller_spec.rb @@ -35,6 +35,21 @@ describe PostsController do expect(json["polls"]["poll"]).to be end + it "schedules auto-close job" do + name = "auto_close" + close_date = 1.month.from_now + + post :create, params: { + title: title, raw: "[poll name=#{name} close=#{close_date.iso8601}]\n- A\n- B\n[/poll]" + }, format: :json + + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["polls"][name]["close"]).to be + + expect(Jobs.scheduled_for(:close_poll, post_id: Post.last.id, poll_name: name)).to be + end + it "should have different options" do post :create, params: { title: title, raw: "[poll]\n- A\n- A\n[/poll]" diff --git a/script/docker_test.rb b/script/docker_test.rb index 0d6a199238..cb3a3bf523 100644 --- a/script/docker_test.rb +++ b/script/docker_test.rb @@ -13,7 +13,7 @@ def run_or_fail(command) end unless ENV['NO_UPDATE'] - run_or_fail("git remote update") + run_or_fail("git pull") checkout = ENV['COMMIT_HASH'] || "HEAD" run_or_fail("git checkout #{checkout}") diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index b366a2a4d7..d3d5ac8a7c 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -53,6 +53,7 @@ class ImportScripts::Lithium < ImportScripts::Base @max_start_id = Post.maximum(:id) + import_groups import_categories import_users import_topics @@ -70,15 +71,15 @@ class ImportScripts::Lithium < ImportScripts::Base puts "", "importing groups..." groups = mysql_query <<-SQL - SELECT usergroupid, title - FROM usergroup - ORDER BY usergroupid + SELECT DISTINCT name + FROM roles + ORDER BY name SQL create_groups(groups) do |group| { - id: group["usergroupid"], - name: @htmlentities.decode(group["title"]).strip + id: group["name"], + name: @htmlentities.decode(group["name"]).strip } end end diff --git a/spec/components/admin_user_index_query_spec.rb b/spec/components/admin_user_index_query_spec.rb index 2eee6d4852..d31e9b5d7d 100644 --- a/spec/components/admin_user_index_query_spec.rb +++ b/spec/components/admin_user_index_query_spec.rb @@ -2,8 +2,8 @@ require 'rails_helper' require_dependency 'admin_user_index_query' describe AdminUserIndexQuery do - def real_users_count(query) - query.find_users_query.where('users.id > 0').count + def real_users(query) + query.find_users_query.where('users.id > 0') end describe "sql order" do @@ -70,7 +70,7 @@ describe AdminUserIndexQuery do TrustLevel.levels.each do |key, value| it "#{key} returns no records" do query = ::AdminUserIndexQuery.new(query: key.to_s) - expect(real_users_count(query)).to eq(0) + expect(real_users(query)).to eq([]) end end @@ -80,9 +80,11 @@ describe AdminUserIndexQuery do TrustLevel.levels.each do |key, value| it "finds user with trust #{key}" do - Fabricate(:user, trust_level: TrustLevel.levels[key]) + user = Fabricate(:user, trust_level: value) + Fabricate(:user, trust_level: value + 1) + query = ::AdminUserIndexQuery.new(query: key.to_s) - expect(real_users_count(query)).to eq(1) + expect(real_users(query)).to eq([user]) end end @@ -146,10 +148,11 @@ describe AdminUserIndexQuery do describe "with an admin user" do let!(:user) { Fabricate(:user, admin: true) } + let!(:user2) { Fabricate(:user, admin: false) } it "finds the admin" do query = ::AdminUserIndexQuery.new(query: 'admins') - expect(real_users_count(query)).to eq(1) + expect(real_users(query)).to eq([user]) end end @@ -157,10 +160,11 @@ describe AdminUserIndexQuery do describe "with a moderator" do let!(:user) { Fabricate(:user, moderator: true) } + let!(:user2) { Fabricate(:user, moderator: false) } it "finds the moderator" do query = ::AdminUserIndexQuery.new(query: 'moderators') - expect(real_users_count(query)).to eq(1) + expect(real_users(query)).to eq([user]) end end @@ -168,10 +172,23 @@ describe AdminUserIndexQuery do describe "with a silenced user" do let!(:user) { Fabricate(:user, silenced_till: 1.year.from_now) } + let!(:user2) { Fabricate(:user) } it "finds the silenced user" do query = ::AdminUserIndexQuery.new(query: 'silenced') - expect(query.find_users.count).to eq(1) + expect(real_users(query)).to eq([user]) + end + + end + + describe "with a staged user" do + + let!(:user) { Fabricate(:user, staged: true) } + let!(:user2) { Fabricate(:user, staged: false) } + + it "finds the staged user" do + query = ::AdminUserIndexQuery.new(query: 'staged') + expect(real_users(query)).to eq([user]) 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 a66d0d9f77..75a76ef27a 100644 --- a/spec/components/auth/default_current_user_provider_spec.rb +++ b/spec/components/auth/default_current_user_provider_spec.rb @@ -313,6 +313,13 @@ describe Auth::DefaultCurrentUserProvider do expect(provider("/", "HTTP_COOKIE" => "_t=#{token.unhashed_auth_token}").current_user).to eq(nil) end + it "always unstage users" do + staged_user = Fabricate(:user, staged: true) + provider("/").log_on_user(staged_user, {}, {}) + staged_user.reload + expect(staged_user.staged).to eq(false) + end + context "user api" do let :user do Fabricate(:user) diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb index 153d9a3956..9ded4025a7 100644 --- a/spec/components/email/receiver_spec.rb +++ b/spec/components/email/receiver_spec.rb @@ -78,6 +78,13 @@ describe Email::Receiver do expect { process(:bad_destinations) }.to raise_error(Email::Receiver::BadDestinationAddress) end + it "raises an OldDestinationError when notification is too old" do + topic = Fabricate(:topic, id: 424242) + post = Fabricate(:post, topic: topic, id: 123456, created_at: 1.year.ago) + + expect { process(:old_destination) }.to raise_error(Email::Receiver::OldDestinationError) + end + it "raises a BouncerEmailError when email is a bounced email" do expect { process(:bounced_email) }.to raise_error(Email::Receiver::BouncedEmailError) expect(IncomingEmail.last.is_bounce).to eq(true) @@ -100,17 +107,12 @@ describe Email::Receiver do let!(:email_log) { Fabricate(:email_log, user: user, bounce_key: bounce_key) } let!(:email_log_2) { Fabricate(:email_log, user: user, bounce_key: bounce_key_2) } - before do - $redis.del("bounce_score:#{user.email}:#{Date.today}") - $redis.del("bounce_score:#{user.email}:#{2.days.from_now.to_date}") - end - it "deals with soft bounces" do expect { process(:soft_bounce_via_verp) }.to raise_error(Email::Receiver::BouncedEmailError) email_log.reload expect(email_log.bounced).to eq(true) - expect(email_log.user.user_stat.bounce_score).to eq(1) + expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score) end it "deals with hard bounces" do @@ -118,17 +120,30 @@ describe Email::Receiver do email_log.reload expect(email_log.bounced).to eq(true) - expect(email_log.user.user_stat.bounce_score).to eq(2) - - freeze_time 2.days.from_now + expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect { process(:hard_bounce_via_verp_2) }.to raise_error(Email::Receiver::BouncedEmailError) email_log_2.reload - expect(email_log_2.user.user_stat.bounce_score).to eq(4) + expect(email_log_2.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score * 2) expect(email_log_2.bounced).to eq(true) end + it "automatically deactive users once they reach the 'bounce_score_threshold_deactivate' threshold" do + expect(user.active).to eq(true) + + user.user_stat.bounce_score = SiteSetting.bounce_score_threshold_deactivate - 1 + user.user_stat.save! + + expect { process(:soft_bounce_via_verp) }.to raise_error(Email::Receiver::BouncedEmailError) + + user.reload + email_log.reload + + expect(email_log.bounced).to eq(true) + expect(user.active).to eq(false) + end + end context "reply" do diff --git a/spec/components/flag_query_spec.rb b/spec/components/flag_query_spec.rb index 592045e641..428a5aa319 100644 --- a/spec/components/flag_query_spec.rb +++ b/spec/components/flag_query_spec.rb @@ -5,6 +5,41 @@ describe FlagQuery do let(:codinghorror) { Fabricate(:coding_horror) } + describe "flagged_topics" do + it "respects `min_flags_staff_visibility`" do + admin = Fabricate(:admin) + moderator = Fabricate(:moderator) + + post = create_post + + PostAction.act(moderator, post, PostActionType.types[:spam]) + + SiteSetting.min_flags_staff_visibility = 1 + + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_present + ft = result[:flagged_topics].first + expect(ft.topic).to eq(post.topic) + expect(ft.flag_counts).to eq(PostActionType.types[:spam] => 1) + + SiteSetting.min_flags_staff_visibility = 2 + + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_blank + + PostAction.act(admin, post, PostActionType.types[:inappropriate]) + result = FlagQuery.flagged_topics + expect(result[:flagged_topics]).to be_present + ft = result[:flagged_topics].first + expect(ft.topic).to eq(post.topic) + expect(ft.flag_counts).to eq( + PostActionType.types[:spam] => 1, + PostActionType.types[:inappropriate] => 1 + ) + end + + end + describe "flagged_posts_report" do it "does not return flags on system posts" do admin = Fabricate(:admin) @@ -75,7 +110,27 @@ describe FlagQuery do posts, users = FlagQuery.flagged_posts_report(moderator) expect(posts.count).to eq(1) + end + it "respects `min_flags_staff_visibility`" do + admin = Fabricate(:admin) + moderator = Fabricate(:moderator) + + post = create_post + + PostAction.act(moderator, post, PostActionType.types[:spam]) + + SiteSetting.min_flags_staff_visibility = 2 + posts, topics, users = FlagQuery.flagged_posts_report(admin) + expect(posts).to be_blank + expect(topics).to be_blank + expect(users).to be_blank + + PostAction.act(admin, post, PostActionType.types[:inappropriate]) + posts, topics, users = FlagQuery.flagged_posts_report(admin) + expect(posts).to be_present + expect(topics).to be_present + expect(users).to be_present end end diff --git a/spec/components/plugin/instance_spec.rb b/spec/components/plugin/instance_spec.rb index de6641218a..b791a7d8f8 100644 --- a/spec/components/plugin/instance_spec.rb +++ b/spec/components/plugin/instance_spec.rb @@ -381,4 +381,19 @@ describe Plugin::Instance do end end + describe '#enabled_site_setting_filter' do + describe 'when filter is blank' do + it 'should return the right value' do + expect(Plugin::Instance.new.enabled_site_setting_filter).to eq(nil) + end + end + + it 'should set the right value' do + instance = Plugin::Instance.new + instance.enabled_site_setting_filter('test') + + expect(instance.enabled_site_setting_filter).to eq('test') + end + end + end diff --git a/spec/components/search_spec.rb b/spec/components/search_spec.rb index 1e5c1a723e..273be47c31 100644 --- a/spec/components/search_spec.rb +++ b/spec/components/search_spec.rb @@ -251,7 +251,6 @@ describe Search do end it 'displays multiple results within a topic' do - topic = Fabricate(:topic) topic2 = Fabricate(:topic) @@ -260,8 +259,7 @@ describe Search do post1 = new_post('this is the other post I am posting', topic) post2 = new_post('this is my first post I am posting', topic) - post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds - with more stuff bla bla', topic) + post3 = new_post('this is a real long and complicated bla this is my second post I am Posting birds with more stuff bla bla', topic) post4 = new_post('this is my fourth post I am posting', topic) # update posts_count @@ -281,6 +279,13 @@ describe Search do results = Search.execute('"fourth post I am posting"', search_context: post1.topic) expect(results.posts.length).to eq(1) end + + it "works for unlisted topics" do + topic.update_attributes(visible: false) + _post = new_post('discourse is awesome', topic) + results = Search.execute('discourse', search_context: topic) + expect(results.posts.length).to eq(1) + end end context 'searching the OP' do @@ -307,6 +312,16 @@ describe Search do end end + context 'searching for quoted title' do + it "can find quoted title" do + create_post(raw: "this is the raw body", title: "I am a title yeah") + result = Search.execute('"a title yeah"') + + expect(result.posts.length).to eq(1) + end + + end + context "search for a topic by id" do let(:result) { Search.execute(topic.id, type_filter: 'topic', search_for_id: true, min_search_term_length: 1) } diff --git a/spec/components/site_setting_extension_spec.rb b/spec/components/site_setting_extension_spec.rb index 77e9b735d6..8dba4429fa 100644 --- a/spec/components/site_setting_extension_spec.rb +++ b/spec/components/site_setting_extension_spec.rb @@ -393,11 +393,30 @@ describe SiteSettingExtension do end describe ".set_and_log" do + before do + settings.setting(:s3_secret_access_key, "old_secret_key") + settings.setting(:title, "Discourse v1") + settings.refresh! + end + it "raises an error when set for an invalid setting name" do expect { settings.set_and_log("provider", "haxxed") }.to raise_error(ArgumentError) end + + it "scrubs secret setting values from logs" do + settings.set_and_log("s3_secret_access_key", "new_secret_key") + expect(UserHistory.last.previous_value).to eq("[FILTERED]") + expect(UserHistory.last.new_value).to eq("[FILTERED]") + end + + it "works" do + settings.set_and_log("title", "Discourse v2") + expect(settings.title).to eq("Discourse v2") + expect(UserHistory.last.previous_value).to eq("Discourse v1") + expect(UserHistory.last.new_value).to eq("Discourse v2") + end end describe "filter domain name" do diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 703021890f..9fb6329fc7 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -529,9 +529,11 @@ describe Admin::UsersController do _post = create_post(topic: topic, user: delete_me) end - it "returns an error" do + it "returns an api response that the user can't be deleted because it has posts" do delete :destroy, params: { id: delete_me.id }, format: :json - expect(response).to be_forbidden + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json['deleted']).to eq(false) end it "doesn't return an error if delete_posts == true" do diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 777a677740..6433a2de7f 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -2,33 +2,6 @@ require 'rails_helper' describe InvitesController do - context '.show' do - render_views - - it "shows error if invite not found" do - get :show, params: { id: 'nopeNOPEnope' } - - expect(response).to be_success - - body = response.body - - expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found')) - end - - it "renders the accept invite page if invite exists" do - i = Fabricate(:invite) - get :show, params: { id: i.invite_key } - - expect(response).to be_success - - body = response.body - - expect(body).to have_tag(:script, with: { src: '/assets/application.js' }) - expect(CGI.unescapeHTML(body)).to_not include(I18n.t('invite.not_found')) - end - end - context '.destroy' do it 'requires you to be logged in' do diff --git a/spec/controllers/session_controller_spec.rb b/spec/controllers/session_controller_spec.rb index b084051300..476bdb1e16 100644 --- a/spec/controllers/session_controller_spec.rb +++ b/spec/controllers/session_controller_spec.rb @@ -28,8 +28,9 @@ describe SessionController do end end - describe '#sso_login' do + let(:logo_fixture) { "http://#{Discourse.current_hostname}/uploads/logo.png" } + describe '#sso_login' do before do @sso_url = "http://somesite.com/discourse_sso" @sso_secret = "shjkfdhsfkjh" @@ -294,6 +295,11 @@ describe SessionController do describe 'can act as an SSO provider' do before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + SiteSetting.enable_sso_provider = true SiteSetting.enable_sso = false SiteSetting.enable_local_logins = true @@ -307,7 +313,15 @@ describe SessionController do @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) group = Fabricate(:group) group.add(@user) + + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + @user.reload + @user.user_avatar.reload + @user.user_profile.reload EmailToken.update_all(confirmed: true) end @@ -334,6 +348,14 @@ describe SessionController do expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end it "successfully redirects user to return_sso_url when the user is logged in" do @@ -353,6 +375,70 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) + end + + it 'handles non local content correctly' do + SiteSetting.avatar_sizes = "100|49" + SiteSetting.enable_s3_uploads = true + SiteSetting.s3_access_key_id = "XXX" + SiteSetting.s3_secret_access_key = "XXX" + SiteSetting.s3_upload_bucket = "test" + SiteSetting.s3_cdn_url = "http://cdn.com" + + stub_request(:any, /test.s3.amazonaws.com/).to_return(status: 200, body: "", headers: {}) + + @user.create_user_avatar! + upload = Fabricate(:upload, url: "//test.s3.amazonaws.com/something") + + Fabricate(:optimized_image, + sha1: SecureRandom.hex << "A" * 8, + upload: upload, + width: 98, + height: 98, + url: "//test.s3.amazonaws.com/something/else" + ) + + @user.update_columns(uploaded_avatar_id: upload.id) + @user.user_profile.update_columns( + profile_background: "//test.s3.amazonaws.com/something", + card_background: "//test.s3.amazonaws.com/something" + ) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload + + log_in_user(@user) + + stub_request(:get, "http://cdn.com/something/else").to_return( + body: lambda { |request| File.new(Rails.root + 'spec/fixtures/images/logo.png') } + ) + + get :sso_provider, params: Rack::Utils.parse_query(@sso.payload) + + location = response.header["Location"] + # javascript code will handle redirection of user to return_sso_url + expect(location).to match(/^http:\/\/somewhere.over.rainbow\/sso/) + + payload = location.split("?")[1] + sso2 = SingleSignOn.parse(payload, "topsecret") + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(SiteSetting.s3_cdn_url) + expect(sso2.profile_background_url).to start_with(SiteSetting.s3_cdn_url) + expect(sso2.card_background_url).to start_with(SiteSetting.s3_cdn_url) end end @@ -414,6 +500,11 @@ describe SessionController do describe '#sso_provider' do before do + stub_request(:any, /#{Discourse.current_hostname}\/uploads/).to_return( + status: 200, + body: lambda { |request| file_from_fixtures("logo.png") } + ) + SiteSetting.enable_sso_provider = true SiteSetting.enable_sso = false SiteSetting.enable_local_logins = true @@ -425,6 +516,14 @@ describe SessionController do @sso.return_sso_url = "http://somewhere.over.rainbow/sso" @user = Fabricate(:user, password: "myfrogs123ADMIN", active: true, admin: true) + @user.create_user_avatar! + UserAvatar.import_url_for_user(logo_fixture, @user) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: false) + UserProfile.import_url_for_user(logo_fixture, @user, is_card_background: true) + + @user.reload + @user.user_avatar.reload + @user.user_profile.reload EmailToken.update_all(confirmed: true) end @@ -450,6 +549,15 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + expect(sso2.groups).to eq(@user.groups.pluck(:name).join(",")) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end it "successfully redirects user to return_sso_url when the user is logged in" do @@ -469,6 +577,14 @@ describe SessionController do expect(sso2.external_id).to eq(@user.id.to_s) expect(sso2.admin).to eq(true) expect(sso2.moderator).to eq(false) + + expect(sso2.avatar_url.blank?).to_not eq(true) + expect(sso2.profile_background_url.blank?).to_not eq(true) + expect(sso2.card_background_url.blank?).to_not eq(true) + + expect(sso2.avatar_url).to start_with(Discourse.base_url) + expect(sso2.profile_background_url).to start_with(Discourse.base_url) + expect(sso2.card_background_url).to start_with(Discourse.base_url) end end diff --git a/spec/fabricators/topic_fabricator.rb b/spec/fabricators/topic_fabricator.rb index 1fd06233cd..b3efbb98e3 100644 --- a/spec/fabricators/topic_fabricator.rb +++ b/spec/fabricators/topic_fabricator.rb @@ -17,7 +17,6 @@ Fabricator(:banner_topic, from: :topic) do end Fabricator(:private_message_topic, from: :topic) do - user category_id { nil } title { sequence(:title) { |i| "This is a private message #{i}" } } archetype "private_message" diff --git a/spec/fixtures/emails/old_destination.eml b/spec/fixtures/emails/old_destination.eml new file mode 100644 index 0000000000..eb847ead44 --- /dev/null +++ b/spec/fixtures/emails/old_destination.eml @@ -0,0 +1,12 @@ +Return-Path:BEFORE
+++ +This is a user quote
+
AFTER
+ HTML + + expect(helper.email_excerpt(cooked)).to eq "BEFORE
\nThis is a user quote
\n
AFTER
" + end end end diff --git a/spec/jobs/automatic_group_membership_spec.rb b/spec/jobs/automatic_group_membership_spec.rb index 55776b99e4..93758a5695 100644 --- a/spec/jobs/automatic_group_membership_spec.rb +++ b/spec/jobs/automatic_group_membership_spec.rb @@ -11,8 +11,13 @@ describe Jobs::AutomaticGroupMembership do user1 = Fabricate(:user, email: "no@bar.com") user2 = Fabricate(:user, email: "no@wat.com") user3 = Fabricate(:user, email: "noo@wat.com", staged: true) + EmailToken.confirm(user3.email_tokens.last.token) user4 = Fabricate(:user, email: "yes@wat.com") EmailToken.confirm(user4.email_tokens.last.token) + user5 = Fabricate(:user, email: "sso@wat.com") + user5.create_single_sign_on_record(external_id: 123, external_email: "hacker@wat.com", last_payload: "") + user6 = Fabricate(:user, email: "sso2@wat.com") + user6.create_single_sign_on_record(external_id: 456, external_email: "sso2@wat.com", last_payload: "") group = Fabricate(:group, automatic_membership_email_domains: "wat.com", automatic_membership_retroactive: true) @@ -23,6 +28,8 @@ describe Jobs::AutomaticGroupMembership do expect(group.users.include?(user2)).to eq(false) expect(group.users.include?(user3)).to eq(false) expect(group.users.include?(user4)).to eq(true) + expect(group.users.include?(user5)).to eq(false) + expect(group.users.include?(user6)).to eq(true) end end diff --git a/spec/jobs/download_profile_background_from_url_spec.rb b/spec/jobs/download_profile_background_from_url_spec.rb new file mode 100644 index 0000000000..a7913cc73c --- /dev/null +++ b/spec/jobs/download_profile_background_from_url_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Jobs::DownloadProfileBackgroundFromUrl do + let(:user) { Fabricate(:user) } + + describe 'when url is invalid' do + it 'should not raise any error' do + expect do + described_class.new.execute( + url: '/assets/something/nice.jpg', + user_id: user.id + ) + end.to_not raise_error + end + end +end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 322b643cf6..bb8118d499 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -424,18 +424,15 @@ describe UserNotifications do end it "includes a list of participants, groups first with member lists" do - group1 = Fabricate(:group) - group2 = Fabricate(:group) - group1.name = "group1" - group2.name = "group2" - user1 = Fabricate(:user) - user2 = Fabricate(:user) - user1.username = "one" - user2.username = "two" - user1.groups = [ group1, group2 ] - user2.groups = [ group1 ] - topic.allowed_users = [ user1, user2 ] - topic.allowed_groups = [ group1, group2 ] + group1 = Fabricate(:group, name: "group1") + group2 = Fabricate(:group, name: "group2") + + user1 = Fabricate(:user, username: "one", groups: [group1, group2]) + user2 = Fabricate(:user, username: "two", groups: [group1]) + + topic.allowed_users = [user1, user2] + topic.allowed_groups = [group1, group2] + mail = UserNotifications.user_private_message( response.user, post: response, diff --git a/spec/models/discourse_single_sign_on_spec.rb b/spec/models/discourse_single_sign_on_spec.rb index ff22598ebe..f125d0c590 100644 --- a/spec/models/discourse_single_sign_on_spec.rb +++ b/spec/models/discourse_single_sign_on_spec.rb @@ -584,4 +584,167 @@ describe DiscourseSingleSignOn do end end end + + context 'when sso_overrides_profile_background is not enabled' do + + it "correctly handles provided profile_background_urls" do + sso = DiscourseSingleSignOn.new + sso.external_id = 666 + sso.email = "sam@sam.com" + sso.name = "sam" + sso.username = "sam" + sso.profile_background_url = "http://awesome.com/image.png" + sso.suppress_welcome_message = true + + FileHelper.stubs(:download).returns(file_from_fixtures("logo.png")) + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + profile_background = user.user_profile.profile_background + + # initial creation ... + expect(profile_background).to_not eq(nil) + expect(profile_background).to_not eq('') + + FileHelper.stubs(:download) { raise "should not be called" } + sso.profile_background_url = "https://some.new/avatar.png" + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + # profile_background updated but no override specified ... + expect(user.user_profile.profile_background).to eq(profile_background) + end + end + + context 'when sso_overrides_profile_background is enabled' do + let!(:sso_record) { Fabricate(:single_sign_on_record, external_profile_background_url: "http://example.com/an_image.png") } + + let!(:sso) { + sso = DiscourseSingleSignOn.new + sso.username = "test" + sso.name = "test" + sso.email = sso_record.user.email + sso.external_id = sso_record.external_id + sso + } + + let(:logo) { file_from_fixtures("logo.png") } + + before do + SiteSetting.sso_overrides_profile_background = true + end + + it "deal with no profile_background_url passed for an existing user with a profile_background" do + Sidekiq::Testing.inline! do + # Deliberately not setting profile_background_url so it should not update + sso_record.user.user_profile.update_columns(profile_background: '') + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.profile_background).to eq('') + end + end + + it "deal with a profile_background_url passed for an existing user with a profile_background" do + Sidekiq::Testing.inline! do + FileHelper.stubs(:download).returns(logo) + + sso_record.user.user_profile.update_columns(profile_background: '') + + sso.profile_background_url = "http://example.com/a_different_image.png" + + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.profile_background).to_not eq('') + end + end + end + + context 'when sso_overrides_card_background is not enabled' do + + it "correctly handles provided card_background_urls" do + sso = DiscourseSingleSignOn.new + sso.external_id = 666 + sso.email = "sam@sam.com" + sso.name = "sam" + sso.username = "sam" + sso.card_background_url = "http://awesome.com/image.png" + sso.suppress_welcome_message = true + + FileHelper.stubs(:download).returns(file_from_fixtures("logo.png")) + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + card_background = user.user_profile.card_background + + # initial creation ... + expect(card_background).to_not eq(nil) + expect(card_background).to_not eq('') + + FileHelper.stubs(:download) { raise "should not be called" } + sso.card_background_url = "https://some.new/avatar.png" + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + # card_background updated but no override specified ... + expect(user.user_profile.card_background).to eq(card_background) + end + end + + context 'when sso_overrides_card_background is enabled' do + let!(:sso_record) { Fabricate(:single_sign_on_record, external_card_background_url: "http://example.com/an_image.png") } + + let!(:sso) { + sso = DiscourseSingleSignOn.new + sso.username = "test" + sso.name = "test" + sso.email = sso_record.user.email + sso.external_id = sso_record.external_id + sso + } + + let(:logo) { file_from_fixtures("logo.png") } + + before do + SiteSetting.sso_overrides_card_background = true + end + + it "deal with no card_background_url passed for an existing user with a card_background" do + Sidekiq::Testing.inline! do + # Deliberately not setting card_background_url so it should not update + sso_record.user.user_profile.update_columns(card_background: '') + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.card_background).to eq('') + end + end + + it "deal with a card_background_url passed for an existing user with a card_background_url" do + Sidekiq::Testing.inline! do + FileHelper.stubs(:download).returns(logo) + + sso_record.user.user_profile.update_columns(card_background: '') + + sso.card_background_url = "http://example.com/a_different_image.png" + + user = sso.lookup_or_create_user(ip_address) + user.reload + user.user_profile.reload + + expect(user).to_not be_nil + expect(user.user_profile.card_background).to_not eq('') + end + end + end + end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index d4d6e5a954..4027250bef 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -21,7 +21,7 @@ describe Group do end end - describe '#username' do + describe '#name' do context 'when a user with a similar name exists' do it 'should not be valid' do new_group = Fabricate.build(:group, name: admin.username.upcase) @@ -685,7 +685,7 @@ describe Group do it "should publish the group's categories to the client" do group.update!(public_admission: true, categories: [category]) - message = MessageBus.track_publish { group.add(user) }.first + message = MessageBus.track_publish("/categories") { group.add(user) }.first expect(message.data[:categories].count).to eq(1) expect(message.data[:categories].first[:id]).to eq(category.id) diff --git a/spec/models/incoming_link_spec.rb b/spec/models/incoming_link_spec.rb index 1e32b9d0bd..00e3b9825c 100644 --- a/spec/models/incoming_link_spec.rb +++ b/spec/models/incoming_link_spec.rb @@ -2,6 +2,8 @@ require 'rails_helper' describe IncomingLink do + let(:sharing_user) { Fabricate(:user, name: 'Alice') } + let(:current_user) { Fabricate(:user, name: 'Bob') } let(:post) { Fabricate(:post) } let(:topic) { post.topic } @@ -82,11 +84,42 @@ describe IncomingLink do end it "is able to look up user_id and log it from the GET params" do - user = Fabricate(:user, username: "Bob") - add(host: 'test.com', username: "bob", post_id: 1) + add(host: 'test.com', username: sharing_user.username, post_id: 1) first = IncomingLink.first - expect(first.user_id).to eq user.id + expect(first.user_id).to eq sharing_user.id + end + + it "logs an incoming and stores IP with no current user" do + add(referer: 'https://example.social/@alice/1234', + post_id: post.id, + username: sharing_user.username, + current_user: nil, + ip_address: '100.64.1.1') + expect(IncomingLink.count).to eq 1 + il = IncomingLink.last + expect(il.ip_address).to eq '100.64.1.1' + end + + it "does not log when the sharing user clicks their own link" do + add(referer: 'https://example.social/@alice/1234', + post_id: post.id, + username: sharing_user.username, + current_user: sharing_user, + ip_address: '100.64.1.2') + expect(IncomingLink.count).to eq 0 + end + + it "does not store ip address when a logged-in user clicks" do + add(referer: 'https://example.social/@alice/1234', + post_id: post.id, + username: sharing_user.username, + current_user: current_user, + ip_address: '100.64.1.3') + expect(IncomingLink.count).to eq 1 + il = IncomingLink.last + expect(il.ip_address).to eq nil + expect(il.current_user_id).to eq current_user.id end end diff --git a/spec/models/incoming_links_report_spec.rb b/spec/models/incoming_links_report_spec.rb index e1582565f6..1ee041b23b 100644 --- a/spec/models/incoming_links_report_spec.rb +++ b/spec/models/incoming_links_report_spec.rb @@ -56,6 +56,45 @@ describe IncomingLinksReport do { topic_id: p2.topic.id, topic_title: p2.topic.title, topic_url: p2.topic.relative_url, num_clicks: 2 + 3 }, ] end + + it "does not report PMs" do + public_topic = Fabricate(:topic) + message_topic = Fabricate(:private_message_topic) + + public_post = Fabricate(:post, topic: public_topic) + message_post = Fabricate(:post, topic: message_topic) + + IncomingLink.add( + referer: "http://foo.com", + host: "http://discourse.example.com", + topic_id: public_topic.id, + id_address: "1.2.3.4", + username: public_post.user.username, + ) + + IncomingLink.add( + referer: "http://foo.com", + host: "http://discourse.example.com", + topic_id: message_topic.id, + id_address: "5.6.7.8", + username: message_post.user.username, + ) + + r = IncomingLinksReport.find('top_referrers').as_json + expect(r[:data]).to eq [ + { username: public_post.user.username, user_id: public_post.user.id, num_clicks: 1, num_topics: 1 }, + ] + + r = IncomingLinksReport.find('top_traffic_sources').as_json + expect(r[:data]).to eq [ + { domain: 'foo.com', num_clicks: 1, num_topics: 1 }, + ] + + r = IncomingLinksReport.find('top_referred_topics').as_json + expect(r[:data]).to eq [ + { topic_id: public_topic.id, topic_title: public_topic.title, topic_url: public_topic.relative_url, num_clicks: 1 }, + ] + end end describe 'top_referrers' do @@ -161,7 +200,9 @@ describe IncomingLinksReport do topic1 = Fabricate.build(:topic, id: 123); topic2 = Fabricate.build(:topic, id: 234) # TODO: OMG OMG THE STUBBING IncomingLinksReport.stubs(:link_count_per_topic).returns(topic1.id => 8, topic2.id => 3) - Topic.stubs(:select).returns(Topic); Topic.stubs(:where).returns(Topic) # bypass some activerecord methods + # bypass some activerecord methods + Topic.stubs(:select).returns(Topic) + Topic.stubs(:where).returns(Topic) Topic.stubs(:all).returns([topic1, topic2]) expect(top_referred_topics[:data][0]).to eq(topic_id: topic1.id, topic_title: topic1.title, topic_url: topic1.relative_url, num_clicks: 8) expect(top_referred_topics[:data][1]).to eq(topic_id: topic2.id, topic_title: topic2.title, topic_url: topic2.relative_url, num_clicks: 3) diff --git a/spec/models/invite_redeemer_spec.rb b/spec/models/invite_redeemer_spec.rb index dea7ea2945..ac31409b52 100644 --- a/spec/models/invite_redeemer_spec.rb +++ b/spec/models/invite_redeemer_spec.rb @@ -130,7 +130,6 @@ describe InviteRedeemer do end it "only allows one user to be created per invite" do - SiteSetting.invite_passthrough_hours = 4800 user = invite_redeemer.redeem invite.reload diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index af5dc736ee..f0411f4b1c 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -301,25 +301,8 @@ describe Invite do end context 'again' do - context "without a passthrough" do - before do - SiteSetting.invite_passthrough_hours = 0 - end - - it 'will not redeem twice' do - expect(invite.redeem).to be_blank - end - end - - context "with a passthrough" do - before do - SiteSetting.invite_passthrough_hours = 1 - end - - it 'will not redeem twice' do - expect(invite.redeem).to be_present - expect(invite.redeem.email).to eq(user.email) - end + it 'will not redeem twice' do + expect(invite.redeem).to be_blank end end end diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb index da30be3cc9..7a10f34648 100644 --- a/spec/models/post_action_spec.rb +++ b/spec/models/post_action_spec.rb @@ -141,6 +141,17 @@ describe PostAction do expect(PostAction.flagged_posts_count).to eq(0) end + it "respects min_flags_staff_visibility" do + SiteSetting.min_flags_staff_visibility = 2 + expect(PostAction.flagged_posts_count).to eq(0) + + PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) + expect(PostAction.flagged_posts_count).to eq(0) + + PostAction.act(eviltrout, post, PostActionType.types[:off_topic]) + expect(PostAction.flagged_posts_count).to eq(1) + end + it "should reset counts when a topic is deleted" do PostAction.act(codinghorror, post, PostActionType.types[:off_topic]) post.topic.trash! diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 45a0772e57..f2507b3515 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -30,6 +30,7 @@ describe Report do describe "topics" do before do + Report.clear_cache freeze_time DateTime.parse('2017-03-01 12:00') ((0..32).to_a + [60, 61, 62, 63]).each do |i| @@ -37,11 +38,21 @@ describe Report do end end - subject(:json) { Report.find("topics").as_json } - it "counts the correct records" do + json = Report.find("topics").as_json expect(json[:data].size).to eq(31) expect(json[:prev30Days]).to eq(3) + + # lets make sure we can ask for the correct options for the report + json = Report.find("topics", + start_date: 5.days.ago.beginning_of_day, + end_date: 1.day.ago.end_of_day, + facets: [:prev_period] + ).as_json + + expect(json[:prev_period]).to eq(5) + expect(json[:data].length).to eq(5) + expect(json[:prev30Days]).to eq(nil) end end end @@ -321,7 +332,9 @@ describe Report do context "with different searches" do before do SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1') - SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.1', user_id: Fabricate(:user).id) + + SearchLog.create!(term: 'ruby', search_result_id: 1, search_type: 1, ip_address: '127.0.0.1', user_id: Fabricate(:user).id) + SearchLog.log(term: 'ruby', search_type: :header, ip_address: '127.0.0.2') SearchLog.log(term: 'php', search_type: :header, ip_address: '127.0.0.1') end @@ -331,13 +344,12 @@ describe Report do end it "returns a report with data" do - expect(report.data[0][0]).to eq("ruby") - expect(report.data[0][1]).to eq(3) - expect(report.data[0][2]).to eq(2) + expect(report.data[0][:term]).to eq("ruby") + expect(report.data[0][:unique_searches]).to eq(2) + expect(report.data[0][:ctr]).to eq('33.4%') - expect(report.data[1][0]).to eq("php") - expect(report.data[1][1]).to eq(1) - expect(report.data[1][2]).to eq(1) + expect(report.data[1][:term]).to eq("php") + expect(report.data[1][:unique_searches]).to eq(1) end end end @@ -373,7 +385,7 @@ describe Report do it "returns a report with data" do expect(report.data.first[:y]).to eq(100) - expect(report.data.last[:y]).to eq(34) + expect(report.data.last[:y]).to eq(33.34) expect(report.prev30Days).to eq(75) end end @@ -423,57 +435,4 @@ describe Report do expect(r.data[0][:y]).to eq(1) end end - - describe "inactive users" do - context "no activity" do - it "returns an empty report" do - report = Report.find('inactive_users') - expect(report.data).to be_blank - end - end - - context "with different users/visits" do - before do - freeze_time - - @arpit = Fabricate(:user, created_at: 200.days.ago) - @sam = Fabricate(:user, created_at: 200.days.ago) - @robin = Fabricate(:user, created_at: 200.days.ago) - @michael = Fabricate(:user, created_at: 200.days.ago) - @gerhard = Fabricate(:user, created_at: 200.days.ago) - end - - it "returns all users as inactive" do - report = Report.find('inactive_users') - expect(report.data.first[:y]).to eq(5) - expect(report.data.last[:y]).to eq(5) - end - - it "correctly returns inactive users" do - @arpit.user_visits.create(visited_at: 100.days.ago) - @sam.user_visits.create(visited_at: 100.days.ago) - report = Report.find('inactive_users') - expect(report.data.first[:y]).to eq(3) - expect(report.data.last[:y]).to eq(5) - expect(report.prev30Days).to eq(3) - expect(report.total).to eq(5) - - @arpit.user_visits.create(visited_at: 80.days.ago) - report = Report.find('inactive_users') - expect(report.data.first[:y]).to eq(3) - expect(report.data.last[:y]).to eq(4) - - @sam.user_visits.create(visited_at: 55.days.ago) - @robin.user_visits.create(visited_at: 50.days.ago) - report = Report.find('inactive_users') - expect(report.data.first[:y]).to eq(2) - expect(report.data.last[:y]).to eq(2) - - Fabricate(:incoming_email, user: @michael, created_at: 20.days.ago, post: Fabricate(:post, user: @michael)) - report = Report.find('inactive_users') - expect(report.data.first[:y]).to eq(2) - expect(report.data.last[:y]).to eq(1) - end - end - end end diff --git a/spec/models/tag_group_spec.rb b/spec/models/tag_group_spec.rb index 8dc4d45373..b2fcd20c9d 100644 --- a/spec/models/tag_group_spec.rb +++ b/spec/models/tag_group_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' describe TagGroup do - describe '#allowed' do + describe '#visible' do let(:user1) { Fabricate(:user) } let(:user2) { Fabricate(:user) } let(:admin) { Fabricate(:admin) } @@ -9,6 +9,10 @@ describe TagGroup do let(:group) { Fabricate(:group) } + let!(:everyone_tag_group) { Fabricate(:tag_group, name: 'Visible & usable by everyone', tag_names: ['foo-bar']) } + let!(:visible_tag_group) { Fabricate(:tag_group, name: 'Visible by everyone, usable by staff', tag_names: ['foo']) } + let!(:staff_only_tag_group) { Fabricate(:tag_group, name: 'Staff only', tag_names: ['bar']) } + let!(:public_tag_group) { Fabricate(:tag_group, name: 'Public', tag_names: ['public1']) } let!(:private_tag_group) { Fabricate(:tag_group, name: 'Private', tag_names: ['privatetag1']) } let!(:staff_tag_group) { Fabricate(:tag_group, name: 'Staff Talk', tag_names: ['stafftag1']) } @@ -16,26 +20,56 @@ describe TagGroup do let!(:public_category) { Fabricate(:category, name: 'Public Category') } let!(:private_category) { Fabricate(:private_category, group: group) } - let(:staff_category) { Fabricate(:category, name: 'Secret') } + let!(:staff_category) { Fabricate(:category, name: 'Secret') } + + let(:everyone) { Group::AUTO_GROUPS[:everyone] } + let(:staff) { Group::AUTO_GROUPS[:staff] } + + let(:full) { TagGroupPermission.permission_types[:full] } + let(:readonly) { TagGroupPermission.permission_types[:readonly] } before do group.add(user2) group.save! + staff_category.set_permissions(admins: :full) staff_category.save! + private_category.set_permissions(staff: :full, group => :full) private_category.save! + public_category.allowed_tag_groups = [public_tag_group.name] private_category.allowed_tag_groups = [private_tag_group.name] staff_category.allowed_tag_groups = [staff_tag_group.name] + + everyone_tag_group.permissions = [[everyone, full]] + everyone_tag_group.save! + + visible_tag_group.permissions = [[everyone, readonly], [staff, full]] + visible_tag_group.save! + + staff_only_tag_group.permissions = [[staff, full]] + staff_only_tag_group.save! end - it "returns correct groups based on category permissions" do - expect(TagGroup.allowed(Guardian.new(admin)).pluck(:name)).to match_array(TagGroup.pluck(:name)) - expect(TagGroup.allowed(Guardian.new(moderator)).pluck(:name)).to match_array(TagGroup.pluck(:name)) - expect(TagGroup.allowed(Guardian.new(user2)).pluck(:name)).to match_array([public_tag_group.name, unrestricted_tag_group.name, private_tag_group.name]) - expect(TagGroup.allowed(Guardian.new(user1)).pluck(:name)).to match_array([public_tag_group.name, unrestricted_tag_group.name]) - expect(TagGroup.allowed(Guardian.new(nil)).pluck(:name)).to match_array([public_tag_group.name, unrestricted_tag_group.name]) + it "returns correct groups based on category & tag group permissions" do + expect(TagGroup.visible(Guardian.new(admin)).pluck(:name)).to match_array(TagGroup.pluck(:name)) + expect(TagGroup.visible(Guardian.new(moderator)).pluck(:name)).to match_array(TagGroup.pluck(:name)) + + expect(TagGroup.visible(Guardian.new(user2)).pluck(:name)).to match_array([ + public_tag_group.name, unrestricted_tag_group.name, private_tag_group.name, + everyone_tag_group.name, visible_tag_group.name, + ]) + + expect(TagGroup.visible(Guardian.new(user1)).pluck(:name)).to match_array([ + public_tag_group.name, unrestricted_tag_group.name, everyone_tag_group.name, + visible_tag_group.name, + ]) + + expect(TagGroup.visible(Guardian.new(nil)).pluck(:name)).to match_array([ + public_tag_group.name, unrestricted_tag_group.name, everyone_tag_group.name, + visible_tag_group.name, + ]) end end end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index 6a17b19bec..72eb939fee 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -1191,11 +1191,8 @@ describe Topic do category.reload end - it 'increases the topic_count' do - expect(category.topic_count).to eq(1) - end - it "doesn't change the topic_count when the value doesn't change" do + expect(category.topic_count).to eq(1) expect { topic.change_category_to_id(category.id); category.reload }.not_to change(category, :topic_count) end @@ -1215,6 +1212,29 @@ describe Topic do expect(category.reload.topic_count).to eq(0) end + describe 'user that watching the new category' do + it 'should generate the notification for the topic' do + topic.posts << Fabricate(:post) + + CategoryUser.set_notification_level_for_category( + user, + CategoryUser::notification_levels[:watching], + new_category.id + ) + + expect do + topic.change_category_to_id(new_category.id) + end.to change { Notification.count }.by(1) + + notification = Notification.last + + expect(notification.notification_type).to eq(Notification.types[:posted]) + expect(notification.topic_id).to eq(topic.id) + expect(notification.user_id).to eq(user.id) + expect(notification.post_number).to eq(1) + end + end + describe 'when new category is set to auto close by default' do before do new_category.update!(auto_close_hours: 5) @@ -1726,7 +1746,7 @@ describe Topic do describe '#listable_count_per_day' do before(:each) do - freeze_time + freeze_time DateTime.parse('2017-03-01 12:00') Fabricate(:topic) Fabricate(:topic, created_at: 1.day.ago) diff --git a/spec/models/user_profile_spec.rb b/spec/models/user_profile_spec.rb index ea5aacf327..52db905933 100644 --- a/spec/models/user_profile_spec.rb +++ b/spec/models/user_profile_spec.rb @@ -202,4 +202,39 @@ describe UserProfile do end end end + + context '.import_url_for_user' do + let(:user) { Fabricate(:user) } + + before do + stub_request(:any, "thisfakesomething.something.com") + .to_return(body: "abc", status: 404, headers: { 'Content-Length' => 3 }) + end + + describe 'when profile_background_url returns an invalid status code' do + it 'should not do anything' do + url = "http://thisfakesomething.something.com/" + + UserProfile.import_url_for_user(url, user, is_card_background: false) + + user.reload + + expect(user.user_profile.profile_background).to eq(nil) + end + end + + describe 'when card_background_url returns an invalid status code' do + it 'should not do anything' do + url = "http://thisfakesomething.something.com/" + + UserProfile.import_url_for_user(url, user, is_card_background: true) + + user.reload + + expect(user.user_profile.card_background).to eq(nil) + end + end + + end + end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index fe2299cb7b..35983247a2 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -94,7 +94,7 @@ describe User do describe '#count_by_signup_date' do before(:each) do User.destroy_all - freeze_time + freeze_time DateTime.parse('2017-02-01 12:00') Fabricate(:user) Fabricate(:user, created_at: 1.day.ago) Fabricate(:user, created_at: 1.day.ago) @@ -1236,24 +1236,28 @@ describe User do describe "#purge_unactivated" do let!(:user) { Fabricate(:user) } - let!(:inactive) { Fabricate(:user, active: false) } - let!(:inactive_old) { Fabricate(:user, active: false, created_at: 1.month.ago) } + let!(:unactivated) { Fabricate(:user, active: false) } + let!(:unactivated_old) { Fabricate(:user, active: false, created_at: 1.month.ago) } + let!(:unactivated_old_with_pm) { Fabricate(:user, active: false, created_at: 2.months.ago) } + + before do + PostCreator.new(Discourse.system_user, + title: "Welcome to our Discourse", + raw: "This is a welcome message", + archetype: Archetype.private_message, + target_usernames: [unactivated_old_with_pm.username], + ).create + end it 'should only remove old, unactivated users' do User.purge_unactivated - all_users = User.all - expect(all_users.include?(user)).to eq(true) - expect(all_users.include?(inactive)).to eq(true) - expect(all_users.include?(inactive_old)).to eq(false) + expect(User.real.all).to match_array([user, unactivated, unactivated_old_with_pm]) end it "does nothing if purge_unactivated_users_grace_period_days is 0" do SiteSetting.purge_unactivated_users_grace_period_days = 0 User.purge_unactivated - all_users = User.all - expect(all_users.include?(user)).to eq(true) - expect(all_users.include?(inactive)).to eq(true) - expect(all_users.include?(inactive_old)).to eq(true) + expect(User.real.all).to match_array([user, unactivated, unactivated_old, unactivated_old_with_pm]) end end @@ -1314,6 +1318,14 @@ describe User do expect(group_history.target_user).to eq(user) end + it "is automatically added to a group when the email matches the SSO record" do + user = Fabricate(:user, active: true, email: "sso@bar.com") + user.create_single_sign_on_record(external_id: 123, external_email: "sso@bar.com", last_payload: "") + user.set_automatic_groups + group.reload + expect(group.users.include?(user)).to eq(true) + end + it "get attributes from the group" do user = Fabricate.build(:user, active: true, diff --git a/spec/requests/admin/groups_controller_spec.rb b/spec/requests/admin/groups_controller_spec.rb index 20caa5b645..7939a48f3b 100644 --- a/spec/requests/admin/groups_controller_spec.rb +++ b/spec/requests/admin/groups_controller_spec.rb @@ -51,6 +51,19 @@ RSpec.describe Admin::GroupsController do end end + describe '#remove_owner' do + it 'should work' do + group.add_owner(user) + + delete "/admin/groups/#{group.id}/owners.json", params: { + user_id: user.id + } + + expect(response.status).to eq(200) + expect(group.group_users.where(owner: true)).to eq([]) + end + end + describe "#bulk_perform" do let(:group) do Fabricate(:group, diff --git a/spec/requests/directory_items_controller_spec.rb b/spec/requests/directory_items_controller_spec.rb index a3626fc496..c912634703 100644 --- a/spec/requests/directory_items_controller_spec.rb +++ b/spec/requests/directory_items_controller_spec.rb @@ -2,6 +2,10 @@ require 'rails_helper' describe DirectoryItemsController do let!(:user) { Fabricate(:user) } + let!(:evil_trout) { Fabricate(:evil_trout) } + let!(:walter_white) { Fabricate(:walter_white) } + let!(:stage_user) { Fabricate(:staged, username: 'stage_user') } + let!(:group) { Fabricate(:group, users: [evil_trout, stage_user]) } it "requires a `period` param" do get '/directory_items.json' @@ -28,10 +32,6 @@ describe DirectoryItemsController do context "with data" do before do - Fabricate(:evil_trout) - Fabricate(:walter_white) - Fabricate(:staged, username: 'stage_user') - DirectoryItem.refresh! end @@ -77,5 +77,29 @@ describe DirectoryItemsController do expect(json['total_rows_directory_items']).to eq(1) expect(json['directory_items'][0]['user']['username']).to eq('stage_user') end + + it "excludes users by username" do + get '/directory_items.json', params: { period: 'all', exclude_usernames: "stage_user,eviltrout" } + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + expect(json['directory_items'].length).to eq(2) + expect(json['total_rows_directory_items']).to eq(2) + expect(json['directory_items'][0]['user']['username']).to eq(walter_white.username) | eq(user.username) + expect(json['directory_items'][1]['user']['username']).to eq(walter_white.username) | eq(user.username) + end + + it "filters users by group" do + get '/directory_items.json', params: { period: 'all', group: group.name } + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json).to be_present + expect(json['directory_items'].length).to eq(2) + expect(json['total_rows_directory_items']).to eq(2) + expect(json['directory_items'][0]['user']['username']).to eq(evil_trout.username) | eq(stage_user.username) + expect(json['directory_items'][1]['user']['username']).to eq(evil_trout.username) | eq(stage_user.username) + end end end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb new file mode 100644 index 0000000000..4f622c196a --- /dev/null +++ b/spec/requests/invites_controller_spec.rb @@ -0,0 +1,40 @@ +require 'rails_helper' + +describe InvitesController do + + context 'show' do + let(:invite) { Fabricate(:invite) } + let(:user) { Fabricate(:coding_horror) } + + it "returns error if invite not found" do + get "/invites/nopeNOPEnope" + + expect(response).to be_success + + body = response.body + expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) + end + + it "renders the accept invite page if invite exists" do + get "/invites/#{invite.invite_key}" + + expect(response).to be_success + + body = response.body + expect(body).to have_tag(:script, with: { src: '/assets/application.js' }) + expect(CGI.unescapeHTML(body)).to_not include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) + end + + it "returns error if invite has already been redeemed" do + invite.update_attributes!(redeemed_at: 1.day.ago) + get "/invites/#{invite.invite_key}" + + expect(response).to be_success + + body = response.body + expect(body).to_not have_tag(:script, with: { src: '/assets/application.js' }) + expect(CGI.unescapeHTML(body)).to include(I18n.t('invite.not_found_template', site_name: SiteSetting.title, base_url: Discourse.base_url)) + end + end +end diff --git a/spec/requests/push_notification_controller_spec.rb b/spec/requests/push_notification_controller_spec.rb new file mode 100644 index 0000000000..42f12e3a07 --- /dev/null +++ b/spec/requests/push_notification_controller_spec.rb @@ -0,0 +1,112 @@ +require 'rails_helper' + +describe PushNotificationController do + let(:user) { Fabricate(:user) } + + context "logged out" do + it "should not allow subscribe" do + post '/push_notifications/subscribe.json', params: { + username: "test", + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + }, + send_confirmation: false + } + + expect(response.status).to eq(403) + end + end + + context "logged in" do + before { sign_in(user) } + + it "should subscribe" do + post '/push_notifications/subscribe.json', params: { + username: user.username, + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + }, + send_confirmation: false + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions.count).to eq(1) + end + + it "should fix duplicate subscriptions" do + subscription = { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + } + PushSubscription.create user: user, data: subscription.to_json + post '/push_notifications/subscribe.json', params: { + username: user.username, + subscription: subscription, + send_confirmation: false + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions.count).to eq(1) + end + + it "should not create duplicate subscriptions" do + 2.times do + post '/push_notifications/subscribe.json', params: { + username: user.username, + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + }, + send_confirmation: false + } + end + + expect(response.status).to eq(200) + expect(user.push_subscriptions.count).to eq(1) + end + + it "should unsubscribe with existing subscription" do + sub = { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } } + PushSubscription.create!(user: user, data: sub.to_json) + + post '/push_notifications/unsubscribe.json', params: { + username: user.username, + subscription: sub + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions).to eq([]) + end + + it "should unsubscribe without subscription" do + post '/push_notifications/unsubscribe.json', params: { + username: user.username, + subscription: { + endpoint: "endpoint", + keys: { + p256dh: "256dh", + auth: "auth" + } + } + } + + expect(response.status).to eq(200) + expect(user.push_subscriptions).to eq([]) + end + end + +end diff --git a/spec/requests/tags_controller_spec.rb b/spec/requests/tags_controller_spec.rb index 1c6ca7d811..13a991ad8d 100644 --- a/spec/requests/tags_controller_spec.rb +++ b/spec/requests/tags_controller_spec.rb @@ -8,8 +8,8 @@ describe TagsController do describe '#index' do before do - tag = Fabricate(:tag, name: 'test') - topic_tag = Fabricate(:tag, name: 'topic-test', topic_count: 1) + Fabricate(:tag, name: 'test') + Fabricate(:tag, name: 'topic-test', topic_count: 1) end shared_examples "successfully retrieve tags with topic_count > 0" do @@ -25,19 +25,28 @@ describe TagsController do end context "with tags_listed_by_group enabled" do - before do - SiteSetting.tags_listed_by_group = true - end - + before { SiteSetting.tags_listed_by_group = true } include_examples "successfully retrieve tags with topic_count > 0" end context "with tags_listed_by_group disabled" do - before do - SiteSetting.tags_listed_by_group = false + before { SiteSetting.tags_listed_by_group = false } + include_examples "successfully retrieve tags with topic_count > 0" + end + + context "when user can admin tags" do + + it "succesfully retrieve all tags" do + sign_in(Fabricate(:admin)) + + get "/tags.json" + + expect(response).to be_success + + tags = JSON.parse(response.body)["tags"] + expect(tags.length).to eq(2) end - include_examples "successfully retrieve tags with topic_count > 0" end end diff --git a/spec/requests/users_email_controller_spec.rb b/spec/requests/users_email_controller_spec.rb index 624ba871e5..e562810319 100644 --- a/spec/requests/users_email_controller_spec.rb +++ b/spec/requests/users_email_controller_spec.rb @@ -61,6 +61,15 @@ describe UsersEmailController do expect(user.user_stat.reset_bounce_score_after).to eq(nil) end + it 'automatically adds the user to a group when the email matches' do + group = Fabricate(:group, automatic_membership_email_domains: "example.com") + + get "/u/authorize-email/#{user.email_tokens.last.token}" + + expect(response).to be_success + expect(group.reload.users.include?(user)).to eq(true) + end + context 'second factor required' do let!(:second_factor) { Fabricate(:user_second_factor, user: user) } @@ -93,8 +102,8 @@ describe UsersEmailController do response_body = response.body - expect(response.body).not_to include(I18n.t("login.second_factor_title")) - expect(response.body).not_to include(I18n.t("login.invalid_second_factor_code")) + expect(response_body).not_to include(I18n.t("login.second_factor_title")) + expect(response_body).not_to include(I18n.t("login.invalid_second_factor_code")) end end end diff --git a/spec/serializers/admin_plugin_serializer_spec.rb b/spec/serializers/admin_plugin_serializer_spec.rb new file mode 100644 index 0000000000..70d8430a05 --- /dev/null +++ b/spec/serializers/admin_plugin_serializer_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe AdminPluginSerializer do + let(:instance) { Plugin::Instance.new } + + subject { described_class.new(instance) } + + describe 'enabled_setting' do + it 'should return the right value' do + instance.enabled_site_setting('test') + expect(subject.enabled_setting).to eq('test') + end + end +end diff --git a/spec/services/post_owner_changer_spec.rb b/spec/services/post_owner_changer_spec.rb index 5ad9d8af57..b9f836533e 100644 --- a/spec/services/post_owner_changer_spec.rb +++ b/spec/services/post_owner_changer_spec.rb @@ -5,8 +5,8 @@ describe PostOwnerChanger do let!(:editor) { Fabricate(:admin) } let(:topic) { Fabricate(:topic) } let(:user_a) { Fabricate(:user) } - let(:p1) { Fabricate(:post, topic_id: topic.id) } - let(:p2) { Fabricate(:post, topic_id: topic.id) } + let(:p1) { Fabricate(:post, topic_id: topic.id, post_number: 1) } + let(:p2) { Fabricate(:post, topic_id: topic.id, post_number: 2) } let(:p3) { Fabricate(:post) } it "raises an error with a parameter missing" do @@ -75,6 +75,14 @@ describe PostOwnerChanger do expect(p1.reload.user).to eq(user_a) end + it "changes the owner when the post is deleted" do + p4 = Fabricate(:post, topic_id: topic.id, reply_to_post_number: p2.post_number) + PostDestroyer.new(editor, p4).destroy + + PostOwnerChanger.new(post_ids: [p4.id], topic_id: topic.id, new_owner: user_a, acting_user: editor).change_owner! + expect(p4.reload.user).to eq(user_a) + end + context "sets topic notification level for the new owner" do let(:p4) { Fabricate(:post, post_number: 2, topic_id: topic.id) } diff --git a/spec/services/push_notification_pusher_spec.rb b/spec/services/push_notification_pusher_spec.rb new file mode 100644 index 0000000000..33ec16c625 --- /dev/null +++ b/spec/services/push_notification_pusher_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe PushNotificationPusher do + + it "returns badges url by default" do + expect(PushNotificationPusher.get_badge).to eq("/assets/push-notifications/discourse.png") + end + + it "returns custom badges url" do + SiteSetting.push_notifications_icon_url = "/test.png" + expect(PushNotificationPusher.get_badge).to eq("/test.png") + end + +end diff --git a/spec/services/user_anonymizer_spec.rb b/spec/services/user_anonymizer_spec.rb index baf7affff2..58eefd90ed 100644 --- a/spec/services/user_anonymizer_spec.rb +++ b/spec/services/user_anonymizer_spec.rb @@ -119,6 +119,33 @@ describe UserAnonymizer do expect(user.uploaded_avatar_id).to eq(nil) end + it "updates the avatar in posts" do + upload = Fabricate(:upload, user: user) + user.user_avatar = UserAvatar.new(user_id: user.id, custom_upload_id: upload.id) + user.uploaded_avatar_id = upload.id # chosen in user preferences + user.save! + + topic = Fabricate(:topic, user: user) + quoted_post = create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") + post = create_post(raw: <<~RAW) + Lorem ipsum + + [quote="#{quoted_post.username}, post:1, topic:#{quoted_post.topic.id}"] + quoted post + [/quote] + RAW + + old_avatar_url = user.avatar_template.gsub("{size}", "40") + expect(post.cooked).to include(old_avatar_url) + + make_anonymous + post.reload + new_avatar_url = user.reload.avatar_template.gsub("{size}", "40") + + expect(post.cooked).to_not include(old_avatar_url) + expect(post.cooked).to include(new_avatar_url) + end + it "logs the action with the original details" do SiteSetting.log_anonymizer_details = true helper = UserAnonymizer.new(user, admin) diff --git a/spec/services/user_merger_spec.rb b/spec/services/user_merger_spec.rb index ac5d3545b4..5d5c1f6693 100644 --- a/spec/services/user_merger_spec.rb +++ b/spec/services/user_merger_spec.rb @@ -289,69 +289,6 @@ describe UserMerger do expect(Notification.where(user_id: target_user.id).count).to eq(2) expect(Notification.where(user_id: source_user.id).count).to eq(0) end - - def create_notification(type, notified_user, post, data = {}) - Fabricate( - :notification, - notification_type: Notification.types[type], - user: notified_user, - data: data.to_json, - topic: post&.topic, - post_number: post&.post_number - ) - end - - def notification_data(notification) - JSON.parse(notification.reload.data, symbolize_names: true) - end - - def original_and_display_username(user) - { original_username: user.username, display_username: user.username, foo: "bar" } - end - - def original_username_and_some_text_as_display_username(user) - { original_username: user.username, display_username: "some text", foo: "bar" } - end - - def only_display_username(user) - { display_username: user.username } - end - - def username_and_something_else(user) - { username: user.username, foo: "bar" } - end - - it "updates notification data" do - notified_user = Fabricate(:user) - p1 = Fabricate(:post, post_number: 1, user: source_user) - p2 = Fabricate(:post, post_number: 1, user: walter) - Fabricate(:invite, invited_by: notified_user, user: source_user) - Fabricate(:invite, invited_by: notified_user, user: walter) - - n01 = create_notification(:mentioned, notified_user, p1, original_and_display_username(source_user)) - n02 = create_notification(:mentioned, notified_user, p2, original_and_display_username(walter)) - n03 = create_notification(:mentioned, notified_user, p1, original_username_and_some_text_as_display_username(source_user)) - n04 = create_notification(:mentioned, notified_user, p1, only_display_username(source_user)) - n05 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(source_user)) - n06 = create_notification(:invitee_accepted, notified_user, nil, only_display_username(walter)) - n07 = create_notification(:granted_badge, source_user, nil, username_and_something_else(source_user)) - n08 = create_notification(:granted_badge, walter, nil, username_and_something_else(walter)) - n09 = create_notification(:group_message_summary, source_user, nil, username_and_something_else(source_user)) - n10 = create_notification(:group_message_summary, walter, nil, username_and_something_else(walter)) - - merge_users! - - expect(notification_data(n01)).to eq(original_and_display_username(target_user)) - expect(notification_data(n02)).to eq(original_and_display_username(walter)) - expect(notification_data(n03)).to eq(original_username_and_some_text_as_display_username(target_user)) - expect(notification_data(n04)).to eq(only_display_username(target_user)) - expect(notification_data(n05)).to eq(only_display_username(target_user)) - expect(notification_data(n06)).to eq(only_display_username(walter)) - expect(notification_data(n07)).to eq(username_and_something_else(target_user)) - expect(notification_data(n08)).to eq(username_and_something_else(walter)) - expect(notification_data(n09)).to eq(username_and_something_else(target_user)) - expect(notification_data(n10)).to eq(username_and_something_else(walter)) - end end context "post actions" do @@ -1068,4 +1005,16 @@ describe UserMerger do expect(User.find_by_username(source_user.username)).to be_nil end + + it "updates the username" do + Jobs::UpdateUsername.any_instance + .expects(:execute) + .with(user_id: source_user.id, + old_username: 'alice1', + new_username: 'alice', + avatar_template: target_user.avatar_template) + .once + + merge_users! + end end diff --git a/spec/services/username_changer_spec.rb b/spec/services/username_changer_spec.rb index dad6e4d158..01d788bc68 100644 --- a/spec/services/username_changer_spec.rb +++ b/spec/services/username_changer_spec.rb @@ -95,16 +95,24 @@ describe UsernameChanger do let(:user) { Fabricate(:user, username: 'foo') } let(:topic) { Fabricate(:topic, user: user) } - before { UserActionCreator.enable } - after { UserActionCreator.disable } + before do + UserActionCreator.enable + Discourse.expects(:warn_exception).never + end - def create_post_and_change_username(args = {}) + after do + UserActionCreator.disable + end + + def create_post_and_change_username(args = {}, &block) post = create_post(args.merge(topic_id: topic.id)) args.delete(:revisions)&.each do |revision| post.revise(post.user, revision, force_new_version: true) end + block.call(post) if block + UsernameChanger.change(user, 'bar') post.reload end @@ -118,6 +126,14 @@ describe UsernameChanger do expect(post.cooked).to eq(%Q(Hello @bar
)) end + it 'removes the username from the search index' do + SearchIndexer.enable + create_post_and_change_username(raw: "Hello @foo") + + results = Search.execute('foo', min_search_term_length: 1) + expect(results.posts).to be_empty + end + it 'ignores case when replacing mentions' do post = create_post_and_change_username(raw: "There's no difference between @foo and @Foo") @@ -208,37 +224,58 @@ describe UsernameChanger do end it 'replaces mentions within revisions' do - revisions = [{ raw: "Hello Foo" }, { raw: "Hello @foo!" }, { raw: "Hello @foo!!" }] + revisions = [{ raw: "Hello Foo" }, { title: "new topic title" }, { raw: "Hello @foo!" }, { raw: "Hello @foo!!" }] post = create_post_and_change_username(raw: "Hello @foo", revisions: revisions) expect(post.raw).to eq("Hello @bar!!") expect(post.cooked).to eq(%Q(Hello @bar!!
)) - expect(post.revisions.count).to eq(3) + expect(post.revisions.count).to eq(4) expect(post.revisions[0].modifications["raw"][0]).to eq("Hello @bar") expect(post.revisions[0].modifications["raw"][1]).to eq("Hello Foo") expect(post.revisions[0].modifications["cooked"][0]).to eq(%Q(Hello @bar
)) expect(post.revisions[0].modifications["cooked"][1]).to eq(%Q(Hello Foo
)) - expect(post.revisions[1].modifications["raw"][0]).to eq("Hello Foo") - expect(post.revisions[1].modifications["raw"][1]).to eq("Hello @bar!") - expect(post.revisions[1].modifications["cooked"][0]).to eq(%Q(Hello Foo
)) - expect(post.revisions[1].modifications["cooked"][1]).to eq(%Q(Hello @bar!
)) + expect(post.revisions[1].modifications).to include("title") - expect(post.revisions[2].modifications["raw"][0]).to eq("Hello @bar!") - expect(post.revisions[2].modifications["raw"][1]).to eq("Hello @bar!!") - expect(post.revisions[2].modifications["cooked"][0]).to eq(%Q(Hello @bar!
)) - expect(post.revisions[2].modifications["cooked"][1]).to eq(%Q(Hello @bar!!
)) + expect(post.revisions[2].modifications["raw"][0]).to eq("Hello Foo") + expect(post.revisions[2].modifications["raw"][1]).to eq("Hello @bar!") + expect(post.revisions[2].modifications["cooked"][0]).to eq(%Q(Hello Foo
)) + expect(post.revisions[2].modifications["cooked"][1]).to eq(%Q(Hello @bar!
)) + + expect(post.revisions[3].modifications["raw"][0]).to eq("Hello @bar!") + expect(post.revisions[3].modifications["raw"][1]).to eq("Hello @bar!!") + expect(post.revisions[3].modifications["cooked"][0]).to eq(%Q(Hello @bar!
)) + expect(post.revisions[3].modifications["cooked"][1]).to eq(%Q(Hello @bar!!
)) + end + + it 'replaces mentions in posts marked for deletion' do + post = create_post_and_change_username(raw: "Hello @foo") do |p| + PostDestroyer.new(p.user, p).destroy + end + + expect(post.raw).to_not include("@foo") + expect(post.cooked).to_not include("foo") + expect(post.revisions.count).to eq(1) + + expect(post.revisions[0].modifications["raw"][0]).to eq("Hello @bar") + expect(post.revisions[0].modifications["cooked"][0]).to eq(%Q(Hello @bar
)) + end + + it 'works when users are mentioned with HTML' do + post = create_post_and_change_username(raw: '@foo and @someuser') + + expect(post.raw).to eq('@bar and @someuser') + expect(post.cooked).to match_html('') end end context 'quotes' do let(:quoted_post) { create_post(user: user, topic: topic, post_number: 1, raw: "quoted post") } + let(:avatar_url) { user.avatar_template.gsub("{size}", "40") } - it 'replaces the username in quote tags' do - avatar_url = user.avatar_template_url.gsub("{size}", "40") - + it 'replaces the username in quote tags and updates avatar' do post = create_post_and_change_username(raw: <<~RAW) Lorem ipsum @@ -280,7 +317,7 @@ describe UsernameChanger do@@ -288,7 +325,7 @@ describe UsernameChanger doquoted post
@@ -296,7 +333,7 @@ describe UsernameChanger doquoted post
User mention: @discourse
+Group mention: @discourse-group
+Category link: #foo
+Sub-category link: #foo:bar
+ `; + + const markdown = `User mention: @discourse\n\nGroup mention: @discourse-group\n\nCategory link: #foo\n\nSub-category link: #foo:bar`; + + assert.equal(toMarkdown(html), markdown); +}); diff --git a/test/javascripts/models/report-test.js.es6 b/test/javascripts/models/report-test.js.es6 index 1e4f73d89e..e9661eadb6 100644 --- a/test/javascripts/models/report-test.js.es6 +++ b/test/javascripts/models/report-test.js.es6 @@ -60,4 +60,4 @@ QUnit.test("thirtyDayCountTitle", assert => { assert.ok(title.indexOf('+50%') !== -1); assert.ok(title.match(/Was 10/)); -}); \ No newline at end of file +});