FOSSology  4.7.1
Open Source License Compliance by Open Source Software
CustomTextImport.php
Go to the documentation of this file.
1 <?php
2 /*
3  SPDX-FileCopyrightText: © 2025 Harshit Gandhi <gandhiharshit716@gmail.com>
4  SPDX-FileCopyrightText: © Fossology contributors
5 
6  SPDX-License-Identifier: GPL-2.0-only
7 */
8 
11 
15 use Exception;
16 
27 {
30  protected $dbManager;
33  protected $userDao;
35  protected $licenseDao;
38  protected $delimiter = ',';
41  protected $enclosure = '"';
44  protected $headrow = null;
50  protected $unescapeNewlines = false;
53  protected $alias = array(
54  'text' => array('text', 'Text'),
55  'is_active' => array('is_active', 'Is Active', 'active'),
56  'created_by' => array('created_by', 'Created By', 'user_name'),
57  'group' => array('group', 'Group', 'group_name'),
58  'license_shortname' => array('license_shortname', 'License Shortname'),
59  'removing' => array('removing', 'Removing'),
60  'comment' => array('comment', 'Comment'),
61  'reportinfo' => array('reportinfo', 'License Text'),
62  'acknowledgement' => array('acknowledgement', 'Acknowledgement'),
63  // legacy flat-format aliases kept for backward compatibility
64  'licenses_to_add' => array('licenses_to_add', 'Licenses To Add', 'add_licenses'),
65  'licenses_to_remove' => array('licenses_to_remove', 'Licenses To Remove', 'remove_licenses')
66  );
67 
73  public function __construct(DbManager $dbManager, UserDao $userDao, LicenseDao $licenseDao = null)
74  {
75  $this->dbManager = $dbManager;
76  $this->userDao = $userDao;
77  $this->licenseDao = $licenseDao ?: $GLOBALS['container']->get('dao.license');
78  }
79 
84  public function setDelimiter($delimiter=',')
85  {
86  $this->delimiter = substr($delimiter,0,1);
87  }
88 
93  public function setEnclosure($enclosure='"')
94  {
95  $this->enclosure = substr($enclosure,0,1);
96  }
97 
105  public function handleFile($filename, $fileExtension)
106  {
107  if ($fileExtension === 'json') {
108  return $this->handleJsonFile($filename);
109  } else {
110  return $this->handleCsvFile($filename);
111  }
112  }
113 
119  private function handleJsonFile($filename)
120  {
121  $this->unescapeNewlines = false;
122 
123  $content = file_get_contents($filename);
124  if ($content === false) {
125  return _("Could not read JSON file");
126  }
127 
128  $data = json_decode($content, true);
129  if (json_last_error() !== JSON_ERROR_NONE) {
130  return _("Invalid JSON format: ") . json_last_error_msg();
131  }
132 
133  if (!is_array($data)) {
134  return _("JSON file must contain an array of phrases");
135  }
136 
137  return $this->importPhrases($data);
138  }
139 
145  private function handleCsvFile($filename)
146  {
147  $this->unescapeNewlines = true;
148 
149  $handle = fopen($filename, 'r');
150  if ($handle === false) {
151  return _("Could not open CSV file");
152  }
153 
154  $this->headrow = fgetcsv($handle, 0, $this->delimiter, $this->enclosure);
155  if ($this->headrow === false) {
156  fclose($handle);
157  return _("Could not read CSV header");
158  }
159 
160  // Strip BOM from the first header column if present
161  $bom = chr(0xEF) . chr(0xBB) . chr(0xBF);
162  if (isset($this->headrow[0]) && strpos($this->headrow[0], $bom) === 0) {
163  $this->headrow[0] = substr($this->headrow[0], 3);
164  }
165 
166  $data = array();
167  $lineNumber = 1;
168  while (($row = fgetcsv($handle, 0, $this->delimiter, $this->enclosure)) !== false) {
169  $lineNumber++;
170  if (count($row) !== count($this->headrow)) {
171  fclose($handle);
172  return sprintf(_("CSV line %d has %d columns, expected %d"),
173  $lineNumber, count($row), count($this->headrow));
174  }
175 
176  $data[] = array_combine($this->headrow, $row);
177  }
178  fclose($handle);
179 
180  return $this->importPhrases($data);
181  }
182 
188  private function importPhrases($data)
189  {
190  $created = 0;
191  $updated = 0;
192  $unchanged = 0;
193  $errors = array();
194 
195  foreach ($data as $index => $phraseData) {
196  try {
197  $result = $this->importSinglePhrase($phraseData);
198  if ($result['success']) {
199  if (!empty($result['unchanged'])) {
200  $unchanged++;
201  } elseif (!empty($result['existing'])) {
202  $updated++;
203  } else {
204  $created++;
205  }
206  } else {
207  $errors[] = sprintf(_("Row %d: %s"), $index + 1, $result['message']);
208  }
209  } catch (\Throwable $e) {
210  $errors[] = sprintf(_("Row %d: %s"), $index + 1, $e->getMessage());
211  }
212  }
213 
214  $parts = array();
215  if ($created > 0) {
216  $parts[] = sprintf(_("%d phrase(s) created"), $created);
217  }
218  if ($updated > 0) {
219  $parts[] = sprintf(_("%d license(s) added to existing phrase(s)"), $updated);
220  }
221  if ($unchanged > 0) {
222  $parts[] = sprintf(_("%d row(s) already up to date"), $unchanged);
223  }
224  $message = _("Import complete") . ": " . (empty($parts) ? _("nothing new to import") : implode(', ', $parts));
225  if (!empty($errors)) {
226  $message .= "\n" . _("Errors:") . "\n" . implode("\n", $errors);
227  }
228 
229  return $message;
230  }
231 
237  private function importSinglePhrase($phraseData)
238  {
239  // Map headers to standard names
240  $mappedData = $this->mapHeaders($phraseData);
241 
242  // Validate required fields
243  if (empty($mappedData['text'])) {
244  return array('success' => false, 'message' => _("Text is required"));
245  }
246 
247  // Get current user info
248  $userId = Auth::getUserId();
249  $groupId = Auth::getGroupId();
250  $textMd5 = md5($mappedData['text']);
251 
252  $this->dbManager->begin();
253  try {
254  // ON CONFLICT avoids a check-then-insert race on concurrent imports.
255  // ack/comments left NULL; metadata lives in the license map.
256  $insertSql = "INSERT INTO custom_phrase (text, text_md5, user_fk, group_fk, is_active)
257  VALUES ($1, $2, $3, $4, $5)
258  ON CONFLICT (text_md5) DO NOTHING
259  RETURNING cp_pk";
260  $params = array(
261  $mappedData['text'],
262  $textMd5,
263  $userId,
264  $groupId,
265  $this->parseBoolean($mappedData['is_active'] ?? false) ? 'true' : 'false'
266  );
267  $row = $this->dbManager->getSingleRow($insertSql, $params, __METHOD__ . '.insertPhrase');
268 
269  $isNewPhrase = ($row !== false);
270  if ($isNewPhrase) {
271  $cpPk = intval($row['cp_pk']);
272  } else {
273  $existing = $this->dbManager->getSingleRow(
274  "SELECT cp_pk FROM custom_phrase WHERE text_md5 = $1", array($textMd5),
275  __METHOD__ . '.findExisting');
276  if ($existing === false) {
277  throw new Exception("custom_phrase insert conflicted but no row found for text_md5");
278  }
279  $cpPk = intval($existing['cp_pk']);
280  }
281 
282  $totalInserted = 0;
283  $allFailed = array();
284 
285  if (!empty($mappedData['licenses'])) {
286  $result = $this->associateLicensesWithMetadata($cpPk, $mappedData['licenses'], $groupId);
287  $totalInserted += $result['inserted'];
288  $allFailed = array_merge($allFailed, $result['failed']);
289  } elseif ($isNewPhrase) {
290  // Backward compat for the old flat licenses_to_add/licenses_to_remove format.
291  if (!empty($mappedData['licenses_to_add'])) {
292  $r = $this->associateLicenseNames($cpPk, $mappedData['licenses_to_add'], false, $groupId);
293  $totalInserted += $r['inserted'];
294  $allFailed = array_merge($allFailed, $r['failed']);
295  }
296  if (!empty($mappedData['licenses_to_remove'])) {
297  $r = $this->associateLicenseNames($cpPk, $mappedData['licenses_to_remove'], true, $groupId);
298  $totalInserted += $r['inserted'];
299  $allFailed = array_merge($allFailed, $r['failed']);
300  }
301  }
302 
303  $this->dbManager->commit();
304 
305  if (!$isNewPhrase) {
306  // Nothing new is "unchanged", not an error: re-importing the same
307  // export must be a safe no-op.
308  if ($totalInserted > 0) {
309  return array('success' => true, 'existing' => true,
310  'message' => sprintf(_("Added %d license(s) to existing phrase"), $totalInserted));
311  }
312  if (!empty($allFailed)) {
313  return array('success' => false,
314  'message' => sprintf(_("Duplicate text; could not associate license(s): %s"), implode(', ', $allFailed)));
315  }
316  return array('success' => true, 'unchanged' => true,
317  'message' => _("Phrase already exists, nothing new to add"));
318  }
319 
320  $message = _("Phrase imported successfully");
321  if (!empty($allFailed)) {
322  $message .= ". " . sprintf(_("Warning: Could not find license(s): %s"), implode(', ', $allFailed));
323  }
324  if ($totalInserted > 0) {
325  $message .= ". " . sprintf(_("Associated %d license(s)"), $totalInserted);
326  }
327  return array('success' => true, 'message' => $message);
328  } catch (\Throwable $e) {
329  $this->dbManager->rollback();
330  return array('success' => false, 'message' => _("Failed to import phrase: ") . $e->getMessage());
331  }
332  }
333 
339  private function mapHeaders($data)
340  {
341  $mapped = array();
342 
343  if (isset($data['licenses']) && is_array($data['licenses'])) {
344  $mapped['licenses'] = $data['licenses'];
345  }
346 
347  foreach ($this->alias as $standardName => $aliases) {
348  foreach ($aliases as $alias) {
349  if (isset($data[$alias])) {
350  $mapped[$standardName] = $data[$alias];
351  break;
352  }
353  }
354  }
355 
356  if (empty($mapped['licenses']) && !empty($mapped['license_shortname'])) {
357  $mapped['licenses'] = array(array(
358  'shortname' => $mapped['license_shortname'],
359  'removing' => $this->parseBoolean($mapped['removing'] ?? false),
360  'comment' => $mapped['comment'] ?? '',
361  'reportinfo' => $mapped['reportinfo'] ?? '',
362  'acknowledgement' => $mapped['acknowledgement'] ?? ''
363  ));
364  }
365 
366  // JSON allows any value type per field; drop what downstream code can't
367  // safely trim()/md5() as a string instead of crashing on it.
368  if (isset($mapped['text']) && !is_string($mapped['text'])) {
369  unset($mapped['text']);
370  }
371  if (!empty($mapped['licenses']) && is_array($mapped['licenses'])) {
372  $mapped['licenses'] = array_values(array_filter($mapped['licenses'], function ($entry) {
373  return is_array($entry) && is_string($entry['shortname'] ?? null);
374  }));
375  foreach ($mapped['licenses'] as &$licenseEntry) {
376  foreach (array('comment', 'reportinfo', 'acknowledgement') as $field) {
377  if (isset($licenseEntry[$field]) && !is_string($licenseEntry[$field])) {
378  $licenseEntry[$field] = '';
379  }
380  }
381  }
382  unset($licenseEntry);
383  }
384 
385  if ($this->unescapeNewlines) {
386  if (isset($mapped['text']) && is_string($mapped['text'])) {
387  $mapped['text'] = CustomTextEscaping::unescapeNewlines($mapped['text']);
388  }
389  if (!empty($mapped['licenses']) && is_array($mapped['licenses'])) {
390  foreach ($mapped['licenses'] as &$licenseEntry) {
391  foreach (array('comment', 'reportinfo', 'acknowledgement') as $field) {
392  if (isset($licenseEntry[$field]) && is_string($licenseEntry[$field])) {
393  $licenseEntry[$field] = CustomTextEscaping::unescapeNewlines($licenseEntry[$field]);
394  }
395  }
396  }
397  unset($licenseEntry);
398  }
399  }
400 
401  return $mapped;
402  }
403 
409  private function parseBoolean($value)
410  {
411  if (is_bool($value)) {
412  return $value;
413  }
414  if (!is_scalar($value)) {
415  return false;
416  }
417 
418  $value = strtolower(trim($value));
419  return in_array($value, array('true', '1', 'yes', 'on', 'active'));
420  }
421 
436  private function associateLicensesWithMetadata($cpPk, $licenses, $groupId = null)
437  {
438  $inserted = 0;
439  $skipped = 0;
440  $failed = array();
441 
442  foreach ($licenses as $entry) {
443  $shortname = trim($entry['shortname'] ?? '');
444  if ($shortname === '') {
445  continue;
446  }
447 
448  $license = $this->licenseDao->getLicenseByShortName($shortname, $groupId);
449  if (!$license) {
450  $failed[] = $shortname . " (unknown)";
451  continue;
452  }
453 
454  $licenseId = $license->getId();
455  $removing = $this->parseBoolean($entry['removing'] ?? false);
456 
457  // Constant statement name: the SQL never varies, so a large import
458  // does not create one prepared statement per (cpPk, licenseId) pair.
459  $existing = $this->dbManager->getSingleRow(
460  "SELECT 1 FROM custom_phrase_license_map WHERE cp_fk = $1 AND rf_fk = $2 LIMIT 1",
461  array($cpPk, $licenseId), __METHOD__ . '.checkMapping');
462  if ($existing) {
463  $skipped++;
464  continue;
465  }
466 
467  $insertData = array(
468  'cp_fk' => $cpPk,
469  'rf_fk' => $licenseId,
470  'removing' => $removing ? 'true' : 'false',
471  'comment' => ($entry['comment'] ?? '') ?: null,
472  'reportinfo' => ($entry['reportinfo'] ?? '') ?: null,
473  'acknowledgement' => ($entry['acknowledgement'] ?? '') ?: null
474  );
475 
476  try {
477  $this->dbManager->insertTableRow('custom_phrase_license_map', $insertData);
478  $inserted++;
479  } catch (\Throwable $e) {
480  $failed[] = $shortname . " (insert failed)";
481  }
482  }
483 
484  return array('inserted' => $inserted, 'skipped' => $skipped, 'failed' => $failed);
485  }
486 
497  private function associateLicenseNames($cpPk, $licenseNames, $removing, $groupId = null)
498  {
499  $names = is_array($licenseNames) ? $licenseNames : $this->splitLicenseNames($licenseNames);
500 
501  $entries = array();
502  foreach ($names as $name) {
503  $name = trim($name);
504  if ($name === '') {
505  continue;
506  }
507  $entries[] = array(
508  'shortname' => $name,
509  'removing' => $removing,
510  'comment' => '',
511  'reportinfo' => '',
512  'acknowledgement' => ''
513  );
514  }
515 
516  $result = $this->associateLicensesWithMetadata($cpPk, $entries, $groupId);
517  return array('inserted' => $result['inserted'], 'failed' => $result['failed']);
518  }
519 
525  private function splitLicenseNames($licenseNames)
526  {
527  foreach (array(', ', ',', ';', '|') as $separator) {
528  if (strpos($licenseNames, $separator) !== false) {
529  return array_map('trim', explode($separator, $licenseNames));
530  }
531  }
532  return array($licenseNames);
533  }
534 
541  public function importJsonData($data, string &$msg): string
542  {
543  $this->unescapeNewlines = false;
544  $msg = $this->importPhrases($data);
545  return $msg;
546  }
547 }
static unescapeNewlines($value)
Reverse escapeNewlines(): literal becomes a real newline, any other escaped character is unescaped ...
Import custom text phrases from CSV/JSON.
importSinglePhrase($phraseData)
Import a single phrase.
importJsonData($data, string &$msg)
Import JSON data directly.
parseBoolean($value)
Parse boolean value from string.
setEnclosure($enclosure='"')
Update the enclosure.
setDelimiter($delimiter=',')
Update the delimiter.
associateLicenseNames($cpPk, $licenseNames, $removing, $groupId=null)
Legacy flat-format helper: split a license-name string/array and delegate to associateLicensesWithMet...
splitLicenseNames($licenseNames)
Split a delimited license-name string on the first separator found.
handleFile($filename, $fileExtension)
Read the CSV/JSON file and import it.
mapHeaders($data)
Map CSV headers to standard field names.
__construct(DbManager $dbManager, UserDao $userDao, LicenseDao $licenseDao=null)
handleCsvFile($filename)
Handle CSV file import.
associateLicensesWithMetadata($cpPk, $licenses, $groupId=null)
Associate licenses with a phrase, writing per-mapping metadata.
handleJsonFile($filename)
Handle JSON file import.
importPhrases($data)
Import phrases from data array.
Contains the constants and helpers for authentication of user.
Definition: Auth.php:24
static getUserId()
Get the current user's id.
Definition: Auth.php:68
static getGroupId()
Get the current user's group id.
Definition: Auth.php:80
Fossology exception.
Definition: Exception.php:15
char * trim(char *ptext)
Trimming whitespace.
Definition: fossconfig.c:690
fo_dbManager * dbManager
fo_dbManager object
Definition: process.c:16
Utility functions for specific applications.