import improvements
This commit is contained in:
@@ -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é.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
565
web/src/pages/import_export/import/prepare.rs
Normal file
565
web/src/pages/import_export/import/prepare.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(""name""), "{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(""name","active""), "{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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
44
web/templates/pages/import_export/import/behaviour.html
Normal file
44
web/templates/pages/import_export/import/behaviour.html
Normal 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>
|
||||||
13
web/templates/pages/import_export/import/carried.html
Normal file
13
web/templates/pages/import_export/import/carried.html
Normal 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 }}">
|
||||||
@@ -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 => 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>
|
|
||||||
@@ -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 %}
|
||||||
|
|||||||
226
web/templates/pages/import_export/import/step.html
Normal file
226
web/templates/pages/import_export/import/step.html
Normal 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 => 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 %}>↑</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 %}>↓</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>
|
||||||
Reference in New Issue
Block a user