Plugin Directory

Changeset 3796621


Ignore:
Timestamp:
11/11/2026 03:36:06 PM (less than one hour ago)
Author:
publishly
Message:

Release Tabloria 0.2.2

Location:
tabloria-searchable-displays
Files:
18 edited
1 copied

Legend:

Unmodified
Added
Removed
  • tabloria-searchable-displays/tags/0.2.2/includes/Database.php

    r3780707 r3796621  
    8989                $rows    = $this->sanitizer->sanitize_rows( $rows, $columns );
    9090                $now = current_time( 'mysql' );
     91                if ( false === $wpdb->query( 'START TRANSACTION' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Keep source metadata and rows together.
     92                        return 0;
     93                }
    9194                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables.
    92                 $wpdb->insert(
     95                $inserted = $wpdb->insert(
    9396                        self::sources_table(),
    9497                        array(
    … …  
    106109
    107110                $source_id = absint( $wpdb->insert_id );
    108                 $this->replace_rows( $source_id, $rows );
     111                if ( false === $inserted || ! $source_id || ! $this->replace_rows( $source_id, $rows ) ) {
     112                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Undo an incomplete source import.
     113                        return 0;
     114                }
     115                if ( false === $wpdb->query( 'COMMIT' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Commit a complete source import.
     116                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Best effort after failed commit.
     117                        return 0;
     118                }
    109119
    110120                return $source_id;
    … …  
    116126                $columns = $this->sanitizer->sanitize_columns( $columns );
    117127                $rows    = $this->sanitizer->sanitize_rows( $rows, $columns );
     128                if ( false === $wpdb->query( 'START TRANSACTION' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Keep old data until the whole replacement is ready.
     129                        return false;
     130                }
    118131
    119132                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables.
    … …  
    133146
    134147                if ( false === $updated ) {
    135                         return false;
    136                 }
    137 
    138                 $this->replace_rows( $source_id, $rows );
     148                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Preserve previous source data.
     149                        return false;
     150                }
     151
     152                if ( ! $this->replace_rows( $source_id, $rows ) ) {
     153                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Preserve previous source data.
     154                        return false;
     155                }
     156
     157                if ( false === $wpdb->query( 'COMMIT' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Commit the complete replacement.
     158                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Best effort after failed commit.
     159                        return false;
     160                }
    139161                return true;
    140162        }
    141163
    142         public function replace_rows( int $source_id, array $rows ): void {
     164        public function replace_rows( int $source_id, array $rows ): bool {
    143165                global $wpdb;
    144166
    145167                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Replacing rows in a plugin-owned custom table.
    146                 $wpdb->delete( self::rows_table(), array( 'source_id' => $source_id ), array( '%d' ) );
     168                if ( false === $wpdb->delete( self::rows_table(), array( 'source_id' => $source_id ), array( '%d' ) ) ) {
     169                        return false;
     170                }
    147171
    148172                $now = current_time( 'mysql' );
    … …  
    153177
    154178                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheet rows in a plugin-owned custom table.
    155                         $wpdb->insert(
     179                        $inserted = $wpdb->insert(
    156180                                self::rows_table(),
    157181                                array(
    … …  
    163187                                array( '%d', '%d', '%s', '%s' )
    164188                        );
    165                 }
     189                        if ( false === $inserted ) {
     190                                return false;
     191                        }
     192                }
     193                return true;
    166194        }
    167195
  • tabloria-searchable-displays/tags/0.2.2/includes/REST/DisplaysController.php

    r3780707 r3796621  
    221221                }
    222222
    223                 require_once ABSPATH . 'wp-admin/includes/file.php';
    224                 $upload = wp_handle_upload(
    225                         $file,
    226                         array(
    227                                 'test_form' => false,
    228                                 'test_type' => false,
    229                         )
    230                 );
    231                 if ( isset( $upload['error'] ) ) {
    232                         return new WP_Error( 'tabloria_upload_failed', sanitize_text_field( $upload['error'] ), array( 'status' => 400 ) );
    233                 }
    234 
    235                 $parsed = ( new CsvParser() )->parse( $upload['file'] );
     223                $parsed = ( new CsvParser() )->parse( $file['tmp_name'] );
    236224                if ( is_wp_error( $parsed ) ) {
    237225                        return $parsed;
    … …  
    239227
    240228                $summary = ( new DataUpdater() )->compare_columns( $source['columns'], $parsed['columns'] );
    241                 $this->database->update_source_data( absint( $source['id'] ), $parsed['columns'], $parsed['rows'], $meta['filename'], 'csv' );
     229                if ( $summary['missingCount'] > 0 ) {
     230                        $missing = array_map( static fn( array $column ): string => (string) ( $column['label'] ?? $column['key'] ?? '' ), array_slice( $summary['missing'], 0, 3 ) );
     231                        return new WP_Error(
     232                                'tabloria_csv_missing_columns',
     233                                sprintf(
     234                                        /* translators: %s: comma-separated column names. */
     235                                        __( 'The replacement is missing existing columns (%s). Keep the same headers so saved Display fields keep working. No data was changed.', 'tabloria-searchable-displays' ),
     236                                        implode( ', ', $missing )
     237                                ),
     238                                array( 'status' => 409 )
     239                        );
     240                }
     241                if ( ! $this->database->update_source_data( absint( $source['id'] ), $parsed['columns'], $parsed['rows'], $meta['filename'], 'csv' ) ) {
     242                        return new WP_Error( 'tabloria_source_update_failed', __( 'The replacement could not be saved. The previous CSV data is still available.', 'tabloria-searchable-displays' ), array( 'status' => 500 ) );
     243                }
    242244
    243245                return new WP_REST_Response(
  • tabloria-searchable-displays/tags/0.2.2/includes/REST/UploadController.php

    r3780769 r3796621  
    5656                }
    5757
    58                 require_once ABSPATH . 'wp-admin/includes/file.php';
    59 
    60                 $upload = wp_handle_upload(
    61                         $file,
    62                         array(
    63                                 'test_form' => false,
    64                                 'test_type' => false,
    65                                 'mimes'     => array(
    66                                         'csv' => 'text/csv',
    67                                 ),
    68                         )
    69                 );
    70 
    71                 if ( isset( $upload['error'] ) ) {
    72                         return new WP_Error( 'tabloria_upload_failed', sanitize_text_field( $upload['error'] ), array( 'status' => 400 ) );
    73                 }
    74 
    75                 $parsed = ( new CsvParser() )->parse( $upload['file'] );
     58                // PHP removes the request's temporary file after this request. The parsed rows
     59                // live in our database, so keeping a raw CSV in public uploads is unnecessary.
     60                $parsed = ( new CsvParser() )->parse( $file['tmp_name'] );
    7661
    7762                if ( is_wp_error( $parsed ) ) {
    … …  
    8873                        $parsed['rows']
    8974                );
     75                if ( ! $source_id ) {
     76                        return new WP_Error( 'tabloria_source_save_failed', __( 'The CSV was read, but its data could not be saved. Please try again.', 'tabloria-searchable-displays' ), array( 'status' => 500 ) );
     77                }
    9078
    9179                $source = $this->database->get_source( $source_id );
  • tabloria-searchable-displays/tags/0.2.2/includes/Services/CsvParser.php

    r3544916 r3796621  
    2424                }
    2525
    26                 $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV parsing requires a stream handle for fgetcsv().
     26                $contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The file was capped at 5 MB above.
     27                if ( false === $contents ) {
     28                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) );
     29                }
     30                if ( str_starts_with( $contents, "\xFF\xFE" ) || str_starts_with( $contents, "\xFE\xFF" ) ) {
     31                        $encoding = str_starts_with( $contents, "\xFF\xFE" ) ? 'UTF-16LE' : 'UTF-16BE';
     32                        $contents = function_exists( 'iconv' ) ? iconv( $encoding, 'UTF-8', substr( $contents, 2 ) ) : false;
     33                        if ( false === $contents ) {
     34                                return new WP_Error( 'tabloria_csv_encoding', __( 'This CSV could not be converted to UTF-8. Export it as UTF-8 CSV and try again.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     35                        }
     36                } elseif ( str_starts_with( $contents, "\xEF\xBB\xBF" ) ) {
     37                        $contents = substr( $contents, 3 );
     38                }
     39                if ( 1 !== preg_match( '//u', $contents ) ) {
     40                        return new WP_Error( 'tabloria_csv_encoding', __( 'The CSV is not valid UTF-8. Export it as UTF-8 CSV and try again.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     41                }
     42
     43                $handle = fopen( 'php://temp', 'w+b' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- fgetcsv() requires a seekable stream.
    2744                if ( false === $handle ) {
    2845                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) );
    2946                }
     47                $written = fwrite( $handle, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes to a private temporary stream only.
     48                if ( false === $written || strlen( $contents ) !== $written ) {
     49                        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the temporary stream on a short write.
     50                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be read completely.', 'tabloria-searchable-displays' ) );
     51                }
     52                $delimiter = $this->detect_delimiter( $handle );
     53                rewind( $handle );
    3054
    31                 $headers = fgetcsv( $handle );
     55                $headers = fgetcsv( $handle, 0, $delimiter, '"', '' );
    3256                if ( ! is_array( $headers ) || empty( $headers ) ) {
    3357                        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
    … …  
    4569                $row_index = 0;
    4670
    47                 while ( false !== ( $data = fgetcsv( $handle ) ) ) {
     71                while ( false !== ( $data = fgetcsv( $handle, 0, $delimiter, '"', '' ) ) ) {
    4872                        if ( $this->is_blank_row( $data ) ) {
    4973                                continue;
    … …  
    5377                                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
    5478                                return new WP_Error( 'tabloria_csv_too_many_rows', __( 'The CSV file has too many rows for this MVP release.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     79                        }
     80                        if ( count( $data ) !== count( $headers ) ) {
     81                                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the temporary stream.
     82                                return new WP_Error( 'tabloria_csv_column_mismatch', sprintf( __( 'CSV row %d has a different number of columns than the header. Check the delimiter and quoted values.', 'tabloria-searchable-displays' ), $row_index + 2 ), array( 'status' => 400 ) );
    5583                        }
    5684
    … …  
    6593
    6694                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
     95                if ( 0 === $row_index ) {
     96                        return new WP_Error( 'tabloria_csv_no_rows', __( 'The CSV needs at least one data row.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     97                }
    6798
    6899                $columns = $this->detector->detect_columns( $headers, $rows );
    … …  
    78109                        'sample'  => array_slice( $rows, 0, 24 ),
    79110                );
     111        }
     112
     113        private function detect_delimiter( $handle ): string {
     114                $best = ',';
     115                $best_score = -1;
     116                foreach ( array( ',', ';', "\t" ) as $candidate ) {
     117                        rewind( $handle );
     118                        $widths = array();
     119                        while ( count( $widths ) < 8 && false !== ( $row = fgetcsv( $handle, 0, $candidate, '"', '' ) ) ) {
     120                                if ( ! $this->is_blank_row( $row ) ) {
     121                                        $widths[] = count( $row );
     122                                }
     123                        }
     124                        if ( ! $widths ) {
     125                                continue;
     126                        }
     127                        $header_width = $widths[0];
     128                        $matching = count( array_filter( $widths, static fn( int $width ): bool => $width === $header_width ) );
     129                        $score = $header_width > 1 ? ( $matching * 100 + $header_width ) : 0;
     130                        if ( $score > $best_score ) {
     131                                $best_score = $score;
     132                                $best = $candidate;
     133                        }
     134                }
     135                return $best;
    80136        }
    81137
  • tabloria-searchable-displays/tags/0.2.2/includes/Services/DataUpdater.php

    r3544916 r3796621  
    88
    99class DataUpdater {
    10         private ColumnDetector $detector;
    11 
    12         public function __construct( ?ColumnDetector $detector = null ) {
    13                 $this->detector = $detector ?: new ColumnDetector();
    14         }
    15 
    1610        public function compare_columns( array $old_columns, array $new_columns ): array {
    1711                $old_by_key = array();
    … …  
    4539                        }
    4640
    47                         $label_match = $this->find_label_match( $old_column, $new_by_key );
    48                         if ( $label_match ) {
    49                                 $matched[] = array(
    50                                         'old' => $old_column,
    51                                         'new' => $label_match,
    52                                 );
    53                         } else {
    54                                 $missing[] = $old_column;
    55                         }
     41                        $missing[] = $old_column;
    5642                }
    5743
    … …  
    7763        }
    7864
    79         private function find_label_match( array $old_column, array $new_columns ): ?array {
    80                 $old_label = $this->detector->normalize_key( (string) ( $old_column['label'] ?? '' ) );
    81 
    82                 foreach ( $new_columns as $new_column ) {
    83                         $new_label = $this->detector->normalize_key( (string) ( $new_column['label'] ?? '' ) );
    84                         if ( $old_label && $new_label && $old_label === $new_label ) {
    85                                 return $new_column;
    86                         }
    87                 }
    88 
    89                 return null;
    90         }
    9165}
  • tabloria-searchable-displays/tags/0.2.2/includes/Services/FrontendRenderer.php

    r3780707 r3796621  
    286286                $roles = $config['fieldRoles'] ?? array();
    287287                $title = $this->typed_field_value( $row, $roles['title'] ?? '', $source );
    288                 $title = '' !== $title ? $title : $this->best_title_value( $row, $source );
     288                $title = '' !== $title ? $title : $this->best_title_value( $row, $source, $this->public_fields( $config, $source ) );
    289289                $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source );
    290                 $subtitle = '' !== $subtitle ? $subtitle : $this->best_subtitle_value( $row, $title, $source );
     290                $subtitle = '' !== $subtitle ? $subtitle : $this->best_subtitle_value( $row, $title, $source, $this->public_fields( $config, $source ) );
    291291                $image = $this->typed_field_value( $row, $roles['image'] ?? '', $source );
    292292                $image_url = $this->sanitizer->sanitize_image_url( $image );
    … …  
    368368                $roles = is_array( $config['fieldRoles'] ?? null ) ? $config['fieldRoles'] : array();
    369369                $title = $this->typed_field_value( $row, $roles['title'] ?? '', $source );
    370                 $title = '' !== $title ? $title : $this->best_title_value( $row, $source );
     370                $title = '' !== $title ? $title : $this->best_title_value( $row, $source, $this->public_fields( $config, $source ) );
    371371                $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source );
    372372                $image_url = $this->sanitizer->sanitize_image_url( $this->typed_field_value( $row, $roles['image'] ?? '', $source ) );
    … …  
    462462
    463463        private function item_data_attributes( array $row, array $config, array $source ): string {
     464                $fields = $this->public_fields( $config, $source );
     465                if ( ! empty( $config['filters']['enabled'] ) ) {
     466                        $fields = array_values( array_unique( array_filter( array_map( 'sanitize_key', array_merge( $fields, (array) ( $config['visitorFilters'] ?? array() ) ) ), fn( string $field ): bool => $this->source_has_field( $source, $field ) ) ) );
     467                }
    464468                $search_text = $this->normalize_search_text(
    465469                        implode(
    … …  
    469473                                                return $this->typed_field_value( $row, (string) $field, $source );
    470474                                        },
    471                                         array_keys( $row )
     475                                        $fields
    472476                                )
    473477                        )
    … …  
    479483                }
    480484
    481                 foreach ( (array) ( $config['visitorFilters'] ?? array() ) as $field ) {
     485                foreach ( ! empty( $config['filters']['enabled'] ) ? (array) ( $config['visitorFilters'] ?? array() ) : array() as $field ) {
    482486                        $field = sanitize_key( $field );
    483                         if ( $field ) {
     487                        if ( $field && $this->source_has_field( $source, $field ) ) {
    484488                                $attributes .= ' data-filter-' . esc_attr( $field ) . '="' . esc_attr( $this->typed_field_value( $row, $field, $source ) ) . '"';
    485489                        }
    … …  
    513517                        array_filter(
    514518                                array_map( 'sanitize_key', (array) ( $config['visibleFields'] ?? array() ) ),
    515                                 fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field )
     519                                fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field ) && $this->source_has_field( $source, $field )
    516520                        )
    517521                );
    518522
    519                 if ( $fields ) {
     523                if ( array_key_exists( 'visibleFields', $config ) ) {
    520524                        return $fields;
    521525                }
    … …  
    530534                        )
    531535                );
     536        }
     537
     538        private function public_fields( array $config, array $source ): array {
     539                $display_type = sanitize_key( $config['displayType'] ?? '' );
     540                if ( 'simple_data_table' === $display_type ) {
     541                        return $this->table_fields( $config, $source );
     542                }
     543
     544                $roles = is_array( $config['fieldRoles'] ?? null ) ? array_values( $config['fieldRoles'] ) : array();
     545                $visible = is_array( $config['visibleFields'] ?? null ) ? $config['visibleFields'] : array();
     546                $fields = array_map( 'sanitize_key', array_merge( $roles, $visible ) );
     547                return array_values( array_unique( array_filter( $fields, fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field ) && $this->source_has_field( $source, $field ) ) ) );
     548        }
     549
     550        private function source_has_field( array $source, string $field ): bool {
     551                foreach ( (array) ( $source['columns'] ?? array() ) as $column ) {
     552                        if ( $field === sanitize_key( $column['key'] ?? '' ) ) {
     553                                return true;
     554                        }
     555                }
     556                return false;
    532557        }
    533558
    … …  
    758783        }
    759784
    760         private function best_title_value( array $row, array $source ): string {
    761                 foreach ( $row as $field => $value ) {
     785        private function best_title_value( array $row, array $source, array $fields ): string {
     786                foreach ( $fields as $field ) {
    762787                        $field = sanitize_key( $field );
    763788                        $value = $this->typed_field_value( $row, $field, $source );
    … …  
    770795                }
    771796
    772                 foreach ( $row as $field => $value ) {
     797                foreach ( $fields as $field ) {
    773798                        $field = sanitize_key( $field );
    774799                        $value = $this->typed_field_value( $row, $field, $source );
    … …  
    781806        }
    782807
    783         private function best_subtitle_value( array $row, string $title, array $source ): string {
    784                 foreach ( $row as $field => $value ) {
     808        private function best_subtitle_value( array $row, string $title, array $source, array $fields ): string {
     809                foreach ( $fields as $field ) {
    785810                        $field = sanitize_key( $field );
    786811                        $value = $this->typed_field_value( $row, $field, $source );
  • tabloria-searchable-displays/tags/0.2.2/includes/Services/SpreadsheetSanitizer.php

    r3780769 r3796621  
    8080                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the uploaded CSV temp file handle.
    8181
    82                 if ( false !== $sample && str_contains( $sample, "\0" ) ) {
     82                $utf16_bom = is_string( $sample ) && ( str_starts_with( $sample, "\xFF\xFE" ) || str_starts_with( $sample, "\xFE\xFF" ) );
     83                if ( false !== $sample && str_contains( $sample, "\0" ) && ! $utf16_bom ) {
    8384                        return new WP_Error( 'tabloria_csv_binary', __( 'The uploaded file does not look like a plain CSV file.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
    8485                }
  • tabloria-searchable-displays/tags/0.2.2/readme.txt

    r3780769 r3796621  
    1 === Tabloria Searchable Displays ===
     1=== Tabloria - CSV Product Catalog & Searchable Displays ===
    22Contributors: publishly
    33Tags: csv, searchable table, product catalog, directory, data table
    … …  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 0.2.1
     7Stable tag: 0.2.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Turn a CSV into searchable catalogs, directories, coupon displays, listings, and responsive data tables—without code.
     11Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block.
    1212
    1313== Description ==
    1414
    15 Tabloria turns CSV rows into polished, searchable WordPress content without custom code.
     15Upload a product CSV, map its columns to card fields, and publish a searchable catalog on a WordPress page. Visitors can search and filter products without a page reload. [See the catalog demo](https://tabloria.site/demo.html) and [download its sample CSV](https://tabloria.site/demo-products/catalog.csv).
     16
     17Tabloria also turns CSV rows into directories, coupon displays, listings, schedules, pricing displays, and responsive data tables.
    1618
    1719Upload a CSV, choose which columns become titles, images, prices, badges, descriptions, coupon codes, or links, and publish the result as a product catalog, directory, coupon display, listing, schedule, pricing display, or responsive data table.
    … …  
    8486
    85871. Upload the `tabloria-searchable-displays` folder to `/wp-content/plugins/`.
    86 2. Activate Tabloria Searchable Displays from the WordPress Plugins screen.
     882. Activate Tabloria from the WordPress Plugins screen.
    87893. Open Tabloria -> Create Display.
    88904. Upload a CSV file and follow the guided setup flow.
    … …  
    113115= What file format does Tabloria support? =
    114116
    115 The current MVP imports CSV files. XLSX import is not part of the main public workflow yet.
     117Tabloria imports UTF-8 CSV (including a BOM) and UTF-16 CSV exports. Comma, semicolon, and tab delimiters are detected. XLSX import is not part of the main public workflow yet.
     118
     119= Is the uploaded CSV publicly accessible? =
     120
     121From version 0.2.2, Tabloria reads the temporary upload and stores parsed rows in its database without saving the raw CSV to public uploads. Columns not selected for a displayed role, visible field, or visitor filter are omitted from frontend HTML and search data. CSV files uploaded with older versions may still remain in `wp-content/uploads/`; review their filenames and dates with your hosting file manager, confirm they are not used elsewhere, then back up and remove those legacy public files. Existing Displays keep using their database rows.
    116122
    117123= Does Tabloria edit spreadsheet rows? =
    … …  
    141147== Changelog ==
    142148
     149= 0.2.2 =
     150
     151* Prevent new raw CSV uploads from being stored in public uploads, and omit unpublished column values from frontend HTML.
     152* Support common CSV delimiters and UTF-16 exports, reject malformed row widths, and preserve old data when replacement fails.
     153* Focus the plugin listing and demo on a searchable product catalog from CSV.
     154
    143155= 0.2.1 =
    144156
  • tabloria-searchable-displays/tags/0.2.2/tabloria-searchable-displays.php

    r3780769 r3796621  
    11<?php
    22/**
    3  * Plugin Name: Tabloria Searchable Displays
     3 * Plugin Name: Tabloria - CSV Product Catalog & Searchable Displays
    44 * Plugin URI: https://tabloria.site/
    5  * Description: Turn CSV rows into searchable catalogs, directories, coupon displays, listings, and responsive data tables.
    6  * Version: 0.2.1
     5 * Description: Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block.
     6 * Version: 0.2.2
    77 * Author: Tabloria
    88 * Text Domain: tabloria-searchable-displays
    … …  
    1818}
    1919
    20 define( 'TABLORIA_VERSION', '0.2.1' );
     20define( 'TABLORIA_VERSION', '0.2.2' );
    2121define( 'TABLORIA_FILE', __FILE__ );
    2222define( 'TABLORIA_DIR', plugin_dir_path( __FILE__ ) );
  • tabloria-searchable-displays/trunk/includes/Database.php

    r3780707 r3796621  
    8989                $rows    = $this->sanitizer->sanitize_rows( $rows, $columns );
    9090                $now = current_time( 'mysql' );
     91                if ( false === $wpdb->query( 'START TRANSACTION' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Keep source metadata and rows together.
     92                        return 0;
     93                }
    9194                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables.
    92                 $wpdb->insert(
     95                $inserted = $wpdb->insert(
    9396                        self::sources_table(),
    9497                        array(
    … …  
    106109
    107110                $source_id = absint( $wpdb->insert_id );
    108                 $this->replace_rows( $source_id, $rows );
     111                if ( false === $inserted || ! $source_id || ! $this->replace_rows( $source_id, $rows ) ) {
     112                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Undo an incomplete source import.
     113                        return 0;
     114                }
     115                if ( false === $wpdb->query( 'COMMIT' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Commit a complete source import.
     116                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Best effort after failed commit.
     117                        return 0;
     118                }
    109119
    110120                return $source_id;
    … …  
    116126                $columns = $this->sanitizer->sanitize_columns( $columns );
    117127                $rows    = $this->sanitizer->sanitize_rows( $rows, $columns );
     128                if ( false === $wpdb->query( 'START TRANSACTION' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Keep old data until the whole replacement is ready.
     129                        return false;
     130                }
    118131
    119132                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables.
    … …  
    133146
    134147                if ( false === $updated ) {
    135                         return false;
    136                 }
    137 
    138                 $this->replace_rows( $source_id, $rows );
     148                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Preserve previous source data.
     149                        return false;
     150                }
     151
     152                if ( ! $this->replace_rows( $source_id, $rows ) ) {
     153                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Preserve previous source data.
     154                        return false;
     155                }
     156
     157                if ( false === $wpdb->query( 'COMMIT' ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Commit the complete replacement.
     158                        $wpdb->query( 'ROLLBACK' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Best effort after failed commit.
     159                        return false;
     160                }
    139161                return true;
    140162        }
    141163
    142         public function replace_rows( int $source_id, array $rows ): void {
     164        public function replace_rows( int $source_id, array $rows ): bool {
    143165                global $wpdb;
    144166
    145167                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Replacing rows in a plugin-owned custom table.
    146                 $wpdb->delete( self::rows_table(), array( 'source_id' => $source_id ), array( '%d' ) );
     168                if ( false === $wpdb->delete( self::rows_table(), array( 'source_id' => $source_id ), array( '%d' ) ) ) {
     169                        return false;
     170                }
    147171
    148172                $now = current_time( 'mysql' );
    … …  
    153177
    154178                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheet rows in a plugin-owned custom table.
    155                         $wpdb->insert(
     179                        $inserted = $wpdb->insert(
    156180                                self::rows_table(),
    157181                                array(
    … …  
    163187                                array( '%d', '%d', '%s', '%s' )
    164188                        );
    165                 }
     189                        if ( false === $inserted ) {
     190                                return false;
     191                        }
     192                }
     193                return true;
    166194        }
    167195
  • tabloria-searchable-displays/trunk/includes/REST/DisplaysController.php

    r3780707 r3796621  
    221221                }
    222222
    223                 require_once ABSPATH . 'wp-admin/includes/file.php';
    224                 $upload = wp_handle_upload(
    225                         $file,
    226                         array(
    227                                 'test_form' => false,
    228                                 'test_type' => false,
    229                         )
    230                 );
    231                 if ( isset( $upload['error'] ) ) {
    232                         return new WP_Error( 'tabloria_upload_failed', sanitize_text_field( $upload['error'] ), array( 'status' => 400 ) );
    233                 }
    234 
    235                 $parsed = ( new CsvParser() )->parse( $upload['file'] );
     223                $parsed = ( new CsvParser() )->parse( $file['tmp_name'] );
    236224                if ( is_wp_error( $parsed ) ) {
    237225                        return $parsed;
    … …  
    239227
    240228                $summary = ( new DataUpdater() )->compare_columns( $source['columns'], $parsed['columns'] );
    241                 $this->database->update_source_data( absint( $source['id'] ), $parsed['columns'], $parsed['rows'], $meta['filename'], 'csv' );
     229                if ( $summary['missingCount'] > 0 ) {
     230                        $missing = array_map( static fn( array $column ): string => (string) ( $column['label'] ?? $column['key'] ?? '' ), array_slice( $summary['missing'], 0, 3 ) );
     231                        return new WP_Error(
     232                                'tabloria_csv_missing_columns',
     233                                sprintf(
     234                                        /* translators: %s: comma-separated column names. */
     235                                        __( 'The replacement is missing existing columns (%s). Keep the same headers so saved Display fields keep working. No data was changed.', 'tabloria-searchable-displays' ),
     236                                        implode( ', ', $missing )
     237                                ),
     238                                array( 'status' => 409 )
     239                        );
     240                }
     241                if ( ! $this->database->update_source_data( absint( $source['id'] ), $parsed['columns'], $parsed['rows'], $meta['filename'], 'csv' ) ) {
     242                        return new WP_Error( 'tabloria_source_update_failed', __( 'The replacement could not be saved. The previous CSV data is still available.', 'tabloria-searchable-displays' ), array( 'status' => 500 ) );
     243                }
    242244
    243245                return new WP_REST_Response(
  • tabloria-searchable-displays/trunk/includes/REST/UploadController.php

    r3780769 r3796621  
    5656                }
    5757
    58                 require_once ABSPATH . 'wp-admin/includes/file.php';
    59 
    60                 $upload = wp_handle_upload(
    61                         $file,
    62                         array(
    63                                 'test_form' => false,
    64                                 'test_type' => false,
    65                                 'mimes'     => array(
    66                                         'csv' => 'text/csv',
    67                                 ),
    68                         )
    69                 );
    70 
    71                 if ( isset( $upload['error'] ) ) {
    72                         return new WP_Error( 'tabloria_upload_failed', sanitize_text_field( $upload['error'] ), array( 'status' => 400 ) );
    73                 }
    74 
    75                 $parsed = ( new CsvParser() )->parse( $upload['file'] );
     58                // PHP removes the request's temporary file after this request. The parsed rows
     59                // live in our database, so keeping a raw CSV in public uploads is unnecessary.
     60                $parsed = ( new CsvParser() )->parse( $file['tmp_name'] );
    7661
    7762                if ( is_wp_error( $parsed ) ) {
    … …  
    8873                        $parsed['rows']
    8974                );
     75                if ( ! $source_id ) {
     76                        return new WP_Error( 'tabloria_source_save_failed', __( 'The CSV was read, but its data could not be saved. Please try again.', 'tabloria-searchable-displays' ), array( 'status' => 500 ) );
     77                }
    9078
    9179                $source = $this->database->get_source( $source_id );
  • tabloria-searchable-displays/trunk/includes/Services/CsvParser.php

    r3544916 r3796621  
    2424                }
    2525
    26                 $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- CSV parsing requires a stream handle for fgetcsv().
     26                $contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The file was capped at 5 MB above.
     27                if ( false === $contents ) {
     28                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) );
     29                }
     30                if ( str_starts_with( $contents, "\xFF\xFE" ) || str_starts_with( $contents, "\xFE\xFF" ) ) {
     31                        $encoding = str_starts_with( $contents, "\xFF\xFE" ) ? 'UTF-16LE' : 'UTF-16BE';
     32                        $contents = function_exists( 'iconv' ) ? iconv( $encoding, 'UTF-8', substr( $contents, 2 ) ) : false;
     33                        if ( false === $contents ) {
     34                                return new WP_Error( 'tabloria_csv_encoding', __( 'This CSV could not be converted to UTF-8. Export it as UTF-8 CSV and try again.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     35                        }
     36                } elseif ( str_starts_with( $contents, "\xEF\xBB\xBF" ) ) {
     37                        $contents = substr( $contents, 3 );
     38                }
     39                if ( 1 !== preg_match( '//u', $contents ) ) {
     40                        return new WP_Error( 'tabloria_csv_encoding', __( 'The CSV is not valid UTF-8. Export it as UTF-8 CSV and try again.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     41                }
     42
     43                $handle = fopen( 'php://temp', 'w+b' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- fgetcsv() requires a seekable stream.
    2744                if ( false === $handle ) {
    2845                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) );
    2946                }
     47                $written = fwrite( $handle, $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes to a private temporary stream only.
     48                if ( false === $written || strlen( $contents ) !== $written ) {
     49                        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the temporary stream on a short write.
     50                        return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be read completely.', 'tabloria-searchable-displays' ) );
     51                }
     52                $delimiter = $this->detect_delimiter( $handle );
     53                rewind( $handle );
    3054
    31                 $headers = fgetcsv( $handle );
     55                $headers = fgetcsv( $handle, 0, $delimiter, '"', '' );
    3256                if ( ! is_array( $headers ) || empty( $headers ) ) {
    3357                        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
    … …  
    4569                $row_index = 0;
    4670
    47                 while ( false !== ( $data = fgetcsv( $handle ) ) ) {
     71                while ( false !== ( $data = fgetcsv( $handle, 0, $delimiter, '"', '' ) ) ) {
    4872                        if ( $this->is_blank_row( $data ) ) {
    4973                                continue;
    … …  
    5377                                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
    5478                                return new WP_Error( 'tabloria_csv_too_many_rows', __( 'The CSV file has too many rows for this MVP release.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     79                        }
     80                        if ( count( $data ) !== count( $headers ) ) {
     81                                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the temporary stream.
     82                                return new WP_Error( 'tabloria_csv_column_mismatch', sprintf( __( 'CSV row %d has a different number of columns than the header. Check the delimiter and quoted values.', 'tabloria-searchable-displays' ), $row_index + 2 ), array( 'status' => 400 ) );
    5583                        }
    5684
    … …  
    6593
    6694                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above.
     95                if ( 0 === $row_index ) {
     96                        return new WP_Error( 'tabloria_csv_no_rows', __( 'The CSV needs at least one data row.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
     97                }
    6798
    6899                $columns = $this->detector->detect_columns( $headers, $rows );
    … …  
    78109                        'sample'  => array_slice( $rows, 0, 24 ),
    79110                );
     111        }
     112
     113        private function detect_delimiter( $handle ): string {
     114                $best = ',';
     115                $best_score = -1;
     116                foreach ( array( ',', ';', "\t" ) as $candidate ) {
     117                        rewind( $handle );
     118                        $widths = array();
     119                        while ( count( $widths ) < 8 && false !== ( $row = fgetcsv( $handle, 0, $candidate, '"', '' ) ) ) {
     120                                if ( ! $this->is_blank_row( $row ) ) {
     121                                        $widths[] = count( $row );
     122                                }
     123                        }
     124                        if ( ! $widths ) {
     125                                continue;
     126                        }
     127                        $header_width = $widths[0];
     128                        $matching = count( array_filter( $widths, static fn( int $width ): bool => $width === $header_width ) );
     129                        $score = $header_width > 1 ? ( $matching * 100 + $header_width ) : 0;
     130                        if ( $score > $best_score ) {
     131                                $best_score = $score;
     132                                $best = $candidate;
     133                        }
     134                }
     135                return $best;
    80136        }
    81137
  • tabloria-searchable-displays/trunk/includes/Services/DataUpdater.php

    r3544916 r3796621  
    88
    99class DataUpdater {
    10         private ColumnDetector $detector;
    11 
    12         public function __construct( ?ColumnDetector $detector = null ) {
    13                 $this->detector = $detector ?: new ColumnDetector();
    14         }
    15 
    1610        public function compare_columns( array $old_columns, array $new_columns ): array {
    1711                $old_by_key = array();
    … …  
    4539                        }
    4640
    47                         $label_match = $this->find_label_match( $old_column, $new_by_key );
    48                         if ( $label_match ) {
    49                                 $matched[] = array(
    50                                         'old' => $old_column,
    51                                         'new' => $label_match,
    52                                 );
    53                         } else {
    54                                 $missing[] = $old_column;
    55                         }
     41                        $missing[] = $old_column;
    5642                }
    5743
    … …  
    7763        }
    7864
    79         private function find_label_match( array $old_column, array $new_columns ): ?array {
    80                 $old_label = $this->detector->normalize_key( (string) ( $old_column['label'] ?? '' ) );
    81 
    82                 foreach ( $new_columns as $new_column ) {
    83                         $new_label = $this->detector->normalize_key( (string) ( $new_column['label'] ?? '' ) );
    84                         if ( $old_label && $new_label && $old_label === $new_label ) {
    85                                 return $new_column;
    86                         }
    87                 }
    88 
    89                 return null;
    90         }
    9165}
  • tabloria-searchable-displays/trunk/includes/Services/FrontendRenderer.php

    r3780707 r3796621  
    286286                $roles = $config['fieldRoles'] ?? array();
    287287                $title = $this->typed_field_value( $row, $roles['title'] ?? '', $source );
    288                 $title = '' !== $title ? $title : $this->best_title_value( $row, $source );
     288                $title = '' !== $title ? $title : $this->best_title_value( $row, $source, $this->public_fields( $config, $source ) );
    289289                $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source );
    290                 $subtitle = '' !== $subtitle ? $subtitle : $this->best_subtitle_value( $row, $title, $source );
     290                $subtitle = '' !== $subtitle ? $subtitle : $this->best_subtitle_value( $row, $title, $source, $this->public_fields( $config, $source ) );
    291291                $image = $this->typed_field_value( $row, $roles['image'] ?? '', $source );
    292292                $image_url = $this->sanitizer->sanitize_image_url( $image );
    … …  
    368368                $roles = is_array( $config['fieldRoles'] ?? null ) ? $config['fieldRoles'] : array();
    369369                $title = $this->typed_field_value( $row, $roles['title'] ?? '', $source );
    370                 $title = '' !== $title ? $title : $this->best_title_value( $row, $source );
     370                $title = '' !== $title ? $title : $this->best_title_value( $row, $source, $this->public_fields( $config, $source ) );
    371371                $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source );
    372372                $image_url = $this->sanitizer->sanitize_image_url( $this->typed_field_value( $row, $roles['image'] ?? '', $source ) );
    … …  
    462462
    463463        private function item_data_attributes( array $row, array $config, array $source ): string {
     464                $fields = $this->public_fields( $config, $source );
     465                if ( ! empty( $config['filters']['enabled'] ) ) {
     466                        $fields = array_values( array_unique( array_filter( array_map( 'sanitize_key', array_merge( $fields, (array) ( $config['visitorFilters'] ?? array() ) ) ), fn( string $field ): bool => $this->source_has_field( $source, $field ) ) ) );
     467                }
    464468                $search_text = $this->normalize_search_text(
    465469                        implode(
    … …  
    469473                                                return $this->typed_field_value( $row, (string) $field, $source );
    470474                                        },
    471                                         array_keys( $row )
     475                                        $fields
    472476                                )
    473477                        )
    … …  
    479483                }
    480484
    481                 foreach ( (array) ( $config['visitorFilters'] ?? array() ) as $field ) {
     485                foreach ( ! empty( $config['filters']['enabled'] ) ? (array) ( $config['visitorFilters'] ?? array() ) : array() as $field ) {
    482486                        $field = sanitize_key( $field );
    483                         if ( $field ) {
     487                        if ( $field && $this->source_has_field( $source, $field ) ) {
    484488                                $attributes .= ' data-filter-' . esc_attr( $field ) . '="' . esc_attr( $this->typed_field_value( $row, $field, $source ) ) . '"';
    485489                        }
    … …  
    513517                        array_filter(
    514518                                array_map( 'sanitize_key', (array) ( $config['visibleFields'] ?? array() ) ),
    515                                 fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field )
     519                                fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field ) && $this->source_has_field( $source, $field )
    516520                        )
    517521                );
    518522
    519                 if ( $fields ) {
     523                if ( array_key_exists( 'visibleFields', $config ) ) {
    520524                        return $fields;
    521525                }
    … …  
    530534                        )
    531535                );
     536        }
     537
     538        private function public_fields( array $config, array $source ): array {
     539                $display_type = sanitize_key( $config['displayType'] ?? '' );
     540                if ( 'simple_data_table' === $display_type ) {
     541                        return $this->table_fields( $config, $source );
     542                }
     543
     544                $roles = is_array( $config['fieldRoles'] ?? null ) ? array_values( $config['fieldRoles'] ) : array();
     545                $visible = is_array( $config['visibleFields'] ?? null ) ? $config['visibleFields'] : array();
     546                $fields = array_map( 'sanitize_key', array_merge( $roles, $visible ) );
     547                return array_values( array_unique( array_filter( $fields, fn( string $field ): bool => '' !== $field && ! $this->is_technical_field( $field ) && $this->source_has_field( $source, $field ) ) ) );
     548        }
     549
     550        private function source_has_field( array $source, string $field ): bool {
     551                foreach ( (array) ( $source['columns'] ?? array() ) as $column ) {
     552                        if ( $field === sanitize_key( $column['key'] ?? '' ) ) {
     553                                return true;
     554                        }
     555                }
     556                return false;
    532557        }
    533558
    … …  
    758783        }
    759784
    760         private function best_title_value( array $row, array $source ): string {
    761                 foreach ( $row as $field => $value ) {
     785        private function best_title_value( array $row, array $source, array $fields ): string {
     786                foreach ( $fields as $field ) {
    762787                        $field = sanitize_key( $field );
    763788                        $value = $this->typed_field_value( $row, $field, $source );
    … …  
    770795                }
    771796
    772                 foreach ( $row as $field => $value ) {
     797                foreach ( $fields as $field ) {
    773798                        $field = sanitize_key( $field );
    774799                        $value = $this->typed_field_value( $row, $field, $source );
    … …  
    781806        }
    782807
    783         private function best_subtitle_value( array $row, string $title, array $source ): string {
    784                 foreach ( $row as $field => $value ) {
     808        private function best_subtitle_value( array $row, string $title, array $source, array $fields ): string {
     809                foreach ( $fields as $field ) {
    785810                        $field = sanitize_key( $field );
    786811                        $value = $this->typed_field_value( $row, $field, $source );
  • tabloria-searchable-displays/trunk/includes/Services/SpreadsheetSanitizer.php

    r3780769 r3796621  
    8080                fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the uploaded CSV temp file handle.
    8181
    82                 if ( false !== $sample && str_contains( $sample, "\0" ) ) {
     82                $utf16_bom = is_string( $sample ) && ( str_starts_with( $sample, "\xFF\xFE" ) || str_starts_with( $sample, "\xFE\xFF" ) );
     83                if ( false !== $sample && str_contains( $sample, "\0" ) && ! $utf16_bom ) {
    8384                        return new WP_Error( 'tabloria_csv_binary', __( 'The uploaded file does not look like a plain CSV file.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) );
    8485                }
  • tabloria-searchable-displays/trunk/readme.txt

    r3780769 r3796621  
    1 === Tabloria Searchable Displays ===
     1=== Tabloria - CSV Product Catalog & Searchable Displays ===
    22Contributors: publishly
    33Tags: csv, searchable table, product catalog, directory, data table
    … …  
    55Tested up to: 7.1
    66Requires PHP: 8.1
    7 Stable tag: 0.2.1
     7Stable tag: 0.2.2
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Turn a CSV into searchable catalogs, directories, coupon displays, listings, and responsive data tables—without code.
     11Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block.
    1212
    1313== Description ==
    1414
    15 Tabloria turns CSV rows into polished, searchable WordPress content without custom code.
     15Upload a product CSV, map its columns to card fields, and publish a searchable catalog on a WordPress page. Visitors can search and filter products without a page reload. [See the catalog demo](https://tabloria.site/demo.html) and [download its sample CSV](https://tabloria.site/demo-products/catalog.csv).
     16
     17Tabloria also turns CSV rows into directories, coupon displays, listings, schedules, pricing displays, and responsive data tables.
    1618
    1719Upload a CSV, choose which columns become titles, images, prices, badges, descriptions, coupon codes, or links, and publish the result as a product catalog, directory, coupon display, listing, schedule, pricing display, or responsive data table.
    … …  
    8486
    85871. Upload the `tabloria-searchable-displays` folder to `/wp-content/plugins/`.
    86 2. Activate Tabloria Searchable Displays from the WordPress Plugins screen.
     882. Activate Tabloria from the WordPress Plugins screen.
    87893. Open Tabloria -> Create Display.
    88904. Upload a CSV file and follow the guided setup flow.
    … …  
    113115= What file format does Tabloria support? =
    114116
    115 The current MVP imports CSV files. XLSX import is not part of the main public workflow yet.
     117Tabloria imports UTF-8 CSV (including a BOM) and UTF-16 CSV exports. Comma, semicolon, and tab delimiters are detected. XLSX import is not part of the main public workflow yet.
     118
     119= Is the uploaded CSV publicly accessible? =
     120
     121From version 0.2.2, Tabloria reads the temporary upload and stores parsed rows in its database without saving the raw CSV to public uploads. Columns not selected for a displayed role, visible field, or visitor filter are omitted from frontend HTML and search data. CSV files uploaded with older versions may still remain in `wp-content/uploads/`; review their filenames and dates with your hosting file manager, confirm they are not used elsewhere, then back up and remove those legacy public files. Existing Displays keep using their database rows.
    116122
    117123= Does Tabloria edit spreadsheet rows? =
    … …  
    141147== Changelog ==
    142148
     149= 0.2.2 =
     150
     151* Prevent new raw CSV uploads from being stored in public uploads, and omit unpublished column values from frontend HTML.
     152* Support common CSV delimiters and UTF-16 exports, reject malformed row widths, and preserve old data when replacement fails.
     153* Focus the plugin listing and demo on a searchable product catalog from CSV.
     154
    143155= 0.2.1 =
    144156
  • tabloria-searchable-displays/trunk/tabloria-searchable-displays.php

    r3780769 r3796621  
    11<?php
    22/**
    3  * Plugin Name: Tabloria Searchable Displays
     3 * Plugin Name: Tabloria - CSV Product Catalog & Searchable Displays
    44 * Plugin URI: https://tabloria.site/
    5  * Description: Turn CSV rows into searchable catalogs, directories, coupon displays, listings, and responsive data tables.
    6  * Version: 0.2.1
     5 * Description: Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block.
     6 * Version: 0.2.2
    77 * Author: Tabloria
    88 * Text Domain: tabloria-searchable-displays
    … …  
    1818}
    1919
    20 define( 'TABLORIA_VERSION', '0.2.1' );
     20define( 'TABLORIA_VERSION', '0.2.2' );
    2121define( 'TABLORIA_FILE', __FILE__ );
    2222define( 'TABLORIA_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.