diff --git a/.github/labeler.yml b/.github/labeler.yml index 9c8a33b6e61..a37a4a806d4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -102,6 +102,10 @@ discourse-hcaptcha: - changed-files: - any-glob-to-any-file: plugins/discourse-hcaptcha/**/* +discourse-gamification: + - changed-files: + - any-glob-to-any-file: plugins/discourse-gamification/**/* + footnote: - changed-files: - any-glob-to-any-file: plugins/footnote/**/* diff --git a/.gitignore b/.gitignore index 69b6f4db3ac..fda4321d2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ !/plugins/discourse-assign !/plugins/discourse-subscriptions !/plugins/discourse-hcaptcha +!/plugins/discourse-gamification /plugins/*/auto_generated /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/plugins/discourse-gamification/README.md b/plugins/discourse-gamification/README.md new file mode 100644 index 00000000000..003e34e2806 --- /dev/null +++ b/plugins/discourse-gamification/README.md @@ -0,0 +1,10 @@ +# **Discourse Gamification** Plugin + +# User Card +Screen Shot 2022-03-18 at 9 33 24 AM + +# User Metadata +Screen Shot 2022-03-18 at 10 48 25 AM + +# Directory +Screen Shot 2022-03-18 at 10 48 54 AM diff --git a/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-create-leaderboard.gjs b/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-create-leaderboard.gjs new file mode 100644 index 00000000000..ee04f978d37 --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-create-leaderboard.gjs @@ -0,0 +1,95 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { readOnly } from "@ember/object/computed"; +import { service } from "@ember/service"; +import Form from "discourse/components/form"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { i18n } from "discourse-i18n"; + +export default class AdminCreateLeaderboard extends Component { + @service currentUser; + @service router; + @service toasts; + + @tracked newLeaderboardName = ""; + @tracked loading = false; + + @readOnly("newLeaderboardName") nameValid; + + get formData() { + return { name: "", created_by_id: this.currentUser.id }; + } + + @action + async createNewLeaderboard(data) { + if (this.loading) { + return; + } + + this.loading = true; + + try { + const leaderboard = await ajax( + "/admin/plugins/gamification/leaderboard", + { + data, + type: "POST", + } + ); + this.toasts.success({ + duration: 3000, + data: { + message: i18n("gamification.leaderboard.create_success"), + }, + }); + this.args.onCancel(); + this.router.transitionTo( + "adminPlugins.show.discourse-gamification-leaderboards.show", + leaderboard.id + ); + } catch (err) { + popupAjaxError(err); + } finally { + this.loading = false; + } + } + + +} diff --git a/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-edit-leaderboard.gjs b/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-edit-leaderboard.gjs new file mode 100644 index 00000000000..3ead173905b --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/admin/components/admin-edit-leaderboard.gjs @@ -0,0 +1,174 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import BackButton from "discourse/components/back-button"; +import Form from "discourse/components/form"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { AUTO_GROUPS } from "discourse/lib/constants"; +import { i18n } from "discourse-i18n"; +import GroupChooser from "select-kit/components/group-chooser"; +import PeriodInput from "discourse/plugins/discourse-gamification/discourse/components/period-input"; + +export default class AdminEditLeaderboard extends Component { + @service currentUser; + @service site; + @service toasts; + @service router; + + get siteGroups() { + return this.site.groups.rejectBy("id", AUTO_GROUPS.everyone.id); + } + + get formData() { + return { + name: this.args.leaderboard.name, + from_date: this.args.leaderboard.fromDate, + to_date: this.args.leaderboard.toDate, + included_groups_ids: this.args.leaderboard.includedGroupsIds, + excluded_groups_ids: this.args.leaderboard.excludedGroupsIds, + visible_to_groups_ids: this.args.leaderboard.visibleToGroupsIds, + default_period: this.args.leaderboard.defaultPeriod, + period_filter_disabled: this.args.leaderboard.periodFilterDisabled, + }; + } + + @action + async save(data) { + try { + await ajax( + `/admin/plugins/gamification/leaderboard/${this.args.leaderboard.id}`, + { + data, + type: "PUT", + } + ); + this.toasts.success({ + duration: 3000, + data: { + message: i18n("gamification.leaderboard.save_success"), + }, + }); + await this.router.transitionTo( + "adminPlugins.show.discourse-gamification-leaderboards.index" + ); + + // To refresh the list of leaderboards in the index. + this.router.refresh(); + } catch (err) { + popupAjaxError(err); + } + } + + +} diff --git a/plugins/discourse-gamification/admin/assets/javascripts/discourse/controllers/admin-plugins-show-discourse-gamification-leaderboards-index.js b/plugins/discourse-gamification/admin/assets/javascripts/discourse/controllers/admin-plugins-show-discourse-gamification-leaderboards-index.js new file mode 100644 index 00000000000..143f85c0478 --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/discourse/controllers/admin-plugins-show-discourse-gamification-leaderboards-index.js @@ -0,0 +1,65 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseComputed from "discourse/lib/decorators"; +import { i18n } from "discourse-i18n"; +import RecalculateScoresForm from "discourse/plugins/discourse-gamification/discourse/components/modal/recalculate-scores-form"; + +export default class AdminPluginsShowDiscourseGamificationLeaderboardsIndexController extends Controller { + @service modal; + @service dialog; + @service toasts; + + creatingNew = false; + + @discourseComputed("model.leaderboards.@each.updatedAt") + sortedLeaderboards(leaderboards) { + return leaderboards?.sortBy("updatedAt").reverse() || []; + } + + @action + resetNewLeaderboard() { + this.set("creatingNew", false); + } + + @action + destroyLeaderboard(leaderboard) { + this.dialog.deleteConfirm({ + message: i18n("gamification.leaderboard.confirm_destroy"), + didConfirm: () => { + return ajax( + `/admin/plugins/gamification/leaderboard/${leaderboard.id}`, + { + type: "DELETE", + } + ) + .then(() => { + this.toasts.success({ + duration: 3000, + data: { + message: i18n("gamification.leaderboard.delete_success"), + }, + }); + this.model.leaderboards.removeObject(leaderboard); + }) + .catch(popupAjaxError); + }, + }); + } + + @action + recalculateScores() { + this.modal.show(RecalculateScoresForm, { + model: this.model, + }); + } + + parseDate(date) { + if (date) { + // using the format YYYY-MM-DD returns the previous day for some timezones + return date.replace(/-/g, "/"); + } + } +} diff --git a/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards-show.js b/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards-show.js new file mode 100644 index 00000000000..ce1fded24a2 --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards-show.js @@ -0,0 +1,24 @@ +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import DiscourseRoute from "discourse/routes/discourse"; +import GamificationLeaderboard from "discourse/plugins/discourse-gamification/discourse/models/gamification-leaderboard"; + +export default class DiscourseGamificationLeaderboardShow extends DiscourseRoute { + @service adminPluginNavManager; + + model(params) { + const leaderboardsData = this.modelFor( + "adminPlugins.show.discourse-gamification-leaderboards" + ); + const id = parseInt(params.id, 10); + + const leaderboard = leaderboardsData.leaderboards.findBy("id", id); + if (leaderboard) { + return leaderboard; + } + + return ajax( + `/admin/plugins/discourse-gamification/leaderboards/${id}` + ).then((response) => GamificationLeaderboard.create(response.leaderboard)); + } +} diff --git a/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards.js b/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards.js new file mode 100644 index 00000000000..15d71193911 --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/discourse/routes/admin-plugins-show-discourse-gamification-leaderboards.js @@ -0,0 +1,24 @@ +import EmberObject from "@ember/object"; +import { service } from "@ember/service"; +import DiscourseRoute from "discourse/routes/discourse"; +import GamificationLeaderboard from "discourse/plugins/discourse-gamification/discourse/models/gamification-leaderboard"; + +export default class DiscourseGamificationLeaderboards extends DiscourseRoute { + @service adminPluginNavManager; + + model() { + if (!this.currentUser?.admin) { + return { model: null }; + } + const gamificationPlugin = this.adminPluginNavManager.currentPlugin; + + return EmberObject.create({ + leaderboards: gamificationPlugin.extras.gamification_leaderboards.map( + (leaderboard) => GamificationLeaderboard.create(leaderboard) + ), + groups: gamificationPlugin.extras.gamification_groups, + recalculate_scores_remaining: + gamificationPlugin.extras.gamification_recalculate_scores_remaining, + }); + } +} diff --git a/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/index.gjs b/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/index.gjs new file mode 100644 index 00000000000..302cefcce5a --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/index.gjs @@ -0,0 +1,112 @@ +import { concat, fn } from "@ember/helper"; +import { LinkTo } from "@ember/routing"; +import RouteTemplate from "ember-route-template"; +import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item"; +import DButton from "discourse/components/d-button"; +import DPageSubheader from "discourse/components/d-page-subheader"; +import formatDate from "discourse/helpers/format-date"; +import { i18n } from "discourse-i18n"; +import AdminCreateLeaderboard from "discourse/plugins/discourse-gamification/admin/components/admin-create-leaderboard"; + +export default RouteTemplate( + +); diff --git a/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/show.gjs b/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/show.gjs new file mode 100644 index 00000000000..1e8c219b92b --- /dev/null +++ b/plugins/discourse-gamification/admin/assets/javascripts/discourse/templates/admin-plugins/show/discourse-gamification-leaderboards/show.gjs @@ -0,0 +1,10 @@ +import RouteTemplate from "ember-route-template"; +import AdminEditLeaderboard from "discourse/plugins/discourse-gamification/admin/components/admin-edit-leaderboard"; + +export default RouteTemplate( + +); diff --git a/plugins/discourse-gamification/app/.gitkeep b/plugins/discourse-gamification/app/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_leaderboard_controller.rb b/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_leaderboard_controller.rb new file mode 100644 index 00000000000..bf8296907a9 --- /dev/null +++ b/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_leaderboard_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class DiscourseGamification::AdminGamificationLeaderboardController < Admin::AdminController + requires_plugin DiscourseGamification::PLUGIN_NAME + + def index + render_serialized( + { leaderboards: DiscourseGamification::GamificationLeaderboard.all }, + AdminGamificationIndexSerializer, + root: false, + ) + end + + def show + render json: + LeaderboardSerializer.new( + DiscourseGamification::GamificationLeaderboard.find(params[:id]), + ) + end + + def create + params.require(%i[name created_by_id]) + + leaderboard = + DiscourseGamification::GamificationLeaderboard.new( + name: params[:name], + created_by_id: params[:created_by_id], + ) + if leaderboard.save + Jobs.enqueue(Jobs::GenerateLeaderboardPositions, leaderboard_id: leaderboard.id) + + render_serialized(leaderboard, LeaderboardSerializer, root: false) + else + render_json_error(leaderboard) + end + end + + def update + params.require(%i[id name]) + + leaderboard = DiscourseGamification::GamificationLeaderboard.find(params[:id]) + raise Discourse::NotFound unless leaderboard + + leaderboard.update( + name: params[:name], + to_date: params[:to_date], + from_date: params[:from_date], + included_groups_ids: params[:included_groups_ids] || [], + excluded_groups_ids: params[:excluded_groups_ids] || [], + visible_to_groups_ids: params[:visible_to_groups_ids] || [], + default_period: params[:default_period], + period_filter_disabled: params[:period_filter_disabled] || false, + ) + + if leaderboard.save + # TODO(selase): Only refresh on specific attribute changes + Jobs.enqueue(Jobs::RefreshLeaderboardPositions, leaderboard_id: leaderboard.id) + + render json: success_json + else + render_json_error(leaderboard) + end + end + + def destroy + params.require(:id) + + leaderboard = DiscourseGamification::GamificationLeaderboard.find(params[:id]) + + if leaderboard && leaderboard.destroy + Jobs.enqueue(Jobs::DeleteLeaderboardPositions, leaderboard_id: leaderboard.id) + end + + render json: success_json + end + + def recalculate_scores + DiscourseGamification::RecalculateScoresRateLimiter.perform! + + since = + begin + Date.parse(params[:from_date]).midnight + rescue StandardError + raise Discourse::InvalidParameters.new(:from_date) + end + + raise Discourse::InvalidParameters.new(:from_date) if since > Time.now + + Jobs.enqueue(Jobs::RecalculateScores, since: since, user_id: current_user.id) + + render json: success_json + end +end diff --git a/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_score_event_controller.rb b/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_score_event_controller.rb new file mode 100644 index 00000000000..372496d0f3f --- /dev/null +++ b/plugins/discourse-gamification/app/controllers/discourse_gamification/admin_gamification_score_event_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class DiscourseGamification::AdminGamificationScoreEventController < Admin::AdminController + requires_plugin DiscourseGamification::PLUGIN_NAME + + def show + params.permit(%i[id user_id date]) + + events = DiscourseGamification::GamificationScoreEvent.limit(100) + events = events.where(id: params[:id]) if params[:id] + events = events.where(user_id: params[:user_id]) if params[:user_id] + events = events.where(date: params[:date]) if params[:date] + + raise Discourse::NotFound unless events + + render_serialized({ events: events }, AdminGamificationScoreEventIndexSerializer, root: false) + end + + def create + params.require(%i[user_id date points]) + params.permit(:description) + + event = + DiscourseGamification::GamificationScoreEvent.new( + user_id: params[:user_id], + date: params[:date], + points: params[:points], + description: params[:description], + ) + + if event.save + render_serialized(event, AdminGamificationScoreEventSerializer, root: false) + else + render_json_error(event) + end + end + + def update + params.require(%i[id points]) + params.permit(:description) + + event = DiscourseGamification::GamificationScoreEvent.find(params[:id]) + raise Discourse::NotFound unless event + + event.update(points: params[:points], description: params[:description] || event.description) + + if event.save + render json: success_json + else + render_json_error(event) + end + end +end diff --git a/plugins/discourse-gamification/app/controllers/discourse_gamification/gamification_leaderboard_controller.rb b/plugins/discourse-gamification/app/controllers/discourse_gamification/gamification_leaderboard_controller.rb new file mode 100644 index 00000000000..eb9ea6406f2 --- /dev/null +++ b/plugins/discourse-gamification/app/controllers/discourse_gamification/gamification_leaderboard_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class GamificationLeaderboardController < ::ApplicationController + requires_plugin PLUGIN_NAME + + def respond + discourse_expires_in 1.minute + + default_leaderboard_id = GamificationLeaderboard.first.id + params[:id] ||= default_leaderboard_id + leaderboard = GamificationLeaderboard.find(params[:id]) + + period_param = params[:period] == "all" ? "all_time" : params[:period] + + raise Discourse::NotFound unless @guardian.can_see_leaderboard?(leaderboard) + + render_serialized( + { + leaderboard: leaderboard, + page: params[:page].to_i, + for_user_id: current_user&.id, + period: leaderboard.resolve_period(period_param), + user_limit: params[:user_limit]&.to_i, + }, + LeaderboardViewSerializer, + root: false, + ) + rescue LeaderboardCachedView::NotReadyError => e + Jobs.enqueue(Jobs::GenerateLeaderboardPositions, leaderboard_id: leaderboard.id) + + render json: + LeaderboardSerializer + .new(leaderboard) + .as_json + .merge({ users: [], reason: e.message }), + status: 202 + end + end +end diff --git a/plugins/discourse-gamification/app/models/discourse_gamification/deleted_gamification_leaderboard.rb b/plugins/discourse-gamification/app/models/discourse_gamification/deleted_gamification_leaderboard.rb new file mode 100644 index 00000000000..e4ca5b5a503 --- /dev/null +++ b/plugins/discourse-gamification/app/models/discourse_gamification/deleted_gamification_leaderboard.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class DeletedGamificationLeaderboard + attr_reader :id + + def initialize(id) + @id = id + end + end +end diff --git a/plugins/discourse-gamification/app/models/discourse_gamification/gamification_leaderboard.rb b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_leaderboard.rb new file mode 100644 index 00000000000..8e901718b5a --- /dev/null +++ b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_leaderboard.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class GamificationLeaderboard < ::ActiveRecord::Base + PAGE_SIZE = 100 + + self.table_name = "gamification_leaderboards" + + validates :name, exclusion: { in: %w[new], message: "%{value} is reserved." } + + attribute :period, :integer + enum :period, { all_time: 0, yearly: 1, quarterly: 2, monthly: 3, weekly: 4, daily: 5 } + + def resolve_period(given_period) + return given_period if self.class.periods.key?(given_period) + + self.class.periods.key(default_period) || "all_time" + end + + def self.find_position_by(leaderboard_id:, for_user_id:, period: nil) + self.scores_for(leaderboard_id, for_user_id: for_user_id, period: period).first + end + + def self.scores_for(leaderboard_id, page: 0, for_user_id: false, period: nil, user_limit: nil) + offset = PAGE_SIZE * page + limit = user_limit || PAGE_SIZE + period = period || "all_time" + + leaderboard = self.find(leaderboard_id) + + return [] unless leaderboard + + LeaderboardCachedView.new(leaderboard).scores( + page: page, + for_user_id: for_user_id, + period: period, + limit: limit, + offset: offset, + ) + end + end +end + +# == Schema Information +# +# Table name: gamification_leaderboards +# +# id :bigint not null, primary key +# name :string not null +# from_date :date +# to_date :date +# for_category_id :integer +# created_by_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# visible_to_groups_ids :integer default([]), not null, is an Array +# included_groups_ids :integer default([]), not null, is an Array +# excluded_groups_ids :integer default([]), not null, is an Array +# default_period :integer default(0) +# period_filter_disabled :boolean default(FALSE), not null +# +# Indexes +# +# index_gamification_leaderboards_on_name (name) UNIQUE +# diff --git a/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score.rb b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score.rb new file mode 100644 index 00000000000..693bcec2f04 --- /dev/null +++ b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class GamificationScore < ::ActiveRecord::Base + self.table_name = "gamification_scores" + + belongs_to :user + + def self.enabled_scorables + Scorable.subclasses.filter { _1.enabled? } + end + + def self.scorables_queries + enabled_scorables.map { "( #{_1.query} )" }.join(" UNION ALL ") + end + + def self.calculate_scores(since_date: Date.today, only_subclass: nil) + queries = only_subclass&.query || scorables_queries + + DB.exec(<<~SQL, since: since_date) + DELETE FROM gamification_scores + WHERE date >= :since; + + INSERT INTO gamification_scores (user_id, date, score) + SELECT user_id, date, SUM(points) AS score + FROM ( + #{queries} + UNION ALL + SELECT user_id, date, SUM(points) AS points + FROM gamification_score_events + WHERE date >= :since + GROUP BY 1, 2 + ) AS source + WHERE user_id IS NOT NULL + GROUP BY 1, 2 + ON CONFLICT (user_id, date) DO UPDATE + SET score = EXCLUDED.score; + SQL + end + + def self.merge_scores(source_user, target_user) + DB.exec(<<~SQL, source_id: source_user.id, target_id: target_user.id) + WITH new_scores AS ( + SELECT :target_id AS user_id, date, SUM(score) AS score + FROM gamification_scores + WHERE user_id IN (:source_id, :target_id) + GROUP BY 1, 2 + ) INSERT INTO gamification_scores (user_id, date, score) + SELECT user_id, date, score AS score + FROM new_scores + ON CONFLICT (user_id, date) DO UPDATE + SET score = EXCLUDED.score; + SQL + + DB.exec(<<~SQL, source_id: source_user.id) + DELETE FROM gamification_scores + WHERE user_id = :source_id; + SQL + end + end +end + +# == Schema Information +# +# Table name: gamification_scores +# +# id :bigint not null, primary key +# user_id :integer not null +# date :date not null +# score :integer not null +# +# Indexes +# +# index_gamification_scores_on_date (date) +# index_gamification_scores_on_user_id_and_date (user_id,date) UNIQUE +# diff --git a/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score_event.rb b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score_event.rb new file mode 100644 index 00000000000..3bd0d2d5ee9 --- /dev/null +++ b/plugins/discourse-gamification/app/models/discourse_gamification/gamification_score_event.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class GamificationScoreEvent < ::ActiveRecord::Base + self.table_name = "gamification_score_events" + + belongs_to :user + end +end + +# == Schema Information +# +# Table name: gamification_score_events +# +# id :bigint not null, primary key +# user_id :integer not null +# date :date not null +# points :integer not null +# description :text +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_gamification_score_events_on_date (date) +# index_gamification_score_events_on_user_id_and_date (user_id,date) +# diff --git a/plugins/discourse-gamification/app/serializers/admin_gamification_index_serializer.rb b/plugins/discourse-gamification/app/serializers/admin_gamification_index_serializer.rb new file mode 100644 index 00000000000..08d57a29153 --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/admin_gamification_index_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AdminGamificationIndexSerializer < ApplicationSerializer + attribute :gamification_recalculate_scores_remaining + has_many :gamification_leaderboards, serializer: LeaderboardSerializer, embed: :objects + has_many :gamification_groups, serializer: BasicGroupSerializer, embed: :object + + def gamification_leaderboards + object[:leaderboards] + end + + def gamification_groups + Group.all + end + + def gamification_recalculate_scores_remaining + DiscourseGamification::RecalculateScoresRateLimiter.remaining + end +end diff --git a/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_index_serializer.rb b/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_index_serializer.rb new file mode 100644 index 00000000000..3d50b26cb1d --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_index_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AdminGamificationScoreEventIndexSerializer < ApplicationSerializer + has_many :events, serializer: AdminGamificationScoreEventSerializer, embed: :objects + + def events + object[:events] + end +end diff --git a/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_serializer.rb b/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_serializer.rb new file mode 100644 index 00000000000..6fc81553fef --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/admin_gamification_score_event_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class AdminGamificationScoreEventSerializer < ApplicationSerializer + attributes :id, :user_id, :date, :points, :description, :created_at, :updated_at +end diff --git a/plugins/discourse-gamification/app/serializers/leaderboard_serializer.rb b/plugins/discourse-gamification/app/serializers/leaderboard_serializer.rb new file mode 100644 index 00000000000..71d9db7ecf9 --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/leaderboard_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class LeaderboardSerializer < ApplicationSerializer + attributes :id, + :name, + :created_by_id, + :from_date, + :to_date, + :visible_to_groups_ids, + :included_groups_ids, + :excluded_groups_ids, + :default_period, + :updated_at, + :period_filter_disabled +end diff --git a/plugins/discourse-gamification/app/serializers/leaderboard_view_serializer.rb b/plugins/discourse-gamification/app/serializers/leaderboard_view_serializer.rb new file mode 100644 index 00000000000..bd095e2a9e1 --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/leaderboard_view_serializer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class LeaderboardViewSerializer < ApplicationSerializer + attributes :personal + + has_one :leaderboard, serializer: LeaderboardSerializer, embed: :objects + has_many :users, serializer: UserScoreSerializer, embed: :objects + + def leaderboard + object[:leaderboard] + end + + def users + DiscourseGamification::GamificationLeaderboard.scores_for( + object[:leaderboard].id, + page: object[:page], + period: object[:period], + user_limit: object[:user_limit], + ) + end + + def personal + return {} if object[:for_user_id].blank? + + user_score = + DiscourseGamification::GamificationLeaderboard.scores_for( + object[:leaderboard].id, + for_user_id: object[:for_user_id], + period: object[:period], + ).take + + { user: UserScoreSerializer.new(user_score, root: false), position: user_score.try(:position) } + end +end diff --git a/plugins/discourse-gamification/app/serializers/user_score_serializer.rb b/plugins/discourse-gamification/app/serializers/user_score_serializer.rb new file mode 100644 index 00000000000..241b6b167f9 --- /dev/null +++ b/plugins/discourse-gamification/app/serializers/user_score_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UserScoreSerializer < BasicUserSerializer + attributes :total_score, :position +end diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/admin-discourse-gamification-plugin-route-map.js b/plugins/discourse-gamification/assets/javascripts/discourse/admin-discourse-gamification-plugin-route-map.js new file mode 100644 index 00000000000..e42e0656838 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/admin-discourse-gamification-plugin-route-map.js @@ -0,0 +1,15 @@ +export default { + resource: "admin.adminPlugins.show", + + path: "/plugins", + + map() { + this.route( + "discourse-gamification-leaderboards", + { path: "leaderboards" }, + function () { + this.route("show", { path: "/:id" }); + } + ); + }, +}; diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard-row.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard-row.gjs new file mode 100644 index 00000000000..3741bd0348c --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard-row.gjs @@ -0,0 +1,41 @@ +import Component from "@ember/component"; +import { tagName } from "@ember-decorators/component"; +import { or } from "truth-helpers"; +import avatar from "discourse/helpers/avatar"; +import number from "discourse/helpers/number"; +import fullnumber from "../helpers/fullnumber"; + +@tagName("") +export default class GamificationLeaderboardRow extends Component { + rank = null; + + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard.gjs new file mode 100644 index 00000000000..e31cab70dc5 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-leaderboard.gjs @@ -0,0 +1,249 @@ +import Component from "@ember/component"; +import { hash } from "@ember/helper"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { tagName } from "@ember-decorators/component"; +import { or } from "truth-helpers"; +import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; +import DButton from "discourse/components/d-button"; +import LoadMore from "discourse/components/load-more"; +import avatar from "discourse/helpers/avatar"; +import icon from "discourse/helpers/d-icon"; +import number from "discourse/helpers/number"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import discourseComputed from "discourse/lib/decorators"; +import { i18n } from "discourse-i18n"; +import PeriodChooser from "select-kit/components/period-chooser"; +import fullnumber from "../helpers/fullnumber"; +import GamificationLeaderboardRow from "./gamification-leaderboard-row"; +import LeaderboardInfo from "./modal/leaderboard-info"; + +export const LEADERBOARD_PERIODS = [ + "all_time", + "yearly", + "quarterly", + "monthly", + "weekly", + "daily", +]; +function periodString(periodValue) { + switch (periodValue) { + case 0: + return "all"; + case 1: + return "yearly"; + case 2: + return "quarterly"; + case 3: + return "monthly"; + case 4: + return "weekly"; + case 5: + return "daily"; + default: + return "all"; + } +} + +@tagName("") +export default class GamificationLeaderboard extends Component { + @service router; + @service modal; + + eyelineSelector = ".user"; + page = 1; + loading = false; + canLoadMore = true; + period = "all"; + + init() { + super.init(...arguments); + const default_leaderboard_period = periodString( + this.model.leaderboard.default_period + ); + this.set("period", default_leaderboard_period); + } + + @discourseComputed("model.reason") + isNotReady(reason) { + return reason !== undefined; + } + + @discourseComputed("model.users") + currentUserRanking() { + const user = this.model.personal; + return user || null; + } + + @discourseComputed("model.users") + winners(users) { + return users.slice(0, 3); + } + + @discourseComputed("model.users.[]") + ranking(users) { + users.forEach((user) => { + if (user.id === this.currentUser?.id) { + user.isCurrentUser = "true"; + } + }); + return users.slice(3); + } + + @action + showLeaderboardInfo() { + this.modal.show(LeaderboardInfo); + } + + @action + loadMore() { + if (this.loading || !this.canLoadMore) { + return; + } + + this.set("loading", true); + + return ajax( + `/leaderboard/${this.model.leaderboard.id}?page=${this.page}&period=${this.period}` + ) + .then((result) => { + if (result.users.length === 0) { + this.set("canLoadMore", false); + } + this.set("page", (this.page += 1)); + this.set("model.users", this.model.users.concat(result.users)); + }) + .finally(() => this.set("loading", false)) + .catch(popupAjaxError); + } + + @action + changePeriod(period) { + this.set("period", period); + return ajax( + `/leaderboard/${this.model.leaderboard.id}?period=${this.period}` + ) + .then((result) => { + if (result.users.length === 0) { + this.set("canLoadMore", false); + this.set("model.reason", result.reason); + } + this.set("page", 1); + this.set("model.users", result.users); + this.set("model.personal", result.personal); + }) + .finally(() => this.set("loading", false)) + .catch(popupAjaxError); + } + + @action + refresh() { + this.router.refresh(); + } + + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-score.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-score.gjs new file mode 100644 index 00000000000..23a3d340e2c --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/gamification-score.gjs @@ -0,0 +1,22 @@ +import Component from "@ember/component"; +import { LinkTo } from "@ember/routing"; +import { classNames, tagName } from "@ember-decorators/component"; +import fullnumber from "../helpers/fullnumber"; + +@tagName("span") +@classNames("gamification-score") +export default class GamificationScore extends Component { + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard-row.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard-row.gjs new file mode 100644 index 00000000000..0b8d8eb115c --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard-row.gjs @@ -0,0 +1,51 @@ +import Component from "@glimmer/component"; +import { service } from "@ember/service"; +import { or } from "truth-helpers"; +import avatar from "discourse/helpers/avatar"; +import concatClass from "discourse/helpers/concat-class"; +import icon from "discourse/helpers/d-icon"; +import number from "discourse/helpers/number"; +import { i18n } from "discourse-i18n"; +import sum from "../helpers/sum"; + +export default class MinimalGamificationLeaderboardRow extends Component { + @service siteSettings; + + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard.gjs new file mode 100644 index 00000000000..0f475110225 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/minimal-gamification-leaderboard.gjs @@ -0,0 +1,83 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { LinkTo } from "@ember/routing"; +import { service } from "@ember/service"; +import icon from "discourse/helpers/d-icon"; +import number from "discourse/helpers/number"; +import { ajax } from "discourse/lib/ajax"; +import { i18n } from "discourse-i18n"; +import fullnumber from "../helpers/fullnumber"; +import MinimalGamificationLeaderboardRow from "./minimal-gamification-leaderboard-row"; + +export default class extends Component { + @service site; + + @tracked model; + + constructor() { + super(...arguments); + + // id is used by discourse-right-sidebar-blocks theme component + const endpoint = this.args.id + ? `/leaderboard/${this.args.id}` + : "/leaderboard"; + + ajax(endpoint, { data: { user_limit: this.args.count || 10 } }).then( + (model) => { + for (const user of model.users) { + if (user.id === model.personal?.user?.id) { + user.isCurrentUser = "true"; + } + } + + if (model.users[0]) { + model.users[0].topRanked = true; + } + + this.model = model; + } + ); + } + + get notTop10() { + return this.model?.personal?.position > 10; + } + + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/leaderboard-info.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/leaderboard-info.gjs new file mode 100644 index 00000000000..5ababb1588c --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/leaderboard-info.gjs @@ -0,0 +1,19 @@ +import { htmlSafe } from "@ember/template"; +import DModal from "discourse/components/d-modal"; +import icon from "discourse/helpers/d-icon"; +import { i18n } from "discourse-i18n"; + +const LeaderboardInfo = ; + +export default LeaderboardInfo; diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/recalculate-scores-form.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/recalculate-scores-form.gjs new file mode 100644 index 00000000000..cc36c0cd2af --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/modal/recalculate-scores-form.gjs @@ -0,0 +1,207 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { eq } from "truth-helpers"; +import DButton from "discourse/components/d-button"; +import DModal from "discourse/components/d-modal"; +import DatePickerPast from "discourse/components/date-picker-past"; +import icon from "discourse/helpers/d-icon"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { bind } from "discourse/lib/decorators"; +import { i18n } from "discourse-i18n"; +import ComboBox from "select-kit/components/combo-box"; + +export default class RecalculateScoresForm extends Component { + @service messageBus; + + @tracked updateRangeValue = 0; + @tracked recalculateFromDate = ""; + @tracked haveAvailability = this.args.model.recalculate_scores_remaining > 0; + @tracked remaining = this.args.model.recalculate_scores_remaining; + @tracked status = "initial"; + + updateRange = [ + { + name: i18n("gamification.update_range.last_10_days"), + value: 0, + calculation: { count: 10, type: "days" }, + }, + { + name: i18n("gamification.update_range.last_30_days"), + value: 1, + calculation: { count: 30, type: "days" }, + }, + { + name: i18n("gamification.update_range.last_90_days"), + value: 2, + calculation: { count: 90, type: "days" }, + }, + { + name: i18n("gamification.update_range.last_year"), + value: 3, + calculation: { count: 1, type: "year" }, + }, + { name: i18n("gamification.update_range.all_time"), value: 4 }, + { name: i18n("gamification.update_range.custom_date_range"), value: 5 }, + ]; + + constructor() { + super(...arguments); + this.messageBus.subscribe("/recalculate_scores", this.onMessage); + } + + willDestroy() { + super.willDestroy(...arguments); + this.messageBus.unsubscribe("/recalculate_scores", this.onMessage); + } + + @bind + onMessage(message) { + if (message.success) { + this.status = "complete"; + this.args.model.recalculate_scores_remaining = message.remaining; + this.remaining = message.remaining; + } + } + + get remainingText() { + return i18n("gamification.daily_update_scores_availability", { + count: this.remaining, + }); + } + + get applyDisabled() { + if (!this.haveAvailability || this.status !== "initial") { + return true; + } else if ( + this.updateRangeValue === 5 && + this.recalculateFromDate <= moment().locale("en").utc().endOf("day") + ) { + return true; + } else { + return false; + } + } + + get dateRange() { + if (this.updateRangeValue === 4) { + return; + } + + let today = moment().locale("en").utc().endOf("day"); + let pastDate = this.dateRangeToDate(this.updateRangeValue); + return `${pastDate} - ${today.format( + i18n("dates.long_with_year_no_time") + )}`; + } + + @bind + dateRangeToDate(updateRangeValue) { + if (updateRangeValue === 4) { + return "2014-8-26"; + } + + if (updateRangeValue === 5) { + return this.recalculateFromDate; + } + + let today = moment().locale("en").utc().endOf("day"); + let updateRange = this.updateRange.find((obj) => { + return obj.value === updateRangeValue; + }); + let pastDate = today + .clone() + .subtract(updateRange.calculation.count, updateRange.calculation.type); + + return pastDate.format(i18n("dates.long_with_year_no_time")); + } + + @action + apply() { + this.status = "loading"; + const data = { + from_date: this.dateRangeToDate(this.updateRangeValue), + }; + + return ajax(`/admin/plugins/gamification/recalculate-scores.json`, { + data, + type: "PUT", + }).catch(popupAjaxError); + } + + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/components/period-input.js b/plugins/discourse-gamification/assets/javascripts/discourse/components/period-input.js new file mode 100644 index 00000000000..259a6132e15 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/components/period-input.js @@ -0,0 +1,31 @@ +import { computed } from "@ember/object"; +import { classNames } from "@ember-decorators/component"; +import { i18n } from "discourse-i18n"; +import ComboBoxComponent from "select-kit/components/combo-box"; +import { + pluginApiIdentifiers, + selectKitOptions, +} from "select-kit/components/select-kit"; +import { LEADERBOARD_PERIODS } from "discourse/plugins/discourse-gamification/discourse/components/gamification-leaderboard"; + +@selectKitOptions({ + filterable: true, + allowAny: false, +}) +@pluginApiIdentifiers("period-input") +@classNames("period-input", "period-input") +export default class PeriodInput extends ComboBoxComponent { + @computed + get content() { + let periods = []; + + periods = periods.concat( + LEADERBOARD_PERIODS.map((period, index) => ({ + name: i18n(`gamification.leaderboard.period.${period}`), + id: index, + })) + ); + + return periods; + } +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-card-metadata/gamification-score.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-card-metadata/gamification-score.gjs new file mode 100644 index 00000000000..d2b353a9767 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-card-metadata/gamification-score.gjs @@ -0,0 +1,15 @@ +import Component from "@ember/component"; +import { classNames, tagName } from "@ember-decorators/component"; +import { i18n } from "discourse-i18n"; +import GamificationScore from "../../components/gamification-score"; + +@tagName("div") +@classNames("user-card-metadata-outlet", "gamification-score") +export default class GamificationScoreConnector extends Component { + +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-profile-secondary/gamification-score.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-profile-secondary/gamification-score.gjs new file mode 100644 index 00000000000..d14545cdb91 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/connectors/user-profile-secondary/gamification-score.gjs @@ -0,0 +1,17 @@ +import { i18n } from "discourse-i18n"; +import GamificationScore from "../../components/gamification-score"; + +const GamificationScoreConnector = ; + +export default GamificationScoreConnector; diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/gamification-route-map.js b/plugins/discourse-gamification/assets/javascripts/discourse/gamification-route-map.js new file mode 100644 index 00000000000..5674f0cffd5 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/gamification-route-map.js @@ -0,0 +1,5 @@ +export default function () { + this.route("gamificationLeaderboard", { path: "/leaderboard" }, function () { + this.route("byName", { path: "/:leaderboardId" }); + }); +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/helpers/fullnumber.js b/plugins/discourse-gamification/assets/javascripts/discourse/helpers/fullnumber.js new file mode 100644 index 00000000000..640b752bcf4 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/helpers/fullnumber.js @@ -0,0 +1,8 @@ +import Helper from "@ember/component/helper"; +import I18n from "discourse-i18n"; + +export function fullnumber(number) { + return I18n.toNumber(number, { precision: 0 }); +} + +export default Helper.helper(fullnumber); diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/helpers/sum.js b/plugins/discourse-gamification/assets/javascripts/discourse/helpers/sum.js new file mode 100644 index 00000000000..d7017c875c2 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/helpers/sum.js @@ -0,0 +1,7 @@ +import Helper from "@ember/component/helper"; + +export function sum(params) { + return params[0] + params[1]; +} + +export default Helper.helper(sum); diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/models/gamification-leaderboard.js b/plugins/discourse-gamification/assets/javascripts/discourse/models/gamification-leaderboard.js new file mode 100644 index 00000000000..295952ccef9 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/models/gamification-leaderboard.js @@ -0,0 +1,47 @@ +import { tracked } from "@glimmer/tracking"; +import { i18n } from "discourse-i18n"; +import { LEADERBOARD_PERIODS } from "discourse/plugins/discourse-gamification/discourse/components/gamification-leaderboard"; + +export default class GamificationLeaderboard { + static create(args = {}) { + return new GamificationLeaderboard(args); + } + + @tracked id; + @tracked createdAt; + @tracked updatedAt; + @tracked createdById; + @tracked excludedGroupsIds; + @tracked includedGroupsIds; + @tracked visibleToGroupsIds; + @tracked forCategoryId; + @tracked fromDate; + @tracked toDate; + @tracked name; + @tracked period; + @tracked periodFilterDisabled; + + constructor(args = {}) { + this.id = args.id; + this.createdAt = args.created_at; + this.updatedAt = args.updated_at; + this.createdById = args.created_by_id; + this.excludedGroupsIds = args.excluded_groups_ids; + this.includedGroupsIds = args.included_groups_ids; + this.visibleToGroupsIds = args.visible_to_groups_ids; + this.forCategoryId = args.for_category_id; + this.fromDate = args.from_date; + this.toDate = args.to_date; + this.name = args.name; + this.period = args.period; + this.periodFilterDisabled = args.period_filter_disabled; + + if (Number.isInteger(args.default_period)) { + this.defaultPeriod = i18n( + `gamification.leaderboard.period.${ + LEADERBOARD_PERIODS[args.default_period] + }` + ); + } + } +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-by-name.js b/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-by-name.js new file mode 100644 index 00000000000..5ec604aa41b --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-by-name.js @@ -0,0 +1,15 @@ +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import DiscourseRoute from "discourse/routes/discourse"; + +export default class GamificationLeaderboardByName extends DiscourseRoute { + @service router; + + model(params) { + return ajax(`/leaderboard/${params.leaderboardId}`) + .then((response) => { + return response; + }) + .catch(() => this.router.replaceWith("/404")); + } +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-index.js b/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-index.js new file mode 100644 index 00000000000..8ee77da93e8 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/routes/gamification-leaderboard-index.js @@ -0,0 +1,15 @@ +import { service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import DiscourseRoute from "discourse/routes/discourse"; + +export default class GamificationLeaderboardIndex extends DiscourseRoute { + @service router; + + model() { + return ajax(`/leaderboard`) + .then((response) => { + return response; + }) + .catch(() => this.router.replaceWith("/404")); + } +} diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-by-name.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-by-name.gjs new file mode 100644 index 00000000000..6ab11a19da9 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-by-name.gjs @@ -0,0 +1,6 @@ +import RouteTemplate from "ember-route-template"; +import GamificationLeaderboard from "../components/gamification-leaderboard"; + +export default RouteTemplate( + +); diff --git a/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-index.gjs b/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-index.gjs new file mode 100644 index 00000000000..6ab11a19da9 --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/discourse/templates/gamification-leaderboard-index.gjs @@ -0,0 +1,6 @@ +import RouteTemplate from "ember-route-template"; +import GamificationLeaderboard from "../components/gamification-leaderboard"; + +export default RouteTemplate( + +); diff --git a/plugins/discourse-gamification/assets/javascripts/initializers/admin-plugin-configuration-nav.js b/plugins/discourse-gamification/assets/javascripts/initializers/admin-plugin-configuration-nav.js new file mode 100644 index 00000000000..28c3906d3ff --- /dev/null +++ b/plugins/discourse-gamification/assets/javascripts/initializers/admin-plugin-configuration-nav.js @@ -0,0 +1,21 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; + +export default { + name: "discourse-gamification-admin-plugin-configuration-nav", + + initialize(container) { + const currentUser = container.lookup("service:current-user"); + if (!currentUser || !currentUser.admin) { + return; + } + + withPluginApi("1.1.0", (api) => { + api.addAdminPluginConfigurationNav("discourse-gamification", [ + { + label: "gamification.leaderboard.title", + route: "adminPlugins.show.discourse-gamification-leaderboards", + }, + ]); + }); + }, +}; diff --git a/plugins/discourse-gamification/assets/stylesheets/common/gamification-score.scss b/plugins/discourse-gamification/assets/stylesheets/common/gamification-score.scss new file mode 100644 index 00000000000..fe7e8580ef9 --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/common/gamification-score.scss @@ -0,0 +1,5 @@ +h3 { + a.gamification-score__link { + color: var(--tertiary); + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-admin.scss b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-admin.scss new file mode 100644 index 00000000000..22b5fc39a84 --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-admin.scss @@ -0,0 +1,44 @@ +.leaderboard-admin { + &__title { + display: inline-block; + } + + &__cta-new { + display: flex; + margin-top: 1rem; + } + + &__btn-recalculate { + float: right; + margin-right: 1rem; + } + + &__btn-new { + float: right; + } + + &__btn-back { + margin-bottom: 1rem; + padding-left: 0; + } + + &__listitem-action { + text-align: right; + display: flex; + flex-direction: row; + gap: 0.5em; + justify-content: flex-end; + } +} + +.leaderboard-edit { + &__cancel { + margin-left: 1rem; + } +} + +.new-leaderboard-container { + .form-kit__row { + padding-top: 0; + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-info-modal.scss b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-info-modal.scss new file mode 100644 index 00000000000..fc6ddebb0e1 --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-info-modal.scss @@ -0,0 +1,12 @@ +.leaderboard-info-modal { + .d-modal__body { + display: flex; + align-items: center; + justify-content: center; + } + + .d-icon-award { + margin-right: 2rem; + font-size: 4rem; + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-minimal.scss b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-minimal.scss new file mode 100644 index 00000000000..a69ef4834be --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard-minimal.scss @@ -0,0 +1,59 @@ +.leaderboard.-minimal { + .page { + &__header { + display: block; + margin: 0; + border-bottom: 0; + } + } + + .ranking-col-names { + position: relative; + padding: 0.5em; + top: unset; + } + + .ranking-col-names__sticky-border { + display: none; + } + + .user { + padding: 0.75em 0.5em; + margin-bottom: 0; + background-color: transparent; + border-bottom: 1px solid var(--primary-low); + border-radius: 0; + + &.-self { + display: none; + } + + &__rank { + font-size: var(--font-up-3); + + &.-winner { + color: $gold; + } + } + + &__name { + font-size: var(--font-0); + vertical-align: middle; + } + + &__avatar { + img { + margin-right: 0.25em; + } + } + + &__score { + font-size: var(--font-up-1); + } + + &-highlight { + background-color: var(--tertiary-50); + color: var(--primary); + } + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/common/leaderboard.scss b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard.scss new file mode 100644 index 00000000000..91776dcc210 --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/common/leaderboard.scss @@ -0,0 +1,314 @@ +.leaderboard { + $gold: #ffd82a; + $silver: #c4c4c4; + $bronze: #cd7f32; + $success: #4bb543; + + .page { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--header_primary); + margin-top: 2rem; + padding-bottom: 0.5rem; + + svg { + vertical-align: middle; + } + } + + &__title { + margin: 0; + } + } + + &__controls { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__period-chooser { + margin: 1.5rem 0 0.75rem; + + .selected-name { + margin: 0; + } + } + + .-ghost, + .-ghost .d-icon { + padding: 0; + color: var(--tertiary); + border: 0; + background-color: transparent; + box-shadow: none; + + &:hover { + color: var(--tertiary); + background-color: transparent; + + .d-icon { + color: var(--tertiary); + background-color: transparent; + } + } + } + + .podium { + display: flex; + justify-content: center; + align-items: center; + padding-top: 2rem; + padding-bottom: 5rem; + + &__wrapper { + background: rgb(var(--tertiary-rgb), 0.1); + border-bottom-left-radius: 20px; + border-bottom-right-radius: 20px; + } + } + + .winner { + overflow: hidden; // best worst solution in case the username is very long, better to cut it than have it wrap mid-name or overflow + + &__crown { + display: none; + margin-bottom: 1rem; + text-align: center; + + .d-icon { + color: $gold; + font-size: 40px; + } + } + + &__avatar { + position: relative; + margin-bottom: 25px; + + img { + width: 100%; + height: auto; + border-radius: 100%; + border-width: 5px; + border-style: solid; + box-sizing: border-box; + } + } + + &__rank { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + bottom: 0; + right: 50%; + transform: translate(50%, 50%); + width: 48px; + height: 48px; + border-radius: 100%; + font-size: 28px; + color: #222; + font-weight: bold; + } + + &__name { + text-align: center; + } + + &__score { + font-size: var(--font-up-5); + font-weight: bold; + text-align: center; + } + + &.-position1 { + position: relative; + z-index: 1; + order: 2; + + .winner__crown { + display: block; + } + + .winner__avatar img { + border-color: $gold; + background-color: #ffe46a; + } + + .winner__rank { + background-color: $gold; + } + } + + &.-position2 { + order: 1; + transform: translate(20%, 35%); + + .winner__avatar img { + border-color: $silver; + background-color: #d6d6d6; + } + + .winner__rank { + background-color: $silver; + } + } + + &.-position3 { + order: 3; + transform: translate(-20%, 35%); + + .winner__avatar img { + border-color: $bronze; + background-color: #dca570; // in case avatar has a transparent bg + } + + .winner__rank { + background-color: $bronze; + } + } + } + + .ranking { + margin-top: 2rem; + } + + .ranking-col-names { + position: sticky; + display: flex; + justify-content: space-between; + top: var(--header-offset); + background-color: var(--secondary); + border-bottom: 1px solid black; + + .d-icon { + margin-right: 0.25rem; + } + + &__sticky-border { + position: relative; + height: 2px; + margin-top: -1px; + background-color: var(--secondary); + } + } + + .user { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; + padding: 0.5rem 1.5rem; + background-color: rgb(var(--primary-rgb), 0.075); + border-radius: 20px; + + &__rank { + flex-shrink: 0; + font-size: var(--font-up-5); + font-weight: bold; + font-family: monospace; + } + + &__avatar { + margin: 0 1rem 0 1rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + img { + margin-right: 1rem; + border-radius: 100%; + } + } + + &__name { + font-size: var(--font-up-2); + } + + &__score { + flex-grow: 1; + text-align: right; + font-size: var(--font-up-4); + font-weight: bold; + } + + &.-self { + margin-bottom: 2rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + background-color: var(--tertiary); + color: var(--secondary); + + .user__name { + flex-grow: 1; + text-align: center; + font-weight: bold; + } + + .user__score { + flex-grow: 0; + } + } + + &-highlight { + background-color: var(--tertiary); + color: var(--secondary); + } + } + + &__not-ready { + display: flex; + align-items: center; + + p { + margin-right: 0.5em; + } + } +} + +.recalculate-scores-form-modal { + .modal-inner-container { + min-width: 25em; + + .select-kit { + width: 100%; + } + } + + .-custom-range { + display: flex; + align-items: baseline; + margin-top: 0.5rem; + + > * { + flex: 1 1 0; + } + + .date-picker-wrapper { + margin-left: auto; + } + + .date-picker { + max-width: 400px; + } + } + + .recalculate-modal__status.is-success .d-icon-check { + color: $success; + } +} + +.recalculate-modal { + &__date-range { + margin-top: 0.5rem; + color: var(--primary-high); + } + + &__footer-text { + margin-left: auto; + color: var(--primary-high); + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/desktop/leaderboard.scss b/plugins/discourse-gamification/assets/stylesheets/desktop/leaderboard.scss new file mode 100644 index 00000000000..a81f535e10d --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/desktop/leaderboard.scss @@ -0,0 +1,35 @@ +.leaderboard { + .podium { + width: 60%; + margin-left: auto; + margin-right: auto; + } + + .winner { + width: 23%; + + &__name { + font-size: var(--font-up-1); + } + + &__avatar { + img { + border-width: 4px; + } + } + + &.-position1 { + width: 30%; + } + } + + .ranking { + width: 75%; + margin-left: auto; + margin-right: auto; + } + + .ranking-col-names { + padding: 1rem 1.5rem 0.25rem 1.5rem; + } +} diff --git a/plugins/discourse-gamification/assets/stylesheets/mobile/leaderboard.scss b/plugins/discourse-gamification/assets/stylesheets/mobile/leaderboard.scss new file mode 100644 index 00000000000..ab6fcdfc17d --- /dev/null +++ b/plugins/discourse-gamification/assets/stylesheets/mobile/leaderboard.scss @@ -0,0 +1,52 @@ +.leaderboard { + &__period-chooser.select-kit.dropdown-select-box { + display: flex; + margin: 1rem 0; + + .period-chooser-header { + text-align: center; + } + } + + .page__header button.-ghost, + &__settings { + font-size: var(--font-up-2); + padding: 0.5rem 0 0.5rem 0.5rem; + } + + .winner { + width: 30%; + + &__avatar { + img { + border-width: 3px; + } + } + + &__rank { + width: 40px; + height: 40px; + font-size: 24px; + } + + &__score { + font-size: var(--font-up-3); + } + + &.-position1 { + width: 40%; + } + } + + .ranking-col-names { + padding: 1rem 1rem 0.25rem 1rem; + } + + .user { + padding: 0.5rem 1rem; + + &__score { + font-size: var(--font-up-3); + } + } +} diff --git a/plugins/discourse-gamification/config/locales/client.ar.yml b/plugins/discourse-gamification/config/locales/client.ar.yml new file mode 100644 index 00000000000..d9ea4b97a77 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ar.yml @@ -0,0 +1,87 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "الهتافات" + gamification: + score: "الهتافات" + you: "أنت" + leaderboard: + title: "لوحات المتصدرين" + info: "كيف تسير الأمور؟" + link_to_settings: "الإعدادات" + refresh: "تحديث" + modal: + title: "كيف تعمل لوحة المتصدرين؟" + text: "يتم منح النقاط مقابل التفاعل مع المجتمع، مثل الزيارة والإعجاب والنشر. يتم تحديث درجاتك كل بضع دقائق. لذا كُن مفيدًا ونشطًا وداعمًا، وارتقِ في الترتيب!" + name: "الاسم" + name_placeholder: "الاسم..." + new: "لوحة متصدرين جديدة" + create_success: "تم إنشاء لوحة المتصدرين" + delete_success: "تم حذف لوحة المتصدرين" + save_success: "تم حفظ لوحة الصدارة" + cta: "اصنع لوحة المتصدرين الأولى لك" + none: "لم يتم إنشاء أي لوحات متصدرين حتى الآن." + confirm_destroy: "هل تريد بالتأكيد حذف لوحة المتصدرين هذه؟" + date: + range: "نطاق التاريخ من/إلى" + from: "من التاريخ" + to: "إلى التاريخ" + helper: "إذا تم ترك التواريخ فارغة، فستعرض لوحة المتصدرين النقاط المكتسبة دون أي قيود زمنية." + visible_to_groups: "مرئية للمجموعات" + visible_to_groups_help: "سيتمكن المستخدمون في هذه المجموعات فقط من عرض لوحة المتصدرين. اتركه فارغًا للسماح للجميع." + included_groups: "المجموعات المتضمنة" + included_groups_help: "سيتم تضمين المستخدمين من هذه المجموعات فقط من عرض لوحة المتصدرين. اتركه فارغًا للسماح للجميع." + excluded_groups: "المجموعات المستثناة" + excluded_groups_help: "قم بحرمان المستخدمين في هذه المجموعات من التضمين في لوحة المتصدرين. اتركه فارغًا للسماح للجميع." + default_period: "الفترة الافتراضية" + default_period_help: "قم بتحديد الفترة الزمنية الافتراضية لعرضها في لوحة المتصدرين هذه." + period_filter_disabled: "إيقاف عامل تصفية الفترة الزمنية" + period: + all_time: "طوال الوقت" + yearly: "سنويًا" + quarterly: "ربع سنوي" + monthly: "شهريًا" + weekly: "أسبوعيًا" + daily: "يوميًا" + rank: "الترتيب" + create: "إنشاء" + cancel: "إلغاء" + close: "إغلاق" + delete: "حذف" + edit: "تعديل" + back: "الرجوع" + save: "حفظ" + apply: "تطبيق" + recalculate: "إعادة حساب النقاط" + recalculating: "جارٍ إعادة حساب النقاط..." + completed: "تم! تمت إعادة حساب النقاط بنجاح." + update_scores_help: "تحديث كل النقاط لجميع لوحات المتصدرين من" + update_range: + last_10_days: "آخر 10 أيام" + last_30_days: "آخر 30 يومًا" + last_90_days: "آخر 90 يومًا" + last_year: "العام الماضي" + all_time: "طوال الوقت" + custom_date_range: "نطاق مخصَّص" + custom_range_from: "من" + daily_update_scores_availability: + zero: "%{count} مرة إعادة حساب يومية متبقية" + one: "مرة إعادة حساب يومية واحدة (%{count}) متبقية" + two: "مرتا إعادة حساب يومية متبقيتان" + few: "%{count} مرات إعادة حساب يومية متبقية" + many: "%{count} مرة إعادة حساب يومية متبقية" + other: "%{count} مرة إعادة حساب يومية متبقية" + admin: + title: "التلعيب" + name: "الاسم" + period: "الفترة" diff --git a/plugins/discourse-gamification/config/locales/client.be.yml b/plugins/discourse-gamification/config/locales/client.be.yml new file mode 100644 index 00000000000..718914e094b --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.be.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: + js: + gamification: + you: "Вы" + leaderboard: + link_to_settings: "Налады" + refresh: "абнавіць" + name: "імя" + period: + all_time: "За ўвесь час" + yearly: "штогод" + quarterly: "штоквартальна" + monthly: "штомесяц" + weekly: "штотыдзень" + daily: "штодня" + create: "стварыць" + cancel: "адмяніць" + close: "зачыніць" + delete: "выдаляць" + edit: "рэдагаваць" + back: "Назад" + save: "захаваць" + apply: "прымяніць" + update_range: + all_time: "За ўвесь час" + custom_range_from: "ад" + admin: + name: "імя" diff --git a/plugins/discourse-gamification/config/locales/client.bg.yml b/plugins/discourse-gamification/config/locales/client.bg.yml new file mode 100644 index 00000000000..c902f01ff1c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.bg.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: + js: + gamification: + you: "Вие" + leaderboard: + link_to_settings: "Настройки" + refresh: "Обнови" + name: "Име" + period: + all_time: "От началото" + yearly: "Годишно" + quarterly: "Тримесечно" + monthly: "Месечно" + weekly: "Седмично" + daily: "Дневно" + create: "Създай" + cancel: "Прекрати" + close: "Затвори" + delete: "Изтрий" + edit: "Редактирай" + back: "Назад" + save: "Запази " + apply: "Приложи" + update_range: + last_10_days: "Последните 10 дни" + last_30_days: "Последните 30 дни" + last_90_days: "Последните 90 дни" + all_time: "От началото" + custom_range_from: "От" + admin: + name: "Име" diff --git a/plugins/discourse-gamification/config/locales/client.bs_BA.yml b/plugins/discourse-gamification/config/locales/client.bs_BA.yml new file mode 100644 index 00000000000..2ab4e089b35 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.bs_BA.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: + js: + gamification: + you: "Vi" + leaderboard: + link_to_settings: "Postavke" + refresh: "Refresh" + name: "Ime" + period: + all_time: "Oduvijek" + yearly: "Godišnje" + quarterly: "Kvartalno" + monthly: "Mesečno" + weekly: "Sedmično" + daily: "Dnevno" + create: "napravi" + cancel: "Odustani" + close: "Zatvori" + delete: "Delete" + edit: "Edit" + back: "Prethodno" + save: "Save" + apply: "Snimi" + update_range: + all_time: "Oduvijek" + custom_range_from: "Od" + admin: + name: "Ime" diff --git a/plugins/discourse-gamification/config/locales/client.ca.yml b/plugins/discourse-gamification/config/locales/client.ca.yml new file mode 100644 index 00000000000..5c820adbb2a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ca.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: + js: + gamification: + you: "Vós" + leaderboard: + link_to_settings: "Configuració" + refresh: "Actualitza" + name: "Nom" + period: + all_time: "Sempre" + yearly: "Anualment" + quarterly: "Trimestralment" + monthly: "Mensualment" + weekly: "Setmanalment" + daily: "Diàriament" + create: "Crea" + cancel: "Cancel·la" + close: "Tanca" + delete: "Suprimeix" + edit: "Edita" + back: "Enrere" + save: "Desa" + apply: "Aplica" + update_range: + last_10_days: "Els darrers 10 dies" + last_30_days: "Els darrers 30 dies" + last_90_days: "Els darrers 90 dies" + all_time: "Sempre" + custom_range_from: "De" + admin: + name: "Nom" diff --git a/plugins/discourse-gamification/config/locales/client.cs.yml b/plugins/discourse-gamification/config/locales/client.cs.yml new file mode 100644 index 00000000000..1ae23e6b1cc --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.cs.yml @@ -0,0 +1,85 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "Ovace" + gamification: + score: "Ovace" + you: "Vy" + leaderboard: + title: "Žebříčky" + info: "Jak to funguje?" + link_to_settings: "Nastavení" + refresh: "Aktualizovat" + modal: + title: "Jak tento žebříček funguje?" + text: "Body se udělují za zapojení do komunity, například za návštěvy, lajkování a příspěvky. Vaše skóre se aktualizuje každých několik minut. Buďte tedy nápomocní, aktivní a podporující a stoupejte v žebříčku!" + name: "Jméno" + name_placeholder: "Název..." + new: "Nový žebříček" + create_success: "Žebříček vytvořen" + delete_success: "Žebříček odstraněn" + save_success: "Žebříček uložen" + cta: "Vytvořte si svůj první žebříček" + none: "Zatím nebyly vytvořeny žádné žebříčky." + confirm_destroy: "Opravdu chcete tento žebříček odstranit?" + date: + range: "Časové období od/do" + from: "Od data" + to: "Do data" + helper: "Pokud data zůstanou prázdná, ve výsledkové tabulce se zobrazí dosažené skóre bez jakéhokoli časového omezení." + visible_to_groups: "Viditelné pro skupiny" + visible_to_groups_help: "Žebříček budou moci zobrazit pouze uživatelé těchto skupin. Pokud chcete povolit zobrazení všem, ponechte prázdné." + included_groups: "Zahrnuté skupiny" + included_groups_help: "Do žebříčku budou zařazeni pouze uživatelé z těchto skupin. Chcete-li uvést všechny, ponechte prázdné." + excluded_groups: "Vyloučené skupiny" + excluded_groups_help: "Odeberte z žebříčku uživatele z těchto skupin. Ponechte prázdné, aby se v seznamu objevili všichni." + default_period: "Výchozí období" + default_period_help: "Nastavte výchozí časové období, pro které se má zobrazit tento žebříček." + period_filter_disabled: "Zakázat filtr časového období" + period: + all_time: "Za celou dobu" + yearly: "Ročně" + quarterly: "Čtvrtletně" + monthly: "Měsíčně" + weekly: "Týdně" + daily: "Denně" + rank: "Pořadí" + create: "Vytvořit" + cancel: "Zrušit" + close: "Zavřít" + delete: "Smazat" + edit: "Upravit" + back: "Zpět" + save: "Uložit" + apply: "Použít" + recalculate: "Přepočítat skóre" + recalculating: "Přepočítávání skóre..." + completed: "Hotovo! Skóre bylo úspěšně přepočítáno." + update_scores_help: "Aktualizovat všechna skóre pro všechny žebříčky od" + update_range: + last_10_days: "Posledních 10 dní" + last_30_days: "Posledních 30 dní" + last_90_days: "Posledních 90 dní" + last_year: "Minulý rok" + all_time: "Za celou dobu" + custom_date_range: "Vlastní rozsah" + custom_range_from: "Od koho" + daily_update_scores_availability: + one: "Zbývá %{count} denní přepočet" + few: "Zbývají %{count} denní přepočty" + many: "Zbývá %{count} denních přepočtů" + other: "Zbývá %{count} denních přepočtů" + admin: + title: "Gamifikace" + name: "Jméno" + period: "Období" diff --git a/plugins/discourse-gamification/config/locales/client.da.yml b/plugins/discourse-gamification/config/locales/client.da.yml new file mode 100644 index 00000000000..31d54068c00 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.da.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: + js: + gamification: + you: "Dig" + leaderboard: + link_to_settings: "Indstillinger" + refresh: "Genindlæs" + name: "Navn" + period: + all_time: "Alt" + yearly: "Årligt" + quarterly: "Kvartalvis" + monthly: "Månedligt" + weekly: "Ugentligt" + daily: "Dagligt" + create: "Opret" + cancel: "Annuller" + close: "Luk" + delete: "Slet" + edit: "Rediger" + back: "Tilbage" + save: "Gem" + apply: "Anvend" + update_range: + last_10_days: "Seneste 10 dage" + last_30_days: "Seneste 30 dage" + last_90_days: "Seneste 90 dage" + all_time: "Alt" + custom_range_from: "Fra" + admin: + name: "Navn" diff --git a/plugins/discourse-gamification/config/locales/client.de.yml b/plugins/discourse-gamification/config/locales/client.de.yml new file mode 100644 index 00000000000..3a52fd16f42 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.de.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse – Gamifizierung" + js: + gamification_score: "Beifall" + gamification: + score: "Beifall" + you: "Du" + leaderboard: + title: "Ranglisten" + info: "Wie funktioniert das?" + link_to_settings: "Einstellungen" + refresh: "Aktualisieren" + modal: + title: "Wie funktioniert die Rangliste?" + text: "Punkte werden für die Interaktion mit der Community vergeben, z. B. für Besuche, „Gefällt mir“ und Posten. Deine Punktzahl wird alle paar Minuten aktualisiert. Sei also hilfsbereit, aktiv sowie solidarisch und steige auf!" + name: "Name" + name_placeholder: "Name …" + new: "Neue Rangliste" + create_success: "Rangliste erstellt" + delete_success: "Rangliste gelöscht" + save_success: "Rangliste gespeichert" + cta: "Erstelle deine erste Rangliste" + none: "Es wurden noch keine Ranglisten erstellt." + confirm_destroy: "Bist du sicher, dass du diese Rangliste löschen möchtest?" + date: + range: "Datumsbereich von/bis" + from: "Ab Datum" + to: "Bis Datum" + helper: "Wenn die Daten leer gelassen werden, zeigt die Rangliste die erzielten Punktzahlen ohne Zeitbeschränkungen." + visible_to_groups: "Sichtbar für Gruppen" + visible_to_groups_help: "Nur Benutzer in diesen Gruppen können die Rangliste sehen. Leer lassen, um dies allen zu ermöglichen." + included_groups: "Eingeschlossene Gruppen" + included_groups_help: "Nur Benutzer in diesen Gruppen werden in die Rangliste aufgenommen. Leer lassen, um alle aufzulisten." + excluded_groups: "Ausgeschlossene Gruppen" + excluded_groups_help: "Benutzer in diesen Gruppen von der Rangliste entfernen. Leer lassen, um alle aufzulisten." + default_period: "Standard-Zeitraum" + default_period_help: "Lege den Standard-Zeitraum fest, der für diese Rangliste angezeigt werden soll." + period_filter_disabled: "Zeitraumfilter deaktivieren" + period: + all_time: "Gesamt" + yearly: "Jährlich" + quarterly: "Vierteljährlich" + monthly: "Monatlich" + weekly: "Wöchentlich" + daily: "Täglich" + rank: "Rang" + create: "Erstellen" + cancel: "Abbrechen" + close: "Schließen" + delete: "Löschen" + edit: "Bearbeiten" + back: "Zurück" + save: "Speichern" + apply: "Anwenden" + recalculate: "Scores neu berechnen" + recalculating: "Scores werden neu berechnet …" + completed: "Fertig! Die Scores wurden erfolgreich neu berechnet." + update_scores_help: "Alle Scores für sämtliche Ranglisten aktualisieren von" + update_range: + last_10_days: "Letzte 10 Tage" + last_30_days: "Letzte 30 Tage" + last_90_days: "Letzte 90 Tage" + last_year: "Letztes Jahr" + all_time: "Gesamt" + custom_date_range: "Benutzerdefinierter Bereich" + custom_range_from: "Von" + daily_update_scores_availability: + one: "%{count} tägliche Neuberechnung übrig" + other: "%{count} tägliche Neuberechnungen übrig" + admin: + title: "Gamifizierung" + name: "Name" + period: "Zeitraum" diff --git a/plugins/discourse-gamification/config/locales/client.el.yml b/plugins/discourse-gamification/config/locales/client.el.yml new file mode 100644 index 00000000000..172c3c586d4 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.el.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: + js: + gamification: + you: "Εσείς" + leaderboard: + link_to_settings: "Ρυθμίσεις" + refresh: "Ανανέωση" + name: "Όνομα" + period: + all_time: "Από πάντα" + yearly: "Ετήσια" + quarterly: "Τριμηνιαία" + monthly: "Μηνιαίες" + weekly: "Εβδομαδιαίες" + daily: "Ημερήσιες" + create: "Δημιουργία" + cancel: "Ακύρωση" + close: "Κλείσιμο" + delete: "Σβήσιμο" + edit: "Επεξεργασία" + back: "Πίσω" + save: "Αποθήκευση" + apply: "Εφαρμογή" + update_range: + last_10_days: "Τελευταίες 10 ημέρες" + last_30_days: "Τελευταίες 30 ημέρες" + last_90_days: "Τελευταίες 90 ημέρες" + all_time: "Από πάντα" + custom_range_from: "Από" + admin: + name: "Όνομα" diff --git a/plugins/discourse-gamification/config/locales/client.en.yml b/plugins/discourse-gamification/config/locales/client.en.yml new file mode 100644 index 00000000000..3738d202900 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.en.yml @@ -0,0 +1,78 @@ +en: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + # directory column workaround + gamification_score: "Cheers" + gamification: + score: "Cheers" + you: "You" + leaderboard: + title: "Leaderboards" + info: "How does this work?" + link_to_settings: "Settings" + refresh: "Refresh" + modal: + title: "How does the leaderboard work?" + text: "Points are awarded for engaging with the community, such as visiting, liking, and posting. Your score is updated every few minutes. So go be helpful, active, and supportive, and rise through the ranks!" + name: "Name" + name_placeholder: "Name..." + new: "New leaderboard" + create_success: "Leaderboard created" + delete_success: "Leaderboard deleted" + save_success: "Leaderboard saved" + cta: "Make your first leaderboard" + none: "No leaderboards created yet." + confirm_destroy: "Are you sure you want to delete this leaderboard?" + date: + range: "From / To Date Range" + from: "From date" + to: "To date" + helper: "If dates are left empty the leaderboard will show scores earned without any time restrictions." + visible_to_groups: "Visible to groups" + visible_to_groups_help: "Only users on those groups will be able to view the leaderboard. Leave empty to allow everyone." + included_groups: "Included groups" + included_groups_help: "Only users on those groups will be included in the leaderboard. Leave empty to list everyone." + excluded_groups: "Excluded groups" + excluded_groups_help: "Remove users on those groups from being included in the leaderboard. Leave empty to list everyone." + default_period: "Default period" + default_period_help: "Set the default time period to display for this leaderboard." + period_filter_disabled: "Disable time period filter" + period: + all_time: "All Time" + yearly: "Yearly" + quarterly: "Quarterly" + monthly: "Monthly" + weekly: "Weekly" + daily: "Daily" + rank: "Rank" + create: "Create" + cancel: "Cancel" + close: "Close" + delete: "Delete" + edit: "Edit" + back: "Back" + save: "Save" + apply: "Apply" + recalculate: "Recalculate scores" + recalculating: "Recalculating scores..." + completed: "Done! The scores have been successfully recalculated." + update_scores_help: "Update all scores for all leaderboards from" + update_range: + last_10_days: "Last 10 days" + last_30_days: "Last 30 days" + last_90_days: "Last 90 days" + last_year: "Last year" + all_time: "All Time" + custom_date_range: "Custom range" + custom_range_from: "From" + daily_update_scores_availability: + one: "%{count} daily recalculation left" + other: "%{count} daily recalculations left" + admin: + title: "Gamification" + name: "Name" + period: "Period" diff --git a/plugins/discourse-gamification/config/locales/client.en_GB.yml b/plugins/discourse-gamification/config/locales/client.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/discourse-gamification/config/locales/client.es.yml b/plugins/discourse-gamification/config/locales/client.es.yml new file mode 100644 index 00000000000..42cad41f4b1 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.es.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Gamificación de Discourse" + js: + gamification_score: "Puntos" + gamification: + score: "Puntos" + you: "Tú" + leaderboard: + title: "Clasificaciones" + info: "¿Cómo funciona?" + link_to_settings: "Ajustes" + refresh: "Volver a cargar" + modal: + title: "¿Cómo funciona la clasificación?" + text: "Recibirás puntos por participar en la comunidad. Por ejemplo, cuando la visitas, usas el botón de me gusta o publicas cosas. Tu puntuación se actualiza cada pocos minutos. Así que ya sabes, ¡participa, ayuda y sube de posición!" + name: "Nombre" + name_placeholder: "Nombre..." + new: "Nueva tabla de clasificación" + create_success: "Tabla de clasificación creada" + delete_success: "Tabla de clasificación eliminada" + save_success: "Tabla de clasificación guardada" + cta: "Crea la primera tabla de clasificación" + none: "Todavía no hay ninguna tabla de clasificación." + confirm_destroy: "¿Estás seguro de que quieres eliminar esta tabla de clasificación?" + date: + range: "Fecha de inicio/fin" + from: "Desde la fecha" + to: "Hasta la fecha" + helper: "Si dejas las fechas vacías, la tabla incluirá puntuaciones obtenidas en cualquier momento" + visible_to_groups: "Visible para los grupos" + visible_to_groups_help: "Solo los usuarios de esos grupos podrán ver la tabla de clasificación. Déjalo vacío para permitir a todos." + included_groups: "Grupos incluidos" + included_groups_help: "Solo los usuarios de esos grupos se incluirán en la tabla de clasificación. Déjelo vacío para incluir a todos." + excluded_groups: "Grupos excluidos" + excluded_groups_help: "Elimina a los usuarios de esos grupos para que no se incluyan en la tabla de clasificación. Déjalo vacío para que aparezcan todos." + default_period: "Período predeterminado" + default_period_help: "Establece el período de tiempo predeterminado que se mostrará en esta tabla de clasificación." + period_filter_disabled: "Desactivar el filtro de periodo de tiempo" + period: + all_time: "Siempre" + yearly: "Anualmente" + quarterly: "Trimestralmente" + monthly: "Mensualmente" + weekly: "Semanalmente" + daily: "Diariamente" + rank: "Rango" + create: "Crear" + cancel: "Cancelar" + close: "Cerrar" + delete: "Eliminar" + edit: "Editar" + back: "Volver" + save: "Guardar" + apply: "Aplicar" + recalculate: "Recalcular puntuaciones" + recalculating: "Recalculando puntuaciones..." + completed: "¡Hecho! Las puntuaciones se han recalculado con éxito." + update_scores_help: "Actualiza todas las puntuaciones de todas las tablas de clasificación desde" + update_range: + last_10_days: "Últimos 10 días" + last_30_days: "Últimos 30 días" + last_90_days: "Últimos 90 días" + last_year: "El año pasado" + all_time: "Siempre" + custom_date_range: "Rango personalizado" + custom_range_from: "De" + daily_update_scores_availability: + one: "%{count} recálculo diario restante" + other: "%{count} recálculos diarios restantes" + admin: + title: "Gamification" + name: "Nombre" + period: "Período" diff --git a/plugins/discourse-gamification/config/locales/client.et.yml b/plugins/discourse-gamification/config/locales/client.et.yml new file mode 100644 index 00000000000..d4bc700badf --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.et.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: + js: + gamification: + you: "Sina" + leaderboard: + link_to_settings: "Sätted" + refresh: "Värskenda" + name: "Nimi" + period: + all_time: "Alates algusest" + yearly: "Iga-aastaselt" + quarterly: "Kvartaalselt" + monthly: "Igakuiselt" + weekly: "Iganädalaselt" + daily: "Igapäevaselt" + create: "Loo" + cancel: "Tühista" + close: "Sulge" + delete: "Kustuta" + edit: "Muuda" + back: "Tagasi" + save: "Salvesta" + apply: "Rakenda" + update_range: + all_time: "Alates algusest" + custom_range_from: "Kellelt" + admin: + name: "Nimi" diff --git a/plugins/discourse-gamification/config/locales/client.fa_IR.yml b/plugins/discourse-gamification/config/locales/client.fa_IR.yml new file mode 100644 index 00000000000..ab212fb4db9 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.fa_IR.yml @@ -0,0 +1,54 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + js: + gamification_score: "امتیازات" + gamification: + score: "امتیازات" + you: "شما" + leaderboard: + title: "تابلو امتیازات" + info: "این چطور کار می‌کنه؟" + link_to_settings: "تنظیمات" + refresh: "تازه‌سازی" + modal: + title: "تابلو امتیازات چطور کار می‌کنه؟" + name: "نام" + name_placeholder: "نام..." + confirm_destroy: "آیا مطمئنید که می‌خواهید این تابلوی امتیاز را حذف کنید؟" + date: + range: "از / تا محدوده تاریخ" + default_period: "بازه زمانی پیش‌فرض" + default_period_help: "بازه زمانی پیش‌فرض را برای نمایش این تابلوی امتیازات تنظیم کنید." + period: + all_time: "همیشه" + yearly: "سالیانه " + quarterly: "فصلی" + monthly: "ماهیانه" + weekly: "هفتگی" + daily: "روزانه" + rank: "رتبه" + create: "ایجاد" + cancel: "لغو" + close: "بستن" + delete: "حذف" + edit: "ویرایش" + back: "بازگشت" + save: "ذخیره" + apply: "اعمال کردن" + update_range: + last_10_days: "۱۰ روز گذشته" + last_30_days: "۳۰ روز گذشته" + last_90_days: "۹۰ روز گذشته" + last_year: "سال گذشته" + all_time: "همیشه" + custom_date_range: "محدوده سفارشی" + custom_range_from: "از طرف" + admin: + title: "بازی‌وارسازی" + name: "نام" + period: "دوره زمانی" diff --git a/plugins/discourse-gamification/config/locales/client.fi.yml b/plugins/discourse-gamification/config/locales/client.fi.yml new file mode 100644 index 00000000000..d396f767c7c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.fi.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discoursen Pelillistäminen" + js: + gamification_score: "Hurraukset" + gamification: + score: "Hurraukset" + you: "Sinä" + leaderboard: + title: "Tulostaulukot" + info: "Miten tämä toimii?" + link_to_settings: "Asetukset" + refresh: "Päivitä" + modal: + title: "Miten tulostaulukko toimii?" + text: "Pisteitä annetaan vuorovaikutuksesta yhteisön kanssa, kuten vierailusta, tykkäämisestä ja viestien lähettämisestä. Tuloksesi päivitetään muutaman minuutin välein. Ole siis avulias, aktiivinen ja tukea antava ja nouse sijoituksissa!" + name: "Nimi" + name_placeholder: "Nimi..." + new: "Uusi tulostaulukko" + create_success: "Tulostaulukko luotu" + delete_success: "Tulostaulukko poistettu" + save_success: "Tulostaulukko tallennettu" + cta: "Tee ensimmäinen tulostaulukkosi" + none: "Tulostaulukoita ei ole vielä luotu." + confirm_destroy: "Oletko varma, että haluat poistaa tämän tulostaulukon?" + date: + range: "Päivämääräalue (alkaen/päättyen)" + from: "Alkamispäivä" + to: "Päättymispäivä" + helper: "Jos päivämäärät jätetään tyhjiksi, tulostaulukko näyttää ansaitut pisteet ilman aikarajoituksia." + visible_to_groups: "Näkyy ryhmille" + visible_to_groups_help: "Vain näiden ryhmien käyttäjät voivat tarkastella tulostaulukkoa. Salli kaikki jättämällä tyhjäksi." + included_groups: "Sisältyvät ryhmät" + included_groups_help: "Vain näiden ryhmien käyttäjät sisältyvät tulostaulukkoon. Listaa kaikki jättämällä tyhjäksi." + excluded_groups: "Poissuljetut ryhmät" + excluded_groups_help: "Jätä näiden ryhmien käyttäjät pois tulostaulukosta. Listaa kaikki jättämällä tyhjäksi." + default_period: "Oletusjakso" + default_period_help: "Aseta näytettävä oletusaika tälle tulostaulukolle." + period_filter_disabled: "Poista ajanjaksosuodatin käytöstä" + period: + all_time: "Kaikilta ajoilta" + yearly: "Vuosittainen" + quarterly: "Neljännesvuosittainen" + monthly: "Kuukausittainen" + weekly: "Viikoittainen" + daily: "Päivittäinen" + rank: "Sijoitus" + create: "Luo" + cancel: "Peruuta" + close: "Sulje" + delete: "Poista" + edit: "Muokkaa" + back: "Takaisin" + save: "Tallenna" + apply: "Käytä" + recalculate: "Laske pisteet uudelleen" + recalculating: "Pisteitä lasketaan uudelleen..." + completed: "Valmista! Pisteet on laskettu uudelleen." + update_scores_help: "Päivitä kaikkien tulostaulukoiden kaikki tulokset alkaen" + update_range: + last_10_days: "Viimeiset 10 päivää" + last_30_days: "Viimeiset 30 päivää" + last_90_days: "Viimeiset 90 päivää" + last_year: "Viime vuosi" + all_time: "Kaikilta ajoilta" + custom_date_range: "Mukautettu ajanjakso" + custom_range_from: "Ajalta" + daily_update_scores_availability: + one: "%{count} päivittäinen uudelleenlasketa jäljellä" + other: "%{count} päivittäistä uudelleenlasketaa jäljellä" + admin: + title: "Pelillistäminen" + name: "Nimi" + period: "Jakso" diff --git a/plugins/discourse-gamification/config/locales/client.fr.yml b/plugins/discourse-gamification/config/locales/client.fr.yml new file mode 100644 index 00000000000..89b52f58577 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.fr.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Gamification de Discourse" + js: + gamification_score: "Acclamations" + gamification: + score: "Acclamations" + you: "Vous" + leaderboard: + title: "Classements" + info: "Comment ça fonctionne ?" + link_to_settings: "Paramètres" + refresh: "Actualiser" + modal: + title: "Comment fonctionne le classement ?" + text: "Des points sont attribués pour l'engagement auprès de la communauté, par exemple pour les visites, les mentions J'aime et les publications. Votre score est mis à jour toutes les quelques minutes. Veillez donc à être aimable, actif(ve) et solidaire, et gravissez les échelons !" + name: "Nom" + name_placeholder: "Nom…" + new: "Nouveau classement" + create_success: "Classement créé" + delete_success: "Classement supprimé" + save_success: "Classement enregistré" + cta: "Créez votre premier classement" + none: "Aucun classement n'a encore été créé." + confirm_destroy: "Voulez-vous vraiment supprimer ce classement ?" + date: + range: "Plage de date du/au" + from: "Date de début" + to: "Date de fin" + helper: "Si les dates sont laissées vides, le classement affichera les scores obtenus sans aucune restriction de temps." + visible_to_groups: "Visible par les groupes" + visible_to_groups_help: "Seuls les utilisateurs de ces groupes pourront voir le classement. Laissez le champ vide pour autoriser tout le monde." + included_groups: "Groupes inclus" + included_groups_help: "Seuls les utilisateurs de ces groupes seront inclus dans le classement. Laissez ce champ vide pour lister tout le monde." + excluded_groups: "Groupes exclus" + excluded_groups_help: "Supprimez les utilisateurs de ces groupes afin qu'ils ne figurent pas dans le classement. Laissez ce champ vide pour lister tout le monde." + default_period: "Période par défaut" + default_period_help: "Définissez la période par défaut à afficher pour ce classement." + period_filter_disabled: "Désactiver le filtre de période" + period: + all_time: "Depuis toujours" + yearly: "Annuel" + quarterly: "Trimestriel" + monthly: "Mensuel" + weekly: "Hebdomadaire" + daily: "Quotidien" + rank: "Rang" + create: "Créer" + cancel: "Annuler" + close: "Fermer" + delete: "Supprimer" + edit: "Modifier" + back: "Retour" + save: "Enregistrer" + apply: "Appliquer" + recalculate: "Recalculer les scores" + recalculating: "Recalcul des scores..." + completed: "Terminé ! Les scores ont été recalculés avec succès." + update_scores_help: "Mettre à jour tous les scores pour tous les classements de" + update_range: + last_10_days: "10 derniers jours" + last_30_days: "30 derniers jours" + last_90_days: "90 derniers jours" + last_year: "L'année dernière" + all_time: "Depuis toujours" + custom_date_range: "Plage personnalisée" + custom_range_from: "De" + daily_update_scores_availability: + one: "%{count} recalcul quotidien restant" + other: "%{count} recalculs quotidiens restants" + admin: + title: "Gamification" + name: "Nom" + period: "Période" diff --git a/plugins/discourse-gamification/config/locales/client.gl.yml b/plugins/discourse-gamification/config/locales/client.gl.yml new file mode 100644 index 00000000000..0d28fe20191 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.gl.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: + js: + gamification: + you: "Vostede" + leaderboard: + link_to_settings: "Configuración" + refresh: "Actualizar" + name: "Nome" + period: + all_time: "Desde o principio" + yearly: "Anual" + quarterly: "Trimestral" + monthly: "Mensual" + weekly: "Semanal" + daily: "Diario" + create: "Crear" + cancel: "Cancelar" + close: "Pechar" + delete: "Eliminar" + edit: "Editar" + back: "Volver" + save: "Gardar" + apply: "Aplicar" + update_range: + all_time: "Desde o principio" + custom_range_from: "De" + admin: + name: "Nome" diff --git a/plugins/discourse-gamification/config/locales/client.he.yml b/plugins/discourse-gamification/config/locales/client.he.yml new file mode 100644 index 00000000000..cf034eee92a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.he.yml @@ -0,0 +1,85 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse משחוק" + js: + gamification_score: "תשועות" + gamification: + score: "תשועות" + you: "אני" + leaderboard: + title: "לוחות תוצאות" + info: "איך זה עובד?" + link_to_settings: "הגדרות" + refresh: "רענון" + modal: + title: "איך עובד לוח התוצאות?" + text: "נקודות מוענקות על מעורבות בקהילה, כגון ביקור, סימוני לייק ופרסום. הניקוד שלך מתעדכן כל כמה דקות. לכן, מומלץ להביא תועלת, לקדם פעילות ולתמוך, ככה הניקוד הולך ועולה!" + name: "שם" + name_placeholder: "שם…" + new: "לוח תוצאות חדש" + create_success: "לוח תוצאות נוצר" + delete_success: "לוח תוצאות נמחק" + save_success: "לוח תוצאות נשמר" + cta: "יצירת לוח התוצאות הראשון שלך" + none: "לא נוצרו עדיין לוחות תוצאות." + confirm_destroy: "למחוק את לוח התוצאות הזה?" + date: + range: "טווח תאריך מ/עד" + from: "מתאריך" + to: "עד תאריך" + helper: "אם התאריכים נשארים ריקים לוח התוצאות יציג ניקוד שהתקבל ללא מגבלות זמן." + visible_to_groups: "גלוי לקבוצות" + visible_to_groups_help: "רק משתמשים מהקבוצות האלו יוכלו לצפות בלוח התוצאות. ניתן ריק כדי שכולם יוכלו." + included_groups: "קבוצות שנכללות" + included_groups_help: "רק משתמשים מהקבוצות האלו יקחו חלק בלוח התוצאות. ניתן להשאיר ריק כדי שכולם יוכלו." + excluded_groups: "קבוצות מוחרגות" + excluded_groups_help: "ניתן להסיר משתמשים מהקבוצות האלו כדי שלא יהיו חלק מלוח התוצאות. או להשאיר ריק כדי להציג את כולם." + default_period: "פרק זמן כברירת מחדל" + default_period_help: "להגדיר את פרק הזמן כברירת המחדל כדי להציג ללוח התוצאות הזה." + period_filter_disabled: "השבתת מסנן פרק זמן" + period: + all_time: "כל הזמן" + yearly: "שנתי" + quarterly: "רבעוני" + monthly: "חודשי" + weekly: "שבועי" + daily: "יומי" + rank: "דירוג" + create: "יצירה" + cancel: "ביטול" + close: "סגירה" + delete: "מחיקה" + edit: "עריכה" + back: "חזרה" + save: "שמירה" + apply: "החלה" + recalculate: "חישוב ניקוד מחדש" + recalculating: "הניקוד מחושב מחדש…" + completed: "סיימנו! הניקוד חושב מחדש בהצלחה." + update_scores_help: "לעדכן את כל הניקוד בכל לוחות התוצאות מתוך" + update_range: + last_10_days: "10 הימים האחרונים" + last_30_days: "30 הימים האחרונים" + last_90_days: "90 הימים האחרונים" + last_year: "שנה שעברה" + all_time: "כל הזמן" + custom_date_range: "טווח מותאם אישית" + custom_range_from: "מאת" + daily_update_scores_availability: + one: "נותר חישוב ניקוד מחדש יומי אחד" + two: "נותרו %{count} חישובים מחדש יומיים" + many: "נותרו %{count} חישובים מחדש יומיים" + other: "נותרו %{count} חישובים מחדש יומיים" + admin: + title: "משחוק" + name: "שם" + period: "פרק זמן" diff --git a/plugins/discourse-gamification/config/locales/client.hr.yml b/plugins/discourse-gamification/config/locales/client.hr.yml new file mode 100644 index 00000000000..c9a798e5e51 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.hr.yml @@ -0,0 +1,49 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + js: + gamification_score: "Živjeli" + gamification: + score: "Živjeli" + you: "Vi" + leaderboard: + title: "Ljestvice članova" + info: "Kako ovo radi?" + link_to_settings: "Postavke" + refresh: "Osvježi" + modal: + title: "Kako radi ploča s najboljim rezultatima?" + text: "Bodovi se dodjeljuju za interakciju sa zajednicom, kao što su posjete, sviđanje i objavljivanje. Vaš rezultat se ažurira svakih nekoliko minuta. Zato budite od pomoći, aktivni i podržavajte i napredujte kroz činove!" + name: "Ime" + name_placeholder: "Ime..." + confirm_destroy: "Jeste li sigurni da želite izbrisati ovu ploču s najboljim rezultatima?" + date: + range: "Od/do datumskog raspona" + period: + all_time: "Oduvijek" + yearly: "Godišnje" + quarterly: "Tromjesečno" + monthly: "Mjesečno" + weekly: "Tjedno" + daily: "Dnevno" + create: "Stvorite" + cancel: "Odustani" + close: "Zatvori" + delete: "Izbriši" + edit: "Uredi" + back: "Natrag" + save: "Spremi" + apply: "Primijeni" + update_range: + last_10_days: "Zadnjih 10 dana" + last_30_days: "Zadnjih 30 dana" + last_90_days: "Zadnjih 90 dana" + all_time: "Oduvijek" + custom_range_from: "Od" + admin: + title: "Gamifikacija" + name: "Ime i prezime" diff --git a/plugins/discourse-gamification/config/locales/client.hu.yml b/plugins/discourse-gamification/config/locales/client.hu.yml new file mode 100644 index 00000000000..acd33ed37eb --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.hu.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamifikáció" + js: + gamification_score: "Pontok" + gamification: + score: "Pontok" + you: "Ön" + leaderboard: + title: "Ranglisták" + info: "Hogyan működik?" + link_to_settings: "Beállítások" + refresh: "Újratöltés" + modal: + title: "Hogyan működik a ranglista?" + text: "Pontok járnak a közösséggel való kapcsolattartásért, például látogatásért, kedvelésért és bejegyzésért. A pontszám néhány percenként frissül. Legyen tehát segítőkész, aktív és támogató, és emelkedjen a ranglétrán!" + name: "Név" + name_placeholder: "Név..." + new: "Új ranglista" + create_success: "Ranglista létrehozva" + delete_success: "Ranglista törölve" + save_success: "Ranglista mentve" + cta: "Első ranglista létrehozása" + none: "Nincs még ranglista létrehozva." + confirm_destroy: "Biztosan törli ezt a ranglistát?" + date: + range: "Dátumtartomány" + from: "Ettől:" + to: "Mostanáig" + helper: "Ha a dátumok üresen maradnak, a ranglistán időkorlátozás nélkül megszerzett pontszámok jelennek meg." + visible_to_groups: "Csoportok számára látható" + visible_to_groups_help: "Csak ezeknek a csoportoknak a felhasználói láthatják a ranglistát. Hagyja üresen, hogy bárki megtekinthesse." + included_groups: "Csatolt csoportok" + included_groups_help: "Csak ezeknek a csoportoknak a felhasználói fognak szerepelni a ranglistán. Hagyja üresen, hogy mindenkit felsoroljon." + excluded_groups: "Kizárt csoportok" + excluded_groups_help: "Távolítsa el a csoportok felhasználóit, hogy ne szerepeljenek a ranglistán. Hagyja üresen, hogy mindenkit felsoroljon." + default_period: "Alapértelmezett időszak" + default_period_help: "Állítsa be a ranglistán megjelenítendő alapértelmezett időtartamot." + period_filter_disabled: "Az időszakszűrő letiltása" + period: + all_time: "Bármikor" + yearly: "Éves" + quarterly: "Negyedéves" + monthly: "Havi" + weekly: "Heti" + daily: "Napi" + rank: "Rangsor" + create: "Létrehozás" + cancel: "Mégse" + close: "Lezárás" + delete: "Törlés" + edit: "Szerkesztés" + back: "Vissza" + save: "Mentés" + apply: "Alkalmaz" + recalculate: "Pontszámok újraszámítása" + recalculating: "A pontszámok újraszámítása..." + completed: "Kész! A pontszámokat sikeresen újraszámoltuk." + update_scores_help: "Frissítse az összes eredményt az összes ranglistán az alábbiakból" + update_range: + last_10_days: "Elmúlt 10 nap" + last_30_days: "Elmúlt 30 nap" + last_90_days: "Elmúlt 90 nap" + last_year: "Tavaly" + all_time: "Bármikor" + custom_date_range: "Egyedi tartomány" + custom_range_from: "From" + daily_update_scores_availability: + one: "%{count} napi újraszámítás maradt" + other: "%{count} napi újraszámolás maradt" + admin: + title: "Gamifikáció" + name: "Név" + period: "Időszak" diff --git a/plugins/discourse-gamification/config/locales/client.hy.yml b/plugins/discourse-gamification/config/locales/client.hy.yml new file mode 100644 index 00000000000..752bbf12e2a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.hy.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: + js: + gamification: + you: "Դուք " + leaderboard: + link_to_settings: "Կարգավորումներ" + refresh: "Թարմացնել" + name: "Անուն" + period: + all_time: "Ամբողջ Ժամանակվա" + yearly: "Տարվա Ընթացքում" + quarterly: "Եռամսյակի Ընթացքում" + monthly: "Ամսվա Ընթացքում" + weekly: "Շաբաթվա Ընթացքում" + daily: "Օրվա Ընթացքում" + create: "Ստեղծել" + cancel: "Չեղարկել" + close: "Փակել" + delete: "Ջնջել" + edit: "Խմբագրել" + back: "Ետ" + save: "Պահպանել" + apply: "Կիրառել" + update_range: + all_time: "Ամբողջ Ժամանակվա" + custom_range_from: "Ում կողմից" + admin: + name: "Անուն" diff --git a/plugins/discourse-gamification/config/locales/client.id.yml b/plugins/discourse-gamification/config/locales/client.id.yml new file mode 100644 index 00000000000..62d0b148539 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.id.yml @@ -0,0 +1,31 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: + js: + gamification: + you: "Anda" + leaderboard: + link_to_settings: "Pengaturan" + refresh: "Segarkan" + name: "Nama" + period: + all_time: "Sepanjang Waktu" + create: "Buat" + cancel: "Batal" + close: "Tutup" + delete: "Hapus" + edit: "Ubah" + save: "Simpan" + apply: "menerapkan" + update_range: + last_10_days: "10 hari terakhir" + last_30_days: "30 hari terakhir" + last_90_days: "90 hari terakhir" + all_time: "Sepanjang Waktu" + custom_range_from: "Dari" + admin: + name: "Nama" diff --git a/plugins/discourse-gamification/config/locales/client.it.yml b/plugins/discourse-gamification/config/locales/client.it.yml new file mode 100644 index 00000000000..d8a3f7aab91 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.it.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "Complimenti" + gamification: + score: "Complimenti" + you: "Tu" + leaderboard: + title: "Classifiche" + info: "Come funziona?" + link_to_settings: "Impostazioni" + refresh: "Aggiorna" + modal: + title: "Come funziona la classifica?" + text: "I punti vengono assegnati per il coinvolgimento con la community, ad esempio in base al numero di visite, Mi piace e pubblicazioni. Il tuo punteggio viene aggiornato ogni pochi minuti. Quindi cerca di essere propositivo, attivo e di supporto per scalare le classifiche!" + name: "Nome" + name_placeholder: "Nome..." + new: "Nuova classifica" + create_success: "Classifica creata" + delete_success: "Classifica eliminata" + save_success: "Classifica salvata" + cta: "Crea la tua prima classifica" + none: "Nessuna classifica ancora creata." + confirm_destroy: "Vuoi cancellare questa classifica?" + date: + range: "Intervallo di date dal/al" + from: "Dalla data" + to: "A oggi" + helper: "Se le date vengono lasciate vuote, la classifica mostrerà i punteggi ottenuti senza alcun limite di tempo." + visible_to_groups: "Visibile ai gruppi" + visible_to_groups_help: "Solo gli utenti di questi gruppi potranno visualizzare la classifica. Lascia vuota l'opzione per consentire a tutti la visione." + included_groups: "Gruppi inclusi" + included_groups_help: "Solo gli utenti di questi gruppi saranno inclusi in classifica. Lascia vuota l'opzione per elencare tutti." + excluded_groups: "Gruppi esclusi" + excluded_groups_help: "Gli utenti di questi gruppi saranno esclusi dalla classifica. Lascia vuota l'opzione per elencare tutti." + default_period: "Periodo predefinito" + default_period_help: "Imposta il periodo di tempo predefinito per visualizzare questa classifica." + period_filter_disabled: "Disattiva il filtro del periodo di tempo" + period: + all_time: "Di Sempre" + yearly: "Annuale" + quarterly: "Trimestrale" + monthly: "Mensile" + weekly: "Settimanale" + daily: "Giornaliero" + rank: "Posizione" + create: "Crea" + cancel: "Annulla" + close: "Chiudi" + delete: "Cancella" + edit: "Modifica" + back: "Indietro" + save: "Salva" + apply: "Applica" + recalculate: "Ricalcola i punteggi" + recalculating: "Ricalcolo dei punteggi..." + completed: "Fatto! I punteggi sono stati ricalcolati correttamente." + update_scores_help: "Aggiorna tutti i punteggi per tutte le classifiche da" + update_range: + last_10_days: "Ultimi 10 giorni" + last_30_days: "Ultimi 30 giorni" + last_90_days: "Ultimi 90 giorni" + last_year: "Ultimo anno" + all_time: "Di Sempre" + custom_date_range: "Intervallo personalizzato" + custom_range_from: "Da" + daily_update_scores_availability: + one: "%{count} ricalcolo giornaliero rimasto" + other: "%{count} ricalcoli giornalieri rimasti" + admin: + title: "Gamification" + name: "Nome" + period: "Periodo" diff --git a/plugins/discourse-gamification/config/locales/client.ja.yml b/plugins/discourse-gamification/config/locales/client.ja.yml new file mode 100644 index 00000000000..bd24de613ed --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ja.yml @@ -0,0 +1,82 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse ゲーミフィケーション" + js: + gamification_score: "拍手" + gamification: + score: "拍手" + you: "あなた" + leaderboard: + title: "リーダーボード" + info: "仕組みは?" + link_to_settings: "設定" + refresh: "更新" + modal: + title: "リーダーボードの仕組みは?" + text: "アクセス、「いいね!」、投稿など、コミュニティーでのアクションに対してポイントが付与されます。スコアは数分ごとに更新されます。活発な協力を通じて、ランクを上げましょう!" + name: "名前" + name_placeholder: "名前..." + new: "新しいリーダーボード" + create_success: "リーダーボードが作成されました" + delete_success: "リーダーボードが削除されました" + save_success: "リーダーボードが保存されました" + cta: "最初のリーダーボードを作成しよう" + none: "リーダーボードはまだ作成されていません。" + confirm_destroy: "このリーダーボードを削除してもよろしいですか?" + date: + range: "開始日/終了日の範囲" + from: "開始日" + to: "終了日" + helper: "日付が空である場合、リーダーボードには期間制限なしで獲得されたスコアが表示されます。" + visible_to_groups: "グループに表示" + visible_to_groups_help: "これらのグループのユーザーのみがリーダーボードを表示できます。全員を許可する場合は、空白のままにします。" + included_groups: "含まれるグループ" + included_groups_help: "これらのグループのユーザーのみがリーダーボードに含まれます。全員を表示する場合は、空白のままにします。" + excluded_groups: "除外されるグループ" + excluded_groups_help: "これらのグループのユーザーは、リーダーボードに含まれません。全員を表示する場合は、空白のままにします。" + default_period: "デフォルトの期間" + default_period_help: "このリーダーボードに表示するデフォルトの期間を設定します。" + period_filter_disabled: "期間フィルタを無効化" + period: + all_time: "全期間" + yearly: "年間" + quarterly: "四半期" + monthly: "月間" + weekly: "週間" + daily: "日間" + rank: "ランク" + create: "作成" + cancel: "キャンセル" + close: "閉じる" + delete: "削除" + edit: "編集" + back: "戻る" + save: "保存" + apply: "適用" + recalculate: "スコアを再計算" + recalculating: "スコアを再計算中..." + completed: "完了!スコアが正常に再計算されました。" + update_scores_help: "次の時間からの全リーダーボードのすべてのスコアを更新します:" + update_range: + last_10_days: "過去 10 日間" + last_30_days: "過去 30 日間" + last_90_days: "過去 90 日間" + last_year: "昨年" + all_time: "全期間" + custom_date_range: "カスタム範囲" + custom_range_from: "開始" + daily_update_scores_availability: + other: "1 日の再計算回数は残り %{count} 回" + admin: + title: "ゲーミフィケーション" + name: "名前" + period: "期間" diff --git a/plugins/discourse-gamification/config/locales/client.ko.yml b/plugins/discourse-gamification/config/locales/client.ko.yml new file mode 100644 index 00000000000..99944f0997c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ko.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: + js: + gamification: + you: "사용자님" + leaderboard: + link_to_settings: "설정" + refresh: "새로 고침" + name: "그룹명" + period: + all_time: "전체 시간" + yearly: "연" + quarterly: "분기마다" + monthly: "월" + weekly: "주" + daily: "일" + create: "글" + cancel: "취소" + close: "닫기" + delete: "삭제하기" + edit: "편집" + back: "뒤로" + save: "저장하기" + apply: "적용" + update_range: + last_10_days: "지난 10일" + last_30_days: "지난 30일" + last_90_days: "지난 90일" + all_time: "전체 시간" + custom_range_from: "보내는사람" + admin: + name: "이름" diff --git a/plugins/discourse-gamification/config/locales/client.lt.yml b/plugins/discourse-gamification/config/locales/client.lt.yml new file mode 100644 index 00000000000..b471cbd9d5a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.lt.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: + js: + gamification: + you: "Jūs" + leaderboard: + link_to_settings: "Nustatymai" + refresh: "Atnaujinti" + name: "Vardas" + period: + all_time: "Per visą laiką" + yearly: "Kasmet" + quarterly: "Kas ketvirtį" + monthly: "Kas mėnesį" + weekly: "Kas savaitę" + daily: "Kasdien" + create: "Sukurti" + cancel: "Atšaukti" + close: "Uždaryti" + delete: "Pašalinti" + edit: "Redaguoti" + back: "Atgal" + save: "Išsaugoti" + apply: "Kandidatuoti" + update_range: + last_10_days: "Paskutinės 10 dienų" + last_30_days: "Paskutinės 30 dienų" + last_90_days: "Paskutinės 90 dienų" + all_time: "Per visą laiką" + custom_range_from: "Nuo" + admin: + name: "Vardas" diff --git a/plugins/discourse-gamification/config/locales/client.lv.yml b/plugins/discourse-gamification/config/locales/client.lv.yml new file mode 100644 index 00000000000..f0cecb3117b --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.lv.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: + js: + gamification: + you: "Tu" + leaderboard: + link_to_settings: "Iestatījumi" + refresh: "Pārlādēt" + name: "Vārds" + period: + all_time: "Vienmēr" + yearly: "Gada" + quarterly: "Ceturkšņa" + monthly: "Mēneša" + weekly: "Nedēļas" + daily: "Dienas" + create: "Izveidot" + cancel: "Atcelt" + close: "Aizvērt" + delete: "Dzēst" + edit: "Rediģēt" + back: "Atpakaļ" + save: "Saglabāt" + apply: "Pielietot" + update_range: + last_10_days: "Pēdējās 10 dienas" + last_30_days: "Pēdējās 30 dienas" + last_90_days: "Pēdējās 90 dienas" + all_time: "Vienmēr" + custom_range_from: "No" + admin: + name: "Vārds" diff --git a/plugins/discourse-gamification/config/locales/client.nb_NO.yml b/plugins/discourse-gamification/config/locales/client.nb_NO.yml new file mode 100644 index 00000000000..d9a73d04f08 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.nb_NO.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: + js: + gamification: + you: "Du" + leaderboard: + link_to_settings: "Instillinger" + refresh: "Last inn siden på nytt" + name: "Navn" + period: + all_time: "Totalt" + yearly: "Årlig" + quarterly: "Kvartalsvis" + monthly: "Månedlig" + weekly: "Ukentlig" + daily: "Daglig" + create: "Opprett" + cancel: "Avbryt" + close: "Lukk" + delete: "Slett" + edit: "Endre" + back: "Forrige" + save: "Lagre" + apply: "Bruk" + update_range: + last_10_days: "Siste 10 dager" + last_30_days: "Siste 30 dager" + last_90_days: "Siste 90 dager" + all_time: "Totalt" + custom_range_from: "Fra" + admin: + name: "Navn" diff --git a/plugins/discourse-gamification/config/locales/client.nl.yml b/plugins/discourse-gamification/config/locales/client.nl.yml new file mode 100644 index 00000000000..25f03670059 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.nl.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "Aanmoedigingen" + gamification: + score: "Aanmoedigingen" + you: "Jij" + leaderboard: + title: "Klassementen" + info: "Hoe werkt dit?" + link_to_settings: "Instellingen" + refresh: "Vernieuwen" + modal: + title: "Hoe werkt het klassement?" + text: "Punten worden toegekend voor interactie met de community, zoals bezoeken, likes en berichten. Je score wordt om de paar minuten bijgewerkt. Dus wees behulpzaam, actief en ondersteunend en stijg in het klassement!" + name: "Naam" + name_placeholder: "Naam..." + new: "Nieuw klassement" + create_success: "Klassement gemaakt" + delete_success: "Klassement verwijderd" + save_success: "Klassement opgeslagen" + cta: "Maak je eerste klassement" + none: "Nog geen klassementen gemaakt." + confirm_destroy: "Weet je zeker dat je dit klassement wilt verwijderen?" + date: + range: "Datumbereik van/tot" + from: "Vanaf datum" + to: "Tot datum" + helper: "Als de datums leeg worden gelaten, toont het klassement behaalde scores zonder enige tijdsbeperking." + visible_to_groups: "Zichtbaar voor groepen" + visible_to_groups_help: "Alleen gebruikers van die groepen kunnen het klassement bekijken. Laat dit leeg om iedereen toe te staan." + included_groups: "Opgenomen groepen" + included_groups_help: "Alleen gebruikers van die groepen wordenopgenomen in het klassement. Laat dit leeg om iedereen toe te staan." + excluded_groups: "Uitgesloten groepen" + excluded_groups_help: "Sluit gebruikers van die groepen uit van het klassement. Laat dit leeg om iedereen toe te staan." + default_period: "Standaard periode" + default_period_help: "Stel de standaard periode in die voor dit klassement moet worden weergegeven." + period_filter_disabled: "Tijdsperiodefilter uitschakelen" + period: + all_time: "Sinds het begin" + yearly: "Jaarlijks" + quarterly: "Driemaandelijks" + monthly: "Maandelijks" + weekly: "Wekelijks" + daily: "Dagelijks" + rank: "Rang" + create: "Maken" + cancel: "Annuleren" + close: "Sluiten" + delete: "Verwijderen" + edit: "Bewerken" + back: "Terug" + save: "Opslaan" + apply: "Toepassen" + recalculate: "Scores herberekenen" + recalculating: "Scores herberekenen..." + completed: "Gereed! De scores zijn herberekend." + update_scores_help: "Werk alle scores bij voor alle klassementen van" + update_range: + last_10_days: "Afgelopen 10 dagen" + last_30_days: "Afgelopen 30 dagen" + last_90_days: "Afgelopen 90 dagen" + last_year: "Afgelopen jaar" + all_time: "Sinds het begin" + custom_date_range: "Aangepast bereik" + custom_range_from: "Van" + daily_update_scores_availability: + one: "%{count} dagelijkse herberekening resterend" + other: "%{count} dagelijkse herberekeningen resterend" + admin: + title: "Gamificatie" + name: "Naam" + period: "Periode" diff --git a/plugins/discourse-gamification/config/locales/client.pl_PL.yml b/plugins/discourse-gamification/config/locales/client.pl_PL.yml new file mode 100644 index 00000000000..abd68091a70 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.pl_PL.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: + js: + gamification: + you: "Ty" + leaderboard: + link_to_settings: "Ustawienia" + refresh: "Odśwież" + name: "Nazwa" + period: + all_time: "Przez cały czas" + yearly: "Rocznie" + quarterly: "Kwartalnie" + monthly: "Miesięcznie" + weekly: "Tygodniowo" + daily: "Dziennie" + create: "Utwórz" + cancel: "Anuluj" + close: "Zamknij" + delete: "Usuń" + edit: "Edytuj" + back: "Poprzednia" + save: "Zapisz" + apply: "Zastosuj" + update_range: + last_10_days: "Ostatnie 10 dni" + last_30_days: "Ostatnie 30 dni" + last_90_days: "Ostatnie 90 dni" + all_time: "Przez cały czas" + custom_range_from: "Od" + admin: + name: "Imię" diff --git a/plugins/discourse-gamification/config/locales/client.pt.yml b/plugins/discourse-gamification/config/locales/client.pt.yml new file mode 100644 index 00000000000..faae96e8124 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.pt.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: + js: + gamification: + you: "Você" + leaderboard: + link_to_settings: "Configurações" + refresh: "Atualizar" + name: "Nome" + period: + all_time: "Desde Sempre" + yearly: "Anual" + quarterly: "Trimestral" + monthly: "Mensal" + weekly: "Semanal" + daily: "Diário" + create: "Criar" + cancel: "Cancelar" + close: "Fechar" + delete: "Eliminar" + edit: "Editar" + back: "Retroceder" + save: "Guardar" + apply: "Aplicar" + update_range: + last_10_days: "Últimos 10 Dias" + last_30_days: "Últimos 30 Dias" + last_90_days: "Últimos 90 Dias" + all_time: "Desde Sempre" + custom_range_from: "De" + admin: + name: "Nome" diff --git a/plugins/discourse-gamification/config/locales/client.pt_BR.yml b/plugins/discourse-gamification/config/locales/client.pt_BR.yml new file mode 100644 index 00000000000..345540f336a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.pt_BR.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "Saudações" + gamification: + score: "Saudações" + you: "Você" + leaderboard: + title: "Tabelas de classificação" + info: "Como isso funciona?" + link_to_settings: "Definições" + refresh: "Atualizar" + modal: + title: "Como funciona a tabela de classificação?" + text: "Os pontos são concedidos por se envolver com a comunidade, como visitar, curtir e postar. Sua pontuação é atualizada dentro de poucos minutos. Então, seja útil, ativo(a) e solidário(a), e suba na hierarquia!" + name: "Nome" + name_placeholder: "Nome..." + new: "Nova tabela de classificação" + create_success: "Tabela de classificação criada" + delete_success: "Tabela de classificação excluída" + save_success: "Tabela de classificação salva" + cta: "Faça sua primeira tabela de classificação" + none: "Nenhuma tabela de classificação foi criada ainda." + confirm_destroy: "Tem certeza de que deseja excluir essa tabela de classificação?" + date: + range: "Intervalo de datas De / Até" + from: "Data de início" + to: "Data final" + helper: "Se as datas forem deixadas em branco, a tabela de classificação mostrará as pontuações obtidas sem restrições de tempo." + visible_to_groups: "Visível para grupos" + visible_to_groups_help: "Apenas os usuários desses grupos poderão visualizar a tabela de classificação. Deixe vazio para permitir que todos visualizem." + included_groups: "Grupos incluídos" + included_groups_help: "Apenas os usuários desses grupos serão incluídos na tabela de classificação. Deixe vazio para listar todos." + excluded_groups: "Grupos excluídos" + excluded_groups_help: "Impeça que os usuários desses grupos sejam incluídos na tabela de classificação. Deixe em branco para listar todos." + default_period: "Período padrão" + default_period_help: "Defina o período de tempo padrão a ser exibido para este placar." + period_filter_disabled: "Desativar filtro de período" + period: + all_time: "Desde o início" + yearly: "Todo ano" + quarterly: "Todo semestre" + monthly: "Todo mês" + weekly: "A cada semana" + daily: "A cada dia" + rank: "Classificação" + create: "Criar" + cancel: "Cancelar" + close: "Fechar" + delete: "Excluir" + edit: "Editar" + back: "Voltar" + save: "Salvar" + apply: "Aplicar" + recalculate: "Recalcular pontuação" + recalculating: "Recalculando pontuação..." + completed: "Pronto! A pontuação foi recalculada com êxito." + update_scores_help: "Atualizar a pontuação de todos os placares" + update_range: + last_10_days: "Últimos 10 dias" + last_30_days: "Últimos 30 dias" + last_90_days: "Últimos 90 dias" + last_year: "Ano passado" + all_time: "Desde o início" + custom_date_range: "Intervalo personalizado" + custom_range_from: "De" + daily_update_scores_availability: + one: "%{count} recálculo diário restante" + other: "%{count} recálculo(s) diário(s) restante(s)" + admin: + title: "Gamificação" + name: "Nome" + period: "Período" diff --git a/plugins/discourse-gamification/config/locales/client.ro.yml b/plugins/discourse-gamification/config/locales/client.ro.yml new file mode 100644 index 00000000000..3d29d8bd41a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ro.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: + js: + gamification: + you: "Tu" + leaderboard: + link_to_settings: "Opțiuni" + refresh: "Reîmprospătează" + name: "Nume" + period: + all_time: "Dintotdeauna" + yearly: "Anual" + quarterly: "Trimestrial" + monthly: "Lunar" + weekly: "Săptămânal" + daily: "Zilnic" + create: "Creează" + cancel: "Anulare" + close: "Închide sondajul" + delete: "Șterge" + edit: "Modifică" + back: "Înapoi" + save: "Salvare" + apply: "Aplică" + update_range: + last_10_days: "Ultimele 10 de zile" + last_30_days: "Ultimele 30 de zile" + last_90_days: "Ultimele 90 de zile" + all_time: "Dintotdeauna" + custom_range_from: "De la" + admin: + name: "Nume" diff --git a/plugins/discourse-gamification/config/locales/client.ru.yml b/plugins/discourse-gamification/config/locales/client.ru.yml new file mode 100644 index 00000000000..e04a0f4db0f --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ru.yml @@ -0,0 +1,85 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Плагин Discourse «Геймификация»" + js: + gamification_score: "Репутация" + gamification: + score: "Репутация" + you: "Вы" + leaderboard: + title: "Таблица лидеров" + info: "Как это работает?" + link_to_settings: "Настройки" + refresh: "Обновить" + modal: + title: "Как устроена таблица лидеров?" + text: "Баллы начисляются за участие в сообществе, например за посещение, за выраженные симпатии, за публикацию сообщений. Баллы обновляются каждые несколько минут. Чем более вы активны на форуме, помогая другим участникам сообщества, тем больше баллов вам будет начислено!" + name: "Имя" + name_placeholder: "Название…" + new: "Новая таблица лидеров" + create_success: "Таблица лидеров создана" + delete_success: "Таблица лидеров удалена." + save_success: "Таблица лидеров сохранена." + cta: "Создать первую таблицу лидеров" + none: "Таблицы лидеров ещё не созданы." + confirm_destroy: "Вы действительно хотите удалить эту таблицу лидеров?" + date: + range: "Диапазон дат (с/по)" + from: "С даты" + to: "По дату" + helper: "Если даты не указаны, в таблице лидеров будут отображаться баллы без каких-либо ограничений по времени." + visible_to_groups: "Видна группам" + visible_to_groups_help: "Только пользователи из этих групп смогут просматривать таблицу лидеров. Оставьте поле пустым, чтобы разрешить доступ всем участникам." + included_groups: "Включённые группы" + included_groups_help: "Только пользователи из этих групп будут включены в таблицу лидеров. Оставьте поле пустым, чтобы отображать в таблице всех участников." + excluded_groups: "Исключённые группы" + excluded_groups_help: "Пользователи из этих групп не будут включены в таблицу лидеров. Оставьте поле пустым, чтобы отображать в таблице всех участников." + default_period: "Период по умолчанию" + default_period_help: "Установите период времени по умолчанию для отображения таблицы лидеров." + period_filter_disabled: "Отключить фильтр по периоду времени" + period: + all_time: "За всё время" + yearly: "За год" + quarterly: "За квартал" + monthly: "За месяц" + weekly: "За неделю" + daily: "За день" + rank: "Рейтинг" + create: "Создать" + cancel: "Отмена" + close: "Закрыть" + delete: "Удалить" + edit: "Редактировать" + back: "Назад" + save: "Сохранить" + apply: "Применить" + recalculate: "Пересчитать баллы" + recalculating: "Пересчет баллов..." + completed: "Готово! Баллы пересчитаны." + update_scores_help: "Обновить все баллы для всех таблиц лидеров:" + update_range: + last_10_days: "За последние 10 дней" + last_30_days: "За последние 30 дней" + last_90_days: "За последние 90 дней" + last_year: "За последний год" + all_time: "За всё время" + custom_date_range: "Настраиваемый диапазон" + custom_range_from: "От" + daily_update_scores_availability: + one: "Остался %{count} ежедневный пересчет" + few: "Осталось %{count} ежедневных пересчета" + many: "Осталось %{count} ежедневных пересчетов" + other: "Осталось %{count} ежедневного пересчета" + admin: + title: "Геймификация" + name: "Название" + period: "Период" diff --git a/plugins/discourse-gamification/config/locales/client.sk.yml b/plugins/discourse-gamification/config/locales/client.sk.yml new file mode 100644 index 00000000000..3da7b99b8ea --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sk.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: + js: + gamification: + you: "Vy" + leaderboard: + link_to_settings: "Nastavenia" + refresh: "Obnoviť" + name: "Meno" + name_placeholder: "Názov..." + period: + all_time: "Za celú dobu" + yearly: "Ročne" + quarterly: "Štvrťročne" + monthly: "Mesačne" + weekly: "Týždenne" + daily: "Denne" + create: "Vytvoriť" + cancel: "Zrušiť" + close: "Zavrieť" + delete: "Odstrániť" + edit: "Upraviť" + back: "Späť" + save: "Uložiť" + apply: "Použi" + update_range: + last_10_days: "Posledných 10 dní" + last_30_days: "Posledných 30 dní" + last_90_days: "Posledných 90 dní" + all_time: "Za celú dobu" + custom_range_from: "Od" + admin: + name: "Meno" diff --git a/plugins/discourse-gamification/config/locales/client.sl.yml b/plugins/discourse-gamification/config/locales/client.sl.yml new file mode 100644 index 00000000000..7d673ec6476 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sl.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: + js: + gamification: + you: "Vi" + leaderboard: + link_to_settings: "Nastavitve" + refresh: "Osveži" + name: "Ime" + period: + all_time: "Ves čas" + yearly: "V letu" + quarterly: "V četrtletju" + monthly: "Mesečno" + weekly: "Tedensko" + daily: "Dnevno" + create: "Ustvari" + cancel: "Prekliči" + close: "Zapri" + delete: "Izbriši" + edit: "Uredi" + back: "Nazaj" + save: "Shrani" + apply: "Uporabi" + update_range: + last_10_days: "Zadnjih 10 dni" + last_30_days: "Zadnjih 30 dni" + last_90_days: "Zadnjih 90 dni" + all_time: "Ves čas" + custom_range_from: "Od" + admin: + name: "Polno ime" diff --git a/plugins/discourse-gamification/config/locales/client.sq.yml b/plugins/discourse-gamification/config/locales/client.sq.yml new file mode 100644 index 00000000000..cc6c5b226e8 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sq.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: + js: + gamification: + you: "Ju" + leaderboard: + link_to_settings: "Rregullimet" + refresh: "Rifresko" + name: "Emri" + period: + all_time: "Gjithë Kohës" + yearly: "Vjetore" + quarterly: "Tremujorsh" + monthly: "Mujore" + weekly: "Javore" + daily: "Ditore" + cancel: "Anulo" + close: "Mbyll" + delete: "Fshij" + edit: "Redakto" + back: "Kthehu mbrapa" + save: "Ruaj" + apply: "Apliko" + update_range: + all_time: "Gjithë Kohës" + custom_range_from: "Nga" + admin: + name: "Emri" diff --git a/plugins/discourse-gamification/config/locales/client.sr.yml b/plugins/discourse-gamification/config/locales/client.sr.yml new file mode 100644 index 00000000000..c972417c3ca --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sr.yml @@ -0,0 +1,35 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: + js: + gamification: + you: "Ti" + leaderboard: + link_to_settings: "Podešavanja" + refresh: "Osveži" + name: "Ime foruma" + period: + all_time: "Oduvek" + yearly: "Top godišnje" + quarterly: "Top kvartalne" + monthly: "Top mesečne" + weekly: "Top nedeljne" + daily: "Top dnevne" + cancel: "Odustani" + close: "Zatvori" + delete: "Obriši" + edit: "Izmeni" + back: "Nazad" + save: "Sačuvaj" + apply: "Primeni" + update_range: + last_10_days: "Последњих 10 дана" + last_30_days: "Последњих 30 дана" + last_90_days: "Последњих 90 дана" + all_time: "Oduvek" + admin: + name: "Ime foruma" diff --git a/plugins/discourse-gamification/config/locales/client.sv.yml b/plugins/discourse-gamification/config/locales/client.sv.yml new file mode 100644 index 00000000000..b507361ad6d --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sv.yml @@ -0,0 +1,59 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + js: + gamification_score: "Hurra" + gamification: + score: "Hurra" + you: "Du" + leaderboard: + title: "Topplistor" + info: "Hur fungerar detta?" + link_to_settings: "Inställningar" + refresh: "Uppdatera" + modal: + title: "Hur fungerar topplistan?" + text: "Poäng delas ut för att du engagerar dig i gemenskapen, till exempel genom att besöka, gilla och publicera. Dina poäng uppdateras med några minuters mellanrum. Var hjälpsam, aktiv och stödjande och stig sedan i graderna!" + name: "Namn" + name_placeholder: "Namn..." + cta: "Skapa din första topplista" + none: "Inga topplistor har skapats ännu." + confirm_destroy: "Är du säker på att du vill ta bort denna topplista?" + date: + range: "Från / Till datumintervall" + helper: "Om datum lämnas tomma kommer topplistan att visa intjänade poäng utan tidsbegränsningar." + visible_to_groups: "Synlig för grupper" + visible_to_groups_help: "Endast användare i dessa grupper kommer att kunna se topplistan. Lämna tomt för att tillåta alla." + included_groups: "Inkluderade grupper" + included_groups_help: "Endast användare i dessa grupper kommer att inkluderas i topplistan. Lämna tomt för att lista alla." + excluded_groups: "Exkluderade grupper" + excluded_groups_help: "Ta bort användare i dessa grupper från att inkluderas i topplistan. Lämna tomt för att lista alla." + period: + all_time: "Alltid" + yearly: "Årsvis" + quarterly: "Kvartalsvis" + monthly: "Månadsvis" + weekly: "Veckovis" + daily: "Dagligen" + create: "Skapa" + cancel: "Avbryt" + close: "Stäng" + delete: "Radera" + edit: "Redigera" + back: "Tillbaka" + save: "Spara" + apply: "Tillämpa" + update_range: + last_10_days: "Senaste 10 dagarna" + last_30_days: "Senaste 30 dagarna" + last_90_days: "Senaste 90 dagarna" + all_time: "Alltid" + custom_range_from: "Från" + admin: + title: "Gamification" + name: "Namn" + period: "Period" diff --git a/plugins/discourse-gamification/config/locales/client.sw.yml b/plugins/discourse-gamification/config/locales/client.sw.yml new file mode 100644 index 00000000000..4e0092a2525 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.sw.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: + js: + gamification: + you: "Wewe" + leaderboard: + link_to_settings: "Mipangilio" + refresh: "Rudisha Tena" + name: "Jina" + period: + all_time: "Wakati wote" + yearly: "Kila Mwaka" + quarterly: "Kila baada ya miezi mitatu" + monthly: "Klla mwezi" + weekly: "Kila wiki" + daily: "Kila siku" + create: "Tengeneza" + cancel: "Ghairi" + close: "Funga" + delete: "Futa" + edit: "Hariri" + back: "Iliyopita" + save: "Hifadhi" + apply: "Tumia" + update_range: + all_time: "Wakati wote" + custom_range_from: "Kutoka" + admin: + name: "Jina" diff --git a/plugins/discourse-gamification/config/locales/client.te.yml b/plugins/discourse-gamification/config/locales/client.te.yml new file mode 100644 index 00000000000..0954b435137 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.te.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: + js: + gamification: + you: "మీరు" + leaderboard: + link_to_settings: "అమరికలు" + refresh: "తాజాపరుచు" + name: "పేరు" + period: + all_time: "ఆల్ టైమ్" + yearly: "వార్షిక" + quarterly: "త్రైమాసిక" + monthly: "నెలవారీ" + weekly: "వారానికోసారి" + daily: "రోజువారీ" + create: "సృష్టించండి" + cancel: "రద్దుచేయి" + close: "మూసివేయి" + delete: "తొలగించు" + edit: "సవరణ" + back: "వెనుకకు" + save: "భద్రపరుచు" + apply: "ఆపాదించు" + update_range: + all_time: "ఆల్ టైమ్" + custom_range_from: "నుండి" + admin: + name: "పేరు" diff --git a/plugins/discourse-gamification/config/locales/client.th.yml b/plugins/discourse-gamification/config/locales/client.th.yml new file mode 100644 index 00000000000..a4cd0ddfc95 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.th.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: + js: + gamification: + you: "คุณ" + leaderboard: + link_to_settings: "การตั้งค่า" + refresh: "รีเฟรช" + name: "ชื่อ" + period: + all_time: "ตลอดเวลา" + yearly: "รายปี" + quarterly: "รายไตรมาส" + monthly: "รายเดือน" + weekly: "รายสัปดาห์" + daily: "รายวัน" + create: "สร้าง" + cancel: "ยกเลิก" + close: "ปิด" + delete: "ลบ" + edit: "แก้ไข" + back: "กลับ" + save: "บันทึก" + apply: "นำไปใช้" + update_range: + last_10_days: "10 วันที่ผ่านมา" + last_30_days: "30 วันที่ผ่านมา" + last_90_days: "90 วันที่ผ่านมา" + all_time: "ตลอดเวลา" + custom_range_from: "จาก" + admin: + name: "ชื่อ" diff --git a/plugins/discourse-gamification/config/locales/client.tr_TR.yml b/plugins/discourse-gamification/config/locales/client.tr_TR.yml new file mode 100644 index 00000000000..d0769b6ae19 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.tr_TR.yml @@ -0,0 +1,83 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Oyunlaştırma" + js: + gamification_score: "Tezahürat" + gamification: + score: "Tezahürat" + you: "Siz" + leaderboard: + title: "Liderlik Tabloları" + info: "Bu nasıl işler?" + link_to_settings: "Ayarlar" + refresh: "Yenile" + modal: + title: "Liderlik tablosu nasıl işler?" + text: "Ziyaret etme, beğenme ve gönderme gibi toplulukla etkileşim için puan verilir. Puanınız birkaç dakikada bir güncellenir. Bu yüzden yardımsever, aktif ve destekleyici olun ve sıralamada yükselin!" + name: "Ad" + name_placeholder: "Ad..." + new: "Yeni liderlik tablosu" + create_success: "Liderlik tablosu oluşturuldu" + delete_success: "Liderlik tablosu silindi" + save_success: "Liderlik tablosu kaydedildi" + cta: "İlk liderlik tablonuzu oluşturun" + none: "Henüz liderlik tablosu oluşturulmadı." + confirm_destroy: "Bu liderlik tablosunu silmek istediğinizden emin misiniz?" + date: + range: "Başlangıç / Bitiş Tarih Aralığı" + from: "Başlangıç tarihi" + to: "Bitiş tarihi" + helper: "Tarihler boş bırakılırsa liderlik tablosu herhangi bir zaman kısıtlaması olmaksızın kazanılan puanları gösterir." + visible_to_groups: "Gruplara görünür" + visible_to_groups_help: "Sadece bu gruplardaki kullanıcılar liderlik tablosunu görüntüleyebilir. Herkese izin vermek için boş bırakın." + included_groups: "Dahil edilen gruplar" + included_groups_help: "Yalnızca bu gruplardaki kullanıcılar liderlik tablosuna dahil edilir. Herkesi listelemek için boş bırakın." + excluded_groups: "Hariç tutulan gruplar" + excluded_groups_help: "Bu gruplardaki kullanıcıların liderlik tablosuna dahil edilmesini kaldırın. Herkesi listelemek için boş bırakın." + default_period: "Varsayılan dönem" + default_period_help: "Bu liderlik tablosu için gösterilecek varsayılan zaman aralığını ayarlayın." + period_filter_disabled: "Zaman aralığı filtresini devre dışı bırak" + period: + all_time: "Tüm Zamanlar" + yearly: "Yıllık" + quarterly: "Üç aylık" + monthly: "Aylık" + weekly: "Haftalık" + daily: "Günlük" + rank: "Sıra" + create: "Oluştur" + cancel: "İptal et" + close: "Kapat" + delete: "Sil" + edit: "Düzenle" + back: "Geri" + save: "Kaydet" + apply: "Uygula" + recalculate: "Puanları yeniden hesapla" + recalculating: "Puanlar yeniden hesaplanıyor..." + completed: "Bitti! Puanlar başarıyla yeniden hesaplandı." + update_scores_help: "Şuradan tüm skor tabloları için tüm puanları güncelleyin:" + update_range: + last_10_days: "Son 10 gün" + last_30_days: "Son 30 gün" + last_90_days: "Son 90 gün" + last_year: "Geçen yıl" + all_time: "Tüm Zamanlar" + custom_date_range: "Özel aralık" + custom_range_from: "Başlangıç:" + daily_update_scores_availability: + one: "%{count} günlük yeniden hesaplama kaldı" + other: "%{count} günlük yeniden hesaplama kaldı" + admin: + title: "Oyunlaştırma" + name: "Ad" + period: "Dönem" diff --git a/plugins/discourse-gamification/config/locales/client.ug.yml b/plugins/discourse-gamification/config/locales/client.ug.yml new file mode 100644 index 00000000000..f58bf7af8b9 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ug.yml @@ -0,0 +1,34 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ug: + js: + gamification: + leaderboard: + link_to_settings: "تەڭشەكلەر" + refresh: "يېڭىلا" + name: "ئىسمى" + name_placeholder: "ئىسمى…" + period: + all_time: "ھەممە ۋاقىت" + yearly: "يىللىق" + quarterly: "پەسىللىك" + monthly: "ئايلىق" + weekly: "ھەپتىلىك" + daily: "كۈندىلىك" + create: "قۇر" + cancel: "ۋاز كەچ" + close: "تاقا" + delete: "ئۆچۈر" + edit: "تەھرىر" + back: "كەينى" + save: "ساقلا" + apply: "قوللان" + update_range: + all_time: "ھەممە ۋاقىت" + custom_range_from: "كىمدىن" + admin: + name: "ئىسمى" diff --git a/plugins/discourse-gamification/config/locales/client.uk.yml b/plugins/discourse-gamification/config/locales/client.uk.yml new file mode 100644 index 00000000000..bab7572d994 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.uk.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: + js: + gamification: + you: "Ви" + leaderboard: + link_to_settings: "Налаштування" + refresh: "Оновити" + name: "Назва" + name_placeholder: "Назва..." + period: + all_time: "Весь час" + yearly: "Щорічно" + quarterly: "Щоквартала" + monthly: "Щомісяця" + weekly: "Щотижня" + daily: "За день" + create: "Створити" + cancel: "Скасувати" + close: "Закрити" + delete: "Видалити" + edit: "Редагувати" + back: "Назад" + save: "Зберегти" + apply: "Застосувати" + update_range: + last_10_days: "Останні 10 днів" + last_30_days: "Останні 30 днів" + last_90_days: "Останні 90 днів" + all_time: "Весь час" + custom_range_from: "Від" + admin: + name: "Назва" diff --git a/plugins/discourse-gamification/config/locales/client.ur.yml b/plugins/discourse-gamification/config/locales/client.ur.yml new file mode 100644 index 00000000000..f0143c228cc --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.ur.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: + js: + gamification: + you: "آپ " + leaderboard: + link_to_settings: "ترتیبات" + refresh: "رِیفریش" + name: "نام" + period: + all_time: "تمام اوقات" + yearly: "سالانہ" + quarterly: "سہ ماہی" + monthly: "ماہانہ" + weekly: "ہفتہ وار" + daily: "روزانہ" + create: "بنائیں" + cancel: "منسوخ" + close: "بند کریں" + delete: "مٹائیں" + edit: "ترمیم کریں" + back: "واپس" + save: "محفوظ کریں" + apply: "لاگو کریں" + update_range: + last_10_days: "پچھلے 10 دن" + last_30_days: "پچھلے 30 دن" + last_90_days: "پچھلے 90 دن" + all_time: "تمام اوقات" + custom_range_from: "سے" + admin: + name: "نام" diff --git a/plugins/discourse-gamification/config/locales/client.vi.yml b/plugins/discourse-gamification/config/locales/client.vi.yml new file mode 100644 index 00000000000..0e193585659 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.vi.yml @@ -0,0 +1,37 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: + js: + gamification: + you: "Bạn" + leaderboard: + link_to_settings: "Cài đặt" + refresh: "Làm mới" + name: "Tên" + period: + all_time: "Từ trước tới nay" + yearly: "Hàng năm" + quarterly: "Hàng quý" + monthly: "Hàng tháng" + weekly: "Hàng tuần" + daily: "hằng ngày" + create: "Tạo" + cancel: "Huỷ" + close: "Đóng" + delete: "Xóa" + edit: "Sửa" + back: "Quay lại" + save: "Lưu lại" + apply: "Áp dụng" + update_range: + last_10_days: "10 ngày qua" + last_30_days: "30 ngày qua" + last_90_days: "90 ngày qua" + all_time: "Từ trước tới nay" + custom_range_from: "Từ" + admin: + name: "Tên" diff --git a/plugins/discourse-gamification/config/locales/client.zh_CN.yml b/plugins/discourse-gamification/config/locales/client.zh_CN.yml new file mode 100644 index 00000000000..dce68d040dd --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.zh_CN.yml @@ -0,0 +1,82 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + admin_js: + admin: + site_settings: + categories: + discourse_gamification: "Discourse Gamification" + js: + gamification_score: "点数" + gamification: + score: "点数" + you: "您" + leaderboard: + title: "排行榜" + info: "排行榜是如何运作的?" + link_to_settings: "设置" + refresh: "刷新" + modal: + title: "排行榜是如何运作的?" + text: "参与社区活动,如访问、点赞和发帖,都会获得积分。您的积分每几分钟就会更新一次。保持活跃,积极帮助并支持其他人来提高自己的排名!" + name: "名称" + name_placeholder: "名称…" + new: "新排行榜" + create_success: "排行榜已创建" + delete_success: "排行榜已删除" + save_success: "排行榜已保存" + cta: "制作您的第一个排行榜" + none: "尚未创建排行榜。" + confirm_destroy: "确定要删除此排行榜吗?" + date: + range: "开始/结束日期范围" + from: "起始日期" + to: "截止日期" + helper: "如果日期留空,排行榜将显示全部时间内获得的分数。" + visible_to_groups: "对以下群组可见" + visible_to_groups_help: "只有这些群组中的用户才能查看排行榜。留空以允许所有用户查看。" + included_groups: "包含的群组" + included_groups_help: "只有这些群组中的用户才会被包含到排行榜中。留空以让所有人都参与排行榜。" + excluded_groups: "排除的群组" + excluded_groups_help: "将这些群组中的用户从排行榜中移除。留空以让所有人都参与排行榜。" + default_period: "默认时间段" + default_period_help: "设置要为此排行榜显示的默认时间段。" + period_filter_disabled: "禁用时间段筛选器" + period: + all_time: "所有时间" + yearly: "每年" + quarterly: "每季度" + monthly: "每月" + weekly: "每周" + daily: "每天" + rank: "排名" + create: "创建" + cancel: "取消" + close: "关闭" + delete: "删除" + edit: "编辑" + back: "返回" + save: "保存" + apply: "应用" + recalculate: "重新计算分数" + recalculating: "正在重新计算分数…" + completed: "完成!分数已成功重新计算。" + update_scores_help: "更新以下时间范围内所有排行榜的所有分数:" + update_range: + last_10_days: "过去 10 天" + last_30_days: "过去 30 天" + last_90_days: "过去 90 天" + last_year: "去年" + all_time: "所有时间" + custom_date_range: "自定义范围" + custom_range_from: "从" + daily_update_scores_availability: + other: "剩余 %{count} 次每日重新计算" + admin: + title: "游戏化" + name: "名称" + period: "时间段" diff --git a/plugins/discourse-gamification/config/locales/client.zh_TW.yml b/plugins/discourse-gamification/config/locales/client.zh_TW.yml new file mode 100644 index 00000000000..9e01b33e9ff --- /dev/null +++ b/plugins/discourse-gamification/config/locales/client.zh_TW.yml @@ -0,0 +1,38 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: + js: + gamification: + you: "你" + leaderboard: + link_to_settings: "設定" + refresh: "重新整理" + name: "名字" + name_placeholder: "名字..." + period: + all_time: "所以時間" + yearly: "年" + quarterly: "季度" + monthly: "月" + weekly: "周" + daily: "日" + create: "創建" + cancel: "取消" + close: "關閉" + delete: "刪除" + edit: "編輯" + back: "上一步" + save: "保存" + apply: "套用" + update_range: + last_10_days: "過去 10 天" + last_30_days: "過去 30 天" + last_90_days: "過去 90 天" + all_time: "所有時間" + custom_range_from: "來自" + admin: + name: "名字" diff --git a/plugins/discourse-gamification/config/locales/server.ar.yml b/plugins/discourse-gamification/config/locales/server.ar.yml new file mode 100644 index 00000000000..db0ebfe70b5 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ar.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ar: + site_settings: + discourse_gamification_enabled: "تفعيل المكوِّن الإضافي لتلعيب Discourse" + like_received_score_value: "قيمة الهتافات الممنوح عند تلقي المستخدم إعجابًا" + like_given_score_value: "قيمة الهتافات الممنوح لكل إعجاب يمنحه المستخدم" + solution_score_value: "قيمة الهتافات الممنوح عند وضع علامة على منشور المستخدم كحل" + user_invited_score_value: "قيمة الهتافات الممنوح عند استخدام دعوة من المستخدم" + time_read_score_value: "قيمة الهتافات الممنوح لكل ساعة قراءة" + post_read_score_value: "قيمة الهتافات الممنوح لكل مئة منشور يقرأه المستخدم" + topic_created_score_value: "قيمة الهتافات الممنوح عند إنشاء المستخدم لموضوع" + post_created_score_value: "قيمة الهتافات الممنوح عند إنشاء المستخدم لمنشور" + flag_created_score_value: "قيمة الهتافات الممنوح عند وضع المستخدم علامة على منشور، ويتم قبول هذه العلامة من قِبل مستخدم في فريق العمل" + day_visited_score_value: "قيمة الهتافات الممنوح لكل يوم يزور فيه المستخدم الموقع" + scorable_categories: "قائمة الفئات التي ستُنشئ فيها الإجراءات هتافات. اتركه فارغًا لتفعيل الهتافات في جميع الفئات" + reaction_received_score_value: "قيمة الهتاف الممنوحة عند تلقي المستخدم تفاعلًا" + reaction_given_score_value: "قيمة الهتاف الممنوحة لكل تفاعل يمنحه المستخدم" + chat_reaction_received_score_value: "قيمة الهتاف الممنوحة عند تلقي المستخدم تفاعلًا على رسالة دردشة" + chat_reaction_given_score_value: "قيمة الهتاف الممنوحة لكل تفاعل يمنحه المستخدم على رسالة دردشة" + chat_message_created_score_value: "قيمة الهتاف الممنوحة لكل رسالة يرسلها مستخدم في دردشة" + score_ranking_strategy: "استراتيجية ترتيب مراكز لوحة المتصدرين" + score: "الهتافات" + default_leaderboard_name: "لوحة المتصدرين العالمية" + rate_limiter: + by_type: + recalculate_scores_remaining: "لقد وصلت إلى الحد الأقصى لإعادة حساب النقاط. يُرجى الانتظار %{time_left} قبل إعادة المحاولة." + errors: + leaderboard_positions_not_ready: "نحن نعمل على إنشاء لوحة المتصدرين الخاصة بك. جرِّب مجددًا بعد بضع دقائق." diff --git a/plugins/discourse-gamification/config/locales/server.be.yml b/plugins/discourse-gamification/config/locales/server.be.yml new file mode 100644 index 00000000000..2ea77a0d350 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.be.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +be: diff --git a/plugins/discourse-gamification/config/locales/server.bg.yml b/plugins/discourse-gamification/config/locales/server.bg.yml new file mode 100644 index 00000000000..52333529d3c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.bg.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bg: diff --git a/plugins/discourse-gamification/config/locales/server.bs_BA.yml b/plugins/discourse-gamification/config/locales/server.bs_BA.yml new file mode 100644 index 00000000000..828a7e65af8 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.bs_BA.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +bs_BA: diff --git a/plugins/discourse-gamification/config/locales/server.ca.yml b/plugins/discourse-gamification/config/locales/server.ca.yml new file mode 100644 index 00000000000..ec737bc1a5b --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ca.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ca: diff --git a/plugins/discourse-gamification/config/locales/server.cs.yml b/plugins/discourse-gamification/config/locales/server.cs.yml new file mode 100644 index 00000000000..8c6674fe38b --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.cs.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +cs: + site_settings: + discourse_gamification_enabled: "Povolit plugin Discourse Gamification" + like_received_score_value: "Hodnota ovace udělené, když uživatel obdrží Líbí se" + like_given_score_value: "Hodnota ovace za každé Líbí se, které uživatel udělí" + solution_score_value: "Hodnota ovace udělené, když je příspěvek uživatele označen jako řešení" + user_invited_score_value: "Hodnota ovace, která se uděluje, když je uplatněna pozvánka uživatele" + time_read_score_value: "Hodnota ovace za každou hodinu strávenou čtením" + post_read_score_value: "Hodnota ovace za každých sto příspěvků, které si uživatel přečte" + topic_created_score_value: "Hodnota ovace udělené, když uživatel vytvoří téma" + post_created_score_value: "Hodnota ovace udělené, když uživatel vytvoří příspěvek" + flag_created_score_value: "Hodnota ovace, která se uděluje, když uživatel nahlásí příspěvek, a tento příznak je přijat členem redakce" + day_visited_score_value: "Hodnota ovace za každý den, kdy uživatel navštíví web" + scorable_categories: "Seznam kategorií, kde aktivita bude generovat ovace. Ponechte prázdné, abyste povolili ovace ve všech kategoriích" + reaction_received_score_value: "Hodnota ovace udělené, když uživatel obdrží reakci" + reaction_given_score_value: "Hodnota ovace udělená za každou reakci uživatele" + chat_reaction_received_score_value: "Hodnota ovace udělené, když uživatel obdrží reakci na zprávu chatu" + chat_reaction_given_score_value: "Hodnota ovace udělené za každou reakci uživatele na zprávu chatu" + chat_message_created_score_value: "Hodnota ovace udělená za každou zprávu, kterou uživatel odešle v chatu" + score_ranking_strategy: "Strategie hodnocení pozice v žebříčku" + score: "Ovace" + default_leaderboard_name: "Globální žebříček" + rate_limiter: + by_type: + recalculate_scores_remaining: "Dosáhli jste maximálního počtu prepočítání skóre. Před dalším pokusem počkejte %{time_left}." + errors: + leaderboard_positions_not_ready: "Právě vytváříme váš žebříček. Zkuste to znovu za pár minut." diff --git a/plugins/discourse-gamification/config/locales/server.da.yml b/plugins/discourse-gamification/config/locales/server.da.yml new file mode 100644 index 00000000000..f39c727c594 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.da.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +da: diff --git a/plugins/discourse-gamification/config/locales/server.de.yml b/plugins/discourse-gamification/config/locales/server.de.yml new file mode 100644 index 00000000000..b082b471261 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.de.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +de: + site_settings: + discourse_gamification_enabled: "Discourse-Gamifizierung-Plug-in aktivieren" + like_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer ein „Gefällt mir“ erhält" + like_given_score_value: "Der Wert des Beifalls, der für jedes „Gefällt mir“ vergeben wird, das ein Benutzer gibt" + solution_score_value: "Der Wert des Beifalls, der vergeben wird, wenn der Beitrag eines Benutzers als Lösung markiert wird" + user_invited_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine eingelöste Einladung hat" + time_read_score_value: "Der Wert des Beifalls, der für jede Stunde Lesezeit vergeben wird" + post_read_score_value: "Der Wert des Beifalls, der für jeweils hundert Beiträge vergeben wird, die ein Benutzer liest" + topic_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer ein Thema erstellt" + post_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer einen Beitrag erstellt" + flag_created_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer einen Beitrag meldet und diese Meldung von einem Team-Benutzer akzeptiert wird" + day_visited_score_value: "Der Wert des Beifalls, der für jeden Tag vergeben wird, an dem ein Benutzer die Website besucht" + scorable_categories: "Liste der Kategorien, in denen Aktionen Beifall erzeugen. Leer lassen, um Beifall für alle Kategorien zu aktivieren" + reaction_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine Reaktion erhält" + reaction_given_score_value: "Der Wert des Beifalls, der für jede Reaktion vergeben wird, die ein Benutzer gibt" + chat_reaction_received_score_value: "Der Wert des Beifalls, der vergeben wird, wenn ein Benutzer eine Reaktion auf eine Chat-Nachricht erhält" + chat_reaction_given_score_value: "Der Wert des Beifalls, der für jede Reaktion vergeben wird, die ein Benutzer für eine Chat-Nachricht gibt" + chat_message_created_score_value: "Der Wert des Beifalls, der für jede Nachricht vergeben wird, die ein Benutzer in einem Chat sendet" + score_ranking_strategy: "Strategie für die Ranglistenposition" + score: "Beifall" + default_leaderboard_name: "Globale Rangliste" + rate_limiter: + by_type: + recalculate_scores_remaining: "Du hast die maximale Anzahl an Score-Neuberechnungen erreicht. Bitte warte %{time_left}, bevor du es wieder versuchst." + errors: + leaderboard_positions_not_ready: "Wir generieren gerade deine Rangliste. Versuche es in ein paar Minuten noch einmal." diff --git a/plugins/discourse-gamification/config/locales/server.el.yml b/plugins/discourse-gamification/config/locales/server.el.yml new file mode 100644 index 00000000000..d872d0ecc40 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.el.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +el: diff --git a/plugins/discourse-gamification/config/locales/server.en.yml b/plugins/discourse-gamification/config/locales/server.en.yml new file mode 100644 index 00000000000..877303a4469 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.en.yml @@ -0,0 +1,27 @@ +en: + site_settings: + discourse_gamification_enabled: "Enable the Discourse Gamification Plugin" + like_received_score_value: "The value of the cheer awarded when a user receives a like" + like_given_score_value: "The value of the cheer awarded for every like a user gives" + solution_score_value: "The value of the cheer awarded when a user's post is marked as a solution" + user_invited_score_value: "The value of the cheer awarded when a user has an invite redeemed" + time_read_score_value: "The value of the cheer awarded for every hour of time spent reading" + post_read_score_value: "The value of the cheer awarded for every one hundred posts a user reads" + topic_created_score_value: "The value of the cheer awarded when a user creates a topic" + post_created_score_value: "The value of the cheer awarded when a user creates a post" + flag_created_score_value: "The value of the cheer awarded when a user flags a post and that flag is accepted by a staff user" + day_visited_score_value: "The value of the cheer awarded for every day a user visits the site" + scorable_categories: "List of categories where actions will generate cheers. Leave empty to enable cheers on all categories" + reaction_received_score_value: "The value of the cheer awarded when a user receives a reaction" + reaction_given_score_value: "The value of the cheer awarded for every reaction a user gives" + chat_reaction_received_score_value: "The value of the cheer awarded when a user receives a reaction to a chat message" + chat_reaction_given_score_value: "The value of the cheer awarded for every reaction a user gives to a chat message" + chat_message_created_score_value: "The value of the cheer awarded for every message a user sends in a chat" + score_ranking_strategy: "Leaderboard position ranking strategy" + score: "Cheers" + default_leaderboard_name: "Global Leaderboard" + rate_limiter: + by_type: + recalculate_scores_remaining: "You’ve reached the maximum number of recalculating scores. Please wait %{time_left} before trying again." + errors: + leaderboard_positions_not_ready: "We are generating your leaderboard. Try again in a few minutes." diff --git a/plugins/discourse-gamification/config/locales/server.en_GB.yml b/plugins/discourse-gamification/config/locales/server.en_GB.yml new file mode 100644 index 00000000000..2d4fa180ec7 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.en_GB.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +en_GB: diff --git a/plugins/discourse-gamification/config/locales/server.es.yml b/plugins/discourse-gamification/config/locales/server.es.yml new file mode 100644 index 00000000000..de679f8f03c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.es.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +es: + site_settings: + discourse_gamification_enabled: "Activar el plugin Discourse Gamification" + like_received_score_value: "Puntos otorgados al autor de una publicación que reciba un «me gusta»" + like_given_score_value: "Puntos otorgados por cada me gusta que un usuario da" + solution_score_value: "Puntos otorgados cuando la publicación de un usuario es marcada como la solución" + user_invited_score_value: "Puntos otorgados a la persona cuya invitación haya sido usada para crear una nueva cuenta" + time_read_score_value: "Puntos otorgados por cada hora leyendo temas" + post_read_score_value: "Puntos otorgados por cada 100 publicaciones leídas" + topic_created_score_value: "Puntos otorgados por cada tema creado" + post_created_score_value: "Puntos otorgados por cada publicación creada" + flag_created_score_value: "Puntos otorgados a los usuarios por cada uno de sus reportes aceptados por el personal" + day_visited_score_value: "Puntos otorgados por cada día que un usuario visita el sitio" + scorable_categories: "Lista de categorías en las que repartir puntos. Dejar vacío para dar puntos en todas las categorías" + reaction_received_score_value: "Puntos otorgados al autor de una publicación que reciba una reacción" + reaction_given_score_value: "Puntos otorgados por cada reacción que un usuario da" + chat_reaction_received_score_value: "Puntos otorgados al autor de una publicación que reciba una reacción a un mensaje de chat" + chat_reaction_given_score_value: "Puntos otorgados por cada reacción que un usuario da a un mensaje de chat" + chat_message_created_score_value: "Puntos otorgados por cada mensaje que un usuario envía en un chat" + score_ranking_strategy: "Estrategia de clasificación por posiciones" + score: "Puntos" + default_leaderboard_name: "Clasificación global" + rate_limiter: + by_type: + recalculate_scores_remaining: "Ha alcanzado el número máximo de puntuaciones recalculables. Espere %{time_left} antes de intentarlo de nuevo." + errors: + leaderboard_positions_not_ready: "Estamos generando tu tabla de clasificación. Inténtalo de nuevo en unos minutos." diff --git a/plugins/discourse-gamification/config/locales/server.et.yml b/plugins/discourse-gamification/config/locales/server.et.yml new file mode 100644 index 00000000000..0ea0b6d554b --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.et.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +et: diff --git a/plugins/discourse-gamification/config/locales/server.fa_IR.yml b/plugins/discourse-gamification/config/locales/server.fa_IR.yml new file mode 100644 index 00000000000..5d08c4c2e83 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.fa_IR.yml @@ -0,0 +1,11 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fa_IR: + site_settings: + discourse_gamification_enabled: "فعال کردن افزونه بازی‌وارسازی دیسکورس" + score: "امتیازات" + default_leaderboard_name: "امتیازات سراسری" diff --git a/plugins/discourse-gamification/config/locales/server.fi.yml b/plugins/discourse-gamification/config/locales/server.fi.yml new file mode 100644 index 00000000000..e1144899fc2 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.fi.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fi: + site_settings: + discourse_gamification_enabled: "Ota Discourse Gamification -lisäosa käyttöön" + like_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa tykkäyksen" + like_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta tykkäyksestä" + solution_score_value: "Annetun hurrauksen arvo, kun käyttäjän viesti merkitään ratkaisuksi" + user_invited_score_value: "Annetun hurrauksen arvo, kun käyttäjän kutsu hyväksytään" + time_read_score_value: "Annetun hurrauksen arvo jokaisesta lukemiseen käytetystä tunnista" + post_read_score_value: "Annetun hurrauksen arvo jokaisesta sadasta viestistä, jotka käyttäjä lukee" + topic_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä luo ketjun" + post_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä luo viestin" + flag_created_score_value: "Annetun hurrauksen arvo, kun käyttäjä liputtaa viestin, ja henkilökunnan käyttäjä hyväksyy lipun" + day_visited_score_value: "Annetun hurrauksen arvo jokaisesta päivästä, jona käyttäjä vierailee sivustolla" + scorable_categories: "Luettelo alueista, joilla toiminta luo hurrauksia. Hurraukset ovat käytössä kaikilla alueilla, jos jätät tämän tyhjäksi." + reaction_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa reaktion" + reaction_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta reaktiosta" + chat_reaction_received_score_value: "Annetun hurrauksen arvo, kun käyttäjä saa reaktion chat-viestiin" + chat_reaction_given_score_value: "Annetun hurrauksen arvo jokaisesta käyttäjän antamasta reaktiosta chat-viestiin" + chat_message_created_score_value: "Annetun hurrauksen arvo jokaisesta viestistä, jonka käyttäjä lähettää chatissa" + score_ranking_strategy: "Tulostaulukon sijoitusstrategia" + score: "Hurraukset" + default_leaderboard_name: "Yleinen tulostaulukko" + rate_limiter: + by_type: + recalculate_scores_remaining: "Olet saavuttanut pisteiden uudelleenlaskennan enimmäismäärän. Odota %{time_left} ennen kuin yrität uudelleen." + errors: + leaderboard_positions_not_ready: "Luomme tulostaulukkoasi. Yritä uudelleen muutaman minuutin kuluttua." diff --git a/plugins/discourse-gamification/config/locales/server.fr.yml b/plugins/discourse-gamification/config/locales/server.fr.yml new file mode 100644 index 00000000000..cd46bcb2d53 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.fr.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +fr: + site_settings: + discourse_gamification_enabled: "Activer l'extension Discourse Gamification" + like_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une mention J'aime" + like_given_score_value: "La valeur de l'acclamation accordée pour chaque mention « J'aime » donnée par un utilisateur" + solution_score_value: "La valeur de l'acclamation accordée lorsque le message d'un utilisateur est marqué comme une solution" + user_invited_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur a reçu une invitation" + time_read_score_value: "La valeur de l'acclamation accordée pour chaque heure passée à lire" + post_read_score_value: "La valeur de l'acclamation accordée pour chaque lot de cent messages lus par un utilisateur" + topic_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur crée un sujet" + post_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur crée un message" + flag_created_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur signale un message et que ce signalement est accepté par un responsable" + day_visited_score_value: "La valeur de l'acclamation accordée pour chaque jour où un utilisateur visite le site" + scorable_categories: "Liste des catégories où les actions susciteront des acclamations. Laissez ce champ vide pour activer les acclamations dans toutes les catégories" + reaction_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une réaction" + reaction_given_score_value: "La valeur de l'acclamation accordée pour chaque réaction donnée par un utilisateur" + chat_reaction_received_score_value: "La valeur de l'acclamation accordée lorsqu'un utilisateur reçoit une réaction à un message de discussion" + chat_reaction_given_score_value: "La valeur de l'acclamation accordée pour chaque réaction donnée par un utilisateur à un message de discussion" + chat_message_created_score_value: "La valeur de l'acclamation accordée pour chaque message qu'un utilisateur envoie dans une discussion" + score_ranking_strategy: "Stratégie de classement de la position au tableau d'affichage" + score: "Acclamations" + default_leaderboard_name: "Classement mondial" + rate_limiter: + by_type: + recalculate_scores_remaining: "Vous avez atteint le nombre maximal de recalculs de scores. Veuillez patienter %{time_left} avant de réessayer." + errors: + leaderboard_positions_not_ready: "Nous générons votre classement. Réessayez dans quelques minutes." diff --git a/plugins/discourse-gamification/config/locales/server.gl.yml b/plugins/discourse-gamification/config/locales/server.gl.yml new file mode 100644 index 00000000000..fb911ce1635 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.gl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +gl: diff --git a/plugins/discourse-gamification/config/locales/server.he.yml b/plugins/discourse-gamification/config/locales/server.he.yml new file mode 100644 index 00000000000..cac106ce2ab --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.he.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +he: + site_settings: + discourse_gamification_enabled: "הפעלת תוסף המשחוק של Discourse" + like_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל לייק" + like_given_score_value: "ערך התשועה שמוענק עבור כל לייק שמשתמש נותן" + solution_score_value: "ערך התשועה שמוענק כאשר פוסט של משתמש מסומן כפתרון" + user_invited_score_value: "ערך התשועה שמוענק כאשר משתמש מקבל הזמנה שסולקה" + time_read_score_value: "ערך התשועה שמוענק עבור כל שעת קריאה" + post_read_score_value: "ערך התשועה שמוענק עבור כל מאה פוסטים שמשתמש קורא" + topic_created_score_value: "ערך התשועה שמוענק כאשר משתמש יוצר נושא" + post_created_score_value: "ערך התשועה שמוענק כאשר משתמש יוצר פוסט" + flag_created_score_value: "ערך התשועה שמוענק כאשר משתמש מסמן פוסט בדגל והדגל מתקבל על ידי משתמש צוות" + day_visited_score_value: "ערך התשועה שמוענק עבור כל יום בו משתמש מבקר באתר" + scorable_categories: "רשימת הקטגוריות בהן פעולות מייצרות תשועות. יש להשאיר ריק כדי להפעיל תשועות בכל הקטגוריות" + reaction_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל רגש" + reaction_given_score_value: "ערך התשועה שמוענק עבור כל רגש שמשתמש נותן" + chat_reaction_received_score_value: "ערך התשועה שמוענק כשמשתמש מקבל תגובה להודעה בצ׳אט" + chat_reaction_given_score_value: "ערך התשועה שמוענק עבור כל רגש שמשתמש הוסיף להודעת צ׳אט" + chat_message_created_score_value: "ערך התשועה שמוענק עבור כל הודעה שמשתמש שולח בצ׳אט" + score_ranking_strategy: "אסטרטגיית דירוג מיקום בלוח התוצאות" + score: "תשועות" + default_leaderboard_name: "לוח תוצאות עולמי" + rate_limiter: + by_type: + recalculate_scores_remaining: "הגעת למספר החישובים מחדש לניקוד המרבי. נא להמתין %{time_left} בטרם ביצוע ניסיון חוזר." + errors: + leaderboard_positions_not_ready: "אנו מייצרים את לוח התוצאות שלך. נא לנסות שוב בעוד כמה דקות." diff --git a/plugins/discourse-gamification/config/locales/server.hr.yml b/plugins/discourse-gamification/config/locales/server.hr.yml new file mode 100644 index 00000000000..e4e2c3d60b7 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.hr.yml @@ -0,0 +1,22 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hr: + site_settings: + discourse_gamification_enabled: "Omogućite dodatak za gamificiranje diskursa" + like_received_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik dobije lajk" + like_given_score_value: "Vrijednost navijanja koja se dodjeljuje za svaki lajk koji korisnik daje" + solution_score_value: "Vrijednost navijanja koja se dodjeljuje kada se objava korisnika označi kao rješenje" + user_invited_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik iskoristi pozivnicu" + time_read_score_value: "Vrijednost navijanja koja se dodjeljuje za svaki sat vremena provedenog u čitanju" + post_read_score_value: "Vrijednost navijanja koja se dodjeljuje za svakih sto postova koje korisnik pročita" + topic_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik kreira temu" + post_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik kreira objavu" + flag_created_score_value: "Vrijednost navijanja koja se dodjeljuje kada korisnik označi objavu i tu zastavu prihvaća korisnik osoblja" + day_visited_score_value: "Vrijednost navijanja koji se dodjeljuje za svaki dan kada korisnik posjeti web stranicu" + scorable_categories: "Popis kategorija u kojima će akcije generirati veselje. Ostavite prazno kako biste omogućili navijanje za sve kategorije" + score: "Živjeli" + default_leaderboard_name: "Globalna ploča s najboljim rezultatima" diff --git a/plugins/discourse-gamification/config/locales/server.hu.yml b/plugins/discourse-gamification/config/locales/server.hu.yml new file mode 100644 index 00000000000..024847e38ea --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.hu.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hu: + site_settings: + discourse_gamification_enabled: "Engedélyezze a Discourse Gamifikáció beépülő modult" + like_received_score_value: "Pontszám értéke, amelyet akkor kap a felhasználó, ha egy like-ot kap." + like_given_score_value: "Pontszám értéke, amelyet a felhasználó minden egyes like-ért kap." + solution_score_value: "Pontszám értéke, amikor egy felhasználó bejegyzését megoldásnak jelölik meg" + user_invited_score_value: "Pontszám értéke, amelyet a felhasználó után beváltott meghívókért kap" + time_read_score_value: "Pontszám értéke, amelyet minden eltert olvasott óráért jár" + post_read_score_value: "Pontszám értéke, amelyet minden századik elolvasott bejegyzés után jár." + topic_created_score_value: "Pontszám értéke, amely új téma létrehozásáért jár" + post_created_score_value: "Pontszám értéke, amely új bejegyzés létrehozásáért jár" + flag_created_score_value: "Pontszám értéke, ami minden olyan megjelölés után jár, amit egy stábtag elfogadott" + day_visited_score_value: "Pontszám értéke, amely minden napi első bejelentkezés után jár" + scorable_categories: "Azoknak a kategóriáknak a listája, ahol a cselekvésekért pontszámok járnak. Hagyja üresen, hogy az összes kategóriát engedélyezze" + reaction_received_score_value: "Pontszám értéke, amelyet akkor kapnak, amikor a felhasználó reakciót kap" + reaction_given_score_value: "Pontszám értéke, amelyet akkor kapnak, amikor a felhasználó reakciót ad" + chat_reaction_received_score_value: "Pontszám értéke, amelyet akkor kap a felhasználó, ha reagál egy chat-üzenetre." + chat_reaction_given_score_value: "A csevegőüzenetre adott felhasználói reakciókért járó pontszám" + chat_message_created_score_value: "A csevegés során a felhasználó által elküldött minden üzenetért járó pontszám" + score_ranking_strategy: "Ranglista pozíció rangsorolási stratégia" + score: "Pontok" + default_leaderboard_name: "Globális ranglista" + rate_limiter: + by_type: + recalculate_scores_remaining: "Elérte az újraszámítási pontszámok maximális számát. Kérjük, várjon %{time_left} , mielőtt újra próbálkozna." + errors: + leaderboard_positions_not_ready: "Létrehozzuk a ranglistát. Próbálja újra néhány perc múlva." diff --git a/plugins/discourse-gamification/config/locales/server.hy.yml b/plugins/discourse-gamification/config/locales/server.hy.yml new file mode 100644 index 00000000000..cb18f64d356 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.hy.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +hy: diff --git a/plugins/discourse-gamification/config/locales/server.id.yml b/plugins/discourse-gamification/config/locales/server.id.yml new file mode 100644 index 00000000000..596e36b2e13 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.id.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +id: diff --git a/plugins/discourse-gamification/config/locales/server.it.yml b/plugins/discourse-gamification/config/locales/server.it.yml new file mode 100644 index 00000000000..0f06f297518 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.it.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +it: + site_settings: + discourse_gamification_enabled: "Abilita il plugin di Discourse Gamification" + like_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve un Mi piace" + like_given_score_value: "Il valore dei complimenti assegnati per ogni Mi piace messo da un utente" + solution_score_value: "Il valore dei complimenti assegnati quando il messaggio di un utente è contrassegnato come soluzione" + user_invited_score_value: "Il valore dei complimenti assegnati quando un utente ha un invito riscattato" + time_read_score_value: "Il valore dei complimenti assegnati per ogni ora trascorsa leggendo" + post_read_score_value: "Il valore dei complimenti assegnati per ogni cento messaggi letti da un utente" + topic_created_score_value: "Il valore dei complimenti assegnati quando un utente crea un argomento" + post_created_score_value: "Il valore dei complimenti assegnati quando un utente crea un messaggio" + flag_created_score_value: "Il valore dei complimenti assegnati quando un utente segnala un messaggio e la segnalazione è accettata da un utente dello staff" + day_visited_score_value: "Il valore dei complimenti assegnati per ogni giorno in cui un utente visita il sito" + scorable_categories: "Elenco di categorie in cui le azioni genereranno complimenti. Lascia vuota l'opzione per abilitare i complimenti in tutte le categorie" + reaction_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve una reazione" + reaction_given_score_value: "Il valore dei complimenti assegnati per ogni reazione messa da un utente" + chat_reaction_received_score_value: "Il valore dei complimenti assegnati quando un utente riceve una reazione a un messaggio di chat" + chat_reaction_given_score_value: "Il valore dei complimenti assegnati per ogni reazione messa da un utente a un messaggio di chat" + chat_message_created_score_value: "Il valore dei complimenti assegnati per ogni messaggio che un utente invia in chat" + score_ranking_strategy: "Strategia di posizionamento in classifica" + score: "Complimenti" + default_leaderboard_name: "Classifica globale" + rate_limiter: + by_type: + recalculate_scores_remaining: "Hai raggiunto il numero massimo di ricalcoli di punteggio. Attendi %{time_left} prima di riprovare." + errors: + leaderboard_positions_not_ready: "Stiamo generando la tua classifica. Riprova tra qualche minuto." diff --git a/plugins/discourse-gamification/config/locales/server.ja.yml b/plugins/discourse-gamification/config/locales/server.ja.yml new file mode 100644 index 00000000000..0b00940d488 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ja.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ja: + site_settings: + discourse_gamification_enabled: "Discourse Gamification プラグインを有効にする" + like_received_score_value: "ユーザーが「いいね!」を受け取った時に付与される拍手の値" + like_given_score_value: "ユーザーが与える「いいね!」ごとに付与される拍手の値" + solution_score_value: "ユーザーの投稿が解決策としてマークされたときに付与される拍手の値" + user_invited_score_value: "ユーザーに招待の引き換えがあった時に付与される拍手の値" + time_read_score_value: "閲覧に費やした時間ごとに付与される拍手の値" + post_read_score_value: "ユーザーが投稿を 100 件読むたびに付与される拍手の値" + topic_created_score_value: "ユーザーがトピックを作成した時に付与される拍手の値" + post_created_score_value: "ユーザーが投稿を作成した時に付与される拍手の値" + flag_created_score_value: "ユーザーが投稿を通報し、その通報がスタッフユーザーに承認された時に付与される拍手の値" + day_visited_score_value: "ユーザーがサイトにアクセスする日ごとに付与される拍手の値" + scorable_categories: "アクションによって拍手が生成されるカテゴリのリスト。全カテゴリで拍手を有効にする場合は、空白のままにします。" + reaction_received_score_value: "ユーザーがリアクションを受けた時に付与される拍手の値" + reaction_given_score_value: "ユーザーがリアクションするたびに付与される拍手の値" + chat_reaction_received_score_value: "ユーザーがチャットメッセージでリアクションを受けた時に付与される拍手の値" + chat_reaction_given_score_value: "ユーザーがチャットメッセージにリアクションするたびに付与される拍手の値" + chat_message_created_score_value: "ユーザーがチャットでメッセージを送信するたびに付与される拍手の値" + score_ranking_strategy: "リーダーボード順位のランク付け戦略" + score: "拍手" + default_leaderboard_name: "グローバルリーダーボード" + rate_limiter: + by_type: + recalculate_scores_remaining: "スコアの再計算回数の上限に達しました。%{time_left}経ってから、もう一度お試しください。" + errors: + leaderboard_positions_not_ready: "リーダーボードを生成しています。数分後にもう一度お試しください。" diff --git a/plugins/discourse-gamification/config/locales/server.ko.yml b/plugins/discourse-gamification/config/locales/server.ko.yml new file mode 100644 index 00000000000..18dd77fd3ec --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ko.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ko: diff --git a/plugins/discourse-gamification/config/locales/server.lt.yml b/plugins/discourse-gamification/config/locales/server.lt.yml new file mode 100644 index 00000000000..16bb19758dc --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.lt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lt: diff --git a/plugins/discourse-gamification/config/locales/server.lv.yml b/plugins/discourse-gamification/config/locales/server.lv.yml new file mode 100644 index 00000000000..59e0ef6f4ed --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.lv.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +lv: diff --git a/plugins/discourse-gamification/config/locales/server.nb_NO.yml b/plugins/discourse-gamification/config/locales/server.nb_NO.yml new file mode 100644 index 00000000000..2e2224d1472 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.nb_NO.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nb_NO: diff --git a/plugins/discourse-gamification/config/locales/server.nl.yml b/plugins/discourse-gamification/config/locales/server.nl.yml new file mode 100644 index 00000000000..7b8775fc52c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.nl.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +nl: + site_settings: + discourse_gamification_enabled: "Schakel de Discourse-gamificatie-plug-in" + like_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een like ontvangt" + like_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke like die een gebruiker geeft" + solution_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een bericht van een gebruiker wordt gemarkeerd als oplossing" + user_invited_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een uitnodiging van een gebruiker wordt verzilverd" + time_read_score_value: "De waarde van de aanmoediging die wordt toegekend voor elk uur besteed aan lezen" + post_read_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke honderd berichten die een gebruiker leest" + topic_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een topic maakt" + post_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een bericht maakt" + flag_created_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een bericht markeert en de markering wordt geaccepteerd door een medewerker" + day_visited_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke dag dat een gebruiker de site bezoekt" + scorable_categories: "Lijst van categorieën waar acties aanmoedigingen opleveren. Laat dit leeg om aanmoedigingen in te schakelen voor alle categorieën" + reaction_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een reactie ontvangt" + reaction_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke reactie die een gebruiker geeft" + chat_reaction_received_score_value: "De waarde van de aanmoediging die wordt toegekend wanneer een gebruiker een reactie ontvangt op een chatbericht" + chat_reaction_given_score_value: "De waarde van de aanmoediging die wordt toegekend voor elke reactie die een gebruiker geeft op een chatbericht" + chat_message_created_score_value: "De waarde van de aanmoediging die wordt toegekend voor elk bericht dat een gebruik stuurt in een chat" + score_ranking_strategy: "Strategie voor bepaling van klassementspositie" + score: "Aanmoedigingen" + default_leaderboard_name: "Algemeen klassement" + rate_limiter: + by_type: + recalculate_scores_remaining: "U hebt het maximale aantal scoreherberekeningen bereikt. Wacht %{time_left} voordat u het opnieuw probeert." + errors: + leaderboard_positions_not_ready: "We genereren je klassement. Probeer het over enkele minuten opnieuw." diff --git a/plugins/discourse-gamification/config/locales/server.pl_PL.yml b/plugins/discourse-gamification/config/locales/server.pl_PL.yml new file mode 100644 index 00000000000..a260c31c64c --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.pl_PL.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pl_PL: diff --git a/plugins/discourse-gamification/config/locales/server.pt.yml b/plugins/discourse-gamification/config/locales/server.pt.yml new file mode 100644 index 00000000000..298ba523c1d --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.pt.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt: diff --git a/plugins/discourse-gamification/config/locales/server.pt_BR.yml b/plugins/discourse-gamification/config/locales/server.pt_BR.yml new file mode 100644 index 00000000000..c5f245d5ab1 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.pt_BR.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +pt_BR: + site_settings: + discourse_gamification_enabled: "Habilite o Plugin de Gamificação do Discourse" + like_received_score_value: "O valor da saudação concedida quando um usuário recebe uma curtida" + like_given_score_value: "O valor da saudação concedida por cada curtida feita por um usuário" + solution_score_value: "O valor da saudação concedida quando a postagem de um usuário é marcada como solução" + user_invited_score_value: "O valor da saudação concedida quando um usuário tiver um convite resgatado" + time_read_score_value: "O valor da saudação concedida por cada hora de tempo gasto na leitura" + post_read_score_value: "O valor da saudação concedida para cada cem postagens que um usuário lê" + topic_created_score_value: "O valor da saudação concedida quando um usuário cria um tópico" + post_created_score_value: "O valor da saudação concedida quando um usuário cria uma postagem" + flag_created_score_value: "O valor da saudação concedida quando um usuário sinaliza uma postagem e essa sinalização é aceita por um usuário da equipe" + day_visited_score_value: "O valor da saudação concedida para cada dia que um usuário visita o site" + scorable_categories: "Lista de categorias em que as ações vão gerar saudações. Deixe em branco para ativar saudações em todas as categorias" + reaction_received_score_value: "O valor da saudação concedida quando um(a) usuário(a) recebe uma reação" + reaction_given_score_value: "O valor da saudação concedida por cada reação feita por um(a) usuário(a)" + chat_reaction_received_score_value: "O valor da saudação concedida quando um(a) usuário(a) recebe uma reação para uma mensagem de chat" + chat_reaction_given_score_value: "O valor da saudação concedida por cada reação feita por um(a) usuário(a) para uma mensagem de chat" + chat_message_created_score_value: "O valor da saudação concedida por cada mensagem enviada por um(a) usuário(a) no chat" + score_ranking_strategy: "Estratégia de classificação de posição no placar" + score: "Saudações" + default_leaderboard_name: "Tabela de Classificação Global" + rate_limiter: + by_type: + recalculate_scores_remaining: "Você atingiu o número máximo de pontuação recalculada. Espere %{time_left} antes de tentar novamente." + errors: + leaderboard_positions_not_ready: "Estamos gerando seu placar. Tente novamente em alguns minutos." diff --git a/plugins/discourse-gamification/config/locales/server.ro.yml b/plugins/discourse-gamification/config/locales/server.ro.yml new file mode 100644 index 00000000000..08a77f812ee --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ro.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ro: diff --git a/plugins/discourse-gamification/config/locales/server.ru.yml b/plugins/discourse-gamification/config/locales/server.ru.yml new file mode 100644 index 00000000000..a38fa2990d0 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ru.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ru: + site_settings: + discourse_gamification_enabled: "Включить плагин геймификации" + like_received_score_value: "Баллы, начисляемые за получение симпатии" + like_given_score_value: "Баллы, начисляемые за выражение симпатии" + solution_score_value: "Баллы, начисляемые за сообщение, получившее статус 'Вопрос решён'" + user_invited_score_value: "Баллы, начисляемые за принятые приглашения" + time_read_score_value: "Баллы, начисляемые за каждый час, поведённый за чтением форума" + post_read_score_value: "Баллы, начисляемые за каждые сто сообщений, прочитанных пользователем" + topic_created_score_value: "Баллы, начисляемые за создание темы" + post_created_score_value: "Баллы, начисляемые за создание сообщения" + flag_created_score_value: "Баллы, начисляемые за жалобу, принятую персоналом форума" + day_visited_score_value: "Баллы, начисляемые за каждый день посещения форума" + scorable_categories: "Список разделов, в которых начисляются баллы. Оставьте этот список пустым, если баллы должны начисляться во всех разделах" + reaction_received_score_value: "Баллы, начисляемые за получение реакции" + reaction_given_score_value: "Баллы, начисляемые за каждую поставленную реакцию" + chat_reaction_received_score_value: "Баллы, начисляемые за получение реакции на сообщение в чате" + chat_reaction_given_score_value: "Баллы, начисляемые за каждую поставленную реакцию на сообщение в чате" + chat_message_created_score_value: "Баллы, начисляемые за каждое отправленное в чат сообщение" + score_ranking_strategy: "Стратегия ранжирования позиций в таблице лидеров" + score: "Репутация" + default_leaderboard_name: "Глобальная таблица лидеров" + rate_limiter: + by_type: + recalculate_scores_remaining: "Вы пересчитали баллы максимальное количество раз. Повторить попытку можно будет через %{time_left}." + errors: + leaderboard_positions_not_ready: "Мы создаем вашу таблицу лидеров. Повторите попытку через несколько минут." diff --git a/plugins/discourse-gamification/config/locales/server.sk.yml b/plugins/discourse-gamification/config/locales/server.sk.yml new file mode 100644 index 00000000000..6f815624081 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sk: diff --git a/plugins/discourse-gamification/config/locales/server.sl.yml b/plugins/discourse-gamification/config/locales/server.sl.yml new file mode 100644 index 00000000000..23489a48b1f --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sl.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sl: diff --git a/plugins/discourse-gamification/config/locales/server.sq.yml b/plugins/discourse-gamification/config/locales/server.sq.yml new file mode 100644 index 00000000000..7f051b7a7cf --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sq.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sq: diff --git a/plugins/discourse-gamification/config/locales/server.sr.yml b/plugins/discourse-gamification/config/locales/server.sr.yml new file mode 100644 index 00000000000..88d63d6ae1a --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sr.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sr: diff --git a/plugins/discourse-gamification/config/locales/server.sv.yml b/plugins/discourse-gamification/config/locales/server.sv.yml new file mode 100644 index 00000000000..4021071e6ea --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sv.yml @@ -0,0 +1,22 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sv: + site_settings: + discourse_gamification_enabled: "Aktivera insticksprogrammet Discourse Gamification" + like_received_score_value: "Värdet på hurrarop som tilldelas när en användare får en gillning" + like_given_score_value: "Värdet på hurrarop som tilldelas för varje gillning en användare ger" + solution_score_value: "Värdet på hurrarop som tilldelas när en användares inlägg markeras som en lösning" + user_invited_score_value: "Värdet på hurrarop som tilldelas när en användare har en inlöst inbjudan" + time_read_score_value: "Värdet på hurrarop som delas ut för varje timmes läsningstid" + post_read_score_value: "Värdet på hurrarop som tilldelas för varje hundra inlägg en användare läser" + topic_created_score_value: "Värdet på hurrarop som tilldelas när en användare skapar ett ämne" + post_created_score_value: "Värdet på hurrarop som tilldelas när en användare skapar ett inlägg" + flag_created_score_value: "Värdet på hurrarop som tilldelas när en användare flaggar ett inlägg och den flaggan accepteras av en personalanvändare" + day_visited_score_value: "Värdet på hurrarop som tilldelas för varje dag en användare besöker webbplatsen" + scorable_categories: "Lista över kategorier där åtgärder kommer att generera jubel. Lämna tomt för att aktivera hejarop i alla kategorier" + score: "Hurra" + default_leaderboard_name: "Global topplista" diff --git a/plugins/discourse-gamification/config/locales/server.sw.yml b/plugins/discourse-gamification/config/locales/server.sw.yml new file mode 100644 index 00000000000..0d7cdd075bf --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.sw.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +sw: diff --git a/plugins/discourse-gamification/config/locales/server.te.yml b/plugins/discourse-gamification/config/locales/server.te.yml new file mode 100644 index 00000000000..03967bdbb07 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.te.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +te: diff --git a/plugins/discourse-gamification/config/locales/server.th.yml b/plugins/discourse-gamification/config/locales/server.th.yml new file mode 100644 index 00000000000..7de85ff91c4 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.th.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +th: diff --git a/plugins/discourse-gamification/config/locales/server.tr_TR.yml b/plugins/discourse-gamification/config/locales/server.tr_TR.yml new file mode 100644 index 00000000000..21609da5e73 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.tr_TR.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +tr_TR: + site_settings: + discourse_gamification_enabled: "Discourse Oyunlaştırma Eklentisini Etkinleştirin" + like_received_score_value: "Bir kullanıcı bir beğeni aldığında verilen tezahüratın değeri" + like_given_score_value: "Bir kullanıcının verdiği her beğeni için verilen tezahüratın değeri" + solution_score_value: "Bir kullanıcının gönderisi çözüm olarak işaretlendiğinde verilen tezahüratın değeri" + user_invited_score_value: "Bir kullanıcı bir davetiyeyi kullandığında verilen tezahüratın değeri" + time_read_score_value: "Okumak için harcanan her saat için verilen tezahüratın değeri" + post_read_score_value: "Bir kullanıcının okuduğu her yüz gönderi için verilen tezahüratın değeri" + topic_created_score_value: "Bir kullanıcı bir konu oluşturduğunda verilen tezahüratın değeri" + post_created_score_value: "Bir kullanıcı bir gönderi oluşturduğunda verilen tezahüratın değeri" + flag_created_score_value: "Bir kullanıcı bir gönderiye bayrak eklendiğinde ve bu bayrak bir personel kullanıcı tarafından kabul edildiğinde verilen tezahüratın değeri" + day_visited_score_value: "Bir kullanıcının siteyi ziyaret ettiği her gün için verilen tezahüratın değeri" + scorable_categories: "Eylemlerin tezahürat oluşturacağı kategorilerin listesi. Tüm kategorilerde tezahüratları etkinleştirmek için boş bırakın" + reaction_received_score_value: "Bir kullanıcı tepki aldığında verilen tezahüratın değeri" + reaction_given_score_value: "Bir kullanıcının verdiği her tepki için verilen tezahüratın değeri" + chat_reaction_received_score_value: "Bir kullanıcı bir sohbet mesajına tepki aldığında verilen tezahüratın değeri" + chat_reaction_given_score_value: "Bir kullanıcının bir sohbet mesajına verdiği her tepki için verilen tezahüratın değeri" + chat_message_created_score_value: "Bir kullanıcının sohbette gönderdiği her mesaj için verilen tezahüratın değeri" + score_ranking_strategy: "Liderlik tablosu pozisyon sıralama stratejisi" + score: "Tezahürat" + default_leaderboard_name: "Küresel Liderlik Tablosu" + rate_limiter: + by_type: + recalculate_scores_remaining: "Maksimum puan yeniden hesaplama sayısına ulaştınız. Lütfen tekrar denemeden önce %{time_left} bekleyin." + errors: + leaderboard_positions_not_ready: "Liderlik tablonuzu oluşturuyoruz. Birkaç dakika içinde tekrar deneyin." diff --git a/plugins/discourse-gamification/config/locales/server.ug.yml b/plugins/discourse-gamification/config/locales/server.ug.yml new file mode 100644 index 00000000000..a6bf0185ae2 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ug.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ug: diff --git a/plugins/discourse-gamification/config/locales/server.uk.yml b/plugins/discourse-gamification/config/locales/server.uk.yml new file mode 100644 index 00000000000..f1390545d1d --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.uk.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +uk: diff --git a/plugins/discourse-gamification/config/locales/server.ur.yml b/plugins/discourse-gamification/config/locales/server.ur.yml new file mode 100644 index 00000000000..b4a9c21ee2f --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.ur.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +ur: diff --git a/plugins/discourse-gamification/config/locales/server.vi.yml b/plugins/discourse-gamification/config/locales/server.vi.yml new file mode 100644 index 00000000000..f629dcf5329 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.vi.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +vi: diff --git a/plugins/discourse-gamification/config/locales/server.zh_CN.yml b/plugins/discourse-gamification/config/locales/server.zh_CN.yml new file mode 100644 index 00000000000..270996983f9 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.zh_CN.yml @@ -0,0 +1,33 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_CN: + site_settings: + discourse_gamification_enabled: "启用 Discourse 游戏化插件" + like_received_score_value: "当用户收到点赞时所获得的点数" + like_given_score_value: "用户每给出一个点赞时所获得的点数" + solution_score_value: "当用户的帖子被标记为解决方案时获得的点数" + user_invited_score_value: "当用户发出的邀请被兑换时所获得的点数" + time_read_score_value: "每花一小时时间阅读所获得的点数" + post_read_score_value: "用户每阅读 100 个帖子所获得的点数" + topic_created_score_value: "当用户创建话题时所获得的点数" + post_created_score_value: "当用户创建帖子时所获得的点数" + flag_created_score_value: "当用户举报帖子并且该举报被管理人员接受时所获得的点数" + day_visited_score_value: "用户每天访问站点所获得的点数" + scorable_categories: "操作将生成点数的类别列表。留空以在所有类别上启用点数。" + reaction_received_score_value: "当用户收到回应时所获得的点数" + reaction_given_score_value: "用户每给出一个回应时所获得的点数" + chat_reaction_received_score_value: "当用户收到聊天消息的回应时所获得的点数" + chat_reaction_given_score_value: "用户每给出一个聊天消息的回应时所获得的点数" + chat_message_created_score_value: "用户在聊天中发送的每条消息所获得的点数" + score_ranking_strategy: "排行榜位置排名策略" + score: "点数" + default_leaderboard_name: "全局排行榜" + rate_limiter: + by_type: + recalculate_scores_remaining: "您已达到重新计算分数的最大次数。请等待 %{time_left}后再试。" + errors: + leaderboard_positions_not_ready: "我们正在生成您的排行榜。请几分钟后再试。" diff --git a/plugins/discourse-gamification/config/locales/server.zh_TW.yml b/plugins/discourse-gamification/config/locales/server.zh_TW.yml new file mode 100644 index 00000000000..7e15fab0018 --- /dev/null +++ b/plugins/discourse-gamification/config/locales/server.zh_TW.yml @@ -0,0 +1,7 @@ +# WARNING: Never edit this file. +# It will be overwritten when translations are pulled from Crowdin. +# +# To work with us on translations, join this project: +# https://translate.discourse.org/ + +zh_TW: diff --git a/plugins/discourse-gamification/config/routes.rb b/plugins/discourse-gamification/config/routes.rb new file mode 100644 index 00000000000..a13bdc7af76 --- /dev/null +++ b/plugins/discourse-gamification/config/routes.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +DiscourseGamification::Engine.routes.draw do + get "/" => "gamification_leaderboard#respond" + get "/:id" => "gamification_leaderboard#respond" +end + +Discourse::Application.routes.draw do + mount ::DiscourseGamification::Engine, at: "/leaderboard" + + scope "/admin/plugins/discourse-gamification", constraints: StaffConstraint.new do + get "/leaderboards" => "discourse_gamification/admin_gamification_leaderboard#index" + get "/leaderboards/:id" => "discourse_gamification/admin_gamification_leaderboard#show" + end + + get "/admin/plugins/gamification" => + "discourse_gamification/admin_gamification_leaderboard#index", + :constraints => StaffConstraint.new + post "/admin/plugins/gamification/leaderboard" => + "discourse_gamification/admin_gamification_leaderboard#create", + :constraints => StaffConstraint.new + put "/admin/plugins/gamification/leaderboard/:id" => + "discourse_gamification/admin_gamification_leaderboard#update", + :constraints => StaffConstraint.new + delete "/admin/plugins/gamification/leaderboard/:id" => + "discourse_gamification/admin_gamification_leaderboard#destroy", + :constraints => StaffConstraint.new + put "/admin/plugins/gamification/recalculate-scores" => + "discourse_gamification/admin_gamification_leaderboard#recalculate_scores", + :constraints => StaffConstraint.new, + :as => :recalculate_scores +end + +Discourse::Application.routes.draw do + get "/admin/plugins/gamification/score_events" => + "discourse_gamification/admin_gamification_score_event#show", + :constraints => StaffConstraint.new + post "/admin/plugins/gamification/score_events" => + "discourse_gamification/admin_gamification_score_event#create", + :constraints => StaffConstraint.new + put "/admin/plugins/gamification/score_events" => + "discourse_gamification/admin_gamification_score_event#update", + :constraints => StaffConstraint.new +end diff --git a/plugins/discourse-gamification/config/settings.yml b/plugins/discourse-gamification/config/settings.yml new file mode 100644 index 00000000000..648934f0425 --- /dev/null +++ b/plugins/discourse-gamification/config/settings.yml @@ -0,0 +1,44 @@ +discourse_gamification: + discourse_gamification_enabled: + default: false + client: true + scorable_categories: + type: category_list + default: "" + like_received_score_value: + default: 1 + like_given_score_value: + default: 1 + solution_score_value: + default: 10 + user_invited_score_value: + default: 10 + time_read_score_value: + default: 1 + post_read_score_value: + default: 1 + topic_created_score_value: + default: 5 + post_created_score_value: + default: 2 + flag_created_score_value: + default: 10 + day_visited_score_value: + default: 1 + reaction_received_score_value: + default: 1 + reaction_given_score_value: + default: 1 + chat_reaction_received_score_value: + default: 1 + chat_reaction_given_score_value: + default: 1 + chat_message_created_score_value: + default: 1 + score_ranking_strategy: + default: dense_rank + type: enum + choices: + - dense_rank + - rank + - row_number diff --git a/plugins/discourse-gamification/db/.gitkeep b/plugins/discourse-gamification/db/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/discourse-gamification/db/fixtures/001_gamification_leaderboards.rb b/plugins/discourse-gamification/db/fixtures/001_gamification_leaderboards.rb new file mode 100644 index 00000000000..4435bb5e11e --- /dev/null +++ b/plugins/discourse-gamification/db/fixtures/001_gamification_leaderboards.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +return if Rails.env.test? || DiscourseGamification::GamificationLeaderboard.any? + +DiscourseGamification::GamificationLeaderboard.seed(:name) do |leaderboard| + leaderboard.name = I18n.t("default_leaderboard_name") + leaderboard.created_by_id = Discourse.system_user.id +end diff --git a/plugins/discourse-gamification/db/migrate/20220314190045_create_gamification_score_table.rb b/plugins/discourse-gamification/db/migrate/20220314190045_create_gamification_score_table.rb new file mode 100644 index 00000000000..0ac5a18cc77 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20220314190045_create_gamification_score_table.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +class CreateGamificationScoreTable < ActiveRecord::Migration[6.1] + def change + create_table :gamification_scores do |t| + t.integer :user_id, null: false + t.date :date, null: false + t.integer :score, null: false + end + + add_index :gamification_scores, %i[user_id date], unique: true + add_index :gamification_scores, :date + end +end diff --git a/plugins/discourse-gamification/db/migrate/20220315172912_add_score_to_directory_items.rb b/plugins/discourse-gamification/db/migrate/20220315172912_add_score_to_directory_items.rb new file mode 100644 index 00000000000..70582e21c13 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20220315172912_add_score_to_directory_items.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class AddScoreToDirectoryItems < ActiveRecord::Migration[6.1] + def up + add_column :directory_items, :gamification_score, :integer, default: 0 + end + + def down + remove_column :directory_items, :gamification_score + end +end diff --git a/plugins/discourse-gamification/db/migrate/20220324210218_create_gamification_leaderboard_table.rb b/plugins/discourse-gamification/db/migrate/20220324210218_create_gamification_leaderboard_table.rb new file mode 100644 index 00000000000..64ad27a339a --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20220324210218_create_gamification_leaderboard_table.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +class CreateGamificationLeaderboardTable < ActiveRecord::Migration[6.1] + def change + create_table :gamification_leaderboards do |t| + t.string :name, null: false + t.date :from_date, null: true + t.date :to_date, null: true + t.integer :for_category_id, null: true + t.integer :created_by_id, null: false + t.timestamps + end + + add_index :gamification_leaderboards, [:name], unique: true + end +end diff --git a/plugins/discourse-gamification/db/migrate/20220331203401_add_groups_to_leaderboards.rb b/plugins/discourse-gamification/db/migrate/20220331203401_add_groups_to_leaderboards.rb new file mode 100644 index 00000000000..178a30f56bd --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20220331203401_add_groups_to_leaderboards.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +class AddGroupsToLeaderboards < ActiveRecord::Migration[6.1] + def change + add_column :gamification_leaderboards, + :visible_to_groups_ids, + :integer, + array: true, + null: false, + default: [] + add_column :gamification_leaderboards, + :included_groups_ids, + :integer, + array: true, + null: false, + default: [] + end +end diff --git a/plugins/discourse-gamification/db/migrate/20220623182333_add_excluded_groups_to_leaderboards.rb b/plugins/discourse-gamification/db/migrate/20220623182333_add_excluded_groups_to_leaderboards.rb new file mode 100644 index 00000000000..ac237901968 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20220623182333_add_excluded_groups_to_leaderboards.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class AddExcludedGroupsToLeaderboards < ActiveRecord::Migration[6.1] + def change + add_column :gamification_leaderboards, + :excluded_groups_ids, + :integer, + array: true, + null: false, + default: [] + end +end diff --git a/plugins/discourse-gamification/db/migrate/20221019171131_add_default_period_to_leaderboards.rb b/plugins/discourse-gamification/db/migrate/20221019171131_add_default_period_to_leaderboards.rb new file mode 100644 index 00000000000..696d7ce25d2 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20221019171131_add_default_period_to_leaderboards.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class AddDefaultPeriodToLeaderboards < ActiveRecord::Migration[6.1] + def up + add_column :gamification_leaderboards, :default_period, :integer, default: 0 + end + + def down + remove_column :gamification_leaderboards, :default_period + end +end diff --git a/plugins/discourse-gamification/db/migrate/20230420185415_create_gamification_score_events.rb b/plugins/discourse-gamification/db/migrate/20230420185415_create_gamification_score_events.rb new file mode 100644 index 00000000000..f9e6d05e2d6 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20230420185415_create_gamification_score_events.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateGamificationScoreEvents < ActiveRecord::Migration[7.0] + def change + create_table :gamification_score_events do |t| + t.integer :user_id, null: false + t.date :date, null: false + t.integer :points, null: false + t.text :description, null: true + + t.timestamps + end + + add_index :gamification_score_events, %i[user_id date], unique: false + add_index :gamification_score_events, %i[date], unique: false + end +end diff --git a/plugins/discourse-gamification/db/migrate/20250102185307_add_period_filter_disabled_to_leaderboards.rb b/plugins/discourse-gamification/db/migrate/20250102185307_add_period_filter_disabled_to_leaderboards.rb new file mode 100644 index 00000000000..602070a2c62 --- /dev/null +++ b/plugins/discourse-gamification/db/migrate/20250102185307_add_period_filter_disabled_to_leaderboards.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddPeriodFilterDisabledToLeaderboards < ActiveRecord::Migration[7.2] + def change + add_column :gamification_leaderboards, + :period_filter_disabled, + :boolean, + default: false, + null: false + end +end diff --git a/plugins/discourse-gamification/db/post_migrate/20250210133038_drop_versioned_leaderboard_materialized_views.rb b/plugins/discourse-gamification/db/post_migrate/20250210133038_drop_versioned_leaderboard_materialized_views.rb new file mode 100644 index 00000000000..006d15c8a59 --- /dev/null +++ b/plugins/discourse-gamification/db/post_migrate/20250210133038_drop_versioned_leaderboard_materialized_views.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class DropVersionedLeaderboardMaterializedViews < ActiveRecord::Migration[7.2] + def up + versioned_mviews_query = <<~SQL + SELECT cls.relname + FROM pg_class cls + INNER JOIN pg_namespace ns ON ns.oid = cls.relnamespace + WHERE cls.relname ~ 'gamification_leaderboard_cache_[0-9]+_[a-zA-Z_]+_[1-9]$' + AND cls.relkind = 'm' + AND ns.nspname = 'public' + SQL + + mviews = DB.query_single(versioned_mviews_query) + + return if mviews.empty? + + execute <<~SQL + DROP MATERIALIZED VIEW IF EXISTS #{mviews.join(", ")} CASCADE + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/plugins/discourse-gamification/jobs/regular/delete_leaderboard_positions.rb b/plugins/discourse-gamification/jobs/regular/delete_leaderboard_positions.rb new file mode 100644 index 00000000000..57ac612713f --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/delete_leaderboard_positions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Jobs + class DeleteLeaderboardPositions < ::Jobs::Base + def execute(args) + leaderboard_id = args[:leaderboard_id] + raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? + + leaderboard = + DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) || + DiscourseGamification::DeletedGamificationLeaderboard.new(leaderboard_id) + + DiscourseGamification::LeaderboardCachedView.new(leaderboard).delete + end + end +end diff --git a/plugins/discourse-gamification/jobs/regular/generate_leaderboard_positions.rb b/plugins/discourse-gamification/jobs/regular/generate_leaderboard_positions.rb new file mode 100644 index 00000000000..77a1c265044 --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/generate_leaderboard_positions.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jobs + class GenerateLeaderboardPositions < ::Jobs::Base + def execute(args) + leaderboard_id = args[:leaderboard_id] + raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? + + DistributedMutex.synchronize( + "gamification_generate_leaderboard_positions_#{leaderboard_id}", + validity: 5.minutes, + ) do + leaderboard = DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) + return unless leaderboard + + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + end + end + end +end diff --git a/plugins/discourse-gamification/jobs/regular/recalculate_scores.rb b/plugins/discourse-gamification/jobs/regular/recalculate_scores.rb new file mode 100644 index 00000000000..32781a8abcb --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/recalculate_scores.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Jobs + class RecalculateScores < ::Jobs::Base + def execute(args) + user_id = args[:user_id] + raise Discourse::InvalidParameters.new(:user_id) if user_id.blank? + + DiscourseGamification::GamificationScore.calculate_scores( + since_date: args[:since] || 10.days.ago, + ) + + ::MessageBus.publish "/recalculate_scores", + { + success: true, + remaining: + DiscourseGamification::RecalculateScoresRateLimiter.remaining, + user_id: [user_id], + } + end + end +end diff --git a/plugins/discourse-gamification/jobs/regular/refresh_leaderboard_positions.rb b/plugins/discourse-gamification/jobs/regular/refresh_leaderboard_positions.rb new file mode 100644 index 00000000000..2fb4f83beff --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/refresh_leaderboard_positions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + class RefreshLeaderboardPositions < ::Jobs::Base + def execute(args) + leaderboard_id = args[:leaderboard_id] + raise Discourse::InvalidParameters.new(:leaderboard_id) if leaderboard_id.blank? + + leaderboard = DiscourseGamification::GamificationLeaderboard.find_by(id: leaderboard_id) + return unless leaderboard + + DiscourseGamification::LeaderboardCachedView.new(leaderboard).refresh + end + end +end diff --git a/plugins/discourse-gamification/jobs/regular/regenerate_leaderboard_positions.rb b/plugins/discourse-gamification/jobs/regular/regenerate_leaderboard_positions.rb new file mode 100644 index 00000000000..924bdc309e1 --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/regenerate_leaderboard_positions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Jobs + class RegenerateLeaderboardPositions < ::Jobs::Base + def execute(args = nil) + DiscourseGamification::LeaderboardCachedView.regenerate_all + end + end +end diff --git a/plugins/discourse-gamification/jobs/regular/update_stale_leaderboard_positions.rb b/plugins/discourse-gamification/jobs/regular/update_stale_leaderboard_positions.rb new file mode 100644 index 00000000000..a93ccba0d84 --- /dev/null +++ b/plugins/discourse-gamification/jobs/regular/update_stale_leaderboard_positions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Jobs + class UpdateStaleLeaderboardPositions < ::Jobs::Base + def execute(args = nil) + DiscourseGamification::LeaderboardCachedView.update_all + end + end +end diff --git a/plugins/discourse-gamification/jobs/scheduled/update_scores_for_ten_days.rb b/plugins/discourse-gamification/jobs/scheduled/update_scores_for_ten_days.rb new file mode 100644 index 00000000000..1babfb28807 --- /dev/null +++ b/plugins/discourse-gamification/jobs/scheduled/update_scores_for_ten_days.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + class UpdateScoresForTenDays < ::Jobs::Scheduled + every 1.day + + def execute(args = nil) + DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago.midnight) + end + end +end diff --git a/plugins/discourse-gamification/jobs/scheduled/update_scores_for_today.rb b/plugins/discourse-gamification/jobs/scheduled/update_scores_for_today.rb new file mode 100644 index 00000000000..4d4487cf3c0 --- /dev/null +++ b/plugins/discourse-gamification/jobs/scheduled/update_scores_for_today.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + class UpdateScoresForToday < ::Jobs::Scheduled + every 1.hour + + def execute(args = nil) + DiscourseGamification::GamificationScore.calculate_scores + + DiscourseGamification::LeaderboardCachedView.purge_all_stale + DiscourseGamification::LeaderboardCachedView.refresh_all + DiscourseGamification::LeaderboardCachedView.create_all + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/directory_integration.rb b/plugins/discourse-gamification/lib/discourse_gamification/directory_integration.rb new file mode 100644 index 00000000000..6f172eb3524 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/directory_integration.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class DirectoryIntegration + def self.query + <<~SQL + WITH default_leaderboard AS ( + SELECT + from_date, + to_date + FROM + gamification_leaderboards + ORDER BY + id ASC + LIMIT 1 + ), total_score AS ( + SELECT + user_id, + SUM(score) AS score + FROM + gamification_scores + LEFT JOIN + default_leaderboard ON true + WHERE + date >= :since + AND + ( + ( + default_leaderboard.from_date IS NULL + OR + date >= default_leaderboard.from_date + ) + AND + ( + default_leaderboard.to_date IS NULL + OR + date <= default_leaderboard.to_date + ) + ) + GROUP BY + 1 + ), scored_directory AS ( + SELECT + directory_items.user_id, + COALESCE(total_score.score, 0) AS score + FROM + directory_items + LEFT JOIN + total_score ON total_score.user_id = directory_items.user_id + WHERE + directory_items.period_type = :period_type + ) + UPDATE + directory_items + SET + gamification_score = scored_directory.score + FROM + scored_directory + WHERE + scored_directory.user_id = directory_items.user_id AND + directory_items.period_type = :period_type AND + scored_directory.score != directory_items.gamification_score + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/engine.rb b/plugins/discourse-gamification/lib/discourse_gamification/engine.rb new file mode 100644 index 00000000000..4aa3b4ed3d7 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/engine.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace DiscourseGamification + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/guardian_extension.rb b/plugins/discourse-gamification/lib/discourse_gamification/guardian_extension.rb new file mode 100644 index 00000000000..8bb66a05e4c --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/guardian_extension.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + module GuardianExtension + def can_see_leaderboard?(leaderboard) + return true if leaderboard.visible_to_groups_ids.empty? + return true if self.is_admin? + return true if self.user && !(leaderboard.visible_to_groups_ids & self.user.group_ids).empty? + + false + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/leaderboard_cached_view.rb b/plugins/discourse-gamification/lib/discourse_gamification/leaderboard_cached_view.rb new file mode 100644 index 00000000000..d7146b32c03 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/leaderboard_cached_view.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class LeaderboardCachedView + class NotReadyError < StandardError + end + + SCORE_RANKING_STRATEGY_MAP = { + row_number: "ROW_NUMBER()", + rank: "RANK()", + dense_rank: "DENSE_RANK()", + }.freeze + PERIOD_INTERVALS = { + "yearly" => "CURRENT_DATE - INTERVAL '1 year'", + "quarterly" => "CURRENT_DATE - INTERVAL '3 months'", + "monthly" => "CURRENT_DATE - INTERVAL '1 month'", + "weekly" => "CURRENT_DATE - INTERVAL '1 week'", + "daily" => "CURRENT_DATE - INTERVAL '1 day'", + }.freeze + + attr_reader :leaderboard + + def initialize(leaderboard) + @leaderboard = leaderboard + end + + def create + periods.each { |period| create_mview(period) } + end + + def refresh + periods.each { |period| refresh_mview(period) } + end + + def delete + periods.each { |period| delete_mview(period) } + end + + def purge_stale + list = stale_mviews + + return if list.empty? + + DB.exec("DROP MATERIALIZED VIEW IF EXISTS #{list.join(", ")} CASCADE") + end + + def stale? + stale_mviews.present? + end + + def scores(period: "all_time", page: 0, for_user_id: false, limit: nil, offset: nil) + user_filter_condition = for_user_id ? ["users.id = ?", for_user_id] : [nil] + + if mview_exists?(period) + User + .where(*user_filter_condition) + .joins("INNER JOIN #{mview_name(period)} p ON p.user_id = users.id") + .select( + "users.id, users.name, users.username, users.uploaded_avatar_id, p.total_score, p.position", + ) + .limit(limit) + .offset(offset) + .order(position: :asc, id: :asc) + .load + else + raise NotReadyError.new(I18n.t("errors.leaderboard_positions_not_ready")) + end + end + + def self.create_all + GamificationLeaderboard.find_each { |leaderboard| self.new(leaderboard).create } + end + + def self.refresh_all + GamificationLeaderboard.find_each { |leaderboard| self.new(leaderboard).refresh } + end + + def self.delete_all + GamificationLeaderboard.find_each { |leaderboard| self.new(leaderboard).delete } + end + + def self.purge_all_stale + GamificationLeaderboard.find_each { |leaderboard| self.new(leaderboard).purge_stale } + end + + def self.update_all + ActiveRecord::Base.transaction do + purge_all_stale + create_all + end + end + + def self.regenerate_all + ActiveRecord::Base.transaction do + delete_all + create_all + end + end + + private + + def create_mview(period) + return if mview_exists?(period) + + name = mview_name(period) + select_query = total_scores_query(period) + + mview_query = <<~SQL + CREATE MATERIALIZED VIEW IF NOT EXISTS #{name} AS + #{select_query} + SQL + + user_id_index_query = <<~SQL + CREATE UNIQUE INDEX IF NOT EXISTS user_id_#{leaderboard.id}_#{period}_index ON #{name} (user_id) + SQL + + ActiveRecord::Base.transaction do + DB.exec(mview_query, leaderboard_id: leaderboard.id) + DB.exec(user_id_index_query) + DB.exec("COMMENT ON MATERIALIZED VIEW #{name} IS '#{query_signature(select_query)}'") + end + end + + def total_scores_query(period) + <<~SQL + WITH leaderboard AS ( + SELECT * FROM gamification_leaderboards WHERE id = :leaderboard_id + ), + + leaderboard_users AS ( + SELECT + u.id + FROM + users u + INNER JOIN + user_emails ON user_emails.primary = TRUE AND user_emails.user_id = u.id + CROSS JOIN + leaderboard lb + WHERE NOT + (user_emails.email LIKE '%@anonymized.invalid%') + AND + u.staged = FALSE + AND + u.id > 0 + AND + ( + NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = u.id) + ) + AND + -- Ensure user is a member of included_groups_ids if it's not empty + ( + (COALESCE(array_length(lb.included_groups_ids, 1), 0) = 0) + OR + (EXISTS (SELECT 1 FROM group_users AS gu WHERE gu.group_id = ANY(lb.included_groups_ids) AND gu.user_id = u.id)) + ) + AND + -- Ensure user is not a member of excluded_groups_ids if it's not empty + ( + (COALESCE(array_length(lb.excluded_groups_ids, 1), 0) = 0) + OR + (NOT EXISTS (SELECT 1 FROM group_users AS gu WHERE gu.group_id = ANY(lb.excluded_groups_ids) AND gu.user_id = u.id)) + ) + ), + + scores AS ( + SELECT + gs.* + FROM + gamification_scores gs + CROSS JOIN + leaderboard lb + WHERE + (CASE + -- Leaderboard with both "to_date" and "from_date" configured. + -- Filter scores within the configured date range AND + -- the relative period window + WHEN lb.from_date IS NOT NULL AND lb.to_date IS NOT NULL THEN + gs.date BETWEEN GREATEST(lb.from_date, #{period_start_sql(period)}) AND lb.to_date + + -- Leaderboard with only "from_date" configured. + -- Filter scores starting from the later of leaderboard's "from_date" + -- and the relative period start date + WHEN lb.from_date IS NOT NULL AND lb.to_date IS NULL THEN + gs.date >= GREATEST(lb.from_date, #{period_start_sql(period)}) + + -- Leaderboard with only "to_date" configured. + -- Filter scores up to leaderboard's "to_date" starting from + -- the relative period start date + WHEN lb.from_date IS NULL AND lb.to_date IS NOT NULL THEN + gs.date >= COALESCE(#{period_start_sql(period)}, gs.date) AND gs.date <= lb.to_date + + -- Leaderboard with no "from_date" and "to_date" configured. + -- Filter scores within the relative period window only + ELSE + gs.date >= COALESCE(#{period_start_sql(period)}, gs.date) + END) + AND gs.date <= CURRENT_DATE -- Ensure scores are not from the future + ) + + SELECT + lu.id AS user_id, + SUM(COALESCE(s.score, 0)) AS total_score, + #{ranking_function} OVER (ORDER BY SUM(COALESCE(s.score, 0)) DESC) AS position + FROM + leaderboard_users lu + INNER JOIN + scores s ON s.user_id = lu.id + GROUP BY + lu.id + ORDER BY + position ASC, + user_id ASC + SQL + end + + def ranking_function + SCORE_RANKING_STRATEGY_MAP[SiteSetting.score_ranking_strategy.to_sym] + end + + def refresh_mview(period) + return unless mview_exists?(period) + + DB.exec("REFRESH MATERIALIZED VIEW CONCURRENTLY #{mview_name(period)}") + end + + def mview_exists?(period) + DB.query_single(<<~SQL).first + SELECT EXISTS ( + SELECT 1 FROM pg_matviews + WHERE schemaname = current_schema() AND matviewname = '#{mview_name(period)}' + ) + SQL + end + + def delete_mview(period) + DB.exec("DROP MATERIALIZED VIEW IF EXISTS #{mview_name(period)} CASCADE") + end + + def mview_name(period) + "gamification_leaderboard_cache_#{leaderboard.id}_#{period}" + end + + def periods + @periods ||= GamificationLeaderboard.periods.keys + end + + def stale_mviews + return [] if periods.none? { |period| stale_mview?(period) } + + # There shouldn't be case where only some of the mviews are stale + periods.map { |period| mview_name(period) } + end + + def stale_mview?(period) + return false unless mview_exists?(period) + + current_signature = DB.query_single(<<~SQL).first + SELECT obj_description('#{mview_name(period)}'::regclass::oid, 'pg_class') + SQL + + # If for some reason there is no signature, assume it's stale + return true if current_signature.nil? + + current_signature != query_signature(total_scores_query(period)) + end + + def query_signature(query) + Digest::SHA256.hexdigest(query.strip.gsub(/\s+/, " ")) + end + + def period_start_sql(period) + PERIOD_INTERVALS[period] || "NULL" + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/recalculate_scores_rate_limiter.rb b/plugins/discourse-gamification/lib/discourse_gamification/recalculate_scores_rate_limiter.rb new file mode 100644 index 00000000000..59af93ca901 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/recalculate_scores_rate_limiter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module DiscourseGamification + class RecalculateScoresRateLimiter + def self.perform! + new.perform! + end + + def self.remaining + new.remaining + end + + def initialize + @rate_limiter = RateLimiter.new(nil, "recalculate_scores_remaining", 5, 24.hours) + end + + def perform! + @rate_limiter.performed! + end + + def remaining + @rate_limiter.remaining + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_message_created.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_message_created.rb new file mode 100644 index 00000000000..edec45dc02a --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_message_created.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class ChatMessageCreated < Scorable + def self.enabled? + SiteSetting.chat_enabled && score_multiplier > 0 + end + + def self.score_multiplier + SiteSetting.chat_message_created_score_value + end + + def self.query + <<~SQL + SELECT + m.user_id, + date_trunc('day', m.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + chat_messages AS m + JOIN + chat_channels AS c ON c.id = m.chat_channel_id + LEFT JOIN ( + SELECT direct_message_channel_id + FROM direct_message_users + GROUP BY direct_message_channel_id + HAVING COUNT(DISTINCT user_id) > 1 + ) AS dm ON dm.direct_message_channel_id = c.chatable_id + WHERE + m.created_at >= :since AND + m.deleted_at IS NULL AND + (c.chatable_type <> 'DirectMessage' OR dm.direct_message_channel_id IS NOT NULL) + GROUP BY + m.user_id, date_trunc('day', m.created_at) + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_given.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_given.rb new file mode 100644 index 00000000000..490fa5681e7 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_given.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class ChatReactionGiven < Scorable + def self.enabled? + SiteSetting.chat_enabled && score_multiplier > 0 + end + + def self.score_multiplier + SiteSetting.chat_reaction_given_score_value + end + + def self.query + <<~SQL + SELECT + reactions.user_id AS user_id, + date_trunc('day', reactions.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + chat_message_reactions AS reactions + INNER JOIN chat_messages AS cm + ON cm.id = reactions.chat_message_id + INNER JOIN chat_channels AS cc + ON cc.id = cm.chat_channel_id + WHERE + cc.deleted_at IS NULL AND + cm.deleted_at IS NULL AND + cm.user_id <> reactions.user_id AND + reactions.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_received.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_received.rb new file mode 100644 index 00000000000..458b3e82592 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/chat_reaction_received.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class ChatReactionReceived < Scorable + def self.enabled? + SiteSetting.chat_enabled && score_multiplier > 0 + end + + def self.score_multiplier + SiteSetting.chat_reaction_received_score_value + end + + def self.query + <<~SQL + SELECT + cm.user_id AS user_id, + date_trunc('day', reactions.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + chat_message_reactions AS reactions + INNER JOIN chat_messages AS cm + ON cm.id = reactions.chat_message_id + INNER JOIN chat_channels AS cc + ON cc.id = cm.chat_channel_id + WHERE + cc.deleted_at IS NULL AND + cm.deleted_at IS NULL AND + cm.user_id <> reactions.user_id AND + reactions.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/day_visited.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/day_visited.rb new file mode 100644 index 00000000000..77547f4efb9 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/day_visited.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class DayVisited < Scorable + def self.score_multiplier + SiteSetting.day_visited_score_value + end + + def self.query + <<~SQL + SELECT + uv.user_id AS user_id, + date_trunc('day', uv.visited_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + user_visits AS uv + WHERE + uv.visited_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/flag_created.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/flag_created.rb new file mode 100644 index 00000000000..d782411c094 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/flag_created.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class FlagCreated < Scorable + def self.score_multiplier + SiteSetting.flag_created_score_value + end + + def self.query + <<~SQL + SELECT + r.created_by_id AS user_id, + date_trunc('day', r.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + reviewables AS r + WHERE + created_at >= :since AND + status = 1#{" "} + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_given.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_given.rb new file mode 100644 index 00000000000..1ee4ad1aadd --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_given.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class LikeGiven < Scorable + def self.score_multiplier + SiteSetting.like_given_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + pa.user_id AS user_id, + date_trunc('day', pa.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + post_actions AS pa + INNER JOIN posts AS p + ON p.id = pa.post_id + INNER JOIN topics AS t + ON t.id = p.topic_id + #{category_filter} + WHERE + p.deleted_at IS NULL AND + t.archetype <> 'private_message' AND + p.wiki IS FALSE AND + post_action_type_id = 2 AND + pa.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_received.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_received.rb new file mode 100644 index 00000000000..9c66ee2d0d8 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/like_received.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class LikeReceived < Scorable + def self.score_multiplier + SiteSetting.like_received_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + p.user_id AS user_id, + date_trunc('day', pa.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + post_actions AS pa + INNER JOIN posts AS p + ON p.id = pa.post_id + INNER JOIN topics AS t + ON t.id = p.topic_id + #{category_filter} + WHERE + p.deleted_at IS NULL AND + t.archetype <> 'private_message' AND + p.wiki IS FALSE AND + post_action_type_id = 2 AND + pa.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_created.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_created.rb new file mode 100644 index 00000000000..463799bcf31 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_created.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class PostCreated < Scorable + def self.score_multiplier + SiteSetting.post_created_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + p.user_id AS user_id, + date_trunc('day', p.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + posts AS p + INNER JOIN topics AS t + ON t.id = p.topic_id + #{category_filter} + WHERE + p.deleted_at IS NULL AND + t.deleted_at IS NULL AND + t.archetype <> 'private_message' AND + p.post_number <> 1 AND + p.post_type = 1 AND + p.wiki IS FALSE AND + p.hidden IS FALSE AND + p.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_read.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_read.rb new file mode 100644 index 00000000000..61951141848 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/post_read.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class PostRead < Scorable + def self.score_multiplier + SiteSetting.post_read_score_value + end + + def self.query + <<~SQL + SELECT + uv.user_id AS user_id, + date_trunc('day', uv.visited_at) AS date, + SUM(uv.posts_read) / 100 * #{score_multiplier} AS points + FROM + user_visits AS uv + WHERE + uv.visited_at >= :since AND + uv.posts_read >= 5 + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_given.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_given.rb new file mode 100644 index 00000000000..abad6606f16 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_given.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class ReactionGiven < Scorable + def self.enabled? + defined?(::DiscourseReactions) && SiteSetting.discourse_reactions_enabled && + score_multiplier > 0 + end + + def self.score_multiplier + SiteSetting.reaction_given_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + reactions.user_id AS user_id, + date_trunc('day', reactions.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + discourse_reactions_reaction_users AS reactions + INNER JOIN posts AS p + ON p.id = reactions.post_id + INNER JOIN topics AS t + ON t.id = p.topic_id + #{category_filter} + WHERE + p.deleted_at IS NULL AND + t.deleted_at IS NULL AND + p.wiki IS FALSE AND + reactions.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_received.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_received.rb new file mode 100644 index 00000000000..f906d034e62 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/reaction_received.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class ReactionReceived < Scorable + def self.enabled? + defined?(::DiscourseReactions) && SiteSetting.discourse_reactions_enabled && + score_multiplier > 0 + end + + def self.score_multiplier + SiteSetting.reaction_received_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + p.user_id AS user_id, + date_trunc('day', reactions.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + discourse_reactions_reaction_users AS reactions + INNER JOIN posts AS p + ON p.id = reactions.post_id + INNER JOIN topics AS t + ON t.id = p.topic_id + #{category_filter} + WHERE + p.deleted_at IS NULL AND + t.archetype <> 'private_message' AND + p.wiki IS FALSE AND + reactions.created_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/scorable.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/scorable.rb new file mode 100644 index 00000000000..292e377efb5 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/scorable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class Scorable + class << self + def enabled? + score_multiplier > 0 + end + + def scorable_category_list + SiteSetting.scorable_categories.split("|").map { _1.to_i }.join(", ") + end + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/solutions.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/solutions.rb new file mode 100644 index 00000000000..87a084bb613 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/solutions.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +module ::DiscourseGamification + class Solutions < Scorable + def self.enabled? + defined?(DiscourseSolved) && SiteSetting.solved_enabled && super + end + + def self.score_multiplier + SiteSetting.solution_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND topics.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + posts.user_id AS user_id, + date_trunc('day', dsst.updated_at) AS date, + COUNT(dsst.topic_id) * #{score_multiplier} AS points + FROM + discourse_solved_solved_topics dsst + INNER JOIN topics + ON dsst.topic_id = topics.id + #{category_filter} + INNER JOIN posts + ON posts.id = dsst.answer_post_id + WHERE + posts.deleted_at IS NULL AND + topics.deleted_at IS NULL AND + topics.archetype <> 'private_message' AND + posts.user_id != topics.user_id AND + dsst.updated_at >= :since + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/time_read.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/time_read.rb new file mode 100644 index 00000000000..a639f7855a9 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/time_read.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class TimeRead < Scorable + def self.score_multiplier + SiteSetting.time_read_score_value + end + + def self.query + <<~SQL + SELECT + uv.user_id AS user_id, + date_trunc('day', uv.visited_at) AS date, + SUM(uv.time_read) / 3600 * #{score_multiplier} AS points + FROM + user_visits AS uv + WHERE + uv.visited_at >= :since AND + uv.time_read >= 60 + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/topic_created.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/topic_created.rb new file mode 100644 index 00000000000..d40cf3d4bde --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/topic_created.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class TopicCreated < Scorable + def self.score_multiplier + SiteSetting.topic_created_score_value + end + + def self.category_filter + return "" if scorable_category_list.empty? + + <<~SQL + AND t.category_id IN (#{scorable_category_list}) + SQL + end + + def self.query + <<~SQL + SELECT + t.user_id AS user_id, + date_trunc('day', t.created_at) AS date, + COUNT(*) * #{score_multiplier} AS points + FROM + topics AS t + WHERE + t.deleted_at IS NULL AND + t.archetype <> 'private_message' AND + t.created_at >= :since + #{category_filter} + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/scorables/user_invited.rb b/plugins/discourse-gamification/lib/discourse_gamification/scorables/user_invited.rb new file mode 100644 index 00000000000..85e138e1c72 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/scorables/user_invited.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + class UserInvited < Scorable + def self.score_multiplier + SiteSetting.user_invited_score_value + end + + def self.query + <<~SQL + SELECT + inv.invited_by_id AS user_id, + date_trunc('day', inv.created_at) AS date, + SUM(inv.redemption_count * #{score_multiplier}) AS points + FROM + invites AS inv + WHERE + inv.created_at >= :since AND + inv.redemption_count > 0 + GROUP BY + 1, 2 + SQL + end + end +end diff --git a/plugins/discourse-gamification/lib/discourse_gamification/user_extension.rb b/plugins/discourse-gamification/lib/discourse_gamification/user_extension.rb new file mode 100644 index 00000000000..4baf1dcc940 --- /dev/null +++ b/plugins/discourse-gamification/lib/discourse_gamification/user_extension.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ::DiscourseGamification + module UserExtension + DEFAULT_SCORE = 0 + + def gamification_score + return DEFAULT_SCORE if !default_leaderboard + + DiscourseGamification::GamificationLeaderboard.find_position_by( + leaderboard_id: default_leaderboard.id, + period: "all_time", + for_user_id: self.id, + )&.total_score || DEFAULT_SCORE + rescue DiscourseGamification::LeaderboardCachedView::NotReadyError + Jobs.enqueue(Jobs::GenerateLeaderboardPositions, leaderboard_id: default_leaderboard.id) + + DEFAULT_SCORE + end + + def default_leaderboard + @default_leaderboard ||= DiscourseGamification::GamificationLeaderboard.select(:id).first + end + end +end diff --git a/plugins/discourse-gamification/lib/tasks/gamification_scores.rake b/plugins/discourse-gamification/lib/tasks/gamification_scores.rake new file mode 100644 index 00000000000..559682dd0a8 --- /dev/null +++ b/plugins/discourse-gamification/lib/tasks/gamification_scores.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +desc "backfill gamification scores from passed day to today" +task "gamification_scores:backfill_scores_from", [:date] => [:environment] do |_, args| + date = args[:date] + if !date + puts "ERROR: Expecting rake gamification_scores:backfill_scores_from[2021-04-01]" + exit 1 + end + + DiscourseGamification::GamificationScore.calculate_scores(since_date: date) + puts "Scores updated" +end diff --git a/plugins/discourse-gamification/plugin.rb b/plugins/discourse-gamification/plugin.rb new file mode 100644 index 00000000000..4c1b2efcbb0 --- /dev/null +++ b/plugins/discourse-gamification/plugin.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +# name: discourse-gamification +# about: Allows admins to create and customize community scoring contests for user accomplishments with leaderboards. +# meta_topic_id: 225916 +# version: 0.0.1 +# authors: Discourse +# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-gamification +# required_version: 2.7.0 + +enabled_site_setting :discourse_gamification_enabled + +register_asset "stylesheets/common/leaderboard.scss" +register_asset "stylesheets/desktop/leaderboard.scss", :desktop +register_asset "stylesheets/mobile/leaderboard.scss", :mobile +register_asset "stylesheets/common/leaderboard-info-modal.scss" +register_asset "stylesheets/common/leaderboard-minimal.scss" +register_asset "stylesheets/common/leaderboard-admin.scss" +register_asset "stylesheets/common/gamification-score.scss" + +register_svg_icon "crown" +register_svg_icon "award" + +module ::DiscourseGamification + PLUGIN_NAME = "discourse-gamification" +end + +require_relative "lib/discourse_gamification/engine" + +after_initialize do + # route: /admin/plugins/discourse-gamification + add_admin_route( + "gamification.admin.title", + "discourse-gamification", + { use_new_show_route: true }, + ) + + require_relative "jobs/scheduled/update_scores_for_ten_days" + require_relative "jobs/scheduled/update_scores_for_today" + require_relative "jobs/regular/recalculate_scores" + require_relative "jobs/regular/generate_leaderboard_positions" + require_relative "jobs/regular/refresh_leaderboard_positions" + require_relative "jobs/regular/delete_leaderboard_positions" + require_relative "jobs/regular/update_stale_leaderboard_positions" + require_relative "jobs/regular/regenerate_leaderboard_positions" + require_relative "lib/discourse_gamification/directory_integration" + require_relative "lib/discourse_gamification/guardian_extension" + require_relative "lib/discourse_gamification/scorables/scorable" + require_relative "lib/discourse_gamification/scorables/day_visited" + require_relative "lib/discourse_gamification/scorables/flag_created" + require_relative "lib/discourse_gamification/scorables/like_given" + require_relative "lib/discourse_gamification/scorables/like_received" + require_relative "lib/discourse_gamification/scorables/post_created" + require_relative "lib/discourse_gamification/scorables/post_read" + require_relative "lib/discourse_gamification/scorables/solutions" + require_relative "lib/discourse_gamification/scorables/time_read" + require_relative "lib/discourse_gamification/scorables/topic_created" + require_relative "lib/discourse_gamification/scorables/user_invited" + require_relative "lib/discourse_gamification/user_extension" + require_relative "lib/discourse_gamification/scorables/reaction_given" + require_relative "lib/discourse_gamification/scorables/reaction_received" + require_relative "lib/discourse_gamification/scorables/chat_reaction_given" + require_relative "lib/discourse_gamification/scorables/chat_reaction_received" + require_relative "lib/discourse_gamification/scorables/chat_message_created" + require_relative "lib/discourse_gamification/recalculate_scores_rate_limiter" + require_relative "lib/discourse_gamification/leaderboard_cached_view" + + reloadable_patch do |plugin| + User.prepend(DiscourseGamification::UserExtension) + Guardian.include(DiscourseGamification::GuardianExtension) + end + + if respond_to?(:add_directory_column) + add_directory_column( + "gamification_score", + query: DiscourseGamification::DirectoryIntegration.query, + ) + end + + add_to_serializer( + :admin_plugin, + :extras, + include_condition: -> { self.name == "discourse-gamification" }, + ) do + { + gamification_recalculate_scores_remaining: + DiscourseGamification::RecalculateScoresRateLimiter.remaining, + gamification_groups: + Group + .includes(:flair_upload) + .all + .map { |group| BasicGroupSerializer.new(group, root: false, scope: self.scope).as_json }, + gamification_leaderboards: + DiscourseGamification::GamificationLeaderboard.all.map do |leaderboard| + LeaderboardSerializer.new(leaderboard, root: false).as_json + end, + } + end + + add_to_serializer(:user_card, :gamification_score) { object.gamification_score } + add_to_serializer(:site, :default_gamification_leaderboard_id) do + DiscourseGamification::GamificationLeaderboard.first&.id + end + + SeedFu.fixture_paths << Rails + .root + .join("plugins", "discourse-gamification", "db", "fixtures") + .to_s + + on(:site_setting_changed) do |name| + next if name != :score_ranking_strategy + + Jobs.enqueue(::Jobs::RegenerateLeaderboardPositions) + end + + on(:merging_users) do |source_user, target_user| + DiscourseGamification::GamificationScore.merge_scores(source_user, target_user) + end +end diff --git a/plugins/discourse-gamification/spec/fabricators/gamification_leaderboard.rb b/plugins/discourse-gamification/spec/fabricators/gamification_leaderboard.rb new file mode 100644 index 00000000000..74399753205 --- /dev/null +++ b/plugins/discourse-gamification/spec/fabricators/gamification_leaderboard.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +Fabricator(:gamification_leaderboard, from: ::DiscourseGamification::GamificationLeaderboard) do + name { sequence(:name) { |i| "leaderboard#{i + 1}" } } + created_by_id { Fabricate(:user).id } + from_date { nil } + to_date { nil } + visible_to_groups_ids { [] } + included_groups_ids { [] } + default_period { 0 } +end diff --git a/plugins/discourse-gamification/spec/fabricators/gamification_score.rb b/plugins/discourse-gamification/spec/fabricators/gamification_score.rb new file mode 100644 index 00000000000..d545b771af3 --- /dev/null +++ b/plugins/discourse-gamification/spec/fabricators/gamification_score.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:gamification_score, from: ::DiscourseGamification::GamificationScore) do + user_id { Fabricate(:user).id } + score { 0 } + date { Date.today } +end diff --git a/plugins/discourse-gamification/spec/jobs/delete_leaderboard_positions_spec.rb b/plugins/discourse-gamification/spec/jobs/delete_leaderboard_positions_spec.rb new file mode 100644 index 00000000000..72e36de8cbc --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/delete_leaderboard_positions_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::DeleteLeaderboardPositions do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } + let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } + + before { leaderboard_positions.create } + + it "deletes leaderboard positions" do + expect(leaderboard_positions.scores.length).to eq(1) + + described_class.new.execute(leaderboard_id: leaderboard.id) + + expect { leaderboard_positions.scores }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + end + + it "deletes leaderboard positions of deleted leaderboards" do + leaderboard.destroy + + expect(leaderboard_positions.scores.length).to eq(1) + + described_class.new.execute(leaderboard_id: leaderboard.id) + + expect { leaderboard_positions.scores }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + end +end diff --git a/plugins/discourse-gamification/spec/jobs/generate_leaderboard_positions_spec.rb b/plugins/discourse-gamification/spec/jobs/generate_leaderboard_positions_spec.rb new file mode 100644 index 00000000000..7cc2bdb31e5 --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/generate_leaderboard_positions_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::GenerateLeaderboardPositions do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } + let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } + + it "generates leaderboard positions" do + expect { leaderboard_positions.scores }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + + described_class.new.execute(leaderboard_id: leaderboard.id) + + expect(leaderboard_positions.scores.length).to eq(1) + end +end diff --git a/plugins/discourse-gamification/spec/jobs/recalculate_scores_spec.rb b/plugins/discourse-gamification/spec/jobs/recalculate_scores_spec.rb new file mode 100644 index 00000000000..52eb423f794 --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/recalculate_scores_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::RecalculateScores do + fab!(:current_user) { Fabricate(:admin) } + + before { RateLimiter.enable } + + it "publishes MessageBus and executes job" do + since = 10.days.ago + DiscourseGamification::GamificationScore.expects(:calculate_scores).with(since_date: since) + + MessageBus + .expects(:publish) + .with("/recalculate_scores", { success: true, remaining: 5, user_id: [current_user.id] }) + .once + Jobs::RecalculateScores.new.execute({ since: since, user_id: current_user.id }) + end +end diff --git a/plugins/discourse-gamification/spec/jobs/refresh_leaderboard_positions_spec.rb b/plugins/discourse-gamification/spec/jobs/refresh_leaderboard_positions_spec.rb new file mode 100644 index 00000000000..7d097f1e4b9 --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/refresh_leaderboard_positions_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::RefreshLeaderboardPositions do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } + + before { leaderboard_positions.create } + + it "refreshes leaderboard positions" do + Fabricate(:gamification_score, user_id: leaderboard.created_by_id, score: 10) + + expect(leaderboard_positions.scores).to be_empty + + described_class.new.execute(leaderboard_id: leaderboard.id) + + expect(leaderboard_positions.scores.length).to eq(1) + expect(leaderboard_positions.scores.first.attributes).to include( + "id" => leaderboard.created_by_id, + "total_score" => 10, + ) + end +end diff --git a/plugins/discourse-gamification/spec/jobs/update_scores_for_ten_days_spec.rb b/plugins/discourse-gamification/spec/jobs/update_scores_for_ten_days_spec.rb new file mode 100644 index 00000000000..d5f8dcf3187 --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/update_scores_for_ten_days_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::UpdateScoresForTenDays do + let(:user) { Fabricate(:user) } + let(:user_2) { Fabricate(:user) } + let(:post) { Fabricate(:post, user: user) } + let!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id, date: 8.days.ago) } + let!(:gamification_score_2) do + Fabricate(:gamification_score, user_id: user_2.id, date: 12.days.ago) + end + let!(:topic_user_created) { Fabricate(:topic, user: user) } + let!(:topic_user_2_created) { Fabricate(:topic, user: user_2) } + + def run_job + described_class.new.execute + end + + before do + topic_user_created.update(created_at: 8.days.ago) + topic_user_2_created.update(created_at: 12.days.ago) + end + + it "updates all scores within the last 10 days" do + expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(0) + run_job + expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(5) + end + + it "does not update scores outside of the last 10 days" do + expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) + run_job + expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) + end +end diff --git a/plugins/discourse-gamification/spec/jobs/update_scores_for_today_spec.rb b/plugins/discourse-gamification/spec/jobs/update_scores_for_today_spec.rb new file mode 100644 index 00000000000..8304de15ccb --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/update_scores_for_today_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::UpdateScoresForToday do + fab!(:user) + fab!(:user_2) { Fabricate(:user) } + fab!(:post) { Fabricate(:post, user: user, post_number: 2) } + fab!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id) } + fab!(:gamification_score_2) do + Fabricate(:gamification_score, user_id: user_2.id, date: 2.days.ago) + end + fab!(:topic_user_created) { Fabricate(:topic, user: user) } + fab!(:topic_user_2_created) { Fabricate(:topic, user: user_2) } + + fab!(:leaderboard_1) { Fabricate(:gamification_leaderboard, created_by_id: user.id) } + fab!(:leaderboard_2) { Fabricate(:gamification_leaderboard, created_by_id: user.id) } + let(:leaderboard_1_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard_1) } + let(:leaderboard_2_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard_2) } + + def run_job + described_class.new.execute + end + + before { topic_user_2_created.update(created_at: 2.days.ago) } + + it "updates all scores for today" do + expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(0) + run_job + expect(DiscourseGamification::GamificationScore.find_by(user_id: user.id).score).to eq(12) + end + + it "does not update scores outside of today" do + expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) + run_job + expect(DiscourseGamification::GamificationScore.find_by(user_id: user_2.id).score).to eq(0) + end + + context "with leaderboard positions" do + it "generates new leaderboard positions" do + ActiveRecord::Base.transaction do + expect { leaderboard_1_positions.scores }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + expect { leaderboard_2_positions.scores }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + end + + run_job + + expect(leaderboard_1_positions.scores.length).to eq(2) + expect(leaderboard_1_positions.scores.map(&:attributes)).to include( + { + "id" => user.id, + "total_score" => 12, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "id" => user_2.id, + "total_score" => 0, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user_2.username, + "name" => user_2.name, + }, + ) + end + + it "refreshes leaderboard positions" do + # Force assignment of scores accrued + DiscourseGamification::GamificationScore.calculate_scores + DiscourseGamification::LeaderboardCachedView.create_all + + expect(leaderboard_1_positions.scores.map(&:attributes)).to include( + { + "id" => user.id, + "total_score" => 12, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "id" => user_2.id, + "total_score" => 0, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user_2.username, + "name" => user_2.name, + }, + ) + + Fabricate(:gamification_score, user_id: user_2.id, date: 3.days.ago, score: 2) + + run_job + + expect(leaderboard_1_positions.scores.map(&:attributes)).to include( + { + "id" => user.id, + "total_score" => 12, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "id" => user_2.id, + "total_score" => 2, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user_2.username, + "name" => user_2.name, + }, + ) + end + + it "purges stale leaderboard positions" do + DiscourseGamification::LeaderboardCachedView.create_all + + # Update query to make existing materialized views stale + allow_any_instance_of(DiscourseGamification::LeaderboardCachedView).to receive( + :total_scores_query, + ).and_wrap_original do |original_method, period| + "#{original_method.call(period)} \n-- This is a new comment" + end + + expect(leaderboard_1_positions.stale?).to eq(true) + expect(leaderboard_2_positions.stale?).to eq(true) + + run_job + + expect(leaderboard_1_positions.stale?).to eq(false) + expect(leaderboard_2_positions.stale?).to eq(false) + + expect(leaderboard_1_positions.scores.length).to eq(2) + expect(leaderboard_1_positions.scores.map(&:attributes)).to include( + { + "id" => user.id, + "total_score" => 12, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "id" => user_2.id, + "total_score" => 0, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user_2.username, + "name" => user_2.name, + }, + ) + end + end +end diff --git a/plugins/discourse-gamification/spec/jobs/update_stale_leaderboard_positions_spec.rb b/plugins/discourse-gamification/spec/jobs/update_stale_leaderboard_positions_spec.rb new file mode 100644 index 00000000000..0ec7f0de23b --- /dev/null +++ b/plugins/discourse-gamification/spec/jobs/update_stale_leaderboard_positions_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Jobs::UpdateStaleLeaderboardPositions do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + fab!(:score) { Fabricate(:gamification_score, user_id: leaderboard.created_by_id) } + let(:leaderboard_positions) { DiscourseGamification::LeaderboardCachedView.new(leaderboard) } + + it "it updates all stale leaderboard positions" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + + expect(leaderboard_positions.scores.length).to eq(1) + expect(leaderboard_positions.scores.first.attributes).to include( + "id" => leaderboard.created_by_id, + "total_score" => 0, + "position" => 1, + ) + + allow_any_instance_of(DiscourseGamification::LeaderboardCachedView).to receive( + :total_scores_query, + ).and_wrap_original do |original_method, period| + "#{original_method.call(period)} \n-- This is a new comment" + end + + expect(leaderboard_positions.stale?).to eq(true) + + described_class.new.execute + + expect(leaderboard_positions.stale?).to eq(false) + expect(leaderboard_positions.scores.length).to eq(1) + expect(leaderboard_positions.scores.first.attributes).to include( + "id" => leaderboard.created_by_id, + "total_score" => 0, + ) + end +end diff --git a/plugins/discourse-gamification/spec/lib/directory_integration_spec.rb b/plugins/discourse-gamification/spec/lib/directory_integration_spec.rb new file mode 100644 index 00000000000..62242de99ca --- /dev/null +++ b/plugins/discourse-gamification/spec/lib/directory_integration_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DiscourseGamification::DirectoryIntegration do + fab!(:user_1) { Fabricate(:admin) } + fab!(:user_2) { Fabricate(:user) } + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + fab!(:score_1) { Fabricate(:gamification_score, user_id: user_1.id, score: 10, date: 8.days.ago) } + fab!(:score_2) { Fabricate(:gamification_score, user_id: user_1.id, score: 40, date: 3.days.ago) } + fab!(:score_3) { Fabricate(:gamification_score, user_id: user_2.id, score: 25, date: 5.days.ago) } + fab!(:score_4) { Fabricate(:gamification_score, user_id: user_2.id, score: 5, date: 2.days.ago) } + + before do + SiteSetting.discourse_gamification_enabled = true + DirectoryItem.refresh! + end + + def all_time_score_for(user) + user.directory_items.find_by(period_type: 1).gamification_score + end + + context "with a date-restricted default leaderboard" do + context "with only a 'from_date'" do + before do + leaderboard.update(from_date: 5.days.ago.to_date) + DirectoryItem.refresh! + end + + it "returns sum of points earned from leaderboard's 'from_date'" do + expect(all_time_score_for(user_1)).to eq(40) + expect(all_time_score_for(user_2)).to eq(30) + end + end + + context "with only a 'to_date'" do + before do + leaderboard.update(to_date: 4.days.ago.to_date) + DirectoryItem.refresh! + end + + it "returns sum of points earned upto leaderboard's 'to_date'" do + expect(all_time_score_for(user_1)).to eq(10) + expect(all_time_score_for(user_2)).to eq(25) + end + end + + context "with both 'from_date' and 'to_date'" do + before do + leaderboard.update(from_date: 5.days.ago.to_date, to_date: 3.days.ago.to_date) + DirectoryItem.refresh! + end + + it "returns sum of points earned between leaderboard's 'from_date' and 'to_date'" do + expect(DiscourseGamification::GamificationScore.where(user: user_1).sum(:score)).to eq(50) + expect(DiscourseGamification::GamificationScore.where(user: user_2).sum(:score)).to eq(30) + + expect(all_time_score_for(user_1)).to eq(40) + expect(all_time_score_for(user_2)).to eq(25) + end + end + end + + context "without a date-restricted default leaderboard" do + it "returns sum of all scores for the period" do + expect(DiscourseGamification::GamificationScore.where(user: user_1).sum(:score)).to eq(50) + expect(DiscourseGamification::GamificationScore.where(user: user_2).sum(:score)).to eq(30) + + expect(all_time_score_for(user_1)).to eq(50) + expect(all_time_score_for(user_2)).to eq(30) + end + end +end diff --git a/plugins/discourse-gamification/spec/lib/leaderboard_cached_view_spec.rb b/plugins/discourse-gamification/spec/lib/leaderboard_cached_view_spec.rb new file mode 100644 index 00000000000..7dbb3ab4d3e --- /dev/null +++ b/plugins/discourse-gamification/spec/lib/leaderboard_cached_view_spec.rb @@ -0,0 +1,351 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe DiscourseGamification::LeaderboardCachedView do + fab!(:admin) + fab!(:user) + fab!(:other_user) { Fabricate(:user) } + fab!(:moderator) + fab!(:leaderboard) { Fabricate(:gamification_leaderboard, created_by_id: admin.id) } + fab!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id, date: 8.days.ago) } + + let(:mviews) do + DiscourseGamification::GamificationLeaderboard.periods.map do |period, _| + "gamification_leaderboard_cache_#{leaderboard.id}_#{period}" + end + end + + let(:mview_count_query) { <<~SQL } + SELECT + count(*) + FROM + pg_matviews + WHERE + matviewname LIKE 'gamification_leaderboard_cache_#{leaderboard.id}_%' + SQL + + let(:mview_names_query) { <<~SQL } + SELECT + matviewname + FROM + pg_matviews + WHERE + matviewname LIKE 'gamification_leaderboard_cache_#{leaderboard.id}_%' + SQL + + describe "#create" do + it "creates a leaderboard materialized view for each period" do + described_class.new(leaderboard).create + + expect(DB.query_single(mview_count_query).first).to eq(6) + end + end + + describe "#refresh" do + before do + described_class.new(leaderboard).create + Fabricate(:gamification_score, user_id: user.id, score: 10) + Fabricate(:gamification_score, user_id: admin.id, score: 20) + Fabricate(:gamification_score, user_id: other_user.id, score: 1, date: 5.days.ago) + Fabricate(:gamification_score, user_id: other_user.id, score: 4, date: 3.days.ago) + end + + it "refreshes leaderboard materialized views with the latest scores" do + expect(DB.query_hash("SELECT * FROM #{mviews.first}")).to include( + { "total_score" => 0, "user_id" => user.id, "position" => 1 }, + ) + + described_class.new(leaderboard).refresh + + expect(DB.query_hash("SELECT * FROM #{mviews.first}")).to include( + { "total_score" => 10, "user_id" => user.id, "position" => 2 }, + { "total_score" => 5, "user_id" => other_user.id, "position" => 3 }, + { "total_score" => 20, "user_id" => admin.id, "position" => 1 }, + ) + end + end + + describe "#delete" do + it "deletes all leaderboard materialized views" do + cached_mview = described_class.new(leaderboard) + cached_mview.create + + expect(DB.query_single(mview_count_query).first).to eq(6) + + cached_mview.delete + + expect(DB.query_single(mview_count_query).first).to eq(0) + end + end + + describe "#purge_stale" do + it "removes all stale materialized views for leaderboard" do + leaderboard_cache = described_class.new(leaderboard) + + leaderboard_cache.create + expect(DB.query_single(mview_count_query).first).to eq(6) + + leaderboard_cache.purge_stale + expect(DB.query_single(mview_count_query).first).to eq(6) + + # Update query to make existing materialized views stale + allow(leaderboard_cache).to receive( + :total_scores_query, + ).and_wrap_original do |original_method, period| + "#{original_method.call(period)} \n-- This is a new comment" + end + + leaderboard_cache.purge_stale + # Query changed, all existing stale materialized views removed + expect(DB.query_single(mview_count_query).first).to eq(0) + end + + it "does nothing if no stale materialized view exist for leaderboard" do + described_class.new(leaderboard).create + expect(DB.query_single(mview_names_query)).to contain_exactly(*mviews) + + described_class.new(leaderboard).purge_stale + expect(DB.query_single(mview_names_query)).to contain_exactly(*mviews) + end + end + + describe "#scores" do + let(:leaderboard_positions) { described_class.new(leaderboard) } + let(:all_time_view_name) { "gamification_leaderboard_cache_#{leaderboard.id}_all_time" } + + context "when the materialized view exists in another schema" do + before do + DB.exec("CREATE SCHEMA IF NOT EXISTS test_backup") + DB.exec(<<~SQL) + CREATE MATERIALIZED VIEW test_backup.#{all_time_view_name} AS + SELECT 1 AS user_id, 100 AS total_score, 1 AS position + SQL + end + + after { DB.exec("DROP SCHEMA IF EXISTS test_backup CASCADE") } + + it "raises NotReadyError" do + expect { leaderboard_positions.scores(period: "all_time") }.to raise_error( + DiscourseGamification::LeaderboardCachedView::NotReadyError, + ) + end + end + + context "with leaderboard dates" do + let(:leaderboard_from) { Date.current - 45.days } + let(:leaderboard_to) { Date.current - 15.days } + + before do + [ + leaderboard_from - 15.days, + leaderboard_from - 5.days, + leaderboard_from - 1.day, + leaderboard_from, + Date.current - 1.month, + leaderboard_to, + leaderboard_to + 1.day, + leaderboard_to + 15.days, + leaderboard_to + 30.days, + ].each { |date| Fabricate(:gamification_score, user_id: user.id, date: date, score: 10) } + end + + it "filters scores for leaderboard with both 'from_date' and 'to_date' configured" do + leaderboard.update!(from_date: leaderboard_from, to_date: leaderboard_to) + leaderboard_positions.create + + expect(leaderboard_positions.scores.first&.total_score).to eq(30) + expect(leaderboard_positions.scores(period: "yearly").first&.total_score).to eq(30) + expect(leaderboard_positions.scores(period: "quarterly").first&.total_score).to eq(30) + expect(leaderboard_positions.scores(period: "monthly").first&.total_score).to eq(20) + expect(leaderboard_positions.scores(period: "weekly").first&.total_score).to be_nil + expect(leaderboard_positions.scores(period: "daily").first&.total_score).to be_nil + end + + it "filters scores for leaderboard with only 'from_date' configured" do + leaderboard.update!(from_date: leaderboard_from) + leaderboard_positions.create + + expect(leaderboard_positions.scores.first&.total_score).to eq(50) + expect(leaderboard_positions.scores(period: "yearly").first&.total_score).to eq(50) + expect(leaderboard_positions.scores(period: "quarterly").first&.total_score).to eq(50) + expect(leaderboard_positions.scores(period: "monthly").first&.total_score).to eq(40) + expect(leaderboard_positions.scores(period: "weekly").first&.total_score).to eq(10) + expect(leaderboard_positions.scores(period: "daily").first&.total_score).to eq(10) + end + + it "filters scores for leaderboard with only 'to_date' configured" do + leaderboard.update!(to_date: leaderboard_to) + leaderboard_positions.create + + expect(leaderboard_positions.scores.first&.total_score).to eq(60) + expect(leaderboard_positions.scores(period: "yearly").first&.total_score).to eq(60) + expect(leaderboard_positions.scores(period: "quarterly").first&.total_score).to eq(60) + expect(leaderboard_positions.scores(period: "monthly").first&.total_score).to eq(20) + expect(leaderboard_positions.scores(period: "weekly").first&.total_score).to be_nil + expect(leaderboard_positions.scores(period: "daily").first&.total_score).to be_nil + end + + it "filters scores for leaderboard with no dates configured" do + leaderboard_positions.create + + expect(leaderboard_positions.scores.first&.total_score).to eq(80) + expect(leaderboard_positions.scores(period: "yearly").first&.total_score).to eq(80) + expect(leaderboard_positions.scores(period: "quarterly").first&.total_score).to eq(80) + expect(leaderboard_positions.scores(period: "monthly").first&.total_score).to eq(40) + expect(leaderboard_positions.scores(period: "weekly").first&.total_score).to eq(10) + expect(leaderboard_positions.scores(period: "daily").first&.total_score).to eq(10) + end + end + + context "with leaderboard ranking strategies" do + before do + Fabricate(:gamification_score, user_id: user.id, score: 20) + Fabricate(:gamification_score, user_id: admin.id, score: 50) + Fabricate(:gamification_score, user_id: other_user.id, score: 20) + Fabricate(:gamification_score, user_id: moderator.id, score: 10) + end + + context "with 'rank'" do + before do + SiteSetting.score_ranking_strategy = "rank" + + described_class.new(leaderboard).create + end + + it "returns ranked scores skipping the next rank after duplicates" do + expect(leaderboard_positions.scores.map(&:attributes)).to eq( + [ + { + "total_score" => 50, + "id" => admin.id, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => admin.username, + "name" => admin.name, + }, + { + "total_score" => 20, + "id" => user.id, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "total_score" => 20, + "id" => other_user.id, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => other_user.username, + "name" => other_user.name, + }, + { + "total_score" => 10, + "id" => moderator.id, + "position" => 4, + "uploaded_avatar_id" => nil, + "username" => moderator.username, + "name" => moderator.name, + }, + ], + ) + end + end + + context "with 'dense_rank'" do + before do + SiteSetting.score_ranking_strategy = "dense_rank" + + described_class.new(leaderboard).create + end + + it "returns ranked scores without skipping the next rank after duplicates" do + expect(leaderboard_positions.scores.map(&:attributes)).to eq( + [ + { + "total_score" => 50, + "id" => admin.id, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => admin.username, + "name" => admin.name, + }, + { + "total_score" => 20, + "id" => user.id, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "total_score" => 20, + "id" => other_user.id, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => other_user.username, + "name" => other_user.name, + }, + { + "total_score" => 10, + "id" => moderator.id, + "position" => 3, + "uploaded_avatar_id" => nil, + "username" => moderator.username, + "name" => moderator.name, + }, + ], + ) + end + end + + context "with 'row_number'" do + before do + SiteSetting.score_ranking_strategy = "row_number" + + described_class.new(leaderboard).create + end + + it "returns ranked scores without distinguishing duplicates" do + expect(leaderboard_positions.scores.map(&:attributes)).to eq( + [ + { + "total_score" => 50, + "id" => admin.id, + "position" => 1, + "uploaded_avatar_id" => nil, + "username" => admin.username, + "name" => admin.name, + }, + { + "total_score" => 20, + "id" => user.id, + "position" => 2, + "uploaded_avatar_id" => nil, + "username" => user.username, + "name" => user.name, + }, + { + "total_score" => 20, + "id" => other_user.id, + "position" => 3, + "uploaded_avatar_id" => nil, + "username" => other_user.username, + "name" => other_user.name, + }, + { + "total_score" => 10, + "id" => moderator.id, + "position" => 4, + "uploaded_avatar_id" => nil, + "username" => moderator.username, + "name" => moderator.name, + }, + ], + ) + end + end + end + end +end diff --git a/plugins/discourse-gamification/spec/lib/scorables/shared_scorables_spec.rb b/plugins/discourse-gamification/spec/lib/scorables/shared_scorables_spec.rb new file mode 100755 index 00000000000..1686e8bc72f --- /dev/null +++ b/plugins/discourse-gamification/spec/lib/scorables/shared_scorables_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +RSpec.shared_examples "Scorable Type" do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + let(:current_user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + let(:third_user) { Fabricate(:user) } + let(:expected_score) { expected_score } + + describe "#{described_class} updates gamification score" do + it "has correct total score" do + DiscourseGamification::GamificationScore.calculate_scores( + since_date: "2022-1-1", + only_subclass: described_class, + ) + DiscourseGamification::LeaderboardCachedView.create_all + + expect(current_user.gamification_score).to eq(expected_score) + end + end +end + +RSpec.shared_examples "Category Scoped Scorable Type" do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + let(:user) { Fabricate(:user) } + let(:user_2) { Fabricate(:user) } + let(:category_allowed) { Fabricate(:category) } + let(:category_not_allowed) { Fabricate(:category) } + let(:expected_score) { described_class.score_multiplier } + let(:after_create_hook) { nil } + + describe "updates gamification score" do + let!(:create_score) { class_action_fabricator } + let!(:trigger_after_create_hook) { after_create_hook } + before { DiscourseGamification::LeaderboardCachedView.create_all } + + it "#{described_class} updates scores for action in the category configured" do + expect(user.gamification_score).to eq(0) + SiteSetting.scorable_categories = category_allowed.id.to_s + DiscourseGamification::GamificationScore.calculate_scores(only_subclass: described_class) + DiscourseGamification::LeaderboardCachedView.refresh_all + expect(user.gamification_score).to eq(expected_score) + end + + it "#{described_class} doesn't updates scores for action in the category configured" do + expect(user_2.gamification_score).to eq(0) + SiteSetting.scorable_categories = category_not_allowed.id.to_s + DiscourseGamification::GamificationScore.calculate_scores(only_subclass: described_class) + DiscourseGamification::LeaderboardCachedView.refresh_all + expect(user_2.gamification_score).to eq(0) + end + end +end + +RSpec.shared_examples "No Score Value" do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + let(:current_user) { Fabricate(:user) } + let(:other_user) { Fabricate(:user) } + let(:class_action_fabricator_for_pm) { nil } + let(:class_action_fabricator_for_deleted_object) { nil } + let(:class_action_fabricator_for_wiki) { nil } + let(:class_action_fabricator_for_themselves) { nil } + let(:after_create_hook) { nil } + + describe "#{described_class} awards no score value" do + let!(:create_score_for_deleted_object) { class_action_fabricator_for_deleted_object } + let!(:create_score_for_pm) { class_action_fabricator_for_pm } + let!(:create_score_for_wiki) { class_action_fabricator_for_wiki } + let!(:create_score_for_themselves) { class_action_fabricator_for_themselves } + let!(:trigger_after_create_hook) { after_create_hook } + + it "does not increase user gamification score" do + DiscourseGamification::GamificationScore.calculate_scores( + since_date: "2022-1-1", + only_subclass: described_class, + ) + DiscourseGamification::LeaderboardCachedView.create_all + + expect(current_user.gamification_score).to eq(0) + end + end +end + +RSpec.describe ::DiscourseGamification::LikeReceived do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(10, :post, user: current_user) + Post.all.each { |p| Fabricate(:post_action, post: p) } + end + + # ten likes received + let(:expected_score) { 10 } + end + + it_behaves_like "Category Scoped Scorable Type" do + let(:topic) { Fabricate(:topic, user: user, category: category_allowed) } + let(:class_action_fabricator) { Fabricate(:post, user: user, topic: topic) } + let(:after_create_hook) { Post.all.each { |p| Fabricate(:post_action, post: p) } } + + # 1 like received + let(:expected_score) { 1 } + end + + it_behaves_like "No Score Value" do + # don't count deleted post towards score + let(:deleted_topic) { Fabricate(:deleted_topic, user: current_user) } + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:post, user: current_user, topic: deleted_topic, deleted_at: Time.now) + end + + # don't count private message towards score + let(:private_message_topic) { Fabricate(:private_message_topic) } + let(:class_action_fabricator_for_pm) do + Fabricate(:post, user: current_user, topic: private_message_topic) + end + + let(:after_create_hook) { Post.all.each { |p| Fabricate(:post_action, post: p) } } + end +end + +RSpec.describe ::DiscourseGamification::LikeGiven do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(10, :post, user: other_user) + Post.all.each do |p| + Fabricate(:post_action, user: current_user, post: p, post_action_type_id: 2) + end + Post + .all + .limit(5) + .each { |p| Fabricate(:post_action, user: third_user, post: p, post_action_type_id: 2) } + end + + # ten likes given + let(:expected_score) { 10 } + end + + it_behaves_like "Category Scoped Scorable Type" do + let(:topic) { Fabricate(:topic, user: user, category: category_allowed) } + let(:post) { Fabricate(:post, user: user, topic: topic) } + let(:class_action_fabricator) { Fabricate(:post_action, user: user, post: post) } + + # one like given + let(:expected_score) { 1 } + end + + it_behaves_like "No Score Value" do + # don't count deleted post towards score + let(:deleted_topic) { Fabricate(:deleted_topic, user: current_user) } + let(:post) { Fabricate(:post, topic: deleted_topic, user: current_user, deleted_at: Time.now) } + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:post_action, user: current_user, post: post) + end + + # don't count private message towards score + let(:private_message_topic) { Fabricate(:private_message_topic) } + let(:post_2) { Fabricate(:post, topic: private_message_topic, user: current_user) } + let(:class_action_fabricator_for_pm) do + Fabricate(:post_action, user: current_user, post: post_2) + end + end +end + +RSpec.describe ::DiscourseGamification::PostCreated do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(2, :post, user: current_user, post_number: 2) + + # OP is not counted + Fabricate(:post, user: current_user, post_number: 1) + + # small action are not counted + Fabricate(:post, post_type: Post.types[:moderator_action], user: current_user, post_number: 2) + + # hidden posts are not counted + Fabricate( + :post, + user: current_user, + hidden: true, + hidden_at: 5.minutes.ago, + hidden_reason_id: Post.hidden_reasons[:flagged_by_tl3_user], + post_number: 2, + ) + + # deleted topics are not counted + deleted_topic = Fabricate(:topic) + Fabricate(:post, user: current_user, post_number: 2, topic: deleted_topic) + deleted_topic.destroy! + end + + let(:expected_score) { 4 } + end + + it_behaves_like "Category Scoped Scorable Type" do + let(:topic) { Fabricate(:topic, user: user, category: category_allowed) } + let(:class_action_fabricator) { Fabricate(:post, topic: topic, user: user, post_number: 2) } + + let(:expected_score) { 2 } + end + + it_behaves_like "No Score Value" do + # don't count deleted post towards score + let(:deleted_topic) { Fabricate(:deleted_topic, user: current_user) } + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:post, topic: deleted_topic, user: current_user, deleted_at: Time.now) + end + + # don't count wiki post towards score + let(:class_action_fabricator_for_wiki) do + Fabricate(:post, topic: deleted_topic, user: current_user) { wiki { true } } + end + end +end + +RSpec.describe ::DiscourseGamification::DayVisited do + it_behaves_like "Scorable Type" do + before do + (Date.new(2022, 01, 01)..Date.new(2022, 01, 30)).each do |date| + UserVisit.create(user_id: current_user.id, visited_at: date) + end + end + + # thirty days visited + let(:expected_score) { 30 } + end +end + +RSpec.describe ::DiscourseGamification::PostRead do + it_behaves_like "Scorable Type" do + before do + (Date.new(2022, 01, 01)..Date.new(2022, 01, 30)).each do |date| + UserVisit.create(user_id: current_user.id, visited_at: date, posts_read: 100) + end + end + + # thirty days of reading 5 posts + let(:expected_score) { 30 } + end +end + +RSpec.describe ::DiscourseGamification::TimeRead do + it_behaves_like "Scorable Type" do + before do + (Date.new(2022, 01, 01)..Date.new(2022, 01, 30)).each do |date| + UserVisit.create(user_id: current_user.id, time_read: 3600, visited_at: date) + end + end + + # thirty days of reading 1 hour + let(:expected_score) { 30 } + end +end + +RSpec.describe ::DiscourseGamification::FlagCreated do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(10, :reviewable, created_by: current_user) do + after_create { self.update(status: 1) } + end + end + + # ten flags created + let(:expected_score) { 100 } + end +end + +RSpec.describe ::DiscourseGamification::TopicCreated do + it_behaves_like "Scorable Type" do + before { Fabricate.times(10, :topic, user: current_user) } + + # ten topics created + let(:expected_score) { 50 } + end + + it_behaves_like "Category Scoped Scorable Type" do + let(:class_action_fabricator) { Fabricate(:topic, user: user, category: category_allowed) } + end + + it_behaves_like "No Score Value" do + # don't count deleted topic towards score + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:deleted_topic, user: current_user) + end + + # don't count private message towards score + let(:class_action_fabricator_for_pm) { Fabricate(:private_message_topic) } + end +end + +RSpec.describe ::DiscourseGamification::UserInvited do + it_behaves_like "Scorable Type" do + before do + stub_request( + :get, + "http://local.hub:3000/api/customers/-1/account?access_token&admin_count=0&moderator_count=0", + ).with( + headers: { + "Accept" => "application/json, application/vnd.discoursehub.v1", + "Host" => "local.hub:3000", + "Referer" => "http://test.localhost", + }, + ).to_return(status: 200, body: "", headers: {}) + Fabricate.times(10, :invite, invited_by: current_user) do + after_create { self.update(redemption_count: 1) } + end + end + + # ten users invited + let(:expected_score) { 100 } + end +end + +RSpec.describe ::DiscourseGamification::ChatReactionReceived do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(10, :chat_message, user: current_user) + Chat::Message.all.each { |m| Fabricate(:chat_message_reaction, chat_message: m) } + end + + # ten reactions recieved + let(:expected_score) { 10 } + end + + it_behaves_like "No Score Value" do + # don't count reaction on deleted message towards score + let(:message1) { Fabricate(:chat_message, user: current_user, deleted_at: Time.now) } + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:chat_message_reaction, chat_message: message1) + end + + # don't count chat reaction by themselves towards score + let(:message2) { Fabricate(:chat_message, user: current_user) } + let(:class_action_fabricator_for_themselves) do + Fabricate(:chat_message_reaction, chat_message: message2, user: current_user) + end + end +end + +RSpec.describe ::DiscourseGamification::ChatReactionGiven do + it_behaves_like "Scorable Type" do + before do + Fabricate.times(10, :chat_message, user: other_user) + Chat::Message.all.each do |m| + Fabricate(:chat_message_reaction, user: current_user, chat_message: m) + end + Chat::Message + .all + .limit(5) + .each { |m| Fabricate(:chat_message_reaction, user: third_user, chat_message: m) } + end + + # ten reactions given + let(:expected_score) { 10 } + end + + it_behaves_like "No Score Value" do + # don't count reaction on deleted message towards score + let(:message1) { Fabricate(:chat_message, user: other_user, deleted_at: Time.now) } + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:chat_message_reaction, chat_message: message1, user: current_user) + end + + # don't count chat reaction by themselves towards score + let(:message2) { Fabricate(:chat_message, user: current_user) } + let(:class_action_fabricator_for_themselves) do + Fabricate(:chat_message_reaction, chat_message: message2, user: current_user) + end + end +end + +RSpec.describe ::DiscourseGamification::ChatMessageCreated do + it_behaves_like "Scorable Type" do + before { Fabricate.times(10, :chat_message, user: current_user) } + + # ten messages created + let(:expected_score) { 10 } + end + + it_behaves_like "No Score Value" do + # don't count deleted post message score + let(:class_action_fabricator_for_deleted_object) do + Fabricate(:chat_message, user: current_user, deleted_at: Time.now) + end + + # don't count chat by themselves towards score + let(:dm_channel) { Fabricate(:direct_message_channel, users: [current_user]) } + let(:class_action_fabricator_for_themselves) do + Fabricate(:chat_message, chat_channel: dm_channel, user: current_user) + end + end +end diff --git a/plugins/discourse-gamification/spec/lib/scorables/solutions_spec.rb b/plugins/discourse-gamification/spec/lib/scorables/solutions_spec.rb new file mode 100644 index 00000000000..367c0f35257 --- /dev/null +++ b/plugins/discourse-gamification/spec/lib/scorables/solutions_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseGamification::Solutions do + fab!(:category) + fab!(:topic) { Fabricate(:topic, category: category) } + fab!(:question_user) { Fabricate(:user) } + fab!(:answer_user) { Fabricate(:user) } + fab!(:answer_post) { Fabricate(:post, topic: topic, user: answer_user) } + + before { SiteSetting.solution_score_value = 5 } + + it "is enabled when score value is positive" do + expect(described_class).to be_enabled + + SiteSetting.solution_score_value = 0 + expect(described_class).not_to be_enabled + end + + describe "scoring query" do + def query_results + DB.query(described_class.query, since: 2.days.ago) + end + + it "scores accepted answers correctly" do + freeze_time DateTime.parse("2024-01-01 12:00") + + DiscourseSolved.accept_answer!(answer_post, Discourse.system_user) + + expect(query_results).to contain_exactly( + have_attributes(user_id: answer_user.id, date: Time.current.beginning_of_day, points: 5.0), + ) + + DiscourseSolved.unaccept_answer!(answer_post, topic:) + expect(query_results).to be_empty + end + + it "doesn't score self-accepted answers" do + topic.update!(user: answer_user) + DiscourseSolved.accept_answer!(answer_post, Discourse.system_user) + + expect(query_results).to be_empty + end + end + + it "is disabled when solved plugin is disabled" do + SiteSetting.solved_enabled = false + expect(described_class).not_to be_enabled + + SiteSetting.solved_enabled = true + SiteSetting.solution_score_value = 0 + expect(described_class).not_to be_enabled + + SiteSetting.solution_score_value = 1 + expect(described_class).to be_enabled + end +end diff --git a/plugins/discourse-gamification/spec/models/gamification_leaderboard_spec.rb b/plugins/discourse-gamification/spec/models/gamification_leaderboard_spec.rb new file mode 100644 index 00000000000..0d33e0640d8 --- /dev/null +++ b/plugins/discourse-gamification/spec/models/gamification_leaderboard_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseGamification::GamificationLeaderboard, type: :model do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + + describe ".resolve_period" do + it "returns default period given a blank period" do + expect(leaderboard.default_period).to eq(0) + expect(leaderboard.resolve_period("")).to eq("all_time") + expect(leaderboard.resolve_period(nil)).to eq("all_time") + leaderboard.default_period = 5 + expect(leaderboard.resolve_period(nil)).to eq("daily") + end + + it "returns given period as is if valid" do + described_class.periods.keys.each do |period| + expect(leaderboard.resolve_period(period)).to eq(period) + end + end + + it "returns default period/all_time given an invalid period" do + expect(leaderboard.default_period).to eq(0) + expect(leaderboard.resolve_period("year")).to eq("all_time") + + leaderboard.default_period = 2 + expect(leaderboard.default_period).to eq(2) + expect(leaderboard.resolve_period("quart")).to eq("quarterly") + + leaderboard.default_period = -1 + expect(leaderboard.default_period).to eq(-1) + expect(leaderboard.resolve_period("invalid")).to eq("all_time") + end + end +end diff --git a/plugins/discourse-gamification/spec/models/gamification_score_spec.rb b/plugins/discourse-gamification/spec/models/gamification_score_spec.rb new file mode 100644 index 00000000000..f77c1615a5b --- /dev/null +++ b/plugins/discourse-gamification/spec/models/gamification_score_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseGamification::GamificationScore, type: :model do + fab!(:user) + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + + before { DiscourseGamification::LeaderboardCachedView.create_all } + + describe ".calculate_scores" do + it "calculates the scores properly" do + Fabricate.times(10, :topic, user: user) + described_class.calculate_scores + DiscourseGamification::LeaderboardCachedView.refresh_all + expect(user.gamification_score).to eq(50) + + user.topics.take(5).each(&:destroy) + described_class.calculate_scores + DiscourseGamification::LeaderboardCachedView.refresh_all + expect(user.gamification_score).to eq(25) + + # this test covers a bug where scores weren't updated if new score was 0 + user.topics.each(&:destroy) + described_class.calculate_scores + DiscourseGamification::LeaderboardCachedView.refresh_all + expect(user.gamification_score).to eq(0) + end + end +end diff --git a/plugins/discourse-gamification/spec/models/user_spec.rb b/plugins/discourse-gamification/spec/models/user_spec.rb new file mode 100644 index 00000000000..5903432a880 --- /dev/null +++ b/plugins/discourse-gamification/spec/models/user_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe User, type: :model do + fab!(:user) + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + + before do + Fabricate(:gamification_score, user_id: user.id, score: 10, date: 8.days.ago) + Fabricate(:gamification_score, user_id: user.id, score: 25, date: 5.days.ago) + leaderboard.update(from_date: 5.days.ago.to_date) + + DiscourseGamification::LeaderboardCachedView.create_all + end + + describe "#gamification_score" do + it "returns default leaderboard 'all_time' total score" do + expect(DiscourseGamification::GamificationScore.where(user_id: user.id).sum(:score)).to eq(35) + expect(user.gamification_score).to eq(25) + end + end +end diff --git a/plugins/discourse-gamification/spec/plugin_spec.rb b/plugins/discourse-gamification/spec/plugin_spec.rb new file mode 100644 index 00000000000..eacff18742d --- /dev/null +++ b/plugins/discourse-gamification/spec/plugin_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ::DiscourseGamification do + let(:user) { Fabricate(:user) } + let!(:gamification_score) { Fabricate(:gamification_score, user_id: user.id) } + + it "adds gamification_score to the UserCardSerializer" do + serializer = UserCardSerializer.new(user) + expect(serializer).to respond_to(:gamification_score) + expect(serializer.gamification_score).to eq(gamification_score.score) + end + + context "with leaderboard positions" do + before { SiteSetting.discourse_gamification_enabled = true } + + it "enqueues job to regenerate leaderboard positions for score ranking strategy changes" do + expect do SiteSetting.score_ranking_strategy = "row_number" end.to change { + Jobs::RegenerateLeaderboardPositions.jobs.size + }.by(1) + end + end +end + +describe ::DiscourseGamification do + let(:guardian) { Guardian.new } + let!(:default_gamification_leaderboard) { Fabricate(:gamification_leaderboard) } + + it "adds default_gamification_leaderboard_id to the SiteSettingSerializer" do + site = Site.new(guardian) + serializer = SiteSerializer.new(site) + expect(serializer).to respond_to(:default_gamification_leaderboard_id) + expect(serializer.default_gamification_leaderboard_id).to eq( + default_gamification_leaderboard.id, + ) + end +end + +context "when merging users" do + fab!(:user_1) { Fabricate(:user) } + fab!(:user_2) { Fabricate(:user) } + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + + before do + SiteSetting.discourse_gamification_enabled = true + DiscourseGamification::LeaderboardCachedView.create_all + Fabricate.times(1, :topic, user: user_1) + Fabricate.times(1, :topic, user: user_2) + DiscourseGamification::GamificationScore.calculate_scores + DiscourseGamification::LeaderboardCachedView.refresh_all + end + + it "sums the scores" do + expect(user_2.gamification_score).to eq(5) + + UserMerger.new(user_1, user_2, Discourse.system_user).merge! + DiscourseGamification::LeaderboardCachedView.refresh_all + + expect(user_2.gamification_score).to eq(10) + end +end diff --git a/plugins/discourse-gamification/spec/requests/admin_gamification_leaderboard_controller_spec.rb b/plugins/discourse-gamification/spec/requests/admin_gamification_leaderboard_controller_spec.rb new file mode 100644 index 00000000000..ed9b271f647 --- /dev/null +++ b/plugins/discourse-gamification/spec/requests/admin_gamification_leaderboard_controller_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseGamification::AdminGamificationLeaderboardController do + fab!(:admin) + + before do + SiteSetting.discourse_gamification_enabled = true + sign_in(admin) + end + + describe "#create" do + it "creates leaderboard and enqueues generation of positions" do + expect(Jobs::GenerateLeaderboardPositions.jobs.size).to eq(0) + + expect do + post "/admin/plugins/gamification/leaderboard.json", + params: { + name: "Test", + created_by_id: admin.id, + } + end.to change { DiscourseGamification::GamificationLeaderboard.count }.by(1) + + expect(response.status).to eq(200) + expect(response.parsed_body).to include("name" => "Test", "created_by_id" => admin.id) + + job_data = Jobs::GenerateLeaderboardPositions.jobs.first["args"].first + expect(job_data).to include("leaderboard_id" => response.parsed_body["id"]) + end + end + + describe "#update" do + it "updates leaderboard and enqueues positions refresh" do + leaderboard = Fabricate(:gamification_leaderboard, created_by_id: admin.id) + + expect(Jobs::RefreshLeaderboardPositions.jobs.size).to eq(0) + + put "/admin/plugins/gamification/leaderboard/#{leaderboard.id}.json", + params: { + name: "New Name", + } + + expect(response.status).to eq(200) + expect(leaderboard.reload.name).to eq("New Name") + + job_data = Jobs::RefreshLeaderboardPositions.jobs.first["args"].first + expect(job_data).to include("leaderboard_id" => leaderboard.id) + end + end + + describe "destroy" do + it "deletes leaderboard and enqueues deletion of positions" do + leaderboard = Fabricate(:gamification_leaderboard, created_by_id: admin.id) + + delete "/admin/plugins/gamification/leaderboard/#{leaderboard.id}.json" + + expect { leaderboard.reload }.to raise_error(ActiveRecord::RecordNotFound) + + job_data = Jobs::DeleteLeaderboardPositions.jobs.first["args"].first + expect(job_data).to include("leaderboard_id" => leaderboard.id) + end + end + + describe "#recalculate_scores" do + it "enqueues the job with 'since' date" do + put "/admin/plugins/gamification/recalculate-scores.json", params: { from_date: 10.days.ago } + expect(response.status).to eq(200) + expect(Jobs::RecalculateScores.jobs.size).to eq(1) + + job_data = Jobs::RecalculateScores.jobs.first["args"].first + expect(Date.parse(job_data["since"])).to eq(10.days.ago.midnight) + end + + it "does not enqueue the job with invalid 'since' date" do + put "/admin/plugins/gamification/recalculate-scores.json", + params: { + from_date: 1.day.from_now, + } + expect(response.status).to eq(400) + expect(Jobs::RecalculateScores.jobs.size).to eq(0) + end + end +end diff --git a/plugins/discourse-gamification/spec/requests/admin_gamification_score_event_controller_spec.rb b/plugins/discourse-gamification/spec/requests/admin_gamification_score_event_controller_spec.rb new file mode 100644 index 00000000000..9b88bf9ed7f --- /dev/null +++ b/plugins/discourse-gamification/spec/requests/admin_gamification_score_event_controller_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseGamification::AdminGamificationScoreEventController do + let(:current_user) { Fabricate(:admin) } + let(:another_user) { Fabricate(:user) } + let(:score_events) { [] } + + before do + SiteSetting.discourse_gamification_enabled = true + + score_events << DiscourseGamification::GamificationScoreEvent.create!( + user_id: current_user.id, + date: Date.today, + points: 7, + ) + + score_events << DiscourseGamification::GamificationScoreEvent.create!( + user_id: current_user.id, + date: Date.yesterday, + points: 17, + ) + + score_events << DiscourseGamification::GamificationScoreEvent.create!( + user_id: another_user.id, + date: Date.yesterday, + points: 27, + ) + + DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago.midnight) + sign_in(current_user) + end + + describe "#index" do + it "returns users and their calculated scores" do + get "/admin/plugins/gamification/score_events.json" + expect(response.status).to eq(200) + expect(response.parsed_body["events"].length).to eq(score_events.size) + expect(response.parsed_body["events"][0]["points"]).to eq(score_events[0].points) + end + + it "returns users and their calculated scores for a specific date" do + get "/admin/plugins/gamification/score_events.json?date=#{Date.today}" + expect(response.status).to eq(200) + expect(response.parsed_body["events"].length).to eq(1) + expect(response.parsed_body["events"][0]["points"]).to eq(7) + end + + it "returns users and their calculated scores for a specific user" do + get "/admin/plugins/gamification/score_events.json?user_id=#{current_user.id}" + expect(response.status).to eq(200) + expect(response.parsed_body["events"].length).to eq(2) + expect(response.parsed_body["events"].map { _1["points"] }.sum).to eq(24) + end + + it "returns users and their calculated scores for a specific user and date" do + get "/admin/plugins/gamification/score_events.json?user_id=#{another_user.id}&date=#{Date.today}" + expect(response.status).to eq(200) + expect(response.parsed_body["events"].length).to eq(0) + end + + it "returns users and their calculated scores for a event id" do + get "/admin/plugins/gamification/score_events.json?id=#{score_events.last.id}" + expect(response.status).to eq(200) + expect(response.parsed_body["events"].length).to eq(1) + expect(response.parsed_body["events"][0]["id"]).to eq(score_events.last.id) + end + + it "affects user scores when a score event is created" do + post "/admin/plugins/gamification/score_events.json", + params: { + points: 10, + user_id: another_user.id, + date: Date.today, + } + expect(response.status).to eq(200) + + DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago.midnight) + user_score = + DiscourseGamification::GamificationScore.where(user_id: another_user.id).sum(:score) + expect(user_score).to eq(37) + end + + it "affects user scores when a score event is deleted" do + put "/admin/plugins/gamification/score_events.json", + params: { + id: score_events.last.id, + points: 13, + user_id: another_user.id, + date: Date.yesterday, + } + expect(response.status).to eq(200) + + DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago.midnight) + + user_score = + DiscourseGamification::GamificationScore.where(user_id: another_user.id).sum(:score) + expect(user_score).to eq(13) + end + end +end diff --git a/plugins/discourse-gamification/spec/requests/gamification_leaderboard_controller_spec.rb b/plugins/discourse-gamification/spec/requests/gamification_leaderboard_controller_spec.rb new file mode 100644 index 00000000000..d5b5893d682 --- /dev/null +++ b/plugins/discourse-gamification/spec/requests/gamification_leaderboard_controller_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DiscourseGamification::GamificationLeaderboardController do + let(:group) { Fabricate(:group) } + let(:current_user) { Fabricate(:user, group_ids: [group.id]) } + let(:user_2) { Fabricate(:user) } + let(:staged_user) { Fabricate(:user, staged: true) } + let(:anon_user) { Fabricate(:user, email: "john@anonymized.invalid") } + let!(:create_score) { UserVisit.create(user_id: current_user.id, visited_at: 2.days.ago) } + let!(:create_score_for_user2) { UserVisit.create(user_id: user_2.id, visited_at: 2.days.ago) } + let!(:create_score_for_staged_user) do + UserVisit.create(user_id: staged_user.id, visited_at: 2.days.ago) + end + let!(:create_score_for_anon_user) do + UserVisit.create(user_id: anon_user.id, visited_at: 2.days.ago) + end + let!(:create_topic) { Fabricate(:topic, user: current_user) } + let!(:leaderboard) do + Fabricate(:gamification_leaderboard, name: "test", created_by_id: current_user.id) + end + let!(:leaderboard_2) do + Fabricate( + :gamification_leaderboard, + name: "test_2", + created_by_id: current_user.id, + from_date: 3.days.ago, + to_date: 1.day.ago, + ) + end + let!(:leaderboard_with_group) do + Fabricate( + :gamification_leaderboard, + name: "test_3", + created_by_id: current_user.id, + included_groups_ids: [group.id], + visible_to_groups_ids: [group.id], + ) + end + + let!(:leaderboard_with_default_period_set_to_daily) do + Fabricate( + :gamification_leaderboard, + name: "test_4", + created_by_id: current_user.id, + default_period: 5, + ) + end + + before do + SiteSetting.discourse_gamification_enabled = true + DiscourseGamification::GamificationScore.calculate_scores(since_date: 10.days.ago) + sign_in(current_user) + end + + describe "#respond" do + it "returns users and their calculated scores" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + + get "/leaderboard/#{leaderboard.id}.json" + expect(response.status).to eq(200) + + data = response.parsed_body + expect(data["users"][0]["username"]).to eq(current_user.username) + expect(data["users"][0]["avatar_template"]).to eq(current_user.avatar_template) + expect(data["users"][0]["total_score"]).to eq(current_user.gamification_score) + end + + it "returns an in progress message when leaderboard positions are not ready" do + expect do get "/leaderboard/#{leaderboard.id}.json" end.to change { + Jobs::GenerateLeaderboardPositions.jobs.size + }.by(1) + + expect(response.status).to eq(202) + expect(response.parsed_body["reason"]).to eq(I18n.t("errors.leaderboard_positions_not_ready")) + end + + it "only returns users and scores for specified date range" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard_2).create + get "/leaderboard/#{leaderboard_2.id}.json" + + expect(response.status).to eq(200) + + data = response.parsed_body + expect(data["users"][0]["username"]).to eq(current_user.username) + expect(data["users"][0]["avatar_template"]).to eq(current_user.avatar_template) + expect(data["users"][0]["total_score"]).to eq(1) + end + + it "respects the user_limit parameter" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + + get "/leaderboard/#{leaderboard.id}.json?user_limit=1" + expect(response.status).to eq(200) + + data = response.parsed_body + expect(data["users"].count).to eq(1) + end + + it "only returns users that are a part of a group within included_groups_ids" do + # multiple scores present + expect(DiscourseGamification::GamificationScore.all.map(&:user_id)).to include( + current_user.id, + user_2.id, + ) + + DiscourseGamification::LeaderboardCachedView.new(leaderboard_with_group).create + + get "/leaderboard/#{leaderboard_with_group.id}.json" + expect(response.status).to eq(200) + + data = response.parsed_body + # scoped to group + expect(data["users"].map { |u| u["id"] }).to eq([current_user.id]) + end + + it "excludes staged and anon users" do + # prove score for staged/anon user exists + expect(DiscourseGamification::GamificationScore.all.map(&:user_id)).to include( + staged_user.id, + anon_user.id, + ) + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + + get "/leaderboard/#{leaderboard.id}.json" + data = response.parsed_body + expect(data["users"].map { |u| u["id"] }).to_not include(staged_user.id, anon_user.id) + end + + it "does not error if visible_to_groups_ids or included_groups_ids are empty" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + get "/leaderboard/#{leaderboard.id}.json" + expect(response.status).to eq(200) + end + + it "errors if visible_to_groups_ids are present and user in not a part of a included group" do + current_user.groups = [] + get "/leaderboard/#{leaderboard_with_group.id}.json" + expect(response.status).to eq(404) + end + + it "displays leaderboard to users included in group within visible_to_groups_ids" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard_with_group).create + + get "/leaderboard/#{leaderboard_with_group.id}.json" + expect(response.status).to eq(200) + end + + it "allows admins to see all leaderboards" do + current_user = Fabricate(:admin) + DiscourseGamification::LeaderboardCachedView.new(leaderboard_with_group).create + + sign_in(current_user) + get "/leaderboard/#{leaderboard_with_group.id}.json" + expect(response.status).to eq(200) + end + + it "displays leaderboard for the default leaderboard period" do + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + DiscourseGamification::LeaderboardCachedView.new( + leaderboard_with_default_period_set_to_daily, + ).create + + get "/leaderboard/#{leaderboard.id}.json" + regular_user_score = response.parsed_body["users"][0]["total_score"] + + get "/leaderboard/#{leaderboard.id}.json?period=daily" + daily_user_score = response.parsed_body["users"][0]["total_score"] + + get "/leaderboard/#{leaderboard_with_default_period_set_to_daily.id}.json" + default_user_score = response.parsed_body["users"][0]["total_score"] + + expect(default_user_score).to eq(daily_user_score) + expect(default_user_score).not_to eq(regular_user_score) + end + end +end diff --git a/plugins/discourse-gamification/spec/system/admin_leaderboards_spec.rb b/plugins/discourse-gamification/spec/system/admin_leaderboards_spec.rb new file mode 100644 index 00000000000..c12e372e905 --- /dev/null +++ b/plugins/discourse-gamification/spec/system/admin_leaderboards_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +describe "Admin leaderboards", type: :system, js: true do + fab!(:current_user) { Fabricate(:admin) } + let(:admin_leaderboard_page) { PageObjects::Pages::AdminLeaderboards.new } + let(:dialog) { PageObjects::Components::Dialog.new } + + before do + SiteSetting.discourse_gamification_enabled = true + sign_in(current_user) + end + + it "can create a leaderboard" do + visit("/admin/plugins/discourse-gamification") + + click_on(I18n.t("js.gamification.leaderboard.cta")) + + admin_leaderboard_page.new_form.field("name").fill_in("My leaderboard") + admin_leaderboard_page.new_form.submit + + expect(page).to have_content(I18n.t("js.gamification.leaderboard.create_success")) + expect(admin_leaderboard_page.full_form.field("name").value).to eq("My leaderboard") + + expect(page).to have_current_path( + "/admin/plugins/discourse-gamification/leaderboards/#{DiscourseGamification::GamificationLeaderboard.last.id}", + ) + + admin_leaderboard_page.full_form.field("from_date").fill_in(12.months.ago.end_of_month) + admin_leaderboard_page.full_form.field("to_date").fill_in(11.months.ago.end_of_month) + + admin_leaderboard_page.select_included_groups("admins") + admin_leaderboard_page.select_excluded_groups("trust_level_0") + admin_leaderboard_page.full_form.submit + + expect(page).to have_content(I18n.t("js.gamification.leaderboard.save_success")) + + expect(::DiscourseGamification::GamificationLeaderboard.last).to have_attributes( + name: "My leaderboard", + from_date: 12.months.ago.end_of_month.to_date, + to_date: 11.months.ago.end_of_month.to_date, + included_groups_ids: [Group::AUTO_GROUPS[:admins]], + excluded_groups_ids: [Group::AUTO_GROUPS[:trust_level_0]], + ) + end + + context "when there is an existing leaderboard" do + fab!(:leaderboard) { Fabricate(:gamification_leaderboard, name: "Coolest duders") } + + it "can edit a leaderboard" do + visit("/admin/plugins/discourse-gamification") + + admin_leaderboard_page.edit_leaderboard(leaderboard) + admin_leaderboard_page.full_form.field("name").fill_in("Coolest dudettes") + admin_leaderboard_page.full_form.submit + + expect(page).to have_content(I18n.t("js.gamification.leaderboard.save_success")) + + expect(leaderboard.reload.name).to eq("Coolest dudettes") + end + + it "can delete a leaderboard" do + visit("/admin/plugins/discourse-gamification") + + admin_leaderboard_page.delete_leaderboard(leaderboard) + expect(page).to have_content(I18n.t("js.gamification.leaderboard.confirm_destroy")) + + dialog.click_danger + expect(page).to have_content(I18n.t("js.gamification.leaderboard.delete_success")) + + expect(::DiscourseGamification::GamificationLeaderboard.exists?(leaderboard.id)).to eq(false) + end + end +end diff --git a/plugins/discourse-gamification/spec/system/core_features_spec.rb b/plugins/discourse-gamification/spec/system/core_features_spec.rb new file mode 100644 index 00000000000..db86db283bd --- /dev/null +++ b/plugins/discourse-gamification/spec/system/core_features_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe "Core features", type: :system do + before { enable_current_plugin } + + it_behaves_like "having working core features" +end diff --git a/plugins/discourse-gamification/spec/system/page_objects/modals/recalculate_scores_form.rb b/plugins/discourse-gamification/spec/system/page_objects/modals/recalculate_scores_form.rb new file mode 100644 index 00000000000..0d6295ef267 --- /dev/null +++ b/plugins/discourse-gamification/spec/system/page_objects/modals/recalculate_scores_form.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module PageObjects + module Modals + class RecalculateScoresForm < PageObjects::Modals::Base + def update_range_dropdown + PageObjects::Components::SelectKit.new("#update-range") + end + + def select_update_range(value: nil) + update_range_dropdown.expand + update_range_dropdown.select_row_by_value(value) + end + + def fill_since_date(since) + find("#custom-from-date").fill_in(with: since) + end + + def date_range + find(".recalculate-modal__date-range") + end + + def custom_since_date + find("#custom-from-date input") + end + + def status + find(".recalculate-modal__status") + end + + def remaining + find(".recalculate-modal__footer-text") + end + + def apply + find("#apply-section") + end + end + end +end diff --git a/plugins/discourse-gamification/spec/system/page_objects/pages/admin_leaderboards.rb b/plugins/discourse-gamification/spec/system/page_objects/pages/admin_leaderboards.rb new file mode 100644 index 00000000000..1f37b6cdfcc --- /dev/null +++ b/plugins/discourse-gamification/spec/system/page_objects/pages/admin_leaderboards.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class AdminLeaderboards < PageObjects::Pages::Base + def new_form + @new_form ||= PageObjects::Components::FormKit.new(".new-leaderboard-form") + end + + def full_form + @full_form ||= PageObjects::Components::FormKit.new(".edit-create-leaderboard-form") + end + + def select_included_groups(*groups) + included_groups_sk = + PageObjects::Components::SelectKit.new("#leaderboard-edit__included-groups") + included_groups_sk.expand + groups.each { |g| included_groups_sk.select_row_by_name(g) } + included_groups_sk.collapse + end + + def select_excluded_groups(*groups) + excluded_groups_sk = + PageObjects::Components::SelectKit.new("#leaderboard-edit__excluded-groups") + excluded_groups_sk.expand + groups.each { |g| excluded_groups_sk.select_row_by_name(g) } + excluded_groups_sk.collapse + end + + def edit_leaderboard(leaderboard) + find("#leaderboard-admin__row-#{leaderboard.id} .leaderboard-admin__edit").click + end + + def delete_leaderboard(leaderboard) + find("#leaderboard-admin__row-#{leaderboard.id} .leaderboard-admin__delete").click + end + end + end +end diff --git a/plugins/discourse-gamification/spec/system/recalculate_scores_form_spec.rb b/plugins/discourse-gamification/spec/system/recalculate_scores_form_spec.rb new file mode 100644 index 00000000000..7feaf00aed1 --- /dev/null +++ b/plugins/discourse-gamification/spec/system/recalculate_scores_form_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +describe "Recalculate Scores Form", type: :system do + let(:recalculate_scores_modal) { PageObjects::Modals::RecalculateScoresForm.new } + + fab!(:admin) + fab!(:leaderboard) { Fabricate(:gamification_leaderboard) } + + before do + RateLimiter.enable + SiteSetting.discourse_gamification_enabled = true + DiscourseGamification::LeaderboardCachedView.new(leaderboard).create + sign_in(admin) + end + + def format_date(date) + date.midnight.strftime("%b %-d, %Y") + end + + it "has date options that are valid and can be applied" do + freeze_time + + visit("/admin/plugins/gamification") + find(".leaderboard-admin__btn-recalculate").click + + today = format_date(Time.now) + + recalculate_scores_modal.select_update_range(value: 0) + expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(10.days.ago)} - #{today}") + + recalculate_scores_modal.select_update_range(value: 1) + expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(30.days.ago)} - #{today}") + + recalculate_scores_modal.select_update_range(value: 2) + expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(90.days.ago)} - #{today}") + + recalculate_scores_modal.select_update_range(value: 3) + expect(recalculate_scores_modal.date_range.text).to eq("#{format_date(1.year.ago)} - #{today}") + + recalculate_scores_modal.select_update_range(value: 4) + expect(recalculate_scores_modal.date_range.text).to eq("") + + recalculate_scores_modal.select_update_range(value: 5) + expect(recalculate_scores_modal.custom_since_date).to be_visible + + recalculate_scores_modal.fill_since_date(today) + expect(recalculate_scores_modal.custom_since_date.value).to eq(today) + end + + context "when admin has daily recalculation remaining" do + it "can trigger recalculation" do + visit("/admin/plugins/gamification") + find(".leaderboard-admin__btn-recalculate").click + + recalculate_scores_modal.apply.click + + expect(recalculate_scores_modal.status).to have_content( + I18n.t("js.gamification.recalculating"), + ) + expect(recalculate_scores_modal).to have_button("apply-section", disabled: true) + + expect(Jobs::RecalculateScores.jobs.count).to eq(1) + end + end + + context "when admin does not have daily recalculation remaining" do + it "disables the 'apply' button" do + 5.times { DiscourseGamification::RecalculateScoresRateLimiter.perform! } + + visit("/admin/plugins/gamification") + find(".leaderboard-admin__btn-recalculate").click + + expect(recalculate_scores_modal).to have_button("apply-section", disabled: true) + end + end +end diff --git a/plugins/discourse-gamification/test/javascripts/acceptance/gamification-score-test.js b/plugins/discourse-gamification/test/javascripts/acceptance/gamification-score-test.js new file mode 100644 index 00000000000..6f38f249c77 --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/acceptance/gamification-score-test.js @@ -0,0 +1,49 @@ +import { click, visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { cloneJSON } from "discourse/lib/object"; +import userFixtures from "discourse/tests/fixtures/user-fixtures"; +import { fixturesByUrl } from "discourse/tests/helpers/create-pretender"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; + +acceptance( + "Discourse Gamification | User Card | Show Gamification Score", + function (needs) { + needs.user(); + needs.pretender((server, helper) => { + const cardResponse = cloneJSON(userFixtures["/u/charlie/card.json"]); + cardResponse.user.gamification_score = 10; + server.get("/u/charlie/card.json", () => helper.response(cardResponse)); + }); + + test("user card gamification score - score is present", async function (assert) { + await visit("/t/internationalization-localization/280"); + await click(".topic-map__users-trigger"); + await click('a[data-user-card="charlie"]'); + + assert + .dom(".user-card .gamification-score") + .hasText("Cheers 10", "user card has gamification score"); + }); + } +); + +acceptance( + "Discourse Gamification | User Metadata | Show Gamification Score", + function (needs) { + needs.user(); + needs.pretender((server, helper) => { + const userResponse = cloneJSON(fixturesByUrl["/u/charlie.json"]); + userResponse.user.gamification_score = 10; + + server.get("/u/charlie.json", () => helper.response(userResponse)); + }); + + test("user profile gamification score - score is present", async function (assert) { + await visit("/u/charlie/summary"); + + assert + .dom(".details .secondary .gamification-score") + .hasText("10", "user metadata has gamification score"); + }); + } +); diff --git a/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-row-test.gjs b/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-row-test.gjs new file mode 100644 index 00000000000..5c04a6a3ed9 --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-row-test.gjs @@ -0,0 +1,44 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import GamificationLeaderboardRow from "../discourse/components/gamification-leaderboard-row"; + +module( + "Discourse Gamification | Component | gamification-leaderboard-row", + function (hooks) { + setupRenderingTest(hooks); + + test("Display name prioritizes name", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const rank = { username: "id", name: "bob" }; + + await render( + + ); + + assert.dom(".user__name").hasText("bob"); + }); + + test("Display name prioritizes username", async function (assert) { + this.siteSettings.prioritize_username_in_ux = true; + const rank = { username: "id", name: "bob" }; + + await render( + + ); + + assert.dom(".user__name").hasText("id"); + }); + + test("Display name prioritizes username when name is empty", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const rank = { username: "id", name: "" }; + + await render( + + ); + + assert.dom(".user__name").hasText("id"); + }); + } +); diff --git a/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-test.gjs b/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-test.gjs new file mode 100644 index 00000000000..c22b44fd9b0 --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/components/gamification-leaderboard-test.gjs @@ -0,0 +1,56 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import GamificationLeaderboard from "../discourse/components/gamification-leaderboard"; + +module( + "Discourse Gamification | Component | gamification-leaderboard", + function (hooks) { + setupRenderingTest(hooks); + + test("Display name prioritizes name", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const model = { + leaderboard: "", + personal: "", + users: [{ username: "id", name: "bob" }], + }; + + await render( + + ); + + assert.dom(".winner__name").hasText("bob"); + }); + + test("Display name prioritizes username", async function (assert) { + this.siteSettings.prioritize_username_in_ux = true; + const model = { + leaderboard: "", + personal: "", + users: [{ username: "id", name: "bob" }], + }; + + await render( + + ); + + assert.dom(".winner__name").hasText("id"); + }); + + test("Display name prioritizes username when name is empty", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const model = { + leaderboard: "", + personal: "", + users: [{ username: "id", name: "" }], + }; + + await render( + + ); + + assert.dom(".winner__name").hasText("id"); + }); + } +); diff --git a/plugins/discourse-gamification/test/javascripts/components/gamification-score-test.gjs b/plugins/discourse-gamification/test/javascripts/components/gamification-score-test.gjs new file mode 100644 index 00000000000..66b337fcd14 --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/components/gamification-score-test.gjs @@ -0,0 +1,29 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import GamificationScore from "../discourse/components/gamification-score"; + +module( + "Discourse Gamification | Component | gamification-score", + function (hooks) { + setupRenderingTest(hooks); + + test("Scores click link to leaderboard", async function (assert) { + this.site.default_gamification_leaderboard_id = 1; + const user = { id: "1", username: "charlie", gamification_score: 1 }; + + await render(); + + assert.dom(".gamification-score a").exists("scores are not clickable"); + }); + + test("Scores show up and are not clickable", async function (assert) { + const user = { id: "1", username: "charlie", gamification_score: 1 }; + + await render(); + + assert.dom(".gamification-score").exists("scores not showing up"); + assert.dom(".gamification-score a").doesNotExist("scores are clickable"); + }); + } +); diff --git a/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-row-test.gjs b/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-row-test.gjs new file mode 100644 index 00000000000..f6d452feb8b --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-row-test.gjs @@ -0,0 +1,50 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import MinimalGamificationLeaderboardRow from "../discourse/components/minimal-gamification-leaderboard-row"; + +module( + "Discourse Gamification | Component | minimal-gamification-leaderboard-row", + function (hooks) { + setupRenderingTest(hooks); + + test("Display name prioritizes name", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const rank = { username: "id", name: "bob" }; + + await render( + + ); + + assert.dom(".user__name").hasText("bob"); + }); + + test("Display name prioritizes username", async function (assert) { + this.siteSettings.prioritize_username_in_ux = true; + const rank = { username: "id", name: "bob" }; + + await render( + + ); + + assert.dom(".user__name").hasText("id"); + }); + + test("Display name prioritizes username when name is empty", async function (assert) { + this.siteSettings.prioritize_username_in_ux = false; + const rank = { username: "id", name: "" }; + + await render( + + ); + + assert.dom(".user__name").hasText("id"); + }); + } +); diff --git a/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-test.gjs b/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-test.gjs new file mode 100644 index 00000000000..557e175de76 --- /dev/null +++ b/plugins/discourse-gamification/test/javascripts/components/minimal-gamification-leaderboard-test.gjs @@ -0,0 +1,46 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import MinimalGamificationLeaderboard from "../discourse/components/minimal-gamification-leaderboard"; + +module( + "Discourse Gamification | Component | minimal-gamification-leaderboard", + function (hooks) { + setupRenderingTest(hooks); + + test("regular leaderboard endpoint", async function (assert) { + pretender.get("/leaderboard", () => + response({ + leaderboard: "", + personal: "", + users: [{ id: 1, username: "foo" }], + }) + ); + + await render(); + + assert.dom(".user__name").hasText("foo"); + }); + + test("leaderboard by id and with custom user count", async function (assert) { + pretender.get("/leaderboard/3", ({ queryParams }) => { + assert.strictEqual(queryParams.user_limit, "5"); + + return response({ + leaderboard: "", + personal: "", + users: [{ id: 1, username: "foo" }], + }); + }); + + await render( + + ); + + assert.dom(".user__name").hasText("foo"); + }); + } +); diff --git a/translator.yml b/translator.yml index fe66f802b73..a4103e721dd 100644 --- a/translator.yml +++ b/translator.yml @@ -241,3 +241,10 @@ files: - source_path: plugins/discourse-hcaptcha/config/locales/server.en.yml destination_path: plugins/hcaptcha/server.yml label: hcaptcha + + - source_path: plugins/discourse-gamification/config/locales/client.en.yml + destination_path: plugins/gamification/client.yml + label: gamification + - source_path: plugins/discourse-gamification/config/locales/server.en.yml + destination_path: plugins/gamification/server.yml + label: gamification