Keyword Export Import Modernized - sebiboga/testlink-upgraded GitHub Wiki
Refs #1615 (enhancement). Modern screen
gui/templates/keywords/keywordsExport.html+ BFFapi/keywordsxml/index.php. Supersedes the legacy popup pairlib/keywords/keywordsExport.php+lib/keywords/keywordsImport.php(gui/templates/dashio/keywords/keywordsExport.tpl/keywordsImport.tpl), which are now session-guarded 302 shims.


The TODO section of docs/MODERNIZATION-STATUS.md is empty β every ASIDE entry already
maps to a modern screen. The keyword exchange was the remaining gap: the modern Keyword
Manager (keywordsView.html) inlined the export and import modals in its own toolbar, so
there was no deep-linkable screen and no dedicated BFF for the flow. The legacy pair was
therefore lifted into one standalone Dashio popup, exactly like the other action popups
(bugAdd #1560, bugDelete #1559, eventinfo #1556).
| Element | Behaviour |
|---|---|
| Header | teal bar, subtitle move your keywords between test projects, TLi18n locale switcher |
| Tools | Refresh, Back to Keyword Management, Close |
| Tabs |
Export β Import, driven by ?mode=export|import
|
| Context card | Test project + live keyword count |
| Format select | XML / CSV, populated from getSupportedSerializationInterfaces()
|
| Format sample | the real snippet for the selected format, from getSupportedSerializationFormatDescriptions()
|
| File format doc link |
View File Format Documentation anchor under the format select, opening docs/tl-file-formats.pdf in a new tab β restored in #1007 (legacy keywordsImport.tpl:31 / keywordsExport.tpl:63) |
| Export panel | editable filename (maxlength=255, default keywords.<ext>) + Download |
| Import panel | file picker, Maximum file size: N KB hint, Upload, "existing keywords with the same name are updated" note |
| States | success box, error box, per-right Access denied cards, unknown project, empty project |
Everything is localized: kwxml.* (37 keys incl. viewDocs and the two server-error strings) +
footers.keywordsExport in all 10 bundles (de, en, es, fr, it, ja, pt, ro, ru, zh),
validated with python3 -m json.tool and checked for key parity.
GET ?action=init&tproject_id=N
-> 200 {tproject_id, tproject_name, keyword_count,
exportTypes[], importTypes[], formatDescriptions{},
rights{export,import}, limits{import_file_max_size_bytes},
default_filename}
-> 403 Access denied (caller holds neither right on the project)
-> 404 Unknown test project
-> 405 non-GET
GET ?action=export&tproject_id=N&type=iSerializationToXML|iSerializationToCSV&filename=F
-> 200 attachment (Content-Type + Content-Disposition, name sanitized + 255-char cap)
-> 400 no_keywords_to_export (empty project, refused BEFORE dispatch)
-> 403 Access denied {right}
POST ?action=import multipart (uploadedFile) or JSON {content_base64}
-> 200 {status, tproject_id, keyword_count}
-> 400 please_choose_keywords_file | wrong_keywords_file | upload_error
-> 403 Access denied {right: mgt_modify_key} | CSRF
-> 413 File too large
Session auth + bffSameOriginGuard (the CSRF guard runs before the session check, so an
anonymous POST answers 403, never 401). Temp file is always TL_TEMP_PATH + session_id()
- server-derived extension β the client filename never influences the path β and is unlinked after the import.
-
Import does not use the serialization ids.
testproject::importKeywordsFromXML/CSV()takesTL_KEYWORDS_IMPORT_XML = 1/TL_KEYWORDS_IMPORT_CSV = 2; the export side usesiSerializationToXML/iSerializationToCSV. The BFF normalizes1|xmland2|csvinto the interface ids so the screen has a single vocabulary. -
Format descriptions are keyed by the extension name (
XML,CSVβ upper case), not by the interface id, andiSerializationToCSVis spelledCSVin caps while the client lower-cases it elsewhere. Both panels therefore map id β ext β sample. -
CSV is
;-delimited with a header row (Keyword;Notes;Number of Test Case Linked). The exporter writes it and the importer now skips it (keywordCsvHeaderMatch(), #1616) β the exported file IS a valid import again, while headerless files keep working. -
mgt_view_keygates the export andmgt_modify_keythe import (legacycheckRights()per action), checked on the owning project withgetAccess = true, so a global-only right no longer reaches a private project. - Deviation (improvement): an empty filename falls back to
keywords.<ext>instead of the legacy alert, and the "view file format doc" link became the inline format sample.
lib/functions/tlUser.class.php:846-880 β three behaviours that make rights fixtures
mislead:
- a project role replaces the global right set for that project, so a view-only user needs a view-only role at project level too;
- a project role holding exactly one right is always denied β the "Special situation =>
just one right" branch returns
falseunless the single right carries adbID. A 1-right project role therefore produces a false 403 on export; - no project role + a private project β denied (
getAccess).
The in-session tlUser is cached, so a rights change applied straight in the DB only shows
up after a fresh login.
| # | Symptom | Fix |
|---|---|---|
| 1 | Locale bundles used printf %s while TLi18n interpolates {name} β the file-size hint and the import confirmation printed the raw %s
|
kwxml.maxFileSize / kwxml.importedMsg use {kb} / {count}
|
| 2 | jQuery was never loaded | added |
| 3 |
TLi18n was initialised before its bundle promise resolved |
TLi18n.load(cb) β apply() β initLocaleSwitcher() β load()
|
| 4 | Format sample looked up with the wrong key and rendered before its <select> existed |
map id β ext β sample, render after the select |
| 5 | Import .fail() dropped xhr.responseText, so every 4xx showed a bare Error box |
parse the JSON body, render the localized reason |
| 6 |
Security: init leaked the project name + count to users with no rights, and 404-vs-200 enumerated project ids |
403 when neither right is held |
| 7 |
500 on any project without keywords β sizeof(null) in exportKeywordsToXML, plus an E_WARNING in the CSV branch |
refuse empty projects up front (400) |
| 8 | Oversized uploads (empty tmp_name) were reported as "please choose a file" |
read $_FILES['error'] first; clamp the advertised cap to the PHP runtime |
| 9 | The export was a plain download navigation β 4xx/5xx JSON saved as a file while the UI claimed success | fetch, check res.ok, download the blob only on success |
| 10 | Typed filename wiped by the re-render, disable guard a no-op, download name unbounded |
CTX.filename, CTX.busy, 255-char cap server-side |
| 11 | CSV import of a zero-row body reported success | compare the keyword count before/after β wrong_keywords_file (XML arm later replaced the count with a real row report, see #1666 below) |
| 12 | 10 kw.* keys orphaned by the modal removal |
removed from all 10 bundles |
#1616 β testproject::importKeywordsFromCSV() never skipped the header row that
exportKeywordsToCSV() itself writes (exportDataToCSV(..., ['addHeader' => 1]),
lib/functions/csv.inc.php:17), so importing a TestLink-exported CSV inserted a junk keyword
literally named Keyword. Every export β import round trip corrupted the project. Fixed in the
import model: the first parsed row is skipped when it matches the localized exporter labels
(keywordCsvHeaderMatch(), lib/functions/testproject.class.php). Details:
Bugfix-Issue-1616-Keyword-CSV-Import-Header-Row. Suite 1616 13/13 PASS.
#1784 β an XML keywords file whose last <keyword> was malformed answered
400 wrong_keywords_file ("the format could not be read") although the rows before it were
already committed, and the same file content imported fine when the bad row came first:
testproject::importKeywordsFromSimpleXML() reused its file-level $status as the per-row
verdict inside the loop, so the last row decided the whole file. The XML arm now takes the
same optional by-ref $stats report as the CSV arm (a rejected row is reported, not fatal),
both import BFFs publish it instead of back-computing it from the keyword-count delta, and a
genuinely unreadable file (parse failure / wrong root node) still answers wrong_keywords_file.
Details: Bugfix-Issue-1784-Keyword-XML-Import-Last-Row-Order. Suite 1784 20/20 PASS
(bash tmp/verify_1784.sh 18/18 PASS).



Exporting a project's keywords to XML and re-importing that same file answered
400 wrong_keywords_file ("the format could not be read") whenever the import touched
only keywords that already exist: api/keywordsxml/index.php used the keyword COUNT
DELTA as a proxy for "rows read", and an update-in-place merge leaves the count flat.
importKeywordsFromSimpleXML() now reports the same per-row {rows, imported, skipped, errors[]} as the CSV arm since #1605, and the XML arm is gated on that report instead of
the count; the CSV arm keeps its own semantics and a <keywords/> document with no child is
still refused. The screen needed no change β it already rendered skipped > 0 as
kwxml.partialImportMsg. Details: Bugfix-Issue-1666-Keyword-XML-Roundtrip-Import.
Suite 1666 41/41 PASS (28/41 before the fix). A code review of that fix found the same count-delta proxy on the second import route api/keywords/index.php/import, which answered 422 EMPTY_FILE β "the file has no data rows" β for a full round trip; it is fixed too.

-
$actions->keywordsExportinlib/functions/common.php. -
gui/templates/keywords/keywordsView.htmlβ the Import/Export toolbar buttons openkeywordsExport.html?mode=β¦&tproject_id=β¦; the inline modals are gone. -
lib/keywords/keywordsExport.php/keywordsImport.phpβ session-guarded 302 shims (anon β login) that forward?doAction=,?exportType=andtestproject_id.
tmp/TLU_Test_Cases.md β Suite 1615, 23/23 PASS: export XML/CSV (CDATA, RFC4180 quoting,
content-disposition), import XML/CSV counts, wrong-format XML, no file, 3 MB upload,
5000-char filename, empty project, view-only (export ok / import 403), no rights (init + export
403, no metadata leak), 400/404/405 contract, anonymous 401/403, ENβRO locale, i18n parity,
Event Viewer clean (only INFO/LOGIN/AUDIT rows).
Fixture: tmp/fixtures_1615.php (project KWXML1615 with 3 keywords β one with quotes and
commas in the notes, one with NULL notes β plus kwview1615 and kwnorights1615).
Commits: 3f6a52e47 (BFF) β 1af75f0a1 (screen + i18n + link switch) β e0c1553f0
(test-found fixes) β 6556dc91e (code-review fixes).
The exchange popup was missing the legacy "view file format documentation" anchor
that both legacy screens rendered next to the file-type select
(keywordsImport.tpl:31, keywordsExport.tpl:63 β
PARTIAL_URL_TL_FILE_FORMATS_DOCUMENT, cfg/const.inc.php:919). Measured before
the fix: 0 anchors in the DOM in either panel. Both panels now render it via a
docLink() string helper at the legacy position, with i18n kwxml.viewDocs in all
10 locales. No BFF change. Suite 1007 13/13 PASS; Event Viewer clean.
Details: docs/Task-Issue-1007-keywords-file-format-doc-link.md
(wiki: Task-Issue-1007-keywords-file-format-doc-link).
Every change of the Format select left another "Format sample" caption behind in
both the Export and the Import panel: 1 -> 2 -> 3 -> 4 captions after 1/2/3 switches,
while pre.sample correctly stayed at 1. Purely client-side - no network request, no
console message, no events row, and the stray nodes vanished on the next full
render(), which made it look like a transient glitch.
Cause - formatSample() (pre-fix :179) returned the caption and the sample as two sibling
nodes, but both refresh paths looked up only pre.sample (pre-fix renderImportSample() :241,
exportTypeChanged() :260) and replaced that single node. old.replaceWith($(html)) swapped
the <pre> and left the old <label> orphaned right next to the freshly inserted one. The two
panels duplicate independently, each with its own .card-b. Not a #1007 regression
(b3d41cfb1 touched neither symbol): the two-node return value and the pre.sample-scoped lookup
were both born in 1af75f0a1 (#1615) - but the block only started rendering in
0dca857cb (close review gaps ... format sample), which re-keyed the lookup by ext, so that is
the earliest commit where the duplication is observable.
Fix - wrap caption + sample in a single div.sample-block and replace that one node in both
handlers (post-fix :250 and :271). Three functional lines; no CSS, no new i18n key, no BFF
change, no other file. The else fallback (box.find('a.doc-link').after(html)) is kept - it is
unreachable today (both XML and CSV always have a description) but is the correct behaviour for a
format without one. The real risk - the caption's margin-top:16px now collapsing into the new
wrapper - was ruled out by serving the pre-fix file side by side and comparing
getBoundingClientRect(): doc-link 404, caption 437, pre 462, file-name label 516, input 538,
all unchanged.
Verified - suite 13/13 PASS: 5 and 10 alternating switches on both panels still
show exactly 1 caption / 1 block / 1 pre with the sample text following the format;
document-wide sweep finds 1 of each; a.doc-link still immediately precedes the block
(legacy order); File name coupling intact (my-keywords left alone, keywords.xml ->
keywords.csv, empty -> keywords.xml, exportTypeChanged :264-267); both export formats
still download (200 text/xml, 200 text/csv); a real CSV upload still imports ("Keywords
imported. The project now has 3 keywords."); Romana locale still renders "Exemplu de format"
with no raw key leakage; all 10 i18n bundles json.tool-valid; node --check clean; console
clean; Event Viewer log_level IN (1,2) = 0. A code review subagent found no blocker and 7
documentation-accuracy findings, all applied (bad pre-fix hash, one off-by-one line citation, an
under-scoped RESUME probe that returned 2, a wrong line range, unannotated pre-fix citations, the
0dca857cb history nuance, and the open issue). Out of scope and deliberately untouched: #1616
(the CSV import writes its own header row as a keyword).
Details: docs/Bugfix-Issue-1675-Format-Sample-Label-Duplication.md
(wiki: Bugfix-Issue-1675-Format-Sample-Label-Duplication).