'customfields-csv-check', 'task' => $id), $extra), admin_url('admin.php'));
}
/** Small aggregate only: no source rows or private comparisons loaded on dashboards. */
public static function summary_for_current_user() {
if (!current_user_can('manage_options')) { return array('state'=>'unavailable','count'=>0,'changes'=>0,'issues'=>0,'scope'=>'','url'=>''); }
$index = self::index(); $changes = 0; $issues = 0; $rows = 0; $eligible = 0; $stale = 0;
foreach ($index as $entry) { $changes += (int)$entry['changes']; $issues += (int)$entry['issues']; $rows += (int)$entry['rows']; $g=self::group($entry['group']); $old=is_wp_error($g)||!hash_equals($entry['schema'],self::hash($g)); if($old){$stale++;} elseif($entry['state']==='compared'&&$entry['changes']>0){$eligible++;} }
return array('state'=>$index ? 'saved' : 'empty', 'count'=>count($index), 'rows'=>$rows, 'changes'=>$changes, 'issues'=>$issues, 'eligible_tasks'=>$eligible, 'stale_tasks'=>$stale, 'requires_recheck'=>true, 'scope'=>__('Your saved CSV checks on this site; counts may include the same post in separate checks.', 'customfields-com'), 'url'=>self::url());
}
public static function get_task($id) {
if (!current_user_can('manage_options') || !is_string($id) || !preg_match('/^[a-f0-9-]{36}$/D', $id)) { return self::error(__('This private CSV check is unavailable.', 'customfields-com')); }
$task = get_option(self::task_name($id));
if (!is_array($task) || ($task['user'] ?? 0) !== get_current_user_id() || ($task['blog'] ?? 0) !== get_current_blog_id()) { return self::error(__('This private CSV check is unavailable.', 'customfields-com')); }
return self::visible_task($task);
}
/** Recheck live access before exposing saved titles, values or validation messages. */
private static function visible_task($task) {
if (!$task['analysis']) { return $task; }
$group=self::group($task['group']);$analysis=$task['analysis'];$analysis['issues']=0;$analysis['changes']=0;$analysis['changed_rows']=0;$analysis['matched']=0;
foreach ($analysis['rows'] as &$row) {
$allowed=false;
if ($row['id'] && !is_wp_error($group)) {
wp_cache_delete($row['id'],'posts');wp_cache_delete($row['id'],'post_meta');$post=get_post($row['id']);
$allowed=$post&&!in_array($post->post_status,array('trash','auto-draft','inherit'),true)&&Relationships::group_matches($group,'post',$row['id'])&&Editor::can_edit('post',$row['id']);
}
$denied=false;$row['changes']=0;
if ($row['id'] && !$allowed) { $row['title']='';$row['issues']=array(__('This post is no longer available in your editable scope. Saved values and title are hidden.','customfields-com'));$denied=true; }
$current_fields=is_wp_error($group)?array():self::fields($group);
foreach ($row['cells'] as &$cell) {
if (!$allowed || !isset($current_fields[$cell['field']]) || !Editor::can_edit('post',$row['id'],$cell['field'])) {
$cell['before']=null;$cell['exists']=false;$cell['after']=null;$cell['valid']=false;$cell['changed']=false;$cell['accessible']=false;$denied=true;
}
if ($cell['changed']) { $row['changes']++; }
} unset($cell);
// Old validation messages can contain previously accessible data from a plugin filter.
if ($denied) { $row['issues']=array(__('This result is unavailable. The post may not exist, may not match this field group, or may not be editable by you. Values are hidden. Check the CSV ID, selected group and your access, then run a new comparison.','customfields-com')); }
if ($allowed) { $analysis['matched']++; }$analysis['issues']+=count($row['issues']);$analysis['changes']+=$row['changes'];if($row['changes']){$analysis['changed_rows']++;}
} unset($row);
$task['analysis']=$analysis;return $task;
}
private static function persist($name, $value) {
self::$touched[$name] = true;
update_option($name, $value, false); wp_cache_delete($name, 'options'); wp_cache_delete('alloptions', 'options');
if (get_option($name) !== $value) { self::fail(__('The private check could not be saved. Please retry.', 'customfields-com')); }
}
private static function save($task) {
if (strlen(serialize($task)) > 8388608) { self::fail(__('This comparison exceeds 8 MB. Use fewer rows or shorter values; no partial report was saved.', 'customfields-com')); }
self::persist(self::task_name($task['id']), $task);
$index = self::index(); $a = $task['analysis'];
$index[$task['id']] = array('label'=>$task['label'], 'group'=>$task['group'], 'schema'=>$task['schema'], 'created'=>$task['created'], 'state'=>$a ? ($a['issues'] ? 'issues' : 'compared') : 'mapping', 'rows'=>count($task['csv']['rows']), 'changes'=>$a ? $a['changes'] : 0, 'issues'=>$a ? $a['issues'] : 0);
self::persist(self::index_name(), $index);
}
private static function locked($callback) {
if (!current_user_can('manage_options')) { return self::error(__('Administrator access required.', 'customfields-com')); }
$owner = ContentStore::enter_transaction(__METHOD__); if ($owner instanceof \WP_Error) { return $owner; }
global $wpdb; $begun = false; $locked = false; $key = 'cfcheck_' . substr(hash('sha256', DB_NAME . ':' . $wpdb->prefix . ':' . get_current_user_id()), 0, 32); $result = null;
try {
if ((string)$wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,2)', $key)) !== '1') { self::fail(__('Another CSV check is saving. Reload and retry.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached.
$locked = true;
$engine = $wpdb->get_var($wpdb->prepare('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $wpdb->options)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees.
if (strtoupper((string)$engine) !== 'INNODB' || false === $wpdb->query('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE') || false === $wpdb->query('START TRANSACTION')) { self::fail(__('Private CSV checks require transactional WordPress options storage.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached.
$begun = true;
foreach (array(self::index_name(), 'customfields_groups', 'alloptions', 'notoptions') as $name) { wp_cache_delete($name, 'options'); }
$result = $callback(); if (is_wp_error($result)) { self::fail($result->get_error_message()); }
if (false === $wpdb->query('COMMIT')) { self::fail(__('The saved check could not be confirmed. Reload before retrying.', 'customfields-com')); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached.
$begun = false;
} catch (\Throwable $e) {
if ($begun) { ContentStore::rollback_transaction(); }
$result = self::error($e->getMessage());
} finally {
foreach (array_merge(array_keys(self::$touched), array('alloptions','notoptions')) as $name) { try { wp_cache_delete($name, 'options'); } catch (\Throwable $ignored) {} }
self::$touched = array();
if ($locked && $wpdb->ready) { try { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $key)); } catch (\Throwable $ignored) {} } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Database transaction/advisory-lock operation; WordPress has no equivalent API and lock results must never be cached.
ContentStore::leave_transaction($owner);
}
return $result;
}
private static function loaded($id, $revision) {
if (is_string($id)) { wp_cache_delete(self::task_name($id), 'options'); }
$task = self::get_task($id); if (is_wp_error($task)) { self::fail($task->get_error_message()); }
if (!is_scalar($revision) || !ctype_digit((string)$revision) || (int)$revision !== $task['revision']) { self::fail(__('This check changed in another tab. Reload before continuing.', 'customfields-com')); }
return $task;
}
public static function parse($text){
if(!is_string($text)||$text===''||strlen($text)>self::MAX_FILE||strpos($text,"\0")!==false||!preg_match('//u',$text)){return self::error(__('Use a UTF-8 CSV smaller than 2 MB, without null bytes.','customfields-com'));}
if(substr($text,0,3)==="\xEF\xBB\xBF"){$text=substr($text,3);}
$rows=array();$row=array();$cell='';$state='start';$len=strlen($text);$ended=false;
try{
$append=static function()use(&$rows,&$row){$rows[]=$row;$row=array();if(count($rows)>self::MAX_ROWS+1){self::fail(__('A CSV can contain at most 500 data rows. Nothing is silently omitted.','customfields-com'));}};
for($i=0;$i<$len;$i++){$c=$text[$i];$ended=false;
if($state==='quoted'){if($c==='"'){if($i+1<$len&&$text[$i+1]==='"'){$cell.='"';$i++;}else{$state='closed';}}else{$cell.=$c;}}
elseif($c===','||$c==="\n"||$c==="\r"){$row[]=$cell;$cell='';$state='start';if(count($row)>32){self::fail(__('Use at most 32 CSV columns.','customfields-com'));}if($c!==','){if($c==="\r"&&$i+1<$len&&$text[$i+1]==="\n"){$i++;}$append();$ended=true;}}
elseif($state==='closed'){self::fail(__('Unexpected characters after a closing CSV quote. Export a standard comma-separated CSV.','customfields-com'));}
elseif($c==='"'){if($state!=='start'){self::fail(__('Quotes inside a CSV cell must be escaped using double quotes.','customfields-com'));}$state='quoted';}
else{$cell.=$c;$state='plain';}
if(strlen($cell)>100000){self::fail(__('A CSV cell exceeds 100 KB.','customfields-com'));}
}
if($state==='quoted'){self::fail(__('The CSV contains an unfinished quoted cell. Nothing was imported.','customfields-com'));}
if(!$ended){$row[]=$cell;if(count($row)>32){self::fail(__('Use at most 32 CSV columns.','customfields-com'));}$append();}
$headers=array_shift($rows);if(!$headers||!$rows){self::fail(__('Include a header row and at least one data row.','customfields-com'));}
foreach($headers as $h){if(!preg_match('/^[A-Za-z][A-Za-z0-9_ -]{0,63}$/D',$h)){self::fail(__('Use unique headers starting with a letter, followed by letters, numbers, spaces, underscores or hyphens.','customfields-com'));}}
if(count(array_unique($headers))!==count($headers)){self::fail(__('CSV headers must be unique.','customfields-com'));}
foreach($rows as $i=>$r){if(count($r)!==count($headers)){self::fail(sprintf(/* translators: %d: CSV row number. */ __('CSV row %d has a different number of columns.','customfields-com'),$i+2));}}
return array('headers'=>$headers,'rows'=>$rows,'hash'=>hash('sha256',serialize(array($headers,$rows))));
}catch(\Throwable $e){return self::error($e->getMessage());}
}
public static function group($key) {
foreach (customfields_get_groups() as $group) { if ($group['key'] === $key && ($group['target'] ?? '') === 'post') { return $group; } }
return self::error(__('Choose an available post field group.', 'customfields-com'));
}
public static function fields($group) {
$out = array(); foreach ($group['fields'] as $field) { if (in_array($field['type'], self::TYPES, true) && empty($field['conditional_logic'])) { $out[$field['name']] = $field; } } return $out;
}
public static function input($cell, $field) {
if (!is_string($cell)) { self::fail(__('A CSV value is not a string.', 'customfields-com')); }
$trim = ltrim($cell);
if (preg_match('/^[=+@]/', $trim) || (preg_match('/^-/', $trim) && $field['type'] !== 'number')) { self::fail(__('Formula-like values are refused. Use literal text; a leading apostrophe is kept visibly.', 'customfields-com')); }
if ($field['type'] === 'number' && $cell !== '') { $digits = ltrim(preg_replace('/[^0-9]/', '', $cell), '0'); if (strlen($digits) > 15) { self::fail(__('A number exceeds 15 significant digits. Use a text field for exact identifiers.', 'customfields-com')); } }
if (in_array($field['type'], array('multiselect','checkboxes'), true)) {
if ($cell === '') { return array(); } $v = json_decode($cell, true, 4);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($v) || ($v && array_keys($v) !== range(0, count($v)-1))) { self::fail(__('Choice-list values must be JSON lists, such as ["red","blue"], or empty.', 'customfields-com')); } return $v;
}
return $cell;
}
public static function suggestions($csv, $group) {
$fields = self::fields($group); $out = array(); $used = array();
foreach ($csv['headers'] as $i=>$header) {
$name = ''; $reason = __('No unambiguous name or label match. Choose a destination or skip.', 'customfields-com');
if (in_array(strtolower($header), array('id','post_id'), true) && !isset($used['__id'])) { $name = '__id'; $reason = __('Header matches the conventional WordPress post ID name. Confirm it contains IDs from this site.', 'customfields-com'); }
else {
$matches = array(); foreach ($fields as $key=>$field) { if (strcasecmp($header, $key) === 0 || strcasecmp($header, $field['label']) === 0) { $matches[$key] = $field; } }
if (count($matches) === 1) { $key = key($matches); $field = $matches[$key]; if (!isset($used[$key])) { $name = $key; $reason = sprintf(/* translators: %s: Field type. */ __('Header matches this field name or label; expected type: %s. All rows are validated after you confirm.', 'customfields-com'), $field['type']); } }
}
if ($name !== '') { $used[$name] = true; } $out[$i] = array('target'=>$name, 'reason'=>$reason);
}
return $out;
}
public static function validate_mapping($csv, $group, $mapping) {
if (!is_array($mapping) || array_keys($mapping) !== range(0, count($csv['headers'])-1)) { return self::error(__('Submit a mapping for every column; truncated forms are refused.', 'customfields-com')); }
$fields = self::fields($group); $seen = array(); $ids = 0; $count = 0;
foreach ($mapping as $name) {
if (!is_string($name)) { return self::error(__('A mapping is invalid.', 'customfields-com')); } if ($name === '') { continue; }
if (isset($seen[$name])) { return self::error(__('Map each destination once.', 'customfields-com')); } $seen[$name] = true;
if ($name === '__id') { $ids++; } elseif (isset($fields[$name])) { $count++; } else { return self::error(__('A field is unsupported or unavailable. Use its regular editor.', 'customfields-com')); }
}
if ($ids !== 1 || $count < 1 || $count > 10) { return self::error(__('Choose one WordPress post ID column and 1–10 supported fields. The Free comparison uses IDs from this site.', 'customfields-com')); }
return true;
}
/** Decode private metadata without allowing serialized objects. */
private static function decode($raw) {
if (!is_serialized($raw)) { return $raw; }
$v = @unserialize($raw, array('allowed_classes'=>false, 'max_depth'=>12)); $nodes = 0;
$visit = static function($item, $depth=0) use (&$visit, &$nodes) { if (++$nodes > 2000 || $depth > 12 || is_object($item) || is_resource($item)) { self::fail(__('Stored data has an unsupported structure.', 'customfields-com')); } if (is_array($item)) { foreach ($item as $child) { $visit($child, $depth+1); } } };
if ($v === false && $raw !== 'b:0;') { self::fail(__('Stored data cannot be decoded safely.', 'customfields-com')); } $visit($v); return $v;
}
private static function snapshot($group, $id) {
global $wpdb;
wp_cache_delete($id, 'posts'); wp_cache_delete($id, 'post_meta');
$post = get_post($id);
if (!$post || in_array($post->post_status, array('trash','auto-draft','inherit'), true) || !Relationships::group_matches($group, 'post', $id) || !Editor::can_edit('post', $id)) { self::fail(__('Post is unavailable, outside this group or not editable by you.', 'customfields-com')); }
$values = array(); $bytes = 0;
foreach ($group['fields'] as $field) {
if(!Editor::can_edit('post',$id,$field['name'])){$values[$field['name']]=array('exists'=>false,'value'=>'');continue;}
$rows = $wpdb->get_results($wpdb->prepare("SELECT OCTET_LENGTH(meta_value) AS bytes, CASE WHEN OCTET_LENGTH(meta_value)<=1048576 THEN meta_value ELSE NULL END AS raw FROM {$wpdb->postmeta} WHERE post_id=%d AND meta_key=%s ORDER BY meta_id LIMIT 2", $id, $field['name']), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows.
if ($wpdb->last_error || count($rows) > 1) { self::fail(__('Post metadata is ambiguous or unreadable.', 'customfields-com')); }
if ($rows) { $bytes += (int)$rows[0]['bytes']; if ($bytes > 1048576) { self::fail(__('Post metadata exceeds the 1 MB comparison budget.', 'customfields-com')); } }
$values[$field['name']] = array('exists'=>(bool)$rows, 'value'=>$rows ? self::decode($rows[0]['raw']) : ($field['default'] ?? ''));
}
return array('title'=>$post->post_title, 'fields'=>$values);
}
/** Every input row is represented, including duplicates, bad IDs and invalid cells. */
public static function analyze($csv, $group, $mapping) {
if (!current_user_can('manage_options')) { return self::error(__('Administrator access required.', 'customfields-com')); }
$valid = self::validate_mapping($csv, $group, $mapping); if (is_wp_error($valid)) { return $valid; }
if (($group['storage'] ?? 'metadata') !== 'metadata') { return self::error(__('The Free CSV check currently compares native WordPress metadata. Use the group editor for custom-table data.', 'customfields-com')); }
$fields = self::fields($group); $column = array_search('__id', $mapping, true); $counts = array();
foreach ($csv['rows'] as $r) { $key = 'id:' . $r[$column]; $counts[$key] = ($counts[$key] ?? 0) + 1; }
$result = array('rows'=>array(), 'changes'=>0, 'issues'=>0, 'changed_rows'=>0, 'matched'=>0, 'created'=>gmdate('c'));
foreach ($csv['rows'] as $n=>$r) {
$row = array('row'=>$n+2, 'id'=>0, 'title'=>'', 'issues'=>array(), 'cells'=>array(), 'changes'=>0);
try {
$raw_id = $r[$column];
if (!preg_match('/^[1-9][0-9]{0,17}$/D', $raw_id) || (string)(int)$raw_id !== $raw_id) { self::fail(__('Use a positive WordPress post ID without leading zeros. Business keys are not IDs.', 'customfields-com')); }
if ($counts['id:'.$raw_id] !== 1) { self::fail(__('This post ID occurs more than once in the CSV. No row will be silently selected.', 'customfields-com')); }
$row['id'] = (int)$raw_id; $before = self::snapshot($group, $row['id']); $row['title'] = $before['title']; $result['matched']++;
$siblings = array(); foreach ($before['fields'] as $name=>$stored) { $siblings[$name] = $stored['value']; }
$raw = array();
foreach ($mapping as $c=>$name) { if ($name !== '' && $name !== '__id') { try { $raw[$name] = self::input($r[$c], $fields[$name]); $siblings[$name] = $raw[$name]; } catch (\Throwable $e) { $row['issues'][] = $fields[$name]['label'] . ': ' . $e->getMessage(); } } }
foreach ($mapping as $c=>$name) {
if ($name === '' || $name === '__id') { continue; }
$field = $fields[$name]; $stored = $before['fields'][$name];
$allowed=Editor::can_edit('post',$row['id'],$name);
$cell = array('field'=>$name,'label'=>$field['label'],'before'=>$allowed?$stored['value']:null,'exists'=>$allowed&&$stored['exists'],'input'=>$r[$c],'after'=>null,'valid'=>false,'changed'=>false,'accessible'=>$allowed);
try {
if (!array_key_exists($name, $raw)) { self::fail(__('Input could not be interpreted.', 'customfields-com')); }
if (!$allowed) { self::fail(__('You cannot edit this field.', 'customfields-com')); }
if ($stored['exists'] && is_wp_error(Fields::storage_error($stored['value'], $field))) { self::fail(__('Stored value does not fit its definition; fix it manually first.', 'customfields-com')); }
if (!apply_filters('customfields_field_is_active', true, $field, $siblings, $row['id'])) { self::fail(__('This field is inactive for this row.', 'customfields-com')); }
$clean = Fields::sanitize($raw[$name], $field, array('has_stored_value'=>$stored['exists'],'stored_value'=>$stored['exists']?$stored['value']:null,'object_kind'=>'post','object_id'=>$row['id']));
if (is_wp_error($clean)) { self::fail($clean->get_error_message()); }
$cell['after'] = $clean; $cell['valid'] = true;
// WordPress scalar metadata is returned as strings; compare canonical stored representations.
$cell['changed'] = !$stored['exists'] || maybe_serialize($stored['value']) !== maybe_serialize(is_scalar($clean)?(string)$clean:$clean);
if ($cell['changed']) { $row['changes']++; }
} catch (\Throwable $e) { $row['issues'][] = $field['label'] . ': ' . $e->getMessage(); }
$row['cells'][] = $cell;
}
} catch (\Throwable $e) { $row['issues'][] = $e->getMessage(); }
$result['issues'] += count($row['issues']); $result['changes'] += $row['changes']; if ($row['changes']) { $result['changed_rows']++; }
$result['rows'][] = $row;
if (strlen(serialize($result)) > 6291456) { return self::error(__('All-row comparison exceeds 6 MB. Use fewer or shorter values; no partial report was saved.', 'customfields-com')); }
}
return $result;
}
public static function create($label, $group_key, $text) {
$csv = self::parse($text); if (is_wp_error($csv)) { return $csv; }
if (!is_string($label) || trim($label) === '' || wp_html_excerpt($label, 120, '') !== $label) { return self::error(__('Name your check with up to 120 characters.', 'customfields-com')); }
return self::locked(static function() use ($label,$group_key,$csv) {
if (count(self::index()) >= 3) { self::fail(__('Keep up to three private checks. Remove an old check before uploading another.', 'customfields-com')); }
$group = self::group($group_key); if (is_wp_error($group)) { return $group; }
if (!self::fields($group)) { return self::error(__('This group has no supported scalar or choice fields.', 'customfields-com')); }
$task = array('id'=>wp_generate_uuid4(),'user'=>get_current_user_id(),'blog'=>get_current_blog_id(),'label'=>sanitize_text_field($label),'group'=>$group_key,'schema'=>self::hash($group),'csv'=>$csv,'mapping'=>null,'analysis'=>null,'revision'=>1,'created'=>gmdate('c'));
self::save($task); return $task;
});
}
public static function compare($id, $revision, $mapping) {
$result = self::locked(static function() use ($id,$revision,$mapping) {
$task = self::loaded($id,$revision); $group = self::group($task['group']); if (is_wp_error($group)) { return $group; }
$analysis = self::analyze($task['csv'],$group,$mapping); if (is_wp_error($analysis)) { return $analysis; }
$task['mapping']=$mapping; $task['schema']=self::hash($group); $task['analysis']=$analysis; $task['revision']++; self::save($task); return $task;
});
if(!is_wp_error($result)){try{do_action('customfields_workflow_event','csv_analyzed');}catch(\Throwable $ignored){}}
return $result;
}
public static function remove($id, $revision) {
return self::locked(static function() use ($id,$revision) {
$task=self::loaded($id,$revision); $name=self::task_name($id); self::$touched[$name]=true;
if (!delete_option($name) || get_option($name,null)!==null) { self::fail(__('The private check could not be removed.', 'customfields-com')); }
$index=self::index(); unset($index[$task['id']]); self::persist(self::index_name(),$index); return array('removed'=>true);
});
}
/** Read-only handoff: Pro must create its own fresh review and obtain a new approval. */
public static function handoff($id, $revision) {
try {
$task=self::loaded($id,$revision); $group=self::group($task['group']); if (is_wp_error($group)) { return $group; }
if (!hash_equals($task['schema'],self::hash($group))) { return self::error(__('Field definitions changed. Refresh the Free comparison before handing it to Pro.', 'customfields-com')); }
if (!$task['mapping']) { return self::error(__('Compare every CSV row before continuing.', 'customfields-com')); }
$analysis=self::analyze($task['csv'],$group,$task['mapping']); if (is_wp_error($analysis)) { return $analysis; }
if ($analysis['issues'] || !$analysis['changes']) { return self::error(__('There are no eligible changes without issues. Refresh the comparison and fix every issue first.', 'customfields-com')); }
$task['analysis']=$analysis; return $task;
} catch (\Throwable $e) { return self::error($e->getMessage()); }
}
public static function export_issues($id) {
$task=self::get_task($id); if (is_wp_error($task)) { return $task; }
if (!$task['analysis']) { return self::error(__('Compare the CSV before exporting issues.', 'customfields-com')); }
$stream=fopen('php://temp','w+'); fputcsv($stream,array('CSV row','Post ID','Issue'),',','"',''); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Bounded PHP upload/temporary stream, validated with is_uploaded_file for uploads; native stream operations preserve CSV bytes and read limits.
foreach ($task['analysis']['rows'] as $row) { foreach ($row['issues'] as $issue) { $safe=preg_match('/^[\s\x00-\x20]*[=+@-]/u',$issue)?"'".$issue:$issue; fputcsv($stream,array($row['row'],$row['id']?:'',$safe),',','"',''); } }
rewind($stream); $out=stream_get_contents($stream); fclose($stream); return $out; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Bounded PHP upload/temporary stream, validated with is_uploaded_file for uploads; native stream operations preserve CSV bytes and read limits.
}
/** Read CSV bytes only from an authorized, genuine PHP upload. */
private static function upload() {
if ( ! current_user_can( 'manage_options' ) ) {
return self::error( __( 'Administrator access required.', 'customfields-com' ) );
}
if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] )
|| ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'customfields_csv_check' ) ) {
return self::error( __( 'Your session expired. Reload the comparison before continuing.', 'customfields-com' ) );
}
if ( ! isset( $_FILES['csv'] ) || ! is_array( $_FILES['csv'] )
|| ! isset( $_FILES['csv']['error'], $_FILES['csv']['name'], $_FILES['csv']['tmp_name'] )
|| ! is_int( $_FILES['csv']['error'] ) || UPLOAD_ERR_OK !== intval( $_FILES['csv']['error'] )
|| ! is_string( $_FILES['csv']['name'] ) || ! is_string( $_FILES['csv']['tmp_name'] ) ) {
return self::error( __( 'Choose a successfully uploaded .csv file.', 'customfields-com' ) );
}
// PHP upload metadata is not magic-quoted. A changed path is rejected.
$name = sanitize_file_name( $_FILES['csv']['name'] );
$temporary = sanitize_text_field( $_FILES['csv']['tmp_name'] );
if ( $temporary !== $_FILES['csv']['tmp_name'] || ! preg_match( '/\.csv$/i', $name ) || ! is_uploaded_file( $temporary ) ) {
return self::error( __( 'Choose a successfully uploaded .csv file.', 'customfields-com' ) );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read only a verified temporary PHP upload with a strict byte bound.
$stream = fopen( $temporary, 'rb' );
if ( false === $stream ) {
return self::error( __( 'The upload could not be read.', 'customfields-com' ) );
}
$text = stream_get_contents( $stream, self::MAX_FILE + 1 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the bounded read without creating or moving files.
fclose( $stream );
return is_string( $text ) && strlen( $text ) <= self::MAX_FILE ? $text : self::error( __( 'The CSV exceeds the supported file size.', 'customfields-com' ) );
}
public static function handle() {
if (!current_user_can('manage_options')) { wp_die(esc_html__('Administrator access required.', 'customfields-com'),'',array('response'=>403)); }
if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'customfields_csv_check' ) ) {
wp_die( esc_html__( 'Your session expired. Reload the comparison before continuing.', 'customfields-com' ), '', array( 'response' => 403 ) );
}
$p = array();
foreach ( array( 'operation', 'complete', 'confirmed', 'label', 'group', 'id', 'revision' ) as $key ) {
$clean = isset( $_POST[$key] ) && is_string( $_POST[$key] ) ? sanitize_text_field( wp_unslash( $_POST[$key] ) ) : '';
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The raw value is compared only; changed control identities are rejected, while the optional display label is intentionally sanitized.
$p[$key] = $key === 'label' || ( isset( $_POST[$key] ) && is_string( $_POST[$key] ) && $clean === wp_unslash( $_POST[$key] ) ) ? $clean : '';
}
$p['mapping'] = isset( $_POST['mapping'] ) && is_array( $_POST['mapping'] ) ? map_deep( wp_unslash( $_POST['mapping'] ), 'sanitize_text_field' ) : null;
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the sanitized mapping with the original only; changed or malformed mappings are rejected before comparison.
if ( isset( $_POST['mapping'] ) && $p['mapping'] !== wp_unslash( $_POST['mapping'] ) ) { $p['mapping'] = null; }
$op = $p['operation'];
if (($p['complete']??'')!=='1' || !is_string($op)) { $r=self::error(__('The form was incomplete. Nothing was changed.', 'customfields-com')); }
elseif ($op==='create') { $text=self::upload(); $r=is_wp_error($text)?$text:self::create($p['label']??null,$p['group']??null,$text); }
elseif ($op==='compare') { $r=self::compare($p['id']??'',$p['revision']??'',$p['mapping']??null); }
elseif ($op==='remove' && ($p['confirmed']??'')==='1') { $r=self::remove($p['id']??'',$p['revision']??''); }
elseif ($op==='export') {
$r=self::export_issues($p['id']??'');
if (!is_wp_error($r)) { nocache_headers(); header('Content-Type: text/csv; charset=UTF-8'); header('Content-Disposition: attachment; filename="customfields-csv-issues.csv"'); echo $r; exit; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV download is serialized by fputcsv with formula-prefix protection and sent as text/csv after owner/nonce checks; HTML escaping would corrupt the CSV.
} else { $r=self::error(__('Choose and confirm a supported action.', 'customfields-com')); }
if (is_wp_error($r)) { wp_die(esc_html($r->get_error_message()),'',array('response'=>409,'back_link'=>true)); }
wp_safe_redirect(self::url($r['id']??'')); exit;
}
private static function form($op,$task=null,$upload=false) {
echo '
'; }
public static function display($value) { return is_array($value)?wp_json_encode($value,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE):((string)$value===''?__('(empty)','customfields-com'):(string)$value); }
public static function page() {
if (!current_user_can('manage_options')) { return; }
wp_enqueue_style('customfields-csv-inspector',CUSTOMFIELDS_URL.'assets/csv-inspector.css',array(),CUSTOMFIELDS_VERSION);
wp_enqueue_script('customfields-csv-inspector',CUSTOMFIELDS_URL.'assets/csv-inspector.js',array('wp-i18n'),CUSTOMFIELDS_VERSION,true);
wp_set_script_translations('customfields-csv-inspector', 'customfields-com', CUSTOMFIELDS_PATH . 'languages');
$id=is_string($_GET['task']??null)?sanitize_text_field(wp_unslash($_GET['task'])):''; $task=$id&&$id!=='new'?self::get_task($id):null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing, filtering or pagination; no mutation is authorized by this query parameter. Object access remains checked by the renderer.
echo '
'.esc_html__('1 · Choose your data','customfields-com').'
'.esc_html__('Start with a spreadsheet export','customfields-com').'
'; self::form('create',null,true);
echo '
'.esc_html__('Include WordPress post IDs from this site. UTF-8 comma-separated CSV, a header row, up to 500 data rows and 2 MB. No row is silently skipped.','customfields-com').'
';
}
echo ''.esc_html__('Format, privacy and supported scope','customfields-com').'
'.esc_html__('Free compares existing posts by WordPress ID, in one native-metadata field group, with 1–10 plain or choice fields. Repeating, conditional, reference, media and custom-table fields use their dedicated editors. Empty mapped cells propose clearing values, subject to validation. Unmapped columns do not change the comparison scope.','customfields-com').'
'.esc_html__('Choice lists use JSON arrays. Formula-like mapped input is refused. Issue exports escape spreadsheet formula prefixes. Parsed files and comparisons are stored privately in this site’s database, with no remote upload, tracking or public attachment. Up to three checks per administrator; delete a check to remove its retained source and comparison. Saved comparisons can become stale: refresh after manual edits.','customfields-com').'
';
}
private static function task_page($task) {
$group=self::group($task['group']);
echo '
'.esc_html($task['label']).'
';
if (is_wp_error($group)) { echo '
'.esc_html($group->get_error_message()).'
'; }
else {
if (!hash_equals($task['schema'],self::hash($group))) { echo '
'.esc_html__('Field definitions changed. Review the mapping and refresh this comparison before using it.','customfields-com').'
'; }
$mapping_container=$task['analysis']?'details':'section';$heading=$task['analysis']?'summary':'h3';
echo '<'.$mapping_container.'><'.$heading.'>'.esc_html__('2 · Match columns and refresh the comparison','customfields-com').''.$heading.'>
'.esc_html__('Suggestions use matching names or labels and show the expected type. They are not AI guesses. Confirm the destinations; you can change this Free comparison at any time.','customfields-com').'
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Tag names come only from the fixed details/section and summary/h3 literals, never request input.
$suggestions=self::suggestions($task['csv'],$group); $fields=self::fields($group); self::form('compare',$task);
foreach ($task['csv']['headers'] as $i=>$header) {
$target=$task['mapping'][$i]??$suggestions[$i]['target'];
echo '
'.esc_html__('Open each matched post to edit it manually, then refresh the comparison. Invalid rows are visible and are never silently skipped.','customfields-com').'
';
self::form('export',$task); self::end(__('Download all issues as CSV','customfields-com'));
if (!$a['issues'] && $a['changes']>0 && Workspace::pro_available() && class_exists('CustomFieldsPro\\CsvWorkflows') && !is_wp_error(self::group($task['group'])) && hash_equals($task['schema'],self::hash(self::group($task['group'])))) {
echo '
'.esc_html__('Continue this saved task in your installed Pro add-on. Every row is rechecked and a separate approval is required before any update.','customfields-com').'
';
}
self::pagination($task['id'],$page,$pages);
foreach (array_slice($a['rows'],($page-1)*10,10) as $row) {
echo '