diff --git a/.eslintrc.cjs b/.eslintrc.cjs index da21f98af..1d858289c 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -14,6 +14,7 @@ module.exports = { ], "globals": { "jQuery": false, + "Toastify": false, }, "rules": { "no-console": "off", diff --git a/dt-core/admin/admin-settings-endpoints.php b/dt-core/admin/admin-settings-endpoints.php index 0bb4b0f2a..cfab83dc4 100644 --- a/dt-core/admin/admin-settings-endpoints.php +++ b/dt-core/admin/admin-settings-endpoints.php @@ -945,9 +945,9 @@ public static function new_field_option( WP_REST_Request $request ) { $field_key = $post_submission['field_key']; $post_type = $post_submission['post_type']; $new_field_option_name = $post_submission['field_option_name']; - $new_field_option_key = dt_create_field_key( $new_field_option_name ); - $new_field_option_description = $post_submission['field_option_description']; - $field_option_icon = $post_submission['field_option_icon']; + $new_field_option_key = $post_submission['field_option_key'] ?? dt_create_field_key( $new_field_option_name ); + $new_field_option_description = $post_submission['field_option_description'] ?? ''; + $field_option_icon = $post_submission['field_option_icon'] ?? ''; $custom_field_options = dt_get_option( 'dt_field_customizations' ); $custom_field_options[$post_type][$field_key]['default'][$new_field_option_key] = [ @@ -980,8 +980,8 @@ public static function edit_field_option( WP_REST_Request $request ) { $post_type = $post_submission['post_type']; $field_option_key = $post_submission['field_option_key']; $new_field_option_label = $post_submission['new_field_option_label']; - $new_field_option_description = $post_submission['new_field_option_description']; - $field_option_icon = $post_submission['field_option_icon']; + $new_field_option_description = $post_submission['new_field_option_description'] ?? ''; + $field_option_icon = $post_submission['field_option_icon'] ?? ''; $fields = DT_Posts::get_post_field_settings( $post_type, false, true ); $field_options = $fields[$field_key]['default'] ?? []; diff --git a/dt-core/admin/menu/tabs/tab-exports.php b/dt-core/admin/menu/tabs/tab-exports.php index ce8ff7794..49daf5d3e 100644 --- a/dt-core/admin/menu/tabs/tab-exports.php +++ b/dt-core/admin/menu/tabs/tab-exports.php @@ -56,11 +56,11 @@ public function __construct(){ } // End __construct() public function add_submenu(){ - add_submenu_page( 'edit.php?post_type=exports', __( 'Exports', 'disciple_tools' ), __( 'Exports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=exports', [ + add_submenu_page( 'edit.php?post_type=exports', __( 'Settings Exports', 'disciple_tools' ), __( 'Settings Exports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=exports', [ 'Disciple_Tools_Settings_Menu', 'content' ] ); - add_submenu_page( 'dt_utilities', __( 'Exports', 'disciple_tools' ), __( 'Exports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=exports', [ + add_submenu_page( 'dt_utilities', __( 'Settings Exports', 'disciple_tools' ), __( 'Settings Exports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=exports', [ 'Disciple_Tools_Settings_Menu', 'content' ] ); @@ -71,7 +71,7 @@ public function add_tab( $tab ){ if ( $tab == 'exports' ){ echo 'nav-tab-active'; } - echo '">' . esc_attr__( 'Exports' ) . ''; + echo '">' . esc_attr__( 'Settings Exports' ) . ''; } public function content( $tab ){ diff --git a/dt-core/admin/menu/tabs/tab-imports.php b/dt-core/admin/menu/tabs/tab-imports.php index bf3bcdb38..e916d5236 100644 --- a/dt-core/admin/menu/tabs/tab-imports.php +++ b/dt-core/admin/menu/tabs/tab-imports.php @@ -56,11 +56,11 @@ public function import_new_post_types( $post_types, $imported_config ) { } public function add_submenu(){ - add_submenu_page( 'edit.php?post_type=imports', __( 'Imports', 'disciple_tools' ), __( 'Imports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=imports', [ + add_submenu_page( 'edit.php?post_type=imports', __( 'Settings Imports', 'disciple_tools' ), __( 'Settings Imports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=imports', [ 'Disciple_Tools_Settings_Menu', 'content' ] ); - add_submenu_page( 'dt_utilities', __( 'Imports', 'disciple_tools' ), __( 'Imports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=imports', [ + add_submenu_page( 'dt_utilities', __( 'Settings Imports', 'disciple_tools' ), __( 'Settings Imports', 'disciple_tools' ), 'manage_dt', 'dt_utilities&tab=imports', [ 'Disciple_Tools_Settings_Menu', 'content' ] ); @@ -71,7 +71,7 @@ public function add_tab( $tab ){ if ( $tab == 'imports' ){ echo 'nav-tab-active'; } - echo '">' . esc_attr__( 'Imports' ) . ''; + echo '">' . esc_attr__( 'Settings Imports' ) . ''; } public function content( $tab ){ diff --git a/dt-import/CSV_IMPORT_DOCUMENTATION.md b/dt-import/CSV_IMPORT_DOCUMENTATION.md new file mode 100644 index 000000000..a63458bb1 --- /dev/null +++ b/dt-import/CSV_IMPORT_DOCUMENTATION.md @@ -0,0 +1,648 @@ +# CSV Import Documentation + +## Overview + +The CSV Import feature allows you to bulk import data into Disciple Tools from CSV (Comma-Separated Values) files. This tool supports importing both **Contacts** and **Groups** with comprehensive field mapping and validation. + +## Getting Started + +### Access the Import Tool + +1. Navigate to **Admin** → **Settings** → **Import** tab +2. You must have `manage_dt` permissions to access the import functionality + +**Note**: Only users with administrator or manager roles can access the import feature. If you don't see the Import tab, contact your system administrator to request the necessary permissions. + +### Import Process + +The import process consists of 4 main steps: + +1. **Select Record Type** - Choose whether to import Contacts or Groups +2. **Upload CSV & Configure Options** - Upload your CSV file (max 10MB) and set import options +3. **Map Fields** - Map your CSV columns to Disciple Tools fields +4. **Preview & Import** - Review and execute the import + +--- + +## File Requirements + +### File Format +- **File Type**: CSV (.csv files only) +- **Maximum Size**: 10MB +- **Encoding**: UTF-8 recommended +- **Delimiters**: Comma (,), semicolon (;), tab, or pipe (|) - automatically detected + +### CSV Structure +- **Headers Required**: First row must contain column headers +- **Consistent Columns**: All rows must have the same number of columns +- **No Duplicate Headers**: Column headers must be unique +- **Empty Values**: Leave cells empty if no data available + +--- + +## Supported Field Types + +The CSV import tool supports 14 different field types. Each field type has specific formatting requirements and validation rules. + +### 1. Text Fields + +**Field Type**: `text` + +**Description**: Single-line text input + +**Accepted Values**: Any string up to reasonable length + +**Examples**: + +| Name | Description | +|------|-------------| +| John Smith | A new contact from the outreach event | +| Mary Johnson | Referred by existing member | + +--- + +### 2. Text Area Fields + +**Field Type**: `textarea` + +**Description**: Multi-line text input + +**Accepted Values**: Any string including line breaks + +**Examples**: + +| Notes | +|-------| +| Met at coffee shop. Very interested in Bible study. Follow up next week. | +| Previous church member. Moving to the area next month. | + +--- + +### 3. Number Fields + +**Field Type**: `number` + +**Description**: Numeric values (integers or decimals) + +**Accepted Values**: +- Integers: `1`, `42`, `-5` +- Decimals: `3.14`, `0.5`, `-2.7` + +**Examples**: + +| Age | Score | +|-----|-------| +| 25 | 8.5 | +| 42 | 10 | +| 18 | 7.2 | + +--- + +### 4. Date Fields + +**Field Type**: `date` + +**Description**: Date values + +**Accepted Formats**: +- ISO format: `2024-01-15` (recommended) +- US format: `01/15/2024`, `1/15/2024` +- European format: `15/01/2024`, `15.01.2024` +- Text dates: `January 15, 2024`, `15 Jan 2024` + +**Examples**: + +| Birth Date | Last Contact | +|------------|--------------| +| 2024-01-15 | 2024-03-20 | +| 01/15/1990 | March 20, 2024 | +| 1985-12-25 | 12/15/2023 | + +--- + +### 5. Boolean Fields + +**Field Type**: `boolean` + +**Description**: True/false values + +**Accepted Values for TRUE**: +- `true`, `True`, `TRUE` +- `yes`, `Yes`, `YES` +- `y`, `Y` +- `1` +- `on`, `On`, `ON` +- `enabled`, `Enabled`, `ENABLED` + +**Accepted Values for FALSE**: +- `false`, `False`, `FALSE` +- `no`, `No`, `NO` +- `n`, `N` +- `0` +- `off`, `Off`, `OFF` +- `disabled`, `Disabled`, `DISABLED` + +**Examples**: + +| Baptized | Requires Follow Up | +|----------|-------------------| +| true | no | +| yes | 1 | +| false | disabled | +| Y | off | + +--- + +### 6. Dropdown Fields (Key Select) + +**Field Type**: `key_select` + +**Description**: Single selection from predefined options + +**Accepted Values**: Must match exact option keys or be mapped during import + +**Common Examples**: + +**Contact Status**: +- `new` +- `unassigned` +- `assigned` +- `active` +- `paused` +- `closed` +- `unassignable` + +**Contact Type**: +- `personal` +- `placeholder` +- `user` + +**Seeker Path** (Contacts): +- `none` +- `attempted` +- `established` +- `scheduled` +- `met` +- `ongoing` +- `coaching` + +**Examples**: + +| Status | Type | Seeker Path | +|--------|------|-------------| +| new | personal | attempted | +| active | personal | ongoing | +| paused | personal | met | +| closed | placeholder | none | + +--- + +### 7. Multi-Select Fields + +**Field Type**: `multi_select` + +**Description**: Multiple selections from predefined options + +**Format**: Separate multiple values with semicolons (`;`) + +**Common Examples**: + +**Sources** (Contacts): +- `web` +- `phone` +- `facebook` +- `twitter` +- `instagram` +- `referral` +- `advertisement` +- `event` + +**Milestones** (Contacts): +- `milestone_has_bible` +- `milestone_reading_bible` +- `milestone_belief` +- `milestone_can_share` +- `milestone_sharing` +- `milestone_baptized` +- `milestone_baptizing` +- `milestone_in_group` +- `milestone_planting` + +**Health Metrics** (Groups): +- `church_giving` +- `church_fellowship` +- `church_communion` +- `church_baptism` +- `church_prayer` +- `church_leaders` +- `church_bible` +- `church_praise` +- `church_sharing` +- `church_commitment` + +**Examples**: + +| Sources | Milestones | +|---------|------------| +| web;referral | milestone_has_bible;milestone_reading_bible | +| facebook | milestone_belief;milestone_baptized;milestone_in_group | +| phone;event | milestone_has_bible;milestone_can_share | + +--- + +### 8. Tags Fields + +**Field Type**: `tags` + +**Description**: Free-form tags/labels + +**Format**: Separate multiple tags with semicolons (`;`) + +**Examples**: + +| Tags | +|------| +| VIP;follow-up-needed;speaks-spanish | +| new-believer;youth;musician | +| staff;volunteer;leader | + +--- + +### 9. Communication Channel Fields + +**Field Type**: `communication_channel` + +**Description**: Contact methods like phone numbers and email addresses + +**Format**: Separate multiple values with semicolons (`;`) + +**Validation**: +- **Email fields**: Must be valid email format +- **Phone fields**: Must contain at least one digit + +**Common Fields**: +- `contact_phone` +- `contact_email` +- `contact_address` +- `contact_facebook` +- `contact_telegram` +- `contact_whatsapp` + +**Examples**: + +| Email | Phone | +|-------|-------| +| john@example.com | +1-555-123-4567 | +| mary.smith@email.com;backup@email.com | 555-987-6543;555-111-2222 | +| contact@church.org | (555) 123-4567;555-987-6543 | + +--- + +### 10. Connection Fields + +**Field Type**: `connection` + +**Description**: Relationships to other records + +**Accepted Values**: +- **Numeric ID**: `123`, `456` +- **Record Title/Name**: `"John Smith"`, `"Downtown Group"` + +**Format**: Separate multiple connections with semicolons (`;`) + +**Connection Logic**: +- **Record ID (numeric)**: Links directly to that specific record +- **Record name (text)**: Searches for existing records by name +- **Single match found**: Connects to the existing record +- **No match found**: Creates a new record with that name +- **Multiple matches found**: That specific connection is skipped (other connections in the same field will still process) + +**Common Examples**: + +**For Contacts**: +- `baptized_by` (connects to other contacts) +- `baptized` (connects to other contacts) +- `coached_by` (connects to other contacts) +- `coaching` (connects to other contacts) +- `groups` (connects to groups) + +**For Groups**: +- `parent_groups` (connects to other groups) +- `child_groups` (connects to other groups) +- `members` (connects to contacts) +- `leaders` (connects to contacts) + +**Examples**: + +| Coached By | Groups | +|------------|--------| +| John Smith | Downtown Bible Study | +| 142 | Small Group Alpha;Youth Group | +| Mary Johnson | Prayer Group;Bible Study | + +**Important**: If multiple records exist with the same name (e.g., two contacts named "John Smith"), that connection will be skipped. To avoid this, use record IDs instead of names, or ensure all records have unique names before importing. + +--- + +### 11. User Select Fields + +**Field Type**: `user_select` + +**Description**: Assignment to system users + +**Accepted Values**: +- **User ID**: `1`, `25` +- **Username**: `admin`, `john_doe` +- **Display Name**: `"John Doe"`, `"Mary Smith"` + +**Common Fields**: +- `assigned_to` +- `overall_status` + +**Examples**: + +| Assigned To | +|-------------| +| john_doe | +| 25 | +| John Doe | +| admin | + +--- + +### 12. Location Fields + +**Field Type**: `location` + +**Description**: Geographic location information using coordinates or grid IDs only + +**Accepted Values**: +- **Grid ID**: `100364199` (numeric location grid ID) +- **Decimal coordinates**: `"40.7128,-74.0060"` (latitude,longitude) +- **DMS coordinates**: `"35°50′40.9″N, 103°27′7.5″E"` (degrees, minutes, seconds) + +**Multiple Locations**: Separate multiple values with semicolons (`;`) + +**Note**: Location fields do NOT accept address strings. For addresses, use `location_meta` fields instead. + +**Coordinate Formats**: + +**Decimal Degrees** (recommended): +- Format: `latitude,longitude` +- Range: -90 to 90 for latitude, -180 to 180 for longitude +- Examples: `40.7128,-74.0060`, `35.8447,103.4521` + +**DMS (Degrees, Minutes, Seconds)**: +- Format: `DD°MM′SS.S″N/S, DDD°MM′SS.S″E/W` +- Direction indicators (N/S/E/W) are **required** +- Supports various symbols: `°′″` or `d m s` or regular quotes `'"` +- Examples: + - `35°50′40.9″N, 103°27′7.5″E` + - `40°42′46″N, 74°0′21″W` + - `51°30′26″N, 0°7′39″W` + +**Examples**: + +| Location | +|----------| +| 100364199 | +| 40.7589, -73.9851 | +| 35°50′40.9″N, 103°27′7.5″E | +| 100089589 | +| 100364199;100089589 | +| 40.7589, -73.9851;35°50′40.9″N, 103°27′7.5″E | + +--- + +### 13. Location Grid Fields + +**Field Type**: `location_grid` + +**Description**: Specific location grid IDs in the system + +**Accepted Values**: Numeric grid IDs only + +**Examples**: + +| Location Grid | +|---------------| +| 100364199 | +| 100089589 | +| 100254781 | + +--- + +### 14. Location Meta Fields + +**Field Type**: `location_meta` + +**Description**: Enhanced location with geocoding support and address processing + +**Accepted Values**: +- **Grid ID**: `100364199` (numeric location grid ID) +- **Decimal coordinates**: `"40.7128,-74.0060"` (latitude,longitude) +- **DMS coordinates**: `"35°50′40.9″N, 103°27′7.5″E"` (degrees, minutes, seconds) +- **Address strings**: `"123 Main St, Springfield, IL"` (requires geocoding service) +- **Location names**: `"Springfield, Illinois"` (requires geocoding service) +- **Multiple locations**: `"Paris, France; Berlin, Germany"` (semicolon-separated) + +**Coordinate Formats**: + +**Decimal Degrees**: +- Format: `latitude,longitude` +- Examples: `40.7128,-74.0060`, `35.8447,103.4521` + +**DMS (Degrees, Minutes, Seconds)**: +- Format: `DD°MM′SS.S″N/S, DDD°MM′SS.S″E/W` +- Direction indicators (N/S/E/W) are **required** +- Examples: `35°50′40.9″N, 103°27′7.5″E`, `40°42′46″N, 74°0′21″W` + +**Multiple Locations**: +- Separate with semicolons: `"France; Germany"`, `"40.7128,-74.0060; 35°50′40.9″N, 103°27′7.5″E"` + +**Geocoding**: Can automatically convert addresses to coordinates if geocoding service is configured + +**Examples**: + +| Location Meta | +|---------------| +| 100364199 | +| 40.7589, -73.9851 | +| 35°50′40.9″N, 103°27′7.5″E | +| 123 Main Street, Springfield, IL 62701 | +| Times Square, New York, NY | +| Paris, France; Berlin, Germany | +| Central Park, Manhattan | +| 100364199;40.7589, -73.9851 | +| 123 Main St, Springfield; Times Square, NYC | + +--- + +## Special Formatting Rules + +### Multi-Value Fields +Use semicolons (`;`) to separate multiple values: + +| Phone Numbers | Sources | Groups | +|---------------|---------|--------| +| 555-123-4567;555-987-6543 | web;referral | Group A;Group B | +| 555-111-2222 | facebook;event | Youth Group | + +### Empty Values +Leave cells empty for no data: + +| Name | Phone | Email | +|------|-------|-------| +| John Smith | | john@example.com | +| Jane Doe | 555-123-4567 | | +| Bob Wilson | 555-987-6543 | bob@example.com | + +### Text with Commas +Use quotes around text containing commas: + +| Address | Notes | +|---------|-------| +| 123 Main St, Springfield, IL | Met at coffee shop, very interested | +| 456 Oak Ave, Chicago, IL | Referred by John, wants to join small group | + +--- + +## Import Options + +### Default Values +Set default values that apply to all imported records: +- **Source**: Default source for tracking (e.g., 'csv_import', 'data_migration') +- **Assigned To**: Default user assignment for all imported records + +### Duplicate Checking +Configure how the system handles potential duplicate records: +- **Enable Duplicate Checking**: Check for existing records with matching values +- **Merge Fields**: Choose which fields to use for duplicate detection (typically phone or email) +- **Behavior**: When duplicates are found, the system updates existing records instead of creating new ones + +### Geocoding Services +Configure automatic address geocoding for location fields: +- **Google Maps**: Requires Google Maps API key configuration +- **Mapbox**: Requires Mapbox API token configuration +- **None**: Import addresses without automatic geocoding + +**Note**: Geocoding services must be configured in Disciple Tools settings before they become available for import. + +--- + +## Common Field Examples + +### Basic Contact Import + +| Name | Email | Phone | Status | Source | +|------|-------|-------|--------|--------| +| John Smith | john@example.com | 555-123-4567 | new | web | +| Mary Johnson | mary@example.com | 555-987-6543 | active | referral | +| Bob Wilson | bob@example.com | 555-111-2222 | new | facebook | + +### Comprehensive Contact Import + +| Name | Email | Phone | Status | Type | Sources | Milestones | Assigned To | Tags | Location | +|------|-------|-------|--------|------|---------|------------|-------------|------|----------| +| John Smith | john@example.com | 555-123-4567 | active | personal | web;referral | milestone_has_bible;milestone_reading_bible | john_doe | VIP;follow-up | 123 Main St, Springfield | +| Mary Johnson | mary@example.com | 555-987-6543 | new | personal | facebook | milestone_belief | mary_admin | new-believer;youth | Downtown Community Center | + +### Basic Group Import + +| Name | Status | Group Type | Start Date | Location | +|------|--------|------------|------------|----------| +| Downtown Bible Study | active | group | 2024-01-15 | Community Center | +| Youth Group | active | church | 2024-02-01 | First Baptist Church | +| Prayer Circle | active | group | 2024-03-10 | Methodist Church | + +### Comprehensive Group Import + +| Name | Status | Group Type | Health Metrics | Members | Leaders | Start Date | End Date | Location | +|------|--------|------------|---------------|---------|---------|------------|----------|----------| +| Downtown Bible Study | active | group | church_bible;church_prayer;church_fellowship | John Smith;Mary Johnson | Pastor Mike | 2024-01-15 | | Community Center | +| Youth Group | active | church | church_giving;church_baptism;church_leaders | Youth Member 1;Youth Member 2 | Youth Pastor | 2024-02-01 | | First Baptist Church | + +--- + +## Troubleshooting + +### Common Errors + +**"Invalid number"**: Ensure numeric fields contain only numbers +**"Invalid date format"**: Use YYYY-MM-DD format or clear date formats +**"Invalid email address"**: Check email format (must contain @ and valid domain) +**"Invalid option for field"**: Check that dropdown values match available options +**"Field does not exist"**: Verify field exists for the selected post type +**"Connection not found"**: Ensure connected records exist or use valid IDs +**"Connection skipped due to duplicate names"**: Multiple records exist with the same name - use record IDs instead + +### Connection Field Behavior + +When importing connection fields, the system processes each connection value as follows: + +1. **Numeric values** (e.g., `123`): Treated as record IDs - will link directly to the record with that ID +2. **Text values** (e.g., `"John Smith"`): Searched by record name/title + - **Single match found**: Links to that existing record + - **No match found**: Creates a new record with that name + - **Multiple matches found**: **Skips that specific connection** (does not fail the entire row) + +**Example**: If your CSV has `"John Smith;Mary Johnson;456"` in a connection field: +- `John Smith`: If 1 record found → connects to it; if 0 found → creates new; if 2+ found → skips +- `Mary Johnson`: Processed independently with same logic +- `456`: Links directly to record ID 456 (if it exists) + +The import will continue processing other connections and other fields even if some connections are skipped. + +### Best Practices + +1. **Start Small**: Test with a few records first +2. **Use Examples**: Download and modify the provided example CSV files +3. **Check Field Options**: Review available options for dropdown fields during mapping +4. **Validate Data**: Clean your data before import +5. **Backup First**: Always backup your data before large imports +6. **Review Mapping**: Carefully review automatic field mappings before proceeding +7. **Check Duplicates**: Enable duplicate checking for communication fields to avoid duplicate records +8. **Test Geocoding**: If using location fields, test geocoding with a few addresses first +9. **Use Record IDs for Connections**: When connecting to existing records, use numeric IDs instead of names to avoid issues with duplicate names + +### System Limitations + +- **File Size**: Maximum 10MB per CSV file +- **Memory**: Large imports may require adequate server memory +- **Timeout**: Very large imports may be processed in batches to avoid timeouts +- **Permissions**: Requires `manage_dt` capability to access import functionality +- **Geocoding**: Requires API keys for Google Maps or Mapbox services +- **Records**: No hard limit on number of records, but performance depends on server resources + +--- + +## Field Mapping + +During the import process, you'll map your CSV columns to Disciple Tools fields: + +1. **Automatic Detection**: The system attempts to match column headers to field names +2. **Manual Mapping**: You can override automatic suggestions +3. **Value Mapping**: For dropdown fields, map your CSV values to valid options +4. **Skip Columns**: Choose to skip columns you don't want to import + +--- + +## Example CSV Files + +The import tool provides four downloadable example files: + +### Basic Templates +- **`example_contacts.csv`**: Simple contact import template with essential fields +- **`example_groups.csv`**: Simple group import template with basic fields + +### Comprehensive Templates +- **`example_contacts_comprehensive.csv`**: Complete contact template with all available field types, milestone options, and relationship examples +- **`example_groups_comprehensive.csv`**: Complete group template with all field types, health metrics, and member/leader relationships + +**Access**: Download these files from the Import interface sidebar or from the admin settings page. + +**Usage Tips**: +- Start with basic templates for simple imports +- Use comprehensive templates to see all available field options +- Modify the templates by removing unnecessary columns for your specific needs +- Keep the header row and follow the same formatting patterns \ No newline at end of file diff --git a/dt-import/DEVELOPER_GUIDE.md b/dt-import/DEVELOPER_GUIDE.md new file mode 100644 index 000000000..6216b9c2c --- /dev/null +++ b/dt-import/DEVELOPER_GUIDE.md @@ -0,0 +1,214 @@ +# DT Import CSV - Developer Guide + +## Overview + +The DT Import system provides comprehensive CSV import functionality for Disciple.Tools post types. This guide covers the technical implementation details for developers working on or integrating with the import system. + +## Core Architecture + +### File Structure +``` +wp-content/themes/disciple-tools-theme/dt-import/ +├── dt-import.php # Main plugin file +├── admin/ +│ ├── dt-import-admin-tab.php # Admin tab integration +│ ├── dt-import-mapping.php # Field mapping logic +│ ├── dt-import-processor.php # Import processing +│ ├── rest-endpoints.php # REST API endpoints +│ └── documentation-modal.php # Documentation modal template +├── includes/ +│ ├── dt-import-field-handlers.php # Field-specific processors +│ ├── dt-import-utilities.php # Utility functions +│ ├── dt-import-validators.php # Data validation +│ └── dt-import-geocoding.php # Geocoding services +└── assets/ + ├── js/ + │ ├── dt-import.js # Main frontend JavaScript + │ └── dt-import-modals.js # Modal handling + ├── css/dt-import.css # Styling + └── example CSV files # Example templates +``` + +### Main Classes +- `DT_Theme_CSV_Import`: Core plugin initialization +- `DT_CSV_Import_Admin_Tab`: WordPress admin integration +- `DT_CSV_Import_Mapping`: Field detection and mapping +- `DT_CSV_Import_Processor`: Data processing and import execution +- `DT_CSV_Import_Field_Handlers`: Field-specific processing logic + +## Field Type Processing + +### Supported Field Types + +Each field type has specific processing logic in `DT_CSV_Import_Field_Handlers`: + +- **Text fields**: Direct assignment with sanitization +- **Date fields**: Format conversion and validation +- **Boolean fields**: Multiple format recognition (true/false, yes/no, 1/0, etc.) +- **Key select**: CSV value mapping to field options +- **Multi select**: Semicolon-separated value processing +- **Communication channels**: Email/phone validation and formatting +- **Connections**: ID/name lookup with duplicate handling +- **User select**: User lookup by ID, username, or display name +- **Location fields**: Address geocoding and coordinate processing + +### Connection Field Processing + +Connection fields have special handling for duplicate names: + +1. **Numeric ID**: Direct lookup by post ID +2. **Text name**: Search by title/name with duplicate detection +3. **Single match**: Connect to existing record +4. **No match**: Create new record (non-preview mode) +5. **Multiple matches**: Skip that connection (continue processing other connections) + +### Value Mapping Structure + +For dropdown fields, mappings are stored as: +- `field_key`: Target DT field +- `column_index`: CSV column number +- `value_mapping`: CSV value → DT option key mappings + +## Automatic Field Detection + +### Detection Priority + +1. **Predefined Field Headings** (100% confidence) +2. **Direct Field Matching** (100% confidence) +3. **Communication Channel Detection** (100% confidence) +4. **Partial Matching** (75% confidence) +5. **Extended Field Aliases** (≤75% confidence) + +### Auto-Mapping Threshold + +- **≥75% confidence**: Automatically mapped +- **<75% confidence**: Manual selection required + +## API Endpoints + +### REST API Structure + +Base URL: `/wp-json/dt-csv-import/v2/` + +- **Field Options**: `/{post_type}/field-options?field_key={field_key}` +- **Column Data**: `/{session_id}/column-data?column_index={index}` +- **Import Processing**: `/import` (POST) + +## Data Processing Flow + +### Import Workflow + +1. **File Upload**: CSV parsing and validation +2. **Field Detection**: Header analysis for field suggestions +3. **Field Mapping**: User column-to-field mapping +4. **Value Mapping**: Dropdown value mapping for select fields +5. **Validation**: Data validation against field requirements +6. **Processing**: Record creation via DT_Posts API +7. **Reporting**: Results and error reporting + +### Multi-Value Field Processing + +Fields supporting multiple values use semicolon (`;`) separation for processing multiple entries in a single CSV cell. + +## Location Field Handling + +### Supported Formats + +- **location_grid**: Numeric grid ID only +- **location_grid_meta**: Multiple formats supported + - Numeric grid IDs + - Decimal coordinates (lat,lng) + - DMS coordinates with direction indicators + - Address strings for geocoding + - Semicolon-separated multiple locations + +### Coordinate Format Support + +- **Decimal Degrees**: Standard lat,lng format with range validation +- **DMS**: Degrees/minutes/seconds with required direction indicators (N/S/E/W) + +### Geocoding Integration + +Uses DT's built-in geocoding services (Google Maps/Mapbox) when configured. The import system sets the geolocate flag for address processing. + +## Value Mapping System + +### Modal Features + +- Real-time CSV data fetching +- Unique value detection from actual CSV data +- Auto-mapping with fuzzy string matching +- Batch operations (clear all, auto-map) +- Live mapping count updates + +### Auto-Mapping Algorithm + +Fuzzy matching compares CSV values to field options with confidence scoring. Mappings above 80% confidence are auto-applied. + +## Security Implementation + +### Access Control +- Requires `manage_dt` capability for all operations +- WordPress nonce verification for all actions + +### File Upload Security +- File type validation (CSV only) +- Size limits (10MB maximum) +- Server-side content validation + +### Data Sanitization +- All inputs sanitized before processing +- Field-specific validation rules applied + +## Error Handling + +### Validation Levels +- **Row-level**: Collect all errors per row for batch reporting +- **Field-specific**: Type-appropriate validation (email format, date parsing, etc.) +- **Connection lookup**: Graceful handling of missing/duplicate records + +### Performance Considerations + +- **Memory Management**: Large file processing in chunks +- **Database Optimization**: Batch operations where possible +- **Progress Tracking**: Session-based progress for long imports + +## Current Limitations + +### Extensibility +The system has limited hook/filter support. Customization requires: +- Direct file modification (not recommended) +- Custom post type registration through DT's existing systems +- Field type extension through DT's field system + +### Extension Points +- Field detection can be modified via `$field_headings` array +- Field handlers can be added to `DT_CSV_Import_Field_Handlers` +- All imports integrate through `DT_Posts::create_post()` + +## Testing + +### Available Methods +1. **Manual Testing**: Admin interface under Utilities +2. **Field Detection**: Test various CSV column headers +3. **Value Mapping**: Dropdown field mapping with sample data +4. **Error Handling**: Invalid data testing + +### Test Resources +- Example CSV files in assets directory +- Basic and comprehensive templates for contacts/groups +- Various format examples for testing edge cases + +## Integration Patterns + +### Custom Post Type Support +Automatic support for any post type registered with DT's system via: +- `DT_Posts::get_post_types()` for available types +- `DT_Posts::get_post_field_settings()` for field definitions + +### Data Access Patterns +- Session data: `DT_CSV_Import_Utilities::get_session_data()` +- Field settings: `DT_Posts::get_post_field_settings($post_type)` +- Import processing: All through DT_Posts API + +This developer guide provides the essential technical reference for understanding and working with the DT Import CSV system architecture and implementation patterns. \ No newline at end of file diff --git a/dt-import/admin/documentation-modal.php b/dt-import/admin/documentation-modal.php new file mode 100644 index 000000000..432e78f22 --- /dev/null +++ b/dt-import/admin/documentation-modal.php @@ -0,0 +1,387 @@ + + + \ No newline at end of file diff --git a/dt-import/admin/dt-import-admin-tab.php b/dt-import/admin/dt-import-admin-tab.php new file mode 100644 index 000000000..40fde1089 --- /dev/null +++ b/dt-import/admin/dt-import-admin-tab.php @@ -0,0 +1,420 @@ + + + + + enqueue_admin_scripts(); + + // Display the import interface + $this->display_import_interface(); + } + + private function enqueue_admin_scripts() { + // Enqueue DT Web Components if available + if ( function_exists( 'dt_theme_enqueue_script' ) ) { + dt_theme_enqueue_script( 'web-components', 'dt-assets/build/components/index.js', [], false ); + dt_theme_enqueue_style( 'web-components-css', 'dt-assets/build/css/light.min.css', [] ); + } + + // Enqueue Toastify for toast notifications + wp_enqueue_style( 'toastify-css', 'https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.css', [], '1.12.0' ); + wp_enqueue_script( 'toastify-js', 'https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.js', [], '1.12.0', true ); + + // Enqueue our custom import scripts + wp_enqueue_script( + 'dt-import-js', + get_template_directory_uri() . '/dt-import/assets/js/dt-import.js', + [ 'jquery', 'toastify-js' ], + '1.0.0', + true + ); + + // Enqueue modal handling script + wp_enqueue_script( + 'dt-import-modals-js', + get_template_directory_uri() . '/dt-import/assets/js/dt-import-modals.js', + [ 'dt-import-js' ], + '1.0.0', + true + ); + + // Enqueue our custom import styles + wp_enqueue_style( + 'dt-import-css', + get_template_directory_uri() . '/dt-import/assets/css/dt-import.css', + [], + '1.0.0' + ); + + // Localize script with necessary data + wp_localize_script('dt-import-js', 'dtImport', [ + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'restUrl' => rest_url( 'dt-csv-import/v2/' ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'postTypes' => $this->get_available_post_types(), + 'translations' => $this->get_translations(), + 'fieldTypes' => $this->get_field_types(), + 'geocodingServices' => $this->get_geocoding_services(), + 'maxFileSize' => $this->get_max_file_size(), + 'allowedFileTypes' => [ 'text/csv', 'application/csv', 'text/plain' ] + ]); + } + + private function get_available_post_types() { + $post_types = DT_Posts::get_post_types(); + $formatted_types = []; + + foreach ( $post_types as $post_type ) { + $post_settings = DT_Posts::get_post_settings( $post_type ); + $formatted_types[] = [ + 'key' => $post_type, + 'label_singular' => $post_settings['label_singular'] ?? ucfirst( $post_type ), + 'label_plural' => $post_settings['label_plural'] ?? ucfirst( $post_type ) . 's', + 'description' => $this->get_post_type_description( $post_type ) + ]; + } + + return $formatted_types; + } + + private function get_post_type_description( $post_type ) { + $descriptions = [ + 'contacts' => __( 'Individual people you are reaching or discipling', 'disciple_tools' ), + 'groups' => __( 'Groups, churches, or gatherings of people', 'disciple_tools' ), + ]; + + return $descriptions[$post_type] ?? ''; + } + + private function display_csv_examples_sidebar() { + ?> +
+
+

+
+
+

+ +

+ + +

+ + +

+ + +
+

+ + +

+
+
+
+ +
+
+

+
+
+

+ +
+ +
+ +
+

+ + +

+
+
+
+ __( 'Select Record Type', 'disciple_tools' ), + 'uploadCsv' => __( 'Upload CSV', 'disciple_tools' ), + 'mapFields' => __( 'Map Fields', 'disciple_tools' ), + 'previewImport' => __( 'Preview & Import', 'disciple_tools' ), + 'next' => __( 'Next', 'disciple_tools' ), + 'back' => __( 'Back', 'disciple_tools' ), + 'upload' => __( 'Upload', 'disciple_tools' ), + 'csv_import' => __( 'CSV Import', 'disciple_tools' ), + 'cancel' => __( 'Cancel', 'disciple_tools' ), + 'skipColumn' => __( 'Skip this column', 'disciple_tools' ), + 'createField' => __( 'Create New Field', 'disciple_tools' ), + 'chooseFile' => __( 'Choose a file...', 'disciple_tools' ), + 'dragDropFile' => __( 'or drag and drop it here', 'disciple_tools' ), + 'fileUploaded' => __( 'File uploaded successfully!', 'disciple_tools' ), + 'uploadError' => __( 'Error uploading file', 'disciple_tools' ), + 'processingFile' => __( 'Processing file...', 'disciple_tools' ), + 'invalidFileType' => __( 'Invalid file type. Please upload a CSV file.', 'disciple_tools' ), + 'fileTooLarge' => __( 'File is too large. Maximum size is', 'disciple_tools' ), + 'noFileSelected' => __( 'Please select a file to upload.', 'disciple_tools' ), + 'mappingComplete' => __( 'Field mapping completed successfully!', 'disciple_tools' ), + 'importProgress' => __( 'Importing records...', 'disciple_tools' ), + 'importComplete' => __( 'Import completed successfully!', 'disciple_tools' ), + 'importFailed' => __( 'Import failed. Please check the error log.', 'disciple_tools' ), + 'recordsImported' => __( 'records imported', 'disciple_tools' ), + 'errorsFound' => __( 'errors found', 'disciple_tools' ), + // CSV delimiter options + 'comma' => __( 'Comma (,)', 'disciple_tools' ), + 'semicolon' => __( 'Semicolon (;)', 'disciple_tools' ), + 'tab' => __( 'Tab', 'disciple_tools' ), + 'pipe' => __( 'Pipe (|)', 'disciple_tools' ), + + // Value mapping translations + 'mapValues' => __( 'Map Values', 'disciple_tools' ), + 'csvValue' => __( 'CSV Value', 'disciple_tools' ), + 'dtFieldValue' => __( 'DT Field Value', 'disciple_tools' ), + 'skipValue' => __( '-- Skip this value --', 'disciple_tools' ), + 'autoMapSimilar' => __( 'Auto-map Similar Values', 'disciple_tools' ), + 'clearAllMappings' => __( 'Clear All Mappings', 'disciple_tools' ), + 'saveMappings' => __( 'Save Mapping', 'disciple_tools' ), + + // Field creation translations + 'createNewField' => __( 'Create New Field', 'disciple_tools' ), + 'fieldName' => __( 'Field Name', 'disciple_tools' ), + 'fieldType' => __( 'Field Type', 'disciple_tools' ), + 'fieldDescription' => __( 'Description', 'disciple_tools' ), + 'creating' => __( 'Creating...', 'disciple_tools' ), + 'fieldCreatedSuccess' => __( 'Field created successfully!', 'disciple_tools' ), + 'fieldCreationError' => __( 'Error creating field', 'disciple_tools' ), + 'ajaxError' => __( 'An error occurred. Please try again.', 'disciple_tools' ), + 'fillRequiredFields' => __( 'Please fill in all required fields.', 'disciple_tools' ), + + // Warning translations + 'warnings' => __( 'Warnings', 'disciple_tools' ), + 'importWarnings' => __( 'Import Warnings', 'disciple_tools' ), + 'newRecordsWillBeCreated' => __( 'Some records will create new connection records. Review the preview below for details.', 'disciple_tools' ), + 'newRecordIndicator' => __( '(NEW)', 'disciple_tools' ), + + // Geocoding translations + 'geocodingService' => __( 'Geocoding', 'disciple_tools' ), + 'enableGeocoding' => __( 'Enable geocoding for addresses', 'disciple_tools' ), + 'geocodingNote' => __( 'When enabled, addresses will be automatically converted to coordinates and location grid data', 'disciple_tools' ), + 'geocodingOptional' => __( 'Geocoding is optional - you can import addresses without converting them', 'disciple_tools' ), + 'locationInfo' => __( 'location fields accept grid IDs, coordinates (lat,lng), or addresses', 'disciple_tools' ), + 'locationMetaInfo' => __( 'location_meta fields accept grid IDs, coordinates (lat,lng), or addresses', 'disciple_tools' ), + 'noGeocodingService' => __( 'No geocoding service is available. Please configure Google Maps or Mapbox API keys.', 'disciple_tools' ), + + // Duplicate checking translations + 'duplicateChecking' => __( 'Duplicate Checking', 'disciple_tools' ), + 'enableDuplicateChecking' => __( 'Check for duplicates and merge with existing records', 'disciple_tools' ), + 'duplicateCheckingNote' => __( 'When enabled, the import will look for existing records with the same value in this field and update them instead of creating duplicates.', 'disciple_tools' ) + ]; + } + + private function get_field_types() { + return [ + 'text' => __( 'Text', 'disciple_tools' ), + 'textarea' => __( 'Text Area', 'disciple_tools' ), + 'number' => __( 'Number', 'disciple_tools' ), + 'date' => __( 'Date', 'disciple_tools' ), + 'boolean' => __( 'Boolean', 'disciple_tools' ), + 'key_select' => __( 'Dropdown', 'disciple_tools' ), + 'multi_select' => __( 'Multi Select', 'disciple_tools' ), + 'tags' => __( 'Tags', 'disciple_tools' ), + 'communication_channel' => __( 'Communication Channel', 'disciple_tools' ), + 'connection' => __( 'Connection', 'disciple_tools' ), + 'user_select' => __( 'User Select', 'disciple_tools' ), + 'location' => __( 'Location', 'disciple_tools' ), + 'location_meta' => __( 'Location Meta', 'disciple_tools' ) + ]; + } + + private function get_geocoding_services() { + // Since frontend is just a checkbox, we only need to know if geocoding is available + $is_available = DT_CSV_Import_Geocoding::is_geocoding_available(); + + return [ + 'available' => $is_available + ]; + } + + private function get_max_file_size() { + return wp_max_upload_size(); + } + + private function display_import_interface() { + ?> +
+
+
+
+ +
+

+ + +
+
    +
  • + 1 + +
  • +
  • + 2 + +
  • +
  • + 3 + +
  • +
  • + 4 + +
  • +
+
+ + +
+ +
+

+

+ +
+ +
+
+
+ + +
+ + +
+ + + +
+ +
+
+ + display_documentation_sidebar(); ?> + display_csv_examples_sidebar(); ?> + +
+
+
+
+
+
+ + + + [ + 'contact_phone', + 'phone', + 'mobile', + 'telephone', + 'cell', + 'phone_number', + 'tel', + 'cellular', + 'mobile_phone', + 'home_phone', + 'work_phone', + 'primary_phone' + ], + 'contact_email' => [ + 'contact_email', + 'email', + 'e-mail', + 'email_address', + 'e_mail', + 'primary_email', + 'work_email', + 'home_email' + ], + 'contact_address' => [ + 'contact_address', + 'address', + 'street_address', + 'home_address', + 'work_address', + 'mailing_address', + 'physical_address' + ], + 'name' => [ + 'title', + 'name', + 'contact_name', + 'full_name', + 'fullname', + 'person_name', + 'first_name', + 'last_name', + 'display_name', + 'firstname', + 'lastname', + 'given_name', + 'family_name', + 'client_name' + ], + 'gender' => [ + 'gender', + 'sex' + ], + 'notes' => [ + 'notes', + 'note', + 'comments', + 'comment', + 'cf_notes', + 'description', + 'remarks', + 'additional_info' + ] + ]; + + /** + * Analyze CSV columns and suggest field mappings + */ + public static function analyze_csv_columns( $csv_data, $post_type ) { + if ( empty( $csv_data ) ) { + return []; + } + + $headers = array_shift( $csv_data ); + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + $post_settings = DT_Posts::get_post_settings( $post_type ); + + $mapping_suggestions = []; + $name_field_mapped = false; + + foreach ( $headers as $index => $column_name ) { + $suggestion = self::suggest_field_mapping( $column_name, $field_settings, $post_settings, $post_type ); + $sample_data = DT_CSV_Import_Utilities::get_sample_data( $csv_data, $index, 5 ); + + // Validate that the suggested field actually exists in field settings + if ( $suggestion && !isset( $field_settings[$suggestion] ) ) { + $suggestion = null; + } + + // Check if this mapping suggests the name field + if ( $suggestion === 'name' ) { + $name_field_mapped = true; + } + + $mapping_suggestions[$index] = [ + 'column_name' => $column_name, + 'suggested_field' => $suggestion, + 'sample_data' => $sample_data, + 'has_match' => !is_null( $suggestion ) + ]; + } + + // If no column was automatically mapped to the name field, try to find the best candidate + if ( !$name_field_mapped ) { + $name_column_index = self::find_best_name_column( $headers ); + if ( $name_column_index !== null ) { + // Update the suggested mapping for the best name column + $mapping_suggestions[$name_column_index]['suggested_field'] = 'name'; + $mapping_suggestions[$name_column_index]['has_match'] = true; + $name_field_mapped = true; + } + } + + // Add metadata about whether name field was mapped + $mapping_suggestions['_meta'] = [ + 'name_field_mapped' => $name_field_mapped, + 'post_type' => $post_type + ]; + + return $mapping_suggestions; + } + + /** + * Find the best column to map to the name field + */ + private static function find_best_name_column( $headers ) { + // Look for columns that are likely to contain name data + $name_patterns = [ 'name', 'title', 'full_name', 'fullname', 'contact_name', 'person_name', 'display_name' ]; + + foreach ( $headers as $index => $column_name ) { + $normalized = self::normalize_string_for_matching( $column_name ); + foreach ( $name_patterns as $pattern ) { + if ( $normalized === self::normalize_string_for_matching( $pattern ) ) { + return $index; + } + } + } + + // If no obvious name column found, look for partial matches + foreach ( $headers as $index => $column_name ) { + $normalized = self::normalize_string_for_matching( $column_name ); + if ( strpos( $normalized, 'name' ) !== false || strpos( $normalized, 'title' ) !== false ) { + return $index; + } + } + + return null; // No suitable column found + } + + /** + * Enhanced field mapping suggestion using the plugin's comprehensive approach + */ + private static function suggest_field_mapping( $column_name, $field_settings, $post_settings, $post_type ) { + $column_normalized = self::normalize_string_for_matching( $column_name ); + + // Get post type prefix for channel fields + $post_type_object = get_post_type_object( $post_type ); + $post_type_labels = get_post_type_labels( $post_type_object ); + $post_label_singular = $post_type_labels->singular_name ?? $post_type; + $prefix = sprintf( '%s_', strtolower( $post_label_singular ) ); + + // Step 1: Check predefined field headings (highest priority) + foreach ( self::$field_headings as $field_key => $headings ) { + foreach ( $headings as $heading ) { + if ( $column_normalized === self::normalize_string_for_matching( $heading ) ) { + // For communication channels, add prefix if needed + if ( in_array( $field_key, [ 'contact_phone', 'contact_email' ] ) ) { + $base_field = str_replace( 'contact_', '', $field_key ); + $channels = $post_settings['channels'] ?? []; + if ( isset( $channels[$base_field] ) ) { + return $field_key; + } + } + + return $field_key; + } + } + } + + // Step 2: Direct field key match + if ( isset( $field_settings[$column_normalized] ) ) { + return $column_normalized; + } + + // Step 3: Channel field match (with and without prefix) + $channels = $post_settings['channels'] ?? []; + + // Try without prefix first + if ( isset( $channels[$column_normalized] ) ) { + return $prefix . $column_normalized; + } + + // Try with prefix removed + $column_without_prefix = str_replace( $prefix, '', $column_normalized ); + if ( isset( $channels[$column_without_prefix] ) ) { + return $prefix . $column_without_prefix; + } + + // Step 4: Field name/label matching + foreach ( $field_settings as $field_key => $field_config ) { + $field_name_normalized = self::normalize_string_for_matching( $field_config['name'] ?? '' ); + + // Exact match + if ( $column_normalized === $field_name_normalized ) { + return $field_key; + } + + // Field key normalized match + if ( $column_normalized === self::normalize_string_for_matching( $field_key ) ) { + return $field_key; + } + } + + // Step 5: Channel name/label matching + foreach ( $channels as $channel_key => $channel_config ) { + $channel_name_normalized = self::normalize_string_for_matching( $channel_config['label'] ?? $channel_config['name'] ?? '' ); + + if ( $column_normalized === $channel_name_normalized ) { + return $prefix . $channel_key; + } + } + + // Step 6: Extended field aliases (moved before partial matching to handle ambiguity) + $aliases = self::get_field_aliases( $post_type ); + $potential_matches = []; + + foreach ( $aliases as $field_key => $field_aliases ) { + foreach ( $field_aliases as $alias ) { + if ( $column_normalized === self::normalize_string_for_matching( $alias ) ) { + $potential_matches[] = $field_key; + } + } + } + + // If we have exactly one match, return it + if ( count( $potential_matches ) === 1 ) { + return $potential_matches[0]; + } + + // If we have multiple matches, it's ambiguous - don't auto-map + if ( count( $potential_matches ) > 1 ) { + return null; + } + + // Step 7: Very restrictive partial matches for field names + foreach ( $field_settings as $field_key => $field_config ) { + $field_name_normalized = self::normalize_string_for_matching( $field_config['name'] ?? '' ); + + if ( !empty( $field_name_normalized ) && !empty( $column_normalized ) ) { + $column_len = strlen( $column_normalized ); + $field_len = strlen( $field_name_normalized ); + + // Much more restrictive: only allow partial matches if: + // 1. Both strings are reasonably long (4+ chars) + // 2. The shorter string is at least 40% of the longer string + // 3. One completely contains the other at the start or end (not in the middle) + if ( $column_len >= 4 && $field_len >= 4 && // minimum meaningful length + min( $column_len, $field_len ) >= ( max( $column_len, $field_len ) * 0.4 ) ) { + + // Only match if one string completely contains the other at start or end + if ( strpos( $field_name_normalized, $column_normalized ) === 0 || // field starts with column + strpos( $column_normalized, $field_name_normalized ) === 0 || // column starts with field + strpos( $field_name_normalized, $column_normalized ) === ( $field_len - $column_len ) || // field ends with column + strpos( $column_normalized, $field_name_normalized ) === ( $column_len - $field_len ) ) { // column ends with field + return $field_key; + } + } + } + } + + // If we have no alias matches, return null + return null; + } + + /** + * Normalize string for matching (more aggressive than the existing normalize_string) + */ + private static function normalize_string_for_matching( $string ) { + return strtolower( trim( preg_replace( '/[^a-zA-Z0-9]/', '', $string ) ) ); + } + + /** + * Enhanced field aliases with more comprehensive mappings + */ + private static function get_field_aliases( $post_type = 'contacts' ) { + $base_aliases = [ + // Contact fields + 'name' => [ + 'title', + 'full_name', + 'contact_name', + 'fullname', + 'person_name', + 'display_name', + 'first_name', + 'last_name', + 'firstname', + 'lastname', + 'given_name', + 'family_name', + 'client_name' + ], + 'contact_phone' => [ + 'phone', + 'telephone', + 'mobile', + 'cell', + 'phone_number', + 'tel', + 'cellular', + 'mobile_phone', + 'home_phone', + 'work_phone', + 'primary_phone', + 'phone1', + 'phone2', + 'main_phone' + ], + 'contact_email' => [ + 'email', + 'e-mail', + 'email_address', + 'e_mail', + 'primary_email', + 'work_email', + 'home_email', + 'email1', + 'email2' + ], + 'contact_address' => [ + 'address', + 'street_address', + 'home_address', + 'work_address', + 'mailing_address', + 'physical_address', + 'location', + 'addr' + ], + 'assigned_to' => [ + 'assigned', + 'worker', + 'assigned_worker', + 'owner', + 'responsible', + 'coach', + 'leader', + 'assigned_user', + 'staff' + ], + 'overall_status' => [ + 'status', + 'contact_status', + 'stage', + 'phase' + ], + 'seeker_path' => [ + 'seeker', + 'spiritual_status', + 'faith_status', + 'seeker_status', + 'spiritual_stage', + 'faith_journey' + ], + 'baptism_date' => [ + 'baptized', + 'baptism', + 'baptized_date', + 'baptism_date', + 'date_baptized', + 'water_baptism' + ], + 'location_grid' => [ + 'location', + 'city', + 'country', + 'state', + 'province', + 'region' + ], + 'age' => [ + 'years_old', + 'years', + 'age_range', + 'age_group' + ], + 'gender' => [ + 'sex', + 'male_female', + 'gender_identity' + ], + 'reason_paused' => [ + 'paused_reason', + 'pause_reason', + 'why_paused' + ], + 'reason_unassignable' => [ + 'unassignable_reason', + 'unassign_reason' + ], + 'tags' => [ + 'tag', + 'labels', + 'categories', + 'keywords', + 'tags' + ], + 'notes' => [ + 'note', + 'comments', + 'comment', + 'cf_notes', + 'description', + 'remarks', + 'additional_info', + 'notes_field', + 'memo' + ], + 'sources' => [ + 'source', + 'lead_source', + 'referral_source', + 'how_found' + ], + 'milestones' => [ + 'milestone', + 'achievements', + 'progress' + ] + ]; + + // Add post-type specific aliases + if ( $post_type === 'groups' ) { + $base_aliases = array_merge( $base_aliases, [ + 'group_type' => [ 'type', 'category', 'kind' ], + 'group_status' => [ 'status', 'state', 'phase' ], + 'start_date' => [ 'started', 'began', 'launch_date' ], + 'end_date' => [ 'ended', 'finished', 'completion_date' ], + 'members' => [ 'participants', 'attendees', 'people' ] + ]); + } + + return $base_aliases; + } + + /** + * Get available options for a field + */ + public static function get_field_options( $field_key, $field_config ) { + if ( !in_array( $field_config['type'], [ 'key_select', 'multi_select' ] ) ) { + return []; + } + + return $field_config['default'] ?? []; + } + + /** + * Validate field mapping configuration + */ + public static function validate_mapping( $mapping_data, $post_type ) { + $errors = []; + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + // First, check if name field is mapped - this is always required + $name_field_mapped = false; + foreach ( $mapping_data as $column_index => $mapping ) { + if ( !empty( $mapping['field_key'] ) && $mapping['field_key'] === 'name' ) { + $name_field_mapped = true; + break; + } + } + + if ( !$name_field_mapped ) { + $errors[] = __( 'The name field is required and must be mapped to a CSV column before proceeding with the import.', 'disciple_tools' ); + } + + foreach ( $mapping_data as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + + // Check if field exists + if ( !isset( $field_settings[$field_key] ) ) { + $errors[] = sprintf( + __( 'Field "%1$s" does not exist for post type "%2$s"', 'disciple_tools' ), + $field_key, + $post_type + ); + continue; + } + + $field_config = $field_settings[$field_key]; + + // Validate field-specific configuration + if ( in_array( $field_config['type'], [ 'key_select', 'multi_select' ] ) ) { + if ( isset( $mapping['value_mapping'] ) ) { + foreach ( $mapping['value_mapping'] as $csv_value => $dt_value ) { + if ( !empty( $dt_value ) && !isset( $field_config['default'][$dt_value] ) ) { + $errors[] = sprintf( + __( 'Invalid option "%1$s" for field "%2$s"', 'disciple_tools' ), + $dt_value, + $field_config['name'] + ); + } + } + } + } + } + + return $errors; + } + + /** + * Generate unique values from CSV column for mapping + */ + public static function get_unique_column_values( $csv_data, $column_index ) { + $values = []; + + foreach ( $csv_data as $row ) { + if ( isset( $row[$column_index] ) && !empty( trim( $row[$column_index] ) ) ) { + $value = trim( $row[$column_index] ); + + // For multi-value fields, split by semicolon + $split_values = DT_CSV_Import_Utilities::split_multi_value( $value ); + foreach ( $split_values as $split_value ) { + if ( !empty( $split_value ) ) { + $values[$split_value] = $split_value; + } + } + } + } + + return array_values( $values ); + } + + /** + * Enhanced value mapping suggestions with fuzzy matching + */ + public static function suggest_value_mappings( $csv_values, $field_options ) { + $mappings = []; + + foreach ( $csv_values as $csv_value ) { + $csv_normalized = self::normalize_string_for_matching( $csv_value ); + $best_match = null; + $best_score = 0; + + foreach ( $field_options as $option_key => $option_config ) { + $option_label = $option_config['label'] ?? $option_key; + $option_normalized = self::normalize_string_for_matching( $option_label ); + $option_key_normalized = self::normalize_string_for_matching( $option_key ); + + // Exact match with label + if ( $csv_normalized === $option_normalized ) { + $best_match = $option_key; + $best_score = 100; + break; + } + + // Exact match with key + if ( $csv_normalized === $option_key_normalized ) { + $best_match = $option_key; + $best_score = 95; + break; + } + + // Partial match with label + if ( !empty( $option_normalized ) && + ( strpos( $option_normalized, $csv_normalized ) !== false || + strpos( $csv_normalized, $option_normalized ) !== false ) ) { + if ( $best_score < 80 ) { + $best_match = $option_key; + $best_score = 80; + } + } + + // Partial match with key + if ( !empty( $option_key_normalized ) && + ( strpos( $option_key_normalized, $csv_normalized ) !== false || + strpos( $csv_normalized, $option_key_normalized ) !== false ) ) { + if ( $best_score < 75 ) { + $best_match = $option_key; + $best_score = 75; + } + } + } + + $mappings[$csv_value] = [ + 'suggested_value' => $best_match + ]; + } + + return $mappings; + } + + /** + * Validate connection values and check if they exist + */ + public static function validate_connection_values( $csv_values, $connection_post_type ) { + $validation_results = []; + + foreach ( $csv_values as $csv_value ) { + $result = [ + 'value' => $csv_value, + 'exists' => false, + 'id' => null, + 'suggestions' => [] + ]; + + // Try to find by ID first + if ( is_numeric( $csv_value ) ) { + $post = DT_Posts::get_post( $connection_post_type, intval( $csv_value ), true, false ); + if ( !is_wp_error( $post ) ) { + $result['exists'] = true; + $result['id'] = intval( $csv_value ); + $validation_results[] = $result; + continue; + } + } + + // Try to find by title/name + $posts = DT_Posts::list_posts( $connection_post_type, [ + 'name' => $csv_value, + 'limit' => 5 + ]); + + if ( !is_wp_error( $posts ) && !empty( $posts['posts'] ) ) { + if ( count( $posts['posts'] ) === 1 ) { + $result['exists'] = true; + $result['id'] = $posts['posts'][0]['ID']; + } else { + // Multiple matches - provide suggestions + foreach ( $posts['posts'] as $post ) { + $result['suggestions'][] = [ + 'id' => $post['ID'], + 'name' => $post['title'] ?? $post['name'] ?? "Record #{$post['ID']}" + ]; + } + } + } + + $validation_results[] = $result; + } + + return $validation_results; + } + + /** + * Validate user values and check if they exist + */ + public static function validate_user_values( $csv_values ) { + $validation_results = []; + + foreach ( $csv_values as $csv_value ) { + $result = [ + 'value' => $csv_value, + 'exists' => false, + 'user_id' => null, + 'display_name' => null + ]; + + $user = null; + + // Try to find by ID + if ( is_numeric( $csv_value ) ) { + $user = get_user_by( 'id', intval( $csv_value ) ); + } + + // Try to find by email + if ( !$user && filter_var( $csv_value, FILTER_VALIDATE_EMAIL ) ) { + $user = get_user_by( 'email', $csv_value ); + } + + // Try to find by username + if ( !$user ) { + $user = get_user_by( 'login', $csv_value ); + } + + // Try to find by display name + if ( !$user ) { + $users = get_users( [ 'search' => $csv_value, 'search_columns' => [ 'display_name' ] ] ); + if ( !empty( $users ) && count( $users ) === 1 ) { + $user = $users[0]; + } + } + + if ( $user ) { + $result['exists'] = true; + $result['user_id'] = $user->ID; + $result['display_name'] = $user->display_name; + } + + $validation_results[] = $result; + } + + return $validation_results; + } +} diff --git a/dt-import/admin/dt-import-processor.php b/dt-import/admin/dt-import-processor.php new file mode 100644 index 000000000..bdf01b995 --- /dev/null +++ b/dt-import/admin/dt-import-processor.php @@ -0,0 +1,1138 @@ +1000 rows), use sampling to estimate counts + // For smaller datasets, analyze all rows for accurate counts + $total_rows = count( $csv_data ); + $use_sampling = $total_rows > 500; + + $total_processable_count = 0; + $total_error_count = 0; + + if ( $use_sampling ) { + // Sample up to 500 rows for estimation + $sample_size = min( 500, $total_rows ); + $sample_indices = array_rand( $csv_data, $sample_size ); + if ( !is_array( $sample_indices ) ) { + $sample_indices = [ $sample_indices ]; + } + + $sample_processable = 0; + $sample_errors = 0; + + foreach ( $sample_indices as $row_index ) { + $row = $csv_data[$row_index]; + $has_errors = false; + $has_valid_data = false; + + foreach ( $field_mappings as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + $raw_value = $row[$column_index] ?? ''; + + try { + $processed_value = self::process_field_value( $raw_value, $field_key, $mapping, $post_type, true ); + if ( $processed_value !== null && $processed_value !== '' ) { + $has_valid_data = true; + } + } catch ( Exception $e ) { + $has_errors = true; + break; + } + } + + if ( $has_errors ) { + $sample_errors++; + } else if ( $has_valid_data ) { + $sample_processable++; + } + } + + // Extrapolate from sample to total + $total_processable_count = round( ( $sample_processable / $sample_size ) * $total_rows ); + $total_error_count = round( ( $sample_errors / $sample_size ) * $total_rows ); + + } else { + // For smaller datasets, analyze all rows for accurate counts + foreach ( $csv_data as $row_index => $row ) { + $has_errors = false; + $has_valid_data = false; + + foreach ( $field_mappings as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + $raw_value = $row[$column_index] ?? ''; + + try { + $processed_value = self::process_field_value( $raw_value, $field_key, $mapping, $post_type, true ); + if ( $processed_value !== null && $processed_value !== '' ) { + $has_valid_data = true; + } + } catch ( Exception $e ) { + $has_errors = true; + break; + } + } + + if ( $has_errors ) { + $total_error_count++; + } else if ( $has_valid_data ) { + $total_processable_count++; + } + } + } + + // Now generate the limited preview data for display + $data_rows = array_slice( $csv_data, $offset, $limit ); + $preview_processed_count = 0; + $preview_skipped_count = 0; + + foreach ( $data_rows as $row_index => $row ) { + $processed_row = []; + $has_errors = false; + $row_errors = []; + $row_warnings = []; + $duplicate_check_fields = []; + + foreach ( $field_mappings as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + $raw_value = $row[$column_index] ?? ''; + $field_config = $field_settings[$field_key] ?? []; + + try { + $processed_value = self::process_field_value( $raw_value, $field_key, $mapping, $post_type, true ); + + // Check if this field has duplicate checking enabled + if ( isset( $mapping['duplicate_checking'] ) && $mapping['duplicate_checking'] === true && !empty( trim( $raw_value ) ) ) { + $duplicate_check_fields[] = $field_key; + } + + // Handle connection fields specially for preview + if ( $field_config['type'] === 'connection' && is_array( $processed_value ) ) { + $connection_display = []; + $has_new_connections = false; + + foreach ( $processed_value as $connection_info ) { + if ( is_array( $connection_info ) ) { + $display_name = $connection_info['name']; + if ( $connection_info['will_create'] ) { + $display_name .= ' (NEW)'; + $has_new_connections = true; + } + $connection_display[] = $display_name; + } else { + $connection_display[] = $connection_info; + } + } + + if ( $has_new_connections ) { + $connection_post_type_settings = DT_Posts::get_post_settings( $field_config['post_type'] ); + $post_type_label = $connection_post_type_settings['label_plural'] ?? $field_config['post_type']; + $row_warnings[] = sprintf( + 'New %s will be created for field "%s"', + $post_type_label, + $field_config['name'] + ); + } + + $formatted_value = implode( ', ', $connection_display ); + } else { + $formatted_value = self::format_value_for_preview( $processed_value, $field_key, $post_type ); + } + + $processed_row[$field_key] = [ + 'raw' => $raw_value, + 'processed' => $formatted_value, + 'valid' => true + ]; + } catch ( Exception $e ) { + $processed_row[$field_key] = [ + 'raw' => $raw_value, + 'processed' => null, + 'valid' => false, + 'error' => $e->getMessage() + ]; + $has_errors = true; + $row_errors[] = $e->getMessage(); + } + } + + // Note about duplicate checking for preview + // In preview mode, we just indicate that duplicate checking will happen + // The actual duplicate checking is handled by DT_Posts during import + $will_update_existing = false; + if ( !empty( $duplicate_check_fields ) ) { + $will_update_existing = false; // We can't easily predict this in preview + // Note: Duplicate checking is configured but we don't show warnings in preview + } + + $preview_data[] = [ + 'row_number' => $offset + $row_index + 2, // +2 for header and 0-based index + 'data' => $processed_row, + 'has_errors' => $has_errors, + 'errors' => $row_errors, + 'warnings' => $row_warnings, + 'will_update_existing' => $will_update_existing, + 'existing_post_id' => null // Not determined in preview + ]; + + if ( $has_errors ) { + $preview_skipped_count++; + } else { + $preview_processed_count++; + } + } + + return [ + 'rows' => $preview_data, + 'total_rows' => count( $csv_data ), + 'preview_count' => count( $preview_data ), + 'processable_count' => $total_processable_count, + 'error_count' => $total_error_count, + 'offset' => $offset, + 'limit' => $limit, + 'is_estimated' => $use_sampling, // Indicate if counts are estimated from sampling + 'sample_size' => $use_sampling ? $sample_size : null + ]; + } + + /** + * Process a single field value based on field type and mapping + */ + public static function process_field_value( $raw_value, $field_key, $mapping, $post_type, $preview_mode = false ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + if ( !isset( $field_settings[$field_key] ) ) { + throw new Exception( "Field {$field_key} not found" ); + } + + $field_config = $field_settings[$field_key]; + $field_type = $field_config['type']; + + // Handle empty values + if ( empty( trim( $raw_value ) ) ) { + return null; + } + + $result = null; + + switch ( $field_type ) { + case 'text': + case 'textarea': + $result = sanitize_text_field( trim( $raw_value ) ); + break; + + case 'number': + if ( !is_numeric( $raw_value ) ) { + throw new Exception( "Invalid number: {$raw_value}" ); + } + $result = floatval( $raw_value ); + break; + + case 'date': + $date_format = isset( $mapping['date_format'] ) ? $mapping['date_format'] : 'auto'; + $normalized_date = DT_CSV_Import_Utilities::normalize_date( $raw_value, $date_format ); + if ( empty( $normalized_date ) ) { + throw new Exception( "Invalid date format: {$raw_value}" ); + } + $result = $normalized_date; + break; + + case 'boolean': + $boolean_value = DT_CSV_Import_Utilities::normalize_boolean( $raw_value ); + if ( $boolean_value === null ) { + throw new Exception( "Invalid boolean value: {$raw_value}" ); + } + $result = $boolean_value; + break; + + case 'key_select': + $result = self::process_key_select_value( $raw_value, $mapping, $field_config ); + break; + + case 'multi_select': + $result = self::process_multi_select_value( $raw_value, $mapping, $field_config ); + break; + + case 'tags': + $result = self::process_tags_value( $raw_value ); + break; + + case 'communication_channel': + $result = self::process_communication_channel_value( $raw_value, $field_key ); + // Add geolocate flag for address fields if geocoding is enabled + if ( ( $field_key === 'contact_address' || strpos( $field_key, 'address' ) !== false ) && is_array( $result ) ) { + $geocode_service = $mapping['geocode_service'] ?? 'none'; + if ( $geocode_service !== 'none' ) { + foreach ( $result as &$address_entry ) { + $address_entry['geolocate'] = true; + } + } + } + break; + + case 'connection': + $result = self::process_connection_value( $raw_value, $field_config, $preview_mode ); + break; + + case 'user_select': + $result = self::process_user_select_value( $raw_value ); + break; + + case 'location': + $result = self::process_location_value( $raw_value, $preview_mode ); + break; + + case 'location_grid': + $result = self::process_location_grid_value( $raw_value ); + break; + + case 'location_meta': + $geocode_service = $mapping['geocode_service'] ?? 'none'; + $result = self::process_location_grid_meta_value( $raw_value, $geocode_service, $preview_mode ); + break; + + default: + $result = sanitize_text_field( trim( $raw_value ) ); + break; + } + + return $result; + } + + /** + * Process key_select field value + */ + private static function process_key_select_value( $raw_value, $mapping, $field_config ) { + $value_mapping = $mapping['value_mapping'] ?? []; + + if ( isset( $value_mapping[$raw_value] ) ) { + $mapped_value = $value_mapping[$raw_value]; + if ( isset( $field_config['default'][$mapped_value] ) ) { + return $mapped_value; + } + } + + // Try direct match + if ( isset( $field_config['default'][$raw_value] ) ) { + return $raw_value; + } + + throw new Exception( "Invalid option for key_select field: {$raw_value}" ); + } + + /** + * Process multi_select field value + */ + private static function process_multi_select_value( $raw_value, $mapping, $field_config ) { + $values = DT_CSV_Import_Utilities::split_multi_value( $raw_value ); + $processed_values = []; + $value_mapping = $mapping['value_mapping'] ?? []; + + foreach ( $values as $value ) { + $value = trim( $value ); + + if ( isset( $value_mapping[$value] ) ) { + $mapped_value = $value_mapping[$value]; + // Skip processing if mapped to empty string (represents "-- Skip --") + if ( !empty( $mapped_value ) && isset( $field_config['default'][$mapped_value] ) ) { + $processed_values[] = $mapped_value; + } + // If mapped to empty string, silently skip this value + } elseif ( isset( $field_config['default'][$value] ) ) { + $processed_values[] = $value; + } else { + // If value mapping is configured, skip unmapped invalid values silently + // If no value mapping is configured, throw exception for invalid values + if ( empty( $value_mapping ) ) { + throw new Exception( "Invalid option for multi_select field: {$value}" ); + } + // Otherwise silently skip invalid values when value mapping is present + } + } + + return $processed_values; + } + + /** + * Process tags field value + */ + private static function process_tags_value( $raw_value ) { + $tags = DT_CSV_Import_Utilities::split_multi_value( $raw_value ); + return array_map(function( $tag ) { + return sanitize_text_field( trim( $tag ) ); + }, $tags); + } + + /** + * Process communication channel value + */ + private static function process_communication_channel_value( $raw_value, $field_key ) { + $channels = DT_CSV_Import_Utilities::split_multi_value( $raw_value ); + $processed_channels = []; + + foreach ( $channels as $channel ) { + $channel = trim( $channel ); + + // No validation here - the API will handle all communication channel validation + + $processed_channels[] = [ + 'value' => $channel, + 'verified' => false + ]; + } + + return $processed_channels; + } + + /** + * Process connection field value + */ + private static function process_connection_value( $raw_value, $field_config, $preview_mode = false ) { + $connection_post_type = $field_config['post_type'] ?? ''; + if ( empty( $connection_post_type ) ) { + throw new Exception( 'Connection field missing post_type configuration' ); + } + + $connections = DT_CSV_Import_Utilities::split_multi_value( $raw_value ); + $processed_connections = []; + + foreach ( $connections as $connection_index => $connection ) { + $connection = trim( $connection ); + $connection_info = [ + 'raw_value' => $connection, + 'id' => null, + 'name' => null, + 'exists' => false, + 'will_create' => false + ]; + + // Try to find by ID first + if ( is_numeric( $connection ) ) { + $post = DT_Posts::get_post( $connection_post_type, intval( $connection ), true, false ); + + if ( !is_wp_error( $post ) ) { + $connection_info['id'] = intval( $connection ); + $connection_info['name'] = $post['title'] ?? $post['name'] ?? "Record #{$connection}"; + $connection_info['exists'] = true; + + if ( $preview_mode ) { + $processed_connections[] = $connection_info; + } else { + $processed_connections[] = intval( $connection ); + } + + continue; + } + } + + // Try to find by title/name - but check for multiple matches first + $posts = DT_Posts::list_posts($connection_post_type, [ + 'name' => $connection, + 'limit' => 2 // Get 2 to check for duplicates + ]); + + if ( !is_wp_error( $posts ) && !empty( $posts['posts'] ) ) { + // Check if multiple records found with same name + if ( count( $posts['posts'] ) > 1 ) { + // Skip this connection instead of failing the entire row + continue; + } + + $found_post = $posts['posts'][0]; + $connection_info['id'] = $found_post['ID']; + $connection_info['name'] = $found_post['title'] ?? $found_post['name'] ?? $connection; + $connection_info['exists'] = true; + + if ( $preview_mode ) { + $processed_connections[] = $connection_info; + } else { + $processed_connections[] = $found_post['ID']; + } + } else { + // Record not found - will need to create it + if ( $preview_mode ) { + $connection_info['name'] = $connection; + $connection_info['will_create'] = true; + $processed_connections[] = $connection_info; + } else { + // Create the record during actual import + $new_post = self::create_connection_record( $connection_post_type, $connection ); + + if ( !is_wp_error( $new_post ) ) { + $connection_info['id'] = $new_post['ID']; + $processed_connections[] = $new_post['ID']; + } else { + throw new Exception( "Failed to create connection record: {$connection} - " . $new_post->get_error_message() ); + } + } + } + } + + return $processed_connections; + } + + /** + * Create a new connection record + */ + private static function create_connection_record( $post_type, $name ) { + $post_data = []; + + // Determine the title field name based on post type + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + if ( isset( $field_settings['title'] ) ) { + $post_data['title'] = $name; + } elseif ( isset( $field_settings['name'] ) ) { + $post_data['name'] = $name; + } else { + throw new Exception( "No title field found for post type: {$post_type}" ); + } + + // Create the post + $result = DT_Posts::create_post( $post_type, $post_data, true, false ); + + return $result; + } + + /** + * Process user_select field value + */ + private static function process_user_select_value( $raw_value ) { + $user = null; + + // Try to find by ID + if ( is_numeric( $raw_value ) ) { + $user = get_user_by( 'id', intval( $raw_value ) ); + } + + // Try to find by email address + if ( !$user && filter_var( $raw_value, FILTER_VALIDATE_EMAIL ) ) { + $user = get_user_by( 'email', $raw_value ); + } + + // Try to find by username + if ( !$user ) { + $user = get_user_by( 'login', $raw_value ); + } + + // Try to find by display name + if ( !$user ) { + $user = get_user_by( 'display_name', $raw_value ); + } + + if ( !$user ) { + throw new Exception( "User not found: {$raw_value}" ); + } + + return $user->ID; + } + + /** + * Process location field value + */ + private static function process_location_value( $raw_value, $preview_mode = false ) { + $raw_value = trim( $raw_value ); + + // Check if it's a grid ID (single numeric value) + if ( is_numeric( $raw_value ) ) { + return intval( $raw_value ); + } + + // Check if it's lat,lng coordinates (decimal degrees) + if ( preg_match( '/^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/', $raw_value ) ) { + list($lat, $lng) = array_map( 'trim', explode( ',', $raw_value ) ); + + // Validate coordinate ranges + $lat = floatval( $lat ); + $lng = floatval( $lng ); + if ( $lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 ) { + throw new Exception( "Invalid coordinates: {$raw_value}. Latitude must be between -90 and 90, longitude between -180 and 180" ); + } + + return [ + 'lat' => $lat, + 'lng' => $lng + ]; + } + + // Check if it's DMS (degrees, minutes, seconds) coordinates + $dms_coords = DT_CSV_Import_Geocoding::parse_dms_coordinates( $raw_value ); + if ( $dms_coords !== null ) { + return [ + 'lat' => $dms_coords['lat'], + 'lng' => $dms_coords['lng'] + ]; + } + + // If none of the above formats match, throw an error + throw new Exception( "Location field requires numeric values (grid ID, decimal coordinates, or DMS coordinates), got: {$raw_value}" ); + } + + /** + * Process location_grid field value + */ + private static function process_location_grid_value( $raw_value ) { + return DT_CSV_Import_Field_Handlers::handle_location_grid_field( $raw_value, [] ); + } + + /** + * Process location_grid_meta field value using DT's native geocoding + */ + private static function process_location_grid_meta_value( $raw_value, $geocode_service, $preview_mode = false ) { + $import_settings = [ + 'geocode_service' => $geocode_service, + 'preview_mode' => $preview_mode + ]; + + $result = DT_CSV_Import_Field_Handlers::handle_location_grid_meta( $raw_value, 'location_grid_meta', '', [], $import_settings ); + + return $result; + } + + /** + * Format processed value for DT_Posts API according to field type + */ + public static function format_value_for_api( $processed_value, $field_key, $post_type ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + if ( !isset( $field_settings[$field_key] ) ) { + return $processed_value; + } + + $field_config = $field_settings[$field_key]; + $field_type = $field_config['type']; + + switch ( $field_type ) { + case 'multi_select': + case 'tags': + // Convert array to values format + if ( is_array( $processed_value ) ) { + return [ + 'values' => array_map(function( $value ) { + return [ 'value' => $value ]; + }, $processed_value) + ]; + } + break; + + case 'connection': + // Convert array of IDs to values format + if ( is_array( $processed_value ) ) { + return [ + 'values' => array_map(function( $value ) { + return [ 'value' => $value ]; + }, $processed_value) + ]; + } + break; + + case 'communication_channel': + // Already in correct format from processor + if ( is_array( $processed_value ) ) { + return [ + 'values' => $processed_value + ]; + } + break; + + case 'location': + // Format location data for DT_Posts API + if ( is_numeric( $processed_value ) ) { + // Grid ID + return [ + 'values' => [ + [ 'value' => $processed_value ] + ] + ]; + } elseif ( is_array( $processed_value ) ) { + // Lat/lng or address data + return [ + 'values' => [ + $processed_value + ] + ]; + } + break; + + case 'location_grid': + // Format location grid ID for DT_Posts API + if ( is_numeric( $processed_value ) ) { + return [ + 'values' => [ + [ 'value' => $processed_value ] + ] + ]; + } + break; + + case 'location_meta': + // DT's native location_grid_meta format - already properly formatted by handlers + if ( is_array( $processed_value ) && !empty( $processed_value ) ) { + // Check if it's already in DT's native format with 'values' key + if ( isset( $processed_value['values'] ) ) { + return $processed_value; + } + // Legacy format - convert to DT's native format + if ( isset( $processed_value[0] ) && is_array( $processed_value[0] ) ) { + // Multiple locations + return [ + 'values' => $processed_value, + 'force_values' => false + ]; + } else { + // Single location object + return [ + 'values' => [ $processed_value ], + 'force_values' => false + ]; + } + } + break; + + case 'user_select': + // Convert single user ID to proper format + if ( is_numeric( $processed_value ) ) { + return $processed_value; + } + break; + } + + // Handle special field keys that might not have standard types + if ( $field_key === 'contact_address' || strpos( $field_key, 'address' ) !== false ) { + // contact_address is a communication channel - format as such + if ( is_array( $processed_value ) && !empty( $processed_value ) ) { + return [ + 'values' => $processed_value + ]; + } + } + + if ( $field_key === 'location_grid_meta' ) { + // DT's native location_grid_meta format - already properly formatted by handlers + if ( is_array( $processed_value ) && !empty( $processed_value ) ) { + // Check if it's already in DT's native format with 'values' key + if ( isset( $processed_value['values'] ) ) { + return $processed_value; + } + } + } + + // For all other field types, return as-is + return $processed_value; + } + + /** + * Format processed value for preview display (human-readable) + */ + public static function format_value_for_preview( $processed_value, $field_key, $post_type ) { + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + if ( !isset( $field_settings[$field_key] ) ) { + return $processed_value; + } + + $field_config = $field_settings[$field_key]; + $field_type = $field_config['type']; + + // Handle null/empty values + if ( $processed_value === null || $processed_value === '' ) { + return ''; + } + + switch ( $field_type ) { + case 'multi_select': + case 'tags': + // Convert array to comma-separated display + if ( is_array( $processed_value ) ) { + return implode( ', ', $processed_value ); + } + break; + + case 'connection': + // Already handled in generate_preview for connection fields + return $processed_value; + + case 'communication_channel': + // Extract values from communication channel array + if ( is_array( $processed_value ) ) { + $values = array_map( function( $channel ) { + return $channel['value'] ?? $channel; + }, $processed_value ); + return implode( ', ', $values ); + } + break; + + case 'location': + case 'location_grid': + case 'location_meta': + // Handle location data for preview display + if ( is_numeric( $processed_value ) ) { + // Grid ID - try to get the location name + global $wpdb; + $location_name = $wpdb->get_var( $wpdb->prepare( + "SELECT name FROM $wpdb->dt_location_grid WHERE grid_id = %d", + intval( $processed_value ) + ) ); + return $location_name ?: "Grid ID: {$processed_value}"; + } elseif ( is_array( $processed_value ) ) { + // Check if this is an array of multiple locations (from semicolon-separated input) + if ( isset( $processed_value[0] ) && is_array( $processed_value[0] ) ) { + // Multiple locations - format each one + $location_displays = []; + foreach ( $processed_value as $location ) { + $location_displays[] = self::format_single_location_for_preview( $location ); + } + return implode( '; ', $location_displays ); + } else { + // Single location object + return self::format_single_location_for_preview( $processed_value ); + } + } + break; + + case 'key_select': + // Return the label for the selected key + if ( isset( $field_config['default'][$processed_value] ) ) { + return $field_config['default'][$processed_value]['label'] ?? $processed_value; + } + break; + + case 'user_select': + // Get user display name + if ( is_numeric( $processed_value ) ) { + $user = get_user_by( 'id', intval( $processed_value ) ); + return $user ? $user->display_name : "User ID: {$processed_value}"; + } + break; + + case 'date': + // Format date for display + if ( is_numeric( $processed_value ) ) { + return gmdate( 'Y-m-d', intval( $processed_value ) ); + } + break; + + case 'boolean': + // Convert boolean to Yes/No + return $processed_value ? 'Yes' : 'No'; + } + + // For all other field types, return as string + return is_array( $processed_value ) ? implode( ', ', $processed_value ) : (string) $processed_value; + } + + /** + * Format a single location object for preview display + */ + private static function format_single_location_for_preview( $location ) { + if ( !is_array( $location ) ) { + return (string) $location; + } + + // If this is preview mode data, just return the raw value as-is + if ( isset( $location['preview_mode'] ) && $location['preview_mode'] === true ) { + return $location['raw_value'] ?? $location['label'] ?? 'Unknown location'; + } + + // Handle coordinate or address arrays (for actual geocoded data) + if ( isset( $location['lat'] ) && isset( $location['lng'] ) ) { + return "Coordinates: {$location['lat']}, {$location['lng']}"; + } elseif ( isset( $location['address'] ) ) { + return $location['address']; + } elseif ( isset( $location['label'] ) ) { + return $location['label']; + } elseif ( isset( $location['name'] ) ) { + return $location['name']; + } elseif ( isset( $location['grid_id'] ) ) { + // Try to get the location name from grid ID + global $wpdb; + $location_name = $wpdb->get_var( $wpdb->prepare( + "SELECT name FROM $wpdb->dt_location_grid WHERE grid_id = %d", + intval( $location['grid_id'] ) + ) ); + return $location_name ?: "Grid ID: {$location['grid_id']}"; + } + + // Fallback: return first non-empty value + foreach ( $location as $key => $value ) { + if ( !empty( $value ) && !in_array( $key, [ 'source', 'geocoding_note', 'geocoding_error', 'preview_mode', 'raw_value' ] ) ) { + return $value; + } + } + + return 'Unknown location'; + } + + /** + * Execute the actual import + */ + public static function execute_import( $session_id ) { + global $wpdb; + + // Get session data from dt_reports table + $session = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM $wpdb->dt_reports WHERE id = %d AND type = 'import_session'", + $session_id + ), + ARRAY_A + ); + + if ( !$session ) { + return new WP_Error( 'session_not_found', 'Import session not found' ); + } + + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $field_mappings = $payload['field_mappings'] ?? []; + $import_options = $payload['import_options'] ?? []; + $post_type = $session['post_type']; + + // Load CSV data from file + $file_path = $payload['file_path'] ?? ''; + if ( empty( $file_path ) || !file_exists( $file_path ) ) { + return new WP_Error( 'csv_file_not_found', 'CSV file not found' ); + } + + $csv_data = DT_CSV_Import_Utilities::parse_csv_file( $file_path ); + if ( is_wp_error( $csv_data ) ) { + return new WP_Error( 'csv_parse_error', 'Failed to parse CSV file: ' . $csv_data->get_error_message() ); + } + + $headers = array_shift( $csv_data ); + $imported_count = 0; + $error_count = 0; + $errors = []; + $imported_records = []; // Store imported record data + $total_rows = count( $csv_data ); + + // Set initial progress to 1% to show processing has started + if ( $total_rows > 0 ) { + $initial_payload = array_merge($payload, [ + 'progress' => 1, + 'records_imported' => 0, + 'errors' => [], + 'imported_records' => [] + ]); + + $wpdb->update( + $wpdb->dt_reports, + [ + 'payload' => maybe_serialize( $initial_payload ), + 'subtype' => 'import_processing', + 'timestamp' => time() + ], + [ 'id' => $session_id ], + [ '%s', '%s', '%d' ], + [ '%d' ] + ); + } + + foreach ( $csv_data as $row_index => $row ) { + try { + $post_data = []; + $duplicate_check_fields = []; + foreach ( $field_mappings as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + $raw_value = $row[$column_index] ?? ''; + + if ( !empty( trim( $raw_value ) ) ) { + $processed_value = self::process_field_value( $raw_value, $field_key, $mapping, $post_type ); + + if ( $processed_value !== null ) { + // Special handling for location_grid_meta that returns contact_address data + if ( $field_key === 'location_grid_meta' && is_array( $processed_value ) && isset( $processed_value['contact_address'] ) ) { + // Extract contact_address data and add it to the contact_address field + $contact_address_data = $processed_value['contact_address']; + + // Format for API + $formatted_address_value = self::format_value_for_api( $contact_address_data, 'contact_address', $post_type ); + + // Add to post_data under contact_address field + $post_data['contact_address'] = $formatted_address_value; + + // Remove contact_address from processed_value to avoid duplication + unset( $processed_value['contact_address'] ); + + // Check if there's still location_grid_meta data to process + if ( isset( $processed_value['values'] ) && !empty( $processed_value['values'] ) ) { + // Process the remaining location_grid_meta data + $formatted_location_value = self::format_value_for_api( $processed_value, $field_key, $post_type ); + $post_data[$field_key] = $formatted_location_value; + } + } else { + // Normal field processing + $formatted_value = self::format_value_for_api( $processed_value, $field_key, $post_type ); + $post_data[$field_key] = $formatted_value; + } + + // Check if this field has duplicate checking enabled + if ( isset( $mapping['duplicate_checking'] ) && $mapping['duplicate_checking'] === true ) { + $duplicate_check_fields[] = $field_key; + } + } + } + } + + // Apply import options as default values (only if not already set from CSV) + if ( !empty( $import_options ) ) { + // Apply assigned_to if set and not already in post_data + if ( isset( $import_options['assigned_to'] ) && $import_options['assigned_to'] !== null && $import_options['assigned_to'] !== '' && !isset( $post_data['assigned_to'] ) ) { + $post_data['assigned_to'] = $import_options['assigned_to']; + } + + // Apply source if set and not already in post_data + if ( isset( $import_options['source'] ) && $import_options['source'] !== null && $import_options['source'] !== '' && !isset( $post_data['sources'] ) ) { + $post_data['sources'] = [ + 'values' => [ + [ 'value' => $import_options['source'] ] + ] + ]; + } + } + + // Prepare create_post arguments + $create_args = []; + if ( !empty( $duplicate_check_fields ) ) { + $create_args['check_for_duplicates'] = $duplicate_check_fields; + } + + // Create the post (DT_Posts will handle duplicate checking internally) + $result = DT_Posts::create_post( $post_type, $post_data, true, false, $create_args ); + + if ( is_wp_error( $result ) ) { + $error_count++; + $error_message = $result->get_error_message(); + + $errors[] = [ + 'row' => $row_index + 2, + 'message' => $error_message + ]; + } else { + $imported_count++; + + // Store imported record data (limit to first 100 records for performance) + if ( count( $imported_records ) < 100 ) { + $record_name = $result['title'] ?? $result['name'] ?? "Record #{$result['ID']}"; + $record_permalink = site_url() . '/' . $post_type . '/' . $result['ID']; + + $imported_records[] = [ + 'id' => $result['ID'], + 'name' => $record_name, + 'permalink' => $record_permalink + ]; + } + } + + // Update progress in payload + $progress = round( ( ( $row_index + 1 ) / count( $csv_data ) ) * 100 ); + + // Only update progress in database for certain conditions to reduce DB load + $should_update_progress = false; + if ( $total_rows <= 5 ) { + // For very small imports (≤5 rows), only update when complete + $should_update_progress = ( $row_index + 1 ) === $total_rows; + } else if ( $total_rows <= 10 ) { + // For small imports, update every other row or when complete + $should_update_progress = ( ( $row_index + 1 ) % 2 === 0 ) || ( $row_index + 1 ) === $total_rows; + } else { + // For larger imports, update every 5 rows or when complete + $should_update_progress = ( ( $row_index + 1 ) % 5 === 0 ) || ( $row_index + 1 ) === $total_rows; + } + + if ( $should_update_progress ) { + $updated_payload = array_merge($payload, [ + 'progress' => $progress, + 'records_imported' => $imported_count, + 'errors' => $errors, + 'imported_records' => $imported_records + ]); + + // Determine subtype based on progress + $subtype = $progress < 100 ? 'import_processing' : 'import_completed'; + + $wpdb->update( + $wpdb->dt_reports, + [ + 'payload' => maybe_serialize( $updated_payload ), + 'subtype' => $subtype, + 'value' => $imported_count, + 'timestamp' => time() + ], + [ 'id' => $session_id ], + [ '%s', '%s', '%d', '%d' ], + [ '%d' ] + ); + } + } catch ( Exception $e ) { + $error_count++; + $error_message = $e->getMessage(); + + $errors[] = [ + 'row' => $row_index + 2, + 'message' => $error_message + ]; + } + } + + // Final update + $final_subtype = $error_count > 0 ? 'import_completed_with_errors' : 'import_completed'; + $final_payload = array_merge($payload, [ + 'progress' => 100, + 'records_imported' => $imported_count, + 'error_count' => $error_count, + 'errors' => $errors, + 'imported_records' => $imported_records + ]); + + $wpdb->update( + $wpdb->dt_reports, + [ + 'payload' => maybe_serialize( $final_payload ), + 'subtype' => $final_subtype, + 'value' => $imported_count, + 'timestamp' => time() + ], + [ 'id' => $session_id ], + [ '%s', '%s', '%d', '%d' ], + [ '%d' ] + ); + + return [ + 'imported_count' => $imported_count, + 'error_count' => $error_count, + 'errors' => $errors, + 'imported_records' => $imported_records + ]; + } +} diff --git a/dt-import/admin/rest-endpoints.php b/dt-import/admin/rest-endpoints.php new file mode 100644 index 000000000..a82ee626e --- /dev/null +++ b/dt-import/admin/rest-endpoints.php @@ -0,0 +1,1133 @@ +namespace = $this->context . '/v' . intval( $this->version ); + add_action( 'rest_api_init', [ $this, 'add_api_routes' ] ); + } + + /** + * Add the API routes + */ + public function add_api_routes() { + $arg_schemas = [ + 'post_type' => [ + 'description' => 'The post type to import', + 'type' => 'string', + 'required' => true, + 'validate_callback' => [ $this, 'validate_args' ] + ], + 'session_id' => [ + 'description' => 'The import session ID', + 'type' => 'integer', + 'required' => true, + 'validate_callback' => [ $this, 'validate_args' ] + ] + ]; + + // Get field settings for post type + register_rest_route( + $this->namespace, '/(?P\w+)/field-settings', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_field_settings' ], + 'args' => [ + 'post_type' => $arg_schemas['post_type'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Upload CSV file + register_rest_route( + $this->namespace, '/upload', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'upload_csv' ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Analyze CSV and suggest field mappings + register_rest_route( + $this->namespace, '/(?P\d+)/analyze', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'analyze_csv' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Save field mappings + register_rest_route( + $this->namespace, '/(?P\d+)/mapping', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'save_mapping' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Preview import data + register_rest_route( + $this->namespace, '/(?P\d+)/preview', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'preview_import' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Execute import + register_rest_route( + $this->namespace, '/(?P\d+)/execute', [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'execute_import' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Get import status/progress + register_rest_route( + $this->namespace, '/(?P\d+)/status', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_import_status' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Delete import session + register_rest_route( + $this->namespace, '/(?P\d+)', [ + [ + 'methods' => 'DELETE', + 'callback' => [ $this, 'delete_session' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + + + // Get field options for key_select and multi_select fields + register_rest_route( + $this->namespace, '/(?P\w+)/field-options', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_field_options' ], + 'args' => [ + 'post_type' => $arg_schemas['post_type'], + 'field_key' => [ + 'description' => 'The field key to get options for', + 'type' => 'string', + 'required' => true, + 'validate_callback' => [ $this, 'validate_args' ] + ] + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + + // Get CSV column data for value mapping + register_rest_route( + $this->namespace, '/(?P\d+)/column-data', [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_column_data' ], + 'args' => [ + 'session_id' => $arg_schemas['session_id'], + 'column_index' => [ + 'description' => 'The column index to get data for', + 'type' => 'integer', + 'required' => true, + 'validate_callback' => [ $this, 'validate_args' ] + ] + ], + 'permission_callback' => [ $this, 'check_import_permissions' ], + ] + ] + ); + } + + /** + * Validate arguments + */ + public function validate_args( $value, $request, $param ) { + $attributes = $request->get_attributes(); + + if ( isset( $attributes['args'][$param] ) ) { + $argument = $attributes['args'][$param]; + + // Check to make sure our argument is a string. + if ( 'string' === $argument['type'] && !is_string( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( '%1$s is not of type %2$s', $param, 'string' ), [ 'status' => 400 ] ); + } + if ( 'integer' === $argument['type'] && !is_numeric( $value ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( '%1$s is not of type %2$s', $param, 'integer' ), [ 'status' => 400 ] ); + } + if ( $param === 'post_type' ) { + $post_types = DT_Posts::get_post_types(); + if ( !in_array( $value, $post_types ) ) { + return new WP_Error( 'rest_invalid_param', sprintf( '%1$s is not a valid post type', $value ), [ 'status' => 400 ] ); + } + } + } else { + return new WP_Error( 'rest_invalid_param', sprintf( '%s was not registered as a request argument.', $param ), [ 'status' => 400 ] ); + } + + return true; + } + + /** + * Check import permissions + */ + public function check_import_permissions() { + return current_user_can( 'manage_dt' ); + } + + + + /** + * Get field settings for a post type + */ + public function get_field_settings( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $post_type = $url_params['post_type']; + + if ( !DT_Posts::can_access( $post_type ) ) { + return new WP_Error( 'no_permission', 'No permission to access this post type', [ 'status' => 403 ] ); + } + + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + return [ + 'success' => true, + 'data' => $field_settings + ]; + } + + /** + * Upload CSV file + */ + public function upload_csv( WP_REST_Request $request ) { + // Check nonce for security + if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) { + return new WP_Error( 'invalid_nonce', 'Invalid security token', [ 'status' => 403 ] ); + } + + $post_type = $request->get_param( 'post_type' ); + + if ( empty( $post_type ) ) { + return new WP_Error( 'missing_post_type', 'Post type is required', [ 'status' => 400 ] ); + } + + if ( !isset( $_FILES['csv_file'] ) ) { + return new WP_Error( 'no_file', 'No file uploaded', [ 'status' => 400 ] ); + } + + // Sanitize file data carefully - preserve tmp_name for file operations + $file = $_FILES['csv_file']; //phpcs:ignore + $sanitized_file = [ + 'name' => sanitize_file_name( $file['name'] ), + 'type' => sanitize_text_field( $file['type'] ), + 'tmp_name' => $file['tmp_name'], // Don't sanitize - needed for file operations + 'error' => intval( $file['error'] ), + 'size' => intval( $file['size'] ) + ]; + + // Validate file + $validation_errors = DT_CSV_Import_Utilities::validate_file_upload( $sanitized_file ); + if ( !empty( $validation_errors ) ) { + return new WP_Error( 'file_validation_failed', implode( ', ', $validation_errors ), [ 'status' => 400 ] ); + } + + // Save file to temporary location + $file_path = DT_CSV_Import_Utilities::save_uploaded_file( $sanitized_file ); + if ( !$file_path ) { + return new WP_Error( 'file_save_failed', 'Failed to save uploaded file', [ 'status' => 500 ] ); + } + + // Parse CSV data + $csv_data = DT_CSV_Import_Utilities::parse_csv_file( $file_path ); + if ( is_wp_error( $csv_data ) ) { + return $csv_data; + } + + // Create import session + $session_id = $this->create_import_session( $post_type, $file_path, $csv_data ); + if ( is_wp_error( $session_id ) ) { + return $session_id; + } + + return [ + 'success' => true, + 'data' => [ + 'session_id' => $session_id, + 'file_name' => $sanitized_file['name'], + 'row_count' => count( $csv_data ) - 1, // Subtract header row + 'column_count' => count( $csv_data[0] ?? [] ), + 'headers' => $csv_data[0] ?? [] + ] + ]; + } + + /** + * Analyze CSV and suggest field mappings + */ + public function analyze_csv( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return $session; + } + + $csv_data = $session['csv_data']; + $post_type = $session['post_type']; + + // Generate field mapping suggestions + $mapping_suggestions = DT_CSV_Import_Mapping::analyze_csv_columns( $csv_data, $post_type ); + + // Update session with mapping suggestions + $this->update_import_session($session_id, [ + 'mapping_suggestions' => $mapping_suggestions, + 'status' => 'analyzed' + ]); + + // Include saved field mappings and do_not_import_columns if they exist + $response_data = [ + 'mapping_suggestions' => $mapping_suggestions + ]; + + if ( !empty( $session['field_mappings'] ) ) { + $response_data['saved_field_mappings'] = $session['field_mappings']; + } + + if ( !empty( $session['do_not_import_columns'] ) ) { + $response_data['saved_do_not_import_columns'] = $session['do_not_import_columns']; + } + + return [ + 'success' => true, + 'data' => $response_data + ]; + } + + /** + * Save field mappings + */ + public function save_mapping( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + $body_params = $request->get_json_params() ?? $request->get_body_params(); + + $mappings = $body_params['mappings'] ?? []; + $do_not_import_columns = $body_params['do_not_import_columns'] ?? []; + $import_options = $body_params['import_options'] ?? []; + + $session = $this->get_import_session( $session_id, false ); // Don't load CSV data, just need metadata + if ( is_wp_error( $session ) ) { + return $session; + } + + // Validate mappings + $validation_errors = DT_CSV_Import_Mapping::validate_mapping( $mappings, $session['post_type'] ); + if ( !empty( $validation_errors ) ) { + return new WP_Error( 'mapping_validation_failed', implode( ', ', $validation_errors ), [ 'status' => 400 ] ); + } + + // Update session with mappings and import options + $update_data = [ + 'field_mappings' => $mappings, + 'do_not_import_columns' => $do_not_import_columns, + 'status' => 'mapped' + ]; + + if ( !empty( $import_options ) ) { + $update_data['import_options'] = $import_options; + } + + $this->update_import_session( $session_id, $update_data ); + + return [ + 'success' => true, + 'data' => [ + 'message' => 'Field mappings saved successfully' + ] + ]; + } + + /** + * Preview import data + */ + public function preview_import( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + $get_params = $request->get_query_params(); + + $limit = intval( $get_params['limit'] ?? 10 ); + $offset = intval( $get_params['offset'] ?? 0 ); + + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return $session; + } + + if ( empty( $session['field_mappings'] ) ) { + return new WP_Error( 'no_mappings', 'Field mappings not configured', [ 'status' => 400 ] ); + } + + // Generate preview data + $preview_data = DT_CSV_Import_Processor::generate_preview( + $session['csv_data'], + $session['field_mappings'], + $session['post_type'], + $limit, + $offset + ); + + return [ + 'success' => true, + 'data' => $preview_data + ]; + } + + /** + * Execute import + */ + public function execute_import( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + + $session = $this->get_import_session( $session_id, false ); // Don't load CSV data, just need metadata + if ( is_wp_error( $session ) ) { + return $session; + } + + if ( empty( $session['field_mappings'] ) ) { + return new WP_Error( 'no_mappings', 'Field mappings not configured', [ 'status' => 400 ] ); + } + + // Update session status to processing and reset progress + $this->update_import_session($session_id, [ + 'status' => 'processing', + 'progress' => 0, + 'records_imported' => 0, + 'error_count' => 0, + 'errors' => [], + 'rows_processed' => 0 + ]); + + // Try to start import immediately + $immediate_result = $this->try_immediate_import( $session_id ); + + if ( $immediate_result['started'] ) { + return [ + 'success' => true, + 'data' => [ + 'message' => 'Import started immediately', + 'session_id' => $session_id, + 'immediate_start' => true + ] + ]; + } else { + // Fallback to scheduled execution + wp_schedule_single_event( time(), 'dt_csv_import_execute', [ $session_id ] ); + + return [ + 'success' => true, + 'data' => [ + 'message' => 'Import scheduled', + 'session_id' => $session_id, + 'immediate_start' => false, + 'reason' => $immediate_result['reason'] + ] + ]; + } + } + + /** + * Get import status and continue processing if needed + */ + public function get_import_status( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + + // Don't load CSV data for status checks - we only need metadata + $session = $this->get_import_session( $session_id, false ); + if ( is_wp_error( $session ) ) { + return $session; + } + + // If import is stuck in processing state, try to continue it + if ( $session['subtype'] === 'import_processing' ) { + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $current_progress = $payload['progress'] ?? 0; + $rows_processed = $payload['rows_processed'] ?? 0; + $total_rows = $session['row_count'] ?? 0; + + // If we're using chunked processing and have more rows to process + if ( $this->should_use_chunked_processing( $session_id ) && $rows_processed < $total_rows ) { + // Continue processing next chunk + $this->process_import_chunk( $session_id, $rows_processed, 25 ); + + // Re-fetch session after processing + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return $session; + } + } else { + // Try standard continuation logic + $this->try_continue_import( $session_id ); + + // Re-fetch session after potential processing + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return $session; + } + } + } + + // Map subtype back to status for backward compatibility + $status_map = [ + 'csv_upload' => 'uploaded', + 'field_analysis' => 'analyzed', + 'field_mapping' => 'mapped', + 'import_processing' => 'processing', + 'import_completed' => 'completed', + 'import_completed_with_errors' => 'completed_with_errors', + 'import_failed' => 'failed' + ]; + $status = $status_map[$session['subtype']] ?? $session['status'] ?? 'pending'; + + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $progress = $payload['progress'] ?? 0; + $records_imported = $payload['records_imported'] ?? 0; + $error_count = $payload['error_count'] ?? 0; + $errors = $payload['errors'] ?? []; + $imported_records = $payload['imported_records'] ?? []; + + return [ + 'success' => true, + 'data' => [ + 'status' => $status, + 'progress' => $progress, + 'records_imported' => $records_imported, + 'error_count' => $error_count, + 'errors' => $errors, + 'imported_records' => $imported_records + ] + ]; + } + + /** + * Delete import session + */ + public function delete_session( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + + $result = $this->delete_import_session( $session_id ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return [ + 'success' => true, + 'data' => [ + 'message' => 'Import session deleted' + ] + ]; + } + + /** + * Get field options for key_select and multi_select fields + */ + public function get_field_options( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $post_type = $url_params['post_type']; + $query_params = $request->get_query_params(); + $field_key = sanitize_text_field( $query_params['field_key'] ?? '' ); + + if ( !$field_key ) { + return new WP_Error( 'missing_field_key', 'Field key is required', [ 'status' => 400 ] ); + } + + $field_settings = DT_Posts::get_post_field_settings( $post_type ); + + if ( !isset( $field_settings[$field_key] ) ) { + return new WP_Error( 'field_not_found', 'Field not found', [ 'status' => 404 ] ); + } + + $field_config = $field_settings[$field_key]; + + if ( !in_array( $field_config['type'], [ 'key_select', 'multi_select' ] ) ) { + return new WP_Error( 'invalid_field_type', 'Field is not a select type', [ 'status' => 400 ] ); + } + + $options = $field_config['default'] ?? []; + + // Convert to label format if available + $formatted_options = []; + foreach ( $options as $key => $value ) { + $formatted_options[$key] = isset( $value['label'] ) ? $value['label'] : $value; + } + + return [ + 'success' => true, + 'data' => $formatted_options + ]; + } + + /** + * Get CSV column data for value mapping + */ + public function get_column_data( WP_REST_Request $request ) { + $url_params = $request->get_url_params(); + $session_id = intval( $url_params['session_id'] ); + $query_params = $request->get_query_params(); + $column_index = intval( $query_params['column_index'] ?? -1 ); + + if ( $column_index < 0 ) { + return new WP_Error( 'invalid_column_index', 'Valid column index is required', [ 'status' => 400 ] ); + } + + // First get session without CSV data to check if it exists + $session = $this->get_import_session( $session_id, false ); + if ( is_wp_error( $session ) ) { + return $session; + } + + // Now load CSV data from file + if ( empty( $session['file_path'] ) || !file_exists( $session['file_path'] ) ) { + return new WP_Error( 'no_csv_file', 'CSV file not found', [ 'status' => 404 ] ); + } + + $csv_data = DT_CSV_Import_Utilities::parse_csv_file( $session['file_path'] ); + if ( is_wp_error( $csv_data ) ) { + return new WP_Error( 'csv_parse_error', 'Failed to parse CSV file', [ 'status' => 500 ] ); + } + + // Skip the header row for unique value extraction + $data_rows = array_slice( $csv_data, 1 ); + + // Get unique values from the column (excluding header) + $unique_values = DT_CSV_Import_Mapping::get_unique_column_values( $data_rows, $column_index ); + + // Also get sample data for preview (also excluding header) + $sample_data = DT_CSV_Import_Utilities::get_sample_data( $data_rows, $column_index, 10 ); + + return [ + 'success' => true, + 'data' => [ + 'unique_values' => $unique_values, + 'sample_data' => $sample_data, + 'total_unique' => count( $unique_values ) + ] + ]; + } + + /** + * Create new field + */ + + + /** + * Create import session + */ + private function create_import_session( $post_type, $file_path, $csv_data ) { + $user_id = get_current_user_id(); + + // Store only metadata, not the entire CSV data + $session_data = [ + 'headers' => $csv_data[0] ?? [], + 'row_count' => count( $csv_data ) - 1, + 'file_path' => $file_path, + 'status' => 'uploaded' + ]; + + $report_id = dt_report_insert([ + 'user_id' => $user_id, + 'post_type' => $post_type, + 'type' => 'import_session', + 'subtype' => 'csv_upload', + 'payload' => $session_data, + 'value' => count( $csv_data ) - 1, // row count + 'label' => basename( $file_path ), + 'timestamp' => time() + ]); + + if ( !$report_id ) { + return new WP_Error( 'session_creation_failed', 'Failed to create import session', [ 'status' => 500 ] ); + } + + return $report_id; + } + + /** + * Get import session + */ + private function get_import_session( $session_id, $load_csv_data = true ) { + global $wpdb; + + $user_id = get_current_user_id(); + + $session = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM $wpdb->dt_reports WHERE id = %d AND user_id = %d AND type = 'import_session'", + $session_id, + $user_id + ), + ARRAY_A + ); + + if ( !$session ) { + return new WP_Error( 'session_not_found', 'Import session not found', [ 'status' => 404 ] ); + } + + // Decode payload data and merge with session + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $session = array_merge( $session, $payload ); + + // Load CSV data from file if requested and file exists + if ( $load_csv_data && !empty( $session['file_path'] ) && file_exists( $session['file_path'] ) ) { + $csv_data = DT_CSV_Import_Utilities::parse_csv_file( $session['file_path'] ); + if ( !is_wp_error( $csv_data ) ) { + $session['csv_data'] = $csv_data; + } + } + + return $session; + } + + /** + * Update import session + */ + private function update_import_session( $session_id, $data ) { + global $wpdb; + + $user_id = get_current_user_id(); + + // Get current session data - don't load CSV data since we don't need it for updates + $current_session = $this->get_import_session( $session_id, false ); + if ( is_wp_error( $current_session ) ) { + return $current_session; + } + + // Get current payload + $current_payload = maybe_unserialize( $current_session['payload'] ) ?: []; + + // Merge with new data + $updated_payload = array_merge( $current_payload, $data ); + + // Remove status from payload if it exists there + $status = $data['status'] ?? $current_payload['status'] ?? 'pending'; + unset( $updated_payload['status'] ); + + // Determine subtype based on status + $subtype_map = [ + 'uploaded' => 'csv_upload', + 'analyzed' => 'field_analysis', + 'mapped' => 'field_mapping', + 'processing' => 'import_processing', + 'completed' => 'import_completed', + 'completed_with_errors' => 'import_completed_with_errors', + 'failed' => 'import_failed' + ]; + $subtype = $subtype_map[$status] ?? 'csv_upload'; + + $result = $wpdb->update( + $wpdb->dt_reports, + [ + 'payload' => maybe_serialize( $updated_payload ), + 'subtype' => $subtype, + 'value' => $updated_payload['records_imported'] ?? $current_session['value'] ?? 0, + 'timestamp' => time() + ], + [ + 'id' => $session_id, + 'user_id' => $user_id, + 'type' => 'import_session' + ], + [ '%s', '%s', '%d', '%d' ], + [ '%d', '%d', '%s' ] + ); + + if ( $result === false ) { + return new WP_Error( 'session_update_failed', 'Failed to update import session', [ 'status' => 500 ] ); + } + + return true; + } + + /** + * Delete import session + */ + private function delete_import_session( $session_id ) { + global $wpdb; + + $user_id = get_current_user_id(); + + // Get session to clean up file - don't load CSV data, just need file path + $session = $this->get_import_session( $session_id, false ); + if ( !is_wp_error( $session ) && !empty( $session['file_path'] ) ) { + if ( file_exists( $session['file_path'] ) ) { + unlink( $session['file_path'] ); + } + } + + $result = $wpdb->delete( + $wpdb->dt_reports, + [ + 'id' => $session_id, + 'user_id' => $user_id, + 'type' => 'import_session' + ], + [ '%d', '%d', '%s' ] + ); + + if ( $result === false ) { + return new WP_Error( 'session_deletion_failed', 'Failed to delete import session', [ 'status' => 500 ] ); + } + + return true; + } + + /** + * Try to start import immediately + */ + private function try_immediate_import( $session_id ) { + // Check if we can safely start the import now + $can_start = $this->can_start_import_now( $session_id ); + + if ( !$can_start['allowed'] ) { + return [ + 'started' => false, + 'reason' => $can_start['reason'] + ]; + } + + $should_chunk = $this->should_use_chunked_processing( $session_id ); + + // Start the import in a way that won't timeout the request + if ( $should_chunk ) { + // For large imports, start first chunk only + $result = $this->process_import_chunk( $session_id, 0, 50 ); + + return [ + 'started' => $result !== false, + 'reason' => $result === false ? 'chunk_processing_failed' : 'chunked_processing_started' + ]; + } else { + // For small imports, process completely + $result = DT_CSV_Import_Processor::execute_import( $session_id ); + + return [ + 'started' => !is_wp_error( $result ), + 'reason' => is_wp_error( $result ) ? $result->get_error_message() : 'completed_immediately' + ]; + } + } + + /** + * Check if we can start import immediately + */ + private function can_start_import_now( $session_id ) { + // Check memory limits + $memory_limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) ); + $memory_usage = memory_get_usage( true ); + $available_memory = $memory_limit - $memory_usage; + + if ( $available_memory < ( 50 * 1024 * 1024 ) ) { // Less than 50MB available + return [ + 'allowed' => false, + 'reason' => 'insufficient_memory' + ]; + } + + // Check if import is already running + $session = $this->get_import_session( $session_id, false ); // Don't load CSV data, just need metadata + if ( is_wp_error( $session ) ) { + return [ + 'allowed' => false, + 'reason' => 'session_not_found' + ]; + } + + // Only consider it "already processing" if it was updated very recently (within 10 seconds) + // and has made actual progress + if ( $session['subtype'] === 'import_processing' ) { + $last_update = $session['timestamp'] ?? 0; + $time_since_update = time() - $last_update; + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $current_progress = $payload['progress'] ?? 0; + $records_imported = $payload['records_imported'] ?? 0; + + // Only block if recently updated AND has actual progress/imports + if ( $time_since_update < 10 && ( $current_progress > 0 || $records_imported > 0 ) ) { + return [ + 'allowed' => false, + 'reason' => 'already_processing' + ]; + } + } + + return [ + 'allowed' => true, + 'reason' => 'ready_to_start' + ]; + } + + /** + * Determine if we should use chunked processing + */ + private function should_use_chunked_processing( $session_id ) { + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return false; + } + + $row_count = $session['row_count'] ?? 0; + + // Use chunked processing for more than 100 records + return $row_count > 100; + } + + /** + * Process a chunk of import records + */ + private function process_import_chunk( $session_id, $start_row, $chunk_size ) { + $session = $this->get_import_session( $session_id, false ); // Don't auto-load CSV data + if ( is_wp_error( $session ) ) { + return false; + } + + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $field_mappings = $payload['field_mappings'] ?? []; + $post_type = $session['post_type']; + + // Load CSV data from file (no longer stored in payload) + $file_path = $payload['file_path'] ?? ''; + if ( empty( $file_path ) || !file_exists( $file_path ) ) { + return false; + } + + $csv_data = DT_CSV_Import_Utilities::parse_csv_file( $file_path ); + if ( is_wp_error( $csv_data ) ) { + return false; + } + + if ( empty( $field_mappings ) ) { + return false; + } + + // Remove headers from CSV data for processing + $headers = array_shift( $csv_data ); + $total_rows = count( $csv_data ); + + // Get current progress + $imported_count = $payload['records_imported'] ?? 0; + $error_count = $payload['error_count'] ?? 0; + $errors = $payload['errors'] ?? []; + $imported_records = $payload['imported_records'] ?? []; + + // Process chunk + $end_row = min( $start_row + $chunk_size, $total_rows ); + + for ( $i = $start_row; $i < $end_row; $i++ ) { + if ( !isset( $csv_data[$i] ) ) { + continue; + } + + $row = $csv_data[$i]; + + try { + $post_data = []; + + foreach ( $field_mappings as $column_index => $mapping ) { + if ( empty( $mapping['field_key'] ) || $mapping['field_key'] === 'skip' ) { + continue; + } + + $field_key = $mapping['field_key']; + $raw_value = $row[$column_index] ?? ''; + + if ( !empty( trim( $raw_value ) ) ) { + $processed_value = DT_CSV_Import_Processor::process_field_value( $raw_value, $field_key, $mapping, $post_type ); + if ( $processed_value !== null ) { + // Format value according to field type for DT_Posts API + $post_data[$field_key] = DT_CSV_Import_Processor::format_value_for_api( $processed_value, $field_key, $post_type ); + } + } + } + + // Create the post + $result = DT_Posts::create_post( $post_type, $post_data, true, false ); + + if ( is_wp_error( $result ) ) { + $error_count++; + $errors[] = [ + 'row' => $i + 2, // +2 for header and 0-based index + 'message' => $result->get_error_message() + ]; + } else { + $imported_count++; + + // Store imported record data (limit to first 100 records for performance) + if ( count( $imported_records ) < 100 ) { + $record_name = $result['title'] ?? $result['name'] ?? "Record #{$result['ID']}"; + $record_permalink = site_url() . '/' . $post_type . '/' . $result['ID']; + + $imported_records[] = [ + 'id' => $result['ID'], + 'name' => $record_name, + 'permalink' => $record_permalink + ]; + } + } + } catch ( Exception $e ) { + $error_count++; + $errors[] = [ + 'row' => $i + 2, + 'message' => $e->getMessage() + ]; + } + } + + // Update progress + $progress = round( ( $end_row / $total_rows ) * 100 ); + $is_complete = $end_row >= $total_rows; + + $updated_payload = array_merge($payload, [ + 'progress' => $progress, + 'records_imported' => $imported_count, + 'error_count' => $error_count, + 'errors' => $errors, + 'rows_processed' => $end_row, + 'imported_records' => $imported_records + ]); + + // Determine status + if ( $is_complete ) { + $subtype = $error_count > 0 ? 'import_completed_with_errors' : 'import_completed'; + } else { + $subtype = 'import_processing'; + } + + // Update session + global $wpdb; + $wpdb->update( + $wpdb->dt_reports, + [ + 'payload' => maybe_serialize( $updated_payload ), + 'subtype' => $subtype, + 'value' => $imported_count, + 'timestamp' => time() + ], + [ 'id' => $session_id ], + [ '%s', '%s', '%d', '%d' ], + [ '%d' ] + ); + + return $end_row; + } + + /** + * Try to continue a stalled import + */ + private function try_continue_import( $session_id ) { + // Check if import has been stalled too long or needs continuation + $session = $this->get_import_session( $session_id ); + if ( is_wp_error( $session ) ) { + return false; + } + + $payload = maybe_unserialize( $session['payload'] ) ?: []; + $last_update = $session['timestamp'] ?? 0; + $current_progress = $payload['progress'] ?? 0; + + // If no progress update in last 30 seconds and not complete, try to continue + if ( ( time() - $last_update ) > 30 && $current_progress < 100 ) { + if ( $this->should_use_chunked_processing( $session_id ) ) { + // Continue with chunked processing from where we left off + $rows_processed = $payload['rows_processed'] ?? 0; + $this->process_import_chunk( $session_id, $rows_processed, 25 ); + } else { + // Restart the full import + DT_CSV_Import_Processor::execute_import( $session_id ); + } + return true; + } + + return false; + } +} + +DT_CSV_Import_Ajax::instance(); diff --git a/dt-import/assets/README.md b/dt-import/assets/README.md new file mode 100644 index 000000000..1dff6f567 --- /dev/null +++ b/dt-import/assets/README.md @@ -0,0 +1,138 @@ +# DT Import Frontend Assets + +This directory contains the frontend assets for the DT Import feature. + +## Files Overview + +### JavaScript Files + +#### `js/dt-import.js` +- **Main application logic** for the step-by-step import workflow +- Handles REST API communication with the backend +- Manages the 4-step import process: + 1. Post type selection + 2. CSV file upload with drag & drop + 3. Field mapping with intelligent suggestions + 4. Preview and import execution +- Features: + - Real-time progress tracking + - File validation and processing + - Error handling and user feedback + - Session-based workflow management + +#### `js/dt-import-modals.js` +- **Modal dialog handling** for advanced features +- Custom field creation modal with field type support +- Value mapping modal for dropdown/multi-select fields +- Features: + - Dynamic form generation + - Field option management + - Value mapping interface + - Modal state management + +### CSS Files + +#### `css/dt-import.css` +- **Complete styling** for the import interface +- WordPress admin design consistency +- Responsive design for mobile compatibility +- Features: + - Step-by-step progress indicator + - Card-based layout for field mapping + - Professional file upload interface + - Data preview tables + - Modal dialog styling + - Loading states and animations + +## Technical Implementation + +### Architecture +- **Vanilla JavaScript ES6+** with jQuery for DOM manipulation +- **Class-based structure** for maintainability +- **Modular design** with separate modal handling +- **REST API integration** using native fetch() +- **WordPress admin styling** compatibility + +### Key Features + +#### File Upload +- Drag and drop support +- File type validation (CSV only) +- Size limit enforcement (10MB) +- Progress feedback during upload + +#### Field Mapping +- Intelligent column detection with confidence scoring +- Support for all DT field types +- Real-time field creation capabilities +- Value mapping for dropdown fields +- Sample data preview + +#### Import Processing +- Progress polling for long-running imports +- Real-time status updates +- Comprehensive error reporting +- Result statistics and summaries + +#### User Experience +- Step-by-step guided workflow +- Visual progress indicators +- Contextual help and guidelines +- Responsive design for all devices +- Accessibility considerations + +### Integration Points + +#### WordPress Admin +- Integrates with DT Settings menu structure +- Uses WordPress admin CSS patterns +- Follows WordPress JavaScript standards +- Proper nonce handling for security + +#### DT Framework +- Uses DT Web Components where available +- Integrates with DT field system +- Respects DT permissions model +- Follows DT REST API patterns + +#### Browser Support +- Modern browsers (Chrome 80+, Firefox 75+, Safari 13+) +- Progressive enhancement approach +- Graceful degradation for older browsers + +### Performance Considerations + +#### Optimization +- Efficient DOM manipulation +- Minimal dependencies +- Lazy loading of complex components +- Chunked processing for large files + +#### Memory Management +- Proper event listener cleanup +- Session-based data storage +- Automatic file cleanup after processing + +## Usage + +The frontend assets are automatically enqueued when accessing the DT Import tab in the WordPress admin. The JavaScript files initialize automatically and provide the complete import workflow interface. + +### Dependencies +- jQuery (included with WordPress) +- DT Web Components (optional, loaded if available) +- WordPress REST API +- Modern browser with ES6 support + +### Configuration +Configuration is provided via `wp_localize_script()` in the admin tab file: +- REST API endpoints +- Security nonces +- Available post types +- Translations +- File size limits + +## Customization + +The CSS can be customized to match specific branding requirements while maintaining the core functionality. The JavaScript is modular and can be extended for additional features. + +For advanced customization, refer to the main implementation guide and project specification documents. \ No newline at end of file diff --git a/dt-import/assets/css/dt-import.css b/dt-import/assets/css/dt-import.css new file mode 100644 index 000000000..e7053bb83 --- /dev/null +++ b/dt-import/assets/css/dt-import.css @@ -0,0 +1,1985 @@ +/* DT Import Styles */ + +/* Main Container */ +.dt-import-container { + margin: 0; + padding: 20px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); +} + +/* CSV Examples Sidebar */ +#postbox-container-1 .postbox { + margin-bottom: 20px; +} + +#postbox-container-1 .postbox .inside { + padding: 12px; +} + +#postbox-container-1 .postbox ul { + list-style: none; + margin: 0 0 15px 0; + padding: 0; +} + +#postbox-container-1 .postbox ul li { + margin-bottom: 10px; +} + +#postbox-container-1 .postbox .button { + width: 100%; + text-align: left; + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: flex-start; + text-decoration: none; + box-sizing: border-box; +} + +#postbox-container-1 .postbox .button .dashicons { + margin-right: 8px; + font-size: 16px; + width: 16px; + height: 16px; +} + +#postbox-container-1 .postbox .description { + margin: 5px 0 0 0; + font-size: 11px; + color: #666; + line-height: 1.4; +} + +#postbox-container-1 .postbox h4 { + margin: 15px 0 10px 0; + font-size: 13px; + font-weight: 600; + color: #23282d; +} + +#postbox-container-1 .postbox h4:first-child { + margin-top: 0; +} + +#postbox-container-1 .postbox .ul-disc { + list-style: disc; + margin-left: 20px; +} + +#postbox-container-1 .postbox .ul-disc li { + margin-bottom: 5px; + font-size: 12px; + line-height: 1.4; +} + +.dt-import-container h1 { + margin: 0 0 20px 0; + font-size: 23px; + font-weight: 400; + line-height: 1.3; + color: #23282d; +} + +/* Progress Indicator */ +.dt-import-progress { + margin: 20px 0 30px 0; + padding: 20px; + background: #f9f9f9; + border: 1px solid #e1e1e1; + border-radius: 4px; +} + +.dt-import-steps { + display: flex; + list-style: none; + margin: 0; + padding: 0; + justify-content: space-between; + position: relative; +} + +.dt-import-steps::before { + content: ''; + position: absolute; + top: 20px; + left: calc(12.5% + 20px); + right: calc(12.5% + 20px); + height: 2px; + background: #ddd; + z-index: 1; +} + +.dt-import-steps .step { + flex: 1; + text-align: center; + position: relative; + z-index: 2; +} + +.dt-import-steps .step-number { + display: inline-block; + width: 40px; + height: 40px; + line-height: 40px; + background: #ddd; + color: #666; + border-radius: 50%; + font-weight: 600; + margin-bottom: 8px; + transition: all 0.3s ease; +} + +.dt-import-steps .step-name { + display: block; + font-size: 14px; + color: #666; + font-weight: 500; +} + +.dt-import-steps .step.active .step-number { + background: #0073aa; + color: #fff; +} + +.dt-import-steps .step.active .step-name { + color: #0073aa; +} + +.dt-import-steps .step.completed .step-number { + background: #46b450; + color: #fff; +} + +.dt-import-steps .step.completed .step-name { + color: #46b450; +} + +/* Step Content */ +.dt-import-step-content { + margin: 20px 0; +} + +.dt-import-step-content h2, +.dt-import-container h2 { + margin: 0 0 10px 0 !important; + font-size: 20px !important; + font-weight: 600 !important; + color: #23282d !important; + line-height: 1.3 !important; + text-transform: none !important; + letter-spacing: normal !important; + text-decoration: none !important; + border: none !important; + background: none !important; + padding: 0 !important; + box-shadow: none !important; +} + +.dt-import-step-content > p { + margin: 0 0 20px 0; + color: #646970; + font-size: 14px; +} + +/* Post Type Selection */ +.post-type-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin: 20px 0; +} + +.post-type-card { + border: 2px solid #ddd; + border-radius: 6px; + padding: 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: #fff; +} + +.post-type-card:hover { + border-color: #0073aa; + box-shadow: 0 2px 8px rgba(0, 115, 170, 0.1); +} + +.post-type-card.selected { + border-color: #0073aa; + background: #f0f8ff; + box-shadow: 0 2px 8px rgba(0, 115, 170, 0.2); +} + +.post-type-icon { + margin-bottom: 15px; +} + +.post-type-icon i { + font-size: 48px; + color: #0073aa; +} + +.post-type-card h3 { + margin: 0 0 10px 0; + font-size: 18px; + font-weight: 600; + color: #23282d; +} + +.post-type-card p { + margin: 0 0 15px 0; + color: #646970; + font-size: 14px; + line-height: 1.4; +} + +.post-type-meta { + padding-top: 10px; + border-top: 1px solid #eee; +} + +.post-type-singular { + font-size: 12px; + color: #999; + font-style: italic; +} + +/* File Upload */ +.file-upload-section { + margin: 20px 0; +} + +.file-upload-area { + border: 2px dashed #ddd; + border-radius: 6px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + background: #fafafa; +} + +.file-upload-area:hover, +.file-upload-area.drag-over { + border-color: #0073aa; + background: #f0f8ff; +} + +.upload-icon i { + font-size: 48px; + color: #0073aa; + margin-bottom: 15px; +} + +.upload-text h3 { + margin: 0 0 10px 0; + font-size: 18px; + color: #23282d; +} + +.upload-text p { + margin: 0; + color: #646970; + font-size: 14px; +} + +.file-info { + padding: 20px; + border: 1px solid #ddd; + border-radius: 4px; + background: #f9f9f9; + display: flex; + justify-content: space-between; + align-items: center; +} + +.file-details h4 { + margin: 0 0 5px 0; + font-size: 16px; + color: #23282d; +} + +.file-details p { + margin: 0; + font-size: 13px; + color: #646970; +} + +.upload-options { + margin-top: 30px; + padding: 20px; + background: #f9f9f9; + border: 1px solid #e1e1e1; + border-radius: 4px; +} + +.upload-options h3 { + margin: 0 0 15px 0; + font-size: 16px; + color: #23282d; +} + +/* Field Mapping */ +.mapping-container { + margin: 20px 0; +} + +.mapping-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + margin: 20px 0; +} + +.column-mapping-card { + border: 1px solid #ddd; + border-radius: 6px; + padding: 20px; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.column-header { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #eee; +} + +.column-header h4 { + margin: 0 0 10px 0; + font-size: 16px; + color: #23282d; + font-weight: 600; +} + +.confidence-indicator { + font-size: 12px; + font-weight: 500; + color: #46b450; + background: #e8f5e8; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; +} + +.confidence-indicator.no-match { + color: #dc3232; + background: #ffebee; +} + +.sample-data { + margin-bottom: 15px; +} + +.sample-data strong { + display: block; + margin-bottom: 8px; + font-size: 13px; + color: #646970; +} + +.sample-data ul { + margin: 0; + padding-left: 20px; + list-style-type: disc; +} + +.sample-data li { + margin: 0 0 4px 0; + font-size: 13px; + color: #666; + word-wrap: break-word; +} + +.mapping-controls label { + display: block; + margin-bottom: 6px; + font-weight: 500; + color: #23282d; + font-size: 13px; +} + +.field-mapping-select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 13px; + margin-bottom: 10px; +} + +.field-specific-options { + margin-top: 15px; + padding: 15px; + background: #f9f9f9; + border: 1px solid #e1e1e1; + border-radius: 4px; +} + +.value-mapping-section h5 { + margin: 0 0 8px 0; + font-size: 14px; + color: #23282d; +} + +.value-mapping-section p { + margin: 0 0 10px 0; + font-size: 13px; + color: #646970; +} + +.mapping-summary { + margin-top: 30px; + padding: 20px; + background: #f0f8ff; + border: 1px solid #c3d9ff; + border-radius: 4px; +} + +.mapping-summary h3 { + margin: 0 0 10px 0; + font-size: 16px; + color: #0073aa; +} + +/* Preview Table */ +.preview-stats { + display: flex; + gap: 20px; + margin: 20px 0; +} + +.stat-card { + flex: 1; + padding: 20px; + text-align: center; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; +} + +.stat-card h3 { + margin: 0 0 8px 0; + font-size: 32px; + font-weight: 600; + color: #0073aa; +} + +.stat-card.error-card h3 { + color: #dc3232; +} + +.stat-card.warning-card h3 { + color: #f56e28; +} + +.stat-card.success-card h3 { + color: #46b450; +} + +.stat-card p { + margin: 0; + font-size: 12px; + color: #666; +} + +.preview-table-container { + margin: 20px 0; + max-height: 400px; + overflow: auto; + border: 1px solid #ddd; + border-radius: 4px; +} + +.preview-table { + width: 100%; + border-collapse: collapse; + background: #fff; +} + +.preview-table th { + background: #f1f1f1; + padding: 12px 8px; + text-align: left; + font-weight: 600; + border-bottom: 1px solid #ddd; + font-size: 13px; + color: #23282d; + position: sticky; + top: 0; +} + +.preview-table td { + padding: 10px 8px; + border-bottom: 1px solid #eee; + font-size: 13px; + color: #646970; + max-width: 200px; + word-wrap: break-word; +} + +.preview-table tr:hover { + background: #f9f9f9; +} + +.preview-table .error-row { + background-color: #ffebee !important; +} + +.preview-table .warning-row { + background-color: #fff8e1 !important; +} + +.preview-table .error-cell { + background-color: #ffcdd2; + border-color: #e57373; +} + +.preview-table .warnings-row { + background-color: #fff8e1; +} + +.preview-table .warnings-row td { + padding: 8px 12px; + border-top: none; +} + +.row-warnings { + font-size: 13px; + color: #85651a; +} + +.row-warnings strong { + color: #f56e28; + font-weight: 600; +} + +.row-warnings strong i { + margin-right: 5px; +} + +.row-warnings ul { + margin: 5px 0 0 20px; + padding: 0; +} + +.row-warnings li { + margin: 2px 0; +} + +/* Navigation */ +.dt-import-navigation { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #ddd; +} + +.dt-import-navigation .execute-import-btn { + margin-left: auto; +} + +.dt-import-actions, +.import-actions { + display: flex; + gap: 10px; +} + +/* Processing Overlay */ +.processing-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 999999; +} + +.processing-message { + text-align: center; + padding: 40px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.processing-message .dt-spinner { + width: 32px; + height: 32px; + margin: 0 auto 20px auto; + border: 3px solid #e1e1e1; + border-top: 3px solid #0073aa; + border-radius: 50%; + animation: dt-spin 1s linear infinite; +} + +@keyframes dt-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.processing-message p { + margin: 0; + font-size: 16px; + color: #23282d; + font-weight: 500; +} + +/* Error/Success Messages */ +.dt-import-errors { + margin: 20px 0; +} + +.dt-import-errors .notice { + margin: 0; + padding: 12px; +} + +/* Modal Styles */ +.dt-import-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + background: #fff; + border-radius: 4px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + position: relative; + z-index: 1; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + padding: 20px 20px 0 20px; + border-bottom: 1px solid #ddd; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close:hover { + color: #333; +} + +.modal-body { + padding: 20px; +} + +.modal-footer { + padding: 15px 20px; + border-top: 1px solid #ddd; + text-align: right; + background: #f9f9f9; + border-radius: 0 0 4px 4px; +} + +/* Import Results */ +.import-results { + text-align: center; + padding: 20px; +} + +.import-results h2 { + margin: 0 0 20px 0; + font-size: 24px; + color: #46b450; +} + +.results-stats { + display: flex; + gap: 20px; + justify-content: center; + margin: 30px 0; +} + +.error-details { + margin: 30px 0; + text-align: left; + max-height: 200px; + overflow-y: auto; + background: #f9f9f9; + padding: 20px; + border-radius: 4px; + border: 1px solid #ddd; +} + +.error-details h3 { + margin: 0 0 15px 0; + font-size: 16px; + color: #dc3232; +} + +.error-details ul { + margin: 0; + padding-left: 20px; +} + +.error-details li { + margin-bottom: 8px; + font-size: 13px; + color: #646970; +} + +.results-actions { + margin-top: 20px; + text-align: center; +} + +/* Imported Records List */ +.imported-records-list { + margin: 25px 0; + padding: 20px; + background: #f9f9f9; + border: 1px solid #e1e1e1; + border-radius: 6px; +} + +.imported-records-list h3 { + margin: 0 0 15px 0; + font-size: 16px; + font-weight: 600; + color: #23282d; +} + +.records-container { + max-height: 300px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; +} + +.records-list { + margin: 0; + padding: 0; + list-style: none; +} + +.records-list li { + border-bottom: 1px solid #f1f1f1; + transition: background-color 0.2s ease; +} + +.records-list li:last-child { + border-bottom: none; +} + +.records-list li:hover { + background-color: #f8f9fa; +} + +.records-list a { + display: block; + padding: 12px 16px; + color: #0073aa; + text-decoration: none; + font-weight: 500; + font-size: 14px; + line-height: 1.4; + transition: color 0.2s ease; +} + +.records-list a:hover, +.records-list a:focus { + color: #005a87; + text-decoration: none; + outline: 2px solid #0073aa; + outline-offset: -2px; +} + +.records-note { + margin: 10px 0 0 0; + padding: 10px 15px; + font-size: 13px; + color: #646970; + background: #f0f0f1; + border-radius: 4px; +} + +.records-note em { + font-style: italic; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dt-import-container { + padding: 15px; + } + + .dt-import-progress { + padding: 15px; + margin: 15px 0 20px 0; + } + + .dt-import-steps { + flex-direction: column; + gap: 12px; + } + + .dt-import-steps::before { + content: ''; + position: absolute; + top: 20px; + bottom: 20px; + left: 20px; + width: 2px; + height: auto; + background: linear-gradient(to bottom, #ddd 0%, #ddd 100%); + z-index: 1; + } + + .dt-import-steps .step { + display: flex; + align-items: center; + text-align: left; + gap: 15px; + padding: 10px 0; + position: relative; + z-index: 2; + } + + .dt-import-steps .step-number { + width: 36px; + height: 36px; + line-height: 34px; + margin-bottom: 0; + flex-shrink: 0; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + } + + .dt-import-steps .step-name { + display: inline; + margin: 0; + font-size: 15px; + font-weight: 600; + flex: 1; + } + + /* Add completion indicator line for mobile */ + .dt-import-steps .step.completed::after { + content: ''; + position: absolute; + left: 20px; + top: 46px; + width: 2px; + height: calc(100% + 12px); + background: #46b450; + z-index: 1; + } + + .dt-import-steps .step.active::after { + content: ''; + position: absolute; + left: 20px; + top: 46px; + width: 2px; + height: calc(100% + 12px); + background: #0073aa; + z-index: 1; + } + + /* Hide the line for the last step */ + .dt-import-steps .step:last-child::after { + display: none; + } + + .post-type-grid { + grid-template-columns: 1fr; + } + + .mapping-columns { + grid-template-columns: 1fr; + } + + .preview-stats { + flex-direction: column; + } + + .results-stats { + flex-direction: column; + } + + .modal-content { + padding: 20px; + margin: 20px; + } + + .dt-import-navigation { + flex-direction: column; + gap: 10px; + } + + .dt-import-navigation .execute-import-btn { + margin-left: 0; + order: 2; + width: 100%; + } + + .imported-records-list { + margin: 20px 0; + padding: 15px; + } + + .records-container { + max-height: 250px; + } + + .records-list a { + padding: 10px 12px; + font-size: 13px; + } + + .records-note { + font-size: 12px; + padding: 8px 12px; + } +} + +/* Additional mobile optimizations for smaller screens */ +@media (max-width: 480px) { + .dt-import-container { + padding: 10px; + } + + .dt-import-progress { + padding: 12px; + margin: 10px 0 15px 0; + } + + .dt-import-steps { + gap: 8px; + } + + .dt-import-steps .step { + gap: 12px; + padding: 8px 0; + } + + .dt-import-steps .step-number { + width: 32px; + height: 32px; + line-height: 30px; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + } + + .dt-import-steps .step-name { + font-size: 14px; + } + + .dt-import-steps::before { + left: 16px; + } + + .dt-import-steps .step.completed::after, + .dt-import-steps .step.active::after { + left: 16px; + top: 40px; + height: calc(100% + 8px); + } + + .dt-import-container h1 { + font-size: 20px; + margin-bottom: 15px; + } + + .dt-import-step-content h2, + .dt-import-container h2 { + font-size: 18px !important; + margin: 0 0 8px 0 !important; + line-height: 1.2 !important; + } +} + +/* Button States */ +.button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.button-primary:disabled { + background-color: #ccc !important; + border-color: #ccc !important; + color: #666 !important; +} + +/* Additional WordPress Admin Compatibility */ +.form-table th { + padding: 20px 10px 20px 0; +} + +.form-table td { + padding: 15px 10px; +} + +.form-table select, +.form-table input[type="text"] { + width: 100%; +} + +/* Helper classes */ +.text-center { + text-align: center; +} + +.text-right { + text-align: right; +} + +.mb-0 { + margin-bottom: 0; +} + +.mt-20 { + margin-top: 20px; +} + +.hidden { + display: none; +} + +/* Toast Notification Styles */ +.dt-import-toast-success { + background: linear-gradient(135deg, #46b450 0%, #3d9c42 100%) !important; + border-radius: 6px !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important; + font-size: 14px !important; + font-weight: 500 !important; + box-shadow: 0 4px 12px rgba(70, 180, 80, 0.3) !important; + border: none !important; +} + +.dt-import-toast-error { + background: linear-gradient(135deg, #dc3232 0%, #c92c2c 100%) !important; + border-radius: 6px !important; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif !important; + font-size: 14px !important; + font-weight: 500 !important; + box-shadow: 0 4px 12px rgba(220, 50, 50, 0.3) !important; + border: none !important; +} + +.dt-import-toast-success .toastify-content, +.dt-import-toast-error .toastify-content { + padding: 12px 16px !important; +} + +/* Toast close button styling */ +.dt-import-toast-success .toast-close, +.dt-import-toast-error .toast-close { + opacity: 0.8 !important; + font-size: 18px !important; + font-weight: bold !important; + margin-left: 10px !important; +} + +.dt-import-toast-success .toast-close:hover, +.dt-import-toast-error .toast-close:hover { + opacity: 1 !important; +} + +/* Value Mapping Modal Enhancements */ +.value-mapping-modal .modal-content { + max-width: 800px; + width: 90%; +} + +.value-mapping-controls { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 15px; + padding: 10px; + background: #f8f9fa; + border-radius: 4px; +} + +.value-mapping-controls .button { + font-size: 12px; + padding: 5px 10px; + height: auto; + line-height: 1.4; +} + +.value-mapping-container { + max-height: 400px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; +} + +.value-mapping-container table { + width: 100%; + border-collapse: collapse; + margin: 0; +} + +.value-mapping-container thead { + background: #f9f9f9; + position: sticky; + top: 0; + z-index: 2; +} + +.value-mapping-container th { + padding: 8px 10px; + text-align: left; + font-weight: 600; + color: #23282d; + border-bottom: 1px solid #ddd; +} + +.value-mapping-container td { + padding: 6px 8px; + border-bottom: 1px solid #f0f0f1; +} + +.value-mapping-container .csv-value { + font-weight: 500; + color: #23282d; +} + +.value-mapping-container .csv-value strong { + font-size: 12px; +} + +.value-mapping-select { + width: 100%; + min-width: 150px; + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 13px; + background: #fff; +} + +.value-mapping-select:focus { + border-color: #0073aa; + outline: none; + box-shadow: 0 0 0 1px #0073aa; +} + +/* Mapping Summary */ +.mapping-summary { + margin: 15px 0; + padding: 10px 15px; + background: #f0f8ff; + border: 1px solid #c8e6ff; + border-radius: 4px; +} + +.mapping-summary .mapping-count { + font-weight: 600; + color: #0073aa; +} + +/* Field creation modal */ +.field-options-list { + margin: 10px 0; +} + +.field-option-row { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +.field-option-row input[type="text"] { + flex: 1; + min-width: 150px; +} + +.field-option-row .remove-option-btn { + padding: 5px 10px; + font-size: 12px; + height: auto; + line-height: 1.4; +} + +.add-option-btn { + padding: 8px 12px; + font-size: 13px; + height: auto; + line-height: 1.4; +} + +/* Modal error messages */ +.modal-error { + margin-bottom: 15px; +} + +.modal-error p { + margin: 0; +} + +/* Inline Value Mapping Styles */ +.inline-value-mapping-section { + margin-top: 15px; + padding: 12px; + background: #f8f9fa; + border: 1px solid #e1e5e9; + border-radius: 4px; +} + +.inline-value-mapping-section h5 { + margin: 0 0 10px 0; + font-size: 13px; + color: #23282d; + font-weight: 600; +} + +.inline-value-mapping-container { + max-height: 200px; + overflow-y: auto; + border: 1px solid #ddd; + border-radius: 3px; + background: #fff; +} + +.inline-value-mapping-container table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + margin: 0; +} + +.inline-value-mapping-container thead { + background: #f9f9f9; + position: sticky; + top: 0; + z-index: 2; +} + +.inline-value-mapping-container th { + padding: 6px 8px; + text-align: left; + font-weight: 600; + color: #23282d; + border-bottom: 1px solid #ddd; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.inline-value-mapping-container td { + padding: 4px 6px; + border-bottom: 1px solid #f0f0f1; + vertical-align: middle; +} + +.inline-value-mapping-container .csv-value-cell { + width: 50%; + font-weight: 500; + color: #23282d; +} + +.inline-value-mapping-container .csv-value-cell strong { + font-size: 11px; + display: block; + word-wrap: break-word; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inline-value-mapping-container .mapping-select-cell { + width: 50%; +} + +.inline-value-mapping-select { + width: 100%; + min-width: 120px; + padding: 3px 6px; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 11px; + background: #fff; +} + +.inline-value-mapping-select:focus { + border-color: #0073aa; + outline: none; + box-shadow: 0 0 0 1px #0073aa; +} + +.inline-mapping-controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 6px 0 0 0; +} + +.inline-mapping-controls .button { + font-size: 10px; + padding: 3px 8px; + height: auto; + line-height: 1.4; + min-height: 24px; + border-radius: 3px; +} + +.inline-mapping-count { + font-size: 10px; +} + +/* Responsive adjustments for inline mapping */ +@media (max-width: 768px) { + .inline-value-mapping-container table { + font-size: 10px; + } + + .inline-value-mapping-container th { + padding: 4px 6px; + font-size: 10px; + } + + .inline-value-mapping-container td { + padding: 3px 4px; + } + + .inline-value-mapping-select { + font-size: 10px; + padding: 2px 4px; + min-width: 100px; + } + + .inline-mapping-controls .button { + font-size: 9px; + padding: 2px 6px; + } + + .inline-mapping-count { + font-size: 10px; + } +} + +/* Warnings Summary */ +.warnings-summary { + margin: 15px 0; +} + +.warnings-summary .notice { + padding: 12px; + margin: 0; + border-left: 4px solid #f56e28; + background: #fff8e1; + border-radius: 4px; +} + +.warnings-summary .notice h4 { + margin: 0 0 5px 0; + font-size: 14px; + font-weight: 600; + color: #f56e28; +} + +.warnings-summary .notice h4 i { + margin-right: 5px; +} + +.warnings-summary .notice p { + margin: 0; + font-size: 13px; + color: #85651a; +} + +/* Duplicate Checking Styles */ +.duplicate-checking-section { + margin-top: 10px; + padding: 10px; + background: #f9f9f9; + border: 1px solid #ddd; + border-radius: 4px; +} + +.duplicate-checking-section h5 { + color: #333; + font-weight: 600; +} + +.duplicate-checking-container { + margin-top: 8px; +} + +.duplicate-checking-checkbox { + margin-right: 8px !important; +} + +.duplicate-checking-container label { + display: flex; + align-items: center; + font-size: 13px; + font-weight: normal; + cursor: pointer; +} + +.duplicate-checking-container p { + margin: 5px 0 0 0; + font-style: italic; +} + +/* Preview table update indicators */ +.preview-row.will-update { + background-color: #fff3cd; + border-left: 4px solid #ffc107; +} + +.preview-row.will-update .row-number::after { + content: " (UPDATE)"; + color: #856404; + font-weight: bold; + font-size: 0.8em; +} + +/* Action indicators in results */ +.action-indicator { + font-size: 0.8em; + font-weight: bold; + margin-left: 8px; + padding: 2px 6px; + border-radius: 3px; +} + +.action-indicator.created { + background-color: #d4edda; + color: #155724; +} + +.action-indicator.updated { + background-color: #fff3cd; + color: #856404; +} + +.update-indicator { + color: #856404; + font-weight: bold; + font-size: 0.9em; +} + +/* Body scroll lock when modal is open */ +body.modal-open { + overflow: hidden; +} + +/* Documentation Modal Styles */ +.dt-import-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 999999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + box-sizing: border-box; +} + +.dt-import-modal-content { + background: #fff; + border-radius: 8px; + max-width: 1000px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.dt-import-modal-header { + padding: 20px; + border-bottom: 1px solid #ddd; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.dt-import-modal-header h2 { + margin: 0; + font-size: 24px; + color: #23282d; +} + +.dt-import-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + padding: 5px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.dt-import-modal-close:hover { + background: #f0f0f1; + color: #d63638; +} + +.dt-import-modal-body { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.dt-import-docs-nav { + border-bottom: 1px solid #ddd; + flex-shrink: 0; +} + +.dt-import-docs-tabs { + display: flex; + margin: 0; + padding: 0; + list-style: none; +} + +.dt-import-docs-tabs li { + margin: 0; +} + +.dt-import-docs-tabs a { + display: block; + padding: 15px 20px; + color: #666; + text-decoration: none; + border-bottom: 3px solid transparent; + transition: all 0.2s ease; +} + +.dt-import-docs-tabs a:hover { + color: #0073aa; + background: #f8f9fa; +} + +.dt-import-docs-tabs a.active { + color: #0073aa; + border-bottom-color: #0073aa; + background: #f8f9fa; +} + +.dt-import-docs-content { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.dt-import-docs-tab-content { + display: none; +} + +.dt-import-docs-tab-content.active { + display: block; +} + +.dt-import-docs-tab-content h3 { + margin: 0 0 15px 0; + color: #23282d; + font-size: 20px; +} + +.dt-import-docs-tab-content h4 { + margin: 20px 0 10px 0; + color: #0073aa; + font-size: 16px; +} + +.dt-import-docs-tab-content h5 { + margin: 15px 0 8px 0; + color: #666; + font-size: 14px; +} + +.dt-import-docs-tab-content p { + margin: 0 0 15px 0; + line-height: 1.6; + color: #555; +} + +.dt-import-docs-tab-content ul, +.dt-import-docs-tab-content ol { + margin: 0 0 15px 20px; + line-height: 1.6; +} + +.dt-import-docs-tab-content li { + margin: 0 0 8px 0; + color: #555; +} + +.dt-import-field-type { + margin: 0 0 25px 0; + padding: 15px; + background: #f8f9fa; + border-radius: 6px; + border-left: 4px solid #0073aa; +} + +.dt-import-field-type h4 { + margin: 0 0 8px 0; + color: #0073aa; +} + +.dt-import-field-type p { + margin: 0 0 10px 0; +} + +.dt-import-example { + background: #fff; + padding: 10px; + border-radius: 4px; + border: 1px solid #ddd; + font-family: monospace; + font-size: 13px; +} + +.dt-import-example strong { + color: #666; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; +} + +.dt-import-example code { + background: #f1f1f1; + padding: 2px 4px; + border-radius: 3px; + font-size: 12px; +} + +.dt-import-example-section { + margin: 0 0 30px 0; +} + +.dt-import-csv-example { + margin: 10px 0; + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + overflow-x: auto; +} + +.dt-import-example-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: #fff; + border-radius: 4px; + overflow: hidden; +} + +.dt-import-example-table th { + background: #0073aa; + color: #fff; + padding: 8px 12px; + text-align: left; + font-weight: 600; +} + +.dt-import-example-table td { + padding: 8px 12px; + border-bottom: 1px solid #ddd; + vertical-align: top; +} + +.dt-import-example-table tr:last-child td { + border-bottom: none; +} + +.dt-import-example-table tr:nth-child(even) { + background: #f8f9fa; +} + +.dt-import-troubleshoot-item { + margin: 0 0 20px 0; + padding: 15px; + background: #fff3cd; + border-radius: 6px; + border-left: 4px solid #ffc107; +} + +.dt-import-troubleshoot-item h4 { + margin: 0 0 8px 0; + color: #856404; +} + +.dt-import-troubleshoot-item p { + margin: 0 0 8px 0; +} + +.dt-import-troubleshoot-item ul { + margin: 8px 0 0 20px; +} + +.dt-import-tip { + background: #d1ecf1; + border: 1px solid #bee5eb; + border-radius: 6px; + padding: 12px; + margin: 15px 0; + color: #0c5460; +} + +.dt-import-modal-footer { + padding: 15px 20px; + border-top: 1px solid #ddd; + text-align: right; + flex-shrink: 0; +} + +/* Documentation Sidebar Styles */ +.dt-import-doc-actions { + margin: 15px 0; +} + +.dt-import-view-docs { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.dt-import-quick-ref h5 { + color: #0073aa; + font-size: 13px; + margin: 15px 0 8px 0; + font-weight: 600; +} + +.dt-import-quick-ref ul { + margin: 0 0 15px 0; +} + +.dt-import-quick-ref li { + margin: 0 0 5px 0; + font-size: 12px; + line-height: 1.4; +} + +.dt-import-quick-ref li strong { + color: #23282d; +} + +/* Responsive styles for documentation modal */ +@media (max-width: 768px) { + .dt-import-modal { + padding: 10px; + } + + .dt-import-modal-content { + max-height: 95vh; + } + + .dt-import-modal-header { + padding: 15px; + } + + .dt-import-modal-header h2 { + font-size: 20px; + } + + .dt-import-docs-tabs { + flex-wrap: wrap; + } + + .dt-import-docs-tabs a { + padding: 10px 15px; + font-size: 14px; + } + + .dt-import-docs-content { + padding: 15px; + } + + .dt-import-field-type { + padding: 12px; + } + + .dt-import-csv-example { + padding: 10px; + } + + .dt-import-example-table { + font-size: 11px; + } + + .dt-import-example-table th, + .dt-import-example-table td { + padding: 6px 8px; + } +} + +/* Connection Field Help Styles */ +.field-specific-options .connection-field-help { + background: #e7f3ff; + border: 1px solid #b3d8ff; + border-radius: 6px; + padding: 15px; + margin: 0; +} + +/* Override the grey box for connection field help */ +.field-specific-options.connection-help { + background: transparent; + border: none; + padding: 0; + margin-top: 10px; +} + +.field-help-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + color: #0073aa; +} + +.field-help-header i { + font-size: 18px; +} + +.field-help-header strong { + font-size: 14px; + font-weight: 600; +} + +.field-help-content p { + margin: 0 0 10px 0; + font-size: 13px; + color: #2c3338; +} + +.field-help-content ul { + margin: 0 0 15px 20px; + padding: 0; +} + +.field-help-content li { + margin: 0 0 6px 0; + font-size: 13px; + color: #2c3338; + line-height: 1.4; +} + + + +@media (max-width: 768px) { + .connection-field-help { + padding: 12px; + } + + .field-help-header { + gap: 6px; + margin-bottom: 10px; + } + + .field-help-header strong { + font-size: 13px; + } + + .field-help-content li { + font-size: 12px; + } +} + + + +/* Name Field Warning Banner */ +.name-field-warning { + margin-bottom: 20px; +} + +.name-field-warning .notice { + border-left: 4px solid #d63638; + background: #fef7f7; + padding: 12px; + margin: 0; + border-radius: 3px; +} + +.name-field-warning .notice p { + margin: 0; + color: #d63638; +} + +.name-field-warning .notice p:first-child { + font-weight: 600; + margin-bottom: 5px; +} + +.name-field-warning .notice p:last-child { + font-weight: normal; + font-size: 14px; +} + +.name-field-warning .mdi { + margin-right: 5px; +} \ No newline at end of file diff --git a/dt-import/assets/example_contacts.csv b/dt-import/assets/example_contacts.csv new file mode 100644 index 000000000..ccd1cb3ad --- /dev/null +++ b/dt-import/assets/example_contacts.csv @@ -0,0 +1,4 @@ +name,phone,email,gender,facebook,twitter,seeker_path,baptism Date,milestones,age +"Mr O,Nubs",123546,test10@example.com; test12@example.com,male,12342.oeu,tx.acc,met,2015-04-03,milestone_has_bible; milestone_baptized ,>18 +"Mrs H'S,mith",7894565,test20@example.com; test22@example.com,,fb22.oeu,tx22.acc,,2017-04-03,milestone_baptized; milestone_sharing, +Dr Renolds,7894565,test203@example.com; test225@example.com,female,fb22.oeu,tx22.acc,met3,2017-04-03,baptized, \ No newline at end of file diff --git a/dt-import/assets/example_contacts_comprehensive.csv b/dt-import/assets/example_contacts_comprehensive.csv new file mode 100644 index 000000000..9cc047cb3 --- /dev/null +++ b/dt-import/assets/example_contacts_comprehensive.csv @@ -0,0 +1,11 @@ +name,nickname,overall_status,seeker_path,faith_status,gender,age,contact_phone,contact_email,contact_address,contact_facebook,contact_twitter,milestones,baptism_date,baptism_generation,assigned_to,reason_paused,reason_closed,reason_unassignable,campaigns,location_grid,sources,requires_update,relation,subassigned,coaching,coached_by,groups +"John Smith",Johnny,active,met,believer,male,25-39,"555-0101; 555-0102",john@example.com; johnsmith@gmail.com,"123 Main St, Anytown, USA","john.smith.fb","@johnsmith","milestone_has_bible; milestone_reading_bible; milestone_belief; milestone_baptized",2023-06-15,2,1,,,,evangelism_campaign; social_media,100089589,personal_contact,false,"Maria Garcia; 456",,"Robert Wilson; 789",,"Grace Community Church; 234" +"Maria Garcia",Mari,unassigned,attempted,seeker,female,18-25,"555-0201",maria.garcia@email.com,"456 Oak Ave, Springfield, USA","maria.garcia","@mariagarcia","milestone_has_bible; milestone_reading_bible",,,2,,,,website_form; facebook_ad,100089590,web_form,true,"123",,,,567 +"David Johnson",,active,ongoing,leader,male,40-54,"555-0301; 555-0302; 555-0303",david@work.com; djohnson@personal.net,"789 Pine St, Hometown, USA; 321 Business Blvd, Office City, USA","david.johnson.page","@davidjohnson","milestone_has_bible; milestone_reading_bible; milestone_belief; milestone_baptized; milestone_baptizing; milestone_in_group; milestone_planting",2022-03-10,1,"Admin User",,,,church_plant; leadership_training,100089591,referral,false,"John Smith; Sarah Williams",,,"789","890; Leadership Development" +"Sarah Williams",Sarah,paused,established,seeker,female,25-39,"555-0401",sarah.w@domain.com,"654 Elm Dr, Newville, USA","sarah.williams","","milestone_has_bible",,,1,vacation,,,radio_ad,100089592,radio_contact,false,"456","Jennifer Davis",,,901 +"Michael Brown",Mike,closed,none,,male,30-39,"555-0501",mike.brown@old.com,"987 Maple Ln, Oldtown, USA","","","",,,1,,duplicate,,print_ad,100089593,print_media,false,,,,, +"Jennifer Davis",Jen,unassignable,none,seeker,female,18-25,"555-0601",jennifer.davis@example.org,"147 Cedar Way, Somewhere, USA","jen.davis.profile","@jendavis","milestone_has_bible; milestone_reading_bible",,,2,,insufficient,,online_search,100089594,online_contact,true,,"567",,,"Seeker Study Circle; 902" +"Robert Wilson",Bob,active,coaching,leader,male,55+,"555-0701; 555-0702",bob.wilson@company.com; rwilson@home.net,"258 Birch Rd, Mytown, USA","bob.wilson","@bobwilson","milestone_has_bible; milestone_reading_bible; milestone_belief; milestone_baptized; milestone_sharing; milestone_in_group",2021-08-22,1,"Admin User",,,,discipleship_group,100089595,friend_referral,false,,"890","123; David Johnson",,"Grace Community Church; 903" +"Lisa Anderson",Lisa,assigned,scheduled,seeker,female,25-39,"555-0801",lisa.anderson@email.net,"369 Spruce Ave, Anyplace, USA","lisa.anderson","","milestone_has_bible",,,3,,,,community_event,100089596,event_contact,false,,,"789",,904 +"James Taylor",Jim,active,ongoing,believer,male,30-39,"555-0901; 555-0902",james.taylor@work.org; jtaylor@gmail.com,"741 Willow St, Everytown, USA","james.taylor.fb","@jamestaylor","milestone_has_bible; milestone_reading_bible; milestone_belief; milestone_can_share; milestone_sharing",,,1,,,,small_group; bible_study,100089597,church_member,false,"1001",,,,905 +"Amanda Thomas",Mandy,active,ongoing,believer,female,18-25,"555-1001",amanda.thomas@university.edu,"852 Ash Blvd, College City, USA","amanda.thomas","@mandythomas","milestone_has_bible; milestone_reading_bible; milestone_belief; milestone_baptized; milestone_in_group",2023-09-05,3,"User Name",,,,youth_ministry; campus_outreach,100089598,campus_contact,true,"901",,,,905 \ No newline at end of file diff --git a/dt-import/assets/example_groups.csv b/dt-import/assets/example_groups.csv new file mode 100644 index 000000000..a0b338580 --- /dev/null +++ b/dt-import/assets/example_groups.csv @@ -0,0 +1,4 @@ +name,address,member count,type,start date,end date,church health +Alpha Group,123 some road; 948 other road,5,group,2015-04-03,2019-05-21,Baptism; Bible Study +Group 1,Church basement,4,church,2017-04-03,,church_communion; church_praise +Church 1,,0,,2017-04-03,,Giving \ No newline at end of file diff --git a/dt-import/assets/example_groups_comprehensive.csv b/dt-import/assets/example_groups_comprehensive.csv new file mode 100644 index 000000000..52c13e733 --- /dev/null +++ b/dt-import/assets/example_groups_comprehensive.csv @@ -0,0 +1,11 @@ +name,group_type,group_status,health_metrics,start_date,end_date,church_start_date,member_count,leader_count,contact_address,assigned_to,location_grid,parent_groups,child_groups,peer_groups,members,leaders,coaches,four_fields_unbelievers,four_fields_believers,four_fields_accountable,four_fields_church_commitment,four_fields_multiplying,requires_update +"Grace Community Church",church,active,"church_baptism; church_bible; church_communion; church_fellowship; church_giving; church_prayer; church_praise; church_sharing; church_leaders; church_commitment",2020-01-15,,2022-03-10,45,3,"123 Church St, Faithville, USA",1,100089589,,,"234; Downtown Church Plant; 567; Leadership Development; House Church Network","123; Robert Wilson; 901","John Smith; 456","789; John Smith",8,35,15,yes,5,false +"New Believers Group",group,active,"church_baptism; church_bible; church_communion; church_fellowship",2023-06-01,,,12,2,"456 Meeting Hall, Hopetown, USA",2,100089590,"234","567",,"123; Maria Garcia; 789","456; Sarah Williams","John Smith; 456",2,8,6,no,1,false +"Seeker Study Circle",pre-group,active,"church_bible; church_fellowship",2023-09-15,,,8,1,"789 Community Center, Seekerville, USA",2,100089591,"234",,,"456; Jennifer Davis","890","789",6,2,0,no,0,true +"Downtown Church Plant",church,active,"church_baptism; church_bible; church_communion; church_fellowship; church_giving; church_prayer; church_praise; church_sharing; church_leaders; church_commitment",2021-05-20,,2023-01-08,28,2,"321 Urban Ave, Metro City, USA",1,100089592,"234","901; Outreach Team Beta",,"456; Robert Wilson; 890","David Johnson; 789","456; Robert Wilson",5,20,12,yes,3,false +"Youth Ministry Team",team,active,"church_bible; church_fellowship; church_sharing",2022-02-14,,,15,4,"654 Youth Center, Youngstown, USA",3,100089593,"234",,,"901; Amanda Thomas; Lisa Anderson","James Taylor; 1001","James Taylor; 789",10,5,8,no,2,true +"Closed House Church",church,inactive,"church_baptism; church_bible; church_communion; church_fellowship; church_giving",2019-03-01,2023-08-30,2020-06-15,0,0,"987 Residential St, Oldville, USA",1,100089594,,,,,,,0,0,0,yes,0,false +"Prayer Group Alpha",group,active,"church_bible; church_prayer; church_fellowship",2023-11-01,,,6,1,"147 Prayer Room, Spiritual Heights, USA",3,100089595,"902",,"1002","890; Jennifer Davis","890","123; Robert Wilson",1,5,4,no,0,false +"Leadership Development",team,active,"church_bible; church_leadership; church_sharing",2023-04-10,,,10,3,"258 Training Center, Leadership City, USA",1,100089596,"234",,,"456; Robert Wilson; 901","David Johnson; 789; James Taylor","456; Robert Wilson",2,8,10,no,1,true +"Outreach Team Beta",team,active,"church_sharing; church_fellowship",2023-08-05,,,7,2,"369 Outreach Hub, Evangelism Town, USA",3,100089597,"902",,"1003","890; Amanda Thomas","890","456; Robert Wilson",0,5,3,no,2,false +"House Church Network",church,active,"church_baptism; church_bible; church_communion; church_fellowship; church_giving; church_prayer; church_commitment",2022-10-12,,2023-02-20,22,2,"741 Network Ave, Connected City, USA",1,100089598,"234","1004; Small Group Gamma",,"789; James Taylor; 1001","Robert Wilson; 901","789; David Johnson",3,18,10,yes,4,false \ No newline at end of file diff --git a/dt-import/assets/js/dt-import-modals.js b/dt-import/assets/js/dt-import-modals.js new file mode 100644 index 000000000..5e9c5c5a9 --- /dev/null +++ b/dt-import/assets/js/dt-import-modals.js @@ -0,0 +1,525 @@ +/* global dtImport */ +(function ($) { + 'use strict'; + + // DT Import Modal Handlers + class DTImportModals { + constructor(dtImportInstance) { + this.dtImport = dtImportInstance; + this.bindModalEvents(); + } + + bindModalEvents() { + // Create field modal events + $(document).on('click', '.create-field-btn', (e) => + this.showCreateFieldModal(e), + ); + $(document).on('click', '.save-field-btn', () => + this.createCustomField(), + ); + $(document).on('click', '.cancel-field-btn', () => this.closeModals()); + + // Handle field mapping dropdown selection + $(document).on('change', '.field-mapping-select', (e) => { + const $select = $(e.target); + const fieldKey = $select.val(); + const columnIndex = $select.data('column-index'); + + if (fieldKey === 'create_new') { + // Reset to empty selection and show modal + $select.val(''); + this.showCreateFieldModal(columnIndex); + } else { + // Handle regular field mapping + this.handleFieldMapping(columnIndex, fieldKey); + } + }); + + // General modal events + $(document).on('click', '.modal-close, .modal-overlay', (e) => { + if (e.target === e.currentTarget) { + this.closeModals(); + } + }); + + // ESC key to close modals + $(document).on('keydown', (e) => { + if (e.key === 'Escape') { + this.closeModals(); + } + }); + } + + showCreateFieldModal(columnIndex) { + const modalHtml = ` +
+ + +
+ `; + + $('body').append(modalHtml); + + // Handle field type change + $('#new-field-type').on('change', (e) => { + const fieldType = $(e.target).val(); + if (['key_select', 'multi_select'].includes(fieldType)) { + $('#field-options-row').show(); + } else { + $('#field-options-row').hide(); + } + }); + + // Handle add option button + $('.add-option-btn').on('click', () => this.addFieldOption()); + } + + getFieldTypeOptions() { + return Object.entries(dtImport.fieldTypes) + .map(([key, label]) => { + return ``; + }) + .join(''); + } + + addFieldOption(key = '', label = '') { + const optionIndex = $('.field-option-row').length; + const optionHtml = ` +
+ + + +
+ `; + + $('.field-options-list').append(optionHtml); + + // Handle remove option + $('.remove-option-btn') + .last() + .on('click', function () { + $(this).parent().remove(); + }); + } + + createCustomField() { + const formData = new FormData( + document.getElementById('create-field-form'), + ); + const fieldData = { + name: formData.get('field_name'), + type: formData.get('field_type'), + description: formData.get('field_description') || '', + post_type: formData.get('post_type'), + column_index: formData.get('column_index'), + }; + + // Validate required fields + if (!fieldData.name || !fieldData.type) { + this.showModalError(dtImport.translations.fillRequiredFields); + return; + } + + // Add field options if applicable + if (['key_select', 'multi_select'].includes(fieldData.type)) { + const optionKeys = formData.getAll('option_keys[]'); + const optionLabels = formData.getAll('option_labels[]'); + + fieldData.options = {}; + optionKeys.forEach((key, index) => { + if (key && optionLabels[index]) { + fieldData.options[key] = optionLabels[index]; + } + }); + } + + // Show loading state + $('.save-field-btn') + .prop('disabled', true) + .text(dtImport.translations.creating); + + // Create field via DT's existing REST API + const createFieldData = new FormData(); + createFieldData.append('new_field_name', fieldData.name); + createFieldData.append('new_field_type', fieldData.type); + createFieldData.append('post_type', fieldData.post_type); + createFieldData.append('tile_key', 'other'); + + fetch( + `${dtImport.restUrl.replace('dt-csv-import/v2/', '')}dt-admin-settings/new-field`, + { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + body: createFieldData, + }, + ) + .then((response) => response.json()) + .then((data) => { + if (data && data.key) { + const fieldKey = data.key; + + // If this is a select field with options, create the field options + if ( + ['key_select', 'multi_select'].includes(fieldData.type) && + Object.keys(fieldData.options || {}).length > 0 + ) { + this.createFieldOptions( + fieldData.post_type, + fieldKey, + fieldData.options, + ) + .then(() => { + this.handleFieldCreationSuccess(fieldData, fieldKey); + }) + .catch((error) => { + console.error('Error creating field options:', error); + // Field was created but options failed - still show success + this.handleFieldCreationSuccess(fieldData, fieldKey); + }); + } else { + this.handleFieldCreationSuccess(fieldData, fieldKey); + } + } else { + this.showModalError(dtImport.translations.fieldCreationError); + } + }) + .catch((error) => { + console.error('Field creation error:', error); + this.showModalError(dtImport.translations.ajaxError); + }) + .finally(() => { + $('.save-field-btn') + .prop('disabled', false) + .text(dtImport.translations.createField); + }); + } + + createFieldOptions(postType, fieldKey, fieldOptions) { + const promises = []; + + Object.entries(fieldOptions).forEach(([optionKey, optionLabel]) => { + const optionData = new FormData(); + optionData.append('post_type', postType); + optionData.append('tile_key', 'other'); + optionData.append('field_key', fieldKey); + optionData.append('field_option_name', optionLabel); + optionData.append('field_option_description', ''); + optionData.append('field_option_key', optionKey); + + const promise = fetch( + `${dtImport.restUrl.replace('dt-csv-import/v2/', '')}dt-admin-settings/new-field-option`, + { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + body: optionData, + }, + ); + + promises.push(promise); + }); + + return Promise.all(promises); + } + + handleFieldCreationSuccess(fieldData, fieldKey) { + // Update the field mapping dropdown + this.updateFieldMappingDropdown( + fieldData.column_index, + fieldKey, + fieldData.name, + fieldData.type, + fieldData.options || {}, + ); + + // Show success message + this.showModalSuccess(dtImport.translations.fieldCreatedSuccess); + + // Close modal + this.closeModals(); + } + + updateFieldMappingDropdown( + columnIndex, + fieldKey, + fieldName, + fieldType, + fieldOptions = {}, + ) { + const $select = $( + `.field-mapping-select[data-column-index="${window.dt_admin_shared.escape(columnIndex)}"]`, + ); + + // Add new option before "Create New Field" + const newOption = ``; + $select.find('option[value="create_new"]').before(newOption); + + // Clear the frontend cache to force refresh of field settings + this.dtImport.cachedFieldSettings = null; + + // Update cached field settings with the new field + const fieldSettings = this.dtImport.getFieldSettingsForPostType(); + if (fieldSettings) { + const fieldConfig = { + name: fieldName, + type: fieldType, + }; + + // Add field options for select fields + if ( + ['key_select', 'multi_select'].includes(fieldType) && + Object.keys(fieldOptions).length > 0 + ) { + fieldConfig.default = {}; + Object.entries(fieldOptions).forEach(([key, label]) => { + fieldConfig.default[key] = { label: label }; + }); + } + + // Update the cached settings with the new field + if (this.dtImport.cachedFieldSettings) { + this.dtImport.cachedFieldSettings[fieldKey] = fieldConfig; + } + } + + // Manually update the field mapping without triggering change event to avoid infinite loop + this.dtImport.fieldMappings[columnIndex] = { + field_key: fieldKey, + column_index: columnIndex, + }; + + // Update field specific options and summary + // For newly created select fields, pass the options directly instead of fetching from server + if ( + ['key_select', 'multi_select'].includes(fieldType) && + Object.keys(fieldOptions).length > 0 + ) { + const fieldConfig = { name: fieldName, type: fieldType }; + this.dtImport.showInlineValueMappingWithOptions( + columnIndex, + fieldKey, + fieldConfig, + fieldOptions, + ); + } else { + this.dtImport.showFieldSpecificOptions(columnIndex, fieldKey); + } + this.dtImport.updateMappingSummary(); + } + + showModalError(message) { + // Use toast notification instead of inline modal error + if (this.dtImport && this.dtImport.showError) { + this.dtImport.showError(message); + } else { + // Fallback to inline modal error if toast is not available + // Remove existing error messages + $('.modal-error').remove(); + + // Add error message to modal + $('.modal-body').prepend(` + + `); + } + } + + showModalSuccess(message) { + // Use toast notification for success messages + if (this.dtImport && this.dtImport.showSuccess) { + this.dtImport.showSuccess(message); + } + } + + closeModals() { + $('.dt-import-modal').remove(); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + handleFieldMapping(columnIndex, fieldKey) { + // Store mapping in the main DTImport instance + if (fieldKey) { + this.dtImport.fieldMappings[columnIndex] = { + field_key: fieldKey, + column_index: columnIndex, + }; + } else { + delete this.dtImport.fieldMappings[columnIndex]; + } + + // Show field-specific options if needed + this.dtImport.showFieldSpecificOptions(columnIndex, fieldKey); + this.dtImport.updateMappingSummary(); + } + } + + // Documentation Modal Handler + class DTImportDocumentationModal { + constructor() { + this.bindEvents(); + } + + bindEvents() { + // Show documentation modal + $(document).on('click', '#dt-import-view-docs', (e) => { + e.preventDefault(); + this.showDocumentationModal(); + }); + + // Handle tab switching + $(document).on('click', '.dt-import-docs-tabs a', (e) => { + e.preventDefault(); + const targetTab = $(e.target).attr('href').substring(1); + this.switchTab(targetTab); + }); + + // Close modal events + $(document).on( + 'click', + '#dt-import-docs-close, #dt-import-docs-close-btn', + (e) => { + e.preventDefault(); + this.closeDocumentationModal(); + }, + ); + + // Close on overlay click + $(document).on('click', '#dt-import-documentation-modal', (e) => { + if (e.target === e.currentTarget) { + this.closeDocumentationModal(); + } + }); + + // Close on escape key + $(document).on('keydown', (e) => { + if ( + e.key === 'Escape' && + $('#dt-import-documentation-modal').is(':visible') + ) { + this.closeDocumentationModal(); + } + }); + } + + showDocumentationModal() { + const $modal = $('#dt-import-documentation-modal'); + if ($modal.length) { + $modal.fadeIn(300); + $('body').addClass('modal-open'); + + // Focus management for accessibility + $modal.find('.dt-import-modal-close').focus(); + } + } + + closeDocumentationModal() { + const $modal = $('#dt-import-documentation-modal'); + $modal.fadeOut(300, () => { + $('body').removeClass('modal-open'); + }); + } + + switchTab(targetTab) { + // Update tab navigation + $('.dt-import-docs-tabs a').removeClass('active'); + $( + `.dt-import-docs-tabs a[href="#${window.dt_admin_shared.escape(targetTab)}"]`, + ).addClass('active'); + + // Show/hide tab content + $('.dt-import-docs-tab-content').removeClass('active'); + $(`#${window.dt_admin_shared.escape(targetTab)}`).addClass('active'); + } + } + + // Extend the main DTImport class with modal functionality + $(document).ready(() => { + if (window.dtImportInstance) { + window.dtImportInstance.modals = new DTImportModals( + window.dtImportInstance, + ); + + // Add modal methods to main instance + window.dtImportInstance.showCreateFieldModal = (columnIndex) => { + window.dtImportInstance.modals.showCreateFieldModal(columnIndex); + }; + + window.dtImportInstance.closeModals = () => { + window.dtImportInstance.modals.closeModals(); + }; + } + + // Initialize documentation modal + window.dtImportDocumentationModal = new DTImportDocumentationModal(); + }); +})(jQuery); diff --git a/dt-import/assets/js/dt-import.js b/dt-import/assets/js/dt-import.js new file mode 100644 index 000000000..864cf8912 --- /dev/null +++ b/dt-import/assets/js/dt-import.js @@ -0,0 +1,2695 @@ +/* global dtImport */ +(function ($) { + 'use strict'; + + // DT Import main class + class DTImport { + constructor() { + this.currentStep = 1; + this.sessionId = null; + this.selectedPostType = null; + this.csvData = null; + this.fieldMappings = {}; + this.doNotImportColumns = new Set(); // Track columns explicitly set to "Do not import" + this.isProcessing = false; + this.cachedFieldSettings = null; + + this.init(); + } + + init() { + this.bindEvents(); + this.loadPostTypes(); + this.updateStepIndicator(); + } + + bindEvents() { + // Navigation buttons + $('.dt-import-next').on('click', () => this.nextStep()); + $('.dt-import-back').on('click', () => this.previousStep()); + + // Post type selection + $(document).on('click', '.post-type-card', (e) => this.selectPostType(e)); + + // File upload change event only + $(document).on('change', '#csv-file-input', (e) => + this.handleFileSelect(e), + ); + + // Drag and drop + $(document).on('dragover', '.file-upload-area', (e) => + this.handleDragOver(e), + ); + $(document).on('drop', '.file-upload-area', (e) => + this.handleFileDrop(e), + ); + + // Field mapping selection + $(document).on('change', '.field-mapping-select', (e) => + this.handleFieldMapping(e), + ); + + // Geocoding service selection + $(document).on('change', '.geocoding-service-checkbox', (e) => + this.handleGeocodingServiceChange(e), + ); + + // Duplicate checking selection + $(document).on('change', '.duplicate-checking-checkbox', (e) => + this.handleDuplicateCheckingChange(e), + ); + + // Date format selection + $(document).on('change', '.date-format-select', (e) => + this.handleDateFormatChange(e), + ); + + // Inline value mapping events + $(document).on('change', '.inline-value-mapping-select', (e) => + this.handleInlineValueMappingChange(e), + ); + $(document).on('click', '.auto-map-inline-btn', (e) => + this.handleAutoMapInline(e), + ); + $(document).on('click', '.clear-inline-btn', (e) => + this.handleClearInline(e), + ); + + // Import execution + $(document).on('click', '.execute-import-btn', () => + this.executeImport(), + ); + } + + // Step 1: Post Type Selection + loadPostTypes() { + const postTypesHtml = dtImport.postTypes + .map( + (postType) => ` +
+
+ +
+

${window.dt_admin_shared.escape(postType.label_plural)}

+

${window.dt_admin_shared.escape(postType.description)}

+
+ ${window.dt_admin_shared.escape(postType.label_singular)} +
+
+ `, + ) + .join(''); + + $('.post-type-grid').html(postTypesHtml); + } + + selectPostType(e) { + const $card = $(e.currentTarget); + const postType = $card.data('post-type'); + + $('.post-type-card').removeClass('selected'); + $card.addClass('selected'); + + this.selectedPostType = postType; + + // Clear cached field settings when post type changes + this.cachedFieldSettings = null; + + // Clear field mappings and do not import tracking when post type changes + this.fieldMappings = {}; + this.doNotImportColumns.clear(); + + $('.dt-import-next').prop('disabled', false); + + // Automatically proceed to step 2 + this.showStep2(); + } + + // Step 2: File Upload + nextStep() { + if (this.isProcessing) return; + + switch (this.currentStep) { + case 1: + if (!this.selectedPostType) { + this.showError(dtImport.translations.selectPostType); + return; + } + this.showStep2(); + break; + case 2: + if (!this.csvData) { + this.showError(dtImport.translations.noFileSelected); + return; + } + this.analyzeCSV(); + break; + case 3: + this.saveFieldMappings(); + break; + case 4: + this.executeImport(); + break; + } + } + + previousStep() { + if (this.isProcessing) return; + + this.currentStep--; + this.updateStepIndicator(); + this.showCurrentStep(); + this.updateNavigation(); + } + + showCurrentStep() { + switch (this.currentStep) { + case 1: + this.loadPostTypes(); + this.showStep1(); + break; + case 2: + this.showStep2(); + break; + case 3: + // Need to re-analyze CSV to show step 3 + if (this.sessionId) { + this.analyzeCSV(); + } + break; + case 4: + this.showStep4(); + break; + } + } + + showStep1() { + const step1Html = ` +
+

Step 1: Select Record Type

+

Choose the type of records you want to import from your CSV file.

+ +
+ +
+
+ `; + + $('.dt-import-step-content').html(step1Html); + this.loadPostTypes(); + + // Restore selected post type if any + if (this.selectedPostType) { + $( + `.post-type-card[data-post-type="${this.selectedPostType}"]`, + ).addClass('selected'); + } + } + + showStep2() { + this.currentStep = 2; + this.updateStepIndicator(); + + const step2Html = ` +
+

${window.dt_admin_shared.escape(dtImport.translations.uploadCsv)}

+

Upload a CSV file containing ${window.dt_admin_shared.escape(this.getPostTypeLabel())} data.

+ +
+
+
+ +
+
+

${window.dt_admin_shared.escape(dtImport.translations.chooseFile)}

+

${window.dt_admin_shared.escape(dtImport.translations.dragDropFile)}

+
+
+ + + +
+ +
+

CSV Options

+ + + + + + + + + +
+ +
+ +
+
+ + ${this.getImportOptionsHtml()} +
+ + `; + + $('.dt-import-step-content').html(step2Html); + this.updateNavigation(); + + // Bind file upload click handler after HTML is inserted + $('.file-upload-area') + .off('click') + .on('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + $('#csv-file-input').get(0).click(); + }); + + // Populate import options dropdowns + this.populateImportOptions(); + } + + handleFileSelect(e) { + const file = e.target.files[0]; + if (file) { + this.processFile(file); + } + } + + handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + $(e.currentTarget).addClass('drag-over'); + } + + handleFileDrop(e) { + e.preventDefault(); + e.stopPropagation(); + $(e.currentTarget).removeClass('drag-over'); + + const files = e.originalEvent.dataTransfer.files; + if (files.length > 0) { + this.processFile(files[0]); + } + } + + processFile(file) { + // Validate file + if (!file.name.toLowerCase().endsWith('.csv')) { + this.showError(dtImport.translations.invalidFileType); + return; + } + + if (file.size > dtImport.maxFileSize) { + this.showError( + `${window.dt_admin_shared.escape(dtImport.translations.fileTooLarge)} ${window.dt_admin_shared.escape(this.formatFileSize(dtImport.maxFileSize))}`, + ); + return; + } + + this.showProcessing(dtImport.translations.processingFile); + + // Upload file via REST API + const formData = new FormData(); + formData.append('csv_file', file); + formData.append('post_type', this.selectedPostType); + + fetch(`${dtImport.restUrl}upload`, { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + credentials: 'same-origin', + body: formData, + }) + .then((response) => { + return response.json().then((data) => { + return { response, data }; + }); + }) + .then(({ response, data }) => { + this.hideProcessing(); + + if (data.success) { + this.sessionId = data.data.session_id; + this.csvData = data.data; + this.showFileInfo(file, data.data); + $('.dt-import-next').prop('disabled', false); + this.showSuccess(dtImport.translations.fileUploaded); + } else { + this.showError(data.message || dtImport.translations.uploadError); + } + }) + .catch((error) => { + this.hideProcessing(); + this.showError(dtImport.translations.uploadError); + console.error('Upload error:', error); + }); + } + + showFileInfo(file, csvData) { + $('.file-upload-area').hide(); + $('.file-info').show(); + $('.file-info h4').text(file.name); + $('.file-size').text(this.formatFileSize(file.size)); + $('.file-rows').text( + `${window.dt_admin_shared.escape(csvData.row_count)} rows, ${window.dt_admin_shared.escape(csvData.column_count)} columns`, + ); + + $('.change-file-btn').on('click', () => { + $('.file-upload-area').show(); + $('.file-info').hide(); + this.csvData = null; + $('.dt-import-next').prop('disabled', true); + }); + } + + // Step 3: Field Mapping + analyzeCSV() { + if (!this.sessionId) return; + + // Capture import options from Step 2 before moving to Step 3 + const assignedToVal = $('#import-assigned-to').val(); + const sourceVal = $('#import-source').val(); + + // Update import options, preserving existing values if form fields are empty + this.importOptions = { + assigned_to: + assignedToVal && assignedToVal !== '' + ? assignedToVal + : this.importOptions + ? this.importOptions.assigned_to + : null, + source: + sourceVal && sourceVal !== '' + ? sourceVal + : this.importOptions + ? this.importOptions.source + : null, + delimiter: + $('#csv-delimiter').val() || + (this.importOptions ? this.importOptions.delimiter : ','), + encoding: + $('#csv-encoding').val() || + (this.importOptions ? this.importOptions.encoding : 'UTF-8'), + }; + + this.showProcessing('Analyzing CSV columns...'); + + fetch(`${dtImport.restUrl}${this.sessionId}/analyze`, { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((data) => { + this.hideProcessing(); + + if (data.success) { + // Handle new response format that may include saved data + const responseData = data.data; + let mappingSuggestions; + + if (responseData.mapping_suggestions) { + // New format with saved data + mappingSuggestions = responseData.mapping_suggestions; + + // Restore saved field mappings if they exist + if (responseData.saved_field_mappings) { + this.fieldMappings = responseData.saved_field_mappings; + } + + // Restore saved do_not_import_columns if they exist + if (responseData.saved_do_not_import_columns) { + this.doNotImportColumns = new Set( + responseData.saved_do_not_import_columns, + ); + + // Remove any field mappings for columns that are marked as "do not import" + // This handles cases where there might be conflicting data + this.doNotImportColumns.forEach((columnIndex) => { + if (this.fieldMappings[columnIndex]) { + delete this.fieldMappings[columnIndex]; + } + }); + } + } else { + // Old format - just mapping suggestions + mappingSuggestions = responseData; + } + + this.showStep3(mappingSuggestions); + } else { + this.showError(data.message || 'Failed to analyze CSV'); + } + }) + .catch((error) => { + this.hideProcessing(); + this.showError('Failed to analyze CSV'); + console.error('Analysis error:', error); + }); + } + + showStep3(mappingSuggestions) { + this.currentStep = 3; + this.updateStepIndicator(); + + // Extract metadata and remove it from suggestions + const meta = mappingSuggestions['_meta'] || {}; + delete mappingSuggestions['_meta']; + + const columnsHtml = Object.entries(mappingSuggestions) + .map(([index, mapping]) => { + return this.createColumnMappingCard(index, mapping); + }) + .join(''); + + const step3Html = ` +
+

${window.dt_admin_shared.escape(dtImport.translations.mapFields)}

+

Map each CSV column to the appropriate field in Disciple.Tools.

+ + + +
+
+ ${columnsHtml} +
+
+ + +
+ `; + + $('.dt-import-step-content').html(step3Html); + + // Initialize field mappings from suggested mappings ONLY if no existing mappings AND no do_not_import_columns + if ( + Object.keys(this.fieldMappings).length === 0 && + this.doNotImportColumns.size === 0 + ) { + // Initialize from suggestions for first-time display (only when no previous user choices exist) + Object.entries(mappingSuggestions).forEach(([index, mapping]) => { + if (mapping.suggested_field) { + this.fieldMappings[index] = { + field_key: mapping.suggested_field, + column_index: parseInt(index), + }; + } + }); + } + + // Restore existing field mappings to the dropdowns + setTimeout(() => { + // First, restore any existing user mappings to the dropdowns + Object.entries(this.fieldMappings).forEach(([columnIndex, mapping]) => { + const $select = $( + `.field-mapping-select[data-column-index="${columnIndex}"]`, + ); + if ($select.length && mapping.field_key) { + $select.val(mapping.field_key); + // Restore field-specific options with existing configurations + this.restoreFieldSpecificOptions( + parseInt(columnIndex), + mapping.field_key, + mapping, + ); + } + }); + + // Ensure "do not import" columns have empty dropdowns + this.doNotImportColumns.forEach((columnIndex) => { + const $select = $( + `.field-mapping-select[data-column-index="${columnIndex}"]`, + ); + if ($select.length) { + $select.val(''); + } + }); + + // For any dropdown that doesn't have an existing mapping but has a selected value, + // create a basic mapping (this handles auto-suggestions that weren't saved yet) + $('.field-mapping-select').each((index, select) => { + const $select = $(select); + const columnIndex = $select.data('column-index'); + const fieldKey = $select.val(); + + // Only create mapping if a field is selected and we don't already have a mapping + // AND the column is not marked as "do not import" + if ( + fieldKey && + fieldKey !== '' && + fieldKey !== 'create_new' && + !this.fieldMappings[columnIndex] && + !this.doNotImportColumns.has(columnIndex) + ) { + this.fieldMappings[columnIndex] = { + field_key: fieldKey, + column_index: parseInt(columnIndex), + }; + // Show basic field-specific options for new mappings + this.showFieldSpecificOptions(parseInt(columnIndex), fieldKey); + } + // Note: We don't modify doNotImportColumns here since we're just restoring the UI + }); + + // Update the summary and name field warning after mappings are restored + this.updateMappingSummary(); + this.updateNameFieldWarning(); + }, 100); + + this.updateNavigation(); + } + + createColumnMappingCard(columnIndex, mapping) { + const sampleDataHtml = mapping.sample_data + .slice(0, 3) + .map((sample) => `
  • ${this.escapeHtml(sample)}
  • `) + .join(''); + + // Check if there's an existing user mapping for this column + const existingMapping = this.fieldMappings[columnIndex]; + const isDoNotImport = this.doNotImportColumns.has(columnIndex); + + let selectedField; + if (isDoNotImport) { + // User explicitly chose "Do not import" + selectedField = ''; + } else if (existingMapping) { + // User has a field mapping + selectedField = existingMapping.field_key; + } else { + // No user choice yet, use auto-suggestion + selectedField = mapping.suggested_field; + } + + // For fields with no match and no existing mapping or explicit choice, ensure empty selection + const finalSelectedField = + !mapping.has_match && !existingMapping && !isDoNotImport + ? '' + : selectedField; + + return ` +
    +
    +

    ${this.escapeHtml(mapping.column_name)}

    + ${ + !mapping.has_match && !existingMapping + ? ` +
    + No match found +
    + ` + : '' + } +
    + +
    + Sample data: +
      ${sampleDataHtml}
    +
    + +
    + + + + +
    +
    + `; + } + + getFieldOptions(suggestedField) { + const fieldSettings = this.getFieldSettingsForPostType(); + + // Filter to ensure we have valid field configurations + const validFields = Object.entries(fieldSettings).filter( + ([fieldKey, fieldConfig]) => { + return fieldConfig && fieldConfig.name && fieldConfig.type; + }, + ); + + return validFields + .map(([fieldKey, fieldConfig]) => { + const selected = fieldKey === suggestedField ? 'selected' : ''; + const fieldName = this.escapeHtml(fieldConfig.name); + const fieldType = this.escapeHtml(fieldConfig.type); + const isNameField = fieldKey === 'name'; + const nameIndicator = isNameField ? ' (Required)' : ''; + return ``; + }) + .join(''); + } + + handleFieldMapping(e) { + const $select = $(e.target); + const columnIndex = $select.data('column-index'); + const fieldKey = $select.val(); + + // Skip processing if create_new is selected - modals file will handle it + if (fieldKey === 'create_new') { + return; + } + + // Check if we're trying to unmap the name field + const previousMapping = this.fieldMappings[columnIndex]; + if ( + previousMapping && + previousMapping.field_key === 'name' && + (!fieldKey || fieldKey === '') + ) { + // Check if there's another column mapped to name + const otherNameMapping = Object.entries(this.fieldMappings).find( + ([idx, mapping]) => + idx != columnIndex && mapping.field_key === 'name', + ); + + if (!otherNameMapping) { + // Don't allow unmapping the last name field + this.showError( + 'The name field is required and cannot be unmapped. Please map another column to the name field first.', + ); + $select.val(previousMapping.field_key); // Restore previous selection + return; + } + } + + // Check if we're trying to map to a field that's already mapped (especially name field) + if (fieldKey && fieldKey !== '') { + const existingMapping = Object.entries(this.fieldMappings).find( + ([idx, mapping]) => + idx != columnIndex && mapping.field_key === fieldKey, + ); + + if (existingMapping) { + if (fieldKey === 'name') { + // For name field, warn and ask if they want to replace + const confirmReplace = confirm( + `The name field is already mapped to column "${window.dt_admin_shared.escape(existingMapping[0])}". Do you want to replace it with this column?`, + ); + + if (confirmReplace) { + // Remove the existing mapping + delete this.fieldMappings[existingMapping[0]]; + // Update the UI for the previously mapped column + $( + `.field-mapping-select[data-column-index="${window.dt_admin_shared.escape(existingMapping[0])}"]`, + ).val(''); + } else { + $select.val(previousMapping ? previousMapping.field_key : ''); + return; + } + } else { + // For other fields, just warn and prevent duplicate mapping + this.showError( + `The ${window.dt_admin_shared.escape(fieldKey)} field is already mapped to another column. Please choose a different field.`, + ); + $select.val(previousMapping ? previousMapping.field_key : ''); + return; + } + } + } + + // Store mapping - properly handle empty values for "do not import" + if (fieldKey && fieldKey !== '') { + this.fieldMappings[columnIndex] = { + field_key: fieldKey, + column_index: columnIndex, + }; + // Remove from do not import set if it was there + this.doNotImportColumns.delete(columnIndex); + } else { + // When "do not import" is selected (empty value), remove the mapping entirely + delete this.fieldMappings[columnIndex]; + // Track that this column was explicitly set to "do not import" + this.doNotImportColumns.add(columnIndex); + } + + // Show field-specific options if needed + this.showFieldSpecificOptions(columnIndex, fieldKey); + this.updateMappingSummary(); + this.updateNameFieldWarning(); + } + + showFieldSpecificOptions(columnIndex, fieldKey) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + if (!fieldKey) { + $options.hide().empty().removeClass('connection-help'); + return; + } + + const fieldSettings = this.getFieldSettingsForPostType(); + const fieldConfig = fieldSettings[fieldKey]; + + if (!fieldConfig) { + $options.hide().empty().removeClass('connection-help'); + return; + } + + // Remove connection-help class first + $options.removeClass('connection-help'); + + if (['key_select', 'multi_select'].includes(fieldConfig.type)) { + this.showInlineValueMapping(columnIndex, fieldKey, fieldConfig); + } else if (fieldConfig.type === 'date') { + this.showDateFormatSelector(columnIndex, fieldKey); + } else if (fieldConfig.type === 'location_meta') { + this.showGeocodingServiceSelector(columnIndex, fieldKey); + } else if ( + fieldConfig.type === 'communication_channel' && + this.isCommunicationFieldForDuplicateCheck(fieldKey) + ) { + this.showDuplicateCheckingOptions(columnIndex, fieldKey, fieldConfig); + } else if (fieldConfig.type === 'connection') { + this.showConnectionFieldHelp(columnIndex, fieldKey, fieldConfig); + } else { + $options.hide().empty(); + } + } + + restoreFieldSpecificOptions(columnIndex, fieldKey, existingMapping) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + if (!fieldKey) { + $options.hide().empty().removeClass('connection-help'); + return; + } + + const fieldSettings = this.getFieldSettingsForPostType(); + const fieldConfig = fieldSettings[fieldKey]; + + if (!fieldConfig) { + $options.hide().empty().removeClass('connection-help'); + return; + } + + // Remove connection-help class first + $options.removeClass('connection-help'); + + if (['key_select', 'multi_select'].includes(fieldConfig.type)) { + this.restoreInlineValueMapping( + columnIndex, + fieldKey, + fieldConfig, + existingMapping, + ); + } else if (fieldConfig.type === 'date') { + this.restoreDateFormatSelector(columnIndex, fieldKey, existingMapping); + } else if (fieldConfig.type === 'location_meta') { + this.restoreGeocodingServiceSelector( + columnIndex, + fieldKey, + existingMapping, + ); + } else if ( + fieldConfig.type === 'communication_channel' && + this.isCommunicationFieldForDuplicateCheck(fieldKey) + ) { + this.restoreDuplicateCheckingOptions( + columnIndex, + fieldKey, + fieldConfig, + existingMapping, + ); + } else if (fieldConfig.type === 'connection') { + this.showConnectionFieldHelp(columnIndex, fieldKey, fieldConfig); + } else { + $options.hide().empty(); + } + } + + showInlineValueMapping(columnIndex, fieldKey, fieldConfig) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + // Get unique values from this CSV column (excluding header row) + this.getColumnUniqueValues(columnIndex) + .then((uniqueValues) => { + if (uniqueValues.length === 0) { + $options.hide().empty(); + return; + } + + // Get field options + this.getFieldOptionsForSelect(fieldKey) + .then((fieldOptions) => { + const mappingHtml = this.createInlineValueMappingHtml( + columnIndex, + fieldKey, + uniqueValues, + fieldOptions, + fieldConfig.type, + ); + $options.html(mappingHtml).show(); + + // Apply auto-mapping + this.autoMapInlineValues(columnIndex, fieldOptions); + + // Update field mappings with initial auto-mapped values + this.updateFieldMappingFromInline(columnIndex, fieldKey); + }) + .catch((error) => { + console.error('Error fetching field options:', error); + $options + .html('

    Error loading field options

    ') + .show(); + }); + }) + .catch((error) => { + console.error('Error fetching column data:', error); + $options + .html('

    Error loading column data

    ') + .show(); + }); + } + + showInlineValueMappingWithOptions( + columnIndex, + fieldKey, + fieldConfig, + fieldOptions, + ) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + // Get unique values from this CSV column (excluding header row) + this.getColumnUniqueValues(columnIndex) + .then((uniqueValues) => { + if (uniqueValues.length === 0) { + $options.hide().empty(); + return; + } + + // Use the provided field options directly + const mappingHtml = this.createInlineValueMappingHtml( + columnIndex, + fieldKey, + uniqueValues, + fieldOptions, + fieldConfig.type, + ); + $options.html(mappingHtml).show(); + + // Apply auto-mapping + this.autoMapInlineValues(columnIndex, fieldOptions); + + // Update field mappings with initial auto-mapped values + this.updateFieldMappingFromInline(columnIndex, fieldKey); + }) + .catch((error) => { + console.error('Error fetching column data:', error); + $options + .html('

    Error loading column data

    ') + .show(); + }); + } + + getColumnUniqueValues(columnIndex) { + // Get unique values from the current session's CSV data + return fetch( + `${dtImport.restUrl}${this.sessionId}/column-data?column_index=${columnIndex}`, + { + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + }, + ) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + return data.data.unique_values || []; + } else { + throw new Error(data.message || 'Failed to fetch column data'); + } + }); + } + + getFieldOptionsForSelect(fieldKey) { + return fetch( + `${dtImport.restUrl}${this.selectedPostType}/field-options?field_key=${fieldKey}`, + { + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + }, + ) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + return data.data; + } else { + throw new Error(data.message || 'Failed to fetch field options'); + } + }); + } + + createInlineValueMappingHtml( + columnIndex, + fieldKey, + uniqueValues, + fieldOptions, + fieldType, + ) { + const fieldOptionsHtml = Object.entries(fieldOptions) + .map( + ([key, label]) => + ``, + ) + .join(''); + + const valueMappingRows = uniqueValues + .slice(0, 10) + .map((csvValue) => { + // Limit to first 10 values + return ` + + + ${this.escapeHtml(csvValue)} + + + + + + `; + }) + .join(''); + + const moreValuesNote = + uniqueValues.length > 10 + ? `

    Showing first 10 of ${window.dt_admin_shared.escape(uniqueValues.length)} unique values

    ` + : ''; + + return ` +
    +
    Value Mapping (${window.dt_admin_shared.escape(fieldType) === 'multi_select' ? 'Multi-Select' : 'Dropdown'})
    +
    + + + + + + + + + ${valueMappingRows} + +
    CSV ValueMaps To
    +
    + ${moreValuesNote} +
    + + + 0 mapped +
    +
    + `; + } + + autoMapInlineValues(columnIndex, fieldOptions) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + + // Build reverse lookup for field options + const optionLookup = {}; + Object.entries(fieldOptions).forEach(([key, label]) => { + optionLookup[key.toLowerCase()] = key; + optionLookup[label.toLowerCase()] = key; + }); + + $card.find('.inline-value-mapping-select').each(function () { + const $select = $(this); + const csvValue = $select.data('csv-value'); + const csvValueLower = csvValue.toString().toLowerCase().trim(); + + // Try exact match first (key or label) + if (optionLookup[csvValueLower]) { + $select.val(optionLookup[csvValueLower]); + return; + } + + // Try partial matches + for (const [optionText, optionKey] of Object.entries(optionLookup)) { + if ( + csvValueLower.includes(optionText) || + optionText.includes(csvValueLower) + ) { + $select.val(optionKey); + break; + } + } + }); + + this.updateInlineMappingCount(columnIndex); + } + + updateInlineMappingCount(columnIndex) { + const $card = $( + `.column-mapping-card[data-column-index="${window.dt_admin_shared.escape(columnIndex)}"]`, + ); + const totalSelects = $card.find('.inline-value-mapping-select').length; + const mappedSelects = $card + .find('.inline-value-mapping-select') + .filter(function () { + return $(this).val() !== ''; + }).length; + + $card + .find('.inline-mapping-count') + .text( + `${window.dt_admin_shared.escape(mappedSelects)} of ${window.dt_admin_shared.escape(totalSelects)} mapped`, + ); + } + + updateFieldMappingFromInline(columnIndex, fieldKey) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const valueMappings = {}; + + $card.find('.inline-value-mapping-select').each(function () { + const $select = $(this); + const csvValue = $select.data('csv-value'); + const dtValue = $select.val(); + + if (dtValue) { + valueMappings[csvValue] = dtValue; + } + }); + + // Update the field mappings + if (!this.fieldMappings[columnIndex]) { + this.fieldMappings[columnIndex] = { + field_key: fieldKey, + column_index: columnIndex, + }; + } + + this.fieldMappings[columnIndex].value_mapping = valueMappings; + } + + // Step 4: Preview & Import + saveFieldMappings() { + // Check if at least one field is mapped + if (Object.keys(this.fieldMappings).length === 0) { + this.showError('Please map at least one field before proceeding.'); + return; + } + + // Check if name field is mapped (critical requirement) + const nameFieldMapped = Object.values(this.fieldMappings).some( + (mapping) => mapping.field_key === 'name', + ); + + if (!nameFieldMapped) { + this.showError( + 'The name field is required and must be mapped to a CSV column before proceeding with the import.', + ); + return; + } + + // Ensure all inline value mappings are up to date before saving + Object.entries(this.fieldMappings).forEach(([columnIndex, mapping]) => { + const fieldSettings = this.getFieldSettingsForPostType(); + const fieldConfig = fieldSettings[mapping.field_key]; + + if ( + fieldConfig && + ['key_select', 'multi_select'].includes(fieldConfig.type) + ) { + // Check if there are inline value mapping selects for this column + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + if ($card.find('.inline-value-mapping-select').length > 0) { + this.updateFieldMappingFromInline( + parseInt(columnIndex), + mapping.field_key, + ); + } + } + }); + this.showProcessing('Saving field mappings...'); + + // Use the import options that were captured in analyzeCSV() + const importOptions = this.importOptions || { + assigned_to: null, + source: null, + delimiter: ',', + encoding: 'UTF-8', + }; + + fetch(`${dtImport.restUrl}${this.sessionId}/mapping`, { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + mappings: this.fieldMappings, + do_not_import_columns: Array.from(this.doNotImportColumns), + import_options: importOptions, + }), + }) + .then((response) => response.json()) + .then((data) => { + this.hideProcessing(); + + if (data.success) { + this.showStep4(); + } else { + this.showError(data.message || 'Failed to save mappings'); + } + }) + .catch((error) => { + this.hideProcessing(); + this.showError('Failed to save mappings'); + console.error('Mapping save error:', error); + }); + } + + showStep4() { + this.currentStep = 4; + this.updateStepIndicator(); + + this.loadPreviewData(); + } + + loadPreviewData(offset = 0, limit = 10) { + this.showProcessing('Loading preview data...'); + + fetch( + `${dtImport.restUrl}${this.sessionId}/preview?offset=${offset}&limit=${limit}`, + { + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + }, + ) + .then((response) => response.json()) + .then((data) => { + this.hideProcessing(); + + if (data.success) { + this.displayPreview(data.data); + } else { + this.showError(data.message || 'Failed to load preview'); + } + }) + .catch((error) => { + this.hideProcessing(); + this.showError('Failed to load preview'); + console.error('Preview error:', error); + }); + } + + displayPreview(previewData) { + // Count total warnings across all rows + const totalWarnings = previewData.rows.reduce((count, row) => { + return count + (row.warnings ? row.warnings.length : 0); + }, 0); + + // Count total errors across all rows + const totalErrors = previewData.rows.reduce((count, row) => { + return count + (row.errors ? row.errors.length : 0); + }, 0); + + const step4Html = ` +
    +

    ${window.dt_admin_shared.escape(dtImport.translations.previewImport)}

    +

    Review the data before importing ${window.dt_admin_shared.escape(previewData.total_rows)} records.

    + + ${ + previewData.is_estimated + ? ` +
    +

    Large File Detected: Counts below are estimated based on a sample of ${window.dt_admin_shared.escape(previewData.sample_size)} rows for faster processing. Actual numbers may vary slightly during import.

    +
    + ` + : '' + } + +
    +
    +

    ${window.dt_admin_shared.escape(previewData.total_rows)}

    +

    Total Records

    +
    +
    +

    ${window.dt_admin_shared.escape(previewData.processable_count)}${previewData.is_estimated ? '*' : ''}

    +

    Will Import

    +
    + ${ + totalWarnings > 0 + ? ` +
    +

    ${window.dt_admin_shared.escape(totalWarnings)}

    +

    Warnings

    +
    + ` + : '' + } +
    +

    ${window.dt_admin_shared.escape(previewData.error_count)}${previewData.is_estimated ? '*' : ''}

    +

    Errors

    +
    +
    + + ${ + previewData.is_estimated + ? ` +

    * Estimated values based on sampling

    + ` + : '' + } + + ${ + totalErrors > 0 + ? ` +
    +
    +

    Import Errors

    +

    Some records have errors and will be skipped during import. Review the errors below for details.

    +
    +
    + ` + : '' + } + + ${ + totalWarnings > 0 + ? ` +
    +
    +

    Import Warnings

    +

    Some records will create new connection records. Review the preview below for details.

    +
    +
    + ` + : '' + } + +
    + ${this.createPreviewTable(previewData.rows)} +
    +
    + `; + + $('.dt-import-step-content').html(step4Html); + this.updateNavigation(); + + // Add import button to navigation area + $('.dt-import-navigation').append(` + + `); + } + + createPreviewTable(rows) { + if (!rows || rows.length === 0) { + return '

    No data to preview.

    '; + } + + // Get only the headers for fields that are actually being imported + // The preview data from the server only contains fields that have mappings + const headers = Object.keys(rows[0].data); + + if (headers.length === 0) { + return '

    No fields selected for import. Please go back and configure field mappings.

    '; + } + + // Get field settings to convert field keys to human-readable names + const fieldSettings = this.getFieldSettingsForPostType(); + + const headerHtml = + `Row #` + + headers + .map((fieldKey) => { + // Convert field key to human-readable field name + const fieldName = + fieldSettings[fieldKey] && fieldSettings[fieldKey].name + ? fieldSettings[fieldKey].name + : fieldKey; // fallback to field key if name not found + return `${this.escapeHtml(fieldName)}`; + }) + .join(''); + + const rowsHtml = rows + .map((row) => { + const hasWarnings = row.warnings && row.warnings.length > 0; + const willUpdate = row.will_update_existing || false; + const rowClass = row.has_errors + ? 'error-row' + : hasWarnings + ? 'warning-row' + : willUpdate + ? 'preview-row will-update' + : 'preview-row'; + + const cellsHtml = headers + .map((header) => { + const cellData = row.data[header]; + const cellClass = cellData && !cellData.valid ? 'error-cell' : ''; + const value = cellData ? cellData.processed || cellData.raw : ''; + return `${this.escapeHtml(this.formatCellValue(value))}`; + }) + .join(''); + + // Create warnings display + const warningsHtml = hasWarnings + ? ` + + +
    + Warnings: +
      + ${row.warnings.map((warning) => `
    • ${this.escapeHtml(warning)}
    • `).join('')} +
    +
    + + + ` + : ''; + + // Create errors display + const hasErrors = row.errors && row.errors.length > 0; + const errorsHtml = hasErrors + ? ` + + +
    + Errors: +
      + ${row.errors.map((error) => `
    • ${this.escapeHtml(error)}
    • `).join('')} +
    +
    + + + ` + : ''; + + // Add row number indicator with update status + const rowNumberDisplay = willUpdate + ? `Row ${window.dt_admin_shared.escape(row.row_number)} (UPDATE)` + : `Row ${window.dt_admin_shared.escape(row.row_number)}`; + + return ` + ${rowNumberDisplay} + ${cellsHtml} + ${errorsHtml}${warningsHtml}`; + }) + .join(''); + + return ` + + + ${headerHtml} + + + ${rowsHtml} + +
    + `; + } + + getColumnIndexForField(fieldKey) { + // Find the column index that maps to this field + for (const [columnIndex, mapping] of Object.entries(this.fieldMappings)) { + if (mapping.field_key === fieldKey) { + return parseInt(columnIndex); + } + } + return null; + } + + executeImport() { + if (this.isProcessing) return; + + this.showProcessing('Starting import...'); + this.isProcessing = true; + + fetch(`${dtImport.restUrl}${this.sessionId}/execute`, { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + // this.startProgressPolling(); + } else { + this.isProcessing = false; + this.hideProcessing(); + this.showError(data.message || 'Failed to start import'); + } + }) + .catch((error) => { + this.isProcessing = false; + this.hideProcessing(); + this.showError('Failed to start import'); + console.error('Import error:', error); + }); + this.startProgressPolling(); + } + + startProgressPolling() { + let isPolling = false; + + const pollStatus = () => { + if (isPolling) return; // Skip if previous request is still in progress + + isPolling = true; + + fetch(`${dtImport.restUrl}${this.sessionId}/status`, { + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + const status = data.data.status; + const progress = data.data.progress || 0; + + this.updateProgress(progress, status); + + if ( + status === 'completed' || + status === 'completed_with_errors' + ) { + clearInterval(pollInterval); + this.isProcessing = false; + this.hideProcessing(); + this.showImportResults(data.data); + return; // Don't continue polling + } else if (status === 'failed') { + clearInterval(pollInterval); + this.isProcessing = false; + this.hideProcessing(); + this.showError('Import failed'); + return; // Don't continue polling + } + } + }) + .catch((error) => { + console.error('Status polling error:', error); + }) + .finally(() => { + isPolling = false; // Reset flag when request completes + }); + }; + + const pollInterval = setInterval(pollStatus, 5000); // Poll every 5 seconds + pollStatus(); // Start first poll immediately + } + + updateProgress(progress, status) { + const $processingMessage = $('.processing-message p'); + + if (progress > 0) { + // Show percentage once progress is above 0% + $processingMessage.text( + `Importing records... ${window.dt_admin_shared.escape(progress)}%`, + ); + } else { + // Show just "Importing Records" when starting (0%) + $processingMessage.text('Importing Records'); + } + } + + showImportResults(results) { + const resultsHtml = ` +
    +

    Import Complete!

    +
    +
    +

    ${window.dt_admin_shared.escape(results.records_imported)}

    +

    Records Imported

    +
    + ${ + results.errors && results.errors.length > 0 + ? ` +
    +

    ${window.dt_admin_shared.escape(results.errors.length)}

    +

    Errors

    +
    + ` + : '' + } +
    + + ${ + results.imported_records && + results.imported_records.length > 0 + ? ` +
    +

    Imported Records:

    +
    + + ${ + results.records_imported > + results.imported_records.length + ? ` +

    + Showing first ${window.dt_admin_shared.escape(results.imported_records.length)} of ${window.dt_admin_shared.escape(results.records_imported)} imported records +

    + ` + : '' + } +
    +
    + ` + : '' + } + + ${ + results.errors && results.errors.length > 0 + ? ` +
    +

    Errors:

    +
      + ${results.errors + .map( + (error) => ` +
    • Row ${window.dt_admin_shared.escape(error.row)}: ${this.escapeHtml(error.message)}
    • + `, + ) + .join('')} +
    +
    + ` + : '' + } + +
    + +
    +
    + `; + + $('.dt-import-step-content').html(resultsHtml); + + // Mark step 4 as completed (green) since import is successful + this.markStepAsCompleted(); + + // Remove the import button from navigation + this.updateNavigation(); + } + + // Utility methods + updateStepIndicator() { + $('.dt-import-steps .step').removeClass('active completed'); + + $('.dt-import-steps .step').each((index, step) => { + const stepNum = index + 1; + if (stepNum < this.currentStep) { + $(step).addClass('completed'); + } else if (stepNum === this.currentStep) { + $(step).addClass('active'); + } + }); + } + + updateNavigation() { + const $backBtn = $('.dt-import-back'); + const $nextBtn = $('.dt-import-next'); + const $importBtn = $('.execute-import-btn'); + + // Clear any existing import buttons from navigation + $importBtn.remove(); + + // Back button + if (this.currentStep === 1) { + $backBtn.hide(); + } else { + $backBtn.show(); + } + + // Next button + if (this.currentStep === 4) { + $nextBtn.hide(); + } else { + $nextBtn.show(); + $nextBtn.prop('disabled', !this.canProceedToNextStep()); + } + } + + canProceedToNextStep() { + switch (this.currentStep) { + case 1: + return !!this.selectedPostType; + case 2: + return !!this.csvData; + case 3: { + // Check if at least one field is mapped AND the name field is mapped + const hasFieldMappings = Object.keys(this.fieldMappings).length > 0; + const nameFieldMapped = Object.values(this.fieldMappings).some( + (mapping) => mapping.field_key === 'name', + ); + return hasFieldMappings && nameFieldMapped; + } + default: + return true; + } + } + + updateMappingSummary() { + const mappedCount = Object.keys(this.fieldMappings).length; + const totalColumns = $('.column-mapping-card').length; + + // Check if name field is mapped + const nameFieldMapped = Object.values(this.fieldMappings).some( + (mapping) => mapping.field_key === 'name', + ); + + $('.mapping-summary').show(); + + if (!nameFieldMapped) { + $('.summary-stats').html(` +

    ${window.dt_admin_shared.escape(mappedCount)} of ${window.dt_admin_shared.escape(totalColumns)} columns mapped

    +

    ⚠ Name field is required and must be mapped

    + `); + } else { + $('.summary-stats').html(` +

    ${window.dt_admin_shared.escape(mappedCount)} of ${window.dt_admin_shared.escape(totalColumns)} columns mapped

    +

    ✓ Name field is mapped

    + `); + } + + // Disable next button if no mappings OR name field is not mapped + $('.dt-import-next').prop( + 'disabled', + mappedCount === 0 || !nameFieldMapped, + ); + } + + showProcessing(message) { + $('.dt-import-container').append(` +
    +
    +
    +

    ${window.dt_admin_shared.escape(message)}

    +
    +
    + `); + } + + hideProcessing() { + $('.processing-overlay').remove(); + } + + showError(message) { + // Use Toastify for error messages if available + if (typeof Toastify !== 'undefined') { + Toastify({ + text: message, + duration: 5000, + close: true, + gravity: 'bottom', + position: 'center', + backgroundColor: '#dc3232', + className: 'dt-import-toast-error', + stopOnFocus: true, + }).showToast(); + } else { + // Fallback to original method if Toastify is not available + $('.dt-import-errors') + .html( + ` +
    +

    ${this.escapeHtml(message)}

    +
    + `, + ) + .show(); + + setTimeout(() => { + $('.dt-import-errors').fadeOut(); + }, 5000); + } + } + + showSuccess(message) { + // Use Toastify for success messages if available + if (typeof Toastify !== 'undefined') { + Toastify({ + text: message, + duration: 3000, + close: true, + gravity: 'bottom', + position: 'center', + backgroundColor: '#46b450', + className: 'dt-import-toast-success', + stopOnFocus: true, + }).showToast(); + } else { + // Fallback to original method if Toastify is not available + $('.dt-import-errors') + .html( + ` +
    +

    ${this.escapeHtml(message)}

    +
    + `, + ) + .show(); + + setTimeout(() => { + $('.dt-import-errors').fadeOut(); + }, 3000); + } + } + + // Helper methods + getPostTypeIcon(postType) { + const icons = { + contacts: 'account', + groups: 'account-group', + default: 'file-document', + }; + return icons[postType] || icons.default; + } + + getPostTypeLabel() { + const postType = dtImport.postTypes.find( + (pt) => pt.key === this.selectedPostType, + ); + return postType ? postType.label_plural : ''; + } + + getFieldSettingsForPostType() { + // Return cached field settings if available + if (this.cachedFieldSettings) { + return this.cachedFieldSettings; + } + + // If we don't have a selected post type, return empty object + if (!this.selectedPostType) { + return {}; + } + + // Fetch field settings synchronously for current post type + // Note: This is acceptable for admin interface with small datasets + let fieldSettings = {}; + + $.ajax({ + url: `${dtImport.restUrl}${this.selectedPostType}/field-settings`, + method: 'GET', + async: false, // Synchronous to maintain current interface flow + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + success: (response) => { + if (response.success && response.data) { + fieldSettings = response.data; + this.cachedFieldSettings = fieldSettings; + } + }, + error: (xhr, status, error) => { + console.error('Failed to fetch field settings:', error); + // Fallback to basic structure + fieldSettings = { + title: { name: 'Name', type: 'text' }, + overall_status: { name: 'Status', type: 'key_select' }, + assigned_to: { name: 'Assigned To', type: 'user_select' }, + }; + }, + }); + + return fieldSettings; + } + + formatFileSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${Math.round(size * 100) / 100} ${units[unitIndex]}`; + } + + formatCellValue(value) { + if (value === null || value === undefined) { + return ''; + } + + // Handle objects with values array (multi_select, tags, communication_channels, etc.) + if ( + typeof value === 'object' && + value.values && + Array.isArray(value.values) + ) { + const values = value.values.map((item) => { + // Handle location_meta objects within values array + if (typeof item === 'object' && item.label !== undefined) { + return item.label; + } + if (typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }); + return values.join('; '); + } + + // Handle arrays directly + if (Array.isArray(value)) { + return value + .map((item) => { + // Handle location_meta objects in arrays + if (typeof item === 'object' && item.label !== undefined) { + return item.label; + } + if (typeof item === 'object' && item.value !== undefined) { + return item.value; + } + return item; + }) + .join('; '); + } + + // Handle location_meta objects directly + if (typeof value === 'object' && value.label !== undefined) { + return value.label; + } + + // Handle coordinate objects + if ( + typeof value === 'object' && + value.lat !== undefined && + value.lng !== undefined + ) { + return `Coordinates: ${value.lat}, ${value.lng}`; + } + + // Handle address objects + if (typeof value === 'object' && value.address !== undefined) { + return value.address; + } + + // Handle grid ID objects + if (typeof value === 'object' && value.grid_id !== undefined) { + return `Grid ID: ${value.grid_id}`; + } + + // Handle name objects + if (typeof value === 'object' && value.name !== undefined) { + return value.name; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + return String(value); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + handleInlineValueMappingChange(e) { + const $select = $(e.target); + const $card = $select.closest('.column-mapping-card'); + const columnIndex = $card.data('column-index'); + const fieldKey = $card.find('.field-mapping-select').val(); + + // Check if "-- Create --" option was selected + if ($select.val() === '__create__') { + this.handleCreateFieldOption($select, fieldKey); + return; + } + + // Update the mapping count + this.updateInlineMappingCount(columnIndex); + + // Update field mappings + this.updateFieldMappingFromInline(columnIndex, fieldKey); + } + + handleAutoMapInline(e) { + const $btn = $(e.target); + const columnIndex = $btn.data('column-index'); + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const fieldKey = $card.find('.field-mapping-select').val(); + + // Get field options and apply auto-mapping + this.getFieldOptionsForSelect(fieldKey) + .then((fieldOptions) => { + this.autoMapInlineValues(columnIndex, fieldOptions); + this.updateFieldMappingFromInline(columnIndex, fieldKey); + }) + .catch((error) => { + console.error('Error during auto-mapping:', error); + }); + } + + handleClearInline(e) { + const $btn = $(e.target); + const columnIndex = $btn.data('column-index'); + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const fieldKey = $card.find('.field-mapping-select').val(); + + // Clear all selections + $card.find('.inline-value-mapping-select').val(''); + + // Update count and mappings + this.updateInlineMappingCount(columnIndex); + this.updateFieldMappingFromInline(columnIndex, fieldKey); + } + + handleCreateFieldOption($select, fieldKey) { + const csvValue = $select.data('csv-value'); + const optionKey = this.sanitizeKey(csvValue); + const optionLabel = csvValue; + + // Show loading state + $select.prop('disabled', true); + const originalOptions = $select.html(); + $select.html(''); + + // Prepare the form data + const optionData = new FormData(); + optionData.append('post_type', this.selectedPostType); + optionData.append('tile_key', 'other'); + optionData.append('field_key', fieldKey); + optionData.append('field_option_name', optionLabel); + optionData.append('field_option_description', ''); + optionData.append('field_option_key', optionKey); + + // Create the field option + fetch( + `${dtImport.restUrl.replace('dt-csv-import/v2/', '')}dt-admin-settings/new-field-option`, + { + method: 'POST', + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + body: optionData, + }, + ) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + // Check if response is successful - API returns the field key directly on success + if ( + data && + (typeof data === 'string' || + (typeof data === 'object' && !data.error && !data.code)) + ) { + // Restore original options and add the new one + $select.html(originalOptions); + const newOption = ``; + $select.find('option[value="__create__"]').before(newOption); + $select.val(optionKey); + $select.prop('disabled', false); + + // Update the mapping count and field mappings + const $card = $select.closest('.column-mapping-card'); + const columnIndex = $card.data('column-index'); + this.updateInlineMappingCount(columnIndex); + this.updateFieldMappingFromInline(columnIndex, fieldKey); + } else { + // Handle error - data contains error information + console.error('Error creating field option:', data); + $select.html(originalOptions); + $select.val(''); + $select.prop('disabled', false); + const errorMessage = + data.error || data.message || 'Unknown error occurred'; + this.showError(`Error creating field option: ${errorMessage}`); + } + }) + .catch((error) => { + console.error('Error creating field option:', error); + $select.html(originalOptions); + $select.val(''); + $select.prop('disabled', false); + this.showError('Error creating field option. Please try again.'); + }); + } + + sanitizeKey(value) { + return value + .toString() + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/__+/g, '_') + .replace(/^_+|_+$/g, ''); + } + + markStepAsCompleted() { + // Mark step 4 as completed (green) and remove active state + $('.dt-import-steps .step').each((index, step) => { + const stepNum = index + 1; + if (stepNum === 4) { + $(step).removeClass('active').addClass('completed'); + } + }); + } + + showGeocodingServiceSelector(columnIndex, fieldKey) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + const geocodingSelectorHtml = ` +
    +
    ${window.dt_admin_shared.escape(dtImport.translations.geocodingService)}
    +
    + +
    +

    ${window.dt_admin_shared.escape(dtImport.translations.geocodingNote)}

    +

    ${window.dt_admin_shared.escape(dtImport.translations.geocodingOptional)}

    +
    +
    +
    + `; + + $options.html(geocodingSelectorHtml).show(); + + // Set default value to false if not already set + const currentMapping = this.fieldMappings[columnIndex]; + if ( + !currentMapping || + !currentMapping.geocode_service || + currentMapping.geocode_service === 'none' + ) { + $options.find('.geocoding-service-checkbox').prop('checked', false); + this.updateFieldMappingGeocodingService(columnIndex, false); + } else { + $options.find('.geocoding-service-checkbox').prop('checked', true); + } + } + + updateFieldMappingGeocodingService(columnIndex, isEnabled) { + // Update the field mappings with the geocoding enabled state + if (!this.fieldMappings[columnIndex]) { + // This shouldn't happen since field mapping should be set first + console.warn( + `No field mapping found for column ${window.dt_admin_shared.escape(columnIndex)}`, + ); + return; + } else { + // Convert boolean to service key for backend compatibility + this.fieldMappings[columnIndex].geocode_service = isEnabled + ? 'auto' + : 'none'; + } + + this.updateMappingSummary(); + } + + handleGeocodingServiceChange(e) { + const $checkbox = $(e.target); + const columnIndex = $checkbox.data('column-index'); + const isEnabled = $checkbox.prop('checked'); + + this.updateFieldMappingGeocodingService(columnIndex, isEnabled); + } + + showDuplicateCheckingOptions(columnIndex, fieldKey, fieldConfig) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + const duplicateCheckingHtml = ` +
    +
    ${window.dt_admin_shared.escape(dtImport.translations.duplicateChecking)}
    +
    + +

    + ${window.dt_admin_shared.escape(dtImport.translations.duplicateCheckingNote)} +

    +
    +
    + `; + + $options.html(duplicateCheckingHtml).show(); + + // Set default value to false if not already set + const currentMapping = this.fieldMappings[columnIndex]; + if (!currentMapping || !currentMapping.duplicate_checking) { + $options.find('.duplicate-checking-checkbox').prop('checked', false); + this.updateFieldMappingDuplicateChecking(columnIndex, false); + } else { + $options + .find('.duplicate-checking-checkbox') + .prop('checked', currentMapping.duplicate_checking); + } + } + + updateFieldMappingDuplicateChecking(columnIndex, isEnabled) { + // Update the field mappings with the selected duplicate checking state + if (!this.fieldMappings[columnIndex]) { + // This shouldn't happen since field mapping should be set first + console.warn( + `No field mapping found for column ${window.dt_admin_shared.escape(columnIndex)}`, + ); + return; + } else { + this.fieldMappings[columnIndex].duplicate_checking = isEnabled; + } + + this.updateMappingSummary(); + } + + isCommunicationFieldForDuplicateCheck(fieldKey) { + const fieldSettings = this.getFieldSettingsForPostType(); + const fieldConfig = fieldSettings[fieldKey]; + + if (fieldConfig && fieldConfig.type === 'communication_channel') { + return true; + } + return false; + } + + handleDuplicateCheckingChange(e) { + const $checkbox = $(e.target); + const columnIndex = $checkbox.data('column-index'); + const isEnabled = $checkbox.prop('checked'); + + this.updateFieldMappingDuplicateChecking(columnIndex, isEnabled); + } + + showDateFormatSelector(columnIndex, fieldKey) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + const dateFormatOptions = { + auto: 'Auto-detect (recommended)', + 'Y-m-d': 'YYYY-MM-DD (2024-01-15)', + 'm/d/Y': 'MM/DD/YYYY (01/15/2024)', + 'd/m/Y': 'DD/MM/YYYY (15/01/2024)', + 'F j, Y': 'Month Day, Year (January 15, 2024)', + 'j M Y': 'Day Mon Year (15 Jan 2024)', + 'Y-m-d H:i:s': 'YYYY-MM-DD HH:MM:SS (2024-01-15 14:30:00)', + }; + + const formatOptionsHtml = Object.entries(dateFormatOptions) + .map( + ([value, label]) => + ``, + ) + .join(''); + + const dateFormatHtml = ` +
    +
    Date Format
    +
    + + +

    + Auto-detect works for most formats, but specifying the exact format ensures accuracy. +

    +
    +
    + `; + + $options.html(dateFormatHtml).show(); + + // Set default value to 'auto' if not already set + const currentMapping = this.fieldMappings[columnIndex]; + if (!currentMapping || !currentMapping.date_format) { + $options.find('.date-format-select').val('auto'); + this.updateFieldMappingDateFormat(columnIndex, 'auto'); + } else { + $options.find('.date-format-select').val(currentMapping.date_format); + } + } + + updateFieldMappingDateFormat(columnIndex, dateFormat) { + // Update the field mappings with the selected date format + if (!this.fieldMappings[columnIndex]) { + // This shouldn't happen since field mapping should be set first + console.warn( + `No field mapping found for column ${window.dt_admin_shared.escape(columnIndex)}`, + ); + return; + } else { + this.fieldMappings[columnIndex].date_format = dateFormat; + } + + this.updateMappingSummary(); + } + + handleDateFormatChange(e) { + const $select = $(e.target); + const columnIndex = $select.data('column-index'); + const dateFormat = $select.val(); + + this.updateFieldMappingDateFormat(columnIndex, dateFormat); + } + + showConnectionFieldHelp(columnIndex, fieldKey, fieldConfig) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + const postTypeName = fieldConfig.post_type || 'records'; + const fieldName = fieldConfig.name || 'Connection'; + + const helpHtml = ` +
    +
    + + Connection Field +
    +
    +
      +
    • Record ID (number): Links to that specific record
    • +
    • Name (text): Searches for existing ${window.dt_admin_shared.escape(postTypeName)}
    • +
    • One match: Links to existing record
    • +
    • No match: Creates new record
    • +
    • Multiple matches: Connection skipped
    • +
    +
    +
    + `; + + $options.html(helpHtml).show().addClass('connection-help'); + } + + getImportOptionsHtml() { + if (!this.selectedPostType) { + return ''; + } + + // Use field settings from getFieldSettingsForPostType instead + const fieldSettings = this.getFieldSettingsForPostType(); + + let html = ''; + + // Check if this post type supports assigned_to field + if (fieldSettings && fieldSettings.assigned_to) { + html += ` +
    +

    Import Options

    + + + + + `; + } + + // Check if this post type supports sources field + if (fieldSettings && fieldSettings.sources) { + if (!html) { + html += ` +
    +

    Import Options

    +
    + +

    All imported records will be assigned to this user.

    +
    `; + } + html += ` + + + + `; + } + + if (html) { + html += ` +
    + +

    All imported records will have this source.

    +
    +
    `; + } + + return html; + } + + populateImportOptions() { + // Populate assigned_to dropdown + if ($('#import-assigned-to').length > 0) { + this.loadUsers() + .then((users) => { + const $select = $('#import-assigned-to'); + $select + .empty() + .append(''); + + users.forEach((user) => { + $select.append( + ``, + ); + }); + + // Restore previously selected value if it exists + if (this.importOptions && this.importOptions.assigned_to) { + $select.val(this.importOptions.assigned_to); + } + }) + .catch((error) => { + console.error('Error loading users:', error); + }); + } + + // Populate source dropdown + if ($('#import-source').length > 0) { + this.loadSources() + .then((sources) => { + const $select = $('#import-source'); + $select + .empty() + .append(''); + + sources.forEach((source) => { + $select.append( + ``, + ); + }); + + // Restore previously selected value if it exists + if (this.importOptions && this.importOptions.source) { + $select.val(this.importOptions.source); + } + }) + .catch((error) => { + console.error('Error loading sources:', error); + }); + } + + // Also restore the CSV options (delimiter and encoding) + if (this.importOptions) { + if (this.importOptions.delimiter) { + $('#csv-delimiter').val(this.importOptions.delimiter); + } + if (this.importOptions.encoding) { + $('#csv-encoding').val(this.importOptions.encoding); + } + } + } + + loadUsers() { + // Use the existing DT users endpoint + const restUrl = window.wpApiSettings + ? window.wpApiSettings.root + : '/wp-json/'; + const usersUrl = `${restUrl}dt/v1/users/get_users`; + + return $.ajax({ + url: usersUrl, + method: 'GET', + headers: { + 'X-WP-Nonce': dtImport.nonce, + }, + data: { + get_all: 1, // Get all assignable users + post_type: this.selectedPostType, // Pass the current post type + }, + }).then((response) => { + if (response && Array.isArray(response)) { + return response; + } else { + throw new Error('Invalid response format for assignable users'); + } + }); + } + + loadSources() { + const fieldSettings = this.getFieldSettingsForPostType(); + const sourcesField = fieldSettings.sources; + + if (sourcesField && sourcesField.default) { + // Convert sources field options to array format + const sources = Object.entries(sourcesField.default).map( + ([key, option]) => ({ + key: key, + label: option.label || key, + }), + ); + return Promise.resolve(sources); + } + + return Promise.resolve([]); + } + + // Restore methods for preserving field-specific configurations + restoreInlineValueMapping( + columnIndex, + fieldKey, + fieldConfig, + existingMapping, + ) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + const $options = $card.find('.field-specific-options'); + + // Get unique values from this CSV column (excluding header row) + this.getColumnUniqueValues(columnIndex) + .then((uniqueValues) => { + if (uniqueValues.length === 0) { + $options.hide().empty(); + return; + } + + // Get field options + this.getFieldOptionsForSelect(fieldKey) + .then((fieldOptions) => { + const mappingHtml = this.createInlineValueMappingHtml( + columnIndex, + fieldKey, + uniqueValues, + fieldOptions, + fieldConfig.type, + ); + $options.html(mappingHtml).show(); + + // Restore existing value mappings instead of auto-mapping + if (existingMapping.value_mapping) { + this.restoreInlineValueMappingSelections( + columnIndex, + existingMapping.value_mapping, + ); + // Update field mappings with restored values + this.updateFieldMappingFromInline(columnIndex, fieldKey); + } else { + // Apply auto-mapping if no existing mappings + this.autoMapInlineValues(columnIndex, fieldOptions); + // Update field mappings with auto-mapped values + this.updateFieldMappingFromInline(columnIndex, fieldKey); + } + + // Update count + this.updateInlineMappingCount(columnIndex); + }) + .catch((error) => { + console.error('Error fetching field options:', error); + $options + .html('

    Error loading field options

    ') + .show(); + }); + }) + .catch((error) => { + console.error('Error fetching column data:', error); + $options + .html('

    Error loading column data

    ') + .show(); + }); + } + + restoreInlineValueMappingSelections(columnIndex, valueMappings) { + const $card = $( + `.column-mapping-card[data-column-index="${columnIndex}"]`, + ); + + // Restore each value mapping + Object.entries(valueMappings).forEach(([csvValue, dtValue]) => { + const $select = $card.find( + `.inline-value-mapping-select[data-csv-value="${csvValue}"]`, + ); + if ($select.length) { + $select.val(dtValue); + } + }); + } + + restoreDateFormatSelector(columnIndex, fieldKey, existingMapping) { + // Show the date format selector first + this.showDateFormatSelector(columnIndex, fieldKey); + + // Then restore the selected value + setTimeout(() => { + if (existingMapping.date_format) { + const $select = $( + `.column-mapping-card[data-column-index="${columnIndex}"] .date-format-select`, + ); + if ($select.length) { + $select.val(existingMapping.date_format); + } + } + }, 50); + } + + restoreGeocodingServiceSelector(columnIndex, fieldKey, existingMapping) { + // Show the geocoding service selector first + this.showGeocodingServiceSelector(columnIndex, fieldKey); + + // Then restore the selected value + setTimeout(() => { + if (existingMapping.geocode_service) { + const isEnabled = existingMapping.geocode_service !== 'none'; + const $checkbox = $( + `.column-mapping-card[data-column-index="${columnIndex}"] .geocoding-service-checkbox`, + ); + if ($checkbox.length) { + $checkbox.prop('checked', isEnabled); + } + } + }, 50); + } + + restoreDuplicateCheckingOptions( + columnIndex, + fieldKey, + fieldConfig, + existingMapping, + ) { + // Show the duplicate checking options first + this.showDuplicateCheckingOptions(columnIndex, fieldKey, fieldConfig); + + // Then restore the selected value + setTimeout(() => { + if (existingMapping.duplicate_checking !== undefined) { + const $checkbox = $( + `.column-mapping-card[data-column-index="${columnIndex}"] .duplicate-checking-checkbox`, + ); + if ($checkbox.length) { + $checkbox.prop('checked', existingMapping.duplicate_checking); + } + } + }, 50); + } + + updateNameFieldWarning() { + // Check if name field is mapped + const nameFieldMapped = Object.values(this.fieldMappings).some( + (mapping) => mapping.field_key === 'name', + ); + + if (nameFieldMapped) { + $('.name-field-warning').hide(); + } else { + $('.name-field-warning').show(); + } + } + } + + // Initialize when DOM is ready + $(document).ready(() => { + if ($('.dt-import-container').length > 0 && !window.dtImportInstance) { + try { + window.dtImportInstance = new DTImport(); + } catch (error) { + console.error('Error initializing DT Import:', error); + } + } + }); +})(jQuery); diff --git a/dt-import/dt-import.php b/dt-import/dt-import.php new file mode 100644 index 000000000..dcac9f103 --- /dev/null +++ b/dt-import/dt-import.php @@ -0,0 +1,135 @@ +load_dependencies(); + + // Register the background import action hook + add_action( 'dt_csv_import_execute', [ DT_CSV_Import_Processor::class, 'execute_import' ] ); + + // Initialize admin interface if in admin + if ( is_admin() ) { + $this->init_admin(); + } + } + + private function load_dependencies() { + require_once plugin_dir_path( __FILE__ ) . 'includes/dt-import-utilities.php'; + require_once plugin_dir_path( __FILE__ ) . 'includes/dt-import-geocoding.php'; + require_once plugin_dir_path( __FILE__ ) . 'includes/dt-import-field-handlers.php'; + require_once plugin_dir_path( __FILE__ ) . 'admin/dt-import-mapping.php'; + require_once plugin_dir_path( __FILE__ ) . 'admin/dt-import-processor.php'; + require_once plugin_dir_path( __FILE__ ) . 'admin/rest-endpoints.php'; + + if ( is_admin() ) { + require_once plugin_dir_path( __FILE__ ) . 'admin/dt-import-admin-tab.php'; + } + } + + private function init_admin() { + DT_CSV_Import_Admin_Tab::instance(); + } + + public function activate() { + // Create secure temp directory outside web root + $dt_import_dir = get_temp_dir() . 'dt-import-temp/'; + + if ( !file_exists( $dt_import_dir ) ) { + wp_mkdir_p( $dt_import_dir ); + } + } + + public function deactivate() { + // Clean up temporary files + $this->cleanup_temp_files(); + } + + private function cleanup_temp_files() { + // Prevent concurrent cleanup processes + if ( get_transient( 'dt_import_cleanup_running' ) ) { + return; + } + set_transient( 'dt_import_cleanup_running', 1, 300 ); // 5 minutes lock + + try { + // Use secure temp directory outside web root + $dt_import_dir = get_temp_dir() . 'dt-import-temp/'; + + if ( file_exists( $dt_import_dir ) ) { + $files = glob( $dt_import_dir . '*' ); + foreach ( $files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + } + + // Clean up old import sessions (older than 24 hours) from dt_reports table + global $wpdb; + + // Get old import sessions to clean up their files + $old_sessions = $wpdb->get_results($wpdb->prepare( + "SELECT payload FROM $wpdb->dt_reports + WHERE type = 'import_session' + AND timestamp < %d", + strtotime( '-24 hours' ) + ), ARRAY_A); + + // Clean up associated files + foreach ( $old_sessions as $session ) { + if ( !empty( $session['payload'] ) ) { + $payload = maybe_unserialize( $session['payload'] ); + if ( isset( $payload['file_path'] ) && file_exists( $payload['file_path'] ) ) { + unlink( $payload['file_path'] ); + } + } + } + + // Delete old import session records + $wpdb->query($wpdb->prepare( + "DELETE FROM $wpdb->dt_reports + WHERE type = 'import_session' + AND timestamp < %d", + strtotime( '-24 hours' ) + )); + } finally { + // Always release the lock, even if an error occurs + delete_transient( 'dt_import_cleanup_running' ); + } + } +} + +// Initialize the plugin +DT_Theme_CSV_Import::instance(); diff --git a/dt-import/includes/dt-import-field-handlers.php b/dt-import/includes/dt-import-field-handlers.php new file mode 100644 index 000000000..d4825dfd6 --- /dev/null +++ b/dt-import/includes/dt-import-field-handlers.php @@ -0,0 +1,115 @@ + [ + [ + 'grid_id' => $location_result['grid_id'] + ] + ], + 'force_values' => false + ]; + } elseif ( isset( $location_result['lat'], $location_result['lng'] ) ) { + // Single coordinate pair + $result = [ + 'values' => [ + [ + 'lng' => $location_result['lng'], + 'lat' => $location_result['lat'] + ] + ], + 'force_values' => false + ]; + } elseif ( isset( $location_result['address_for_geocoding'] ) ) { + // Single address mapped to location_grid_meta - create contact_address with geocoding + $result = [ + 'contact_address' => [ + [ + 'value' => $location_result['address_for_geocoding'], + 'geolocate' => $geocode_service !== 'none' + ] + ] + ]; + } elseif ( isset( $location_result['contact_address'] ) ) { + // Multiple addresses mapped to location_grid_meta - use existing contact_address format + $result = [ + 'contact_address' => $location_result['contact_address'] + ]; + } else { + // Unknown format + return null; + } + + return $result; + + } catch ( Exception $e ) { + + return [ + 'error' => 'Location processing failed: ' . $e->getMessage() + ]; + } + } +} diff --git a/dt-import/includes/dt-import-geocoding.php b/dt-import/includes/dt-import-geocoding.php new file mode 100644 index 000000000..4ee88b448 --- /dev/null +++ b/dt-import/includes/dt-import-geocoding.php @@ -0,0 +1,356 @@ +get_var( $wpdb->prepare( + "SELECT grid_id FROM $wpdb->dt_location_grid WHERE grid_id = %d", + intval( $grid_id ) + ) ); + + return !empty( $exists ); + } + + /** + * Process location data for import + * Note: This doesn't do actual geocoding - it just formats data and sets geolocate flag + * DT core handles the actual geocoding when geolocate=true + */ + public static function process_for_import( $value, $geocode_service = 'none', $country_code = null, $preview_mode = false ) { + // Convert boolean geocoding flag to service name for backwards compatibility + if ( $geocode_service === true || $geocode_service === 'auto' ) { + $geocode_service = self::is_geocoding_available() ? 'enabled' : 'none'; + } elseif ( $geocode_service === false ) { + $geocode_service = 'none'; + } + + $value = trim( $value ); + + if ( empty( $value ) ) { + return null; + } + + $result = null; + + // In preview mode, don't perform actual geocoding - just return the raw values formatted for display + if ( $preview_mode ) { + $result = self::process_for_preview( $value ); + } else { + // Check if value contains multiple addresses separated by semicolons + if ( strpos( $value, ';' ) !== false ) { + $result = self::process_multiple_locations_for_dt( $value, $geocode_service, $country_code ); + } else { + // Process single location using DT's built-in capabilities + $result = self::process_single_location_for_dt( $value, $geocode_service, $country_code ); + } + } + + return $result; + } + + /** + * Process location data for preview mode (no geocoding) + * Just formats the raw values for display + */ + private static function process_for_preview( $value ) { + // Check if value contains multiple addresses separated by semicolons + if ( strpos( $value, ';' ) !== false ) { + $addresses = explode( ';', $value ); + $processed_locations = []; + + foreach ( $addresses as $address ) { + $address = trim( $address ); + if ( empty( $address ) ) { + continue; + } + + // For preview, just return the raw address/value with minimal processing + if ( is_numeric( $address ) ) { + $processed_locations[] = [ + 'label' => "Grid ID: {$address}", + 'raw_value' => $address, + 'preview_mode' => true + ]; + } elseif ( self::parse_dms_coordinates( $address ) !== null ) { + $processed_locations[] = [ + 'label' => "DMS Coordinates: {$address}", + 'raw_value' => $address, + 'preview_mode' => true + ]; + } elseif ( preg_match( '/^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/', $address ) ) { + $processed_locations[] = [ + 'label' => "Decimal Coordinates: {$address}", + 'raw_value' => $address, + 'preview_mode' => true + ]; + } else { + $processed_locations[] = [ + 'label' => $address, + 'raw_value' => $address, + 'preview_mode' => true + ]; + } + } + + return count( $processed_locations ) > 1 ? $processed_locations : ( $processed_locations[0] ?? null ); + } else { + // Single address/location for preview + if ( is_numeric( $value ) ) { + return [ + 'label' => "Grid ID: {$value}", + 'raw_value' => $value, + 'preview_mode' => true + ]; + } elseif ( self::parse_dms_coordinates( $value ) !== null ) { + return [ + 'label' => "DMS Coordinates: {$value}", + 'raw_value' => $value, + 'preview_mode' => true + ]; + } elseif ( preg_match( '/^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/', $value ) ) { + return [ + 'label' => "Decimal Coordinates: {$value}", + 'raw_value' => $value, + 'preview_mode' => true + ]; + } else { + return [ + 'label' => $value, + 'raw_value' => $value, + 'preview_mode' => true + ]; + } + } + } + + /** + * Process multiple locations using DT's built-in capabilities + */ + private static function process_multiple_locations_for_dt( $value, $geocode_service = 'none', $country_code = null ) { + $addresses = explode( ';', $value ); + $location_grid_values = []; + $address_values = []; + + foreach ( $addresses as $index => $address ) { + $address = trim( $address ); + if ( empty( $address ) ) { + continue; + } + + try { + $location_result = self::process_single_location_for_dt( $address, $geocode_service, $country_code ); + + if ( $location_result !== null ) { + if ( isset( $location_result['grid_id'] ) ) { + // Grid ID + $location_grid_values[] = [ + 'grid_id' => $location_result['grid_id'] + ]; + } elseif ( isset( $location_result['lat'], $location_result['lng'] ) ) { + // Coordinates + $location_grid_values[] = [ + 'lng' => $location_result['lng'], + 'lat' => $location_result['lat'] + ]; + } elseif ( isset( $location_result['address_for_geocoding'] ) ) { + // Address for geocoding - let DT core handle the geocoding + $address_values[] = [ + 'value' => $location_result['address_for_geocoding'], + 'geolocate' => $geocode_service !== 'none' + ]; + } + } + } catch ( Exception $e ) { + // Add as regular address without geocoding + $address_values[] = [ + 'value' => $address, + 'geolocate' => false + ]; + } + } + + $result = []; + + // Return location_grid_meta if we have grid IDs or coordinates + if ( !empty( $location_grid_values ) ) { + $result['location_grid_meta'] = [ + 'values' => $location_grid_values, + 'force_values' => false + ]; + } + + // Return contact_address if we have addresses to geocode + if ( !empty( $address_values ) ) { + $result['contact_address'] = $address_values; + } + + return $result; + } + + /** + * Process a single location using DT's built-in capabilities + */ + private static function process_single_location_for_dt( $value, $geocode_service = 'none', $country_code = null ) { + try { + // If it's a numeric grid ID, return grid format + if ( is_numeric( $value ) ) { + $grid_id = intval( $value ); + if ( self::validate_grid_id( $grid_id ) ) { + return [ + 'grid_id' => $grid_id + ]; + } else { + throw new Exception( "Invalid location grid ID: {$grid_id}" ); + } + } + + // Check if it's coordinates in DMS format (degrees, minutes, seconds) + $dms_coords = self::parse_dms_coordinates( $value ); + + if ( $dms_coords !== null ) { + $lat = $dms_coords['lat']; + $lng = $dms_coords['lng']; + + // Validate coordinates + if ( $lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 ) { + throw new Exception( "Invalid DMS coordinates: {$value}" ); + } + + return [ + 'lng' => $lng, + 'lat' => $lat + ]; + } + + // Check if it's coordinates in decimal format (lat,lng) + if ( preg_match( '/^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/', $value ) ) { + $coords = array_map( 'trim', explode( ',', $value ) ); + $lat = floatval( $coords[0] ); + $lng = floatval( $coords[1] ); + + // Validate coordinates + if ( $lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 ) { + throw new Exception( "Invalid coordinates: {$value}" ); + } + + return [ + 'lng' => $lng, + 'lat' => $lat + ]; + } + + // Treat as address - let DT handle the geocoding + return [ + 'address_for_geocoding' => $value + ]; + + } catch ( Exception $e ) { + // Return as regular address without geocoding on error + return [ + 'address_for_geocoding' => $value + ]; + } + } + + /** + * Parse DMS (Degrees, Minutes, Seconds) coordinates to decimal degrees + * Supports formats like: 35°50′40.9″N, 103°27′7.5″E + */ + public static function parse_dms_coordinates( $value ) { + // Pattern to match DMS coordinates + // Supports various symbols: ° ' " or d m s or deg min sec + // Supports both N/S/E/W notation + // Using Unicode-aware regex with proper UTF-8 character classes + $pattern = '/^ + \s* + (\d{1,3}) # degrees + [°d]? # degree symbol (optional) - ° or d + \s* + (\d{1,2}) # minutes + [′\'m]? # minute symbol (optional) - ′ or \' or m + \s* + ([\d.]+) # seconds (can be decimal) + [″"s]? # second symbol (optional) - ″ or " or s + \s* + ([NSEW]) # direction (required) + \s*,?\s* # comma separator (optional) + (\d{1,3}) # degrees + [°d]? # degree symbol (optional) - ° or d + \s* + (\d{1,2}) # minutes + [′\'m]? # minute symbol (optional) - ′ or \' or m + \s* + ([\d.]+) # seconds (can be decimal) + [″"s]? # second symbol (optional) - ″ or " or s + \s* + ([NSEW]) # direction (required) + \s* + $/xu'; + + if ( !preg_match( $pattern, $value, $matches ) ) { + return null; + } + + $lat_deg = intval( $matches[1] ); + $lat_min = intval( $matches[2] ); + $lat_sec = floatval( $matches[3] ); + $lat_dir = strtoupper( $matches[4] ); + + $lng_deg = intval( $matches[5] ); + $lng_min = intval( $matches[6] ); + $lng_sec = floatval( $matches[7] ); + $lng_dir = strtoupper( $matches[8] ); + + // Convert DMS to decimal degrees + $lat = $lat_deg + ( $lat_min / 60 ) + ( $lat_sec / 3600 ); + $lng = $lng_deg + ( $lng_min / 60 ) + ( $lng_sec / 3600 ); + + // Apply direction (negative for South and West) + if ( $lat_dir === 'S' ) { + $lat = -$lat; + } + if ( $lng_dir === 'W' ) { + $lng = -$lng; + } + + // Validate that we have proper direction indicators + if ( !in_array( $lat_dir, [ 'N', 'S' ] ) || !in_array( $lng_dir, [ 'E', 'W' ] ) ) { + return null; + } + + // Validate ranges + if ( $lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 ) { + return null; + } + + return [ + 'lat' => $lat, + 'lng' => $lng + ]; + } +} diff --git a/dt-import/includes/dt-import-utilities.php b/dt-import/includes/dt-import-utilities.php new file mode 100644 index 000000000..ce9afbc12 --- /dev/null +++ b/dt-import/includes/dt-import-utilities.php @@ -0,0 +1,372 @@ += $count ) { + break; + } + + if ( isset( $row[$column_index] ) && !empty( trim( $row[$column_index] ) ) ) { + $samples[] = trim( $row[$column_index] ); + $row_count++; + } + } + + return $samples; + } + + /** + * Normalize string for comparison + */ + public static function normalize_string( $string ) { + return strtolower( trim( preg_replace( '/[^a-zA-Z0-9]/', '', $string ) ) ); + } + + /** + * Save uploaded file to temporary directory + */ + public static function save_uploaded_file( $file_data ) { + // Use WordPress temp directory which is outside web root + $temp_dir = get_temp_dir() . 'dt-import-temp/'; + + // Ensure directory exists + if ( !file_exists( $temp_dir ) ) { + wp_mkdir_p( $temp_dir ); + } + + // Generate unique filename + $filename = 'import_' . uniqid() . '_' . sanitize_file_name( $file_data['name'] ); + $filepath = $temp_dir . $filename; + + // Path traversal protection - ensure file stays within temp directory + $real_temp_dir = realpath( $temp_dir ); + $real_filepath = realpath( dirname( $filepath ) ) . '/' . basename( $filepath ); + + if ( $real_temp_dir === false || strpos( $real_filepath, $real_temp_dir ) !== 0 ) { + return new WP_Error( 'invalid_path', 'Invalid file path detected' ); + } + + // Move uploaded file + if ( move_uploaded_file( $file_data['tmp_name'], $filepath ) ) { + return $filepath; + } + + return false; + } + + /** + * Validate file upload + */ + public static function validate_file_upload( $file_data ) { + $errors = []; + + // Check for upload errors + if ( $file_data['error'] !== UPLOAD_ERR_OK ) { + $errors[] = __( 'File upload failed.', 'disciple_tools' ); + } + + // Check file size (10MB limit) + if ( $file_data['size'] > 10 * 1024 * 1024 ) { + $errors[] = __( 'File size exceeds 10MB limit.', 'disciple_tools' ); + } + + // Check file type + $file_info = finfo_open( FILEINFO_MIME_TYPE ); + $mime_type = finfo_file( $file_info, $file_data['tmp_name'] ); + finfo_close( $file_info ); + + $allowed_types = [ 'text/csv', 'text/plain', 'application/csv' ]; + if ( !in_array( $mime_type, $allowed_types ) ) { + $errors[] = __( 'Invalid file type. Please upload a CSV file.', 'disciple_tools' ); + } + + // Check file extension + $file_extension = strtolower( pathinfo( $file_data['name'], PATHINFO_EXTENSION ) ); + if ( $file_extension !== 'csv' ) { + $errors[] = __( 'Invalid file extension. Please upload a .csv file.', 'disciple_tools' ); + } + + return $errors; + } + + /** + * Create custom field for post type + */ + public static function create_custom_field( $post_type, $field_key, $field_config ) { + // Check if field already exists + $existing_fields = DT_Posts::get_post_field_settings( $post_type ); + if ( isset( $existing_fields[$field_key] ) ) { + return new WP_Error( 'field_exists', __( 'Field already exists.', 'disciple_tools' ) ); + } + + // Validate field type + $allowed_types = [ 'text', 'textarea', 'number', 'date', 'boolean', 'key_select', 'multi_select', 'tags', 'communication_channel', 'connection', 'user_select', 'location' ]; + if ( !in_array( $field_config['type'], $allowed_types ) ) { + return new WP_Error( 'invalid_field_type', __( 'Invalid field type.', 'disciple_tools' ) ); + } + + // Create field using DT's field customization API + $field_settings = [ + 'name' => $field_config['name'], + 'description' => $field_config['description'] ?? '', + 'type' => $field_config['type'], + 'default' => $field_config['default'] ?? [], + 'tile' => 'other' + ]; + + // Add field using DT's field customization hooks + add_filter('dt_custom_fields_settings', function( $fields, $post_type_filter ) use ( $post_type, $field_key, $field_settings ) { + if ( $post_type_filter === $post_type ) { + $fields[$field_key] = $field_settings; + } + return $fields; + }, 10, 2); + + // Force refresh of field settings cache + wp_cache_delete( $post_type . '_field_settings', 'dt_post_fields' ); + + return true; + } + + /** + * Clean old temporary files + */ + public static function cleanup_old_files( $hours = 24 ) { + // Prevent concurrent cleanup processes + $lock_key = 'dt_import_cleanup_old_files_running'; + if ( get_transient( $lock_key ) ) { + return; + } + set_transient( $lock_key, 1, 300 ); // 5 minutes lock + + try { + // Use WordPress temp directory which is outside web root + $temp_dir = get_temp_dir() . 'dt-import-temp/'; + + if ( !file_exists( $temp_dir ) ) { + return; + } + + $files = glob( $temp_dir . '*' ); + $cutoff_time = time() - ( $hours * 3600 ); + + foreach ( $files as $file ) { + // Additional security: validate each file path before deletion + if ( is_file( $file ) && self::validate_file_path( $file ) && filemtime( $file ) < $cutoff_time ) { + unlink( $file ); + } + } + } finally { + // Always release the lock, even if an error occurs + delete_transient( $lock_key ); + } + } + + /** + * Format file size for display + */ + public static function format_file_size( $bytes ) { + $units = [ 'B', 'KB', 'MB', 'GB' ]; + $factor = 1024; + $units_count = count( $units ); + + for ( $i = 0; $bytes >= $factor && $i < $units_count - 1; $i++ ) { + $bytes /= $factor; + } + + return round( $bytes, 2 ) . ' ' . $units[$i]; + } + + /** + * Convert various date formats to Y-m-d + */ + public static function normalize_date( $date_string, $format = 'auto' ) { + if ( empty( $date_string ) ) { + return ''; + } + + // If format is specified and not 'auto', try to parse with that format + if ( $format !== 'auto' ) { + $date = DateTime::createFromFormat( $format, $date_string ); + if ( $date !== false ) { + return $date->format( 'Y-m-d' ); + } + // If specified format fails, fall through to auto-detection + } + + // Auto-detection: Try multiple common formats + $formats_to_try = [ + 'Y-m-d', // 2024-01-15 + 'Y-m-d H:i:s', // 2024-01-15 14:30:00 + 'm/d/Y', // 01/15/2024 + 'd/m/Y', // 15/01/2024 + 'F j, Y', // January 15, 2024 + 'j M Y', // 15 Jan 2024 + 'M j, Y', // Jan 15, 2024 + 'j F Y', // 15 January 2024 + 'd-m-Y', // 15-01-2024 + 'm-d-Y', // 01-15-2024 + ]; + + foreach ( $formats_to_try as $try_format ) { + $date = DateTime::createFromFormat( $try_format, $date_string ); + if ( $date !== false ) { + return $date->format( 'Y-m-d' ); + } + } + + // Fallback to strtotime for other formats + $timestamp = strtotime( $date_string ); + if ( $timestamp !== false ) { + return gmdate( 'Y-m-d', $timestamp ); + } + + return ''; + } + + /** + * Convert boolean-like values to boolean + */ + public static function normalize_boolean( $value ) { + $value = strtolower( trim( $value ) ); + + $true_values = [ 'true', 'yes', 'y', '1', 'on', 'enabled' ]; + $false_values = [ 'false', 'no', 'n', '0', 'off', 'disabled' ]; + + if ( in_array( $value, $true_values ) ) { + return true; + } elseif ( in_array( $value, $false_values ) ) { + return false; + } + + return null; + } + + /** + * Split multi-value string (semicolon separated by default) + */ + public static function split_multi_value( $value, $separator = ';' ) { + if ( empty( $value ) ) { + return []; + } + + $values = explode( $separator, $value ); + return array_map( 'trim', $values ); + } + + /** + * Validate that a file path is within the allowed temp directory + */ + public static function validate_file_path( $file_path ) { + if ( empty( $file_path ) ) { + return false; + } + + // Use WordPress temp directory which is outside web root + $temp_dir = get_temp_dir() . 'dt-import-temp/'; + $real_temp_dir = realpath( $temp_dir ); + + // If temp directory doesn't exist, it's invalid + if ( $real_temp_dir === false ) { + return false; + } + + // Get the real path of the file + $real_file_path = realpath( $file_path ); + + // If file doesn't exist yet or path is invalid, check the parent directory + if ( $real_file_path === false ) { + $parent_dir = realpath( dirname( $file_path ) ); + if ( $parent_dir === false ) { + return false; + } + $real_file_path = $parent_dir . '/' . basename( $file_path ); + } + + // Ensure the file path is within the temp directory + return strpos( $real_file_path, $real_temp_dir ) === 0; + } +} diff --git a/dt-posts/posts.php b/dt-posts/posts.php index 389ce1192..fea8f227e 100644 --- a/dt-posts/posts.php +++ b/dt-posts/posts.php @@ -1872,13 +1872,17 @@ public static function update_post_contact_methods( array $post_settings, int $p if ( in_array( $field['value'], $existing_values, true ) ){ continue; } - $potential_error = self::add_post_contact_method( $post_settings, $post_id, $field['key'], $field['value'], $field ); - if ( is_wp_error( $potential_error ) ){ - return $potential_error; - } + $is_address_to_geocode = $details_key === 'contact_address' && isset( $field['geolocate'] ) && !empty( $field['geolocate'] ); + $geocode_potential_error = null; // Geocode any identified addresses ahead of field creation - if ( $details_key === 'contact_address' && isset( $field['geolocate'] ) && !empty( $field['geolocate'] ) ){ - $potential_error = self::geolocate_addresses( $post_id, $post_settings['post_type'], $details_key, $field['value'] ); + if ( $is_address_to_geocode ){ + $geocode_potential_error = self::geolocate_addresses( $post_id, $post_settings['post_type'], $details_key, $field['value'] ); + } + if ( !$is_address_to_geocode || is_wp_error( $geocode_potential_error ) || empty( $geocode_potential_error ) ){ + $potential_error = self::add_post_contact_method( $post_settings, $post_id, $field['key'], $field['value'], $field ); + if ( is_wp_error( $potential_error ) ){ + return $potential_error; + } } } } else { diff --git a/functions.php b/functions.php index 0722377e8..afa0cb177 100755 --- a/functions.php +++ b/functions.php @@ -323,6 +323,12 @@ public function __construct() { require_once( 'dt-workflows/workflows.php' ); Disciple_Tools_Workflows::instance(); + /** + * dt-import + */ + require_once( 'dt-import/dt-import.php' ); + DT_Theme_CSV_Import::instance(); + /** * core */