import improvements

This commit is contained in:
Priec
2026-08-17 14:33:45 +02:00
parent 69dd0137ab
commit 1b9136f1de
17 changed files with 2162 additions and 905 deletions

View File

@@ -566,57 +566,92 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export ------------------------------------------------------------- # --- Import / export -------------------------------------------------------------
transfer-eyebrow = Přenos dat transfer-eyebrow = Přenos dat
import-title = Import CSV 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-lead = Import připravíte ve třech krocích: určíte, kam data směřují, řeknete, co je který sloupec vašeho souboru, a výsledek si prohlédnete dřív, než se cokoli zapíše.
import-scope = Rozsah import-scope = Rozsah
import-choose-scope = Vyberte globální nebo profil import-choose-scope = Vyberte globální nebo profil
import-target-tables = Cílová tabulka import-target-tables = Cílová tabulka
import-choose-table = Vyberte tabulku import-choose-table = Vyberte tabulku
import-csv-file = Soubor CSV import-csv-file = Soubor CSV
import-csv-data = Data CSV import-csv-data = Data CSV
import-csv-format-hint = Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "name","number","active" import-csv-format-hint = Použijte CSV v UTF-8 s oddělovačem čárka a každé pole uzavřete do dvojitých uvozovek, například: "Acme","12345678","true"
import-source-mode = Jak je zdroj uspořádán
import-source-header = Soubor má řádek záhlaví
import-source-data = Soubor obsahuje pouze data
import-source-template = Vygenerovat prázdnou šablonu
import-source-mode-hint = Toto je vždy vaše odpověď, nikdy odhad. Skutečný datový řádek může obsahovat slova, která vypadají přesně jako názvy sloupců, takže se zde nic samo nerozhoduje, zda je první řádek záhlaví.
import-continue = Pokračovat
import-carried = Import do tabulky { $table } v rozsahu { $scope }.
import-error-title = CSV se nepodařilo importovat import-error-title = CSV se nepodařilo importovat
import-import-rows = Importovat řádky import-import-rows = Importovat připravená data
import-success-title = Import dokončen import-success-title = Import dokončen
import-success-message = Vloženo { $inserted -> import-success-message = Vloženo { $inserted ->
[one] { $inserted } záznam [one] { $inserted } řádek
[few] { $inserted } záznamy [few] { $inserted } řádky
*[other] { $inserted } záznamů *[other] { $inserted } řádků
} z { $source_rows -> } do tabulky { $table }, z { $source_rows ->
[one] { $source_rows } řádku CSV [one] { $source_rows } připraveného řádku
[few] { $source_rows } řádků CSV [few] { $source_rows } připravených řádků
*[other] { $source_rows } řádků CSV *[other] { $source_rows } připravených řádků
} ve { $table_count ->
[one] { $table_count } tabulce
[few] { $table_count } tabulkách
*[other] { $table_count } tabulkách
}. }.
# --- Krok 2a: mapování -----------------------------------------------------
import-mapping-heading = Pošlete každou pozici do sloupce tabulky { $table }
import-mapping-hint = Hodnoty se umisťují podle pozice, nikoli podle názvu. Název ze zdroje se zobrazuje jen proto, abyste pozice rozeznali; o ničem nerozhoduje. Pozice ponechaná na Ignorovat se neimportuje a sloupec, který zde nevyberete, se ponechá tabulce.
import-mapping-rows = Soubor obsahuje { $rows } datových řádků.
import-th-position = Pozice
import-th-source-name = Název ve zdroji
import-th-example = Ukázková hodnota
import-th-destination = Poslat do
import-destination-ignore = Ignorovat
import-back-to-source = Zpět na zdroj
import-to-preview = Připravit a zobrazit
# --- Krok 2b: generátor šablony --------------------------------------------
import-template-heading = Sestavte šablonu pro tabulku { $table }
import-template-hint = Zaškrtněte sloupce, které má soubor obsahovat, a uspořádejte je v pořadí, v jakém je chcete vyplňovat.
import-th-order = Pořadí
import-th-include = Zahrnout
import-required-column = povinný
import-template-generated = Záhlaví, které se vygeneruje:
import-template-nothing-chosen = Zaškrtněte alespoň jeden sloupec.
import-template-round-trip = Vyplňte tento soubor jinde a pak jej přineste zpět s vybranou možností "Soubor má řádek záhlaví". Protože jde o vlastní názvy sloupců tabulky, každá pozice už bude namířena na správný sloupec a vy ji jen potvrdíte.
import-download-template = Stáhnout šablonu
# --- Krok 3: náhled --------------------------------------------------------
import-preview-heading = Připravený import pro tabulku { $table }
import-summary-rows = Řádky zdroje
import-summary-columns-used = Použité sloupce
import-summary-ignored = Ignorované pozice zdroje
import-summary-omitted = Vynechané cílové sloupce
import-summary-missing-required = Tento import je nevyplňuje a tabulka je deklaruje jako NOT NULL: { $columns }. Pokud tyto sloupce nemají výchozí hodnotu, server řádky odmítne.
import-preview-more-rows = Dalších { $rows } řádků je připraveno a zde se nezobrazuje.
import-prepared-csv = Připravené CSV
import-prepared-csv-hint = Toto je soubor, který se importuje, a tentýž soubor, který předá stažení.
import-download-prepared = Stáhnout připravené CSV
import-back-to-mapping = Zpět na mapování
import-move-up = Posunout { $name } nahoru
import-move-down = Posunout { $name } dolů
# --- Odmítnutí -------------------------------------------------------------
import-err-select-profile = Vyberte rozsah. import-err-select-profile = Vyberte rozsah.
import-err-tables-required = Vyberte cílovou tabulku. import-err-tables-required = Vyberte cílovou tabulku.
import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV. import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV.
import-err-unknown-profile = Neznámý rozsah. import-err-unknown-profile = Neznámý rozsah.
import-err-tables-not-in-profile = Cílová tabulka nepatří do vybraného rozsahu. 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-missing-structure = Backend vynechal požadovanou strukturu tabulky.
import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'. import-err-no-importable-columns = Tabulka '{ $table }' nemá žádné sloupce, do kterých by import mohl zapisovat.
import-err-column-not-importable = Sloupec '{ $column }' není importovatelný pro tabulku '{ $table }'. import-err-csv-row = Řádek { $row }: { $error }
import-err-csv-row = Řádek CSV { $row }: { $error } import-err-row-width = Každý řádek musí obsahovat stejný počet pozic. Tento soubor začíná s { $expected } a později má řádek s { $found }.
import-err-multi-headers = Více-tabulkové CSV potřebuje řádek s hlavičkou tabulek a řádek s hlavičkou sloupců. import-err-no-data-rows = Soubor neobsahuje žádné datové řádky.
import-err-first-row = První řádek CSV musí obsahovat jen vybrané názvy tabulek. import-err-source-mode = Uveďte, jak je zdroj uspořádán.
import-err-header-lengths = Řádek hlavičky tabulek a sloupců mají různou délku. import-err-template-has-no-source = Šablona se generuje z tabulky, takže není co připravovat ze souboru.
import-err-row-width = Řádek dat CSV má jiný počet polí než hlavička. import-err-template-empty = Zaškrtněte pro šablonu alespoň jeden sloupec.
import-err-no-data-rows = CSV obsahuje hlavičky, ale žádné řádky dat. import-err-mapping-stale = Mapování bylo sestaveno pro soubor s { $mapped } pozicemi a tento má { $positions }. Připravte jej znovu.
import-err-header-padded = Hlavička '{ $header }' má kolem sebe mezery, takže není sloupcem '{ $trimmed }'. Odstraňte je, nebo použijte „Normalizovat hlavičky“. import-err-mapping-unknown-column = Pozice { $position } míří na '{ $column }', což není sloupec, do kterého lze do této tabulky importovat. Tabulka se mohla změnit, zatímco byl formulář otevřený.
import-err-duplicate-header = Hlavička '{ $header }' se u téže tabulky vyskytuje dvakrát, takže není jasné, do kterého sloupce hodnoty patří. import-err-mapping-duplicate = Dvě pozice míří na '{ $column }'. Sloupec lze naplnit jen z jedné pozice.
import-err-column-near-match = Hlavička '{ $header }' není sloupcem tabulky '{ $table }', ale '{ $column }' ano - liší se jen mezerami nebo velikostí písmen. Opravte hlavičku, nebo použijte „Normalizovat hlavičky“ a výsledek si před importem prohlédněte. import-err-mapping-empty = Všechny pozice jsou nastaveny na Ignorovat, takže není co importovat.
import-normalize = Normalizovat hlavičky
import-normalize-hint = Přepíše řádky hlaviček na názvy sloupců tabulky. Nic se neimportuje - změněné CSV nejprve uvidíte.
import-normalized-message = Hlavičky přepsány. Než CSV naimportujete, přečtěte si ho níže.
import-normalized-unchanged = Hlavičky už tabulce přesně odpovídají; nic se nezměnilo.
import-system-columns = Soubor obsahuje systémové sloupce
import-system-columns-hint = Zaškrtněte pro soubor exportovaný se systémovými sloupci. Načte se tak, jak je: 'deleted' se zapíše ze souboru, takže smazaný řádek přijde jako smazaný. 'id', 'row_revision' a 'created_at' přiděluje server a přenést je nelze.
import-err-system-columns-present = Soubor obsahuje systémové sloupce ({ $columns }). Zaškrtněte „Soubor obsahuje systémové sloupce“, aby se načetl tak, jak je, nebo je ze souboru odstraňte.
import-success-ignored-system = { $columns } přiděluje server, importované řádky proto nesou nové.
import-err-missing-type = Chybí typ sloupce '{ $column }' import-err-missing-type = Chybí typ sloupce '{ $column }'
import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění k vkládání, aby do ní šlo načítat. import-err-permission = Vyžaduje se oprávnění k importu. Tabulka navíc potřebuje oprávnění ke vkládání, aby do ní šlo načítat.
import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu. import-err-unterminated-quote = CSV obsahuje neuzavřenou uvozovkovou hodnotu.
import-err-strict-csv = Neplatný formát CSV. Každé pole musí být uzavřeno v dvojitých uvozovkách, pole musí být oddělena čárkami a každý záznam musí zůstat na jednom řádku. import-err-strict-csv = Neplatný formát CSV. Každé pole musí být uzavřeno v dvojitých uvozovkách, pole musí být oddělena čárkami a každý záznam musí zůstat na jednom řádku.
import-err-empty = CSV je prázdné. import-err-empty = CSV je prázdné.

View File

@@ -556,52 +556,88 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export ------------------------------------------------------- # --- Import / export -------------------------------------------------------
transfer-eyebrow = Data transfer transfer-eyebrow = Data transfer
import-title = Import CSV 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-lead = Prepare an import in three steps: say where the data is going, say what each column of your file is, then look at the result before anything is written.
import-scope = Scope import-scope = Scope
import-choose-scope = Choose global or a profile import-choose-scope = Choose global or a profile
import-target-tables = Target table import-target-tables = Target table
import-choose-table = Choose a table import-choose-table = Choose a table
import-csv-file = CSV file import-csv-file = CSV file
import-csv-data = CSV data import-csv-data = CSV data
import-csv-format-hint = Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "name","number","active" import-csv-format-hint = Use UTF-8 CSV with a comma separator and every field in double quotes, for example: "Acme","12345678","true"
import-source-mode = How the source is laid out
import-source-header = The file has a header row
import-source-data = The file is data only
import-source-template = Generate an empty template
import-source-mode-hint = This is always your answer, never a guess. A real data row can hold words that read exactly like column names, so nothing here decides on its own whether the first row is a header.
import-continue = Continue
import-carried = Importing into { $table } in { $scope }.
import-error-title = Could not import CSV import-error-title = Could not import CSV
import-import-rows = Import rows import-import-rows = Import prepared data
import-success-title = Import complete import-success-title = Import complete
import-success-message = Inserted { $inserted -> import-success-message = Inserted { $inserted ->
[one] { $inserted } record [one] { $inserted } row
*[other] { $inserted } records *[other] { $inserted } rows
} from { $source_rows -> } into { $table }, from { $source_rows ->
[one] { $source_rows } CSV row [one] { $source_rows } prepared row
*[other] { $source_rows } CSV rows *[other] { $source_rows } prepared rows
} across { $table_count ->
[one] { $table_count } table
*[other] { $table_count } tables
}. }.
# --- Step 2a: mapping ------------------------------------------------------
import-mapping-heading = Send each position to a column of { $table }
import-mapping-hint = Values are placed by position, not by name. A source name is shown only so you can tell the positions apart; it decides nothing. A position left on Ignore is not imported, and a column not chosen here is left to the table.
import-mapping-rows = The file holds { $rows } data rows.
import-th-position = Position
import-th-source-name = Source name
import-th-example = Example value
import-th-destination = Send to
import-destination-ignore = Ignore
import-back-to-source = Back to the source
import-to-preview = Prepare and preview
# --- Step 2b: template generator -------------------------------------------
import-template-heading = Build a template for { $table }
import-template-hint = Tick the columns the file should contain and arrange them in the order you want to fill them in.
import-th-order = Order
import-th-include = Include
import-required-column = required
import-template-generated = The header this generates:
import-template-nothing-chosen = Tick at least one column.
import-template-round-trip = Fill this in elsewhere, then bring it back with "The file has a header row" selected. Because these are the table's own column names, every position will already be pointed at the right column for you to confirm.
import-download-template = Download template
# --- Step 3: preview -------------------------------------------------------
import-preview-heading = Prepared import for { $table }
import-summary-rows = Source rows
import-summary-columns-used = Columns used
import-summary-ignored = Source positions ignored
import-summary-omitted = Destination columns omitted
import-summary-missing-required = Not filled in by this import, and declared NOT NULL by the table: { $columns }. If those columns have no default, the server will refuse the rows.
import-preview-more-rows = { $rows } further rows are prepared and not shown here.
import-prepared-csv = The prepared CSV
import-prepared-csv-hint = This is the file that gets imported, and the same file the download hands over.
import-download-prepared = Download prepared CSV
import-back-to-mapping = Back to the mapping
import-move-up = Move { $name } up
import-move-down = Move { $name } down
# --- Refusals --------------------------------------------------------------
import-err-select-profile = Select a scope. import-err-select-profile = Select a scope.
import-err-tables-required = Select a target table. import-err-tables-required = Select a target table.
import-err-csv-required = Choose a CSV file or paste CSV data. import-err-csv-required = Choose a CSV file or paste CSV data.
import-err-unknown-profile = Unknown scope. import-err-unknown-profile = Unknown scope.
import-err-tables-not-in-profile = The target table does not belong to the selected 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-missing-structure = The backend omitted a requested table structure.
import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'. import-err-no-importable-columns = Table '{ $table }' has no columns an import can write into.
import-err-column-not-importable = Column '{ $column }' is not importable for table '{ $table }'. import-err-csv-row = Row { $row }: { $error }
import-err-csv-row = CSV row { $row }: { $error } import-err-row-width = Every row must hold the same number of positions. This file starts with { $expected } and later has a row of { $found }.
import-err-multi-headers = Multi-table CSV needs a table-header row and a column-header row. import-err-no-data-rows = The file holds no data rows.
import-err-first-row = The first CSV row must contain only selected table names. import-err-source-mode = Say how the source is laid out.
import-err-header-lengths = Table and column header rows have different lengths. import-err-template-has-no-source = A template is generated from the table, so there is no file to prepare.
import-err-row-width = A CSV data row has a different number of fields than the header. import-err-template-empty = Tick at least one column for the template.
import-err-no-data-rows = CSV contains headers but no data rows. import-err-mapping-stale = The mapping was built for a file of { $mapped } positions and this one has { $positions }. Prepare it again.
import-err-header-padded = Header '{ $header }' has spaces around it, so it is not the column '{ $trimmed }'. Remove them, or use “Normalize headers”. import-err-mapping-unknown-column = Position { $position } is pointed at '{ $column }', which is not a column this table can be imported into. The table may have changed while the form was open.
import-err-duplicate-header = Header '{ $header }' appears twice for the same table, so it is not clear which column the values belong to. import-err-mapping-duplicate = Two positions are pointed at '{ $column }'. A column can be filled from one position only.
import-err-column-near-match = Header '{ $header }' is not a column of table '{ $table }', but '{ $column }' is - they differ only in spacing or capitalisation. Fix the header, or use “Normalize headers” to rewrite it and see the result before importing. import-err-mapping-empty = Every position is set to Ignore, so there is nothing to import.
import-normalize = Normalize headers
import-normalize-hint = Rewrites the header rows to the table's own column names. Nothing is imported - you see the changed CSV first.
import-normalized-message = Headers rewritten. Read the CSV below before importing it.
import-normalized-unchanged = Headers already match the table exactly; nothing was changed.
import-system-columns = File includes system columns
import-system-columns-hint = Tick this for a file exported with system columns. It loads as it stands: 'deleted' is written from the file, so a row that left deleted arrives deleted. 'id', 'row_revision' and 'created_at' are assigned by the server and cannot be carried over.
import-err-system-columns-present = The file contains system columns ({ $columns }). Tick “File includes system columns” to import it as it stands, or remove those columns from the file.
import-success-ignored-system = { $columns } are assigned by the server, so the imported rows carry new ones.
import-err-missing-type = Missing type for column '{ $column }' import-err-missing-type = Missing type for column '{ $column }'
import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded. import-err-permission = Import permission is required. A table also needs insert permission before it can be loaded.
import-err-unterminated-quote = CSV contains an unterminated quoted value. import-err-unterminated-quote = CSV contains an unterminated quoted value.

View File

@@ -566,55 +566,88 @@ analytics-result-meta-truncated = { $rows ->
# --- Import / export --------------------------------------------------------- # --- Import / export ---------------------------------------------------------
transfer-eyebrow = Prenos údajov transfer-eyebrow = Prenos údajov
import-title = Import CSV 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-lead = Import pripravíte v troch krokoch: určíte, kam údaje smerujú, poviete, čo je ktorý stĺpec vášho súboru, a výsledok si pozriete skôr, než sa čokoľvek zapíše.
import-scope = Rozsah import-scope = Rozsah
import-choose-scope = Vyberte globálne alebo profil import-choose-scope = Vyberte globálne alebo profil
import-target-tables = Cieľová tabuľka import-target-tables = Cieľová tabuľka
import-choose-table = Vyberte tabuľku import-choose-table = Vyberte tabuľku
import-csv-file = Súbor CSV import-csv-file = Súbor CSV
import-csv-data = Údaje CSV import-csv-data = Údaje CSV
import-csv-format-hint = Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "name","number","active" import-csv-format-hint = Použite CSV v UTF-8 s oddeľovačom čiarka a každé pole uzavrite do dvojitých úvodzoviek, napríklad: "Acme","12345678","true"
import-source-mode = Ako je zdroj usporiadaný
import-source-header = Súbor má riadok hlavičky
import-source-data = Súbor obsahuje len údaje
import-source-template = Vygenerovať prázdnu šablónu
import-source-mode-hint = Toto je vždy vaša odpoveď, nikdy odhad. Skutočný riadok údajov môže obsahovať slová, ktoré vyzerajú presne ako názvy stĺpcov, takže sa tu nič samo nerozhoduje, či je prvý riadok hlavička.
import-continue = Pokračovať
import-carried = Import do tabuľky { $table } v rozsahu { $scope }.
import-error-title = CSV sa nepodarilo importovať import-error-title = CSV sa nepodarilo importovať
import-import-rows = Importovať riadky import-import-rows = Importovať pripravené údaje
import-success-title = Import dokončený import-success-title = Import dokončený
import-success-message = Vložené { $inserted -> import-success-message = Vložený { $inserted ->
[one] { $inserted } záznam [one] { $inserted } riadok
[few] { $inserted } záznamy *[other] { $inserted } riadkov
*[other] { $inserted } záznamov } do tabuľky { $table }, z { $source_rows ->
} z { $source_rows -> [one] { $source_rows } pripraveného riadka
[one] { $source_rows } riadku CSV *[other] { $source_rows } pripravených riadkov
[few] { $source_rows } riadkov CSV
*[other] { $source_rows } riadkov CSV
} v { $table_count ->
[one] { $table_count } tabuľke
[few] { $table_count } tabuľkách
*[other] { $table_count } tabuľkách
}. }.
# --- Krok 2a: mapovanie ----------------------------------------------------
import-mapping-heading = Pošlite každú pozíciu do stĺpca tabuľky { $table }
import-mapping-hint = Hodnoty sa umiestňujú podľa pozície, nie podľa názvu. Názov zo zdroja sa zobrazuje len preto, aby ste pozície rozoznali; nerozhoduje o ničom. Pozícia ponechaná na Ignorovať sa neimportuje a stĺpec, ktorý tu nevyberiete, sa ponechá tabuľke.
import-mapping-rows = Súbor obsahuje { $rows } riadkov údajov.
import-th-position = Pozícia
import-th-source-name = Názov v zdroji
import-th-example = Ukážková hodnota
import-th-destination = Poslať do
import-destination-ignore = Ignorovať
import-back-to-source = Späť na zdroj
import-to-preview = Pripraviť a zobraziť
# --- Krok 2b: generátor šablóny --------------------------------------------
import-template-heading = Zostavte šablónu pre tabuľku { $table }
import-template-hint = Zaškrtnite stĺpce, ktoré má súbor obsahovať, a usporiadajte ich v poradí, v akom ich chcete vypĺňať.
import-th-order = Poradie
import-th-include = Zahrnúť
import-required-column = povinný
import-template-generated = Hlavička, ktorá sa vygeneruje:
import-template-nothing-chosen = Zaškrtnite aspoň jeden stĺpec.
import-template-round-trip = Vyplňte tento súbor inde a potom ho prineste späť s vybranou možnosťou "Súbor má riadok hlavičky". Keďže ide o vlastné názvy stĺpcov tabuľky, každá pozícia už bude nasmerovaná na správny stĺpec a vy ju len potvrdíte.
import-download-template = Stiahnuť šablónu
# --- Krok 3: náhľad --------------------------------------------------------
import-preview-heading = Pripravený import pre tabuľku { $table }
import-summary-rows = Riadky zdroja
import-summary-columns-used = Použité stĺpce
import-summary-ignored = Ignorované pozície zdroja
import-summary-omitted = Vynechané cieľové stĺpce
import-summary-missing-required = Tento import ich nevypĺňa a tabuľka ich deklaruje ako NOT NULL: { $columns }. Ak tieto stĺpce nemajú predvolenú hodnotu, server riadky odmietne.
import-preview-more-rows = Ďalších { $rows } riadkov je pripravených a tu sa nezobrazuje.
import-prepared-csv = Pripravené CSV
import-prepared-csv-hint = Toto je súbor, ktorý sa importuje, a ten istý súbor, ktorý odovzdá stiahnutie.
import-download-prepared = Stiahnuť pripravené CSV
import-back-to-mapping = Späť na mapovanie
import-move-up = Posunúť { $name } nahor
import-move-down = Posunúť { $name } nadol
# --- Odmietnutia -----------------------------------------------------------
import-err-select-profile = Vyberte rozsah. import-err-select-profile = Vyberte rozsah.
import-err-tables-required = Vyberte cieľovú tabuľku. import-err-tables-required = Vyberte cieľovú tabuľku.
import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV. import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV.
import-err-unknown-profile = Neznámy rozsah. import-err-unknown-profile = Neznámy rozsah.
import-err-tables-not-in-profile = Cieľová tabuľka nepatrí do vybraného rozsahu. 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-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-no-importable-columns = Tabuľka '{ $table }' nemá žiadne stĺpce, do ktorých by import mohol zapisovať.
import-err-column-not-importable = Stĺpec '{ $column }' nie je importovateľný pre tabuľku '{ $table }'. import-err-csv-row = Riadok { $row }: { $error }
import-err-csv-row = Riadok CSV { $row }: { $error } import-err-row-width = Každý riadok musí obsahovať rovnaký počet pozícií. Tento súbor začína s { $expected } a neskôr má riadok s { $found }.
import-err-multi-headers = Viac-tabuľkové CSV potrebuje riadok s hlavičkou tabuliek a riadok s hlavičkou stĺpcov. import-err-no-data-rows = Súbor neobsahuje žiadne riadky údajov.
import-err-first-row = Prvý riadok CSV musí obsahovať len vybrané názvy tabuliek. import-err-source-mode = Uveďte, ako je zdroj usporiadaný.
import-err-header-lengths = Riadok hlavičky tabuliek a stĺpcov majú rôznu dĺžku. import-err-template-has-no-source = Šablóna sa generuje z tabuľky, takže nie je čo pripravovať zo súboru.
import-err-row-width = Riadok údajov CSV má iný počet polí ako hlavička. import-err-template-empty = Zaškrtnite pre šablónu aspoň jeden stĺpec.
import-err-no-data-rows = CSV obsahuje hlavičky, ale žiadne riadky údajov. import-err-mapping-stale = Mapovanie bolo zostavené pre súbor s { $mapped } pozíciami a tento má { $positions }. Pripravte ho znova.
import-err-header-padded = Hlavička '{ $header }' má okolo seba medzery, takže nie je stĺpcom '{ $trimmed }'. Odstráňte ich, alebo použite „Normalizovať hlavičky“. import-err-mapping-unknown-column = Pozícia { $position } smeruje na '{ $column }', čo nie je stĺpec, do ktorého sa dá do tejto tabuľky importovať. Tabuľka sa mohla zmeniť, kým bol formulár otvorený.
import-err-duplicate-header = Hlavička '{ $header }' sa pri tej istej tabuľke vyskytuje dvakrát, takže nie je jasné, do ktorého stĺpca hodnoty patria. import-err-mapping-duplicate = Dve pozície smerujú na '{ $column }'. Stĺpec sa dá naplniť len z jednej pozície.
import-err-column-near-match = Hlavička '{ $header }' nie je stĺpcom tabuľky '{ $table }', ale '{ $column }' áno - líšia sa len medzerami alebo veľkosťou písmen. Opravte hlavičku, alebo použite „Normalizovať hlavičky“ a výsledok si pred importom pozrite. import-err-mapping-empty = Všetky pozície sú nastavené na Ignorovať, takže nie je čo importovať.
import-normalize = Normalizovať hlavičky
import-normalize-hint = Prepíše riadky hlavičiek na názvy stĺpcov tabuľky. Nič sa neimportuje - zmenené CSV najprv uvidíte.
import-normalized-message = Hlavičky prepísané. Kým CSV naimportujete, prečítajte si ho nižšie.
import-normalized-unchanged = Hlavičky už tabuľke presne zodpovedajú; nič sa nezmenilo.
import-system-columns = Súbor obsahuje systémové stĺpce
import-system-columns-hint = Zaškrtnite pre súbor exportovaný so systémovými stĺpcami. Načíta sa tak, ako je: 'deleted' sa zapíše zo súboru, takže zmazaný riadok príde ako zmazaný. 'id', 'row_revision' a 'created_at' prideľuje server a preniesť ich nemožno.
import-err-system-columns-present = Súbor obsahuje systémové stĺpce ({ $columns }). Zaškrtnite „Súbor obsahuje systémové stĺpce“, aby sa načítal tak, ako je, alebo ich zo súboru odstráňte.
import-success-ignored-system = { $columns } prideľuje server, importované riadky preto nesú nové.
import-err-missing-type = Chýba typ stĺpca '{ $column }' import-err-missing-type = Chýba typ stĺpca '{ $column }'
import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať. import-err-permission = Vyžaduje sa oprávnenie na import. Tabuľka navyše potrebuje oprávnenie na vkladanie, aby sa do nej dalo načítať.
import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu. import-err-unterminated-quote = CSV obsahuje neuzavretú úvodzovkovú hodnotu.

View File

@@ -469,6 +469,11 @@ mod tests {
("/admin/validation/rules", ""), ("/admin/validation/rules", ""),
("/admin/validation/sets", ""), ("/admin/validation/sets", ""),
("/admin/import", ""), ("/admin/import", ""),
("/admin/import/source", ""),
("/admin/import/prepare", ""),
("/admin/import/preview", ""),
("/admin/import/prepared.csv", ""),
("/admin/import/template.csv", ""),
("/admin/export.csv", ""), ("/admin/export.csv", ""),
] { ] {
let response = test_router() let response = test_router()

View File

@@ -6,12 +6,12 @@ use crate::{i18n::Locale, tr};
use crate::definitions::table_structure::TableStructureResponse; use crate::definitions::table_structure::TableStructureResponse;
/// The columns an import can write, and the export's default header. /// The export's default header.
/// ///
/// Both ends use this list, so a file the default export writes is a file the /// A file the default export writes is a file the import can be pointed at
/// import accepts. That only holds if every system column is left out: a row is /// column for column. That only holds if every system column is left out: a row
/// inserted with `post_table_data`, which takes user columns and nothing else, /// is inserted with `post_table_data`, which takes user columns and nothing
/// so exporting `row_revision` or `created_at` produced a file whose own /// else, so exporting `row_revision` or `created_at` produced a file whose own
/// re-import the server answered with `Invalid column`. The names come from /// re-import the server answered with `Invalid column`. The names come from
/// the server's declarations rather than a list spelled out here, so a system /// the server's declarations rather than a list spelled out here, so a system
/// column added there is excluded here too. /// column added there is excluded here too.
@@ -80,24 +80,49 @@ fn is_read_omitted_column(name: &str) -> bool {
.any(|column| column.name == name) .any(|column| column.name == name)
} }
/// Column names keyed by their lowercased form, for the one caller allowed to /// The columns an import may write into: the destinations the mapping step
/// match loosely: the normalizer, which rewrites a header into the column's own /// offers, in the order the table declares them.
/// spelling and shows the user the result before anything is imported.
/// ///
/// Two columns that fold to the same key leave that key out entirely. There is /// This is the list the whole import rests on. A source position lands in a
/// no answer to "which one did the header mean", and inventing one would be the /// column because the user picked it from here, so what is not here cannot be
/// normalizer quietly choosing for the user. /// written to at all — which is why the exclusions are the server's own flags
pub(crate) fn folded_column_lookup(columns: &[String]) -> HashMap<String, String> { /// rather than a guess:
let mut lookup: HashMap<String, Option<String>> = HashMap::new(); ///
for column in columns { /// * `is_primary_key` — `id` comes from a sequence.
lookup /// * `read_only` — the server sets this for a quantity-ledger column and for a
.entry(column.to_lowercase()) /// link projection, and refuses an insert that names either. Accounting
.and_modify(|entry| *entry = None) /// columns are marked `generated` but *not* read-only, so they stay: they are
.or_insert_with(|| Some(column.clone())); /// a user's to fill in.
} /// * system columns, except the ones an insert actually takes. `deleted` is
lookup /// offered, because writing it is how a file that recorded deleted rows loads
.into_iter() /// back as deleted rows; `row_revision` and `created_at` are not, because the
.filter_map(|(folded, column)| Some((folded, column?))) /// server assigns them and answers `Invalid column` to anything else.
pub(crate) fn importable_columns(schema: &TableStructureResponse) -> Vec<String> {
schema
.columns
.iter()
.filter(|column| !column.is_primary_key && !column.read_only)
.filter(|column| {
!is_system_column(&column.name) || is_importable_system_column(&column.name)
})
.map(|column| column.name.clone())
.collect()
}
/// The writable columns the table declares `NOT NULL`.
///
/// Shown in the preview as a warning rather than enforced as a rule: a
/// `NOT NULL` column may still have a default, and `information_schema` does
/// not say which do. Refusing here would block imports the server would have
/// accepted, so the server stays the one that decides and this only tells the
/// user which columns are the likely reason if it refuses.
pub(crate) fn required_columns(schema: &TableStructureResponse) -> Vec<String> {
schema
.columns
.iter()
.filter(|column| !column.is_primary_key && !column.read_only)
.filter(|column| !is_system_column(&column.name) && !column.is_nullable)
.map(|column| column.name.clone())
.collect() .collect()
} }
@@ -160,6 +185,7 @@ mod tests {
name: name.to_string(), name: name.to_string(),
data_type: "TEXT".to_string(), data_type: "TEXT".to_string(),
is_primary_key, is_primary_key,
is_nullable: true,
..Default::default() ..Default::default()
} }
} }
@@ -207,4 +233,65 @@ mod tests {
] ]
); );
} }
/// What the mapping step may offer as a destination. `deleted` is in,
/// because an insert takes it; the columns the server assigns and the ones
/// it marks read-only are out, because an insert naming them is refused.
#[test]
fn only_the_columns_an_insert_takes_are_offered_as_destinations() {
let ledger = TableColumn {
read_only: true,
..column("stock", false)
};
// Accounting columns are generated companions, but the server leaves
// them writable — so they are a destination like any other.
let accounting = TableColumn {
generated: true,
generated_from: "accounting".to_string(),
..column("debit", false)
};
let schema = TableStructureResponse {
columns: vec![
column("id", true),
column("deleted", false),
column("row_revision", false),
column("number", false),
ledger,
accounting,
column("created_at", false),
],
};
assert_eq!(
importable_columns(&schema),
vec![
"deleted".to_string(),
"number".to_string(),
"debit".to_string(),
]
);
}
/// Only the user's own `NOT NULL` columns are reported as required.
/// `deleted` is `NOT NULL` on every managed table and has a default, so
/// reporting it would warn about every import ever prepared.
#[test]
fn required_columns_are_the_users_own_not_null_ones() {
let schema = TableStructureResponse {
columns: vec![
column("id", true),
TableColumn {
is_nullable: false,
..column("deleted", false)
},
TableColumn {
is_nullable: false,
..column("number", false)
},
column("note", false),
],
};
assert_eq!(required_columns(&schema), vec!["number".to_string()]);
}
} }

View File

@@ -4,20 +4,20 @@ use crate::AppState;
use super::{ use super::{
super::common::loader::{LoadError, load_catalog}, super::common::loader::{LoadError, load_catalog},
state::{ImportForm, ImportPageState}, state::{ImportForm, ImportPageState, Step},
}; };
pub(crate) async fn load_page( pub(crate) async fn load_page(
state: AppState, state: AppState,
headers: &HeaderMap, headers: &HeaderMap,
form: ImportForm, form: ImportForm,
error: Option<String>, step: Step,
) -> Result<ImportPageState, LoadError> { ) -> Result<ImportPageState, LoadError> {
let catalog = load_catalog(state, headers, crate::authz::IMPORT, "insert").await?; let catalog = load_catalog(state, headers, crate::authz::IMPORT, "insert").await?;
Ok(ImportPageState { Ok(ImportPageState {
nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization), nav: crate::ui::Nav::from_authorization(headers, "", &catalog.authorization),
catalog, catalog,
form, form,
error, step,
}) })
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,29 @@
mod loader; mod loader;
mod logic; mod logic;
mod prepare;
mod state; mod state;
mod ui; mod ui;
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}}; use axum::{
Router,
extract::DefaultBodyLimit,
routing::{get, post},
};
use crate::AppState; use crate::AppState;
pub(crate) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/admin/import", get(logic::import_page)) .route("/admin/import", get(logic::import_page))
// The preparation, step by step. Each one swaps the step block and
// leaves the rest of the form alone.
.route("/admin/import/source", post(logic::source_step))
.route("/admin/import/prepare", post(logic::prepare_step))
.route("/admin/import/preview", post(logic::preview_step))
// The two things a prepared import can be: rows in the table, or a file
// to look at first. Both read the same preparation.
.route("/admin/import", post(logic::import_csv)) .route("/admin/import", post(logic::import_csv))
.route("/admin/import/normalize", post(logic::normalize_headers)) .route("/admin/import/prepared.csv", post(logic::download_prepared))
.route("/admin/import/template.csv", post(logic::download_template))
.layer(DefaultBodyLimit::max(128 * 1024 * 1024)) .layer(DefaultBodyLimit::max(128 * 1024 * 1024))
} }

View File

@@ -0,0 +1,565 @@
//! Turning what the user uploaded into the one CSV the import understands.
//!
//! Everything here is about *position*. A value lands in a column because the
//! user pointed position 3 at `active`, and for no other reason — not because
//! a source header spells something similar, not because the words look alike.
//! The source's own header, when it has one, is read out for orientation and
//! then thrown away.
//!
//! The output is the canonical form: fully quoted CSV whose header names real
//! destination columns, in the order the source presents them. From there the
//! import is the strict one it always was — types, validations, scripts, links
//! and permissions are all the server's, unchanged.
use std::collections::HashSet;
use crate::{i18n::Locale, tr};
use super::super::common::csv::{parse_csv, write_record};
/// How the uploaded text is laid out.
///
/// Always the user's answer, never inferred. A data row can hold words that
/// read exactly like column names — `"name","num"` is a perfectly good pair of
/// customer records — so deciding for them is how a real row gets silently
/// eaten as a header.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum SourceMode {
/// The first row is the source's own header. It labels the mapping rows
/// and is not imported.
#[default]
Header,
/// Every row is data.
Data,
/// No source at all: pick and order destination columns, and take away a
/// header to fill in elsewhere.
Template,
}
impl SourceMode {
pub(crate) fn parse(locale: Locale, raw: &str) -> Result<Self, String> {
match raw {
"header" => Ok(Self::Header),
"data" => Ok(Self::Data),
"template" => Ok(Self::Template),
_ => Err(tr!(locale, "import-err-source-mode")),
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::Data => "data",
Self::Template => "template",
}
}
/// Whether a source file is needed at all. A template is generated from the
/// table, so there is nothing to upload.
pub(crate) fn reads_a_file(self) -> bool {
!matches!(self, Self::Template)
}
}
/// The source file, split into the header it states and the rows that carry
/// values.
#[derive(Debug)]
pub(crate) struct Source {
/// The source's own header row, kept only to label the mapping rows. It is
/// `None` for a data-only file, and it never decides anything.
pub header: Option<Vec<String>>,
pub rows: Vec<Vec<String>>,
/// How many positions every row has. The mapping has exactly this many
/// entries, which is what makes "position 3" mean one thing.
pub width: usize,
}
impl Source {
/// What the first data row holds at each position, for the mapping step to
/// show beside the destination picker. An example is the one thing that
/// reliably tells a user which column they are looking at.
pub(crate) fn examples(&self) -> Vec<String> {
(0..self.width)
.map(|position| {
self.rows
.first()
.and_then(|row| row.get(position))
.cloned()
.unwrap_or_default()
})
.collect()
}
/// The source's name for a position, when it has one.
pub(crate) fn source_name(&self, position: usize) -> Option<String> {
self.header
.as_ref()
.and_then(|header| header.get(position))
.cloned()
}
}
/// Reads the uploaded text as `mode` says it is laid out.
pub(crate) fn read_source(locale: Locale, csv: &str, mode: SourceMode) -> Result<Source, String> {
let mut rows = parse_csv(locale, csv)?;
let header = match mode {
SourceMode::Header => {
if rows.len() < 2 {
return Err(tr!(locale, "import-err-no-data-rows"));
}
Some(rows.remove(0))
}
SourceMode::Data => None,
// Nothing to read: the caller decides the columns from the table.
SourceMode::Template => return Err(tr!(locale, "import-err-template-has-no-source")),
};
if rows.is_empty() {
return Err(tr!(locale, "import-err-no-data-rows"));
}
let width = header
.as_ref()
.map_or_else(|| rows[0].len(), |header| header.len());
if width == 0 {
return Err(tr!(locale, "import-err-empty"));
}
// Positions only mean anything if every row has the same ones. A short row
// would otherwise shift every value after the gap into the wrong column.
if let Some(row) = rows.iter().find(|row| row.len() != width) {
return Err(tr!(
locale,
"import-err-row-width",
"expected" => width as i64,
"found" => row.len() as i64,
));
}
Ok(Source { header, rows, width })
}
/// Where each source position is to be written, `None` being "ignore".
#[derive(Debug)]
pub(crate) struct Mapping(Vec<Option<String>>);
impl Mapping {
/// The destination chosen for a position, for the form to re-render with
/// the user's own answers still selected.
pub(crate) fn target(&self, position: usize) -> Option<&str> {
self.0.get(position).and_then(Option::as_deref)
}
pub(crate) fn len(&self) -> usize {
self.0.len()
}
}
/// Reads the posted mapping and checks the rules that make "proper data in the
/// proper place" true rather than hoped for.
///
/// `writable` is the destination list the table itself declares, loaded again
/// on every step: a column dropped or renamed while the form was open is caught
/// here instead of writing values into a column that no longer means what the
/// user chose.
pub(crate) fn read_mapping(
locale: Locale,
posted: &[String],
width: usize,
writable: &[String],
) -> Result<Mapping, String> {
// One select per position, always posted, so the form's shape and the
// file's have to agree. They will not if the CSV was edited after the
// mapping was built.
if posted.len() != width {
return Err(tr!(
locale,
"import-err-mapping-stale",
"positions" => width as i64,
"mapped" => posted.len() as i64,
));
}
let known = writable.iter().map(String::as_str).collect::<HashSet<_>>();
let mut used = HashSet::new();
let mut targets = Vec::with_capacity(width);
for (position, target) in posted.iter().enumerate() {
let target = target.trim();
if target.is_empty() {
targets.push(None);
continue;
}
if !known.contains(target) {
return Err(tr!(
locale,
"import-err-mapping-unknown-column",
"column" => target.to_string(),
"position" => (position + 1) as i64,
));
}
// Two positions pointed at one column let whichever is read second
// decide the value, silently. Refused instead.
if !used.insert(target.to_string()) {
return Err(tr!(
locale,
"import-err-mapping-duplicate",
"column" => target.to_string(),
));
}
targets.push(Some(target.to_string()));
}
if used.is_empty() {
return Err(tr!(locale, "import-err-mapping-empty"));
}
Ok(Mapping(targets))
}
/// The canonical import, and what it leaves behind.
pub(crate) struct Prepared {
/// The destination header, in source-position order — which is the only
/// order the data can be written in, since the values arrive in it.
pub columns: Vec<String>,
/// Data rows holding only the mapped positions, aligned to `columns`.
pub rows: Vec<Vec<String>>,
/// The source positions sent nowhere, numbered from 1 as the mapping step
/// numbers them.
pub ignored: Vec<usize>,
/// Destination columns this import does not write. The server fills them
/// with their defaults, or refuses the row if it cannot.
pub omitted: Vec<String>,
}
impl Prepared {
pub(crate) fn row_count(&self) -> usize {
self.rows.len()
}
/// The first `limit` rows, for the preview. A preview is for recognising
/// your own data, and nobody recognises it on row 40 000.
pub(crate) fn preview_rows(&self, limit: usize) -> &[Vec<String>] {
&self.rows[..self.rows.len().min(limit)]
}
pub(crate) fn hidden_rows(&self, limit: usize) -> usize {
self.rows.len().saturating_sub(limit)
}
}
/// Applies the mapping: the values that were pointed somewhere, under the names
/// they were pointed at.
pub(crate) fn prepare(mapping: &Mapping, source: &Source, writable: &[String]) -> Prepared {
let taken = (0..mapping.len())
.filter_map(|position| Some((position, mapping.target(position)?.to_string())))
.collect::<Vec<_>>();
let columns = taken
.iter()
.map(|(_, column)| column.clone())
.collect::<Vec<_>>();
let rows = source
.rows
.iter()
.map(|row| {
taken
.iter()
.map(|(position, _)| row.get(*position).cloned().unwrap_or_default())
.collect()
})
.collect();
let ignored = (0..source.width)
.filter(|position| mapping.target(*position).is_none())
.map(|position| position + 1)
.collect();
let omitted = writable
.iter()
.filter(|column| !columns.contains(column))
.cloned()
.collect();
Prepared {
columns,
rows,
ignored,
omitted,
}
}
/// The prepared import as text: exactly what the importer reads, and exactly
/// what "Download prepared CSV" hands over, so inspecting the file and
/// importing it cannot disagree.
pub(crate) fn canonical_csv(prepared: &Prepared) -> String {
let mut csv = String::new();
write_record(&mut csv, &prepared.columns);
for row in &prepared.rows {
write_record(&mut csv, row);
}
csv
}
/// A header and nothing else: the columns the user picked, in the order they
/// arranged them, ready to be filled in elsewhere and brought back as a file
/// with a header.
pub(crate) fn template_csv(columns: &[String]) -> String {
let mut csv = String::new();
write_record(&mut csv, columns);
csv
}
/// The destination a position starts out pointed at when the mapping step first
/// opens.
///
/// Only an exact match counts, and only against a real destination column. That
/// is not name interpretation: a header cell that *is* the column's name is the
/// column's name, which is the case whenever the file came from this system's
/// own template or export. Anything else starts at "Ignore", and every row is
/// in front of the user to confirm or change before a single value moves.
pub(crate) fn suggest_mapping(source: &Source, writable: &[String]) -> Vec<String> {
let mut used = HashSet::new();
(0..source.width)
.map(|position| {
let Some(name) = source.source_name(position) else {
return String::new();
};
if writable.contains(&name) && used.insert(name.clone()) {
name
} else {
String::new()
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
fn writable() -> Vec<String> {
strings(&["name", "company_number", "active", "note"])
}
/// A data-only file is all rows, and nothing in it is read as a header —
/// even when the first row happens to hold the column names.
#[test]
fn a_data_only_file_keeps_its_first_row() {
let csv = "\"name\",\"num\"\n\"Acme\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
assert!(source.header.is_none());
assert_eq!(source.rows.len(), 2);
assert_eq!(source.examples(), strings(&["name", "num"]));
}
/// A file declared to have a header loses its first row, and that row is
/// only ever shown back to the user.
#[test]
fn a_header_file_loses_its_first_row_to_labels() {
let csv = "\"Company\",\"Registration number\"\n\"Acme\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(source.rows.len(), 1);
assert_eq!(source.source_name(0).as_deref(), Some("Company"));
assert_eq!(source.examples(), strings(&["Acme", "12345678"]));
}
/// A header with no rows under it is not an import, and says so rather than
/// preparing an empty one.
#[test]
fn a_header_with_nothing_under_it_is_refused() {
let csv = "\"Company\",\"Registration number\"\n";
assert!(read_source(Locale::default(), csv, SourceMode::Header).is_err());
}
/// Positions only mean something if every row has the same ones.
#[test]
fn rows_of_different_widths_are_refused() {
let csv = "\"Acme\",\"12345678\"\n\"Example\"\n";
let error = read_source(Locale::default(), csv, SourceMode::Data)
.expect_err("a short row shifts every value after it");
assert!(error.contains('2') && error.contains('1'), "{error}");
}
/// The heart of it: the user's choices decide where values go, source names
/// are irrelevant, and an ignored position takes its value nowhere.
#[test]
fn values_land_where_the_user_pointed_them() {
let csv = "\"whatever\",\"old field 7\",\"enabled value\",\"legacy\"\n\
\"Acme\",\"12345678\",\"true\",\"drop me\"\n\
\"Example\",\"87654321\",\"false\",\"drop me too\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
let mapping = read_mapping(
Locale::default(),
&strings(&["name", "company_number", "active", ""]),
source.width,
&writable(),
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
assert_eq!(
prepared.columns,
strings(&["name", "company_number", "active"])
);
assert_eq!(prepared.rows[0], strings(&["Acme", "12345678", "true"]));
assert_eq!(prepared.ignored, vec![4]);
assert_eq!(prepared.omitted, strings(&["note"]));
assert_eq!(
canonical_csv(&prepared),
"\"name\",\"company_number\",\"active\"\n\
\"Acme\",\"12345678\",\"true\"\n\
\"Example\",\"87654321\",\"false\"\n"
);
}
/// Out-of-order and sparse mappings are the normal case, not an edge one:
/// the header follows the source's positions, because the values do.
#[test]
fn the_prepared_header_follows_the_source_order() {
let csv = "\"Acme\",\"unused legacy value\",\"true\",\"12345678\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
let mapping = read_mapping(
Locale::default(),
&strings(&["name", "", "active", "company_number"]),
source.width,
&writable(),
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
assert_eq!(
canonical_csv(&prepared),
"\"name\",\"active\",\"company_number\"\n\"Acme\",\"true\",\"12345678\"\n"
);
assert_eq!(prepared.ignored, vec![2]);
}
/// What is downloaded and what is imported are the same file. The import
/// reads `Prepared` directly rather than its own text, so this is the check
/// that the two cannot drift apart.
#[test]
fn the_downloaded_csv_parses_back_to_the_rows_that_get_imported() {
let csv = "\"Acme, s.r.o.\",\"said \"\"yes\"\"\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Data).unwrap();
let mapping = read_mapping(
Locale::default(),
&strings(&["name", "note"]),
source.width,
&writable(),
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
let reparsed = parse_csv(Locale::default(), &canonical_csv(&prepared)).unwrap();
assert_eq!(reparsed[0], prepared.columns);
assert_eq!(reparsed[1..], prepared.rows[..]);
}
/// One destination cannot be filled from two positions: the second would
/// silently win.
#[test]
fn a_destination_cannot_be_chosen_twice() {
let error = read_mapping(
Locale::default(),
&strings(&["name", "name"]),
2,
&writable(),
)
.expect_err("two positions cannot both be `name`");
assert!(error.contains("name"), "{error}");
}
/// A destination that is not a writable column of this table is refused,
/// whatever the form posted — the table is asked again on every step.
#[test]
fn a_destination_the_table_does_not_offer_is_refused() {
let error = read_mapping(
Locale::default(),
&strings(&["name", "id"]),
2,
&writable(),
)
.expect_err("`id` is the server's");
assert!(error.contains("id"), "{error}");
}
/// A mapping built against a different file is refused rather than applied
/// to whatever positions happen to line up.
#[test]
fn a_mapping_that_does_not_cover_the_file_is_refused() {
assert!(read_mapping(Locale::default(), &strings(&["name"]), 3, &writable()).is_err());
}
/// Ignoring everything is not an import.
#[test]
fn a_mapping_that_writes_nothing_is_refused() {
assert!(read_mapping(Locale::default(), &strings(&["", ""]), 2, &writable()).is_err());
}
/// A file this system generated the template for comes back with its own
/// column names, so every row starts out pointed at the right place — still
/// shown, still confirmed, never applied on its own.
#[test]
fn an_exact_header_starts_the_mapping_off() {
let csv = "\"name\",\"active\"\n\"Acme\",\"true\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", "active"]));
}
/// Anything that is not exactly a column name starts at "Ignore". There is
/// no folding, no trimming and no similarity: ` name` and `Name` are not
/// the column `name`, and "Company" is not a guess anyone should make.
#[test]
fn nothing_but_an_exact_name_is_suggested() {
let csv = "\"Company\",\" name\",\"NAME\",\"note\"\n\"Acme\",\"x\",\"y\",\"z\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(
suggest_mapping(&source, &writable()),
strings(&["", "", "", "note"])
);
}
/// A header naming one column twice suggests it once: the second would be
/// a duplicate the mapping step refuses anyway.
#[test]
fn a_repeated_header_is_only_suggested_once() {
let csv = "\"name\",\"name\"\n\"Acme\",\"Other\"\n";
let source = read_source(Locale::default(), csv, SourceMode::Header).unwrap();
assert_eq!(suggest_mapping(&source, &writable()), strings(&["name", ""]));
}
/// The generated template is a header and nothing else, in the order it was
/// arranged in.
#[test]
fn a_template_is_the_chosen_columns_in_the_chosen_order() {
assert_eq!(
template_csv(&strings(&["active", "name", "company_number"])),
"\"active\",\"name\",\"company_number\"\n"
);
}
/// And it is a file this page can read straight back: the header mode plus
/// the exact-name suggestion close the loop.
#[test]
fn a_generated_template_comes_back_ready_to_import() {
let mut csv = template_csv(&strings(&["name", "active"]));
csv.push_str("\"Acme\",\"true\"\n");
let source = read_source(Locale::default(), &csv, SourceMode::Header).unwrap();
let mapping = read_mapping(
Locale::default(),
&suggest_mapping(&source, &writable()),
source.width,
&writable(),
)
.unwrap();
let prepared = prepare(&mapping, &source, &writable());
assert_eq!(prepared.columns, strings(&["name", "active"]));
assert_eq!(prepared.rows, vec![strings(&["Acme", "true"])]);
}
#[test]
fn a_source_mode_round_trips_and_an_unknown_one_is_refused() {
for mode in [SourceMode::Header, SourceMode::Data, SourceMode::Template] {
assert_eq!(SourceMode::parse(Locale::default(), mode.as_str()), Ok(mode));
}
assert!(SourceMode::parse(Locale::default(), "guess").is_err());
}
}

View File

@@ -1,53 +1,214 @@
use crate::{i18n::Locale, tr}; use crate::{i18n::Locale, tr};
use super::prepare::SourceMode;
/// Every field the import form carries, at every step.
///
/// The whole preparation is one form posted back and forth: the page keeps no
/// server-side session, so each step re-renders the fields the next one needs
/// and the user can go back without losing what they chose.
#[derive(Clone, Debug, Default, serde::Deserialize)] #[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ImportForm { pub(crate) struct ImportForm {
#[serde(default)] #[serde(default)]
pub profile_name: String, pub profile_name: String,
/// One entry per checked table. The form posts the key once per checked /// One table. An import writes into one table, and a file for two tables is
/// box, which only `axum_extra`'s `Form` decodes into a `Vec`. /// two prepared imports rather than one file with a second header row.
#[serde(default)] #[serde(default)]
pub table_names: Vec<String>, pub table_name: String,
/// `header`, `data` or `template` — see [`SourceMode`]. Kept as text so an
/// unrecognised value is a message rather than a form rejection.
#[serde(default)]
pub source_mode: String,
#[serde(default)] #[serde(default)]
pub csv_data: String, pub csv_data: String,
/// Whether a system column in the file is written rather than left to the /// The destination chosen for each source position, in position order, one
/// server. Only `deleted` can be: see /// entry per position; empty means "ignore". Posted once per `<select>`,
/// [`IMPORTABLE_SYSTEM_COLUMNS`](super::super::common::schema::IMPORTABLE_SYSTEM_COLUMNS). /// which only `axum_extra`'s `Form` decodes into a `Vec`.
/// An unchecked box is not posted at all, so its absence is the `false`.
#[serde(default)] #[serde(default)]
pub import_system_columns: Option<String>, pub mapping: Vec<String>,
/// Every destination column in the order the template step has them
/// arranged, chosen or not, so the arrangement survives a re-render.
#[serde(default)]
pub template_order: Vec<String>,
/// The columns ticked in the template step, which the checkboxes post in
/// the arranged order — so this *is* the generated header.
#[serde(default)]
pub template_columns: Vec<String>,
/// `up` or `down` when a move button posted, with `index` naming the row.
#[serde(default)]
pub action: Option<String>,
#[serde(default)]
pub index: Option<usize>,
}
impl ImportForm {
pub(crate) fn mode(&self, locale: Locale) -> Result<SourceMode, String> {
if self.source_mode.is_empty() {
return Ok(SourceMode::default());
}
SourceMode::parse(locale, &self.source_mode)
}
/// The scope and table this import writes into, checked for being answered
/// at all. Whether they are the user's to write into is the catalog's
/// answer, and then the backend's.
pub(crate) fn target(&self, locale: Locale) -> Result<(String, String), String> {
let profile = self.profile_name.trim();
if profile.is_empty() {
return Err(tr!(locale, "import-err-select-profile"));
}
let table = self.table_name.trim();
if table.is_empty() {
return Err(tr!(locale, "import-err-tables-required"));
}
if self.mode(locale)?.reads_a_file() && self.csv_data.trim().is_empty() {
return Err(tr!(locale, "import-err-csv-required"));
}
Ok((profile.to_string(), table.to_string()))
}
/// Whether `name` is the table the form has selected.
pub(crate) fn is_table(&self, name: &str) -> bool {
self.table_name == name
}
/// Whether the radio for `mode` is the one selected, for re-rendering the
/// choice the user made.
pub(crate) fn is_mode(&self, mode: &str) -> bool {
if self.source_mode.is_empty() {
return mode == SourceMode::default().as_str();
}
self.source_mode == mode
}
}
/// Which of the preparation's steps the page is showing.
///
/// One page, three stops: say what the source is, say where each position goes,
/// then look at the result before anything is written.
pub(crate) enum Step {
/// Choose the destination table and describe the source.
Source,
/// Point each source position at a destination column.
Mapping(MappingStep),
/// Pick and arrange destination columns, and take away the header.
Template(TemplateStep),
/// The prepared import, as it will be sent.
Preview(PreviewStep),
}
pub(crate) struct MappingStep {
pub table_name: String,
/// The destinations to offer, in the order the table declares them.
pub columns: Vec<String>,
pub rows: Vec<MappingRow>,
pub source_rows: usize,
/// Whether the source stated a header, so the table can show that column at
/// all — and say that it decides nothing.
pub has_source_names: bool,
}
/// One source position, and what the user has pointed it at.
pub(crate) struct MappingRow {
/// Numbered from 1, the way the source is read by a person.
pub position: usize,
/// What the source called this position, when it said. Orientation only.
pub source_name: Option<String>,
/// The first data row's value here — usually the fastest way to recognise
/// which column this is.
pub example: String,
/// The chosen destination, empty for "ignore".
pub target: String,
}
impl MappingRow {
/// Whether this position is pointed at `column`, for re-rendering the
/// picker with the user's own answer selected.
pub(crate) fn targets(&self, column: &str) -> bool {
self.target == column
}
}
pub(crate) struct TemplateStep {
pub table_name: String,
pub rows: Vec<TemplateRow>,
/// The generated header as it stands, empty when nothing is ticked.
pub header: String,
pub chosen: usize,
}
pub(crate) struct TemplateRow {
/// Position in the arrangement, from 0, for the move buttons.
pub index: usize,
pub name: String,
pub chosen: bool,
/// Declared `NOT NULL`, so leaving it out of the template is likely to
/// produce rows the server refuses.
pub required: bool,
pub first: bool,
pub last: bool,
}
pub(crate) struct PreviewStep {
pub table_name: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
/// Rows the preview does not show, so a large file says how large.
pub hidden_rows: usize,
pub source_rows: usize,
/// Source positions written nowhere, labelled with the source's own name
/// when it gave one.
pub ignored: Vec<String>,
/// Destination columns this import does not write.
pub omitted: Vec<String>,
/// Of those, the ones the table declares `NOT NULL`. A warning, not a
/// refusal: the column may have a default, and only the server knows.
pub missing_required: Vec<String>,
/// The canonical CSV, which is both what gets imported and what the
/// download hands over.
pub csv: String,
} }
pub(crate) struct ImportPageState { pub(crate) struct ImportPageState {
pub nav: crate::ui::Nav, pub nav: crate::ui::Nav,
pub catalog: super::super::common::loader::Catalog, pub catalog: super::super::common::loader::Catalog,
pub form: ImportForm, pub form: ImportForm,
pub error: Option<String>, pub step: Step,
} }
impl ImportForm { #[cfg(test)]
pub(crate) fn import_system_columns(&self) -> bool { mod tests {
self.import_system_columns.is_some() use super::*;
/// A template needs no file, so the form does not insist on one — and the
/// other two modes do.
#[test]
fn only_a_file_import_needs_a_file() {
let form = ImportForm {
profile_name: "acme".to_string(),
table_name: "customers".to_string(),
source_mode: "template".to_string(),
..ImportForm::default()
};
assert_eq!(
form.target(Locale::default()).unwrap(),
("acme".to_string(), "customers".to_string())
);
let form = ImportForm {
source_mode: "data".to_string(),
..form
};
assert!(form.target(Locale::default()).is_err());
} }
pub(crate) fn targets(&self, locale: Locale) -> Result<(String, Vec<String>), String> { /// The default is a file with a header, and the radio renders as such
let profile = self.profile_name.trim(); /// before anything has been posted.
if profile.is_empty() { #[test]
return Err(tr!(locale, "import-err-select-profile")); fn the_source_mode_defaults_to_a_header_file() {
} let form = ImportForm::default();
let tables = self assert_eq!(form.mode(Locale::default()).unwrap(), SourceMode::Header);
.table_names assert!(form.is_mode("header"));
.iter() assert!(!form.is_mode("data"));
.map(|table| table.trim())
.filter(|table| !table.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
if tables.is_empty() {
return Err(tr!(locale, "import-err-tables-required"));
}
if self.csv_data.trim().is_empty() {
return Err(tr!(locale, "import-err-csv-required"));
}
Ok((profile.to_string(), tables))
} }
} }

View File

@@ -1,50 +1,44 @@
use askama::Template; use askama::Template;
use crate::{i18n::Locale, tr};
use crate::ui::{Alert, Nav, render}; use crate::ui::{Alert, Nav, render};
use crate::{i18n::Locale, tr};
use super::state::ImportPageState; // `Step` is named by the step template's `{% match %}`, so it has to be in
// scope here — the derive expands into this module.
use super::state::{ImportPageState, Step};
/// GET /admin/import /// GET /admin/import — the whole page, whichever step it is showing.
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/import_export/import/import.html")] #[template(path = "pages/import_export/import/import.html")]
struct ImportPage<'a> { struct ImportPage<'a> {
nav: Nav, nav: Nav,
page: &'a ImportPageState, page: &'a ImportPageState,
/// `None` on a fresh page: the included fields template shows its
/// "normalized" line only after a rewrite has actually run.
changed: Option<bool>,
} }
/// POST /admin/import/normalize — the form comes back with the rewritten CSV /// The step block on its own. Every button that advances or goes back swaps
/// in it, so the user reads what changed before importing anything. /// this, so the form around it — and the file the user chose — stays put.
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/import_export/import/fields.html")] #[template(path = "pages/import_export/import/step.html")]
struct ImportFields<'a> { struct ImportStep<'a> {
nav: Nav, nav: Nav,
page: &'a ImportPageState, page: &'a ImportPageState,
/// Whether the rewrite actually changed the text, so a file that was
/// already exact says so instead of looking like it was edited.
changed: Option<bool>,
} }
pub(crate) fn render_page(page: &ImportPageState) -> String { pub(crate) fn render_page(page: &ImportPageState) -> String {
render(&ImportPage { render(&ImportPage {
nav: page.nav.clone(), nav: page.nav.clone(),
page, page,
changed: None,
}) })
} }
pub(crate) fn render_normalized(page: &ImportPageState, changed: bool) -> String { pub(crate) fn render_step(page: &ImportPageState) -> String {
render(&ImportFields { render(&ImportStep {
nav: page.nav.clone(), nav: page.nav.clone(),
page, page,
changed: Some(changed),
}) })
} }
/// POST /admin/import — the #submission-status swaps. /// The #submission-status swaps.
pub(crate) fn render_error(locale: Locale, message: &str) -> String { pub(crate) fn render_error(locale: Locale, message: &str) -> String {
render(&Alert::error( render(&Alert::error(
locale, locale,
@@ -56,30 +50,176 @@ pub(crate) fn render_error(locale: Locale, message: &str) -> String {
pub(crate) fn render_success( pub(crate) fn render_success(
locale: Locale, locale: Locale,
inserted: usize, inserted: usize,
source_rows: usize, prepared_rows: usize,
table_count: usize, table_name: &str,
ignored_system_columns: &[String],
) -> String { ) -> String {
let mut message = tr!(
locale,
"import-success-message",
"inserted" => inserted as i64,
"source_rows" => source_rows as i64,
"table_count" => table_count as i64,
);
// What the file said and what was written differ here, so the result says
// so rather than leaving the user to assume their ids came across.
if !ignored_system_columns.is_empty() {
message.push('\n');
message.push_str(&tr!(
locale,
"import-success-ignored-system",
"columns" => ignored_system_columns.join(", "),
));
}
render(&Alert::success( render(&Alert::success(
locale, locale,
&tr!(locale, "import-success-title"), &tr!(locale, "import-success-title"),
&message, &tr!(
locale,
"import-success-message",
"inserted" => inserted as i64,
"source_rows" => prepared_rows as i64,
"table" => table_name.to_string(),
),
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthorizationSnapshot;
use crate::pages::import_export::common::loader::{Catalog, Profile};
use super::super::state::{
ImportForm, MappingRow, MappingStep, PreviewStep, TemplateRow, TemplateStep,
};
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| value.to_string()).collect()
}
fn page(step: Step) -> ImportPageState {
let authorization = AuthorizationSnapshot {
role: "bookkeeper".to_string(),
permissions: Vec::new(),
};
ImportPageState {
nav: Nav::from_authorization(&axum::http::HeaderMap::new(), "", &authorization),
catalog: Catalog {
profiles: vec![Profile {
name: "acme".to_string(),
label: "acme".to_string(),
tables: strings(&["customers"]),
}],
authorization,
},
form: ImportForm {
profile_name: "acme".to_string(),
table_name: "customers".to_string(),
source_mode: "header".to_string(),
csv_data: "\"Company\"\n\"Acme\"\n".to_string(),
mapping: strings(&["name"]),
..ImportForm::default()
},
step,
}
}
/// The first step asks where the data goes and how the source is laid out,
/// and asks the second question outright — the page never decides for
/// itself whether the first row is a header.
#[test]
fn the_first_step_asks_how_the_source_is_laid_out() {
let html = render_page(&page(Step::Source));
for mode in ["header", "data", "template"] {
assert!(
html.contains(&format!(r#"name="source_mode" value="{mode}""#)),
"{html}"
);
}
assert!(html.contains(r#"name="table_name""#), "{html}");
assert!(html.contains(r#"name="csv_data""#), "{html}");
}
/// The mapping step is one picker per source position, and every picker
/// offers Ignore alongside the table's own columns. The source's own name
/// is shown next to it and is not what any option is built from.
#[test]
fn the_mapping_step_offers_a_destination_for_every_position() {
let html = render_step(&page(Step::Mapping(MappingStep {
table_name: "customers".to_string(),
columns: strings(&["name", "company_number"]),
rows: vec![
MappingRow {
position: 1,
source_name: Some("Company".to_string()),
example: "Acme".to_string(),
target: "name".to_string(),
},
MappingRow {
position: 2,
source_name: Some("Internal note".to_string()),
example: "old customer".to_string(),
target: String::new(),
},
],
source_rows: 2,
has_source_names: true,
})));
assert_eq!(html.matches(r#"name="mapping""#).count(), 2);
assert!(html.contains(r#"<option value="name" selected>"#), "{html}");
assert!(html.contains("Internal note"), "{html}");
assert!(html.contains("old customer"), "{html}");
// The destination and the source it came from are carried forward, so
// going back does not lose the file.
assert!(html.contains(r#"name="csv_data""#), "{html}");
}
/// The template step is a chooser with an arrangement, and it shows the
/// header it would generate rather than describing it.
#[test]
fn the_template_step_shows_the_header_it_generates() {
let html = render_step(&page(Step::Template(TemplateStep {
table_name: "customers".to_string(),
rows: vec![
TemplateRow {
index: 0,
name: "name".to_string(),
chosen: true,
required: true,
first: true,
last: false,
},
TemplateRow {
index: 1,
name: "note".to_string(),
chosen: false,
required: false,
first: false,
last: true,
},
],
header: "\"name\"\n".to_string(),
chosen: 1,
})));
assert!(html.contains("&#34;name&#34;"), "{html}");
assert_eq!(html.matches(r#"name="template_order""#).count(), 2);
assert!(html.contains(r#"formaction="/admin/import/template.csv""#), "{html}");
// The ends of the arrangement have nowhere to move to.
assert_eq!(html.matches("disabled").count(), 2);
}
/// The preview shows the values under the columns they will actually be
/// written to, says what is being left out, and offers both exits.
#[test]
fn the_preview_shows_the_prepared_import_and_both_ways_out() {
let html = render_step(&page(Step::Preview(PreviewStep {
table_name: "customers".to_string(),
columns: strings(&["name", "active"]),
rows: vec![strings(&["Acme", "true"])],
hidden_rows: 3,
source_rows: 4,
ignored: strings(&["2 (Internal note)"]),
omitted: strings(&["note"]),
missing_required: strings(&["company_number"]),
csv: "\"name\",\"active\"\n\"Acme\",\"true\"\n".to_string(),
})));
assert!(html.contains("<th>name</th>"), "{html}");
assert!(html.contains("<td>Acme</td>"), "{html}");
assert!(html.contains("2 (Internal note)"), "{html}");
assert!(html.contains("company_number"), "{html}");
// Import, and download the very same file.
assert!(html.contains(r#"hx-post="/admin/import""#), "{html}");
assert!(html.contains("&#34;name&#34;,&#34;active&#34;"), "{html}");
// The mapping travels with it, so the download and the import prepare
// the identical file.
assert!(html.contains(r#"name="mapping" value="name""#), "{html}");
}
}

View File

@@ -315,6 +315,30 @@
.cell-closed { color: #c3cad4; } .cell-closed { color: #c3cad4; }
.cell-inherited { color: #1d4ed8; } .cell-inherited { color: #1d4ed8; }
/* ---------- Import preparation (pages/import_export/import) ---------- */
/* The one question the page must not answer for the user: whether the first
row is a header. It is a fieldset rather than a row of loose checkboxes so
the three answers read as one choice. */
.source-mode { margin: 0; border: 1px solid #e1e6ee; border-radius: 8px; padding: 12px 14px; background: #fbfcfe; }
.source-mode legend { padding: 0 6px; color: #33415c; font-size: 13px; font-weight: 600; }
.source-mode .check { margin: 4px 0; font-size: 13px; color: #33415c; }
.source-mode .hint { margin: 8px 0 0; font-size: 12px; }
/* A mapping row is read left to right as a sentence: position 3, which holds
"12345678", goes to company_number. The example is what people actually
recognise their data by, so it gets the room. */
.mapping-table .source-name { color: #7a8496; font-size: 12px; }
.mapping-table .example { max-width: 320px; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #33415c; }
.mapping-table select { width: 100%; min-width: 180px; }
.template-table .check { justify-content: center; }
/* A secondary action in a form's action row — "back a step". The row's own
rule paints every button as the primary one, so this has to say otherwise
at the same specificity. */
.form-actions button.secondary { margin-top: 0; border: 1px solid #c9d2de; color: #24324a; background: #f4f6fa; }
.form-actions button.secondary:hover { background: #e9edf4; }
/* ---------- Narrow screens ---------- */ /* ---------- Narrow screens ---------- */
@media (max-width: 850px) { @media (max-width: 850px) {

View File

@@ -0,0 +1,44 @@
{#
Two things the mapping form does in the browser, both of them conveniences
over rules the server enforces anyway.
Everything is delegated from `document`, because each step swaps the whole
block: a listener bound to a select would be thrown away with it.
#}
<script>
(function () {
// A template is generated from the table, so there is no file to give.
// Hiding the fields is not the check — the server refuses a file import
// with no CSV either way.
function syncSourceFields() {
const mode = document.querySelector("[data-source-mode]:checked");
const fields = document.querySelector("[data-source-fields]");
if (!mode || !fields) return;
fields.hidden = mode.value === "template";
}
// A destination column can be filled from one source position only. The
// mapping step refuses a repeat outright; this stops it being offered, so
// the refusal is not how the user finds out.
function syncDestinations() {
const selects = Array.from(document.querySelectorAll("[data-destination]"));
const taken = new Set(selects.map((select) => select.value).filter(Boolean));
for (const select of selects) {
for (const option of select.options) {
option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value);
}
}
}
document.addEventListener("change", function (event) {
if (event.target.matches("[data-source-mode]")) syncSourceFields();
if (event.target.matches("[data-destination]")) syncDestinations();
});
document.body.addEventListener("htmx:afterSwap", function () {
syncSourceFields();
syncDestinations();
});
syncSourceFields();
syncDestinations();
})();
</script>

View File

@@ -0,0 +1,13 @@
{#
What the first step answered, carried forward.
Steps 2 and 3 replace the fields that hold these, so they re-state them: the
form is the whole state of the preparation, and the destination and the source
text have to survive every swap. The summary line above them is so the user
can still see what they are preparing after the selects are gone.
#}
<p class="hint">{{ nav.tr_args("import-carried", [("scope", page.form.profile_name.clone()), ("table", page.form.table_name.clone())]) }}</p>
<input type="hidden" name="profile_name" value="{{ page.form.profile_name }}">
<input type="hidden" name="table_name" value="{{ page.form.table_name }}">
<input type="hidden" name="source_mode" value="{{ page.form.source_mode }}">
<input type="hidden" name="csv_data" value="{{ page.form.csv_data }}">

View File

@@ -1,40 +0,0 @@
{#
The form's fields, on their own so that "Normalize headers" can hand them
back with the rewritten CSV in the textarea — see
crate::pages::import_export::import::ui::ImportFields. `changed` is `None`
when the page first renders and `Some` once a rewrite has run.
#}
<div id="import-fields">
<div class="form-grid">
<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 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>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>
<label class="check">
<input type="checkbox" name="import_system_columns" value="true"{% if page.form.import_system_columns() %} checked{% endif %}>
{{ nav.tr("import-system-columns") }}
</label>
<small>{{ nav.tr("import-system-columns-hint") }}</small>
{%- if let Some(changed) = changed %}
<p class="hint" role="status">{% if changed %}{{ nav.tr("import-normalized-message") }}{% else %}{{ nav.tr("import-normalized-unchanged") }}{% endif %}</p>
{%- endif %}
</div>

View File

@@ -1,6 +1,5 @@
{# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #} {# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #}
{% extends "ui/form_page.html" %} {% extends "ui/form_page.html" %}
{% import "ui/alert.html" as alert %}
{% block title %}{{ nav.tr("import-title") }}{% endblock %} {% block title %}{{ nav.tr("import-title") }}{% endblock %}
{% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %} {% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %}
@@ -8,22 +7,33 @@
{% block lead %}<p>{{ nav.tr("import-lead") }}</p>{% endblock %} {% block lead %}<p>{{ nav.tr("import-lead") }}</p>{% endblock %}
{% block form %} {% block form %}
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML" {#
hx-disabled-elt="button[type=submit]"> One form for the whole preparation. The steps swap the block inside it rather
{% include "pages/import_export/import/fields.html" %} than the form itself, so the scope, the table and the CSV the user chose stay
<div id="submission-status" aria-live="polite"> where they are while they move back and forth.
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
</div> Its own `action` is the prepared-CSV download: a file has to come back from a
<div class="form-actions"> real browser submit, so the plain submit button is the one that downloads and
<a href="/">{{ nav.tr("common-cancel") }}</a> every other button carries an `hx-post` of its own.
{# Rewrites the header rows and puts the result back in the textarea. It #}
imports nothing: the file that gets imported is the one the user can see <form id="import-form" method="post" action="/admin/import/prepared.csv"
and has had the chance to read. #} hx-target="#import-step" hx-swap="outerHTML" hx-disabled-elt="this">
<button type="button" hx-post="/admin/import/normalize" hx-include="closest form" {% include "pages/import_export/import/step.html" %}
hx-target="#import-fields" hx-swap="outerHTML"
title="{{ nav.tr("import-normalize-hint") }}">{{ nav.tr("import-normalize") }}</button>
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
</div>
</form> </form>
{% include "pages/import_export/table_picker.html" %} {% include "pages/import_export/table_picker.html" %}
{% include "pages/import_export/import/behaviour.html" %}
{#
A refusal is not a step. Without this the alert answering "Continue" would be
swapped in place of the step it was refusing, and the user would lose the
mapping they were being told to fix.
#}
<script>
document.addEventListener("htmx:beforeSwap", function (event) {
const form = event.detail.elt.closest && event.detail.elt.closest("#import-form");
const status = document.getElementById("submission-status");
if (!form || !status || event.detail.xhr.status < 400) return;
event.detail.target = status;
event.detail.swapOverride = "innerHTML";
});
</script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,226 @@
{#
The preparation, one step at a time —
crate::pages::import_export::import::ui::ImportStep.
Every button that moves between steps swaps this whole block, so each step
re-states the answers the next one needs as hidden fields. The page keeps no
server-side draft: what the form carries is the entire state of the
preparation.
#}
<div id="import-step">
{% match page.step %}
{#- ------------------------------------------------------------------
Step 1 — where the data is going, and how the source is laid out.
------------------------------------------------------------------ -#}
{% when Step::Source %}
<div class="form-grid">
<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_name" 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.is_table(table) %} selected{% endif %}>{{ table }}</option>
{%- endfor %}{% endfor %}
</select>
</label>
{#
Always asked, never guessed. A data row can hold words that read exactly
like column names, so deciding for the user is how a real row gets eaten
as a header.
#}
<fieldset class="wide source-mode">
<legend>{{ nav.tr("import-source-mode") }}</legend>
<label class="check"><input type="radio" name="source_mode" value="header" data-source-mode
{% if page.form.is_mode("header") %} checked{% endif %}>{{ nav.tr("import-source-header") }}</label>
<label class="check"><input type="radio" name="source_mode" value="data" data-source-mode
{% if page.form.is_mode("data") %} checked{% endif %}>{{ nav.tr("import-source-data") }}</label>
<label class="check"><input type="radio" name="source_mode" value="template" data-source-mode
{% if page.form.is_mode("template") %} checked{% endif %}>{{ nav.tr("import-source-template") }}</label>
<p class="hint">{{ nav.tr("import-source-mode-hint") }}</p>
</fieldset>
<div class="wide" data-source-fields>
<div class="form-grid">
<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="12">{{ page.form.csv_data }}</textarea></label>
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
</div>
</div>
</div>
<div class="form-actions">
<a href="/">{{ nav.tr("common-cancel") }}</a>
<button type="button" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-continue") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 2a — one row per source position, pointed wherever the user says.
------------------------------------------------------------------ -#}
{% when Step::Mapping with (step) %}
{% include "pages/import_export/import/carried.html" %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-mapping-heading", [("table", step.table_name.clone())]) }}</h2>
<p class="hint">{{ nav.tr("import-mapping-hint") }}</p>
<p class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</p>
<table class="builder-table mapping-table">
<thead>
<tr>
<th>{{ nav.tr("import-th-position") }}</th>
{% if step.has_source_names %}<th>{{ nav.tr("import-th-source-name") }}</th>{% endif %}
<th>{{ nav.tr("import-th-example") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th>
</tr>
</thead>
<tbody>
{% for row in step.rows %}
<tr>
<td class="mark">{{ row.position }}</td>
{% if step.has_source_names %}<td class="source-name">{% if let Some(name) = row.source_name %}{{ name }}{% endif %}</td>{% endif %}
<td class="example">{{ row.example }}</td>
<td>
<select name="mapping" data-destination>
<option value="">{{ nav.tr("import-destination-ignore") }}</option>
{% for column in step.columns %}
<option value="{{ column }}"{% if row.targets(column) %} selected{% endif %}>{{ column }}</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
<button type="button" hx-post="/admin/import/preview" hx-include="closest form">{{ nav.tr("import-to-preview") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 2b — the template generator: pick the columns, arrange them, take
away the header.
------------------------------------------------------------------ -#}
{% when Step::Template with (step) %}
{% include "pages/import_export/import/carried.html" %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-template-heading", [("table", step.table_name.clone())]) }}</h2>
<p class="hint">{{ nav.tr("import-template-hint") }}</p>
<table class="builder-table template-table">
<thead>
<tr>
<th>{{ nav.tr("import-th-order") }}</th>
<th>{{ nav.tr("import-th-include") }}</th>
<th>{{ nav.tr("import-th-destination") }}</th>
</tr>
</thead>
<tbody>
{% for row in step.rows %}
<tr>
<td class="order">
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-up", [("name", row.name.clone())]) }}"
{% if row.first %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "up", "index": "{{ row.index }}"}'{% endif %}>&uarr;</button>
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-down", [("name", row.name.clone())]) }}"
{% if row.last %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
hx-vals='{"action": "down", "index": "{{ row.index }}"}'{% endif %}>&darr;</button>
</td>
<td>
{#
Both fields post in document order, so moving a row moves the
generated header with it: `template_order` remembers where the
unticked ones sit, `template_columns` is the header itself.
#}
<input type="hidden" name="template_order" value="{{ row.name }}">
<label class="check"><input type="checkbox" name="template_columns" value="{{ row.name }}"{% if row.chosen %} checked{% endif %}
hx-post="/admin/import/prepare" hx-include="closest form"></label>
</td>
<td><code>{{ row.name }}</code>{% if row.required %} <span class="hint">{{ nav.tr("import-required-column") }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if step.chosen > 0 %}
<p class="hint">{{ nav.tr("import-template-generated") }}</p>
<pre class="sql-preview">{{ step.header }}</pre>
{% else %}
<p class="hint">{{ nav.tr("import-template-nothing-chosen") }}</p>
{% endif %}
<p class="hint">{{ nav.tr("import-template-round-trip") }}</p>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
{# A real submit: the answer is a file. #}
<button type="submit" formaction="/admin/import/template.csv" formnovalidate>{{ nav.tr("import-download-template") }}</button>
</div>
{#- ------------------------------------------------------------------
Step 3 — the prepared import, exactly as it will be sent.
------------------------------------------------------------------ -#}
{% when Step::Preview with (step) %}
{% include "pages/import_export/import/carried.html" %}
{% for value in page.form.mapping %}<input type="hidden" name="mapping" value="{{ value }}">{% endfor %}
<div class="builder-section">
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
<dl class="postable-grid">
<div>
<dt>{{ nav.tr("import-summary-rows") }}</dt>
<dd><strong>{{ step.source_rows }}</strong></dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-columns-used") }}</dt>
<dd>{{ step.columns|join(", ") }}</dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-ignored") }}</dt>
<dd>{% if step.ignored.is_empty() %}<span class="hint"></span>{% else %}{{ step.ignored|join(", ") }}{% endif %}</dd>
</div>
<div>
<dt>{{ nav.tr("import-summary-omitted") }}</dt>
<dd>{% if step.omitted.is_empty() %}<span class="hint"></span>{% else %}{{ step.omitted|join(", ") }}{% endif %}</dd>
</div>
</dl>
{% if !step.missing_required.is_empty() %}
<p class="error">{{ nav.tr_args("import-summary-missing-required", [("columns", step.missing_required.join(", "))]) }}</p>
{% endif %}
<div class="table-wrap">
<table class="builder-table preview">
<thead><tr>{% for column in step.columns %}<th>{{ column }}</th>{% endfor %}</tr></thead>
<tbody>
{% for row in step.rows %}
<tr>{% for value in row %}<td>{{ value }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if step.hidden_rows > 0 %}
<p class="hint">{{ nav.tr_args("import-preview-more-rows", [("rows", step.hidden_rows.to_string())]) }}</p>
{% endif %}
<details class="rename-generated">
<summary>{{ nav.tr("import-prepared-csv") }}</summary>
<p class="hint">{{ nav.tr("import-prepared-csv-hint") }}</p>
<pre class="sql-preview">{{ step.csv }}</pre>
</details>
</div>
<div class="form-actions">
<button type="button" class="secondary" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-back-to-mapping") }}</button>
{# A real submit: the answer is a file. #}
<button type="submit" formnovalidate>{{ nav.tr("import-download-prepared") }}</button>
<button type="button" hx-post="/admin/import" hx-include="closest form"
hx-target="#submission-status" hx-swap="innerHTML">{{ nav.tr("import-import-rows") }}</button>
</div>
{% endmatch %}
<div id="submission-status" aria-live="polite"></div>
</div>