export works from web

This commit is contained in:
Priec
2026-08-15 19:05:59 +02:00
parent 10d9784b9b
commit e3104a367e
11 changed files with 199 additions and 92 deletions

View File

@@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows ->
transfer-eyebrow = Přenos dat
import-title = Import CSV
import-lead = Vyberte soubor v prohlížeči nebo vložte CSV. Řádky se ověřují proti živým strukturám tabulek před hromadným vkládáním přes gRPC.
import-profile = Profil
import-target-tables = Cílové tabulky
import-scope = Rozsah
import-choose-scope = Vyberte globální nebo profil
import-target-tables = Cílová tabulka
import-choose-table = Vyberte tabulku
import-csv-file = Soubor CSV
import-csv-data = Data CSV
import-error-title = CSV se nepodařilo importovat
@@ -586,11 +588,11 @@ import-success-message = Vloženo { $inserted ->
[few] { $table_count } tabulkách
*[other] { $table_count } tabulkách
}.
import-err-select-profile = Vyberte profil.
import-err-tables-required = Zadejte alespoň jednu cílovou tabulku.
import-err-select-profile = Vyberte rozsah.
import-err-tables-required = Vyberte cílovou tabulku.
import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV.
import-err-unknown-profile = Neznámý profil.
import-err-tables-not-in-profile = Jedna nebo více cílových tabulek nepatří do profilu.
import-err-unknown-profile = Neznámý rozsah.
import-err-tables-not-in-profile = Cílová tabulka nepatří do vybraného rozsahu.
import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky.
import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'.
import-err-column-not-importable = Sloupec '{ $column }' není importovatelný pro tabulku '{ $table }'.
@@ -609,18 +611,18 @@ import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }'
export-title = Export CSV
export-lead = Soubor se generuje z živých dat tabulek přes gRPC.
export-profile = Profil
export-choose-profile = Vyberte profil
export-tables = Tabulky
export-comma-hint = Více tabulek oddělte čárkami.
export-scope = Rozsah
export-choose-scope = Vyberte globální nebo profil
export-tables = Tabulka
export-choose-table = Vyberte tabulku
export-include-system = Zahrnout systémové sloupce
export-include-system-hint = Do souboru přidá id, deleted a row_revision. Import je ignoruje.
export-error-title = CSV se nepodařilo exportovat
export-download = Stáhnout CSV
export-err-select-profile = Vyberte profil.
export-err-tables-required = Zadejte alespoň jednu tabulku.
export-err-unknown-profile = Neznámý profil.
export-err-tables-not-in-profile = Jedna nebo více tabulek nepatří do vybraného profilu.
export-err-select-profile = Vyberte rozsah.
export-err-tables-required = Vyberte tabulku.
export-err-unknown-profile = Neznámý rozsah.
export-err-tables-not-in-profile = Tabulka nepatří do vybraného rozsahu.
export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky.
export-err-negative-count = Backend vrátil záporný počet řádků.
export-err-range = Export přesahuje podporovaný rozsah pozic řádků.

View File

@@ -556,8 +556,10 @@ analytics-result-meta-truncated = { $rows ->
transfer-eyebrow = Data transfer
import-title = Import CSV
import-lead = Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.
import-profile = Profile
import-target-tables = Target tables
import-scope = Scope
import-choose-scope = Choose global or a profile
import-target-tables = Target table
import-choose-table = Choose a table
import-csv-file = CSV file
import-csv-data = CSV data
import-error-title = Could not import CSV
@@ -573,11 +575,11 @@ import-success-message = Inserted { $inserted ->
[one] { $table_count } table
*[other] { $table_count } tables
}.
import-err-select-profile = Select a profile.
import-err-tables-required = Enter at least one target table.
import-err-select-profile = Select a scope.
import-err-tables-required = Select a target table.
import-err-csv-required = Choose a CSV file or paste CSV data.
import-err-unknown-profile = Unknown profile.
import-err-tables-not-in-profile = One or more target tables do not belong to the profile.
import-err-unknown-profile = Unknown scope.
import-err-tables-not-in-profile = The target table does not belong to the selected scope.
import-err-missing-structure = The backend omitted a requested table structure.
import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'.
import-err-column-not-importable = Column '{ $column }' is not importable for table '{ $table }'.
@@ -596,18 +598,18 @@ import-err-bad-integer = Invalid integer value '{ $value }'
export-title = Export CSV
export-lead = The download is generated from live table data through gRPC.
export-profile = Profile
export-choose-profile = Choose a profile
export-tables = Tables
export-comma-hint = Separate multiple tables with commas.
export-scope = Scope
export-choose-scope = Choose global or a profile
export-tables = Table
export-choose-table = Choose a table
export-include-system = Include system columns
export-include-system-hint = Adds id, deleted and row_revision to the file. An import ignores them.
export-error-title = Could not export CSV
export-download = Download CSV
export-err-select-profile = Select a profile.
export-err-tables-required = Enter at least one table.
export-err-unknown-profile = Unknown profile.
export-err-tables-not-in-profile = One or more tables do not belong to the selected profile.
export-err-select-profile = Select a scope.
export-err-tables-required = Select a table.
export-err-unknown-profile = Unknown scope.
export-err-tables-not-in-profile = The table does not belong to the selected scope.
export-err-missing-structure = The backend omitted a requested table structure.
export-err-negative-count = The backend returned a negative row count.
export-err-range = The export exceeds the supported row-position range.

View File

@@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows ->
transfer-eyebrow = Prenos údajov
import-title = Import CSV
import-lead = Vyberte súbor v prehliadači alebo vložte CSV. Riadky sa overujú proti živým štruktúram tabuliek pred hromadným vkladaním cez gRPC.
import-profile = Profil
import-target-tables = Cieľové tabuľky
import-scope = Rozsah
import-choose-scope = Vyberte globálne alebo profil
import-target-tables = Cieľová tabuľka
import-choose-table = Vyberte tabuľku
import-csv-file = Súbor CSV
import-csv-data = Údaje CSV
import-error-title = CSV sa nepodarilo importovať
@@ -586,11 +588,11 @@ import-success-message = Vložené { $inserted ->
[few] { $table_count } tabuľkách
*[other] { $table_count } tabuľkách
}.
import-err-select-profile = Vyberte profil.
import-err-tables-required = Zadajte aspoň jednu cieľovú tabuľku.
import-err-select-profile = Vyberte rozsah.
import-err-tables-required = Vyberte cieľovú tabuľku.
import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV.
import-err-unknown-profile = Neznámy profil.
import-err-tables-not-in-profile = Jedna alebo viac cieľových tabuliek nepatrí do profilu.
import-err-unknown-profile = Neznámy rozsah.
import-err-tables-not-in-profile = Cieľová tabuľka nepatrí do vybraného rozsahu.
import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky.
import-err-no-importable-columns = CSV nemá importovateľné stĺpce pre tabuľku '{ $table }'.
import-err-column-not-importable = Stĺpec '{ $column }' nie je importovateľný pre tabuľku '{ $table }'.
@@ -609,18 +611,18 @@ import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }'
export-title = Export CSV
export-lead = Súbor sa generuje z živých údajov tabuliek cez gRPC.
export-profile = Profil
export-choose-profile = Vyberte profil
export-tables = Tabuľky
export-comma-hint = Viacero tabuliek oddeľte čiarkami.
export-scope = Rozsah
export-choose-scope = Vyberte globálne alebo profil
export-tables = Tabuľka
export-choose-table = Vyberte tabuľku
export-include-system = Zahrnúť systémové stĺpce
export-include-system-hint = Do súboru pridá id, deleted a row_revision. Import ich ignoruje.
export-error-title = CSV sa nepodarilo exportovať
export-download = Stiahnuť CSV
export-err-select-profile = Vyberte profil.
export-err-tables-required = Zadajte aspoň jednu tabuľku.
export-err-unknown-profile = Neznámy profil.
export-err-tables-not-in-profile = Jedna alebo viac tabuliek nepatrí do vybraného profilu.
export-err-select-profile = Vyberte rozsah.
export-err-tables-required = Vyberte tabuľku.
export-err-unknown-profile = Neznámy rozsah.
export-err-tables-not-in-profile = Tabuľka nepatrí do vybraného rozsahu.
export-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky.
export-err-negative-count = Backend vrátil záporný počet riadkov.
export-err-range = Export presahuje podporovaný rozsah pozícií riadkov.

View File

@@ -3,7 +3,9 @@ use axum::http::HeaderMap;
use crate::{
AppState,
auth::GetAuthorizationRequest,
definitions::common::Empty,
definitions::{common::Empty, table_definition::GetTableCatalogRequest},
i18n::Locale,
pages::{GLOBAL_SCOPE, table_scope},
services::authenticated_request,
};
@@ -12,8 +14,14 @@ pub(crate) struct Catalog {
pub authorization: crate::auth::AuthorizationSnapshot,
}
/// One entry of the scope selector: the global scope, or a profile.
pub(crate) struct Profile {
/// What the form posts, and what the backend resolves the table in —
/// `__global` for the shared tables, the schema name otherwise.
pub name: String,
/// What the selector shows. The global scope is not a schema a user names,
/// so it is labelled in their language instead.
pub label: String,
pub tables: Vec<String>,
}
@@ -45,35 +53,67 @@ pub(crate) async fn load_catalog(
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
let mut profiles = tree
.profiles
// A global table is shared by every profile, so the tree repeats it under
// each one. Listing it there would offer the same table once per profile
// and make "the tables of this profile" mean two different things; it is
// offered under the global scope instead, which — like every other page —
// is read from the catalog rather than derived from the tree. See
// `crate::pages::table_scope`.
let global = table_scope::global_tables(
&definitions
.get_table_catalog(
authenticated_request(headers, GetTableCatalogRequest { profile_name: None })
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.tables,
)
.into_iter()
.map(|table| table.name)
.filter(|table| {
crate::authz::permits_table(&authorization, GLOBAL_SCOPE, table, required_action)
})
.collect::<Vec<_>>();
let locale = Locale::from_headers(headers);
let mut profiles = Vec::new();
if !global.is_empty() {
profiles.push(Profile {
name: GLOBAL_SCOPE.to_string(),
label: scope_label(locale, GLOBAL_SCOPE),
tables: global,
});
}
profiles.extend(tree.profiles.into_iter().filter_map(|profile| {
let tables = profile
.tables
.into_iter()
.filter_map(|profile| {
let tables = profile
.tables
.into_iter()
.filter(|table| {
crate::authz::permits_table(
&authorization,
&profile.name,
&table.name,
required_action,
)
})
.map(|table| table.name)
.collect::<Vec<_>>();
(!tables.is_empty()).then_some(Profile {
name: profile.name,
tables,
})
.filter(|table| !table.global)
.filter(|table| {
crate::authz::permits_table(
&authorization,
&profile.name,
&table.name,
required_action,
)
})
.map(|table| table.name)
.collect::<Vec<_>>();
(!tables.is_empty()).then_some(Profile {
label: profile.name.clone(),
name: profile.name,
tables,
})
}));
// An insert-only role is deliberately absent from GetProfileTree because
// that listing is filtered by read permission. Exact table grants still
// carry enough information to offer their target here. Wildcard-only
// insert roles can type a target manually and the backend remains the
// authoritative permission check.
// carry enough information to offer their target here. A wildcard-only
// insert role has no table name to offer, so its scope stays empty and the
// backend remains the authoritative permission check.
for permission in authorization
.permissions
.iter()
@@ -98,6 +138,7 @@ pub(crate) async fn load_catalog(
} else {
profiles.push(Profile {
name: profile_name.to_string(),
label: scope_label(locale, profile_name),
tables: vec![table_name.to_string()],
});
}
@@ -108,6 +149,16 @@ pub(crate) async fn load_catalog(
})
}
/// A profile shows its schema name; the global scope is not one a user named,
/// so it is labelled in their language.
fn scope_label(locale: Locale, name: &str) -> String {
if name == GLOBAL_SCOPE {
crate::tr!(locale, "admin-global-label")
} else {
name.to_string()
}
}
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,

View File

@@ -1,9 +1,11 @@
use axum::{
Form,
extract::State,
http::{HeaderMap, HeaderValue, header},
response::{Html, IntoResponse, Redirect, Response},
};
// The table checkboxes post `table_names` once per checked box, and
// `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`.
use axum_extra::extract::Form;
use crate::{
AppState,

View File

@@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr};
pub(crate) struct ExportForm {
#[serde(default)]
pub profile_name: String,
/// One entry per checked table. The form posts the key once per checked
/// box, which only `axum_extra`'s `Form` decodes into a `Vec`.
#[serde(default)]
pub table_names: String,
pub table_names: Vec<String>,
/// An unchecked checkbox is not posted at all, so its absence is the
/// `false` and any value it does carry is the `true`.
#[serde(default)]
@@ -25,8 +27,8 @@ impl ExportForm {
}
let tables = self
.table_names
.split(',')
.map(str::trim)
.iter()
.map(|table| table.trim())
.filter(|table| !table.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();

View File

@@ -1,11 +1,13 @@
use std::collections::{HashMap, HashSet};
use axum::{
Form,
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Redirect, Response},
};
// The table checkboxes post `table_names` once per checked box, and
// `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`.
use axum_extra::extract::Form;
use crate::{
AppState,

View File

@@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr};
pub(crate) struct ImportForm {
#[serde(default)]
pub profile_name: String,
/// One entry per checked table. The form posts the key once per checked
/// box, which only `axum_extra`'s `Form` decodes into a `Vec`.
#[serde(default)]
pub table_names: String,
pub table_names: Vec<String>,
#[serde(default)]
pub csv_data: String,
}
@@ -25,8 +27,8 @@ impl ImportForm {
}
let tables = self
.table_names
.split(',')
.map(str::trim)
.iter()
.map(|table| table.trim())
.filter(|table| !table.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();

View File

@@ -10,17 +10,21 @@
{# A real form post, not HTMX: the response is a file download. #}
<form method="post" action="/admin/export.csv">
<div class="form-grid">
<label>{{ nav.tr("export-profile") }}
<select name="profile_name" required>
<option value="">{{ nav.tr("export-choose-profile") }}</option>
<label>{{ nav.tr("export-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("export-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}">{{ profile.name }}</option>
<option value="{{ profile.name }}">{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("export-tables") }}
<input name="table_names" list="export-tables" required placeholder="invoices, customers">
<small>{{ nav.tr("export-comma-hint") }}</small>
<select name="table_names" data-table-select required>
<option value="">{{ nav.tr("export-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}">{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
</div>
<label class="check">
@@ -28,12 +32,10 @@
{{ nav.tr("export-include-system") }}
</label>
<small>{{ nav.tr("export-include-system-hint") }}</small>
<datalist id="export-tables">
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
</datalist>
<div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a>
<button type="submit">{{ nav.tr("export-download") }}</button>
</div>
</form>
{% include "pages/import_export/table_picker.html" %}
{% endblock %}

View File

@@ -11,22 +11,28 @@
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div class="form-grid">
<label>{{ nav.tr("import-profile") }}
<input name="profile_name" list="import-profiles" value="{{ page.form.profile_name }}" required placeholder="billing">
<label>{{ nav.tr("import-scope") }}
<select name="profile_name" data-profile-select required>
<option value="">{{ nav.tr("import-choose-scope") }}</option>
{% for profile in page.catalog.profiles %}
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}
<select name="table_names" data-table-select required>
<option value="">{{ nav.tr("import-choose-table") }}</option>
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.table_names.contains(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
<label>{{ nav.tr("import-target-tables") }}<input name="table_names" list="import-tables" value="{{ page.form.table_names }}" required placeholder="invoices, customers"></label>
<label class="wide">{{ nav.tr("import-csv-file") }}
<input type="file" accept=".csv,text/csv"
onchange="this.files[0]?.text().then(value =&gt; document.getElementById('csv-data').value = value)">
</label>
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
</div>
<datalist id="import-profiles">
{% for profile in page.catalog.profiles %}<option value="{{ profile.name }}"></option>{% endfor %}
</datalist>
<datalist id="import-tables">
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
</datalist>
<div id="submission-status" aria-live="polite">
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
</div>
@@ -35,4 +41,5 @@
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
</div>
</form>
{% include "pages/import_export/table_picker.html" %}
{% endblock %}

View File

@@ -0,0 +1,33 @@
{#
Leaves the table dropdown holding only the tables of the selected profile.
The options are rebuilt rather than hidden: `hidden` on an `<option>` is not
honoured everywhere, and a hidden option is still selectable with the
keyboard. With scripting off the dropdown lists every table it was rendered
with, and the server still refuses a table that is not in the profile.
#}
<script>
(function () {
const profileSelect = document.querySelector("[data-profile-select]");
const tableSelect = document.querySelector("[data-table-select]");
if (!profileSelect || !tableSelect) return;
const placeholder = tableSelect.options[0];
const all = Array.from(tableSelect.options).slice(1);
function sync() {
const chosen = tableSelect.value;
tableSelect.replaceChildren(placeholder);
for (const option of all) {
if (option.dataset.profile === profileSelect.value) {
tableSelect.append(option);
}
}
tableSelect.value = Array.from(tableSelect.options).some(
(option) => option.value === chosen,
)
? chosen
: "";
}
profileSelect.addEventListener("change", sync);
sync();
})();
</script>