Changeset 3796621
- Timestamp:
- 11/11/2026 03:36:06 PM (less than one hour ago)
- Location:
- tabloria-searchable-displays
- Files:
-
- 18 edited
- 1 copied
-
tags/0.2.2 (copied) (copied from tabloria-searchable-displays/trunk )
-
tags/0.2.2/includes/Database.php (modified) (6 diffs)
-
tags/0.2.2/includes/REST/DisplaysController.php (modified) (2 diffs)
-
tags/0.2.2/includes/REST/UploadController.php (modified) (2 diffs)
-
tags/0.2.2/includes/Services/CsvParser.php (modified) (5 diffs)
-
tags/0.2.2/includes/Services/DataUpdater.php (modified) (3 diffs)
-
tags/0.2.2/includes/Services/FrontendRenderer.php (modified) (10 diffs)
-
tags/0.2.2/includes/Services/SpreadsheetSanitizer.php (modified) (1 diff)
-
tags/0.2.2/readme.txt (modified) (5 diffs)
-
tags/0.2.2/tabloria-searchable-displays.php (modified) (2 diffs)
-
trunk/includes/Database.php (modified) (6 diffs)
-
trunk/includes/REST/DisplaysController.php (modified) (2 diffs)
-
trunk/includes/REST/UploadController.php (modified) (2 diffs)
-
trunk/includes/Services/CsvParser.php (modified) (5 diffs)
-
trunk/includes/Services/DataUpdater.php (modified) (3 diffs)
-
trunk/includes/Services/FrontendRenderer.php (modified) (10 diffs)
-
trunk/includes/Services/SpreadsheetSanitizer.php (modified) (1 diff)
-
trunk/readme.txt (modified) (5 diffs)
-
trunk/tabloria-searchable-displays.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
tabloria-searchable-displays/tags/0.2.2/includes/Database.php
r3780707 r3796621 89 89 $rows = $this->sanitizer->sanitize_rows( $rows, $columns ); 90 90 $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 } 91 94 // 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( 93 96 self::sources_table(), 94 97 array( … … 106 109 107 110 $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 } 109 119 110 120 return $source_id; … … 116 126 $columns = $this->sanitizer->sanitize_columns( $columns ); 117 127 $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 } 118 131 119 132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables. … … 133 146 134 147 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 } 139 161 return true; 140 162 } 141 163 142 public function replace_rows( int $source_id, array $rows ): void{164 public function replace_rows( int $source_id, array $rows ): bool { 143 165 global $wpdb; 144 166 145 167 // 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 } 147 171 148 172 $now = current_time( 'mysql' ); … … 153 177 154 178 // 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( 156 180 self::rows_table(), 157 181 array( … … 163 187 array( '%d', '%d', '%s', '%s' ) 164 188 ); 165 } 189 if ( false === $inserted ) { 190 return false; 191 } 192 } 193 return true; 166 194 } 167 195 -
tabloria-searchable-displays/tags/0.2.2/includes/REST/DisplaysController.php
r3780707 r3796621 221 221 } 222 222 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'] ); 236 224 if ( is_wp_error( $parsed ) ) { 237 225 return $parsed; … … 239 227 240 228 $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 } 242 244 243 245 return new WP_REST_Response( -
tabloria-searchable-displays/tags/0.2.2/includes/REST/UploadController.php
r3780769 r3796621 56 56 } 57 57 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'] ); 76 61 77 62 if ( is_wp_error( $parsed ) ) { … … 88 73 $parsed['rows'] 89 74 ); 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 } 90 78 91 79 $source = $this->database->get_source( $source_id ); -
tabloria-searchable-displays/tags/0.2.2/includes/Services/CsvParser.php
r3544916 r3796621 24 24 } 25 25 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. 27 44 if ( false === $handle ) { 28 45 return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) ); 29 46 } 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 ); 30 54 31 $headers = fgetcsv( $handle );55 $headers = fgetcsv( $handle, 0, $delimiter, '"', '' ); 32 56 if ( ! is_array( $headers ) || empty( $headers ) ) { 33 57 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above. … … 45 69 $row_index = 0; 46 70 47 while ( false !== ( $data = fgetcsv( $handle ) ) ) {71 while ( false !== ( $data = fgetcsv( $handle, 0, $delimiter, '"', '' ) ) ) { 48 72 if ( $this->is_blank_row( $data ) ) { 49 73 continue; … … 53 77 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above. 54 78 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 ) ); 55 83 } 56 84 … … 65 93 66 94 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 } 67 98 68 99 $columns = $this->detector->detect_columns( $headers, $rows ); … … 78 109 'sample' => array_slice( $rows, 0, 24 ), 79 110 ); 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; 80 136 } 81 137 -
tabloria-searchable-displays/tags/0.2.2/includes/Services/DataUpdater.php
r3544916 r3796621 8 8 9 9 class DataUpdater { 10 private ColumnDetector $detector;11 12 public function __construct( ?ColumnDetector $detector = null ) {13 $this->detector = $detector ?: new ColumnDetector();14 }15 16 10 public function compare_columns( array $old_columns, array $new_columns ): array { 17 11 $old_by_key = array(); … … 45 39 } 46 40 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; 56 42 } 57 43 … … 77 63 } 78 64 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 }91 65 } -
tabloria-searchable-displays/tags/0.2.2/includes/Services/FrontendRenderer.php
r3780707 r3796621 286 286 $roles = $config['fieldRoles'] ?? array(); 287 287 $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 ) ); 289 289 $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 ) ); 291 291 $image = $this->typed_field_value( $row, $roles['image'] ?? '', $source ); 292 292 $image_url = $this->sanitizer->sanitize_image_url( $image ); … … 368 368 $roles = is_array( $config['fieldRoles'] ?? null ) ? $config['fieldRoles'] : array(); 369 369 $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 ) ); 371 371 $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source ); 372 372 $image_url = $this->sanitizer->sanitize_image_url( $this->typed_field_value( $row, $roles['image'] ?? '', $source ) ); … … 462 462 463 463 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 } 464 468 $search_text = $this->normalize_search_text( 465 469 implode( … … 469 473 return $this->typed_field_value( $row, (string) $field, $source ); 470 474 }, 471 array_keys( $row )475 $fields 472 476 ) 473 477 ) … … 479 483 } 480 484 481 foreach ( (array) ( $config['visitorFilters'] ?? array()) as $field ) {485 foreach ( ! empty( $config['filters']['enabled'] ) ? (array) ( $config['visitorFilters'] ?? array() ) : array() as $field ) { 482 486 $field = sanitize_key( $field ); 483 if ( $field ) {487 if ( $field && $this->source_has_field( $source, $field ) ) { 484 488 $attributes .= ' data-filter-' . esc_attr( $field ) . '="' . esc_attr( $this->typed_field_value( $row, $field, $source ) ) . '"'; 485 489 } … … 513 517 array_filter( 514 518 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 ) 516 520 ) 517 521 ); 518 522 519 if ( $fields) {523 if ( array_key_exists( 'visibleFields', $config ) ) { 520 524 return $fields; 521 525 } … … 530 534 ) 531 535 ); 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; 532 557 } 533 558 … … 758 783 } 759 784 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 ) { 762 787 $field = sanitize_key( $field ); 763 788 $value = $this->typed_field_value( $row, $field, $source ); … … 770 795 } 771 796 772 foreach ( $ row as $field => $value) {797 foreach ( $fields as $field ) { 773 798 $field = sanitize_key( $field ); 774 799 $value = $this->typed_field_value( $row, $field, $source ); … … 781 806 } 782 807 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 ) { 785 810 $field = sanitize_key( $field ); 786 811 $value = $this->typed_field_value( $row, $field, $source ); -
tabloria-searchable-displays/tags/0.2.2/includes/Services/SpreadsheetSanitizer.php
r3780769 r3796621 80 80 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the uploaded CSV temp file handle. 81 81 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 ) { 83 84 return new WP_Error( 'tabloria_csv_binary', __( 'The uploaded file does not look like a plain CSV file.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) ); 84 85 } -
tabloria-searchable-displays/tags/0.2.2/readme.txt
r3780769 r3796621 1 === Tabloria Searchable Displays ===1 === Tabloria - CSV Product Catalog & Searchable Displays === 2 2 Contributors: publishly 3 3 Tags: csv, searchable table, product catalog, directory, data table … … 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.1 7 Stable tag: 0.2. 17 Stable tag: 0.2.2 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html 10 10 11 Turn a CSV into searchable catalogs, directories, coupon displays, listings, and responsive data tables—without code.11 Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block. 12 12 13 13 == Description == 14 14 15 Tabloria turns CSV rows into polished, searchable WordPress content without custom code. 15 Upload 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 17 Tabloria also turns CSV rows into directories, coupon displays, listings, schedules, pricing displays, and responsive data tables. 16 18 17 19 Upload 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. … … 84 86 85 87 1. Upload the `tabloria-searchable-displays` folder to `/wp-content/plugins/`. 86 2. Activate Tabloria Searchable Displaysfrom the WordPress Plugins screen.88 2. Activate Tabloria from the WordPress Plugins screen. 87 89 3. Open Tabloria -> Create Display. 88 90 4. Upload a CSV file and follow the guided setup flow. … … 113 115 = What file format does Tabloria support? = 114 116 115 The current MVP imports CSV files. XLSX import is not part of the main public workflow yet. 117 Tabloria 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 121 From 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. 116 122 117 123 = Does Tabloria edit spreadsheet rows? = … … 141 147 == Changelog == 142 148 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 143 155 = 0.2.1 = 144 156 -
tabloria-searchable-displays/tags/0.2.2/tabloria-searchable-displays.php
r3780769 r3796621 1 1 <?php 2 2 /** 3 * Plugin Name: Tabloria Searchable Displays3 * Plugin Name: Tabloria - CSV Product Catalog & Searchable Displays 4 4 * 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. 15 * Description: Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block. 6 * Version: 0.2.2 7 7 * Author: Tabloria 8 8 * Text Domain: tabloria-searchable-displays … … 18 18 } 19 19 20 define( 'TABLORIA_VERSION', '0.2. 1' );20 define( 'TABLORIA_VERSION', '0.2.2' ); 21 21 define( 'TABLORIA_FILE', __FILE__ ); 22 22 define( 'TABLORIA_DIR', plugin_dir_path( __FILE__ ) ); -
tabloria-searchable-displays/trunk/includes/Database.php
r3780707 r3796621 89 89 $rows = $this->sanitizer->sanitize_rows( $rows, $columns ); 90 90 $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 } 91 94 // 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( 93 96 self::sources_table(), 94 97 array( … … 106 109 107 110 $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 } 109 119 110 120 return $source_id; … … 116 126 $columns = $this->sanitizer->sanitize_columns( $columns ); 117 127 $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 } 118 131 119 132 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Tabloria stores imported spreadsheets in plugin-owned custom tables. … … 133 146 134 147 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 } 139 161 return true; 140 162 } 141 163 142 public function replace_rows( int $source_id, array $rows ): void{164 public function replace_rows( int $source_id, array $rows ): bool { 143 165 global $wpdb; 144 166 145 167 // 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 } 147 171 148 172 $now = current_time( 'mysql' ); … … 153 177 154 178 // 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( 156 180 self::rows_table(), 157 181 array( … … 163 187 array( '%d', '%d', '%s', '%s' ) 164 188 ); 165 } 189 if ( false === $inserted ) { 190 return false; 191 } 192 } 193 return true; 166 194 } 167 195 -
tabloria-searchable-displays/trunk/includes/REST/DisplaysController.php
r3780707 r3796621 221 221 } 222 222 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'] ); 236 224 if ( is_wp_error( $parsed ) ) { 237 225 return $parsed; … … 239 227 240 228 $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 } 242 244 243 245 return new WP_REST_Response( -
tabloria-searchable-displays/trunk/includes/REST/UploadController.php
r3780769 r3796621 56 56 } 57 57 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'] ); 76 61 77 62 if ( is_wp_error( $parsed ) ) { … … 88 73 $parsed['rows'] 89 74 ); 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 } 90 78 91 79 $source = $this->database->get_source( $source_id ); -
tabloria-searchable-displays/trunk/includes/Services/CsvParser.php
r3544916 r3796621 24 24 } 25 25 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. 27 44 if ( false === $handle ) { 28 45 return new WP_Error( 'tabloria_csv_open_failed', __( 'The uploaded CSV file could not be opened.', 'tabloria-searchable-displays' ) ); 29 46 } 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 ); 30 54 31 $headers = fgetcsv( $handle );55 $headers = fgetcsv( $handle, 0, $delimiter, '"', '' ); 32 56 if ( ! is_array( $headers ) || empty( $headers ) ) { 33 57 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above. … … 45 69 $row_index = 0; 46 70 47 while ( false !== ( $data = fgetcsv( $handle ) ) ) {71 while ( false !== ( $data = fgetcsv( $handle, 0, $delimiter, '"', '' ) ) ) { 48 72 if ( $this->is_blank_row( $data ) ) { 49 73 continue; … … 53 77 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the fgetcsv() stream opened above. 54 78 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 ) ); 55 83 } 56 84 … … 65 93 66 94 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 } 67 98 68 99 $columns = $this->detector->detect_columns( $headers, $rows ); … … 78 109 'sample' => array_slice( $rows, 0, 24 ), 79 110 ); 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; 80 136 } 81 137 -
tabloria-searchable-displays/trunk/includes/Services/DataUpdater.php
r3544916 r3796621 8 8 9 9 class DataUpdater { 10 private ColumnDetector $detector;11 12 public function __construct( ?ColumnDetector $detector = null ) {13 $this->detector = $detector ?: new ColumnDetector();14 }15 16 10 public function compare_columns( array $old_columns, array $new_columns ): array { 17 11 $old_by_key = array(); … … 45 39 } 46 40 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; 56 42 } 57 43 … … 77 63 } 78 64 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 }91 65 } -
tabloria-searchable-displays/trunk/includes/Services/FrontendRenderer.php
r3780707 r3796621 286 286 $roles = $config['fieldRoles'] ?? array(); 287 287 $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 ) ); 289 289 $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 ) ); 291 291 $image = $this->typed_field_value( $row, $roles['image'] ?? '', $source ); 292 292 $image_url = $this->sanitizer->sanitize_image_url( $image ); … … 368 368 $roles = is_array( $config['fieldRoles'] ?? null ) ? $config['fieldRoles'] : array(); 369 369 $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 ) ); 371 371 $subtitle = $this->typed_field_value( $row, $roles['subtitle'] ?? '', $source ); 372 372 $image_url = $this->sanitizer->sanitize_image_url( $this->typed_field_value( $row, $roles['image'] ?? '', $source ) ); … … 462 462 463 463 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 } 464 468 $search_text = $this->normalize_search_text( 465 469 implode( … … 469 473 return $this->typed_field_value( $row, (string) $field, $source ); 470 474 }, 471 array_keys( $row )475 $fields 472 476 ) 473 477 ) … … 479 483 } 480 484 481 foreach ( (array) ( $config['visitorFilters'] ?? array()) as $field ) {485 foreach ( ! empty( $config['filters']['enabled'] ) ? (array) ( $config['visitorFilters'] ?? array() ) : array() as $field ) { 482 486 $field = sanitize_key( $field ); 483 if ( $field ) {487 if ( $field && $this->source_has_field( $source, $field ) ) { 484 488 $attributes .= ' data-filter-' . esc_attr( $field ) . '="' . esc_attr( $this->typed_field_value( $row, $field, $source ) ) . '"'; 485 489 } … … 513 517 array_filter( 514 518 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 ) 516 520 ) 517 521 ); 518 522 519 if ( $fields) {523 if ( array_key_exists( 'visibleFields', $config ) ) { 520 524 return $fields; 521 525 } … … 530 534 ) 531 535 ); 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; 532 557 } 533 558 … … 758 783 } 759 784 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 ) { 762 787 $field = sanitize_key( $field ); 763 788 $value = $this->typed_field_value( $row, $field, $source ); … … 770 795 } 771 796 772 foreach ( $ row as $field => $value) {797 foreach ( $fields as $field ) { 773 798 $field = sanitize_key( $field ); 774 799 $value = $this->typed_field_value( $row, $field, $source ); … … 781 806 } 782 807 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 ) { 785 810 $field = sanitize_key( $field ); 786 811 $value = $this->typed_field_value( $row, $field, $source ); -
tabloria-searchable-displays/trunk/includes/Services/SpreadsheetSanitizer.php
r3780769 r3796621 80 80 fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the uploaded CSV temp file handle. 81 81 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 ) { 83 84 return new WP_Error( 'tabloria_csv_binary', __( 'The uploaded file does not look like a plain CSV file.', 'tabloria-searchable-displays' ), array( 'status' => 400 ) ); 84 85 } -
tabloria-searchable-displays/trunk/readme.txt
r3780769 r3796621 1 === Tabloria Searchable Displays ===1 === Tabloria - CSV Product Catalog & Searchable Displays === 2 2 Contributors: publishly 3 3 Tags: csv, searchable table, product catalog, directory, data table … … 5 5 Tested up to: 7.1 6 6 Requires PHP: 8.1 7 Stable tag: 0.2. 17 Stable tag: 0.2.2 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html 10 10 11 Turn a CSV into searchable catalogs, directories, coupon displays, listings, and responsive data tables—without code.11 Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block. 12 12 13 13 == Description == 14 14 15 Tabloria turns CSV rows into polished, searchable WordPress content without custom code. 15 Upload 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 17 Tabloria also turns CSV rows into directories, coupon displays, listings, schedules, pricing displays, and responsive data tables. 16 18 17 19 Upload 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. … … 84 86 85 87 1. Upload the `tabloria-searchable-displays` folder to `/wp-content/plugins/`. 86 2. Activate Tabloria Searchable Displaysfrom the WordPress Plugins screen.88 2. Activate Tabloria from the WordPress Plugins screen. 87 89 3. Open Tabloria -> Create Display. 88 90 4. Upload a CSV file and follow the guided setup flow. … … 113 115 = What file format does Tabloria support? = 114 116 115 The current MVP imports CSV files. XLSX import is not part of the main public workflow yet. 117 Tabloria 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 121 From 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. 116 122 117 123 = Does Tabloria edit spreadsheet rows? = … … 141 147 == Changelog == 142 148 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 143 155 = 0.2.1 = 144 156 -
tabloria-searchable-displays/trunk/tabloria-searchable-displays.php
r3780769 r3796621 1 1 <?php 2 2 /** 3 * Plugin Name: Tabloria Searchable Displays3 * Plugin Name: Tabloria - CSV Product Catalog & Searchable Displays 4 4 * 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. 15 * Description: Build a searchable WordPress product catalog from CSV, with filters, cards, and a Gutenberg block. 6 * Version: 0.2.2 7 7 * Author: Tabloria 8 8 * Text Domain: tabloria-searchable-displays … … 18 18 } 19 19 20 define( 'TABLORIA_VERSION', '0.2. 1' );20 define( 'TABLORIA_VERSION', '0.2.2' ); 21 21 define( 'TABLORIA_FILE', __FILE__ ); 22 22 define( 'TABLORIA_DIR', plugin_dir_path( __FILE__ ) );
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)