Bugfix Issue 1616 Keyword CSV Import Header Row - sebiboga/testlink-upgraded GitHub Wiki
Bugfix ā Issue #1616: the keyword CSV import created a bogus Keyword entry from its own export header
Issue: #1616 ā Keyword CSV import creates a bogus 'Keyword' entry: importKeywordsFromCSV() does not skip the header row its own export writes
Branch: fix/issue-1616
Affected screen: Keyword Export / Import (gui/templates/keywords/keywordsExport.html) + BFF api/keywordsxml/index.php (the legacy lib/keywords/keywordsImport.php path shares the same model function)
Regression suite: tmp/TLU_Test_Cases.md ā Suite 1616 (13/13 PASS)
1. Symptom
Exporting the keywords of a test project to CSV and importing that file back
created a junk keyword literally named Keyword (the first two header cells) on
top of the real ones. Measured on the fixture project KW1616Proj (id 1, 2
keywords):
1 | smoke_login | smoke test of login
2 | regression_nightly | NULL
3 | Keyword | Notes <- bogus, created from the header row
The round trip corrupted the project data on every export ā import cycle.
2. Reproduction (pre-fix, measured)
# 1. export CSV from the modern screen's BFF
curl -b cookies 'http://localhost:8082/api/keywordsxml/index.php?action=export&tproject_id=1&type=iSerializationToCSV&filename=keywords.csv'
# body starts with: Keyword;Notes;Number of Test Case Linked\r\n
# 2. import that exact file back
curl -b cookies -F tproject_id=1 -F type=iSerializationToCSV -F [email protected] \
'http://localhost:8082/api/keywordsxml/index.php?action=import'
# => {"status":"ok","keyword_count":3,"imported":1,"skipped":2,"rows":3}
# ... and `SELECT ... FROM keywords` gained the row 3 | Keyword | Notes
3. Root cause
Hop 1 ā the exporter writes a header the importer never expects
api/keywordsxml/index.php:478 exportKeywordsToCSV() is a faithful port of the
legacy lib/keywords/keywordsExport.php helper:
function exportKeywordsToCSV($kwSet) {
$keys = array("keyword", "notes", "tcv_qty");
return exportDataToCSV($kwSet, $keys, $keys, array('addHeader' => 1));
}
addHeader => 1 makes exportDataToCSV() (lib/functions/csv.inc.php:17) emit a
localized header line Keyword;Notes;Number of Test Case Linked (en_GB) before the
data rows.
Hop 2 ā importKeywordsFromCSV() treats every non-blank row as a keyword
lib/functions/testproject.class.php:1409-1423 (pre-fix):
while($data = fgetcsv($handle, TL_IMPORT_ROW_MAX, $delim))
{
$rowNo++;
$isBlank = (count($data) === 1) && (trim((string)$data[0]) === '');
if ($isBlank) { continue; }
...
$kw = new tlKeyword();
$kw->initialize(null,$testproject_id,NULL,NULL);
$rowCode = $kw->readFromCSV(implode($delim,$data), $delim); // field[0]->name, field[1]->notes
if ($rowCode >= tl::OK) { $rowCode = $kw->writeToDB($this->db); }
The only filter was the blank-line check, so the header row became a keyword named
Keyword with notes Notes. The keywords uniqueness key
(keyword,testproject_id) had no conflict for that name ā writeToDB() wrote it.
Why it breaks now
The 2.0.1 export screen (Refs #1615) faithfully recreated the legacy exporter header; no model-side import skip has ever existed (1.9.20 had the same bug). The modern screen simply made the round trip easy to perform.
Blast radius
importKeywordsFromCSV()callers:api/keywordsxml/index.php:328(the modern Exchange screen) andapi/keywords/index.php:419(keywords edit import route); both pass the$statsout-param and neither iterates rows itself, so the fix is transparent to both.- The XML round trip is unaffected:
importKeywordsFromSimpleXML()iterates$simpleXMLObj->keywordchild nodes ā no header node exists.
4. The fix (approach)
Chosen method: skip the header in the import model, at the top of the parse loop, when the first parsed row matches the exporter header. The screen keeps legacy parity (header + delimiter + labels unchanged) exactly as the issue asked.
Why comparison in the model uses the same localized labels the exporter writes
(lang_get('keyword'), lang_get('notes'), lang_get('tcv_qty')) instead of the
issue's suggested hardcoded $data[0] === 'Keyword': a German or Romanian export
uses localized labels (Schlagwort / Notizen / ā¦), so an English-only match
would still corrupt those round trips. lang_get() resolves against the session
locale ā the same call exportDataToCSV() makes ā so a same-locale round trip
always matches.
Match rules (helper keywordCsvHeaderMatch(array $data)):
- Only the first DATA row is ever tested (blank lines are skipped first).
- All three cells must match their label, case-insensitively, trimmed.
- No extra non-empty cell may follow the third column.
- Robustness (from code review): a UTF-8 BOM tolerated on the first cell (spreadsheet re-saves), and the en_GB labels accepted as a second candidate set so a file exported under a non-English locale still skips its header when imported elsewhere.
These rules are what let a genuine keyword literally named Keyword keep
importing: the exporter writes an numeric tcv_qty in the third cell of a data
row (Keyword;Notes;0), which never equals the label Number of Test Case Linked.
A 2-column headerless row (Keyword;SomeNotes) fails rule 2 (only 2 of 3 cells)
and imports normally.
The header row is excluded from both rows and skipped in $stats: it is
not a data row and not a rejection, so a successful round trip reports the clean
imported count, and a header-only file still degrades to the callers' existing
EMPTY_FILE guard (imported <= 0 && rows == 0).
Rejected alternatives:
- Hardcoded
if ($rowNo === 1 && $data[0] === 'Keyword')ā English-only, breaks every non-English round trip. - A separate import option "file has no header" ā API/screen change, out of scope; the auto-detect is unambiguous because the header is a full 3-cell label line.
- Counting the header in
skippedā would make every round trip show the partial import message (imported N of M rows, 1 skipped) for a fully successful import.
5. Files changed
| File | Change |
|---|---|
lib/functions/testproject.class.php |
header skip at the top of the importKeywordsFromCSV() parse loop + new private keywordCsvHeaderMatch() helper |
No i18n bundle, BFF, or screen change: the fix is invisible to the UI and the import payloads are unchanged.
6. Verification (post-fix, measured)

| case | input | result |
|---|---|---|
| same-project round trip | export p1 ā import back into p1 | 400 NO_KEYWORDS_IMPORTED (duplicates), no Keyword row |
| clean round trip | export p1 ā import into empty p2 | 200, imported=2, skipped=0, rows=2, p2 holds exactly the exported keywords |
| hardest round trip | export p2 (holds a REAL keyword named Keyword) ā import into empty p3 |
200, imported=5, skipped=0, rows=5, p3 is an exact mirror of p2 |
| headerless 2-col file | a;note\r\nb;note\r\n |
200, 2 imported |
| real keyword in row 1 | single row Keyword;Notes;0 |
200, 1 imported ā keyword Keyword preserved |
| header-only file | Keyword;Notes;Number of Test Case Linked\r\n |
400 EMPTY_FILE, nothing created |
| comma-delimited header | Keyword,Notes,Number of Test Case Linked\r\n⦠|
comma header skipped too |
| BOM'd + header | \uFEFFKeyword;Notes;ā¦\r\nbom_kw;;0\r\n |
header skipped, bom_kw imported, no \uFEFF junk row |
| leading blank line + header | \r\nKeyword;Notes;ā¦\r\nblank_kw;;0\r\n |
header skipped, blank_kw imported |
| real keyword + round trip | export p2 (holds real Keyword) ā import into fresh p4 |
p4 mirrors p2 exactly; real Keyword preserved |
Plus: php -l clean, all 10 locale bundles untouched (still json.tool-valid),
Event Viewer shows only log_level=16 CREATE audit rows ā 0 new ERROR/WARNING ā
clean browser console. Regression suite 1616 13/13 PASS.
7. Commit
See the issue trail for commit hashes and the verification evidence; the work is
pushed on fix/issue-1616.