├── .discourse-compatibility ├── .github └── workflows │ └── discourse-plugin.yml ├── .gitignore ├── .npmrc ├── .prettierrc.cjs ├── .rubocop.yml ├── .streerc ├── .template-lintrc.cjs ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── about.json ├── app ├── controllers │ └── discourse_data_explorer │ │ └── query_controller.rb ├── jobs │ └── scheduled │ │ └── delete_hidden_queries.rb ├── models │ └── discourse_data_explorer │ │ ├── query.rb │ │ └── query_group.rb └── serializers │ └── discourse_data_explorer │ ├── query_details_serializer.rb │ ├── query_group_bookmark_serializer.rb │ ├── query_group_serializer.rb │ ├── query_serializer.rb │ ├── small_badge_serializer.rb │ └── small_post_with_excerpt_serializer.rb ├── assets ├── javascripts │ ├── admin │ │ └── adapters │ │ │ └── query.js │ └── discourse │ │ ├── components │ │ ├── code-view.gjs │ │ ├── data-explorer-bar-chart.gjs │ │ ├── explorer-schema.gjs │ │ ├── explorer-schema │ │ │ ├── enum-info.gjs │ │ │ └── one-table.gjs │ │ ├── modal │ │ │ └── query-help.gjs │ │ ├── param-input-form.gjs │ │ ├── param-input │ │ │ ├── boolean-three.gjs │ │ │ ├── category-id-input.gjs │ │ │ ├── group-input.gjs │ │ │ ├── user-id-input.gjs │ │ │ └── user-list-input.gjs │ │ ├── query-result.gjs │ │ ├── query-results-wrapper.gjs │ │ ├── query-row-content.gjs │ │ ├── result-types │ │ │ ├── badge.gjs │ │ │ ├── category.gjs │ │ │ ├── group.gjs │ │ │ ├── html.gjs │ │ │ ├── json.gjs │ │ │ ├── post.gjs │ │ │ ├── reltime.gjs │ │ │ ├── text.gjs │ │ │ ├── topic.gjs │ │ │ ├── url.gjs │ │ │ └── user.gjs │ │ └── share-report.gjs │ │ ├── connectors │ │ └── group-reports-nav-item │ │ │ └── nav-item.gjs │ │ ├── controllers │ │ ├── admin-plugins-explorer-index.js │ │ ├── admin-plugins-explorer-queries-details.js │ │ ├── group-reports-index.js │ │ └── group-reports-show.js │ │ ├── explorer-route-map.js │ │ ├── group-reports-route-map.js │ │ ├── initializers │ │ └── initialize-data-explorer.js │ │ ├── lib │ │ └── themeColor.js │ │ ├── models │ │ └── query.js │ │ ├── routes │ │ ├── admin-plugins-explorer-index.js │ │ ├── admin-plugins-explorer-queries-details.js │ │ ├── group-reports-index.js │ │ └── group-reports-show.js │ │ └── templates │ │ ├── admin │ │ ├── plugins-explorer-index.gjs │ │ └── plugins-explorer-queries-details.gjs │ │ ├── group-reports-index.gjs │ │ └── group-reports-show.gjs └── stylesheets │ └── explorer.scss ├── config ├── locales │ ├── client.ar.yml │ ├── client.be.yml │ ├── client.bg.yml │ ├── client.bs_BA.yml │ ├── client.ca.yml │ ├── client.cs.yml │ ├── client.da.yml │ ├── client.de.yml │ ├── client.el.yml │ ├── client.en.yml │ ├── client.en_GB.yml │ ├── client.es.yml │ ├── client.et.yml │ ├── client.fa_IR.yml │ ├── client.fi.yml │ ├── client.fr.yml │ ├── client.gl.yml │ ├── client.he.yml │ ├── client.hr.yml │ ├── client.hu.yml │ ├── client.hy.yml │ ├── client.id.yml │ ├── client.it.yml │ ├── client.ja.yml │ ├── client.ko.yml │ ├── client.lt.yml │ ├── client.lv.yml │ ├── client.nb_NO.yml │ ├── client.nl.yml │ ├── client.pl_PL.yml │ ├── client.pt.yml │ ├── client.pt_BR.yml │ ├── client.ro.yml │ ├── client.ru.yml │ ├── client.sk.yml │ ├── client.sl.yml │ ├── client.sq.yml │ ├── client.sr.yml │ ├── client.sv.yml │ ├── client.sw.yml │ ├── client.te.yml │ ├── client.th.yml │ ├── client.tr_TR.yml │ ├── client.ug.yml │ ├── client.uk.yml │ ├── client.ur.yml │ ├── client.vi.yml │ ├── client.zh_CN.yml │ ├── client.zh_TW.yml │ ├── server.ar.yml │ ├── server.be.yml │ ├── server.bg.yml │ ├── server.bs_BA.yml │ ├── server.ca.yml │ ├── server.cs.yml │ ├── server.da.yml │ ├── server.de.yml │ ├── server.el.yml │ ├── server.en.yml │ ├── server.en_GB.yml │ ├── server.es.yml │ ├── server.et.yml │ ├── server.fa_IR.yml │ ├── server.fi.yml │ ├── server.fr.yml │ ├── server.gl.yml │ ├── server.he.yml │ ├── server.hr.yml │ ├── server.hu.yml │ ├── server.hy.yml │ ├── server.id.yml │ ├── server.it.yml │ ├── server.ja.yml │ ├── server.ko.yml │ ├── server.lt.yml │ ├── server.lv.yml │ ├── server.nb_NO.yml │ ├── server.nl.yml │ ├── server.pl_PL.yml │ ├── server.pt.yml │ ├── server.pt_BR.yml │ ├── server.ro.yml │ ├── server.ru.yml │ ├── server.sk.yml │ ├── server.sl.yml │ ├── server.sq.yml │ ├── server.sr.yml │ ├── server.sv.yml │ ├── server.sw.yml │ ├── server.te.yml │ ├── server.th.yml │ ├── server.tr_TR.yml │ ├── server.ug.yml │ ├── server.uk.yml │ ├── server.ur.yml │ ├── server.vi.yml │ ├── server.zh_CN.yml │ └── server.zh_TW.yml ├── routes.rb └── settings.yml ├── db └── migrate │ ├── 20200810053843_create_data_explorer_queries.rb │ ├── 20200902225712_fix_query_ids.rb │ ├── 20230227102505_rename_data_explorer_namespace.rb │ └── 20241009161603_alter_query_id_to_bigint.rb ├── eslint.config.mjs ├── lib ├── discourse_data_explorer │ ├── data_explorer.rb │ ├── engine.rb │ ├── parameter.rb │ ├── queries.rb │ ├── query_group_bookmarkable.rb │ ├── report_generator.rb │ ├── result_format_converter.rb │ ├── result_to_markdown.rb │ └── validation_error.rb └── tasks │ ├── data_explorer.rake │ └── fix_query_ids.rake ├── package.json ├── plugin.rb ├── pnpm-lock.yaml ├── spec ├── automation │ ├── recurring_data_explorer_result_pm_spec.rb │ └── recurring_data_explorer_result_topic_spec.rb ├── data_explorer_spec.rb ├── fabricators │ └── query_fabricator.rb ├── guardian_spec.rb ├── integration │ └── custom_api_key_scopes_spec.rb ├── jobs │ └── scheduled │ │ └── delete_hidden_queries_spec.rb ├── lib │ ├── data_explorer │ │ └── query_group_bookmarkable_spec.rb │ └── parameter_spec.rb ├── models │ └── query_spec.rb ├── report_generator_spec.rb ├── requests │ ├── group_spec.rb │ └── query_controller_spec.rb ├── result_format_converter_spec.rb ├── result_to_markdown_spec.rb ├── system │ ├── bookmark_spec.rb │ ├── core_features_spec.rb │ ├── explorer_spec.rb │ ├── param_input_spec.rb │ └── reports_spec.rb └── tasks │ ├── data_explorer_spec.rb │ └── fix_query_ids_spec.rb ├── stylelint.config.mjs ├── test └── javascripts │ ├── acceptance │ ├── list-queries-test.js │ ├── new-query-test.js │ ├── param-input-test.js │ └── run-query-test.js │ ├── components │ ├── explorer-schema-test.gjs │ └── param-input-test.gjs │ └── integration │ └── components │ ├── data-explorer-bar-chart-test.gjs │ └── query-result-test.gjs └── translator.yml /.discourse-compatibility: -------------------------------------------------------------------------------- 1 | < 3.5.0.beta5-dev: 71141cb6b274c778ad00c8f5eb17b3b6bc59dce9 2 | < 3.5.0.beta1-dev: 2ba204a1de2638a7959e588b88f3b6c7fcf7a70f 3 | < 3.4.0.beta2-dev: bdff229ca088ead9512ba333eea4fdbbb258b250 4 | < 3.4.0.beta1-dev: 2d0dc39767f0c68d333f113c550731a5546c3137 5 | < 3.3.0.beta1-dev: ebe71a7a138c856d88737eb11b5096a42d4fbaf3 6 | < 3.2.0.beta4-dev: 9f841a4c6f45b4a5bf81e2b9a6f389acf69cd13c 7 | 3.1.999: e4f8d3924a18b303c2bb7da9472cf0c060060e4e 8 | 3.1.0.beta4: 2cc87a10157852112712b7df1f7135b71023ad92 9 | 3.1.0.beta3: 3907c4926383303ff85d992bb276e2b173cf0843 10 | 3.1.0.beta1: 0f6b30c2d84d44978be1c845267609446ca9dbf2 11 | 2.9.0.beta8: 729e5a2add46fb4e2d8ed092da05a87ebbfcf05b 12 | 2.9.0.beta6: 272e9dd760f82e33f9756866434ddcbe470cdb8e 13 | 2.9.0.beta2: e7c19ac107dcd37618c7ac7b98530e99c7fe31db 14 | 2.8.0.beta3: 23287ece952cb45203819e7b470ebc194c58cb13 15 | 2.7.7: 23287ece952cb45203819e7b470ebc194c58cb13 16 | 2.7.0.beta3: 60ffd4bc4d357b365cdb8a6764cec621f2edcf81 17 | 2.6.0.beta2: 16873e708a3c924549e77f3fea011069358d1511 18 | -------------------------------------------------------------------------------- /.github/workflows/discourse-plugin.yml: -------------------------------------------------------------------------------- 1 | name: Discourse Plugin 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | ci: 11 | uses: discourse/.github/.github/workflows/discourse-plugin.yml@v1 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /gems 3 | /auto_generated 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | auto-install-peers = false 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/prettier"); 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: stree-compat.yml 3 | -------------------------------------------------------------------------------- /.streerc: -------------------------------------------------------------------------------- 1 | --print-width=100 2 | --plugins=plugin/trailing_comma 3 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@discourse/lint-configs/template-lint"); 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | group :development do 6 | gem "rubocop-discourse" 7 | gem "syntax_tree" 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | ast (2.4.3) 18 | base64 (0.3.0) 19 | benchmark (0.4.1) 20 | bigdecimal (3.2.0) 21 | concurrent-ruby (1.3.5) 22 | connection_pool (2.5.3) 23 | drb (2.2.3) 24 | i18n (1.14.7) 25 | concurrent-ruby (~> 1.0) 26 | json (2.12.2) 27 | language_server-protocol (3.17.0.5) 28 | lint_roller (1.1.0) 29 | logger (1.7.0) 30 | minitest (5.25.5) 31 | parallel (1.27.0) 32 | parser (3.3.8.0) 33 | ast (~> 2.4.1) 34 | racc 35 | prettier_print (1.2.1) 36 | prism (1.4.0) 37 | racc (1.8.1) 38 | rack (3.1.15) 39 | rainbow (3.1.1) 40 | regexp_parser (2.10.0) 41 | rubocop (1.75.8) 42 | json (~> 2.3) 43 | language_server-protocol (~> 3.17.0.2) 44 | lint_roller (~> 1.1.0) 45 | parallel (~> 1.10) 46 | parser (>= 3.3.0.2) 47 | rainbow (>= 2.2.2, < 4.0) 48 | regexp_parser (>= 2.9.3, < 3.0) 49 | rubocop-ast (>= 1.44.0, < 2.0) 50 | ruby-progressbar (~> 1.7) 51 | unicode-display_width (>= 2.4.0, < 4.0) 52 | rubocop-ast (1.44.1) 53 | parser (>= 3.3.7.2) 54 | prism (~> 1.4) 55 | rubocop-capybara (2.22.1) 56 | lint_roller (~> 1.1) 57 | rubocop (~> 1.72, >= 1.72.1) 58 | rubocop-discourse (3.12.1) 59 | activesupport (>= 6.1) 60 | lint_roller (>= 1.1.0) 61 | rubocop (>= 1.73.2) 62 | rubocop-capybara (>= 2.22.0) 63 | rubocop-factory_bot (>= 2.27.0) 64 | rubocop-rails (>= 2.30.3) 65 | rubocop-rspec (>= 3.0.1) 66 | rubocop-rspec_rails (>= 2.31.0) 67 | rubocop-factory_bot (2.27.1) 68 | lint_roller (~> 1.1) 69 | rubocop (~> 1.72, >= 1.72.1) 70 | rubocop-rails (2.32.0) 71 | activesupport (>= 4.2.0) 72 | lint_roller (~> 1.1) 73 | rack (>= 1.1) 74 | rubocop (>= 1.75.0, < 2.0) 75 | rubocop-ast (>= 1.44.0, < 2.0) 76 | rubocop-rspec (3.6.0) 77 | lint_roller (~> 1.1) 78 | rubocop (~> 1.72, >= 1.72.1) 79 | rubocop-rspec_rails (2.31.0) 80 | lint_roller (~> 1.1) 81 | rubocop (~> 1.72, >= 1.72.1) 82 | rubocop-rspec (~> 3.5) 83 | ruby-progressbar (1.13.0) 84 | securerandom (0.4.1) 85 | syntax_tree (6.2.0) 86 | prettier_print (>= 1.2.0) 87 | tzinfo (2.0.6) 88 | concurrent-ruby (~> 1.0) 89 | unicode-display_width (3.1.4) 90 | unicode-emoji (~> 4.0, >= 4.0.4) 91 | unicode-emoji (4.0.4) 92 | uri (1.0.3) 93 | 94 | PLATFORMS 95 | ruby 96 | 97 | DEPENDENCIES 98 | rubocop-discourse 99 | syntax_tree 100 | 101 | BUNDLED WITH 102 | 2.6.9 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Civilized Discourse Construction Kit, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data Explorer Plugin 2 | 3 | This plugin allows admins to run SQL queries against the live Discourse database, 4 | including parameterized queries and formatting for several common column types. 5 | 6 | For more information, please see: https://meta.discourse.org/t/data-explorer-plugin/32566 7 | -------------------------------------------------------------------------------- /about.json: -------------------------------------------------------------------------------- 1 | { 2 | "tests": { 3 | "requiredPlugins": [ 4 | "https://github.com/discourse/discourse-automation" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/jobs/scheduled/delete_hidden_queries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class DeleteHiddenQueries < ::Jobs::Scheduled 5 | every 7.days 6 | 7 | def execute(args) 8 | return unless SiteSetting.data_explorer_enabled 9 | 10 | DiscourseDataExplorer::Query 11 | .where("id > 0") 12 | .where(hidden: true) 13 | .where( 14 | "(last_run_at IS NULL OR last_run_at < :days_ago) AND updated_at < :days_ago", 15 | days_ago: 7.days.ago, 16 | ) 17 | .delete_all 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/models/discourse_data_explorer/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryFinder 5 | def self.find(id) 6 | default_query = Queries.default[id.to_s] 7 | return raise ActiveRecord::RecordNotFound unless default_query 8 | 9 | query = Query.find_by(id: id) || Query.new 10 | query.attributes = default_query 11 | query.user_id = Discourse::SYSTEM_USER_ID.to_s 12 | query 13 | end 14 | end 15 | 16 | class Query < ActiveRecord::Base 17 | self.table_name = "data_explorer_queries" 18 | 19 | has_many :query_groups 20 | has_many :groups, through: :query_groups 21 | belongs_to :user 22 | validates :name, presence: true 23 | 24 | scope :for_group, 25 | ->(group) do 26 | where(hidden: false).joins( 27 | "INNER JOIN data_explorer_query_groups 28 | ON data_explorer_query_groups.query_id = data_explorer_queries.id 29 | AND data_explorer_query_groups.group_id = #{group.id}", 30 | ) 31 | end 32 | 33 | def params 34 | @params ||= Parameter.create_from_sql(sql) 35 | end 36 | 37 | def cast_params(input_params) 38 | result = {}.with_indifferent_access 39 | self.params.each do |pobj| 40 | result[pobj.identifier] = pobj.cast_to_ruby input_params[pobj.identifier] 41 | end 42 | result 43 | end 44 | 45 | def slug 46 | Slug.for(name).presence || "query-#{id}" 47 | end 48 | 49 | def self.find(id) 50 | return super if id.to_i >= 0 51 | QueryFinder.find(id) 52 | end 53 | 54 | private 55 | 56 | # for `Query.unscoped.find` 57 | class ActiveRecord_Relation 58 | def find(id) 59 | return super if id.to_i >= 0 60 | QueryFinder.find(id) 61 | end 62 | end 63 | end 64 | end 65 | 66 | # == Schema Information 67 | # 68 | # Table name: data_explorer_queries 69 | # 70 | # id :bigint not null, primary key 71 | # name :string 72 | # description :text 73 | # sql :text default("SELECT 1"), not null 74 | # user_id :integer 75 | # last_run_at :datetime 76 | # hidden :boolean default(FALSE), not null 77 | # created_at :datetime not null 78 | # updated_at :datetime not null 79 | # 80 | -------------------------------------------------------------------------------- /app/models/discourse_data_explorer/query_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryGroup < ActiveRecord::Base 5 | self.table_name = "data_explorer_query_groups" 6 | 7 | belongs_to :query 8 | belongs_to :group 9 | 10 | has_many :bookmarks, as: :bookmarkable 11 | end 12 | end 13 | 14 | # == Schema Information 15 | # 16 | # Table name: data_explorer_query_groups 17 | # 18 | # id :bigint not null, primary key 19 | # query_id :bigint 20 | # group_id :integer 21 | # 22 | # Indexes 23 | # 24 | # index_data_explorer_query_groups_on_group_id (group_id) 25 | # index_data_explorer_query_groups_on_query_id (query_id) 26 | # index_data_explorer_query_groups_on_query_id_and_group_id (query_id,group_id) UNIQUE 27 | # 28 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/query_details_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryDetailsSerializer < QuerySerializer 5 | attributes :sql, :param_info, :created_at, :hidden 6 | 7 | def param_info 8 | object&.params&.uniq { |p| p.identifier }&.map(&:to_hash) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/query_group_bookmark_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryGroupBookmarkSerializer < UserBookmarkBaseSerializer 5 | def title 6 | fancy_title 7 | end 8 | 9 | def fancy_title 10 | data_explorer_query.name 11 | end 12 | 13 | def cooked 14 | data_explorer_query.description 15 | end 16 | 17 | def bookmarkable_user 18 | @bookmarkable_user ||= data_explorer_query.user 19 | end 20 | 21 | def bookmarkable_url 22 | "/g/#{data_explorer_query_group.group.name}/reports/#{data_explorer_query_group.query_id}" 23 | end 24 | 25 | def excerpt 26 | return nil unless cooked 27 | @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) 28 | end 29 | 30 | private 31 | 32 | def data_explorer_query 33 | data_explorer_query_group.query 34 | end 35 | 36 | def data_explorer_query_group 37 | object.bookmarkable 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/query_group_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryGroupSerializer < ActiveModel::Serializer 5 | attributes :id, :group_id, :query_id, :bookmark 6 | 7 | def query_group_bookmark 8 | @query_group_bookmark ||= Bookmark.find_by(user: scope.user, bookmarkable: object) 9 | end 10 | 11 | def include_bookmark? 12 | query_group_bookmark.present? 13 | end 14 | 15 | def bookmark 16 | { 17 | id: query_group_bookmark.id, 18 | reminder_at: query_group_bookmark.reminder_at, 19 | name: query_group_bookmark.name, 20 | auto_delete_preference: query_group_bookmark.auto_delete_preference, 21 | bookmarkable_id: query_group_bookmark.bookmarkable_id, 22 | bookmarkable_type: query_group_bookmark.bookmarkable_type, 23 | } 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/query_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QuerySerializer < ActiveModel::Serializer 5 | attributes :id, :name, :description, :username, :group_ids, :last_run_at, :user_id 6 | 7 | def username 8 | object&.user&.username 9 | end 10 | 11 | def group_ids 12 | object.groups.map(&:id) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/small_badge_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DiscourseDataExplorer::SmallBadgeSerializer < ApplicationSerializer 4 | attributes :id, :name, :display_name, :badge_type, :description, :icon 5 | end 6 | -------------------------------------------------------------------------------- /app/serializers/discourse_data_explorer/small_post_with_excerpt_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class SmallPostWithExcerptSerializer < ApplicationSerializer 5 | attributes :id, :topic_id, :post_number, :excerpt, :username, :avatar_template 6 | 7 | def excerpt 8 | Post.excerpt(object.cooked, 70) 9 | end 10 | 11 | def username 12 | object.user && object.user.username 13 | end 14 | 15 | def avatar_template 16 | object.user && object.user.avatar_template 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /assets/javascripts/admin/adapters/query.js: -------------------------------------------------------------------------------- 1 | import buildPluginAdapter from "admin/adapters/build-plugin"; 2 | 3 | export default buildPluginAdapter("explorer").extend({}); 4 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/code-view.gjs: -------------------------------------------------------------------------------- 1 | const CodeView = ; 4 | 5 | export default CodeView; 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/data-explorer-bar-chart.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { on } from "@ember/modifier"; 3 | import { action } from "@ember/object"; 4 | import didInsert from "@ember/render-modifiers/modifiers/did-insert"; 5 | import { bind } from "discourse/lib/decorators"; 6 | import loadScript from "discourse/lib/load-script"; 7 | import themeColor from "../lib/themeColor"; 8 | 9 | export default class DataExplorerBarChart extends Component { 10 | chart; 11 | barsColor = themeColor("--tertiary"); 12 | barsHoverColor = themeColor("--tertiary-high"); 13 | gridColor = themeColor("--primary-low"); 14 | labelsColor = themeColor("--primary-medium"); 15 | 16 | willDestroy() { 17 | super.willDestroy(...arguments); 18 | this.chart.destroy(); 19 | } 20 | 21 | get config() { 22 | const data = this.data; 23 | const options = this.options; 24 | return { 25 | type: "bar", 26 | data, 27 | options, 28 | }; 29 | } 30 | 31 | get data() { 32 | const labels = this.args.labels; 33 | return { 34 | labels, 35 | datasets: [ 36 | { 37 | label: this.args.datasetName, 38 | data: this.args.values, 39 | backgroundColor: this.barsColor, 40 | hoverBackgroundColor: this.barsHoverColor, 41 | }, 42 | ], 43 | }; 44 | } 45 | 46 | get options() { 47 | return { 48 | scales: { 49 | legend: { 50 | labels: { 51 | fontColor: this.labelsColor, 52 | }, 53 | }, 54 | xAxes: [ 55 | { 56 | gridLines: { 57 | color: this.gridColor, 58 | zeroLineColor: this.gridColor, 59 | }, 60 | ticks: { 61 | fontColor: this.labelsColor, 62 | }, 63 | }, 64 | ], 65 | yAxes: [ 66 | { 67 | gridLines: { 68 | color: this.gridColor, 69 | zeroLineColor: this.gridColor, 70 | }, 71 | ticks: { 72 | beginAtZero: true, 73 | fontColor: this.labelsColor, 74 | }, 75 | }, 76 | ], 77 | }, 78 | }; 79 | } 80 | 81 | @bind 82 | async initChart(canvas) { 83 | await loadScript("/javascripts/Chart.min.js"); 84 | const context = canvas.getContext("2d"); 85 | // eslint-disable-next-line 86 | this.chart = new Chart(context, this.config); 87 | } 88 | 89 | @action 90 | updateChartData() { 91 | this.chart.data = this.data; 92 | this.chart.update(); 93 | } 94 | 95 | 101 | } 102 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/explorer-schema/enum-info.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | 3 | export default class EnumInfo extends Component { 4 | get enuminfo() { 5 | return Object.entries(this.args.col.enum).map(([value, name]) => ({ 6 | value, 7 | name, 8 | })); 9 | } 10 | 11 | 20 | } 21 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/explorer-schema/one-table.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { on } from "@ember/modifier"; 4 | import icon from "discourse/helpers/d-icon"; 5 | import { bind } from "discourse/lib/decorators"; 6 | import { i18n } from "discourse-i18n"; 7 | import EnumInfo from "./enum-info"; 8 | 9 | export default class OneTable extends Component { 10 | @tracked open = this.args.table.open; 11 | 12 | get styles() { 13 | return this.open ? "open" : ""; 14 | } 15 | 16 | @bind 17 | toggleOpen() { 18 | this.open = !this.open; 19 | } 20 | 21 | 78 | } 79 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/modal/query-help.gjs: -------------------------------------------------------------------------------- 1 | import { htmlSafe } from "@ember/template"; 2 | import DModal from "discourse/components/d-modal"; 3 | import { i18n } from "discourse-i18n"; 4 | 5 | const QueryHelp = ; 18 | 19 | export default QueryHelp; 20 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/param-input/boolean-three.gjs: -------------------------------------------------------------------------------- 1 | import { i18n } from "discourse-i18n"; 2 | 3 | const BooleanThree = ; 16 | 17 | export default BooleanThree; 18 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/param-input/category-id-input.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { hash } from "@ember/helper"; 3 | import CategoryChooser from "select-kit/components/category-chooser"; 4 | 5 | export default class CategoryIdInput extends Component { 6 | // CategoryChooser will try to modify the value of value, 7 | // triggering a setting-on-hash error. So we have to do the dirty work. 8 | get data() { 9 | return { 10 | value: this.args.field.value, 11 | }; 12 | } 13 | 14 | 28 | } 29 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/param-input/group-input.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { service } from "@ember/service"; 3 | import GroupChooser from "select-kit/components/group-chooser"; 4 | 5 | export default class GroupInput extends Component { 6 | @service site; 7 | 8 | get allGroups() { 9 | return this.site.get("groups"); 10 | } 11 | 12 | get groupChooserOption() { 13 | return this.args.info.type === "group_id" 14 | ? { 15 | maximum: 1, 16 | } 17 | : {}; 18 | } 19 | 20 | 33 | } 34 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/param-input/user-id-input.gjs: -------------------------------------------------------------------------------- 1 | import { hash } from "@ember/helper"; 2 | import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser"; 3 | 4 | const UserIdInput = ; 14 | 15 | export default UserIdInput; 16 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/param-input/user-list-input.gjs: -------------------------------------------------------------------------------- 1 | import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser"; 2 | 3 | const UserListInput = ; 12 | 13 | export default UserListInput; 14 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/query-results-wrapper.gjs: -------------------------------------------------------------------------------- 1 | import QueryResult from "./query-result"; 2 | 3 | const QueryResultsWrapper = ; 16 | 17 | export default QueryResultsWrapper; 18 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/query-row-content.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { cached } from "@glimmer/tracking"; 3 | import { capitalize } from "@ember/string"; 4 | import getURL from "discourse/lib/get-url"; 5 | import { escapeExpression } from "discourse/lib/utilities"; 6 | import TextViewComponent from "./result-types/text"; 7 | 8 | export default class QueryRowContent extends Component { 9 | @cached 10 | get results() { 11 | return this.args.columnComponents.map((componentDefinition, idx) => { 12 | const value = this.args.row[idx], 13 | id = parseInt(value, 10); 14 | 15 | const ctx = { 16 | value, 17 | id, 18 | baseuri: getURL(""), 19 | }; 20 | 21 | if (this.args.row[idx] === null) { 22 | return { 23 | component: TextViewComponent, 24 | textValue: "NULL", 25 | }; 26 | } else if (componentDefinition.name === "text") { 27 | return { 28 | component: TextViewComponent, 29 | textValue: escapeExpression(this.args.row[idx].toString()), 30 | }; 31 | } 32 | 33 | const lookupFunc = 34 | this.args[`lookup${capitalize(componentDefinition.name)}`]; 35 | if (lookupFunc) { 36 | ctx[componentDefinition.name] = lookupFunc.call(this.args, id); 37 | } 38 | 39 | if (componentDefinition.name === "url") { 40 | let [url, name] = guessUrl(value); 41 | ctx["href"] = url; 42 | ctx["target"] = name; 43 | } 44 | 45 | try { 46 | return { 47 | component: componentDefinition.component || TextViewComponent, 48 | ctx, 49 | }; 50 | } catch { 51 | return "error"; 52 | } 53 | }); 54 | } 55 | 56 | 69 | } 70 | 71 | function guessUrl(columnValue) { 72 | let [dest, name] = [columnValue, columnValue]; 73 | 74 | const split = columnValue.split(/,(.+)/); 75 | 76 | if (split.length > 1) { 77 | name = split[0]; 78 | dest = split[1]; 79 | } 80 | 81 | return [dest, name]; 82 | } 83 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/badge.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { htmlSafe } from "@ember/template"; 3 | import { isEmpty } from "@ember/utils"; 4 | import { convertIconClass, iconHTML } from "discourse/lib/icon-library"; 5 | 6 | export default class Badge extends Component { 7 | get iconOrImageReplacement() { 8 | if (isEmpty(this.args.ctx.badge.icon)) { 9 | return ""; 10 | } 11 | 12 | if (this.args.ctx.badge.icon.indexOf("fa-") > -1) { 13 | const icon = iconHTML(convertIconClass(this.args.ctx.badge.icon)); 14 | return htmlSafe(icon); 15 | } else { 16 | return htmlSafe(""); 17 | } 18 | } 19 | 20 | 31 | } 32 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/category.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { categoryLinkHTML } from "discourse/helpers/category-link"; 3 | 4 | export default class Category extends Component { 5 | get categoryBadgeReplacement() { 6 | return categoryLinkHTML(this.args.ctx.category, { 7 | allowUncategorized: true, 8 | }); 9 | } 10 | 11 | 18 | } 19 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/group.gjs: -------------------------------------------------------------------------------- 1 | const Group = ; 10 | 11 | export default Group; 12 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/html.gjs: -------------------------------------------------------------------------------- 1 | import htmlSafe from "discourse/helpers/html-safe"; 2 | 3 | const Html = ; 4 | 5 | export default Html; 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/json.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { cached } from "@glimmer/tracking"; 3 | import { action } from "@ember/object"; 4 | import { service } from "@ember/service"; 5 | import DButton from "discourse/components/d-button"; 6 | import FullscreenCodeModal from "discourse/components/modal/fullscreen-code"; 7 | 8 | export default class Json extends Component { 9 | @service dialog; 10 | @service modal; 11 | 12 | @cached 13 | get parsedJson() { 14 | try { 15 | return JSON.parse(this.args.ctx.value); 16 | } catch { 17 | return null; 18 | } 19 | } 20 | 21 | @action 22 | viewJson() { 23 | this.modal.show(FullscreenCodeModal, { 24 | model: { 25 | code: this.parsedJson 26 | ? JSON.stringify(this.parsedJson, null, 2) 27 | : this.args.ctx.value, 28 | codeClasses: "", 29 | }, 30 | }); 31 | } 32 | 33 | 44 | } 45 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/post.gjs: -------------------------------------------------------------------------------- 1 | import avatar from "discourse/helpers/avatar"; 2 | import htmlSafe from "discourse/helpers/html-safe"; 3 | 4 | const Post = ; 40 | 41 | export default Post; 42 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/reltime.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { htmlSafe } from "@ember/template"; 3 | import { autoUpdatingRelativeAge } from "discourse/lib/formatter"; 4 | 5 | export default class Reltime extends Component { 6 | get boundDateReplacement() { 7 | return htmlSafe( 8 | autoUpdatingRelativeAge(new Date(this.args.ctx.value), { title: true }) 9 | ); 10 | } 11 | 12 | 13 | } 14 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/text.gjs: -------------------------------------------------------------------------------- 1 | const Text = ; 2 | 3 | export default Text; 4 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/topic.gjs: -------------------------------------------------------------------------------- 1 | import htmlSafe from "discourse/helpers/html-safe"; 2 | 3 | const Topic = ; 13 | 14 | export default Topic; 15 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/url.gjs: -------------------------------------------------------------------------------- 1 | const Url = ; 4 | 5 | export default Url; 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/result-types/user.gjs: -------------------------------------------------------------------------------- 1 | import avatar from "discourse/helpers/avatar"; 2 | 3 | const User = ; 16 | 17 | export default User; 18 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/share-report.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { on } from "@ember/modifier"; 4 | import { action } from "@ember/object"; 5 | import didInsert from "@ember/render-modifiers/modifiers/did-insert"; 6 | import willDestroy from "@ember/render-modifiers/modifiers/will-destroy"; 7 | import DButton from "discourse/components/d-button"; 8 | import icon from "discourse/helpers/d-icon"; 9 | import { bind } from "discourse/lib/decorators"; 10 | import getURL from "discourse/lib/get-url"; 11 | import { i18n } from "discourse-i18n"; 12 | 13 | export default class ShareReport extends Component { 14 | @tracked visible = false; 15 | element; 16 | 17 | get link() { 18 | return getURL(`/g/${this.args.group}/reports/${this.args.query.id}`); 19 | } 20 | 21 | @bind 22 | mouseDownHandler(e) { 23 | if (!this.element.contains(e.target)) { 24 | this.close(); 25 | } 26 | } 27 | 28 | @bind 29 | keyDownHandler(e) { 30 | if (e.keyCode === 27) { 31 | this.close(); 32 | } 33 | } 34 | 35 | @action 36 | registerListeners(element) { 37 | if (!element || this.isDestroying || this.isDestroyed) { 38 | return; 39 | } 40 | 41 | this.element = element; 42 | document.addEventListener("mousedown", this.mouseDownHandler); 43 | element.addEventListener("keydown", this.keyDownHandler); 44 | } 45 | 46 | @action 47 | unregisterListeners(element) { 48 | this.element = element; 49 | document.removeEventListener("mousedown", this.mouseDownHandler); 50 | element.removeEventListener("keydown", this.keyDownHandler); 51 | } 52 | 53 | @action 54 | focusInput(e) { 55 | e.select(); 56 | e.focus(); 57 | } 58 | 59 | @action 60 | open(e) { 61 | e.preventDefault(); 62 | this.visible = true; 63 | } 64 | 65 | @action 66 | close() { 67 | this.visible = false; 68 | } 69 | 70 | 101 | } 102 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/group-reports-nav-item/nav-item.gjs: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | import { LinkTo } from "@ember/routing"; 3 | import { classNames, tagName } from "@ember-decorators/component"; 4 | import icon from "discourse/helpers/d-icon"; 5 | import { i18n } from "discourse-i18n"; 6 | 7 | @tagName("li") 8 | @classNames("group-reports-nav-item-outlet", "nav-item") 9 | export default class NavItem extends Component { 10 | static shouldRender(args) { 11 | return args.group.has_visible_data_explorer_queries; 12 | } 13 | 14 | 19 | } 20 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/controllers/group-reports-index.js: -------------------------------------------------------------------------------- 1 | import Controller from "@ember/controller"; 2 | 3 | export default class GroupReportsIndexController extends Controller {} 4 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/controllers/group-reports-show.js: -------------------------------------------------------------------------------- 1 | import { tracked } from "@glimmer/tracking"; 2 | import Controller from "@ember/controller"; 3 | import { action } from "@ember/object"; 4 | import { service } from "@ember/service"; 5 | import BookmarkModal from "discourse/components/modal/bookmark"; 6 | import { ajax } from "discourse/lib/ajax"; 7 | import { popupAjaxError } from "discourse/lib/ajax-error"; 8 | import { BookmarkFormData } from "discourse/lib/bookmark-form-data"; 9 | import { bind } from "discourse/lib/decorators"; 10 | import { 11 | NO_REMINDER_ICON, 12 | WITH_REMINDER_ICON, 13 | } from "discourse/models/bookmark"; 14 | import { ParamValidationError } from "discourse/plugins/discourse-data-explorer/discourse/components/param-input-form"; 15 | 16 | export default class GroupReportsShowController extends Controller { 17 | @service currentUser; 18 | @service modal; 19 | @service router; 20 | 21 | @tracked showResults = false; 22 | @tracked loading = false; 23 | @tracked results = this.model.results; 24 | @tracked queryGroupBookmark = this.queryGroup?.bookmark; 25 | 26 | queryParams = ["params"]; 27 | form = null; 28 | explain = false; 29 | 30 | get parsedParams() { 31 | return this.params ? JSON.parse(this.params) : null; 32 | } 33 | 34 | get hasParams() { 35 | return this.model.param_info.length > 0; 36 | } 37 | 38 | get bookmarkLabel() { 39 | return this.queryGroupBookmark 40 | ? "bookmarked.edit_bookmark" 41 | : "bookmarked.title"; 42 | } 43 | 44 | get bookmarkIcon() { 45 | if (this.queryGroupBookmark && this.queryGroupBookmark.reminder_at) { 46 | return WITH_REMINDER_ICON; 47 | } 48 | return NO_REMINDER_ICON; 49 | } 50 | 51 | get bookmarkClassName() { 52 | return this.queryGroupBookmark 53 | ? ["query-group-bookmark", "bookmarked"].join(" ") 54 | : "query-group-bookmark"; 55 | } 56 | 57 | @bind 58 | async run() { 59 | try { 60 | let params = null; 61 | if (this.hasParams) { 62 | params = await this.form.submit(); 63 | if (params == null) { 64 | return; 65 | } 66 | } 67 | this.loading = true; 68 | this.showResults = false; 69 | const stringifiedParams = JSON.stringify(params); 70 | this.router.transitionTo({ 71 | queryParams: { 72 | params: params ? stringifiedParams : null, 73 | }, 74 | }); 75 | const response = await ajax( 76 | `/g/${this.get("group.name")}/reports/${this.model.id}/run`, 77 | { 78 | type: "POST", 79 | data: { 80 | params: stringifiedParams, 81 | explain: this.explain, 82 | }, 83 | } 84 | ); 85 | 86 | this.results = response; 87 | if (!response.success) { 88 | return; 89 | } 90 | this.showResults = true; 91 | } catch (error) { 92 | if (error.jqXHR?.status === 422 && error.jqXHR.responseJSON) { 93 | this.results = error.jqXHR.responseJSON; 94 | } else if (!(error instanceof ParamValidationError)) { 95 | popupAjaxError(error); 96 | } 97 | } finally { 98 | this.loading = false; 99 | } 100 | } 101 | 102 | @action 103 | toggleBookmark() { 104 | const modalBookmark = 105 | this.queryGroupBookmark || 106 | this.store.createRecord("bookmark", { 107 | bookmarkable_type: "DiscourseDataExplorer::QueryGroup", 108 | bookmarkable_id: this.queryGroup.id, 109 | user_id: this.currentUser.id, 110 | }); 111 | return this.modal.show(BookmarkModal, { 112 | model: { 113 | bookmark: new BookmarkFormData(modalBookmark), 114 | afterSave: (bookmarkFormData) => { 115 | const bookmark = this.store.createRecord( 116 | "bookmark", 117 | bookmarkFormData.saveData 118 | ); 119 | this.queryGroupBookmark = bookmark; 120 | this.appEvents.trigger( 121 | "bookmarks:changed", 122 | bookmarkFormData.saveData, 123 | bookmark.attachedTo() 124 | ); 125 | }, 126 | afterDelete: () => { 127 | this.queryGroupBookmark = null; 128 | }, 129 | }, 130 | }); 131 | } 132 | 133 | @action 134 | onRegisterApi(form) { 135 | this.form = form; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/explorer-route-map.js: -------------------------------------------------------------------------------- 1 | export default { 2 | resource: "admin.adminPlugins", 3 | path: "/plugins", 4 | map() { 5 | this.route("explorer", function () { 6 | this.route("queries", function () { 7 | this.route("details", { path: "/:query_id" }); 8 | }); 9 | }); 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/group-reports-route-map.js: -------------------------------------------------------------------------------- 1 | export default { 2 | resource: "group", 3 | 4 | map() { 5 | this.route("reports", function () { 6 | this.route("show", { path: "/:query_id" }); 7 | }); 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/initialize-data-explorer.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: "initialize-data-explorer", 3 | initialize(container) { 4 | container.lookup("service:store").addPluralization("query", "queries"); 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/lib/themeColor.js: -------------------------------------------------------------------------------- 1 | export default function themeColor(name) { 2 | const style = getComputedStyle(document.body); 3 | return style.getPropertyValue(name); 4 | } 5 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/models/query.js: -------------------------------------------------------------------------------- 1 | import { computed } from "@ember/object"; 2 | import getURL from "discourse/lib/get-url"; 3 | import RestModel from "discourse/models/rest"; 4 | 5 | export default class Query extends RestModel { 6 | static updatePropertyNames = [ 7 | "name", 8 | "description", 9 | "sql", 10 | "user_id", 11 | "created_at", 12 | "group_ids", 13 | "last_run_at", 14 | ]; 15 | 16 | params = {}; 17 | 18 | constructor() { 19 | super(...arguments); 20 | this.param_info?.resetParams(); 21 | } 22 | 23 | get downloadUrl() { 24 | return getURL(`/admin/plugins/explorer/queries/${this.id}.json?export=1`); 25 | } 26 | 27 | @computed("param_info", "updating") 28 | get hasParams() { 29 | // When saving, we need to refresh the param-input component to clean up the old key 30 | return this.param_info.length && !this.updating; 31 | } 32 | 33 | beforeUpdate() { 34 | this.set("updating", true); 35 | } 36 | 37 | afterUpdate() { 38 | this.set("updating", false); 39 | } 40 | 41 | resetParams() { 42 | const newParams = {}; 43 | const oldParams = this.params; 44 | this.param_info.forEach((pinfo) => { 45 | const name = pinfo.identifier; 46 | if (oldParams[pinfo.identifier]) { 47 | newParams[name] = oldParams[name]; 48 | } else if (pinfo["default"] !== null) { 49 | newParams[name] = pinfo["default"]; 50 | } else if (pinfo["type"] === "boolean") { 51 | newParams[name] = "false"; 52 | } else if (pinfo["type"] === "user_id") { 53 | newParams[name] = null; 54 | } else if (pinfo["type"] === "user_list") { 55 | newParams[name] = null; 56 | } else if (pinfo["type"] === "group_list") { 57 | newParams[name] = null; 58 | } else { 59 | newParams[name] = ""; 60 | } 61 | }); 62 | this.params = newParams; 63 | } 64 | 65 | updateProperties() { 66 | const props = this.getProperties(Query.updatePropertyNames); 67 | if (this.destroyed) { 68 | props.id = this.id; 69 | } 70 | return props; 71 | } 72 | 73 | createProperties() { 74 | if (this.sql) { 75 | // Importing 76 | return this.updateProperties(); 77 | } 78 | return this.getProperties("name"); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/admin-plugins-explorer-index.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | 5 | export default class AdminPluginsExplorerIndex extends DiscourseRoute { 6 | @service router; 7 | 8 | beforeModel(transition) { 9 | // Redirect old /explorer?id=123 route to /explorer/queries/123 10 | if (transition.to.queryParams.id) { 11 | this.router.transitionTo( 12 | "adminPlugins.explorer.queries.details", 13 | transition.to.queryParams.id 14 | ); 15 | } 16 | } 17 | 18 | model() { 19 | if (!this.currentUser.admin) { 20 | // display "Only available to admins" message 21 | return { model: null, schema: null, disallow: true, groups: null }; 22 | } 23 | 24 | const groupPromise = ajax("/admin/plugins/explorer/groups.json"); 25 | const queryPromise = this.store.findAll("query"); 26 | 27 | return groupPromise.then((groups) => { 28 | let groupNames = {}; 29 | groups.forEach((g) => { 30 | groupNames[g.id] = g.name; 31 | }); 32 | return queryPromise.then((model) => { 33 | model.forEach((query) => { 34 | query.set( 35 | "group_names", 36 | (query.group_ids || []).map((id) => groupNames[id]) 37 | ); 38 | }); 39 | return { model, groups }; 40 | }); 41 | }); 42 | } 43 | 44 | setupController(controller, model) { 45 | controller.setProperties(model); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/admin-plugins-explorer-queries-details.js: -------------------------------------------------------------------------------- 1 | import { ajax } from "discourse/lib/ajax"; 2 | import DiscourseRoute from "discourse/routes/discourse"; 3 | 4 | export default class AdminPluginsExplorerQueriesDetails extends DiscourseRoute { 5 | model(params) { 6 | if (!this.currentUser.admin) { 7 | // display "Only available to admins" message 8 | return { model: null, schema: null, disallow: true, groups: null }; 9 | } 10 | 11 | const groupPromise = ajax("/admin/plugins/explorer/groups.json"); 12 | const schemaPromise = ajax("/admin/plugins/explorer/schema.json", { 13 | cache: true, 14 | }); 15 | const queryPromise = this.store.find("query", params.query_id); 16 | 17 | return groupPromise.then((groups) => { 18 | let groupNames = {}; 19 | groups.forEach((g) => { 20 | groupNames[g.id] = g.name; 21 | }); 22 | return schemaPromise.then((schema) => { 23 | return queryPromise.then((model) => { 24 | model.set( 25 | "group_names", 26 | (model.group_ids || []).map((id) => groupNames[id]) 27 | ); 28 | return { model, schema, groups }; 29 | }); 30 | }); 31 | }); 32 | } 33 | 34 | setupController(controller, model) { 35 | controller.setProperties(model); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/group-reports-index.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | 5 | export default class GroupReportsIndexRoute extends DiscourseRoute { 6 | @service router; 7 | 8 | model() { 9 | const group = this.modelFor("group"); 10 | return ajax(`/g/${group.name}/reports`) 11 | .then((queries) => { 12 | return { 13 | model: queries, 14 | group, 15 | }; 16 | }) 17 | .catch(() => this.router.transitionTo("group.members", group)); 18 | } 19 | 20 | afterModel(model) { 21 | if ( 22 | !model.group.get("is_group_user") && 23 | !(this.currentUser && this.currentUser.admin) 24 | ) { 25 | this.router.transitionTo("group.members", model.group); 26 | } 27 | } 28 | 29 | setupController(controller, model) { 30 | controller.setProperties(model); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/routes/group-reports-show.js: -------------------------------------------------------------------------------- 1 | import { service } from "@ember/service"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | import DiscourseRoute from "discourse/routes/discourse"; 4 | 5 | export default class GroupReportsShowRoute extends DiscourseRoute { 6 | @service router; 7 | 8 | model(params) { 9 | const group = this.modelFor("group"); 10 | return ajax(`/g/${group.name}/reports/${params.query_id}`) 11 | .then((response) => { 12 | const query = response.query; 13 | const queryGroup = response.query_group; 14 | 15 | const queryParamInfo = query.param_info; 16 | const queryParams = queryParamInfo.reduce((acc, param) => { 17 | acc[param.identifier] = param.default; 18 | return acc; 19 | }, {}); 20 | 21 | return { 22 | model: Object.assign({ params: queryParams }, query), 23 | group, 24 | queryGroup, 25 | results: null, 26 | showResults: false, 27 | }; 28 | }) 29 | .catch(() => { 30 | this.router.transitionTo("group.members", group); 31 | }); 32 | } 33 | 34 | setupController(controller, model) { 35 | controller.setProperties(model); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/group-reports-index.gjs: -------------------------------------------------------------------------------- 1 | import { array } from "@ember/helper"; 2 | import { LinkTo } from "@ember/routing"; 3 | import RouteTemplate from "ember-route-template"; 4 | import boundDate from "discourse/helpers/bound-date"; 5 | import { i18n } from "discourse-i18n"; 6 | 7 | export default RouteTemplate( 8 | 45 | ); 46 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/group-reports-show.gjs: -------------------------------------------------------------------------------- 1 | import { on } from "@ember/modifier"; 2 | import RouteTemplate from "ember-route-template"; 3 | import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; 4 | import DButton from "discourse/components/d-button"; 5 | import ParamInputForm from "../components/param-input-form"; 6 | import QueryResult from "../components/query-result"; 7 | 8 | export default RouteTemplate( 9 | 58 | ); 59 | -------------------------------------------------------------------------------- /config/locales/client.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | js: 9 | explorer: 10 | or: "або" 11 | types: 12 | bool: 13 | yes: "Так" 14 | no: "Не" 15 | export: "экспарт" 16 | save: "Захаваць" 17 | edit: "Рэдагаваць" 18 | delete: "Выдаліць" 19 | query_groups: "групы" 20 | query_description: "апісанне" 21 | reset_params: "скінуць" 22 | group: 23 | reports: "Справаздачы" 24 | -------------------------------------------------------------------------------- /config/locales/client.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | js: 9 | explorer: 10 | or: "или" 11 | import: 12 | label: "Импорт" 13 | help: 14 | label: "Помощ" 15 | schema: 16 | filter: "Търсене ... " 17 | types: 18 | bool: 19 | yes: "Да" 20 | no: "Не" 21 | export: "Експорт " 22 | save: "Запази промените" 23 | edit: "Редактирай" 24 | delete: "Изтрий" 25 | result_count: 26 | one: "%{count} резултат." 27 | other: "%{count} резултата." 28 | query_groups: "Групи" 29 | query_description: "Описание" 30 | reset_params: "Нулиране" 31 | search_placeholder: "Търсене ... " 32 | -------------------------------------------------------------------------------- /config/locales/client.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | js: 9 | explorer: 10 | or: "ili" 11 | help: 12 | label: "Pomoć" 13 | schema: 14 | filter: "Pretraga..." 15 | types: 16 | bool: 17 | yes: "Da" 18 | no: "Ne" 19 | export: "Izvoz" 20 | save: "Spremiti promjene" 21 | edit: "Izmijeni" 22 | delete: "Delete" 23 | query_groups: "Grupa" 24 | query_description: "Opis" 25 | reset_params: "Resetovati" 26 | search_placeholder: "Pretraga..." 27 | group: 28 | reports: "Izvještaji" 29 | -------------------------------------------------------------------------------- /config/locales/client.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Elimina els punts i coma de la consulta." 12 | dirty: "Cal desar la consulta abans d'executar-la" 13 | explorer: 14 | or: "o" 15 | admins_only: "L'explorador de dades sols és disponible per a administradors." 16 | title: "Explorador de dades" 17 | create: "Crea'n un de nou" 18 | create_placeholder: "Nom de consulta..." 19 | description_placeholder: "Introduïu una descripció aquí" 20 | import: 21 | label: "Importa" 22 | modal: "Importa una consulta" 23 | help: 24 | label: "Ajuda" 25 | schema: 26 | title: "Esquema de base de dades" 27 | filter: "Cerca..." 28 | sensitive: "El contingut d'aquesta columna pot contenir informació particularment sensible o privada. Tingueu precaució quan utilitzeu el contingut d'aquesta columna." 29 | types: 30 | bool: 31 | yes: "Sí" 32 | no: "No" 33 | null_: "Nul" 34 | export: "Exporta" 35 | save: "Desa els canvis" 36 | saverun: "Desa els canvis i executa" 37 | run: "Executa" 38 | undo: "Descarta els canvis" 39 | edit: "Edita" 40 | delete: "Suprimeix" 41 | recover: "Restaura la consulta" 42 | download_json: "JSON" 43 | download_csv: "CSV" 44 | others_dirty: "Una consulta té canvis no desats que es perdran si continueu navegant." 45 | run_time: "La consulta s'ha completat en %{value} ms." 46 | result_count: 47 | one: "%{count} resultat." 48 | other: "%{count} resultats." 49 | query_name: "Consulta" 50 | query_groups: "Grups" 51 | link: "Enllaç de" 52 | report_name: "Report" 53 | query_description: "Descripció" 54 | query_time: "Darrera execució" 55 | query_user: "Creat per" 56 | column: "Columna %{number}" 57 | explain_label: "Hi incloem pla de consulta?" 58 | save_params: "Estableix valors per defecte" 59 | reset_params: "Reinicia" 60 | search_placeholder: "Cerca..." 61 | no_search_results: "Ho sentim, no hem trobat cap resultat que coincideixi amb el vostre text." 62 | group: 63 | reports: "Reports" 64 | -------------------------------------------------------------------------------- /config/locales/client.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | js: 9 | explorer: 10 | or: "nebo" 11 | create: "Vytvořit nový" 12 | import: 13 | label: "Import" 14 | help: 15 | label: "Nápověda" 16 | schema: 17 | filter: "Hledat..." 18 | types: 19 | bool: 20 | yes: "Ano" 21 | no: "Ne" 22 | null_: "Null" 23 | export: "Export" 24 | save: "Uložit změny" 25 | edit: "Upravit" 26 | delete: "Smazat" 27 | download_json: "JSON" 28 | download_csv: "CSV" 29 | result_count: 30 | one: "%{count} výsledek." 31 | few: "%{count} výsledky." 32 | many: "%{count} výsledky." 33 | other: "%{count} výsledků." 34 | query_groups: "Skupiny" 35 | query_description: "Popis" 36 | reset_params: "obnovit výchozí" 37 | search_placeholder: "Hledat..." 38 | group: 39 | reports: "Přehledy" 40 | -------------------------------------------------------------------------------- /config/locales/client.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Fjern semikoloner fra forespørgslen." 12 | dirty: "Du skal gemme forespørgslen, før du kører den." 13 | explorer: 14 | or: "eller" 15 | admins_only: "Data explorer er kun tilgængelige for administratorer." 16 | allow_groups: "Tillad grupper at få adgang til denne forespørgsel" 17 | title: "Data Udforsker" 18 | create: "Opret Ny" 19 | create_placeholder: "Navn på forespørgsel..." 20 | description_placeholder: "Indtast en beskrivelse her" 21 | import: 22 | label: "Importer" 23 | modal: "Importer en forespørgsel" 24 | help: 25 | label: "Hjælp" 26 | modal_title: "Hjælp til Data Udforsker" 27 | schema: 28 | title: "Database Skema" 29 | filter: "Søg..." 30 | types: 31 | bool: 32 | yes: "Ja" 33 | no: "Nej" 34 | null_: "Null" 35 | export: "Eksporter" 36 | save: "Gem ændringer" 37 | saverun: "Gem ændringer og kør" 38 | run: "Kør" 39 | undo: "Kassér Ændringer" 40 | edit: "Rediger" 41 | delete: "Slet" 42 | recover: "Fortryd sletning af forespørgsel" 43 | download_json: "JSON" 44 | download_csv: "CSV" 45 | show_table: "Tabel" 46 | show_graph: "Graf" 47 | query_name: "Forespørgsel" 48 | query_groups: "grupper" 49 | query_description: "Beskrivelse" 50 | query_user: "Oprettet af" 51 | reset_params: "Nulstil" 52 | search_placeholder: "Søg..." 53 | group: 54 | reports: "Rapporter" 55 | -------------------------------------------------------------------------------- /config/locales/client.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | js: 9 | explorer: 10 | or: "ή" 11 | import: 12 | label: "Εισαγωγή" 13 | help: 14 | label: "Βοήθεια" 15 | schema: 16 | filter: "Αναζήτηση..." 17 | types: 18 | bool: 19 | yes: "Ναι " 20 | no: "Όχι" 21 | export: "Εξαγωγή" 22 | save: "Αποθήκευση Αλλαγών" 23 | edit: "Επεξεργασία" 24 | delete: "Σβήσιμο" 25 | query_groups: "Ομάδες" 26 | query_description: "Περιγραφή" 27 | query_user: "Δημιουργήθηκε από" 28 | reset_params: "Επαναφορά" 29 | search_placeholder: "Αναζήτηση..." 30 | group: 31 | reports: "Αναφορές" 32 | -------------------------------------------------------------------------------- /config/locales/client.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | js: 9 | explorer: 10 | query_description: "Description" 11 | -------------------------------------------------------------------------------- /config/locales/client.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | js: 9 | explorer: 10 | or: "või" 11 | title: "Andmete eksportija" 12 | create: "Loo uus" 13 | create_placeholder: "Päringu nimi..." 14 | import: 15 | label: "Impordi" 16 | modal: "Impordi päring" 17 | help: 18 | label: "Abi" 19 | schema: 20 | title: "Andmebaasi skeem" 21 | filter: "Otsi..." 22 | types: 23 | bool: 24 | yes: "Jah" 25 | no: "Ei" 26 | null_: "Nulli" 27 | export: "Ekspordi" 28 | save: "Salvesta muudatused" 29 | saverun: "Salvesta muudatused ja käivita" 30 | run: "Käivita" 31 | undo: "Loobu muudatustest" 32 | edit: "Muuda" 33 | delete: "Kustuta" 34 | download_json: "JSON" 35 | download_csv: "CSV" 36 | query_groups: "Grupid" 37 | query_description: "Kirjeldus" 38 | save_params: "Määra vaikeväärtused" 39 | reset_params: "Nulli" 40 | search_placeholder: "Otsi..." 41 | -------------------------------------------------------------------------------- /config/locales/client.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | js: 9 | explorer: 10 | or: "یا" 11 | import: 12 | label: "ورود داده‌ها" 13 | help: 14 | label: "کمک" 15 | schema: 16 | filter: "جستجو..." 17 | types: 18 | bool: 19 | yes: "بله" 20 | no: "خیر" 21 | export: "خروجی گرفتن" 22 | save: "ذخیره تغییرات" 23 | run: "اجرا" 24 | edit: "ویرایش" 25 | delete: "حذف" 26 | result_count: 27 | one: "%{count} نتیجه." 28 | other: "%{count} نتیجه." 29 | max_result_count: 30 | one: "نمایش %{count} نتیجه برتر." 31 | other: "نمایش %{count} نتیجه برتر." 32 | query_groups: "گروه ها" 33 | link: "پیوند برای" 34 | report_name: "گزارش" 35 | query_description: "توضیح" 36 | query_time: "آخرین اجرا" 37 | query_user: "ایجاد شده توسط" 38 | reset_params: "بازنشانی" 39 | search_placeholder: "جستجو..." 40 | group: 41 | reports: "گزارشات" 42 | -------------------------------------------------------------------------------- /config/locales/client.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | js: 9 | explorer: 10 | or: "ou" 11 | import: 12 | label: "Importar" 13 | help: 14 | label: "Axuda" 15 | schema: 16 | filter: "Buscar..." 17 | types: 18 | bool: 19 | yes: "Si" 20 | no: "Non" 21 | export: "Exportar" 22 | save: "Gardar os cambios" 23 | edit: "Editar" 24 | delete: "Eliminar" 25 | query_groups: "Grupos" 26 | query_description: "Descrición" 27 | query_user: "Creado por" 28 | reset_params: "Restabelecer" 29 | search_placeholder: "Buscar..." 30 | group: 31 | reports: "Informes" 32 | -------------------------------------------------------------------------------- /config/locales/client.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | js: 9 | explorer: 10 | or: "ili" 11 | help: 12 | label: "Pomoć" 13 | schema: 14 | filter: "Pretraži..." 15 | types: 16 | bool: 17 | yes: "Da" 18 | no: "Ne" 19 | export: "Izvoz" 20 | save: "Zabilježi promjene" 21 | edit: "Izmijeni" 22 | delete: "Pobriši" 23 | result_count: 24 | one: "%{count} rezultat." 25 | few: "%{count} rezultata." 26 | other: "%{count} rezultata." 27 | query_groups: "Grupe" 28 | query_description: "Opis" 29 | query_user: "Napravio" 30 | reset_params: "Resetirati" 31 | search_placeholder: "Pretraži..." 32 | group: 33 | reports: "Izvještaji" 34 | -------------------------------------------------------------------------------- /config/locales/client.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Pontosvesszők eltávolítása a lekérdezésből." 12 | dirty: "Futtatás előtt el kell mentenie a lekérdezést." 13 | explorer: 14 | or: "vagy" 15 | admins_only: "Az adatfelfedező csak az adminok számára érhető el." 16 | allow_groups: "Engedélyezés csoportok számára, hogy hozzáférjenek ehhez a lekérdezéshez" 17 | title: "Adatfelfedező" 18 | create: "Új létrehozása" 19 | create_placeholder: "Lekérdezés neve…" 20 | description_placeholder: "Adjon meg egy leírást" 21 | import: 22 | label: "Importálás" 23 | modal: "Lekérdezés importálása" 24 | unparseable_json: "Nem értelmezhető JSON-fájl." 25 | wrong_json: "Hibás JSON-fájl. A JSON-fájlnak kellene tartalmaznia egy „query” objektumot, amelynek legalább egy „sql” tulajdonsággal kell rendelkeznie." 26 | help: 27 | label: "Súgó" 28 | modal_title: "Adatfelfedező súgó" 29 | auto_resolution: "

Automatikus entitásfeloldás

Ha egy lekérdezés egy entitásazonosítót ad vissza, akkor az Adatfelfedező automatikusan lecserélheti az entitás nevére és más hasznos információkra a találatokban. Az automatikus feloldás ezeknél érhető el: user_id, group_id, topic_id, category_id és badge_id. Próbálja ki ezen lekérdezés futtatásával:

SELECT user_id\nFROM posts
" 30 | custom_params: "

Egyéni paraméterek létrehozása

Hogy egyéni paramétereket hozzon létre a lekérdezéseihez, tegye ezt a lekérdezés fölé, és kövesse a formátumot:

-- [params]\n-- int :num = 1\n\nSELECT :num

Megjegyzés: a [params] sor kötelező, az azt megelőző két kötőjellel együtt, és deklarálnia kell minden egyes egyéni paramétert.

" 31 | default_values: "

Alapértelmezett értékek

Deklarálhatja a paramétereket alapértelmezett értékkel vagy azok nélkül. Az alapértelmezett értékek meg fognak jelenni a szövegmezőben, a lekérdezésszerkesztő alatt, amellyel a céljainak megfelelően szerkesztheti azokat. Az alapértelmezett érték nélküli paraméterek is szövegmezőt fognak kapni, de üresek lesznek, és pirossal lesznek kiemelve..

-- [params]\n-- text :username = saját_felhasználónév\n-- int :age
" 32 | data_types: "

Adattípusok

Itt szerepel néhány gyakori használható adattípus:

További információkért az adattípusokról, keresse fel ezt a weboldalt.

" 33 | schema: 34 | title: "Adatbázis-séma" 35 | filter: "Keresés…" 36 | sensitive: "Az oszlop tartalma különösen érzékeny vagy privát információkat tartalmazhat. Legyen óvatos az oszlop tartalmának használatakor." 37 | types: 38 | bool: 39 | yes: "Igen" 40 | no: "Nem" 41 | null_: "Null" 42 | export: "Exportálás" 43 | save: "Módosítások mentése" 44 | saverun: "Módosítások mentése és futtatása" 45 | run: "Futtatás" 46 | undo: "Módosítások elvetése" 47 | edit: "Szerkesztés" 48 | delete: "Törlés" 49 | recover: "Lekérdezés törlésének visszavonása" 50 | download_json: "JSON" 51 | download_csv: "CSV" 52 | show_table: "Tábla" 53 | show_graph: "Grafikon" 54 | others_dirty: "A lekérdezés nem mentett módosításokat tartalmaz, amelyek elvesznek, ha elnavigál." 55 | run_time: "A lekérdezés %{value} ms alatt fejeződött be." 56 | result_count: 57 | one: "%{count} találat." 58 | other: "%{count} találat." 59 | query_name: "Lekérdezés" 60 | query_groups: "Csoportok" 61 | link: "Hivatkozás ehhez:" 62 | report_name: "Jelentés" 63 | query_description: "Leírás" 64 | query_time: "Utolsó futás" 65 | query_user: "Létrehozta:" 66 | column: "%{number}. oszlop" 67 | explain_label: "Beleveszi a lekérdezési tervet?" 68 | save_params: "Alapértékek beállítása" 69 | reset_params: "Alaphelyzet" 70 | search_placeholder: "Keresés…" 71 | no_search_results: "Sajnos nem találtunk a szövegének megfelelő találatot." 72 | group: 73 | reports: "Jelentések" 74 | -------------------------------------------------------------------------------- /config/locales/client.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Հեռացրեք կետ-ստորակատերերը հարցումից" 12 | dirty: "Դուք պետք է պահպանեք հարցումը գործարկելուց առաջ" 13 | explorer: 14 | or: "կամ" 15 | admins_only: "Տվյալների ուսումնասիրումը հասանելի է միասյն ադմիններին:" 16 | title: "Տվյալների Ուսումնասիրում" 17 | create: "Ստեղծել Նոր" 18 | create_placeholder: "Հարցման անուն..." 19 | description_placeholder: "Մուտքագրեք նկարագրություն այստեղ" 20 | import: 21 | label: "Ներմուծել" 22 | modal: "Ներմուծել Հարցում" 23 | help: 24 | label: "Օգնություն" 25 | schema: 26 | title: "Տվյալների Բազայի Սխեմա" 27 | filter: "Որոնում..." 28 | sensitive: "Այս սյունակը կարող է պարունակել մասնակի անձնական կամ անձնական տեղեկություններ: Խնդրում ենք ուշադիր լինել այս սյունակի բովանդակությունն օգտագործելիս:" 29 | types: 30 | bool: 31 | yes: "Այո" 32 | no: "Ոչ" 33 | null_: "Զրոյական" 34 | export: "Արտահանել" 35 | save: "Պահպանել Փոփոխությունները" 36 | saverun: "Պահպանել փոփոխությունները և Գործարկել" 37 | run: "Գործարկել" 38 | undo: "Չեղարկել Փոփոխությունները" 39 | edit: "Խմբագրել" 40 | delete: "Ջնջել" 41 | recover: "Վերականգնել Հարցումը" 42 | download_json: "JSON" 43 | download_csv: "CSV" 44 | others_dirty: "Հարցումն ունի չպահպանված փոփոխություններ, որոնք կկորչեն, եթե Դուք տեղափոխվեք:" 45 | result_count: 46 | one: "%{count} արդյունք" 47 | other: "%{count} արդյունք" 48 | query_name: "Հարցում" 49 | query_groups: "Խմբեր" 50 | query_description: "Նկարագրություն" 51 | query_time: "Վերջին գործարկումը" 52 | query_user: "Ստեղծել է՝" 53 | explain_label: "Ներառե՞լ հարցման պլանը:" 54 | save_params: "Սահմանել Լռելյայններ" 55 | reset_params: "Վերահաստատե;" 56 | search_placeholder: "Որոնում..." 57 | no_search_results: "Ներողություն, մենք չկարողացանք գտնել Ձեր տեքստի հետ համընկնող որևէ արդյունք:" 58 | group: 59 | reports: "Հաշվետվություններ" 60 | -------------------------------------------------------------------------------- /config/locales/client.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | js: 9 | explorer: 10 | or: "atau" 11 | help: 12 | label: "Bantuan" 13 | schema: 14 | filter: "Cari..." 15 | types: 16 | bool: 17 | yes: "Ya" 18 | no: "Tidak" 19 | export: "Ekspor" 20 | save: "Simpan perubahan" 21 | edit: "Ubah" 22 | delete: "Hapus" 23 | query_groups: "Grup" 24 | query_description: "Deskripsi" 25 | reset_params: "Reset" 26 | search_placeholder: "Cari..." 27 | -------------------------------------------------------------------------------- /config/locales/client.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "クエリからセミコロンを削除します。" 12 | dirty: "クエリは保存してから実行する必要があります。" 13 | explorer: 14 | or: "または" 15 | admins_only: "データエクスプローラーは管理者のみが利用できます。" 16 | allow_groups: "このクエリへのアクセスをグループに許可する" 17 | title: "データエクスプローラー" 18 | create: "新規作成" 19 | create_placeholder: "クエリ名..." 20 | description_placeholder: "説明をここに入力" 21 | import: 22 | label: "インポート" 23 | modal: "クエリをインポート" 24 | unparseable_json: "解析不能な JSON ファイルです。" 25 | wrong_json: "不正な JSON ファイル。JSON ファイルには 'query' オブジェクトと、その中に少なくとも 'sql' プロパティが含まれている必要があります。" 26 | help: 27 | label: "ヘルプ" 28 | modal_title: "データエクスプローラーのヘルプ" 29 | auto_resolution: "

エンティティの自動解決

クエリがエンティティ ID を返す場合、データエクスプローラーはクエリ結果において、それを自動的にエンティティ名とその他の有用な情報に置き換えることができます。自動解決は、user_idgroup_idtopic_idcategory_id、および badge_id で利用できます。次のクエリを実行して試してください。

\n
SELECT user_id\nFROM posts
" 30 | custom_params: "

カスタムパラメーターの作成

クエリのカスタムパラメーターを作成するには、次のコードをクエリの先頭に配置し、フォーマットに従います。

-- [params]\n-- int :num = 1\n\nSELECT :num

注意: 最初の [params] の行は必須です。また、その前と宣言するカスタムパラメーターの前に 2 つのダッシュが必要です。

" 31 | default_values: "

デフォルト値

パラメーターはデフォルト値の有無に関係なく宣言できます。デフォルト値はクエリエディターの下のテキストフィールドに表示され、必要に応じて編集することができます。パラメーターはデフォルト値なしで宣言された場合でもテキストフィールドを生成しますが、空となり、赤でハイライトされます。

\n
-- [params]\n-- text :username = my_username\n-- int :age
" 32 | data_types: "

データ型

以下は、使用できる共通のデータ型です。

データ型の詳細については、こちらのウェブサイトをご覧ください。

" 33 | schema: 34 | title: "データベーススキーマ" 35 | filter: "検索..." 36 | sensitive: "この列のコンテンツには、機密性の特に高い情報または個人情報が含まれることがあります。この列のコンテンツを使用する際には十分注意してください。" 37 | types: 38 | bool: 39 | yes: "はい" 40 | no: "いいえ" 41 | null_: "Null" 42 | export: "エクスポート" 43 | view_json: "JSON を表示" 44 | save: "変更を保存" 45 | saverun: "変更を保存して実行" 46 | run: "実行" 47 | undo: "変更を破棄" 48 | edit: "編集" 49 | delete: "削除" 50 | recover: "クエリの削除を取り消す" 51 | download_json: "JSON" 52 | download_csv: "CSV" 53 | show_table: "テーブル" 54 | show_graph: "グラフ" 55 | others_dirty: "クエリには保存されていない変更があり、移動すると失われます。" 56 | run_time: "クエリは %{value} ミリ秒で完了しました。" 57 | result_count: 58 | other: "%{count} 件の結果。" 59 | max_result_count: 60 | other: "上位 %{count} 件を表示中。" 61 | query_name: "クエリ" 62 | query_groups: "グループ" 63 | link: "リンク" 64 | report_name: "レポート" 65 | query_description: "説明" 66 | query_time: "最終実行" 67 | query_user: "作成者" 68 | column: "列 %{number}" 69 | explain_label: "クエリプランを含めますか?" 70 | save_params: "デフォルトを設定" 71 | reset_params: "リセット" 72 | search_placeholder: "検索..." 73 | no_search_results: "残念ながら、テキストに一致する結果は見つかりませんでした。" 74 | form: 75 | errors: 76 | invalid: "無効です" 77 | no_such_category: "そのようなカテゴリはありません" 78 | no_such_group: "そのようなグループはありません" 79 | invalid_date: "%{date} は無効な日付です" 80 | invalid_time: "%{time} は無効な時刻です" 81 | group: 82 | reports: "レポート" 83 | admin: 84 | api: 85 | scopes: 86 | descriptions: 87 | discourse_data_explorer: 88 | run_queries: "データエクスプローラーのクエリを実行します。クエリ ID を指定して、API キーを一連のクエリに制限します。" 89 | discourse_automation: 90 | scriptables: 91 | recurring_data_explorer_result_pm: 92 | fields: 93 | recipients: 94 | label: ユーザー、グループ、またはメールに送る 95 | query_id: 96 | label: データエクスプローラーのクエリ 97 | query_params: 98 | label: データエクスプローラーのクエリパラメーター 99 | skip_empty: 100 | label: 結果がない場合は PM の送信をスキップする 101 | attach_csv: 102 | label: CSV ファイルを PM に添付する 103 | recurring_data_explorer_result_topic: 104 | fields: 105 | topic_id: 106 | label: クエリ結果を投稿するトピック 107 | query_id: 108 | label: データエクスプローラーのクエリ 109 | query_params: 110 | label: データエクスプローラーのクエリパラメーター 111 | skip_empty: 112 | label: 結果が無い場合は投稿をスキップする 113 | attach_csv: 114 | label: CSV ファイルを投稿に添付する 115 | -------------------------------------------------------------------------------- /config/locales/client.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "쿼리에서 세미콜론을 제거하십시오." 12 | dirty: "실행하기 전에 쿼리를 저장해야합니다." 13 | explorer: 14 | or: "또는" 15 | admins_only: "데이터 탐색기는 관리자 만 사용할 수 있습니다." 16 | allow_groups: "그룹이이 쿼리에 액세스하도록 허용" 17 | title: "데이터 탐색기" 18 | create: "새로 만들기" 19 | create_placeholder: "검색어 이름 ..." 20 | description_placeholder: "여기에 설명을 입력하세요" 21 | import: 22 | label: "가져오기" 23 | modal: "검색어 가져 오기" 24 | help: 25 | label: "도움말" 26 | schema: 27 | title: "데이터베이스 스키마" 28 | filter: "검색..." 29 | sensitive: "이 열의 내용에는 특히 민감한 정보 또는 개인 정보가 포함될 수 있습니다. 이 열의 내용을 사용할 때는주의하십시오." 30 | types: 31 | bool: 32 | yes: "네" 33 | no: "아니오" 34 | null_: "없는" 35 | export: "내보내기" 36 | save: "변경사항 저장" 37 | saverun: "변경 사항 저장 및 실행" 38 | run: "운영" 39 | undo: "변경 사항을 취소" 40 | edit: "수정" 41 | delete: "삭제" 42 | recover: "삭제 취소 쿼리" 43 | download_json: "JSON" 44 | download_csv: "CSV" 45 | others_dirty: "검색어에 저장하지 않은 변경 사항이있어 이동하면 사라집니다." 46 | result_count: 47 | other: "%{count}개의 결과." 48 | query_name: "쿼리" 49 | query_groups: "그룹" 50 | link: "링크" 51 | report_name: "보고서" 52 | query_description: "설명" 53 | query_time: "마지막 실행" 54 | query_user: "작성자" 55 | explain_label: "쿼리 계획을 포함 하시겠습니까?" 56 | save_params: "기본값 설정" 57 | reset_params: "리셋" 58 | search_placeholder: "검색..." 59 | no_search_results: "죄송합니다. 귀하의 텍스트와 일치하는 결과를 찾을 수 없습니다." 60 | group: 61 | reports: "보고서" 62 | -------------------------------------------------------------------------------- /config/locales/client.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | js: 9 | explorer: 10 | or: "arba" 11 | import: 12 | label: "Importuoti" 13 | help: 14 | label: "Pagalba" 15 | schema: 16 | filter: "Paieška..." 17 | types: 18 | bool: 19 | yes: "Taip" 20 | no: "Ne" 21 | export: "Eksportuoti" 22 | save: "Išsaugoti pakeitimus" 23 | edit: "Redaguoti" 24 | delete: "Pašalinti" 25 | result_count: 26 | one: "%{count} rezultatas." 27 | few: "%{count} rezultatai." 28 | many: "%{count} rezultatai." 29 | other: "%{count} rezultatai." 30 | query_groups: "Grupės" 31 | query_description: "Aprašymas" 32 | query_user: "Sukurta" 33 | reset_params: "Atstatyti" 34 | search_placeholder: "Paieška..." 35 | group: 36 | reports: "Pranešimai" 37 | -------------------------------------------------------------------------------- /config/locales/client.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | js: 9 | explorer: 10 | or: "vai" 11 | import: 12 | label: "Importēt" 13 | help: 14 | label: "Palīdzība" 15 | schema: 16 | filter: "Meklēt..." 17 | types: 18 | bool: 19 | yes: "Jā" 20 | no: "Nē" 21 | export: "Eksportēt" 22 | save: "Saglabāt izmaiņas" 23 | edit: "Labot" 24 | delete: "Dzēst" 25 | query_groups: "Grupas" 26 | query_description: "Apraksts" 27 | reset_params: "Atlikt" 28 | search_placeholder: "Meklēt..." 29 | -------------------------------------------------------------------------------- /config/locales/client.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Fjern semikolonene fra spørringen." 12 | dirty: "Du må lagre spørringen før du kjører den." 13 | explorer: 14 | or: "eller" 15 | admins_only: "Datautforskeren er kun tilgjengelig for administratorer." 16 | title: "Datautforsker" 17 | create: "Opprett ny" 18 | create_placeholder: "Spørringsnavn…" 19 | import: 20 | label: "Importer" 21 | modal: "Importer en spørring" 22 | help: 23 | label: "Hjelp" 24 | schema: 25 | filter: "Søk…" 26 | types: 27 | bool: 28 | yes: "Ja" 29 | no: "Nei" 30 | null_: "Null" 31 | export: "Eksporter" 32 | save: "Lagre endringer" 33 | undo: "Forkast endringer" 34 | edit: "Rediger" 35 | delete: "Slett" 36 | recover: "Angre sletting av spørring" 37 | download_json: "JSON" 38 | download_csv: "CSV" 39 | others_dirty: "En spørring har ulagrede endringer, og vil gå tapt hvis du navigerer vekk." 40 | query_groups: "Grupper" 41 | query_description: "Beskrivelse" 42 | explain_label: "Inkluder spørringsplan?" 43 | save_params: "Sett forvalg" 44 | reset_params: "Tilbakestill" 45 | search_placeholder: "Søk…" 46 | group: 47 | reports: "Rapporter" 48 | -------------------------------------------------------------------------------- /config/locales/client.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Remova os pontos e vírgulas da consulta." 12 | dirty: "Deve guardar a consulta antes de executar." 13 | explorer: 14 | or: "ou" 15 | admins_only: "O explorador de dados está disponível apenas para os administradores." 16 | title: "Explorador de Dados" 17 | create: "Criar Nova" 18 | create_placeholder: "Nome da consulta..." 19 | import: 20 | label: "Importar" 21 | modal: "Importar Uma Consulta" 22 | help: 23 | label: "Ajuda" 24 | schema: 25 | title: "Esquema da Base de Dados" 26 | filter: "Pesquisar..." 27 | sensitive: "O conteúdo desta coluna pode conter informação particularmente sensível ou privada. Por favor, tenha cuidado ao utilizar os conteúdos desta coluna." 28 | types: 29 | bool: 30 | yes: "Sim" 31 | no: "Não" 32 | export: "Exportar" 33 | save: "Guardar alterações" 34 | saverun: "Guardar Alterações e Executar" 35 | run: "Executar" 36 | undo: "Ignorar Alterações" 37 | edit: "Editar" 38 | delete: "Eliminar" 39 | recover: "Recuperar Consulta" 40 | download_json: "JSON" 41 | download_csv: "CSV" 42 | others_dirty: "Uma consulta tem alterações não guardadas que serão perdidas se navegar para outro lado." 43 | run_time: "Consulta concluída em %{value} ms." 44 | result_count: 45 | one: "%{count} resultado." 46 | other: "%{count} resultados." 47 | query_groups: "Grupos" 48 | query_description: "Descrição" 49 | column: "Coluna %{number}" 50 | explain_label: "Incluir plano de consulta?" 51 | save_params: "Definir Predefinições" 52 | reset_params: "Repor" 53 | search_placeholder: "Pesquisar..." 54 | group: 55 | reports: "Relatórios" 56 | -------------------------------------------------------------------------------- /config/locales/client.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | js: 9 | explorer: 10 | or: "sau" 11 | import: 12 | label: "Importă" 13 | help: 14 | label: "Ajutor" 15 | schema: 16 | filter: "Caută..." 17 | types: 18 | bool: 19 | yes: "Da" 20 | no: "Nu" 21 | export: "Exportă" 22 | save: "Salvează schimbările" 23 | edit: "Editează" 24 | delete: "Șterge" 25 | query_groups: "Grupuri" 26 | query_description: "Descriere" 27 | reset_params: "Resetează" 28 | search_placeholder: "Caută..." 29 | group: 30 | reports: "Rapoarte" 31 | -------------------------------------------------------------------------------- /config/locales/client.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | js: 9 | explorer: 10 | or: "alebo" 11 | admins_only: "Prieskumník údajov je dostupný iba administrátorom." 12 | title: "Prieskumník údajov" 13 | create: "Vztvoriť novú" 14 | import: 15 | label: "Import" 16 | help: 17 | label: "Pomoc" 18 | schema: 19 | filter: "Hľadať" 20 | types: 21 | bool: 22 | yes: "Áno" 23 | no: "Nie" 24 | export: "Exportovať" 25 | save: "Uložiť zmeny" 26 | edit: "Upraviť" 27 | delete: "Zmazať" 28 | download_json: "JSON" 29 | download_csv: "CSV" 30 | result_count: 31 | one: "%{count} výsledok." 32 | few: "%{count} výsledkov." 33 | many: "%{count} výsledky." 34 | other: "%{count} výsledky." 35 | query_groups: "Skupiny" 36 | query_description: "Popis" 37 | query_user: "Vytvoril" 38 | save_params: "Predvolené" 39 | reset_params: "Resetovať" 40 | search_placeholder: "Hľadať" 41 | group: 42 | reports: "Hlásenia" 43 | -------------------------------------------------------------------------------- /config/locales/client.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Odstranite podpičja iz poizvedbe." 12 | dirty: "Pred zagonom morate poizvedbo shraniti." 13 | explorer: 14 | or: "ali" 15 | admins_only: "Raziskovalec podatkov je na voljo samo skrbnikom." 16 | allow_groups: "Dovoli skupinam dostop do te poizvedbe" 17 | title: "Raziskovalec podatkov" 18 | create: "Ustvari novo" 19 | create_placeholder: "Ime poizvedbe..." 20 | description_placeholder: "Vnesite opis tukaj" 21 | import: 22 | label: "Uvozi" 23 | modal: "Uvozi poizvedbo" 24 | help: 25 | label: "Pomoč" 26 | schema: 27 | title: "Shema zbirke podatkov" 28 | filter: "Išči..." 29 | sensitive: "Vsebina tega stolpca lahko vsebuje posebno občutljive ali zasebne podatke. Pri uporabi vsebine tega stolpca bodite previdni." 30 | types: 31 | bool: 32 | yes: "Da" 33 | no: "Ne" 34 | null_: "Null" 35 | export: "Izvozi" 36 | save: "Shrani spremembe" 37 | saverun: "Shrani spremembe in zaženi" 38 | run: "Zaženi" 39 | undo: "Zavrzi spremembe" 40 | edit: "Uredi" 41 | delete: "Izbriši" 42 | recover: "Povrni poizvedbo" 43 | download_json: "JSON" 44 | download_csv: "CSV" 45 | show_table: "Tabela" 46 | show_graph: "Graf" 47 | others_dirty: "Poizvedba ima neshranjene spremembe, ki se bodo izgubile, če boste šli drugam." 48 | run_time: "Poizvedba končana v %{value} ms." 49 | result_count: 50 | one: "%{count} zadetek." 51 | two: "%{count} zadetka." 52 | few: "%{count} zadetki." 53 | other: "%{count} zadetkov." 54 | query_name: "Poizvedba" 55 | query_groups: "Skupine" 56 | link: "Povezava za" 57 | report_name: "Poročilo" 58 | query_description: "Opis" 59 | query_time: "Zadnja izvedba" 60 | query_user: "Ustvaril" 61 | column: "Stolpec %{number}" 62 | explain_label: "Vključi načrt poizvedbe?" 63 | save_params: "Nastavi privzete vrednosti" 64 | reset_params: "Ponastavi" 65 | search_placeholder: "Išči..." 66 | no_search_results: "Oprostite, nismo našli nobenega zadetka, ki bi ustrezal vaši poizvedbi." 67 | group: 68 | reports: "Poročila" 69 | -------------------------------------------------------------------------------- /config/locales/client.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | js: 9 | explorer: 10 | or: "ose" 11 | import: 12 | label: "Importo" 13 | types: 14 | bool: 15 | yes: "Po" 16 | no: "Jo" 17 | export: "Eksporto" 18 | save: "Ruaj ndryshimet" 19 | edit: "Redakto" 20 | delete: "Fshij" 21 | query_groups: "Grupet" 22 | query_description: "Përshkrimi" 23 | reset_params: "Rivendos" 24 | -------------------------------------------------------------------------------- /config/locales/client.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | js: 9 | explorer: 10 | or: "ili" 11 | types: 12 | bool: 13 | yes: "Da" 14 | no: "Ne" 15 | export: "Izvoz" 16 | save: "Sačuvaj izmene" 17 | edit: "Izmeni" 18 | delete: "Obriši" 19 | query_groups: "Grupe" 20 | query_description: "Opis" 21 | -------------------------------------------------------------------------------- /config/locales/client.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Ta bort semikolon från sökningen." 12 | dirty: "Du måste spara sökningen innan du kör." 13 | explorer: 14 | or: "eller" 15 | admins_only: "Datautforskaren är endast tillgänglig för administratörer." 16 | allow_groups: "Tillåt att grupper går till denna sökning" 17 | title: "Datautforskaren" 18 | create: "Skapa ny" 19 | create_placeholder: "Sökningsnamn..." 20 | description_placeholder: "Ange en beskrivning här" 21 | import: 22 | label: "Importera" 23 | modal: "Importera en sökning" 24 | unparseable_json: "JSON-fil kan inte tolkas." 25 | wrong_json: "Fel JSON-fil. En JSON-fil bör innehålla ett 'query'-objekt, som åtminstone bör ha en 'sql'-egenskap." 26 | help: 27 | label: "Hjälp" 28 | modal_title: "Hjälp med Datautforskaren" 29 | auto_resolution: "

Automatisk enhetsmatchning

När din fråga returnerar ett enhets-ID kan Datautforskaren automatiskt ersätta det med enhetens namn och annan användbar information i frågeresultaten. Automatisk matchning finns tillgängligt för user_id, group_id, topic_id, category_id och badge_id. För att prova detta kör du den här frågan:

SELECT user_id\nFROM posts
" 30 | custom_params: "

Skapa anpassade parametrar

För att skapa anpassade parametrar för dina sökfrågor skriver du detta högst upp i din sökfråga, enligt formatet:

-- [params]\n-- int :num = 1\n\nSELECT :num

Obs: Den första raden med [params] krävs, med två bindestreck före den och varje anpassad parameter som du vill ange.

" 31 | default_values: "

Standardvärden

Du kan ange parametrar med eller utan standardvärden. Standardvärden kommer att visas i ett textfält under frågeredigeraren, som du kan redigera enligt dina behov. Parametrar som anges utan standardvärden kommer fortfarande att generera ett textfält, men kommer att vara tomma och rödmarkerade.

-- [params]\n-- text :username = my_username\n-- int :age
" 32 | data_types: "

Datatyper

Här är vanliga datatyper som du kan använda:

För mer information om datatyper, besök denna webbplats.

" 33 | schema: 34 | title: "Databasschema" 35 | filter: "Sök..." 36 | sensitive: "Innehållet i denna kolumn kan innehålla särskilt känslig eller privat information. Var försiktig när du använder innehållet i denna kolumn." 37 | types: 38 | bool: 39 | yes: "Ja" 40 | no: "Nej" 41 | null_: "Tomt" 42 | export: "Exportera" 43 | save: "Spara ändringar" 44 | saverun: "Spara ändringar och kör" 45 | run: "Kör" 46 | undo: "Ignorera ändringar" 47 | edit: "Redigera" 48 | delete: "Radera" 49 | recover: "Återställ sökning" 50 | download_json: "JSON" 51 | download_csv: "CSV" 52 | show_table: "Tabell" 53 | show_graph: "Graf" 54 | others_dirty: "En sökning har ändringar som inte har sparats och kommer att förloras om du lämnar sidan." 55 | run_time: "Sökning slutförd efter %{value} ms." 56 | result_count: 57 | one: "%{count} resultat." 58 | other: "%{count} resultat." 59 | max_result_count: 60 | one: "Visar det %{count} översta resultatet." 61 | other: "Visar de %{count} översta resultaten." 62 | query_name: "Sökning" 63 | query_groups: "Grupper" 64 | link: "Länk för" 65 | report_name: "Rapport" 66 | query_description: "Beskrivning" 67 | query_time: "Senast körd" 68 | query_user: "Skapad av" 69 | column: "Kolumn %{number}" 70 | explain_label: "Inkludera sökningsplan?" 71 | save_params: "Ange förvalda värden" 72 | reset_params: "Återställ" 73 | search_placeholder: "Sök..." 74 | no_search_results: "Tyvärr kunde vi inte hitta några resultat som stämmer överens med din text." 75 | group: 76 | reports: "Rapporter" 77 | admin: 78 | api: 79 | scopes: 80 | descriptions: 81 | discourse_data_explorer: 82 | run_queries: "Kör frågor för datautforskaren. Begränsa API-nyckeln till en viss uppsättning frågor genom att specificera fråge-ID:n." 83 | -------------------------------------------------------------------------------- /config/locales/client.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Ondoa alama ya mkato kwenye swali." 12 | dirty: "Lazima uhifadhi taarifa kabla ya kutekeleza." 13 | explorer: 14 | or: "au" 15 | admins_only: "Uwezo wa kupitia taarifa zote unapatikana kwa viongozi tu." 16 | title: "Utafiti wa Taarifa" 17 | create: "Tengeneza Mpya" 18 | create_placeholder: "Jina la ulizo..." 19 | description_placeholder: "Andika maelezo hapa" 20 | import: 21 | label: "Imeingizwa kutoka sehemu nyingine" 22 | modal: "Ingiza Ulizo" 23 | help: 24 | label: "Msaada" 25 | schema: 26 | title: "Mpango wa kuhifadhidata" 27 | filter: "Tafuta..." 28 | sensitive: "Taarifa zilizomo ndani ya safuwima zinaweza kuwa za kibinafsi au zinazozingatiwa. Tafadhali kuwa makini na mwangalifu unapotumia hizi taarifa. " 29 | types: 30 | bool: 31 | yes: "Ndio" 32 | no: "Hapana" 33 | null_: "Hamna Kitu" 34 | export: "Hamisha" 35 | save: "Hifadhi Mabadiliko" 36 | undo: "Kataa Mabadiliko" 37 | edit: "Hariri" 38 | delete: "Futa" 39 | recover: "Rejesha Suala" 40 | download_json: "JSON" 41 | download_csv: "CSV" 42 | others_dirty: "Mabaliko uliyofanya kwenye ulizo hayajahifadhiwa, hifadhi kabla ya kwenda kwingine." 43 | query_name: "Swali" 44 | query_groups: "Makundi" 45 | query_description: "Maelezo" 46 | query_user: "Imetengenezwa na" 47 | explain_label: "pamoja na mpango wa swala?" 48 | save_params: "Seti Chaguo-misingi" 49 | reset_params: "Weka/Anzisha Upya" 50 | search_placeholder: "Tafuta" 51 | -------------------------------------------------------------------------------- /config/locales/client.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | js: 9 | explorer: 10 | or: "లేదా" 11 | help: 12 | label: "సహాయం" 13 | types: 14 | bool: 15 | yes: "అవును" 16 | no: "లేదు" 17 | export: "ఎగుమతి" 18 | save: "మార్పులను భద్రపరచు" 19 | edit: "సవరించు" 20 | delete: "తొలగించు" 21 | query_groups: "గుంపులు" 22 | query_description: "వివరణ" 23 | query_user: "సృష్టికర్త" 24 | reset_params: "రీసెట్ చేయండి" 25 | group: 26 | reports: "నివేదికలు" 27 | -------------------------------------------------------------------------------- /config/locales/client.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | js: 9 | explorer: 10 | or: "หรือ" 11 | help: 12 | label: "ช่วยเหลือ" 13 | schema: 14 | filter: "ค้นหา..." 15 | types: 16 | bool: 17 | yes: "ใช่" 18 | no: "ไม่ใช่" 19 | export: "ส่งออก" 20 | save: "บันทึกการเปลี่ยนแปลง" 21 | edit: "แก้ไข" 22 | delete: "ลบ" 23 | query_groups: "กลุ่ม" 24 | query_description: "รายละเอียด" 25 | query_user: "สร้างโดย" 26 | search_placeholder: "ค้นหา..." 27 | -------------------------------------------------------------------------------- /config/locales/client.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | js: 9 | explorer: 10 | or: "ياكى" 11 | help: 12 | label: "ياردەم" 13 | types: 14 | bool: 15 | yes: "ھەئە" 16 | no: "ياق" 17 | export: "چىقار" 18 | save: "ئۆزگەرتىشنى ساقلا" 19 | edit: "تەھرىر" 20 | delete: "ئۆچۈر" 21 | result_count: 22 | one: "%{count} نەتىجە." 23 | other: "%{count} نەتىجە." 24 | query_groups: "گۇرۇپپا" 25 | query_description: "چۈشەندۈرۈش" 26 | query_user: "قۇرغۇچى" 27 | reset_params: "ئەسلىگە قايتۇر" 28 | group: 29 | reports: "دوكلات" 30 | -------------------------------------------------------------------------------- /config/locales/client.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "Вилучіть крапки з комою з запиту." 12 | dirty: "Ви повинні зберегти запит перед запуском." 13 | explorer: 14 | or: "або" 15 | admins_only: "Data explorer доступний лише адміністраторам." 16 | title: "Data Explorer" 17 | create: "Створити новий" 18 | create_placeholder: "Назва запиту..." 19 | description_placeholder: "Введіть тут опис" 20 | import: 21 | label: "Імпорт" 22 | modal: "Імпорт запиту" 23 | help: 24 | label: "Довідка" 25 | schema: 26 | title: "Схема бази даних" 27 | filter: "Пошук..." 28 | sensitive: "Вміст цього стовпця може містити особливо чутливу або приватну інформацію. Будьте обережні, використовуючи вміст цього стовпця." 29 | types: 30 | bool: 31 | yes: "Так" 32 | no: "Ні" 33 | null_: "Null" 34 | export: "Експорт" 35 | save: "Зберегти зміни" 36 | saverun: "Збережіть зміни та запустіть" 37 | run: "Запуск" 38 | undo: "Скасувати зміни" 39 | edit: "Редагувати" 40 | delete: "Видалити" 41 | recover: "Видалити запит" 42 | download_json: "JSON" 43 | download_csv: "CSV" 44 | others_dirty: "У запиті є не збережені зміни, які будуть втрачені, якщо ви перейдете далі." 45 | result_count: 46 | one: "%{count} результат." 47 | few: "%{count} результатів." 48 | many: "%{count} результатів." 49 | other: "%{count} результатів." 50 | query_name: "Запит" 51 | query_groups: "Групи" 52 | link: "Посилання для" 53 | report_name: "Звіт" 54 | query_description: "Опис" 55 | query_time: "Останній запуск" 56 | query_user: "Створено" 57 | explain_label: "Включити план запитів?" 58 | save_params: "Встановити значення за замовчуванням" 59 | reset_params: "Скинути" 60 | search_placeholder: "Пошук ..." 61 | no_search_results: "На жаль, не вдалося знайти жодного результату, відповідного вашому тексту." 62 | group: 63 | reports: "Звіти" 64 | -------------------------------------------------------------------------------- /config/locales/client.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "کمانڈ سے semicolons ہٹا دیں۔" 12 | dirty: "چلانے سے پہلے آپ کا کمانڈ کو محفوظ کرنا لازمی ہے۔" 13 | explorer: 14 | or: "یا" 15 | admins_only: "ڈیٹا ایکسپلورر صرف ایڈمنوں کو دستیاب ہے۔" 16 | title: "ڈیٹا ایکسپلورر" 17 | create: "نیا بنائیں" 18 | create_placeholder: "ڈیٹا کمانڈ کا نام..." 19 | description_placeholder: "یہاں تفصیل درج کریں" 20 | import: 21 | label: "درآمد" 22 | modal: "ڈیٹا کمانڈ درآمد کریں" 23 | help: 24 | label: "مدد" 25 | schema: 26 | title: "ڈیٹا بیس سکیمہ" 27 | filter: "تلاش کریں..." 28 | sensitive: "اس کالم کے مواد میں خاص طور پر حساس یا نِجی معلومات موجود ہوسکتی ہے۔ براہ مہربانی، اِس کالم کے مواد کا استعمال کرتے وقت احتیاط کریں۔" 29 | types: 30 | bool: 31 | yes: "ہاں " 32 | no: "نہیں " 33 | null_: "خالی" 34 | export: "برآمد" 35 | save: "تبدیلیاں محفوظ کریں" 36 | saverun: "تبدیلیاں محفوظ کریں اور چلائیں" 37 | run: "چلائیں" 38 | undo: "تبدیلیاں منسوخ کریں" 39 | edit: "ترمیم کریں" 40 | delete: "حذف کریں" 41 | recover: "ڈیٹا کمانڈ کا حذف کرنا منسوخ کریں" 42 | download_json: "JSON" 43 | download_csv: "CSV" 44 | others_dirty: "ایک ڈیٹا کمانڈ کی غیرمحفوظ کردہ تبدیلیاں موجود ہیں جو آپ کے یہاں سے جانے کے بعد کھو جائیں گی۔" 45 | result_count: 46 | one: "%{count} نتیجہ۔" 47 | other: "%{count} نتائج۔" 48 | query_name: "ڈیٹا کمانڈ" 49 | query_groups: "گروپس" 50 | report_name: "رپورٹ" 51 | query_description: "تفصیل" 52 | query_time: "چلا آخری دفعہ" 53 | query_user: "نے بنایا" 54 | explain_label: "ڈیٹا کمانڈ پلَین شامل کریں؟" 55 | save_params: "ڈیفالٹس سَیٹ کریں" 56 | reset_params: "رِی سَیٹ" 57 | search_placeholder: "تلاش کریں..." 58 | no_search_results: "معذرت، ہمیں آپ کے متن سے ملتے ہوے کوئی نتائج نہ مل سکے۔" 59 | group: 60 | reports: "رپورٹیں" 61 | -------------------------------------------------------------------------------- /config/locales/client.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | js: 9 | explorer: 10 | or: "hoặc" 11 | import: 12 | label: "Nhập" 13 | help: 14 | label: "Giúp " 15 | schema: 16 | filter: "Tìm kiến..." 17 | types: 18 | bool: 19 | yes: "Đồng ý" 20 | no: "Không" 21 | export: "Xuất" 22 | save: "Lưu thay đổi" 23 | edit: "Sửa" 24 | delete: "Xóa" 25 | result_count: 26 | other: "%{count} kết quả." 27 | query_groups: "Nhóm" 28 | query_description: "Mô tả" 29 | query_user: "Tạo bởi" 30 | reset_params: "Cài lại" 31 | search_placeholder: "Tìm kiến..." 32 | group: 33 | reports: "Báo cáo" 34 | -------------------------------------------------------------------------------- /config/locales/client.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | js: 9 | errors: 10 | explorer: 11 | no_semicolons: "从查询中移除分号。" 12 | dirty: "您必须先保存查询,然后才能运行。" 13 | explorer: 14 | or: "或者" 15 | admins_only: "数据资源管理器仅供管理员使用。" 16 | allow_groups: "允许群组访问此查询" 17 | title: "数据资源管理器" 18 | create: "创建新查询" 19 | create_placeholder: "查询名称…" 20 | description_placeholder: "在此处输入描述" 21 | import: 22 | label: "导入" 23 | modal: "导入查询" 24 | unparseable_json: "无法解析的 JSON 文件。" 25 | wrong_json: "错误的 JSON 文件。JSON 文件应包含一个 'query' 对象,该对象至少应具有一个 'sql' 属性。" 26 | help: 27 | label: "帮助" 28 | modal_title: "数据资源管理器帮助" 29 | auto_resolution: "

自动实体解析

当您的查询返回实体 ID 时,数据资源管理器可能会自动将其替换为查询结果中的实体名称和其他有用信息。自动解析适用于 USER_IDGROUP_IDtopic_idCATEGORY_IDbadge_id。要尝试此操作,请运行以下查询:

SELECT user_id\nFROM posts
" 30 | custom_params: "

创建自定义参数

要为您的查询创建自定义参数,请将其放在查询的顶部并遵循以下格式:

-- [params]\n-- int :num = 1\n\nSELECT :num

注意:包含 [params] 以及它前面的两个破折号和您要声明的每个自定义参数的第一行是必需的。

" 31 | default_values: "

默认值

您可以声明带或不带默认值的参数。默认值将显示在查询编辑器下方的文本字段中,您可以根据需要对其进行编辑。没有默认值声明的参数仍将生成一个文本字段,但将为空并以红色突出显示。

-- [params]\n-- text :username = my_username\n-- int :age
" 32 | data_types: "

数据类型

以下是您可以使用的常见数据类型:

有关数据类型的更多信息,请访问此网站

" 33 | schema: 34 | title: "数据库架构" 35 | filter: "搜索…" 36 | sensitive: "此列的内容可能包含特别敏感或私人的信息。使用此列的内容时请谨慎。" 37 | types: 38 | bool: 39 | yes: "是" 40 | no: "否" 41 | null_: "空" 42 | export: "导出" 43 | view_json: "查看 JSON" 44 | save: "保存变更" 45 | saverun: "保存变更并运行" 46 | run: "运行" 47 | undo: "舍弃变更" 48 | edit: "编辑" 49 | delete: "删除" 50 | recover: "取消删除查询" 51 | download_json: "JSON" 52 | download_csv: "CSV" 53 | show_table: "表" 54 | show_graph: "图表" 55 | others_dirty: "查询具有未保存的更改,如果您离开,这些更改将丢失。" 56 | run_time: "查询在 %{value} 毫秒内完成。" 57 | result_count: 58 | other: "%{count} 个结果。" 59 | max_result_count: 60 | other: "显示前 %{count} 个结果。" 61 | query_name: "查询" 62 | query_groups: "群组" 63 | link: "链接" 64 | report_name: "报告" 65 | query_description: "描述" 66 | query_time: "最后运行" 67 | query_user: "创建者" 68 | column: "第 %{number} 列" 69 | explain_label: "包括查询计划?" 70 | save_params: "设置默认值" 71 | reset_params: "重置" 72 | search_placeholder: "搜索…" 73 | no_search_results: "抱歉,我们找不到任何与您的文本匹配的结果。" 74 | form: 75 | errors: 76 | invalid: "无效" 77 | no_such_category: "没有此类类别" 78 | no_such_group: "没有此类群组" 79 | invalid_date: "%{date} 是无效日期" 80 | invalid_time: "%{time} 是无效时间" 81 | group: 82 | reports: "报告" 83 | admin: 84 | api: 85 | scopes: 86 | descriptions: 87 | discourse_data_explorer: 88 | run_queries: "运行数据资源管理器查询。通过指定查询 ID 将 API 密钥限制为一组查询。" 89 | discourse_automation: 90 | scriptables: 91 | recurring_data_explorer_result_pm: 92 | fields: 93 | recipients: 94 | label: 发送至用户、群组或电子邮件 95 | query_id: 96 | label: 数据资源管理器查询 97 | query_params: 98 | label: 数据资源管理器查询参数 99 | skip_empty: 100 | label: 如果没有结果,则跳过发送 PM 101 | attach_csv: 102 | label: 将 CSV 文件附加到私信 103 | recurring_data_explorer_result_topic: 104 | fields: 105 | topic_id: 106 | label: 发布查询结果的话题 107 | query_id: 108 | label: 数据资源管理器查询 109 | query_params: 110 | label: 数据资源管理器查询参数 111 | skip_empty: 112 | label: 如果没有结果,则跳过发布 113 | attach_csv: 114 | label: 将 CSV 文件附加到帖子 115 | -------------------------------------------------------------------------------- /config/locales/client.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | js: 9 | explorer: 10 | or: "或" 11 | import: 12 | label: "匯入" 13 | help: 14 | label: "幫助" 15 | schema: 16 | filter: "搜尋..." 17 | types: 18 | bool: 19 | yes: "是" 20 | no: "否" 21 | export: "匯出" 22 | save: "儲存變更" 23 | edit: "編輯" 24 | delete: "刪除" 25 | query_groups: "群組" 26 | query_description: "描述" 27 | reset_params: "重置" 28 | search_placeholder: "搜尋..." 29 | group: 30 | reports: "報告" 31 | -------------------------------------------------------------------------------- /config/locales/server.ar.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ar: 8 | site_settings: 9 | data_explorer_enabled: "تفعيل مستكشف البيانات في /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: جدولة رسالة خاصة بنتائج مستكشف البيانات 14 | description: احصل على تقارير مجدولة يتم إرسالها إلى رسائلك 15 | no_csv_allowed: "عند استخدام الحقل `attach_csv`، يجب إضافة `csv` إلى قائمة الامتدادات المعتمدة في إعدادات الموقع." 16 | recurring_data_explorer_result_topic: 17 | title: جدولة منشور في موضوع باستخدام نتائج مستكشف البيانات 18 | description: احصل على تقارير مجدولة يتم نشرها في موضوع معيَّن 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "تقرير مجدول للاستعلام %{query_name}" 23 | upload_appendix: "الملحق: [%{filename}|مرفق](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.be.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | be: 8 | -------------------------------------------------------------------------------- /config/locales/server.bg.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bg: 8 | -------------------------------------------------------------------------------- /config/locales/server.bs_BA.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | bs_BA: 8 | site_settings: 9 | data_explorer_enabled: "Omogućite Data Explorer u / admin / plugins / explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.ca.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ca: 8 | site_settings: 9 | data_explorer_enabled: "Activa l'Explorador de dades en /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.cs.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | cs: 8 | site_settings: 9 | data_explorer_enabled: "Povolit Data Explorer v /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.da.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | da: 8 | site_settings: 9 | data_explorer_enabled: "Aktivér Data Explorer ved /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.de.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | de: 8 | site_settings: 9 | data_explorer_enabled: "Aktiviere den Daten-Explorer unter /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Plane eine PN mit den Ergebnissen des Daten-Explorers 14 | description: Geplante Berichte an deine Nachrichten schicken 15 | no_csv_allowed: "Wenn das Feld `attach_csv` verwendet wird, muss `csv` zur Liste der autorisierten Erweiterungen in den Website-Einstellungen hinzugefügt werden." 16 | recurring_data_explorer_result_topic: 17 | title: Einen Beitrag in einem Thema mit Daten-Explorer-Ergebnissen planen 18 | description: Geplante Berichte zu einem bestimmten Thema veröffentlichen 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Geplanter Bericht für %{query_name}" 23 | body: | 24 | Hallo %{recipient_name}, dein Daten Explorer Bericht ist fertig. 25 | 26 | Anfragename: 27 | %{query_name} 28 | 29 | Hier sind die Ergebnisse: 30 | %{table} 31 | 32 | Abfrage im Daten-Explorer anzeigen 33 | 34 | Bericht erstellt um %{created_at} (%{timezone}) 35 | post: 36 | body: | 37 | ### Geplanter Bericht für %{query_name} 38 | 39 | Hier sind die Ergebnisse: 40 | %{table} 41 | 42 | Abfrage im Data Explorer anzeigen 43 | 44 | Bericht erstellt um %{created_at} (%{timezone}) 45 | upload_appendix: "Anhang: [%{filename}|attachment](%{short_url})" 46 | -------------------------------------------------------------------------------- /config/locales/server.el.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | el: 8 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | data_explorer_enabled: "Enable the Data Explorer at /admin/plugins/explorer" 4 | discourse_automation: 5 | scriptables: 6 | recurring_data_explorer_result_pm: 7 | title: Schedule a PM with Data Explorer results 8 | description: Get scheduled reports sent to your messages 9 | no_csv_allowed: "When using `attach_csv` field, `csv` must be added to the list of authorized extensions in the site settings." 10 | recurring_data_explorer_result_topic: 11 | title: Schedule a post in a topic with Data Explorer results 12 | description: Get scheduled reports posted to a specific topic 13 | data_explorer: 14 | report_generator: 15 | private_message: 16 | title: "Scheduled report for %{query_name}" 17 | body: | 18 | Hi %{recipient_name}, your Data Explorer report is ready. 19 | 20 | Query name: 21 | %{query_name} 22 | 23 | Here are the results: 24 | %{table} 25 | 26 | View query in Data Explorer 27 | 28 | Report created at %{created_at} (%{timezone}) 29 | post: 30 | body: | 31 | ### Scheduled report for %{query_name} 32 | 33 | Here are the results: 34 | %{table} 35 | 36 | View query in Data Explorer 37 | 38 | Report created at %{created_at} (%{timezone}) 39 | upload_appendix: "Appendix: [%{filename}|attachment](%{short_url})" 40 | -------------------------------------------------------------------------------- /config/locales/server.en_GB.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | en_GB: 8 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | es: 8 | site_settings: 9 | data_explorer_enabled: "Habilita el explorador de datos en /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Programar un MP con los resultados del explorador de datos 14 | description: Recibir informes programados enviados a tus mensajes 15 | no_csv_allowed: "Cuando utilices el campo `attach_csv`, debe añadirse `csv` a la lista de extensiones autorizadas en los ajustes del sitio." 16 | recurring_data_explorer_result_topic: 17 | title: Programar una publicación en un tema con los resultados del Explorador de datos 18 | description: Obtener informes programados publicados en un tema específico 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Informe programado para %{query_name}" 23 | upload_appendix: "Apéndice: [%{filename}|archivo adjunto](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.et.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | et: 8 | -------------------------------------------------------------------------------- /config/locales/server.fa_IR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fa_IR: 8 | site_settings: 9 | data_explorer_enabled: "Data Explorer را در /admin/plugins/explorer فعال کنید" 10 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fi: 8 | site_settings: 9 | data_explorer_enabled: "Ota käyttöön Dataselain, ilmestyy polkuun /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Ajoita yksityisviesti Dataselaimen tuloksilla 14 | description: Vastaanota ajoitettuja raportteja viesteihisi 15 | no_csv_allowed: "Kun käytät attach_csv-kenttää, \"csv\" täytyy lisätä sallittujen päätteiden luetteloon sivustoasetuksissa." 16 | recurring_data_explorer_result_topic: 17 | title: Ajoita viesti ketjussa Dataselaimen tuloksilla 18 | description: Vastaanota ajoitettuja raportteja tiettyyn ketjuun 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Kyselyn %{query_name} ajoitettu raportti" 23 | upload_appendix: "Liite: [%{filename}|attachment](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | fr: 8 | site_settings: 9 | data_explorer_enabled: "Activer l'explorateur de données dans /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Planifier un MP avec les résultats de l'explorateur de données 14 | description: Recevez des rapports planifiés dans vos messages 15 | no_csv_allowed: "Lors de l'utilisation du champ `attach_csv`, `csv` doit être ajouté à la liste des extensions autorisées dans les paramètres du site." 16 | recurring_data_explorer_result_topic: 17 | title: Planifier une publication dans un sujet avec les résultats de l'explorateur de données 18 | description: Recevoir des rapports planifiés sur un sujet spécifique 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Rapport planifié pour %{query_name}" 23 | upload_appendix: "Annexe : [%{filename}|pièce jointe](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.gl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | gl: 8 | -------------------------------------------------------------------------------- /config/locales/server.he.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | he: 8 | site_settings: 9 | data_explorer_enabled: "הפעלת דפדפן הנתונים תחת ‎/admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: תזמון הודעה פרטית עם תוצאות דפדפן נתונים 14 | description: קבלת דוחות מתוזמנים להודעות שלך 15 | no_csv_allowed: "בעת שימוש בשדה `attach_csv` (הצמדת CSV), יש להוסיף `csv` לרשימת ההרחבות המורשות בהגדרות האתר." 16 | recurring_data_explorer_result_topic: 17 | title: תזמון פוסט בנושא עם תוצאות דפדפן נתונים 18 | description: קבלת דוחות מתוזמנים מפורסמים בנושא מסוים 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "דוח מתוזמן עבור %{query_name}" 23 | upload_appendix: "נספח: [%{filename}|attachment](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.hr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hr: 8 | site_settings: 9 | data_explorer_enabled: "Uključi \"Data Explorer\" na /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.hu.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hu: 8 | site_settings: 9 | data_explorer_enabled: "Adatfelfedező engedélyezése az /admin/plugins/explorer oldalon" 10 | -------------------------------------------------------------------------------- /config/locales/server.hy.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | hy: 8 | site_settings: 9 | data_explorer_enabled: "Միացնել Տվյալների Ուսումնասիրումը այստեղ՝ /ադմին/պլագիններ/ուսումնասիրում" 10 | -------------------------------------------------------------------------------- /config/locales/server.id.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | id: 8 | -------------------------------------------------------------------------------- /config/locales/server.it.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | it: 8 | site_settings: 9 | data_explorer_enabled: "Abilitare il plugin Data Explorer su /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Pianifica un MP con i risultati di Data Explorer 14 | description: Ricevi report pianificati inviati ai tuoi messaggi 15 | no_csv_allowed: "Quando si utilizza il campo `attach_csv`, `csv` deve essere aggiunto all'elenco delle estensioni autorizzate nelle impostazioni del sito." 16 | recurring_data_explorer_result_topic: 17 | title: Pianifica un messaggio a un argomento con i risultati di Data Explorer 18 | description: Ricevi report programmati pubblicati su un argomento specifico 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Report programmato per %{query_name}" 23 | upload_appendix: "Appendice: [%{filename}|allegato](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.ja.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ja: 8 | site_settings: 9 | data_explorer_enabled: "/admin/plugins/explorer のデータエクスプローラーを許可する" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: データエクスプローラーの結果で PM をスケジュールする 14 | description: 定期レポートをメッセージで送信します 15 | no_csv_allowed: "`attach_csv` フィールドを使用する場合は、`csv` がサイト設定の承認済み拡張機能のリストに追加されている必要があります。" 16 | recurring_data_explorer_result_topic: 17 | title: データエクスプローラーの結果を使用してトピックへの投稿をスケジュールする 18 | description: 定期レポートを特定のトピックに投稿します 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "%{query_name} の定期レポート" 23 | upload_appendix: "付録: [%{filename}|添付ファイル](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.ko.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ko: 8 | site_settings: 9 | data_explorer_enabled: "/ admin / plugins / explorer에서 데이터 탐색기를 활성화하십시오." 10 | -------------------------------------------------------------------------------- /config/locales/server.lt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lt: 8 | -------------------------------------------------------------------------------- /config/locales/server.lv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | lv: 8 | -------------------------------------------------------------------------------- /config/locales/server.nb_NO.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nb_NO: 8 | -------------------------------------------------------------------------------- /config/locales/server.nl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | nl: 8 | site_settings: 9 | data_explorer_enabled: "Gegevensverkenner op /admin/plugins/explorer inschakelen" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Plan een PB met de resultaten van Gegevensverkenner 14 | description: Laat geplande rapporten naar je berichten sturen 15 | no_csv_allowed: "Als het veld 'attach_csv' wordt gebruikt, moet 'csv' worden toegevoegd aan de lijst van toegestane extensies in de site-instellingen." 16 | recurring_data_explorer_result_topic: 17 | title: Plaats een bericht in een topic met Gegevensverkenner-resultaten 18 | description: Ontvang geplande rapporten over een specifiek topic 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Gepland rapport voor %{query_name}" 23 | upload_appendix: "Bijlage: [%{filename}|bijlage](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.pl_PL.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pl_PL: 8 | site_settings: 9 | data_explorer_enabled: "Włącz eksplorator danych w /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Zaplanuj PW z wynikami eksploratora danych 14 | -------------------------------------------------------------------------------- /config/locales/server.pt.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt: 8 | site_settings: 9 | data_explorer_enabled: "Ativar o «Explorador de Dados» em \"/admin/plugins/explorer\"" 10 | -------------------------------------------------------------------------------- /config/locales/server.pt_BR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | pt_BR: 8 | site_settings: 9 | data_explorer_enabled: "Ativar o Explorador de Dados em /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Agendar uma MP com os resultados do explorador de dados 14 | description: Obter relatórios agendados enviados às suas mensagens 15 | no_csv_allowed: "Ao usar o campo \"attach_csv\", é preciso acrescentar \"csv\" à lista de extensões autorizadas nas configurações do site." 16 | recurring_data_explorer_result_topic: 17 | title: Agendar uma postagem em um tópico com resultados do Data Explorer 18 | description: Obter relatórios programados publicados em um tópico específico 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Relatório programado para %{query_name}" 23 | upload_appendix: "Apêndice: [%{filename}|anexo](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.ro.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ro: 8 | -------------------------------------------------------------------------------- /config/locales/server.ru.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ru: 8 | site_settings: 9 | data_explorer_enabled: "Включить проводник данных в /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Запланировать личное сообщение с результатами от проводника данных 14 | description: Отправка запланированных отчетов сообщением 15 | no_csv_allowed: "При использовании поля `attach_csv` необходимо добавить `csv` в список разрешенных расширений в настройках сайта." 16 | recurring_data_explorer_result_topic: 17 | title: Запланировать публикацию в теме с результатами от проводника данных 18 | description: Отправка запланированных отчетов через публикацию в определенной теме 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "Запланированный отчет по запросу «%{query_name}»" 23 | upload_appendix: "Приложение: [%{filename}|вложение](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.sk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sk: 8 | site_settings: 9 | data_explorer_enabled: "Povoľ \"Data explorer\" v /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.sl.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sl: 8 | site_settings: 9 | data_explorer_enabled: "Omogočite vtičnik Raziskovalec podatkov na /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.sq.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sq: 8 | -------------------------------------------------------------------------------- /config/locales/server.sr.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sr: 8 | -------------------------------------------------------------------------------- /config/locales/server.sv.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sv: 8 | site_settings: 9 | data_explorer_enabled: "Aktivera Datautforskaren på /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.sw.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | sw: 8 | site_settings: 9 | data_explorer_enabled: "Ruhusu Utafiti wa taarifa kwenye /kiongozi/programu jalizi/utafutaji" 10 | -------------------------------------------------------------------------------- /config/locales/server.te.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | te: 8 | -------------------------------------------------------------------------------- /config/locales/server.th.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | th: 8 | -------------------------------------------------------------------------------- /config/locales/server.tr_TR.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | tr_TR: 8 | site_settings: 9 | data_explorer_enabled: "Veri gezginini şuradan etkinleştirin: /admin/plugins/explorer" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: Veri Gezgini sonuçlarıyla kişisel mesaj planlayın 14 | description: Mesajlarınıza gönderilen planlanmış raporları alın 15 | no_csv_allowed: "`attach_csv` alanını kullanırken site ayarlarında izin verilen uzantılar listesine `csv` eklenmelidir." 16 | recurring_data_explorer_result_topic: 17 | title: Veri Gezgini sonuçlarını içeren bir konuda gönderi planlayın 18 | description: Belirli bir konuya gönderilen planlanmış raporları alın 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "%{query_name} için planlanmış rapor" 23 | upload_appendix: "Ek: [%{filename}|ek](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.ug.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ug: 8 | -------------------------------------------------------------------------------- /config/locales/server.uk.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | uk: 8 | site_settings: 9 | data_explorer_enabled: "Увімкніть Data Explorer в /admin/plugins/explorer" 10 | -------------------------------------------------------------------------------- /config/locales/server.ur.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | ur: 8 | site_settings: 9 | data_explorer_enabled: "ڈیٹا ایکسپلورر کو /admin/plugins/explorer پر فعال کریں" 10 | -------------------------------------------------------------------------------- /config/locales/server.vi.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | vi: 8 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_CN: 8 | site_settings: 9 | data_explorer_enabled: "请在 /admin/plugins/explorer 下启用数据资源管理器" 10 | discourse_automation: 11 | scriptables: 12 | recurring_data_explorer_result_pm: 13 | title: 使用数据资源管理器结果安排 PM 14 | description: 将定时报告发送到您的消息中 15 | no_csv_allowed: "使用 `attach_csv` 字段时,必须将 `csv` 添加到网站设置中的授权扩展程序列表中。" 16 | recurring_data_explorer_result_topic: 17 | title: 使用数据资源管理器结果安排某一话题的帖子 18 | description: 将定时报告发布到特定话题 19 | data_explorer: 20 | report_generator: 21 | private_message: 22 | title: "%{query_name} 的定时报告" 23 | upload_appendix: "附录:[%{filename}|附件](%{short_url})" 24 | -------------------------------------------------------------------------------- /config/locales/server.zh_TW.yml: -------------------------------------------------------------------------------- 1 | # WARNING: Never edit this file. 2 | # It will be overwritten when translations are pulled from Crowdin. 3 | # 4 | # To work with us on translations, join this project: 5 | # https://translate.discourse.org/ 6 | 7 | zh_TW: 8 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | DiscourseDataExplorer::Engine.routes.draw do 4 | root to: "query#index" 5 | get "queries" => "query#index" 6 | get "queries/:id" => "query#show" 7 | 8 | scope "/", defaults: { format: :json } do 9 | get "schema" => "query#schema" 10 | get "groups" => "query#groups" 11 | post "queries" => "query#create" 12 | put "queries/:id" => "query#update" 13 | delete "queries/:id" => "query#destroy" 14 | post "queries/:id/run" => "query#run", :constraints => { format: /(json|csv)/ } 15 | end 16 | end 17 | 18 | Discourse::Application.routes.draw do 19 | get "/g/:group_name/reports" => "discourse_data_explorer/query#group_reports_index" 20 | get "/g/:group_name/reports/:id" => "discourse_data_explorer/query#group_reports_show" 21 | post "/g/:group_name/reports/:id/run" => "discourse_data_explorer/query#group_reports_run" 22 | 23 | mount DiscourseDataExplorer::Engine, at: "/admin/plugins/explorer" 24 | end 25 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | data_explorer_enabled: 3 | default: false 4 | client: true 5 | data_explorer_query_result_limit: 6 | default: 1000 7 | hidden: true 8 | max: 10000 9 | -------------------------------------------------------------------------------- /db/migrate/20200810053843_create_data_explorer_queries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateDataExplorerQueries < ActiveRecord::Migration[6.0] 4 | def up 5 | create_table :data_explorer_queries do |t| 6 | t.string :name 7 | t.text :description 8 | t.text :sql, default: "SELECT 1", null: false 9 | t.integer :user_id 10 | t.datetime :last_run_at 11 | t.boolean :hidden, default: false, null: false 12 | t.timestamps 13 | end 14 | 15 | create_table :data_explorer_query_groups do |t| 16 | t.integer :query_id 17 | t.integer :group_id 18 | t.index :query_id 19 | t.index :group_id 20 | end 21 | add_index(:data_explorer_query_groups, %i[query_id group_id], unique: true) 22 | 23 | DB.exec <<~SQL, now: Time.zone.now 24 | INSERT INTO data_explorer_queries(id, name, description, sql, user_id, last_run_at, hidden, created_at, updated_at) 25 | SELECT 26 | (replace(key, 'q:',''))::integer, 27 | value::json->>'name', 28 | value::json->>'description', 29 | value::json->>'sql', 30 | CASE WHEN (value::json->'created_by')::text = 'null' THEN 31 | null 32 | WHEN (value::json->'created_by')::text = '""' THEN 33 | null 34 | WHEN (value::jsonb ? 'created_by') THEN 35 | (value::json->>'created_by')::integer 36 | ELSE 37 | null 38 | END, 39 | CASE WHEN (value::json->'last_run_at')::text = 'null' THEN 40 | null 41 | WHEN (value::json->'last_run_at')::text = '""' THEN 42 | null 43 | ELSE 44 | (value::json->'last_run_at')::text::timestamptz 45 | END, 46 | CASE WHEN (value::json->'hidden')::text = 'null' THEN 47 | false 48 | WHEN (value::jsonb ? 'hidden') THEN 49 | (value::json->'hidden')::text::boolean 50 | ELSE 51 | false 52 | END, 53 | :now, 54 | :now 55 | FROM plugin_store_rows 56 | WHERE plugin_name = 'discourse-data-explorer' AND type_name = 'JSON' 57 | SQL 58 | 59 | DB 60 | .query( 61 | "SELECT * FROM plugin_store_rows WHERE plugin_name = 'discourse-data-explorer' AND type_name = 'JSON'", 62 | ) 63 | .each do |row| 64 | json = JSON.parse(row.value) 65 | next if json["group_ids"].blank? 66 | query_id = 67 | DB 68 | .query( 69 | "SELECT id FROM data_explorer_queries WHERE 70 | name = ? AND sql = ?", 71 | json["name"], 72 | json["sql"], 73 | ) 74 | .first 75 | .id 76 | 77 | json["group_ids"].each do |group_id| 78 | next if group_id.blank? || query_id.blank? 79 | DB.exec <<~SQL 80 | INSERT INTO data_explorer_query_groups(query_id, group_id) 81 | VALUES(#{query_id}, #{group_id}) 82 | SQL 83 | end 84 | end 85 | 86 | DB.exec <<~SQL 87 | SELECT 88 | setval( 89 | pg_get_serial_sequence('data_explorer_queries', 'id'), 90 | (select greatest(max(id), 1) from data_explorer_queries) 91 | ); 92 | SQL 93 | end 94 | 95 | def down 96 | raise ActiveRecord::IrreversibleMigration 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /db/migrate/20200902225712_fix_query_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixQueryIds < ActiveRecord::Migration[6.0] 4 | def up 5 | Rake::Task["data_explorer:fix_query_ids"].invoke 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20230227102505_rename_data_explorer_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RenameDataExplorerNamespace < ActiveRecord::Migration[7.0] 4 | def up 5 | execute <<~SQL 6 | UPDATE api_key_scopes 7 | SET resource = 'discourse_data_explorer' 8 | WHERE resource = 'data_explorer' 9 | SQL 10 | 11 | execute <<~SQL 12 | UPDATE bookmarks 13 | SET bookmarkable_type = 'DiscourseDataExplorer::QueryGroup' 14 | WHERE bookmarkable_type = 'DataExplorer::QueryGroup' 15 | SQL 16 | end 17 | 18 | def down 19 | execute <<~SQL 20 | UPDATE api_key_scopes 21 | SET resource = 'data_explorer' 22 | WHERE resource = 'discourse_data_explorer' 23 | SQL 24 | 25 | execute <<~SQL 26 | UPDATE bookmarks 27 | SET bookmarkable_type = 'DiscourseDataExplorer::QueryGroup' 28 | WHERE bookmarkable_type = 'DataExplorer::QueryGroup' 29 | SQL 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20241009161603_alter_query_id_to_bigint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AlterQueryIdToBigint < ActiveRecord::Migration[7.1] 4 | def up 5 | change_column :data_explorer_query_groups, :query_id, :bigint 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import DiscourseRecommended from "@discourse/lint-configs/eslint"; 2 | 3 | export default [...DiscourseRecommended]; 4 | -------------------------------------------------------------------------------- /lib/discourse_data_explorer/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class Engine < ::Rails::Engine 5 | engine_name PLUGIN_NAME 6 | isolate_namespace DiscourseDataExplorer 7 | config.autoload_paths << File.join(config.root, "lib") 8 | scheduled_job_dir = "#{config.root}/app/jobs/scheduled" 9 | config.to_prepare { Rails.autoloaders.main.eager_load_dir(scheduled_job_dir) } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/discourse_data_explorer/query_group_bookmarkable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class QueryGroupBookmarkable < BaseBookmarkable 5 | def self.model 6 | QueryGroup 7 | end 8 | 9 | def self.serializer 10 | QueryGroupBookmarkSerializer 11 | end 12 | 13 | def self.preload_associations 14 | %i[data_explorer_queries groups] 15 | end 16 | 17 | def self.list_query(user, guardian) 18 | group_ids = [] 19 | if !user.admin? 20 | group_ids = user.visible_groups.pluck(:id) 21 | return if group_ids.empty? 22 | end 23 | 24 | query = 25 | user 26 | .bookmarks_of_type("DiscourseDataExplorer::QueryGroup") 27 | .joins( 28 | "INNER JOIN data_explorer_query_groups ON data_explorer_query_groups.id = bookmarks.bookmarkable_id", 29 | ) 30 | .joins( 31 | "LEFT JOIN data_explorer_queries ON data_explorer_queries.id = data_explorer_query_groups.query_id", 32 | ) 33 | query = query.where("data_explorer_query_groups.group_id IN (?)", group_ids) if !user.admin? 34 | query 35 | end 36 | 37 | # Searchable only by data_explorer_queries name 38 | def self.search_query(bookmarks, query, ts_query, &bookmarkable_search) 39 | bookmarkable_search.call(bookmarks, "data_explorer_queries.name ILIKE :q") 40 | end 41 | 42 | def self.reminder_handler(bookmark) 43 | send_reminder_notification( 44 | bookmark, 45 | data: { 46 | title: bookmark.bookmarkable.query.name, 47 | bookmarkable_url: 48 | "/g/#{bookmark.bookmarkable.group.name}/reports/#{bookmark.bookmarkable.query.id}", 49 | }, 50 | ) 51 | end 52 | 53 | def self.reminder_conditions(bookmark) 54 | bookmark.bookmarkable.present? 55 | end 56 | 57 | def self.can_see?(guardian, bookmark) 58 | can_see_bookmarkable?(guardian, bookmark.bookmarkable) 59 | end 60 | 61 | def self.can_see_bookmarkable?(guardian, bookmarkable) 62 | return false if !bookmarkable.group 63 | guardian.user_is_a_member_of_group?(bookmarkable.group) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/discourse_data_explorer/result_format_converter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module ::DiscourseDataExplorer 3 | class ResultFormatConverter 4 | def self.convert(file_type, result, opts = {}) 5 | self.new(result, opts).send("to_#{file_type}") 6 | end 7 | 8 | def initialize(result, opts) 9 | @result = result 10 | @opts = opts 11 | end 12 | 13 | private 14 | 15 | attr_reader :result 16 | attr_reader :opts 17 | 18 | def pg_result 19 | @pg_result ||= @result[:pg_result] 20 | end 21 | 22 | def cols 23 | @cols ||= pg_result.fields 24 | end 25 | 26 | def to_csv 27 | require "csv" 28 | CSV.generate do |csv| 29 | csv << cols 30 | pg_result.values.each { |row| csv << row } 31 | end 32 | end 33 | 34 | def to_json 35 | json = { 36 | success: true, 37 | errors: [], 38 | duration: (result[:duration_secs].to_f * 1000).round(1), 39 | result_count: pg_result.values.length || 0, 40 | params: opts[:query_params], 41 | columns: cols, 42 | default_limit: SiteSetting.data_explorer_query_result_limit, 43 | } 44 | json[:explain] = result[:explain] if opts[:explain] 45 | 46 | if !opts[:download] 47 | relations, colrender = DataExplorer.add_extra_data(pg_result) 48 | json[:relations] = relations 49 | json[:colrender] = colrender 50 | end 51 | 52 | json[:rows] = pg_result.values 53 | 54 | json 55 | end 56 | 57 | #TODO: we can move ResultToMarkdown here 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/discourse_data_explorer/result_to_markdown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | include HasSanitizableFields 4 | 5 | module ::DiscourseDataExplorer 6 | class ResultToMarkdown 7 | def self.convert(pg_result, render_url_columns = false) 8 | relations, colrender = DataExplorer.add_extra_data(pg_result) 9 | result_data = [] 10 | 11 | # column names to search in place of id columns (topic_id, user_id etc) 12 | cols = %w[name title username] 13 | 14 | # find values from extra data, based on result id 15 | pg_result.values.each do |row| 16 | row_data = [] 17 | 18 | row.each_with_index do |col, col_index| 19 | col_name = pg_result.fields[col_index] 20 | col_render = colrender[col_index] 21 | related = relations.dig(col_render.to_sym) if col_render.present? 22 | 23 | if related.is_a?(ActiveModel::ArraySerializer) 24 | related_row = related.object.find_by(id: col) 25 | if col_name.include?("_id") 26 | column = cols.find { |c| related_row.try c } 27 | else 28 | column = related_row.try(col_name) 29 | end 30 | 31 | if column.nil? 32 | row_data[col_index] = col 33 | else 34 | row_data[col_index] = "#{related_row[column]} (#{col})" 35 | end 36 | elsif col_render == "url" && render_url_columns 37 | url, text = guess_url(col) 38 | row_data[col_index] = "[#{text}](#{url})" 39 | else 40 | row_data[col_index] = col 41 | end 42 | end 43 | 44 | result_data << row_data.map { |c| "| #{sanitize_field(c.to_s)} " }.join + "|\n" 45 | end 46 | 47 | table_headers = pg_result.fields.map { |c| " #{c.gsub("_id", "")} |" }.join 48 | table_body = pg_result.fields.size.times.map { " :----- |" }.join 49 | 50 | "|#{table_headers}\n|#{table_body}\n#{result_data.join}" 51 | end 52 | 53 | def self.guess_url(column_value) 54 | text, url = column_value.split(/,(.+)/) 55 | 56 | [url || column_value, text || column_value] 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/discourse_data_explorer/validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ::DiscourseDataExplorer 4 | class ValidationError < StandardError 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/tasks/data_explorer.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rake data_explorer:list_hidden_queries 4 | desc "Shows a list of hidden queries" 5 | task "data_explorer:list_hidden_queries" => :environment do |t| 6 | puts "\nHidden Queries\n\n" 7 | 8 | hidden_queries = DiscourseDataExplorer::Query.where(hidden: false) 9 | 10 | hidden_queries.each do |query| 11 | puts "Name: #{query.name}" 12 | puts "Description: #{query.description}" 13 | puts "ID: #{query.id}\n\n" 14 | end 15 | end 16 | 17 | # rake data_explorer[-1] 18 | # rake data_explorer[1,-2,3,-4,5] 19 | desc "Hides one or multiple queries by ID" 20 | task "data_explorer" => :environment do |t, args| 21 | args.extras.each do |arg| 22 | id = arg.to_i 23 | query = DiscourseDataExplorer::Query.find_by(id: id) 24 | if query 25 | puts "\nFound query with id #{id}" 26 | query.update!(hidden: true) 27 | puts "Query no.#{id} is now hidden" 28 | else 29 | puts "\nError finding query with id #{id}" 30 | end 31 | end 32 | puts "" 33 | end 34 | 35 | # rake data_explorer:unhide_query[-1] 36 | # rake data_explorer:unhide_query[1,-2,3,-4,5] 37 | desc "Unhides one or multiple queries by ID" 38 | task "data_explorer:unhide_query" => :environment do |t, args| 39 | args.extras.each do |arg| 40 | id = arg.to_i 41 | query = DiscourseDataExplorer::Query.find_by(id: id) 42 | if query 43 | puts "\nFound query with id #{id}" 44 | query.update!(hidden: false) 45 | puts "Query no.#{id} is now visible" 46 | else 47 | puts "\nError finding query with id #{id}" 48 | end 49 | end 50 | puts "" 51 | end 52 | 53 | # rake data_explorer:hard_delete[-1] 54 | # rake data_explorer:hard_delete[1,-2,3,-4,5] 55 | desc "Hard deletes one or multiple queries by ID" 56 | task "data_explorer:hard_delete" => :environment do |t, args| 57 | args.extras.each do |arg| 58 | id = arg.to_i 59 | query = DiscourseDataExplorer::Query.find_by(id: id) 60 | if query 61 | puts "\nFound query with id #{id}" 62 | 63 | if query.hidden 64 | query.destroy! 65 | puts "Query no.#{id} has been deleted" 66 | else 67 | puts "Query no.#{id} must be hidden in order to hard delete" 68 | puts "To hide the query, run: " + "rake data_explorer[#{id}]" 69 | end 70 | else 71 | puts "\nError finding query with id #{id}" 72 | end 73 | end 74 | puts "" 75 | end 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@discourse/lint-configs": "2.23.0", 5 | "ember-template-lint": "7.7.0", 6 | "eslint": "9.28.0", 7 | "prettier": "3.5.3", 8 | "stylelint": "16.20.0" 9 | }, 10 | "engines": { 11 | "node": ">= 22", 12 | "npm": "please-use-pnpm", 13 | "yarn": "please-use-pnpm", 14 | "pnpm": "9.x" 15 | }, 16 | "packageManager": "pnpm@9.15.5" 17 | } 18 | -------------------------------------------------------------------------------- /spec/automation/recurring_data_explorer_result_topic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe "RecurringDataExplorerResultTopic" do 6 | fab!(:admin) 7 | 8 | fab!(:user) 9 | fab!(:another_user) { Fabricate(:user) } 10 | fab!(:group_user) { Fabricate(:user) } 11 | fab!(:not_allowed_user) { Fabricate(:user) } 12 | fab!(:topic) 13 | 14 | fab!(:group) { Fabricate(:group, users: [user, another_user]) } 15 | 16 | fab!(:automation) do 17 | Fabricate(:automation, script: "recurring_data_explorer_result_topic", trigger: "recurring") 18 | end 19 | fab!(:query) { Fabricate(:query, sql: "SELECT 'testabcd' AS data") } 20 | fab!(:query_group) { Fabricate(:query_group, query: query, group: group) } 21 | 22 | before do 23 | SiteSetting.data_explorer_enabled = true 24 | SiteSetting.discourse_automation_enabled = true 25 | 26 | automation.upsert_field!("query_id", "choices", { value: query.id }) 27 | automation.upsert_field!("topic_id", "text", { value: topic.id }) 28 | automation.upsert_field!( 29 | "query_params", 30 | "key-value", 31 | { value: [%w[from_days_ago 0], %w[duration_days 15]] }, 32 | ) 33 | automation.upsert_field!( 34 | "recurrence", 35 | "period", 36 | { value: { interval: 1, frequency: "day" } }, 37 | target: "trigger", 38 | ) 39 | automation.upsert_field!("start_date", "date_time", { value: 2.minutes.ago }, target: "trigger") 40 | end 41 | 42 | context "when using recurring trigger" do 43 | it "sends the post at recurring date_date" do 44 | freeze_time 1.day.from_now do 45 | expect { Jobs::DiscourseAutomation::Tracker.new.execute }.to change { 46 | topic.reload.posts.count 47 | }.by(1) 48 | 49 | expect(topic.posts.last.raw).to eq( 50 | I18n.t( 51 | "data_explorer.report_generator.post.body", 52 | query_name: query.name, 53 | table: "| data |\n| :----- |\n| testabcd |\n", 54 | base_url: Discourse.base_url, 55 | query_id: query.id, 56 | created_at: Time.zone.now.strftime("%Y-%m-%d at %H:%M:%S"), 57 | timezone: Time.zone.name, 58 | ).chomp, 59 | ) 60 | end 61 | end 62 | 63 | it "has appropriate content from the report generator" do 64 | freeze_time 65 | automation.update(last_updated_by_id: admin.id) 66 | automation.trigger! 67 | 68 | expect(topic.posts.last.raw).to eq( 69 | I18n.t( 70 | "data_explorer.report_generator.post.body", 71 | query_name: query.name, 72 | table: "| data |\n| :----- |\n| testabcd |\n", 73 | base_url: Discourse.base_url, 74 | query_id: query.id, 75 | created_at: Time.zone.now.strftime("%Y-%m-%d at %H:%M:%S"), 76 | timezone: Time.zone.name, 77 | ).chomp, 78 | ) 79 | end 80 | 81 | it "does not create the post if skip_empty" do 82 | query.update!(sql: "SELECT NULL LIMIT 0") 83 | automation.upsert_field!("skip_empty", "boolean", { value: true }) 84 | 85 | automation.update(last_updated_by_id: admin.id) 86 | 87 | expect { automation.trigger! }.to_not change { Post.count } 88 | end 89 | 90 | it "works with a query that returns URLs in a number,url format" do 91 | freeze_time 92 | query.update!(sql: "SELECT 3 || ',https://test.com' AS some_url") 93 | automation.update(last_updated_by_id: admin.id) 94 | automation.trigger! 95 | 96 | expect(topic.posts.last.raw).to eq( 97 | I18n.t( 98 | "data_explorer.report_generator.post.body", 99 | query_name: query.name, 100 | table: "| some_url |\n| :----- |\n| [3](https://test.com) |\n", 101 | base_url: Discourse.base_url, 102 | query_id: query.id, 103 | created_at: Time.zone.now.strftime("%Y-%m-%d at %H:%M:%S"), 104 | timezone: Time.zone.name, 105 | ).chomp, 106 | ) 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/data_explorer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe DiscourseDataExplorer::DataExplorer do 4 | describe ".run_query" do 5 | fab!(:topic) 6 | 7 | it "should run a query that includes PG template patterns" do 8 | sql = <<~SQL 9 | WITH query AS ( 10 | SELECT TO_CHAR(created_at, 'yyyy:mm:dd') AS date FROM topics 11 | ) SELECT * FROM query 12 | SQL 13 | 14 | query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 15 | 16 | result = described_class.run_query(query) 17 | 18 | expect(result[:error]).to eq(nil) 19 | expect(result[:pg_result][0]["date"]).to eq(topic.created_at.strftime("%Y:%m:%d")) 20 | end 21 | 22 | it "should run a query containing a question mark in the comment" do 23 | sql = <<~SQL 24 | WITH query AS ( 25 | SELECT id FROM topics -- some SQL ? comment ? 26 | ) SELECT * FROM query 27 | SQL 28 | 29 | query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 30 | 31 | result = described_class.run_query(query) 32 | 33 | expect(result[:error]).to eq(nil) 34 | expect(result[:pg_result][0]["id"]).to eq(topic.id) 35 | end 36 | 37 | it "can run a query with params interpolation" do 38 | topic2 = Fabricate(:topic) 39 | 40 | sql = <<~SQL 41 | -- [params] 42 | -- int :topic_id = 99999999 43 | WITH query AS ( 44 | SELECT 45 | id, 46 | TO_CHAR(created_at, 'yyyy:mm:dd') AS date 47 | FROM topics 48 | WHERE topics.id = :topic_id 49 | ) SELECT * FROM query 50 | SQL 51 | 52 | query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 53 | 54 | result = described_class.run_query(query, { "topic_id" => topic2.id.to_s }) 55 | 56 | expect(result[:error]).to eq(nil) 57 | expect(result[:pg_result].to_a.size).to eq(1) 58 | expect(result[:pg_result][0]["id"]).to eq(topic2.id) 59 | end 60 | 61 | describe ".add_extra_data" do 62 | it "treats any column with payload in the name as 'json'" do 63 | Fabricate(:reviewable_queued_post) 64 | sql = <<~SQL 65 | SELECT id, payload FROM reviewables LIMIT 10 66 | SQL 67 | query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 68 | result = described_class.run_query(query) 69 | _, colrender = DiscourseDataExplorer::DataExplorer.add_extra_data(result[:pg_result]) 70 | expect(colrender).to eq({ 1 => "json" }) 71 | end 72 | 73 | it "treats columns with the actual json data type as 'json'" do 74 | ApiKeyScope.create( 75 | resource: "topics", 76 | action: "update", 77 | api_key_id: Fabricate(:api_key).id, 78 | allowed_parameters: { 79 | "category_id" => ["#{topic.category_id}"], 80 | }, 81 | ) 82 | sql = <<~SQL 83 | SELECT id, allowed_parameters FROM api_key_scopes LIMIT 10 84 | SQL 85 | query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 86 | result = described_class.run_query(query) 87 | _, colrender = DiscourseDataExplorer::DataExplorer.add_extra_data(result[:pg_result]) 88 | expect(colrender).to eq({ 1 => "json" }) 89 | end 90 | 91 | describe "serializing models to serializer" do 92 | it "serializes correctly to BasicTopicSerializer for topic relations" do 93 | topic = Fabricate(:topic, locale: "ja") 94 | query = Fabricate(:query, sql: "SELECT id AS topic_id FROM topics WHERE id = #{topic.id}") 95 | 96 | pg_result = described_class.run_query(query)[:pg_result] 97 | relations, _ = DiscourseDataExplorer::DataExplorer.add_extra_data(pg_result) 98 | 99 | expect { 100 | records = relations[:topic].object 101 | records.map { |t| BasicTopicSerializer.new(t, root: false).as_json } 102 | }.not_to raise_error 103 | end 104 | end 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /spec/fabricators/query_fabricator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Fabricator(:query, from: DiscourseDataExplorer::Query) do 4 | name { sequence(:name) { |i| "cat#{i}" } } 5 | description { sequence(:desc) { |i| "description #{i}" } } 6 | sql { sequence(:sql) { |i| "SELECT * FROM users WHERE id > 0 LIMIT #{i}" } } 7 | user 8 | end 9 | 10 | Fabricator(:query_group, from: DiscourseDataExplorer::QueryGroup) do 11 | query 12 | group 13 | end 14 | -------------------------------------------------------------------------------- /spec/guardian_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Guardian do 6 | before { SiteSetting.data_explorer_enabled = true } 7 | 8 | def make_query(group_ids = []) 9 | query = 10 | DiscourseDataExplorer::Query.create!( 11 | name: "Query number #{Fabrication::Sequencer.sequence("query-id", 1)}", 12 | sql: "SELECT 1", 13 | ) 14 | 15 | group_ids.each { |group_id| query.query_groups.create!(group_id: group_id) } 16 | 17 | query 18 | end 19 | 20 | let(:user) { build(:user) } 21 | let(:admin) { build(:admin) } 22 | fab!(:group) 23 | 24 | describe "#user_is_a_member_of_group?" do 25 | it "is true when the user is an admin" do 26 | expect(Guardian.new(admin).user_is_a_member_of_group?(group)).to eq(true) 27 | end 28 | 29 | it "is true when the user is not an admin, but is a member of the group" do 30 | group.add(user) 31 | 32 | expect(Guardian.new(user).user_is_a_member_of_group?(group)).to eq(true) 33 | end 34 | 35 | it "is false when the user is not an admin, and is not a member of the group" do 36 | expect(Guardian.new(user).user_is_a_member_of_group?(group)).to eq(false) 37 | end 38 | end 39 | 40 | describe "#group_and_user_can_access_query?" do 41 | it "is true if the user is an admin" do 42 | expect(Guardian.new(admin).group_and_user_can_access_query?(group, make_query)).to eq(true) 43 | end 44 | 45 | it "is true if the user is a member of the group, and query contains the group id" do 46 | query = make_query(["#{group.id}"]) 47 | group.add(user) 48 | 49 | expect(Guardian.new(user).group_and_user_can_access_query?(group, query)).to eq(true) 50 | end 51 | 52 | it "is false if the query does not contain the group id" do 53 | group.add(user) 54 | 55 | expect(Guardian.new(user).group_and_user_can_access_query?(group, make_query)).to eq(false) 56 | end 57 | 58 | it "is false if the user is not member of the group" do 59 | query = make_query(["#{group.id}"]) 60 | 61 | expect(Guardian.new(user).group_and_user_can_access_query?(group, query)).to eq(false) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/integration/custom_api_key_scopes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe "API keys scoped to query#run" do 6 | before { SiteSetting.data_explorer_enabled = true } 7 | 8 | fab!(:query1) do 9 | DiscourseDataExplorer::Query.create!(name: "Query 1", sql: "SELECT 1 AS query1_res") 10 | end 11 | fab!(:query2) do 12 | DiscourseDataExplorer::Query.create!(name: "Query 2", sql: "SELECT 1 AS query2_res") 13 | end 14 | fab!(:admin) 15 | 16 | let(:all_queries_api_key) do 17 | key = ApiKey.create! 18 | ApiKeyScope.create!( 19 | resource: "discourse_data_explorer", 20 | action: "run_queries", 21 | api_key_id: key.id, 22 | ) 23 | key 24 | end 25 | 26 | let(:single_query_api_key) do 27 | key = ApiKey.create! 28 | ApiKeyScope.create!( 29 | resource: "discourse_data_explorer", 30 | action: "run_queries", 31 | api_key_id: key.id, 32 | allowed_parameters: { 33 | "id" => [query1.id.to_s], 34 | }, 35 | ) 36 | key 37 | end 38 | 39 | it "cannot hit any other endpoints" do 40 | get "/latest.json", 41 | headers: { 42 | "Api-Key" => all_queries_api_key.key, 43 | "Api-Username" => admin.username, 44 | } 45 | expect(response.status).to eq(403) 46 | 47 | get "/latest.json", 48 | headers: { 49 | "Api-Key" => single_query_api_key.key, 50 | "Api-Username" => admin.username, 51 | } 52 | expect(response.status).to eq(403) 53 | 54 | get "/u/#{admin.username}.json", 55 | headers: { 56 | "Api-Key" => all_queries_api_key.key, 57 | "Api-Username" => admin.username, 58 | } 59 | expect(response.status).to eq(403) 60 | 61 | get "/u/#{admin.username}.json", 62 | headers: { 63 | "Api-Key" => single_query_api_key.key, 64 | "Api-Username" => admin.username, 65 | } 66 | expect(response.status).to eq(403) 67 | end 68 | 69 | it "can only run the queries they're allowed to run" do 70 | expect { 71 | post "/admin/plugins/explorer/queries/#{query1.id}/run.json", 72 | headers: { 73 | "Api-Key" => single_query_api_key.key, 74 | "Api-Username" => admin.username, 75 | } 76 | }.to change { query1.reload.last_run_at } 77 | expect(response.status).to eq(200) 78 | expect(response.parsed_body["success"]).to eq(true) 79 | expect(response.parsed_body["columns"]).to eq(["query1_res"]) 80 | 81 | expect { 82 | post "/admin/plugins/explorer/queries/#{query2.id}/run.json", 83 | headers: { 84 | "Api-Key" => single_query_api_key.key, 85 | "Api-Username" => admin.username, 86 | } 87 | }.not_to change { query2.reload.last_run_at } 88 | expect(response.status).to eq(403) 89 | end 90 | 91 | it "can run all queries if they're not restricted to any queries" do 92 | expect { 93 | post "/admin/plugins/explorer/queries/#{query1.id}/run.json", 94 | headers: { 95 | "Api-Key" => all_queries_api_key.key, 96 | "Api-Username" => admin.username, 97 | } 98 | }.to change { query1.reload.last_run_at } 99 | expect(response.status).to eq(200) 100 | expect(response.parsed_body["success"]).to eq(true) 101 | expect(response.parsed_body["columns"]).to eq(["query1_res"]) 102 | 103 | expect { 104 | post "/admin/plugins/explorer/queries/#{query2.id}/run.json", 105 | headers: { 106 | "Api-Key" => all_queries_api_key.key, 107 | "Api-Username" => admin.username, 108 | } 109 | }.to change { query2.reload.last_run_at } 110 | expect(response.status).to eq(200) 111 | expect(response.parsed_body["success"]).to eq(true) 112 | expect(response.parsed_body["columns"]).to eq(["query2_res"]) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/jobs/scheduled/delete_hidden_queries_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe Jobs::DeleteHiddenQueries do 6 | before do 7 | Jobs.run_immediately! 8 | SiteSetting.data_explorer_enabled = true 9 | end 10 | 11 | it "will correctly destroy old hidden queries" do 12 | DiscourseDataExplorer::Query.create!( 13 | id: 1, 14 | name: "A", 15 | description: "A description for A", 16 | sql: "SELECT 1 as value", 17 | hidden: false, 18 | last_run_at: 2.days.ago, 19 | updated_at: 2.days.ago, 20 | ) 21 | DiscourseDataExplorer::Query.create!( 22 | id: 2, 23 | name: "B", 24 | description: "A description for B", 25 | sql: "SELECT 1 as value", 26 | hidden: true, 27 | last_run_at: 8.days.ago, 28 | updated_at: 8.days.ago, 29 | ) 30 | DiscourseDataExplorer::Query.create!( 31 | id: 3, 32 | name: "C", 33 | description: "A description for C", 34 | sql: "SELECT 1 as value", 35 | hidden: true, 36 | last_run_at: 4.days.ago, 37 | updated_at: 4.days.ago, 38 | ) 39 | DiscourseDataExplorer::Query.create!( 40 | id: 4, 41 | name: "D", 42 | description: "A description for D", 43 | sql: "SELECT 1 as value", 44 | hidden: true, 45 | last_run_at: nil, 46 | updated_at: 10.days.ago, 47 | ) 48 | DiscourseDataExplorer::Query.create!( 49 | id: 5, 50 | name: "E", 51 | description: "A description for E", 52 | sql: "SELECT 1 as value", 53 | hidden: true, 54 | last_run_at: 5.days.ago, 55 | updated_at: 10.days.ago, 56 | ) 57 | DiscourseDataExplorer::Query.create!( 58 | id: 6, 59 | name: "F", 60 | description: "A description for F", 61 | sql: "SELECT 1 as value", 62 | hidden: true, 63 | last_run_at: 10.days.ago, 64 | updated_at: 5.days.ago, 65 | ) 66 | 67 | described_class.new.execute(nil) 68 | expect(DiscourseDataExplorer::Query.all.length).to eq(4) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/models/query_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DiscourseDataExplorer::Query do 4 | before { SiteSetting.data_explorer_enabled = true } 5 | 6 | describe ".find" do 7 | it "returns default queries" do 8 | expect(DiscourseDataExplorer::Query.find(-1)).to be_present 9 | end 10 | end 11 | 12 | describe "unscoped .find" do 13 | it "returns default queries" do 14 | expect(DiscourseDataExplorer::Query.unscoped.find(-1)).to be_present 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/requests/group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe "Data explorer group serializer additions" do 6 | fab!(:group_user) { Fabricate(:user) } 7 | fab!(:other_user) { Fabricate(:user) } 8 | fab!(:group) 9 | let!(:query) { DiscourseDataExplorer::Query.create!(name: "My query", sql: "") } 10 | 11 | before do 12 | SiteSetting.data_explorer_enabled = true 13 | group.add(group_user) 14 | DiscourseDataExplorer::QueryGroup.create!(group: group, query: query) 15 | end 16 | 17 | it "query boolean is true for group user" do 18 | sign_in group_user 19 | get "/g/#{group.name}.json" 20 | expect(response.status).to eq(200) 21 | expect(response.parsed_body["group"]["has_visible_data_explorer_queries"]).to eq(true) 22 | end 23 | 24 | it "query boolean is false for group user when there are no queries" do 25 | query.destroy! 26 | sign_in group_user 27 | get "/g/#{group.name}.json" 28 | expect(response.status).to eq(200) 29 | expect(response.parsed_body["group"]["has_visible_data_explorer_queries"]).to eq(false) 30 | end 31 | 32 | it "does not include query boolean for anon" do 33 | get "/g/#{group.name}.json" 34 | expect(response.status).to eq(200) 35 | expect(response.parsed_body["group"]["has_visible_data_explorer_queries"]).to eq(nil) 36 | end 37 | 38 | it "does not include query boolean for non-group user" do 39 | sign_in other_user 40 | get "/g/#{group.name}.json" 41 | expect(response.status).to eq(200) 42 | expect(response.parsed_body["group"]["has_visible_data_explorer_queries"]).to eq(nil) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/result_format_converter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe DiscourseDataExplorer::ResultFormatConverter do 4 | fab!(:user) 5 | fab!(:post) 6 | fab!(:query) { DiscourseDataExplorer::Query.find(-1) } 7 | 8 | let(:query_params) { [{ from_days_ago: 0 }, { duration_days: 15 }] } 9 | let(:query_result) { DiscourseDataExplorer::DataExplorer.run_query(query, query_params) } 10 | 11 | before { SiteSetting.data_explorer_enabled = true } 12 | 13 | describe ".convert" do 14 | context "for csv files" do 15 | it "format results as a csv table with headers and columns" do 16 | result = described_class.convert(:csv, query_result) 17 | 18 | table = <<~CSV 19 | liker_user_id,liked_user_id,count 20 | CSV 21 | 22 | expect(result).to include(table) 23 | end 24 | end 25 | 26 | context "for json files" do 27 | it "format results as a json file" do 28 | result = described_class.convert(:json, query_result, { query_params: }) 29 | 30 | expect(result[:columns]).to contain_exactly("liker_user_id", "liked_user_id", "count") 31 | expect(result[:params]).to eq(query_params) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/result_to_markdown_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe DiscourseDataExplorer::ResultToMarkdown do 4 | fab!(:user) 5 | fab!(:post) 6 | fab!(:query) { DiscourseDataExplorer::Query.find(-1) } 7 | 8 | let(:query_params) { [{ from_days_ago: 0 }, { duration_days: 15 }] } 9 | let(:query_result) { DiscourseDataExplorer::DataExplorer.run_query(query, query_params) } 10 | 11 | before { SiteSetting.data_explorer_enabled = true } 12 | 13 | describe ".convert" do 14 | it "format results as a markdown table with headers and columns" do 15 | result = described_class.convert(query_result[:pg_result]) 16 | 17 | table = <<~MD 18 | | liker_user | liked_user | count | 19 | | :----- | :----- | :----- | 20 | MD 21 | 22 | expect(result).to include(table) 23 | end 24 | 25 | it "enriches result data within the table rows" do 26 | PostActionCreator.new(user, post, PostActionType.types[:like]).perform 27 | result = described_class.convert(query_result[:pg_result]) 28 | 29 | expect(result).to include( 30 | "| #{user.username} (#{user.id}) | #{post.user.username} (#{post.user.id}) | 1 |\n", 31 | ) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/system/bookmark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe "Bookmarking reports attached to a group", type: :system do 4 | fab!(:current_user) { Fabricate(:admin) } 5 | fab!(:query_1) do 6 | Fabricate( 7 | :query, 8 | name: "My query", 9 | description: "Test query", 10 | sql: "SELECT * FROM users", 11 | user: current_user, 12 | ) 13 | end 14 | fab!(:group) { Fabricate(:group, name: "group") } 15 | fab!(:group_user) { Fabricate(:group_user, user: current_user, group: group) } 16 | fab!(:query_group_1) { Fabricate(:query_group, query: query_1, group: group) } 17 | let(:bookmark_modal) { PageObjects::Modals::Bookmark.new } 18 | 19 | before do 20 | SiteSetting.data_explorer_enabled = true 21 | sign_in(current_user) 22 | end 23 | 24 | it "allows the user to bookmark a group report" do 25 | visit("/g/group/reports/#{query_1.id}") 26 | find(".query-group-bookmark").click 27 | expect(bookmark_modal).to be_open 28 | bookmark_modal.click_primary_button 29 | expect(page).to have_css(".query-group-bookmark.bookmarked") 30 | expect(Bookmark.exists?(user: current_user, bookmarkable: query_group_1)).to eq(true) 31 | end 32 | 33 | it "allows the user to edit and delete a group report bookmark" do 34 | bookmark = 35 | Fabricate(:bookmark, user: current_user, bookmarkable: query_group_1, reminder_at: nil) 36 | 37 | visit("/g/group/reports/#{query_1.id}") 38 | find(".query-group-bookmark").click 39 | expect(bookmark_modal).to be_open 40 | bookmark_modal.fill_name("Remember this query") 41 | bookmark_modal.click_primary_button 42 | expect(bookmark_modal).to be_closed 43 | expect(bookmark.reload.name).to eq("Remember this query") 44 | 45 | find(".query-group-bookmark").click 46 | expect(bookmark_modal).to be_open 47 | bookmark_modal.delete 48 | expect(bookmark_modal).to be_closed 49 | expect(page).not_to have_css(".query-group-bookmark.bookmarked") 50 | expect(Bookmark.exists?(user: current_user, bookmarkable: query_group_1)).to eq(false) 51 | end 52 | 53 | it "shows bookmarked group reports in the user bookmark list" do 54 | bookmark = Fabricate(:bookmark, user: current_user, bookmarkable: query_group_1) 55 | visit("/u/#{current_user.username_lower}/activity/bookmarks") 56 | expect(page.find(".bookmark-list")).to have_content("My query") 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/system/core_features_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Core features", type: :system do 4 | before { enable_current_plugin } 5 | 6 | it_behaves_like "having working core features" 7 | end 8 | -------------------------------------------------------------------------------- /spec/system/explorer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Explorer", type: :system, js: true do 4 | fab!(:admin) 5 | fab!(:group) { Fabricate(:group, name: "group") } 6 | fab!(:group_user) { Fabricate(:group_user, user: admin, group: group) } 7 | 8 | before do 9 | SiteSetting.data_explorer_enabled = true 10 | sign_in admin 11 | end 12 | 13 | context "with a query using a default param" do 14 | fab!(:query_1) do 15 | Fabricate( 16 | :query, 17 | name: "My default param query", 18 | description: "Test default param query", 19 | sql: "-- [params]\n-- string :limit = 42\n\nSELECT * FROM users LIMIT :limit", 20 | user: admin, 21 | ) 22 | end 23 | fab!(:query_group_1) { Fabricate(:query_group, query: query_1, group: group) } 24 | 25 | it "pre-fills the field with the default param" do 26 | visit("/g/group/reports/#{query_1.id}") 27 | 28 | expect(page).to have_field("limit", with: 42) 29 | end 30 | end 31 | 32 | context "with the old url format" do 33 | fab!(:query_1) do 34 | Fabricate( 35 | :query, 36 | name: "My query", 37 | description: "Test query", 38 | sql: "SELECT * FROM users", 39 | user: admin, 40 | ) 41 | end 42 | 43 | it "redirects to the new url format" do 44 | visit("/admin/plugins/explorer/?id=#{query_1.id}") 45 | 46 | expect(page).to have_current_path("/admin/plugins/explorer/queries/#{query_1.id}") 47 | end 48 | 49 | it "redirects to the new url format with params" do 50 | visit("/admin/plugins/explorer/?id=#{query_1.id}¶ms=%7B%22limit%22%3A%2210%22%7D") 51 | 52 | expect(page).to have_current_path( 53 | "/admin/plugins/explorer/queries/#{query_1.id}?params=%7B%22limit%22%3A%2210%22%7D", 54 | ) 55 | end 56 | end 57 | 58 | context "with a group_list param" do 59 | fab!(:q2) do 60 | Fabricate( 61 | :query, 62 | name: "My query with group_list", 63 | description: "Test group_list query", 64 | sql: 65 | "-- [params]\n-- group_list :groups\n\nSELECT g.id,g.name FROM groups g WHERE g.name IN(:groups) ORDER BY g.name ASC", 66 | user: admin, 67 | ) 68 | end 69 | 70 | it "supports setting a group_list param" do 71 | visit( 72 | "/admin/plugins/explorer/queries/#{q2.id}?params=%7B\"groups\"%3A\"admins%2Ctrust_level_1\"%7D", 73 | ) 74 | find(".query-run .btn-primary").click 75 | 76 | expect(page).to have_css(".query-results .result-header") 77 | 78 | expect(page).to have_css( 79 | ".query-results tbody tr:nth-child(1) td:nth-child(2)", 80 | text: "admins", 81 | ) 82 | expect(page).to have_css( 83 | ".query-results tbody tr:nth-child(2) td:nth-child(2)", 84 | text: "trust_level_1", 85 | ) 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/system/param_input_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Param input", type: :system, js: true do 4 | ALL_PARAMS_SQL = <<~SQL 5 | -- [params] 6 | -- int :int 7 | -- bigint :bigint 8 | -- boolean :boolean 9 | -- null boolean :boolean_three 10 | -- string :string 11 | -- date :date 12 | -- time :time 13 | -- datetime :datetime 14 | -- double :double 15 | -- string :inet 16 | -- user_id :user_id 17 | -- post_id :post_id 18 | -- topic_id :topic_id 19 | -- int_list :int_list 20 | -- string_list :string_list 21 | -- category_id :category_id 22 | -- group_id :group_id 23 | -- group_list :group_list 24 | -- user_list :mul_users 25 | -- int :int_with_default = 3 26 | -- bigint :bigint_with_default = 12345678912345 27 | -- boolean :boolean 28 | -- null boolean :boolean_three_with_default = #null 29 | -- boolean :boolean_with_default = true 30 | -- string :string_with_default = little bunny foo foo 31 | -- date :date_with_default = 14 jul 2015 32 | -- time :time_with_default = 5:02 pm 33 | -- datetime :datetime_with_default = 14 jul 2015 5:02 pm 34 | -- double :double_with_default = 3.1415 35 | -- string :inet_with_default = 127.0.0.1/8 36 | -- user_id :user_id_with_default = system 37 | -- post_id :post_id_with_default = http://localhost:3000/t/adsfdsfajadsdafdsds-sf-awerjkldfdwe/21/1?u=system 38 | -- topic_id :topic_id_with_default = /t/-/21 39 | -- int_list :int_list_with_default = 1,2,3 40 | -- string_list :string_list_with_default = a,b,c 41 | -- category_id :category_id_with_default = general 42 | -- group_id :group_id_with_default = staff 43 | -- group_list :group_list_with_default = trust_level_0,trust_level_1 44 | -- user_list :mul_users_with_default = system,discobot 45 | SELECT 1 46 | SQL 47 | 48 | fab!(:current_user) { Fabricate(:admin) } 49 | fab!(:all_params_query) do 50 | Fabricate( 51 | :query, 52 | name: "All params query", 53 | description: "", 54 | sql: ALL_PARAMS_SQL, 55 | user: current_user, 56 | ) 57 | end 58 | 59 | before do 60 | SiteSetting.data_explorer_enabled = true 61 | sign_in(current_user) 62 | end 63 | 64 | it "correctly displays parameter input boxes" do 65 | visit("/admin/plugins/explorer/queries/#{all_params_query.id}") 66 | 67 | ::DiscourseDataExplorer::Parameter 68 | .create_from_sql(ALL_PARAMS_SQL) 69 | .each do |param| 70 | expect(page).to have_css(".query-params .param [name=\"#{param.identifier}\"]") 71 | 72 | # select-kit fields 73 | ignore_fields = %i[user_id post_id topic_id category_id group_id group_list user_list] 74 | 75 | if param.default.present? && ignore_fields.exclude?(param.type) 76 | expect(page).to have_field( 77 | param.identifier, 78 | with: simple_normalize(param.type, param.default), 79 | ) 80 | end 81 | end 82 | end 83 | end 84 | 85 | def simple_normalize(type, value) 86 | case type 87 | when :date 88 | value.to_date.to_s 89 | when :time 90 | value.to_time.strftime("%H:%M") 91 | when :datetime 92 | value.to_datetime.strftime("%Y-%m-%dT%H:%M") 93 | when :boolean 94 | value == "#null" ? "#null" : value ? "on" : "off" 95 | when :boolean_three 96 | value == "#null" ? "#null" : value ? "Y" : "N" 97 | else 98 | value.to_s 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/system/reports_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "Reports", type: :system, js: true do 4 | fab!(:group) { Fabricate(:group, name: "group") } 5 | fab!(:user) { Fabricate(:admin) } 6 | fab!(:group_user) { Fabricate(:group_user, user: user, group: group) } 7 | fab!(:query_1) do 8 | Fabricate( 9 | :query, 10 | name: "My First Query", 11 | description: "This is the description of my 1st query.", 12 | sql: "SELECT * FROM users limit 1", 13 | user: user, 14 | ) 15 | end 16 | fab!(:query_2) do 17 | Fabricate( 18 | :query, 19 | name: "My Second Query", 20 | description: "This is my 2nd query's description.", 21 | sql: "SELECT * FROM users limit 1", 22 | user: user, 23 | ) 24 | end 25 | fab!(:query_group_1) { Fabricate(:query_group, query: query_1, group: group) } 26 | fab!(:query_group_2) { Fabricate(:query_group, query: query_2, group: group) } 27 | 28 | before { SiteSetting.data_explorer_enabled = true } 29 | 30 | it "allows user to switch between reports" do 31 | sign_in(user) 32 | visit("/g/group/reports/#{query_2.id}") 33 | expect(find(".user-content h1")).to have_content("My Second Query") 34 | expect(page).not_to have_css(".query-results .result-header") 35 | find(".query-run .btn-primary").click 36 | expect(page).to have_css(".query-results .result-header") 37 | 38 | find(".group-reports-nav-item-outlet a").click 39 | all(".group-reports a ").last.click 40 | expect(find(".user-content h1")).to have_content("My Second Query") 41 | expect(page).not_to have_css(".query-results .result-header") 42 | find(".query-run .btn-primary").click 43 | expect(page).to have_css(".query-results .result-header") 44 | end 45 | 46 | it "allows user to run a report with a JSON column and open a fullscreen code viewer" do 47 | Fabricate(:reviewable_queued_post) 48 | sql = <<~SQL 49 | SELECT id, payload FROM reviewables LIMIT 10 50 | SQL 51 | json_query = DiscourseDataExplorer::Query.create!(name: "some query", sql: sql) 52 | sign_in(user) 53 | visit("/g/group/reports/#{json_query.id}") 54 | find(".query-run .btn-primary").click 55 | expect(page).to have_css(".query-results .result-json") 56 | first(".query-results .result-json .btn.result-json-button").click 57 | expect(page).to have_css(".fullscreen-code-modal") 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/tasks/fix_query_ids_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails_helper" 4 | 5 | describe "fix query ids rake task" do 6 | before do 7 | Rake::Task.clear 8 | Discourse::Application.load_tasks 9 | end 10 | 11 | let(:query_name) { "Awesome query" } 12 | 13 | it "fixes the ID of the query if they share the same name" do 14 | original_query_id = 4 15 | create_plugin_store_row(query_name, original_query_id) 16 | create_query(query_name) 17 | 18 | run_task 19 | 20 | expect(find(query_name).id).to eq(original_query_id) 21 | end 22 | 23 | it "only fixes queries with unique name" do 24 | original_query_id = 4 25 | create_plugin_store_row(query_name, original_query_id) 26 | create_query(query_name) 27 | create_query(query_name) 28 | 29 | run_task 30 | 31 | expect(find(query_name).id).not_to eq(original_query_id) 32 | end 33 | 34 | it "skips queries that already have the same ID" do 35 | db_query = create_query(query_name) 36 | last_updated_at = db_query.updated_at 37 | create_plugin_store_row(query_name, db_query.id) 38 | 39 | run_task 40 | 41 | expect(find(query_name).updated_at).to eq_time(last_updated_at) 42 | end 43 | 44 | it "keeps queries the rest of the queries" do 45 | original_query_id = 4 46 | different_query_name = "Another query" 47 | create_plugin_store_row(query_name, original_query_id) 48 | create_query(query_name) 49 | create_query(different_query_name) 50 | 51 | run_task 52 | 53 | expect(find(different_query_name)).not_to be_nil 54 | end 55 | 56 | it "works even if they are additional conflicts" do 57 | different_query_name = "Another query" 58 | additional_conflict = create_query(different_query_name) 59 | create_query(query_name) 60 | create_plugin_store_row(query_name, additional_conflict.id) 61 | 62 | run_task 63 | 64 | expect(find(different_query_name).id).not_to eq(additional_conflict.id) 65 | expect(find(query_name).id).to eq(additional_conflict.id) 66 | end 67 | 68 | describe "query groups" do 69 | let(:group) { Fabricate(:group) } 70 | 71 | it "fixes the query group's query_id" do 72 | original_query_id = 4 73 | create_query(query_name, [group.id]) 74 | create_plugin_store_row(query_name, original_query_id, [group.id]) 75 | 76 | run_task 77 | 78 | expect(find_query_group(original_query_id)).not_to be_nil 79 | end 80 | 81 | it "works with additional conflicts" do 82 | different_query_name = "Another query" 83 | additional_conflict = create_query(different_query_name, [group.id]) 84 | create_query(query_name, [group.id]) 85 | create_plugin_store_row(query_name, additional_conflict.id, [group.id]) 86 | 87 | run_task 88 | 89 | conflict = find(different_query_name).query_groups.first 90 | fixed = find_query_group(additional_conflict.id) 91 | 92 | expect(conflict.query_id).not_to eq(additional_conflict.id) 93 | expect(fixed.query_id).to eq(additional_conflict.id) 94 | end 95 | 96 | def find_query_group(id) 97 | DiscourseDataExplorer::QueryGroup.find_by(query_id: id) 98 | end 99 | end 100 | 101 | it "changes the serial sequence for future queries" do 102 | original_query_id = 4 103 | create_plugin_store_row(query_name, original_query_id) 104 | create_query(query_name) 105 | 106 | run_task 107 | post_fix_query = create_query(query_name) 108 | 109 | expect(post_fix_query.id).to eq(original_query_id + 1) 110 | end 111 | 112 | def run_task 113 | Rake::Task["data_explorer:fix_query_ids"].invoke 114 | end 115 | 116 | def create_plugin_store_row(name, id, group_ids = []) 117 | key = "q:#{id}" 118 | 119 | PluginStore.set( 120 | DiscourseDataExplorer::PLUGIN_NAME, 121 | key, 122 | attributes(name).merge(group_ids: group_ids, id: id), 123 | ) 124 | end 125 | 126 | def create_query(name, group_ids = []) 127 | DiscourseDataExplorer::Query 128 | .create!(attributes(name)) 129 | .tap { |query| group_ids.each { |group_id| query.query_groups.create!(group_id: group_id) } } 130 | end 131 | 132 | def attributes(name) 133 | { 134 | id: 135 | DiscourseDataExplorer::Query.count == 0 ? 5 : DiscourseDataExplorer::Query.maximum(:id) + 1, 136 | name: name, 137 | description: "A Query", 138 | sql: "SELECT 1", 139 | created_at: 3.hours.ago, 140 | last_run_at: 1.hour.ago, 141 | hidden: false, 142 | } 143 | end 144 | 145 | def find(name) 146 | DiscourseDataExplorer::Query.find_by(name: name) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@discourse/lint-configs/stylelint"], 3 | }; 4 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/list-queries-test.js: -------------------------------------------------------------------------------- 1 | import { visit } from "@ember/test-helpers"; 2 | import { test } from "qunit"; 3 | import { acceptance } from "discourse/tests/helpers/qunit-helpers"; 4 | import { i18n } from "discourse-i18n"; 5 | 6 | acceptance("Data Explorer Plugin | List Queries", function (needs) { 7 | needs.user(); 8 | needs.settings({ data_explorer_enabled: true }); 9 | 10 | needs.pretender((server, helper) => { 11 | server.get("/admin/plugins/explorer/groups.json", () => { 12 | return helper.response([]); 13 | }); 14 | 15 | server.get("/admin/plugins/explorer/queries", () => { 16 | return helper.response({ 17 | queries: [ 18 | { 19 | id: -5, 20 | name: "Top 100 Active Topics", 21 | description: 22 | "based on the number of replies, it accepts a ‘months_ago’ parameter, defaults to 1 to give results for the last calendar month.", 23 | username: "system", 24 | group_ids: [], 25 | last_run_at: "2021-02-08T15:37:49.188Z", 26 | user_id: -1, 27 | }, 28 | { 29 | id: -6, 30 | name: "Top 100 Likers", 31 | description: 32 | "returns the top 100 likers for a given monthly period ordered by like_count. It accepts a ‘months_ago’ parameter, defaults to 1 to give results for the last calendar month.", 33 | username: "system", 34 | group_ids: [], 35 | last_run_at: "2021-02-11T08:29:59.337Z", 36 | user_id: -1, 37 | }, 38 | ], 39 | }); 40 | }); 41 | }); 42 | 43 | test("renders the page with the list of queries", async function (assert) { 44 | await visit("/admin/plugins/explorer"); 45 | 46 | assert 47 | .dom("div.query-list input.ember-text-field") 48 | .hasAttribute( 49 | "placeholder", 50 | i18n("explorer.search_placeholder"), 51 | "the search box was rendered" 52 | ); 53 | 54 | assert 55 | .dom("div.query-list button.btn-icon svg.d-icon-plus") 56 | .exists("the add query button was rendered"); 57 | 58 | assert 59 | .dom("div.query-list button.btn-icon-text span.d-button-label") 60 | .hasText(i18n("explorer.import.label"), "the import button was rendered"); 61 | 62 | assert 63 | .dom("div.container table.recent-queries tbody tr") 64 | .exists({ count: 2 }, "the list of queries was rendered"); 65 | 66 | assert 67 | .dom("div.container table.recent-queries tbody tr:nth-child(1) td a") 68 | .hasText(/^\s*Top 100 Likers/, "The first query was rendered"); 69 | 70 | assert 71 | .dom("div.container table.recent-queries tbody tr:nth-child(2) td a") 72 | .hasText(/^\s*Top 100 Active Topics/, "The second query was rendered"); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/new-query-test.js: -------------------------------------------------------------------------------- 1 | import { click, currentURL, fillIn, visit } from "@ember/test-helpers"; 2 | import { test } from "qunit"; 3 | import { acceptance } from "discourse/tests/helpers/qunit-helpers"; 4 | 5 | acceptance("Data Explorer Plugin | New Query", function (needs) { 6 | needs.user(); 7 | needs.settings({ data_explorer_enabled: true }); 8 | 9 | needs.pretender((server, helper) => { 10 | server.get("/admin/plugins/explorer/groups.json", () => { 11 | return helper.response([]); 12 | }); 13 | 14 | server.get("/admin/plugins/explorer/schema.json", () => { 15 | return helper.response({}); 16 | }); 17 | 18 | server.get("/admin/plugins/explorer/queries", () => { 19 | return helper.response({ 20 | queries: [], 21 | }); 22 | }); 23 | 24 | server.post("/admin/plugins/explorer/queries", () => { 25 | return helper.response({ 26 | query: { 27 | id: -15, 28 | sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS\n(SELECT date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' AS period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' AS period_end)\nSELECT t.id AS topic_id,\n t.category_id,\n COUNT(p.id) AS reply_count\nFROM topics t\nJOIN posts p ON t.id = p.topic_id\nJOIN query_period qp ON p.created_at >= qp.period_start\nAND p.created_at <= qp.period_end\nWHERE t.archetype = 'regular'\nAND t.user_id > 0\nGROUP BY t.id\nORDER BY COUNT(p.id) DESC, t.score DESC\nLIMIT 100\n", 29 | name: "foo", 30 | description: 31 | "based on the number of replies, it accepts a ‘months_ago’ parameter, defaults to 1 to give results for the last calendar month.", 32 | param_info: [ 33 | { 34 | identifier: "months_ago", 35 | type: "int", 36 | default: "1", 37 | nullable: false, 38 | }, 39 | ], 40 | created_at: "2021-02-05T16:42:45.572Z", 41 | username: "system", 42 | group_ids: [], 43 | last_run_at: "2021-02-08T15:37:49.188Z", 44 | hidden: false, 45 | user_id: -1, 46 | }, 47 | }); 48 | }); 49 | 50 | server.get("/admin/plugins/explorer/queries/-15", () => { 51 | return helper.response({ 52 | query: { 53 | id: -15, 54 | sql: "-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS\n(SELECT date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' AS period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' AS period_end)\nSELECT t.id AS topic_id,\n t.category_id,\n COUNT(p.id) AS reply_count\nFROM topics t\nJOIN posts p ON t.id = p.topic_id\nJOIN query_period qp ON p.created_at >= qp.period_start\nAND p.created_at <= qp.period_end\nWHERE t.archetype = 'regular'\nAND t.user_id > 0\nGROUP BY t.id\nORDER BY COUNT(p.id) DESC, t.score DESC\nLIMIT 100\n", 55 | name: "foo", 56 | description: 57 | "based on the number of replies, it accepts a ‘months_ago’ parameter, defaults to 1 to give results for the last calendar month.", 58 | param_info: [ 59 | { 60 | identifier: "months_ago", 61 | type: "int", 62 | default: "1", 63 | nullable: false, 64 | }, 65 | ], 66 | created_at: "2021-02-05T16:42:45.572Z", 67 | username: "system", 68 | group_ids: [], 69 | last_run_at: "2021-02-08T15:37:49.188Z", 70 | hidden: false, 71 | user_id: -1, 72 | }, 73 | }); 74 | }); 75 | }); 76 | 77 | test("creates a new query", async function (assert) { 78 | await visit("/admin/plugins/explorer"); 79 | 80 | // select new query button 81 | await click(".query-list button"); 82 | await fillIn(".query-create input", "foo"); 83 | // select create new query button 84 | await click(".query-create button"); 85 | 86 | assert.strictEqual(currentURL(), "/admin/plugins/explorer/queries/-15"); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/javascripts/components/explorer-schema-test.gjs: -------------------------------------------------------------------------------- 1 | import { fillIn, render } from "@ember/test-helpers"; 2 | import { module, test } from "qunit"; 3 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 4 | import ExplorerSchema from "discourse/plugins/discourse-data-explorer/discourse/components/explorer-schema"; 5 | 6 | const schema = { 7 | posts: [ 8 | { 9 | column_name: "id", 10 | data_type: "serial", 11 | primary: true, 12 | notes: "primary key", 13 | havetypeinfo: true, 14 | }, 15 | { 16 | column_name: "raw", 17 | data_type: "text", 18 | column_desc: "The raw Markdown that the user entered into the composer.", 19 | havepopup: true, 20 | havetypeinfo: true, 21 | }, 22 | ], 23 | categories: [ 24 | { 25 | column_name: "id", 26 | data_type: "serial", 27 | primary: true, 28 | notes: "primary key", 29 | havetypeinfo: true, 30 | }, 31 | { 32 | column_name: "name", 33 | data_type: "varchar(50)", 34 | havetypeinfo: false, 35 | }, 36 | ], 37 | }; 38 | 39 | module("Data Explorer Plugin | Component | explorer-schema", function (hooks) { 40 | setupRenderingTest(hooks); 41 | 42 | test("will automatically convert to lowercase", async function (assert) { 43 | const self = this; 44 | 45 | this.setProperties({ 46 | schema, 47 | hideSchema: false, 48 | updateHideSchema: () => {}, 49 | }); 50 | 51 | await render( 52 | 59 | ); 60 | 61 | await fillIn(`.schema-search input`, "Cat"); 62 | 63 | assert.dom(".schema-table").exists(); 64 | 65 | await fillIn(`.schema-search input`, "NotExist"); 66 | 67 | assert.dom(".schema-table").doesNotExist(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/javascripts/integration/components/data-explorer-bar-chart-test.gjs: -------------------------------------------------------------------------------- 1 | import { array } from "@ember/helper"; 2 | import { render } from "@ember/test-helpers"; 3 | import { module, test } from "qunit"; 4 | import { setupRenderingTest } from "discourse/tests/helpers/component-test"; 5 | import DataExplorerBarChart from "../../discourse/components/data-explorer-bar-chart"; 6 | 7 | module( 8 | "Data Explorer Plugin | Integration | Component | data-explorer-bar-chart", 9 | function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | test("renders a chart", async function (assert) { 13 | await render( 14 | 21 | ); 22 | 23 | assert.dom("canvas").exists("renders a canvas"); 24 | }); 25 | } 26 | ); 27 | -------------------------------------------------------------------------------- /translator.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for discourse-translator-bot 2 | 3 | files: 4 | - source_path: config/locales/client.en.yml 5 | destination_path: client.yml 6 | - source_path: config/locales/server.en.yml 7 | destination_path: server.yml 8 | --------------------------------------------------------------------------------