pgkiln 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SECURITY.md +3 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/01-installation.md +5 -0
- package/docs/guide/03-builder.md +140 -11
- package/docs/guide/04-pages-and-regions.md +25 -1
- package/docs/guide/12-development.md +47 -11
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/package.json +1 -1
- package/public/app.css +6 -0
- package/public/builder.css +95 -3
- package/public/code-editor.css +32 -79
- package/public/code-editor.js +58 -3
- package/public/data-modeler.css +17 -14
- package/public/worksheet.css +14 -7
- package/public/worksheet.js +119 -3
- package/src/app.ts +5 -1
- package/src/builder/changelog.ts +1 -1
- package/src/builder/data-modeler.ts +1 -1
- package/src/builder/datasets.ts +472 -0
- package/src/builder/db-users.ts +2 -2
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/object-browser.ts +4 -4
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/quicksql.ts +1 -1
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +3 -3
- package/src/builder/scheduling.ts +2 -2
- package/src/builder/scripts.ts +1 -1
- package/src/builder/sql-charts.ts +1 -1
- package/src/builder/sql-monitor.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/table-editor.ts +2 -2
- package/src/builder/ui.ts +7 -5
- package/src/builder/worksheet.ts +171 -23
- package/src/builder/workspace-home.ts +283 -0
- package/src/builder/workspaces.ts +7 -4
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +16 -0
- package/src/i18n/cs.ts +16 -0
- package/src/i18n/da.ts +16 -0
- package/src/i18n/de.ts +16 -0
- package/src/i18n/el.ts +16 -0
- package/src/i18n/es.ts +16 -0
- package/src/i18n/fi.ts +16 -0
- package/src/i18n/fr.ts +16 -0
- package/src/i18n/he.ts +16 -0
- package/src/i18n/it.ts +16 -0
- package/src/i18n/ja.ts +16 -0
- package/src/i18n/ko.ts +16 -0
- package/src/i18n/nb.ts +16 -0
- package/src/i18n/pl.ts +16 -0
- package/src/i18n/pt.ts +16 -0
- package/src/i18n/ru.ts +16 -0
- package/src/i18n/sv.ts +16 -0
- package/src/i18n/tr.ts +16 -0
- package/src/i18n/uk.ts +16 -0
- package/src/i18n/zh.ts +16 -0
- package/src/i18n.ts +32 -0
- package/src/instance.ts +1 -0
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/session.ts +7 -0
- package/src/sqlworksheet.ts +9 -6
- package/src/subscriptions.ts +2 -1
- package/src/worksheet-txn.ts +270 -0
- package/src/workspace-export.ts +24 -4
package/src/datasets.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import { heading } from './runtime/items.ts';
|
|
3
|
+
import { OPERATORS } from './runtime/report.ts';
|
|
4
|
+
|
|
5
|
+
// (sprint 110, migration 120) Data Reporter datasets: a reusable, trusted view of a workspace's data for business
|
|
6
|
+
// users. A dataset is tables and views of one schema, joined (each table after the first joins to an earlier one on
|
|
7
|
+
// column pairs; the builder proposes the pairs from foreign keys), the columns users may see with labels, format
|
|
8
|
+
// masks and descriptions, and an optional row filter. Stored in meta.dataset per workspace; a Data Reporter region
|
|
9
|
+
// offers one as a source ({"id", "dataset"} in config.sources).
|
|
10
|
+
//
|
|
11
|
+
// checkDataset() keeps only what is well formed (and, with a catalog, what exists); datasetSql() builds the
|
|
12
|
+
// dataset's SELECT: identifiers come only from the checked definition and are always quoted, values are escaped
|
|
13
|
+
// literals, operators come from the report filter whitelist. The runtime wraps it as a subquery and runs every
|
|
14
|
+
// report on it as the application's database role.
|
|
15
|
+
|
|
16
|
+
export interface DatasetJoin {
|
|
17
|
+
/** the alias of an earlier table */
|
|
18
|
+
to: string;
|
|
19
|
+
type: 'inner' | 'left';
|
|
20
|
+
/** column of this table = ref column of the earlier table */
|
|
21
|
+
on: { column: string; ref: string }[];
|
|
22
|
+
}
|
|
23
|
+
export interface DatasetTable {
|
|
24
|
+
alias: string;
|
|
25
|
+
table: string;
|
|
26
|
+
join?: DatasetJoin;
|
|
27
|
+
}
|
|
28
|
+
export interface DatasetColumn {
|
|
29
|
+
/** the name users and report definitions use (a static id) */
|
|
30
|
+
name: string;
|
|
31
|
+
/** the alias of its table */
|
|
32
|
+
table: string;
|
|
33
|
+
column: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
format?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface DatasetFilter {
|
|
39
|
+
table: string;
|
|
40
|
+
column: string;
|
|
41
|
+
op: string;
|
|
42
|
+
value: string;
|
|
43
|
+
}
|
|
44
|
+
export interface Dataset {
|
|
45
|
+
name: string;
|
|
46
|
+
label: string;
|
|
47
|
+
description?: string;
|
|
48
|
+
schema: string;
|
|
49
|
+
tables: DatasetTable[];
|
|
50
|
+
columns: DatasetColumn[];
|
|
51
|
+
filters: DatasetFilter[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const DATASET_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
55
|
+
export const DATASET_LIMITS = { tables: 10, columns: 200, filters: 5, joinColumns: 4, label: 80, description: 500, columnDescription: 300, format: 40, value: 200 };
|
|
56
|
+
const RESERVED = /^(pg_.*|information_schema|meta)$/i;
|
|
57
|
+
const ALIAS = /^t[1-9][0-9]?$/;
|
|
58
|
+
|
|
59
|
+
/** The columns of the schema's tables and views (name → column names), for checks against the catalog. */
|
|
60
|
+
export type Catalog = Map<string, Set<string>>;
|
|
61
|
+
|
|
62
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
63
|
+
const clip = (v: unknown, max: number) => str(v).replace(/\0/g, '').trim().slice(0, max);
|
|
64
|
+
const arr = (v: unknown): any[] => (Array.isArray(v) ? v : []);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A dataset with only what is well formed: valid names, joins to earlier tables, columns and filters on its tables,
|
|
68
|
+
* whitelisted operators, bounded counts and lengths. With a catalog, tables and columns must also exist. Anything
|
|
69
|
+
* else is dropped and reported in errors (in words for the developer).
|
|
70
|
+
*/
|
|
71
|
+
export function checkDataset(loose: any, catalog?: Catalog): { dataset: Dataset; errors: string[] } {
|
|
72
|
+
const errors: string[] = [];
|
|
73
|
+
const name = str(loose?.name).trim().toLowerCase();
|
|
74
|
+
if (!DATASET_NAME.test(name)) errors.push(`"${name}" is not a valid dataset name: lower case letters, digits and _, starting with a letter (at most 40).`);
|
|
75
|
+
const label = clip(loose?.label, DATASET_LIMITS.label) || name;
|
|
76
|
+
const description = clip(loose?.description, DATASET_LIMITS.description) || undefined;
|
|
77
|
+
const schema = str(loose?.schema).trim();
|
|
78
|
+
if (!schema || schema.length > 63 || RESERVED.test(schema)) errors.push(`The schema "${schema}" can't be used for a dataset.`);
|
|
79
|
+
|
|
80
|
+
const tables: DatasetTable[] = [];
|
|
81
|
+
const has = (table: string, column: string) => !catalog || !!catalog.get(table)?.has(column);
|
|
82
|
+
for (const t of arr(loose?.tables)) {
|
|
83
|
+
const table = str(t?.table);
|
|
84
|
+
const alias = str(t?.alias);
|
|
85
|
+
if (!table || table.length > 63 || !ALIAS.test(alias) || tables.some((x) => x.alias === alias)) {
|
|
86
|
+
errors.push(`A table entry "${table || '?'}" is not valid.`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (catalog && !catalog.has(table)) {
|
|
90
|
+
errors.push(`The table or view ${schema}.${table} does not exist (any more).`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (tables.length >= DATASET_LIMITS.tables) {
|
|
94
|
+
errors.push(`A dataset has at most ${DATASET_LIMITS.tables} tables.`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (!tables.length) {
|
|
98
|
+
tables.push({ alias, table });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const j = t?.join;
|
|
102
|
+
const to = tables.find((x) => x.alias === str(j?.to));
|
|
103
|
+
const on = arr(j?.on)
|
|
104
|
+
.map((p: any) => ({ column: str(p?.column), ref: str(p?.ref) }))
|
|
105
|
+
.filter((p) => p.column && p.ref && to && has(table, p.column) && has(to.table, p.ref))
|
|
106
|
+
.slice(0, DATASET_LIMITS.joinColumns);
|
|
107
|
+
if (!to || !on.length) {
|
|
108
|
+
errors.push(`The table ${table} needs a join to an earlier table on existing columns.`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
tables.push({ alias, table, join: { to: to.alias, type: j?.type === 'inner' ? 'inner' : 'left', on } });
|
|
112
|
+
}
|
|
113
|
+
if (!tables.length) errors.push('A dataset needs at least one table or view.');
|
|
114
|
+
|
|
115
|
+
const tableOf = (alias: string) => tables.find((x) => x.alias === alias);
|
|
116
|
+
const columns: DatasetColumn[] = [];
|
|
117
|
+
for (const c of arr(loose?.columns)) {
|
|
118
|
+
const t = tableOf(str(c?.table));
|
|
119
|
+
const column = str(c?.column);
|
|
120
|
+
const cname = str(c?.name).trim().toLowerCase();
|
|
121
|
+
if (!t || !column || !has(t.table, column)) continue;
|
|
122
|
+
if (!DATASET_NAME.test(cname)) {
|
|
123
|
+
errors.push(`"${cname}" is not a valid column name: lower case letters, digits and _, starting with a letter.`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (columns.some((x) => x.name === cname)) {
|
|
127
|
+
errors.push(`Two columns are named ${cname}.`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (columns.length >= DATASET_LIMITS.columns) break;
|
|
131
|
+
const lbl = clip(c?.label, DATASET_LIMITS.label);
|
|
132
|
+
const format = clip(c?.format, DATASET_LIMITS.format);
|
|
133
|
+
const desc = clip(c?.description, DATASET_LIMITS.columnDescription);
|
|
134
|
+
columns.push({ name: cname, table: t.alias, column, ...(lbl ? { label: lbl } : {}), ...(format ? { format } : {}), ...(desc ? { description: desc } : {}) });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const filters: DatasetFilter[] = [];
|
|
138
|
+
for (const f of arr(loose?.filters)) {
|
|
139
|
+
const t = tableOf(str(f?.table));
|
|
140
|
+
const column = str(f?.column);
|
|
141
|
+
const op = str(f?.op);
|
|
142
|
+
if (!t || !column || !has(t.table, column) || !Object.hasOwn(OPERATORS, op)) continue;
|
|
143
|
+
const value = OPERATORS[op].noValue ? '' : str(f?.value).replace(/\0/g, '').slice(0, DATASET_LIMITS.value);
|
|
144
|
+
if (!OPERATORS[op].noValue && value === '') continue;
|
|
145
|
+
if (filters.length >= DATASET_LIMITS.filters) break;
|
|
146
|
+
filters.push({ table: t.alias, column, op, value });
|
|
147
|
+
}
|
|
148
|
+
return { dataset: { name, label, ...(description ? { description } : {}), schema, tables, columns, filters }, errors };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const id = (s: string) => pg.escapeIdentifier(s);
|
|
152
|
+
|
|
153
|
+
/** The dataset's SELECT: its columns under their names, from its joined tables, with its row filter. */
|
|
154
|
+
export function datasetSql(d: Dataset): string {
|
|
155
|
+
const cols = d.columns.length ? d.columns : [];
|
|
156
|
+
const select = cols.map((c) => `${id(c.table)}.${id(c.column)} as ${id(c.name)}`);
|
|
157
|
+
const from = d.tables
|
|
158
|
+
.map((t, i) =>
|
|
159
|
+
i === 0 || !t.join
|
|
160
|
+
? `${id(d.schema)}.${id(t.table)} ${id(t.alias)}`
|
|
161
|
+
: `${t.join.type === 'inner' ? 'join' : 'left join'} ${id(d.schema)}.${id(t.table)} ${id(t.alias)} on ${t.join.on.map((p) => `${id(t.alias)}.${id(p.column)} = ${id(t.join!.to)}.${id(p.ref)}`).join(' and ')}`,
|
|
162
|
+
)
|
|
163
|
+
.join(' ');
|
|
164
|
+
const where = d.filters.map((f) => OPERATORS[f.op].sql(`${id(f.table)}.${id(f.column)}`, f.value));
|
|
165
|
+
return `select ${select.length ? select.join(', ') : 'null as "__none"'} from ${from}${where.length ? ` where ${where.join(' and ')}` : ''}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A dataset row of meta.dataset as a definition. */
|
|
169
|
+
export const datasetOf = (row: { name: string; label: string; description?: string | null; schema_name: string; tables: unknown; columns: unknown; filters: unknown }) =>
|
|
170
|
+
checkDataset({ name: row.name, label: row.label, description: row.description, schema: row.schema_name, tables: row.tables, columns: row.columns, filters: row.filters }).dataset;
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------- files
|
|
173
|
+
|
|
174
|
+
export const DATASET_FORMAT = 'pgkiln-dataset/1';
|
|
175
|
+
|
|
176
|
+
/** Datasets as a JSON file. */
|
|
177
|
+
export function datasetFile(list: Dataset[]) {
|
|
178
|
+
return `${JSON.stringify({ format: DATASET_FORMAT, datasets: list }, null, 2)}\n`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The datasets of a file (one dataset, or {format, datasets}); throws an Error with a message for the developer. */
|
|
182
|
+
export function readDatasetFile(text: string): any[] {
|
|
183
|
+
let doc: any;
|
|
184
|
+
try {
|
|
185
|
+
doc = JSON.parse(text);
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error('The file is not valid JSON.');
|
|
188
|
+
}
|
|
189
|
+
if (doc && typeof doc === 'object' && doc.format !== undefined && doc.format !== DATASET_FORMAT) throw new Error(`Unknown format "${String(doc.format).slice(0, 40)}": expected ${DATASET_FORMAT}.`);
|
|
190
|
+
const list = Array.isArray(doc?.datasets) ? doc.datasets : doc && typeof doc === 'object' && doc.tables ? [doc] : null;
|
|
191
|
+
if (!list?.length) throw new Error('The file has no datasets.');
|
|
192
|
+
if (list.length > 50) throw new Error('A file has at most 50 datasets.');
|
|
193
|
+
return list;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A name for a column that the dataset doesn't use yet (from the column's name, or table_column). */
|
|
197
|
+
export function freeColumnName(taken: Set<string>, table: string, column: string) {
|
|
198
|
+
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 40);
|
|
199
|
+
let base = norm(column) || 'col';
|
|
200
|
+
if (taken.has(base)) base = norm(`${table}_${column}`) || base;
|
|
201
|
+
let n = base;
|
|
202
|
+
for (let i = 2; taken.has(n); i++) n = `${base.slice(0, 36)}_${i}`;
|
|
203
|
+
return n;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------- the catalog
|
|
207
|
+
|
|
208
|
+
/** A table or view of a schema with its columns (name and type, in table order). */
|
|
209
|
+
export interface CatalogObject {
|
|
210
|
+
name: string;
|
|
211
|
+
kind: 'table' | 'view';
|
|
212
|
+
columns: { name: string; type: string; numeric: boolean }[];
|
|
213
|
+
}
|
|
214
|
+
/** A foreign key between two tables of the schema: table(columns) references ref(refColumns). */
|
|
215
|
+
export interface CatalogFk {
|
|
216
|
+
table: string;
|
|
217
|
+
columns: string[];
|
|
218
|
+
ref: string;
|
|
219
|
+
refColumns: string[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type Queryable = { query: <T extends pg.QueryResultRow = any>(sql: string, params?: unknown[]) => Promise<pg.QueryResult<T>> };
|
|
223
|
+
|
|
224
|
+
/** The tables and views of a schema with their columns, and the foreign keys inside it (read as the given connection). */
|
|
225
|
+
export async function readSchemaCatalog(db: Queryable, schema: string): Promise<{ objects: CatalogObject[]; fks: CatalogFk[] }> {
|
|
226
|
+
if (RESERVED.test(schema)) return { objects: [], fks: [] };
|
|
227
|
+
const objects = (
|
|
228
|
+
await db.query<CatalogObject>(
|
|
229
|
+
`select c.relname as name, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
|
|
230
|
+
coalesce(json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod),
|
|
231
|
+
'numeric', t.typcategory = 'N') order by a.attnum)
|
|
232
|
+
filter (where a.attnum > 0), '[]') as columns
|
|
233
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
234
|
+
left join pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
|
235
|
+
left join pg_type t on t.oid = a.atttypid
|
|
236
|
+
where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and has_schema_privilege(n.oid, 'USAGE')
|
|
237
|
+
group by c.relname, c.relkind order by c.relname`,
|
|
238
|
+
[schema],
|
|
239
|
+
)
|
|
240
|
+
).rows;
|
|
241
|
+
const fks = (
|
|
242
|
+
await db.query<CatalogFk>(
|
|
243
|
+
`select c.relname as table, f.relname as ref,
|
|
244
|
+
array(select a.attname::text from unnest(k.conkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.n order by u.i) as columns,
|
|
245
|
+
array(select a.attname::text from unnest(k.confkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.confrelid and a.attnum = u.n order by u.i) as "refColumns"
|
|
246
|
+
from pg_constraint k join pg_class c on c.oid = k.conrelid join pg_class f on f.oid = k.confrelid
|
|
247
|
+
where k.contype = 'f' and c.relnamespace = $1::regnamespace and f.relnamespace = $1::regnamespace
|
|
248
|
+
order by c.relname, k.conname`,
|
|
249
|
+
[schema],
|
|
250
|
+
)
|
|
251
|
+
).rows;
|
|
252
|
+
return { objects, fks };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The catalog as checkDataset() wants it. */
|
|
256
|
+
export const catalogOf = (objects: CatalogObject[]): Catalog => new Map(objects.map((o) => [o.name, new Set(o.columns.map((c) => c.name))]));
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The join of a table to one of the earlier tables, proposed from foreign keys (either direction; the first match).
|
|
260
|
+
* null: no foreign key links it to them.
|
|
261
|
+
*/
|
|
262
|
+
export function proposeJoin(table: string, earlier: DatasetTable[], fks: CatalogFk[]): DatasetJoin | null {
|
|
263
|
+
for (const t of earlier) {
|
|
264
|
+
// this table references the earlier one (a child joined to its parent)
|
|
265
|
+
const out = fks.find((f) => f.table === table && f.ref === t.table);
|
|
266
|
+
if (out) return { to: t.alias, type: 'left', on: out.columns.map((c, i) => ({ column: c, ref: out.refColumns[i] })).slice(0, DATASET_LIMITS.joinColumns) };
|
|
267
|
+
// the earlier one references this table (a parent joined to the child before it)
|
|
268
|
+
const back = fks.find((f) => f.table === t.table && f.ref === table);
|
|
269
|
+
if (back) return { to: t.alias, type: 'left', on: back.refColumns.map((c, i) => ({ column: c, ref: back.columns[i] })).slice(0, DATASET_LIMITS.joinColumns) };
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A new dataset on tables and views of a schema: joins proposed from foreign keys (a table without one joins to
|
|
276
|
+
* nothing and is reported), every column except binary ones, labelled from its name; numeric columns get a number mask.
|
|
277
|
+
*/
|
|
278
|
+
export function proposeDataset(name: string, label: string, schema: string, tableNames: string[], cat: { objects: CatalogObject[]; fks: CatalogFk[] }): { dataset: Dataset; errors: string[] } {
|
|
279
|
+
const errors: string[] = [];
|
|
280
|
+
const tables: DatasetTable[] = [];
|
|
281
|
+
// the tables in the order chosen, but a table that joins to none of the earlier ones waits for the others
|
|
282
|
+
const pending = tableNames.filter((t, i) => tableNames.indexOf(t) === i && cat.objects.some((o) => o.name === t)).slice(0, DATASET_LIMITS.tables);
|
|
283
|
+
let progress = true;
|
|
284
|
+
while (pending.length && progress) {
|
|
285
|
+
progress = false;
|
|
286
|
+
for (let i = 0; i < pending.length; i++) {
|
|
287
|
+
const table = pending[i];
|
|
288
|
+
const alias = `t${tables.length + 1}`;
|
|
289
|
+
if (!tables.length) tables.push({ alias, table });
|
|
290
|
+
else {
|
|
291
|
+
const join = proposeJoin(table, tables, cat.fks);
|
|
292
|
+
if (!join) continue;
|
|
293
|
+
tables.push({ alias, table, join });
|
|
294
|
+
}
|
|
295
|
+
pending.splice(i, 1);
|
|
296
|
+
i--;
|
|
297
|
+
progress = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const t of pending) errors.push(`No foreign key links ${t} to the other tables: add it on the dataset's page with a join of your own.`);
|
|
301
|
+
const taken = new Set<string>();
|
|
302
|
+
const columns: DatasetColumn[] = [];
|
|
303
|
+
for (const t of tables) {
|
|
304
|
+
const obj = cat.objects.find((o) => o.name === t.table)!;
|
|
305
|
+
// a join column of a later table repeats the earlier one's: offered once
|
|
306
|
+
const joined = new Set(t.join?.on.map((p) => p.column) ?? []);
|
|
307
|
+
for (const c of obj.columns) {
|
|
308
|
+
if (c.type === 'bytea' || joined.has(c.name) || columns.length >= DATASET_LIMITS.columns) continue;
|
|
309
|
+
const n = freeColumnName(taken, t.table, c.name);
|
|
310
|
+
taken.add(n);
|
|
311
|
+
columns.push({ name: n, table: t.alias, column: c.name, label: heading(c.name), ...(c.numeric && /^(numeric|real|double|money)/.test(c.type) ? { format: '999G999G990D00' } : {}) });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { dataset: { name, label: label || name, schema, tables, columns, filters: [] }, errors };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------- storage
|
|
318
|
+
|
|
319
|
+
export interface DatasetRow {
|
|
320
|
+
id: number;
|
|
321
|
+
workspace_id: number;
|
|
322
|
+
name: string;
|
|
323
|
+
label: string;
|
|
324
|
+
description: string | null;
|
|
325
|
+
schema_name: string;
|
|
326
|
+
tables: DatasetTable[];
|
|
327
|
+
columns: DatasetColumn[];
|
|
328
|
+
filters: DatasetFilter[];
|
|
329
|
+
updated_at: string;
|
|
330
|
+
updated_by: string | null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The datasets of a workspace (name order). */
|
|
334
|
+
export async function workspaceDatasets(db: Queryable, workspaceId: number): Promise<DatasetRow[]> {
|
|
335
|
+
return (await db.query<DatasetRow>('select * from meta.dataset where workspace_id = $1 order by name', [workspaceId])).rows;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Insert or replace a checked dataset of a workspace (by name); returns its id. */
|
|
339
|
+
export async function storeDataset(db: Queryable, workspaceId: number, d: Dataset, by: string | null, opts: { replace?: boolean } = {}): Promise<number> {
|
|
340
|
+
const vals = [workspaceId, d.name, d.label, d.description ?? null, d.schema, JSON.stringify(d.tables), JSON.stringify(d.columns), JSON.stringify(d.filters), by];
|
|
341
|
+
const res = await db.query<{ id: number }>(
|
|
342
|
+
`insert into meta.dataset (workspace_id, name, label, description, schema_name, tables, columns, filters, created_by, updated_by)
|
|
343
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
|
344
|
+
on conflict (workspace_id, name) do ${opts.replace === false ? 'nothing' : `update
|
|
345
|
+
set label = excluded.label, description = excluded.description, schema_name = excluded.schema_name, tables = excluded.tables,
|
|
346
|
+
columns = excluded.columns, filters = excluded.filters, updated_by = excluded.updated_by, updated_at = now()`}
|
|
347
|
+
returning id`,
|
|
348
|
+
vals,
|
|
349
|
+
);
|
|
350
|
+
return res.rows[0]?.id ?? 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The datasets an application offers, by name, as definitions (read through the runtime's own login: row level
|
|
355
|
+
* security shows an isolated workspace's login its own workspace's datasets only).
|
|
356
|
+
*/
|
|
357
|
+
export async function appDatasets(db: Queryable, appId: number, names: string[]): Promise<Map<string, Dataset>> {
|
|
358
|
+
if (!names.length) return new Map();
|
|
359
|
+
const rows = (
|
|
360
|
+
await db.query(
|
|
361
|
+
`select name, label, description, schema_name, tables, columns, filters from meta.dataset
|
|
362
|
+
where workspace_id = meta.dataset_workspace($1) and name = any($2::text[])`,
|
|
363
|
+
[appId, names],
|
|
364
|
+
)
|
|
365
|
+
).rows;
|
|
366
|
+
return new Map(rows.map((r) => [r.name as string, datasetOf(r)]));
|
|
367
|
+
}
|
package/src/i18n/ar.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const ar: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "اختر البيانات لبدء تقرير: حدد الأعمدة، صفِّ، جمّع مع المجاميع، أضف مخططًا، ثم احفظه أو شاركه.",
|
|
687
687
|
'reporter.columns_count': "{n} أعمدة",
|
|
688
688
|
'reporter.search': "البحث في التقارير",
|
|
689
|
+
'reporter.published_reports': "التقارير المنشورة",
|
|
690
|
+
'reporter.published_tag': "منشور",
|
|
691
|
+
'reporter.publish': "نشر",
|
|
692
|
+
'reporter.unpublish': "إلغاء النشر",
|
|
693
|
+
'reporter.publish_on_save': "نشر لجميع مستخدمي التطبيق",
|
|
694
|
+
'reporter.published': "تم نشر التقرير \"{name}\".",
|
|
695
|
+
'reporter.unpublished': "تم إلغاء نشر التقرير \"{name}\".",
|
|
696
|
+
'reporter.pin': "تثبيت",
|
|
697
|
+
'reporter.unpin': "إلغاء التثبيت",
|
|
698
|
+
'reporter.pinned': "المثبتة",
|
|
699
|
+
'reporter.pinned_msg': "تم تثبيت التقرير \"{name}\".",
|
|
700
|
+
'reporter.unpinned_msg': "تم إلغاء تثبيت التقرير \"{name}\".",
|
|
701
|
+
'reporter.link': "رابط هذا التقرير",
|
|
702
|
+
'reporter.open_link': "فتح",
|
|
703
|
+
'reporter.no_published': "لا توجد تقارير منشورة بعد.",
|
|
704
|
+
'reporter.welcome_viewer': "افتح تقريرًا منشورًا: بياناته محدثة دائمًا.",
|
|
689
705
|
'reporter.changed': "عُدِّل في {date}",
|
|
690
706
|
'reporter.no_match': "لا توجد تقارير مطابقة للبحث.",
|
|
691
707
|
'reporter.chart_tag': "مخطط",
|
package/src/i18n/cs.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const cs: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Vyberte data pro novou sestavu: sloupce, filtry, seskupení se součty a graf, potom ji uložte nebo sdílejte.",
|
|
687
687
|
'reporter.columns_count': "{n} sloupců",
|
|
688
688
|
'reporter.search': "Hledat sestavy",
|
|
689
|
+
'reporter.published_reports': "Publikované sestavy",
|
|
690
|
+
'reporter.published_tag': "publikováno",
|
|
691
|
+
'reporter.publish': "Publikovat",
|
|
692
|
+
'reporter.unpublish': "Zrušit publikování",
|
|
693
|
+
'reporter.publish_on_save': "Publikovat pro všechny uživatele aplikace",
|
|
694
|
+
'reporter.published': "Sestava „{name}“ publikována.",
|
|
695
|
+
'reporter.unpublished': "Publikování sestavy „{name}“ zrušeno.",
|
|
696
|
+
'reporter.pin': "Připnout",
|
|
697
|
+
'reporter.unpin': "Odepnout",
|
|
698
|
+
'reporter.pinned': "Připnuté",
|
|
699
|
+
'reporter.pinned_msg': "Sestava „{name}“ připnuta.",
|
|
700
|
+
'reporter.unpinned_msg': "Sestava „{name}“ odepnuta.",
|
|
701
|
+
'reporter.link': "Odkaz na tuto sestavu",
|
|
702
|
+
'reporter.open_link': "Otevřít",
|
|
703
|
+
'reporter.no_published': "Zatím nejsou publikovány žádné sestavy.",
|
|
704
|
+
'reporter.welcome_viewer': "Otevřete publikovanou sestavu: její data jsou vždy aktuální.",
|
|
689
705
|
'reporter.changed': "změněno {date}",
|
|
690
706
|
'reporter.no_match': "Hledání neodpovídá žádná sestava.",
|
|
691
707
|
'reporter.chart_tag': "graf",
|
package/src/i18n/da.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const da: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Vælg data for at starte en rapport: vælg kolonner, filtrér, gruppér med totaler, tilføj et diagram, og gem eller del den.",
|
|
687
687
|
'reporter.columns_count': "{n} kolonner",
|
|
688
688
|
'reporter.search': "Søg i rapporter",
|
|
689
|
+
'reporter.published_reports': "Publicerede rapporter",
|
|
690
|
+
'reporter.published_tag': "publiceret",
|
|
691
|
+
'reporter.publish': "Publicer",
|
|
692
|
+
'reporter.unpublish': "Fjern publicering",
|
|
693
|
+
'reporter.publish_on_save': "Publicer for alle brugere af applikationen",
|
|
694
|
+
'reporter.published': "Rapporten \"{name}\" publiceret.",
|
|
695
|
+
'reporter.unpublished': "Publiceringen af rapporten \"{name}\" er fjernet.",
|
|
696
|
+
'reporter.pin': "Fastgør",
|
|
697
|
+
'reporter.unpin': "Frigør",
|
|
698
|
+
'reporter.pinned': "Fastgjorte",
|
|
699
|
+
'reporter.pinned_msg': "Rapporten \"{name}\" fastgjort.",
|
|
700
|
+
'reporter.unpinned_msg': "Rapporten \"{name}\" frigjort.",
|
|
701
|
+
'reporter.link': "Link til denne rapport",
|
|
702
|
+
'reporter.open_link': "Åbn",
|
|
703
|
+
'reporter.no_published': "Ingen rapporter er publiceret endnu.",
|
|
704
|
+
'reporter.welcome_viewer': "Åbn en publiceret rapport: dens data er altid opdaterede.",
|
|
689
705
|
'reporter.changed': "ændret {date}",
|
|
690
706
|
'reporter.no_match': "Ingen rapporter matcher søgningen.",
|
|
691
707
|
'reporter.chart_tag': "diagram",
|
package/src/i18n/de.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const de: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Wählen Sie die Daten für einen neuen Bericht: Spalten auswählen, filtern, mit Summen gruppieren, ein Diagramm hinzufügen und den Bericht speichern oder teilen.",
|
|
687
687
|
'reporter.columns_count': "{n} Spalten",
|
|
688
688
|
'reporter.search': "Berichte suchen",
|
|
689
|
+
'reporter.published_reports': "Veröffentlichte Berichte",
|
|
690
|
+
'reporter.published_tag': "veröffentlicht",
|
|
691
|
+
'reporter.publish': "Veröffentlichen",
|
|
692
|
+
'reporter.unpublish': "Veröffentlichung zurückziehen",
|
|
693
|
+
'reporter.publish_on_save': "Für alle Benutzer der Anwendung veröffentlichen",
|
|
694
|
+
'reporter.published': "Bericht \"{name}\" veröffentlicht.",
|
|
695
|
+
'reporter.unpublished': "Veröffentlichung von Bericht \"{name}\" zurückgezogen.",
|
|
696
|
+
'reporter.pin': "Anheften",
|
|
697
|
+
'reporter.unpin': "Lösen",
|
|
698
|
+
'reporter.pinned': "Angeheftet",
|
|
699
|
+
'reporter.pinned_msg': "Bericht \"{name}\" angeheftet.",
|
|
700
|
+
'reporter.unpinned_msg': "Bericht \"{name}\" gelöst.",
|
|
701
|
+
'reporter.link': "Link zu diesem Bericht",
|
|
702
|
+
'reporter.open_link': "Öffnen",
|
|
703
|
+
'reporter.no_published': "Es sind noch keine Berichte veröffentlicht.",
|
|
704
|
+
'reporter.welcome_viewer': "Öffnen Sie einen veröffentlichten Bericht: seine Daten sind immer aktuell.",
|
|
689
705
|
'reporter.changed': "geändert {date}",
|
|
690
706
|
'reporter.no_match': "Keine Berichte gefunden.",
|
|
691
707
|
'reporter.chart_tag': "Diagramm",
|
package/src/i18n/el.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const el: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Επιλέξτε τα δεδομένα για μια νέα αναφορά: στήλες, φίλτρα, ομαδοποίηση με σύνολα, ένα γράφημα, και αποθηκεύστε ή μοιραστείτε την.",
|
|
687
687
|
'reporter.columns_count': "{n} στήλες",
|
|
688
688
|
'reporter.search': "Αναζήτηση αναφορών",
|
|
689
|
+
'reporter.published_reports': "Δημοσιευμένες αναφορές",
|
|
690
|
+
'reporter.published_tag': "δημοσιευμένη",
|
|
691
|
+
'reporter.publish': "Δημοσίευση",
|
|
692
|
+
'reporter.unpublish': "Κατάργηση δημοσίευσης",
|
|
693
|
+
'reporter.publish_on_save': "Δημοσίευση για όλους τους χρήστες της εφαρμογής",
|
|
694
|
+
'reporter.published': "Η αναφορά «{name}» δημοσιεύτηκε.",
|
|
695
|
+
'reporter.unpublished': "Η δημοσίευση της αναφοράς «{name}» καταργήθηκε.",
|
|
696
|
+
'reporter.pin': "Καρφίτσωμα",
|
|
697
|
+
'reporter.unpin': "Ξεκαρφίτσωμα",
|
|
698
|
+
'reporter.pinned': "Καρφιτσωμένες",
|
|
699
|
+
'reporter.pinned_msg': "Η αναφορά «{name}» καρφιτσώθηκε.",
|
|
700
|
+
'reporter.unpinned_msg': "Η αναφορά «{name}» ξεκαρφιτσώθηκε.",
|
|
701
|
+
'reporter.link': "Σύνδεσμος προς αυτή την αναφορά",
|
|
702
|
+
'reporter.open_link': "Άνοιγμα",
|
|
703
|
+
'reporter.no_published': "Δεν υπάρχουν ακόμη δημοσιευμένες αναφορές.",
|
|
704
|
+
'reporter.welcome_viewer': "Ανοίξτε μια δημοσιευμένη αναφορά: τα δεδομένα της είναι πάντα ενημερωμένα.",
|
|
689
705
|
'reporter.changed': "άλλαξε {date}",
|
|
690
706
|
'reporter.no_match': "Καμία αναφορά δεν ταιριάζει με την αναζήτηση.",
|
|
691
707
|
'reporter.chart_tag': "γράφημα",
|
package/src/i18n/es.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const es: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Elija los datos para empezar un informe: columnas, filtros, agrupaciones con totales y un gráfico; después guárdelo o compártalo.",
|
|
687
687
|
'reporter.columns_count': "{n} columnas",
|
|
688
688
|
'reporter.search': "Buscar informes",
|
|
689
|
+
'reporter.published_reports': "Informes publicados",
|
|
690
|
+
'reporter.published_tag': "publicado",
|
|
691
|
+
'reporter.publish': "Publicar",
|
|
692
|
+
'reporter.unpublish': "Retirar publicación",
|
|
693
|
+
'reporter.publish_on_save': "Publicar para todos los usuarios de la aplicación",
|
|
694
|
+
'reporter.published': "Informe \"{name}\" publicado.",
|
|
695
|
+
'reporter.unpublished': "Publicación del informe \"{name}\" retirada.",
|
|
696
|
+
'reporter.pin': "Fijar",
|
|
697
|
+
'reporter.unpin': "Desfijar",
|
|
698
|
+
'reporter.pinned': "Fijados",
|
|
699
|
+
'reporter.pinned_msg': "Informe \"{name}\" fijado.",
|
|
700
|
+
'reporter.unpinned_msg': "Informe \"{name}\" desfijado.",
|
|
701
|
+
'reporter.link': "Enlace a este informe",
|
|
702
|
+
'reporter.open_link': "Abrir",
|
|
703
|
+
'reporter.no_published': "Aún no hay informes publicados.",
|
|
704
|
+
'reporter.welcome_viewer': "Abra un informe publicado: sus datos están siempre al día.",
|
|
689
705
|
'reporter.changed': "modificado el {date}",
|
|
690
706
|
'reporter.no_match': "Ningún informe coincide con la búsqueda.",
|
|
691
707
|
'reporter.chart_tag': "gráfico",
|
package/src/i18n/fi.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const fi: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Valitse tiedot uutta raporttia varten: sarakkeet, suodattimet, ryhmittely summineen ja kaavio, ja tallenna tai jaa raportti.",
|
|
687
687
|
'reporter.columns_count': "{n} saraketta",
|
|
688
688
|
'reporter.search': "Hae raportteja",
|
|
689
|
+
'reporter.published_reports': "Julkaistut raportit",
|
|
690
|
+
'reporter.published_tag': "julkaistu",
|
|
691
|
+
'reporter.publish': "Julkaise",
|
|
692
|
+
'reporter.unpublish': "Peru julkaisu",
|
|
693
|
+
'reporter.publish_on_save': "Julkaise kaikille sovelluksen käyttäjille",
|
|
694
|
+
'reporter.published': "Raportti \"{name}\" julkaistu.",
|
|
695
|
+
'reporter.unpublished': "Raportin \"{name}\" julkaisu peruttu.",
|
|
696
|
+
'reporter.pin': "Kiinnitä",
|
|
697
|
+
'reporter.unpin': "Irrota",
|
|
698
|
+
'reporter.pinned': "Kiinnitetyt",
|
|
699
|
+
'reporter.pinned_msg': "Raportti \"{name}\" kiinnitetty.",
|
|
700
|
+
'reporter.unpinned_msg': "Raportti \"{name}\" irrotettu.",
|
|
701
|
+
'reporter.link': "Linkki tähän raporttiin",
|
|
702
|
+
'reporter.open_link': "Avaa",
|
|
703
|
+
'reporter.no_published': "Raportteja ei ole vielä julkaistu.",
|
|
704
|
+
'reporter.welcome_viewer': "Avaa julkaistu raportti: sen tiedot ovat aina ajan tasalla.",
|
|
689
705
|
'reporter.changed': "muutettu {date}",
|
|
690
706
|
'reporter.no_match': "Haulla ei löytynyt raportteja.",
|
|
691
707
|
'reporter.chart_tag': "kaavio",
|
package/src/i18n/fr.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const fr: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Choisissez les données d'un nouveau rapport : colonnes, filtres, regroupements avec totaux, un graphique, puis enregistrez-le ou partagez-le.",
|
|
687
687
|
'reporter.columns_count': "{n} colonnes",
|
|
688
688
|
'reporter.search': "Rechercher des rapports",
|
|
689
|
+
'reporter.published_reports': "Rapports publiés",
|
|
690
|
+
'reporter.published_tag': "publié",
|
|
691
|
+
'reporter.publish': "Publier",
|
|
692
|
+
'reporter.unpublish': "Dépublier",
|
|
693
|
+
'reporter.publish_on_save': "Publier pour tous les utilisateurs de l'application",
|
|
694
|
+
'reporter.published': "Rapport « {name} » publié.",
|
|
695
|
+
'reporter.unpublished': "Rapport « {name} » dépublié.",
|
|
696
|
+
'reporter.pin': "Épingler",
|
|
697
|
+
'reporter.unpin': "Désépingler",
|
|
698
|
+
'reporter.pinned': "Épinglés",
|
|
699
|
+
'reporter.pinned_msg': "Rapport « {name} » épinglé.",
|
|
700
|
+
'reporter.unpinned_msg': "Rapport « {name} » désépinglé.",
|
|
701
|
+
'reporter.link': "Lien vers ce rapport",
|
|
702
|
+
'reporter.open_link': "Ouvrir",
|
|
703
|
+
'reporter.no_published': "Aucun rapport n'est encore publié.",
|
|
704
|
+
'reporter.welcome_viewer': "Ouvrez un rapport publié : ses données sont toujours à jour.",
|
|
689
705
|
'reporter.changed': "modifié le {date}",
|
|
690
706
|
'reporter.no_match': "Aucun rapport ne correspond à la recherche.",
|
|
691
707
|
'reporter.chart_tag': "graphique",
|
package/src/i18n/he.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const he: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "בחרו את הנתונים כדי להתחיל דוח: עמודות, סינון, קיבוץ עם סיכומים ותרשים, ואז שמרו או שתפו אותו.",
|
|
687
687
|
'reporter.columns_count': "{n} עמודות",
|
|
688
688
|
'reporter.search': "חיפוש דוחות",
|
|
689
|
+
'reporter.published_reports': "דוחות שפורסמו",
|
|
690
|
+
'reporter.published_tag': "פורסם",
|
|
691
|
+
'reporter.publish': "פרסום",
|
|
692
|
+
'reporter.unpublish': "ביטול פרסום",
|
|
693
|
+
'reporter.publish_on_save': "פרסום לכל משתמשי היישום",
|
|
694
|
+
'reporter.published': "הדוח \"{name}\" פורסם.",
|
|
695
|
+
'reporter.unpublished': "פרסום הדוח \"{name}\" בוטל.",
|
|
696
|
+
'reporter.pin': "הצמדה",
|
|
697
|
+
'reporter.unpin': "ביטול הצמדה",
|
|
698
|
+
'reporter.pinned': "מוצמדים",
|
|
699
|
+
'reporter.pinned_msg': "הדוח \"{name}\" הוצמד.",
|
|
700
|
+
'reporter.unpinned_msg': "הצמדת הדוח \"{name}\" בוטלה.",
|
|
701
|
+
'reporter.link': "קישור לדוח זה",
|
|
702
|
+
'reporter.open_link': "פתיחה",
|
|
703
|
+
'reporter.no_published': "עדיין לא פורסמו דוחות.",
|
|
704
|
+
'reporter.welcome_viewer': "פתחו דוח שפורסם: הנתונים שלו תמיד עדכניים.",
|
|
689
705
|
'reporter.changed': "שונה ב-{date}",
|
|
690
706
|
'reporter.no_match': "אין דוחות התואמים לחיפוש.",
|
|
691
707
|
'reporter.chart_tag': "תרשים",
|
package/src/i18n/it.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const it: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "Scegli i dati per iniziare un report: colonne, filtri, raggruppamenti con totali e un grafico, poi salvalo o condividilo.",
|
|
687
687
|
'reporter.columns_count': "{n} colonne",
|
|
688
688
|
'reporter.search': "Cerca report",
|
|
689
|
+
'reporter.published_reports': "Report pubblicati",
|
|
690
|
+
'reporter.published_tag': "pubblicato",
|
|
691
|
+
'reporter.publish': "Pubblica",
|
|
692
|
+
'reporter.unpublish': "Annulla pubblicazione",
|
|
693
|
+
'reporter.publish_on_save': "Pubblica per tutti gli utenti dell'applicazione",
|
|
694
|
+
'reporter.published': "Report \"{name}\" pubblicato.",
|
|
695
|
+
'reporter.unpublished': "Pubblicazione del report \"{name}\" annullata.",
|
|
696
|
+
'reporter.pin': "Fissa",
|
|
697
|
+
'reporter.unpin': "Sblocca",
|
|
698
|
+
'reporter.pinned': "Fissati",
|
|
699
|
+
'reporter.pinned_msg': "Report \"{name}\" fissato.",
|
|
700
|
+
'reporter.unpinned_msg': "Report \"{name}\" sbloccato.",
|
|
701
|
+
'reporter.link': "Link a questo report",
|
|
702
|
+
'reporter.open_link': "Apri",
|
|
703
|
+
'reporter.no_published': "Non ci sono ancora report pubblicati.",
|
|
704
|
+
'reporter.welcome_viewer': "Apri un report pubblicato: i suoi dati sono sempre aggiornati.",
|
|
689
705
|
'reporter.changed': "modificato il {date}",
|
|
690
706
|
'reporter.no_match': "Nessun report corrisponde alla ricerca.",
|
|
691
707
|
'reporter.chart_tag': "grafico",
|
package/src/i18n/ja.ts
CHANGED
|
@@ -686,6 +686,22 @@ export const ja: Record<MessageKey, string> = {
|
|
|
686
686
|
'reporter.welcome_text': "データを選んでレポートを始めます。列の選択、フィルター、合計付きのグループ化、グラフの追加を行い、保存または共有できます。",
|
|
687
687
|
'reporter.columns_count': "{n} 列",
|
|
688
688
|
'reporter.search': "レポートを検索",
|
|
689
|
+
'reporter.published_reports': "公開済みレポート",
|
|
690
|
+
'reporter.published_tag': "公開済み",
|
|
691
|
+
'reporter.publish': "公開",
|
|
692
|
+
'reporter.unpublish': "公開を取り消す",
|
|
693
|
+
'reporter.publish_on_save': "アプリケーションのすべてのユーザーに公開",
|
|
694
|
+
'reporter.published': "レポート「{name}」を公開しました。",
|
|
695
|
+
'reporter.unpublished': "レポート「{name}」の公開を取り消しました。",
|
|
696
|
+
'reporter.pin': "ピン留め",
|
|
697
|
+
'reporter.unpin': "ピン留めを外す",
|
|
698
|
+
'reporter.pinned': "ピン留め済み",
|
|
699
|
+
'reporter.pinned_msg': "レポート「{name}」をピン留めしました。",
|
|
700
|
+
'reporter.unpinned_msg': "レポート「{name}」のピン留めを外しました。",
|
|
701
|
+
'reporter.link': "このレポートへのリンク",
|
|
702
|
+
'reporter.open_link': "開く",
|
|
703
|
+
'reporter.no_published': "公開済みのレポートはまだありません。",
|
|
704
|
+
'reporter.welcome_viewer': "公開済みレポートを開きます。データは常に最新です。",
|
|
689
705
|
'reporter.changed': "{date} に変更",
|
|
690
706
|
'reporter.no_match': "検索に一致するレポートはありません。",
|
|
691
707
|
'reporter.chart_tag': "グラフ",
|