2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

DEV: Move discourse-data-explorer to core (#33570)

https://meta.discourse.org/t/373574

Internal `/t/-/156778`
This commit is contained in:
Jarek Radosz 2025-07-11 18:45:12 +02:00
parent 079dd05376
commit e65677f217
208 changed files with 14870 additions and 0 deletions

4
.github/labeler.yml vendored
View file

@ -78,6 +78,10 @@ discourse-chat-integration:
- changed-files:
- any-glob-to-any-file: plugins/discourse-chat-integration/**/*

discourse-data-explorer:
- changed-files:
- any-glob-to-any-file: plugins/discourse-data-explorer/**/*

footnote:
- changed-files:
- any-glob-to-any-file: plugins/footnote/**/*

1
.gitignore vendored
View file

@ -62,6 +62,7 @@
!/plugins/discourse-rss-polling
!/plugins/discourse-math
!/plugins/discourse-chat-integration
!/plugins/discourse-data-explorer
/plugins/*/auto_generated

/spec/fixtures/plugins/my_plugin/auto_generated

View file

@ -0,0 +1,6 @@
# Data Explorer Plugin

This plugin allows admins to run SQL queries against the live Discourse database,
including parameterized queries and formatting for several common column types.

For more information, please see: https://meta.discourse.org/t/data-explorer-plugin/32566

View file

@ -0,0 +1,236 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryController < ApplicationController
requires_plugin PLUGIN_NAME

before_action :set_group, only: %i[group_reports_index group_reports_show group_reports_run]
before_action :set_query, only: %i[group_reports_show group_reports_run show update]
before_action :ensure_admin

skip_before_action :check_xhr, only: %i[show group_reports_run run]
skip_before_action :ensure_admin,
only: %i[group_reports_index group_reports_show group_reports_run]

def index
queries = Query.where(hidden: false).order(:last_run_at, :name).includes(:groups).to_a

database_queries_ids = Query.pluck(:id)
Queries.default.each do |params|
attributes = params.last
next if database_queries_ids.include?(attributes["id"])
query = Query.new
query.id = attributes["id"]
query.sql = attributes["sql"]
query.name = attributes["name"]
query.description = attributes["description"]
query.user_id = Discourse::SYSTEM_USER_ID.to_s
queries << query
end

render_serialized queries, QuerySerializer, root: "queries"
end

def show
check_xhr unless params[:export]

if params[:export]
response.headers["Content-Disposition"] = "attachment; filename=#{@query.slug}.dcquery.json"
response.sending_file = true
end

return raise Discourse::NotFound if !guardian.user_can_access_query?(@query) || @query.hidden
render_serialized @query, QueryDetailsSerializer, root: "query"
end

def groups
render json: Group.all.select(:id, :name).as_json(only: %i[id name]), root: false
end

def group_reports_index
return raise Discourse::NotFound unless guardian.user_is_a_member_of_group?(@group)

respond_to do |format|
format.json do
queries = Query.for_group(@group)
render_serialized(queries, QuerySerializer, root: "queries")
end
end
end

def group_reports_show
if !guardian.group_and_user_can_access_query?(@group, @query) || @query.hidden
return raise Discourse::NotFound
end

respond_to do |format|
format.json do
query_group = QueryGroup.find_by(query_id: @query.id, group_id: @group.id)

render json: {
query: serialize_data(@query, QueryDetailsSerializer, root: nil),
query_group: serialize_data(query_group, QueryGroupSerializer, root: nil),
}
end
end
end

def group_reports_run
if !guardian.group_and_user_can_access_query?(@group, @query) || @query.hidden
return raise Discourse::NotFound
end

run
end

def create
query =
Query.create!(
params
.require(:query)
.permit(:name, :description, :sql)
.merge(user_id: current_user.id, last_run_at: Time.now),
)
group_ids = params.require(:query)[:group_ids]
group_ids&.each { |group_id| query.query_groups.find_or_create_by!(group_id: group_id) }
render_serialized query, QueryDetailsSerializer, root: "query"
end

def update
ActiveRecord::Base.transaction do
@query.update!(
params.require(:query).permit(:name, :sql, :description).merge(hidden: false),
)

group_ids = params.require(:query)[:group_ids]
QueryGroup.where.not(group_id: group_ids).where(query_id: @query.id).delete_all
group_ids&.each { |group_id| @query.query_groups.find_or_create_by!(group_id: group_id) }
end

render_serialized @query, QueryDetailsSerializer, root: "query"
rescue ValidationError => e
render_json_error e.message
end

def destroy
query = Query.where(id: params[:id]).first_or_initialize
query.update!(hidden: true)

render json: { success: true, errors: [] }
end

def schema
schema_version = DB.query_single("SELECT max(version) AS tag FROM schema_migrations").first
if stale?(public: true, etag: schema_version, template: false)
render json: DataExplorer.schema
end
end

# Return value:
# success - true/false. if false, inspect the errors value.
# errors - array of strings.
# params - hash. Echo of the query parameters as executed.
# duration - float. Time to execute the query, in milliseconds, to 1 decimal place.
# columns - array of strings. Titles of the returned columns, in order.
# explain - string. (Optional - pass explain=true in the request) Postgres query plan, UNIX newlines.
# rows - array of array of strings. Results of the query. In the same order as 'columns'.
def run
rate_limit_query_runs!

check_xhr unless params[:download]

query = Query.find(params[:id].to_i)
query.update!(last_run_at: Time.now)

response.sending_file = true if params[:download]

query_params = {}
query_params = MultiJson.load(params[:params]) if params[:params]

opts = { current_user: current_user&.username }
opts[:explain] = true if params[:explain] == "true"

opts[:limit] = if params[:format] == "csv"
if params[:limit].present?
limit = params[:limit].to_i
limit = QUERY_RESULT_MAX_LIMIT if limit > QUERY_RESULT_MAX_LIMIT
limit
else
QUERY_RESULT_MAX_LIMIT
end
elsif params[:limit].present?
params[:limit] == "ALL" ? "ALL" : params[:limit].to_i
end

result = DataExplorer.run_query(query, query_params, opts)

if result[:error]
err = result[:error]

# Pretty printing logic
err_class = err.class
err_msg = err.message
if err.is_a? ActiveRecord::StatementInvalid
err_class = err.original_exception.class
err_msg.gsub!("#{err_class}:", "")
else
err_msg = "#{err_class}: #{err_msg}"
end

render json: { success: false, errors: [err_msg] }, status: 422
else
content_disposition =
"attachment; filename=#{query.slug}@#{Slug.for(Discourse.current_hostname, "discourse")}-#{Date.today}.dcqresult"

respond_to do |format|
format.json do
response.headers["Content-Disposition"] = "#{content_disposition}.json" if params[
:download
]

render json:
ResultFormatConverter.convert(
:json,
result,
query_params:,
download: params[:download],
explain: params[:explain] == "true",
)
end
format.csv do
response.headers["Content-Disposition"] = "#{content_disposition}.csv"

render plain: ResultFormatConverter.convert(:csv, result)
end
end
end
end

private

def rate_limit_query_runs!
return if !is_api? && !is_user_api?

RateLimiter.new(
nil,
"api-query-run-10-sec",
GlobalSetting.max_data_explorer_api_reqs_per_10_seconds,
10.seconds,
).performed!
rescue RateLimiter::LimitExceeded => e
if GlobalSetting.max_data_explorer_api_req_mode.include?("warn")
Discourse.warn("Query run 10 second rate limit exceeded", query_id: params[:id])
end
raise e if GlobalSetting.max_data_explorer_api_req_mode.include?("block")
end

def set_group
@group = Group.find_by(name: params["group_name"])
end

def set_query
@query = Query.find(params[:id])
raise Discourse::NotFound unless @query
end
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true

module Jobs
class DeleteHiddenQueries < ::Jobs::Scheduled
every 7.days

def execute(args)
return unless SiteSetting.data_explorer_enabled

DiscourseDataExplorer::Query
.where("id > 0")
.where(hidden: true)
.where(
"(last_run_at IS NULL OR last_run_at < :days_ago) AND updated_at < :days_ago",
days_ago: 7.days.ago,
)
.delete_all
end
end
end

View file

@ -0,0 +1,79 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryFinder
def self.find(id)
default_query = Queries.default[id.to_s]
return raise ActiveRecord::RecordNotFound unless default_query

query = Query.find_by(id: id) || Query.new
query.attributes = default_query
query.user_id = Discourse::SYSTEM_USER_ID.to_s
query
end
end

class Query < ActiveRecord::Base
self.table_name = "data_explorer_queries"

has_many :query_groups
has_many :groups, through: :query_groups
belongs_to :user
validates :name, presence: true

scope :for_group,
->(group) do
where(hidden: false).joins(
"INNER JOIN data_explorer_query_groups
ON data_explorer_query_groups.query_id = data_explorer_queries.id
AND data_explorer_query_groups.group_id = #{group.id}",
)
end

def params
@params ||= Parameter.create_from_sql(sql)
end

def cast_params(input_params)
result = {}.with_indifferent_access
self.params.each do |pobj|
result[pobj.identifier] = pobj.cast_to_ruby input_params[pobj.identifier]
end
result
end

def slug
Slug.for(name).presence || "query-#{id}"
end

def self.find(id)
return super if id.to_i >= 0
QueryFinder.find(id)
end

private

# for `Query.unscoped.find`
class ActiveRecord_Relation
def find(id)
return super if id.to_i >= 0
QueryFinder.find(id)
end
end
end
end

# == Schema Information
#
# Table name: data_explorer_queries
#
# id :bigint not null, primary key
# name :string
# description :text
# sql :text default("SELECT 1"), not null
# user_id :integer
# last_run_at :datetime
# hidden :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryGroup < ActiveRecord::Base
self.table_name = "data_explorer_query_groups"

belongs_to :query
belongs_to :group

has_many :bookmarks, as: :bookmarkable
end
end

# == Schema Information
#
# Table name: data_explorer_query_groups
#
# id :bigint not null, primary key
# query_id :bigint
# group_id :integer
#
# Indexes
#
# index_data_explorer_query_groups_on_group_id (group_id)
# index_data_explorer_query_groups_on_query_id (query_id)
# index_data_explorer_query_groups_on_query_id_and_group_id (query_id,group_id) UNIQUE
#

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryDetailsSerializer < QuerySerializer
attributes :sql, :param_info, :created_at, :hidden

def param_info
object&.params&.uniq { |p| p.identifier }&.map(&:to_hash)
end
end
end

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryGroupBookmarkSerializer < UserBookmarkBaseSerializer
def title
fancy_title
end

def fancy_title
data_explorer_query.name
end

def cooked
data_explorer_query.description
end

def bookmarkable_user
@bookmarkable_user ||= data_explorer_query.user
end

def bookmarkable_url
"/g/#{data_explorer_query_group.group.name}/reports/#{data_explorer_query_group.query_id}"
end

def excerpt
return nil unless cooked
@excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true)
end

private

def data_explorer_query
data_explorer_query_group.query
end

def data_explorer_query_group
object.bookmarkable
end
end
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QueryGroupSerializer < ActiveModel::Serializer
attributes :id, :group_id, :query_id, :bookmark

def query_group_bookmark
@query_group_bookmark ||= Bookmark.find_by(user: scope.user, bookmarkable: object)
end

def include_bookmark?
query_group_bookmark.present?
end

def bookmark
{
id: query_group_bookmark.id,
reminder_at: query_group_bookmark.reminder_at,
name: query_group_bookmark.name,
auto_delete_preference: query_group_bookmark.auto_delete_preference,
bookmarkable_id: query_group_bookmark.bookmarkable_id,
bookmarkable_type: query_group_bookmark.bookmarkable_type,
}
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class QuerySerializer < ActiveModel::Serializer
attributes :id, :name, :description, :username, :group_ids, :last_run_at, :user_id

def username
object&.user&.username
end

def group_ids
object.groups.map(&:id)
end
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true

class DiscourseDataExplorer::SmallBadgeSerializer < ApplicationSerializer
attributes :id, :name, :display_name, :badge_type, :description, :icon
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true

module ::DiscourseDataExplorer
class SmallPostWithExcerptSerializer < ApplicationSerializer
attributes :id, :topic_id, :post_number, :excerpt, :username, :avatar_template

def excerpt
Post.excerpt(object.cooked, 70)
end

def username
object.user && object.user.username
end

def avatar_template
object.user && object.user.avatar_template
end
end
end

View file

@ -0,0 +1,3 @@
import buildPluginAdapter from "admin/adapters/build-plugin";

export default buildPluginAdapter("explorer").extend({});

View file

@ -0,0 +1,5 @@
const CodeView = <template>
<pre><code class={{@codeClass}}>{{@value}}</code></pre>
</template>;

export default CodeView;

View file

@ -0,0 +1,101 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { bind } from "discourse/lib/decorators";
import loadScript from "discourse/lib/load-script";
import themeColor from "../lib/themeColor";

export default class DataExplorerBarChart extends Component {
chart;
barsColor = themeColor("--tertiary");
barsHoverColor = themeColor("--tertiary-high");
gridColor = themeColor("--primary-low");
labelsColor = themeColor("--primary-medium");

willDestroy() {
super.willDestroy(...arguments);
this.chart.destroy();
}

get config() {
const data = this.data;
const options = this.options;
return {
type: "bar",
data,
options,
};
}

get data() {
const labels = this.args.labels;
return {
labels,
datasets: [
{
label: this.args.datasetName,
data: this.args.values,
backgroundColor: this.barsColor,
hoverBackgroundColor: this.barsHoverColor,
},
],
};
}

get options() {
return {
scales: {
legend: {
labels: {
fontColor: this.labelsColor,
},
},
xAxes: [
{
gridLines: {
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
fontColor: this.labelsColor,
},
},
],
yAxes: [
{
gridLines: {
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
beginAtZero: true,
fontColor: this.labelsColor,
},
},
],
},
};
}

@bind
async initChart(canvas) {
await loadScript("/javascripts/Chart.min.js");
const context = canvas.getContext("2d");
// eslint-disable-next-line
this.chart = new Chart(context, this.config);
}

@action
updateChartData() {
this.chart.data = this.data;
this.chart.update();
}

<template>
<canvas
{{didInsert this.initChart}}
{{on "change" this.updateChartData}}
></canvas>
</template>
}

View file

@ -0,0 +1,177 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { isBlank, isEmpty } from "@ember/utils";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import { debounce } from "discourse/lib/decorators";
import OneTable from "./explorer-schema/one-table";

export default class ExplorerSchema extends Component {
@tracked filter;
@tracked loading;
@tracked hideSchema = this.args.hideSchema;

get transformedSchema() {
const schema = this.args.schema;
for (const key in schema) {
if (!schema.hasOwnProperty(key)) {
continue;
}

schema[key].forEach((col) => {
const notes_components = [];
if (col.primary) {
notes_components.push("primary key");
}
if (col.is_nullable) {
notes_components.push("null");
}
if (col.column_default) {
notes_components.push("default " + col.column_default);
}
if (col.fkey_info) {
notes_components.push("fkey " + col.fkey_info);
}
if (col.denormal) {
notes_components.push("denormal " + col.denormal);
}
const notes = notes_components.join(", ");

if (notes) {
col.notes = notes;
}

if (col.enum || col.column_desc) {
col.havepopup = true;
}

col.havetypeinfo = !!(col.notes || col.enum || col.column_desc);
});
}
return schema;
}

get filteredTables() {
let tables = [];
let filter = this.filter;

try {
if (!isBlank(this.filter)) {
filter = new RegExp(this.filter);
}
} catch {
filter = null;
}

const haveFilter = !!filter;

for (const key in this.transformedSchema) {
if (!this.transformedSchema.hasOwnProperty(key)) {
continue;
}
if (!haveFilter) {
tables.push({
name: key,
columns: this.transformedSchema[key],
open: false,
});
continue;
}

// Check the table name vs the filter
if (filter.source === key || filter.source + "s" === key) {
tables.unshift({
name: key,
columns: this.transformedSchema[key],
open: haveFilter,
});
} else if (filter.test(key)) {
// whole table matches
tables.push({
name: key,
columns: this.transformedSchema[key],
open: haveFilter,
});
} else {
// filter the columns
let filterCols = [];
this.transformedSchema[key].forEach((col) => {
if (filter.source === col.column_name) {
filterCols.unshift(col);
} else if (filter.test(col.column_name)) {
filterCols.push(col);
}
});
if (!isEmpty(filterCols)) {
tables.push({
name: key,
columns: filterCols,
open: haveFilter,
});
}
}
}
return tables;
}

@debounce(500)
updateFilter(value) {
this.filter = value.toLowerCase();
this.loading = false;
}

@action
filterChanged(value) {
this.loading = true;
this.updateFilter(value);
}

@action
collapseSchema() {
this.hideSchema = true;
this.args.updateHideSchema(true);
}

@action
expandSchema() {
this.hideSchema = false;
this.args.updateHideSchema(false);
}

<template>
{{#if this.hideSchema}}
<DButton
@action={{this.expandSchema}}
@icon="chevron-left"
class="no-text unhide"
/>
{{else}}
<div class="schema">
<div class="schema-search inline-form full-width">
<input
type="text"
{{! template-lint-disable no-action }}
{{on "input" (action "filterChanged" value="target.value")}}
/>
<DButton
@action={{this.collapseSchema}}
@icon="chevron-right"
class="no-text"
/>
</div>

<div class="schema-container">
<ConditionalLoadingSpinner @condition={{this.loading}}>
<ul>
{{#each this.filteredTables as |table|}}
<OneTable @table={{table}} />
{{/each}}
</ul>
</ConditionalLoadingSpinner>
</div>
</div>
{{/if}}
</template>
}

View file

@ -0,0 +1,20 @@
import Component from "@glimmer/component";

export default class EnumInfo extends Component {
get enuminfo() {
return Object.entries(this.args.col.enum).map(([value, name]) => ({
value,
name,
}));
}

<template>
<ol>
{{#each this.enuminfo as |enum|}}
<li value={{enum.value}}>
{{enum.name}}
</li>
{{/each}}
</ol>
</template>
}

View file

@ -0,0 +1,78 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import icon from "discourse/helpers/d-icon";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";
import EnumInfo from "./enum-info";

export default class OneTable extends Component {
@tracked open = this.args.table.open;

get styles() {
return this.open ? "open" : "";
}

@bind
toggleOpen() {
this.open = !this.open;
}

<template>
<li class="schema-table {{this.styles}}">
{{! template-lint-enable no-invalid-interactive }}
<div
class="schema-table-name"
role="button"
{{on "click" this.toggleOpen}}
>
{{#if this.open}}
{{icon "caret-down"}}
{{else}}
{{icon "caret-right"}}
{{/if}}
{{@table.name}}
</div>

<div class="schema-table-cols">
{{#if this.open}}
<dl>
{{#each @table.columns as |col|}}
<div>
<dt
class={{if col.sensitive "sensitive"}}
title={{if col.sensitive (i18n "explorer.schema.sensitive")}}
>
{{#if col.sensitive}}
{{icon "triangle-exclamation"}}
{{/if}}
{{col.column_name}}
</dt>
<dd>
{{col.data_type}}
{{#if col.havetypeinfo}}
<br />
{{#if col.havepopup}}
<div class="popup-info">
{{icon "info"}}
<div class="popup">
{{col.column_desc}}
{{#if col.enum}}
<EnumInfo @col={{col}} />
{{/if}}
</div>
</div>
{{/if}}
<span class="schema-typenotes">
{{col.notes}}
</span>
{{/if}}
</dd>
</div>
{{/each}}
</dl>
{{/if}}
</div>
</li>
</template>
}

View file

@ -0,0 +1,19 @@
import { htmlSafe } from "@ember/template";
import DModal from "discourse/components/d-modal";
import { i18n } from "discourse-i18n";

const QueryHelp = <template>
<DModal
@title={{i18n "explorer.help.modal_title"}}
@closeModal={{@closeModal}}
>
<:body>
{{htmlSafe (i18n "explorer.help.auto_resolution")}}
{{htmlSafe (i18n "explorer.help.custom_params")}}
{{htmlSafe (i18n "explorer.help.default_values")}}
{{htmlSafe (i18n "explorer.help.data_types")}}
</:body>
</DModal>
</template>;

export default QueryHelp;

View file

@ -0,0 +1,453 @@
import Component from "@glimmer/component";
import EmberObject, { action } from "@ember/object";
import { service } from "@ember/service";
import { dasherize } from "@ember/string";
import { isEmpty } from "@ember/utils";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import Form from "discourse/components/form";
import Category from "discourse/models/category";
import { i18n } from "discourse-i18n";
import BooleanThree from "./param-input/boolean-three";
import CategoryIdInput from "./param-input/category-id-input";
import GroupInput from "./param-input/group-input";
import UserIdInput from "./param-input/user-id-input";
import UserListInput from "./param-input/user-list-input";

export class ParamValidationError extends Error {}

const layoutMap = {
int: "int",
bigint: "string",
boolean: "boolean",
string: "string",
time: "time",
date: "date",
datetime: "datetime",
double: "string",
user_id: "user_id",
post_id: "string",
topic_id: "generic",
category_id: "category_id",
group_id: "group_list",
badge_id: "generic",
int_list: "generic",
string_list: "generic",
user_list: "user_list",
group_list: "group_list",
};

export const ERRORS = {
REQUIRED: i18n("form_kit.errors.required"),
NOT_AN_INTEGER: i18n("form_kit.errors.not_an_integer"),
NOT_A_NUMBER: i18n("form_kit.errors.not_a_number"),
OVERFLOW_HIGH: i18n("form_kit.errors.too_high", { count: 2147484647 }),
OVERFLOW_LOW: i18n("form_kit.errors.too_low", { count: -2147484648 }),
INVALID: i18n("explorer.form.errors.invalid"),
NO_SUCH_CATEGORY: i18n("explorer.form.errors.no_such_category"),
NO_SUCH_GROUP: i18n("explorer.form.errors.no_such_group"),
INVALID_DATE: (date) => i18n("explorer.form.errors.invalid_date", { date }),
INVALID_TIME: (time) => i18n("explorer.form.errors.invalid_time", { time }),
};

function digitalizeCategoryId(value) {
value = String(value || "");
const isPositiveInt = /^\d+$/.test(value);
if (!isPositiveInt && value.trim()) {
return Category.asyncFindBySlugPath(dasherize(value))
.then((res) => res.id)
.catch((err) => {
if (err.jqXHR?.status === 404) {
throw new ParamValidationError(
`${ERRORS.NO_SUCH_CATEGORY}: ${value}`
);
} else {
throw new Error(err.errorThrow || err.message);
}
});
}
return value;
}

function serializeValue(type, value) {
switch (type) {
case "string":
case "int":
return value != null ? String(value) : "";
case "boolean":
return String(value);
case "group_list":
case "user_list":
return value?.join(",");
case "group_id":
return value[0];
case "datetime":
return value?.replaceAll("T", " ");
default:
return value?.toString();
}
}

function validationOf(info) {
switch (layoutMap[info.type]) {
case "boolean":
return info.nullable ? "required" : "";
case "string":
case "string_list":
case "generic":
return info.nullable ? "" : "required:trim";
default:
return info.nullable ? "" : "required";
}
}

const components = {
int: <template>
<@field.Input @type="number" name={{@info.identifier}} />
</template>,
boolean: <template><@field.Checkbox name={{@info.identifier}} /></template>,
boolean_three: BooleanThree,
category_id: CategoryIdInput, // TODO
user_id: UserIdInput,
user_list: UserListInput,
group_list: GroupInput,
date: <template>
<@field.Input @type="date" name={{@info.identifier}} />
</template>,
time: <template>
<@field.Input @type="time" name={{@info.identifier}} />
</template>,
datetime: <template>
<@field.Input @type="datetime-local" name={{@info.identifier}} />
</template>,
default: <template><@field.Input name={{@info.identifier}} /></template>,
};

function componentOf(info) {
let type = layoutMap[info.type] || "generic";
if (info.nullable && type === "boolean") {
type = "boolean_three";
}
return components[type] || components.default;
}

export default class ParamInputForm extends Component {
@service site;

data = {};
paramInfo = [];
infoOf = {};
form = null;

promiseNormalizations = [];
formLoaded = new Promise((res) => {
this.__form_load_callback = res;
});

constructor() {
super(...arguments);
this.initializeParams();

this.args.onRegisterApi?.({
submit: this.submit,
allNormalized: Promise.allSettled(this.promiseNormalizations),
});
}

initializeParams() {
this.args.paramInfo.forEach((info) => {
const identifier = info.identifier;
const pinfo = this.createParamInfo(info);

this.paramInfo.push(pinfo);
this.infoOf[identifier] = info;

const normalized = this.getNormalizedValue(info);

if (normalized instanceof Promise) {
this.handlePromiseNormalization(normalized, pinfo);
} else {
this.data[identifier] = normalized;
}
});
}

createParamInfo(info) {
return EmberObject.create({
...info,
validation: validationOf(info),
validate: this.validatorOf(info),
component: componentOf(info),
});
}

@action
async addError(identifier, message) {
await this.formLoaded;
this.form.addError(identifier, {
title: identifier,
message,
});
}

@action
normalizeValue(info, value) {
switch (info.type) {
case "category_id":
return digitalizeCategoryId(value);
case "boolean":
if (value == null || value === "#null") {
return info.nullable ? "#null" : false;
}
return value === "true";
case "group_id":
case "group_list":
const normalized = this.normalizeGroups(value);
if (normalized.errorMsg) {
this.addError(info.identifier, normalized.errorMsg);
}
return info.type === "group_id"
? normalized.value.slice(0, 1)
: normalized.value;
case "user_list":
if (Array.isArray(value)) {
return value || null;
}
return value?.split(",") || null;
case "user_id":
if (Array.isArray(value)) {
return value[0];
}
return value;
case "date":
try {
if (!value) {
return null;
}
return moment(value).format("YYYY-MM-DD");
} catch {
this.addError(info.identifier, ERRORS.INVALID_DATE(String(value)));
return null;
}
case "time":
try {
if (!value) {
return null;
}
return moment(new Date(`1970/01/01 ${value}`).toISOString()).format(
"HH:mm"
);
} catch {
this.addError(info.identifier, ERRORS.INVALID_TIME(String(value)));
return null;
}
case "datetime":
try {
if (!value) {
return null;
}
return moment(new Date(value).toISOString()).format(
"YYYY-MM-DD HH:mm"
);
} catch {
this.addError(info.identifier, ERRORS.INVALID_TIME(String(value)));
return null;
}
default:
return value;
}
}

getNormalizedValue(info) {
const initialValues = this.args.initialValues;
const identifier = info.identifier;
return this.normalizeValue(
info,
initialValues && identifier in initialValues
? initialValues[identifier]
: info.default
);
}

handlePromiseNormalization(promise, pinfo) {
this.promiseNormalizations.push(promise);
pinfo.set("loading", true);
this.data[pinfo.identifier] = null;

promise
.then((res) => this.form.set(pinfo.identifier, res))
.catch((err) => this.addError(pinfo.identifier, err.message))
.finally(() => pinfo.set("loading", false));
}

@action
normalizeGroups(values) {
values ||= [];
if (typeof values === "string") {
values = values.split(",");
}

const GroupNames = new Set(this.site.get("groups").map((g) => g.name));
const GroupNameOf = Object.fromEntries(
this.site.get("groups").map((g) => [g.id, g.name])
);

const valid_groups = [];
const invalid_groups = [];

for (const val of values) {
if (GroupNames.has(val)) {
valid_groups.push(val);
} else if (GroupNameOf[Number(val)]) {
valid_groups.push(GroupNameOf[Number(val)]);
} else {
invalid_groups.push(String(val));
}
}

return {
value: valid_groups,
errorMsg:
invalid_groups.length !== 0
? `${ERRORS.NO_SUCH_GROUP}: ${invalid_groups.join(", ")}`
: null,
};
}

getErrorFn(info) {
const isPositiveInt = (value) => /^\d+$/.test(value);
const VALIDATORS = {
int: (value) => {
if (value >= 2147483648) {
return ERRORS.OVERFLOW_HIGH;
}
if (value <= -2147483649) {
return ERRORS.OVERFLOW_LOW;
}
return null;
},
bigint: (value) => {
if (isNaN(parseInt(value, 10))) {
return ERRORS.NOT_A_NUMBER;
}
return /^-?\d+$/.test(value) ? null : ERRORS.NOT_AN_INTEGER;
},
boolean: (value) => {
return /^Y|N|#null|true|false/.test(String(value))
? null
: ERRORS.INVALID;
},
double: (value) => {
if (isNaN(parseFloat(value))) {
if (/^(-?)Inf(inity)?$/i.test(value) || /^(-?)NaN$/i.test(value)) {
return null;
}
return ERRORS.NOT_A_NUMBER;
}
return null;
},
int_list: (value) => {
return value.split(",").every((i) => /^(-?\d+|null)$/.test(i.trim()))
? null
: ERRORS.INVALID;
},
post_id: (value) => {
return isPositiveInt(value) ||
/\d+\/\d+(\?u=.*)?$/.test(value) ||
/\/t\/[^/]+\/(\d+)(\?u=.*)?/.test(value)
? null
: ERRORS.INVALID;
},
topic_id: (value) => {
return isPositiveInt(value) || /\/t\/[^/]+\/(\d+)/.test(value)
? null
: ERRORS.INVALID;
},
category_id: (value) => {
return this.site.categoriesById.get(Number(value))
? null
: ERRORS.NO_SUCH_CATEGORY;
},
group_list: (value) => {
return this.normalizeGroups(value).errorMsg;
},
group_id: (value) => {
return this.normalizeGroups(value).errorMsg;
},
};
return VALIDATORS[info.type] ?? (() => null);
}

validatorOf(info) {
const getError = this.getErrorFn(info);
return (name, value, { addError }) => {
// skip require validation for we have used them in @validation
if (isEmpty(value) || value == null) {
return;
}
const message = getError(value);
if (message != null) {
addError(name, { title: info.identifier, message });
}
};
}

@action
async submit() {
if (this.form == null) {
throw "No form";
}
this.serializedData = null;
await this.form.submit();
if (this.serializedData == null) {
throw new ParamValidationError("validation_failed");
} else {
return this.serializedData;
}
}

@action
onRegisterApi(form) {
this.__form_load_callback();
this.form = form;
}

@action
onSubmit(data) {
const serializedData = {};
for (const [id, val] of Object.entries(data)) {
serializedData[id] =
serializeValue(this.infoOf[id].type, val) ?? undefined;
}
this.serializedData = serializedData;
}

<template>
<div class="query-params">
<Form
@data={{this.data}}
@onRegisterApi={{this.onRegisterApi}}
@onSubmit={{this.onSubmit}}
class="params-form"
as |form|
>
{{#each this.paramInfo as |info|}}
<div class="param">
<form.Field
@name={{info.identifier}}
@title={{info.identifier}}
@validation={{info.validation}}
@validate={{info.validate}}
as |field|
>
<info.component @field={{field}} @info={{info}} />
<ConditionalLoadingSpinner
@condition={{info.loading}}
@size="small"
/>
</form.Field>
</div>
{{/each}}
</Form>
</div>
</template>
}

View file

@ -0,0 +1,17 @@
import { i18n } from "discourse-i18n";

const BooleanThree = <template>
<@field.Select name={{@info.identifier}} as |select|>
<select.Option @value="Y">
{{i18n "explorer.types.bool.true"}}
</select.Option>
<select.Option @value="N">
{{i18n "explorer.types.bool.false"}}
</select.Option>
<select.Option @value="#null">
{{i18n "explorer.types.bool.null_"}}
</select.Option>
</@field.Select>
</template>;

export default BooleanThree;

View file

@ -0,0 +1,28 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import CategoryChooser from "select-kit/components/category-chooser";

export default class CategoryIdInput extends Component {
// CategoryChooser will try to modify the value of value,
// triggering a setting-on-hash error. So we have to do the dirty work.
get data() {
return {
value: this.args.field.value,
};
}

<template>
<@field.Custom id={{@field.id}}>
<CategoryChooser
@value={{this.data.value}}
@onChange={{@field.set}}
@options={{hash
allowUncategorized=null
autoInsertNoneItem=true
none=true
}}
name={{@info.identifier}}
/>
</@field.Custom>
</template>
}

View file

@ -0,0 +1,33 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import GroupChooser from "select-kit/components/group-chooser";

export default class GroupInput extends Component {
@service site;

get allGroups() {
return this.site.get("groups");
}

get groupChooserOption() {
return this.args.info.type === "group_id"
? {
maximum: 1,
}
: {};
}

<template>
<@field.Custom id={{@field.id}}>
<GroupChooser
@content={{this.allGroups}}
@value={{@field.value}}
@labelProperty="name"
@valueProperty="name"
@onChange={{@field.set}}
@options={{this.groupChooserOption}}
name={{@info.identifier}}
/>
</@field.Custom>
</template>
}

View file

@ -0,0 +1,15 @@
import { hash } from "@ember/helper";
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";

const UserIdInput = <template>
<@field.Custom id={{@field.id}}>
<EmailGroupUserChooser
@value={{@field.value}}
@options={{hash maximum=1}}
@onChange={{@field.set}}
name={{@info.identifier}}
/>
</@field.Custom>
</template>;

export default UserIdInput;

View file

@ -0,0 +1,13 @@
import EmailGroupUserChooser from "select-kit/components/email-group-user-chooser";

const UserListInput = <template>
<@field.Custom id={{@field.id}}>
<EmailGroupUserChooser
@value={{@field.value}}
@onChange={{@field.set}}
name={{@info.identifier}}
/>
</@field.Custom>
</template>;

export default UserListInput;

View file

@ -0,0 +1,419 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { service } from "@ember/service";
import { capitalize } from "@ember/string";
import DButton from "discourse/components/d-button";
import { ajax } from "discourse/lib/ajax";
import getURL from "discourse/lib/get-url";
import Badge from "discourse/models/badge";
import Category from "discourse/models/category";
import I18n, { i18n } from "discourse-i18n";
import DataExplorerBarChart from "./data-explorer-bar-chart";
import QueryRowContent from "./query-row-content";
import BadgeViewComponent from "./result-types/badge";
import CategoryViewComponent from "./result-types/category";
import GroupViewComponent from "./result-types/group";
import HtmlViewComponent from "./result-types/html";
import JsonViewComponent from "./result-types/json";
import PostViewComponent from "./result-types/post";
import ReltimeViewComponent from "./result-types/reltime";
import TagGroupViewComponent from "./result-types/tag-group";
import TextViewComponent from "./result-types/text";
import TopicViewComponent from "./result-types/topic";
import UrlViewComponent from "./result-types/url";
import UserViewComponent from "./result-types/user";

const VIEW_COMPONENTS = {
topic: TopicViewComponent,
text: TextViewComponent,
post: PostViewComponent,
reltime: ReltimeViewComponent,
badge: BadgeViewComponent,
url: UrlViewComponent,
user: UserViewComponent,
group: GroupViewComponent,
html: HtmlViewComponent,
json: JsonViewComponent,
category: CategoryViewComponent,
tag_group: TagGroupViewComponent,
};

export default class QueryResult extends Component {
@service site;

@tracked chartDisplayed = false;

get colRender() {
return this.args.content.colrender || {};
}

get rows() {
return this.args.content.rows;
}

get columns() {
return this.args.content.columns;
}

get params() {
return this.args.content.params;
}

get explainText() {
return this.args.content.explain;
}

get chartDatasetName() {
return this.columnNames[1];
}

get columnNames() {
if (!this.columns) {
return [];
}
return this.columns.map((colName) => {
if (colName.endsWith("_id")) {
return colName.slice(0, -3);
}
const dIdx = colName.indexOf("$");
if (dIdx >= 0) {
return colName.substring(dIdx + 1);
}
return colName;
});
}

get columnComponents() {
if (!this.columns) {
return [];
}
return this.columns.map((_, idx) => {
let type = "text";
if (this.colRender[idx]) {
type = this.colRender[idx];
}
return { name: type, component: VIEW_COMPONENTS[type] };
});
}

get chartValues() {
// return an array with the second value of this.row
return this.rows.mapBy(1);
}

get colCount() {
return this.columns.length;
}

get resultCount() {
const count = this.args.content.result_count;
if (count === this.args.content.default_limit) {
return i18n("explorer.max_result_count", { count });
} else {
return i18n("explorer.result_count", { count });
}
}

get duration() {
return i18n("explorer.run_time", {
value: I18n.toNumber(this.args.content.duration, { precision: 1 }),
});
}

get parameterAry() {
let arr = [];
for (let key in this.params) {
if (this.params.hasOwnProperty(key)) {
arr.push({ key, value: this.params[key] });
}
}
return arr;
}

get transformedUserTable() {
return transformedRelTable(this.args.content.relations.user);
}

get transformedBadgeTable() {
return transformedRelTable(this.args.content.relations.badge, Badge);
}

get transformedPostTable() {
return transformedRelTable(this.args.content.relations.post);
}

get transformedTopicTable() {
return transformedRelTable(this.args.content.relations.topic);
}

get transformedTagGroupTable() {
return transformedRelTable(this.args.content.relations.tag_group);
}

get transformedGroupTable() {
return transformedRelTable(this.site.groups);
}

get canShowChart() {
const hasTwoColumns = this.colCount === 2;
const secondColumnContainsNumber =
this.resultCount[0] > 0 && typeof this.rows[0][1] === "number";
const secondColumnContainsId = this.colRender[1];

return (
hasTwoColumns && secondColumnContainsNumber && !secondColumnContainsId
);
}

get chartLabels() {
const labelSelectors = {
user: (user) => user.username,
badge: (badge) => badge.name,
topic: (topic) => topic.title,
group: (group) => group.name,
category: (category) => category.name,
};

const relationName = this.colRender[0];
if (relationName) {
const lookupFunc = this[`lookup${capitalize(relationName)}`];
const labelSelector = labelSelectors[relationName];

if (lookupFunc && labelSelector) {
return this.rows.map((r) => {
const relation = lookupFunc.call(this, r[0]);
const label = labelSelector(relation);
return this._cutChartLabel(label);
});
}
}

return this.rows.map((r) => this._cutChartLabel(r[0]));
}

lookupUser(id) {
return this.transformedUserTable[id];
}

lookupBadge(id) {
return this.transformedBadgeTable[id];
}

lookupPost(id) {
return this.transformedPostTable[id];
}

lookupTopic(id) {
return this.transformedTopicTable[id];
}

lookupTagGroup(id) {
return this.transformedTagGroupTable[id];
}

lookupGroup(id) {
return this.transformedGroupTable[id];
}

lookupCategory(id) {
return Category.findById(id);
}

_cutChartLabel(label) {
const labelString = label.toString();
if (labelString.length > 25) {
return `${labelString.substring(0, 25)}...`;
} else {
return labelString;
}
}

@action
downloadResultJson() {
this._downloadResult("json");
}

@action
downloadResultCsv() {
this._downloadResult("csv");
}

@action
showChart() {
this.chartDisplayed = true;
}

@action
hideChart() {
this.chartDisplayed = false;
}

_download_url() {
return this.args.group
? `/g/${this.args.group.name}/reports/`
: "/admin/plugins/explorer/queries/";
}

_downloadResult(format) {
// Create a frame to submit the form in (?)
// to avoid leaving an about:blank behind
let windowName = randomIdShort();
const newWindowContents =
"<style>body{font-size:36px;display:flex;justify-content:center;align-items:center;}</style><body>Click anywhere to close this window once the download finishes.<script>window.onclick=function(){window.close()};</script>";

window.open("data:text/html;base64," + btoa(newWindowContents), windowName);

let form = document.createElement("form");
form.setAttribute("id", "query-download-result");
form.setAttribute("method", "post");
form.setAttribute(
"action",
getURL(
this._download_url() +
this.args.query.id +
"/run." +
format +
"?download=1"
)
);
form.setAttribute("target", windowName);
form.setAttribute("style", "display:none;");

function addInput(name, value) {
let field;
field = document.createElement("input");
field.setAttribute("name", name);
field.setAttribute("value", value);
form.appendChild(field);
}

addInput("params", JSON.stringify(this.params));
addInput("explain", this.explainText);
addInput("limit", "1000000");

ajax("/session/csrf.json").then((csrf) => {
addInput("authenticity_token", csrf.csrf);

document.body.appendChild(form);
form.submit();
schedule("afterRender", () => document.body.removeChild(form));
});
}

<template>
<article>
<header class="result-header">
<div class="result-info">
<DButton
@action={{this.downloadResultJson}}
@icon="download"
@label="explorer.download_json"
/>

<DButton
@action={{this.downloadResultCsv}}
@icon="download"
@label="explorer.download_csv"
/>

{{#if this.canShowChart}}
{{#if this.chartDisplayed}}
<DButton
@action={{this.hideChart}}
@icon="table"
@label="explorer.show_table"
/>
{{else}}
<DButton
@action={{this.showChart}}
@icon="chart-bar"
@label="explorer.show_graph"
/>
{{/if}}
{{/if}}
</div>

<div class="result-about">
{{this.resultCount}}
{{this.duration}}
</div>

<br />

{{~#if this.explainText}}
<pre class="result-explain">
<code>
{{~this.explainText}}
</code>
</pre>
{{~/if}}

<br />
</header>

<section>
{{#if this.chartDisplayed}}
<DataExplorerBarChart
@labels={{this.chartLabels}}
@values={{this.chartValues}}
@datasetName={{this.chartDatasetName}}
/>
{{else}}
<table>
<thead>
<tr class="headers">
{{#each this.columnNames as |col|}}
<th>{{col}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.rows as |row|}}
<QueryRowContent
@row={{row}}
@columnComponents={{this.columnComponents}}
@lookupUser={{this.lookupUser}}
@lookupBadge={{this.lookupBadge}}
@lookupPost={{this.lookupPost}}
@lookupTopic={{this.lookupTopic}}
@lookupTagGroup={{this.lookupTagGroup}}
@lookupGroup={{this.lookupGroup}}
@lookupCategory={{this.lookupCategory}}
@transformedPostTable={{this.transformedPostTable}}
@transformedBadgeTable={{this.transformedBadgeTable}}
@transformedUserTable={{this.transformedUserTable}}
@transformedTagGroupTable={{this.transformedTagGroupTable}}
@transformedGroupTable={{this.transformedGroupTable}}
@transformedTopicTable={{this.transformedTopicTable}}
@site={{this.site}}
/>
{{/each}}
</tbody>
</table>
{{/if}}
</section>
</article>
</template>
}

function randomIdShort() {
return "xxxxxxxx".replace(/[xy]/g, () => {
/*eslint-disable*/
return ((Math.random() * 16) | 0).toString(16);
/*eslint-enable*/
});
}

function transformedRelTable(table, modelClass) {
const result = {};
table?.forEach((item) => {
if (modelClass) {
result[item.id] = modelClass.create(item);
} else {
result[item.id] = item;
}
});
return result;
}

View file

@ -0,0 +1,17 @@
import QueryResult from "./query-result";

const QueryResultsWrapper = <template>
{{#if @results}}
<div class="query-results">
{{#if @showResults}}
<QueryResult @query={{@query}} @content={{@results}} />
{{else}}
{{#each @results.errors as |err|}}
<pre class="query-error"><code>{{~err}}</code></pre>
{{/each}}
{{/if}}
</div>
{{/if}}
</template>;

export default QueryResultsWrapper;

View file

@ -0,0 +1,82 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { classify } from "@ember/string";
import getURL from "discourse/lib/get-url";
import { escapeExpression } from "discourse/lib/utilities";
import TextViewComponent from "./result-types/text";

export default class QueryRowContent extends Component {
@cached
get results() {
return this.args.columnComponents.map((componentDefinition, idx) => {
const value = this.args.row[idx],
id = parseInt(value, 10);

const ctx = {
value,
id,
baseuri: getURL(""),
};

if (this.args.row[idx] === null) {
return {
component: TextViewComponent,
textValue: "NULL",
};
} else if (componentDefinition.name === "text") {
return {
component: TextViewComponent,
textValue: escapeExpression(this.args.row[idx].toString()),
};
}

const lookupFunc =
this.args[`lookup${classify(componentDefinition.name)}`];
if (lookupFunc) {
ctx[componentDefinition.name] = lookupFunc.call(this.args, id);
}

if (componentDefinition.name === "url") {
let [url, name] = guessUrl(value);
ctx["href"] = url;
ctx["target"] = name;
}

try {
return {
component: componentDefinition.component || TextViewComponent,
ctx,
};
} catch {
return "error";
}
});
}

<template>
<tr>
{{#each this.results as |result|}}
<td>
<result.component
@ctx={{result.ctx}}
@params={{result.params}}
@textValue={{result.textValue}}
/>
</td>
{{/each}}
</tr>
</template>
}

function guessUrl(columnValue) {
let [dest, name] = [columnValue, columnValue];

const split = columnValue.split(/,(.+)/);

if (split.length > 1) {
name = split[0];
dest = split[1];
}

return [dest, name];
}

View file

@ -0,0 +1,31 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import { isEmpty } from "@ember/utils";
import { convertIconClass, iconHTML } from "discourse/lib/icon-library";

export default class Badge extends Component {
get iconOrImageReplacement() {
if (isEmpty(this.args.ctx.badge.icon)) {
return "";
}

if (this.args.ctx.badge.icon.indexOf("fa-") > -1) {
const icon = iconHTML(convertIconClass(this.args.ctx.badge.icon));
return htmlSafe(icon);
} else {
return htmlSafe("<img src='" + this.args.ctx.badge.icon + "'>");
}
}

<template>
<a
href="{{@ctx.baseuri}}/badges/{{@ctx.badge.id}}/{{@ctx.badge.name}}"
class="user-badge {{@ctx.badge.badgeTypeClassName}}"
title={{@ctx.badge.display_name}}
data-badge-name={{@ctx.badge.name}}
>
{{this.iconOrImageReplacement}}
<span class="badge-display-name">{{@ctx.badge.display_name}}</span>
</a>
</template>
}

View file

@ -0,0 +1,18 @@
import Component from "@glimmer/component";
import { categoryLinkHTML } from "discourse/helpers/category-link";

export default class Category extends Component {
get categoryBadgeReplacement() {
return categoryLinkHTML(this.args.ctx.category, {
allowUncategorized: true,
});
}

<template>
{{#if @ctx.category}}
{{this.categoryBadgeReplacement}}
{{else}}
<a href="{{@ctx.baseuri}}/t/{{@ctx.id}}">{{@ctx.id}}</a>
{{/if}}
</template>
}

View file

@ -0,0 +1,11 @@
const Group = <template>
{{#if @ctx.group}}
<a
href="{{@ctx.baseuri}}/groups/{{@ctx.group.name}}"
>{{@ctx.group.name}}</a>
{{else}}
{{@ctx.id}}
{{/if}}
</template>;

export default Group;

View file

@ -0,0 +1,5 @@
import htmlSafe from "discourse/helpers/html-safe";

const Html = <template>{{htmlSafe @ctx.value}}</template>;

export default Html;

View file

@ -0,0 +1,45 @@
import Component from "@glimmer/component";
import { cached } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import FullscreenCodeModal from "discourse/components/modal/fullscreen-code";

export default class Json extends Component {
@service dialog;
@service modal;

@cached
get parsedJson() {
try {
return JSON.parse(this.args.ctx.value);
} catch {
return null;
}
}

@action
viewJson() {
this.modal.show(FullscreenCodeModal, {
model: {
code: this.parsedJson
? JSON.stringify(this.parsedJson, null, 2)
: this.args.ctx.value,
codeClasses: "",
},
});
}

<template>
<div class="result-json">
<div class="result-json-value">{{@ctx.value}}</div>
<DButton
class="result-json-button"
{{! template-lint-disable no-action }}
@action={{action "viewJson"}}
@icon="ellipsis"
@title="explorer.view_json"
/>
</div>
</template>
}

View file

@ -0,0 +1,41 @@
import avatar from "discourse/helpers/avatar";
import htmlSafe from "discourse/helpers/html-safe";

const Post = <template>
{{#if @ctx.post}}
<aside
class="quote"
data-post={{@ctx.post.post_number}}
data-topic={{@ctx.post.topic_id}}
>
<div class="title">
<div class="quote-controls">
{{! template-lint-disable no-invalid-link-text }}
<a
href="/t/via-quote/{{@ctx.post.topic_id}}/{{@ctx.post.post_number}}"
title="go to the quoted post"
class="quote-other-topic"
>
</a>
</div>

<a
class="result-post-link"
href="/t/{{@ctx.post.topic_id}}/{{@ctx.post.post_number}}"
>
{{avatar @ctx.post imageSize="tiny"}}{{@ctx.post.username}}:
</a>
</div>

<blockquote>
<p>
{{htmlSafe @ctx.post.excerpt}}
</p>
</blockquote>
</aside>
{{else}}
{{@ctx.id}}
{{/if}}
</template>;

export default Post;

View file

@ -0,0 +1,13 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import { autoUpdatingRelativeAge } from "discourse/lib/formatter";

export default class Reltime extends Component {
get boundDateReplacement() {
return htmlSafe(
autoUpdatingRelativeAge(new Date(this.args.ctx.value), { title: true })
);
}

<template>{{this.boundDateReplacement}}</template>
}

View file

@ -0,0 +1,11 @@
const Group = <template>
{{#if @ctx.tag_group}}
<a
href="{{@ctx.baseuri}}/tag_groups/{{@ctx.id}}"
>{{@ctx.tag_group.name}}</a>
{{else}}
{{@ctx.id}}
{{/if}}
</template>;

export default Group;

View file

@ -0,0 +1,3 @@
const Text = <template>{{@textValue}}</template>;

export default Text;

View file

@ -0,0 +1,14 @@
import htmlSafe from "discourse/helpers/html-safe";

const Topic = <template>
{{#if @ctx.topic}}
<a href="{{@ctx.baseuri}}/t/{{@ctx.topic.slug}}/{{@ctx.topic.id}}">
{{htmlSafe @ctx.topic.fancy_title}}
</a>
({{@ctx.topic.posts_count}})
{{else}}
<a href="{{@ctx.baseuri}}/t/{{@ctx.id}}">{{@ctx.id}}</a>
{{/if}}
</template>;

export default Topic;

View file

@ -0,0 +1,5 @@
const Url = <template>
<a href={{@ctx.href}}>{{@ctx.target}}</a>
</template>;

export default Url;

View file

@ -0,0 +1,17 @@
import avatar from "discourse/helpers/avatar";

const User = <template>
{{#if @ctx.user}}
<a
href="{{@ctx.baseuri}}/u/{{@ctx.user.username}}/activity"
data-user-card={{@ctx.user.username}}
>
{{avatar @ctx.user imageSize="tiny"}}
{{@ctx.user.username}}
</a>
{{else}}
{{@ctx.id}}
{{/if}}
</template>;

export default User;

View file

@ -0,0 +1,101 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { bind } from "discourse/lib/decorators";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";

export default class ShareReport extends Component {
@tracked visible = false;
element;

get link() {
return getURL(`/g/${this.args.group}/reports/${this.args.query.id}`);
}

@bind
mouseDownHandler(e) {
if (!this.element.contains(e.target)) {
this.close();
}
}

@bind
keyDownHandler(e) {
if (e.keyCode === 27) {
this.close();
}
}

@action
registerListeners(element) {
if (!element || this.isDestroying || this.isDestroyed) {
return;
}

this.element = element;
document.addEventListener("mousedown", this.mouseDownHandler);
element.addEventListener("keydown", this.keyDownHandler);
}

@action
unregisterListeners(element) {
this.element = element;
document.removeEventListener("mousedown", this.mouseDownHandler);
element.removeEventListener("keydown", this.keyDownHandler);
}

@action
focusInput(e) {
e.select();
e.focus();
}

@action
open(e) {
e.preventDefault();
this.visible = true;
}

@action
close() {
this.visible = false;
}

<template>
<div class="share-report">
<a href="#" {{on "click" this.open}} class="share-report-button">
{{icon "link"}}
{{@group}}
</a>

{{#if this.visible}}
<div
class="popup"
{{didInsert this.registerListeners}}
{{willDestroy this.unregisterListeners}}
>
<label>{{i18n "explorer.link"}} {{@group}}</label>
<input
type="text"
value={{this.link}}
{{didInsert this.focusInput}}
/>

<DButton
@action={{this.close}}
@icon="xmark"
@aria-label="share.close"
@title="share.close"
class="btn-flat close"
/>
</div>
{{/if}}
</div>
</template>
}

View file

@ -0,0 +1,19 @@
import Component from "@ember/component";
import { LinkTo } from "@ember/routing";
import { classNames, tagName } from "@ember-decorators/component";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";

@tagName("li")
@classNames("group-reports-nav-item-outlet", "nav-item")
export default class NavItem extends Component {
static shouldRender(args) {
return args.group.has_visible_data_explorer_queries;
}

<template>
<LinkTo @route="group.reports">
{{icon "chart-bar"}}{{i18n "group.reports"}}
</LinkTo>
</template>
}

View file

@ -0,0 +1,187 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { i18n } from "discourse-i18n";

export default class PluginsExplorerController extends Controller {
@service dialog;
@service appEvents;
@service router;

@tracked sortByProperty = "last_run_at";
@tracked sortDescending = true;
@tracked params;
@tracked search;
@tracked newQueryName;
@tracked showCreate;
@tracked loading = false;

queryParams = ["id"];
explain = false;
acceptedImportFileTypes = ["application/json"];
order = null;
form = null;

get sortedQueries() {
const sortedQueries = this.model.sortBy(this.sortByProperty);
return this.sortDescending ? sortedQueries.reverse() : sortedQueries;
}

get parsedParams() {
return this.params ? JSON.parse(this.params) : null;
}

get filteredContent() {
const regexp = new RegExp(this.search, "i");
return this.sortedQueries.filter(
(result) => regexp.test(result.name) || regexp.test(result.description)
);
}

get createDisabled() {
return (this.newQueryName || "").trim().length === 0;
}

addCreatedRecord(record) {
this.model.pushObject(record);
this.router.transitionTo(
"adminPlugins.explorer.queries.details",
record.id
);
}

async _importQuery(file) {
const json = await this._readFileAsTextAsync(file);
const query = this._parseQuery(json);
const record = this.store.createRecord("query", query);
const response = await record.save();
return response.target;
}

_parseQuery(json) {
const parsed = JSON.parse(json);
const query = parsed.query;
if (!query || !query.sql) {
throw new TypeError();
}
query.id = 0; // 0 means no Id yet
return query;
}

_readFileAsTextAsync(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;

reader.readAsText(file);
});
}

@bind
dragMove(e) {
if (!e.movementY && !e.movementX) {
return;
}

const editPane = document.querySelector(".query-editor");
const target = editPane.querySelector(".panels-flex");
const grippie = editPane.querySelector(".grippie");

// we need to get the initial height / width of edit pane
// before we manipulate the size
if (!this.initialPaneWidth && !this.originalPaneHeight) {
this.originalPaneWidth = target.clientWidth;
this.originalPaneHeight = target.clientHeight;
}

const newHeight = Math.max(
this.originalPaneHeight,
target.clientHeight + e.movementY
);
const newWidth = Math.max(
this.originalPaneWidth,
target.clientWidth + e.movementX
);

target.style.height = newHeight + "px";
target.style.width = newWidth + "px";
grippie.style.width = newWidth + "px";
this.appEvents.trigger("ace:resize");
}

@bind
scrollTop() {
window.scrollTo(0, 0);
}

@action
async import(files) {
try {
this.loading = true;
const file = files[0];
const record = await this._importQuery(file);
this.addCreatedRecord(record);
} catch (e) {
if (e.jqXHR) {
popupAjaxError(e);
} else if (e instanceof SyntaxError) {
this.dialog.alert(i18n("explorer.import.unparseable_json"));
} else if (e instanceof TypeError) {
this.dialog.alert(i18n("explorer.import.wrong_json"));
} else {
this.dialog.alert(i18n("errors.desc.unknown"));
// eslint-disable-next-line no-console
console.error(e);
}
} finally {
this.loading = false;
}
}

@action
displayCreate() {
this.showCreate = true;
}

@action
updateSortProperty(property) {
if (this.sortByProperty === property) {
this.sortDescending = !this.sortDescending;
} else {
this.sortByProperty = property;
this.sortDescending = true;
}
}

@action
async create() {
try {
const name = this.newQueryName.trim();
this.loading = true;
this.showCreate = false;
const result = await this.store.createRecord("query", { name }).save();
this.addCreatedRecord(result.target);
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}

@action
updateSearch(value) {
this.search = value;
}

@action
updateNewQueryName(value) {
this.newQueryName = value;
}
}

View file

@ -0,0 +1,292 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { Promise } from "rsvp";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import QueryHelp from "discourse/plugins/discourse-data-explorer/discourse/components/modal/query-help";
import { ParamValidationError } from "discourse/plugins/discourse-data-explorer/discourse/components/param-input-form";
import Query from "discourse/plugins/discourse-data-explorer/discourse/models/query";

export default class PluginsExplorerController extends Controller {
@service modal;
@service appEvents;
@service router;

@tracked params;
@tracked editingName = false;
@tracked editingQuery = false;
@tracked loading = false;
@tracked showResults = false;
@tracked hideSchema = false;
@tracked results = this.model.results;
@tracked dirty = false;

queryParams = ["params"];
explain = false;
order = null;
form = null;

get saveDisabled() {
return !this.dirty;
}

get runDisabled() {
return this.dirty;
}

get parsedParams() {
return this.params ? JSON.parse(this.params) : null;
}

get editDisabled() {
return parseInt(this.model.id, 10) < 0 ? true : false;
}

get groupOptions() {
return this.groups
.filter((g) => g.id !== 0)
.map((g) => {
return { id: g.id, name: g.name };
});
}

@action
async save() {
try {
this.loading = true;
await this.model.save();

this.dirty = false;
this.editingName = false;
} catch (error) {
popupAjaxError(error);
throw error;
} finally {
this.loading = false;
}
}

@action
saveAndRun() {
this.save().then(() => this.run());
}

async _importQuery(file) {
const json = await this._readFileAsTextAsync(file);
const query = this._parseQuery(json);
const record = this.store.createRecord("query", query);
const response = await record.save();
return response.target;
}

_parseQuery(json) {
const parsed = JSON.parse(json);
const query = parsed.query;
if (!query || !query.sql) {
throw new TypeError();
}
query.id = 0; // 0 means no Id yet
return query;
}

_readFileAsTextAsync(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;

reader.readAsText(file);
});
}

@bind
dragMove(e) {
if (!e.movementY && !e.movementX) {
return;
}

const editPane = document.querySelector(".query-editor");
const target = editPane.querySelector(".panels-flex");
const grippie = editPane.querySelector(".grippie");

// we need to get the initial height / width of edit pane
// before we manipulate the size
if (!this.initialPaneWidth && !this.originalPaneHeight) {
this.originalPaneWidth = target.clientWidth;
this.originalPaneHeight = target.clientHeight;
}

const newHeight = Math.max(
this.originalPaneHeight,
target.clientHeight + e.movementY
);
const newWidth = Math.max(
this.originalPaneWidth,
target.clientWidth + e.movementX
);

target.style.height = newHeight + "px";
target.style.width = newWidth + "px";
grippie.style.width = newWidth + "px";
this.appEvents.trigger("ace:resize");
}

@bind
didStartDrag() {}

@bind
didEndDrag() {}

@action
updateGroupIds(value) {
this.dirty = true;
this.model.set("group_ids", value);
}

@action
updateHideSchema(value) {
this.hideSchema = value;
}

@action
editName() {
this.editingName = true;
}

@action
editQuery() {
this.editingQuery = true;
}

@action
download() {
window.open(this.model.downloadUrl, "_blank");
}

@action
goHome() {
this.router.transitionTo("adminPlugins.explorer");
}

@action
showHelpModal() {
this.modal.show(QueryHelp);
}

@action
resetParams() {
this.model.resetParams();
}

@action
async discard() {
try {
this.loading = true;
const result = await this.store.find("query", this.model.id);
this.model.setProperties(result.getProperties(Query.updatePropertyNames));
if (!this.model.group_ids || !Array.isArray(this.model.group_ids)) {
this.model.set("group_ids", []);
}
this.dirty = false;
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}

@action
async destroyQuery() {
try {
this.loading = true;
this.showResults = false;
await this.store.destroyRecord("query", this.model);
this.model.set("destroyed", true);
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}

@action
async recover() {
try {
this.loading = true;
this.showResults = true;
await this.model.save();
this.model.set("destroyed", false);
} catch (error) {
popupAjaxError(error);
} finally {
this.loading = false;
}
}

@action
onRegisterApi(form) {
this.form = form;
}

@action
setDirty() {
this.dirty = true;
}

@action
exitEdit() {
this.editingName = false;
}

@action
async run() {
let params = null;
if (this.model.hasParams) {
try {
params = await this.form?.submit();
} catch (err) {
if (err instanceof ParamValidationError) {
return;
}
}
if (params == null) {
return;
}
}
this.setProperties({
loading: true,
showResults: false,
params: JSON.stringify(params),
});

ajax("/admin/plugins/explorer/queries/" + this.model.id + "/run", {
type: "POST",
data: {
params: JSON.stringify(params),
explain: this.explain,
},
})
.then((result) => {
this.results = result;
if (!result.success) {
this.showResults = false;
return;
}
this.showResults = true;
})
.catch((err) => {
this.showResults = false;
if (err.jqXHR && err.jqXHR.status === 422 && err.jqXHR.responseJSON) {
this.results = err.jqXHR.responseJSON;
} else {
popupAjaxError(err);
}
})
.finally(() => (this.loading = false));
}
}

View file

@ -0,0 +1,3 @@
import Controller from "@ember/controller";

export default class GroupReportsIndexController extends Controller {}

View file

@ -0,0 +1,137 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import BookmarkModal from "discourse/components/modal/bookmark";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { BookmarkFormData } from "discourse/lib/bookmark-form-data";
import { bind } from "discourse/lib/decorators";
import {
NO_REMINDER_ICON,
WITH_REMINDER_ICON,
} from "discourse/models/bookmark";
import { ParamValidationError } from "discourse/plugins/discourse-data-explorer/discourse/components/param-input-form";

export default class GroupReportsShowController extends Controller {
@service currentUser;
@service modal;
@service router;

@tracked showResults = false;
@tracked loading = false;
@tracked results = this.model.results;
@tracked queryGroupBookmark = this.queryGroup?.bookmark;

queryParams = ["params"];
form = null;
explain = false;

get parsedParams() {
return this.params ? JSON.parse(this.params) : null;
}

get hasParams() {
return this.model.param_info.length > 0;
}

get bookmarkLabel() {
return this.queryGroupBookmark
? "bookmarked.edit_bookmark"
: "bookmarked.title";
}

get bookmarkIcon() {
if (this.queryGroupBookmark && this.queryGroupBookmark.reminder_at) {
return WITH_REMINDER_ICON;
}
return NO_REMINDER_ICON;
}

get bookmarkClassName() {
return this.queryGroupBookmark
? ["query-group-bookmark", "bookmarked"].join(" ")
: "query-group-bookmark";
}

@bind
async run() {
try {
let params = null;
if (this.hasParams) {
params = await this.form.submit();
if (params == null) {
return;
}
}
this.loading = true;
this.showResults = false;
const stringifiedParams = JSON.stringify(params);
this.router.transitionTo({
queryParams: {
params: params ? stringifiedParams : null,
},
});
const response = await ajax(
`/g/${this.get("group.name")}/reports/${this.model.id}/run`,
{
type: "POST",
data: {
params: stringifiedParams,
explain: this.explain,
},
}
);

this.results = response;
if (!response.success) {
return;
}
this.showResults = true;
} catch (error) {
if (error.jqXHR?.status === 422 && error.jqXHR.responseJSON) {
this.results = error.jqXHR.responseJSON;
} else if (!(error instanceof ParamValidationError)) {
popupAjaxError(error);
}
} finally {
this.loading = false;
}
}

@action
toggleBookmark() {
const modalBookmark =
this.queryGroupBookmark ||
this.store.createRecord("bookmark", {
bookmarkable_type: "DiscourseDataExplorer::QueryGroup",
bookmarkable_id: this.queryGroup.id,
user_id: this.currentUser.id,
});
return this.modal.show(BookmarkModal, {
model: {
bookmark: new BookmarkFormData(modalBookmark),
afterSave: (bookmarkFormData) => {
const bookmark = this.store.createRecord(
"bookmark",
bookmarkFormData.saveData
);
this.queryGroupBookmark = bookmark;
this.appEvents.trigger(
"bookmarks:changed",
bookmarkFormData.saveData,
bookmark.attachedTo()
);
},
afterDelete: () => {
this.queryGroupBookmark = null;
},
},
});
}

@action
onRegisterApi(form) {
this.form = form;
}
}

View file

@ -0,0 +1,11 @@
export default {
resource: "admin.adminPlugins",
path: "/plugins",
map() {
this.route("explorer", function () {
this.route("queries", function () {
this.route("details", { path: "/:query_id" });
});
});
},
};

View file

@ -0,0 +1,9 @@
export default {
resource: "group",

map() {
this.route("reports", function () {
this.route("show", { path: "/:query_id" });
});
},
};

View file

@ -0,0 +1,6 @@
export default {
name: "initialize-data-explorer",
initialize(container) {
container.lookup("service:store").addPluralization("query", "queries");
},
};

View file

@ -0,0 +1,4 @@
export default function themeColor(name) {
const style = getComputedStyle(document.body);
return style.getPropertyValue(name);
}

View file

@ -0,0 +1,80 @@
import { computed } from "@ember/object";
import getURL from "discourse/lib/get-url";
import RestModel from "discourse/models/rest";

export default class Query extends RestModel {
static updatePropertyNames = [
"name",
"description",
"sql",
"user_id",
"created_at",
"group_ids",
"last_run_at",
];

params = {};

constructor() {
super(...arguments);
this.param_info?.resetParams();
}

get downloadUrl() {
return getURL(`/admin/plugins/explorer/queries/${this.id}.json?export=1`);
}

@computed("param_info", "updating")
get hasParams() {
// When saving, we need to refresh the param-input component to clean up the old key
return this.param_info.length && !this.updating;
}

beforeUpdate() {
this.set("updating", true);
}

afterUpdate() {
this.set("updating", false);
}

resetParams() {
const newParams = {};
const oldParams = this.params;
this.param_info.forEach((pinfo) => {
const name = pinfo.identifier;
if (oldParams[pinfo.identifier]) {
newParams[name] = oldParams[name];
} else if (pinfo["default"] !== null) {
newParams[name] = pinfo["default"];
} else if (pinfo["type"] === "boolean") {
newParams[name] = "false";
} else if (pinfo["type"] === "user_id") {
newParams[name] = null;
} else if (pinfo["type"] === "user_list") {
newParams[name] = null;
} else if (pinfo["type"] === "group_list") {
newParams[name] = null;
} else {
newParams[name] = "";
}
});
this.params = newParams;
}

updateProperties() {
const props = this.getProperties(Query.updatePropertyNames);
if (this.destroyed) {
props.id = this.id;
}
return props;
}

createProperties() {
if (this.sql) {
// Importing
return this.updateProperties();
}
return this.getProperties("name");
}
}

View file

@ -0,0 +1,47 @@
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";

export default class AdminPluginsExplorerIndex extends DiscourseRoute {
@service router;

beforeModel(transition) {
// Redirect old /explorer?id=123 route to /explorer/queries/123
if (transition.to.queryParams.id) {
this.router.transitionTo(
"adminPlugins.explorer.queries.details",
transition.to.queryParams.id
);
}
}

model() {
if (!this.currentUser.admin) {
// display "Only available to admins" message
return { model: null, schema: null, disallow: true, groups: null };
}

const groupPromise = ajax("/admin/plugins/explorer/groups.json");
const queryPromise = this.store.findAll("query");

return groupPromise.then((groups) => {
let groupNames = {};
groups.forEach((g) => {
groupNames[g.id] = g.name;
});
return queryPromise.then((model) => {
model.forEach((query) => {
query.set(
"group_names",
(query.group_ids || []).map((id) => groupNames[id])
);
});
return { model, groups };
});
});
}

setupController(controller, model) {
controller.setProperties(model);
}
}

View file

@ -0,0 +1,37 @@
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";

export default class AdminPluginsExplorerQueriesDetails extends DiscourseRoute {
model(params) {
if (!this.currentUser.admin) {
// display "Only available to admins" message
return { model: null, schema: null, disallow: true, groups: null };
}

const groupPromise = ajax("/admin/plugins/explorer/groups.json");
const schemaPromise = ajax("/admin/plugins/explorer/schema.json", {
cache: true,
});
const queryPromise = this.store.find("query", params.query_id);

return groupPromise.then((groups) => {
let groupNames = {};
groups.forEach((g) => {
groupNames[g.id] = g.name;
});
return schemaPromise.then((schema) => {
return queryPromise.then((model) => {
model.set(
"group_names",
(model.group_ids || []).map((id) => groupNames[id])
);
return { model, schema, groups };
});
});
});
}

setupController(controller, model) {
controller.setProperties(model);
}
}

View file

@ -0,0 +1,32 @@
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";

export default class GroupReportsIndexRoute extends DiscourseRoute {
@service router;

model() {
const group = this.modelFor("group");
return ajax(`/g/${group.name}/reports`)
.then((queries) => {
return {
model: queries,
group,
};
})
.catch(() => this.router.transitionTo("group.members", group));
}

afterModel(model) {
if (
!model.group.get("is_group_user") &&
!(this.currentUser && this.currentUser.admin)
) {
this.router.transitionTo("group.members", model.group);
}
}

setupController(controller, model) {
controller.setProperties(model);
}
}

View file

@ -0,0 +1,37 @@
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";

export default class GroupReportsShowRoute extends DiscourseRoute {
@service router;

model(params) {
const group = this.modelFor("group");
return ajax(`/g/${group.name}/reports/${params.query_id}`)
.then((response) => {
const query = response.query;
const queryGroup = response.query_group;

const queryParamInfo = query.param_info;
const queryParams = queryParamInfo.reduce((acc, param) => {
acc[param.identifier] = param.default;
return acc;
}, {});

return {
model: Object.assign({ params: queryParams }, query),
group,
queryGroup,
results: null,
showResults: false,
};
})
.catch(() => {
this.router.transitionTo("group.members", group);
});
}

setupController(controller, model) {
controller.setProperties(model);
}
}

View file

@ -0,0 +1,187 @@
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import RouteTemplate from "ember-route-template";
import { not } from "truth-helpers";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import PickFilesButton from "discourse/components/pick-files-button";
import TableHeaderToggle from "discourse/components/table-header-toggle";
import TextField from "discourse/components/text-field";
import boundDate from "discourse/helpers/bound-date";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
import ShareReport from "../../components/share-report";

export default RouteTemplate(
<template>
{{#if @controller.disallow}}
<h1>{{i18n "explorer.admins_only"}}</h1>
{{else}}
<div class="query-list">
<TextField
@value={{@controller.search}}
@placeholderKey="explorer.search_placeholder"
@onChange={{@controller.updateSearch}}
/>
<DButton
@action={{@controller.displayCreate}}
@icon="plus"
class="no-text btn-right"
/>
<PickFilesButton
@label="explorer.import.label"
@icon="upload"
@acceptedFormatsOverride={{@controller.acceptedImportFileTypes}}
@showButton="true"
@onFilesPicked={{@controller.import}}
class="import-btn"
/>
</div>

{{#if @controller.showCreate}}
<div class="query-create">
<TextField
@value={{@controller.newQueryName}}
@placeholderKey="explorer.create_placeholder"
@onChange={{@controller.updateNewQueryName}}
/>
<DButton
@action={{@controller.create}}
@disabled={{@controller.createDisabled}}
@label="explorer.create"
@icon="plus"
/>
</div>
{{/if}}

{{#if @controller.othersDirty}}
<div class="warning">
{{icon "triangle-exclamation"}}
{{i18n "explorer.others_dirty"}}
</div>
{{/if}}

{{#if @controller.model.length}}
<ConditionalLoadingSpinner @condition={{@controller.loading}} />

<div class="container">
<table class="d-admin-table recent-queries">
<thead class="heading-container">
<th class="col heading name">
<div
role="button"
class="heading-toggle"
{{on "click" (fn @controller.updateSortProperty "name")}}
>
<TableHeaderToggle
@field="name"
@labelKey="explorer.query_name"
@order={{@controller.order}}
@asc={{not @controller.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading created-by">
<div
role="button"
class="heading-toggle"
{{on "click" (fn @controller.updateSortProperty "username")}}
>
<TableHeaderToggle
@field="username"
@labelKey="explorer.query_user"
@order={{@controller.order}}
@asc={{not @controller.sortDescending}}
@automatic="true"
/>
</div>
</th>
<th class="col heading group-names">
<div class="group-names-header">
{{i18n "explorer.query_groups"}}
</div>
</th>
<th class="col heading created-at">
<div
role="button"
class="heading-toggle"
{{on
"click"
(fn @controller.updateSortProperty "last_run_at")
}}
>
<TableHeaderToggle
@field="last_run_at"
@labelKey="explorer.query_time"
@order={{@controller.order}}
@asc={{not @controller.sortDescending}}
@automatic="true"
/>
</div>
</th>
</thead>
<tbody>
{{#each @controller.filteredContent as |query|}}
<tr class="d-admin-row__content query-row">
<td class="d-admin-row__overview">
<a
{{on "click" @controller.scrollTop}}
href="/admin/plugins/explorer/queries/{{query.id}}"
>
<b class="query-name">{{query.name}}</b>
<span class="query-desc">{{query.description}}</span>
</a>
</td>
<td class="d-admin-row__detail query-created-by">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_user"}}
</div>
{{#if query.username}}
<div>
<a href="/u/{{query.username}}/activity">
<span>{{query.username}}</span>
</a>
</div>
{{/if}}
</td>
<td class="d-admin-row__detail query-group-names">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_groups"}}
</div>
<div class="group-names">
{{#each query.group_names as |group|}}
<ShareReport @group={{group}} @query={{query}} />
{{/each}}
</div>
</td>
<td class="d-admin-row__detail query-created-at">
<div class="d-admin-row__mobile-label">
{{i18n "explorer.query_time"}}
</div>
{{#if query.last_run_at}}
<span>
{{boundDate query.last_run_at}}
</span>
{{else if query.created_at}}
<span>
{{boundDate query.created_at}}
</span>
{{/if}}
</td>
</tr>
{{else}}
<br />
<em class="no-search-results">
{{i18n "explorer.no_search_results"}}
</em>
{{/each}}
</tbody>
</table>
</div>

<div class="explorer-pad-bottom"></div>
{{/if}}
{{/if}}
</template>
);

View file

@ -0,0 +1,262 @@
import { Input } from "@ember/component";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import RouteTemplate from "ember-route-template";
import AceEditor from "discourse/components/ace-editor";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DTextarea from "discourse/components/d-textarea";
import TextField from "discourse/components/text-field";
import icon from "discourse/helpers/d-icon";
import draggable from "discourse/modifiers/draggable";
import { i18n } from "discourse-i18n";
import MultiSelect from "select-kit/components/multi-select";
import CodeView from "../../components/code-view";
import ExplorerSchema from "../../components/explorer-schema";
import ParamInputForm from "../../components/param-input-form";
import QueryResultsWrapper from "../../components/query-results-wrapper";

export default RouteTemplate(
<template>
{{#if @controller.disallow}}
<h1>{{i18n "explorer.admins_only"}}</h1>
{{else}}

<div class="query-edit {{if @controller.editingName 'editing'}}">
{{#if @controller.editingName}}
<div class="name">
<DButton
@action={{@controller.goHome}}
@icon="chevron-left"
class="previous"
/>
<DButton
@action={{@controller.exitEdit}}
@icon="xmark"
class="previous"
/>
<div class="name-text-field">
<TextField
@value={{@controller.model.name}}
@onChange={{@controller.setDirty}}
/>
</div>
</div>

<div class="desc">
<DTextarea
@value={{@controller.model.description}}
@placeholder={{i18n "explorer.description_placeholder"}}
@input={{@controller.setDirty}}
/>
</div>
{{else}}
<div class="name">
<DButton
@action={{@controller.goHome}}
@icon="chevron-left"
class="previous"
/>

<h1>
<span>{{@controller.model.name}}</span>
{{#unless @controller.editDisabled}}
<DButton
@action={{@controller.editName}}
@icon="pencil"
class="edit-query-name btn-transparent"
/>
{{/unless}}
</h1>
</div>

<div class="desc">
{{@controller.model.description}}
</div>
{{/if}}

{{#unless @controller.model.destroyed}}
<div class="groups">
<span class="label">{{i18n "explorer.allow_groups"}}</span>
<span>
<MultiSelect
@value={{@controller.model.group_ids}}
@content={{@controller.groupOptions}}
@options={{hash allowAny=false}}
@onChange={{@controller.updateGroupIds}}
/>
</span>
</div>
{{/unless}}

<div class="clear"></div>

{{#if @controller.editingQuery}}
<div class="query-editor {{if @controller.hideSchema 'no-schema'}}">
<div class="panels-flex">
<div class="editor-panel">
<AceEditor
{{on "click" @controller.setDirty}}
@content={{@controller.model.sql}}
@onChange={{fn (mut @controller.model.sql)}}
@mode="sql"
@disabled={{@controller.model.destroyed}}
@save={{@controller.save}}
@submit={{@controller.saveAndRun}}
/>
</div>

<div class="right-panel">
<ExplorerSchema
@schema={{@controller.schema}}
@hideSchema={{@controller.hideSchema}}
@updateHideSchema={{@controller.updateHideSchema}}
/>
</div>
</div>

<div
class="grippie"
{{draggable
didStartDrag=@controller.didStartDrag
didEndDrag=@controller.didEndDrag
dragMove=@controller.dragMove
}}
>
{{icon "discourse-expand"}}
</div>

<div class="clear"></div>
</div>
{{else}}
<div class="sql">
<CodeView
@value={{@controller.model.sql}}
@codeClass="sql"
@setDirty={{@controller.setDirty}}
/>
</div>
{{/if}}

<div class="clear"></div>

<div class="pull-left left-buttons">
{{#if @controller.editingQuery}}
<DButton
class="btn-save-query"
@action={{@controller.save}}
@label="explorer.save"
@disabled={{@controller.saveDisabled}}
/>
{{else}}
{{#unless @controller.editDisabled}}
<DButton
class="btn-edit-query"
@action={{@controller.editQuery}}
@label="explorer.edit"
@icon="pencil"
/>
{{/unless}}
{{/if}}

<DButton
@action={{@controller.download}}
@label="explorer.export"
@disabled={{@controller.runDisabled}}
@icon="download"
/>

{{#if @controller.editingQuery}}
<DButton
@action={{@controller.showHelpModal}}
@label="explorer.help.label"
@icon="circle-question"
/>
{{/if}}
</div>

<div class="pull-right right-buttons">
{{#if @controller.model.destroyed}}
<DButton
@action={{@controller.recover}}
@icon="arrow-rotate-left"
@label="explorer.recover"
/>
{{else}}
{{#if @controller.editingQuery}}
<DButton
@action={{@controller.discard}}
@icon="arrow-rotate-left"
@label="explorer.undo"
@disabled={{@controller.saveDisabled}}
/>
{{/if}}

<DButton
@action={{@controller.destroyQuery}}
@icon="trash-can"
@label="explorer.delete"
class="btn-danger"
/>
{{/if}}
</div>
<div class="clear"></div>
</div>

<form class="query-run" {{on "submit" @controller.run}}>
{{#if @controller.model.hasParams}}
<ParamInputForm
@initialValues={{@controller.parsedParams}}
@paramInfo={{@controller.model.param_info}}
@onRegisterApi={{@controller.onRegisterApi}}
/>
{{/if}}

{{#if @controller.runDisabled}}
{{#if @controller.saveDisabled}}
<DButton
@label="explorer.run"
@disabled="true"
class="btn-primary"
/>
{{else}}
<DButton
@action={{@controller.saveAndRun}}
@icon="play"
@label="explorer.saverun"
class="btn-primary"
/>
{{/if}}
{{else}}
<DButton
@action={{@controller.run}}
@icon="play"
@label="explorer.run"
@disabled={{@controller.runDisabled}}
@type="submit"
class="btn-primary"
/>
{{/if}}

<label class="query-plan">
<Input
@type="checkbox"
@checked={{@controller.explain}}
name="explain"
/>
{{i18n "explorer.explain_label"}}
</label>
</form>
<hr />

<ConditionalLoadingSpinner @condition={{@controller.loading}} />

<QueryResultsWrapper
@results={{@controller.results}}
@showResults={{@controller.showResults}}
@query={{@controller.model}}
@content={{@controller.results}}
/>
{{/if}}
</template>
);

View file

@ -0,0 +1,45 @@
import { array } from "@ember/helper";
import { LinkTo } from "@ember/routing";
import RouteTemplate from "ember-route-template";
import boundDate from "discourse/helpers/bound-date";
import { i18n } from "discourse-i18n";

export default RouteTemplate(
<template>
<section class="user-content">
<table class="group-reports">
<thead>
<th>
{{i18n "explorer.report_name"}}
</th>
<th>
{{i18n "explorer.query_description"}}
</th>
<th>
{{i18n "explorer.query_time"}}
</th>
</thead>
<tbody>
{{#each @controller.model.queries as |query|}}
<tr>
<td>
<LinkTo
@route="group.reports.show"
@models={{array @controller.group.name query.id}}
>
{{query.name}}
</LinkTo>
</td>
<td>{{query.description}}</td>
<td>
{{#if query.last_run_at}}
{{boundDate query.last_run_at}}
{{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
</template>
);

View file

@ -0,0 +1,58 @@
import { on } from "@ember/modifier";
import RouteTemplate from "ember-route-template";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import ParamInputForm from "../components/param-input-form";
import QueryResult from "../components/query-result";

export default RouteTemplate(
<template>
<section class="user-content">
<h1>{{@controller.model.name}}</h1>
<p>{{@controller.model.description}}</p>

<form class="query-run" {{on "submit" @controller.run}}>
{{#if @controller.hasParams}}
<ParamInputForm
@initialValues={{@controller.parsedParams}}
@paramInfo={{@controller.model.param_info}}
@onRegisterApi={{@controller.onRegisterApi}}
/>
{{/if}}

<DButton
@action={{@controller.run}}
@icon="play"
@label="explorer.run"
@type="submit"
class="btn-primary"
/>

<DButton
@action={{@controller.toggleBookmark}}
@label={{@controller.bookmarkLabel}}
@icon={{@controller.bookmarkIcon}}
class={{@controller.bookmarkClassName}}
/>
</form>

<ConditionalLoadingSpinner @condition={{@controller.loading}} />

{{#if @controller.results}}
<div class="query-results">
{{#if @controller.showResults}}
<QueryResult
@query={{@controller.model}}
@content={{@controller.results}}
@group={{@controller.group}}
/>
{{else}}
{{#each @controller.results.errors as |err|}}
<pre class="query-error"><code>{{~err}}</code></pre>
{{/each}}
{{/if}}
</div>
{{/if}}
</section>
</template>
);

View file

@ -0,0 +1,568 @@
@use "lib/viewport";

table.group-reports {
width: 100%;
table-layout: fixed;

th:first-child {
width: 30%;
}

th:nth-child(2) {
width: 60%;
}

th:last-child {
width: 20%;
text-align: right;
}

tbody tr td {
padding: 0.5em;

&:first-child {
font-size: $font-up-1;
}

&:last-child {
text-align: right;
}
}
}

.https-warning {
color: var(--danger);
}

.query-editor {
margin-bottom: 15px;

.panels-flex {
display: flex;
height: 400px;
border: 1px solid var(--primary-very-low);
}

&.no-schema {
.editor-panel {
width: 100%;
}

.right-panel {
width: 0;

button.unhide {
position: absolute;
margin-left: -53px;
z-index: 1;
}
}
}

.editor-panel {
flex-grow: 1;

.ace-wrapper {
position: relative;
height: 100%;
width: 100%;
}

.ace_editor {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
}

.right-panel {
flex-shrink: 0;
flex-grow: 0;
width: 345px;

.schema {
border-left: 1px solid var(--primary-low);
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
color: var(--primary-high);
font-size: var(--font-down-1);
position: relative;

.schema-search {
padding: 0.5em;
position: sticky;
background-color: var(--secondary);
top: 0;
z-index: 1;
}

.schema-table-name {
font-weight: bold;
border-bottom: 1px solid var(--primary-low);
padding-left: 5px;

.fa {
width: 6px;
}
}

ul {
list-style-type: none;
margin: 0;
}

dl {
margin: 0;
}

dl > div > * {
padding-top: 2px;
padding-bottom: 2px;
}

dl > div {
clear: both;
}

dt {
display: inline-block;
vertical-align: text-top;
overflow-wrap: break-word;
width: 110px;
margin-left: 5px;

&.sensitive {
color: var(--danger);
}
}

dd {
display: inline-block;
vertical-align: text-top;
width: 110px;
color: var(--tertiary);
margin: 0;
padding-left: 7px;
border-left: 1px dotted var(--primary-low-mid);

.schema-typenotes {
color: var(--primary-medium);
font-style: italic;
}

.popup-info {
color: var(--primary-medium);

.popup {
display: none;
width: 180px;
padding: 4px;
position: relative;
border: 1px solid;
background: var(--secondary);
padding-right: calc(5px + 0.5em);
}

&:hover .popup {
display: block;
}

&:focus .popup {
display: block;
}

.popup ol {
padding: 0;
position: relative;
list-style: none;

> li::before {
content: attr(value) ": ";
}
}
}
}
}
}

.grippie {
cursor: nwse-resize;
clear: both;
font-size: $font-down-2;
user-select: none;
color: var(--primary);
text-align: right;
background: var(--primary-very-low);
border: 1px solid var(--primary-very-low);

.d-icon {
transform: rotate(90deg);
}
}
}

.query-edit {
> .name {
display: flex;
align-items: center;
margin-bottom: 1em;

h1 {
display: inline-block;
margin: 0 0.5em 0 0;
color: var(--primary);

button .d-icon {
color: currentcolor;
}
}
}

button.previous {
margin-right: 0.5em;

.d-icon {
margin-left: -0.15em; // fixing fontawesome horizontal alignment
}
}

.name-text-field {
width: 100%;
}

.name input,
.desc textarea {
width: 100%;
margin: 0;
}

&:not(.editing) .desc {
margin: 10px 0;
}

.groups {
margin: 10px 0;
display: flex;
flex-direction: row;
align-items: center;

@include viewport.until(md) {
flex-direction: column;
align-items: flex-start;
}

.label {
margin-right: 10px;
color: var(--primary-high);
}

.name {
display: inline;
}

.select-kit.multi-select {
@include viewport.until(md) {
width: 360px;
}
}
}
}

.query-run {
margin-top: 25px;

.query-plan {
display: inline-block;
margin-left: 15px;
}
}

.clear {
clear: both;
}

.schema-title {
display: block;
margin: auto;
}

.query-params {
border: 1px solid var(--header_primary-medium);

.params-form {
margin: 5px;

html.desktop-view & {
flex-direction: row;
flex-wrap: wrap;
}
}

.param > input,
.param > .select-kit {
margin: 9px;
}

.invalid input {
background-color: var(--danger-low);
}

.invalid .ac-wrap {
background-color: var(--danger-low);
}

.param {
display: flex;
align-items: center;
flex: 0;

.ac-wrap {
display: inline-block;

input {
width: 100px !important; // override an inline style
}
}

input,
.select-kit {
width: auto;
max-width: 250px;
}
}
}

.query-list,
.query-create,
.query-edit,
.query-results,
.query-params,
.https-warning {
margin: 10px 0;
}

.query-create {
display: flex;

input {
margin-right: 0.5em;
margin-bottom: 0;
}
}

.query-results {
section {
width: 100%;
overflow: auto;
max-height: 1000px;
}

table {
width: 100%;
margin-top: 10px;

td {
padding: 8px;
}
}

thead {
th {
position: sticky;
top: 0;
color: var(--primary);
background: var(--primary-low);
z-index: z("base");
}
}
}

.query-list {
display: flex;
align-items: flex-start;

.btn-left {
margin-right: 0.5em;
}

.btn-right {
margin-left: auto;
margin-right: 0.5em;
}

li.none {
display: none;
}

.import-btn {
display: flex;
}
}

.recent-queries {
thead {
.created-by {
width: 15%;
}

.group-names {
width: 15%;

.group-names-header {
position: absolute;
bottom: 8px;
left: 6px;
}
}

.created-at {
width: 15%;
}

.heading {
position: relative;
color: var(--primary-medium);
padding: 50px 0 0 0;

th.sortable {
font-weight: normal;
}

.heading-toggle {
bottom: 0;
position: absolute;
display: inline-block;
}
}
}

.query-row {
a {
width: 100%;
}

.query-name {
color: var(--primary);
}

.query-desc {
display: block;
color: var(--primary-high);
}

.query-created-by {
color: var(--primary-high);
}

.query-group-names {
color: var(--tertiary);

a {
display: inline;
}
}

.group-names {
@include viewport.until(md) {
text-align: right;
}
}

.query-created-at {
color: var(--primary-medium);
}
}

.query-row:hover {
background-color: inherit;
}

tr a {
color: inherit;
cursor: pointer;
display: inline-block;
}

.no-search-results {
color: var(--primary-medium);
}
}

.result-info {
float: left;
}

.result-about {
color: var(--primary-high);
float: right;
}

.result-explain {
padding-top: 1em;
margin-bottom: 0;
}

.result-post-link {
display: block;
color: inherit !important;
}

.result-json {
display: flex;
}

.result-json-value {
flex: 1;
margin-right: 0.5em;
max-width: 250px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}

.explorer-pad-bottom {
margin-bottom: 200px;
}

.share-report {
cursor: pointer;

label {
color: var(--primary-high);
}

input {
margin-right: 4px;
}

.popup {
background-color: var(--secondary);
position: absolute;
box-shadow: var(--shadow-card);
padding: 12px;
z-index: 1;
}
}

.left-buttons,
.right-buttons {
display: flex;
align-items: flex-start;
}

.left-buttons .btn {
margin-right: 0.5em;
}

.right-buttons .btn {
margin-left: 0.5em;
}

.query-group-bookmark {
&.bookmarked .d-icon {
color: var(--tertiary);
}
}

View file

@ -0,0 +1,124 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ar:
js:
errors:
explorer:
no_semicolons: "أزِل الفواصل المنقوطة من الاستعلام."
dirty: "يجب عليك حفظ الاستعلام قبل التشغيل."
explorer:
or: "أو"
admins_only: "مستكشف البيانات متاح للمسؤولين فقط."
allow_groups: "السماح للمجموعات بالوصول إلى هذا الاستعلام"
title: "مستكشف البيانات"
create: "إنشاء جديد"
create_placeholder: "اسم الاستعلام..."
description_placeholder: "أدخِل وصفًا هنا"
import:
label: "استيراد"
modal: "استيراد استعلام"
unparseable_json: "ملف JSON غير قابل للتحليل"
wrong_json: "ملف JSON خاطئ. يجب أن يحتوي ملف JSON على كائن \"استعلام\"، والذي يجب أن يحتوي على الأقل على خاصية \"sql\"."
help:
label: "المساعدة"
modal_title: "مساعدة مستكشف البيانات"
auto_resolution: "<h2>ربط السجلات التلقائي</h2> <p>عندما يؤدي الاستعلام إلى إرجاع مُعرِّف الكيان، فقد يستبدله مستكشف البيانات تلقائيًا باسم الكيان والمعلومات المفيدة الأخرى في نتائج الاستعلام. يتوفَّر الربط التلقائي لـ <i><b>user_id</b></i> و<i><b>group_id</b></i> و<i><b>topic_id</b></i> و<i><b>category_id</b></i> و<i><b>badge_id</b></i>. لتجربة ذلك، شغِّل هذا الاستعلام:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>إنشاء معلمات مخصَّصة</h2> <p>لإنشاء معلمات مخصَّصة لاستعلاماتك، ضع هذا في الجزء العلوي من استعلامك واتبع التنسيق:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>ملاحظة: السطر الأول الذي يحتوي على [params] مطلوب، بالإضافة إلى شرطتين قبله وكل معلمة مخصَّصة تريد الإعلان عنها.</i></p>"
default_values: "<h3>القيم الافتراضية</h3> <p>يمكنك إعلان المعلمات بقيم افتراضية أو من دونها. ستظهر القيم الافتراضية في حقلٍ نصي أسفل محرِّر الاستعلام، والذي يمكنك تعديله حسب احتياجاتك. ستظل المعلمات المعلنة دون قيم افتراضية تنشئ حقلًا نصيًا، لكنها ستكون فارغة ومظللة باللون الأحمر.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>أنواع البيانات</h3> <p>فيما يلي أنواع البيانات الشائعة التي يمكنك استخدامها:</p> <ul> <li><b>عدد صحيح</b> - عدد صحيح رباعي البايت مع مراعاة إشارته</li> <li><b>نص</b> - سلسلة أحرف متغيرة الطول</li> <li><b>قيمة منطقية</b> - صواب/خطأ</li> <li><b>تاريخ</b> - تاريخ تقويمي (سنة، شهر، يوم)</li> </ul> <p>لمزيد من المعلومات بشأن أنواع البيانات، انتقل إلى <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>هذا الموقع</a>.</p>"
schema:
title: "مخطط قاعدة البيانات"
filter: "بحث..."
sensitive: "قد تتضمَّن محتويات هذا العمود معلومات حساسة أو سرية بشكلٍ خاص. يُرجى توخي الحذر عند استخدام محتويات هذا العمود."
types:
bool:
yes: "نعم"
no: "لا"
null_: "فارغ"
export: "تصدير"
view_json: "عرض JSON"
save: "حفظ التغييرات"
saverun: "حفظ التغييرات والتشغيل"
run: "تشغيل"
undo: "تجاهل التغييرات"
edit: "تعديل"
delete: "حذف"
recover: "إلغاء حذف الاستعلام"
download_json: "JSON"
download_csv: "CSV"
show_table: "جدول"
show_graph: "رسم بياني"
others_dirty: "يحتوي الاستعلام على تغييرات غير محفوظة سيتم فقدها إذا انتقلت بعيدًا."
run_time: "اكتمل الاستعلام في %{value} مللي ثانية."
result_count:
zero: "%{count} نتيجة."
one: "نتيجة واحدة (%{count})."
two: "نتيجتان (%{count})"
few: "%{count} نتائج."
many: "%{count} نتيجةً."
other: "%{count} نتيجة."
max_result_count:
zero: "جارٍ عرض أبرز %{count} نتيجة."
one: "جارٍ عرض أبرز نتيجة (%{count})."
two: "جارٍ عرض أبرز نتيجتين (%{count})."
few: "جارٍ عرض أبرز %{count} نتائج."
many: "جارٍ عرض أبرز %{count} نتيجةً."
other: "جارٍ عرض أبرز %{count} نتيجة."
query_name: "استعلام"
query_groups: "المجموعات"
link: "رابط لـ"
report_name: "تقرير"
query_description: "الوصف"
query_time: "آخر تشغيل"
query_user: "تم الإنشاء من قِبل"
column: "العمود %{number}"
explain_label: "هل تريد تضمين خطة الاستعلام؟"
save_params: "الضبط على الإعدادات الافتراضية"
reset_params: "إعادة التعيين"
search_placeholder: "بحث..."
no_search_results: "عذرًا، لم نتمكن من العثور على أي نتائج مطابقة لنصك."
form:
errors:
invalid: "غير صالح"
no_such_category: "لا توجد فئة بهذا الاسم"
no_such_group: "لا توجد مجموعة بهذا الاسم"
invalid_date: "%{date} هو تاريخ غير صالح"
invalid_time: "%{time} هو وقت غير صالح"
group:
reports: "التقارير"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "تشغيل استعلامات مستكشف البيانات. يمكنك تقييد مفتاح واجهة برمجة التطبيقات (API) إلى مجموعة من الاستعلامات عن طريق تحديد معرِّفات الاستعلامات."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: الإرسال إلى مستخدم أو مجموعة أو بريد إلكتروني
query_id:
label: استعلام مستكشف البيانات
query_params:
label: معلمات استعلام مستكشف البيانات
skip_empty:
label: تخطي إرسال رسالة شخصية إذا لم تكن هناك نتائج
attach_csv:
label: إرفاق ملف CSV بالرسالة الشخصية
recurring_data_explorer_result_topic:
fields:
topic_id:
label: الموضوع الذي سيتم نشر نتائج الاستعلام فيه
query_id:
label: استعلام مستكشف البيانات
query_params:
label: معلمات استعلام مستكشف البيانات
skip_empty:
label: تخطي النشر إذا لم تكن هناك نتائج
attach_csv:
label: إرفاق ملف CSV بالمنشور

View file

@ -0,0 +1,23 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

be:
js:
explorer:
or: "або"
types:
bool:
yes: "Так"
no: "Не"
export: "экспарт"
save: "Захаваць"
edit: "Рэдагаваць"
delete: "Выдаліць"
query_groups: "групы"
query_description: "апісанне"
reset_params: "скінуць"
group:
reports: "Справаздачы"

View file

@ -0,0 +1,31 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

bg:
js:
explorer:
or: "или"
import:
label: "Импорт"
help:
label: "Помощ"
schema:
filter: "Търсене ... "
types:
bool:
yes: "Да"
no: "Не"
export: "Експорт "
save: "Запази промените"
edit: "Редактирай"
delete: "Изтрий"
result_count:
one: "%{count} резултат."
other: "%{count} резултата."
query_groups: "Групи"
query_description: "Описание"
reset_params: "Нулиране"
search_placeholder: "Търсене ... "

View file

@ -0,0 +1,28 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

bs_BA:
js:
explorer:
or: "ili"
help:
label: "Pomoć"
schema:
filter: "Pretraga..."
types:
bool:
yes: "Da"
no: "Ne"
export: "Izvoz"
save: "Spremiti promjene"
edit: "Izmijeni"
delete: "Delete"
query_groups: "Grupa"
query_description: "Opis"
reset_params: "Resetovati"
search_placeholder: "Pretraga..."
group:
reports: "Izvještaji"

View file

@ -0,0 +1,63 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ca:
js:
errors:
explorer:
no_semicolons: "Elimina els punts i coma de la consulta."
dirty: "Cal desar la consulta abans d'executar-la"
explorer:
or: "o"
admins_only: "L'explorador de dades sols és disponible per a administradors."
title: "Explorador de dades"
create: "Crea'n un de nou"
create_placeholder: "Nom de consulta..."
description_placeholder: "Introduïu una descripció aquí"
import:
label: "Importa"
modal: "Importa una consulta"
help:
label: "Ajuda"
schema:
title: "Esquema de base de dades"
filter: "Cerca..."
sensitive: "El contingut d'aquesta columna pot contenir informació particularment sensible o privada. Tingueu precaució quan utilitzeu el contingut d'aquesta columna."
types:
bool:
yes: "Sí"
no: "No"
null_: "Nul"
export: "Exporta"
save: "Desa els canvis"
saverun: "Desa els canvis i executa"
run: "Executa"
undo: "Descarta els canvis"
edit: "Edita"
delete: "Suprimeix"
recover: "Restaura la consulta"
download_json: "JSON"
download_csv: "CSV"
others_dirty: "Una consulta té canvis no desats que es perdran si continueu navegant."
run_time: "La consulta s'ha completat en %{value} ms."
result_count:
one: "%{count} resultat."
other: "%{count} resultats."
query_name: "Consulta"
query_groups: "Grups"
link: "Enllaç de"
report_name: "Report"
query_description: "Descripció"
query_time: "Darrera execució"
query_user: "Creat per"
column: "Columna %{number}"
explain_label: "Hi incloem pla de consulta?"
save_params: "Estableix valors per defecte"
reset_params: "Reinicia"
search_placeholder: "Cerca..."
no_search_results: "Ho sentim, no hem trobat cap resultat que coincideixi amb el vostre text."
group:
reports: "Reports"

View file

@ -0,0 +1,39 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

cs:
js:
explorer:
or: "nebo"
create: "Vytvořit nový"
import:
label: "Import"
help:
label: "Nápověda"
schema:
filter: "Hledat..."
types:
bool:
yes: "Ano"
no: "Ne"
null_: "Null"
export: "Export"
save: "Uložit změny"
edit: "Upravit"
delete: "Smazat"
download_json: "JSON"
download_csv: "CSV"
result_count:
one: "%{count} výsledek."
few: "%{count} výsledky."
many: "%{count} výsledky."
other: "%{count} výsledků."
query_groups: "Skupiny"
query_description: "Popis"
reset_params: "obnovit výchozí"
search_placeholder: "Hledat..."
group:
reports: "Přehledy"

View file

@ -0,0 +1,54 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

da:
js:
errors:
explorer:
no_semicolons: "Fjern semikoloner fra forespørgslen."
dirty: "Du skal gemme forespørgslen, før du kører den."
explorer:
or: "eller"
admins_only: "Data explorer er kun tilgængelige for administratorer."
allow_groups: "Tillad grupper at få adgang til denne forespørgsel"
title: "Data Udforsker"
create: "Opret Ny"
create_placeholder: "Navn på forespørgsel..."
description_placeholder: "Indtast en beskrivelse her"
import:
label: "Importer"
modal: "Importer en forespørgsel"
help:
label: "Hjælp"
modal_title: "Hjælp til Data Udforsker"
schema:
title: "Database Skema"
filter: "Søg..."
types:
bool:
yes: "Ja"
no: "Nej"
null_: "Null"
export: "Eksporter"
save: "Gem ændringer"
saverun: "Gem ændringer og kør"
run: "Kør"
undo: "Kassér Ændringer"
edit: "Rediger"
delete: "Slet"
recover: "Fortryd sletning af forespørgsel"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabel"
show_graph: "Graf"
query_name: "Forespørgsel"
query_groups: "grupper"
query_description: "Beskrivelse"
query_user: "Oprettet af"
reset_params: "Nulstil"
search_placeholder: "Søg..."
group:
reports: "Rapporter"

View file

@ -0,0 +1,118 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

de:
js:
errors:
explorer:
no_semicolons: "Entferne die Semikolons aus der Anfrage."
dirty: "Du musst die Anfrage vor der Ausführung speichern."
explorer:
or: "oder"
admins_only: "Der Daten-Explorer ist nur für Administratoren verfügbar."
allow_groups: "Gruppen erlauben, auf diese Anfrage zuzugreifen"
title: "Daten-Explorer"
create: "Neu erstellen"
create_placeholder: "Name der Anfrage …"
description_placeholder: "Hier bitte eine Beschreibung eingeben"
import:
label: "Importieren"
modal: "Anfrage importieren"
unparseable_json: "JSON-Datei kann nicht verarbeitet werden."
wrong_json: "Falsche JSON-Datei. Eine JSON-Datei sollte ein „query“-Objekt enthalten, das zumindest eine „sql“-Eigenschaft haben sollte."
help:
label: "Hilfe"
modal_title: "Daten-Explorer-Hilfe"
auto_resolution: "<h2>Automatische Auflösung von Entitäten</h2> <p>Wenn deine Anfrage eine Entitäts-ID zurückgibt, kann der Daten-Explorer diese automatisch durch den Entitätsnamen und andere nützliche Informationen in den Anfrageergebnissen ersetzen. Die automatische Auflösung ist verfügbar für <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> und <i><b>badge_id</b></i>. Um dies auszuprobieren, führe diese Anfrage aus:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Benutzerdefinierte Parameter erstellen</h2> <p>Um benutzerdefinierte Parameter für deine Anfragen zu erstellen, füge Folgendes am Anfang deiner Anfrage ein und befolge das Format:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Hinweis: Die erste Zeile mit [params] ist erforderlich, zusammen mit zwei Bindestrichen davor und jedem benutzerdefinierten Parameter, den du deklarieren möchtest.</i></p>"
default_values: "<h3>Standardwerte</h3> <p>Du kannst Parameter mit oder ohne Standardwerte deklarieren. Die Standardwerte werden in einem Textfeld unter dem Anfrage-Editor angezeigt, das du beliebig bearbeiten kannst. Bei Parametern, die ohne Standardwerte angegeben werden, erscheint zwar immer noch ein Textfeld, aber es ist leer und rot markiert.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Datentypen</h3> <p>Hier sind gängige Datentypen, die du verwenden kannst:</p> <ul> <li><b>Ganzzahl</b> - vorzeichenbehaftete Vier-Byte-Ganzzahl</li> <li><b>Text</b> - Zeichenkette mit variabler Länge</li> <li><b>Boolean</b> wahr/falsch</li> <li><b>Datum</b> - Kalenderdatum (Jahr, Monat, Tag)</li> </ul> <p>Weitere Informationen über Datentypen findest du auf <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>dieser Website</a>.</p>"
schema:
title: "Datenbank-Schema"
filter: "Suchen …"
sensitive: "Die Inhalte dieser Spalte enthalten möglicherweise besonders sensible oder persönliche Informationen. Bitte gehe behutsam mit den Inhalten dieser Spalte um."
types:
bool:
yes: "Ja"
no: "Nein"
null_: "Null"
export: "Exportieren"
view_json: "JSON anzeigen"
save: "Änderungen speichern"
saverun: "Änderungen speichern und ausführen"
run: "Ausführen"
undo: "Änderungen verwerfen"
edit: "Bearbeiten"
delete: "Löschen"
recover: "Anfrage wiederherstellen"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabelle"
show_graph: "Diagramm"
others_dirty: "Eine Anfrage hat nicht gespeicherte Änderungen, die verloren gehen, wenn du woanders hin navigierst."
run_time: "Anfrage in %{value} ms abgeschlossen."
result_count:
one: "%{count} Ergebnis."
other: "%{count} Ergebnisse."
max_result_count:
one: "Zeige das Top-%{count} Ergebnis."
other: "Zeige die Top-%{count} Ergebnisse."
query_name: "Anfrage"
query_groups: "Gruppen"
link: "Link für"
report_name: "Bericht"
query_description: "Beschreibung"
query_time: "Letzter Lauf"
query_user: "Erstellt von"
column: "Spalte %{number}"
explain_label: "Anfrageplan einbeziehen?"
save_params: "Standards festlegen"
reset_params: "Zurücksetzen"
search_placeholder: "Suchen …"
no_search_results: "Leider konnten wir keine Ergebnisse finden, die deinem Text entsprechen."
form:
errors:
invalid: "Ungültig"
no_such_category: "Keine solche Kategorie"
no_such_group: "Keine solche Gruppe"
invalid_date: "%{date} ist ein ungültiges Datum"
invalid_time: "%{time} ist eine ungültige Zeit"
group:
reports: "Berichte"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Daten-Explorer-Anfragen ausführen. Schränke den API-Schlüssel auf eine Reihe von Anfragen ein, indem du Anfrage-IDs angibst."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: An Benutzer, Gruppe oder E-Mail senden
query_id:
label: Daten-Explorer-Anfrage
query_params:
label: Daten-Explorer-Anfrageparameter
skip_empty:
label: Überspringe das Senden einer PN, wenn es keine Ergebnisse gibt
attach_csv:
label: Hänge die CSV-Datei an die PN an
users_from_group:
label: Senden jedem Gruppenmitglied eine individuelle PN
recurring_data_explorer_result_topic:
fields:
topic_id:
label: Das Thema, in dem die Anfrageergebnisse veröffentlicht werden
query_id:
label: Daten-Explorer-Anfrage
query_params:
label: Daten-Explorer-Anfrageparameter
skip_empty:
label: Überspringe die Veröffentlichung, wenn keine Ergebnisse vorliegen
attach_csv:
label: Hänge die CSV-Datei an den Beitrag an

View file

@ -0,0 +1,31 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

el:
js:
explorer:
or: "ή"
import:
label: "Εισαγωγή"
help:
label: "Βοήθεια"
schema:
filter: "Αναζήτηση..."
types:
bool:
yes: "Ναι "
no: "Όχι"
export: "Εξαγωγή"
save: "Αποθήκευση Αλλαγών"
edit: "Επεξεργασία"
delete: "Σβήσιμο"
query_groups: "Ομάδες"
query_description: "Περιγραφή"
query_user: "Δημιουργήθηκε από"
reset_params: "Επαναφορά"
search_placeholder: "Αναζήτηση..."
group:
reports: "Αναφορές"

View file

@ -0,0 +1,134 @@
en:
js:
errors:
explorer:
no_semicolons: "Remove the semicolons from the query."
dirty: "You must save the query before running."
explorer:
or: "or"
admins_only: "The data explorer is only available to admins."
allow_groups: "Allow groups to access this query"
title: "Data Explorer"
create: "Create New"
create_placeholder: "Query name..."
description_placeholder: "Enter a description here"
import:
label: "Import"
modal: "Import A Query"
unparseable_json: "Unparseable JSON file."
wrong_json: "Wrong JSON file. A JSON file should contain a 'query' object, which should at least have an 'sql' property."
help:
label: "Help"
modal_title: "Data Explorer Help"
auto_resolution: "<h2>Automatic Entity Resolution</h2>
<p>When your query returns an entity id, Data Explorer may automatically substitute it with
the entity name and other useful information in query results. Automatic resolution is available for
<i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i>
and <i><b>badge_id</b></i>. To try this out run this query:</p>
<pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Creating Custom Parameters</h2>
<p>To create custom parameters for your queries, put this at the top of your query and follow the format:</p>
<pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre>
<p><i>Note: the first line with [params] is required, along with two dashes preceding it and every
custom parameter you want to declare.</i></p>"
default_values: "<h3>Default Values</h3>
<p>You can declare parameters with or without default values. Default values will show up in a text field
below the query editor, which you can edit to your needs. Parameters declared without default values will
still generate a text field, but will be empty and highlighted red.</p>
<pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Data Types</h3>
<p>Here are common data types you can use:</p>
<ul>
<li><b>integer</b> - signed four-byte Integer</li>
<li><b>text</b> - variable-length character string</li>
<li><b>boolean</b> true/false</li>
<li><b>date</b> - calendar date (year, month, day)</li>
</ul>
<p>For more information on data types, visit
<a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>this website</a>.</p>"
schema:
title: "Database Schema"
filter: "Search..."
sensitive: "The contents of this column may contain particularly sensitive or private information. Please exercise caution when using the contents of this column."
types:
bool:
yes: "Yes"
no: "No"
null_: "Null"
export: "Export"
view_json: "View JSON"
save: "Save Changes"
saverun: "Save Changes and Run"
run: "Run"
undo: "Discard Changes"
edit: "Edit"
delete: "Delete"
recover: "Undelete Query"
download_json: "JSON"
download_csv: "CSV"
show_table: "Table"
show_graph: "Graph"
others_dirty: "A query has unsaved changes that will be lost if you navigate away."
run_time: "Query completed in %{value} ms."
result_count:
one: "%{count} result."
other: "%{count} results."
max_result_count:
one: "Showing top %{count} result."
other: "Showing top %{count} results."
query_name: "Query"
query_groups: "Groups"
link: "Link for"
report_name: "Report"
query_description: "Description"
query_time: "Last run"
query_user: "Created by"
column: "Column %{number}"
explain_label: "Include query plan?"
save_params: "Set Defaults"
reset_params: "Reset"
search_placeholder: "Search..."
no_search_results: "Sorry, we couldn't find any results matching your text."
form:
errors:
invalid: "Invalid"
no_such_category: "No such category"
no_such_group: "No such group"
invalid_date: "%{date} is a invalid date"
invalid_time: "%{time} is a invalid time"
group:
reports: "Reports"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Run Data Explorer queries. Restrict the API key to a set of queries by specifying queries IDs."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Send to User, Group or Email
query_id:
label: Data Explorer Query
query_params:
label: Data Explorer Query parameters
skip_empty:
label: Skip sending PM if there are no results
attach_csv:
label: Attach the CSV file to the PM
users_from_group:
label: Send individual PM to each group member
recurring_data_explorer_result_topic:
fields:
topic_id:
label: The topic to post query results in
query_id:
label: Data Explorer Query
query_params:
label: Data Explorer Query parameters
skip_empty:
label: Skip posting if there are no results
attach_csv:
label: Attach the CSV file to the post

View file

@ -0,0 +1,10 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

en_GB:
js:
explorer:
query_description: "Description"

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

es:
js:
errors:
explorer:
no_semicolons: "Eliminar los punto y coma de la consulta."
dirty: "Debes guardar la consulta antes de ejecutarla."
explorer:
or: "o"
admins_only: "El explorador de datos solo esta disponible para administradores."
allow_groups: "Permitir a los grupos acceder a esta consulta"
title: "Explorador de datos"
create: "Crear nueva"
create_placeholder: "Nombre de la consulta..."
description_placeholder: "Escribe una descripción aquí"
import:
label: "Importar"
modal: "Importar una consulta"
unparseable_json: "Archivo JSON no analizable."
wrong_json: "Archivo JSON incorrecto. Debería contener un objeto «query», con al menos una propiedad «sql»."
help:
label: "Ayuda"
modal_title: "Ayuda sobre el explorador de datos"
auto_resolution: "<h2>Resolución automática de entidades</h2> <p>Cuando tu consulta devuelva el ID de una entidad, puede que el explorador de datos lo sustituya por el nombre de la entidad y otra información útil en los resultados de la consulta. La resolución automática está disponible para <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> y <i><b>badge_id</b></i>. Para probarla, ejecuta esta consulta:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Crear parámetros personalizados</h2><p>Para crear parámetros personalizados en tus consultas, pon lo siguiente al principio siguiendo el formato:</p><pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Nota: hay que incluir la primera línea con [params], además de los dos guiones que van al principio de la línea, y también debes incluir los guiones en cada parámetro personalizado que quieras declarar.</i></p>"
default_values: "<h3>Valores por defecto</h3> <p>Puedes declarar parámetros de manera que tengan o no un valor por defecto. Los valores por defecto aparecerán en un campo de texto debajo del editor de consultas, y puedes editarlos según lo necesites. Los parámetros declarados sin un valor por defecto también crearán un campo de texto, pero estará vacío y marcado en rojo.</p><pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Tipos de datos</h3> <p>Algunos tipos de datos que puedes usar:</p> <ul> <li><b>integer</b> - número entero de 4 bytes, positivo o negativo</li><li><b>text</b> - conjunto de caracteres de longitud variable</li><li><b>boolean</b> - true/false (verdadero/falso)</li><li><b>date</b> - date (año, mes, día)</li></ul><p>Para más información sobre los tipos de datos, visita <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>esta página</a>.</p>"
schema:
title: "Esquema de base de datos"
filter: "Buscar..."
sensitive: "Los contenidos de esta columna pueden incluir información particularmente sensible o privada. Ten cuidado cuando la uses."
types:
bool:
yes: "Sí"
no: "No"
null_: "Nulo"
export: "Exportar"
view_json: "Ver JSON"
save: "Guardar cambios"
saverun: "Guardar cambios y ejecutar"
run: "Ejecutar"
undo: "Descartar cambios"
edit: "Editar"
delete: "Eliminar"
recover: "Recuperar consulta"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabla"
show_graph: "Gráfico"
others_dirty: "Una consulta tiene cambios no guardados que se perderán si sales."
run_time: "Consulta completada en %{value} ms."
result_count:
one: "%{count} resultado."
other: "%{count} resultados."
max_result_count:
one: "Mostrando el mejor resultado (%{count})."
other: "Mostrando los mejores %{count} resultados."
query_name: "Consulta"
query_groups: "Grupos"
link: "Enlace para"
report_name: "Informe"
query_description: "Descripción"
query_time: "Última ejecución"
query_user: "Creado por"
column: "Columna %{number}"
explain_label: "¿Incluir plan de consulta?"
save_params: "Establecer configuración por defecto"
reset_params: "Restablecer"
search_placeholder: "Buscar..."
no_search_results: "Lo sentimos, no hemos podido encontrar ningún resultado que coincida con tu texto."
form:
errors:
invalid: "No válido"
no_such_category: "No existe dicha categoria"
no_such_group: "No existe dicho grupo"
invalid_date: "%{date} es una fecha no válida"
invalid_time: "%{time} es una hora no válida"
group:
reports: "Informes"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Ejecuta consultas del Explorador de Datos. Restringe la clave API a un conjunto de consultas especificando los ID de las mismas."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Enviar a usuario, grupo o correo electrónico
query_id:
label: Consulta del explorador de datos
query_params:
label: Parámetros de consulta del explorador de datos
skip_empty:
label: Omitir el envío de MP si no hay resultados
attach_csv:
label: Adjuntar el archivo CSV al MP
recurring_data_explorer_result_topic:
fields:
topic_id:
label: El tema en el que se publicarán los resultados de la consulta
query_id:
label: Consulta del Explorador de datos
query_params:
label: Parámetros de consulta del Explorador de datos
skip_empty:
label: Omitir la publicación si no hay resultados
attach_csv:
label: Adjuntar el archivo CSV a la publicación

View file

@ -0,0 +1,40 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

et:
js:
explorer:
or: "või"
title: "Andmete eksportija"
create: "Loo uus"
create_placeholder: "Päringu nimi..."
import:
label: "Impordi"
modal: "Impordi päring"
help:
label: "Abi"
schema:
title: "Andmebaasi skeem"
filter: "Otsi..."
types:
bool:
yes: "Jah"
no: "Ei"
null_: "Nulli"
export: "Ekspordi"
save: "Salvesta muudatused"
saverun: "Salvesta muudatused ja käivita"
run: "Käivita"
undo: "Loobu muudatustest"
edit: "Muuda"
delete: "Kustuta"
download_json: "JSON"
download_csv: "CSV"
query_groups: "Grupid"
query_description: "Kirjeldus"
save_params: "Määra vaikeväärtused"
reset_params: "Nulli"
search_placeholder: "Otsi..."

View file

@ -0,0 +1,41 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

fa_IR:
js:
explorer:
or: "یا"
import:
label: "ورود داده‌ها"
help:
label: "کمک"
schema:
filter: "جستجو..."
types:
bool:
yes: "بله"
no: "خیر"
export: "خروجی گرفتن"
save: "ذخیره تغییرات"
run: "اجرا"
edit: "ویرایش"
delete: "حذف"
result_count:
one: "%{count} نتیجه."
other: "%{count} نتیجه."
max_result_count:
one: "نمایش %{count} نتیجه برتر."
other: "نمایش %{count} نتیجه برتر."
query_groups: "گروه ها"
link: "پیوند برای"
report_name: "گزارش"
query_description: "توضیح"
query_time: "آخرین اجرا"
query_user: "ایجاد شده توسط"
reset_params: "بازنشانی"
search_placeholder: "جستجو..."
group:
reports: "گزارشات"

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

fi:
js:
errors:
explorer:
no_semicolons: "Poista puolipisteet kyselystä."
dirty: "Kysely täytyy tallentaa ennen suorittamista."
explorer:
or: "tai"
admins_only: "Dataselain on vain ylläpitäjille."
allow_groups: "Salli ryhmien käyttää tätä kyselyä"
title: "Dataselain"
create: "Luo uusi"
create_placeholder: "Kyselyn nimi..."
description_placeholder: "Kirjoita kuvaus tähän"
import:
label: "Tuo"
modal: "Tuo kysely"
unparseable_json: "JSON-tiedosto ei ole jäsennettävissä."
wrong_json: "Väärä JSON-tiedosto. JSON-tiedoston tulisi sisältää query-objekti, jolla tulisi olla ainakin sql-ominaisuus."
help:
label: "Apua"
modal_title: "Dataselaimen ohje"
auto_resolution: "<h2>Automaattinen yksikön selvitys</h2> <p>Kun kyselysi palauttaa yksikön, Dataselain voi korvata sen automaattisesti yksikön nimellä ja muilla hyödyllisillä tiedoilla kyselyn tuloksissa. Automaattinen selvitys on käytettävissä <i><b>user_id</b></i>-, <i><b>group_id</b></i>-, <i><b>topic_id</b></i>-, <i><b>category_id</b></i>- ja <i><b>badge_id</b></i>-tietueissa. Voit kokeilla tätä suorittamalla tämän kyselyn:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Mukautettujen parametrien luominen</h2> <p>Voit luoda mukautettuja parametreja kyselyillesi lisäämällä tämän kyselysi yläosaan ja käyttämällä muotoa:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Huomautus: ensimmäinen rivi, jossa on [params], on pakollinen yhdessä sitä edeltävien väliviivojen kanssa, sekä jokainen mukautettu parametri, jonka haluat esitellä.</i></p>"
default_values: "<h3>Oletusarvot</h3> <p>Voit esitellä parametreja oletusarvoilla tai ilman. Oletusarvot näkyvät tekstikentässä kyselyeditorin alla, ja voit muokata niitä tarpeidesi mukaan. Ilman oletusarvoja esitellyt parametrit luovat tekstikentän, mutta ne ovat tyhjiä ja korostettu punaisena.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Datatyyppejä</h3> <p>Nämä ovat yleisiä datatyyppejä, joita voit käyttää:</p> <ul> <li><b>kokonaisluku</b> etumerkillinen nelitavuinen kokonaisluku</li> <li><b>teksti</b> vaihtelevanpituinen merkkijono</li> <li><b>totuusarvo</b> tosi/epätosi</li> <li><b>päivämäärä</b> kalenteripäivä (vuosi, kuukausi, päivä)</li> </ul> <p>Lisätietoja datatyypeistä saat <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>tältä verkkosivustolta</a>.</p>"
schema:
title: "Tietokannan rakenne"
filter: "Hae..."
sensitive: "Tämän sarakkeen sisältö voi sisältää erityisen arkaluontoista tai yksityistä tietoa. Noudata erityistä harkintaa käyttäessäsi sarakkeen sisältämää tietoa."
types:
bool:
yes: "Kyllä"
no: "Ei"
null_: "Tyhjä"
export: "Vie"
view_json: "Näytä JSON"
save: "Tallenna muutokset"
saverun: "Tallenna muutokset ja suorita"
run: "Suorita"
undo: "Kumoa muutokset"
edit: "Muokkaa"
delete: "Poista"
recover: "Palauta poistettu kysely"
download_json: "JSON"
download_csv: "CSV"
show_table: "Taulukko"
show_graph: "Kaavio"
others_dirty: "Kyselyyn tehdyt tallentamattomat muutokset menetetään, jos navigoit pois."
run_time: "Kysely suoritettu %{value} millisekunnissa."
result_count:
one: "%{count} tulos."
other: "%{count} tulosta."
max_result_count:
one: "Näytetään osuvin %{count} tulos."
other: "Näytetään osuvimmat %{count} tulosta."
query_name: "Kysely"
query_groups: "Ryhmät"
link: "Linkki:"
report_name: "Raportti"
query_description: "Kuvaus"
query_time: "Viimeksi suoritettu"
query_user: "Luonut"
column: "Sarake %{number}"
explain_label: "Sisällytä kyselysuunnitelma?"
save_params: "Aseta oletusarvot"
reset_params: "Nollaa"
search_placeholder: "Hae..."
no_search_results: "Pahoittelut, emme löytäneet tekstiäsi vastaavia tuloksia."
form:
errors:
invalid: "Virheellinen"
no_such_category: "Tällaista luokkaa ei ole"
no_such_group: "Tällaista ryhmää ei ole"
invalid_date: "%{date} on virheellinen päivämäärä"
invalid_time: "%{time} on virheellinen aika"
group:
reports: "Raportit"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Suorita Dataselaimen kyselyitä. Rajoita API-avain tiettyihin kyselyihin määrittämällä kyselyiden tunnuksia."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Lähetä käyttäjälle, ryhmälle tai sähköpostiosoitteeseen
query_id:
label: Dataselaimen kysely
query_params:
label: Dataselaimen kyselyparametrit
skip_empty:
label: Ohita yksityisviestin lähettäminen, jos tuloksia ei ole
attach_csv:
label: Liitä CSV-tiedosto yksityisviestiin
recurring_data_explorer_result_topic:
fields:
topic_id:
label: Ketju, johon kyselyn tulokset lähetetään
query_id:
label: Dataselaimen kysely
query_params:
label: Dataselaimen kyselyparametrit
skip_empty:
label: Ohita viestin lähettäminen, jos tuloksia ei ole
attach_csv:
label: Liitä CSV-tiedosto viestiin

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

fr:
js:
errors:
explorer:
no_semicolons: "Supprimez les points-virgules de la requête."
dirty: "Vous devez enregistrer la requête avant de l'exécuter."
explorer:
or: "ou"
admins_only: "L'explorateur de données n'est disponible que pour les administrateurs."
allow_groups: "Autoriser les groupes à accéder à cette requête"
title: "Explorateur de données"
create: "Nouvelle requête"
create_placeholder: "Nom de la requête…"
description_placeholder: "Saisissez une description ici"
import:
label: "Importer"
modal: "Importer une requête"
unparseable_json: "Fichier JSON non analysable."
wrong_json: "Fichier JSON incorrect. Un fichier JSON doit contenir un objet « query », qui doit au moins avoir une propriété « sql »."
help:
label: "Aide"
modal_title: "Aide de l'explorateur de données"
auto_resolution: "<h2>Résolution d'entité automatique</h2> <p>Lorsque votre requête renvoie un identifiant d'entité, l'explorateur de données peut le remplacer automatiquement par le nom de l'entité et d'autres informations utiles dans les résultats de la requête. La résolution automatique est disponible pour <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> et <i><b>badge_id</b></i>. Pour essayer cette fonction, exécutez cette requête :</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Création de paramètres personnalisés</h2> <p>Pour créer des paramètres personnalisés pour vos requêtes, placez ceci en haut de votre requête et respectez le format :</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Remarque : la première ligne avec [params] est requise, ainsi que les deux tirets qui la précèdent et chaque paramètre personnalisé que vous souhaitez déclarer.</i></p>"
default_values: "<h3>Valeurs par défaut</h3> <p>Vous pouvez déclarer des paramètres avec ou sans valeurs par défaut. Les valeurs par défaut s'afficheront dans un champ de texte sous l'éditeur de requête, que vous pourrez modifier selon vos besoins. Les paramètres déclarés sans valeurs par défaut généreront toujours un champ de texte, mais seront vides et surlignés en rouge.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Types de données</h3> <p>Voici les types de données courants que vous pouvez utiliser :</p> <ul> <li><b>integer</b> - entier signé à quatre octets</li> <li><b>text</b> - chaîne de caractères de longueur variable</li> <li><b>boolean</b> - vrai/faux</li> <li><b>date</b> - date calendaire (année, mois, jour)</li> </ul> <p>Pour plus d'informations concernant les types de données, visitez <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>ce site Web</a>.</p>"
schema:
title: "Schéma de la base de données"
filter: "Recherche…"
sensitive: "Le contenu de cette colonne peut contenir des informations particulièrement sensibles ou privées. Veuillez faire preuve de prudence lorsque vous utilisez ce contenu."
types:
bool:
yes: "Oui"
no: "Non"
null_: "Nul"
export: "Exporter"
view_json: "Afficher JSON"
save: "Enregistrer les modifications"
saverun: "Enregistrer les modifications et exécuter"
run: "Exécuter"
undo: "Annuler les modifications"
edit: "Modifier"
delete: "Supprimer"
recover: "Annuler la suppression de la requête"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tableau"
show_graph: "Graphique"
others_dirty: "Une requête comporte des modifications non enregistrées qui seront perdues si vous quittez la page"
run_time: "Requête exécutée en %{value} millisecondes."
result_count:
one: "%{count} résultat."
other: "%{count} résultats."
max_result_count:
one: "Affichage du premier (%{count}) résultat."
other: "Affichage des %{count} premiers résultats."
query_name: "Requête"
query_groups: "Groupes"
link: "Lien pour"
report_name: "Rapport"
query_description: "Description"
query_time: "Dernière exécution"
query_user: "Créée par"
column: "Colonne %{number}"
explain_label: "Inclure le plan d'exécution ?"
save_params: "Définir les valeurs par défaut"
reset_params: "Réinitialiser"
search_placeholder: "Recherche…"
no_search_results: "Désolé, nous n'avons trouvé aucun résultat correspondant à votre texte."
form:
errors:
invalid: "Invalide"
no_such_category: "Aucune catégorie de ce type"
no_such_group: "Aucun groupe de ce type"
invalid_date: "%{date} est une date invalide"
invalid_time: "%{time} est une heure invalide"
group:
reports: "Rapports"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Exécutez des requêtes de l'explorateur de données. Limitez la clé API à un ensemble de requêtes en spécifiant des ID de requêtes."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Envoyer à l'utilisateur, au groupe ou à l'adresse e-mail
query_id:
label: Requête de l'explorateur de données
query_params:
label: Paramètres de requête de l'explorateur de données
skip_empty:
label: Ignorer l'envoi d'un MP s'il n'y a aucun résultat
attach_csv:
label: Joindre le fichier CSV au MD
recurring_data_explorer_result_topic:
fields:
topic_id:
label: Le sujet dans lequel les résultats de la requête doivent être affichés
query_id:
label: Requête de l'explorateur de données
query_params:
label: Paramètres de requête de l'explorateur de données
skip_empty:
label: Ignorer la publication s'il n'y a aucun résultat
attach_csv:
label: Joindre le fichier CSV à la publication

View file

@ -0,0 +1,31 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

gl:
js:
explorer:
or: "ou"
import:
label: "Importar"
help:
label: "Axuda"
schema:
filter: "Buscar..."
types:
bool:
yes: "Si"
no: "Non"
export: "Exportar"
save: "Gardar os cambios"
edit: "Editar"
delete: "Eliminar"
query_groups: "Grupos"
query_description: "Descrición"
query_user: "Creado por"
reset_params: "Restabelecer"
search_placeholder: "Buscar..."
group:
reports: "Informes"

View file

@ -0,0 +1,122 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

he:
js:
errors:
explorer:
no_semicolons: "הסרת הנקודה פסיק מהשאילתה."
dirty: "עליך לשמור את השאילתה בטרם ההרצה."
explorer:
or: "או"
admins_only: "דפדפן הנתונים זמין למנהלים בלבד."
allow_groups: "לאפשר לקבוצות לגשת לשאילתה הזאת"
title: "דפדפן נתונים"
create: "יצירת חדשה"
create_placeholder: "שם השאילתה…"
description_placeholder: "יש להקליד תיאור כאן"
import:
label: "ייבוא"
modal: "ייבוא שאילתה"
unparseable_json: "קובץ JSON שאינו ניתן לפענוח."
wrong_json: "קובץ JSON שגוי. קובץ JSON צריך להכיל רכיב query, שצריך לכלול לפחות מאפיין sql."
help:
label: "עזרה"
modal_title: "עזרה עם דפדפן הנתונים"
auto_resolution: "<h2>אבחנת יישות אוטומטית</h2> <p>כאשר השאילתה שלך מחזירה מזהה יישות, סייר הנתונים עשוי להחליף אותו בשם היישות ובפרטים מועילים נוספים אוטומטית בתוצאות השאילתה. אבחנה אוטומטית זמינה עבור <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> ו־<i><b>badge_id</b></i>. כדי לנסות זאת יש להריץ את השאילתה הבאה:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>יצירת משתנים מותאמים אישית</h2> <p>כדי ליצור משתנים מותאמים אישית לשאילתות שלך, יש להציב זאת בראש השאילתה ולהיצמד לתבנית:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>לתשומך לבך: השורה הראשונה עם [params] נחוצה, בצירוף שני מינוסים לפניה וכל משתנה מותאם אישית שעליו ברצונך להכריז.</i></p>"
default_values: "<h3>ערכי בררת מחדל</h3> <p>ניתן להכריז על משתנים עם או בלי ערכי בררת מחדל. ערכי בררת מחדל יופיעו בשדה טקסט מעל לעורך השאילתות אותם ניתן לערוך בהתאם לצורך. משתנים שמוכרזים ללא ערכי בררת מחדל עדיין ייצרו שדה טקסט אך הם יהיו ריקים ויודגשו באדום.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>סוגי נתונים</h3> <p>להלן סוגי הנתונים הנפוצים בהם ניתן להשתמש:</p> <ul> <li><b>integer</b> - מספר שלם וחיובי בגודל ארבעה בתים</li> <li><b>text</b> - מחרוזת תווים באורך משתנה</li> <li><b>boolean</b> אמת/שקר</li> <li><b>date</b> - תאריך בלוח השנה (שנה, חודש, יום)</li> </ul> <p>למידע נוסף על סוגי נתונים ניתן לבקר <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>באתר הזה</a>.</p>"
schema:
title: "סכמת מסד הנתונים"
filter: "חיפוש…"
sensitive: "תכני העמודה הזו עשויים להכיל מידע רגיש או פרטי. נא לנקוט במשנה זהירות בעת השימוש בעמודה זו."
types:
bool:
yes: "כן"
no: "לא"
null_: "ריק"
export: "ייצוא"
view_json: "הצגת JSON"
save: "שמירת שינויים"
saverun: "שמירת השינויים והרצה"
run: "הרצה"
undo: "התעלמות מהשינויים"
edit: "עריכה"
delete: "מחיקה"
recover: "ביטול מחיקת השאילתה"
download_json: "JSON"
download_csv: "CSV"
show_table: "טבלה"
show_graph: "תרשים"
others_dirty: "בשאילתה נערכו שינויים שטרם נשמרו ועשויים ללכת לאיבוד עקב ניווט למקום אחר."
run_time: "השאילתה הושלמה תוך %{value} מילישניות."
result_count:
one: "תוצאה אחת."
two: "2 תוצאות."
many: "%{count} תוצאות."
other: "%{count} תוצאות."
max_result_count:
one: "התוצאה המובילה מופיעה (%{count})."
two: "%{count} התוצאות המובילות מופיעות."
many: "%{count} התוצאות המובילות מופיעות."
other: "%{count} התוצאות המובילות מופיעות."
query_name: "שאילתה"
query_groups: "קבוצות"
link: "קישור אל"
report_name: "דוח"
query_description: "תיאור"
query_time: "הרצה אחרונה"
query_user: "נוצר על ידי"
column: "עמודה %{number}"
explain_label: "לכלול תכנית שאילתה?"
save_params: "הגדרת בררות מחדל"
reset_params: "איפוס"
search_placeholder: "חיפוש…"
no_search_results: "לא הצלחנו למצוא תוצאות שעונות לטקסט שלך, עמך הסליחה."
form:
errors:
invalid: "שגוי"
no_such_category: "אין קטגוריה כזו"
no_such_group: "אין קבוצה כזו"
invalid_date: "%{date} הוא תאריך שגוי"
invalid_time: "%{time} היא שעה שגויה"
group:
reports: "דוחות"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "הרצת שאילתות של סייר נתונים. ניתן להגביל את מפתח ה־API לסדרה של שאילתות על ידי ציון מזהה שאילתה."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: שליחה למשתמש, לקבוצה או לדוא״ל
query_id:
label: שאילתת דפדפן נתונים
query_params:
label: משתני שאילתת דפדפן נתונים
skip_empty:
label: דילוג על שליחת הודעה פרטית אם אין תוצאות
attach_csv:
label: צירוף קובץ ה־CSV להודעה הפרטית
users_from_group:
label: שליחת הודעה פרטית לכל חבר בקבוצה
recurring_data_explorer_result_topic:
fields:
topic_id:
label: הנושא לפרסום תוצאות השאילתה אליו
query_id:
label: שאילתת דפדפן נתונים
query_params:
label: משתני שאילתת דפדפן נתונים
skip_empty:
label: דילוג על פרסום אם אין תוצאות
attach_csv:
label: צירוף קובץ ה־CSV לפוסט

View file

@ -0,0 +1,33 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

hr:
js:
explorer:
or: "ili"
help:
label: "Pomoć"
schema:
filter: "Pretraži..."
types:
bool:
yes: "Da"
no: "Ne"
export: "Izvoz"
save: "Zabilježi promjene"
edit: "Izmijeni"
delete: "Pobriši"
result_count:
one: "%{count} rezultat."
few: "%{count} rezultata."
other: "%{count} rezultata."
query_groups: "Grupe"
query_description: "Opis"
query_user: "Napravio"
reset_params: "Resetirati"
search_placeholder: "Pretraži..."
group:
reports: "Izvještaji"

View file

@ -0,0 +1,73 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

hu:
js:
errors:
explorer:
no_semicolons: "Pontosvesszők eltávolítása a lekérdezésből."
dirty: "Futtatás előtt el kell mentenie a lekérdezést."
explorer:
or: "vagy"
admins_only: "Az adatfelfedező csak az adminok számára érhető el."
allow_groups: "Engedélyezés csoportok számára, hogy hozzáférjenek ehhez a lekérdezéshez"
title: "Adatfelfedező"
create: "Új létrehozása"
create_placeholder: "Lekérdezés neve…"
description_placeholder: "Adjon meg egy leírást"
import:
label: "Importálás"
modal: "Lekérdezés importálása"
unparseable_json: "Nem értelmezhető JSON-fájl."
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."
help:
label: "Súgó"
modal_title: "Adatfelfedező súgó"
auto_resolution: "<h2>Automatikus entitásfeloldás</h2> <p>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: <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> és <i><b>badge_id</b></i>. Próbálja ki ezen lekérdezés futtatásával:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Egyéni paraméterek létrehozása</h2> <p>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:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>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.</i></p>"
default_values: "<h3>Alapértelmezett értékek</h3> <p>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..</p> <pre><code>-- [params]\n-- text :username = saját_felhasználónév\n-- int :age</code></pre>"
data_types: "<h3>Adattípusok</h3> <p>Itt szerepel néhány gyakori használható adattípus:</p> <ul> <li><b>integer</b> - előjeles négybájtos egész</li> <li><b>text</b> - változó hosszúságú karakterlánc</li> <li><b>boolean</b> igaz/hamis</li> <li><b>date</b> - naptári dátum (év, hó, nap)</li> </ul> <p>További információkért az adattípusokról, keresse fel <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>ezt a weboldalt</a>.</p>"
schema:
title: "Adatbázis-séma"
filter: "Keresés…"
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."
types:
bool:
yes: "Igen"
no: "Nem"
null_: "Null"
export: "Exportálás"
save: "Módosítások mentése"
saverun: "Módosítások mentése és futtatása"
run: "Futtatás"
undo: "Módosítások elvetése"
edit: "Szerkesztés"
delete: "Törlés"
recover: "Lekérdezés törlésének visszavonása"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tábla"
show_graph: "Grafikon"
others_dirty: "A lekérdezés nem mentett módosításokat tartalmaz, amelyek elvesznek, ha elnavigál."
run_time: "A lekérdezés %{value} ms alatt fejeződött be."
result_count:
one: "%{count} találat."
other: "%{count} találat."
query_name: "Lekérdezés"
query_groups: "Csoportok"
link: "Hivatkozás ehhez:"
report_name: "Jelentés"
query_description: "Leírás"
query_time: "Utolsó futás"
query_user: "Létrehozta:"
column: "%{number}. oszlop"
explain_label: "Beleveszi a lekérdezési tervet?"
save_params: "Alapértékek beállítása"
reset_params: "Alaphelyzet"
search_placeholder: "Keresés…"
no_search_results: "Sajnos nem találtunk a szövegének megfelelő találatot."
group:
reports: "Jelentések"

View file

@ -0,0 +1,59 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

hy:
js:
errors:
explorer:
no_semicolons: "Հեռացրեք կետ-ստորակատերերը հարցումից"
dirty: "Դուք պետք է պահպանեք հարցումը գործարկելուց առաջ"
explorer:
or: "կամ"
admins_only: "Տվյալների ուսումնասիրումը հասանելի է միասյն ադմիններին:"
title: "Տվյալների Ուսումնասիրում"
create: "Ստեղծել Նոր"
create_placeholder: "Հարցման անուն..."
description_placeholder: "Մուտքագրեք նկարագրություն այստեղ"
import:
label: "Ներմուծել"
modal: "Ներմուծել Հարցում"
help:
label: "Օգնություն"
schema:
title: "Տվյալների Բազայի Սխեմա"
filter: "Որոնում..."
sensitive: "Այս սյունակը կարող է պարունակել մասնակի անձնական կամ անձնական տեղեկություններ: Խնդրում ենք ուշադիր լինել այս սյունակի բովանդակությունն օգտագործելիս:"
types:
bool:
yes: "Այո"
no: "Ոչ"
null_: "Զրոյական"
export: "Արտահանել"
save: "Պահպանել Փոփոխությունները"
saverun: "Պահպանել փոփոխությունները և Գործարկել"
run: "Գործարկել"
undo: "Չեղարկել Փոփոխությունները"
edit: "Խմբագրել"
delete: "Ջնջել"
recover: "Վերականգնել Հարցումը"
download_json: "JSON"
download_csv: "CSV"
others_dirty: "Հարցումն ունի չպահպանված փոփոխություններ, որոնք կկորչեն, եթե Դուք տեղափոխվեք:"
result_count:
one: "%{count} արդյունք"
other: "%{count} արդյունք"
query_name: "Հարցում"
query_groups: "Խմբեր"
query_description: "Նկարագրություն"
query_time: "Վերջին գործարկումը"
query_user: "Ստեղծել է՝"
explain_label: "Ներառե՞լ հարցման պլանը:"
save_params: "Սահմանել Լռելյայններ"
reset_params: "Վերահաստատե;"
search_placeholder: "Որոնում..."
no_search_results: "Ներողություն, մենք չկարողացանք գտնել Ձեր տեքստի հետ համընկնող որևէ արդյունք:"
group:
reports: "Հաշվետվություններ"

View file

@ -0,0 +1,26 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

id:
js:
explorer:
or: "atau"
help:
label: "Bantuan"
schema:
filter: "Cari..."
types:
bool:
yes: "Ya"
no: "Tidak"
export: "Ekspor"
save: "Simpan perubahan"
edit: "Ubah"
delete: "Hapus"
query_groups: "Grup"
query_description: "Deskripsi"
reset_params: "Reset"
search_placeholder: "Cari..."

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

it:
js:
errors:
explorer:
no_semicolons: "Rimuovi i punti e virgola dalla query."
dirty: "È necessario salvare la query prima di eseguirla."
explorer:
or: "oppure"
admins_only: "Data explorer è disponibile solo per gli amministratori."
allow_groups: "Consenti ai gruppi di accedere a questa query"
title: "Data Explorer"
create: "Crea Nuovo"
create_placeholder: "Nome query..."
description_placeholder: "Inserisci una descrizione qui"
import:
label: "Importa"
modal: "Importa una query"
unparseable_json: "File JSON non analizzabile."
wrong_json: "File JSON errato. Un file JSON dovrebbe contenere un oggetto \"query\" contenente almeno una proprietà \"sql\"."
help:
label: "Guida"
modal_title: "Guida di Data Explorer"
auto_resolution: "<h2>Risoluzione automatica dell'entità</h2> <p>Quando la query restituisce l'ID di un'entità, Data Explorer può sostituirla automaticamente con il nome dell'entità e altre informazioni utili nei risultati delle query. La risoluzione automatica è disponibile per i dati <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> e <i><b>badge_id</b></i>. Fai una prova, eseguendo questa query:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Creazione di parametri personalizzati</h2> <p>Per creare parametri personalizzati per le query, inserisci questo codice nella parte superiore della query e segui il formato:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Nota: la prima riga con [params] è obbligatoria, come i due trattini che precedono e tutti i parametri personalizzati che intendi dichiarare.</i></p>"
default_values: "<h3>Valori predefiniti</h3> <p>È possibile dichiarare parametri con o senza valori predefiniti. I valori predefiniti verranno visualizzati in un campo di testo sotto l'editor di query, che può essere modificato in base alle esigenze. I parametri dichiarati senza valori predefiniti genereranno comunque un campo di testo, ma saranno vuoti ed evidenziati in rosso.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Tipi di dati</h3> <p>Di seguito sono riportati i tipi di dati comuni che puoi utilizzare:</p> <ul> <li><b>integer</b> - intero a quattro byte con segno</li> <li><b>text</b> - stringa di caratteri di lunghezza variabile</li> <li><b>boolean</b> valore vero/falso</li> <li><b>data</b> - data di calendario (anno, mese, giorno)</li> </ul> <p>Per ulteriori informazioni sui tipi di dati, visita <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>questo sito web</a>.</p>"
schema:
title: "Schema del Database "
filter: "Ricerca..."
sensitive: "Questa colonna potrebbe contenere dati sensibili o informazioni private. Occorre prestare particolare attenzione nell'uso dei contenuti di questa colonna."
types:
bool:
yes: "Sì"
no: "No"
null_: "Null"
export: "Esporta"
view_json: "Visualizza JSON"
save: "Salva Modifiche"
saverun: "Salva modifiche ed esegui"
run: "Esegui"
undo: "Annulla Modifiche"
edit: "Modifica"
delete: "Elimina"
recover: "Ripristina Query"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabella"
show_graph: "Grafico"
others_dirty: "Una query ha modifiche non salvate che andranno perse se esci."
run_time: "Query completata in %{value} ms."
result_count:
one: "%{count} risultato."
other: "%{count} risultati."
max_result_count:
one: "Visualizzazione del primo (%{count}) risultato"
other: "Visualizzazione dei primi %{count} risultati."
query_name: "Query"
query_groups: "Gruppi"
link: "Collegamento per"
report_name: "Rapporto"
query_description: "Descrizione"
query_time: "Ultima esecuzione"
query_user: "Creato da"
column: "Colonna %{number}"
explain_label: "Includere il piano query?"
save_params: "Imposta Predefiniti"
reset_params: "Reimposta"
search_placeholder: "Ricerca..."
no_search_results: "Spiacenti, non abbiamo trovato alcun risultato che corrisponda al tuo testo."
form:
errors:
invalid: "Non valido"
no_such_category: "Categoria inesistente"
no_such_group: "Gruppo inesistente"
invalid_date: "%{date} è una data non valida"
invalid_time: "%{time} è un orario non valido"
group:
reports: "Rapporti"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Esegui le query di Data Explorer. Limita la chiave API a un insieme di query specificando i loro ID."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Invia a utente, gruppo o e-mail
query_id:
label: Query di Data Explorer
query_params:
label: Parametri della query di Data Explorer
skip_empty:
label: Salta l'invio di MP se non ci sono risultati
attach_csv:
label: Allega il file CSV al MP
recurring_data_explorer_result_topic:
fields:
topic_id:
label: L'argomento in cui pubblicare i risultati della query
query_id:
label: Query di Data Explorer
query_params:
label: Parametri della query di Data Explorer
skip_empty:
label: Salta la pubblicazione se non ci sono risultati
attach_csv:
label: Allega il file CSV all'argomento

View file

@ -0,0 +1,114 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ja:
js:
errors:
explorer:
no_semicolons: "クエリからセミコロンを削除します。"
dirty: "クエリは保存してから実行する必要があります。"
explorer:
or: "または"
admins_only: "データエクスプローラーは管理者のみが利用できます。"
allow_groups: "このクエリへのアクセスをグループに許可する"
title: "データエクスプローラー"
create: "新規作成"
create_placeholder: "クエリ名..."
description_placeholder: "説明をここに入力"
import:
label: "インポート"
modal: "クエリをインポート"
unparseable_json: "解析不能な JSON ファイルです。"
wrong_json: "不正な JSON ファイル。JSON ファイルには 'query' オブジェクトと、その中に少なくとも 'sql' プロパティが含まれている必要があります。"
help:
label: "ヘルプ"
modal_title: "データエクスプローラーのヘルプ"
auto_resolution: "<h2>エンティティの自動解決</h2> <p>クエリがエンティティ ID を返す場合、データエクスプローラーはクエリ結果において、それを自動的にエンティティ名とその他の有用な情報に置き換えることができます。自動解決は、<i><b>user_id</b></i>、<i><b>group_id</b></i>、<i><b>topic_id</b></i>、<i><b>category_id</b></i>、および <i><b>badge_id</b></i> で利用できます。次のクエリを実行して試してください。</p>\n<pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>カスタムパラメーターの作成</h2> <p>クエリのカスタムパラメーターを作成するには、次のコードをクエリの先頭に配置し、フォーマットに従います。</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>注意: 最初の [params] の行は必須です。また、その前と宣言するカスタムパラメーターの前に 2 つのダッシュが必要です。</i></p>"
default_values: "<h3>デフォルト値</h3> <p>パラメーターはデフォルト値の有無に関係なく宣言できます。デフォルト値はクエリエディターの下のテキストフィールドに表示され、必要に応じて編集することができます。パラメーターはデフォルト値なしで宣言された場合でもテキストフィールドを生成しますが、空となり、赤でハイライトされます。</p>\n<pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>データ型</h3> <p>以下は、使用できる共通のデータ型です。</p> <ul> <li><b>整数</b> - 符号付き 4 バイト整数</li> <li><b>テキスト</b> - 可変長文字列</li> <li><b>ブール</b> true/false</li> <li><b>日付</b> - 暦日(年、月、日)</li> </ul> <p>データ型の詳細については、<a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>こちらのウェブサイト</a>をご覧ください。</p>"
schema:
title: "データベーススキーマ"
filter: "検索..."
sensitive: "この列のコンテンツには、機密性の特に高い情報または個人情報が含まれることがあります。この列のコンテンツを使用する際には十分注意してください。"
types:
bool:
yes: "はい"
no: "いいえ"
null_: "Null"
export: "エクスポート"
view_json: "JSON を表示"
save: "変更を保存"
saverun: "変更を保存して実行"
run: "実行"
undo: "変更を破棄"
edit: "編集"
delete: "削除"
recover: "クエリの削除を取り消す"
download_json: "JSON"
download_csv: "CSV"
show_table: "テーブル"
show_graph: "グラフ"
others_dirty: "クエリには保存されていない変更があり、移動すると失われます。"
run_time: "クエリは %{value} ミリ秒で完了しました。"
result_count:
other: "%{count} 件の結果。"
max_result_count:
other: "上位 %{count} 件を表示中。"
query_name: "クエリ"
query_groups: "グループ"
link: "リンク"
report_name: "レポート"
query_description: "説明"
query_time: "最終実行"
query_user: "作成者"
column: "列 %{number}"
explain_label: "クエリプランを含めますか?"
save_params: "デフォルトを設定"
reset_params: "リセット"
search_placeholder: "検索..."
no_search_results: "残念ながら、テキストに一致する結果は見つかりませんでした。"
form:
errors:
invalid: "無効です"
no_such_category: "そのようなカテゴリはありません"
no_such_group: "そのようなグループはありません"
invalid_date: "%{date} は無効な日付です"
invalid_time: "%{time} は無効な時刻です"
group:
reports: "レポート"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "データエクスプローラーのクエリを実行します。クエリ ID を指定して、API キーを一連のクエリに制限します。"
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: ユーザー、グループ、またはメールに送る
query_id:
label: データエクスプローラーのクエリ
query_params:
label: データエクスプローラーのクエリパラメーター
skip_empty:
label: 結果がない場合は PM の送信をスキップする
attach_csv:
label: CSV ファイルを PM に添付する
recurring_data_explorer_result_topic:
fields:
topic_id:
label: クエリ結果を投稿するトピック
query_id:
label: データエクスプローラーのクエリ
query_params:
label: データエクスプローラーのクエリパラメーター
skip_empty:
label: 結果が無い場合は投稿をスキップする
attach_csv:
label: CSV ファイルを投稿に添付する

View file

@ -0,0 +1,61 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ko:
js:
errors:
explorer:
no_semicolons: "쿼리에서 세미콜론을 제거하십시오."
dirty: "실행하기 전에 쿼리를 저장해야합니다."
explorer:
or: "또는"
admins_only: "데이터 탐색기는 관리자 만 사용할 수 있습니다."
allow_groups: "그룹이이 쿼리에 액세스하도록 허용"
title: "데이터 탐색기"
create: "새로 만들기"
create_placeholder: "검색어 이름 ..."
description_placeholder: "여기에 설명을 입력하세요"
import:
label: "가져오기"
modal: "검색어 가져 오기"
help:
label: "도움말"
schema:
title: "데이터베이스 스키마"
filter: "검색..."
sensitive: "이 열의 내용에는 특히 민감한 정보 또는 개인 정보가 포함될 수 있습니다. 이 열의 내용을 사용할 때는주의하십시오."
types:
bool:
yes: "네"
no: "아니오"
null_: "없는"
export: "내보내기"
save: "변경사항 저장"
saverun: "변경 사항 저장 및 실행"
run: "운영"
undo: "변경 사항을 취소"
edit: "수정"
delete: "삭제"
recover: "삭제 취소 쿼리"
download_json: "JSON"
download_csv: "CSV"
others_dirty: "검색어에 저장하지 않은 변경 사항이있어 이동하면 사라집니다."
result_count:
other: "%{count}개의 결과."
query_name: "쿼리"
query_groups: "그룹"
link: "링크"
report_name: "보고서"
query_description: "설명"
query_time: "마지막 실행"
query_user: "작성자"
explain_label: "쿼리 계획을 포함 하시겠습니까?"
save_params: "기본값 설정"
reset_params: "리셋"
search_placeholder: "검색..."
no_search_results: "죄송합니다. 귀하의 텍스트와 일치하는 결과를 찾을 수 없습니다."
group:
reports: "보고서"

View file

@ -0,0 +1,36 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

lt:
js:
explorer:
or: "arba"
import:
label: "Importuoti"
help:
label: "Pagalba"
schema:
filter: "Paieška..."
types:
bool:
yes: "Taip"
no: "Ne"
export: "Eksportuoti"
save: "Išsaugoti pakeitimus"
edit: "Redaguoti"
delete: "Pašalinti"
result_count:
one: "%{count} rezultatas."
few: "%{count} rezultatai."
many: "%{count} rezultatai."
other: "%{count} rezultatai."
query_groups: "Grupės"
query_description: "Aprašymas"
query_user: "Sukurta"
reset_params: "Atstatyti"
search_placeholder: "Paieška..."
group:
reports: "Pranešimai"

View file

@ -0,0 +1,28 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

lv:
js:
explorer:
or: "vai"
import:
label: "Importēt"
help:
label: "Palīdzība"
schema:
filter: "Meklēt..."
types:
bool:
yes: "Jā"
no: "Nē"
export: "Eksportēt"
save: "Saglabāt izmaiņas"
edit: "Labot"
delete: "Dzēst"
query_groups: "Grupas"
query_description: "Apraksts"
reset_params: "Atlikt"
search_placeholder: "Meklēt..."

View file

@ -0,0 +1,47 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

nb_NO:
js:
errors:
explorer:
no_semicolons: "Fjern semikolonene fra spørringen."
dirty: "Du må lagre spørringen før du kjører den."
explorer:
or: "eller"
admins_only: "Datautforskeren er kun tilgjengelig for administratorer."
title: "Datautforsker"
create: "Opprett ny"
create_placeholder: "Spørringsnavn…"
import:
label: "Importer"
modal: "Importer en spørring"
help:
label: "Hjelp"
schema:
filter: "Søk…"
types:
bool:
yes: "Ja"
no: "Nei"
null_: "Null"
export: "Eksporter"
save: "Lagre endringer"
undo: "Forkast endringer"
edit: "Rediger"
delete: "Slett"
recover: "Angre sletting av spørring"
download_json: "JSON"
download_csv: "CSV"
others_dirty: "En spørring har ulagrede endringer, og vil gå tapt hvis du navigerer vekk."
query_groups: "Grupper"
query_description: "Beskrivelse"
explain_label: "Inkluder spørringsplan?"
save_params: "Sett forvalg"
reset_params: "Tilbakestill"
search_placeholder: "Søk…"
group:
reports: "Rapporter"

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

nl:
js:
errors:
explorer:
no_semicolons: "Verwijder de puntkomma's uit de query."
dirty: "Je moet de query opslaan voordat je deze uitvoert."
explorer:
or: "of"
admins_only: "De gegevensverkenner is alleen beschikbaar voor beheerders."
allow_groups: "Groepen hebben toegang tot deze query"
title: "Gegevensverkenner"
create: "Nieuwe maken"
create_placeholder: "Querynaam..."
description_placeholder: "Voer hier een beschrijving in"
import:
label: "Importeren"
modal: "Een query importeren"
unparseable_json: "Onparseerbaar JSON-bestand."
wrong_json: "Onjuist JSON-bestand. Een JSON-bestand moet een 'query'-object bevatten, dat minimaal een 'sql'-eigenschap moet hebben."
help:
label: "Help"
modal_title: "Gegevensverkenner-help"
auto_resolution: "<h2>Automatische omzetting van entiteiten</h2> <p>Wanneer je query een entiteit-ID retourneert, kan Gegevensverkenner deze automatisch vervangen door de entiteitnaam en andere nuttige informatie in queryresultaten. Automatische omzetting is beschikbaar voor <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> en <i><b>badge_id</b></i>. Voer deze query uit om dit te proberen:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Aangepaste parameters maken</h2> <p>Om aangepaste parameters te maken voor je query's, plaats je dit bovenaan je query en volg je de notatie:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Opmerking : de eerste regel met [params] is vereist, samen met twee streepjes ervoor en elke aangepaste parameter die je wilt declareren.</i></p>"
default_values: "<h3>Standaardwaarden</h3> <p>Je kunt parameters declareren met of zonder standaardwaarde. Standaardwaarden worden weergegeven in een tekstveld onder de query-editor, die je naar wens kunt bewerken. Parameters die zijn gedeclareerd zonder standaardwaarde leveren nog steeds een tekstveld op, maar zijn leeg en rood gemarkeerd.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Gegevenstypen</h3> <p>Hier volgen algemene gegevenstypen die je kunt gebruiken:</p> <ul> <li><b>integer</b> - positief of negatief geheel getal van vier bytes</li> <li><b>text</b> - tekenreeks met variabele lengte</li> <li><b>boolean</b> true/false</li> <li><b>date</b> - kalenderdatum (jaar, maand, dag)</li> </ul> <p>Ga voor meer informatie over gegevenstypen naar <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>deze website</a>.</p>"
schema:
title: "Databaseschema"
filter: "Zoeken..."
sensitive: "De inhoud van deze kolom kan bijzonder gevoelige of privégegevens bevatten. Wees voorzichtig bij gebruik van de inhoud van deze kolom."
types:
bool:
yes: "Ja"
no: "Nee"
null_: "Null"
export: "Exporteren"
view_json: "JSON weergeven"
save: "Wijzigingen opslaan"
saverun: "Wijzigingen opslaan en uitvoeren"
run: "Uitvoeren"
undo: "Wijzigingen negeren"
edit: "Bewerken"
delete: "Verwijderen"
recover: "Query verwijderen ongedaan maken"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabel"
show_graph: "Grafiek"
others_dirty: "Een query heeft niet-opgeslagen wijzigingen die verloren gaan als je de pagina verlaat."
run_time: "Query voltooid in %{value} ms."
result_count:
one: "%{count} resultaat."
other: "%{count} resultaten."
max_result_count:
one: "Eerste %{count} resultaat wordt weergegeven."
other: "Eerste %{count} resultaten worden weergegeven."
query_name: "Query"
query_groups: "Groepen"
link: "Link voor"
report_name: "Rapport"
query_description: "Beschrijving"
query_time: "Laatste uitvoering"
query_user: "Gemaakt door"
column: "Kolom %{number}"
explain_label: "Queryplan bijvoegen?"
save_params: "Standaardwaarden instellen"
reset_params: "Herstellen"
search_placeholder: "Zoeken..."
no_search_results: "Er zijn geen resultaten gevonden voor je tekst."
form:
errors:
invalid: "Ongeldig"
no_such_category: "Geen dergelijke categorie"
no_such_group: "Geen dergelijke groep"
invalid_date: "%{date} is een ongeldige datum"
invalid_time: "%{time} is een ongeldige tijd"
group:
reports: "Rapporten"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Voer Gegevensverkenner-query's uit. Beperk de API-sleutel tot een reeks query's door query-ID's op te geven."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Verzenden naar gebruiker, groep of e-mailadres
query_id:
label: Gegevensverkenner-query
query_params:
label: Gegevensverkenner-queryparameters
skip_empty:
label: PB verzenden overslaan als er geen resultaten zijn
attach_csv:
label: Voeg het CSV-bestand bij de PB
recurring_data_explorer_result_topic:
fields:
topic_id:
label: Het topic om queryresultaten in te plaatsen
query_id:
label: Gegevensverkenner-query
query_params:
label: Gegevensverkenner-queryparameters
skip_empty:
label: Plaatsen overslaan als er geen resultaten zijn
attach_csv:
label: Voeg het CSV-bestand bij het bericht

View file

@ -0,0 +1,98 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

pl_PL:
js:
errors:
explorer:
no_semicolons: "Usuń średniki z zapytania."
dirty: "Musisz zapisać zapytanie zanim je wykonasz."
explorer:
or: "lub"
admins_only: "Eksplorator danych jest dostępny wyłącznie dla administratorów."
allow_groups: "Zezwól grupom na dostęp do tego zapytania"
title: "Eksplorator danych"
create: "Stwórz nowy"
create_placeholder: "Nazwa zapytania…"
description_placeholder: "Wprowadź opis zapytania"
import:
label: "Import"
modal: "Importuj zapytanie"
unparseable_json: "Nieprzetwarzalny plik JSON."
wrong_json: "Nieprawidłowy plik JSON. Plik JSON powinien zawierać obiekt 'query', który powinien mieć przynajmniej właściwość 'sql'."
help:
label: "Pomoc"
modal_title: "Pomoc eksploratora danych"
auto_resolution: "<h2>Automatyczne rozpoznawanie encji</h2> <p>Gdy Twoje zapytanie zwróci identyfikator encji, eksplorator danych może automatycznie zastąpić go nazwą encji i innymi przydatnymi informacjami w wynikach zapytania. Automatyczne rozpoznawanie jest dostępne dla <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> i <i><b>badge_id</b></i>. Aby to wypróbować, wykonaj następujące zapytanie:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Tworzenie niestandardowych parametrów</h2> <p>Aby utworzyć niestandardowe parametry dla zapytań, umieść to na początku zapytania i postępuj zgodnie z formatem:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Uwaga: pierwszy wiersz z [params] jest wymagany, wraz z dwoma myślnikami poprzedzającymi go i każdym niestandardowym parametrem, który chcesz zadeklarować.</i></p>"
default_values: "<h3>Wartości domyślne</h3> <p>Możesz zadeklarować parametry z wartościami domyślnymi lub bez nich. Wartości domyślne pojawią się w polu tekstowym poniżej edytora zapytań, które możesz edytować zgodnie z własnymi potrzebami. Parametry zadeklarowane bez wartości domyślnych nadal będą generować pole tekstowe, ale będzie ono puste i podświetlone na czerwono.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Typy danych</h3> <p>Tutaj są powszechnie używane typy danych, których możesz użyć:</p> <ul> <li><b>liczba całkowita</b> - podpisane czterobajtowe liczby całkowite</li> <li><b>tekst</b> - ciąg znaków o zmiennej długości</li> <li><b>liczba logiczna</b> - prawda/fałsz</li> <li><b>data</b> - data kalendarzowa (rok, miesiąc, dzień)</li> </ul> <p>Aby uzyskać więcej informacji na temat typów danych, odwiedź <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>tę stronę</a></p>"
schema:
title: "Schemat bazy danych"
filter: "Wyszukiwanie..."
sensitive: "Zawartość tej kolumny może zawierać szczególnie wrażliwe lub prywatne dane. Prosimy o zachowanie ostrożności przy używaniu tych danych."
types:
bool:
yes: "Tak"
no: "Nie"
null_: "Null"
export: "Eksport"
view_json: "Wyświetl JSON"
save: "Zapisz zmiany"
saverun: "Zapisz zmiany i wykonaj"
run: "Wykonaj"
undo: "Odrzuć zmiany"
edit: "Edytuj"
delete: "Usuń"
recover: "Przywróć zapytanie"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabela"
show_graph: "Wykres"
others_dirty: "Zapytanie ma niezapisane zmiany, które utracisz, jeśli przejdziesz w inne miejsce."
run_time: "Zapytanie zakończone w %{value} ms."
result_count:
one: "%{count}wynik."
few: "%{count}wyniki."
many: "%{count}wyników."
other: "%{count}wyników."
query_name: "Zapytanie"
query_groups: "Grupy"
link: "Link do"
report_name: "Zgłoszenie"
query_description: "Opis"
query_time: "Ostatnio wykonano"
query_user: "Stworzone przez"
column: "Kolumna %{number}"
explain_label: "Dołączyć plan zapytania?"
save_params: "Ustaw domyślne"
reset_params: "Przywróć"
search_placeholder: "Wyszukiwanie..."
no_search_results: "Nie znaleziono żadnych wyników."
group:
reports: "Zgłoszenia"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Uruchamiaj zapytania eksploratora danych. Ogranicz klucz API do zestawu zapytań, określając identyfikatory zapytań."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Wyślij do użytkownika, grupy lub na adres e-mail
query_id:
label: Zapytanie eksploratora danych
query_params:
label: Parametry zapytania eksploratora danych
recurring_data_explorer_result_topic:
fields:
query_id:
label: Zapytanie eksploratora danych
query_params:
label: Parametry zapytania eksploratora danych

View file

@ -0,0 +1,55 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

pt:
js:
errors:
explorer:
no_semicolons: "Remova os pontos e vírgulas da consulta."
dirty: "Deve guardar a consulta antes de executar."
explorer:
or: "ou"
admins_only: "O explorador de dados está disponível apenas para os administradores."
title: "Explorador de Dados"
create: "Criar Nova"
create_placeholder: "Nome da consulta..."
import:
label: "Importar"
modal: "Importar Uma Consulta"
help:
label: "Ajuda"
schema:
title: "Esquema da Base de Dados"
filter: "Pesquisar..."
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."
types:
bool:
yes: "Sim"
no: "Não"
export: "Exportar"
save: "Guardar alterações"
saverun: "Guardar Alterações e Executar"
run: "Executar"
undo: "Ignorar Alterações"
edit: "Editar"
delete: "Eliminar"
recover: "Recuperar Consulta"
download_json: "JSON"
download_csv: "CSV"
others_dirty: "Uma consulta tem alterações não guardadas que serão perdidas se navegar para outro lado."
run_time: "Consulta concluída em %{value} ms."
result_count:
one: "%{count} resultado."
other: "%{count} resultados."
query_groups: "Grupos"
query_description: "Descrição"
column: "Coluna %{number}"
explain_label: "Incluir plano de consulta?"
save_params: "Definir Predefinições"
reset_params: "Repor"
search_placeholder: "Pesquisar..."
group:
reports: "Relatórios"

View file

@ -0,0 +1,116 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

pt_BR:
js:
errors:
explorer:
no_semicolons: "Remover os pontos e vírgulas da consulta."
dirty: "Você precisa salvar a consulta antes de executar."
explorer:
or: "ou"
admins_only: "O explorador de dados só está disponível para administradores."
allow_groups: "Permitir que grupos acessem esta consulta"
title: "Explorador de Dados"
create: "Criar Nova"
create_placeholder: "Nome da consulta…"
description_placeholder: "Insira uma descrição aqui"
import:
label: "Importar"
modal: "Importar Uma Consulta"
unparseable_json: "Arquivo JSON não analisável"
wrong_json: "Arquivo JSON incorreto. Um arquivo JSON deve conter um objeto \"consulta\", que deve ter pelo menos a propriedade \"sql\""
help:
label: "Ajuda"
modal_title: "Ajuda do Explorador de Dados"
auto_resolution: "<h2>Resolução de entidade automática</h2> <p>Quando a sua consulta retorna uma id de entidade, o Explorador de Dados pode substituí-la automaticamente pelo nome da entidade e outras informações úteis nos resultados da consulta. A resolução automática está disponível para <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> e <i><b>badge_id</b></i>. Para tentar isso, execute esta consulta:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Criação de parâmetros personalizados/h2> <p>Para criar parâmetros personalizados para suas consultas, insira isto no topo da sua consulta e siga o formato:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Observação: a primeira linha com [params] é necessária, junto com dois traços antes dela e de cada parâmetro personalizado que você deseja criar.</i></p>"
default_values: "<h3>Valores padrão</h3> <p>Você pode declarar parâmetros com ou sem valores padrão. Valores padrão serão exibidos em um campo de texto abaixo do editor de consultas, que pode ser editado conforme necessário. Parâmetros declarados sem valores padrão ainda irão gerar um campo de texto, mas estarão vazios e destacados em vermelho.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Tipos de dados</h3> <p>Estes são os tipos de dados comuns que você pode usar:</p> <ul> <li><b>integer</b> - Integer assinado de quatro bytes</li> <li><b>text</b> - cadeia de caracteres de tamanho variado</li> <li><b>boolean</b> verdadeiro/falso</li> <li><b>data</b> - data do calendário (ano, mês, dia)</li> </ul> <p>Para mais informações sobre tipos de dados, acesse <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>este site</a>.</p>"
schema:
title: "Esquema de Banco de Dados"
filter: "Pesquisar…"
sensitive: "O conteúdo desta coluna pode conter informações particularmente sensíveis ou privadas. Por favor, tenha cuidado ao usar o conteúdo desta coluna."
types:
bool:
yes: "Sim"
no: "Não"
null_: "Nulo"
export: "Exportar"
view_json: "Visualizar JSON"
save: "Salvar Mudanças"
saverun: "Salvar Mudanças e Executar"
run: "Executar"
undo: "Descartar Mudanças"
edit: "Editar"
delete: "Apagar"
recover: "Desfazer Exclusão da Consulta"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabela"
show_graph: "Gráfico"
others_dirty: "Uma consulta tem alterações não salvas que serão perdidas se você sair desta página."
run_time: "Consulta concluída em %{value} ms."
result_count:
one: "%{count} resultado."
other: "%{count} resultados."
max_result_count:
one: "Mostrando melhor %{count} resultado."
other: "Mostrando melhores %{count} resultados."
query_name: "Consulta"
query_groups: "Grupos"
link: "Link para"
report_name: "Reportar"
query_description: "Descrição"
query_time: "Última execução"
query_user: "Criado por"
column: "Coluna %{number}"
explain_label: "Incluir plano de consulta?"
save_params: "Definir Padrões"
reset_params: "Redefinir"
search_placeholder: "Pesquisar..."
no_search_results: "Desculpe, não conseguimos encontrar nenhum resultado contendo o seu texto."
form:
errors:
invalid: "Inválido"
no_such_category: "Não há esta categoria"
no_such_group: "Não há este grupo"
invalid_date: "%{date} é uma data inválida"
invalid_time: "%{time} é um horário inválido"
group:
reports: "Relatórios"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Execute consultas do Explorador de Dados. Restrinja a chave de API a um conjunto de consultas ao especificar suas IDs."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Enviar ao(à) usuário(a), grupo ou e-mail
query_id:
label: Consulta do explorador de dados
query_params:
label: Parâmetros da consulta do explorador de dados
skip_empty:
label: Pular envio de PM se não houver resultados
attach_csv:
label: Anexe um arquivo CSV à MP
recurring_data_explorer_result_topic:
fields:
topic_id:
label: O tópico no qual postar os resultados da consulta
query_id:
label: Consulta do Data Explorer
query_params:
label: Parâmetros da consulta do Data Explorer
skip_empty:
label: Pular postagem se não houver resultados
attach_csv:
label: Anexe um arquivo CSV à postagem

View file

@ -0,0 +1,30 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ro:
js:
explorer:
or: "sau"
import:
label: "Importă"
help:
label: "Ajutor"
schema:
filter: "Caută..."
types:
bool:
yes: "Da"
no: "Nu"
export: "Exportă"
save: "Salvează schimbările"
edit: "Editează"
delete: "Șterge"
query_groups: "Grupuri"
query_description: "Descriere"
reset_params: "Resetează"
search_placeholder: "Caută..."
group:
reports: "Rapoarte"

View file

@ -0,0 +1,120 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

ru:
js:
errors:
explorer:
no_semicolons: "Удалите точку с запятой из запроса."
dirty: "Вы должны сохранить запрос перед его выполнением."
explorer:
or: "или"
admins_only: "Исследование данных доступно только для администраторов."
allow_groups: "Разрешить группам доступ к этому запросу"
title: "Проводник данных"
create: "Создать"
create_placeholder: "Имя запроса..."
description_placeholder: "Введите описание"
import:
label: "Импорт"
modal: "Импорт запроса"
unparseable_json: "Невозможно распарсить JSON-файл."
wrong_json: "Неверный формат JSON-файла. Файл должен содержать объект 'запрос', у которого как минимум должно быть свойство 'sql'."
help:
label: "Справка"
modal_title: "Проводник данных. Справка."
auto_resolution: "<h2>Автоматическое разрешение</h2> <p>Когда запрос возвращает идентификатор объекта, проводник данных может автоматически заменить его именем объекта и другой полезной информацией в результатах запроса. Автоматическое разрешение доступно для <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> и <i><b>badge_id</b></i>. Для пробы запустите этот запрос:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Создание настраиваемых параметров</h2> <p>Чтобы создать настраиваемые параметры для запроса, поместите эти данные в верхней части запроса, придерживаясь следующего формата:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Примечание: первая строка с [params] обязательна, с двумя дефисами перед ней и всеми настраиваемыми параметрами, которые вы хотите объявить.</i></p>"
default_values: "<h3>Значения по умолчанию</h3> <p>Вы можете объявлять параметры со значениями по умолчанию или без таковых. Значения по умолчанию будут отображаться в текстовом поле под редактором запросов, эти значения вы можете менять по своему усмотрению. Параметры, объявленные без значений по умолчанию, по-прежнему будут создавать текстовое поле, но такие поля будут пустыми и будут выделены красным цветом.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Типы данных</h3> <p>Основные типы данных, которые можно использовать:</p> <ul> <li><b>целое число</b> — знаковое четырехбайтовое целое число,</li> <li><b>текст</b> — строка символов переменной длины,</li> <li><b>логическое значение</b> — истина, ложь,</li> <li><b>дата</b> — календарная дата (год, месяц, день).</li> </ul> <p>Подробнее о типах данных смотрите на <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>этом веб-сайте</a>.</p>"
schema:
title: "Схема базы данных"
filter: "Поиск..."
sensitive: "Эта колонка может содержать конфиденциальную информацию. Будьте осторожны при использовании её содержимого."
types:
bool:
yes: "Да"
no: "Нет"
null_: "Null"
export: "Экспорт"
view_json: "Просмотреть JSON"
save: "Сохранить изменения"
saverun: "Сохранить изменения и выполнить запрос"
run: "Выполнить запрос"
undo: "Отменить изменения"
edit: "Редактировать"
delete: "Удалить"
recover: "Восстановить запрос"
download_json: "JSON"
download_csv: "CSV"
show_table: "Таблица"
show_graph: "График"
others_dirty: "Запрос имеет несохранённые изменения, которые будут потеряны, если вы уйдёте со страницы."
run_time: "Запрос завершен за %{value} мс."
result_count:
one: "%{count} результат."
few: "%{count} результата."
many: "%{count} результатов."
other: "%{count} результатов."
max_result_count:
one: "Показан %{count} лучший результат."
few: "Показаны %{count} лучших результата."
many: "Показаны %{count} лучших результатов."
other: "Показаны %{count} лучшего результата."
query_name: "Запрос"
query_groups: "Группы"
link: "Ссылка для"
report_name: "Отчёт"
query_description: "Описание"
query_time: "Время выполнения последнего запроса"
query_user: "Создано"
column: "Колонка %{number}"
explain_label: "Отобразить план запроса"
save_params: "Установка значений по умолчанию"
reset_params: "Сброс"
search_placeholder: "Поиск..."
no_search_results: "К сожалению, мы не смогли найти результаты, соответствующие вашему запросу."
form:
errors:
invalid: "Недопустимое значение"
no_such_category: "Нет такой категории"
no_such_group: "Нет такой группы"
invalid_date: "%{date} — недопустимая дата"
invalid_time: "%{time} — недопустимое время"
group:
reports: "Отчёты"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
run_queries: "Выполнение запросов проводника данных. Ограничьте ключ API набором запросов, указав идентификаторы запросов."
discourse_automation:
scriptables:
recurring_data_explorer_result_pm:
fields:
recipients:
label: Отправить пользователю, в группу или на адрес электронной почты
query_id:
label: Запрос проводника данных
query_params:
label: Параметры запроса проводника данных
skip_empty:
label: Пропустить отправку личного сообщения, если нет результатов
attach_csv:
label: Прикрепить CSV-файл к личному сообщению
recurring_data_explorer_result_topic:
fields:
topic_id:
label: Тема для публикации результатов запроса
query_id:
label: Запрос проводника данных
query_params:
label: Параметры запроса для проводника данных
skip_empty:
label: Пропустить публикацию, если нет результатов
attach_csv:
label: Прикрепить CSV-файл к публикации

View file

@ -0,0 +1,42 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

sk:
js:
explorer:
or: "alebo"
admins_only: "Prieskumník údajov je dostupný iba administrátorom."
title: "Prieskumník údajov"
create: "Vztvoriť novú"
import:
label: "Import"
help:
label: "Pomoc"
schema:
filter: "Hľadať"
types:
bool:
yes: "Áno"
no: "Nie"
export: "Exportovať"
save: "Uložiť zmeny"
edit: "Upraviť"
delete: "Zmazať"
download_json: "JSON"
download_csv: "CSV"
result_count:
one: "%{count} výsledok."
few: "%{count} výsledkov."
many: "%{count} výsledky."
other: "%{count} výsledky."
query_groups: "Skupiny"
query_description: "Popis"
query_user: "Vytvoril"
save_params: "Predvolené"
reset_params: "Resetovať"
search_placeholder: "Hľadať"
group:
reports: "Hlásenia"

View file

@ -0,0 +1,68 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

sl:
js:
errors:
explorer:
no_semicolons: "Odstranite podpičja iz poizvedbe."
dirty: "Pred zagonom morate poizvedbo shraniti."
explorer:
or: "ali"
admins_only: "Raziskovalec podatkov je na voljo samo skrbnikom."
allow_groups: "Dovoli skupinam dostop do te poizvedbe"
title: "Raziskovalec podatkov"
create: "Ustvari novo"
create_placeholder: "Ime poizvedbe..."
description_placeholder: "Vnesite opis tukaj"
import:
label: "Uvozi"
modal: "Uvozi poizvedbo"
help:
label: "Pomoč"
schema:
title: "Shema zbirke podatkov"
filter: "Išči..."
sensitive: "Vsebina tega stolpca lahko vsebuje posebno občutljive ali zasebne podatke. Pri uporabi vsebine tega stolpca bodite previdni."
types:
bool:
yes: "Da"
no: "Ne"
null_: "Null"
export: "Izvozi"
save: "Shrani spremembe"
saverun: "Shrani spremembe in zaženi"
run: "Zaženi"
undo: "Zavrzi spremembe"
edit: "Uredi"
delete: "Izbriši"
recover: "Povrni poizvedbo"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabela"
show_graph: "Graf"
others_dirty: "Poizvedba ima neshranjene spremembe, ki se bodo izgubile, če boste šli drugam."
run_time: "Poizvedba končana v %{value} ms."
result_count:
one: "%{count} zadetek."
two: "%{count} zadetka."
few: "%{count} zadetki."
other: "%{count} zadetkov."
query_name: "Poizvedba"
query_groups: "Skupine"
link: "Povezava za"
report_name: "Poročilo"
query_description: "Opis"
query_time: "Zadnja izvedba"
query_user: "Ustvaril"
column: "Stolpec %{number}"
explain_label: "Vključi načrt poizvedbe?"
save_params: "Nastavi privzete vrednosti"
reset_params: "Ponastavi"
search_placeholder: "Išči..."
no_search_results: "Oprostite, nismo našli nobenega zadetka, ki bi ustrezal vaši poizvedbi."
group:
reports: "Poročila"

View file

@ -0,0 +1,23 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

sq:
js:
explorer:
or: "ose"
import:
label: "Importo"
types:
bool:
yes: "Po"
no: "Jo"
export: "Eksporto"
save: "Ruaj ndryshimet"
edit: "Redakto"
delete: "Fshij"
query_groups: "Grupet"
query_description: "Përshkrimi"
reset_params: "Rivendos"

View file

@ -0,0 +1,20 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

sr:
js:
explorer:
or: "ili"
types:
bool:
yes: "Da"
no: "Ne"
export: "Izvoz"
save: "Sačuvaj izmene"
edit: "Izmeni"
delete: "Obriši"
query_groups: "Grupe"
query_description: "Opis"

View file

@ -0,0 +1,82 @@
# WARNING: Never edit this file.
# It will be overwritten when translations are pulled from Crowdin.
#
# To work with us on translations, join this project:
# https://translate.discourse.org/

sv:
js:
errors:
explorer:
no_semicolons: "Ta bort semikolon från sökningen."
dirty: "Du måste spara sökningen innan du kör."
explorer:
or: "eller"
admins_only: "Datautforskaren är endast tillgänglig för administratörer."
allow_groups: "Tillåt att grupper går till denna sökning"
title: "Datautforskaren"
create: "Skapa ny"
create_placeholder: "Sökningsnamn..."
description_placeholder: "Ange en beskrivning här"
import:
label: "Importera"
modal: "Importera en sökning"
unparseable_json: "JSON-fil kan inte tolkas."
wrong_json: "Fel JSON-fil. En JSON-fil bör innehålla ett 'query'-objekt, som åtminstone bör ha en 'sql'-egenskap."
help:
label: "Hjälp"
modal_title: "Hjälp med Datautforskaren"
auto_resolution: "<h2>Automatisk enhetsmatchning</h2> <p>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 <i><b>user_id</b></i>, <i><b>group_id</b></i>, <i><b>topic_id</b></i>, <i><b>category_id</b></i> och <i><b>badge_id</b></i>. För att prova detta kör du den här frågan:</p> <pre><code>SELECT user_id\nFROM posts</code></pre>"
custom_params: "<h2>Skapa anpassade parametrar</h2> <p>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:</p> <pre><code>-- [params]\n-- int :num = 1\n\nSELECT :num</code></pre> <p><i>Obs: Den första raden med [params] krävs, med två bindestreck före den och varje anpassad parameter som du vill ange.</i></p>"
default_values: "<h3>Standardvärden</h3> <p>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.</p> <pre><code>-- [params]\n-- text :username = my_username\n-- int :age</code></pre>"
data_types: "<h3>Datatyper</h3> <p>Här är vanliga datatyper som du kan använda:</p> <ul> <li><b>heltal</b> - signerat fyra byte-heltal</li> <li><b>text</b> - teckensträng av varierande längd</li> <li><b>boolesk</b> sant/falsk</li> <li><b>datum</b> - kalenderdatum (år, månad, dag)</li> </ul> <p>För mer information om datatyper, besök <a href='http://www.postgresql.org/docs/9.3/static/datatype.html#DATATYPE-TABLE' target='_blank'>denna webbplats</a>.</p>"
schema:
title: "Databasschema"
filter: "Sök..."
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."
types:
bool:
yes: "Ja"
no: "Nej"
null_: "Tomt"
export: "Exportera"
save: "Spara ändringar"
saverun: "Spara ändringar och kör"
run: "Kör"
undo: "Ignorera ändringar"
edit: "Redigera"
delete: "Radera"
recover: "Återställ sökning"
download_json: "JSON"
download_csv: "CSV"
show_table: "Tabell"
show_graph: "Graf"
others_dirty: "En sökning har ändringar som inte har sparats och kommer att förloras om du lämnar sidan."
run_time: "Sökning slutförd efter %{value} ms."
result_count:
one: "%{count} resultat."
other: "%{count} resultat."
max_result_count:
one: "Visar det %{count} översta resultatet."
other: "Visar de %{count} översta resultaten."
query_name: "Sökning"
query_groups: "Grupper"
link: "Länk för"
report_name: "Rapport"
query_description: "Beskrivning"
query_time: "Senast körd"
query_user: "Skapad av"
column: "Kolumn %{number}"
explain_label: "Inkludera sökningsplan?"
save_params: "Ange förvalda värden"
reset_params: "Återställ"
search_placeholder: "Sök..."
no_search_results: "Tyvärr kunde vi inte hitta några resultat som stämmer överens med din text."
group:
reports: "Rapporter"
admin:
api:
scopes:
descriptions:
discourse_data_explorer:
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."

Some files were not shown because too many files have changed in this diff Show more