i18n on the web - deepseek translations

This commit is contained in:
Priec
2026-08-14 20:35:37 +02:00
parent 2dc0d94ba0
commit 8f1bfdbe0c
91 changed files with 4679 additions and 1237 deletions

View File

@@ -13,6 +13,7 @@
use crate::{
definitions::table_definition::{GeneratedColumnAlias, PostTableDefinitionRequest},
{i18n::Locale, tr},
schema::{
ColumnCatalog, ColumnDraft, proto_columns, validate_identifier, validate_table_name,
},
@@ -115,11 +116,19 @@ impl TableDraft {
// ---- mutations -------------------------------------------------------
/// Removes one column, and drops it from the display columns with it.
pub(crate) fn remove_column(&mut self, index: usize) -> Result<String, String> {
let removed = self.columns.remove(index)?;
pub(crate) fn remove_column(
&mut self,
locale: Locale,
index: usize,
) -> Result<String, String> {
let removed = self.columns.remove(locale, index)?;
self.row_display_columns
.retain(|display| display != &removed.name);
Ok(format!("Column `{}` removed.", removed.name))
Ok(tr!(
locale,
"schema-status-column-removed",
"name" => removed.name,
))
}
/// Moves one column one place up or down the list.
@@ -127,8 +136,13 @@ impl TableDraft {
/// The display columns keep their own order, which is the order they were
/// chosen in rather than the order the columns are declared in, so nothing
/// here touches them.
pub(crate) fn move_column(&mut self, index: usize, offset: isize) -> Option<String> {
self.columns.move_column(index, offset)
pub(crate) fn move_column(
&mut self,
locale: Locale,
index: usize,
offset: isize,
) -> Option<String> {
self.columns.move_column(locale, index, offset)
}
/// Adds or removes one display-column candidate.
@@ -331,7 +345,7 @@ impl TableDraft {
/// Every alias has to be a legal column name, and has to be free: the table
/// is about to hold the declared columns, the generated ones and the system
/// ones, and two columns cannot share a name.
pub(crate) fn validate_generated_aliases(&self) -> Result<(), String> {
pub(crate) fn validate_generated_aliases(&self, locale: Locale) -> Result<(), String> {
let generated = self.aliasable_generated_columns();
let renames = self.aliased_generated_columns();
@@ -350,12 +364,17 @@ impl TableDraft {
);
for (source, alias) in &renames {
if let Some(error) = validate_identifier(alias, "Column alias", true) {
if let Some(error) =
validate_identifier(locale, alias, "label-column-alias", true)
{
return Err(error);
}
if taken.iter().filter(|name| *name == alias).count() > 1 {
return Err(format!(
"Alias `{alias}` for generated column `{source}` is already taken by another column."
return Err(tr!(
locale,
"draft-err-alias-taken",
"alias" => alias.clone(),
"source" => source.clone(),
));
}
}
@@ -392,7 +411,7 @@ impl TableDraft {
/// The schema as it will exist: system columns, relation columns, then the
/// user's own. Mirrors the client's preview pane.
pub(crate) fn preview_rows(&self) -> Vec<PreviewRow> {
pub(crate) fn preview_rows(&self, locale: &Locale) -> Vec<PreviewRow> {
let mut rows = vec![
PreviewRow {
mark: if self.row_display_columns.is_empty() {
@@ -402,14 +421,14 @@ impl TableDraft {
},
column: "id".to_string(),
data_type: "BIGSERIAL".to_string(),
option: "primary key".to_string(),
option: tr!(*locale, "builder-preview-primary-key"),
source: "system".to_string(),
},
PreviewRow {
mark: String::new(),
column: "deleted".to_string(),
data_type: "BOOLEAN".to_string(),
option: "default false".to_string(),
option: tr!(*locale, "builder-preview-default-false"),
source: "system".to_string(),
},
// Every managed table carries one, and the preview claims to be the
@@ -418,7 +437,7 @@ impl TableDraft {
mark: String::new(),
column: "row_revision".to_string(),
data_type: "BIGINT".to_string(),
option: "not null, default 1".to_string(),
option: tr!(*locale, "builder-preview-not-null-1"),
source: "system".to_string(),
},
];
@@ -431,7 +450,7 @@ impl TableDraft {
.unwrap_or_else(|| "[ ]".to_string()),
column: column.name.clone(),
data_type: column.data_type.clone(),
option: column.option_label(),
option: column.option_label(locale),
source: "user".to_string(),
});
@@ -445,7 +464,15 @@ impl TableDraft {
column: self.generated_display_name(&generated.name),
data_type: generated.data_type.clone(),
option: if generated.inherits_currency {
format!("{}, {}", column.currency, column.money_mode.label())
let mode = match column.money_mode {
crate::schema::MoneyMode::Exact => "exact",
crate::schema::MoneyMode::Rounded => "half-up",
};
format!(
"{}, {}",
column.currency,
tr!(*locale, &format!("td-money-{mode}")),
)
} else {
String::new()
},
@@ -462,9 +489,10 @@ impl TableDraft {
mark: String::new(),
column: "account_id".to_string(),
data_type: "BIGINT".to_string(),
option: format!(
"not null, → accounts, written as {}",
self.generated_display_name(ACCOUNT_API_COLUMN)
option: tr!(
*locale,
"builder-preview-account-option",
"field" => self.generated_display_name(ACCOUNT_API_COLUMN),
),
source: "system".to_string(),
});
@@ -475,7 +503,7 @@ impl TableDraft {
mark: String::new(),
column: "created_at".to_string(),
data_type: "TIMESTAMPTZ".to_string(),
option: "current time".to_string(),
option: tr!(*locale, "builder-preview-current-time"),
source: "system".to_string(),
});
rows
@@ -484,18 +512,22 @@ impl TableDraft {
// ---- validation and submission ---------------------------------------
/// Every check the client runs before it will save.
pub(crate) fn validate(&self) -> Result<(), String> {
pub(crate) fn validate(&self, locale: Locale) -> Result<(), String> {
let profile_name = self.effective_profile_name();
if !self.global && self.creating_new_profile && profile_name.is_empty() {
return Err("Enter a name for the new profile.".to_string());
return Err(tr!(locale, "draft-err-profile-name"));
}
if !self.global && let Some(error) = validate_identifier(&profile_name, "Profile name", false) {
if !self.global {
if let Some(error) =
validate_identifier(locale, &profile_name, "label-profile-name", false)
{
return Err(error);
}
}
if let Some(error) = validate_accounting_currency(locale, self) {
return Err(error);
}
if let Some(error) = validate_accounting_currency(self) {
return Err(error);
}
if let Some(error) = validate_table_name(self.table_name.trim()) {
if let Some(error) = validate_table_name(locale, self.table_name.trim()) {
return Err(error);
}
if self.table_name_conflicts() {
@@ -503,27 +535,33 @@ impl TableDraft {
// be free in all of them — and naming a profile in that message
// would name the wrong thing, there being none.
return Err(if self.global {
format!(
"A table named `{}` already exists. A shared table's name has to be free in every profile.",
self.table_name
tr!(
locale,
"draft-err-table-conflict-global",
"name" => self.table_name.clone(),
)
} else {
format!(
"A table named `{}` already exists in profile `{profile_name}`, or is shared by every profile.",
self.table_name
tr!(
locale,
"draft-err-table-conflict",
"name" => self.table_name.clone(),
"profile" => profile_name.clone(),
)
});
}
if self.columns.is_empty() {
return Err("Add at least one column before saving.".to_string());
return Err(tr!(locale, "draft-err-no-columns"));
}
self.validate_generated_aliases()?;
self.columns.validate()
self.validate_generated_aliases(locale)?;
self.columns.validate(locale)
}
pub(crate) fn into_request(mut self) -> Result<PostTableDefinitionRequest, String> {
pub(crate) fn into_request(
mut self,
locale: Locale,
) -> Result<PostTableDefinitionRequest, String> {
self.table_name = self.table_name.trim().to_string();
self.validate()?;
self.validate(locale)?;
Ok(PostTableDefinitionRequest {
table_name: self.table_name.clone(),
@@ -545,13 +583,13 @@ impl TableDraft {
}
}
pub(crate) fn validate_accounting_currency(draft: &TableDraft) -> Option<String> {
pub(crate) fn validate_accounting_currency(locale: Locale, draft: &TableDraft) -> Option<String> {
if !draft.creating_new_profile || draft.global {
return None;
}
let currency = draft.accounting_currency.to_ascii_uppercase();
if rusty_money::iso::find(&currency).is_none() {
return Some("Accounting currency must be a three-letter ISO-4217 code".to_string());
return Some(tr!(locale, "draft-err-accounting-currency"));
}
None
}
@@ -591,11 +629,11 @@ mod tests {
draft.profile_name_input = "billing".to_string();
draft.accounting_currency = "AAA".to_string();
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.accounting_currency = "eur".to_string();
assert!(draft.validate().is_ok());
assert_eq!(draft.into_request().unwrap().accounting_currency, "EUR");
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().accounting_currency, "EUR");
}
/// Every column an ACCOUNTING row generates may be aliased, including the
@@ -669,16 +707,16 @@ mod tests {
);
assert_eq!(draft.generated_display_name("debit"), "md");
assert_eq!(draft.generated_display_name("credit"), "credit");
assert!(draft.validate().is_ok());
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
assert!(
draft
.preview_rows()
.preview_rows(&crate::i18n::Locale::default())
.iter()
.any(|row| row.column == "md" && row.source == "generated")
);
// The request that creates the columns is the request that names them.
let request = draft.into_request().unwrap();
let request = draft.into_request(crate::i18n::Locale::default()).unwrap();
assert_eq!(request.generated_aliases.len(), 1);
assert_eq!(request.generated_aliases[0].generated_name, "debit");
assert_eq!(request.generated_aliases[0].alias, "md");
@@ -701,25 +739,25 @@ mod tests {
source: "debit".to_string(),
alias: "Md".to_string(),
}];
assert!(draft.validate().is_err(), "an alias is a column name");
assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "an alias is a column name");
draft.generated_aliases[0].alias = "note".to_string();
assert!(draft.validate().is_err(), "a declared column holds the name");
assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a declared column holds the name");
draft.generated_aliases[0].alias = "credit".to_string();
assert!(draft.validate().is_err(), "another generated column does");
assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "another generated column does");
draft.generated_aliases[0].alias = "id".to_string();
assert!(draft.validate().is_err(), "a system column does");
assert!(draft.validate(crate::i18n::Locale::default()).is_err(), "a system column does");
draft.generated_aliases[0].alias = "md".to_string();
assert!(draft.validate().is_ok());
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
}
#[test]
fn existing_profile_sends_no_accounting_currency() {
let draft = draft_with_column("total", "int");
let request = draft.into_request().unwrap();
let request = draft.into_request(crate::i18n::Locale::default()).unwrap();
assert_eq!(request.accounting_currency, "");
assert_eq!(request.profile_name, "billing");
@@ -730,13 +768,13 @@ mod tests {
let mut draft = draft_with_column("total", "int");
draft.existing_profile_tables = vec!["invoice".to_string()];
let error = draft.validate().unwrap_err();
let error = draft.validate(crate::i18n::Locale::default()).unwrap_err();
assert!(error.contains("profile `billing`"), "{error}");
// A shared table has no profile to name, and its name has to be free
// everywhere rather than in one place.
draft.global = true;
let error = draft.validate().unwrap_err();
let error = draft.validate(crate::i18n::Locale::default()).unwrap_err();
assert!(!error.contains("profile ``"), "{error}");
assert!(error.contains("every profile"), "{error}");
}
@@ -746,15 +784,15 @@ mod tests {
let mut draft = draft_with_column("total", "int");
draft.profile_name = "pg_catalog".to_string();
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
#[test]
fn a_table_with_no_columns_is_refused() {
let mut draft = draft_with_column("total", "int");
draft.remove_column(0).unwrap();
draft.remove_column(crate::i18n::Locale::default(), 0).unwrap();
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
#[test]
@@ -840,7 +878,7 @@ mod tests {
draft.toggle_row_display_candidate(1);
assert_eq!(draft.row_display_columns, vec!["number"]);
draft.remove_column(0).unwrap();
draft.remove_column(crate::i18n::Locale::default(), 0).unwrap();
assert!(draft.row_display_columns.is_empty());
}
@@ -848,7 +886,7 @@ mod tests {
fn the_preview_shows_system_and_user_columns() {
let draft = draft_with_column("number", "text");
let rows = draft.preview_rows();
let rows = draft.preview_rows(&crate::i18n::Locale::default());
let columns = rows
.iter()
.map(|row| row.column.as_str())
@@ -880,7 +918,7 @@ mod tests {
draft.toggle_row_display_candidate(1); // number
draft.toggle_row_display_candidate(2); // issued_on
draft.move_column(0, 1).unwrap();
draft.move_column(crate::i18n::Locale::default(), 0, 1).unwrap();
assert_eq!(
draft
@@ -909,7 +947,7 @@ mod tests {
currency: "CZK".to_string(),
});
let rows = draft.preview_rows();
let rows = draft.preview_rows(&crate::i18n::Locale::default());
let columns = rows
.iter()
.map(|row| row.column.as_str())
@@ -954,7 +992,7 @@ mod tests {
let mut draft = draft_with_column("number", "text");
draft.columns.toggle_indexed(0);
assert_eq!(draft.into_request().unwrap().indexes, vec!["number"]);
assert_eq!(draft.into_request(crate::i18n::Locale::default()).unwrap().indexes, vec!["number"]);
}
/// The request the builder sends is one the server will take: a link
@@ -976,12 +1014,15 @@ mod tests {
});
// Refused rather than quietly repaired, because the server refuses it.
let error = draft.clone().into_request().unwrap_err();
let error = draft
.clone()
.into_request(crate::i18n::Locale::default())
.unwrap_err();
assert!(error.contains("indexed automatically"), "{error}");
draft.columns.added[1].indexed = false;
draft.columns.toggle_indexed(0);
let request = draft.into_request().unwrap();
let request = draft.into_request(crate::i18n::Locale::default()).unwrap();
assert_eq!(request.indexes, vec!["number"]);
assert!(request.columns[0].required);
@@ -995,16 +1036,16 @@ mod tests {
let mut draft = draft_with_column("total", "int");
draft.table_name = "t".repeat(39);
let error = draft.validate().unwrap_err();
let error = draft.validate(crate::i18n::Locale::default()).unwrap_err();
assert!(error.contains("38 characters"), "{error}");
draft.table_name = "accounts".to_string();
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.table_name = "general_ledger".to_string();
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.table_name = "invoice".to_string();
assert!(draft.validate().is_ok());
assert!(draft.validate(crate::i18n::Locale::default()).is_ok());
}
/// A shared table has no books of its own, so the two definition rows that
@@ -1015,7 +1056,7 @@ mod tests {
draft.global = true;
draft.columns.global = true;
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
draft.columns.added.clear();
draft.columns.added.push(ColumnDefinition {
@@ -1027,7 +1068,7 @@ mod tests {
money_mode: MoneyMode::Exact,
currency: String::new(),
});
assert!(draft.validate().is_err());
assert!(draft.validate(crate::i18n::Locale::default()).is_err());
}
/// The preview is the schema as it will exist, so it carries every system
@@ -1038,7 +1079,7 @@ mod tests {
let draft = draft_with_column("number", "text");
assert!(
draft
.preview_rows()
.preview_rows(&crate::i18n::Locale::default())
.iter()
.any(|row| row.column == "row_revision" && row.source == "system")
);

View File

@@ -13,6 +13,7 @@ use axum_extra::extract::Form;
use crate::{
AppState,
{i18n::Locale, tr},
services::{authenticated_request, reject_cross_site},
};
@@ -45,7 +46,7 @@ pub(crate) async fn new_table_page(
match load_page(state, &headers, draft, None, None).await {
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error_response(error),
Err(error) => load_error_response(&headers, error),
}
}
@@ -61,7 +62,7 @@ pub(crate) async fn update_builder(
let mut page = match load_page(state, &headers, form.to_draft(), None, None).await {
Ok(page) => page,
Err(error) => return load_error_response(error),
Err(error) => return load_error_response(&headers, error),
};
apply_action(&mut page, &form);
@@ -80,12 +81,12 @@ pub(crate) async fn create_table(
let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await {
Ok(page) => page,
Err(error) => return load_error_response(error),
Err(error) => return load_error_response(&headers, error),
};
// The draft is validated here with exactly the checks the client runs
// before it will save; the server re-validates authoritatively.
let request = match page.draft.clone().into_request() {
let request = match page.draft.clone().into_request(Locale::from_headers(&headers)) {
Ok(request) => request,
Err(message) => {
page.error = Some(message);
@@ -127,7 +128,10 @@ pub(crate) async fn create_table(
}
Ok(response) => {
page.error = Some(if response.get_ref().sql.is_empty() {
"The backend did not create the table.".to_string()
tr!(
Locale::from_headers(&headers),
"builder-err-backend-no-create"
)
} else {
response.get_ref().sql.clone()
});
@@ -146,30 +150,35 @@ pub(crate) async fn create_table(
/// changes which fields apply, which is the client's field-visibility rule.
fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
let index = form.index.unwrap_or(0);
let locale = page.nav.locale;
match form.action.as_str() {
"add-column" => match page.draft.columns.add_from_inputs() {
"add-column" => match page.draft.columns.add_from_inputs(locale) {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
"remove-column" => match page.draft.remove_column(index) {
"remove-column" => match page.draft.remove_column(locale, index) {
Ok(status) => page.status = Some(status),
Err(message) => page.error = Some(message),
},
// The order columns are declared in is the order the table gets them
// in, so moving one is a change to the draft like any other.
"move-column-up" => page.status = page.draft.move_column(index, -1),
"move-column-down" => page.status = page.draft.move_column(index, 1),
"move-column-up" => page.status = page.draft.move_column(locale, index, -1),
"move-column-down" => page.status = page.draft.move_column(locale, index, 1),
// The alias fields carry no `change` trigger of their own — a text
// input fires `change` on blur, which would swap the builder out from
// under the click that is still in flight. This button is what applies
// what was typed, so the column list and the preview say what the
// table will really be called.
"apply-generated-names" => match page.draft.validate_generated_aliases() {
"apply-generated-names" => match page.draft.validate_generated_aliases(locale) {
Ok(()) => {
page.status = Some(match page.draft.aliased_generated_columns().len() {
0 => "The generated columns keep their own names.".to_string(),
1 => "1 generated column renamed.".to_string(),
count => format!("{count} generated columns renamed."),
0 => tr!(locale, "builder-status-generated-keep"),
1 => tr!(locale, "builder-status-generated-one"),
count => tr!(
locale,
"builder-status-generated-many",
"count" => count as i64,
),
})
}
Err(message) => page.error = Some(message),
@@ -182,7 +191,7 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
// one that does: the picker stops offering the columns that post to a
// profile's books, and one added before the switch is still there.
"refresh" => {
if let Err(message) = page.draft.columns.validate() {
if let Err(message) = page.draft.columns.validate(locale) {
page.error = Some(message);
}
}
@@ -190,19 +199,26 @@ fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
}
}
fn load_error_response(error: LoadError) -> Response {
fn load_error_response(headers: &HeaderMap, error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
StatusCode::FORBIDDEN,
Html(ui::render_submission_error(
"Table-management permission is required.",
Locale::from_headers(headers),
&tr!(
Locale::from_headers(headers),
"builder-err-permission"
),
)),
)
.into_response(),
LoadError::Backend(message) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_submission_error(&message)),
Html(ui::render_submission_error(
Locale::from_headers(headers),
&message,
)),
)
.into_response(),
}

View File

@@ -1,6 +1,7 @@
use askama::Template;
use crate::{
{i18n::Locale, tr},
schema::CURRENCY_CODES,
ui::{Alert, Nav, render},
};
@@ -24,6 +25,7 @@ struct AddTablePage<'a> {
#[derive(Template)]
#[template(path = "pages/add_table/builder.html")]
struct BuilderFragment<'a> {
nav: Nav,
page: &'a AddTablePageState,
column_types: Vec<String>,
temporal_types: Vec<String>,
@@ -45,6 +47,7 @@ pub(crate) fn render_page(page: &AddTablePageState) -> String {
pub(crate) fn render_builder(page: &AddTablePageState) -> String {
render(&BuilderFragment {
nav: page.nav.clone(),
page,
column_types: page.draft.columns.offered_types(),
temporal_types: page.draft.columns.temporal_types(),
@@ -55,8 +58,12 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String {
/// Used when the page itself cannot be loaded (auth or backend failure).
/// There is no draft left to render, so this replaces the builder — the dialog
/// is what tells the user why the form just emptied.
pub(crate) fn render_submission_error(message: &str) -> String {
render(&Alert::error("Could not create the table", message))
pub(crate) fn render_submission_error(locale: Locale, message: &str) -> String {
render(&Alert::error(
locale,
&tr!(locale, "builder-err-title"),
message,
))
}
#[cfg(test)]